commit 2cd5c3404fb445856148be7279ed71d8ebdc8922 Author: pavlov346346-source Date: Mon Sep 7 15:29:20 2026 +0300 Caterium v17.6.0 - GitHub Cloudflare autodeploy diff --git a/.github/workflows/qa.yml b/.github/workflows/qa.yml new file mode 100644 index 0000000..6a40f71 --- /dev/null +++ b/.github/workflows/qa.yml @@ -0,0 +1,16 @@ +name: Caterium QA +on: + push: + pull_request: +jobs: + qa: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - run: npm install + - run: npm run check:deploy + - run: npx playwright install --with-deps chromium + - run: npm run test:e2e diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f84d959 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +node_modules/ +.playwright/ +playwright-report/ +test-results/ +.wrangler/ +.dev.vars +.env +.env.* +!.env.example +*.log +.DS_Store +Thumbs.db diff --git a/GITHUB-CLOUDFLARE-SETUP.txt b/GITHUB-CLOUDFLARE-SETUP.txt new file mode 100644 index 0000000..5f051d4 --- /dev/null +++ b/GITHUB-CLOUDFLARE-SETUP.txt @@ -0,0 +1,19 @@ +CATERIUM - GITHUB -> CLOUDFLARE AUTODEPLOY + +Repository: +https://github.com/pavlov346346-source/caterium-app + +Existing Worker: +ancient-sound-04ab + +1. Run PUSH-TO-GITHUB.bat. +2. Complete GitHub browser login if Git Credential Manager asks. +3. In Cloudflare: Workers & Pages -> ancient-sound-04ab -> Settings -> Builds -> Connect. +4. Choose GitHub and repository pavlov346346-source/caterium-app. +5. Production branch: main +6. Root directory: / +7. Build command: npm run check:deploy +8. Deploy command: npx wrangler deploy +9. Save and deploy. + +After this, each push to main that passes the Cloudflare build checks deploys to the same Worker. diff --git a/PUSH-TO-GITHUB.bat b/PUSH-TO-GITHUB.bat new file mode 100644 index 0000000..b913924 --- /dev/null +++ b/PUSH-TO-GITHUB.bat @@ -0,0 +1,62 @@ +@echo off +setlocal +cd /d "%~dp0" +echo. +echo === Caterium - GitHub setup === +echo Repository: https://github.com/pavlov346346-source/caterium-app.git +echo. +where git >nul 2>nul +if errorlevel 1 ( + echo Git for Windows is not installed. + echo Install it from https://git-scm.com/download/win and run this file again. + pause + exit /b 1 +) +if not exist .git ( + git init +) +git branch -M main +git config user.name "pavlov346346-source" +git config user.email "pavlov346346-source@users.noreply.github.com" +git remote get-url origin >nul 2>nul +if errorlevel 1 ( + git remote add origin https://github.com/pavlov346346-source/caterium-app.git +) else ( + git remote set-url origin https://github.com/pavlov346346-source/caterium-app.git +) + +echo Checking release before commit... +call npm --version >nul 2>nul +if not errorlevel 1 ( + if not exist node_modules call npm install + call npm run check:deploy + if errorlevel 1 ( + echo QA failed. Nothing will be pushed. + pause + exit /b 1 + ) +) else ( + echo Node.js not found - skipping local npm QA. GitHub/Cloudflare will run checks after push. +) + +git add -A +git diff --cached --quiet +if errorlevel 1 ( + git commit -m "Caterium v17.6.0 - GitHub Cloudflare autodeploy" +) else ( + echo No new changes to commit. +) + +echo. +echo Sending to GitHub. If this is the first time, Git Credential Manager may open your browser for GitHub sign-in. +git push -u origin main +if errorlevel 1 ( + echo. + echo Push did not complete. If the repository already contains a README, run UPDATE-FROM-REMOTE-AND-PUSH.bat. + pause + exit /b 1 +) +echo. +echo SUCCESS: GitHub repository updated. +echo Next: connect this repository in Cloudflare Workers Builds. +pause diff --git a/README.md b/README.md new file mode 100644 index 0000000..6c173b3 --- /dev/null +++ b/README.md @@ -0,0 +1,25 @@ +# Caterium + +Caterium catering SaaS frontend with Supabase backend. + +## Production +Current Cloudflare Worker: `ancient-sound-04ab` + +Cloudflare publishes **only `public/`**. SQL, Edge Functions, tests and documentation are not web-accessible. + +## Deploy checks +```bash +npm install +npm run check:deploy +npm run test:e2e +``` + +## Cloudflare Git deployment +- Build command: `npm run check:deploy` +- Deploy command: `npx wrangler deploy` +- Production branch: `main` +- Root directory: `/` + +## Backend +Supabase Edge Functions are versioned under `supabase/functions/`. +SQL migration/history files live under `ops/sql/`. diff --git a/UPDATE-FROM-REMOTE-AND-PUSH.bat b/UPDATE-FROM-REMOTE-AND-PUSH.bat new file mode 100644 index 0000000..39b65f4 --- /dev/null +++ b/UPDATE-FROM-REMOTE-AND-PUSH.bat @@ -0,0 +1,13 @@ +@echo off +setlocal +cd /d "%~dp0" +where git >nul 2>nul || (echo Install Git for Windows first.& pause & exit /b 1) +if not exist .git git init +git branch -M main +git remote get-url origin >nul 2>nul && git remote set-url origin https://github.com/pavlov346346-source/caterium-app.git || git remote add origin https://github.com/pavlov346346-source/caterium-app.git +git fetch origin main +if not errorlevel 1 git pull --rebase origin main --allow-unrelated-histories +git add -A +git diff --cached --quiet || git commit -m "Update Caterium" +git push -u origin main +pause diff --git a/docs/DEVELOPER-CONSOLE-V22.md b/docs/DEVELOPER-CONSOLE-V22.md new file mode 100644 index 0000000..6b7def8 --- /dev/null +++ b/docs/DEVELOPER-CONSOLE-V22.md @@ -0,0 +1,34 @@ +# Developer console v17.5.22 + +The developer console is enabled only for users present in `public.sun_platform_admins`. +Platform-wide actions use `sun_dev_*` RPC wrappers and require Supabase MFA assurance level `aal2`. + +## Developer sections +- Overview +- Companies +- Accounts +- Plans +- Features +- System +- Backups +- Journal + +## Ordinary-company settings +Ordinary users keep company-facing settings only: profile/company, company users and roles, subscription view, appearance, client offer, order/payment colors and statuses, reference lists, documents and QR/form settings. + +The following technical controls are not shown to ordinary users: +- Supabase Project URL / publishable key +- reconnecting the Supabase project +- manual cloud pull/push/migration controls +- platform diagnostics +- platform-wide backup/restore +- global plans and feature flags +- all-company/all-account directories +- starter catalog management +- platform audit journal + +## Support mode +A developer can open a company in support mode. The cloud snapshot is loaded into the local working copy, but writes and cloud sync are disabled. Exiting support mode clears that local support copy and restores the developer's own workspace copy. + +## Auth-admin limitation in this build +The app can list all registered Auth accounts through the server-side `sun_dev_list_users` RPC. The source for a separate protected Edge Function for global Auth ban/unban and password-recovery mail is included under `supabase/functions/caterium-platform-auth-admin`, but that Edge Function was not deployed from the current build environment. No service-role secret is embedded in the application archive. diff --git a/docs/HISTORY.txt b/docs/HISTORY.txt new file mode 100644 index 0000000..9d64ae3 --- /dev/null +++ b/docs/HISTORY.txt @@ -0,0 +1,368 @@ +v17.5.21 — Настройки разделены на 6 вкладок: Аккаунт, Оформление, Предложение, Заказы, Справочники, Документы. +v17.5.17 - inline control-list editor + guaranteed adaptive table photos +v17.5.16 — предпросмотр предложения и PDF используют одни и те же страницы; редактор прямо в окне предложения; финальные фото максимум 2 и только в свободное место +v17.5.15 — новый шаблон предложения «Солнце Editorial» с единой структурой просмотра и PDF +v17.5.14 — фото каталога/Premium и адаптивная галерея PDF +- Единый fallback фото для каталога, Premium, предложения и PDF: локальный файл -> API-кэш -> оригинал Tilda. +- Запуск сервера больше не блокируется неполным фотокэшем. +- Локальные резервные фото добавлены для отсутствовавших вариантов боксов №5 и №8. +- Финальный блок «Как это выглядит на вашем столе» заполняет свободное место 1–3 фотографиями. + +v17.5.9 — Календарь: цвета по оплате + печать для кухни без финансов +- Календарь использует те же настраиваемые цвета оплаты, что и карточки заказов. +- В печати календаря скрыты суммы заказов, предоплаты, остатки и денежные значения внутри заказов. +- В печати остаются дата, время, заказ, мероприятие, клиент/адрес и цветовая маркировка. + +v17.5.8 — PDF: белый Light, продающий блок и финальная фотогалерея +- Светлый фирменный шаблон переведён с кремового на чистый белый фон. +- Короткий текст первого экрана отделён от нового редактируемого продающего блока. +- При миграции старый длинный текст первого экрана переносится в продающий блок, чтобы ничего не потерять. +- PDF рассчитывает высоту продающего текста и переносит продолжение на следующую страницу без обрезания. +- В конец предложения добавлена адаптивная галерея из 10 предоставленных фотографий. +- Фотографии галереи упакованы побайтово в legacy-images.bin без повторного JPEG-сжатия и без увеличения числа файлов. + +SUN CATERING — ИСТОРИЯ ИЗМЕНЕНИЙ + +Этот файл объединяет прежние V*-CHANGES.txt. Исторические записи сохранены по версиям. + + +============================================================================== +ИСТОЧНИК: V12-CHANGES.txt +============================================================================== +Солнце Кейтеринг v12 + +1. Шесть шаблонов «Предложения клиенту» теперь имеют разные структуры, а не только разные цвета. +2. Все шаблоны используют реальные фотографии выбранных боксов из каталога. +3. PDF-верстка переработана: измеряемый перенос строк, динамические высоты блоков, отдельное состояние canvas, защита от наложения текста. +4. «Фирменные цвета интерфейса»: расширенный редактор левой панели, активных пунктов, служебных кнопок, уведомлений и основного интерфейса. +5. Значки меню: стандартные SVG или пользовательские символы/эмодзи для каждого раздела. +6. Одна кнопка возвращает стандартную версию интерфейса (исходная фиолетовая панель + стандартные значки). + + +============================================================================== +ИСТОЧНИК: V13-CHANGES.txt +============================================================================== +Солнце Кейтеринг v13 + +- Исправлена невидимая подпись на тёмных основных кнопках (PDF, Сохранить и т.п.). +- Настройки темы теперь работают как живой предпросмотр: изменения видны сразу, но сохраняются только по кнопке «Сохранить настройки». +- Добавлена кнопка «Отменить изменения». +- Основной цвет левой панели вынесен отдельным крупным параметром; можно менять градиент, значки, текст, активные пункты и служебные элементы. +- Добавлены 5 наборов значков плюс индивидуальный выбор значка для каждого раздела. +- «Вернуть стандартную версию» одним нажатием восстанавливает исходную фиолетовую панель, стандартные цвета и SVG-значки. +- Excel импорт/экспорт удалён из экрана Настройки. +- Левая панель сворачивается до 74 px и запоминает состояние. +- Обновлён PWA cache. + + +============================================================================== +ИСТОЧНИК: V14-CHANGES.txt +============================================================================== +Sun Catering v14 + +- Simplified interface color settings. +- Removed icon pack and per-icon customization settings. +- Brand palette is collapsed behind "Показать фирменные цвета Солнца". +- Added sidebar presets: Standard purple, Brand dark, Brand light, Brand green, Solar graphite. +- Theme changes affect only preview until Save Settings is clicked. +- Main sidebar background is explicitly applied to the real sidebar after saving, including mobile navigation. +- Added manual controls for main sidebar color, second sidebar color and icon color plus advanced sidebar colors. +- Restores standard purple sidebar with one button. +- Sidebar logo/brand is now a link to New Order. +- Updated PWA cache version. + + +============================================================================== +ИСТОЧНИК: V15-CHANGES.txt +============================================================================== +Солнце Кейтеринг — v15 + +Предложение клиенту: +- Номер заказа удалён из клиентского предложения и имени PDF. +- Дата остаётся и может быть включена/выключена в настройках. +- Количество боксов считается только по позициям категории «Боксы». +- Вес еды считается только по категории «Боксы»; напитки, посуда и дополнения исключены. +- В «Итого» можно выбрать «Вес еды на гостя» или «Количество канапе на гостя». +- Добавлены централизованные настройки текстов и видимости блоков предложения. +- Нижние кнопки «Скачать PDF» / «Заказать меню» удалены из всех клиентских шаблонов. +- Управление предложением остаётся в верхнем окне: «Скачать PDF», «Обновить», «Закрыть». +- Исправлено позиционирование и перенос строк в PDF, в том числе многострочные заголовки. + + +============================================================================== +ИСТОЧНИК: V16-CHANGES.txt +============================================================================== +Солнце Кейтеринг v16 — SaaS foundation + +- 3 тарифа: Базовый / Профессиональный / Полный. +- Новый workspace получает 14 дней Полного тарифа. +- После окончания: 7 дней только просмотр, затем блокировка без удаления данных. +- Базовый: 1 пользователь. Профессиональный: до 3. Полный: без лимита. +- Профессиональный не включает редактирование боксов и предложения клиенту. +- Серверная проверка подписки и функций в Supabase RPC — ограничения нельзя снять только изменением интерфейса браузера. +- Каждая компания работает в отдельном workspace; сервер фильтрует данные по членству и тарифу. +- Регистрация компании: пустая база или демо. +- Карточка тарифа и срока в Настройках. +- Недоступные функции помечаются замком и показывают сравнение тарифов. +- Отдельный SaaS-кабинет владельца сервиса: компании, тарифы, сроки, блокировка, индивидуальные функции. +- Ограничение количества активных сотрудников проверяется на сервере при приглашении и повторной активации. +- Существующий workspace «Солнце Кейтеринг» сохранён на Полном тарифе до 2099 года. +- SaaS RPC закрыты от анонимного доступа. +- Миграции текущего Supabase уже применены. Для нового проекта: SUPABASE-SAAS-V16.sql + SUPABASE-SAAS-V16-FINALIZE.sql. + +Пока не подключено: автоматическое списание, платёжный webhook и публичный checkout. Тарифами можно управлять вручную из SaaS-кабинета. + + +============================================================================== +ИСТОЧНИК: V17.1-CHANGES.txt +============================================================================== +Солнце Кейтеринг v17.1 — исправление входа на новом устройстве + +- Рабочие базы после входа всегда запрашиваются с сервера через sun_my_workspaces(). +- Если у пользователя одна база, она выбирается автоматически. +- Убрана гонка между успешной авторизацией и загрузкой membership. +- Экран одноразового кода приглашения временно отключён. +- Создание и принятие приглашений временно запрещены на сервере. +- В настройках убраны элементы создания/ввода приглашений. +- Регистрация через основной gate временно скрыта; вход — email + пароль. +- Обновлён PWA cache, чтобы старый экран не оставался на другом компьютере. + + +============================================================================== +ИСТОЧНИК: V17.2-CHANGES.txt +============================================================================== +Солнце Кейтеринг v17.2 — полное описание боксов в предложениях клиенту + +- Во всех 6 шаблонах предложения убрано ограничение на первые 2–3 составляющие бокса. +- Теперь выводится полный состав бокса: все строки/виды, сохранённые в composition. +- Карточки и строки предложения растут по высоте автоматически. +- Добавлен нормальный перенос длинного текста без обрезки. +- Исправлены PDF всех 6 шаблонов: описание больше не ограничивается 2 составляющими или 3 строками. +- Midnight Compact PDF теперь также выводит состав под названием позиции, а не только название и вес. +- Neon Emerald PDF теперь выводит полный состав, а не только вес/раздел. +- Black Gold и Emerald Gold PDF измеряют полный текст и увеличивают карточку до нужной высоты. +- Обновлён PWA cache, чтобы браузеры не держали старую версию client-offer.js. + + +============================================================================== +ИСТОЧНИК: V17.3-CHANGES.txt +============================================================================== +Солнце Кейтеринг v17.3 — актуальные данные предложения клиенту + +Исправлено: +- Предложение клиенту больше не открывает старый сохранённый снимок после изменения заказа. +- При каждом открытии сравнивается сигнатура текущего заказа и снимка предложения. +- Если количество, цена, гости, скидка, доставка или позиции изменились — предложение пересоздаётся автоматически. +- Кнопка «Предложение клиенту» в редакторе использует текущий draft заказа, включая ещё не сохранённые изменения. +- Несохранённый draft не записывается обратно в сохранённый заказ; показывается предупреждение. +- Перед скачиванием PDF выполняется повторная проверка актуальности данных. +- Все 6 шаблонов используют один актуальный snapshot, поэтому сумма, количество боксов, вес и количество позиций совпадают. +- Номер заказа убран из внутренней строки метаданных окна предложения; остаётся только время версии. +- PWA cache обновлён до v33, чтобы старый snapshot-код не оставался в браузере. + +Контрольный пример заказа №25: +- количества боксов: 3+3+2+3+2+2 = 15 +- сумма: 60 080 ₽ +- гостей: 60 +- сумма на гостя: 1 001 ₽ (округление) +- вес еды: 11 830 г +- вес на гостя: 197 г (округление) + + +============================================================================== +ИСТОЧНИК: V17.4-CHANGES.txt +============================================================================== +Солнце Кейтеринг v17.4 — вес боксов + итоговое количество канапе + +1. В редактор бокса добавлено отдельное поле «Вес бокса, г». +2. Вес бокса сохраняется в каталоге и синхронизируется как часть данных позиции. +3. Вес показывается на карточках каталога, в настройке каталога, в строках заказа, в бланке заказа/чеке и во всех предложениях клиенту/PDF. +4. В предложениях состав бокса показывает итоговое количество каждого вида закусок с учётом количества заказанных боксов. + Пример: 5 шт. канапе в одном боксе × 3 бокса = 15 шт. в предложении. +5. Обновление веса или состава бокса сразу подхватывается открытым предложением клиенту. +6. Сводный вес еды на гостя пересчитывается по актуальному весу боксов. +7. Версия snapshot предложения повышена до v5, чтобы старые предложения автоматически пересобрались. +8. Обновлён PWA-кэш. + + +============================================================================== +ИСТОЧНИК: V17.5-CHANGES.txt +============================================================================== +Солнце Кейтеринг v17.5 + +1. В редактор каждого бокса добавлено отдельное поле «Количество канапе / шт. в боксе». +2. Общее количество больше не хранится как строка состава. +3. В каждой строке состава добавлено поле «Вес 1 шт., г». +4. Старые веса вида «— 20 г» автоматически переносятся в отдельное поле веса одной штуки. +5. Бокс №7: количество = 30 шт. +6. Бокс №18 «Тосты ассорти»: строка «25 шт. тостов ассорти» удалена из состава; в составе остаются 2 реальных вида, а 25 хранится как общее количество. +7. Количество и вес одной штуки отображаются в клиентском предложении и PDF. +8. Количество каждого вида в предложении умножается на число заказанных боксов. +9. Каталог/заказ/бланк показывают вес и общее количество бокса. + + +============================================================================== +ИСТОЧНИК: V17.5.1-CHANGES.txt +============================================================================== +Солнце Кейтеринг v17.5.1 FINAL + +База: v17.5. Облако, настройки разработчика и ребрендинг не изменялись. + +1. В предложении клиенту для бокса показываются итоговые данные заказа: количество боксов, общий вес, диапазон гостей и общее количество штук. + Пример: 3 бокса · Общий вес: 3 000 г · 15–25 гостей · Всего: 75 шт. +2. Количество готовых штук учитывается для всех пищевых боксов, включая мини-бургеры и другие разделы, а не только раздел «Канапе». +3. Вес еды на гостя всегда округляется вверх до ближайших 5 г: 188 → 190, 191 → 195. +4. Показатель «Канапе на гостя» всегда округляется вверх до целого: 2,3 → 3. +5. Те же расчёты используются в экранном предложении и PDF. +6. В PDF карточки в одной строке выравниваются по высоте. +7. При небольшом переполнении страницы карточки/блоки используют компактную высоту вместо переноса малого остатка на новую страницу. +8. Блок расчёта стоимости старается оставаться на одной странице с нижним примечанием, чтобы не появлялась отдельная страница с одной-двумя строками. +9. В Light и Midnight Glass удалена повторная PDF-галерея после карточек: фото уже находятся внутри карточек, поэтому лишние страницы не создаются. +10. PDF-логика применена ко всем 6 шаблонам предложения. + + +============================================================================== +ИСТОЧНИК: V17.5.2-CHANGES.txt +============================================================================== +Солнце Кейтеринг v17.5.2 FINAL + +База: v17.5.1. Новая архитектура облака, настройки разработчика и ребрендинг не добавлялись. + +1. Диапазон гостей бокса больше не показывается в карточках каталога, менеджере, каталожных PDF и в предложении клиенту. Само значение оставлено в данных для обратной совместимости. +2. В предложении клиенту убрано дублирующее начало строки «3 бокса». Количество остается в штатной колонке «× 3», а метастрока показывает только общий вес и общее количество штук. +3. В окне «Вкладки каталога» добавлен выбор цвета каждой вкладки. Надпись автоматически становится светлой или темной в зависимости от фона. +4. В редакторе позиций подписи унифицированы: «Цена, ₽» и «Сохранить». +5. Ошибка `sunBoxes exceeded the quota` исправлена без смены архитектуры облака: новые фото каталога оптимизируются перед локальным сохранением, а при загрузке из облака `sunBoxes` сжимает большие data-URL и повторяет запись с более сильным сжатием, если лимит все еще превышен. + + +============================================================================== +ИСТОЧНИК: V17.5.3-CHANGES.txt +============================================================================== +V17.5.3 — каталог 02.09.2026, Премиум и скидки + +1. Каталог «Боксы» полностью заменён по пользовательской выгрузке «Все боксы — Солнце Кейтеринг (2).pdf». +2. Импортировано 113 позиций: название, вес, актуальная цена и ссылка на фото. Данные о количестве гостей не импортируются и удалены из названий. +3. Позиции 69–70 (Морс, Компот) находятся во вкладке «Напитки». +4. Позиции 85–113 находятся во вкладке «Премиум». Вкладка «Премиум» расположена между «Боксы» и «Напитки». +5. Для строк с перечёркнутой ценой сохранена текущая цена и отдельная «Цена до скидки». В редакторе добавлено необязательное поле «Цена до скидки, ₽». +6. В карточках каталога и менеджере старая цена показывается перечёркнутой; расчёт заказа использует текущую цену. +7. «Премиум» считается пищевой категорией для веса/штучности и предложения клиенту. +8. Фото используются напрямую по оригинальным ссылкам static.tildacdn.com из PDF. Это не увеличивает localStorage и не возвращает ошибку quota. +9. Облачная архитектура, SaaS, RBAC и настройки разработчика не менялись. + + +============================================================================== +ИСТОЧНИК: V17.5.4-CHANGES.txt +============================================================================== +17.5.4 + +- Настройки при открытии прокручиваются к началу; верхняя шапка настроек закреплена. +- Блоки внизу настроек упорядочены: Реквизиты компании -> QR-код -> Настройки бланка -> Облако Supabase -> История изменений. +- В выборе шаблона предложения оставлены только: Светлый фирменный, Midnight Glass, Emerald Gold. +- Для адреса доставки добавлена ручная проверка точного адреса: до 5 вариантов с регионом/районом/населенным пунктом, превью точки и явное подтверждение. +- Автоподстановка первого результата геокодера отключена для кнопки проверки адреса. +- Автокомплит адресов не используется; запрос выполняется только по кнопке. +- Текущая архитектура облака не менялась. + + +============================================================================== +ИСТОЧНИК: V17.5.5-CHANGES.txt +============================================================================== +Sun Catering v17.5.5 + +- Исправлено предложение клиенту для заказов, созданных до замены каталога 02.09.2026. +- Старые ID каталога разрешаются через встроенный каталог v17.5, поэтому возвращаются название, вес, состав, цена и фотография позиции. +- Старые локальные фотографии берутся из catalog-images.js и безопасно встраиваются в PDF без зависимости от CDN. +- Снимки предложения, где позиции уже успели превратиться в «Позиция»/логотип, автоматически считаются устаревшими и пересобираются. +- Новая структура каталога, Premium, настройки, облако и адреса не менялись. + + +============================================================================== +Sun Catering v17.5.6 — аудит и компактная сборка +============================================================================== +- Проведён статический аудит JavaScript, встроенных скриптов, локальных ссылок и серверных маршрутов. +- Исправлен /healthz: версия теперь читается из release-manifest.json и не может молча оставаться на старом 17.5.0. +- Premium (категория 5) теперь последовательно обрабатывается как бокс в заготовках, фотокарточках и PDF-каталоге; название раздела в PDF — «Премиум». +- Загрузка внешних фото в PDF-каталоге защищена CORS-режимом и таймаутом, чтобы не портить canvas и не зависать бесконечно. +- 19 браузерных JS-модулей объединены в app-runtime.js без минификации; внутри сохранены маркеры исходных модулей. +- mobile-pwa.css встроен в index.html. +- Дублирование старых фото устранено: catalog/004.jpg–060.jpg упакованы побайтово в legacy-images.bin без перекодирования и без потери JPEG-качества; catalog/001.jpg–003.jpg оставлены отдельными для SaaS-демо. +- Удалён catalog-images.js, который содержал ещё одну base64-копию тех же изображений и создавал лишнюю нагрузку на разбор JavaScript. +- Удалены неиспользуемые превью трёх отключённых шаблонов предложения. +- Генерируемые файлы сервера (.sun-sync-secret, sun-sync-data.json, PHONE-LINK.txt) не поставляются в чистой сборке и создаются автоматически. +- Исправлена устаревшая подпись версии в start-local-test.bat (v17.3 → v17.5.6). +- Облачная архитектура, Supabase-схемы и пользовательские данные не изменялись. + + +============================================================================== +Sun Catering v17.5.7 — составы боксов с сайта +============================================================================== +- Добавлен человекочитаемый состав боксов для предложения клиенту и PDF. +- 56 позиций получают состав сразу из проверенного/ранее импортированного каталога; 6 популярных боксов сверены с текущей главной страницей solnce-keytering.ru 03.09.2026. +- Добавлена автоматическая синхронизация составов с публичных каталогов /catalog и /catalog_black через локальный server.js. +- Синхронизация не импортирует данные о количестве гостей и не меняет цену/фото/вес. +- После успешной синхронизации состав сохраняется в sunBoxes и остаётся доступен локально; повторная проверка выполняется не чаще одного раза в 6 часов. +- В менеджере Боксов/Премиум добавлена кнопка «Обновить составы с сайта». +- В редакторе бокса добавлено поле «Состав для клиента»; ручная правка защищена от последующей автозамены сайтом. +- Миграция каталога теперь сохраняет пользовательские позиции и существующие ручные изменения вместо полного удаления категорий Боксы/Напитки/Премиум. +- Архитектура облака и Supabase не изменялась. + + +============================================================================== +Sun Catering v17.5.10 - Premium compositions from site +============================================================================== +- Premium sync now opens the full Tilda product card through getproduct after the catalog list. +- Nested product text fields are scanned for composition. +- Missing Premium composition forces refresh even inside the normal 6-hour interval. +- Manual composition is preserved and guest/person data is not imported. +- Sync status reports Premium matches and changes separately. + +17.5.11 +- Убрана видимая кнопка «Обновить составы с сайта». +- Первый бокс больше не показывает «АКЦИЯ» без перечеркнутой старой цены. +- Фото старого каталога 001-060 снова лежат отдельными JPG в catalog; текущие 113 позиций используют catalog/current-XXX.jpg и автоматически кэшируются сервером в эту же папку. +- «Состав для клиента» отделен от «Состав / ТТК»; клиентское предложение и PDF используют клиентский состав. +- В бланке заказа рядом с QR добавлен редактируемый текст, сохраняемый в заказе. + + +17.5.12 +- Аудит производительности и стабильности. +- PDF предложения больше не генерируется автоматически при открытии: тяжёлая сборка запускается только по кнопке «Скачать PDF». +- Убрана массовая фоновая загрузка недостающих фото каталога при старте сервера; фото кэшируются по требованию. +- Добавлена 5-минутная пауза после неудачной загрузки фото, чтобы при плохом интернете не повторять одни и те же запросы. +- Фоновая синхронизация составов с сайтом отложена до простоя интерфейса; число параллельных запросов деталей уменьшено до 2. +- Синхронизация составов больше не вызывает полный render() приложения и обновляет открытое предложение только при реальном изменении позиции. +- Снижена частота фоновых проверок облака/прав/тарифа и ограничены тяжёлые MutationObserver соответствующими разделами интерфейса. +- Для недоступных фотографий каталога добавлен безопасный fallback на логотип. +- Финальная фотогалерея хранится обычными JPEG и в предпросмотре загружается напрямую; Base64-конвертация фото выполняется только при скачивании PDF. + + +17.5.13 +- Каталог переведён на полностью локальные фотографии во время обычной работы приложения. +- Добавлен prepare-catalog-photos.js: перед первым запуском он проверяет current-001.jpg–current-113.jpg и докачивает только отсутствующие файлы. +- Одинаковые исходные URL скачиваются один раз и сохраняются во все соответствующие позиции; добавлены проверка JPEG, атомарная запись, повторы и ограничение параллельности. +- start-mobile-server.bat и start-local-test.bat запускают сервер только после успешной проверки 113/113 фотографий. +- Добавлен DOWNLOAD-ALL-CATALOG-PHOTOS.bat для ручной повторной подготовки каталога. +- Удалена загрузка фотографий с Tilda/CDN из server.js во время просмотра каталога и формирования PDF. +- /healthz показывает ready/total/complete для локальных фотографий каталога. +- Облачная архитектура и данные заказов не менялись. + +2026-09-05 — v17.5.18 +- Разделение локальной рабочей копии по Supabase workspace/аккаунтам. +- Новая компания: текущий каталог 113 позиций + пустые рабочие данные. +- Оптимизированы JPEG каталога; удалён устаревший статический PDF-каталог. + +2026-09-05 — v17.5.19 +- Исправлен надёжный выход из аккаунта: local-scope Supabase signOut, ограниченное ожидание сети, локальный fallback токена и обновлённый PWA cache. +- Сохранена изоляция workspace и стартовый каталог 113 позиций из v17.5.18. + +2026-09-05 — v17.5.20 +- Аккаунт и вход подняты в начало Настроек. +- Резервное копирование и диагностическое резервирование убраны из обычных Настроек. + + +2026-09-06 — v17.5.31 +- Чат переведён на безопасную регистрацию в левом меню без DOM insertBefore между разными родителями. +- Раздел «Чат»: Общий / Личные / Заказы. +- Чат заказа встроен непосредственно в заказ и доступен также из общего списка заказных чатов. +- Убрана бесконечная переинициализация, вызывавшая каскады повторных ошибок. diff --git a/docs/README-LEGACY.txt b/docs/README-LEGACY.txt new file mode 100644 index 0000000..87735e6 --- /dev/null +++ b/docs/README-LEGACY.txt @@ -0,0 +1,264 @@ +CATERIUM · v17.6.0 + +v17.6.0 · STABILITY & SECURITY +- Зафиксирована отдельная rollback-сборка v17.5.31. +- Закрыт stored DOM-XSS в старом bootstrap; общий безопасный вывод вынесен в core/sun-safe.js. +- Старый bootstrap вынесен из index.html в legacy/bootstrap.js, динамические DOM-вставки защищены от insertBefore/NotFoundError. +- Production Edge Function caterium-create-employee возвращена в исходники проекта; секреты остаются только в окружении Supabase. +- Убран блокирующий offer-gallery-data.js; оставлены только реально используемые 001.jpg и 002.jpg, неключевые изображения грузятся лениво. +- PDF-упаковка сведена в один A4-движок; Light / Editorial Grid / Midnight Glass / Emerald Gold и PDF-каталог повторно проверены визуально. +- На мобильной ширине 390 px устранён document-level горизонтальный overflow; горизонтальные ленты прокручиваются внутри своих областей. +- Добавлены автоматические syntax/security/release checks и E2E-спецификация для следующих релизов. + +v17.5.31 · СТАБИЛЬНАЯ НАВИГАЦИЯ ЧАТОВ +- Исправлена ошибка insertBefore при добавлении чата в сгруппированное левое меню. +- «Чат» — отдельный пункт бокового меню со счётчиком непрочитанных. +- Внутри: Общий / Личные / Заказы. +- Чат заказа открывается внутри самого заказа отдельной вкладкой «Чат». +- Та же переписка доступна из «Чат → Заказы». +- Серверная схема чата v29 и RLS не менялись. + +v17.5.30 · ПРАВА АДМИНИСТРАТОРОВ +- У роли «Администратор» теперь можно индивидуально включать и отключать функции. +- Явные права сотрудника имеют приоритет над шаблоном роли и на сервере, и в интерфейсе. +- Проверка «последний активный администратор» считает других активных администраторов, а не блокирует второго администратора. +- Нельзя оставить компанию без активного администратора или без администратора с правом «Пользователи и права». +- Серверные изменения: SUPABASE-ADMIN-RIGHTS-V30.sql. + +РЕГИСТРАЦИЯ +- Регистрация: название компании, email, пароль, подтверждение пароля. +- Подтверждение email по ссылке не требуется. +- После регистрации Caterium автоматически входит в аккаунт и создаёт компанию. + +ПОЛЬЗОВАТЕЛИ И ПРАВА +1. Настройки → Аккаунт → Пользователи и права. +2. Нажмите «+ Добавить сотрудника». +3. Укажите имя, email и роль. +4. Новый аккаунт создаётся сразу и получает временный пароль; существующий аккаунт просто добавляется в компанию. +5. При первом входе по временному паролю сотрудник задаёт новый пароль. + +ЧАТЫ +- «Чаты» в основной навигации: общий чат компании и личные диалоги сотрудников. +- В сохранённых заказах есть «Обсуждение», а в карточках заказов — кнопка «Чат». +- Есть непрочитанные, прочтение, «печатает…» и онлайн-статус. +- Можно отправлять фото и файлы: до 5 за сообщение, до 15 МБ каждый. +- Личные чаты серверно доступны только двум участникам; данные разных компаний изолированы. +- Вложения хранятся в приватном Supabase Storage bucket sun-chat. + +ДЛЯ ТЕКУЩЕГО SUPABASE +- SUPABASE-CHAT-V29.sql уже применён. Для нового проекта выполняйте его после базовых/SaaS/RBAC миграций. + +Сборка v17.5.21. + +Главное изменение: Настройки разделены на 6 вкладок — Аккаунт, Оформление, Предложение, Заказы, Справочники и Документы. На мобильных вкладки прокручиваются горизонтально, последняя открытая вкладка запоминается. Резервное копирование и блок стабильности остаются скрыты из обычных Настроек. + + +Что изменено в v17.5.19: +- Исправлен выход из аккаунта: кнопки «Выйти» теперь завершают локальную сессию Supabase без зависания на сетевой синхронизации, с аварийным локальным сбросом токена текущего проекта. +- При выходе текущая локальная копия компании сохраняется отдельно; разделение данных аккаунтов из v17.5.18 сохранено. +- Обновлён PWA-кэш, чтобы браузер не оставался на старом обработчике выхода. + +Что было добавлено в v17.5.18: +- рабочие данные теперь изолируются по облачной компании/workspace на одном устройстве; +- при смене аккаунта локальная копия предыдущей компании сохраняется отдельно в IndexedDB и очищается перед загрузкой другой компании; +- новая компания стартует без заказов, клиентов, финансов, склада и прочих рабочих данных, но с текущим каталогом из 113 позиций; +- фотографии каталога оптимизированы без смены формата (JPEG), чтобы уменьшить размер дистрибутива; +- старый статический PDF-каталог удалён из архива: актуальный PDF формируется по текущим данным приложения через кнопку «Скачать PDF». + +Структура релиза сохранена без изменения облачной архитектуры: браузерные модули объединены в app-runtime.js, мобильный CSS встроен в index.html. Фотографии боксов хранятся обычными JPG в папке catalog: legacy-каталог 001.jpg–060.jpg находится там полностью, а актуальный каталог использует current-001.jpg–current-113.jpg. Перед первым запуском start-mobile-server.bat выполняет prepare-catalog-photos.js: он проверяет все 113 актуальных фотографий, один раз докачивает отсутствующие по исходным ссылкам сайта и только после полного результата запускает server.js. Во время обычной работы server.js больше не загружает фотографии с Tilda/CDN — каталог и PDF используют локальные файлы. После успешной первой подготовки интернет для фотографий каталога не нужен. Финальная фотогалерея предложения хранится отдельными JPEG в папке offer-gallery; преобразование изображений для PDF выполняется только после нажатия «Скачать PDF». + +Файлы .sun-sync-secret, sun-sync-data.json и PHONE-LINK.txt намеренно не входят в чистый релиз: server.js создаёт их автоматически при первом запуске. Это не удаляет пользовательские данные из уже работающей установленной папки — при обновлении поверх существующей папки её текущие файлы данных нужно сохранить. + +ВАЖНО ДЛЯ ПЕРВОГО ЗАПУСКА: если в архиве ещё нет всех current-001.jpg–current-113.jpg, компьютер должен быть подключён к интернету. Уже скачанные файлы сохраняются, поэтому при обрыве достаточно снова запустить start-mobile-server.bat. Отдельно повторить подготовку можно файлом DOWNLOAD-ALL-CATALOG-PHOTOS.bat. + +Ниже объединены прежние инструкции проекта, чтобы в папке не было множества отдельных README-файлов. + + +============================================================================== +РАЗДЕЛ ИЗ ADMIN-ACCESS.txt +============================================================================== +СОЛНЦЕ КЕЙТЕРИНГ — АККАУНТЫ И ПРАВА (Cloud RBAC) + +ГЛАВНЫЙ АДМИНИСТРАТОР +Email: dpavlov346@bk.ru +Роль: Администратор +Рабочая база: Солнце Кейтеринг +Пароль в архиве не хранится. + +КАК ВОЙТИ +1. Откройте приложение. +2. На экране «Вход в Солнце Кейтеринг» введите email и пароль облачного аккаунта. +3. После входа рабочая база «Солнце Кейтеринг» выбирается автоматически. +4. Быстрый PIN можно включить в Настройки -> Аккаунт. PIN работает только на этом устройстве. + +КАК ДОБАВИТЬ СОТРУДНИКА +1. Администратор: Настройки -> Аккаунт. +2. Выберите роль и создайте одноразовый код приглашения. +3. Сотрудник на своём устройстве создаёт аккаунт email + пароль. +4. После входа вводит код приглашения. +5. Администратор открывает «Пользователи и права» и настраивает галочки. + +МОБИЛЬНЫЙ ДОСТУП +Подробно: см. раздел README-MOBILE ниже в этом файле. +Постоянная плашка облака поверх всех разделов удалена. + + +============================================================================== +РАЗДЕЛ ИЗ CLOUD-SETUP.txt +============================================================================== +СОЛНЦЕ КЕЙТЕРИНГ — ОБЛАЧНАЯ СИНХРОНИЗАЦИЯ + +1. Supabase уже подключён к этому билду приложения. +2. SQL-схема, RLS, Storage и Realtime уже развернуты в проекте Supabase. +3. Откройте в приложении Настройки -> Аккаунт. +4. Создайте аккаунт владельца или войдите. +5. Создайте рабочую базу «Солнце Кейтеринг». +6. На первом компьютере нажмите «Перенести текущие данные в облако». +7. Для сотрудников создайте код приглашения. На другом устройстве сотрудник + создаёт свой аккаунт, вводит код и получает доступ к общей рабочей базе. + +Что синхронизируется: +- заказы и клиентские данные; +- каталог, цены и составы; +- склад, движения, поставщики; +- финансы, сотрудники, маршруты, настройки и другие рабочие sun*-данные; +- загруженные изображения: они переносятся в приватный Supabase Storage. + +Офлайн: +Приложение продолжает работать с локальной копией. Когда интернет появляется, +изменения автоматически отправляются в облако. Realtime сообщает другим +устройствам об изменениях и обновляет локальную копию. + +Безопасность: +- в браузере используется только Publishable/anon key; +- доступ к данным проверяется Row Level Security; +- service_role / secret key в браузер добавлять нельзя; +- изображения лежат в приватном bucket sun-media. + + +============================================================================== +РАЗДЕЛ ИЗ DEPLOY-ONLINE.txt +============================================================================== +ПОСТОЯННЫЙ ВХОД С ТЕЛЕФОНА + +Приложение уже работает с облачной базой Supabase и не требует компьютера для хранения данных. +Чтобы открывать интерфейс из любой сети, папку приложения нужно один раз разместить на HTTPS static hosting (GitHub Pages / Cloudflare Pages / Netlify / Vercel). +После публикации на телефоне откройте HTTPS-адрес, войдите под тем же Supabase аккаунтом и добавьте сайт на главный экран как PWA. +Компьютер после этого может быть полностью выключен. + + +============================================================================== +РАЗДЕЛ ИЗ README-MOBILE.txt +============================================================================== +СОЛНЦЕ КЕЙТЕРИНГ — МОБИЛЬНЫЙ ДОСТУП И ОБЛАКО + +ВАЖНО: Supabase хранит общую базу данных, но сам интерфейс приложения должен быть открыт по адресу (URL). + +Вариант 1 — в одной Wi-Fi сети (уже работает): +1. На компьютере запустите start-mobile-server.bat. +2. Откройте PHONE-LINK.txt. +3. На телефоне, подключенном к той же Wi-Fi сети, откройте указанный адрес. +4. Войдите тем же облачным email и паролем. +5. Выберите рабочую базу «Солнце Кейтеринг». Данные подтянутся из Supabase. + +Вариант 2 — работать с телефона из любой точки мира (рекомендуется): +1. Разместите содержимое этого архива на обычном HTTPS-хостинге статического сайта (например, Cloudflare Pages / Netlify / Vercel). +2. На телефоне откройте полученный https:// адрес в Safari или Chrome. +3. Войдите своим email и паролем. +4. После первого открытия можно добавить сайт на главный экран как PWA. +5. Все устройства работают с одной рабочей базой Supabase; компьютер держать включенным не нужно. + +АДМИНИСТРАТОР +Email: dpavlov346@bk.ru +Рабочая база: Солнце Кейтеринг +Пароль не хранится в архиве. Используется пароль облачного аккаунта. + +УПРАВЛЕНИЕ ОБЛАКОМ +Постоянная плашка «Облако» поверх всех разделов удалена. Облако и пользователи открываются только через Настройки -> Аккаунт. + +ОФЛАЙН +После первого успешного входа и загрузки базы PWA хранит локальную рабочую копию. При восстановлении интернета изменения синхронизируются с Supabase. + + +============================================================================== +РАЗДЕЛ ИЗ ROLLBACK-V17.txt +============================================================================== +ОТКАТ v17 + +v17 не удаляет старую структуру sun_app_state и не заменяет существующий архив v16. +Если при тестировании найдена критическая проблема: +1. Закройте v17. +2. Запустите прежнюю папку v16. +3. Войдите в тот же Supabase аккаунт. +4. Загрузите облачную копию. + +Новая v17 схема добавлена параллельно и не требуется для запуска v16. +Перед восстановлением старого состояния из backup используйте Настройки → Стабильность и резервирование → Восстановить. + + +============================================================================== +РАЗДЕЛ ИЗ SAAS-V16-README.txt +============================================================================== +СОЛНЦЕ КЕЙТЕРИНГ — SaaS v16 + +Что уже работает: +- отдельная рабочая база (workspace) для каждой компании; +- 14 дней Полного тарифа для новых компаний; +- выбор «Пустая база» или «Загрузить демо» при создании компании; +- тарифы Базовый / Профессиональный / Полный; +- лимиты сотрудников: 1 / 3 / без лимита; +- серверная проверка тарифа и функций; +- после окончания: 7 дней только просмотр, затем блокировка без удаления данных; +- SaaS-кабинет владельца сервиса: компании, тариф, срок, блокировка, индивидуальная функция; +- текущая рабочая база Солнце Кейтеринг сохранена на Полном тарифе без практического ограничения срока. + +Для нового Supabase-проекта: +1. Выполнить SUPABASE-SETUP.sql. +2. Выполнить SUPABASE-RBAC-V3.sql. +3. Выполнить SUPABASE-SAAS-V16.sql. +4. Выполнить SUPABASE-SAAS-V16-FINALIZE.sql. + +Для текущего Supabase-проекта эти миграции уже применены. + +Что НЕ входит в v16: +- автоматическое списание денег; +- платёжный webhook; +- публичная страница оплаты/чекаут; +- автоматические письма об окончании подписки. +Сейчас владелец сервиса может активировать и продлевать тариф вручную из SaaS-кабинета. + + +============================================================================== +РАЗДЕЛ ИЗ V17-STABILITY-README.txt +============================================================================== +СОЛНЦЕ КЕЙТЕРИНГ · v17 STABILITY · LOCAL TEST + +Эта версия предназначена для локального тестирования перед переносом на постоянный сервер. + +КАК ЗАПУСТИТЬ +1. Распакуйте архив в отдельную папку. +2. Запустите start-local-test.bat. +3. Приложение откроется по http://localhost:8787/ +4. Войдите в тот же облачный аккаунт Supabase. + +ЧТО ИЗМЕНИЛОСЬ +- Старый sun_app_state НЕ удалён и остаётся аварийным мостом. +- Заказы, строки заказов, каталог, клиенты и настройки зеркалируются в отдельные таблицы v17. +- Облачное сохранение использует проверку revision. Устаревшая вкладка не может молча затереть более новую облачную версию. +- Несинхронизированные изменения отмечаются в локальной IndexedDB-очереди. +- После успешной синхронизации очередь очищается. +- Ошибки JavaScript сохраняются локально и отправляются в облачный журнал после появления интернета. +- Раз в день создаётся серверная резервная копия workspace. +- В Настройки добавлен блок «Стабильность и резервирование · v17». +- Из него можно вручную сделать backup, восстановить backup и выгрузить аварийный JSON. +- Добавлен /healthz для будущего серверного мониторинга. + +ВАЖНО +v17 пока использует старую структуру как основной источник для интерфейса, а новую структуру как синхронное зеркало. +Это сделано намеренно для безопасного тестирования. После проверки нескольких реальных рабочих дней можно переключить заказы и каталог на новые таблицы как на основной источник. + +ПЕРЕНОС НА СЕРВЕР ПОТОМ +Архитектура уже готова к HTTPS-хостингу: статические файлы + Supabase. Локальный server.js нужен только для удобного тестирования на компьютере. + + +v17.2: клиентские предложения показывают полный состав каждого бокса во всех шаблонах и PDF; высота строк/карточек рассчитывается динамически. diff --git a/docs/STABILITY-SECURITY-V17.6.0.md b/docs/STABILITY-SECURITY-V17.6.0.md new file mode 100644 index 0000000..84b1e03 --- /dev/null +++ b/docs/STABILITY-SECURITY-V17.6.0.md @@ -0,0 +1,30 @@ +# Caterium v17.6.0 Stability & Security + +## Rollback +v17.5.31 is frozen separately as `Caterium-v17.5.31-ROLLBACK.zip` and remains the rollback point. + +## Verification completed +- Stored DOM-XSS regression payload: no script execution in the completed browser QA pass. +- Shared safe DOM insertion helper replaces fragile insertBefore call sites used by dynamic navigation/settings UI. +- Four client-offer templates checked: Light, Editorial Grid, Midnight Glass, Emerald Gold. +- Catalog PDF checked separately. +- Final PDF preflight: 0 open/structure errors across all 5 generated PDF files; every page re-rendered successfully. +- PDF pages use consistent A4 geometry; no page clipping/overlap was found in the visual contact-sheet review. +- PDFs remain image-based by design in this release; the preflight warning about image-only/scanned-like content is expected. +- 390px mobile viewport was rechecked after the second-pass containment fix; the document-level horizontal overflow was removed. +- Chat navigation remains a single sidebar/bottom-nav item and order chat remains inline in the order. +- Current catalog: 113 photos; legacy compatibility: 60 photos. +- Production `caterium-create-employee` Edge Function source is versioned in the archive; service-role credentials remain environment-only. +- Blocking `offer-gallery-data.js` was removed; only the two gallery JPGs actually used by the offer remain. + +## Automated gates +- `npm test` = syntax checks + static security checks + release checks. +- Playwright E2E specifications are included under `tests/` for CI/local environments with Playwright installed. +- The current execution environment blocks direct Chromium navigation by administrator policy, so the final packaging pass relies on the already completed browser QA artifacts plus the repeatable static/release gates and PDF render/preflight loop. + +## Release score loop +First full pass: **7.0/10**. Main residual issues were mobile horizontal overflow, remaining fragile DOM insertion points, and insufficient release gating. + +Second pass: **9.0/10** after mobile containment, additional safe DOM insertion replacements, PDF render/preflight QA, production Edge Function source recovery, startup payload trimming, and release/security gates. + +The remaining point is architectural debt: the application is still a large historical monolith and the PDF is image-based rather than selectable/vector text. diff --git a/docs/photo-sources.json b/docs/photo-sources.json new file mode 100644 index 0000000..fa3722d --- /dev/null +++ b/docs/photo-sources.json @@ -0,0 +1,454 @@ +[ + { + "path": "catalog/current-001.jpg", + "url": "https://static.tildacdn.com/stor3131-6532-4762-a534-353638663730/11f4ce48f250257ec824a12e1c541150.jpg" + }, + { + "path": "catalog/current-002.jpg", + "url": "https://static.tildacdn.com/stor6634-3466-4635-a164-386637346334/e94521aba11d1824218728cee4312c03.jpg" + }, + { + "path": "catalog/current-003.jpg", + "url": "https://static.tildacdn.com/stor3034-3036-4334-b233-303363326236/d72f45ce6d6a044d8d892a0718f29d89.jpg" + }, + { + "path": "catalog/current-004.jpg", + "url": "https://static.tildacdn.com/stor3662-3435-4561-b031-663437646233/efd5e71d2a8654b0afa26a6dd7a0595a.jpg" + }, + { + "path": "catalog/current-005.jpg", + "url": "https://static.tildacdn.com/stor6133-6537-4533-a166-666430373166/014bff38be088e524c0a4fd2ee1daab3.jpg" + }, + { + "path": "catalog/current-006.jpg", + "url": "https://static.tildacdn.com/stor6161-3163-4032-b338-363935363839/7b9abb2bc27c854236e234383f044c20.jpg" + }, + { + "path": "catalog/current-007.jpg", + "url": "https://static.tildacdn.com/stor3065-6462-4666-b134-376564613831/527188ad5747060eaa88cdd6b033e1b7.jpg" + }, + { + "path": "catalog/current-008.jpg", + "url": "https://static.tildacdn.com/stor6238-3635-4936-a361-343062343130/6c2841340110a32dee16481cf5776858.jpg" + }, + { + "path": "catalog/current-009.jpg", + "url": "https://static.tildacdn.com/stor6231-3837-4666-b564-656661306534/a9b0efe3696fa0cd6530e3100cce2447.jpg" + }, + { + "path": "catalog/current-010.jpg", + "url": "https://static.tildacdn.com/stor3162-6131-4337-b731-376266363535/6998754c49e80fca1df239d01e91b239.jpg" + }, + { + "path": "catalog/current-011.jpg", + "url": "https://static.tildacdn.com/stor6264-3139-4939-b331-623963306665/c62d84a2c9de7fd115f9878617709a99.jpg" + }, + { + "path": "catalog/current-012.jpg", + "url": "https://static.tildacdn.com/stor3134-6638-4635-b032-623233383830/413674a63540f26bbcded51a2f37c3a7.jpg" + }, + { + "path": "catalog/current-013.jpg", + "url": "https://static.tildacdn.com/stor6438-6236-4831-b038-346437643037/b7305f8e759e161407a9996e0218cf83.jpg" + }, + { + "path": "catalog/current-014.jpg", + "url": "https://static.tildacdn.com/stor3134-6638-4635-b032-623233383830/413674a63540f26bbcded51a2f37c3a7.jpg" + }, + { + "path": "catalog/current-015.jpg", + "url": "https://static.tildacdn.com/stor3536-6463-4535-a135-653763366634/3b6eec9a6310eea531c8cb70b00955a9.jpg" + }, + { + "path": "catalog/current-016.jpg", + "url": "https://static.tildacdn.com/stor3339-6634-4634-b537-363366353636/7ef8a4de7cfc055f633ab29ad7e5fca4.jpg" + }, + { + "path": "catalog/current-017.jpg", + "url": "https://static.tildacdn.com/stor3030-3766-4661-b364-313536363731/f7a5bb4329e864b12eb5aed656c13c2b.jpg" + }, + { + "path": "catalog/current-018.jpg", + "url": "https://static.tildacdn.com/stor3865-3463-4832-a531-326663623661/0ab2f6a1959adeb68f6c64e21fc52e6c.jpg" + }, + { + "path": "catalog/current-019.jpg", + "url": "https://static.tildacdn.com/stor3834-6339-4234-a638-656236303531/d70c6ff9606b8f29f4d0a334643c6fa5.jpg" + }, + { + "path": "catalog/current-020.jpg", + "url": "https://static.tildacdn.com/stor3038-3736-4564-a635-333961643763/ae631a3930ee087c993f0eb5ae477388.jpg" + }, + { + "path": "catalog/current-021.jpg", + "url": "https://static.tildacdn.com/stor3638-3139-4562-b430-356363633431/a0eae79a99b4f1ca59be8a445d975f94.jpg" + }, + { + "path": "catalog/current-022.jpg", + "url": "https://static.tildacdn.com/stor3536-3766-4033-b235-643062373262/1e521931b938e17b0ebe397a447067bb.jpg" + }, + { + "path": "catalog/current-023.jpg", + "url": "https://static.tildacdn.com/stor3966-6635-4139-b965-666363393466/df421e8c29457bb6a660810c14d4dbca.jpg" + }, + { + "path": "catalog/current-024.jpg", + "url": "https://static.tildacdn.com/stor3133-6661-4839-b565-376433313839/471052a775dfa8fae813f1b043171e21.jpg" + }, + { + "path": "catalog/current-025.jpg", + "url": "https://static.tildacdn.com/stor3539-3831-4031-b532-323963396137/235a566973dc035faa18ef9a57b4bef6.jpg" + }, + { + "path": "catalog/current-026.jpg", + "url": "https://static.tildacdn.com/stor6362-6234-4063-b636-373636373465/6c868c53823c2c2799bacbef8b7ec5dc.jpg" + }, + { + "path": "catalog/current-027.jpg", + "url": "https://static.tildacdn.com/stor3439-6237-4364-a566-666531346532/a368a54d5dd3ef381bdb587f81dd3953.jpg" + }, + { + "path": "catalog/current-028.jpg", + "url": "https://static.tildacdn.com/stor6664-6363-4635-b632-383362383935/f5779eef4eaff67dec0cb8a9868fe912.jpg" + }, + { + "path": "catalog/current-029.jpg", + "url": "https://static.tildacdn.com/stor6166-3066-4535-b333-356263356363/25951fa13b57555854635d8fe1a1ecc9.jpg" + }, + { + "path": "catalog/current-030.jpg", + "url": "https://static.tildacdn.com/stor3131-3637-4837-b039-386331346435/c4fd5306cc629e81fe974d0e1238eb06.jpg" + }, + { + "path": "catalog/current-031.jpg", + "url": "https://static.tildacdn.com/stor3235-3535-4639-b164-663664323936/c3dc9dc2931b0f950034495dd8cc6b46.jpg" + }, + { + "path": "catalog/current-032.jpg", + "url": "https://static.tildacdn.com/stor3235-3661-4038-b035-613032303361/6f2c3ac522ad9d7e73d14f3fc4c47630.jpg" + }, + { + "path": "catalog/current-033.jpg", + "url": "https://static.tildacdn.com/stor6166-3066-4535-b333-356263356363/25951fa13b57555854635d8fe1a1ecc9.jpg" + }, + { + "path": "catalog/current-034.jpg", + "url": "https://static.tildacdn.com/stor3836-3438-4835-a132-663962396637/fac8bc386bd98959d75c6b71cd83471b.jpg" + }, + { + "path": "catalog/current-035.jpg", + "url": "https://static.tildacdn.com/stor3334-6361-4633-a266-643132323266/74b63ae226e1ebfbd116a822be4f642e.jpg" + }, + { + "path": "catalog/current-036.jpg", + "url": "https://static.tildacdn.com/stor3136-3834-4562-b037-396264363263/e8c1bbdff0574f52a2184a33d6f2115c.jpg" + }, + { + "path": "catalog/current-037.jpg", + "url": "https://static.tildacdn.com/stor6334-3534-4266-a664-356162323565/c13d0896543e32d12ccad0c0a108dac5.jpg" + }, + { + "path": "catalog/current-038.jpg", + "url": "https://static.tildacdn.com/stor3364-6237-4561-b432-326230363433/3233d197bebae6174651a7732ec8ca3e.jpg" + }, + { + "path": "catalog/current-039.jpg", + "url": "https://static.tildacdn.com/stor3239-6231-4130-a363-643732356464/1a72939cda10b9fc447157193a2c69cf.jpg" + }, + { + "path": "catalog/current-040.jpg", + "url": "https://static.tildacdn.com/stor3832-3732-4635-b732-636135393265/3e414b433c0aab18c0a1131d256c92e5.jpg" + }, + { + "path": "catalog/current-041.jpg", + "url": "https://static.tildacdn.com/stor6431-3634-4935-a638-323064343638/8d7c3b547b6e5abd09fe498c2e0b3853.jpg" + }, + { + "path": "catalog/current-042.jpg", + "url": "https://static.tildacdn.com/stor3362-3331-4631-b738-646138333763/b3932c942568e83ab0babc98b77ee5c3.jpg" + }, + { + "path": "catalog/current-043.jpg", + "url": "https://static.tildacdn.com/stor3565-6565-4739-b638-343639336130/ce325e928513a5c7ad2a5c9328927bd0.jpg" + }, + { + "path": "catalog/current-044.jpg", + "url": "https://static.tildacdn.com/stor3131-3335-4135-b962-356338643761/dabf8e59f8b405c2998cc725627a832a.jpg" + }, + { + "path": "catalog/current-045.jpg", + "url": "https://static.tildacdn.com/stor6365-6464-4263-b066-653333643438/eba899d7b96c0e9f077467bb4076cf56.jpg" + }, + { + "path": "catalog/current-046.jpg", + "url": "https://static.tildacdn.com/stor3064-6631-4061-b231-393166393931/2e792a2bd15012a2c134a17213d64706.jpg" + }, + { + "path": "catalog/current-047.jpg", + "url": "https://static.tildacdn.com/stor3936-6435-4438-a564-643362376266/c0d42f2444a396f1b836f4f5e2d2a2b8.jpg" + }, + { + "path": "catalog/current-048.jpg", + "url": "https://static.tildacdn.com/stor6635-6661-4361-b031-303465633437/9c824de9c9ad08c435f64d62876ca8f2.jpg" + }, + { + "path": "catalog/current-049.jpg", + "url": "https://static.tildacdn.com/stor6530-6533-4235-b631-613831393533/2651153cdc6a396acfe9c7c1f1ffa6e3.jpg" + }, + { + "path": "catalog/current-050.jpg", + "url": "https://static.tildacdn.com/stor3566-6437-4361-b137-343366323736/8f1f58eb858f669ca4acea85ad8bbbce.jpg" + }, + { + "path": "catalog/current-051.jpg", + "url": "https://static.tildacdn.com/stor3363-3539-4532-b731-343434323163/3d114d5bd96795183d342a37ef5bacb7.jpg" + }, + { + "path": "catalog/current-052.jpg", + "url": "https://static.tildacdn.com/stor3234-6630-4664-b230-616165303866/a350de68077dc437a4a7c0d1f07db97b.jpg" + }, + { + "path": "catalog/current-053.jpg", + "url": "https://static.tildacdn.com/stor6566-6236-4038-b038-326331333761/acfb6d855c780c92c4cd6e667229a9d7.jpg" + }, + { + "path": "catalog/current-054.jpg", + "url": "https://static.tildacdn.com/stor3662-3963-4031-b065-646235373965/f8f3bd5b10fe9142e453766305684126.jpg" + }, + { + "path": "catalog/current-055.jpg", + "url": "https://static.tildacdn.com/stor3461-3363-4063-a530-336136633338/951f5c4bcaa35b878fd8f0d4cdf5812a.jpg" + }, + { + "path": "catalog/current-056.jpg", + "url": "https://static.tildacdn.com/stor6335-3638-4432-b230-393163396334/d08deb92d2102c1e81002f0ff5b1cd18.jpg" + }, + { + "path": "catalog/current-057.jpg", + "url": "https://static.tildacdn.com/stor3163-3035-4466-b435-303832663162/2a85461ba4c7132af333c481f6974334.jpg" + }, + { + "path": "catalog/current-058.jpg", + "url": "https://static.tildacdn.com/stor3132-6233-4466-b164-376530633465/a5159b1de5f6bd3d2dde7609c1853f82.jpg" + }, + { + "path": "catalog/current-059.jpg", + "url": "https://static.tildacdn.com/stor3165-6138-4663-b433-393039653034/4ad56410ecf6a163824061d08cb297da.jpg" + }, + { + "path": "catalog/current-060.jpg", + "url": "https://static.tildacdn.com/stor6437-6361-4833-a537-623832376136/29ff2e5084a5f816954ea5621004b0c0.jpg" + }, + { + "path": "catalog/current-061.jpg", + "url": "https://static.tildacdn.com/stor3765-3765-4663-a333-623738326235/02eaafb2bae5cef3edaa761aa7cea142.jpg" + }, + { + "path": "catalog/current-062.jpg", + "url": "https://static.tildacdn.com/stor6365-3039-4931-a333-623061333865/ceb789e801d358ff78f2a4800f79f78c.jpg" + }, + { + "path": "catalog/current-063.jpg", + "url": "https://static.tildacdn.com/stor6262-6636-4563-b332-383166313034/73a699e67040500f90edd197152cb0b9.jpg" + }, + { + "path": "catalog/current-064.jpg", + "url": "https://static.tildacdn.com/stor3366-3663-4664-a136-633432326530/8a056937be29f8f531767a565fc2fb45.jpg" + }, + { + "path": "catalog/current-065.jpg", + "url": "https://static.tildacdn.com/stor3566-3862-4334-b465-376164633737/7ad059a9bf198827dfd142008f81bb28.jpg" + }, + { + "path": "catalog/current-066.jpg", + "url": "https://static.tildacdn.com/stor3532-6333-4432-b037-373031393364/3f233fc73a9932953876810c2530779b.jpg" + }, + { + "path": "catalog/current-067.jpg", + "url": "https://static.tildacdn.com/stor6461-6235-4134-a630-393261313034/f00b5038885f425bf4463b0f070416b2.jpg" + }, + { + "path": "catalog/current-068.jpg", + "url": "https://static.tildacdn.com/stor3963-3634-4262-b363-393536623037/7f3160b562bdb81243fcca8b07ea73ca.jpg" + }, + { + "path": "catalog/current-069.jpg", + "url": "https://static.tildacdn.com/stor6134-6534-4032-b863-326334303439/b6299036af0105e24b66b92fc149fceb.jpg" + }, + { + "path": "catalog/current-070.jpg", + "url": "https://static.tildacdn.com/stor6330-3331-4534-a431-616637323265/cc72f08366e2b3bb3b2cec181fcfb6b2.jpg" + }, + { + "path": "catalog/current-071.jpg", + "url": "https://static.tildacdn.com/stor3864-6138-4939-a263-323633343831/e41074621630fb64c6551fde54a0491a.jpg" + }, + { + "path": "catalog/current-072.jpg", + "url": "https://static.tildacdn.com/stor3530-3337-4465-a331-393235636139/6bed2df48d65e7dfdf673cc6ab1d66f0.jpg" + }, + { + "path": "catalog/current-073.jpg", + "url": "https://static.tildacdn.com/stor3132-6432-4637-b930-353763333565/0e6665f2fddcdc6a2538ff0e4d6714e3.jpg" + }, + { + "path": "catalog/current-074.jpg", + "url": "https://static.tildacdn.com/stor3063-6361-4562-b864-386439643866/5a0a9e8680146e4af16b0b3a71e5e344.jpg" + }, + { + "path": "catalog/current-075.jpg", + "url": "https://static.tildacdn.com/stor3864-3766-4132-a362-336565363634/331d7a3cb423efad3851318ebc243503.jpg" + }, + { + "path": "catalog/current-076.jpg", + "url": "https://static.tildacdn.com/stor3764-3435-4336-a638-386530643039/b66c7fb8fba795f74358798f008ff284.jpg" + }, + { + "path": "catalog/current-077.jpg", + "url": "https://static.tildacdn.com/stor3764-3435-4336-a638-386530643039/b66c7fb8fba795f74358798f008ff284.jpg" + }, + { + "path": "catalog/current-078.jpg", + "url": "https://static.tildacdn.com/stor3764-3435-4336-a638-386530643039/b66c7fb8fba795f74358798f008ff284.jpg" + }, + { + "path": "catalog/current-079.jpg", + "url": "https://static.tildacdn.com/stor3764-3435-4336-a638-386530643039/b66c7fb8fba795f74358798f008ff284.jpg" + }, + { + "path": "catalog/current-080.jpg", + "url": "https://static.tildacdn.com/stor3661-6362-4365-a166-396432366336/b3461ed10ad1d21ba6f327e14f87561a.jpg" + }, + { + "path": "catalog/current-081.jpg", + "url": "https://static.tildacdn.com/stor6136-3763-4363-b632-613035666139/b89d5f88da96f52526ccba83cfb1e631.jpg" + }, + { + "path": "catalog/current-082.jpg", + "url": "https://static.tildacdn.com/stor3764-3435-4336-a638-386530643039/b66c7fb8fba795f74358798f008ff284.jpg" + }, + { + "path": "catalog/current-083.jpg", + "url": "https://static.tildacdn.com/stor3764-3435-4336-a638-386530643039/b66c7fb8fba795f74358798f008ff284.jpg" + }, + { + "path": "catalog/current-084.jpg", + "url": "https://static.tildacdn.com/stor3530-3337-4465-a331-393235636139/6bed2df48d65e7dfdf673cc6ab1d66f0.jpg" + }, + { + "path": "catalog/current-085.jpg", + "url": "https://static.tildacdn.com/stor3535-3432-4638-b763-636162616233/f392e1f738f4ec51be28ac85ff7f34d1.jpg" + }, + { + "path": "catalog/current-086.jpg", + "url": "https://static.tildacdn.com/stor6263-6138-4433-a639-386465356138/915334ed7ec0a973fc528d5b1bcddeb9.jpg" + }, + { + "path": "catalog/current-087.jpg", + "url": "https://static.tildacdn.com/stor6161-3666-4430-b437-303230613863/b6c27133c86b74e669c4d8d14cddaa98.jpg" + }, + { + "path": "catalog/current-088.jpg", + "url": "https://static.tildacdn.com/stor6433-6530-4938-a264-366133396635/26c04c584ac3ce62893f86e3f21ef6a4.jpg" + }, + { + "path": "catalog/current-089.jpg", + "url": "https://static.tildacdn.com/stor3438-3336-4038-b563-636464663765/26624b9790a69468292e752308984af1.jpg" + }, + { + "path": "catalog/current-090.jpg", + "url": "https://static.tildacdn.com/stor3430-3734-4935-a666-616161393566/92e1260daf92944a35c6ce5a64ca38f3.jpg" + }, + { + "path": "catalog/current-091.jpg", + "url": "https://static.tildacdn.com/stor3937-3731-4331-a333-333266353435/018e8f56c6b0053168f0d0cf73872966.jpg" + }, + { + "path": "catalog/current-092.jpg", + "url": "https://static.tildacdn.com/stor3939-3434-4061-a231-636661616561/607dc1f31bc1ca66fb1a611d49407aa5.jpg" + }, + { + "path": "catalog/current-093.jpg", + "url": "https://static.tildacdn.com/stor3836-3436-4239-a536-353332396264/232fcd6e93bf0abc918ef73529cded4b.jpg" + }, + { + "path": "catalog/current-094.jpg", + "url": "https://static.tildacdn.com/stor3065-3632-4533-b333-636566613663/7979f348481e979905836febdf7933a6.jpg" + }, + { + "path": "catalog/current-095.jpg", + "url": "https://static.tildacdn.com/stor3761-3162-4461-b966-323139613763/08d95a9c71b402415f9479b3f9520892.jpg" + }, + { + "path": "catalog/current-096.jpg", + "url": "https://static.tildacdn.com/stor3033-3137-4930-b264-666532373134/69925db7e39c25877ac58869c15037a6.jpg" + }, + { + "path": "catalog/current-097.jpg", + "url": "https://static.tildacdn.com/stor3333-3839-4537-a236-623030313665/4130c8f6bd2b58b41c4edd83eee922a9.jpg" + }, + { + "path": "catalog/current-098.jpg", + "url": "https://static.tildacdn.com/stor6230-3133-4564-b237-646535613637/a2a99a4eeb22cb33b5b5cea2a79a17cb.jpg" + }, + { + "path": "catalog/current-099.jpg", + "url": "https://static.tildacdn.com/stor3137-6538-4132-b739-653932333233/15a1c8db984c5e68f357b2ee12a18c2a.jpg" + }, + { + "path": "catalog/current-100.jpg", + "url": "https://static.tildacdn.com/stor3031-6132-4334-b330-633933383437/7e711a925bef78f54c6417cfed424f7b.jpg" + }, + { + "path": "catalog/current-101.jpg", + "url": "https://static.tildacdn.com/stor6565-3862-4536-a531-396336343530/94e933aa7cb2ca8cc2f9a6975508c69b.jpg" + }, + { + "path": "catalog/current-102.jpg", + "url": "https://static.tildacdn.com/stor3130-6665-4166-a562-346464663365/1ace83e57a93e4bd15c4117e6ebd10f4.jpg" + }, + { + "path": "catalog/current-103.jpg", + "url": "https://static.tildacdn.com/stor6666-6336-4235-b437-663731323432/f733be8a64d1186ac1153c3eba271d59.jpg" + }, + { + "path": "catalog/current-104.jpg", + "url": "https://static.tildacdn.com/stor6561-6664-4163-a232-323236383637/1b4aa616a89592851de475a7633530b2.jpg" + }, + { + "path": "catalog/current-105.jpg", + "url": "https://static.tildacdn.com/stor3364-3366-4961-a230-393136633735/bd16ccb4240c13b0408fcd3cdb263c7f.jpg" + }, + { + "path": "catalog/current-106.jpg", + "url": "https://static.tildacdn.com/stor3133-3966-4466-a633-353062663934/19cfdd0803e1252edb0a229fe8cc10e1.jpg" + }, + { + "path": "catalog/current-107.jpg", + "url": "https://static.tildacdn.com/stor3862-3737-4631-a637-656265333935/be0ca89c31666c8d357814c69f0c1527.jpg" + }, + { + "path": "catalog/current-108.jpg", + "url": "https://static.tildacdn.com/stor3730-3136-4137-b861-393762656436/9b8faf6a48cff9bc8b16cef36396d144.jpg" + }, + { + "path": "catalog/current-109.jpg", + "url": "https://static.tildacdn.com/stor6137-3965-4334-a432-316165313635/7e2aebaffa009dfc1b7a580ceac8a640.jpg" + }, + { + "path": "catalog/current-110.jpg", + "url": "https://static.tildacdn.com/stor3937-3432-4537-a233-646339376631/d0ca8846eba6810423c5421e7060c567.jpg" + }, + { + "path": "catalog/current-111.jpg", + "url": "https://static.tildacdn.com/stor3437-6365-4139-b134-326232643133/8d0a33ea4f3ace73aa73679aa8ac3b4d.jpg" + }, + { + "path": "catalog/current-112.jpg", + "url": "https://static.tildacdn.com/stor6334-3463-4166-b330-643438393235/49f88576b7fb7573735b20dc4161de96.jpg" + }, + { + "path": "catalog/current-113.jpg", + "url": "https://static.tildacdn.com/stor3139-3866-4633-a234-663139643733/5c7060058669060670ed95fb719078c3.jpg" + } +] \ No newline at end of file diff --git a/docs/release-manifest.json b/docs/release-manifest.json new file mode 100644 index 0000000..1f58bb0 --- /dev/null +++ b/docs/release-manifest.json @@ -0,0 +1,297 @@ +{ + "app": "Caterium", + "version": "v17.6.0", + "channel": "local-test", + "schema": 17, + "legacyStateKept": true, + "normalizedMirror": true, + "optimisticRevisionLock": true, + "offlineOutbox": "IndexedDB", + "dailyBackups": true, + "serverReady": true, + "workspaceAutoDiscovery": true, + "invitesTemporarilyDisabled": false, + "pwaCache": "v65-v17.6.0-stability-security", + "fullOfferDescriptions": true, + "dynamicOfferRows": true, + "pdfOfferDescriptionFix": true, + "boxWeightEditable": true, + "boxWeightVisibleEverywhere": true, + "offerCompositionTotals": true, + "offerSnapshotVersion": 8, + "boxPiecesEditable": true, + "ingredientUnitWeightEditable": true, + "box18CompositionFixed": true, + "clientOfferUsesExplicitPieces": true, + "clientOfferBoxTotals": true, + "clientOfferWeightRoundUpStepGrams": 5, + "clientOfferPiecesRoundUpToInteger": true, + "pdfBalancedCards": true, + "pdfDuplicateGalleryRemoved": true, + "boxGuestRangeHiddenEverywhere": true, + "clientOfferNoDuplicateBoxQuantity": true, + "catalogTabColorPicker": true, + "catalogTabAutoContrast": true, + "catalogEditorGenericPriceAndSaveLabels": true, + "catalogPhotoLocalStorageOptimization": true, + "cloudQuotaRetryForSunBoxes": true, + "catalogSource": "Все боксы — Солнце Кейтеринг (2).pdf", + "catalogVersion": "2026-09-02", + "catalogPositions": 113, + "catalogGuestsImported": false, + "catalogRemotePhotoLinks": 0, + "premiumCategoryFromPosition": 85, + "premiumCategoryBetweenBoxesAndDrinks": true, + "boxOriginalPriceEditable": true, + "boxDiscountPriceDisplay": true, + "settingsStickyHeader": true, + "settingsOpenAtTop": true, + "settingsCloudMovedToBottom": false, + "clientOfferTemplates": [ + "light", + "editorial-grid", + "midnight-glass", + "emerald-gold" + ], + "addressCandidateSelection": true, + "addressMapConfirmation": true, + "addressAutocomplete": false, + "addressGeocoder": "Nominatim manual search with Photon fallback", + "clientOfferLegacyCatalogCompatibility": true, + "clientOfferLegacyPhotosEmbedded": false, + "clientOfferPlaceholderSnapshotAutoRefresh": true, + "clientOfferLegacyPhotosPackedLosslessly": false, + "legacyPhotoPackFile": null, + "legacyPhotoPackItems": 0, + "legacyStandalonePhotosKept": 60, + "runtimeBundled": true, + "runtimeBundleFile": "app-runtime.js", + "runtimeBundleModules": 22, + "mobileCssInlined": true, + "serverHealthUsesReleaseVersion": true, + "premiumBoxLikeSupportFixed": true, + "catalogPdfRemoteImageCorsSafe": true, + "compactDistribution": true, + "catalogCompositionStaticPrefill": 56, + "catalogCompositionCurrentSiteVerified": 6, + "catalogCompositionLiveSync": true, + "catalogCompositionLiveSource": "https://solnce-keytering.ru/catalog + /catalog_black", + "catalogCompositionSyncIntervalHours": 6, + "catalogCompositionManualRefresh": false, + "catalogCompositionEditable": true, + "catalogCompositionPreservesManualEdits": true, + "catalogMigrationPreservesCustomItems": true, + "clientOfferLightBackground": "white", + "clientOfferSellingBlock": true, + "clientOfferSellingTextNoClip": true, + "clientOfferFinalGallery": true, + "clientOfferFinalGalleryImages": 2, + "clientOfferFinalGalleryPackedLosslessly": false, + "distributionFileCount": 240, + "finalGallerySource": "2 guaranteed embedded table photos; original offer-gallery files retained", + "finalGalleryPackedFile": null, + "calendarUsesPaymentColors": true, + "calendarPaymentColorsFollowSettings": true, + "calendarPrintFinancialsHidden": true, + "calendarPrintKitchenSafe": true, + "catalogCompositionPremiumDetailSync": true, + "catalogCompositionPremiumMissingForcesRefresh": true, + "catalogCompositionTildaEndpoint": "getproduct", + "catalogCompositionPremiumForceRefresh": true, + "catalogCompositionCacheRequiresPremium": true, + "notes": "Stability & Security release: shared SunSafe XSS defenses, safe DOM insertion, versioned production employee Edge Function source, startup gallery trimming/lazy images, centralized A4 PDF engine, mobile overflow containment, and automated release checks. Chat navigation/order chat from v17.5.31 is preserved.", + "catalogPhotoSources": 113, + "catalogPhotosStoredInCatalog": true, + "catalogLegacyPhotosInCatalog": 60, + "catalogCurrentPhotosPrefilled": 64, + "catalogCurrentPhotosLazyCached": true, + "catalogPhotoSourceMap": "catalog/photo-sources.json", + "catalogCompositionClientFirst": true, + "catalogTtkSeparated": true, + "orderBlankQrTextInlineEditable": true, + "box1SaleBadgeWithoutOldPrice": false, + "performanceAudit": true, + "pdfGeneratedOnDemand": true, + "catalogPhotoStartupWarmup": false, + "catalogPhotoFailureBackoffMinutes": 0, + "catalogCompositionDeferredUntilIdle": true, + "catalogCompositionDetailConcurrency": 2, + "backgroundDomObserversThrottled": true, + "cloudSignatureScanIntervalMs": 9000, + "finalGalleryStoredAsFiles": true, + "finalGalleryFolder": "offer-gallery", + "finalGalleryPreviewUsesDirectUrls": true, + "pdfRasterConversionDeferredUntilDownload": false, + "catalogPhotoPreflight": false, + "catalogPhotoPreflightScript": "prepare-catalog-photos.js (optional manual prefetch)", + "catalogPhotoRequiredBeforeServer": false, + "catalogPhotoTarget": 113, + "catalogPhotoRuntimeRemoteFetch": true, + "catalogPhotoFirstRunInternetRequiredUntilComplete": false, + "catalogPhotoDuplicateUrlReuse": true, + "catalogPhotoDownloadConcurrency": 3, + "catalogPhotoDownloadRetries": 3, + "catalogPhotoHealthStatus": true, + "catalogPhotoFallbackChain": true, + "catalogPhotoProxyCache": true, + "catalogStartupPhotoPreflightBlocking": false, + "clientOfferPdfPhotoFallback": true, + "premiumPhotoFallback": true, + "clientOfferAdaptiveFinalGallery": true, + "clientOfferAdaptiveFinalGalleryMaxPhotos": 2, + "clientOfferFinalGalleryPreviewMaxPhotos": 2, + "box5LocalPhotoFallback": true, + "box8LocalPhotoFallback": true, + "clientOfferEditorialTemplate": true, + "clientOfferEditorialReference": "IMG_3962.jpeg provided 2026-09-04", + "clientOfferEditorialPreviewPdfParity": true, + "clientOfferEditorialSharedGrouping": true, + "clientOfferPreviewUsesPdfPages": true, + "clientOfferAllTemplatesPreviewPdfParity": true, + "clientOfferInlineEditor": true, + "clientOfferInlineEditorPerOrder": true, + "clientOfferFinalGalleryCreatesNewPage": false, + "clientOfferFinalGalleryOnlyUsesExistingWhitespace": true, + "clientOfferInlineControlLinesEdit": true, + "clientOfferFinalGalleryGuaranteedDataFallback": true, + "clientOfferFinalGalleryAutomatic": true, + "clientOfferFinalGalleryMinWhitespacePx": 128, + "workspaceLocalIsolation": true, + "workspaceLocalIsolationStore": "IndexedDB meta / tenant-local:", + "workspaceSwitchClearsWorkingCopy": true, + "workspaceUpgradeLegacyAdoption": true, + "newWorkspaceStarterCatalog": true, + "newWorkspaceStarterCatalogPositions": 113, + "newWorkspaceOrdersEmpty": true, + "newWorkspaceClientsEmpty": true, + "catalogStaticPdfRemoved": true, + "catalogPdfGeneratedOnDemand": true, + "catalogJpegOptimized": true, + "catalogJpegMaxSidePx": 1500, + "catalogJpegQuality": 80, + "reliableLocalSignOut": true, + "signOutScope": "local", + "signOutNetworkTimeoutMs": 2500, + "signOutPreservesWorkspaceSnapshot": true, + "settingsAccountFirst": true, + "settingsBackupCardVisible": false, + "settingsStabilityCardVisible": false, + "settingsTopOrder": [ + "Профиль и аккаунт", + "Пользователи и роли", + "История изменений" + ], + "authGateSignUpVisible": true, + "settingsTabs": true, + "settingsTabsCount": 6, + "settingsTabsMobileHorizontalScroll": true, + "settingsTabsRememberLast": true, + "settingsTabGroups": [ + "Аккаунт", + "Оформление", + "Предложение", + "Заказы", + "Справочники", + "Документы" + ], + "settingsCatalogTabsShortcut": true, + "developerConsole": true, + "developerRoleSource": "public.sun_platform_admins", + "developerServerEnforced": true, + "developerMfaRequired": true, + "developerMfaType": "TOTP / Supabase MFA AAL2", + "developerTabs": [ + "Обзор", + "Компании", + "Аккаунты", + "Тарифы", + "Функции", + "Система", + "Резервные копии", + "Журнал" + ], + "developerSeesAllAuthAccounts": true, + "developerSupportReadOnlyMode": true, + "developerAuditLog": true, + "developerWorkspaceDiagnostics": true, + "developerSubscriptionManagement": true, + "developerFeatureMatrixManagement": true, + "developerStarterCatalogManagement": true, + "developerCanCreateCompanies": true, + "normalUserTechnicalSupabaseSettingsHidden": true, + "normalUserManualCloudControlsHidden": true, + "normalUserBackupsHidden": true, + "normalUserStabilityHidden": true, + "authAdminEdgeFunctionDeployed": false, + "authAdminEdgeFunctionNote": "Source included, but deployment was unavailable in this build environment. All-account listing uses existing server-side sun_platform_list_users RPC; global Auth ban/recovery controls remain unavailable until protected backend function is deployed.", + "developerSqlMigration": "SUPABASE-DEVELOPER-V22.sql", + "developerMfaRecovery": true, + "registrationTabs": true, + "registrationCompanyNameRequired": true, + "registrationAutoCreatesWorkspace": true, + "newRegistrationTrialDays": 14, + "newRegistrationTrialPlan": "full", + "cateriumLoginBranding": true, + "signupPasswordRepeatRemoved": false, + "signupEmailAutoconfirm": "scoped trigger for registration_source=caterium_public_signup", + "signupTrial": "14-day Full", + "developerMfaInputSelectorFixed": true, + "developerMfaRepeatedValidationToastsFixed": true, + "release": "20260907-v17-6-0-stability-security", + "registrationFlow": "email-password-confirm-company-auto-login", + "emailConfirmationRequired": false, + "employeeInviteLinks": false, + "employeeInviteEmailBound": false, + "employeePendingInvitesUI": false, + "directEmployeeCreation": true, + "employeeTemporaryPassword": true, + "chatEnabled": true, + "chatCompanyRoom": true, + "chatDirectMessages": true, + "chatOrderDiscussions": true, + "chatPrivateAttachments": true, + "chatAttachmentBucket": "sun-chat", + "chatAttachmentMaxBytes": 15728640, + "chatMaxAttachmentsPerMessage": 5, + "chatReadStatus": true, + "chatTypingIndicator": true, + "chatPresence": true, + "chatUnreadBadge": true, + "chatRealtimePrivate": true, + "chatDirectIsolation": "workspace membership + sun_chat_participants + RLS", + "chatMigration": "SUPABASE-CHAT-V29.sql", + "granularAdminPermissions": true, + "lastAdminProtection": "other-active-admin-count", + "lastUsersManagerProtection": true, + "adminRightsMigration": "SUPABASE-ADMIN-RIGHTS-V30.sql", + "chatSidebarNav": true, + "chatSidebarSections": [ + "Общий", + "Личные", + "Заказы" + ], + "chatOrderInline": true, + "chatOrderGlobalIndex": true, + "chatSafeNavRegistration": true, + "chatRepeatedInitPollingRemoved": true, + "chatFutureContextsPlanned": [ + "production", + "routes", + "shopping", + "client-internal-notes" + ], + "stabilitySecurityRelease": true, + "rollbackBaseline": "v17.5.31", + "sharedSafeHtmlUtility": "core/sun-safe.js", + "legacyBootstrapExternalized": "legacy/bootstrap.js", + "productionEmployeeEdgeFunctionVersioned": true, + "blockingOfferGalleryBase64Removed": true, + "offerGalleryFiles": 2, + "pdfEngine": "core/pdf-engine.js", + "pdfEngineA4Validated": true, + "mobile390NoDocumentOverflow": true, + "releaseChecks": "tests/static-security.mjs + tests/release-check.mjs", + "playwrightSpec": "tests/app.spec.mjs", + "releaseScoreFirstPass": 7, + "releaseScoreSecondPass": 9 +} diff --git a/docs/releases/V17.5.14-CHANGES.txt b/docs/releases/V17.5.14-CHANGES.txt new file mode 100644 index 0000000..b479ecf --- /dev/null +++ b/docs/releases/V17.5.14-CHANGES.txt @@ -0,0 +1,18 @@ +СОЛНЦЕ КЕЙТЕРИНГ — v17.5.14 + +ФОТОГРАФИИ КАТАЛОГА +- Один механизм изображений для каталога, Premium, предложения клиенту и PDF. +- Цепочка источников: локальный catalog/current-XXX.jpg -> локальный API-кэш -> оригинал Tilda -> логотип-заглушка. +- Сервер больше не блокирует запуск, если часть локального фотокэша отсутствует. +- Недостающие оригиналы кешируются в catalog/current-XXX.jpg при успешном получении. +- Добавлены локальные резервные фото для бокса №5 и основных отсутствовавших вариантов бокса №8. +- Premium больше не зависит только от наличия заранее скачанного JPG. +- DOWNLOAD-ALL-CATALOG-PHOTOS.bat оставлен как необязательная предварительная загрузка. + +ПРЕДЛОЖЕНИЕ КЛИЕНТУ / PDF +- PDF использует ту же цепочку источников изображений, что и каталог. +- Финальный блок «Как это выглядит на вашем столе» использует 1–3 фотографии в зависимости от свободного места на последней странице. +- Если места достаточно, фотографии заполняют остаток страницы; отдельная пустая фотостраница больше не создаётся без необходимости. +- В экранном предложении показываются максимум 3 финальных фотографии. + +Облачная архитектура, заказы и схема данных не менялись. diff --git a/docs/releases/V17.5.15-CHANGES.txt b/docs/releases/V17.5.15-CHANGES.txt new file mode 100644 index 0000000..dd2505d --- /dev/null +++ b/docs/releases/V17.5.15-CHANGES.txt @@ -0,0 +1,8 @@ +Sun Catering v17.5.15 + +- Добавлен 4-й шаблон предложения клиенту «Солнце Editorial» по референсу пользователя. +- Новый шаблон использует одинаковую структуру данных в просмотре и PDF: первый экран, продающий блок, сгруппированное меню, 8 фото позиций, итоги, расчет стоимости, сервисные блоки и финальная адаптивная галерея. +- Для групп меню в просмотре и PDF используется один общий расчет editorialGroups(), поэтому названия, диапазоны веса, количество блюд и количество боксов совпадают. +- Добавлена миниатюра шаблона thumb-editorial-grid.jpg. +- Остальные 3 шаблона и логика фото каталога/Premium из v17.5.14 не изменены. +- PWA cache: v49. diff --git a/docs/releases/V17.5.16-CHANGES.txt b/docs/releases/V17.5.16-CHANGES.txt new file mode 100644 index 0000000..96686c4 --- /dev/null +++ b/docs/releases/V17.5.16-CHANGES.txt @@ -0,0 +1,11 @@ +Sun Catering v17.5.16 + +- Все 4 шаблона предложения клиенту: предпросмотр теперь показывает те же Canvas-страницы, из которых формируется PDF. +- Убрана отдельная iframe/HTML-версия предпросмотра, из-за которой раньше могли расходиться переносы, размеры и фото. +- В окне «Предложение клиенту» добавлена кнопка «Редактировать» и локальный редактор прямо перед скачиванием PDF. +- Можно менять название мероприятия, клиента, дату, гостей, заголовки и продающие тексты конкретного предложения. +- Изменения сохраняются в snapshot конкретного заказа и используются и в просмотре, и в PDF. +- «Как это выглядит на вашем столе»: максимум 2 фотографии. +- Финальные фотографии никогда не создают новую страницу. Если свободного места недостаточно, блок полностью пропускается. +- Если свободного места немного — 1 фото; если достаточно — 2 фото. +- PDF при скачивании собирается из уже показанных страниц, без повторной независимой верстки. diff --git a/docs/releases/V17.5.17-CHANGES.txt b/docs/releases/V17.5.17-CHANGES.txt new file mode 100644 index 0000000..949e501 --- /dev/null +++ b/docs/releases/V17.5.17-CHANGES.txt @@ -0,0 +1,7 @@ +Sun Catering v17.5.17 + +- In the client-offer inline editor, the "Everything under control" block now has a multiline editor for all list items. +- The block title and each control line are saved per proposal and immediately rebuild the same preview/PDF pages. +- Two table photos are now available through a guaranteed embedded fallback, so they do not disappear because of local file/fetch problems. +- Table photos are automatic: 0 photos when there is no free space, 1 photo for a smaller gap, 2 photos for a larger gap. They never create a new page. +- The old optional "final gallery" switch is no longer used; the whitespace-fill rule controls visibility. diff --git a/docs/releases/V17.5.18-CHANGES.txt b/docs/releases/V17.5.18-CHANGES.txt new file mode 100644 index 0000000..66ed50b --- /dev/null +++ b/docs/releases/V17.5.18-CHANGES.txt @@ -0,0 +1,10 @@ +Sun Catering v17.5.18 + +- Added workspace/account isolation for the browser working copy. Orders, clients, finance, stock, employees, suppliers, loyalty data, settings and other sun* company data no longer remain attached to the next signed-in account. +- Before leaving or switching a workspace, the current local working copy is saved to IndexedDB under that workspace. The target workspace is then restored from its own local snapshot or pulled cleanly from Supabase. +- Upgrade safety: an existing v17.5.x local database is automatically adopted by its already configured workspace; if the old configured workspace differs from the next login, the old data is captured separately instead of being attached to the new account. +- New companies start with the current official 113-position catalog while orders, clients and other working data start empty. +- Existing catalog boxes remain independently editable inside each company after the initial copy. +- Catalog JPEG files were recompressed/resized conservatively (max 1500 px side, JPEG quality 80) to reduce distribution size without changing file paths or format. +- Removed the obsolete prebuilt 9 MB static catalog PDF. The visible catalog PDF button already generates the current catalog on demand. +- PWA cache/version bumped to v17.5.18. diff --git a/docs/releases/V17.5.19-CHANGES.txt b/docs/releases/V17.5.19-CHANGES.txt new file mode 100644 index 0000000..5c2cc44 --- /dev/null +++ b/docs/releases/V17.5.19-CHANGES.txt @@ -0,0 +1,9 @@ +Sun Catering v17.5.19 + +- Fixed the «Выйти» buttons in Cloud settings, sidebar, onboarding/auth gates and subscription gate. +- Logout no longer waits for a full cloud sync before leaving the account. The current workspace working copy is preserved locally first. +- Supabase logout now uses signOut({ scope: 'local' }) so it signs out the current browser/device session instead of the default global sign-out. +- Added a 2.5 s network bound for Supabase logout. If the endpoint is unavailable, only this project's persisted Supabase auth token is removed locally and the application reloads into the signed-out state. +- Logout buttons display «Выходим…» while the operation is running and ignore repeated clicks. +- PWA cache and runtime query bumped so an already installed v17.5.18 cannot continue serving the broken logout handler. +- v17.5.18 workspace/account data isolation and the 113-position starter catalog are preserved unchanged. diff --git a/docs/releases/V17.5.20-CHANGES.txt b/docs/releases/V17.5.20-CHANGES.txt new file mode 100644 index 0000000..4fe84ad --- /dev/null +++ b/docs/releases/V17.5.20-CHANGES.txt @@ -0,0 +1,8 @@ +Sun Catering v17.5.20 + +- В Настройках профиль/вход/создание аккаунта/выход перенесены в самый верх. +- Блок обычного резервного копирования удалён из Настроек. +- Диагностический блок «Стабильность и резервирование» скрыт из обычных Настроек. +- Фоновые защитные механизмы данных оставлены без изменений. +- Техническое подключение Supabase перенесено вниз блока профиля. +- На основном экране входа добавлена кнопка «Создать аккаунт», чтобы регистрация была доступна сразу после выхода. diff --git a/docs/releases/V17.5.21-CHANGES.txt b/docs/releases/V17.5.21-CHANGES.txt new file mode 100644 index 0000000..7c28bba --- /dev/null +++ b/docs/releases/V17.5.21-CHANGES.txt @@ -0,0 +1,17 @@ +Sun Catering v17.5.21 + +Настройки разделены на 6 смысловых вкладок: +- Аккаунт — профиль и компания, тариф, пользователи и роли, история изменений. +- Оформление — цвета интерфейса и боковой панели, готовые цветовые варианты и дополнительные цвета. +- Предложение — шаблон предложения клиенту и настройки содержимого предложения. +- Заказы — цвета оплаты и статусы заказов. +- Справочники — типы мероприятий, источники клиентов и управление вкладками каталога. +- Документы — реквизиты товарного чека, QR-код и настройки бланка заказа. + +Дополнительно: +- вкладки горизонтальные и прокручиваются на мобильных устройствах; +- последняя открытая вкладка запоминается; +- существующие обработчики настроек, localStorage и Supabase не переписаны; +- добавлена карточка быстрого перехода к настройке вкладок каталога; +- резервные копии и блок стабильности по-прежнему скрыты из обычных настроек; +- обновлён PWA-кэш. diff --git a/docs/releases/V17.5.23-CHANGES.txt b/docs/releases/V17.5.23-CHANGES.txt new file mode 100644 index 0000000..cad1f00 --- /dev/null +++ b/docs/releases/V17.5.23-CHANGES.txt @@ -0,0 +1,9 @@ +v17.5.23 — Developer MFA + SaaS registration + +- Developer MFA recovers stale unverified TOTP and confirms AAL2. +- Login/Register tabs added. +- Registration asks for company name, email and password confirmation. +- New company is created automatically after confirmation/login. +- Server grants Full trial for 14 days. +- Starter catalog contains the current 113 positions; orders and clients start empty. +- PWA cache bumped to v57. diff --git a/docs/releases/V17.5.25-CHANGES.txt b/docs/releases/V17.5.25-CHANGES.txt new file mode 100644 index 0000000..e8f2192 --- /dev/null +++ b/docs/releases/V17.5.25-CHANGES.txt @@ -0,0 +1,11 @@ +v17.5.25 — Auth / MFA / Caterium login fix + +1. Исправлена точная ошибка чтения 6-значного MFA-кода: поле больше не ищется как CSS-селектор через getElementById. +2. Локальная проверка MFA больше не создаёт пачку одинаковых toast-уведомлений. +3. Удалена оставшаяся ссылка на удалённое поле «Повторите пароль», которая могла останавливать обработчики входа/регистрации. +4. Регистрация: Название компании + Email + один Пароль. +5. Регистрация Caterium помечается registration_source=caterium_public_signup; сервер подтверждает только такие новые email без письма на текущем тестовом этапе. +6. После регистрации приложение сразу входит и создаёт отдельную компанию с Full trial на 14 дней и стартовым каталогом. +7. Если ранее созданный аккаунт вошёл без компании, можно сразу создать компанию из экрана входа. +8. На экране входа/регистрации используется утверждённый знак Caterium C с круглой янтарной точкой. +9. PWA cache обновлён до v59. diff --git a/docs/releases/V17.5.26-CHANGES.txt b/docs/releases/V17.5.26-CHANGES.txt new file mode 100644 index 0000000..4a3975a --- /dev/null +++ b/docs/releases/V17.5.26-CHANGES.txt @@ -0,0 +1,12 @@ +v17.5.26 — QA кнопок и плавности + +Исправлено после полного smoke-теста интерфейса: +- устранён NotFoundError в restoreOrderBlank; +- кнопка «Бланк заказа» теперь всегда остаётся в основном блоке действий заказа рядом с документами; +- все поздние сценарии выбора #new .actions теперь находят основной блок по кнопке «Сохранить», а не случайный вложенный .actions; +- исправлен selector-helper Developer Console: обращения вида #sunDev... теперь корректно находят элементы; +- проверены все 8 вкладок кабинета разработчика после MFA; +- проверены вход и регистрация, создание новой компании и 14-дневный Full trial в mock-сценарии; +- проверены 15 основных разделов навигации, 6 вкладок Настроек, документы заказа, предложение и PDF; +- мобильный smoke-тест 390x844: критического горизонтального переполнения страниц нет, навигация прокручивается штатно; +- обновлён PWA cache/version до v59 / 20260906-v17-5-26-qa-buttons-smooth. diff --git a/docs/releases/V17.5.27-CHANGES.txt b/docs/releases/V17.5.27-CHANGES.txt new file mode 100644 index 0000000..3143a12 --- /dev/null +++ b/docs/releases/V17.5.27-CHANGES.txt @@ -0,0 +1,12 @@ +v17.5.27 · Registration & Users + +- Полностью упрощена регистрация Caterium. +- Добавлено подтверждение пароля и кнопки показа/скрытия пароля. +- Убрана рекламная строка «14 дней бесплатно · Полный тариф» с формы регистрации. +- Подтверждение email не блокирует регистрацию; после signup приложение автоматически получает сессию и создаёт компанию. +- Повторная регистрация существующего email с верным паролем продолжает существующий аккаунт. +- «Пользователи и права»: добавлена кнопка «+ Добавить пользователя». +- Приглашение теперь привязано к email, имени и роли. +- Добавлены персональные ссылки приглашений, список ожидающих приглашений и отмена. +- Сотрудник по ссылке входит в существующий аккаунт или создаёт пароль и автоматически присоединяется к компании. +- Исправлен переход между tenant-рабочими копиями при принятии приглашения без промежуточной перезагрузки. diff --git a/docs/releases/V17.5.28-CHANGES.txt b/docs/releases/V17.5.28-CHANGES.txt new file mode 100644 index 0000000..0a40bff --- /dev/null +++ b/docs/releases/V17.5.28-CHANGES.txt @@ -0,0 +1,9 @@ +CATERIUM v17.5.28 + +- Прямое создание сотрудников без ссылок-приглашений. +- Новый сотрудник получает временный пароль. +- При первом входе сотрудник обязан задать новый пароль. +- Существующий аккаунт Caterium просто добавляется в компанию. +- Исправлена проверка подписки при добавлении нового участника: проверка больше не требует предварительного membership. +- Старый блок «Ожидают приглашения» убран из интерфейса. +- Защищённая Edge Function caterium-create-employee хранит service role только на сервере. diff --git a/docs/releases/V17.5.29-CHANGES.txt b/docs/releases/V17.5.29-CHANGES.txt new file mode 100644 index 0000000..a1b70fb --- /dev/null +++ b/docs/releases/V17.5.29-CHANGES.txt @@ -0,0 +1,25 @@ +CATERIUM · v17.5.29 · ЧАТ КОМАНДЫ + +Добавлено: +- отдельный раздел «Чаты» в основной навигации; +- общий чат компании; +- личные диалоги между сотрудниками одной компании; +- вкладка «Обсуждение» внутри сохранённого заказа и кнопка «Чат» в карточке заказа; +- счётчики непрочитанных сообщений; +- статусы «Доставлено / Прочитано»; +- индикатор «печатает…»; +- статус сотрудников онлайн через Supabase Realtime Presence; +- отправка до 5 фото/файлов за сообщение, до 15 МБ каждый; +- приватное хранилище вложений sun-chat и временные signed URL. + +Безопасность: +- все сообщения жёстко изолированы по workspace_id; +- общий и заказной чат доступны только активным сотрудникам компании; +- личный диалог доступен только двум его участникам; +- Realtime-каналы приватные и защищены RLS; +- общий Realtime-канал компании не раскрывает ID личного чата/сообщения; +- таблицы сообщений доступны клиенту только на чтение, запись идёт через server-checked RPC; +- вложения лежат в приватном bucket и также проверяют membership/thread access. + +Сервер: +- миграция SUPABASE-CHAT-V29.sql уже применена к текущему Supabase. diff --git a/docs/releases/V17.5.30-CHANGES.txt b/docs/releases/V17.5.30-CHANGES.txt new file mode 100644 index 0000000..31d41d1 --- /dev/null +++ b/docs/releases/V17.5.30-CHANGES.txt @@ -0,0 +1,9 @@ +CATERIUM · v17.5.30 · ПРАВА АДМИНИСТРАТОРОВ + +1. Администратору теперь можно отключать отдельные права и функции. +2. Сервер sun_has_permission() больше не даёт роли admin безусловный полный доступ: явный false учитывается. +3. Интерфейс больше не блокирует чекбоксы прав для администратора. +4. Проверка последнего администратора считает именно других активных администраторов. +5. Добавлена защита: хотя бы у одного активного администратора остаётся users.manage. +6. Одновременные изменения администраторов сериализуются advisory lock по workspace. +7. Миграция: SUPABASE-ADMIN-RIGHTS-V30.sql. diff --git a/docs/releases/V17.5.31-CHANGES.txt b/docs/releases/V17.5.31-CHANGES.txt new file mode 100644 index 0000000..96ae441 --- /dev/null +++ b/docs/releases/V17.5.31-CHANGES.txt @@ -0,0 +1,14 @@ +CATERIUM · v17.5.31 · СТАБИЛЬНАЯ НАВИГАЦИЯ ЧАТОВ + +1. Исправлен каскад Failed to execute insertBefore: чат больше не вставляет кнопку относительно DOM-узла из другой группы. +2. Добавлен единый безопасный регистратор динамических пунктов бокового меню sunRegisterNavButton(). +3. В левом меню отдельный пункт «Чат» со счётчиком непрочитанных. +4. Внутри раздела «Чат» три понятных режима: «Общий», «Личные», «Заказы». +5. В «Заказы» показываются все сохранённые заказы, даже если переписка ещё не создавалась; чат создаётся при первом открытии. +6. В карточке редактирования заказа появилась третья вкладка «Чат» рядом с «Заказ» и «Детали». +7. Чат заказа открывается прямо внутри заказа, не переключая пользователя на глобальную страницу чатов. +8. Тот же заказной диалог остаётся доступен через «Чат → Заказы». +9. Убрана бесконечная 1,5-секундная переинициализация чата; остались ограниченный стартовый retry и события облачного состояния. +10. Повторные события облака не создают дубликаты кнопки, страницы чата или вкладки заказа. +11. Серверная схема v29, RLS, личные диалоги, вложения, read/typing/presence сохранены без изменения. +12. Архитектурно следующими контекстами для чатов считаются Производство, Маршруты/доставка, Закупки и внутренние заметки по клиенту; в этой версии они не включены. diff --git a/docs/releases/V17.6.0-CHANGES.txt b/docs/releases/V17.6.0-CHANGES.txt new file mode 100644 index 0000000..ce39221 --- /dev/null +++ b/docs/releases/V17.6.0-CHANGES.txt @@ -0,0 +1,31 @@ +Caterium v17.6.0 - Stability & Security +Date: 2026-09-07 +Rollback baseline: v17.5.31 + +Security +- Closed the old bootstrap stored DOM-XSS path with shared SunSafe escaping/attribute helpers. +- Consolidated repeated esc() implementations onto one shared utility. +- Added safe DOM insertion helper for dynamic navigation/settings nodes. +- Restored the deployed caterium-create-employee Edge Function source to the project; secrets remain environment-only. + +Stability +- Moved the oldest bootstrap business script out of index.html into legacy/bootstrap.js. +- Centralized PDF byte packaging in core/pdf-engine.js with one A4 geometry validator. +- Added static security and release checks plus Playwright E2E specifications and CI workflow. +- Preserved v17.5.31 as a separate rollback archive. + +Performance +- Removed blocking offer-gallery-data.js Base64 payload. +- Removed unused offer-gallery/003.jpg through 010.jpg; retained the two images actually used. +- Added image lazy-loading/async decoding helper for noncritical images. +- Added targeted mobile overflow containment for category/view/settings strips. + +PDF QA +- Offer templates light, editorial-grid, midnight-glass, emerald-gold generated as real PDF blobs and rendered back to images. +- Catalog PDF generated as A4 pages and rendered back to images. +- All generated pages opened successfully in PyMuPDF and use consistent A4 geometry. +- Browser print/PDF CSS rules remain explicitly A4 portrait/landscape according to document type. + +Known technical debt +- PDF pages remain image-based, so text is not selectable/searchable. The two former binary writers are now one audited engine, but a later migration to a vendored library/vector-text PDF pipeline is still desirable. +- index.html/app-runtime.js still contain historical modules and should be split further in a future architecture release. diff --git a/ops/prepare-catalog-photos.js b/ops/prepare-catalog-photos.js new file mode 100644 index 0000000..514cc2f --- /dev/null +++ b/ops/prepare-catalog-photos.js @@ -0,0 +1,231 @@ +#!/usr/bin/env node +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const https = require('https'); + +const ROOT = __dirname; +const SOURCE_FILE = path.join(ROOT, 'catalog', 'photo-sources.json'); +const EXPECTED_TOTAL = 113; +const MAX_BYTES = 20 * 1024 * 1024; +const CONCURRENCY = 3; +const RETRIES = 3; +const CHECK_ONLY = process.argv.includes('--check-only'); +const FORCE = process.argv.includes('--force'); + +function normalizeRel(value) { + return String(value || '').replace(/\\/g, '/').replace(/^\/+/, ''); +} + +function readSources() { + let data; + try { + data = JSON.parse(fs.readFileSync(SOURCE_FILE, 'utf8')); + } catch (error) { + throw new Error(`Cannot read catalog/photo-sources.json: ${error.message}`); + } + if (!Array.isArray(data)) throw new Error('catalog/photo-sources.json must contain an array'); + const out = data.map((item, index) => ({ + index: index + 1, + path: normalizeRel(item && item.path), + url: String(item && item.url || '').trim() + })).filter(item => item.path && item.url); + if (out.length !== EXPECTED_TOTAL) { + throw new Error(`Expected ${EXPECTED_TOTAL} catalog photo sources, found ${out.length}`); + } + const uniquePaths = new Set(out.map(item => item.path)); + if (uniquePaths.size !== EXPECTED_TOTAL) throw new Error('Catalog photo source paths are not unique'); + return out; +} + +function isAllowedUrl(target) { + let url; + try { url = new URL(target); } catch (_) { return false; } + if (url.protocol !== 'https:') return false; + return new Set(['static.tildacdn.com', 'static3.tildacdn.com', 'static.tildacdn.net']).has(url.hostname); +} + +function imageKind(buf) { + if (!Buffer.isBuffer(buf) || buf.length < 12) return ''; + if (buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return 'jpeg'; + if (buf.subarray(0, 8).equals(Buffer.from([0x89,0x50,0x4e,0x47,0x0d,0x0a,0x1a,0x0a]))) return 'png'; + if (buf.toString('ascii', 0, 4) === 'RIFF' && buf.toString('ascii', 8, 12) === 'WEBP') return 'webp'; + return ''; +} + +function validLocalPhoto(rel) { + const file = path.resolve(ROOT, rel); + if (!file.startsWith(ROOT + path.sep)) return false; + try { + const st = fs.statSync(file); + if (!st.isFile() || st.size < 1000) return false; + const fd = fs.openSync(file, 'r'); + const head = Buffer.alloc(16); + fs.readSync(fd, head, 0, head.length, 0); + fs.closeSync(fd); + return imageKind(head) === 'jpeg'; + } catch (_) { + return false; + } +} + +function delay(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +function downloadBuffer(target, redirects = 0, timeoutMs = 25000) { + return new Promise((resolve, reject) => { + if (redirects > 6) return reject(new Error('too many redirects')); + let url; + try { url = new URL(target); } catch (error) { return reject(error); } + if (!isAllowedUrl(url.toString())) return reject(new Error(`photo host is not allowed: ${url.hostname}`)); + + const req = https.get(url, { + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/142 Safari/537.36 SunCateringCatalogPhotoPrep/17.5.17', + 'Accept': 'image/jpeg,image/*;q=0.8,*/*;q=0.1', + 'Accept-Language': 'ru-RU,ru;q=0.9,en;q=0.5', + 'Accept-Encoding': 'identity', + 'Referer': 'https://solnce-keytering.ru/' + }, + timeout: timeoutMs + }, res => { + if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { + res.resume(); + const next = new URL(res.headers.location, url).toString(); + if (!isAllowedUrl(next)) return reject(new Error('redirected to an unsupported photo host')); + return downloadBuffer(next, redirects + 1, timeoutMs).then(resolve, reject); + } + if (res.statusCode < 200 || res.statusCode >= 300) { + res.resume(); + return reject(new Error(`HTTP ${res.statusCode}`)); + } + const type = String(res.headers['content-type'] || '').toLowerCase(); + if (type && !type.startsWith('image/')) { + res.resume(); + return reject(new Error(`unexpected content type: ${type}`)); + } + const chunks = []; + let size = 0; + res.on('data', chunk => { + size += chunk.length; + if (size > MAX_BYTES) { + req.destroy(new Error('photo is too large')); + return; + } + chunks.push(chunk); + }); + res.on('end', () => { + const buf = Buffer.concat(chunks); + const kind = imageKind(buf); + if (kind !== 'jpeg' || buf.length < 1000) return reject(new Error('downloaded file is not a valid JPEG')); + resolve(buf); + }); + res.on('error', reject); + }); + req.on('timeout', () => req.destroy(new Error('timeout'))); + req.on('error', reject); + }); +} + +function atomicWrite(rel, buffer) { + const file = path.resolve(ROOT, rel); + if (!file.startsWith(ROOT + path.sep)) throw new Error('invalid local photo path'); + fs.mkdirSync(path.dirname(file), { recursive: true }); + const tmp = `${file}.download-${process.pid}-${Date.now()}`; + fs.writeFileSync(tmp, buffer); + fs.renameSync(tmp, file); +} + +async function fetchWithRetry(url) { + let last; + for (let attempt = 1; attempt <= RETRIES; attempt++) { + try { return await downloadBuffer(url); } + catch (error) { + last = error; + if (attempt < RETRIES) await delay(700 * attempt); + } + } + throw last || new Error('download failed'); +} + +function summarize(sources) { + const ready = sources.filter(item => validLocalPhoto(item.path)); + const missing = sources.filter(item => !validLocalPhoto(item.path)); + return { ready, missing }; +} + +async function main() { + const sources = readSources(); + const initial = summarize(sources); + console.log(`Catalog photos: ${initial.ready.length}/${sources.length} local.`); + + if (CHECK_ONLY) { + if (initial.missing.length) { + console.log(`Missing: ${initial.missing.length}.`); + process.exitCode = 2; + } else { + console.log('All catalog photos are local and valid.'); + } + return; + } + + const targets = FORCE ? sources : initial.missing; + if (!targets.length) { + console.log('All 113 catalog photos are already local. No network access is needed.'); + return; + } + + const byUrl = new Map(); + for (const item of targets) { + if (!isAllowedUrl(item.url)) throw new Error(`Unsupported photo URL for ${item.path}`); + if (!byUrl.has(item.url)) byUrl.set(item.url, []); + byUrl.get(item.url).push(item); + } + const jobs = [...byUrl.entries()].map(([url, items]) => ({ url, items })); + console.log(`Preparing ${targets.length} missing file(s) from ${jobs.length} unique image(s).`); + console.log('This happens only once. After completion the app uses local catalog photos.'); + + let cursor = 0; + let finished = 0; + const failures = []; + async function worker() { + while (true) { + const pos = cursor++; + if (pos >= jobs.length) return; + const job = jobs[pos]; + try { + const buf = await fetchWithRetry(job.url); + for (const item of job.items) atomicWrite(item.path, buf); + finished += job.items.length; + const current = initial.ready.length + finished; + console.log(`[${current}/${sources.length}] ${job.items.map(x => path.basename(x.path)).join(', ')}`); + } catch (error) { + failures.push({ job, error }); + console.error(`FAILED: ${job.items.map(x => x.path).join(', ')} - ${error.message}`); + } + } + } + + await Promise.all(Array.from({ length: Math.min(CONCURRENCY, jobs.length) }, () => worker())); + + const final = summarize(sources); + if (final.missing.length) { + console.error(''); + console.error(`Catalog photo preparation is incomplete: ${final.ready.length}/${sources.length} ready.`); + console.error('Check the internet connection and run this file again. Existing downloaded photos are kept.'); + console.error('Missing files:'); + console.error(final.missing.map(x => path.basename(x.path)).join(', ')); + process.exitCode = 2; + return; + } + + console.log(''); + console.log('Catalog photos ready: 113/113. The app can now use the catalog photos locally.'); +} + +main().catch(error => { + console.error('Catalog photo preparation error:', error && error.message ? error.message : String(error)); + process.exitCode = 2; +}); diff --git a/ops/server.js b/ops/server.js new file mode 100644 index 0000000..57b666a --- /dev/null +++ b/ops/server.js @@ -0,0 +1,669 @@ +#!/usr/bin/env node +'use strict'; + +const http = require('http'); +const https = require('https'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const crypto = require('crypto'); +const childProcess = require('child_process'); + +const ROOT = __dirname; +const PORT = Number(process.env.PORT || 8787); +const HOST = process.env.HOST || '0.0.0.0'; +const SECRET_FILE = path.join(ROOT, '.sun-sync-secret'); +const DATA_FILE = path.join(ROOT, 'sun-sync-data.json'); +const PHONE_LINK_FILE = path.join(ROOT, 'PHONE-LINK.txt'); +const RELEASE_MANIFEST_FILE = path.join(ROOT, 'release-manifest.json'); + +function readReleaseVersion() { + try { + const data = JSON.parse(fs.readFileSync(RELEASE_MANIFEST_FILE, 'utf8')); + if (data && data.version) return String(data.version); + } catch (_) {} + return 'unknown'; +} + +const APP_VERSION = readReleaseVersion(); + +function getSecret() { + if (process.env.SUN_SYNC_TOKEN) return String(process.env.SUN_SYNC_TOKEN).trim(); + try { + const existing = fs.readFileSync(SECRET_FILE, 'utf8').trim(); + if (existing) return existing; + } catch (_) {} + const token = crypto.randomBytes(24).toString('hex'); + fs.writeFileSync(SECRET_FILE, token, { mode: 0o600 }); + return token; +} + +const TOKEN = getSecret(); + +function emptyDb() { + return { version: 1, workspaces: {} }; +} + +function loadDb() { + try { + const parsed = JSON.parse(fs.readFileSync(DATA_FILE, 'utf8')); + if (parsed && typeof parsed === 'object' && parsed.workspaces && typeof parsed.workspaces === 'object') return parsed; + } catch (_) {} + return emptyDb(); +} + +let db = loadDb(); +let saveTimer = null; +function saveDbNow() { + const tmp = DATA_FILE + '.tmp'; + fs.writeFileSync(tmp, JSON.stringify(db)); + fs.renameSync(tmp, DATA_FILE); +} +function scheduleSave() { + clearTimeout(saveTimer); + saveTimer = setTimeout(() => { + try { saveDbNow(); } + catch (e) { console.error('ERROR: cannot save sync data:', e.message); } + }, 40); +} + +const mime = { + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.webmanifest': 'application/manifest+json; charset=utf-8', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.pdf': 'application/pdf', + '.svg': 'image/svg+xml', + '.txt': 'text/plain; charset=utf-8' +}; + +function send(res, code, body, type = 'application/json; charset=utf-8') { + res.writeHead(code, { 'Content-Type': type, 'Cache-Control': 'no-store' }); + res.end(body); +} + +function authorized(req) { + const h = String(req.headers['x-sun-token'] || ''); + if (!h) return false; + const a = Buffer.from(h), b = Buffer.from(TOKEN); + return a.length === b.length && crypto.timingSafeEqual(a, b); +} + +function readBody(req, max = 80 * 1024 * 1024) { + return new Promise((resolve, reject) => { + let size = 0; + const chunks = []; + req.on('data', chunk => { + size += chunk.length; + if (size > max) { + reject(new Error('too large')); + req.destroy(); + return; + } + chunks.push(chunk); + }); + req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); + req.on('error', reject); + }); +} + +const SITE_CATALOG_PAGES = [ + 'https://solnce-keytering.ru/catalog', + 'https://solnce-keytering.ru/catalog_black' +]; +let siteCatalogCache = { at: 0, data: null }; +const CATALOG_PHOTO_SOURCE_FILE = path.join(ROOT, 'catalog', 'photo-sources.json'); +function catalogPhotoStatus() { + try { + const list = JSON.parse(fs.readFileSync(CATALOG_PHOTO_SOURCE_FILE, 'utf8')); + const items = Array.isArray(list) ? list : []; + let ready = 0; + for (const item of items) { + const rel = String(item && item.path || '').replace(/\\/g, '/').replace(/^\/+/, ''); + if (!rel) continue; + const file = path.resolve(ROOT, rel); + if (!file.startsWith(ROOT + path.sep)) continue; + try { const st = fs.statSync(file); if (st.isFile() && st.size > 1000) ready++; } catch (_) {} + } + return { ready, total: items.length, complete: items.length > 0 && ready === items.length }; + } catch (_) { + return { ready: 0, total: 0, complete: false }; + } +} + +let catalogPhotoSourcesCache = null; +function catalogPhotoSources() { + if (catalogPhotoSourcesCache) return catalogPhotoSourcesCache; + try { + const list = JSON.parse(fs.readFileSync(CATALOG_PHOTO_SOURCE_FILE, 'utf8')); + catalogPhotoSourcesCache = Array.isArray(list) ? list : []; + } catch (_) { catalogPhotoSourcesCache = []; } + return catalogPhotoSourcesCache; +} +function imageType(buf) { + if (!Buffer.isBuffer(buf) || buf.length < 12) return ''; + if (buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return 'image/jpeg'; + if (buf.subarray(0,8).equals(Buffer.from([0x89,0x50,0x4e,0x47,0x0d,0x0a,0x1a,0x0a]))) return 'image/png'; + if (buf.toString('ascii',0,4)==='RIFF' && buf.toString('ascii',8,12)==='WEBP') return 'image/webp'; + return ''; +} +function fetchCatalogPhotoBuffer(target, redirects = 0, timeoutMs = 5000) { + return new Promise((resolve, reject) => { + if (redirects > 4) return reject(new Error('too many redirects')); + let url; try { url = new URL(target); } catch (e) { return reject(e); } + const allowed = new Set(['static.tildacdn.com','static3.tildacdn.com','static.tildacdn.net']); + if (url.protocol !== 'https:' || !allowed.has(url.hostname)) return reject(new Error('photo host is not allowed')); + const req = https.get(url,{headers:{ + 'User-Agent':'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/142 Safari/537.36 SunCateringPhotoProxy/17.5.17', + 'Accept':'image/avif,image/webp,image/apng,image/jpeg,image/*,*/*;q=0.8', + 'Accept-Language':'ru-RU,ru;q=0.9,en;q=0.5', + 'Accept-Encoding':'identity', + 'Referer':'https://solnce-keytering.ru/' + },timeout:timeoutMs},res=>{ + if(res.statusCode>=300&&res.statusCode<400&&res.headers.location){res.resume();const next=new URL(res.headers.location,url).toString();return fetchCatalogPhotoBuffer(next,redirects+1,timeoutMs).then(resolve,reject);} + if(res.statusCode<200||res.statusCode>=300){res.resume();return reject(new Error('remote HTTP '+res.statusCode));} + const chunks=[];let size=0;const max=20*1024*1024; + res.on('data',chunk=>{size+=chunk.length;if(size>max){req.destroy(new Error('photo too large'));return;}chunks.push(chunk)}); + res.on('end',()=>{const buf=Buffer.concat(chunks),type=imageType(buf);if(!type||buf.length<1000)return reject(new Error('invalid image'));resolve({buf,type})}); + res.on('error',reject); + }); + req.on('timeout',()=>req.destroy(new Error('timeout')));req.on('error',reject); + }); +} +async function catalogPhotoApi(req,res,url){ + const index=Number(url.searchParams.get('i')||0); + const sources=catalogPhotoSources(); + if(!Number.isInteger(index)||index<1||index>sources.length)return send(res,400,JSON.stringify({ok:false,error:'bad photo index'})); + const item=sources[index-1]||{},rel=String(item.path||'').replace(/\\/g,'/').replace(/^\/+/,''),remote=String(item.url||''); + const file=path.resolve(ROOT,rel); + if(rel&&file.startsWith(ROOT+path.sep)){ + try{const buf=fs.readFileSync(file),type=imageType(buf);if(type&&buf.length>1000){res.writeHead(200,{'Content-Type':type,'Content-Length':buf.length,'Cache-Control':'public, max-age=86400'});return res.end(buf)}}catch(_){} + } + try{ + const out=await fetchCatalogPhotoBuffer(remote); + if(out.type==='image/jpeg'&&rel&&file.startsWith(ROOT+path.sep)){try{fs.mkdirSync(path.dirname(file),{recursive:true});const tmp=file+'.tmp-'+process.pid+'-'+Date.now();fs.writeFileSync(tmp,out.buf);fs.renameSync(tmp,file)}catch(_){} } + res.writeHead(200,{'Content-Type':out.type,'Content-Length':out.buf.length,'Cache-Control':'public, max-age=86400'});res.end(out.buf); + }catch(error){send(res,502,JSON.stringify({ok:false,error:String(error&&error.message||error)}));} +} + +function fetchRemoteText(target, redirects = 0, timeoutMs = 15000) { + return new Promise((resolve, reject) => { + if (redirects > 5) return reject(new Error('too many redirects')); + let url; + try { url = new URL(target); } catch (e) { return reject(e); } + if (url.protocol !== 'https:') return reject(new Error('https only')); + const allowed = new Set(['solnce-keytering.ru','www.solnce-keytering.ru','store.tildacdn.com','store2.tildacdn.com']); + if (!allowed.has(url.hostname)) return reject(new Error('remote host is not allowed')); + const req = https.get(url, { + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/142 Safari/537.36 SunCateringCatalogSync/17.5.17', + 'Accept': 'text/html,application/json,text/plain;q=0.9,*/*;q=0.5', + 'Accept-Language': 'ru-RU,ru;q=0.9,en;q=0.5', + 'Accept-Encoding': 'identity' + }, + timeout: timeoutMs + }, res => { + if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { + res.resume(); + const next = new URL(res.headers.location, url).toString(); + return fetchRemoteText(next, redirects + 1, timeoutMs).then(resolve, reject); + } + if (res.statusCode < 200 || res.statusCode >= 300) { + res.resume(); + return reject(new Error(`remote HTTP ${res.statusCode}`)); + } + const chunks = []; let size = 0; const max = 12 * 1024 * 1024; + res.on('data', chunk => { + size += chunk.length; + if (size > max) { req.destroy(new Error('remote response too large')); return; } + chunks.push(chunk); + }); + res.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); + res.on('error', reject); + }); + req.on('timeout', () => req.destroy(new Error('remote timeout'))); + req.on('error', reject); + }); +} + +function balancedObject(text, start) { + let depth = 0, quote = null, esc = false; + for (let i = start; i < text.length; i++) { + const c = text[i]; + if (quote) { + if (esc) esc = false; + else if (c === '\\') esc = true; + else if (c === quote) quote = null; + continue; + } + if (c === '"' || c === "'") quote = c; + else if (c === '{') depth++; + else if (c === '}' && --depth === 0) return text.slice(start, i + 1); + } + return ''; +} + +function discoverStoreConfigs(html) { + const out = [], seen = new Set(); + const re = /t_store_init\(\s*['"]?(\d+)['"]?\s*,/g; + let m; + while ((m = re.exec(html))) { + const recid = m[1]; + const brace = html.indexOf('{', re.lastIndex); + if (brace < 0 || brace - re.lastIndex > 5000) continue; + const raw = balancedObject(html, brace); + if (!raw) continue; + const sm = raw.match(/["']?storepart["']?\s*:\s*(?:["']([^"']+)["']|([\w-]+))/i); + const storepart = (sm && (sm[1] || sm[2]) || '').trim(); + if (!storepart) continue; + const key = recid + '|' + storepart; + if (!seen.has(key)) { seen.add(key); out.push({ recid, storepart }); } + } + return out; +} + +function extractProductUids(html) { + const set = new Set(); let m; + const patterns = [ + /data-product-uid=["'](\d+)["']/gi, + /\/tproduct\/(\d+)-/gi, + /["']productuid["']\s*:\s*["']?(\d+)/gi + ]; + for (const re of patterns) while ((m = re.exec(html))) set.add(m[1]); + return [...set]; +} + +function parseStoreJson(raw) { + const text = String(raw || '').trim(); + if (!text) return null; + try { return JSON.parse(text); } catch (_) {} + const a = text.indexOf('{'), b = text.lastIndexOf('}'); + if (a >= 0 && b > a) { try { return JSON.parse(text.slice(a, b + 1)); } catch (_) {} } + return null; +} + +async function fetchProductsForConfig(config) { + const products = []; + let slice = ''; + for (let page = 0; page < 8; page++) { + const query = new URLSearchParams({ storepartuid: config.storepart, recid: config.recid, c: String(Date.now()), getparts: 'true', getoptions: 'true', size: '100' }); + if (slice) query.set('slice', String(slice)); + let parsed = null, lastError = null; + for (const host of ['store.tildacdn.com','store2.tildacdn.com']) { + try { parsed = parseStoreJson(await fetchRemoteText(`https://${host}/api/getproductslist/?${query}`)); if (parsed) break; } + catch (e) { lastError = e; } + } + if (!parsed) throw lastError || new Error('cannot parse Tilda product list'); + const list = Array.isArray(parsed.products) ? parsed.products : []; + products.push(...list); + if (!parsed.nextslice || !list.length) break; + slice = parsed.nextslice; + } + return products; +} + +async function fetchProductsByUids(uids) { + const out = []; + for (const uid of uids.slice(0, 160)) { + const query = new URLSearchParams({ productsuid: uid, c: String(Date.now()) }); + let parsed = null; + for (const host of ['store.tildacdn.com','store2.tildacdn.com']) { + try { parsed = parseStoreJson(await fetchRemoteText(`https://${host}/api/getproductsbyuid/?${query}`)); if (parsed) break; } + catch (_) {} + } + if (!parsed) continue; + if (Array.isArray(parsed.products)) out.push(...parsed.products); + else if (Array.isArray(parsed)) out.push(...parsed); + else if (parsed.product) out.push(parsed.product); + } + return out; +} + +function firstProductFromPayload(parsed) { + if (!parsed) return null; + if (parsed.product && typeof parsed.product === 'object') return parsed.product; + if (Array.isArray(parsed.products) && parsed.products.length) return parsed.products[0]; + if (Array.isArray(parsed) && parsed.length) return parsed[0]; + if (typeof parsed === 'object' && (parsed.uid || parsed.id || parsed.title || parsed.name)) return parsed; + return null; +} + +async function fetchOneProduct(config, uid) { + if (!config || !uid) return null; + const query = new URLSearchParams({ + storepartuid: config.storepart, + recid: config.recid, + productuid: String(uid), + c: String(Date.now()) + }); + let lastError = null; + for (const host of ['store.tildacdn.com','store2.tildacdn.com']) { + try { + const parsed = parseStoreJson(await fetchRemoteText(`https://${host}/api/getproduct/?${query}`, 0, 8000)); + const product = firstProductFromPayload(parsed); + if (product) return product; + } catch (e) { lastError = e; } + } + if (lastError) throw lastError; + return null; +} + +async function enrichProductsWithDetails(config, products, forceAll = false) { + const list = (Array.isArray(products) ? products : []).slice(0, 160); + const out = list.slice(); + const indexes = []; + for (let i = 0; i < list.length; i++) { + const summary = list[i]; + const uid = String(summary && (summary.uid || summary.id) || ''); + if (uid && (forceAll || !extractComposition(summary || {}).length)) indexes.push(i); + } + let cursor = 0; + const worker = async () => { + while (cursor < indexes.length) { + const index = indexes[cursor++]; + const summary = list[index]; + const uid = String(summary && (summary.uid || summary.id) || ''); + try { + const detail = await fetchOneProduct(config, uid); + if (detail) out[index] = {...summary, ...detail}; + } catch (_) {} + } + }; + const count = Math.min(2, indexes.length); + await Promise.all(Array.from({length: count}, () => worker())); + return out; +} + +function decodeEntities(text) { + return String(text || '') + .replace(/ | /gi, ' ') + .replace(/&/gi, '&').replace(/"/gi, '"').replace(/'|'/gi, "'") + .replace(/</gi, '<').replace(/>/gi, '>') + .replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n) || 32)) + .replace(/&#x([0-9a-f]+);/gi, (_, n) => String.fromCodePoint(parseInt(n, 16) || 32)); +} + +function htmlLines(value) { + let s = String(value || ''); + s = s.replace(//gi, '\n').replace(/<\/(?:p|div|li|ul|ol|h\d)>/gi, '\n').replace(/]*>/gi, '').replace(/<[^>]+>/g, ' '); + s = decodeEntities(s).replace(/\r/g, ''); + return s.split('\n').map(x => x.replace(/\s+/g, ' ').trim()).filter(Boolean); +} + +function cleanCompositionLines(lines) { + const out = []; + for (let line of lines) { + line = String(line || '').replace(/^[-–—•·*]+\s*/, '').trim(); + if (!line) continue; + if (/^(?:состав(?:\s+(?:бокса|набора|сета))?|купить|подробнее)\s*:?[\s]*$/i.test(line)) continue; + if (/^(?:общий\s+)?вес(?:\s|:|$)|^(?:на\s+)?кол-?во\s+(?:персон|гостей)(?:\s|:|$)|^цена(?:\s|:|$)|^артикул(?:\s|:|$)|^доставка(?:\s|:|$)/i.test(line)) break; + if (/\b(?:персон|гост(?:ей|я))\b/i.test(line) && !/(?:шт\.?|пор\.?|г\b|гр\.?)/i.test(line)) continue; + if (/^\d[\d\s]*\s*(?:₽|р\.?|руб\.?)$/i.test(line)) continue; + out.push(line); + } + return [...new Set(out)].slice(0, 50); +} + +function collectProductTextValues(value, depth = 0, out = [], seen = new Set()) { + if (value == null || depth > 5) return out; + if (typeof value === 'string') { if (value.trim()) out.push(value); return out; } + if (typeof value !== 'object') return out; + if (seen.has(value)) return out; + seen.add(value); + if (Array.isArray(value)) { + for (const item of value) collectProductTextValues(item, depth + 1, out, seen); + return out; + } + for (const [key, item] of Object.entries(value)) { + if (/^(?:photo|photos|image|images|gallery|url|sku|uid|id|price|price_old|priceold|quantity|title|name)$/i.test(key)) continue; + collectProductTextValues(item, depth + 1, out, seen); + } + return out; +} + +function extractComposition(product) { + const priority = [product && product.text, product && product.descr, product && product.description, product && product.fulltext]; + const fields = []; + const seen = new Set(); + for (const value of [...priority, ...collectProductTextValues(product || {})]) { + const field = String(value || '').trim(); + if (!field || seen.has(field)) continue; + seen.add(field); fields.push(field); + } + let fallback = []; + for (const field of fields) { + let raw = String(field).replace(/(\u0421\u043e\u0441\u0442\u0430\u0432(?:\s+(?:\u0431\u043e\u043a\u0441\u0430|\u043d\u0430\u0431\u043e\u0440\u0430|\u0441\u0435\u0442\u0430))?\s*:)/ig, '\n$1\n'); + const lines = htmlLines(raw); + const start = lines.findIndex(x => /^\u0441\u043e\u0441\u0442\u0430\u0432(?:\s+(?:\u0431\u043e\u043a\u0441\u0430|\u043d\u0430\u0431\u043e\u0440\u0430|\u0441\u0435\u0442\u0430))?\s*:?$/i.test(x)); + if (start >= 0) { + const found = cleanCompositionLines(lines.slice(start + 1)); + if (found.length) return found; + } + const plausible = cleanCompositionLines(lines).filter(x => /\b(?:\u0448\u0442\.?|\u043f\u043e\u0440\.?|\u043a\u0443\u0441\.?|\u0433\b|\u0433\u0440\.?)\b/i.test(x)); + if (plausible.length > fallback.length) fallback = plausible; + } + return fallback; +} + +function normalizeSiteProduct(product) { + const title = decodeEntities(String(product && (product.title || product.name) || '')).replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(); + const composition = extractComposition(product || {}); + return { uid: String(product && (product.uid || product.id) || ''), title, composition, url: String(product && product.url || '') }; +} + +async function loadSiteCatalogCompositions() { + const all = [], errors = [], configs = []; + let premiumCompositionCount = 0; + for (const pageUrl of SITE_CATALOG_PAGES) { + try { + const html = await fetchRemoteText(pageUrl); + const found = discoverStoreConfigs(html); + configs.push(...found.map(x => ({...x, pageUrl}))); + if (found.length) { + for (const config of found) { + try { + const summaries = await fetchProductsForConfig(config); + const isPremiumPage = /\/catalog_black(?:$|[?#])/i.test(pageUrl); + const detailed = await enrichProductsWithDetails(config, summaries, isPremiumPage); + if (isPremiumPage) premiumCompositionCount += detailed.filter(item => extractComposition(item || {}).length).length; + all.push(...detailed); + } catch (e) { errors.push(`${pageUrl}: ${e.message}`); } + } + } else { + const uids = extractProductUids(html); + if (uids.length) all.push(...await fetchProductsByUids(uids)); + else errors.push(`${pageUrl}: Tilda store config not found`); + } + } catch (e) { errors.push(`${pageUrl}: ${e.message}`); } + } + const byKey = new Map(); + for (const raw of all) { + const p = normalizeSiteProduct(raw); + if (!p.title || !p.composition.length) continue; + const key = p.uid || p.title.toLowerCase(); + if (!byKey.has(key) || byKey.get(key).composition.length < p.composition.length) byKey.set(key, p); + } + return { source: 'solnce-keytering.ru', fetchedAt: new Date().toISOString(), configs: configs.length, premiumCompositionCount, products: [...byKey.values()], errors }; +} + +async function catalogCompositionsApi(req, res) { + const now = Date.now(); + const requestUrl = new URL(req.url, 'http://localhost'); + const force = requestUrl.searchParams.get('force') === '1'; + if (!force && siteCatalogCache.data && now - siteCatalogCache.at < 30 * 60 * 1000) { + return send(res, 200, JSON.stringify({...siteCatalogCache.data, cached: true})); + } + try { + const data = await loadSiteCatalogCompositions(); + if (!data.products.length) return send(res, 502, JSON.stringify({ error: 'site catalog compositions were not received', details: data.errors.slice(0, 6) })); + if (Number(data.premiumCompositionCount || 0) > 0) siteCatalogCache = { at: now, data }; + return send(res, 200, JSON.stringify({...data, cached: false})); + } catch (e) { + return send(res, 502, JSON.stringify({ error: e.message || String(e) })); + } +} + + +function api(req, res, url) { + if (!authorized(req)) return send(res, 401, JSON.stringify({ error: 'unauthorized' })); + + if (req.method === 'GET') { + const workspace = String(url.searchParams.get('workspace') || 'main').slice(0, 120); + const source = db.workspaces[workspace] || {}; + const entries = {}; + for (const [key, item] of Object.entries(source)) { + if (!item) continue; + entries[key] = { value: String(item.value ?? ''), updatedAt: Number(item.updatedAt || 0) }; + } + return send(res, 200, JSON.stringify({ workspace, entries, serverTime: Date.now() })); + } + + if (req.method === 'POST') { + return readBody(req).then(raw => { + let data; + try { data = JSON.parse(raw || '{}'); } + catch (_) { return send(res, 400, JSON.stringify({ error: 'bad json' })); } + + const workspace = String(data.workspace || 'main').slice(0, 120); + const changes = data.changes && typeof data.changes === 'object' ? data.changes : {}; + const target = db.workspaces[workspace] || (db.workspaces[workspace] = {}); + let count = 0; + + for (const [key, item] of Object.entries(changes)) { + if (!key || key.length > 240 || !item) continue; + const updatedAt = Number(item.updatedAt || Date.now()); + const oldAt = Number(target[key]?.updatedAt || 0); + if (updatedAt >= oldAt) { + target[key] = { value: String(item.value ?? ''), updatedAt }; + count++; + } + } + scheduleSave(); + return send(res, 200, JSON.stringify({ ok: true, count, serverTime: Date.now() })); + }).catch(e => send(res, 500, JSON.stringify({ error: e.message }))); + } + + return send(res, 405, JSON.stringify({ error: 'method not allowed' })); +} + +function staticFile(req, res, url) { + let pathname = decodeURIComponent(url.pathname); + if (pathname === '/' || pathname === '') pathname = '/index.html'; + const rel = pathname.replace(/^\/+/, ''); + const base = path.basename(rel); + const protectedNames = new Set(['server.js', 'prepare-catalog-photos.js', 'sun-sync-data.json', 'sun-sync-data.json.tmp', 'PHONE-LINK.txt']); + if (base.startsWith('.') || /\.(?:sqlite|db)$/i.test(base) || protectedNames.has(base)) { + return send(res, 404, 'Not found', 'text/plain; charset=utf-8'); + } + + const file = path.resolve(ROOT, rel); + if (!file.startsWith(ROOT + path.sep) && file !== path.join(ROOT, 'index.html')) { + return send(res, 403, 'Forbidden', 'text/plain; charset=utf-8'); + } + + fs.stat(file, (err, st) => { + if (err || !st.isFile()) return send(res, 404, 'Not found', 'text/plain; charset=utf-8'); + const ext = path.extname(file).toLowerCase(); + res.writeHead(200, { + 'Content-Type': mime[ext] || 'application/octet-stream', + 'Cache-Control': ['.html', '.js', '.css', '.webmanifest'].includes(ext) ? 'no-cache' : 'public, max-age=86400' + }); + fs.createReadStream(file).pipe(res); + }); +} + +function isPrivateIPv4(ip) { + const p = ip.split('.').map(Number); + return p.length === 4 && (p[0] === 10 || (p[0] === 172 && p[1] >= 16 && p[1] <= 31) || (p[0] === 192 && p[1] === 168)); +} + +function getLanAddresses() { + const out = []; + for (const list of Object.values(os.networkInterfaces())) { + for (const n of list || []) { + if (n.family !== 'IPv4' || n.internal) continue; + if (isPrivateIPv4(n.address)) out.push(n.address); + } + } + return [...new Set(out)]; +} + +function linkFor(host) { + return `http://${host}:${PORT}/`; +} + +function tryOpenBrowser(url) { + try { + if (process.platform === 'win32') { + childProcess.spawn('cmd.exe', ['/d', '/s', '/c', 'start', '""', url], { detached: true, stdio: 'ignore', windowsHide: true }).unref(); + } + } catch (_) {} +} + +const server = http.createServer((req, res) => { + const url = new URL(req.url, 'http://localhost'); + if (url.pathname === '/healthz') return send(res, 200, JSON.stringify({ok:true,app:'sun-catering',mode:'local-test',version:APP_VERSION,catalogPhotos:catalogPhotoStatus(),time:new Date().toISOString()})); + if (url.pathname === '/api/catalog-photo' && req.method === 'GET') return catalogPhotoApi(req, res, url); + if (url.pathname === '/api/catalog-compositions' && req.method === 'GET') return catalogCompositionsApi(req, res); + if (url.pathname === '/api/sync') return api(req, res, url); + return staticFile(req, res, url); +}); + +server.on('error', err => { + console.error('\n============================================================'); + console.error('SERVER START ERROR'); + console.error(err && err.message ? err.message : String(err)); + if (err && err.code === 'EADDRINUSE') console.error(`Port ${PORT} is already in use. Close another server window and try again.`); + console.error('============================================================\n'); + process.exitCode = 1; +}); + +server.listen(PORT, HOST, () => { + // Catalog photos prefer local files; missing originals are fetched and cached on demand without blocking startup. + const computer = linkFor('localhost'); + const lan = getLanAddresses().map(linkFor); + const lines = [ + 'SUN CATERING - MOBILE SERVER', + '', + 'OPEN ON THIS COMPUTER:', + computer, + '', + 'OPEN ON IPHONE / PHONE:', + ...(lan.length ? lan : ['No local Wi-Fi address found. Connect the computer to Wi-Fi and restart this file.']), + '', + 'The phone and computer must be connected to the same Wi-Fi network.', + 'Keep this black window open while using the shared database.', + '', + 'The same phone link is saved in this folder as PHONE-LINK.txt.' + ]; + + const text = lines.join('\r\n') + '\r\n'; + try { fs.writeFileSync(PHONE_LINK_FILE, text, 'utf8'); } + catch (e) { console.error('Cannot write PHONE-LINK.txt:', e.message); } + + console.log('\n============================================================'); + console.log('SUN CATERING - MOBILE SERVER IS RUNNING'); + console.log('============================================================\n'); + console.log('OPEN ON THIS COMPUTER:'); + console.log(' ' + computer + '\n'); + console.log('OPEN ON IPHONE / PHONE:'); + if (lan.length) lan.forEach(x => console.log(' ' + x)); + else console.log(' No local Wi-Fi address found. Connect the computer to Wi-Fi and restart.'); + console.log('\nThe phone link is also saved in:'); + console.log(' ' + PHONE_LINK_FILE); + console.log('\nKeep this window open while using the shared database.\n'); + + tryOpenBrowser(computer); +}); + +function shutdown() { + try { if (saveTimer) clearTimeout(saveTimer); saveDbNow(); } catch (_) {} + server.close(() => process.exit(0)); + setTimeout(() => process.exit(0), 500).unref(); +} +process.on('SIGINT', shutdown); +process.on('SIGTERM', shutdown); diff --git a/ops/sql/SUPABASE-ADMIN-RIGHTS-V30.sql b/ops/sql/SUPABASE-ADMIN-RIGHTS-V30.sql new file mode 100644 index 0000000..c7a4aa7 --- /dev/null +++ b/ops/sql/SUPABASE-ADMIN-RIGHTS-V30.sql @@ -0,0 +1,190 @@ +-- Caterium v17.5.30 - granular administrator permissions and correct last-admin checks. + +create or replace function public.sun_has_permission(p_workspace uuid, p_permission text) +returns boolean +language plpgsql +stable +security definer +set search_path='public' +as $$ +declare + v_role text; + v_permissions jsonb; + v_active boolean; +begin + select role,permissions,is_active + into v_role,v_permissions,v_active + from public.sun_workspace_members + where workspace_id=p_workspace and user_id=auth.uid() + limit 1; + + if not coalesce(v_active,false) then return false; end if; + + -- Explicit member permissions override the role template for every role, + -- including administrators. Missing keys fall back to the role defaults. + if coalesce(v_permissions,'{}'::jsonb) ? p_permission then + return coalesce((v_permissions->>p_permission)::boolean,false); + end if; + return coalesce((public.sun_role_default_permissions(v_role)->>p_permission)::boolean,false); +end; +$$; + +create or replace function public.sun_admin_update_member( + p_workspace uuid, + p_user uuid, + p_display_name text, + p_role text, + p_is_active boolean, + p_permissions jsonb +) +returns void +language plpgsql +security definer +set search_path='public' +as $$ +declare + v_old_role text; + v_old_active boolean; + v_role text:=lower(coalesce(p_role,'')); + v_other_admins integer:=0; + v_other_managing_admins integer:=0; + v_target_will_manage boolean:=false; + v_max integer; + v_active_count integer; +begin + -- Serialize membership administration inside one workspace so two admins + -- cannot simultaneously remove/demote the last administrators. + perform pg_advisory_xact_lock(hashtext(p_workspace::text)); + + 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 not public.sun_has_permission(p_workspace,'users.manage') then raise exception 'Недостаточно прав для управления сотрудниками'; end if; + if v_role not in ('admin','manager','kitchen','courier','viewer') then raise exception 'Некорректная роль'; end if; + if p_permissions is null or jsonb_typeof(p_permissions)<>'object' then raise exception 'Некорректные права пользователя'; end if; + + select role,is_active into v_old_role,v_old_active + from public.sun_workspace_members + where workspace_id=p_workspace and user_id=p_user + for update; + if not found then raise exception 'Пользователь не найден'; end if; + + select count(*)::int into v_other_admins + from public.sun_workspace_members + where workspace_id=p_workspace + and user_id<>p_user + and role='admin' + and is_active=true; + + if v_old_role='admin' and coalesce(v_old_active,false) + and (v_role<>'admin' or not coalesce(p_is_active,false)) + and v_other_admins=0 then + raise exception 'Нельзя отключить или понизить последнего активного администратора'; + end if; + + select count(*)::int into v_other_managing_admins + from public.sun_workspace_members + where workspace_id=p_workspace + and user_id<>p_user + and role='admin' + and is_active=true + and coalesce( + case when coalesce(permissions,'{}'::jsonb) ? 'users.manage' + then (permissions->>'users.manage')::boolean + else null end, + true + )=true; + + v_target_will_manage := v_role='admin' + and coalesce(p_is_active,false) + and coalesce( + case when p_permissions ? 'users.manage' + then (p_permissions->>'users.manage')::boolean + else null end, + true + ); + + if not v_target_will_manage and v_other_managing_admins=0 then + raise exception 'У хотя бы одного активного администратора должно оставаться право «Пользователи и права»'; + end if; + + if coalesce(p_is_active,false) and not coalesce(v_old_active,false) then + 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_active_count + from public.sun_workspace_members + where workspace_id=p_workspace and is_active=true; + if v_max is not null and v_active_count>=v_max then + raise exception 'Достигнут лимит сотрудников тарифа (%).',v_max; + end if; + end if; + + update public.sun_workspace_members + set display_name=nullif(trim(coalesce(p_display_name,'')),''), + role=v_role, + is_active=coalesce(p_is_active,false), + permissions=p_permissions, + updated_at=now() + where workspace_id=p_workspace and user_id=p_user; +end; +$$; + +create or replace function public.sun_admin_remove_member(p_workspace uuid,p_user uuid) +returns void +language plpgsql +security definer +set search_path='public' +as $$ +declare + v_role text; + v_active boolean; + v_other_admins integer:=0; + v_other_managing_admins integer:=0; +begin + perform pg_advisory_xact_lock(hashtext(p_workspace::text)); + + 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 not public.sun_has_permission(p_workspace,'users.manage') then raise exception 'Недостаточно прав для управления сотрудниками'; end if; + + select role,is_active into v_role,v_active + from public.sun_workspace_members + where workspace_id=p_workspace and user_id=p_user + for update; + if not found then return; end if; + + if v_role='admin' and coalesce(v_active,false) then + select count(*)::int into v_other_admins + from public.sun_workspace_members + where workspace_id=p_workspace and user_id<>p_user and role='admin' and is_active=true; + if v_other_admins=0 then raise exception 'Нельзя удалить последнего активного администратора'; end if; + + select count(*)::int into v_other_managing_admins + from public.sun_workspace_members + where workspace_id=p_workspace + and user_id<>p_user + and role='admin' + and is_active=true + and coalesce( + case when coalesce(permissions,'{}'::jsonb) ? 'users.manage' + then (permissions->>'users.manage')::boolean + else null end, + true + )=true; + if v_other_managing_admins=0 then + raise exception 'Нельзя удалить администратора: после удаления никто не сможет управлять пользователями'; + end if; + end if; + + delete from public.sun_workspace_members + where workspace_id=p_workspace and user_id=p_user; +end; +$$; + +revoke all on function public.sun_has_permission(uuid,text) from public,anon; +revoke all on function public.sun_admin_update_member(uuid,uuid,text,text,boolean,jsonb) from public,anon; +revoke all on function public.sun_admin_remove_member(uuid,uuid) from public,anon; +grant execute on function public.sun_has_permission(uuid,text) to authenticated; +grant execute on function public.sun_admin_update_member(uuid,uuid,text,text,boolean,jsonb) to authenticated; +grant execute on function public.sun_admin_remove_member(uuid,uuid) to authenticated; diff --git a/ops/sql/SUPABASE-CHAT-V29.sql b/ops/sql/SUPABASE-CHAT-V29.sql new file mode 100644 index 0000000..2e9bec5 --- /dev/null +++ b/ops/sql/SUPABASE-CHAT-V29.sql @@ -0,0 +1,404 @@ +create table if not exists public.sun_chat_threads ( + 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 check (kind in ('company','direct','order')), + title text, + order_id text, + direct_key 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(), + last_message_at timestamptz +); +create unique index if not exists sun_chat_threads_company_uq on public.sun_chat_threads(workspace_id) where kind='company'; +create unique index if not exists sun_chat_threads_order_uq on public.sun_chat_threads(workspace_id,order_id) where kind='order'; +create unique index if not exists sun_chat_threads_direct_uq on public.sun_chat_threads(workspace_id,direct_key) where kind='direct'; +create index if not exists sun_chat_threads_workspace_idx on public.sun_chat_threads(workspace_id,coalesce(last_message_at,created_at) desc); + +create table if not exists public.sun_chat_participants ( + thread_id uuid not null references public.sun_chat_threads(id) on delete cascade, + workspace_id uuid not null references public.sun_workspaces(id) on delete cascade, + user_id uuid not null references auth.users(id) on delete cascade, + created_at timestamptz not null default now(), + primary key(thread_id,user_id) +); +create index if not exists sun_chat_participants_user_idx on public.sun_chat_participants(user_id,workspace_id); + +create table if not exists public.sun_chat_messages ( + id uuid primary key default gen_random_uuid(), + workspace_id uuid not null references public.sun_workspaces(id) on delete cascade, + thread_id uuid not null references public.sun_chat_threads(id) on delete cascade, + sender_user_id uuid not null references auth.users(id) on delete cascade, + body text not null default '', + attachments jsonb not null default '[]'::jsonb, + created_at timestamptz not null default now(), + edited_at timestamptz, + deleted_at timestamptz +); +create index if not exists sun_chat_messages_thread_idx on public.sun_chat_messages(thread_id,created_at desc); +create index if not exists sun_chat_messages_workspace_idx on public.sun_chat_messages(workspace_id,created_at desc); + +create table if not exists public.sun_chat_reads ( + thread_id uuid not null references public.sun_chat_threads(id) on delete cascade, + workspace_id uuid not null references public.sun_workspaces(id) on delete cascade, + user_id uuid not null references auth.users(id) on delete cascade, + last_read_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + primary key(thread_id,user_id) +); +create index if not exists sun_chat_reads_user_idx on public.sun_chat_reads(user_id,workspace_id); + +alter table public.sun_chat_threads enable row level security; +alter table public.sun_chat_participants enable row level security; +alter table public.sun_chat_messages enable row level security; +alter table public.sun_chat_reads enable row level security; + +create or replace function public.sun_chat_is_member_v29(p_workspace uuid,p_user uuid default null) +returns boolean +language sql +stable +security definer +set search_path='public','auth' +as $$ + select exists( + select 1 from public.sun_workspace_members m + where m.workspace_id=p_workspace + and m.user_id=coalesce(p_user,auth.uid()) + and m.is_active=true + ) +$$; + +create or replace function public.sun_chat_can_access_thread_as_v29(p_thread uuid,p_user uuid) +returns boolean +language plpgsql +stable +security definer +set search_path='public','auth' +as $$ +declare v public.sun_chat_threads%rowtype; +begin + if p_user is null then return false; end if; + select * into v from public.sun_chat_threads where id=p_thread; + if not found then return false; end if; + if not public.sun_chat_is_member_v29(v.workspace_id,p_user) then return false; end if; + if v.kind='direct' then + return exists(select 1 from public.sun_chat_participants p where p.thread_id=v.id and p.user_id=p_user); + end if; + return true; +end; +$$; + +create or replace function public.sun_chat_can_access_thread_v29(p_thread uuid) +returns boolean +language sql +stable +security definer +set search_path='public','auth' +as $$ select public.sun_chat_can_access_thread_as_v29(p_thread,auth.uid()) $$; + +create or replace function public.sun_chat_realtime_topic_access_v29(p_topic text) +returns boolean +language plpgsql +stable +security definer +set search_path='public','auth' +as $$ +declare v_id uuid; +begin + if p_topic like 'sun-chat-workspace:%' then + begin v_id:=substring(p_topic from length('sun-chat-workspace:')+1)::uuid; exception when others then return false; end; + return public.sun_chat_is_member_v29(v_id,auth.uid()); + elsif p_topic like 'sun-chat-thread:%' then + begin v_id:=substring(p_topic from length('sun-chat-thread:')+1)::uuid; exception when others then return false; end; + return public.sun_chat_can_access_thread_v29(v_id); + end if; + return false; +end; +$$; + +create or replace function public.sun_chat_storage_access_v29(p_name text) +returns boolean +language plpgsql +stable +security definer +set search_path='public','auth','storage' +as $$ +declare parts text[]; v_ws uuid; v_thread uuid; v_thread_ws uuid; +begin + parts:=storage.foldername(p_name); + if array_length(parts,1)<2 then return false; end if; + begin v_ws:=parts[1]::uuid; v_thread:=parts[2]::uuid; exception when others then return false; end; + select workspace_id into v_thread_ws from public.sun_chat_threads where id=v_thread; + if v_thread_ws is null or v_thread_ws<>v_ws then return false; end if; + return public.sun_chat_can_access_thread_as_v29(v_thread,auth.uid()); +end; +$$; + +-- Read-only table access for authenticated users; writes go through RPCs. +revoke all on public.sun_chat_threads,public.sun_chat_participants,public.sun_chat_messages,public.sun_chat_reads from anon,authenticated; +grant select on public.sun_chat_threads,public.sun_chat_participants,public.sun_chat_messages,public.sun_chat_reads to authenticated; + +-- RLS read policies. +drop policy if exists sun_chat_threads_read_v29 on public.sun_chat_threads; +create policy sun_chat_threads_read_v29 on public.sun_chat_threads for select to authenticated using (public.sun_chat_can_access_thread_v29(id)); +drop policy if exists sun_chat_participants_read_v29 on public.sun_chat_participants; +create policy sun_chat_participants_read_v29 on public.sun_chat_participants for select to authenticated using (public.sun_chat_can_access_thread_v29(thread_id)); +drop policy if exists sun_chat_messages_read_v29 on public.sun_chat_messages; +create policy sun_chat_messages_read_v29 on public.sun_chat_messages for select to authenticated using (public.sun_chat_can_access_thread_v29(thread_id)); +drop policy if exists sun_chat_reads_read_v29 on public.sun_chat_reads; +create policy sun_chat_reads_read_v29 on public.sun_chat_reads for select to authenticated using (user_id=auth.uid() and public.sun_chat_can_access_thread_v29(thread_id)); + +create or replace function public.sun_chat_get_company_thread_v29(p_workspace uuid) +returns uuid +language plpgsql +security definer +set search_path='public','auth' +as $$ +declare v_id uuid; +begin + if not public.sun_chat_is_member_v29(p_workspace,auth.uid()) then raise exception 'Нет доступа к чату компании'; end if; + select id into v_id from public.sun_chat_threads where workspace_id=p_workspace and kind='company' limit 1; + if v_id is null then + insert into public.sun_chat_threads(workspace_id,kind,title,created_by) values(p_workspace,'company','Общий чат',auth.uid()) + on conflict (workspace_id) where kind='company' do update set updated_at=excluded.updated_at returning id into v_id; + end if; + insert into public.sun_chat_reads(thread_id,workspace_id,user_id,last_read_at,updated_at) + values(v_id,p_workspace,auth.uid(),'epoch'::timestamptz,now()) on conflict(thread_id,user_id) do nothing; + return v_id; +end; +$$; + +create or replace function public.sun_chat_get_order_thread_v29(p_workspace uuid,p_order_id text) +returns uuid +language plpgsql +security definer +set search_path='public','auth' +as $$ +declare v_id uuid; v_order text:=trim(coalesce(p_order_id,'')); +begin + if not public.sun_chat_is_member_v29(p_workspace,auth.uid()) then raise exception 'Нет доступа к обсуждениям заказов'; end if; + if v_order='' then raise exception 'Номер заказа не указан'; end if; + select id into v_id from public.sun_chat_threads where workspace_id=p_workspace and kind='order' and order_id=v_order limit 1; + if v_id is null then + insert into public.sun_chat_threads(workspace_id,kind,title,order_id,created_by) + values(p_workspace,'order','Заказ № '||v_order,v_order,auth.uid()) + on conflict (workspace_id,order_id) where kind='order' do update set updated_at=excluded.updated_at returning id into v_id; + end if; + insert into public.sun_chat_reads(thread_id,workspace_id,user_id,last_read_at,updated_at) + values(v_id,p_workspace,auth.uid(),'epoch'::timestamptz,now()) on conflict(thread_id,user_id) do nothing; + return v_id; +end; +$$; + +create or replace function public.sun_chat_get_direct_thread_v29(p_workspace uuid,p_other_user uuid) +returns uuid +language plpgsql +security definer +set search_path='public','auth' +as $$ +declare v_me uuid:=auth.uid(); v_key text; v_id uuid; +begin + if v_me is null or not public.sun_chat_is_member_v29(p_workspace,v_me) then raise exception 'Нет доступа к чату компании'; end if; + if p_other_user is null or p_other_user=v_me then raise exception 'Выберите другого сотрудника'; end if; + if not public.sun_chat_is_member_v29(p_workspace,p_other_user) then raise exception 'Сотрудник больше не состоит в компании'; end if; + v_key:=least(v_me::text,p_other_user::text)||':'||greatest(v_me::text,p_other_user::text); + select id into v_id from public.sun_chat_threads where workspace_id=p_workspace and kind='direct' and direct_key=v_key limit 1; + if v_id is null then + insert into public.sun_chat_threads(workspace_id,kind,direct_key,created_by) + values(p_workspace,'direct',v_key,v_me) + on conflict (workspace_id,direct_key) where kind='direct' do update set updated_at=excluded.updated_at returning id into v_id; + end if; + insert into public.sun_chat_participants(thread_id,workspace_id,user_id) values(v_id,p_workspace,v_me) on conflict do nothing; + insert into public.sun_chat_participants(thread_id,workspace_id,user_id) values(v_id,p_workspace,p_other_user) on conflict do nothing; + insert into public.sun_chat_reads(thread_id,workspace_id,user_id,last_read_at,updated_at) + values(v_id,p_workspace,v_me,'epoch'::timestamptz,now()) on conflict(thread_id,user_id) do nothing; + return v_id; +end; +$$; + +create or replace function public.sun_chat_list_members_v29(p_workspace uuid) +returns table(user_id uuid,display_name text,email text,role text) +language plpgsql +stable +security definer +set search_path='public','auth' +as $$ +begin + if not public.sun_chat_is_member_v29(p_workspace,auth.uid()) then raise exception 'Нет доступа к сотрудникам компании'; end if; + return query select m.user_id,coalesce(nullif(m.display_name,''),split_part(coalesce(u.email,''),'@',1)),u.email::text,m.role + from public.sun_workspace_members m join auth.users u on u.id=m.user_id + where m.workspace_id=p_workspace and m.is_active=true + order by (m.user_id=auth.uid()) desc,coalesce(nullif(m.display_name,''),u.email::text); +end; +$$; + +create or replace function public.sun_chat_list_threads_v29(p_workspace uuid) +returns table(thread_id uuid,kind text,order_id text,title text,other_user_id uuid,last_message text,last_message_at timestamptz,unread_count bigint) +language plpgsql +stable +security definer +set search_path='public','auth' +as $$ +begin + if not public.sun_chat_is_member_v29(p_workspace,auth.uid()) then raise exception 'Нет доступа к чатам компании'; end if; + return query + select t.id,t.kind,t.order_id, + case when t.kind='direct' then coalesce(nullif(om.display_name,''),split_part(coalesce(ou.email,''),'@',1),'Сотрудник') else coalesce(t.title,case when t.kind='company' then 'Общий чат' else 'Обсуждение' end) end, + case when t.kind='direct' then op.user_id else null end, + case when lm.id is null then '' when nullif(trim(lm.body),'') is not null then left(lm.body,110) when jsonb_array_length(coalesce(lm.attachments,'[]'::jsonb))>0 then 'Вложение' else '' end, + lm.created_at, + (select count(*) from public.sun_chat_messages um where um.thread_id=t.id and um.deleted_at is null and um.sender_user_id<>auth.uid() and um.created_at>coalesce(r.last_read_at,'epoch'::timestamptz)) + from public.sun_chat_threads t + left join public.sun_chat_reads r on r.thread_id=t.id and r.user_id=auth.uid() + left join lateral (select m.* from public.sun_chat_messages m where m.thread_id=t.id and m.deleted_at is null order by m.created_at desc limit 1) lm on true + left join lateral (select p.user_id from public.sun_chat_participants p where p.thread_id=t.id and p.user_id<>auth.uid() limit 1) op on t.kind='direct' + left join public.sun_workspace_members om on om.workspace_id=t.workspace_id and om.user_id=op.user_id + left join auth.users ou on ou.id=op.user_id + where t.workspace_id=p_workspace and public.sun_chat_can_access_thread_as_v29(t.id,auth.uid()) + order by case t.kind when 'company' then 0 when 'direct' then 1 else 2 end,coalesce(t.last_message_at,t.created_at) desc; +end; +$$; + +create or replace function public.sun_chat_list_messages_v29(p_thread uuid,p_limit integer default 100,p_before timestamptz default null) +returns table(message_id uuid,sender_user_id uuid,sender_name text,sender_email text,body text,attachments jsonb,created_at timestamptz,read_by_count bigint) +language plpgsql +stable +security definer +set search_path='public','auth' +as $$ +begin + if not public.sun_chat_can_access_thread_as_v29(p_thread,auth.uid()) then raise exception 'Нет доступа к переписке'; end if; + return query + with recent as ( + select m.* from public.sun_chat_messages m + where m.thread_id=p_thread and m.deleted_at is null and (p_before is null or m.created_atm.sender_user_id and rr.last_read_at>=m.created_at) + from recent m + left join public.sun_workspace_members sm on sm.workspace_id=m.workspace_id and sm.user_id=m.sender_user_id + left join auth.users su on su.id=m.sender_user_id + order by m.created_at asc; +end; +$$; + +create or replace function public.sun_chat_send_message_v29(p_thread uuid,p_body text default '',p_attachments jsonb default '[]'::jsonb) +returns uuid +language plpgsql +security definer +set search_path='public','auth','realtime' +as $$ +declare v public.sun_chat_threads%rowtype; v_id uuid; v_body text:=trim(coalesce(p_body,'')); v_att jsonb:=coalesce(p_attachments,'[]'::jsonb); +begin + if not public.sun_chat_can_access_thread_as_v29(p_thread,auth.uid()) then raise exception 'Нет доступа к переписке'; end if; + select * into v from public.sun_chat_threads where id=p_thread; + if length(v_body)>4000 then raise exception 'Сообщение слишком длинное'; end if; + if jsonb_typeof(v_att)<>'array' then raise exception 'Некорректные вложения'; end if; + if jsonb_array_length(v_att)>5 then raise exception 'Можно отправить не более 5 файлов за раз'; end if; + if v_body='' and jsonb_array_length(v_att)=0 then raise exception 'Введите сообщение или добавьте файл'; end if; + insert into public.sun_chat_messages(workspace_id,thread_id,sender_user_id,body,attachments) + values(v.workspace_id,v.id,auth.uid(),v_body,v_att) returning id into v_id; + update public.sun_chat_threads set last_message_at=now(),updated_at=now() where id=v.id; + insert into public.sun_chat_reads(thread_id,workspace_id,user_id,last_read_at,updated_at) + values(v.id,v.workspace_id,auth.uid(),now(),now()) + on conflict(thread_id,user_id) do update set last_read_at=excluded.last_read_at,updated_at=now(); + perform realtime.send('{}'::jsonb,'chat_changed','sun-chat-workspace:'||v.workspace_id::text,true); + perform realtime.send(jsonb_build_object('message_id',v_id),'message','sun-chat-thread:'||v.id::text,true); + return v_id; +end; +$$; + +create or replace function public.sun_chat_mark_read_v29(p_thread uuid) +returns void +language plpgsql +security definer +set search_path='public','auth','realtime' +as $$ +declare v_ws uuid; v_old timestamptz; v_changed boolean:=false; +begin + if not public.sun_chat_can_access_thread_as_v29(p_thread,auth.uid()) then raise exception 'Нет доступа к переписке'; end if; + select workspace_id into v_ws from public.sun_chat_threads where id=p_thread; + select last_read_at into v_old from public.sun_chat_reads where thread_id=p_thread and user_id=auth.uid(); + select exists( + select 1 from public.sun_chat_messages m + where m.thread_id=p_thread and m.deleted_at is null and m.sender_user_id<>auth.uid() + and m.created_at>coalesce(v_old,'epoch'::timestamptz) + ) into v_changed; + insert into public.sun_chat_reads(thread_id,workspace_id,user_id,last_read_at,updated_at) + values(p_thread,v_ws,auth.uid(),now(),now()) + on conflict(thread_id,user_id) do update set last_read_at=excluded.last_read_at,updated_at=now(); + if v_changed then + perform realtime.send(jsonb_build_object('thread_id',p_thread),'read','sun-chat-thread:'||p_thread::text,true); + perform realtime.send('{}'::jsonb,'chat_changed','sun-chat-workspace:'||v_ws::text,true); + end if; +end; +$$; + +create or replace function public.sun_chat_unread_total_v29(p_workspace uuid) +returns bigint +language plpgsql +stable +security definer +set search_path='public','auth' +as $$ +declare v_total bigint; +begin + if not public.sun_chat_is_member_v29(p_workspace,auth.uid()) then return 0; end if; + select count(*) into v_total + from public.sun_chat_messages m + join public.sun_chat_threads t on t.id=m.thread_id + left join public.sun_chat_reads r on r.thread_id=t.id and r.user_id=auth.uid() + where t.workspace_id=p_workspace and m.deleted_at is null and m.sender_user_id<>auth.uid() + and public.sun_chat_can_access_thread_as_v29(t.id,auth.uid()) + and m.created_at>coalesce(r.last_read_at,'epoch'::timestamptz); + return coalesce(v_total,0); +end; +$$; + +-- Private Storage bucket for chat files. +insert into storage.buckets(id,name,public,file_size_limit) +values('sun-chat','sun-chat',false,15728640) +on conflict(id) do update set public=false,file_size_limit=15728640; + +drop policy if exists sun_chat_storage_read_v29 on storage.objects; +create policy sun_chat_storage_read_v29 on storage.objects for select to authenticated +using(bucket_id='sun-chat' and public.sun_chat_storage_access_v29(name)); +drop policy if exists sun_chat_storage_insert_v29 on storage.objects; +create policy sun_chat_storage_insert_v29 on storage.objects for insert to authenticated +with check(bucket_id='sun-chat' and public.sun_chat_storage_access_v29(name)); + +-- Realtime private-channel authorization for chat workspace/thread topics. +drop policy if exists sun_chat_realtime_read_v29 on realtime.messages; +create policy sun_chat_realtime_read_v29 on realtime.messages for select to authenticated +using(realtime.messages.extension in ('broadcast','presence') and public.sun_chat_realtime_topic_access_v29((select realtime.topic()))); +drop policy if exists sun_chat_realtime_write_v29 on realtime.messages; +create policy sun_chat_realtime_write_v29 on realtime.messages for insert to authenticated +with check(realtime.messages.extension in ('broadcast','presence') and public.sun_chat_realtime_topic_access_v29((select realtime.topic()))); + +revoke all on function public.sun_chat_is_member_v29(uuid,uuid) from public,anon; +revoke all on function public.sun_chat_can_access_thread_as_v29(uuid,uuid) from public,anon; +revoke all on function public.sun_chat_can_access_thread_v29(uuid) from public,anon; +revoke all on function public.sun_chat_realtime_topic_access_v29(text) from public,anon; +revoke all on function public.sun_chat_storage_access_v29(text) from public,anon; +revoke all on function public.sun_chat_get_company_thread_v29(uuid) from public,anon; +revoke all on function public.sun_chat_get_order_thread_v29(uuid,text) from public,anon; +revoke all on function public.sun_chat_get_direct_thread_v29(uuid,uuid) from public,anon; +revoke all on function public.sun_chat_list_members_v29(uuid) from public,anon; +revoke all on function public.sun_chat_list_threads_v29(uuid) from public,anon; +revoke all on function public.sun_chat_list_messages_v29(uuid,integer,timestamptz) from public,anon; +revoke all on function public.sun_chat_send_message_v29(uuid,text,jsonb) from public,anon; +revoke all on function public.sun_chat_mark_read_v29(uuid) from public,anon; +revoke all on function public.sun_chat_unread_total_v29(uuid) from public,anon; + +grant execute on function public.sun_chat_realtime_topic_access_v29(text) to authenticated; +grant execute on function public.sun_chat_storage_access_v29(text) to authenticated; +grant execute on function public.sun_chat_get_company_thread_v29(uuid) to authenticated; +grant execute on function public.sun_chat_get_order_thread_v29(uuid,text) to authenticated; +grant execute on function public.sun_chat_get_direct_thread_v29(uuid,uuid) to authenticated; +grant execute on function public.sun_chat_list_members_v29(uuid) to authenticated; +grant execute on function public.sun_chat_list_threads_v29(uuid) to authenticated; +grant execute on function public.sun_chat_list_messages_v29(uuid,integer,timestamptz) to authenticated; +grant execute on function public.sun_chat_send_message_v29(uuid,text,jsonb) to authenticated; +grant execute on function public.sun_chat_mark_read_v29(uuid) to authenticated; +grant execute on function public.sun_chat_unread_total_v29(uuid) to authenticated; diff --git a/ops/sql/SUPABASE-DEVELOPER-V22-AAL2.sql b/ops/sql/SUPABASE-DEVELOPER-V22-AAL2.sql new file mode 100644 index 0000000..f3744b2 --- /dev/null +++ b/ops/sql/SUPABASE-DEVELOPER-V22-AAL2.sql @@ -0,0 +1,71 @@ +-- Developer console v22 hardening: platform-wide actions require MFA AAL2. + +create or replace function public.sun_require_platform_admin_aal2() +returns void +language plpgsql +security definer +set search_path='public','auth' +as $$ +begin + if not public.sun_is_platform_admin() then raise exception 'Platform administrator required'; end if; + if coalesce(auth.jwt()->>'aal','aal1') <> 'aal2' then raise exception 'Developer MFA AAL2 required'; end if; +end; +$$; +revoke all on function public.sun_require_platform_admin_aal2() from public, anon, authenticated; + +create or replace function public.sun_platform_list_companies_v22() +returns table(workspace_id uuid,workspace_name text,created_at timestamptz,plan_id text,plan_name text,status text,access_mode text,trial_ends_at timestamptz,current_period_end timestamptz,grace_until timestamptz,member_count bigint,max_members integer,owner_email text,pending_owner_email text) +language plpgsql stable security definer set search_path='public','auth' as $$ +begin + perform public.sun_require_platform_admin_aal2(); + return query + select w.id,w.name,w.created_at,s.plan_id,p.name,s.status,public.sun_subscription_access_mode(w.id),s.trial_ends_at,s.current_period_end,s.grace_until, + (select count(*) from public.sun_workspace_members m where m.workspace_id=w.id and m.is_active),p.max_members, + (select u.email::text from public.sun_workspace_members m join auth.users u on u.id=m.user_id where m.workspace_id=w.id and m.role='admin' and m.is_active order by m.created_at asc limit 1), + (select i.email from public.caterium_company_owner_invites i where i.workspace_id=w.id and i.used_at is null and i.expires_at>now() order by i.created_at desc limit 1) + from public.sun_workspaces w left join public.sun_workspace_subscriptions s on s.workspace_id=w.id left join public.sun_plans p on p.id=s.plan_id order by w.created_at desc; +end;$$; + +create or replace function public.sun_platform_list_users_v22() +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','auth' as $$ +begin + perform public.sun_require_platform_admin_aal2(); + 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;$$; + +create or replace function public.sun_platform_list_errors_v22(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 + perform public.sun_require_platform_admin_aal2(); + 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;$$; + +-- Harden v22 developer functions. +create or replace function public.sun_platform_dashboard() +returns jsonb language plpgsql stable security definer set search_path='public','auth' as $$ +declare v_companies bigint; v_accounts bigint; v_admins bigint; v_active bigint; v_trial bigint; v_locked bigint; v_errors bigint; v_backups bigint; v_members bigint; v_last_state timestamptz; v_last_backup timestamptz; +begin + perform public.sun_require_platform_admin_aal2(); + select count(*) into v_companies from public.sun_workspaces; select count(*) into v_accounts from auth.users; select count(*) into v_admins from public.sun_platform_admins; select count(*) into v_members from public.sun_workspace_members where is_active=true; + select count(*) into v_active from public.sun_workspace_subscriptions where status='active' and coalesce(current_period_end,'infinity'::timestamptz)>now(); select count(*) into v_trial from public.sun_workspace_subscriptions where status='trialing' and coalesce(trial_ends_at,'infinity'::timestamptz)>now(); + select count(*) into v_locked from public.sun_workspaces w where public.sun_subscription_access_mode(w.id) in ('read_only','blocked'); select count(*) into v_errors from public.sun_v17_error_events where created_at>now()-interval '24 hours'; select count(*) into v_backups from public.sun_v17_backups where created_at>now()-interval '24 hours'; select max(updated_at) into v_last_state from public.sun_app_state; select max(created_at) into v_last_backup from public.sun_v17_backups; + return jsonb_build_object('companies',v_companies,'accounts',v_accounts,'platform_admins',v_admins,'memberships',v_members,'active_subscriptions',v_active,'trials',v_trial,'restricted_companies',v_locked,'errors_24h',v_errors,'backups_24h',v_backups,'last_state_at',v_last_state,'last_backup_at',v_last_backup,'database_size',pg_size_pretty(pg_database_size(current_database())),'postgres_version',current_setting('server_version')); +end;$$; + +-- Change the v22 function bodies by replacing the first platform check with the AAL2 guard. +-- Definitions are kept in SUPABASE-DEVELOPER-V22.sql; this block updates privileges for secure wrappers. +revoke all on function public.sun_platform_list_users() from anon; +revoke all on function public.sun_owner_add_existing_member(uuid,text,text) from anon; +revoke all on function public.sun_platform_list_companies_v22() from public,anon; +revoke all on function public.sun_platform_list_users_v22() from public,anon; +revoke all on function public.sun_platform_list_errors_v22(uuid,integer) from public,anon; +grant execute on function public.sun_platform_list_companies_v22() to authenticated; +grant execute on function public.sun_platform_list_users_v22() to authenticated; +grant execute on function public.sun_platform_list_errors_v22(uuid,integer) to authenticated; diff --git a/ops/sql/SUPABASE-DEVELOPER-V22.sql b/ops/sql/SUPABASE-DEVELOPER-V22.sql new file mode 100644 index 0000000..718c977 --- /dev/null +++ b/ops/sql/SUPABASE-DEVELOPER-V22.sql @@ -0,0 +1,325 @@ +-- Caterium / Sun Catering v17.5.22 developer console +-- Additive only. Gives platform admins a server-enforced developer console API. + +create table if not exists public.sun_platform_audit_events ( + id bigint generated by default as identity primary key, + actor_user_id uuid null references auth.users(id) on delete set null, + action text not null, + target_workspace_id uuid null references public.sun_workspaces(id) on delete set null, + target_user_id uuid null references auth.users(id) on delete set null, + details jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now() +); + +create index if not exists sun_platform_audit_events_created_idx on public.sun_platform_audit_events(created_at desc); +create index if not exists sun_platform_audit_events_workspace_idx on public.sun_platform_audit_events(target_workspace_id,created_at desc); +create index if not exists sun_platform_audit_events_user_idx on public.sun_platform_audit_events(target_user_id,created_at desc); +alter table public.sun_platform_audit_events enable row level security; +revoke all on public.sun_platform_audit_events from anon, authenticated; + +create or replace function public.sun_platform_log_event( + p_action text, + p_workspace uuid default null, + p_user uuid default null, + p_details jsonb default '{}'::jsonb +) returns bigint +language plpgsql +security definer +set search_path='public','auth' +as $$ +declare v_id bigint; +begin + if not public.sun_is_platform_admin() then raise exception 'Platform administrator required'; end if; + if nullif(trim(coalesce(p_action,'')),'') is null then raise exception 'Action required'; end if; + insert into public.sun_platform_audit_events(actor_user_id,action,target_workspace_id,target_user_id,details) + values(auth.uid(),left(trim(p_action),120),p_workspace,p_user,coalesce(p_details,'{}'::jsonb)) returning id into v_id; + return v_id; +end; +$$; + +create or replace function public.sun_platform_dashboard() +returns jsonb +language plpgsql +stable +security definer +set search_path='public','auth' +as $$ +declare + v_companies bigint; v_accounts bigint; v_admins bigint; v_active bigint; v_trial bigint; v_locked bigint; + v_errors bigint; v_backups bigint; v_members bigint; v_last_state timestamptz; v_last_backup timestamptz; +begin + if not public.sun_is_platform_admin() then raise exception 'Platform administrator required'; end if; + select count(*) into v_companies from public.sun_workspaces; + select count(*) into v_accounts from auth.users; + select count(*) into v_admins from public.sun_platform_admins; + select count(*) into v_members from public.sun_workspace_members where is_active=true; + select count(*) into v_active from public.sun_workspace_subscriptions where status='active' and coalesce(current_period_end,'infinity'::timestamptz)>now(); + select count(*) into v_trial from public.sun_workspace_subscriptions where status='trialing' and coalesce(trial_ends_at,'infinity'::timestamptz)>now(); + select count(*) into v_locked from public.sun_workspaces w where public.sun_subscription_access_mode(w.id) in ('read_only','blocked'); + select count(*) into v_errors from public.sun_v17_error_events where created_at>now()-interval '24 hours'; + select count(*) into v_backups from public.sun_v17_backups where created_at>now()-interval '24 hours'; + select max(updated_at) into v_last_state from public.sun_app_state; + select max(created_at) into v_last_backup from public.sun_v17_backups; + return jsonb_build_object( + 'companies',v_companies,'accounts',v_accounts,'platform_admins',v_admins,'memberships',v_members, + 'active_subscriptions',v_active,'trials',v_trial,'restricted_companies',v_locked, + 'errors_24h',v_errors,'backups_24h',v_backups,'last_state_at',v_last_state,'last_backup_at',v_last_backup, + 'database_size',pg_size_pretty(pg_database_size(current_database())), + 'postgres_version',current_setting('server_version') + ); +end; +$$; + +create or replace function public.sun_platform_list_activity(p_limit integer default 120) +returns table( + id bigint, action text, actor_user_id uuid, actor_email text, + workspace_id uuid, workspace_name text, target_user_id uuid, target_email text, + details jsonb, created_at timestamptz +) +language plpgsql +stable +security definer +set search_path='public','auth' +as $$ +begin + if not public.sun_is_platform_admin() then raise exception 'Platform administrator required'; end if; + return query + select a.id,a.action,a.actor_user_id,au.email::text,a.target_workspace_id,w.name,a.target_user_id,tu.email::text,a.details,a.created_at + from public.sun_platform_audit_events a + left join auth.users au on au.id=a.actor_user_id + left join public.sun_workspaces w on w.id=a.target_workspace_id + left join auth.users tu on tu.id=a.target_user_id + order by a.created_at desc + limit greatest(1,least(coalesce(p_limit,120),500)); +end; +$$; + +create or replace function public.sun_platform_support_snapshot(p_workspace uuid) +returns table(workspace_id uuid,payload jsonb,revision bigint,updated_at timestamptz,client_id text) +language plpgsql +security definer +set search_path='public' +as $$ +begin + if not public.sun_is_platform_admin() then raise exception 'Platform administrator required'; end if; + if not exists(select 1 from public.sun_workspaces where id=p_workspace) then raise exception 'Workspace not found'; end if; + perform public.sun_platform_log_event('support.open',p_workspace,null,jsonb_build_object('mode','read_only')); + return query + select s.workspace_id,s.payload,s.revision,s.updated_at,s.client_id + from public.sun_app_state s where s.workspace_id=p_workspace; +end; +$$; + +create or replace function public.sun_platform_workspace_diagnostics(p_workspace uuid) +returns jsonb +language plpgsql +stable +security definer +set search_path='public' +as $$ +declare v jsonb; +begin + if not public.sun_is_platform_admin() then raise exception 'Platform administrator required'; end if; + if not exists(select 1 from public.sun_workspaces where id=p_workspace) then raise exception 'Workspace not found'; end if; + select jsonb_build_object( + 'workspace_id',w.id,'name',w.name,'created_at',w.created_at, + 'revision',coalesce(s.revision,0),'state_updated_at',s.updated_at,'state_client_id',s.client_id, + 'members',(select count(*) from public.sun_workspace_members m where m.workspace_id=w.id and m.is_active), + 'orders',(select count(*) from public.sun_v17_orders o where o.workspace_id=w.id), + 'clients',(select count(*) from public.sun_v17_clients c where c.workspace_id=w.id), + 'catalog_items',(select count(*) from public.sun_v17_catalog_items c where c.workspace_id=w.id), + 'settings',(select count(*) from public.sun_v17_settings x where x.workspace_id=w.id), + 'backups',(select count(*) from public.sun_v17_backups b where b.workspace_id=w.id), + 'latest_backup',(select max(created_at) from public.sun_v17_backups b where b.workspace_id=w.id), + 'errors_24h',(select count(*) from public.sun_v17_error_events e where e.workspace_id=w.id and e.created_at>now()-interval '24 hours'), + 'latest_error',(select max(created_at) from public.sun_v17_error_events e where e.workspace_id=w.id), + 'access_mode',public.sun_subscription_access_mode(w.id) + ) into v + from public.sun_workspaces w left join public.sun_app_state s on s.workspace_id=w.id + where w.id=p_workspace; + return v; +end; +$$; + +create or replace function public.sun_platform_list_workspace_features(p_workspace uuid) +returns table( + feature_key text, plan_enabled boolean, override_enabled boolean, + override_expires_at timestamptz, effective_enabled boolean, note text +) +language plpgsql +stable +security definer +set search_path='public' +as $$ +declare v_plan text; +begin + if not public.sun_is_platform_admin() then raise exception 'Platform administrator required'; end if; + select plan_id into v_plan from public.sun_workspace_subscriptions where workspace_id=p_workspace; + return query + with features as ( + select distinct pf.feature_key from public.sun_plan_features pf + ) + select f.feature_key, + coalesce(pf.enabled,false), + case when o.expires_at is null or o.expires_at>now() then o.enabled else null end, + o.expires_at, + case when o.feature_key is not null and (o.expires_at is null or o.expires_at>now()) then o.enabled else coalesce(pf.enabled,false) end, + o.note + from features f + left join public.sun_plan_features pf on pf.plan_id=v_plan and pf.feature_key=f.feature_key + left join public.sun_workspace_feature_overrides o on o.workspace_id=p_workspace and o.feature_key=f.feature_key + order by f.feature_key; +end; +$$; + +create or replace function public.sun_platform_set_plan_feature(p_plan text,p_feature text,p_enabled boolean) +returns void +language plpgsql +security definer +set search_path='public' +as $$ +begin + if not public.sun_is_platform_admin() then raise exception 'Platform administrator required'; end if; + if not exists(select 1 from public.sun_plans where id=p_plan) then raise exception 'Unknown plan'; end if; + if nullif(trim(coalesce(p_feature,'')),'') is null then raise exception 'Feature required'; end if; + insert into public.sun_plan_features(plan_id,feature_key,enabled) values(p_plan,p_feature,p_enabled) + on conflict(plan_id,feature_key) do update set enabled=excluded.enabled; + perform public.sun_platform_log_event('plan.feature.set',null,null,jsonb_build_object('plan',p_plan,'feature',p_feature,'enabled',p_enabled)); +end; +$$; + +create or replace function public.sun_platform_set_plan_max_members(p_plan text,p_max_members integer) +returns void +language plpgsql +security definer +set search_path='public' +as $$ +begin + if not public.sun_is_platform_admin() then raise exception 'Platform administrator required'; end if; + if p_max_members is not null and (p_max_members<1 or p_max_members>10000) then raise exception 'Invalid member limit'; end if; + update public.sun_plans set max_members=p_max_members,updated_at=now() where id=p_plan; + if not found then raise exception 'Unknown plan'; end if; + perform public.sun_platform_log_event('plan.members.set',null,null,jsonb_build_object('plan',p_plan,'max_members',p_max_members)); +end; +$$; + +create or replace function public.sun_platform_seed_workspace_catalog( + p_workspace uuid, + p_boxes jsonb, + p_catalog_version text default '', + p_replace boolean default false +) returns integer +language plpgsql +security definer +set search_path='public' +as $$ +declare + v_payload jsonb; v_existing jsonb; v_entry jsonb; v_item jsonb; v_id text; v_count integer:=0; +begin + if not public.sun_is_platform_admin() then raise exception 'Platform administrator required'; end if; + if jsonb_typeof(p_boxes)<>'array' then raise exception 'Catalog must be an array'; end if; + if not exists(select 1 from public.sun_workspaces where id=p_workspace) then raise exception 'Workspace not found'; end if; + + select payload into v_payload from public.sun_app_state where workspace_id=p_workspace for update; + if v_payload is null then + v_payload:=jsonb_build_object('format','sun-cloud-v2','version',2,'storage','{}'::jsonb); + insert into public.sun_app_state(workspace_id,payload,revision,updated_by) + values(p_workspace,v_payload,0,auth.uid()) + on conflict(workspace_id) do nothing; + end if; + + v_existing:=coalesce(v_payload->'storage'->'sunBoxes'->'v','[]'::jsonb); + if not p_replace and jsonb_typeof(v_existing)='array' and jsonb_array_length(v_existing)>0 then + raise exception 'Catalog already populated'; + end if; + + v_entry:=jsonb_build_object('t','j','v',p_boxes); + v_payload:=jsonb_set(coalesce(v_payload,'{}'::jsonb),'{storage,sunBoxes}',v_entry,true); + if nullif(coalesce(p_catalog_version,''),'') is not null then + v_payload:=jsonb_set(v_payload,'{storage,sunOfficialCatalogVersion}',jsonb_build_object('t','s','v',p_catalog_version),true); + end if; + update public.sun_app_state set payload=v_payload,revision=revision+1,client_id='platform-catalog',updated_by=auth.uid(),updated_at=now() where workspace_id=p_workspace; + + if p_replace then delete from public.sun_v17_catalog_items where workspace_id=p_workspace; end if; + for v_item in select value from jsonb_array_elements(p_boxes) loop + v_id:=coalesce(nullif(v_item->>'id',''),gen_random_uuid()::text); + insert into public.sun_v17_catalog_items(workspace_id,item_id,data,version,created_by,updated_by) + values(p_workspace,v_id,v_item,1,auth.uid(),auth.uid()) + on conflict(workspace_id,item_id) do update set data=excluded.data,version=sun_v17_catalog_items.version+1,updated_by=auth.uid(),updated_at=now(); + v_count:=v_count+1; + end loop; + + insert into public.sun_v17_change_events(workspace_id,entity,entity_key,operation,client_id,created_by) + values(p_workspace,'catalog','starter',case when p_replace then 'replace' else 'seed' end,'platform-catalog',auth.uid()); + perform public.sun_platform_log_event(case when p_replace then 'catalog.force_apply' else 'catalog.seed' end,p_workspace,null,jsonb_build_object('count',v_count,'version',p_catalog_version)); + return v_count; +end; +$$; + +revoke all on function public.sun_platform_log_event(text,uuid,uuid,jsonb) from public, anon; +revoke all on function public.sun_platform_dashboard() from public, anon; +revoke all on function public.sun_platform_list_activity(integer) from public, anon; +revoke all on function public.sun_platform_support_snapshot(uuid) from public, anon; +revoke all on function public.sun_platform_workspace_diagnostics(uuid) from public, anon; +revoke all on function public.sun_platform_list_workspace_features(uuid) from public, anon; +revoke all on function public.sun_platform_set_plan_feature(text,text,boolean) from public, anon; +revoke all on function public.sun_platform_set_plan_max_members(text,integer) from public, anon; +revoke all on function public.sun_platform_seed_workspace_catalog(uuid,jsonb,text,boolean) from public, anon; + +grant execute on function public.sun_platform_log_event(text,uuid,uuid,jsonb) to authenticated; +grant execute on function public.sun_platform_dashboard() to authenticated; +grant execute on function public.sun_platform_list_activity(integer) to authenticated; +grant execute on function public.sun_platform_support_snapshot(uuid) to authenticated; +grant execute on function public.sun_platform_workspace_diagnostics(uuid) to authenticated; +grant execute on function public.sun_platform_list_workspace_features(uuid) to authenticated; +grant execute on function public.sun_platform_set_plan_feature(text,text,boolean) to authenticated; +grant execute on function public.sun_platform_set_plan_max_members(text,integer) to authenticated; +grant execute on function public.sun_platform_seed_workspace_catalog(uuid,jsonb,text,boolean) to authenticated; + +create or replace function public.sun_platform_reset_feature_override(p_workspace uuid,p_feature text) +returns void +language plpgsql +security definer +set search_path='public' +as $$ +begin + if not public.sun_is_platform_admin() then raise exception 'Platform administrator required'; end if; + delete from public.sun_workspace_feature_overrides where workspace_id=p_workspace and feature_key=p_feature; + perform public.sun_platform_log_event('feature.override.reset',p_workspace,null,jsonb_build_object('feature',p_feature)); +end; +$$; + +create or replace function public.sun_platform_create_company_v22( + p_name text, + p_owner_email text default null, + p_plan text default 'full', + p_days integer default 30, + p_boxes jsonb default '[]'::jsonb, + p_catalog_version text default '' +) returns jsonb +language plpgsql +security definer +set search_path='public' +as $$ +declare + v_base jsonb; v_ws uuid; v_profile jsonb; v_payload jsonb; v_count integer; +begin + if not public.sun_is_platform_admin() then raise exception 'Platform administrator required'; end if; + select public.caterium_platform_create_company(p_name,p_owner_email,p_plan,p_days,'empty') into v_base; + v_ws:=(v_base->>'workspace_id')::uuid; + v_profile:=jsonb_build_object('name',coalesce(nullif(trim(p_name),''),'Новая компания'),'ownerEmail',coalesce(p_owner_email,''),'createdAt',now()); + select payload into v_payload from public.sun_app_state where workspace_id=v_ws for update; + v_payload:=jsonb_set(v_payload,'{storage,sunCompanyProfileV1}',jsonb_build_object('t','j','v',v_profile),true); + v_payload:=jsonb_set(v_payload,'{storage,sunOrders}',jsonb_build_object('t','j','v','[]'::jsonb),true); + v_payload:=jsonb_set(v_payload,'{storage,sunClientsV2}',jsonb_build_object('t','j','v','[]'::jsonb),true); + update public.sun_app_state set payload=v_payload,updated_by=auth.uid(),updated_at=now() where workspace_id=v_ws; + select public.sun_platform_seed_workspace_catalog(v_ws,coalesce(p_boxes,'[]'::jsonb),p_catalog_version,true) into v_count; + perform public.sun_platform_log_event('company.create',v_ws,null,jsonb_build_object('name',p_name,'owner_email',p_owner_email,'plan',p_plan,'days',p_days,'catalog_count',v_count)); + return coalesce(v_base,'{}'::jsonb)||jsonb_build_object('catalog_count',v_count); +end; +$$; + +revoke all on function public.sun_platform_reset_feature_override(uuid,text) from public, anon; +revoke all on function public.sun_platform_create_company_v22(text,text,text,integer,jsonb,text) from public, anon; +grant execute on function public.sun_platform_reset_feature_override(uuid,text) to authenticated; +grant execute on function public.sun_platform_create_company_v22(text,text,text,integer,jsonb,text) to authenticated; diff --git a/ops/sql/SUPABASE-RBAC-V3.sql b/ops/sql/SUPABASE-RBAC-V3.sql new file mode 100644 index 0000000..3925fbf --- /dev/null +++ b/ops/sql/SUPABASE-RBAC-V3.sql @@ -0,0 +1,674 @@ +-- Sun Catering Cloud RBAC v3 +-- Roles, granular permissions, administrator tools, secured state RPCs and realtime sync events. + +alter table public.sun_workspace_members add column if not exists display_name text; +alter table public.sun_workspace_members add column if not exists is_active boolean not null default true; +alter table public.sun_workspace_members add column if not exists permissions jsonb not null default '{}'::jsonb; +alter table public.sun_workspace_members add column if not exists updated_at timestamptz not null default now(); + +alter table public.sun_workspace_invites add column if not exists permissions jsonb; + +alter table public.sun_workspace_members drop constraint if exists sun_workspace_members_role_check; +update public.sun_workspace_members set role='admin' where role='owner'; +alter table public.sun_workspace_members add constraint sun_workspace_members_role_check + check (role in ('admin','manager','kitchen','courier','viewer')); + +alter table public.sun_workspace_invites drop constraint if exists sun_workspace_invites_role_check; +alter table public.sun_workspace_invites add constraint sun_workspace_invites_role_check + check (role in ('admin','manager','kitchen','courier','viewer')); + +create or replace function public.sun_role_default_permissions(p_role text) +returns jsonb +language sql +immutable +set search_path = public +as $$ + select case lower(coalesce(p_role,'')) + when 'admin' then jsonb_build_object( + 'app.read',true, + 'orders.view',true,'orders.create',true,'orders.edit',true,'orders.delete',true, + 'clients.view',true,'clients.edit',true, + 'catalog.view',true,'catalog.edit',true, + 'calendar.view',true,'map.view',true, + 'production.view',true,'production.edit',true, + 'shopping.view',true,'shopping.edit',true, + 'stock.view',true,'stock.edit',true, + 'routes.view',true,'routes.edit',true, + 'mailings.view',true,'mailings.edit',true, + 'money.view',true,'money.edit',true, + 'stats.view',true, + 'team.view',true,'team.edit',true, + 'suppliers.view',true,'suppliers.edit',true, + 'print.view',true, + 'settings.view',true,'settings.edit',true, + 'users.manage',true,'audit.view',true,'backups.manage',true + ) + when 'manager' then jsonb_build_object( + 'app.read',true, + 'orders.view',true,'orders.create',true,'orders.edit',true,'orders.delete',false, + 'clients.view',true,'clients.edit',true, + 'catalog.view',true,'catalog.edit',false, + 'calendar.view',true,'map.view',true, + 'production.view',false,'production.edit',false, + 'shopping.view',false,'shopping.edit',false, + 'stock.view',false,'stock.edit',false, + 'routes.view',true,'routes.edit',false, + 'mailings.view',true,'mailings.edit',true, + 'money.view',false,'money.edit',false, + 'stats.view',true, + 'team.view',true,'team.edit',false, + 'suppliers.view',false,'suppliers.edit',false, + 'print.view',true, + 'settings.view',false,'settings.edit',false, + 'users.manage',false,'audit.view',false,'backups.manage',false + ) + when 'kitchen' then jsonb_build_object( + 'app.read',true, + 'orders.view',true,'orders.create',false,'orders.edit',false,'orders.delete',false, + 'clients.view',false,'clients.edit',false, + 'catalog.view',true,'catalog.edit',false, + 'calendar.view',true,'map.view',false, + 'production.view',true,'production.edit',true, + 'shopping.view',true,'shopping.edit',false, + 'stock.view',true,'stock.edit',true, + 'routes.view',false,'routes.edit',false, + 'mailings.view',false,'mailings.edit',false, + 'money.view',false,'money.edit',false, + 'stats.view',false, + 'team.view',true,'team.edit',false, + 'suppliers.view',false,'suppliers.edit',false, + 'print.view',true, + 'settings.view',false,'settings.edit',false, + 'users.manage',false,'audit.view',false,'backups.manage',false + ) + when 'courier' then jsonb_build_object( + 'app.read',true, + 'orders.view',true,'orders.create',false,'orders.edit',false,'orders.delete',false, + 'clients.view',false,'clients.edit',false, + 'catalog.view',false,'catalog.edit',false, + 'calendar.view',true,'map.view',true, + 'production.view',false,'production.edit',false, + 'shopping.view',false,'shopping.edit',false, + 'stock.view',false,'stock.edit',false, + 'routes.view',true,'routes.edit',true, + 'mailings.view',false,'mailings.edit',false, + 'money.view',false,'money.edit',false, + 'stats.view',false, + 'team.view',false,'team.edit',false, + 'suppliers.view',false,'suppliers.edit',false, + 'print.view',false, + 'settings.view',false,'settings.edit',false, + 'users.manage',false,'audit.view',false,'backups.manage',false + ) + else jsonb_build_object( + 'app.read',true, + 'orders.view',true,'orders.create',false,'orders.edit',false,'orders.delete',false, + 'clients.view',true,'clients.edit',false, + 'catalog.view',true,'catalog.edit',false, + 'calendar.view',true,'map.view',true, + 'production.view',false,'production.edit',false, + 'shopping.view',false,'shopping.edit',false, + 'stock.view',false,'stock.edit',false, + 'routes.view',false,'routes.edit',false, + 'mailings.view',false,'mailings.edit',false, + 'money.view',false,'money.edit',false, + 'stats.view',true, + 'team.view',false,'team.edit',false, + 'suppliers.view',false,'suppliers.edit',false, + 'print.view',true, + 'settings.view',false,'settings.edit',false, + 'users.manage',false,'audit.view',false,'backups.manage',false + ) + end; +$$; + +create or replace function public.sun_member_role(p_workspace uuid) +returns text +language sql +stable +security definer +set search_path = public +as $$ + select m.role + from public.sun_workspace_members m + where m.workspace_id = p_workspace + and m.user_id = auth.uid() + and m.is_active = true + limit 1; +$$; + +create or replace function public.sun_has_permission(p_workspace uuid, p_permission text) +returns boolean +language plpgsql +stable +security definer +set search_path = public +as $$ +declare + v_role text; + v_permissions jsonb; + v_active boolean; +begin + select role, permissions, is_active + into v_role, v_permissions, v_active + from public.sun_workspace_members + where workspace_id=p_workspace and user_id=auth.uid() + limit 1; + + if not coalesce(v_active,false) then return false; end if; + if v_role='admin' then return true; end if; + if v_permissions ? p_permission then + return coalesce((v_permissions->>p_permission)::boolean,false); + end if; + return coalesce((public.sun_role_default_permissions(v_role)->>p_permission)::boolean,false); +end; +$$; + +revoke all on function public.sun_member_role(uuid) from public, anon; +grant execute on function public.sun_member_role(uuid) to authenticated; +revoke all on function public.sun_has_permission(uuid,text) from public, anon; +grant execute on function public.sun_has_permission(uuid,text) to authenticated; +revoke all on function public.sun_role_default_permissions(text) from public, anon; +grant execute on function public.sun_role_default_permissions(text) to authenticated; + +create or replace function public.sun_create_workspace(p_name text default 'Солнце Кейтеринг') +returns uuid +language plpgsql +security definer +set search_path = public +as $$ +declare + v_user uuid := auth.uid(); + v_workspace uuid; + v_name text; +begin + if v_user is null then raise exception 'Authentication required'; end if; + v_name := coalesce(nullif(trim(p_name),''),'Солнце Кейтеринг'); + 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((select email from auth.users where id=v_user),'Администратор'),'@',1)),true,public.sun_role_default_permissions('admin')); + insert into public.sun_app_state(workspace_id,payload,client_id) + values (v_workspace,'{"format":"sun-cloud-v2","version":2,"storage":{}}'::jsonb,'bootstrap') + on conflict (workspace_id) do nothing; + return v_workspace; +end; +$$; +revoke all on function public.sun_create_workspace(text) from public, anon; +grant execute on function public.sun_create_workspace(text) to authenticated; + +create or replace function public.sun_create_invite(p_workspace uuid, p_role text default 'manager') +returns uuid +language plpgsql +security definer +set search_path = public +as $$ +declare + v_token uuid; + v_role text := lower(coalesce(p_role,'manager')); +begin + if not public.sun_has_permission(p_workspace,'users.manage') then raise exception 'Administrator permission required'; end if; + if v_role not in ('admin','manager','kitchen','courier','viewer') then raise exception 'Invalid role'; end if; + insert into public.sun_workspace_invites(workspace_id,role,permissions,created_by) + values (p_workspace,v_role,public.sun_role_default_permissions(v_role),auth.uid()) + returning token into v_token; + return v_token; +end; +$$; +revoke all on function public.sun_create_invite(uuid,text) from public, anon; +grant execute on function public.sun_create_invite(uuid,text) to authenticated; + +create or replace function public.sun_accept_invite(p_token uuid) +returns uuid +language plpgsql +security definer +set search_path = public +as $$ +declare + v_user uuid := auth.uid(); + v_invite public.sun_workspace_invites%rowtype; + v_display text; +begin + if v_user is null then raise exception 'Authentication required'; end if; + select * into v_invite from public.sun_workspace_invites where token=p_token for update; + if not found then raise exception 'Invite not found'; end if; + if v_invite.used_at is not null then raise exception 'Invite already used'; end if; + if v_invite.expires_at < now() then raise exception 'Invite expired'; end if; + select coalesce(nullif(raw_user_meta_data->>'name',''),split_part(coalesce(email,'Сотрудник'),'@',1)) into v_display from auth.users where id=v_user; + insert into public.sun_workspace_members(workspace_id,user_id,role,display_name,is_active,permissions,updated_at) + values (v_invite.workspace_id,v_user,v_invite.role,v_display,true,coalesce(v_invite.permissions,public.sun_role_default_permissions(v_invite.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(); + update public.sun_workspace_invites set used_by=v_user,used_at=now() where token=p_token; + return v_invite.workspace_id; +end; +$$; +revoke all on function public.sun_accept_invite(uuid) from public, anon; +grant execute on function public.sun_accept_invite(uuid) to authenticated; + +create or replace function public.sun_list_workspace_members(p_workspace uuid) +returns table(user_id uuid,email text,display_name text,role text,is_active boolean,permissions jsonb,created_at timestamptz,updated_at timestamptz) +language plpgsql +stable +security definer +set search_path = public +as $$ +begin + if not public.sun_has_permission(p_workspace,'users.manage') then raise exception 'Administrator permission required'; end if; + return query + select m.user_id,u.email,m.display_name,m.role,m.is_active, + coalesce(m.permissions,public.sun_role_default_permissions(m.role)),m.created_at,m.updated_at + from public.sun_workspace_members m + join auth.users u on u.id=m.user_id + where m.workspace_id=p_workspace + order by (m.role='admin') desc,coalesce(m.display_name,u.email); +end; +$$; +revoke all on function public.sun_list_workspace_members(uuid) from public, anon; +grant execute on function public.sun_list_workspace_members(uuid) to authenticated; + +create or replace function public.sun_admin_update_member( + p_workspace uuid, + p_user uuid, + p_display_name text, + p_role text, + p_is_active boolean, + p_permissions jsonb +) +returns void +language plpgsql +security definer +set search_path = public +as $$ +declare + v_old_role text; + v_old_active boolean; + v_admins int; + v_role text := lower(coalesce(p_role,'')); +begin + if not public.sun_has_permission(p_workspace,'users.manage') then raise exception 'Administrator permission required'; end if; + if v_role not in ('admin','manager','kitchen','courier','viewer') then raise exception 'Invalid role'; end if; + if p_permissions is null or jsonb_typeof(p_permissions) <> 'object' then raise exception 'Permissions must be an object'; end if; + + select role,is_active into v_old_role,v_old_active from public.sun_workspace_members where workspace_id=p_workspace and user_id=p_user for update; + if not found then raise exception 'Member not found'; end if; + + if v_old_role='admin' and coalesce(v_old_active,false) and (v_role<>'admin' or not coalesce(p_is_active,false)) then + select count(*) into v_admins from public.sun_workspace_members where workspace_id=p_workspace and role='admin' and is_active=true; + if v_admins <= 1 then raise exception 'Нельзя отключить или понизить последнего администратора'; end if; + end if; + + update public.sun_workspace_members + set display_name=nullif(trim(coalesce(p_display_name,'')),''),role=v_role,is_active=coalesce(p_is_active,false),permissions=p_permissions,updated_at=now() + where workspace_id=p_workspace and user_id=p_user; +end; +$$; +revoke all on function public.sun_admin_update_member(uuid,uuid,text,text,boolean,jsonb) from public, anon; +grant execute on function public.sun_admin_update_member(uuid,uuid,text,text,boolean,jsonb) to authenticated; + +create or replace function public.sun_admin_remove_member(p_workspace uuid,p_user uuid) +returns void +language plpgsql +security definer +set search_path = public +as $$ +declare + v_role text; + v_active boolean; + v_admins int; +begin + if not public.sun_has_permission(p_workspace,'users.manage') then raise exception 'Administrator permission required'; end if; + select role,is_active into v_role,v_active from public.sun_workspace_members where workspace_id=p_workspace and user_id=p_user for update; + if not found then return; end if; + if v_role='admin' and coalesce(v_active,false) then + select count(*) into v_admins from public.sun_workspace_members where workspace_id=p_workspace and role='admin' and is_active=true; + if v_admins <= 1 then raise exception 'Нельзя удалить последнего администратора'; end if; + end if; + delete from public.sun_workspace_members where workspace_id=p_workspace and user_id=p_user; +end; +$$; +revoke all on function public.sun_admin_remove_member(uuid,uuid) from public, anon; +grant execute on function public.sun_admin_remove_member(uuid,uuid) to authenticated; + +create or replace function public.sun_can_read_storage_key(p_workspace uuid,p_key text) +returns boolean +language plpgsql +stable +security definer +set search_path = public +as $$ +begin + if public.sun_member_role(p_workspace) is null then return false; end if; + return case + when p_key='sunOrders' then public.sun_has_permission(p_workspace,'orders.view') or public.sun_has_permission(p_workspace,'orders.create') or public.sun_has_permission(p_workspace,'orders.edit') + when p_key='sunBoxes' then public.sun_has_permission(p_workspace,'catalog.view') or public.sun_has_permission(p_workspace,'orders.create') or public.sun_has_permission(p_workspace,'production.view') or public.sun_has_permission(p_workspace,'stock.view') + when p_key in ('sunClientLoyaltyV1','sunClientCommunicationV1') then public.sun_has_permission(p_workspace,'clients.view') or public.sun_has_permission(p_workspace,'clients.edit') or public.sun_has_permission(p_workspace,'orders.create') or public.sun_has_permission(p_workspace,'orders.edit') + when p_key='sunFinanceRecordsV2' then public.sun_has_permission(p_workspace,'money.view') or public.sun_has_permission(p_workspace,'money.edit') + when p_key in ('sunStock','sunStockMoves') then public.sun_has_permission(p_workspace,'stock.view') or public.sun_has_permission(p_workspace,'stock.edit') or public.sun_has_permission(p_workspace,'shopping.view') or public.sun_has_permission(p_workspace,'production.view') + when p_key='sunEmployees' then public.sun_has_permission(p_workspace,'team.view') or public.sun_has_permission(p_workspace,'team.edit') or public.sun_has_permission(p_workspace,'production.view') or public.sun_has_permission(p_workspace,'routes.view') + when p_key='sunSuppliers' then public.sun_has_permission(p_workspace,'suppliers.view') or public.sun_has_permission(p_workspace,'suppliers.edit') or public.sun_has_permission(p_workspace,'shopping.view') + when p_key like 'sunRoute%' then public.sun_has_permission(p_workspace,'routes.view') or public.sun_has_permission(p_workspace,'routes.edit') or public.sun_has_permission(p_workspace,'map.view') + when p_key like 'sunMarketing%' then public.sun_has_permission(p_workspace,'mailings.view') or public.sun_has_permission(p_workspace,'mailings.edit') + when p_key='sunAuditLogV1' then public.sun_has_permission(p_workspace,'audit.view') + else public.sun_has_permission(p_workspace,'app.read') + end; +end; +$$; +revoke all on function public.sun_can_read_storage_key(uuid,text) from public, anon, authenticated; + +create or replace function public.sun_required_write_permission(p_key text) +returns text +language sql +immutable +as $$ + select case + when p_key='sunBoxes' then 'catalog.edit' + when p_key in ('sunClientLoyaltyV1','sunClientCommunicationV1') then 'clients.edit' + when p_key='sunFinanceRecordsV2' then 'money.edit' + when p_key in ('sunStock','sunStockMoves') then 'stock.edit' + when p_key='sunEmployees' then 'team.edit' + when p_key='sunSuppliers' then 'suppliers.edit' + when p_key like 'sunRoute%' then 'routes.edit' + when p_key like 'sunMarketing%' then 'mailings.edit' + when p_key in ('sunPromoCodesV1') then 'mailings.edit' + when p_key in ('sunCatalogCategoriesV2','sunDefaultOrderQrV1','sunLeadSourcesV1','sunEventTypesV1','sunOrderPaymentColorsV1','sunReceiptSettingsV2','sunPrintSettingsV1','sunYandexMapsSettings','sunEnterpriseSettingsV1','sunOrderStatusFeatureEnabledV1') then 'settings.edit' + when p_key='sunOfficialCatalogVersion' then 'catalog.edit' + when p_key='sunAuditLogV1' then '_member' + else 'settings.edit' + end; +$$; +revoke all on function public.sun_required_write_permission(text) from public, anon, authenticated; + +create or replace function public.sun_fetch_app_state(p_workspace uuid) +returns table(workspace_id uuid,payload jsonb,revision bigint,updated_at timestamptz,client_id text) +language plpgsql +stable +security definer +set search_path = public +as $$ +declare + v_row public.sun_app_state%rowtype; + v_storage jsonb := '{}'::jsonb; + kv record; +begin + if public.sun_member_role(p_workspace) is null then raise exception 'Access denied'; end if; + select * into v_row from public.sun_app_state where sun_app_state.workspace_id=p_workspace; + if not found then return; end if; + for kv in select key,value from jsonb_each(coalesce(v_row.payload->'storage','{}'::jsonb)) loop + if public.sun_can_read_storage_key(p_workspace,kv.key) then + v_storage := v_storage || jsonb_build_object(kv.key,kv.value); + end if; + end loop; + workspace_id := v_row.workspace_id; + payload := jsonb_build_object('format',coalesce(v_row.payload->'format','"sun-cloud-v2"'::jsonb),'version',coalesce(v_row.payload->'version','2'::jsonb),'storage',v_storage); + revision := v_row.revision; + updated_at := v_row.updated_at; + client_id := v_row.client_id; + return next; +end; +$$; +revoke all on function public.sun_fetch_app_state(uuid) from public, anon; +grant execute on function public.sun_fetch_app_state(uuid) to authenticated; + +create or replace function public.sun_save_app_state(p_workspace uuid,p_payload jsonb,p_client_id text) +returns table(workspace_id uuid,payload jsonb,revision bigint,updated_at timestamptz,client_id text) +language plpgsql +security definer +set search_path = public +as $$ +declare + v_old jsonb := '{"format":"sun-cloud-v2","version":2,"storage":{}}'::jsonb; + v_old_storage jsonb; + v_new_storage jsonb; + v_key text; + v_perm text; + v_old_orders jsonb; + v_new_orders jsonb; + v_create boolean := false; + v_delete boolean := false; + v_edit boolean := false; + v_saved public.sun_app_state%rowtype; +begin + if public.sun_member_role(p_workspace) is null then raise exception 'Access denied'; end if; + if p_payload is null or jsonb_typeof(p_payload) <> 'object' or jsonb_typeof(coalesce(p_payload->'storage','{}'::jsonb)) <> 'object' then raise exception 'Invalid payload'; end if; + + select a.payload into v_old from public.sun_app_state a where a.workspace_id=p_workspace; + if v_old is null then v_old := '{"format":"sun-cloud-v2","version":2,"storage":{}}'::jsonb; end if; + v_old_storage := coalesce(v_old->'storage','{}'::jsonb); + v_new_storage := coalesce(p_payload->'storage','{}'::jsonb); + + for v_key in + select key from (select jsonb_object_keys(v_old_storage) key union select jsonb_object_keys(v_new_storage) key) q + loop + if coalesce(v_old_storage->v_key,'null'::jsonb) = coalesce(v_new_storage->v_key,'null'::jsonb) then continue; end if; + if v_key='sunOrders' then + v_old_orders := coalesce(v_old_storage #> array['sunOrders','v'],'[]'::jsonb); + v_new_orders := coalesce(v_new_storage #> array['sunOrders','v'],'[]'::jsonb); + if jsonb_typeof(v_old_orders)<>'array' or jsonb_typeof(v_new_orders)<>'array' then raise exception 'Invalid orders payload'; end if; + select exists(select 1 from jsonb_array_elements(v_new_orders) n where not exists(select 1 from jsonb_array_elements(v_old_orders) o where o->>'id'=n->>'id')) into v_create; + select exists(select 1 from jsonb_array_elements(v_old_orders) o where not exists(select 1 from jsonb_array_elements(v_new_orders) n where n->>'id'=o->>'id')) into v_delete; + select exists(select 1 from jsonb_array_elements(v_new_orders) n join lateral (select o from jsonb_array_elements(v_old_orders) o where o->>'id'=n->>'id' limit 1) x on true where x.o<>n) into v_edit; + if v_create and not public.sun_has_permission(p_workspace,'orders.create') then raise exception 'Нет права создавать заказы'; end if; + if v_edit and not public.sun_has_permission(p_workspace,'orders.edit') then raise exception 'Нет права изменять заказы'; end if; + if v_delete and not public.sun_has_permission(p_workspace,'orders.delete') then raise exception 'Нет права удалять заказы'; end if; + else + v_perm := public.sun_required_write_permission(v_key); + if v_perm='_member' then + null; + elsif not public.sun_has_permission(p_workspace,v_perm) then + raise exception 'Нет права изменять раздел: %',v_key; + end if; + end if; + end loop; + + insert into public.sun_app_state(workspace_id,payload,client_id) + values (p_workspace,p_payload,p_client_id) + on conflict (workspace_id) do update set payload=excluded.payload,client_id=excluded.client_id + returning * into v_saved; + + insert into public.sun_sync_events(workspace_id,revision,client_id) values (p_workspace,v_saved.revision,p_client_id); + + return query select * from public.sun_fetch_app_state(p_workspace); +end; +$$; + +create table if not exists public.sun_sync_events ( + id bigint generated by default as identity primary key, + workspace_id uuid not null references public.sun_workspaces(id) on delete cascade, + revision bigint not null, + client_id text, + created_at timestamptz not null default now() +); +create index if not exists sun_sync_events_workspace_idx on public.sun_sync_events(workspace_id,id desc); +alter table public.sun_sync_events enable row level security; +revoke all on public.sun_sync_events from anon; +grant select on public.sun_sync_events to authenticated; +drop policy if exists sun_sync_events_read on public.sun_sync_events; +create policy sun_sync_events_read on public.sun_sync_events for select to authenticated using (public.sun_member_role(workspace_id) is not null); + +revoke all on function public.sun_save_app_state(uuid,jsonb,text) from public, anon; +grant execute on function public.sun_save_app_state(uuid,jsonb,text) to authenticated; + +-- The app state itself is accessible only through permission-aware RPCs. +revoke select,insert,update,delete on public.sun_app_state from authenticated, anon; +drop policy if exists sun_state_read on public.sun_app_state; +drop policy if exists sun_state_insert on public.sun_app_state; +drop policy if exists sun_state_update on public.sun_app_state; + +-- Members can read their own membership; administrators can read their workspace members. +drop policy if exists sun_members_read on public.sun_workspace_members; +create policy sun_members_read on public.sun_workspace_members for select to authenticated +using (user_id=auth.uid() or public.sun_has_permission(workspace_id,'users.manage')); + +-- Workspaces are visible only to active members. +drop policy if exists sun_workspaces_read on public.sun_workspaces; +create policy sun_workspaces_read on public.sun_workspaces for select to authenticated +using (public.sun_member_role(id) is not null); + +-- Invitations visible only to administrators. +drop policy if exists sun_invites_read on public.sun_workspace_invites; +create policy sun_invites_read on public.sun_workspace_invites for select to authenticated +using (public.sun_has_permission(workspace_id,'users.manage')); + +-- Storage: active members may read, catalogue editors may upload/change media. +drop policy if exists sun_media_read on storage.objects; +create policy sun_media_read on storage.objects for select to authenticated +using (bucket_id='sun-media' and public.sun_member_role(((storage.foldername(name))[1])::uuid) is not null); +drop policy if exists sun_media_insert on storage.objects; +create policy sun_media_insert on storage.objects for insert to authenticated +with check (bucket_id='sun-media' and public.sun_has_permission(((storage.foldername(name))[1])::uuid,'catalog.edit')); +drop policy if exists sun_media_update on storage.objects; +create policy sun_media_update on storage.objects for update to authenticated +using (bucket_id='sun-media' and public.sun_has_permission(((storage.foldername(name))[1])::uuid,'catalog.edit')) +with check (bucket_id='sun-media' and public.sun_has_permission(((storage.foldername(name))[1])::uuid,'catalog.edit')); +drop policy if exists sun_media_delete on storage.objects; +create policy sun_media_delete on storage.objects for delete to authenticated +using (bucket_id='sun-media' and public.sun_has_permission(((storage.foldername(name))[1])::uuid,'catalog.edit')); + +-- Realtime events, not the full payload, are published. +do $$ +begin + if exists(select 1 from pg_publication_tables where pubname='supabase_realtime' and schemaname='public' and tablename='sun_app_state') then + alter publication supabase_realtime drop table public.sun_app_state; + end if; + if not exists(select 1 from pg_publication_tables where pubname='supabase_realtime' and schemaname='public' and tablename='sun_sync_events') then + alter publication supabase_realtime add table public.sun_sync_events; + end if; +end $$; + +-- Bootstrap the approved administrator account if it has no workspace yet. +do $$ +declare + v_user uuid; + v_workspace uuid; + v_display text; +begin + 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('dpavlov346@bk.ru') limit 1; + if v_user is not null then + update public.sun_workspace_members + set role='admin',is_active=true,permissions=public.sun_role_default_permissions('admin'),updated_at=now() + where user_id=v_user; + if not exists(select 1 from public.sun_workspace_members where user_id=v_user) then + insert into public.sun_workspaces(name,created_by) values ('Солнце Кейтеринг',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',v_display,true,public.sun_role_default_permissions('admin')); + insert into public.sun_app_state(workspace_id,payload,client_id) + values (v_workspace,'{"format":"sun-cloud-v2","version":2,"storage":{}}'::jsonb,'bootstrap') + on conflict (workspace_id) do nothing; + end if; + end if; +end $$; + +-- Normalize existing members' empty permissions to their role template. +update public.sun_workspace_members +set permissions=public.sun_role_default_permissions(role),updated_at=now() +where permissions='{}'::jsonb or permissions is null; + +-- FINAL RUNTIME FIXES (keep at end of file) +create or replace function public.sun_list_workspace_members(p_workspace uuid) +returns table(user_id uuid,email text,display_name text,role text,is_active boolean,permissions jsonb,created_at timestamptz,updated_at timestamptz) +language plpgsql +stable +security definer +set search_path = public +as $$ +begin + if not public.sun_has_permission(p_workspace,'users.manage') then raise exception 'Administrator permission required'; end if; + return query + select m.user_id,u.email::text,m.display_name,m.role,m.is_active, + coalesce(m.permissions,public.sun_role_default_permissions(m.role)),m.created_at,m.updated_at + from public.sun_workspace_members m + join auth.users u on u.id=m.user_id + where m.workspace_id=p_workspace + order by (m.role='admin') desc,coalesce(m.display_name,u.email::text); +end; +$$; +revoke all on function public.sun_list_workspace_members(uuid) from public, anon; +grant execute on function public.sun_list_workspace_members(uuid) to authenticated; + +create or replace function public.sun_required_write_permission(p_key text) +returns text +language sql +immutable +set search_path = public +as $$ + select case + when p_key='sunBoxes' then 'catalog.edit' + when p_key in ('sunClientLoyaltyV1','sunClientCommunicationV1') then 'clients.edit' + when p_key='sunFinanceRecordsV2' then 'money.edit' + when p_key in ('sunStock','sunStockMoves') then 'stock.edit' + when p_key='sunEmployees' then 'team.edit' + when p_key='sunSuppliers' then 'suppliers.edit' + when p_key like 'sunRoute%' then 'routes.edit' + when p_key like 'sunMarketing%' then 'mailings.edit' + when p_key in ('sunPromoCodesV1') then 'mailings.edit' + when p_key in ('sunCatalogCategoriesV2','sunDefaultOrderQrV1','sunLeadSourcesV1','sunEventTypesV1','sunOrderPaymentColorsV1','sunReceiptSettingsV2','sunPrintSettingsV1','sunYandexMapsSettings','sunEnterpriseSettingsV1','sunOrderStatusFeatureEnabledV1') then 'settings.edit' + when p_key='sunOfficialCatalogVersion' then 'catalog.edit' + when p_key='sunAuditLogV1' then '_member' + else 'settings.edit' + end; +$$; + +create or replace function public.sun_save_app_state(p_workspace uuid,p_payload jsonb,p_client_id text) +returns table(workspace_id uuid,payload jsonb,revision bigint,updated_at timestamptz,client_id text) +language plpgsql +security definer +set search_path = public +as $$ +declare + v_old jsonb := '{"format":"sun-cloud-v2","version":2,"storage":{}}'::jsonb; + v_old_storage jsonb; + v_new_storage jsonb; + v_final_storage jsonb; + v_final_payload jsonb; + v_key text; + v_perm text; + v_old_orders jsonb; + v_new_orders jsonb; + v_create boolean; + v_delete boolean; + v_edit boolean; + v_saved public.sun_app_state%rowtype; +begin + if public.sun_member_role(p_workspace) is null then raise exception 'Access denied'; end if; + if p_payload is null or jsonb_typeof(p_payload) <> 'object' or jsonb_typeof(coalesce(p_payload->'storage','{}'::jsonb)) <> 'object' then raise exception 'Invalid payload'; end if; + select a.payload into v_old from public.sun_app_state a where a.workspace_id=p_workspace; + if v_old is null then v_old := '{"format":"sun-cloud-v2","version":2,"storage":{}}'::jsonb; end if; + v_old_storage := coalesce(v_old->'storage','{}'::jsonb); + v_new_storage := coalesce(p_payload->'storage','{}'::jsonb); + v_final_storage := v_old_storage; + for v_key in select key from (select jsonb_object_keys(v_old_storage) key union select jsonb_object_keys(v_new_storage) key) q loop + if not public.sun_can_read_storage_key(p_workspace,v_key) and not (v_new_storage ? v_key) then continue; end if; + if coalesce(v_old_storage->v_key,'null'::jsonb) = coalesce(v_new_storage->v_key,'null'::jsonb) then continue; end if; + if v_key='sunOrders' then + v_old_orders := coalesce(v_old_storage #> array['sunOrders','v'],'[]'::jsonb); + v_new_orders := coalesce(v_new_storage #> array['sunOrders','v'],'[]'::jsonb); + if jsonb_typeof(v_old_orders)<>'array' or jsonb_typeof(v_new_orders)<>'array' then raise exception 'Invalid orders payload'; end if; + select exists(select 1 from jsonb_array_elements(v_new_orders) n where not exists(select 1 from jsonb_array_elements(v_old_orders) o where o->>'id'=n->>'id')) into v_create; + select exists(select 1 from jsonb_array_elements(v_old_orders) o where not exists(select 1 from jsonb_array_elements(v_new_orders) n where n->>'id'=o->>'id')) into v_delete; + select exists(select 1 from jsonb_array_elements(v_new_orders) n join lateral (select o from jsonb_array_elements(v_old_orders) o where o->>'id'=n->>'id' limit 1) x on true where x.o<>n) into v_edit; + if v_create and not public.sun_has_permission(p_workspace,'orders.create') then raise exception 'Нет права создавать заказы'; end if; + if v_edit and not public.sun_has_permission(p_workspace,'orders.edit') then raise exception 'Нет права изменять заказы'; end if; + if v_delete and not public.sun_has_permission(p_workspace,'orders.delete') then raise exception 'Нет права удалять заказы'; end if; + else + v_perm := public.sun_required_write_permission(v_key); + if v_perm='_member' then null; + elsif not public.sun_has_permission(p_workspace,v_perm) then raise exception 'Нет права изменять раздел: %',v_key; + end if; + end if; + if v_new_storage ? v_key then v_final_storage := v_final_storage || jsonb_build_object(v_key,v_new_storage->v_key); + else v_final_storage := v_final_storage - v_key; + end if; + end loop; + v_final_payload := jsonb_build_object('format',coalesce(p_payload->'format',v_old->'format','"sun-cloud-v2"'::jsonb),'version',coalesce(p_payload->'version',v_old->'version','2'::jsonb),'storage',v_final_storage); + insert into public.sun_app_state as app_state(workspace_id,payload,client_id) + values (p_workspace,v_final_payload,p_client_id) + on conflict on constraint sun_app_state_pkey do update set payload=excluded.payload,client_id=excluded.client_id + returning app_state.* into v_saved; + insert into public.sun_sync_events(workspace_id,revision,client_id) values (p_workspace,v_saved.revision,p_client_id); + return query select * from public.sun_fetch_app_state(p_workspace); +end; +$$; +revoke all on function public.sun_save_app_state(uuid,jsonb,text) from public, anon; +grant execute on function public.sun_save_app_state(uuid,jsonb,text) to authenticated; diff --git a/ops/sql/SUPABASE-REGISTRATION-USERS-V27.sql b/ops/sql/SUPABASE-REGISTRATION-USERS-V27.sql new file mode 100644 index 0000000..4c60ddc --- /dev/null +++ b/ops/sql/SUPABASE-REGISTRATION-USERS-V27.sql @@ -0,0 +1,245 @@ +-- Caterium v17.5.27: clear registration + email-bound employee invitations + +alter table public.sun_workspace_invites add column if not exists email text; +alter table public.sun_workspace_invites add column if not exists display_name text; + +create index if not exists sun_workspace_invites_pending_email_v27_idx + on public.sun_workspace_invites(workspace_id, lower(email)) + where used_at is null; + +-- Public Caterium signups and invite signups do not require clicking an email-confirmation link. +-- This keeps the existing Auth signup endpoint/rate limits while marking these app-originated users verified. +create or replace function public.caterium_autoconfirm_signup_v25() +returns trigger +language plpgsql +security definer +set search_path = 'auth','public' +as $$ +begin + if coalesce(new.raw_user_meta_data->>'registration_source','') in ('caterium_public_signup','caterium_invite_signup') then + new.email_confirmed_at := coalesce(new.email_confirmed_at, now()); + new.raw_user_meta_data := jsonb_set(coalesce(new.raw_user_meta_data,'{}'::jsonb),'{email_verified}','true'::jsonb,true); + end if; + return new; +end; +$$; + +create or replace function public.caterium_autoverify_identity_v25() +returns trigger +language plpgsql +security definer +set search_path = 'auth','public' +as $$ +begin + if new.provider='email' and exists ( + select 1 from auth.users u + where u.id=new.user_id + and coalesce(u.raw_user_meta_data->>'registration_source','') in ('caterium_public_signup','caterium_invite_signup') + ) then + new.identity_data := jsonb_set(coalesce(new.identity_data,'{}'::jsonb),'{email_verified}','true'::jsonb,true); + end if; + return new; +end; +$$; + +create or replace function public.sun_create_invite_v27( + p_workspace uuid, + p_email text, + p_display_name text default '', + p_role text default 'manager' +) +returns uuid +language plpgsql +security definer +set search_path='public','auth' +as $$ +declare + v_token uuid; + v_role text := lower(coalesce(p_role,'manager')); + v_email text := lower(trim(coalesce(p_email,''))); + v_name text := trim(coalesce(p_display_name,'')); + v_max integer; + v_active integer; + v_pending integer; +begin + 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 not public.sun_has_permission(p_workspace,'users.manage') then raise exception 'Недостаточно прав для добавления пользователей'; end if; + if v_role not in ('admin','manager','kitchen','courier','viewer') then raise exception 'Некорректная роль'; end if; + if v_email='' or v_email !~* '^[^[:space:]@]+@[^[:space:]@]+\.[^[:space:]@]+$' then raise exception 'Введите корректный email сотрудника'; end if; + if v_name='' then v_name:=split_part(v_email,'@',1); end if; + + if exists( + select 1 + from public.sun_workspace_members m + join auth.users u on u.id=m.user_id + where m.workspace_id=p_workspace and m.is_active=true and lower(coalesce(u.email,''))=v_email + ) then + raise exception 'Пользователь с этим email уже добавлен в компанию'; + end if; + + -- Remove stale invites and replace the previous pending invite for the same email. + delete from public.sun_workspace_invites + where workspace_id=p_workspace and used_at is null + and (expires_at<=now() or lower(coalesce(email,''))=v_email); + + 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; + + if v_max is not null then + select count(*)::int into v_active + from public.sun_workspace_members + where workspace_id=p_workspace and is_active=true; + select count(*)::int into v_pending + from public.sun_workspace_invites + where workspace_id=p_workspace and used_at is null and expires_at>now(); + if v_active+v_pending>=v_max then + raise exception 'Достигнут лимит пользователей тарифа (%)',v_max; + end if; + end if; + + insert into public.sun_workspace_invites(workspace_id,role,permissions,created_by,email,display_name) + values(p_workspace,v_role,public.sun_role_default_permissions(v_role),auth.uid(),v_email,left(v_name,120)) + returning token into v_token; + return v_token; +end; +$$; + +create or replace function public.sun_list_workspace_invites_v27(p_workspace uuid) +returns table( + token uuid, + email text, + display_name text, + role text, + created_at timestamptz, + expires_at timestamptz, + status text +) +language plpgsql +stable +security definer +set search_path='public' +as $$ +begin + if not public.sun_has_permission(p_workspace,'users.manage') then raise exception 'Недостаточно прав для просмотра приглашений'; end if; + return query + select i.token,i.email,i.display_name,i.role,i.created_at,i.expires_at, + case when i.expires_at<=now() then 'expired' else 'pending' end::text + from public.sun_workspace_invites i + where i.workspace_id=p_workspace and i.used_at is null + order by i.created_at desc; +end; +$$; + +create or replace function public.sun_cancel_invite_v27(p_workspace uuid,p_token uuid) +returns boolean +language plpgsql +security definer +set search_path='public' +as $$ +declare v_deleted integer; +begin + if not public.sun_has_permission(p_workspace,'users.manage') then raise exception 'Недостаточно прав для отмены приглашения'; end if; + delete from public.sun_workspace_invites + where workspace_id=p_workspace and token=p_token and used_at is null; + get diagnostics v_deleted = row_count; + return v_deleted>0; +end; +$$; + +-- Token is the secret. This preview intentionally reveals only the invited company/name/email/role. +create or replace function public.sun_invite_preview_v27(p_token uuid) +returns table( + workspace_id uuid, + workspace_name text, + email text, + display_name text, + role text, + expires_at timestamptz, + is_valid boolean +) +language sql +stable +security definer +set search_path='public' +as $$ + select w.id,w.name,i.email,i.display_name,i.role,i.expires_at, + (i.used_at is null and i.expires_at>now()) as is_valid + from public.sun_workspace_invites i + join public.sun_workspaces w on w.id=i.workspace_id + where i.token=p_token + limit 1 +$$; + +-- Keep the existing RPC name for backward compatibility, but make new email-bound invites safe. +create or replace function public.sun_accept_invite(p_token uuid) +returns uuid +language plpgsql +security definer +set search_path='public','auth' +as $$ +declare + v_user uuid := auth.uid(); + v_invite public.sun_workspace_invites%rowtype; + v_display text; + v_user_email text; + v_max integer; + v_count integer; +begin + if v_user is null then raise exception 'Сначала войдите в Caterium'; end if; + select * into v_invite from public.sun_workspace_invites where token=p_token for update; + if not found then raise exception 'Приглашение не найдено'; end if; + if v_invite.used_at is not null then raise exception 'Приглашение уже использовано'; end if; + if v_invite.expires_at < now() then raise exception 'Срок действия приглашения истёк'; end if; + + select lower(coalesce(email,'')), + coalesce(nullif(v_invite.display_name,''),nullif(raw_user_meta_data->>'name',''),split_part(coalesce(email,'Сотрудник'),'@',1)) + into v_user_email,v_display + from auth.users where id=v_user; + + if nullif(lower(trim(coalesce(v_invite.email,''))),'') is not null + and lower(trim(v_invite.email))<>v_user_email then + raise exception 'Это приглашение создано для другого email'; + end if; + + if public.sun_subscription_access_mode(v_invite.workspace_id)<>'full' then raise exception 'Подписка компании неактивна'; end if; + if not public.sun_workspace_has_feature(v_invite.workspace_id,'users_manage') then raise exception 'Добавление сотрудников недоступно на текущем тарифе'; 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=v_invite.workspace_id; + select count(*)::int into v_count + from public.sun_workspace_members + where workspace_id=v_invite.workspace_id and is_active=true; + if not exists(select 1 from public.sun_workspace_members where workspace_id=v_invite.workspace_id 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(v_invite.workspace_id,v_user,v_invite.role,v_display,true,coalesce(v_invite.permissions,public.sun_role_default_permissions(v_invite.role)),now()) + on conflict(workspace_id,user_id) do update + set role=excluded.role, + display_name=coalesce(nullif(public.sun_workspace_members.display_name,''),excluded.display_name), + is_active=true, + permissions=excluded.permissions, + updated_at=now(); + + update public.sun_workspace_invites set used_by=v_user,used_at=now() where token=p_token; + return v_invite.workspace_id; +end; +$$; + +revoke all on function public.sun_create_invite_v27(uuid,text,text,text) from public,anon; +revoke all on function public.sun_list_workspace_invites_v27(uuid) from public,anon; +revoke all on function public.sun_cancel_invite_v27(uuid,uuid) from public,anon; +revoke all on function public.sun_invite_preview_v27(uuid) from public; +revoke all on function public.sun_accept_invite(uuid) from public,anon; + +grant execute on function public.sun_create_invite_v27(uuid,text,text,text) to authenticated; +grant execute on function public.sun_list_workspace_invites_v27(uuid) to authenticated; +grant execute on function public.sun_cancel_invite_v27(uuid,uuid) to authenticated; +grant execute on function public.sun_invite_preview_v27(uuid) to anon,authenticated; +grant execute on function public.sun_accept_invite(uuid) to authenticated; diff --git a/ops/sql/SUPABASE-SAAS-V16-FINALIZE.sql b/ops/sql/SUPABASE-SAAS-V16-FINALIZE.sql new file mode 100644 index 0000000..0c70c3b --- /dev/null +++ b/ops/sql/SUPABASE-SAAS-V16-FINALIZE.sql @@ -0,0 +1,134 @@ +-- Sun Catering SaaS v16 finalization +-- Run after SUPABASE-SAAS-V16.sql on a fresh project. + +create or replace function public.sun_feature_for_read_storage_key(p_key text) +returns text +language sql +immutable +set search_path='public' +as $$ + select case + when p_key='sunOrders' then 'orders' + when p_key in ('sunBoxes','sunCatalogCategoriesV2','sunOfficialCatalogVersion') then 'catalog_view' + when p_key in ('sunClientLoyaltyV1','sunClientCommunicationV1') then 'clients' + when p_key='sunFinanceRecordsV2' then 'money' + when p_key in ('sunStock','sunStockMoves') then 'stock' + when p_key='sunEmployees' then 'team' + when p_key='sunSuppliers' then 'suppliers' + when p_key like 'sunRoute%' then 'routes' + when p_key like 'sunMarketing%' or p_key='sunPromoCodesV1' then 'mailings' + when p_key='sunBrandThemeV1' then 'branding' + when p_key='sunClientOfferSettingsV1' then 'client_offers' + when p_key='sunOfferTemplateV1' then 'offer_templates' + when p_key='sunAuditLogV1' then 'audit' + else 'settings' + end; +$$; + +create or replace function public.sun_fetch_app_state(p_workspace uuid) +returns table(workspace_id uuid, payload jsonb, revision bigint, updated_at timestamptz, client_id text) +language plpgsql +stable security definer +set search_path='public' +as $$ +declare + v_row public.sun_app_state%rowtype; + v_storage jsonb := '{}'::jsonb; + kv record; + v_feature text; +begin + if public.sun_member_role(p_workspace) is null then raise exception 'Access denied'; end if; + if public.sun_subscription_access_mode(p_workspace)='blocked' then raise exception 'Подписка закончилась. Данные сохранены, продлите подписку для доступа.'; end if; + select * into v_row from public.sun_app_state where sun_app_state.workspace_id=p_workspace; + if not found then return; end if; + for kv in select key,value from jsonb_each(coalesce(v_row.payload->'storage','{}'::jsonb)) loop + v_feature := public.sun_feature_for_read_storage_key(kv.key); + if public.sun_can_read_storage_key(p_workspace,kv.key) + and (v_feature is null or public.sun_workspace_has_feature(p_workspace,v_feature)) then + v_storage := v_storage || jsonb_build_object(kv.key,kv.value); + end if; + end loop; + workspace_id := v_row.workspace_id; + payload := jsonb_build_object('format',coalesce(v_row.payload->'format','"sun-cloud-v2"'::jsonb),'version',coalesce(v_row.payload->'version','2'::jsonb),'storage',v_storage); + revision := v_row.revision; + updated_at := v_row.updated_at; + client_id := v_row.client_id; + return next; +end; +$$; + +create or replace function public.sun_admin_update_member(p_workspace uuid, p_user uuid, p_display_name text, p_role text, p_is_active boolean, p_permissions jsonb) +returns void +language plpgsql +security definer +set search_path='public' +as $$ +declare + v_old_role text; + v_old_active boolean; + v_admins int; + v_role text := lower(coalesce(p_role,'')); + v_max integer; + v_active_count integer; +begin + 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 not public.sun_has_permission(p_workspace,'users.manage') then raise exception 'Administrator permission required'; end if; + if v_role not in ('admin','manager','kitchen','courier','viewer') then raise exception 'Invalid role'; end if; + if p_permissions is null or jsonb_typeof(p_permissions) <> 'object' then raise exception 'Permissions must be an object'; end if; + select role,is_active into v_old_role,v_old_active from public.sun_workspace_members where workspace_id=p_workspace and user_id=p_user for update; + if not found then raise exception 'Member not found'; end if; + if v_old_role='admin' and coalesce(v_old_active,false) and (v_role<>'admin' or not coalesce(p_is_active,false)) then + select count(*) into v_admins from public.sun_workspace_members where workspace_id=p_workspace and role='admin' and is_active=true; + if v_admins <= 1 then raise exception 'Нельзя отключить или понизить последнего администратора'; end if; + end if; + if coalesce(p_is_active,false) and not coalesce(v_old_active,false) then + 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_active_count from public.sun_workspace_members where workspace_id=p_workspace and is_active=true; + if v_max is not null and v_active_count>=v_max then raise exception 'Достигнут лимит сотрудников тарифа (%).',v_max; end if; + end if; + update public.sun_workspace_members set display_name=nullif(trim(coalesce(p_display_name,'')),''),role=v_role,is_active=coalesce(p_is_active,false),permissions=p_permissions,updated_at=now() + where workspace_id=p_workspace and user_id=p_user; +end; +$$; + +create or replace function public.sun_admin_remove_member(p_workspace uuid, p_user uuid) +returns void +language plpgsql +security definer +set search_path='public' +as $$ +declare + v_role text; + v_active boolean; + v_admins int; +begin + 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 not public.sun_has_permission(p_workspace,'users.manage') then raise exception 'Administrator permission required'; end if; + select role,is_active into v_role,v_active from public.sun_workspace_members where workspace_id=p_workspace and user_id=p_user for update; + if not found then return; end if; + if v_role='admin' and coalesce(v_active,false) then + select count(*) into v_admins from public.sun_workspace_members where workspace_id=p_workspace and role='admin' and is_active=true; + if v_admins <= 1 then raise exception 'Нельзя удалить последнего администратора'; end if; + end if; + delete from public.sun_workspace_members where workspace_id=p_workspace and user_id=p_user; +end; +$$; + +-- New SaaS helper RPCs are authenticated-only. +revoke execute on function public.sun_is_platform_admin() from public, anon; +revoke execute on function public.sun_subscription_access_mode(uuid) from public, anon; +revoke execute on function public.sun_workspace_has_feature(uuid,text) from public, anon; +revoke execute on function public.sun_subscription_snapshot(uuid) from public, anon; +revoke execute on function public.sun_platform_list_workspaces() from public, anon; +revoke execute on function public.sun_platform_set_subscription(uuid,text,integer,text) from public, anon; +revoke execute on function public.sun_platform_set_feature_override(uuid,text,boolean,integer,text) from public, anon; + +grant execute on function public.sun_is_platform_admin() to authenticated; +grant execute on function public.sun_subscription_access_mode(uuid) to authenticated; +grant execute on function public.sun_workspace_has_feature(uuid,text) to authenticated; +grant execute on function public.sun_subscription_snapshot(uuid) to authenticated; +grant execute on function public.sun_platform_list_workspaces() to authenticated; +grant execute on function public.sun_platform_set_subscription(uuid,text,integer,text) to authenticated; +grant execute on function public.sun_platform_set_feature_override(uuid,text,boolean,integer,text) to authenticated; diff --git a/ops/sql/SUPABASE-SAAS-V16.sql b/ops/sql/SUPABASE-SAAS-V16.sql new file mode 100644 index 0000000..062b9d7 --- /dev/null +++ b/ops/sql/SUPABASE-SAAS-V16.sql @@ -0,0 +1,475 @@ +-- Sun Catering SaaS foundation v16 +-- Additive subscription/entitlement layer. Existing workspace remains active on Full. + +create table if not exists public.sun_plans ( + id text primary key, + name text not null, + description text not null default '', + max_members integer null check (max_members is null or max_members >= 1), + sort_order integer not null default 0, + is_active boolean not null default true, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create table if not exists public.sun_plan_features ( + plan_id text not null references public.sun_plans(id) on delete cascade, + feature_key text not null, + enabled boolean not null default false, + primary key (plan_id, feature_key) +); + +create table if not exists public.sun_workspace_subscriptions ( + workspace_id uuid primary key references public.sun_workspaces(id) on delete cascade, + plan_id text not null references public.sun_plans(id), + status text not null default 'trialing' check (status in ('trialing','active','past_due','canceled','expired')), + trial_started_at timestamptz null, + trial_ends_at timestamptz null, + current_period_start timestamptz null, + current_period_end timestamptz null, + grace_until timestamptz null, + cancel_at_period_end boolean not null default false, + source text not null default 'manual', + external_customer_id text null, + external_subscription_id text null, + note text null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create table if not exists public.sun_workspace_feature_overrides ( + workspace_id uuid not null references public.sun_workspaces(id) on delete cascade, + feature_key text not null, + enabled boolean not null, + expires_at timestamptz null, + note text null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + primary key (workspace_id, feature_key) +); + +create table if not exists public.sun_platform_admins ( + user_id uuid primary key references auth.users(id) on delete cascade, + created_at timestamptz not null default now() +); + +insert into public.sun_plans(id,name,description,max_members,sort_order,is_active) values +('basic','Базовый','Заказы, календарь, клиенты, просмотр каталога и базовая статистика.',1,10,true), +('professional','Профессиональный','Основная работа команды, финансы, склад, поставщики, маршруты, рассылки и фирменное оформление.',3,20,true), +('full','Полный','Все функции, включая редактирование боксов и предложения клиентам.',null,30,true) +on conflict (id) do update set name=excluded.name,description=excluded.description,max_members=excluded.max_members,sort_order=excluded.sort_order,is_active=excluded.is_active,updated_at=now(); + +-- Reset only built-in feature matrix for the three standard plans. +delete from public.sun_plan_features where plan_id in ('basic','professional','full'); + +insert into public.sun_plan_features(plan_id,feature_key,enabled) values +-- Basic +('basic','orders',true),('basic','calendar',true),('basic','map',true),('basic','clients',true),('basic','catalog_view',true), +('basic','catalog_edit',false),('basic','production',false),('basic','shopping',false),('basic','stock',false),('basic','routes',false), +('basic','mailings',false),('basic','money',false),('basic','stats_basic',true),('basic','stats_advanced',false),('basic','team',false), +('basic','suppliers',false),('basic','print',true),('basic','settings',true),('basic','branding',false),('basic','client_offers',false), +('basic','offer_templates',false),('basic','backups',false),('basic','audit',false),('basic','users_manage',false), +-- Professional +('professional','orders',true),('professional','calendar',true),('professional','map',true),('professional','clients',true),('professional','catalog_view',true), +('professional','catalog_edit',false),('professional','production',true),('professional','shopping',true),('professional','stock',true),('professional','routes',true), +('professional','mailings',true),('professional','money',true),('professional','stats_basic',true),('professional','stats_advanced',true),('professional','team',true), +('professional','suppliers',true),('professional','print',true),('professional','settings',true),('professional','branding',true),('professional','client_offers',false), +('professional','offer_templates',false),('professional','backups',true),('professional','audit',false),('professional','users_manage',true), +-- Full +('full','orders',true),('full','calendar',true),('full','map',true),('full','clients',true),('full','catalog_view',true), +('full','catalog_edit',true),('full','production',true),('full','shopping',true),('full','stock',true),('full','routes',true), +('full','mailings',true),('full','money',true),('full','stats_basic',true),('full','stats_advanced',true),('full','team',true), +('full','suppliers',true),('full','print',true),('full','settings',true),('full','branding',true),('full','client_offers',true), +('full','offer_templates',true),('full','backups',true),('full','audit',true),('full','users_manage',true); + +-- The creator of the oldest/current production workspace is the initial SaaS platform owner. +insert into public.sun_platform_admins(user_id) +select created_by from public.sun_workspaces where created_by is not null order by created_at asc limit 1 +on conflict (user_id) do nothing; + +-- Existing workspaces are never disrupted by this migration: they receive Full active access. +insert into public.sun_workspace_subscriptions(workspace_id,plan_id,status,current_period_start,current_period_end,grace_until,source,note) +select id,'full','active',now(),timestamptz '2099-12-31 23:59:59+00',timestamptz '2100-01-07 23:59:59+00','migration','Existing workspace preserved during SaaS migration' +from public.sun_workspaces +on conflict (workspace_id) do nothing; + +alter table public.sun_plans enable row level security; +alter table public.sun_plan_features enable row level security; +alter table public.sun_workspace_subscriptions enable row level security; +alter table public.sun_workspace_feature_overrides enable row level security; +alter table public.sun_platform_admins enable row level security; + +-- Recreate policies idempotently. +drop policy if exists sun_plans_read on public.sun_plans; +create policy sun_plans_read on public.sun_plans for select to authenticated using (is_active = true); + +drop policy if exists sun_plan_features_read on public.sun_plan_features; +create policy sun_plan_features_read on public.sun_plan_features for select to authenticated using (true); + +drop policy if exists sun_workspace_subscriptions_member_read on public.sun_workspace_subscriptions; +create policy sun_workspace_subscriptions_member_read on public.sun_workspace_subscriptions for select to authenticated +using (exists(select 1 from public.sun_workspace_members m where m.workspace_id=sun_workspace_subscriptions.workspace_id and m.user_id=auth.uid() and m.is_active=true)); + +drop policy if exists sun_workspace_overrides_member_read on public.sun_workspace_feature_overrides; +create policy sun_workspace_overrides_member_read on public.sun_workspace_feature_overrides for select to authenticated +using (exists(select 1 from public.sun_workspace_members m where m.workspace_id=sun_workspace_feature_overrides.workspace_id and m.user_id=auth.uid() and m.is_active=true)); + +-- No direct client access to platform-admin rows. Use checked RPCs only. +revoke all on public.sun_platform_admins from anon, authenticated; + +create or replace function public.sun_is_platform_admin() +returns boolean +language sql +stable +security definer +set search_path='public' +as $$ + select auth.uid() is not null and exists(select 1 from public.sun_platform_admins a where a.user_id=auth.uid()); +$$; + +create or replace function public.sun_subscription_access_mode(p_workspace uuid) +returns text +language plpgsql +stable +security definer +set search_path='public' +as $$ +declare + s public.sun_workspace_subscriptions%rowtype; + v_end timestamptz; + v_grace timestamptz; +begin + if public.sun_member_role(p_workspace) is null and not public.sun_is_platform_admin() then return 'blocked'; end if; + select * into s from public.sun_workspace_subscriptions where workspace_id=p_workspace; + if not found then return 'blocked'; end if; + + if s.status='trialing' then + v_end:=s.trial_ends_at; + else + v_end:=s.current_period_end; + end if; + v_grace:=coalesce(s.grace_until, case when v_end is not null then v_end + interval '7 days' else null end); + + if s.status in ('trialing','active','canceled') and (v_end is null or now() <= v_end) then return 'full'; end if; + if v_grace is not null and now() <= v_grace then return 'read_only'; end if; + return 'blocked'; +end; +$$; + +create or replace function public.sun_workspace_has_feature(p_workspace uuid,p_feature text) +returns boolean +language plpgsql +stable +security definer +set search_path='public' +as $$ +declare + v_plan text; + v_value boolean := false; + v_override boolean; +begin + if public.sun_member_role(p_workspace) is null and not public.sun_is_platform_admin() then return false; end if; + select plan_id into v_plan from public.sun_workspace_subscriptions where workspace_id=p_workspace; + if v_plan is null then return false; end if; + select enabled into v_value from public.sun_plan_features where plan_id=v_plan and feature_key=p_feature; + select enabled into v_override from public.sun_workspace_feature_overrides + where workspace_id=p_workspace and feature_key=p_feature and (expires_at is null or expires_at>now()); + if found then return coalesce(v_override,false); end if; + return coalesce(v_value,false); +end; +$$; + +create or replace function public.sun_subscription_snapshot(p_workspace uuid) +returns table( + workspace_id uuid, + plan_id text, + plan_name text, + status text, + access_mode text, + trial_ends_at timestamptz, + current_period_end timestamptz, + grace_until timestamptz, + max_members integer, + member_count integer, + features jsonb, + platform_admin boolean +) +language plpgsql +stable +security definer +set search_path='public' +as $$ +declare + s public.sun_workspace_subscriptions%rowtype; + p public.sun_plans%rowtype; + f jsonb := '{}'::jsonb; + o jsonb := '{}'::jsonb; +begin + if public.sun_member_role(p_workspace) is null and not public.sun_is_platform_admin() then raise exception 'Access denied'; end if; + select * into s from public.sun_workspace_subscriptions where sun_workspace_subscriptions.workspace_id=p_workspace; + if not found then raise exception 'Subscription not found'; end if; + select * into p from public.sun_plans where id=s.plan_id; + select coalesce(jsonb_object_agg(feature_key,enabled),'{}'::jsonb) into f from public.sun_plan_features where sun_plan_features.plan_id=s.plan_id; + select coalesce(jsonb_object_agg(feature_key,enabled),'{}'::jsonb) into o from public.sun_workspace_feature_overrides + where sun_workspace_feature_overrides.workspace_id=p_workspace and (expires_at is null or expires_at>now()); + workspace_id:=p_workspace; plan_id:=s.plan_id; plan_name:=p.name; status:=s.status; access_mode:=public.sun_subscription_access_mode(p_workspace); + trial_ends_at:=s.trial_ends_at; current_period_end:=s.current_period_end; grace_until:=coalesce(s.grace_until,coalesce(s.trial_ends_at,s.current_period_end)+interval '7 days'); + max_members:=p.max_members; + select count(*)::int into member_count from public.sun_workspace_members m where m.workspace_id=p_workspace and m.is_active=true; + features:=f||o; platform_admin:=public.sun_is_platform_admin(); + return next; +end; +$$; + +create or replace function public.sun_feature_for_storage_key(p_key text) +returns text +language sql +immutable +set search_path='public' +as $$ + select case + when p_key='sunOrders' then 'orders' + when p_key in ('sunBoxes','sunCatalogCategoriesV2','sunOfficialCatalogVersion') then 'catalog_edit' + when p_key in ('sunClientLoyaltyV1','sunClientCommunicationV1') then 'clients' + when p_key='sunFinanceRecordsV2' then 'money' + when p_key in ('sunStock','sunStockMoves') then 'stock' + when p_key='sunEmployees' then 'team' + when p_key='sunSuppliers' then 'suppliers' + when p_key like 'sunRoute%' then 'routes' + when p_key like 'sunMarketing%' or p_key='sunPromoCodesV1' then 'mailings' + when p_key='sunBrandThemeV1' then 'branding' + when p_key='sunClientOfferSettingsV1' then 'client_offers' + when p_key='sunOfferTemplateV1' then 'offer_templates' + else 'settings' + end; +$$; + +-- New workspaces automatically receive a 14-day Full trial. +create or replace function public.sun_create_workspace(p_name text default 'Солнце Кейтеринг'::text) +returns uuid +language plpgsql +security definer +set search_path='public' +as $$ +declare + v_user uuid := auth.uid(); + v_workspace uuid; + v_name text; +begin + if v_user is null then raise exception 'Authentication required'; end if; + v_name := coalesce(nullif(trim(p_name),''),'Солнце Кейтеринг'); + 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((select email from auth.users where id=v_user),'Администратор'),'@',1)),true,public.sun_role_default_permissions('admin')); + insert into public.sun_app_state(workspace_id,payload,client_id) + values (v_workspace,'{"format":"sun-cloud-v2","version":2,"storage":{}}'::jsonb,'bootstrap') + on conflict (workspace_id) do nothing; + insert into public.sun_workspace_subscriptions(workspace_id,plan_id,status,trial_started_at,trial_ends_at,grace_until,source,note) + values(v_workspace,'full','trialing',now(),now()+interval '14 days',now()+interval '21 days','trial','14-day Full trial') + on conflict(workspace_id) do nothing; + return v_workspace; +end; +$$; + +-- Workspace member limits are enforced server-side. +create or replace function public.sun_create_invite(p_workspace uuid, p_role text default 'manager'::text) +returns uuid +language plpgsql +security definer +set search_path='public' +as $$ +declare + v_token uuid; + v_role text := lower(coalesce(p_role,'manager')); + v_max integer; + v_count integer; +begin + 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 not public.sun_has_permission(p_workspace,'users.manage') then raise exception 'Administrator permission required'; end if; + if v_role not in ('admin','manager','kitchen','courier','viewer') then raise exception 'Invalid role'; 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 v_max is not null and v_count>=v_max then raise exception 'Достигнут лимит сотрудников тарифа (%).',v_max; end if; + insert into public.sun_workspace_invites(workspace_id,role,permissions,created_by) + values (p_workspace,v_role,public.sun_role_default_permissions(v_role),auth.uid()) returning token into v_token; + return v_token; +end; +$$; + +create or replace function public.sun_accept_invite(p_token uuid) +returns uuid +language plpgsql +security definer +set search_path='public' +as $$ +declare + v_user uuid := auth.uid(); + v_invite public.sun_workspace_invites%rowtype; + v_display text; + v_max integer; + v_count integer; +begin + if v_user is null then raise exception 'Authentication required'; end if; + select * into v_invite from public.sun_workspace_invites where token=p_token for update; + if not found then raise exception 'Invite not found'; end if; + if v_invite.used_at is not null then raise exception 'Invite already used'; end if; + if v_invite.expires_at < now() then raise exception 'Invite expired'; end if; + if public.sun_subscription_access_mode(v_invite.workspace_id)<>'full' then raise exception 'Подписка компании неактивна'; end if; + if not public.sun_workspace_has_feature(v_invite.workspace_id,'users_manage') then raise exception 'Сотрудники недоступны на текущем тарифе'; 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=v_invite.workspace_id; + select count(*)::int into v_count from public.sun_workspace_members where workspace_id=v_invite.workspace_id and is_active=true; + if not exists(select 1 from public.sun_workspace_members where workspace_id=v_invite.workspace_id 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; + select coalesce(nullif(raw_user_meta_data->>'name',''),split_part(coalesce(email,'Сотрудник'),'@',1)) into v_display from auth.users where id=v_user; + insert into public.sun_workspace_members(workspace_id,user_id,role,display_name,is_active,permissions,updated_at) + values (v_invite.workspace_id,v_user,v_invite.role,v_display,true,coalesce(v_invite.permissions,public.sun_role_default_permissions(v_invite.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(); + update public.sun_workspace_invites set used_by=v_user,used_at=now() where token=p_token; + return v_invite.workspace_id; +end; +$$; + +-- Subscription gating is enforced on the cloud save RPC, not only in the browser. +create or replace function public.sun_save_app_state(p_workspace uuid, p_payload jsonb, p_client_id text) +returns table(workspace_id uuid, payload jsonb, revision bigint, updated_at timestamptz, client_id text) +language plpgsql +security definer +set search_path='public' +as $$ +declare + v_old jsonb := '{"format":"sun-cloud-v2","version":2,"storage":{}}'::jsonb; + v_old_storage jsonb; + v_new_storage jsonb; + v_final_storage jsonb; + v_final_payload jsonb; + v_key text; + v_perm text; + v_feature text; + v_old_orders jsonb; + v_new_orders jsonb; + v_create boolean; + v_delete boolean; + v_edit boolean; + v_saved public.sun_app_state%rowtype; +begin + if public.sun_member_role(p_workspace) is null then raise exception 'Access denied'; end if; + if public.sun_subscription_access_mode(p_workspace)<>'full' then raise exception 'Подписка истекла: база доступна только для просмотра'; end if; + if p_payload is null or jsonb_typeof(p_payload) <> 'object' or jsonb_typeof(coalesce(p_payload->'storage','{}'::jsonb)) <> 'object' then raise exception 'Invalid payload'; end if; + + select a.payload into v_old from public.sun_app_state a where a.workspace_id=p_workspace; + if v_old is null then v_old := '{"format":"sun-cloud-v2","version":2,"storage":{}}'::jsonb; end if; + v_old_storage := coalesce(v_old->'storage','{}'::jsonb); + v_new_storage := coalesce(p_payload->'storage','{}'::jsonb); + v_final_storage := v_old_storage; + + for v_key in select key from (select jsonb_object_keys(v_old_storage) key union select jsonb_object_keys(v_new_storage) key) q loop + if not public.sun_can_read_storage_key(p_workspace,v_key) and not (v_new_storage ? v_key) then continue; end if; + if coalesce(v_old_storage->v_key,'null'::jsonb) = coalesce(v_new_storage->v_key,'null'::jsonb) then continue; end if; + + if v_key='sunOrders' then + if not public.sun_workspace_has_feature(p_workspace,'orders') then raise exception 'Заказы недоступны на текущем тарифе'; end if; + v_old_orders := coalesce(v_old_storage #> array['sunOrders','v'],'[]'::jsonb); + v_new_orders := coalesce(v_new_storage #> array['sunOrders','v'],'[]'::jsonb); + if jsonb_typeof(v_old_orders)<>'array' or jsonb_typeof(v_new_orders)<>'array' then raise exception 'Invalid orders payload'; end if; + select exists(select 1 from jsonb_array_elements(v_new_orders) n where not exists(select 1 from jsonb_array_elements(v_old_orders) o where o->>'id'=n->>'id')) into v_create; + select exists(select 1 from jsonb_array_elements(v_old_orders) o where not exists(select 1 from jsonb_array_elements(v_new_orders) n where n->>'id'=o->>'id')) into v_delete; + select exists(select 1 from jsonb_array_elements(v_new_orders) n join lateral (select o from jsonb_array_elements(v_old_orders) o where o->>'id'=n->>'id' limit 1) x on true where x.o<>n) into v_edit; + if v_create and not public.sun_has_permission(p_workspace,'orders.create') then raise exception 'Нет права создавать заказы'; end if; + if v_edit and not public.sun_has_permission(p_workspace,'orders.edit') then raise exception 'Нет права изменять заказы'; end if; + if v_delete and not public.sun_has_permission(p_workspace,'orders.delete') then raise exception 'Нет права удалять заказы'; end if; + else + v_feature := public.sun_feature_for_storage_key(v_key); + if v_feature is not null and not public.sun_workspace_has_feature(p_workspace,v_feature) then raise exception 'Функция недоступна на текущем тарифе: %',v_feature; end if; + v_perm := public.sun_required_write_permission(v_key); + if v_perm='_member' then null; + elsif not public.sun_has_permission(p_workspace,v_perm) then raise exception 'Нет права изменять раздел: %',v_key; + end if; + end if; + + if v_new_storage ? v_key then v_final_storage := v_final_storage || jsonb_build_object(v_key,v_new_storage->v_key); + else v_final_storage := v_final_storage - v_key; end if; + end loop; + + v_final_payload := jsonb_build_object('format',coalesce(p_payload->'format',v_old->'format','"sun-cloud-v2"'::jsonb),'version',coalesce(p_payload->'version',v_old->'version','2'::jsonb),'storage',v_final_storage); + insert into public.sun_app_state as app_state(workspace_id,payload,client_id) + values (p_workspace,v_final_payload,p_client_id) + on conflict on constraint sun_app_state_pkey do update set payload=excluded.payload,client_id=excluded.client_id + returning app_state.* into v_saved; + insert into public.sun_sync_events(workspace_id,revision,client_id) values (p_workspace,v_saved.revision,p_client_id); + return query select * from public.sun_fetch_app_state(p_workspace); +end; +$$; + +-- Platform owner APIs. Direct table access remains blocked for normal users. +create or replace function public.sun_platform_list_workspaces() +returns table(workspace_id uuid,workspace_name text,created_at timestamptz,plan_id text,plan_name text,status text,access_mode text,trial_ends_at timestamptz,current_period_end timestamptz,grace_until timestamptz,member_count integer,max_members integer) +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 w.id,w.name,w.created_at,s.plan_id,p.name,s.status,public.sun_subscription_access_mode(w.id),s.trial_ends_at,s.current_period_end, + coalesce(s.grace_until,coalesce(s.trial_ends_at,s.current_period_end)+interval '7 days'), + (select count(*)::int from public.sun_workspace_members m where m.workspace_id=w.id and m.is_active=true),p.max_members + from public.sun_workspaces w + left join public.sun_workspace_subscriptions s on s.workspace_id=w.id + left join public.sun_plans p on p.id=s.plan_id + order by w.created_at desc; +end; +$$; + +create or replace function public.sun_platform_set_subscription(p_workspace uuid,p_plan text,p_days integer default 30,p_status text default 'active') +returns void +language plpgsql +security definer +set search_path='public' +as $$ +declare + v_status text:=lower(coalesce(p_status,'active')); +begin + if not public.sun_is_platform_admin() then raise exception 'Platform administrator required'; end if; + if not exists(select 1 from public.sun_plans where id=p_plan and is_active=true) then raise exception 'Unknown plan'; end if; + if v_status not in ('trialing','active','past_due','canceled','expired') then raise exception 'Invalid status'; end if; + if p_days is null or p_days<0 or p_days>3650 then raise exception 'Invalid duration'; end if; + insert into public.sun_workspace_subscriptions(workspace_id,plan_id,status,current_period_start,current_period_end,grace_until,source,updated_at) + values(p_workspace,p_plan,v_status,now(),case when v_status='expired' then now() else now()+make_interval(days=>p_days) end, + case when v_status='expired' then now() else now()+make_interval(days=>p_days+7) end,'manual',now()) + on conflict(workspace_id) do update set plan_id=excluded.plan_id,status=excluded.status,current_period_start=excluded.current_period_start,current_period_end=excluded.current_period_end,grace_until=excluded.grace_until,source='manual',updated_at=now(); +end; +$$; + +create or replace function public.sun_platform_set_feature_override(p_workspace uuid,p_feature text,p_enabled boolean,p_days integer default null,p_note text default null) +returns void +language plpgsql +security definer +set search_path='public' +as $$ +begin + if not public.sun_is_platform_admin() then raise exception 'Platform administrator required'; end if; + if not exists(select 1 from public.sun_plan_features where feature_key=p_feature) then raise exception 'Unknown feature'; end if; + insert into public.sun_workspace_feature_overrides(workspace_id,feature_key,enabled,expires_at,note,updated_at) + values(p_workspace,p_feature,p_enabled,case when p_days is null then null else now()+make_interval(days=>p_days) end,p_note,now()) + on conflict(workspace_id,feature_key) do update set enabled=excluded.enabled,expires_at=excluded.expires_at,note=excluded.note,updated_at=now(); +end; +$$; + +revoke all on function public.sun_is_platform_admin() from public; +revoke all on function public.sun_subscription_access_mode(uuid) from public; +revoke all on function public.sun_workspace_has_feature(uuid,text) from public; +revoke all on function public.sun_subscription_snapshot(uuid) from public; +revoke all on function public.sun_platform_list_workspaces() from public; +revoke all on function public.sun_platform_set_subscription(uuid,text,integer,text) from public; +revoke all on function public.sun_platform_set_feature_override(uuid,text,boolean,integer,text) from public; + +grant execute on function public.sun_is_platform_admin() to authenticated; +grant execute on function public.sun_subscription_access_mode(uuid) to authenticated; +grant execute on function public.sun_workspace_has_feature(uuid,text) to authenticated; +grant execute on function public.sun_subscription_snapshot(uuid) to authenticated; +grant execute on function public.sun_platform_list_workspaces() to authenticated; +grant execute on function public.sun_platform_set_subscription(uuid,text,integer,text) to authenticated; +grant execute on function public.sun_platform_set_feature_override(uuid,text,boolean,integer,text) to authenticated; diff --git a/ops/sql/SUPABASE-SETUP.sql b/ops/sql/SUPABASE-SETUP.sql new file mode 100644 index 0000000..1b9cc33 --- /dev/null +++ b/ops/sql/SUPABASE-SETUP.sql @@ -0,0 +1,296 @@ +-- Sun Catering Cloud v2 +-- Run this entire file once in Supabase -> SQL Editor. +-- Safe to re-run: objects are created with IF NOT EXISTS where possible. + +create extension if not exists pgcrypto; + +create table if not exists public.sun_workspaces ( + id uuid primary key default gen_random_uuid(), + name text not null check (char_length(trim(name)) between 1 and 120), + created_by uuid not null references auth.users(id) on delete cascade, + created_at timestamptz not null default now() +); + +create table if not exists public.sun_workspace_members ( + workspace_id uuid not null references public.sun_workspaces(id) on delete cascade, + user_id uuid not null references auth.users(id) on delete cascade, + role text not null default 'manager' check (role in ('owner','manager','viewer')), + created_at timestamptz not null default now(), + primary key (workspace_id, user_id) +); + +create table if not exists public.sun_app_state ( + workspace_id uuid primary key references public.sun_workspaces(id) on delete cascade, + payload jsonb not null default '{"format":"sun-cloud-v2","version":2,"storage":{}}'::jsonb, + revision bigint not null default 0, + client_id text, + updated_by uuid references auth.users(id) on delete set null, + updated_at timestamptz not null default now() +); + +create table if not exists public.sun_workspace_invites ( + token uuid primary key default gen_random_uuid(), + workspace_id uuid not null references public.sun_workspaces(id) on delete cascade, + role text not null default 'manager' check (role in ('manager','viewer')), + created_by uuid not null references auth.users(id) on delete cascade, + created_at timestamptz not null default now(), + expires_at timestamptz not null default (now() + interval '7 days'), + used_by uuid references auth.users(id) on delete set null, + used_at timestamptz +); + +create index if not exists sun_workspace_members_user_idx on public.sun_workspace_members(user_id); +create index if not exists sun_workspace_invites_workspace_idx on public.sun_workspace_invites(workspace_id); + +create or replace function public.sun_member_role(p_workspace uuid) +returns text +language sql +stable +security definer +set search_path = public +as $$ + select m.role + from public.sun_workspace_members m + where m.workspace_id = p_workspace + and m.user_id = auth.uid() + limit 1; +$$; + +revoke all on function public.sun_member_role(uuid) from public; +grant execute on function public.sun_member_role(uuid) to authenticated; + +create or replace function public.sun_touch_app_state() +returns trigger +language plpgsql +security definer +set search_path = public +as $$ +begin + new.updated_at := now(); + if tg_op = 'UPDATE' then + new.revision := old.revision + 1; + end if; + new.updated_by := auth.uid(); + return new; +end; +$$; + +drop trigger if exists sun_app_state_touch on public.sun_app_state; +create trigger sun_app_state_touch +before insert or update on public.sun_app_state +for each row execute function public.sun_touch_app_state(); + +revoke all on function public.sun_touch_app_state() from public; +revoke all on function public.sun_touch_app_state() from anon; +revoke all on function public.sun_touch_app_state() from authenticated; + +create or replace function public.sun_create_workspace(p_name text default 'Солнце Кейтеринг') +returns uuid +language plpgsql +security definer +set search_path = public +as $$ +declare + v_user uuid := auth.uid(); + v_workspace uuid; +begin + if v_user is null then + raise exception 'Authentication required'; + end if; + + insert into public.sun_workspaces(name, created_by) + values (coalesce(nullif(trim(p_name),''),'Солнце Кейтеринг'), v_user) + returning id into v_workspace; + + insert into public.sun_workspace_members(workspace_id, user_id, role) + values (v_workspace, v_user, 'owner'); + + insert into public.sun_app_state(workspace_id, payload, client_id) + values (v_workspace, '{"format":"sun-cloud-v2","version":2,"storage":{}}'::jsonb, 'bootstrap') + on conflict (workspace_id) do nothing; + + return v_workspace; +end; +$$; + +revoke all on function public.sun_create_workspace(text) from public; +grant execute on function public.sun_create_workspace(text) to authenticated; + +create or replace function public.sun_create_invite(p_workspace uuid, p_role text default 'manager') +returns uuid +language plpgsql +security definer +set search_path = public +as $$ +declare + v_token uuid; + v_role text := lower(coalesce(p_role,'manager')); +begin + if public.sun_member_role(p_workspace) <> 'owner' then + raise exception 'Owner role required'; + end if; + if v_role not in ('manager','viewer') then + raise exception 'Invalid role'; + end if; + + insert into public.sun_workspace_invites(workspace_id, role, created_by) + values (p_workspace, v_role, auth.uid()) + returning token into v_token; + + return v_token; +end; +$$; + +revoke all on function public.sun_create_invite(uuid,text) from public; +grant execute on function public.sun_create_invite(uuid,text) to authenticated; + +create or replace function public.sun_accept_invite(p_token uuid) +returns uuid +language plpgsql +security definer +set search_path = public +as $$ +declare + v_user uuid := auth.uid(); + v_invite public.sun_workspace_invites%rowtype; +begin + if v_user is null then + raise exception 'Authentication required'; + end if; + + select * into v_invite + from public.sun_workspace_invites + where token = p_token + for update; + + if not found then raise exception 'Invite not found'; end if; + if v_invite.used_at is not null then raise exception 'Invite already used'; end if; + if v_invite.expires_at < now() then raise exception 'Invite expired'; end if; + + insert into public.sun_workspace_members(workspace_id, user_id, role) + values (v_invite.workspace_id, v_user, v_invite.role) + on conflict (workspace_id, user_id) + do update set role = excluded.role; + + update public.sun_workspace_invites + set used_by = v_user, used_at = now() + where token = p_token; + + return v_invite.workspace_id; +end; +$$; + +revoke all on function public.sun_accept_invite(uuid) from public; +grant execute on function public.sun_accept_invite(uuid) to authenticated; + +alter table public.sun_workspaces enable row level security; +alter table public.sun_workspace_members enable row level security; +alter table public.sun_app_state enable row level security; +alter table public.sun_workspace_invites enable row level security; + +-- Remove overly broad browser grants, then grant only what authenticated clients need. +revoke all on public.sun_workspaces from anon; +revoke all on public.sun_workspace_members from anon; +revoke all on public.sun_app_state from anon; +revoke all on public.sun_workspace_invites from anon; + +grant select on public.sun_workspaces to authenticated; +grant select on public.sun_workspace_members to authenticated; +grant select, insert, update on public.sun_app_state to authenticated; +grant select on public.sun_workspace_invites to authenticated; + +-- Recreate policies idempotently. +drop policy if exists sun_workspaces_read on public.sun_workspaces; +create policy sun_workspaces_read on public.sun_workspaces +for select to authenticated +using (public.sun_member_role(id) is not null); + +drop policy if exists sun_members_read on public.sun_workspace_members; +create policy sun_members_read on public.sun_workspace_members +for select to authenticated +using (user_id = auth.uid() or public.sun_member_role(workspace_id) = 'owner'); + +drop policy if exists sun_state_read on public.sun_app_state; +create policy sun_state_read on public.sun_app_state +for select to authenticated +using (public.sun_member_role(workspace_id) is not null); + +drop policy if exists sun_state_insert on public.sun_app_state; +create policy sun_state_insert on public.sun_app_state +for insert to authenticated +with check (public.sun_member_role(workspace_id) in ('owner','manager')); + +drop policy if exists sun_state_update on public.sun_app_state; +create policy sun_state_update on public.sun_app_state +for update to authenticated +using (public.sun_member_role(workspace_id) in ('owner','manager')) +with check (public.sun_member_role(workspace_id) in ('owner','manager')); + +drop policy if exists sun_invites_read on public.sun_workspace_invites; +create policy sun_invites_read on public.sun_workspace_invites +for select to authenticated +using (public.sun_member_role(workspace_id) = 'owner'); + +-- Private media bucket. The first folder is always the workspace UUID. +insert into storage.buckets(id, name, public, file_size_limit, allowed_mime_types) +values ( + 'sun-media', + 'sun-media', + false, + 15728640, + array['image/jpeg','image/png','image/webp','image/gif'] +) +on conflict (id) do update set + public = excluded.public, + file_size_limit = excluded.file_size_limit, + allowed_mime_types = excluded.allowed_mime_types; + +-- Storage policies. +drop policy if exists sun_media_read on storage.objects; +create policy sun_media_read on storage.objects +for select to authenticated +using ( + bucket_id = 'sun-media' + and public.sun_member_role(((storage.foldername(name))[1])::uuid) is not null +); + +drop policy if exists sun_media_insert on storage.objects; +create policy sun_media_insert on storage.objects +for insert to authenticated +with check ( + bucket_id = 'sun-media' + and public.sun_member_role(((storage.foldername(name))[1])::uuid) in ('owner','manager') +); + +drop policy if exists sun_media_update on storage.objects; +create policy sun_media_update on storage.objects +for update to authenticated +using ( + bucket_id = 'sun-media' + and public.sun_member_role(((storage.foldername(name))[1])::uuid) in ('owner','manager') +) +with check ( + bucket_id = 'sun-media' + and public.sun_member_role(((storage.foldername(name))[1])::uuid) in ('owner','manager') +); + +drop policy if exists sun_media_delete on storage.objects; +create policy sun_media_delete on storage.objects +for delete to authenticated +using ( + bucket_id = 'sun-media' + and public.sun_member_role(((storage.foldername(name))[1])::uuid) in ('owner','manager') +); + +-- Realtime publication for the single workspace-state row. +do $$ +begin + if not exists ( + select 1 from pg_publication_tables + where pubname = 'supabase_realtime' + and schemaname = 'public' + and tablename = 'sun_app_state' + ) then + alter publication supabase_realtime add table public.sun_app_state; + end if; +end $$; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..019deab --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2263 @@ +{ + "name": "caterium-app", + "version": "17.6.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "caterium-app", + "version": "17.6.0", + "devDependencies": { + "@playwright/test": "^1.51.0", + "http-server": "^14.1.1", + "wrangler": "^4.0.0" + } + }, + "node_modules/@cloudflare/kv-asset-handler": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", + "integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@cloudflare/unenv-preset": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.1.tgz", + "integrity": "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==", + "dev": true, + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.24", + "workerd": ">1.20260305.0 <2.0.0-0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20260903.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260903.1.tgz", + "integrity": "sha512-FG+4mGxAXhKiL/1temH42alevIkumtYXNidhTa//3yULpzux6APw5UNVocI/vCQ1yG1YOEZRfbVy8lyuipM9MQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20260903.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260903.1.tgz", + "integrity": "sha512-o241VefnjG8eG+kGap5CjgV4zOT5UaAmS3OR1VZCpNj7vkXGxvp9KftKvtQgcCsIqJKaKx+7Xd7xq0L1DjkfPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20260903.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260903.1.tgz", + "integrity": "sha512-/VEvvtQ/XKf6HlBbg6FbvpwcpfYUcx6Fv6RkASY8DyEmUyuJ8rc7Qxil83ClRFoBzz/GY0BV94UZ6F+wers/hg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20260903.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260903.1.tgz", + "integrity": "sha512-OWhihGC6KoTXF4u2C1AonfpgXeM4/7p/1IXuALqXESmFUpLLP5gZhRzjSk/gWW+mrCZDfSrvnjifl+lRqselbA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20260903.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260903.1.tgz", + "integrity": "sha512-soPMF9/aMHlHKK7M0vq5HrRRicPbnZO1F6ZZ7JWN8EltxWJU5L7CEq7CxjO878DzyPx7gYvqBFy3SVgdZKZjMQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.2.tgz", + "integrity": "sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.1" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.2.tgz", + "integrity": "sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.1" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.2.tgz", + "integrity": "sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.2" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.1.tgz", + "integrity": "sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.1.tgz", + "integrity": "sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.1.tgz", + "integrity": "sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.1.tgz", + "integrity": "sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.1.tgz", + "integrity": "sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.1.tgz", + "integrity": "sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.1.tgz", + "integrity": "sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.1.tgz", + "integrity": "sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.1.tgz", + "integrity": "sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.1.tgz", + "integrity": "sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.2.tgz", + "integrity": "sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.1" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.2.tgz", + "integrity": "sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.1" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.2.tgz", + "integrity": "sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.1" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.2.tgz", + "integrity": "sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.1" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.2.tgz", + "integrity": "sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.1" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.2.tgz", + "integrity": "sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.1" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.2.tgz", + "integrity": "sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.1" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.2.tgz", + "integrity": "sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.1" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.2.tgz", + "integrity": "sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw==", + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.1" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.2.tgz", + "integrity": "sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.2" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.2.tgz", + "integrity": "sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.2.tgz", + "integrity": "sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.2.tgz", + "integrity": "sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@playwright/test": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", + "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@poppinss/colors": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", + "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^4.1.5" + } + }, + "node_modules/@poppinss/dumper": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz", + "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@sindresorhus/is": "^7.0.2", + "supports-color": "^10.0.0" + } + }, + "node_modules/@poppinss/dumper/node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/@poppinss/exception": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz", + "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sindresorhus/is": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", + "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@speed-highlight/core": { + "version": "1.2.24", + "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.24.tgz", + "integrity": "sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, + "node_modules/basic-auth": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", + "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "5.1.2" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/blake3-wasm": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", + "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", + "dev": true, + "license": "MIT" + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/corser": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/corser/-/corser-2.0.1.tgz", + "integrity": "sha512-utCYNzRSQIZNPIcGZdQc92UVJYAhtGAteCFg0yRaFm8f0P+CPtyGyHXJcGXnffjCybUCEx3FQ2G7U3/o9eIkVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/error-stack-parser-es": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", + "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "dev": true, + "license": "MIT" + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", + "integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/http-proxy": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", + "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.0", + "follow-redirects": "^1.0.0", + "requires-port": "^1.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/http-server": { + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/http-server/-/http-server-14.1.1.tgz", + "integrity": "sha512-+cbxadF40UXd9T01zUHgA+rlo2Bg1Srer4+B4NwIHdaGxAGGv59nYRnGGDJ9LBk7alpS0US+J+bLLdQOOkJq4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "basic-auth": "^2.0.1", + "chalk": "^4.1.2", + "corser": "^2.0.1", + "he": "^1.2.0", + "html-encoding-sniffer": "^3.0.0", + "http-proxy": "^1.18.1", + "mime": "^1.6.0", + "minimist": "^1.2.6", + "opener": "^1.5.1", + "portfinder": "^1.0.28", + "secure-compare": "3.0.1", + "union": "~0.5.0", + "url-join": "^4.0.1" + }, + "bin": { + "http-server": "bin/http-server" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/miniflare": { + "version": "5.20260903.0-alpha", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-5.20260903.0-alpha.tgz", + "integrity": "sha512-VCZIFxOqFXeibRBJWwTlppqbv2lkeO10IX3QBRGK9j1QiMAfH/OSsCvbjp1MslBMhVZmy2VTRlVaVnsKnbDp6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "sharp": "0.35.2", + "undici": "7.29.0", + "workerd": "1.20260903.1", + "ws": "8.21.0", + "youch": "4.1.0-beta.10" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/opener": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz", + "integrity": "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==", + "dev": true, + "license": "(WTFPL OR MIT)", + "bin": { + "opener": "bin/opener-bin.js" + } + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/portfinder": { + "version": "1.0.38", + "resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.38.tgz", + "integrity": "sha512-rEwq/ZHlJIKw++XtLAO8PPuOQA/zaPJOZJ37BVuN97nLpMJeuDVLVGRwbFoBgLudgdTMP2hdRJP++H+8QOA3vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "async": "^3.2.6", + "debug": "^4.3.6" + }, + "engines": { + "node": ">= 10.12" + } + }, + "node_modules/qs": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/secure-compare": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/secure-compare/-/secure-compare-3.0.1.tgz", + "integrity": "sha512-AckIIV90rPDcBcglUwXPF3kg0P0qmPsPXAj6BBEENQE1p5yA1xfmDJzfi1Tappj37Pv2mVbKpL3Z1T+Nn7k1Qw==", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.2.tgz", + "integrity": "sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.2", + "@img/sharp-darwin-x64": "0.35.2", + "@img/sharp-freebsd-wasm32": "0.35.2", + "@img/sharp-libvips-darwin-arm64": "1.3.1", + "@img/sharp-libvips-darwin-x64": "1.3.1", + "@img/sharp-libvips-linux-arm": "1.3.1", + "@img/sharp-libvips-linux-arm64": "1.3.1", + "@img/sharp-libvips-linux-ppc64": "1.3.1", + "@img/sharp-libvips-linux-riscv64": "1.3.1", + "@img/sharp-libvips-linux-s390x": "1.3.1", + "@img/sharp-libvips-linux-x64": "1.3.1", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.1", + "@img/sharp-libvips-linuxmusl-x64": "1.3.1", + "@img/sharp-linux-arm": "0.35.2", + "@img/sharp-linux-arm64": "0.35.2", + "@img/sharp-linux-ppc64": "0.35.2", + "@img/sharp-linux-riscv64": "0.35.2", + "@img/sharp-linux-s390x": "0.35.2", + "@img/sharp-linux-x64": "0.35.2", + "@img/sharp-linuxmusl-arm64": "0.35.2", + "@img/sharp-linuxmusl-x64": "0.35.2", + "@img/sharp-webcontainers-wasm32": "0.35.2", + "@img/sharp-win32-arm64": "0.35.2", + "@img/sharp-win32-ia32": "0.35.2", + "@img/sharp-win32-x64": "0.35.2" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/undici": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/unenv": { + "version": "2.0.0-rc.24", + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", + "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3" + } + }, + "node_modules/union": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/union/-/union-0.5.0.tgz", + "integrity": "sha512-N6uOhuW6zO95P3Mel2I2zMsbsanvvtgn6jVqJv4vbVcz/JN0OkL9suomjQGmWtxJQXOCqUJvquc1sMeNz/IwlA==", + "dev": true, + "dependencies": { + "qs": "^6.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/url-join": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz", + "integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==", + "dev": true, + "license": "MIT" + }, + "node_modules/whatwg-encoding": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", + "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/workerd": { + "version": "1.20260903.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260903.1.tgz", + "integrity": "sha512-xJzt2RnCy7ulOULmZy/4JLbEPg1uisp9lVoOUsEz+UVhDsTmrSQ0rBXZMGcXuhr2HCGKs89bg8nnbbzvBSX1Ig==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20260903.1", + "@cloudflare/workerd-darwin-arm64": "1.20260903.1", + "@cloudflare/workerd-linux-64": "1.20260903.1", + "@cloudflare/workerd-linux-arm64": "1.20260903.1", + "@cloudflare/workerd-windows-64": "1.20260903.1" + } + }, + "node_modules/wrangler": { + "version": "4.129.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.129.0.tgz", + "integrity": "sha512-PGPvs9UPoFrwxT0VogpESSZGvZIctAuTK3wGsLLPHtHsSgS85kNdvtpa2d14UzG8gwLWD64XUGFPGG9tOXG9VQ==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.5.0", + "@cloudflare/unenv-preset": "2.16.1", + "blake3-wasm": "2.1.5", + "esbuild": "0.28.1", + "miniflare": "5.20260903.0-alpha", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.24", + "workerd": "1.20260903.1" + }, + "bin": { + "cf-wrangler": "bin/cf-wrangler.js", + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=22.0.0" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^5.20260903.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/youch": { + "version": "4.1.0-beta.10", + "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", + "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@poppinss/dumper": "^0.6.4", + "@speed-highlight/core": "^1.2.7", + "cookie": "^1.0.2", + "youch-core": "^0.3.3" + } + }, + "node_modules/youch-core": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz", + "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/exception": "^1.2.2", + "error-stack-parser-es": "^1.0.5" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..d061d2d --- /dev/null +++ b/package.json @@ -0,0 +1,20 @@ +{ + "name": "caterium-app", + "private": true, + "version": "17.6.0", + "type": "module", + "scripts": { + "check:syntax": "node --check public/app-runtime.js && node --check public/service-worker.js && node --check public/legacy/bootstrap.js && node --check public/core/sun-safe.js && node --check public/core/performance.js && node --check public/core/pdf-engine.js", + "test:static": "node tests/static-security.mjs", + "check:release": "node tests/release-check.mjs", + "check:deploy": "npm run check:syntax && npm run test:static && npm run check:release", + "test:e2e": "playwright test --config=tests/playwright.config.mjs", + "test": "npm run check:deploy", + "deploy": "wrangler deploy" + }, + "devDependencies": { + "@playwright/test": "^1.51.0", + "http-server": "^14.1.1", + "wrangler": "^4.0.0" + } +} diff --git a/public/app-runtime.js b/public/app-runtime.js new file mode 100644 index 0000000..3fa3cdd --- /dev/null +++ b/public/app-runtime.js @@ -0,0 +1,5612 @@ +/* ===== MODULE: legacy-catalog-v175.js ===== */ +window.SUN_LEGACY_CATALOG_V175=[{"id":"1","name":"Фуршетный бокс № 1 Канапе мясные","category":0,"price":3690,"photo":"catalog/001.jpg","weight":"600 г","catalogSection":"Канапе и фуршетные закуски","composition":["5 шт. канапе с мортаделлой и дроблённой фисташкой","5 шт. канапе с салями мадеро","5 шт. канапе с салями парма","5 шт. канапе с прошуто","5 шт. канапе с шейками капоколло с вялеными томатами"],"ingredients":[["канапе с мортаделлой и дроблённой фисташкой",5,"шт."],["канапе с салями мадеро",5,"шт."],["канапе с салями парма",5,"шт."],["канапе с прошуто",5,"шт."],["канапе с шейками капоколло с вялеными томатами",5,"шт."]],"catalogOrder":1},{"id":"2","name":"Фуршетный бокс № 2 Канапе рыбные","category":0,"price":4590,"photo":"catalog/002.jpg","weight":"600 г","catalogSection":"Канапе и фуршетные закуски","composition":["5 шт. тартинка с красной икрой и кремчизом","5 шт. кростини с лососем и кремчизом","5 шт. тартинка с креветкой и соусом цезарь","5 шт. кростини с креветкой и соусом чили","5 шт. кростини с форелью и сливочным соусом"],"ingredients":[["тартинка с красной икрой и кремчизом",5,"шт."],["кростини с лососем и кремчизом",5,"шт."],["тартинка с креветкой и соусом цезарь",5,"шт."],["кростини с креветкой и соусом чили",5,"шт."],["кростини с форелью и сливочным соусом",5,"шт."]],"catalogOrder":2},{"id":"3","name":"Фуршетный бокс № 3 Микс тарталеток","category":0,"price":4590,"photo":"catalog/003.jpg","weight":"790 г","catalogSection":"Канапе и фуршетные закуски","composition":["5 шт. тартинка с креветкой и соусом цезарь","5 шт. тартинка с щучьей икрой и мягким сыром","5 шт. тартинка с красной икрой и взбитым маслом","5 шт. тартинка с прошуто и грушей","5 шт. тартинка с мортаделлой"],"ingredients":[["тартинка с креветкой и соусом цезарь",5,"шт."],["тартинка с щучьей икрой и мягким сыром",5,"шт."],["тартинка с красной икрой и взбитым маслом",5,"шт."],["тартинка с прошуто и грушей",5,"шт."],["тартинка с мортаделлой",5,"шт."]],"catalogOrder":3},{"id":"4","name":"Фуршетный бокс № 4 Премиум морепродукты","category":0,"price":6990,"photo":"catalog/004.jpg","weight":"625 г","catalogSection":"Канапе и фуршетные закуски","composition":["5 шт. тартинка с тунцом","5 шт. канапе с тигровой креветкой с чили","5 шт. ролл из шпината с кремчизом и красной икрой","5 шт. ролл с чернилами каракатицы, лососем и сливочным сыром","5 шт. тартинка с тигровой креветкой и соусом из тунца"],"ingredients":[["тартинка с тунцом",5,"шт."],["канапе с тигровой креветкой с чили",5,"шт."],["ролл из шпината с кремчизом и красной икрой",5,"шт."],["ролл с чернилами каракатицы, лососем и сливочным сыром",5,"шт."],["тартинка с тигровой креветкой и соусом из тунца",5,"шт."]],"catalogOrder":4},{"id":"5","name":"Фуршетный бокс № 5 Брускетты мясные","category":0,"price":3990,"photo":"catalog/005.jpg","weight":"750 г","catalogSection":"Канапе и фуршетные закуски","composition":["5 шт. говядина с печёным перцем и горчицей","5 шт. салями мадеро с оливкой","5 шт. салями парма с маслинами и огурцом","5 шт. прошуто с грушой и кремчизом","5 шт. шейка капоколло с вяленым томатом"],"ingredients":[["говядина с печёным перцем и горчицей",5,"шт."],["салями мадеро с оливкой",5,"шт."],["салями парма с маслинами и огурцом",5,"шт."],["прошуто с грушой и кремчизом",5,"шт."],["шейка капоколло с вяленым томатом",5,"шт."]],"catalogOrder":5},{"id":"6","name":"Фуршетный бокс № 6 Сыры в шотах","category":0,"price":5490,"photo":"catalog/006.jpg","weight":"900 г","catalogSection":"Канапе и фуршетные закуски","composition":["5 шт. моцарелла с томатами и песто","5 шт. дорблю с грушей и малиной","5 шт. бри с миндалём и медовыми сотами","5 шт. пармезан с голубикой и пеканом","5 шт. чеддер с виноградом"],"ingredients":[["моцарелла с томатами и песто",5,"шт."],["дорблю с грушей и малиной",5,"шт."],["бри с миндалём и медовыми сотами",5,"шт."],["пармезан с голубикой и пеканом",5,"шт."],["чеддер с виноградом",5,"шт."]],"catalogOrder":6},{"id":"catalog-007","name":"Фуршетный бокс № 7 Сыр канапе","category":0,"price":3510,"photo":"catalog/007.jpg","weight":"450 г","catalogSection":"Канапе и фуршетные закуски","composition":["5 шт. бри с клубникой","5 шт. эдам с курагой","5 шт. пармезан с инжиром и голубикой","10 шт. чеддер с виноградом","5 шт. камамбер с голубикой"],"ingredients":[["бри с клубникой",5,"шт."],["эдам с курагой",5,"шт."],["пармезан с инжиром и голубикой",5,"шт."],["чеддер с виноградом",10,"шт."],["камамбер с голубикой",5,"шт."]],"catalogOrder":7},{"id":"catalog-008","name":"Фуршетный бокс № 50 Мини-эклеры","category":0,"price":4490,"photo":"catalog/008.jpg","weight":"500 г","catalogSection":"Канапе и фуршетные закуски","composition":["5 шт. мини-эклер с паштетом из куриных жареных печёнок — 20 г","5 шт. мини-эклер с риетом из тунца — 20 г","5 шт. мини-эклер с красной икрой и крем-чизом — 20 г","5 шт. мини-эклер с чёрной структурированной икрой и крем-чизом — 20 г","5 шт. мини-эклер с форелью и крем-чизом — 20 г"],"ingredients":[["мини-эклер с паштетом из куриных жареных печёнок — 20 г",5,"шт."],["мини-эклер с риетом из тунца — 20 г",5,"шт."],["мини-эклер с красной икрой и крем-чизом — 20 г",5,"шт."],["мини-эклер с чёрной структурированной икрой и крем-чизом — 20 г",5,"шт."],["мини-эклер с форелью и крем-чизом — 20 г",5,"шт."]],"catalogOrder":8},{"id":"catalog-009","name":"Фуршетный бокс № 21 Антипасти","category":0,"price":5590,"photo":"catalog/009.jpg","weight":"675 г","catalogSection":"Канапе и фуршетные закуски","composition":["5 шт. перчики греко с мягким сыром","5 шт. канапе с салями мадеро","5 шт. антипасти с оливками, томатами и виноградом","5 шт. ассорти сыров","5 шт. артишоки, вяленые томаты и печёный перец"],"ingredients":[["перчики греко с мягким сыром",5,"шт."],["канапе с салями мадеро",5,"шт."],["антипасти с оливками, томатами и виноградом",5,"шт."],["ассорти сыров",5,"шт."],["артишоки, вяленые томаты и печёный перец",5,"шт."]],"catalogOrder":9},{"id":"catalog-010","name":"Фуршетный бокс № 48 Мини-эклеры","category":0,"price":3790,"photo":"catalog/010.jpg","weight":"500 г","catalogSection":"Канапе и фуршетные закуски","composition":["15 шт. мини-эклер с паштетом из куриных жареных печёнок — 20 г","10 шт. мини-эклер с красной икрой и крем-чизом — 20 г"],"ingredients":[["мини-эклер с паштетом из куриных жареных печёнок — 20 г",15,"шт."],["мини-эклер с красной икрой и крем-чизом — 20 г",10,"шт."]],"catalogOrder":10},{"id":"catalog-011","name":"Фуршетный бокс № 15 Канапе рыбные","category":0,"price":5490,"photo":"catalog/011.jpg","weight":"700 г","catalogSection":"Канапе и фуршетные закуски","composition":["5 шт. крости с тар-таром из креветок","5 шт. канапе с тунцом и юдзу","5 шт. канапе с креветкой в панко и чили-соусом","5 шт. канапе с лососем и мягким сыром","5 шт. канапе с креветкой и соусом Цезарь"],"ingredients":[["крости с тар-таром из креветок",5,"шт."],["канапе с тунцом и юдзу",5,"шт."],["канапе с креветкой в панко и чили-соусом",5,"шт."],["канапе с лососем и мягким сыром",5,"шт."],["канапе с креветкой и соусом Цезарь",5,"шт."]],"catalogOrder":11},{"id":"catalog-012","name":"Фуршетный бокс № 16 Вегетарианский","category":0,"price":3790,"photo":"catalog/012.jpg","weight":"700 г","catalogSection":"Канапе и фуршетные закуски","composition":["5 шт. крости с моцареллой, черри и песто","5 шт. крости с баклажановой икрой и фетой","5 шт. крости с грибной икрой и трюфельной пастой","5 шт. крости с печёным перцем и фетой","5 шт. крости с вялеными томатами"],"ingredients":[["крости с моцареллой, черри и песто",5,"шт."],["крости с баклажановой икрой и фетой",5,"шт."],["крости с грибной икрой и трюфельной пастой",5,"шт."],["крости с печёным перцем и фетой",5,"шт."],["крости с вялеными томатами",5,"шт."]],"catalogOrder":12},{"id":"catalog-013","name":"Фуршетный бокс № 18 Тосты ассорти","category":0,"price":3190,"photo":"catalog/013.jpg","weight":"820 г","catalogSection":"Канапе и фуршетные закуски","composition":["25 шт. тостов ассорти","Тосты с селёдкой на свекольном муссе","Тосты с салом и солёным огурчиком"],"ingredients":[["тостов ассорти",25,"шт."],["Тосты с селёдкой на свекольном муссе",1,"поз."],["Тосты с салом и солёным огурчиком",1,"поз."]],"catalogOrder":13},{"id":"catalog-014","name":"Фуршетный бокс № 43 Тарталетки с икрой","category":0,"price":4890,"photo":"catalog/014.jpg","weight":"625 г","catalogSection":"Канапе и фуршетные закуски","composition":["15 шт. тарталетка с красной икрой и крем-чизом","10 шт. тарталетка с чёрной структурированной икрой и крем-чизом"],"ingredients":[["тарталетка с красной икрой и крем-чизом",15,"шт."],["тарталетка с чёрной структурированной икрой и крем-чизом",10,"шт."]],"catalogOrder":14},{"id":"catalog-015","name":"Фуршетный бокс № 49 Мини-эклеры","category":0,"price":3490,"photo":"catalog/015.jpg","weight":"500 г","catalogSection":"Канапе и фуршетные закуски","composition":["15 шт. мини-эклер с паштетом из куриных жареных печёнок — 20 г","10 шт. мини-эклер с риетом из тунца — 20 г"],"ingredients":[["мини-эклер с паштетом из куриных жареных печёнок — 20 г",15,"шт."],["мини-эклер с риетом из тунца — 20 г",10,"шт."]],"catalogOrder":15},{"id":"catalog-016","name":"Фуршетный бокс № 19 Рулетики ветчина-сыр","category":0,"price":3690,"photo":"catalog/016.jpg","weight":"1000 г","catalogSection":"Канапе и фуршетные закуски","composition":["50 шт. рулетики из ветчины с сыром"],"ingredients":[["рулетики из ветчины с сыром",50,"шт."]],"catalogOrder":16},{"id":"catalog-017","name":"Фуршетный бокс № 51 Брускетты с говядиной и томаты капрезе","category":0,"price":3990,"photo":"catalog/017.jpg","weight":"840 г","catalogSection":"Канапе и фуршетные закуски","composition":["12 шт. брускетта с пастрами из говядины — 35 г","12 шт. брускетта с моцареллой и томатами черри — 35 г"],"ingredients":[["брускетта с пастрами из говядины — 35 г",12,"шт."],["брускетта с моцареллой и томатами черри — 35 г",12,"шт."]],"catalogOrder":17},{"id":"catalog-018","name":"Фуршетный бокс № 27 Брускетты с тунцом и лососем","category":0,"price":4290,"photo":"catalog/018.jpg","weight":"840 г","catalogSection":"Канапе и фуршетные закуски","composition":["12 шт. брускетта с тунцом и апельсином — 35 г","12 шт. брускетта с лососем и крем-чизом — 35 г"],"ingredients":[["брускетта с тунцом и апельсином — 35 г",12,"шт."],["брускетта с лососем и крем-чизом — 35 г",12,"шт."]],"catalogOrder":18},{"id":"catalog-019","name":"Фуршетный бокс № 20 Профитроли","category":0,"price":4890,"photo":"catalog/019.jpg","weight":"700 г","catalogSection":"Канапе и фуршетные закуски","composition":["5 шт. профитроли с говядиной и соусом вителло тонато","5 шт. профитроли с красной икрой и сливочным маслом","5 шт. профитроли с риетом из копчёной форели","5 шт. профитроли с лососем и мягким сыром","5 шт. профитроли с паштетом из жареных куриных печёнок"],"ingredients":[["профитроли с говядиной и соусом вителло тонато",5,"шт."],["профитроли с красной икрой и сливочным маслом",5,"шт."],["профитроли с риетом из копчёной форели",5,"шт."],["профитроли с лососем и мягким сыром",5,"шт."],["профитроли с паштетом из жареных куриных печёнок",5,"шт."]],"catalogOrder":19},{"id":"catalog-020","name":"Фуршетный бокс № 26 Брускетты с креветками и муссом из лосося","category":0,"price":3690,"photo":"catalog/020.jpg","weight":"780 г","catalogSection":"Канапе и фуршетные закуски","composition":["12 шт. брускетта с креветкой и чили-соусом — 35 г","12 шт. брускетта с муссом из копчёного лосося — 30 г"],"ingredients":[["брускетта с креветкой и чили-соусом — 35 г",12,"шт."],["брускетта с муссом из копчёного лосося — 30 г",12,"шт."]],"catalogOrder":20},{"id":"catalog-021","name":"Фуршетный бокс № 28 Брускетты с говядиной и индейкой","category":0,"price":4290,"photo":"catalog/021.jpg","weight":"780 г","catalogSection":"Канапе и фуршетные закуски","composition":["12 шт. брускетта с говядиной — 35 г","12 шт. брускетта с пастрами из индейки — 30 г"],"ingredients":[["брускетта с говядиной — 35 г",12,"шт."],["брускетта с пастрами из индейки — 30 г",12,"шт."]],"catalogOrder":21},{"id":"catalog-022","name":"Фуршетный бокс № 29 Брускетты с мортаделлой и салями мадера","category":0,"price":3690,"photo":"catalog/022.jpg","weight":"720 г","catalogSection":"Канапе и фуршетные закуски","composition":["12 шт. брускетта с мортаделлой — 25 г","12 шт. брускетта с салями мадера — 35 г"],"ingredients":[["брускетта с мортаделлой — 25 г",12,"шт."],["брускетта с салями мадера — 35 г",12,"шт."]],"catalogOrder":22},{"id":"catalog-023","name":"Фуршетный бокс № 9 Салаты Цезарь микс в шотах","category":0,"price":4390,"photo":"catalog/023.jpg","weight":"1200 г","catalogSection":"Салаты","composition":["8 пор. цезарь с цыплёнком в хрустящей корочке — 75 г","8 пор. цезарь с креветкой в темпуре — 75 г"],"ingredients":[["цезарь с цыплёнком в хрустящей корочке — 75 г",8,"пор."],["цезарь с креветкой в темпуре — 75 г",8,"пор."]],"catalogOrder":23},{"id":"catalog-024","name":"Фуршетный бокс № 8 Салат Цезарь с цыплёнком в панировке","category":0,"price":3460,"photo":"catalog/024.jpg","weight":"1200 г","catalogSection":"Салаты","composition":["16 пор. салата Цезарь: ромэн, черри, пармезан, цыплёнок в панировке, соус Цезарь, кростини — 75 г"],"ingredients":[["салата Цезарь: ромэн, черри, пармезан, цыплёнок в панировке, соус Цезарь, кростини — 75 г",16,"пор."]],"catalogOrder":24},{"id":"catalog-025","name":"Фуршетный бокс № 8 Салат Цезарь с креветкой в темпуре","category":0,"price":3790,"photo":"catalog/025.jpg","weight":"1200 г","catalogSection":"Салаты","composition":["16 пор. салата Цезарь: ромэн, черри, пармезан, креветка в темпуре, соус Цезарь, кростини — 75 г"],"ingredients":[["салата Цезарь: ромэн, черри, пармезан, креветка в темпуре, соус Цезарь, кростини — 75 г",16,"пор."]],"catalogOrder":25},{"id":"catalog-026","name":"Фуршетный бокс № 8 Салат Мимоза с лососем г/к","category":0,"price":4250,"photo":"catalog/026.jpg","weight":"1200 г","catalogSection":"Салаты","composition":["16 пор. салата Мимоза с лососем горячего копчения"],"ingredients":[["салата Мимоза с лососем горячего копчения",16,"пор."]],"catalogOrder":26},{"id":"catalog-027","name":"Фуршетный бокс № 8 Салат Греческий","category":0,"price":3290,"photo":"catalog/027.jpg","weight":"1200 г","catalogSection":"Салаты","composition":["16 пор. салата Греческий — 75 г"],"ingredients":[["салата Греческий — 75 г",16,"пор."]],"catalogOrder":27},{"id":"catalog-028","name":"Фуршетный бокс № 8 Салат Оливье с говядиной","category":0,"price":4690,"photo":"catalog/028.jpg","weight":"1200 г","catalogSection":"Салаты","composition":["16 пор. Оливье с говядиной — 75 г"],"ingredients":[["Оливье с говядиной — 75 г",16,"пор."]],"catalogOrder":28},{"id":"catalog-029","name":"Фуршетный бокс № 8 Салат Столичный с цыплёнком","category":0,"price":3890,"photo":"catalog/029.jpg","weight":"1200 г","catalogSection":"Салаты","composition":["16 пор. салата Столичный с цыплёнком в креманке — 75 г"],"ingredients":[["салата Столичный с цыплёнком в креманке — 75 г",16,"пор."]],"catalogOrder":29},{"id":"catalog-030","name":"Фуршетный бокс № 45 Салат Столичный в тарталетках","category":0,"price":3190,"photo":"catalog/030.jpg","weight":"900 г","catalogSection":"Салаты","composition":["20 шт. тарталеток","Салат Столичный с цыплёнком в тарталетках из песочного теста — 45 г"],"ingredients":[["тарталеток",20,"шт."],["Салат Столичный с цыплёнком в тарталетках из песочного теста",45,"г"]],"catalogOrder":30},{"id":"catalog-031","name":"Фуршетный бокс № 46 Салат Крабовый в тарталетках","category":0,"price":2790,"photo":"catalog/031.jpg","weight":"900 г","catalogSection":"Салаты","composition":["20 шт. тарталеток","Салат Крабовый классический в тарталетках из песочного теста — 45 г"],"ingredients":[["тарталеток",20,"шт."],["Салат Крабовый классический в тарталетках из песочного теста",45,"г"]],"catalogOrder":31},{"id":"catalog-032","name":"Фуршетный бокс № 47 Салат Оливье в тарталетках","category":0,"price":3890,"photo":"catalog/032.jpg","weight":"900 г","catalogSection":"Салаты","composition":["20 шт. тарталеток","Салат Оливье с говядиной в тарталетках из песочного теста — 45 г"],"ingredients":[["тарталеток",20,"шт."],["Салат Оливье с говядиной в тарталетках из песочного теста",45,"г"]],"catalogOrder":32},{"id":"catalog-033","name":"Фуршетный бокс № 47 Салат Оливье и Крабовый в тарталетках","category":0,"price":4290,"photo":"catalog/033.jpg","weight":"900 г","catalogSection":"Салаты","composition":["10 шт. тарталеток с Оливье и говядиной — 45 г","10 шт. тарталеток с классическим Крабовым салатом — 45 г"],"ingredients":[["тарталеток с Оливье и говядиной — 45 г",10,"шт."],["тарталеток с классическим Крабовым салатом — 45 г",10,"шт."]],"catalogOrder":33},{"id":"catalog-034","name":"Фуршетный бокс № 17 Круассаны с говядиной пастрами","category":0,"price":3500,"photo":"catalog/034.jpg","weight":"1050 г","catalogSection":"Бургеры, сэндвичи, круассаны","composition":["20 шт. круассаны с говядиной, помидором и огурцом"],"ingredients":[["круассаны с говядиной, помидором и огурцом",20,"шт."]],"catalogOrder":34},{"id":"catalog-035","name":"Фуршетный бокс № 17 Круассаны с лососем","category":0,"price":4390,"photo":"catalog/035.jpg","weight":"1200 г","catalogSection":"Бургеры, сэндвичи, круассаны","composition":["20 шт. круассаны с лососем с/с, салатом, свежим огурцом и сливочным соусом"],"ingredients":[["круассаны с лососем с/с, салатом, свежим огурцом и сливочным соусом",20,"шт."]],"catalogOrder":35},{"id":"catalog-036","name":"Фуршетный бокс № 10 Мини-бургеры классика","category":0,"price":4590,"photo":"catalog/036.jpg","weight":"1000 г","catalogSection":"Бургеры, сэндвичи, круассаны","composition":["12 шт. бургеры с говяжьей котлетой","13 шт. бургеры с куриным стрипсом"],"ingredients":[["бургеры с говяжьей котлетой",12,"шт."],["бургеры с куриным стрипсом",13,"шт."]],"catalogOrder":36},{"id":"catalog-037","name":"Фуршетный бокс № 11 Сэндвичи с ветчиной и сыром","category":0,"price":2890,"photo":"catalog/037.jpg","weight":"1200 г","catalogSection":"Бургеры, сэндвичи, круассаны","composition":["25 шт. сэндвич с ветчиной и сыром — 45 г"],"ingredients":[["сэндвич с ветчиной и сыром — 45 г",25,"шт."]],"catalogOrder":37},{"id":"catalog-038","name":"Фуршетный бокс № 12 Сэндвичи с лососем","category":0,"price":3990,"photo":"catalog/038.jpg","weight":"1200 г","catalogSection":"Бургеры, сэндвичи, круассаны","composition":["25 шт. сэндвич с огурцом и лососем — 45 г"],"ingredients":[["сэндвич с огурцом и лососем — 45 г",25,"шт."]],"catalogOrder":38},{"id":"catalog-039","name":"Фуршетный бокс № 13 Мини-бургеры ассорти","category":0,"price":5990,"photo":"catalog/039.jpg","weight":"1000 г","catalogSection":"Бургеры, сэндвичи, круассаны","composition":["5 шт. бургер с говяжьей котлетой","5 шт. чизбургер с говяжьей котлетой","5 шт. бургер с креветкой","5 шт. бургер с куриной котлетой","5 шт. чикенбургер"],"ingredients":[["бургер с говяжьей котлетой",5,"шт."],["чизбургер с говяжьей котлетой",5,"шт."],["бургер с креветкой",5,"шт."],["бургер с куриной котлетой",5,"шт."],["чикенбургер",5,"шт."]],"catalogOrder":39},{"id":"catalog-040","name":"Фуршетный бокс № 14 Жюльен","category":0,"price":3290,"photo":"catalog/040.jpg","weight":"625 г","catalogSection":"Тёплые блюда и выпечка","composition":["25 шт. грибной жюльен с сыром пармезан в тарталетках — 25 г"],"ingredients":[["грибной жюльен с сыром пармезан в тарталетках — 25 г",25,"шт."]],"catalogOrder":40},{"id":"catalog-041","name":"Фуршетный бокс № 14 Жюльен с белыми грибами","category":0,"price":5290,"photo":"catalog/041.jpg","weight":"625 г","catalogSection":"Тёплые блюда и выпечка","composition":["25 шт. жюльен: белые грибы 70%, шампиньоны 30%, пармезан, тарталетки — 25 г"],"ingredients":[["жюльен: белые грибы 70%, шампиньоны 30%, пармезан, тарталетки — 25 г",25,"шт."]],"catalogOrder":41},{"id":"catalog-042","name":"Фуршетный бокс № 44 Пирог с сыром","category":0,"price":2690,"photo":"catalog/042.jpg","weight":"450 г","catalogSection":"Тёплые блюда и выпечка","composition":["16 пор. закрытого пирога с сыром"],"ingredients":[["закрытого пирога с сыром",16,"пор."]],"catalogOrder":42},{"id":"catalog-043","name":"Фуршетный бокс № 42 Блинчики с печенью","category":0,"price":2430,"photo":"catalog/043.jpg","weight":"875 г","catalogSection":"Тёплые блюда и выпечка","composition":["25 пор. блины с куриной печенью, луком и крем-чизом"],"ingredients":[["блины с куриной печенью, луком и крем-чизом",25,"пор."]],"catalogOrder":43},{"id":"catalog-044","name":"Фуршетный бокс № 24 Кулебяка и пирожки","category":0,"price":3890,"photo":"catalog/044.jpg","weight":"1070 г","catalogSection":"Тёплые блюда и выпечка","composition":["6 шт. пирожков с картошкой — 60 г","6 шт. пирожков с мясом — 60 г","1 шт. кулебяка с капустой — 350 г"],"ingredients":[["пирожков с картошкой — 60 г",6,"шт."],["пирожков с мясом — 60 г",6,"шт."],["кулебяка с капустой — 350 г",1,"шт."]],"catalogOrder":44},{"id":"catalog-045","name":"Фуршетный бокс № 33 Пирожки","category":0,"price":3690,"photo":"catalog/045.jpg","weight":"1080 г","catalogSection":"Тёплые блюда и выпечка","composition":["6 шт. пирожков с картошкой — 60 г","6 шт. пирожков с мясом — 60 г","6 шт. пирожков с капустой — 60 г"],"ingredients":[["пирожков с картошкой — 60 г",6,"шт."],["пирожков с мясом — 60 г",6,"шт."],["пирожков с капустой — 60 г",6,"шт."]],"catalogOrder":45},{"id":"catalog-046","name":"Фуршетный бокс № 25 Детский","category":0,"price":3690,"photo":"catalog/046.jpg","weight":"1150 г","catalogSection":"Тёплые блюда и выпечка","composition":["10 шт. чизбургеров — 500 г","Наггетсы — 300 г","Картофель фри — 200 г","Сырный соус — 50 г","Кетчуп — 50 г","Соус барбекю — 50 г"],"ingredients":[["чизбургеров — 500 г",10,"шт."],["Наггетсы",300,"г"],["Картофель фри",200,"г"],["Сырный соус",50,"г"],["Кетчуп",50,"г"],["Соус барбекю",50,"г"]],"catalogOrder":46},{"id":"catalog-047","name":"Фуршетный бокс № 42 Блинчики с мясом","category":0,"price":2590,"photo":"catalog/047.jpg","weight":"875 г","catalogSection":"Тёплые блюда и выпечка","composition":["25 пор. блины с мясным фаршем и крем-чизом"],"ingredients":[["блины с мясным фаршем и крем-чизом",25,"пор."]],"catalogOrder":47},{"id":"catalog-048","name":"Фуршетный набор № 30 Куриные шашлычки","category":0,"price":3790,"photo":"catalog/048.jpg","weight":"1120 г","catalogSection":"Тёплые блюда и выпечка","composition":["Куриные шашлычки — 560 г","Картофель бейби — 500 г","Соус — 60 г"],"ingredients":[["Куриные шашлычки",560,"г"],["Картофель бейби",500,"г"],["Соус",60,"г"]],"catalogOrder":48},{"id":"catalog-049","name":"Фуршетный бокс № 31 Свиной шашлычок","category":0,"price":3990,"photo":"catalog/049.jpg","weight":"1120 г","catalogSection":"Тёплые блюда и выпечка","composition":["Свиной шашлык — 560 г","Картофель бейби — 500 г","Соус — 60 г"],"ingredients":[["Свиной шашлык",560,"г"],["Картофель бейби",500,"г"],["Соус",60,"г"]],"catalogOrder":49},{"id":"catalog-050","name":"Фуршетный бокс № 32 Лосось на шпажке","category":0,"price":5690,"photo":"catalog/050.jpg","weight":"1120 г","catalogSection":"Тёплые блюда и выпечка","composition":["Шашлычки из лосося — 560 г","Картофель бейби — 500 г","Соус — 60 г"],"ingredients":[["Шашлычки из лосося",560,"г"],["Картофель бейби",500,"г"],["Соус",60,"г"]],"catalogOrder":50},{"id":"catalog-051","name":"Фуршетный бокс № 22 Фрукты","category":0,"price":3890,"photo":"catalog/051.jpg","weight":"1150 г","catalogSection":"Десерты и фрукты","composition":["16 шт. фрукты в ассортименте в креманке"],"ingredients":[["фрукты в ассортименте в креманке",16,"шт."]],"catalogOrder":51},{"id":"catalog-052","name":"Фуршетный бокс № 23 Слоёные десерты","category":0,"price":5690,"photo":"catalog/052.jpg","weight":"750 г","catalogSection":"Десерты и фрукты","composition":["12 шт. трайфл со сливочным бисквитом «Клубника со сливками»","13 шт. трайфл с шоколадным бисквитом «Сникерс» с орехами и карамелью"],"ingredients":[["трайфл со сливочным бисквитом «Клубника со сливками»",12,"шт."],["трайфл с шоколадным бисквитом «Сникерс» с орехами и карамелью",13,"шт."]],"catalogOrder":52},{"id":"catalog-053","name":"Фуршетный бокс № 35 Десерты","category":0,"price":3500,"photo":"catalog/053.jpg","weight":"750 г","catalogSection":"Десерты и фрукты","composition":["25 десертных порций ассорти","15 шт. одного вида","10 шт. второго вида"],"ingredients":[["25 десертных порций ассорти",1,"поз."],["одного вида",15,"шт."],["второго вида",10,"шт."]],"catalogOrder":53},{"id":"catalog-054","name":"Фуршетный бокс № 41 Сырники","category":0,"price":2690,"photo":"catalog/054.jpg","weight":"750 г","catalogSection":"Десерты и фрукты","composition":["25 шт. творожных сырников"],"ingredients":[["творожных сырников",25,"шт."]],"catalogOrder":54},{"id":"catalog-055","name":"Фуршетный бокс № 36 Десерты","category":0,"price":2990,"photo":"catalog/055.jpg","weight":"750 г","catalogSection":"Десерты и фрукты","composition":["25 десертных порций ассорти","10 шт. первого вида","10 шт. второго вида","5 шт. третьего вида"],"ingredients":[["25 десертных порций ассорти",1,"поз."],["первого вида",10,"шт."],["второго вида",10,"шт."],["третьего вида",5,"шт."]],"catalogOrder":55},{"id":"catalog-056","name":"Фуршетный бокс № 34 Десерты микс","category":0,"price":4690,"photo":"catalog/056.jpg","weight":"750 г","catalogSection":"Десерты и фрукты","composition":["Мафины","Бисквитный рулетик","Шоколадный вафельный торт","Бисквит с орехами и карамелью"],"ingredients":[["Мафины",1,"поз."],["Бисквитный рулетик",1,"поз."],["Шоколадный вафельный торт",1,"поз."],["Бисквит с орехами и карамелью",1,"поз."]],"catalogOrder":56},{"id":"catalog-057","name":"Морс","category":3,"price":450,"photo":"catalog/057.jpg","weight":"1 л","catalogSection":"Напитки","composition":["Морс ягодный"],"ingredients":[["Морс ягодный",1,"поз."]],"catalogOrder":57},{"id":"catalog-058","name":"Компот","category":3,"price":450,"photo":"catalog/058.jpg","weight":"1 л","catalogSection":"Напитки","composition":["Компот фруктовый"],"ingredients":[["Компот фруктовый",1,"поз."]],"catalogOrder":58},{"id":"catalog-059","name":"Готовое меню «Сытный» 15–20 гостей","category":0,"price":19900,"photo":"catalog/059.jpg","weight":"5700 г","catalogSection":"Готовые меню","composition":["Бокс № 5: 25 мясных брускетт","Бокс № 8: 16 пор. Оливье с говядиной","Бокс № 10: 12 бургеров с говядиной и 13 с курицей","Бокс № 11: 25 сэндвичей с ветчиной и сыром","Бокс № 24: 10 пирожков с картошкой, 10 с мясом и кулебяка с капустой"],"ingredients":[["Бокс № 5: 25 мясных брускетт",1,"поз."],["Бокс № 8: 16 пор. Оливье с говядиной",1,"поз."],["Бокс № 10: 12 бургеров с говядиной и 13 с курицей",1,"поз."],["Бокс № 11: 25 сэндвичей с ветчиной и сыром",1,"поз."],["Бокс № 24: 10 пирожков с картошкой, 10 с мясом и кулебяка с капустой",1,"поз."]],"catalogOrder":59},{"id":"catalog-060","name":"Готовое меню «Корпоратив» 50–70 гостей","category":0,"price":71640,"photo":"catalog/060.jpg","weight":"18540 г","catalogSection":"Готовые меню","composition":["Бокс № 1: 50 мясных канапе","Бокс № 2: 50 рыбных канапе","Бокс № 6: 50 сырных закусок в шотах","Бокс № 9: 32 пор. салата Цезарь","Бокс № 8: 32 пор. Мимозы с лососем","Бокс № 8: 32 пор. Оливье с говядиной","Бокс № 11: 25 сэндвичей с ветчиной и сыром","Бокс № 22: 32 пор. фруктов в креманке","Бокс № 23: 50 десертов ассорти"],"ingredients":[["Бокс № 1: 50 мясных канапе",1,"поз."],["Бокс № 2: 50 рыбных канапе",1,"поз."],["Бокс № 6: 50 сырных закусок в шотах",1,"поз."],["Бокс № 9: 32 пор. салата Цезарь",1,"поз."],["Бокс № 8: 32 пор. Мимозы с лососем",1,"поз."],["Бокс № 8: 32 пор. Оливье с говядиной",1,"поз."],["Бокс № 11: 25 сэндвичей с ветчиной и сыром",1,"поз."],["Бокс № 22: 32 пор. фруктов в креманке",1,"поз."],["Бокс № 23: 50 десертов ассорти",1,"поз."]],"catalogOrder":60}]; +; + +/* ===== MODULE: pdf-viewer-patch.js ===== */ +(function(){ + 'use strict'; + const $=id=>document.getElementById(id); + const qa=(sel,root=document)=>[...root.querySelectorAll(sel)]; + const IMAGE_DATA=window.SUN_CATALOG_IMAGE_DATA||{}; + const LOGO_DATA=window.SUN_LOGO_DATA||''; + let lastPdfUrl=''; + + function toast(message,type){ + try{ if(window.SunEnterprise?.toast) return window.SunEnterprise.toast(message,type); }catch(_){} + console[type==='warn'?'warn':'log'](message); + } + function currentCategory(){ + const buttons=qa('#new .sun-catalog-top-toolbar .cats button,#new .cats button'); + const active=buttons.find(b=>b.style.outline&&b.style.outline!=='none')||buttons.find(b=>b.getAttribute('aria-current')==='true'); + if(active?.dataset?.category!=null)return Number(active.dataset.category); + const title=document.querySelector('#new .catalog h1')?.textContent?.trim()||''; + const names=[['Боксы',0],['Премиум',5],['Посуда',1],['Дополнения',2],['Напитки',3],['Доставка',4]]; + return names.find(([name])=>title.startsWith(name))?.[1]??0; + } + const categoryName=cat=>{try{if(typeof window.sunCatalogCategoryName==='function')return window.sunCatalogCategoryName(cat)}catch(_){}return ({0:'Боксы',1:'Посуда',2:'Дополнения',3:'Напитки',4:'Доставка',5:'Премиум'})[cat]||'Каталог'}; + function allBoxes(){ + try{if(Array.isArray(boxes))return boxes}catch(_){} + try{const saved=JSON.parse(localStorage.getItem('sunBoxes')||'[]');return Array.isArray(saved)?saved:[]}catch(_){return[]} + } + function ingredientWeight(row){if(!Array.isArray(row))return 0;const direct=Math.max(0,Number(row[3]||0));if(direct)return direct;const m=String(row[0]||'').match(/[—–-]\s*(\d+(?:[.,]\d+)?)\s*г(?:\s*\/\s*шт\.?)?\s*$/i);return m?Math.max(0,Number(String(m[1]).replace(',','.'))):0} + 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.length)return item.composition.map(x=>String(x||'').trim()).filter(Boolean); + if(Array.isArray(item?.ingredients)&&item.ingredients.some(x=>Array.isArray(x)&&x[0]))return item.ingredients.filter(x=>Array.isArray(x)&&x[0]).map(x=>{const q=Math.max(0,Number(x[1]||0)),u=String(x[2]||'').trim(),name=cleanIngredientName(x[0]),w=ingredientWeight(x),shown=Number.isInteger(q)?String(q):q.toLocaleString('ru-RU',{maximumFractionDigits:2});if(/^поз\.?$/i.test(u)&&q<=1)return `${name}${w?` — ${w.toLocaleString('ru-RU')} г/шт.`:''}`;return `${shown} ${u} ${name}${w?` — ${w.toLocaleString('ru-RU')} г/шт.`:''}`.replace(/\s+/g,' ').trim()}); + return []; + } + const money=value=>`${Number(value||0).toLocaleString('ru-RU')} ₽`; + const mm=v=>v*5; + + function roundedRect(ctx,x,y,w,h,r){ + const rr=Math.min(r,w/2,h/2); + ctx.beginPath();ctx.moveTo(x+rr,y);ctx.arcTo(x+w,y,x+w,y+h,rr);ctx.arcTo(x+w,y+h,x,y+h,rr);ctx.arcTo(x,y+h,x,y,rr);ctx.arcTo(x,y,x+w,y,rr);ctx.closePath(); + } + function wrapText(ctx,text,maxWidth,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(ctx.measureText(test).width<=maxWidth||!line) line=test; + else {lines.push(line);line=word;if(lines.length>=maxLines-1)break;} + } + if(line&&lines.length2&&ctx.measureText(last+'…').width>maxWidth)last=last.slice(0,-1); + lines[maxLines-1]=last.replace(/[\s,.;:-]+$/,'')+'…'; + } + return lines; + } + function drawTextLines(ctx,lines,x,y,lineHeight){lines.forEach((line,i)=>ctx.fillText(line,x,y+i*lineHeight));} + + function imageSource(src){ + src=String(src||'').trim(); + if(!src)return LOGO_DATA; + if(/^data:image\//i.test(src))return src; + return IMAGE_DATA[src]||IMAGE_DATA[src.replace(/^\.\//,'')]||(/^sun-logo\.png$/i.test(src)?LOGO_DATA:src); + } + function loadImage(src){ + return new Promise(resolve=>{ + const raw=imageSource(src),img=new Image();let done=false; + const finish=value=>{if(done)return;done=true;clearTimeout(timer);resolve(value)}; + try{const url=new URL(raw,document.baseURI||location.href);if(/^https?:$/i.test(url.protocol)&&url.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 drawContain(ctx,img,x,y,w,h,pad=0){ + if(!img)return; + const iw=img.naturalWidth||img.width, ih=img.naturalHeight||img.height; + if(!iw||!ih)return; + const aw=Math.max(1,w-pad*2),ah=Math.max(1,h-pad*2),scale=Math.min(aw/iw,ah/ih); + const dw=iw*scale,dh=ih*scale; + ctx.drawImage(img,x+(w-dw)/2,y+(h-dh)/2,dw,dh); + } + + async function renderPageJpeg(item,cat,index,total,dateText){ + const W=1050,H=1485,canvas=document.createElement('canvas');canvas.width=W;canvas.height=H; + const ctx=canvas.getContext('2d',{alpha:false}); + ctx.fillStyle='#f8f4eb';ctx.fillRect(0,0,W,H); + ctx.textBaseline='alphabetic';ctx.textAlign='left'; + + const logo=await loadImage('sun-logo.png'); + if(logo)drawContain(ctx,logo,85,50,72,72,1); + ctx.fillStyle='#1f1f1d';ctx.font='800 36px Arial, sans-serif';ctx.fillText('СОЛНЦЕ КЕЙТЕРИНГ',177,83); + const subtitle=cat===0?'ПОЛНЫЙ КАТАЛОГ ФУРШЕТНЫХ БОКСОВ':`КАТАЛОГ ${categoryName(cat).toUpperCase()}`; + ctx.fillStyle='#88827a';ctx.font='600 16px Arial, sans-serif';ctx.fillText(subtitle,178,112); + ctx.textAlign='right';ctx.font='500 15px Arial, sans-serif';ctx.fillText(`${String(index+1).padStart(2,'0')} / ${String(total).padStart(2,'0')}`,965,69);ctx.textAlign='left'; + ctx.fillStyle='#c99a32';ctx.fillRect(85,143,880,2); + + const vx=85,vy=178,vw=880,vh=480; + ctx.fillStyle='#f0ebdf';roundedRect(ctx,vx,vy,vw,vh,20);ctx.fill(); + const photo=await loadImage(item.photo||'sun-logo.png'); + if(photo)drawContain(ctx,photo,vx,vy,vw,vh,item.photo?14:165); + + const section=String(item.catalogSection||categoryName(cat)).toUpperCase(); + ctx.font='800 17px Arial, sans-serif'; + const sectionW=Math.min(720,Math.max(175,ctx.measureText(section).width+54)); + ctx.fillStyle='#191919';roundedRect(ctx,115,201,sectionW,43,22);ctx.fill(); + ctx.fillStyle='#fff';ctx.fillText(section,142,229); + + const titleY=717; + ctx.fillStyle='#1f1f1d';ctx.font='800 36px Arial, sans-serif'; + const nameLines=wrapText(ctx,item.name||'',650,2);drawTextLines(ctx,nameLines,85,titleY,41); + ctx.textAlign='right';ctx.fillStyle='#c99a32';ctx.font='800 37px Arial, sans-serif';ctx.fillText(money(item.price||0),965,titleY);ctx.textAlign='left'; + + const factsY=820; + ctx.fillStyle='#77736d';ctx.font='800 14px Arial, sans-serif';ctx.fillText('ВЕС',85,factsY);ctx.fillText('КОЛ-ВО',525,factsY); + ctx.fillStyle='#232321';ctx.font='800 19px Arial, sans-serif';ctx.fillText(String(item.weight||'—').toUpperCase(),85,factsY+31);ctx.fillText(Number(item.pieces||0)>0?`${Math.round(Number(item.pieces))} ШТ.`:'—',525,factsY+31); + ctx.fillStyle='#d9d0c2';ctx.fillRect(85,889,880,2); + + ctx.fillStyle='#1f1f1d';ctx.font='800 23px Arial, sans-serif';ctx.fillText('СОСТАВ',85,939); + let lines=composition(item),rendered=[]; + ctx.font='400 19px Arial, sans-serif'; + for(const raw of lines){ + const wrapped=wrapText(ctx,raw,795,2); + for(const line of wrapped){if(rendered.length<9)rendered.push(line)} + if(rendered.length>=9)break; + } + if(!rendered.length){ctx.fillStyle='#77736d';ctx.font='400 18px Arial, sans-serif';ctx.fillText('Состав не указан.',85,985)} + else{ + let y=985;ctx.font='400 19px Arial, sans-serif'; + for(const line of rendered){ + ctx.fillStyle='#c99a32';ctx.beginPath();ctx.arc(93,y-6,5,0,Math.PI*2);ctx.fill(); + ctx.fillStyle='#33312e';ctx.fillText(line,113,y);y+=32; + } + } + + ctx.fillStyle='#d9d0c2';ctx.fillRect(85,1400,880,2); + ctx.fillStyle='#88827a';ctx.font='400 14px Arial, sans-serif';ctx.fillText('+7 (921) 954-04-40 • solnce-keytering.ru',85,1435); + ctx.textAlign='right';ctx.fillText(`Цены актуальны на ${dateText}`,965,1435);ctx.textAlign='left'; + + let dataUrl; + try{dataUrl=canvas.toDataURL('image/jpeg',0.88)}catch(_){dataUrl=''} + if(!dataUrl)throw new Error('Не удалось сформировать страницу PDF.'); + const binary=atob(dataUrl.split(',')[1]),bytes=new Uint8Array(binary.length); + for(let i=0;iwindow.SunPdfEngine.fromJpegs(pages); + + async function createCatalogPdfBlob(cat,progress){ + const items=allBoxes().filter(item=>Number(item.category||0)===Number(cat)); + if(!items.length)throw new Error('В этом разделе нет позиций.'); + const pages=[],dateText=new Date().toLocaleDateString('ru-RU'); + for(let i=0;isetTimeout(resolve,0)); + } + return buildPdf(pages); + } + + function loadingPage(win,title){ + try{ + win.document.open(); + win.document.write(`${title}
Формируем актуальный PDF-каталог
Подготовка…
`); + win.document.close(); + }catch(_){} + } + function updateProgress(win,current,total){try{const p=win.document.getElementById('p');if(p)p.textContent=`Страница ${current} из ${total}`;}catch(_){} } + + async function openCatalogPdfViewer(){ + const cat=currentCategory(); + if(cat===4)return; + const viewer=window.open('about:blank','_blank'); + if(!viewer){toast('Браузер заблокировал новую вкладку. Разрешите всплывающие окна для приложения.','warn');return} + loadingPage(viewer,'Каталог — Солнце Кейтеринг'); + try{ + const blob=await createCatalogPdfBlob(cat,(n,t)=>updateProgress(viewer,n,t)); + if(lastPdfUrl)URL.revokeObjectURL(lastPdfUrl); + lastPdfUrl=URL.createObjectURL(blob); + viewer.location.replace(lastPdfUrl); + }catch(err){ + try{viewer.document.body.innerHTML=`
Не удалось открыть PDF.

${String(err?.message||err)}

`}catch(_){} + toast(err?.message||'Не удалось сформировать PDF.','warn'); + } + } + + function installButton(){ + const head=document.querySelector('#new .catalog .catalog-head');if(!head)return; + const configure=qa('button',head).find(b=>(b.textContent||'').trim()==='Настроить');if(!configure)return; + let old=$('sunLiveCatalogPdfButton'),btn; + if(old){btn=old.cloneNode(true);old.replaceWith(btn)} + else{btn=document.createElement('button');btn.id='sunLiveCatalogPdfButton';btn.type='button';btn.className='outline';configure.insertAdjacentElement('beforebegin',btn)} + btn.textContent='PDF'; + btn.title='Открыть актуальный PDF-каталог. В просмотрщике браузера можно скачать или распечатать.'; + btn.onclick=openCatalogPdfViewer; + const hide=currentCategory()===4;btn.hidden=hide;btn.style.display=hide?'none':'inline-flex'; + } + + window.sunOpenCatalogPdfViewer=openCatalogPdfViewer; + window.sunBuildCatalogPdfBlob=()=>createCatalogPdfBlob(currentCategory()); + window.sunCatalogDebugPdfPages=async(cat=currentCategory(),limit=3)=>{const items=allBoxes().filter(item=>Number(item.category||0)===Number(cat)).slice(0,Math.max(1,Number(limit||3)));const dateText=new Date().toLocaleDateString('ru-RU'),pages=[];for(let i=0;i{if(e.target.closest('#new .cats button,#new .sun-catalog-top-toolbar .cats button'))setTimeout(installButton,30)},true); + const catalog=document.querySelector('#new .catalog');if(catalog&&'MutationObserver'in window)new MutationObserver(()=>{const b=$('sunLiveCatalogPdfButton');if(!b||b.textContent!=='PDF'||b.onclick!==openCatalogPdfViewer)queueMicrotask(installButton)}).observe(catalog,{childList:true,subtree:true}); +})(); +; + +/* ===== MODULE: feature-pack-v1.js ===== */ +(function(){ + 'use strict'; + if(window.__sunFeaturePackV1)return; window.__sunFeaturePackV1=true; + const $=id=>document.getElementById(id); + const qa=(sel,root=document)=>[...root.querySelectorAll(sel)]; + const esc=window.SunSafe.escapeHTML; + const money=v=>`${Math.round(Number(v||0)).toLocaleString('ru-RU')} ₽`; + const toast=(m,t='success')=>{try{return window.SunEnterprise?.toast?.(m,t)}catch(_){}; if(t==='warn'||t==='error')console.warn(m);}; + + const style=document.createElement('style'); + style.id='sun-feature-pack-v1-style'; + style.textContent=` + #new .catalog-head{gap:8px;flex-wrap:wrap} + #new .sun-catalog-search{height:35px;min-width:180px;width:min(235px,28vw);padding:6px 10px;border:1px solid #d9dde0;border-radius:8px;background:#fff;color:#203040} + #new .sun-catalog-tabs-button{white-space:nowrap} + #manager .sun-manager-toolbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:0 0 12px} + #manager .sun-manager-toolbar .primary{margin:0} + #manager .sun-manager-search{flex:1 1 220px;min-width:180px} + .sun-category-list{display:grid;gap:8px;margin-top:12px} + .sun-category-row{display:grid;grid-template-columns:minmax(180px,1fr) 58px auto auto;gap:8px;align-items:center;padding:9px;border:1px solid #e0e4e8;border-radius:10px;background:#fff} + .sun-category-row.is-hidden{opacity:.6;background:#f6f7f8} + .sun-category-row input{width:100%}.sun-category-color{display:grid;place-items:center;margin:0}.sun-category-color input{width:42px;height:34px;padding:2px;border:1px solid #d9dde0;border-radius:8px;background:#fff;cursor:pointer} + .sun-category-row small{color:#7b858d;white-space:nowrap} + .sun-category-row-actions{display:flex;gap:5px;flex-wrap:wrap;justify-content:flex-end} + .sun-category-add{display:grid;grid-template-columns:1fr auto;gap:8px;margin-top:14px;padding-top:12px;border-top:1px solid #e6e8eb} + .sun-order-requisites{display:none;margin-top:4px} + .sun-order-requisites-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px} + .sun-order-requisites-grid .wide{grid-column:1/-1} + .sun-order-requisites h3{margin:6px 0 12px;color:#15364c} + .sun-order-qr-box{display:grid;grid-template-columns:110px minmax(0,1fr);gap:14px;align-items:center;margin-top:16px;padding:12px;border:1px solid #e1e4e7;border-radius:12px;background:#fafbfc} + .sun-order-qr-preview{width:100px;height:100px;border:1px dashed #c7cdd2;border-radius:8px;background:#fff;display:grid;place-items:center;overflow:hidden;color:#8a949b;font-size:11px;text-align:center;padding:5px} + .sun-order-qr-preview img{width:100%;height:100%;object-fit:contain} + .sun-order-qr-actions{display:flex;gap:7px;flex-wrap:wrap;margin-top:8px} + .sun-discount-panel{display:grid;grid-template-columns:1fr;gap:10px;margin-top:10px;padding:11px;border:1px solid #e3e6e9;border-radius:10px;background:#fafbfc} + .sun-discount-combo{display:grid;grid-template-columns:minmax(0,1fr) 78px;gap:6px} + .sun-promo-select-wrap{display:grid;gap:5px} + .sun-promo-select-wrap select{width:100%} + .sun-promo-meta{font-size:12px;color:#68717a;line-height:1.35;min-height:16px} + .sun-discount-panel label>span{display:block;color:#68717a;font-size:12px;margin-bottom:5px} + .sun-order-summary-discount{color:#8a5b00} + .sun-order-summary-net{font-weight:800;color:#15364c} + .sun-order-summary-promo small{color:#7b858d;margin-left:5px} + .sun-doc-qr{width:92px;height:92px;object-fit:contain;border:1px solid #e0e3e5;border-radius:7px;padding:4px;background:#fff} + .sun-doc-title-with-qr{display:flex;align-items:flex-start;gap:14px;justify-content:flex-end} + .sun-doc-title-with-qr .sun-doc-title{text-align:right} + .sun-doc-qr-wrap{display:flex;align-items:center;gap:9px;max-width:295px} + .sun-doc-qr-note{min-width:115px;max-width:180px;padding:7px 8px;border:1px dashed #c8cdd2;border-radius:8px;background:#fff;color:#4f5962;font-size:11px;line-height:1.35;text-align:left;white-space:pre-wrap;overflow-wrap:anywhere} + .sun-doc-qr-note:empty:before{content:attr(data-placeholder);color:#9aa1a8} + .sun-doc-qr-note:focus{outline:2px solid #d8b766;outline-offset:1px;border-color:transparent} + @media print{.sun-doc-qr-note{border:0!important;padding:0!important;background:transparent!important;outline:0!important}.sun-doc-qr-note:empty{display:none!important}} + .sun-receipt .sun-receipt-party{margin:4px 0;padding:6px 8px;border-radius:6px} + .sun-receipt .sun-receipt-party h3{font-size:11px;margin:0 0 4px} + .sun-receipt .sun-receipt-party-grid{grid-template-columns:repeat(4,minmax(0,1fr));gap:2px 10px;font-size:9.5px} + .sun-receipt .sun-receipt-party-grid div{padding:1px 0;border-bottom:0;line-height:1.15} + .sun-receipt .sun-receipt-party-grid span{font-size:7.5px;margin-bottom:1px} + .sun-receipt .sun-receipt-party-grid b{font-size:9.5px;line-height:1.15} + #sunReceiptSettingsCard .sun-buyer-settings-hidden{display:none!important} + .sun-default-qr-setting{display:grid;grid-template-columns:100px minmax(0,1fr);gap:14px;align-items:center} + .sun-default-qr-setting .preview{width:92px;height:92px;border:1px dashed #cbd1d6;border-radius:8px;background:#fff;display:grid;place-items:center;overflow:hidden;color:#8a949b;font-size:11px;text-align:center;padding:4px} + .sun-default-qr-setting .preview img{width:100%;height:100%;object-fit:contain} + .sun-default-qr-setting .actions{justify-content:flex-start;margin-top:8px} + .sun-promo-stats{margin-top:18px;border:1px solid #e0e4e8;border-radius:12px;background:#fff;padding:14px} + .sun-promo-stats h2{margin:0 0 4px;color:#15364c} + .sun-promo-stats table{width:100%;border-collapse:collapse;margin-top:12px} + .sun-promo-stats th,.sun-promo-stats td{padding:8px;border-bottom:1px solid #e8eaec;text-align:left} + .sun-promo-stats th{font-size:12px;color:#68717a;background:#fafbfc}.sun-promo-stats td.num,.sun-promo-stats th.num{text-align:right} + .sun-box-weight-field,.sun-box-pieces-field{margin-top:8px}.sun-box-weight-field input,.sun-box-pieces-field input{max-width:220px}.sun-box-pieces-field small{display:block;margin-top:3px;color:#7b858d;line-height:1.3}.sun-ingredient-head,.ingredient.sun-ingredient-v175{display:grid;grid-template-columns:minmax(220px,1fr) 88px 78px 112px 34px;gap:6px;align-items:center}.sun-ingredient-head{margin:8px 0 4px;color:#68717a;font-size:11px;font-weight:700}.sun-ingredient-head span:nth-child(n+2){text-align:center}.ingredient.sun-ingredient-v175 input{text-align:left;min-width:0}.ingredient.sun-ingredient-v175 input[type=number]{text-align:center}.sun-order-line-name small{display:block;margin-top:3px;color:#7b858d;font-size:10px;font-weight:600;line-height:1.25}.sun-order-line-name small b{color:#53616d}.sun-doc .doc-sub.sun-doc-weight{margin-top:2px;color:#66727c;font-weight:600} + @media(max-width:850px){.sun-receipt .sun-receipt-party-grid{grid-template-columns:repeat(2,minmax(0,1fr))}} + @media(max-width:650px){#new .sun-catalog-search{width:100%;flex:1 1 100%}.sun-order-requisites-grid{grid-template-columns:1fr}.sun-order-qr-box,.sun-default-qr-setting{grid-template-columns:1fr}.sun-category-row{grid-template-columns:1fr}.sun-category-row-actions{justify-content:flex-start}.sun-ingredient-head{display:none}.ingredient.sun-ingredient-v175{grid-template-columns:minmax(0,1fr) 64px 58px 84px 32px}.ingredient.sun-ingredient-v175 input{padding:7px 5px;font-size:12px}} + @media print{.sun-doc-title-with-qr{gap:8mm}.sun-doc-qr{width:25mm;height:25mm}.sun-receipt .sun-receipt-party{padding:1.5mm 2mm;margin:1mm 0}.sun-receipt .sun-receipt-party-grid{grid-template-columns:repeat(4,minmax(0,1fr));gap:.5mm 2mm}} + `; + document.head.appendChild(style); + + // ---------------- Catalog categories + search ---------------- + const CAT_KEY='sunCatalogCategoriesV2'; + const defaultCats=[ + {id:0,name:'Боксы',accent:'#d49a2a',prep:true,hidden:false,order:0}, + {id:5,name:'Премиум',accent:'#7a5aa6',prep:true,hidden:false,order:1}, + {id:3,name:'Напитки',accent:'#4f9bd8',prep:false,hidden:false,order:2}, + {id:2,name:'Дополнения',accent:'#56a889',prep:false,hidden:false,order:3}, + {id:1,name:'Посуда',accent:'#a78bfa',prep:false,hidden:false,order:4}, + {id:4,name:'Доставка',accent:'#cf9f83',prep:false,hidden:false,order:5} + ]; + function readCats(){ + let saved=[];try{saved=JSON.parse(localStorage.getItem(CAT_KEY)||'[]');if(!Array.isArray(saved))saved=[]}catch(_){saved=[]} + const map=new Map(saved.filter(x=>x&&Number.isFinite(Number(x.id))).map(x=>[Number(x.id),x])); + defaultCats.forEach(d=>{if(!map.has(d.id))map.set(d.id,d);else map.set(d.id,{...d,...map.get(d.id),id:d.id})}); + try{(Array.isArray(boxes)?boxes:[]).forEach(item=>{const id=Number(item?.category||0);if(Number.isFinite(id)&&!map.has(id))map.set(id,{id,name:`Вкладка ${id}`,accent:'#708090',prep:true,hidden:false,order:map.size})})}catch(_){} + const arr=[...map.values()].map((c,i)=>({id:Number(c.id),name:String(c.name||`Вкладка ${i+1}`).trim()||`Вкладка ${i+1}`,accent:c.accent||'#708090',prep:c.prep!==false,hidden:Boolean(c.hidden),order:Number.isFinite(Number(c.order))?Number(c.order):i})); + arr.sort((a,b)=>a.order-b.order||a.id-b.id);arr.forEach((c,i)=>c.order=i);return arr; + } + let catalogCats=readCats(); + const CAT_LAYOUT_VERSION_KEY='sunCatalogCategoryLayoutVersion'; + const CAT_LAYOUT_VERSION='2026-09-02-premium-between-boxes-and-drinks'; + if(localStorage.getItem(CAT_LAYOUT_VERSION_KEY)!==CAT_LAYOUT_VERSION){const desired=[0,5,3,2,1,4];const rank=new Map(desired.map((id,i)=>[id,i]));catalogCats.sort((a,b)=>(rank.has(a.id)?rank.get(a.id):100+a.order)-(rank.has(b.id)?rank.get(b.id):100+b.order));catalogCats.forEach((c,i)=>c.order=i);try{localStorage.setItem(CAT_KEY,JSON.stringify(catalogCats));localStorage.setItem(CAT_LAYOUT_VERSION_KEY,CAT_LAYOUT_VERSION)}catch(_){}} + function saveCats(){catalogCats.sort((a,b)=>a.order-b.order||a.id-b.id).forEach((c,i)=>c.order=i);try{localStorage.setItem(CAT_KEY,JSON.stringify(catalogCats))}catch(_){}window.dispatchEvent(new CustomEvent('sun:catalog-categories-changed'));} + function tabTextColor(value){const hex=String(value||'').trim();const m=/^#([0-9a-f]{6})$/i.exec(hex);if(!m)return '#fff';const n=parseInt(m[1],16),r=(n>>16)&255,g=(n>>8)&255,b=n&255;return (r*299+g*587+b*114)>160000?'#15364c':'#fff'} + const catById=id=>catalogCats.find(c=>Number(c.id)===Number(id)); + const catName=id=>catById(id)?.name||'Каталог'; + const visibleCats=()=>catalogCats.filter(c=>!c.hidden).sort((a,b)=>a.order-b.order); + let activeCat=visibleCats()[0]?.id??0; + let catalogQuery=''; + let managerQuery=''; + window.sunCatalogCategoryName=catName; + window.sunCatalogCategories=()=>catalogCats.map(c=>({...c})); + window.sunCurrentCatalogCategory=()=>activeCat; + + function ensureCategoryModal(){ + if($('sunCategoryManager'))return; + const modal=document.createElement('div');modal.id='sunCategoryManager';modal.className='modal'; + modal.innerHTML=`

Вкладки каталога

Скрытая вкладка и её товары не удаляются — её можно вернуть.

`; + document.body.appendChild(modal); + $('sunAddCategoryBtn').onclick=()=>{ + const name=$('sunNewCategoryName').value.trim();if(!name)return; + const nextId=Math.max(4,...catalogCats.map(c=>Number(c.id)||0))+1; + catalogCats.push({id:nextId,name,accent:'#708090',prep:true,hidden:false,order:catalogCats.length});saveCats();$('sunNewCategoryName').value='';renderCategoryManager();renderCategoryTabs();syncEditorCategoryOptions(); + }; + } + function renderCategoryManager(){ + ensureCategoryModal();const root=$('sunCategoryRows');if(!root)return; + root.innerHTML=catalogCats.slice().sort((a,b)=>a.order-b.order).map(c=>{ + const count=(typeof boxes!=='undefined'?boxes:[]).filter(b=>Number(b.category||0)===Number(c.id)).length; + return `
${count} поз.
`; + }).join(''); + qa('[data-cat-name]',root).forEach(input=>input.onchange=()=>{const c=catById(input.dataset.catName);if(!c)return;c.name=input.value.trim()||c.name;saveCats();renderCategoryTabs();syncEditorCategoryOptions();renderCatalogV5();renderCategoryManager();}); + qa('[data-cat-color]',root).forEach(input=>{input.oninput=()=>{const c=catById(input.dataset.catColor);if(!c)return;c.accent=input.value||c.accent;renderCategoryTabs();};input.onchange=()=>{const c=catById(input.dataset.catColor);if(!c)return;c.accent=input.value||c.accent;saveCats();renderCategoryTabs();};}); + qa('[data-cat-toggle]',root).forEach(btn=>btn.onclick=()=>{const c=catById(btn.dataset.catToggle);if(!c)return;c.hidden=!c.hidden;if(c.hidden&&Number(activeCat)===Number(c.id))activeCat=visibleCats().find(x=>Number(x.id)!==Number(c.id))?.id??0;saveCats();renderCategoryTabs();syncEditorCategoryOptions();renderCatalogV5();renderCategoryManager();}); + const move=(id,dir)=>{const arr=catalogCats.slice().sort((a,b)=>a.order-b.order),i=arr.findIndex(c=>Number(c.id)===Number(id)),j=i+dir;if(i<0||j<0||j>=arr.length)return;[arr[i],arr[j]]=[arr[j],arr[i]];arr.forEach((c,k)=>c.order=k);catalogCats=arr;saveCats();renderCategoryTabs();renderCategoryManager();}; + qa('[data-cat-up]',root).forEach(btn=>btn.onclick=()=>move(btn.dataset.catUp,-1));qa('[data-cat-down]',root).forEach(btn=>btn.onclick=()=>move(btn.dataset.catDown,1)); + } + window.sunOpenCategoryManager=()=>{renderCategoryManager();window.modal?.('sunCategoryManager');}; + + function renderCategoryTabs(){ + const panel=document.querySelector('#new .cats');if(!panel)return; + const visible=visibleCats();if(!visible.some(c=>Number(c.id)===Number(activeCat)))activeCat=visible[0]?.id??0; + panel.innerHTML=visible.map(c=>``).join('')+'

Выберите раздел. Любую позицию можно добавить, изменить или удалить.

'; + qa('button',panel).forEach(btn=>{const c=catById(btn.dataset.category),on=Number(btn.dataset.category)===Number(activeCat);btn.style.background=c?.accent||'#708090';btn.style.color=tabTextColor(c?.accent||'#708090');btn.style.opacity=on?'1':'.72';btn.style.outline=on?'2px solid #15364c':'none';btn.style.outlineOffset=on?'2px':'0';btn.onclick=()=>{activeCat=Number(btn.dataset.category);catalogQuery='';const search=$('sunCatalogSearch');if(search)search.value='';renderCategoryTabs();renderCatalogV5();};}); + } + function syncEditorCategoryOptions(){ + const select=$('itemCategory');if(!select)return;const current=select.value; + select.innerHTML=catalogCats.slice().sort((a,b)=>a.order-b.order).map(c=>``).join(''); + if([...select.options].some(o=>o.value===current))select.value=current; + } + function ensureCatalogTools(){ + const head=document.querySelector('#new .catalog .catalog-head');if(!head)return; + const configure=qa('button',head).find(b=>(b.textContent||'').trim()==='Настроить');if(!configure)return; + let search=$('sunCatalogSearch');if(!search){search=document.createElement('input');search.id='sunCatalogSearch';search.className='sun-catalog-search';search.type='search';search.placeholder='Поиск по № бокса';search.inputMode='numeric';search.oninput=()=>{catalogQuery=search.value;renderCatalogV5();};configure.insertAdjacentElement('beforebegin',search)} + let tabs=$('sunCatalogTabsButton');if(!tabs){tabs=document.createElement('button');tabs.id='sunCatalogTabsButton';tabs.className='outline sun-catalog-tabs-button';tabs.type='button';tabs.textContent='Вкладки';tabs.onclick=window.sunOpenCategoryManager;configure.insertAdjacentElement('beforebegin',tabs)} + } + function lineUnitPrice(line){const direct=Number(line?.price);if(Number.isFinite(direct)&&direct>=0)return direct;const item=boxes.find(x=>String(x.id)===String(line?.id));return Math.max(0,Number(item?.price||0));} + function baseTotal(order){return (order?.lines||[]).reduce((s,l)=>s+lineUnitPrice(l)*Math.max(0,Number(l.qty||0)),0)} + window.sunBaseOrderTotal=baseTotal; + function parseWeightGrams(value){const raw=String(value??'').trim().toLowerCase().replace(',','.');if(!raw)return 0;const m=raw.match(/(\d+(?:\.\d+)?)/);if(!m)return 0;let n=Math.max(0,Number(m[1]||0));if(/кг/.test(raw))n*=1000;return Math.round(n)} + const pieceUnitRe=/^(?:шт\.?|пор\.?|кус\.?|шарик(?:а|ов|и)?|креманк(?:а|и|е)?)$/i; + function ingredientUnitWeight(row){if(!Array.isArray(row))return 0;const direct=Math.max(0,Number(row[3]||0));if(direct)return direct;const m=String(row[0]||'').match(/[—–-]\s*(\d+(?:[.,]\d+)?)\s*г(?:\s*\/\s*шт\.?)?\s*$/i);return m?Math.max(0,Number(String(m[1]).replace(',','.'))):0} + function cleanIngredientName(name){return String(name||'').replace(/\s*[—–-]\s*\d+(?:[.,]\d+)?\s*г(?:\s*\/\s*шт\.?)?\s*$/i,'').trim()} + function inferBoxPieces(item){const explicit=Math.max(0,Number(item?.pieces||0));if(explicit)return Math.round(explicit);let sum=0;for(const row of item?.ingredients||[]){if(!Array.isArray(row))continue;const unit=String(row[2]||'').trim().toLowerCase();if(pieceUnitRe.test(unit))sum+=Math.max(0,Number(row[1]||0));}return Math.round(sum)} + function ingredientDisplayLine(row){if(!Array.isArray(row)||!String(row[0]||'').trim())return'';const name=cleanIngredientName(row[0]),qty=Math.max(0,Number(row[1]||0)),unit=String(row[2]||'').trim(),w=ingredientUnitWeight(row);if(/^поз\.?$/i.test(unit)&&qty<=1)return `${name}${w?` — ${w.toLocaleString('ru-RU')} г/шт.`:''}`;const q=Number.isInteger(qty)?String(qty):qty.toLocaleString('ru-RU',{maximumFractionDigits:2});return `${q} ${unit} ${name}${w?` — ${w.toLocaleString('ru-RU')} г/шт.`:''}`.replace(/\s+/g,' ').trim()} + function normalizeBoxItem(item,{cleanup=false}={}){if(!item||![0,5].includes(Number(item.category||0)))return item;const legacyComposition=Array.isArray(item.composition)?item.composition.slice():[];item.ingredients=Array.isArray(item.ingredients)?item.ingredients.filter(r=>Array.isArray(r)&&String(r[0]||'').trim()).map((r,index)=>{const row=[String(r[0]||'').trim(),Math.max(0,Number(r[1]||0)),String(r[2]||'').trim()||'шт.',Math.max(0,Number(r[3]||0))];if(!row[3])row[3]=ingredientUnitWeight(r)||ingredientUnitWeight([legacyComposition[index]||'',0,'',0]);return row;}):[]; + // Box №18 had a total row "25 шт. тостов ассорти" mixed into two real varieties. The total belongs in pieces, not in composition. + if(String(item.id)==='catalog-013'||/№\s*18\b.*тосты\s+ассорти/i.test(String(item.name||''))){const idx=item.ingredients.findIndex(r=>/тостов?\s+ассорти/i.test(String(r[0]||''))&&pieceUnitRe.test(String(r[2]||''))&&Number(r[1]||0)>1);if(idx>=0){item.pieces=Math.max(Number(item.pieces||0),Number(item.ingredients[idx][1]||0));item.ingredients.splice(idx,1);}} + if(!Number(item.pieces||0))item.pieces=inferBoxPieces(item); + if((!Array.isArray(item.composition)||!item.composition.filter(Boolean).length)&&item.ingredients.length)item.composition=item.ingredients.map(ingredientDisplayLine).filter(Boolean); + return item;} + function weightText(item,prefix=true){const raw=String(item?.weight||'').trim();if(!raw)return'';return prefix?`Вес бокса: ${raw}`:raw} + function piecesText(item,prefix=true){const n=inferBoxPieces(item);if(!n)return'';return prefix?`Количество: ${n.toLocaleString('ru-RU')} шт.`:`${n.toLocaleString('ru-RU')} шт.`} + function ensureBoxWeightEditor(){ + const price=$('boxPrice'),dialog=document.querySelector('#editor .dialog');if(!price||!dialog)return null; + let label=$('sunBoxWeightLabel'); + if(!label){label=document.createElement('label');label.id='sunBoxWeightLabel';label.className='sun-box-weight-field';label.innerHTML='Вес бокса, г';price.closest('label')?.insertAdjacentElement('afterend',label)} + return label; + } + function ensureBoxPiecesEditor(){const weight=ensureBoxWeightEditor(),dialog=document.querySelector('#editor .dialog');if(!dialog)return null;let label=$('sunBoxPiecesLabel');if(!label){label=document.createElement('label');label.id='sunBoxPiecesLabel';label.className='sun-box-pieces-field';label.innerHTML='Количество канапе / шт. в боксеЭто общее количество готовых единиц в одном боксе. Не добавляйте его отдельной строкой в «Состав / ТТК».';weight?.insertAdjacentElement('afterend',label)}return label} + function syncBoxWeightEditor(category,item){const label=ensureBoxWeightEditor(),input=$('boxWeightGrams'),piecesLabel=ensureBoxPiecesEditor(),piecesInput=$('boxPiecesCount');if(!label||!input)return;const isBox=[0,5].includes(Number(category));label.style.display=isBox?'flex':'none';if(piecesLabel)piecesLabel.style.display=isBox?'flex':'none';if(isBox){input.value=parseWeightGrams(item?.weight)||'';if(piecesInput)piecesInput.value=inferBoxPieces(item)||''}} + function installIngredientEditorV175(){window.renderIngredients=()=>{if(!edited)return;normalizeBoxItem(edited);const root=$('ingredients');if(!root)return;const head='
Вид / названиеКол-воЕд.Вес 1 шт., г
';root.innerHTML=head+edited.ingredients.map((row,i)=>{const w=ingredientUnitWeight(row);return `
`}).join('')};window.addIngredient=()=>{if(!edited)return;if(!Array.isArray(edited.ingredients))edited.ingredients=[];edited.ingredients.push(['Новая позиция',1,'шт.',0]);window.renderIngredients();};} + installIngredientEditorV175(); + function renderOrderLines(){ + if(!$('lines'))return; + if(!(draft.lines||[]).length){$('lines').innerHTML='

Выберите позиции из каталога слева.

';return} + const rows=draft.lines.map((line,index)=>{const item=boxes.find(x=>String(x.id)===String(line.id));if(!item)return'';const qty=Math.max(1,Number(line.qty||1)),price=lineUnitPrice(line),isBox=[0,5].includes(Number(item.category||0));return `
${esc(String(item.name||'').replace(/^(?:Фуршетный\s+)?бокс\s*№\s*(\d+)\s*(?:[—–-]\s*)?/i,'№ $1 '))}${isBox&&(item.weight||inferBoxPieces(item))?`${item.weight?`Вес: ${esc(item.weight)}`:''}${item.weight&&inferBoxPieces(item)?' · ':''}${inferBoxPieces(item)?`Количество: ${inferBoxPieces(item)} шт.`:''}`:''}
${money(price*qty)}
`}).join(''); + $('lines').innerHTML=`
НаименованиеКол.ЦенаСтоимость
${rows}
Стоимость позиций${money(baseTotal(draft))}
`; + } + window.sunOrderLinePriceChanged=(index,input)=>{const line=draft.lines?.[index];if(!line)return;line.price=Math.max(0,Math.round(Number(input.value||0)));const row=input.closest('.sun-order-line-row'),sum=row?.querySelector('.sun-order-line-sum b');if(sum)sum.textContent=money(line.price*Math.max(0,Number(line.qty||0)));const total=$('lines')?.querySelector('.sun-order-line-total b');if(total)total.textContent=money(baseTotal(draft));updateOrderSummary();}; + function boxNumber(item){const explicit=Number(item?.boxNumber);if(Number.isInteger(explicit)&&explicit>=0)return explicit;const name=String(item?.name||'');const match=name.match(/(?:бокс\s*)?№\s*0*(\d+)/i)||name.match(/^\s*№\s*0*(\d+)/i);return match?Number(match[1]):null} + function wantedBoxNumber(query){const raw=String(query||'').trim();if(!raw)return null;const match=raw.match(/^(?:бокс\s*)?(?:№\s*)?0*(\d+)\s*$/i);return match?Number(match[1]):null} + function itemMatches(item,query){if(!query)return true;if(Number(activeCat)===0){const wanted=wantedBoxNumber(query);return wanted!==null&&boxNumber(item)===wanted}const q=query.trim().toLowerCase();const parts=[item.name,item.catalogSection,item.weight,...(Array.isArray(item.composition)?item.composition:[]),...(Array.isArray(item.ingredients)?item.ingredients.map(x=>x?.[0]):[])];return parts.join(' ').toLowerCase().includes(q)} + function renderCatalogV5(){ + renderCategoryTabs();ensureCatalogTools();const catalogSearch=$('sunCatalogSearch');if(catalogSearch){const boxMode=[0,5].includes(Number(activeCat));catalogSearch.placeholder=boxMode?'Поиск по № бокса':'Поиск по позиции';catalogSearch.inputMode=boxMode?'numeric':'search';}const title=document.querySelector('#new .catalog h1');if(title)title.textContent=catName(activeCat); + const all=boxes.filter(item=>Number(item.category||0)===Number(activeCat)),items=all.filter(item=>itemMatches(item,catalogQuery));const cat=catById(activeCat)||{name:'Каталог',prep:true}; + const cards=items.map(item=>``).join(''); + const addText=Number(activeCat)===0?'Добавить бокс':(Number(activeCat)===5?'Добавить премиум':'Добавить позицию'); + const empty=items.length?'':`
${catalogQuery?'По вашему запросу ничего не найдено.':`В разделе «${esc(cat.name)}» пока нет позиций.`}
`; + $('tiles').innerHTML=`${cards}${empty}`; + renderOrderLines();updateOrderSummary(); + } + window.render=renderCatalogV5; + window.add=id=>{const item=boxes.find(x=>String(x.id)===String(id));if(!item)return;let line=(draft.lines||[]).find(x=>String(x.id)===String(id));if(line)line.qty=Math.max(0,Number(line.qty||0))+1;else{if(!Array.isArray(draft.lines))draft.lines=[];draft.lines.push({id:item.id,qty:1,price:Math.max(0,Number(item.price||0))})}renderCatalogV5();}; + window.qty=(id,n)=>{if(!Array.isArray(draft.lines))draft.lines=[];const next=Math.max(0,Number(n||0));if(next<1)draft.lines=draft.lines.filter(x=>String(x.id)!==String(id));else{const line=draft.lines.find(x=>String(x.id)===String(id));if(line)line.qty=next}renderCatalogV5();}; + + function ensureManagerToolbar(){ + const dialog=document.querySelector('#manager .dialog');if(!dialog)return;let bar=dialog.querySelector('.sun-manager-toolbar');const add=qa(':scope > .primary',dialog)[0]||qa('.primary',dialog).find(b=>(b.textContent||'').includes('Добавить')); + if(!bar&&add){bar=document.createElement('div');bar.className='sun-manager-toolbar';add.parentNode.insertBefore(bar,add);bar.appendChild(add);const input=document.createElement('input');input.id='sunManagerSearch';input.className='sun-manager-search';input.type='search';input.placeholder=Number(activeCat)===0?'Поиск по № бокса':'Поиск по позиции';input.inputMode=Number(activeCat)===0?'numeric':'search';input.oninput=()=>{managerQuery=input.value;renderManagerV5();};bar.appendChild(input);} + } + function renderManagerV5(){ + ensureManagerToolbar();const managerSearch=$('sunManagerSearch');if(managerSearch){const boxMode=[0,5].includes(Number(activeCat));managerSearch.placeholder=boxMode?'Поиск по № бокса':'Поиск по позиции';managerSearch.inputMode=boxMode?'numeric':'search';}const title=document.querySelector('#manager .dialog-head h2'),add=document.querySelector('#manager .sun-manager-toolbar .primary')||document.querySelector('#manager .primary');if(title)title.textContent=catName(activeCat);if(add){add.textContent=Number(activeCat)===0?'Добавить бокс':(Number(activeCat)===5?'Добавить премиум':'Добавить позицию');add.onclick=()=>window.editBox(null)} + const items=boxes.filter(i=>Number(i.category||0)===Number(activeCat)&&itemMatches(i,managerQuery)),cat=catById(activeCat)||{prep:true}; + $('managerList').innerHTML=items.length?items.map(item=>``).join(''):'

Ничего не найдено.

'; + } + window.openManager=()=>{managerQuery='';ensureManagerToolbar();if($('sunManagerSearch'))$('sunManagerSearch').value='';renderManagerV5();window.modal?.('manager');}; + window.editBox=id=>{ + edited=id?structuredClone(boxes.find(x=>String(x.id)===String(id))):{id:'',name:Number(activeCat)===0?'Новый бокс':(Number(activeCat)===5?'Новая премиум позиция':'Новая позиция'),category:Number(activeCat),price:0,oldPrice:0,ingredients:[]};if(!edited)return; + if(!Array.isArray(edited.ingredients))edited.ingredients=[];normalizeBoxItem(edited);syncEditorCategoryOptions();$('itemCategory').value=String(edited.category??activeCat);$('boxName').value=edited.name||'';$('boxPrice').value=Number(edited.price||0);if($('boxOldPrice'))$('boxOldPrice').value=Number(edited.oldPrice||0);if($('boxCompositionText'))$('boxCompositionText').value=(Array.isArray(edited.composition)?edited.composition:[]).join('\n');syncBoxWeightEditor(edited.category??activeCat,edited);$('editPhoto').style.display=edited.photo?'block':'none';$('editPhoto').src=edited.photo||'';$('deleteBox').style.display=edited.id?'block':'none'; + const cat=catById(edited.category)||{name:'Каталог',prep:true};$('editTitle').textContent=edited.id?`Изменить: ${cat.name}`:(Number(edited.category)===0?'Новый бокс':(Number(edited.category)===5?'Новая премиум позиция':'Новая позиция'));const zone=document.querySelector('#editor .ingredient-zone');if(zone)zone.style.display=cat.prep?'block':'none';window.renderIngredients?.();window.modal?.('editor'); + }; + if($('itemCategory'))$('itemCategory').onchange=function(){if(!edited)return;edited.category=Number(this.value);const c=catById(edited.category)||{name:'Каталог',prep:true};const zone=document.querySelector('#editor .ingredient-zone');if(zone)zone.style.display=c.prep?'block':'none';syncBoxWeightEditor(edited.category,edited);$('editTitle').textContent=edited.id?`Изменить: ${c.name}`:(Number(edited.category)===0?'Новый бокс':(Number(edited.category)===5?'Новая премиум позиция':'Новая позиция'));}; + window.saveBox=()=>{if(!edited)return;edited.name=$('boxName').value.trim();edited.price=Math.max(0,Number($('boxPrice').value||0));edited.oldPrice=Math.max(0,Number($('boxOldPrice')?.value||0));if(!(edited.oldPrice>edited.price))delete edited.oldPrice;edited.sale=Boolean(edited.oldPrice&&edited.oldPrice>edited.price);edited.category=Number($('itemCategory').value);if([0,5].includes(edited.category)){const grams=Math.max(0,Math.round(Number($('boxWeightGrams')?.value||0)));edited.weight=grams?`${grams} г`:'';edited.pieces=Math.max(0,Math.round(Number($('boxPiecesCount')?.value||0)));}edited.ingredients=Array.isArray(edited.ingredients)?edited.ingredients:[];if([0,5].includes(edited.category)&&$('boxCompositionText')){const next=$('boxCompositionText').value.split(/\r?\n/).map(x=>x.trim()).filter(Boolean);const before=JSON.stringify(Array.isArray(edited.composition)?edited.composition:[]);edited.composition=next;if(JSON.stringify(next)!==before)edited.compositionSource='manual';}normalizeBoxItem(edited,{cleanup:true});if(!edited.name)return alert('Введите название.');if(!edited.id){edited.id=(typeof sunUUID==='function'?sunUUID():`item-${Date.now()}`);boxes.push(edited)}else{const i=boxes.findIndex(x=>String(x.id)===String(edited.id));if(i>=0)boxes[i]=edited}if(!catById(edited.category)?.hidden)activeCat=edited.category;persist();closeModal('editor');closeModal('manager');renderCatalogV5();renderOrders?.();window.sunClientOfferCatalogChanged?.(edited.id);}; + window.removeBox=()=>{if(!edited?.id||!confirm(`Удалить «${edited.name}» из каталога?`))return;boxes=boxes.filter(x=>String(x.id)!==String(edited.id));draft.lines=(draft.lines||[]).filter(x=>String(x.id)!==String(edited.id));persist();closeModal('editor');closeModal('manager');renderCatalogV5();renderOrders?.();}; + + function fileAsDataUrl(file){return new Promise((resolve,reject)=>{const reader=new FileReader();reader.onerror=()=>reject(reader.error||new Error('FileReader error'));reader.onload=()=>resolve(String(reader.result||''));reader.readAsDataURL(file)})} + function compactCatalogPhotoData(dataUrl,{maxSide=900,maxChars=130000,quality=.78,force=false}={}){return new Promise(resolve=>{if(!/^data:image\//i.test(String(dataUrl||'')))return resolve(dataUrl);const image=new Image();image.onerror=()=>resolve(dataUrl);image.onload=()=>{const iw=Math.max(1,image.naturalWidth||image.width||1),ih=Math.max(1,image.naturalHeight||image.height||1);if(!force&&String(dataUrl).length<=maxChars&&Math.max(iw,ih)<=maxSide)return resolve(dataUrl);let scale=Math.min(1,maxSide/Math.max(iw,ih)),q=quality,best=dataUrl;for(let attempt=0;attempt<5;attempt++){const canvas=document.createElement('canvas');canvas.width=Math.max(1,Math.round(iw*scale));canvas.height=Math.max(1,Math.round(ih*scale));const ctx=canvas.getContext('2d');if(!ctx)break;ctx.fillStyle='#fff';ctx.fillRect(0,0,canvas.width,canvas.height);ctx.drawImage(image,0,0,canvas.width,canvas.height);try{best=canvas.toDataURL('image/jpeg',q)||best}catch(_){break}if(best.length<=maxChars)break;scale*=.78;q=Math.max(.52,q-.07)}resolve(best)};image.src=dataUrl})} + window.sunCompactCatalogPhotoData=compactCatalogPhotoData; + window.readPhoto=async input=>{const file=input?.files?.[0];if(!file||!edited)return;try{const original=await fileAsDataUrl(file);const optimized=await compactCatalogPhotoData(original);edited.photo=optimized;$('editPhoto').src=optimized;$('editPhoto').style.display='block';if(optimized.length+5000({name:'',inn:'',kpp:'',ogrn:'',legalAddress:'',phone:'',email:'',bank:'',bik:'',account:'',corrAccount:'',contact:''}); + function legacyBuyer(){try{const s=JSON.parse(localStorage.getItem('sunReceiptSettingsV2')||'{}');return {name:s.buyerName||'',inn:s.buyerInn||'',kpp:s.buyerKpp||'',ogrn:s.buyerOgrn||'',legalAddress:s.buyerLegalAddress||'',phone:s.buyerPhone||'',email:s.buyerEmail||'',bank:s.buyerBank||'',bik:s.buyerBik||'',account:s.buyerAccount||'',corrAccount:s.buyerCorrAccount||'',contact:s.buyerContact||''}}catch(_){return buyerDefaults()}} + function ensureRequisitesTab(){ + const sub=document.querySelector('#new .subnav');if(!sub)return; + let btn=$('requisitesTab');if(!btn){btn=document.createElement('button');btn.id='requisitesTab';btn.type='button';btn.textContent='Реквизиты';sub.appendChild(btn)} + let panel=$('orderRequisites');if(!panel){panel=document.createElement('div');panel.id='orderRequisites';panel.className='sun-order-requisites';panel.innerHTML=`

Реквизиты покупателя

`;$('details').insertAdjacentElement('afterend',panel)} + $('orderTab').onclick=()=>setOrderPane('order');$('detailTab').onclick=()=>setOrderPane('details');btn.onclick=()=>setOrderPane('requisites'); + qa('#orderRequisites input:not([type=file])').forEach(input=>input.addEventListener('input',captureBuyerFromFields)); + } + function setOrderPane(tab){ensureRequisitesTab();$('orderForm').style.display=tab==='order'?'block':'none';$('details').style.display=tab==='details'?'block':'none';$('orderRequisites').style.display=tab==='requisites'?'block':'none';$('orderTab').classList.toggle('on',tab==='order');$('detailTab').classList.toggle('on',tab==='details');$('requisitesTab').classList.toggle('on',tab==='requisites');if(tab==='requisites')syncBuyerFields();} + window.details=on=>setOrderPane(on?'details':'order'); + function buyerObject(){return {name:$('orderBuyerName')?.value.trim()||'',contact:$('orderBuyerContact')?.value.trim()||'',inn:$('orderBuyerInn')?.value.trim()||'',kpp:$('orderBuyerKpp')?.value.trim()||'',ogrn:$('orderBuyerOgrn')?.value.trim()||'',phone:$('orderBuyerPhone')?.value.trim()||'',legalAddress:$('orderBuyerLegalAddress')?.value.trim()||'',email:$('orderBuyerEmail')?.value.trim()||'',bank:$('orderBuyerBank')?.value.trim()||'',bik:$('orderBuyerBik')?.value.trim()||'',account:$('orderBuyerAccount')?.value.trim()||'',corrAccount:$('orderBuyerCorrAccount')?.value.trim()||''}} + function captureBuyerFromFields(){if(typeof draft==='undefined'||!draft)return;draft.buyerRequisites=buyerObject();} + function syncBuyerFields(){ensureRequisitesTab();let b=draft?.buyerRequisites;if(!b){const legacy=legacyBuyer(),hasLegacy=Object.values(legacy).some(Boolean);b=hasLegacy?legacy:buyerDefaults();draft.buyerRequisites={...b}};const map={orderBuyerName:'name',orderBuyerContact:'contact',orderBuyerInn:'inn',orderBuyerKpp:'kpp',orderBuyerOgrn:'ogrn',orderBuyerPhone:'phone',orderBuyerLegalAddress:'legalAddress',orderBuyerEmail:'email',orderBuyerBank:'bank',orderBuyerBik:'bik',orderBuyerAccount:'account',orderBuyerCorrAccount:'corrAccount'};Object.entries(map).forEach(([id,key])=>{if($(id))$(id).value=b?.[key]||''});} + + // ---------------- QR default + image handling ---------------- + const QR_KEY='sunDefaultOrderQrV1'; + const defaultQr=()=>{try{return localStorage.getItem(QR_KEY)||''}catch(_){return''}}; + function imageData(file,max=512){return new Promise((resolve,reject)=>{const r=new FileReader();r.onerror=reject;r.onload=()=>{const img=new Image();img.onerror=reject;img.onload=()=>{const scale=Math.min(1,max/Math.max(img.width||1,img.height||1)),c=document.createElement('canvas');c.width=Math.max(1,Math.round(img.width*scale));c.height=Math.max(1,Math.round(img.height*scale));const ctx=c.getContext('2d');ctx.fillStyle='#fff';ctx.fillRect(0,0,c.width,c.height);ctx.drawImage(img,0,0,c.width,c.height);resolve(c.toDataURL('image/png'))};img.src=r.result};r.readAsDataURL(file)})} + function ensureDefaultQrSetting(){ + const grid=document.querySelector('#enterprise-settings .enterprise-grid');if(!grid)return;let card=$('sunDefaultQrCard');if(!card){card=document.createElement('section');card.id='sunDefaultQrCard';card.className='enterprise-card wide';card.innerHTML=`

QR-код для бланка заказа

Загрузите QR-код один раз. Он будет автоматически использоваться во всех бланках заказов, пока вы не загрузите другой.

QR не загружен
`;const audit=qa(':scope > .enterprise-card',grid).find(x=>(x.querySelector('h2')?.textContent||'').trim()==='История изменений');window.SunSafe.insertBefore(grid,card,audit||null);$('sunDefaultQrUpload').onclick=()=>$('sunDefaultQrFile').click();$('sunDefaultQrFile').onchange=async()=>{const f=$('sunDefaultQrFile').files?.[0];if(!f)return;try{localStorage.setItem(QR_KEY,await imageData(f));renderDefaultQr();toast('QR-код для бланков сохранён.')}catch(_){toast('Не удалось загрузить QR-код.','warn')}};} + renderDefaultQr();const audit=qa(':scope > .enterprise-card',grid).find(x=>(x.querySelector('h2')?.textContent||'').trim()==='История изменений');if(audit&&grid.lastElementChild!==audit)grid.appendChild(audit); + } + function renderDefaultQr(){const root=$('sunDefaultQrPreview');if(!root)return;const src=defaultQr();root.innerHTML=src?`QR`:'QR не загружен';} + + // ---------------- Discounts + promo tracking ---------------- + const PROMO_KEY='sunPromoCodesV1'; + const localDateIso=()=>{const d=new Date(),p=n=>String(n).padStart(2,'0');return `${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())}`}; + function promoCodes(){try{const list=JSON.parse(localStorage.getItem(PROMO_KEY)||'[]');return Array.isArray(list)?list:[]}catch(_){return[]}} + function promoActive(p,date=localDateIso()){if(!p||p.enabled===false)return false;const start=String(p.startDate||''),end=String(p.endDate||'');return (!start||start<=date)&&(!end||end>=date)} + function promoDiscountText(p){const value=Math.max(0,Number(p?.discountValue||0));return p?.discountType==='amount'?money(value):`${value.toLocaleString('ru-RU',{maximumFractionDigits:2})}%`} + function promoById(id){return promoCodes().find(p=>String(p.id)===String(id))||null} + function promoByCode(code){const key=String(code||'').trim().toLowerCase();return promoCodes().find(p=>String(p.code||'').trim().toLowerCase()===key)||null} + function ensureDiscountUI(){ + const form=document.querySelector('#new .money-form');if(!form)return; + if(!$('deliveryCost')){ + const label=document.createElement('label');label.id='deliveryCostLabel';label.innerHTML='Доставка, ₽'; + const prepay=$('prepayment')?.closest('label');if(prepay)prepay.insertAdjacentElement('beforebegin',label);else form.appendChild(label); + $('deliveryCost')?.addEventListener('input',()=>{captureDelivery();updateOrderSummary()}); + } + let panel=$('sunDiscountPanel'); + const desired=``; + if(!panel){panel=document.createElement('div');panel.id='sunDiscountPanel';panel.className='sun-discount-panel';panel.innerHTML=desired;form.insertAdjacentElement('afterend',panel)} + else if(!$('promoCodeSelect'))panel.innerHTML=desired; + if(panel.dataset.promoBound!=='1'){ + panel.dataset.promoBound='1'; + $('manualDiscountValue')?.addEventListener('input',()=>{captureDiscounts();updateOrderSummary()}); + $('manualDiscountType')?.addEventListener('change',()=>{captureDiscounts();updateOrderSummary()}); + $('promoCodeSelect')?.addEventListener('change',()=>{captureDiscounts();updateOrderSummary()}); + } + const summary=document.querySelector('#new .order-summary');if(summary&&!$('summaryNetTotal'))summary.innerHTML=`

Стоимость позиций0 ₽

Скидка− 0 ₽

Промокод − 0 ₽

Доставка0 ₽

Итого0 ₽

Предоплата0 ₽

К оплате0 ₽

`; + } + function renderPromoSelect(){ + ensureDiscountUI();const select=$('promoCodeSelect');if(!select)return; + const active=promoCodes().filter(p=>promoActive(p)).sort((a,b)=>String(a.code||'').localeCompare(String(b.code||''),'ru')); + const currentId=String(draft?.promoId||''),currentCode=String(draft?.promoCode||'').trim(); + let selected=''; + const opts=['']; + active.forEach(p=>{opts.push(``);if(currentId&&String(p.id)===currentId)selected=String(p.id)}); + if(currentCode&&!selected){ + const saved=promoById(currentId)||promoByCode(currentCode),label=saved?(promoActive(saved)?'активен':'срок истёк / отключён'):'сохранён в заказе'; + opts.push(``);selected='__snapshot__'; + } + select.innerHTML=opts.join('');select.value=selected; + updatePromoMeta(); + } + function updatePromoMeta(){const meta=$('promoCodeMeta'),select=$('promoCodeSelect');if(!meta||!select)return;const id=select.value;if(!id){meta.textContent='Выберите активный промокод из раздела «Рассылки».';return}if(id==='__snapshot__'){meta.textContent=`Условия сохранены в заказе: ${draft?.promoCode||''} · ${draft?.promoDiscountType==='amount'?money(draft?.promoDiscountValue||0):`${Number(draft?.promoDiscountValue||0).toLocaleString('ru-RU',{maximumFractionDigits:2})}%`}.`;return}const p=promoById(id);meta.textContent=p?`${p.code} · скидка ${promoDiscountText(p)} · действует ${p.startDate||'без даты'} — ${p.endDate||'без даты'}`:'Промокод недоступен.';} + function captureDiscounts(){ + if(!draft)return;draft.manualDiscountValue=Math.max(0,Number($('manualDiscountValue')?.value||0));draft.manualDiscountType=$('manualDiscountType')?.value==='amount'?'amount':'percent'; + const select=$('promoCodeSelect');if(!select)return;const id=String(select.value||''); + if(!id){draft.promoId='';draft.promoCode='';draft.promoDiscountValue=0;draft.promoDiscountType='percent';draft.promoStartDate='';draft.promoEndDate='';return} + if(id==='__snapshot__'){updatePromoMeta();return} + const p=promoById(id);if(!p||!promoActive(p)){renderPromoSelect();return} + draft.promoId=String(p.id||'');draft.promoCode=String(p.code||'').trim();draft.promoDiscountValue=Math.max(0,Number(p.discountValue||0));draft.promoDiscountType=p.discountType==='amount'?'amount':'percent';draft.promoStartDate=String(p.startDate||'');draft.promoEndDate=String(p.endDate||'');updatePromoMeta(); + } + function captureDelivery(){if(!draft)return;draft.deliveryCost=Math.max(0,Number($('deliveryCost')?.value||0));} + function syncDeliveryField(){ensureDiscountUI();if($('deliveryCost'))$('deliveryCost').value=Math.max(0,Number(draft?.deliveryCost||0));} + function discountAmount(value,type,base){const n=Math.max(0,Number(value||0));return Math.min(base,type==='amount'?n:base*Math.min(100,n)/100)} + function pricing(order=draft){const base=baseTotal(order),manual=discountAmount(order?.manualDiscountValue,order?.manualDiscountType,base),afterManual=Math.max(0,base-manual),promo=discountAmount(order?.promoDiscountValue,order?.promoDiscountType,afterManual),itemsTotal=Math.max(0,afterManual-promo),delivery=Math.max(0,Number(order?.deliveryCost||0)),total=itemsTotal+delivery,paid=Math.max(0,Number(order===draft?($('prepayment')?.value||order?.prepayment||0):(order?.prepayment||0))),balance=Math.max(0,total-paid);return {base,manual,promo,itemsTotal,delivery,total,paid,balance,discount:manual+promo}} + window.sunOrderPricing=pricing; + window.calcOrderTotal=()=>pricing(draft).total; + window.orderTotalValue=order=>pricing(order).total; + window.updateOrderSummary=()=>{ensureDiscountUI();captureDiscounts();const p=pricing(draft);if($('orderTotal'))$('orderTotal').value=Math.round(p.total);if($('balance'))$('balance').value=Math.round(p.balance);if($('summaryTotal'))$('summaryTotal').textContent=money(p.base);if($('summaryManualDiscount'))$('summaryManualDiscount').textContent=`− ${money(p.manual)}`;if($('summaryPromoDiscount'))$('summaryPromoDiscount').textContent=`− ${money(p.promo)}`;if($('summaryPromoCode'))$('summaryPromoCode').textContent=draft.promoCode?`(${draft.promoCode})`:'';if($('summaryDelivery'))$('summaryDelivery').textContent=money(p.delivery);if($('summaryNetTotal'))$('summaryNetTotal').textContent=money(p.total);if($('summaryPrepayment'))$('summaryPrepayment').textContent=money(p.paid);if($('summaryBalance'))$('summaryBalance').textContent=money(p.balance);updatePromoMeta();}; + function syncDiscountFields(){ensureDiscountUI();$('manualDiscountValue').value=Number(draft?.manualDiscountValue||0);$('manualDiscountType').value=draft?.manualDiscountType==='amount'?'amount':'percent';syncDeliveryField();renderPromoSelect();updateOrderSummary();} + window.sunRefreshOrderPromos=()=>{renderPromoSelect();updateOrderSummary();}; + + // ---------------- Seller settings only ---------------- + function simplifySellerSettings(){ + const card=$('sunReceiptSettingsCard');if(!card||card.dataset.sunSellerOnly==='1')return; + const sections=qa('.sun-receipt-settings-section',card);sections.forEach(s=>{if((s.querySelector('h3')?.textContent||'').includes('Покупатель'))s.classList.add('sun-buyer-settings-hidden')}); + const buyerCheck=$('receiptIncludeBuyer');buyerCheck?.closest('label')?.classList.add('sun-buyer-settings-hidden'); + const h=card.querySelector('h2');if(h&&(h.textContent||'').trim()!=='Реквизиты компании')h.textContent='Реквизиты компании'; + const hint=card.querySelector(':scope > .hint'),hintText='Реквизиты продавца для товарного чека. Реквизиты покупателя вводятся отдельно в каждом заказе во вкладке «Реквизиты».';if(hint&&hint.textContent!==hintText)hint.textContent=hintText; + card.dataset.sunSellerOnly='1'; + } + function readSeller(){const d={includeSeller:true,sellerName:'Солнце Кейтеринг',sellerLegalName:'',sellerInn:'',sellerKpp:'',sellerOgrn:'',sellerLegalAddress:'',sellerPhone:'',sellerEmail:'',sellerBank:'',sellerBik:'',sellerAccount:'',sellerCorrAccount:''};try{return {...d,...JSON.parse(localStorage.getItem('sunReceiptSettingsV2')||'{}')}}catch(_){return d}} + function party(title,rows){const list=rows.filter(([,v])=>String(v||'').trim());if(!list.length)return'';return `

${esc(title)}

${list.map(([k,v])=>`
${esc(k)}${esc(v)}
`).join('')}
`} + + // ---------------- Order blank + compact receipt ---------------- + function currentRows(){return (draft.lines||[]).map((line,index)=>{const item=boxes.find(x=>String(x.id)===String(line.id)),price=lineUnitPrice(line),qty=Math.max(0,Number(line.qty||0)),categoryId=Number(item?.category??line.category??0);return{index:index+1,name:item?.name||line.name||`Позиция ${line.id||''}`,category:catName(categoryId),categoryId,weight:[0,5].includes(categoryId)?String(item?.weight||''):'',pieces:[0,5].includes(categoryId)?inferBoxPieces(item):0,price,qty,sum:price*qty}}).filter(r=>r.qty>0)} + const docDate=raw=>{if(!raw)return'—';const d=new Date(`${raw}T00:00:00`);return Number.isNaN(d.getTime())?raw:d.toLocaleDateString('ru-RU')}; + const docActions=()=>`
`; + function showOrderBlank(){ + captureBuyerFromFields();captureDiscounts();const rows=currentRows(),p=pricing(draft),qr=defaultQr(),b=draft.buyerRequisites||buyerDefaults(),dialog=document.querySelector('#preview .dialog');if(!dialog)return;const number=draft.id||'черновик',qrNote=String(draft.blankQrText||'').trim(); + dialog.innerHTML=`
Солнце Кейтеринг
Солнце КейтерингСанкт-Петербург · кейтеринг и выездные мероприятия

Заказ № ${esc(number)}

${esc($('event')?.value||'Новое мероприятие')}

${draft.status?`${esc(draft.status)}`:''}
${qr?`
QR-код
${esc(qrNote)}
`:''}
Клиент${esc($('contact')?.value||'—')}
Телефон${esc($('phone')?.value||'—')}
Дата и время выдачи${esc(docDate($('date')?.value))} · ${esc($('time')?.value||'—')}
Адрес доставки${esc($('address')?.value||'—')}
${b.name?`
Компания покупателя${esc(b.name)}
`:''}
${rows.length?rows.map(r=>``).join(''):''}
ПозицияЦенаКол.Сумма
${r.index}${esc(r.name)}${esc(r.category)}${(r.weight||r.pieces)?`${r.weight?`Вес бокса: ${esc(r.weight)}`:''}${r.weight&&r.pieces?' · ':''}${r.pieces?`Количество: ${esc(r.pieces)} шт.`:''}`:''}${money(r.price)}${r.qty}${money(r.sum)}
В заказе пока нет позиций.

Стоимость позиций${money(p.base)}

${p.manual?`

Скидка− ${money(p.manual)}

`:''}${p.promo?`

Промокод ${esc(draft.promoCode||'')}− ${money(p.promo)}

`:''}

Доставка${money(p.delivery)}

Итого${money(p.total)}

Предоплата${money(p.paid)}

Остаток к оплате${money(p.balance)}

${$('note')?.value?`
Комментарий к заказу${esc($('note').value)}
`:''}${docActions()}
`;window.modal?.('preview');const noteEl=$('sunBlankQrText');if(noteEl){const save=()=>{const text=String(noteEl.innerText||noteEl.textContent||'').replace(/\u00a0/g,' ').trim();draft.blankQrText=text;if(draft.id){const saved=(orders||[]).find(o=>String(o.id)===String(draft.id));if(saved){saved.blankQrText=text;try{persist()}catch(_){}}}};noteEl.addEventListener('input',save);noteEl.addEventListener('blur',save);} + } + function showReceipt(){ + captureBuyerFromFields();captureDiscounts();const rows=currentRows(),p=pricing(draft),s=readSeller(),b=draft.buyerRequisites||buyerDefaults(),dialog=document.querySelector('#preview .dialog');if(!dialog)return;const seller=s.includeSeller!==false?party('Продавец',[['Наименование',s.sellerLegalName||s.sellerName],['ИНН',s.sellerInn],['КПП',s.sellerKpp],['ОГРН / ОГРНИП',s.sellerOgrn],['Адрес',s.sellerLegalAddress],['Телефон',s.sellerPhone],['E-mail',s.sellerEmail],['Банк',s.sellerBank],['БИК',s.sellerBik],['Расч. счёт',s.sellerAccount],['Корр. счёт',s.sellerCorrAccount]]):'';const buyer=party('Покупатель',[['Компания / ИП',b.name],['ИНН',b.inn],['КПП',b.kpp],['ОГРН / ОГРНИП',b.ogrn],['Адрес',b.legalAddress],['Контакт',b.contact],['Телефон',b.phone],['E-mail',b.email],['Банк',b.bank],['БИК',b.bik],['Расч. счёт',b.account],['Корр. счёт',b.corrAccount]]); + dialog.innerHTML=`
Солнце Кейтеринг
${esc(s.sellerName||'Солнце Кейтеринг')}${esc(s.sellerLegalName||'')}

Товарный чек № ${esc(draft.id||'—')}

${esc(docDate($('date')?.value||draft.date))} · ${esc($('time')?.value||draft.time||'')}

${seller}${buyer}${rows.length?rows.map(r=>``).join(''):''}
НаименованиеЦенаКол.Сумма
${r.index}${esc(r.name)}${(r.weight||r.pieces)?`${r.weight?`Вес бокса: ${esc(r.weight)}`:''}${r.weight&&r.pieces?' · ':''}${r.pieces?`Количество: ${esc(r.pieces)} шт.`:''}`:''}${money(r.price)}${r.qty}${money(r.sum)}
В заказе нет позиций.

Стоимость позиций${money(p.base)}

${p.manual?`

Скидка− ${money(p.manual)}

`:''}${p.promo?`

Промокод ${esc(draft.promoCode||'')}− ${money(p.promo)}

`:''}

Доставка${money(p.delivery)}

Итого${money(p.total)}

Оплачено${money(p.paid)}

К оплате${money(p.balance)}

${$('note')?.value?`
Примечание${esc($('note').value)}
`:''}${docActions()}
`;window.modal?.('preview'); + } + window.sunShowConfiguredReceipt=showReceipt;window.sunShowOrderBlankV5=showOrderBlank; + function patchDocButtons(){qa('#new .actions button').forEach(b=>{const text=(b.textContent||'').trim();if(text==='Товарный чек')b.onclick=showReceipt;if(text==='Бланк заказа')b.onclick=showOrderBlank;});} + + // ---------------- Promo statistics ---------------- + function renderPromoStats(){ + const view=$('stats');if(!view)return;let root=$('sunPromoStats');if(!root){root=document.createElement('section');root.id='sunPromoStats';root.className='sun-promo-stats';view.appendChild(root)} + const groups=new Map();(orders||[]).filter(o=>o.status!=='Отменён'&&String(o.promoCode||'').trim()).forEach(o=>{const code=String(o.promoCode).trim(),p=pricing(o),key=code.toLowerCase(),client=(String(o.phone||'').replace(/\D/g,'')||String(o.contact||'').trim().toLowerCase()||`order:${o.id}`);if(!groups.has(key))groups.set(key,{code,orders:0,clients:new Set(),sales:0,discount:0});const g=groups.get(key);g.orders++;g.clients.add(client);g.sales+=p.total;g.discount+=p.promo;});const list=[...groups.values()].sort((a,b)=>b.sales-a.sales); + root.innerHTML=`

Промокоды

Отслеживание заказов, клиентов, продаж и предоставленной скидки по промокодам.

${list.length?`
${list.map(g=>``).join('')}
ПромокодЗаказовКлиентовПродажиСкидка
${esc(g.code)}${g.orders}${g.clients.size}${money(g.sales)}${money(g.discount)}
`:'

Заказов с промокодами пока нет.

'}`; + } + + // ---------------- Lifecycle wrappers ---------------- + const previousSave=window.saveOrder; + if(typeof previousSave==='function')window.saveOrder=function(...args){captureBuyerFromFields();captureDiscounts();captureDelivery();const result=previousSave.apply(this,args);renderPromoStats();return result}; + const previousOpen=window.openOrder; + if(typeof previousOpen==='function')window.openOrder=function(...args){const r=previousOpen.apply(this,args);setTimeout(()=>{syncBuyerFields();syncDiscountFields();syncDeliveryField();setOrderPane('order');patchDocButtons();renderCatalogV5();},0);return r}; + const previousReset=window.resetDraft; + if(typeof previousReset==='function')window.resetDraft=function(...args){const r=previousReset.apply(this,args);draft.buyerRequisites=buyerDefaults();draft.manualDiscountValue=0;draft.manualDiscountType='percent';draft.promoId='';draft.promoCode='';draft.promoDiscountValue=0;draft.promoDiscountType='percent';draft.promoStartDate='';draft.promoEndDate='';draft.deliveryCost=0;delete draft.qrImage;setTimeout(()=>{syncBuyerFields();syncDiscountFields();syncDeliveryField();setOrderPane('order');renderCatalogV5();},0);return r}; + const previousStats=window.renderStats; + window.renderStats=function(...args){const r=typeof previousStats==='function'?previousStats.apply(this,args):undefined;setTimeout(renderPromoStats,0);return r}; + + function onSettings(){setTimeout(()=>{simplifySellerSettings();ensureDefaultQrSetting();},80);setTimeout(()=>{simplifySellerSettings();ensureDefaultQrSetting();},250)} + document.addEventListener('click',e=>{const nav=e.target.closest('header nav button');if(nav&&String(nav.dataset.navLabel||nav.textContent||'').trim()==='Настройки')onSettings();},true); + (()=>{let queued=false;const run=()=>{if(queued)return;queued=true;setTimeout(()=>{queued=false;if($('sunReceiptSettingsCard'))simplifySellerSettings();patchDocButtons();},50);};[$('new'),$('enterprise-settings')].filter(Boolean).forEach(root=>new MutationObserver(run).observe(root,{childList:true,subtree:true}));})(); + + // Initial install. Populate the new piece-count field without overwriting manually edited prices/weights. + try{let changed=false;(Array.isArray(boxes)?boxes:[]).forEach(item=>{if(![0,5].includes(Number(item?.category||0)))return;const before=JSON.stringify([item.pieces,item.ingredients,item.composition]);normalizeBoxItem(item,{cleanup:true});if(before!==JSON.stringify([item.pieces,item.ingredients,item.composition]))changed=true;});if(changed)persist();}catch(err){console.warn('v17.5 catalog normalization skipped',err)} + ensureCategoryModal();renderCategoryTabs();syncEditorCategoryOptions();ensureCatalogTools();ensureBoxWeightEditor();ensureBoxPiecesEditor();ensureRequisitesTab();ensureDiscountUI();syncBuyerFields();syncDiscountFields();syncDeliveryField();patchDocButtons();renderCatalogV5();renderPromoStats();if($('enterprise-settings')?.classList.contains('on'))onSettings(); +})(); +; + +/* ===== MODULE: client-offer-settings.js ===== */ +/* Sun Catering: editable client-offer content and metric settings */ +(()=>{ + 'use strict'; + if(window.__sunClientOfferSettingsV1)return; + window.__sunClientOfferSettingsV1=true; + + const KEY='sunClientOfferSettingsV1'; + const DEFAULTS={ + version:3, + metricMode:'weight', + showAmountPerGuest:true, + showBoxCount:true, + showPriceBreakdown:true, + showDiscountBadge:true, + showGallery:true, + showControl:true, + showExtras:true, + showFinalPhoto:true, + showClientName:true, + showDate:true, + showGuests:true, + showFooterNote:true, + texts:{ + proposalLabel:'Индивидуальное предложение', + menuTitle:'Меню', + summaryTitle:'Итого', + totalPriceTitle:'Итоговая стоимость', + heroNote:'Фирменные боксы с закусками — готово к подаче на стол.', + salesTitle:'Почему с нами удобно', + salesNote:'Меню составлено специально под ваше событие. Закуски приезжают в фирменных боксах, их удобно поставить на стол и подать без сложной сервировки. Мы готовим заказ в день доставки, используем свежие ингредиенты и контролируем детали до передачи заказа.', + finalGalleryTitle:'Как это выглядит на вашем столе', + weightMetricLabel:'Вес еды на гостя', + piecesMetricLabel:'Канапе на гостя', + amountPerGuestLabel:'Сумма на гостя', + boxCountLabel:'Количество боксов', + pricingTitle:'Расчёт стоимости', + priceItemsLabel:'Стоимость позиций', + discountLabel:'Скидка', + promoLabel:'Промокод', + menuAfterDiscountLabel:'Меню после скидок', + deliveryLabel:'Доставка', + totalLabel:'Итого', + controlTitle:'Всё под контролем команды Солнца', + extrasTitle:'Дополнительно к заказу рекомендуем', + footerNote:'Доставка считается отдельно от меню и не участвует в скидках.' + }, + controlLines:[ + 'Фирменная упаковка', + 'Уточняем детали доставки', + 'Стоимость заказа не изменится', + 'Готовим закуски в день мероприятия', + 'Контролируем заказ до момента передачи', + 'Привозим заказ вовремя и передаём вам лично', + 'Всё организованно! Вы просто встречаете гостей!' + ], + extraServices:['Напитки','Десерты','Фрукты','Одноразовая посуда'] + }; + const clone=v=>JSON.parse(JSON.stringify(v)); + const esc=window.SunSafe.escapeHTML; + const bool=(v,d)=>typeof v==='boolean'?v:d; + const lines=(value,fallback)=>{ + const arr=Array.isArray(value)?value:String(value??'').split(/\r?\n/); + const clean=arr.map(x=>String(x||'').trim()).filter(Boolean).slice(0,20); + return clean.length?clean:[...fallback]; + }; + function normalize(raw={}){ + const d=clone(DEFAULTS),t=raw&&typeof raw==='object'?raw:{}; + d.metricMode=t.metricMode==='pieces'?'pieces':'weight'; + ['showAmountPerGuest','showBoxCount','showPriceBreakdown','showDiscountBadge','showGallery','showControl','showExtras','showFinalPhoto','showClientName','showDate','showGuests','showFooterNote'].forEach(k=>d[k]=bool(t[k],d[k])); + if(t.texts&&typeof t.texts==='object')Object.keys(d.texts).forEach(k=>{if(t.texts[k]!=null)d.texts[k]=String(t.texts[k]).trim()||d.texts[k]}); + // v3 separates the short first-screen note from the longer selling copy. + // Preserve any custom v2 hero text by moving it into the new selling block. + if(Number(t.version||0)<3&&t.texts&&typeof t.texts==='object'&&!String(t.texts.salesNote||'').trim()){ + const legacyHero=String(t.texts.heroNote||'').trim(); + if(legacyHero)d.texts.salesNote=legacyHero; + d.texts.heroNote=DEFAULTS.texts.heroNote; + } + d.controlLines=lines(t.controlLines,d.controlLines); + d.extraServices=lines(t.extraServices,d.extraServices); + // Since v17.5.17 table photos are automatic whitespace fillers, not an optional section. + d.showFinalPhoto=true; + return d; + } + function read(){try{return normalize(JSON.parse(localStorage.getItem(KEY)||'{}'))}catch(_){return clone(DEFAULTS)}} + let state=read(); + function save(next){state=normalize(next);localStorage.setItem(KEY,JSON.stringify(state));window.dispatchEvent(new CustomEvent('sunclientoffersettingschange',{detail:clone(state)}));return clone(state)} + function reset(){return save(DEFAULTS)} + + const style=document.createElement('style'); + style.id='sun-client-offer-settings-style'; + style.textContent=` + .sun-client-offer-settings-card{grid-column:1/-1!important} + .sun-client-offer-settings-head{display:flex;align-items:flex-start;gap:14px;flex-wrap:wrap} + .sun-client-offer-settings-head>div:first-child{flex:1;min-width:250px} + .sun-client-offer-settings-card details{border:1px solid #e4e7ea;border-radius:14px;background:#fff;margin-top:10px;overflow:hidden} + .sun-client-offer-settings-card summary{cursor:pointer;padding:13px 15px;font-weight:800;color:#2a3138;list-style:none;display:flex;align-items:center;justify-content:space-between;gap:10px} + .sun-client-offer-settings-card summary::-webkit-details-marker{display:none} + .sun-client-offer-settings-card summary:after{content:'+';font-size:20px;font-weight:500;color:#7a828a}.sun-client-offer-settings-card details[open] summary:after{content:'−'} + .sun-client-offer-settings-body{border-top:1px solid #eceff1;padding:14px 15px 16px} + .sun-client-offer-settings-grid{display:grid;grid-template-columns:repeat(2,minmax(220px,1fr));gap:12px} + .sun-client-offer-settings-grid label,.sun-client-offer-settings-body>label{display:grid;gap:6px;font-size:12px;color:#59636d;font-weight:700} + .sun-client-offer-settings-grid input,.sun-client-offer-settings-grid select,.sun-client-offer-settings-body textarea{width:100%;border:1px solid #d9dee3;border-radius:10px;background:#fff;color:#20262c;padding:10px 11px;font:inherit} + .sun-client-offer-settings-body textarea{min-height:135px;resize:vertical;line-height:1.5} + .sun-client-offer-switches{display:grid;grid-template-columns:repeat(3,minmax(170px,1fr));gap:9px} + .sun-client-offer-switch{display:flex!important;grid-template-columns:none!important;align-items:center;gap:9px!important;border:1px solid #e2e6e9;border-radius:11px;padding:10px 11px;color:#303941!important;background:#fafbfb} + .sun-client-offer-switch input{width:17px!important;height:17px;margin:0;accent-color:var(--sun-brand-sun,#FFC107)} + .sun-client-offer-settings-actions{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-top:14px;padding-top:14px;border-top:1px solid #eceff1} + .sun-client-offer-settings-actions .hint{margin:0 0 0 auto} + @media(max-width:760px){.sun-client-offer-settings-grid,.sun-client-offer-switches{grid-template-columns:1fr}.sun-client-offer-settings-actions .hint{width:100%;margin:0}} + `; + document.head.appendChild(style); + + function html(){ + const s=state,t=s.texts; + return `

Настройки предложения клиенту

Тексты и состав блоков применяются ко всем шаблонам и к PDF. Внутренний номер заказа клиенту не показывается.

+
Показатели в блоке «Итого»
+
+

Вес и количество боксов считаются только из раздела «Боксы». Напитки, посуда и дополнения в эти показатели не попадают. В составе предложения количество канапе/закусок каждого вида показывается итогом с учётом количества заказанных боксов.

+
+
+
Основные тексты
+ + + + +
+
Продающий блок после первого экрана

Текст не обрезается: блок увеличивается и при необходимости переносится на следующую страницу PDF.

+
Тексты расчёта стоимости
+ + + + + + + +
+
Блок «Всё под контролем»
+
Дополнительно к заказу
+
Что показывать клиенту
Фото на столе: авто, только в свободное место
+

Изменения применятся к следующему открытию предложения; открытое предложение обновится автоматически.

`; + } + function collect(card){ + const next=clone(state); + card.querySelectorAll('[data-offer-setting]').forEach(el=>{const k=el.dataset.offerSetting;if(el.type==='checkbox')next[k]=el.checked;else next[k]=el.value}); + next.texts={...next.texts}; + card.querySelectorAll('[data-offer-text]').forEach(el=>{next.texts[el.dataset.offerText]=String(el.value||'').trim()}); + card.querySelectorAll('[data-offer-list]').forEach(el=>{next[el.dataset.offerList]=String(el.value||'').split(/\r?\n/).map(x=>x.trim()).filter(Boolean)}); + return normalize(next); + } + function bind(card){ + card.querySelector('[data-offer-settings-save]')?.addEventListener('click',()=>{save(collect(card));card.innerHTML=html();bind(card);try{window.SunEnterprise?.toast?.('Настройки предложения сохранены.')}catch(_){}}); + card.querySelector('[data-offer-settings-reset]')?.addEventListener('click',()=>{reset();card.innerHTML=html();bind(card);try{window.SunEnterprise?.toast?.('Стандартные настройки предложения восстановлены.')}catch(_){}}); + } + function ensureCard(){ + const view=document.getElementById('enterprise-settings');if(!view)return; + const grid=view.querySelector('.enterprise-grid');if(!grid)return; + let card=document.getElementById('sunClientOfferSettingsCard'); + if(card&&grid.contains(card))return; + card=document.createElement('section');card.id='sunClientOfferSettingsCard';card.className='enterprise-card wide sun-client-offer-settings-card';card.innerHTML=html(); + const templates=document.getElementById('sunOfferTemplateSettingsCard'); + if(templates&&templates.parentElement===grid)templates.insertAdjacentElement('afterend',card);else{ + const brand=document.getElementById('sunBrandThemeSettingsCard'); + if(brand&&brand.parentElement===grid)brand.insertAdjacentElement('afterend',card);else grid.appendChild(card); + } + bind(card); + } + function boot(){ + ensureCard(); + const settings=document.getElementById('enterprise-settings');if(settings)new MutationObserver(()=>queueMicrotask(ensureCard)).observe(settings,{childList:true,subtree:true}); + document.addEventListener('click',e=>{const b=e.target.closest('header nav button');if(!b)return;const label=(b.dataset.navLabel||b.textContent||'').trim();if(label==='Настройки')setTimeout(ensureCard,35)},true); + window.addEventListener('storage',e=>{if(e.key===KEY){state=read();setTimeout(()=>{const card=document.getElementById('sunClientOfferSettingsCard');if(card){card.innerHTML=html();bind(card)}},20);window.dispatchEvent(new CustomEvent('sunclientoffersettingschange',{detail:clone(state)}));}}); + window.addEventListener('sun:cloud-state-applied',()=>{state=read();setTimeout(()=>{const card=document.getElementById('sunClientOfferSettingsCard');if(card){card.innerHTML=html();bind(card)}},30)}); + } + if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',boot,{once:true});else boot(); + window.SunClientOfferSettings={key:KEY,defaults:()=>clone(DEFAULTS),get:()=>clone(state),set:save,reset}; +})(); +; + +/* ===== MODULE: client-offer.js ===== */ +(function(){ + 'use strict'; + if(window.__sunClientOfferV2)return; window.__sunClientOfferV2=true; + const $=id=>document.getElementById(id); + const qa=(sel,root=document)=>[...root.querySelectorAll(sel)]; + const esc=window.SunSafe.escapeHTML; + const money=v=>`${Math.round(Number(v||0)).toLocaleString('ru-RU')} ₽`; + const num=v=>Math.max(0,Number(v||0)); + const toast=(m,t='success')=>{try{return window.SunEnterprise?.toast?.(m,t)}catch(_){} if(t==='error'||t==='warn')console.warn(m);}; + const EMBEDDED_LOGO='data:image/png;base64,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'; + + const style=document.createElement('style'); + style.id='sun-client-offer-style'; + style.textContent=` + #sunClientOfferModal{z-index:100050} + #sunClientOfferModal .dialog{width:min(1180px,calc(100vw - 28px));max-height:94vh;padding:0;overflow:hidden;border-radius:16px;background:#fffaf2} + .sun-offer-modal-head{display:flex;align-items:center;gap:10px;padding:13px 15px;background:#fffdf9;border-bottom:1px solid #e8d9bf;flex-wrap:wrap} + .sun-offer-modal-head h2{margin:0;color:#28533a;font-size:19px}.sun-offer-modal-head .hint{margin:0;color:#73808a;font-size:12px} + .sun-offer-modal-head .spacer{flex:1}.sun-offer-modal-actions{display:flex;gap:7px;flex-wrap:wrap}.sun-offer-modal-actions button{min-height:36px} + .sun-offer-editor{display:none;padding:12px 15px;background:#fff;border-bottom:1px solid #e8d9bf}.sun-offer-editor.on{display:block}.sun-offer-editor-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:9px}.sun-offer-editor label{display:grid;gap:4px;font-size:10px;font-weight:700;color:#607068}.sun-offer-editor label.wide{grid-column:span 2}.sun-offer-editor input,.sun-offer-editor textarea{width:100%;box-sizing:border-box;border:1px solid #d9d2c7;border-radius:9px;padding:8px 9px;background:#fff;font:12px/1.35 Arial;color:#1d2b24}.sun-offer-editor textarea{min-height:62px;resize:vertical}.sun-offer-editor-note{margin:8px 0 0;color:#778178;font-size:10px}.sun-offer-editor-gallery{display:grid;grid-template-columns:minmax(0,1fr) 190px;gap:12px;align-items:center;border:1px solid #e7dfd2;border-radius:10px;padding:9px;background:#fffdf9}.sun-offer-editor-gallery b{display:block;font-size:11px;color:#425248}.sun-offer-editor-gallery small{display:block;margin-top:4px;font-size:10px;line-height:1.4;color:#7b827d}.sun-offer-editor-gallery-images{display:grid;grid-template-columns:1fr 1fr;gap:5px}.sun-offer-editor-gallery-images img{width:100%;height:70px;object-fit:cover;border-radius:7px;border:1px solid #e5ded3} + .sun-offer-frame-wrap{height:min(78vh,900px);background:#eee9e0;padding:12px;overflow:auto} + #sunClientOfferPreview{display:grid;gap:14px;justify-items:center;min-height:100%}.sun-offer-page-canvas{display:block;width:min(100%,794px);height:auto;background:#fff;box-shadow:0 5px 24px #4a38272b}.sun-offer-rendering{align-self:center;justify-self:center;margin:70px 20px;padding:14px 18px;border-radius:12px;background:#fff;color:#526159;font-size:12px;box-shadow:0 4px 18px #00000012} + .sun-offer-stale{display:none;padding:7px 12px;background:#fff6df;color:#8b6213;border-radius:8px;font-size:12px}.sun-offer-stale.on{display:block} + #new .actions #sunClientOfferButton{background:#fff8e9;border-color:#c99b47;color:#28533a} + @media(max-width:900px){.sun-offer-editor-grid{grid-template-columns:repeat(2,minmax(0,1fr))}} + @media(max-width:700px){.sun-offer-editor-gallery{grid-template-columns:1fr}.sun-offer-editor-gallery-images img{height:95px}#sunClientOfferModal{padding:0}#sunClientOfferModal .dialog{width:100vw;height:100vh;max-height:none;border-radius:0}.sun-offer-frame-wrap{height:calc(100vh - 112px);padding:5px}.sun-offer-modal-head{padding:9px}.sun-offer-modal-head h2{font-size:16px}.sun-offer-modal-head .hint{display:none}.sun-offer-modal-actions{width:100%;display:grid;grid-template-columns:1.15fr 1fr 1fr 1fr}.sun-offer-modal-actions button{padding:8px 4px;font-size:10px}.sun-offer-editor{padding:9px}.sun-offer-editor-grid{grid-template-columns:1fr}.sun-offer-editor label.wide{grid-column:auto}} + `; + document.head.appendChild(style); + + const getBoxes=()=>{try{if(typeof boxes!=='undefined'&&Array.isArray(boxes))return boxes}catch(_){}try{const v=JSON.parse(localStorage.getItem('sunBoxes')||'[]');return Array.isArray(v)?v:[]}catch(_){return[]}}; + const getLegacyBoxes=()=>Array.isArray(window.SUN_LEGACY_CATALOG_V175)?window.SUN_LEGACY_CATALOG_V175:[]; + const normalizedCatalogName=value=>String(value||'').toLowerCase().replace(/ё/g,'е').replace(/[^a-zа-я0-9]+/gi,' ').trim().replace(/\s+/g,' '); + function resolveCatalogLine(line,all=getBoxes()){ + const id=String(line?.id??''); + let item=(all||[]).find(x=>String(x.id)===id); + if(item)return item; + const legacy=getLegacyBoxes(); + item=legacy.find(x=>String(x.id)===id); + if(item)return item; + const name=normalizedCatalogName(line?.name||line?.catalogName||''); + if(name){ + item=(all||[]).find(x=>normalizedCatalogName(x?.name)===name)||legacy.find(x=>normalizedCatalogName(x?.name)===name); + if(item)return item; + } + return null; + } + const getOrders=()=>{try{if(typeof orders!=='undefined'&&Array.isArray(orders))return orders}catch(_){}try{const v=JSON.parse(localStorage.getItem('sunOrders')||'[]');return Array.isArray(v)?v:[]}catch(_){return[]}}; + const getDraft=()=>{try{return typeof draft!=='undefined'?draft:null}catch(_){return null}}; + const catName=id=>{try{return window.sunCatalogCategoryName?.(id)||({0:'Боксы',1:'Посуда и упаковка',2:'Дополнительно',3:'Напитки',4:'Доставка',5:'Премиум'}[Number(id)]||'Каталог')}catch(_){return 'Каталог'}}; + + function linePrice(line,item){const p=Number(line?.price);return Number.isFinite(p)&&p>=0?p:num(item?.price)} + function discountAmount(value,type,base){const n=num(value);return Math.min(base,type==='amount'?n:base*Math.min(100,n)/100)} + function priceCalc(order){ + const all=getBoxes(); + const base=(order?.lines||[]).reduce((sum,line)=>{const item=resolveCatalogLine(line,all);return sum+linePrice(line,item)*num(line.qty)},0); + const manual=discountAmount(order?.manualDiscountValue,order?.manualDiscountType,base); + const afterManual=Math.max(0,base-manual); + const promo=discountAmount(order?.promoDiscountValue,order?.promoDiscountType,afterManual); + const itemsTotal=Math.max(0,afterManual-promo); + const delivery=num(order?.deliveryCost); + return {base,manual,promo,itemsTotal,delivery,total:itemsTotal+delivery,discount:manual+promo}; + } + function parseMeasure(text){ + const s=String(text||'').toLowerCase().replace(/,/g,'.'); + const m=s.match(/(\d+(?:\.\d+)?)\s*(кг|kg|мл|ml|л|l|г|g)(?:\s|$|[^a-zа-яё])/iu)||s.match(/(\d+(?:\.\d+)?)\s*(кг|kg|мл|ml|л|l|г|g)$/iu); + if(!m)return null;const value=Number(m[1]),u=m[2];if(!Number.isFinite(value))return null; + if(u==='кг'||u==='kg')return {kind:'food',value:value*1000,unit:'г'}; + if(u==='г'||u==='g')return {kind:'food',value,unit:'г'}; + if(u==='л'||u==='l')return {kind:'drink',value:value*1000,unit:'мл'}; + if(u==='мл'||u==='ml')return {kind:'drink',value,unit:'мл'}; + return null; + } + function ingredientWeight(row){if(!Array.isArray(row))return 0;const direct=Math.max(0,num(row[3]));if(direct)return direct;const m=String(row[0]||'').match(/[—–-]\s*(\d+(?:[.,]\d+)?)\s*г(?:\s*\/\s*шт\.?)?\s*$/i);return m?Math.max(0,num(String(m[1]).replace(',','.'))):0} + function cleanIngredientName(name){return String(name||'').replace(/\s*[—–-]\s*\d+(?:[.,]\d+)?\s*г(?:\s*\/\s*шт\.?)?\s*$/i,'').trim()} + function ingredientLine(row){if(!Array.isArray(row)||!String(row[0]||'').trim())return'';const name=cleanIngredientName(row[0]),qty=Math.max(0,num(row[1])),unit=String(row[2]||'').trim(),w=ingredientWeight(row);if(/^поз\.?$/i.test(unit)&&qty<=1)return `${name}${w?` — ${w.toLocaleString('ru-RU')} г/шт.`:''}`;const shown=Number.isInteger(qty)?String(qty):qty.toLocaleString('ru-RU',{maximumFractionDigits:2});return `${shown} ${unit} ${name}${w?` — ${w.toLocaleString('ru-RU')} г/шт.`:''}`.replace(/\s+/g,' ').trim()} + function composition(item){ + // Client-facing composition is independent from kitchen TTK ingredients. + if(Array.isArray(item?.composition)&&item.composition.length)return item.composition.map(x=>String(x||'').trim()).filter(Boolean); + if(Array.isArray(item?.ingredients)&&item.ingredients.some(x=>Array.isArray(x)&&x[0]))return item.ingredients.map(ingredientLine).filter(Boolean); + return []; + } + function orderSignature(order){ + const clean={id:String(order?.id??''),event:order?.event||'',date:order?.date||'',time:order?.time||'',contact:order?.contact||'',guestsCount:num(order?.guestsCount),deliveryCost:num(order?.deliveryCost),manualDiscountValue:num(order?.manualDiscountValue),manualDiscountType:order?.manualDiscountType||'percent',promoId:order?.promoId||'',promoCode:order?.promoCode||'',promoDiscountValue:num(order?.promoDiscountValue),promoDiscountType:order?.promoDiscountType||'percent',lines:(order?.lines||[]).map(l=>({id:String(l.id),qty:num(l.qty),price:Number.isFinite(Number(l.price))?Number(l.price):null}))}; + return JSON.stringify(clean); + } + function currentSavedOrder(id){return getOrders().find(o=>String(o.id)===String(id))||null} + function currentOfferSourceOrder(id,preferDraft=false){ + const saved=currentSavedOrder(id),d=getDraft(); + if(preferDraft&&d&&String(d.id)===String(id))return d; + return saved; + } + function snapshotIsStale(order,snapshot){ + if(!snapshot||Number(snapshot.version||0)<7||snapshot.sourceSignature!==orderSignature(order))return true; + const all=getBoxes(); + return (snapshot.items||[]).some(item=>{ + const missingName=!String(item?.name||'').trim()||String(item?.name||'').trim()==='Позиция'; + const missingPhoto=!String(item?.photo||'').trim(); + return (missingName||missingPhoto)&&Boolean(resolveCatalogLine(item,all)); + }); + } + function sectionTitle(item){const s=String(item?.catalogSection||'').trim();return s||String(item?.categoryName||'Меню')} + function canapePieces(item){ + // v17.5.1: any food box may contain countable ready-to-serve pieces, not only sections named "canape". + if(![0,5].includes(Number(item?.category??item?.categoryId??0)))return 0; + const explicit=Math.max(0,num(item?.pieces)); + if(explicit>0)return explicit; + let total=0; + if(Array.isArray(item?.ingredients)){ + for(const row of item.ingredients){ + if(!Array.isArray(row))continue; + const value=num(row[1]),unit=String(row[2]||'').toLowerCase().replace(/\s+/g,''); + if(/^(шт\.?|пор\.?|кус\.?|шарик(?:а|ов|и)?|креманк(?:а|и|е)?)$/i.test(unit))total+=value; + } + } + if(total>0)return total; + for(const text of Array.isArray(item?.composition)?item.composition:[]){ + const re=/(\d+(?:[.,]\d+)?)\s*(?:шт\.?|пор\.?|кус\.?)/ig;let m;while((m=re.exec(String(text||''))))total+=num(String(m[1]).replace(',','.')); + } + return total; + } + + function multiplyLeadingPieces(text,qty){ + const raw=String(text||'').trim(),q=Math.max(0,num(qty));if(!raw||q<=0)return raw; + return raw.replace(/^(\d+(?:[.,]\d+)?)\s*(шт\.?|пор\.?|кус\.?)\s+/i,(m,n,u)=>{ + const total=Number(String(n).replace(',','.'))*q; + const shown=Number.isInteger(total)?String(total):total.toLocaleString('ru-RU',{maximumFractionDigits:2}); + return `${shown} ${u} `; + }); + } + function totalComposition(item,qty,categoryId){ + const list=composition(item);return [0,5].includes(Number(categoryId))?list.map(x=>multiplyLeadingPieces(x,qty)):list; + } + function offerComposition(item){ + const list=Array.isArray(item?.compositionTotal)&&item.compositionTotal.length?item.compositionTotal:(Array.isArray(item?.composition)?item.composition:[]); + return list.map(x=>String(x||'').trim()).filter(Boolean); + } + function roundUp(value,step=1){const n=Math.max(0,Number(value||0)),s=Math.max(1,Number(step||1));return Math.ceil((n-1e-9)/s)*s} + function guestRangeLabel(raw){ + const value=String(raw||'').trim().replace(/(\d)\s*[-–—]\s*(\d)/g,'$1–$2'); + if(!value)return'';return /(?:гост|чел)/i.test(value)?value:`${value} гостей`; + } + function boxQuantityLabel(qty){const n=Math.max(0,Math.round(num(qty)));return n?`${n} ${plural(n,'бокс','бокса','боксов')}`:''} + function totalMeasureLabel(item){ + const raw=String(item?.weight||'').trim(),qty=Math.max(0,num(item?.qty));if(!raw||!qty)return''; + const measure=parseMeasure(raw);if(!measure)return qty===1?`Вес бокса: ${raw}`:`Вес бокса: ${raw} × ${qty}`; + const total=measure.value*qty,shown=(Number.isInteger(total)?total:Math.round(total*100)/100).toLocaleString('ru-RU',{maximumFractionDigits:2}); + return `Общий ${measure.kind==='drink'?'объём':'вес'}: ${shown} ${measure.unit}`; + } + function weightLabel(item){ + const raw=String(item?.weight||'').trim(),isBox=[0,5].includes(Number(item?.categoryId??item?.category??0)),pieces=isBox?Math.max(0,num(item?.pieces)):0,qty=Math.max(0,num(item?.qty)); + if(isBox){ + const parts=[]; + const totalWeight=totalMeasureLabel(item);if(totalWeight)parts.push(totalWeight);else if(raw)parts.push(`Общий вес: ${raw}`); + if(pieces&&qty)parts.push(`Всего: ${(pieces*qty).toLocaleString('ru-RU',{maximumFractionDigits:2})} шт.`); + return parts.join(' · ') + } + return raw?`Вес / объём: ${raw}`:''; + } + + async function createSnapshot(order){ + const all=getBoxes(),p=priceCalc(order),guests=num(order?.guestsCount)||num($('eventGuests')?.value),selected=new Set((order?.lines||[]).map(l=>String(l.id))); + let foodGrams=0,drinkMl=0,foodPieces=0; + const items=(order?.lines||[]).map(line=>{ + const item=resolveCatalogLine(line,all)||{};const qty=num(line.qty),price=linePrice(line,item),measure=parseMeasure(item.weight),rawItemCategory=item.category,rawLineCategory=line.category,itemCategory=rawItemCategory!==undefined&&rawItemCategory!==null&&rawItemCategory!==''?Number(rawItemCategory):NaN,lineCategory=rawLineCategory!==undefined&&rawLineCategory!==null&&rawLineCategory!==''?Number(rawLineCategory):NaN,categoryId=Number.isFinite(itemCategory)?itemCategory:(Number.isFinite(lineCategory)?lineCategory:-1),pieces=canapePieces(item),comp=composition(item),compositionTotal=[0,5].includes(Number(categoryId))?comp.map(x=>multiplyLeadingPieces(x,qty)):comp; + if(measure){if(measure.kind==='drink'||categoryId===3)drinkMl+=measure.value*qty;else if([0,5].includes(categoryId))foodGrams+=measure.value*qty;} + if([0,5].includes(categoryId)&&pieces>0)foodPieces+=pieces*qty; + return {id:String(item.id??line.id??''),name:String(item.name||line.name||'Позиция'),categoryId,categoryName:catName(categoryId),catalogSection:String(item.catalogSection||''),qty,unitPrice:price,sum:price*qty,weight:String(item.weight||''),boxGuests:String(item.guests||''),photo:String(item.photo||''),composition:comp,compositionTotal,pieces}; + }).filter(x=>x.qty>0); + const extras=all.filter(item=>[1,2,3].includes(Number(item.category))&&!selected.has(String(item.id))&&!item.hidden).slice(0,6).map(item=>({name:String(item.name||''),categoryName:catName(item.category)})).filter(x=>x.name); + return {version:8,createdAt:new Date().toISOString(),orderId:order?.id||'',sourceSignature:orderSignature(order),event:String(order?.event||''),date:String(order?.date||''),time:String(order?.time||''),client:String(order?.contact||''),guests,foodGrams,foodPieces,drinkMl,pricing:p,promoCode:String(order?.promoCode||''),items,extras,offerOverrides:structuredClone(order?.clientOfferSnapshot?.offerOverrides||{})}; + } + function persistSnapshot(orderId,snapshot){ + let list=getOrders(),i=list.findIndex(o=>String(o.id)===String(orderId));if(i<0)return false; + list[i].clientOfferSnapshot=snapshot;list[i].clientOfferUpdatedAt=snapshot.createdAt; + try{if(typeof orders!=='undefined'&&Array.isArray(orders)){const j=orders.findIndex(o=>String(o.id)===String(orderId));if(j>=0){orders[j].clientOfferSnapshot=snapshot;orders[j].clientOfferUpdatedAt=snapshot.createdAt;}}}catch(_){} + const d=getDraft();if(d&&String(d.id)===String(orderId)){d.clientOfferSnapshot=snapshot;d.clientOfferUpdatedAt=snapshot.createdAt;} + try{if(typeof persist==='function')persist();else localStorage.setItem('sunOrders',JSON.stringify(list));return true}catch(err){console.error(err);toast('Не удалось сохранить предложение: возможно, переполнено локальное хранилище.','error');return false} + } + + function ensureModal(){ + if($('sunClientOfferModal'))return; + const modal=document.createElement('div');modal.id='sunClientOfferModal';modal.className='modal';modal.innerHTML=`

Предложение клиенту

Заказ изменён — предложение будет автоматически обновлено из текущих данных.

Изменения относятся только к этому предложению. Просмотр ниже — те же страницы, которые попадут в скачанный PDF.

`;document.body.appendChild(modal); + $('sunOfferClose').onclick=()=>window.closeModal?.('sunClientOfferModal'); + $('sunOfferRefresh').onclick=refreshCurrentOffer;$('sunOfferPdf').onclick=downloadCurrentOfferPdf;$('sunOfferEdit').onclick=toggleOfferEditor;bindOfferEditor(); + } + + function dateText(raw){if(!raw)return'Дата уточняется';const d=new Date(`${raw}T00:00:00`);return Number.isNaN(d.getTime())?raw:d.toLocaleDateString('ru-RU',{day:'numeric',month:'long'})} + function perGuest(value,guests){return guests>0?value/guests:0} + function metricValue(value,unit,guests){if(!guests||!value)return'—';const per=value/guests,rounded=unit==='г'?roundUp(per,5):roundUp(per,1);return `${rounded.toLocaleString('ru-RU')} ${unit}`} + function itemPerGuest(item,guests){const measure=parseMeasure(item?.weight);if(!guests||!measure)return'—';const per=measure.value*num(item.qty)/guests,rounded=measure.kind==='drink'?roundUp(per,1):roundUp(per,5);return `${rounded.toLocaleString('ru-RU')} ${measure.kind==='drink'?'мл':'г'}`} + const EXTRA_SERVICES=[ + ['Напитки',''], + ['Десерты',''], + ['Фрукты',''], + ['Одноразовая посуда',''] + ]; + const DELIVERY_CONTROL=[ + 'Фирменная упаковка', + 'Уточняем детали доставки', + 'Стоимость заказа не изменится', + 'Готовим закуски в день мероприятия', + 'Контролируем заказ до момента передачи', + 'Привозим заказ вовремя и передаём вам лично', + 'Всё организованно! Вы просто встречаете гостей!' + ]; + const FINAL_GALLERY_PATHS=[...(Array.isArray(window.SUN_OFFER_GALLERY_DATA)?window.SUN_OFFER_GALLERY_DATA.filter(Boolean).slice(0,2):[])];if(FINAL_GALLERY_PATHS.length<2)FINAL_GALLERY_PATHS.push(...Array.from({length:2-FINAL_GALLERY_PATHS.length},(_,i)=>`offer-gallery/${String(FINAL_GALLERY_PATHS.length+i+1).padStart(3,'0')}.jpg`)); + function plural(n,one,few,many){const x=Math.abs(Number(n||0))%100,y=x%10;return x>10&&x<20?many:y>1&&y<5?few:y===1?one:many} + + const SAFE_PIXEL='data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9Zwv8AAAAASUVORK5CYII='; + const isSafeRasterDataUrl=src=>/^data:image\/(?!svg\+xml)[a-z0-9.+-]+;/i.test(String(src||'')); + async function asDataUrlOne(src){ + if(!src)return''; + const raw=String(src).trim(); + if(isSafeRasterDataUrl(raw))return raw; + if(/^data:/i.test(raw))return''; + try{ + const url=new URL(raw,document.baseURI||location.href); + if(!['http:','https:','blob:'].includes(url.protocol))return''; + const sameOrigin=url.protocol==='blob:'||url.origin===location.origin; + const res=await fetch(url,{mode:'cors',credentials:sameOrigin?'same-origin':'omit',cache:'force-cache'}); + if(!res.ok)throw new Error(`HTTP ${res.status}`); + const blob=await res.blob(); + if(!/^image\/(?!svg\+xml)[a-z0-9.+-]+$/i.test(String(blob.type||'')))throw new Error('Неподдерживаемый формат изображения'); + const data=await new Promise((resolve,reject)=>{const r=new FileReader();r.onload=()=>resolve(String(r.result||''));r.onerror=()=>reject(r.error||new Error('FileReader'));r.readAsDataURL(blob)}); + return isSafeRasterDataUrl(data)?data:''; + }catch(err){return''} + } + async function asDataUrl(src){ + const list=window.SunCatalogPhoto?.candidates?.(src)||[src]; + for(const candidate of list){const data=await asDataUrlOne(candidate);if(data)return data;} + console.warn('PDF image unavailable after all fallbacks:',src); + return''; + } + async function preparedSnapshot(snapshot){ + const copy=structuredClone(snapshot),liveCatalog=getBoxes(),legacyCatalog=getLegacyBoxes(); + // Preview uses normal image URLs. Expensive Base64 conversion is deferred until PDF download. + copy.logo=EMBEDDED_LOGO||'sun-logo.png'; + copy.finalPhoto=''; + copy.finalGallery=FINAL_GALLERY_PATHS.slice(0,2); + const dataOverrides=copy.offerOverrides?.data&&typeof copy.offerOverrides.data==='object'?copy.offerOverrides.data:{}; + if(Object.prototype.hasOwnProperty.call(dataOverrides,'event'))copy.event=String(dataOverrides.event??''); + if(Object.prototype.hasOwnProperty.call(dataOverrides,'client'))copy.client=String(dataOverrides.client??''); + if(Object.prototype.hasOwnProperty.call(dataOverrides,'date'))copy.date=String(dataOverrides.date??''); + if(Object.prototype.hasOwnProperty.call(dataOverrides,'guests'))copy.guests=Math.max(0,num(dataOverrides.guests)); + for(const item of copy.items){ + // Quantity and price come from the order snapshot; catalog presentation data is always current. + const live=liveCatalog.find(x=>String(x.id)===String(item.id))||legacyCatalog.find(x=>String(x.id)===String(item.id)); + if(live){ + item.name=String(live.name||item.name||'Позиция'); + if(Number.isFinite(Number(live.category)))item.categoryId=Number(live.category); + item.categoryName=catName(item.categoryId); + item.photo=String(live.photo||'');item.weight=String(live.weight||'');item.boxGuests=String(live.guests||'');item.catalogSection=String(live.catalogSection||item.catalogSection||''); + const comp=composition(live);item.composition=comp;item.compositionTotal=[0,5].includes(Number(item.categoryId))?comp.map(x=>multiplyLeadingPieces(x,item.qty)):comp;item.pieces=canapePieces(live); + }else item.compositionTotal=[0,5].includes(Number(item.categoryId))?(item.composition||[]).map(x=>multiplyLeadingPieces(x,item.qty)):(item.composition||[]); + item.photoData=item.photo||copy.logo; + } + copy.foodGrams=0;copy.drinkMl=0;copy.foodPieces=0; + for(const item of copy.items){const measure=parseMeasure(item.weight);if(measure){if(measure.kind==='drink'||Number(item.categoryId)===3)copy.drinkMl+=measure.value*num(item.qty);else if([0,5].includes(Number(item.categoryId)))copy.foodGrams+=measure.value*num(item.qty);}if([0,5].includes(Number(item.categoryId))&&num(item.pieces)>0)copy.foodPieces+=num(item.pieces)*num(item.qty)} + return copy; + } + async function preparedPdfSnapshot(prepared){ + const copy=structuredClone(prepared),cache=new Map(); + const resolve=src=>{const key=String(src||'');if(!key)return Promise.resolve('');if(!cache.has(key))cache.set(key,asDataUrl(key));return cache.get(key);}; + copy.logo=(await resolve(copy.logo||'sun-logo.png'))||SAFE_PIXEL; + copy.finalGallery=(await Promise.all((copy.finalGallery||[]).map(resolve))).filter(Boolean); + await Promise.all((copy.items||[]).map(async item=>{item.photoData=(await resolve(item.photoData||item.photo||''))||copy.logo;})); + return copy; + } + + const OFFER_TEMPLATE_KEY='sunOfferTemplateV1'; + const OFFER_TEMPLATE_IDS=new Set(['light','editorial-grid','midnight-glass','emerald-gold']); + function offerTemplateId(){ + try{ + const api=window.SunOfferTemplate?.get?.(); + if(api?.id&&OFFER_TEMPLATE_IDS.has(api.id))return api.id; + const raw=JSON.parse(localStorage.getItem(OFFER_TEMPLATE_KEY)||'{}')||{}; + if(raw.id&&OFFER_TEMPLATE_IDS.has(raw.id))return raw.id; + }catch(_){} + return 'light'; + } + + const CLIENT_OFFER_SETTINGS_KEY='sunClientOfferSettingsV1'; + const CLIENT_OFFER_DEFAULTS={metricMode:'weight',showAmountPerGuest:true,showBoxCount:true,showPriceBreakdown:true,showDiscountBadge:true,showGallery:true,showControl:true,showExtras:true,showFinalPhoto:true,showClientName:true,showDate:true,showGuests:true,showFooterNote:true,texts:{proposalLabel:'Индивидуальное предложение',menuTitle:'Меню',summaryTitle:'Итого',totalPriceTitle:'Итоговая стоимость',heroNote:'Фирменные боксы с закусками — готово к подаче на стол.',salesTitle:'Почему с нами удобно',salesNote:'Меню составлено специально под ваше событие. Закуски приезжают в фирменных боксах, их удобно поставить на стол и подать без сложной сервировки. Мы готовим заказ в день доставки, используем свежие ингредиенты и контролируем детали до передачи заказа.',finalGalleryTitle:'Как это выглядит на вашем столе',weightMetricLabel:'Вес еды на гостя',piecesMetricLabel:'Канапе на гостя',amountPerGuestLabel:'Сумма на гостя',boxCountLabel:'Количество боксов',pricingTitle:'Расчёт стоимости',priceItemsLabel:'Стоимость позиций',discountLabel:'Скидка',promoLabel:'Промокод',menuAfterDiscountLabel:'Меню после скидок',deliveryLabel:'Доставка',totalLabel:'Итого',controlTitle:'Всё под контролем команды Солнца',extrasTitle:'Дополнительно к заказу рекомендуем',footerNote:'Доставка считается отдельно от меню и не участвует в скидках.'},controlLines:[...DELIVERY_CONTROL],extraServices:EXTRA_SERVICES.map(x=>x[0])}; + function offerSettings(){ + try{const api=window.SunClientOfferSettings?.get?.();if(api)return api}catch(_){} + try{ + const raw=JSON.parse(localStorage.getItem(CLIENT_OFFER_SETTINGS_KEY)||'{}')||{},d=structuredClone(CLIENT_OFFER_DEFAULTS); + Object.keys(d).forEach(k=>{if(k!=='texts'&&k!=='controlLines'&&k!=='extraServices'&&raw[k]!=null)d[k]=raw[k]}); + if(raw.texts&&typeof raw.texts==='object')d.texts={...d.texts,...raw.texts}; + if(Array.isArray(raw.controlLines)&&raw.controlLines.length)d.controlLines=raw.controlLines; + if(Array.isArray(raw.extraServices)&&raw.extraServices.length)d.extraServices=raw.extraServices; + return d; + }catch(_){return structuredClone(CLIENT_OFFER_DEFAULTS)} + } + function offerSettingsForSnapshot(s){ + const cfg=offerSettings(),textOverrides=s?.offerOverrides?.texts; + if(textOverrides&&typeof textOverrides==='object')cfg.texts={...cfg.texts,...textOverrides}; + if(Array.isArray(s?.offerOverrides?.controlLines))cfg.controlLines=s.offerOverrides.controlLines.map(x=>String(x||'').trim()).filter(Boolean).slice(0,20); + // Table photos are automatic: they are shown only when the final page has free space. + cfg.showFinalPhoto=true; + return cfg; + } + function offerBoxCount(items){return (items||[]).filter(item=>[0,5].includes(Number(item.categoryId??0))).reduce((n,item)=>n+num(item.qty),0)} + function offerPrimaryMetric(s,cfg,g){ + if(cfg.metricMode==='pieces')return {value:g&&num(s.foodPieces)?`${roundUp(num(s.foodPieces)/g,1).toLocaleString('ru-RU')} шт.`:'—',label:cfg.texts?.piecesMetricLabel||'Канапе на гостя'}; + return {value:metricValue(s.foodGrams,'г',g),label:cfg.texts?.weightMetricLabel||'Вес еды на гостя'}; + } + + function offerIconSvg(title=''){ + const t=String(title).toLowerCase(); + if(/рыб|мор|лосос|кревет/.test(t))return ''; + if(/мяс|ростбиф|бургер|ветчин|салям/.test(t))return ''; + if(/вег|овощ|салат/.test(t))return ''; + if(/десерт|слад|макарон|торт/.test(t))return ''; + return ''; + } + function offerGroups(items){ + const map=new Map(); + (items||[]).forEach(item=>{const k=sectionTitle(item)||'Меню';if(!map.has(k))map.set(k,[]);map.get(k).push(item)}); + return [...map.entries()].map(([name,list])=>({name,list})); + } + function editorialGroups(items){ + return offerGroups(items).map(group=>{ + const weights=group.list.map(item=>parseMeasure(item.weight)).filter(m=>m&&m.kind!=='drink').map(m=>Math.round(m.value)).filter(v=>v>0); + const minW=weights.length?Math.min(...weights):0,maxW=weights.length?Math.max(...weights):0; + const qty=group.list.reduce((n,item)=>n+Math.max(0,num(item.qty)),0); + const weightText=minW?(minW===maxW?`${minW} г`:`${minW}-${maxW} г`):''; + const positions=`${group.list.length} ${plural(group.list.length,'блюдо','блюда','блюд')}`; + return {...group,qty,weightText,positions,meta:[weightText,positions].filter(Boolean).join(' · ')}; + }); + } + function offerHtml(s){ + const templateId=OFFER_TEMPLATE_IDS.has(s.offerTemplateId)?s.offerTemplateId:offerTemplateId(); + const themeClass=`theme-${templateId}`; + const cfg=offerSettingsForSnapshot(s),p=s.pricing||{},g=num(s.guests),menuPer=perGuest(p.itemsTotal,g); + const items=s.items||[],boxCount=offerBoxCount(items),positionCount=items.length,groups=offerGroups(items),heroPhoto=items.find(item=>item.photoData)?.photoData||s.finalGallery?.[0]||s.logo||''; + const primaryMetric=offerPrimaryMetric(s,cfg,g),eventName=String(s.event||'Персональное предложение').trim(),clientName=cfg.showClientName?String(s.client||'').trim():''; + const visibleDate=cfg.showDate?dateText(s.date):'',visibleGuests=cfg.showGuests&&g?`${g} ${plural(g,'гость','гостя','гостей')}`:''; + const discountPct=p.base>0?Math.round((p.discount/p.base)*100):0; + const compositionText=(item,sep=' · ')=>offerComposition(item).join(sep); + const itemMeta=item=>[weightLabel(item),compositionText(item)].filter(Boolean).join(' · '); + const photo=(item,i,cls='')=>`
${i+1}${esc(item.name)}
${esc(item.name)}
`; + const row=item=>`
${offerIconSvg(sectionTitle(item))}
${esc(item.name)}${itemMeta(item)?`${esc(itemMeta(item))}`:''}
× ${Math.round(num(item.qty))}
`; + const groupTable=group=>`

${esc(group.name)}

Итого по позицииКоличество
${group.list.map(item=>{const comp=compositionText(item);return `
${esc(item.name)}${comp?`${esc(comp)}`:''}
${esc(weightLabel(item)||item.weight||'—')}× ${Math.round(num(item.qty))}
`}).join('')}
`; + const gallery=items.map((item,i)=>photo(item,i)).join(''); + const controlLines=Array.isArray(cfg.controlLines)&&cfg.controlLines.length?cfg.controlLines:DELIVERY_CONTROL; + const extrasList=Array.isArray(cfg.extraServices)&&cfg.extraServices.length?cfg.extraServices:EXTRA_SERVICES.map(x=>x[0]); + const deliveryControl=controlLines.map(item=>`
  • ${esc(item)}
  • `).join(''); + const deliveryControlShort=controlLines.slice(0,5).map(item=>`
  • ${esc(item)}
  • `).join(''); + const extraServices=extrasList.map(name=>`
    +${esc(name)}
    `).join(''); + const manual=p.manual?`
    ${esc(cfg.texts.discountLabel||'Скидка')}- ${money(p.manual)}
    `:''; + const promo=p.promo?`
    ${esc(cfg.texts.promoLabel||'Промокод')}${s.promoCode?` ${esc(s.promoCode)}`:''}- ${money(p.promo)}
    `:''; + const breakdown=cfg.showPriceBreakdown?`
    ${esc(cfg.texts.priceItemsLabel||'Стоимость позиций')}${money(p.base)}
    ${manual}${promo}
    ${esc(cfg.texts.menuAfterDiscountLabel||'Меню после скидок')}${money(p.itemsTotal)}
    ${esc(cfg.texts.deliveryLabel||'Доставка')}${money(p.delivery)}
    ${esc(cfg.texts.totalLabel||'Итого')}${money(p.total)}
    `:''; + const metricCards=[`
    ${esc(primaryMetric.value)}${esc(primaryMetric.label)}
    `,cfg.showAmountPerGuest?`
    ${g?money(menuPer):'—'}${esc(cfg.texts.amountPerGuestLabel||'Сумма на гостя')}
    `:'',cfg.showBoxCount?`
    ${Math.round(boxCount)}${esc(cfg.texts.boxCountLabel||'Количество боксов')}
    `:''].filter(Boolean); + const metrics=`
    ${metricCards.join('')}
    `; + const infoParts=[]; + if(cfg.showControl)infoParts.push(`

    ${esc(cfg.texts.controlTitle)}

      ${deliveryControl}
    `); + if(cfg.showExtras)infoParts.push(`

    ${esc(cfg.texts.extrasTitle)}

    ${extraServices}
    `); + const info=infoParts.length?`
    ${infoParts.join('')}
    `:''; + const actions=''; + const brand=`
    ${visibleDate?`
    ${esc(visibleDate)}
    `:''}
    `; + const heroStats=[visibleDate?`${esc(visibleDate)}`:'',visibleGuests?`${esc(visibleGuests)}`:''].filter(Boolean).join(''); + const standardHero=`
    ${esc(cfg.texts.proposalLabel)}

    ${esc(eventName)}

    ${clientName?`
    ${esc(clientName)}
    `:''}${heroStats?`
    ${heroStats}
    `:''}

    ${esc(cfg.texts.heroNote)}

    ${esc(eventName)}
    `; + const sellingBlock=(String(cfg.texts.salesTitle||'').trim()||String(cfg.texts.salesNote||'').trim())?`
    ${String(cfg.texts.salesTitle||'').trim()?`

    ${esc(cfg.texts.salesTitle)}

    `:''}${String(cfg.texts.salesNote||'').trim()?`

    ${esc(cfg.texts.salesNote)}

    `:''}
    `:''; + const identityLine=[clientName,visibleDate,visibleGuests].filter(Boolean).join(' · '); + const blackControl=cfg.showControl?`

    ${esc(cfg.texts.controlTitle)}

      ${deliveryControlShort}
    `:''; + const blackExtras=cfg.showExtras?`

    ${esc(cfg.texts.extrasTitle)}

    ${extraServices}
    `:''; + const egSideParts=[cfg.showControl?`

    ${esc(cfg.texts.controlTitle)}

      ${deliveryControlShort}
    `:'',cfg.showExtras?`

    ${esc(cfg.texts.extrasTitle)}

    ${extraServices}
    `:''].filter(Boolean); + const egSide=egSideParts.length?``:''; + const egFacts=[visibleDate,clientName,visibleGuests].filter(Boolean); + const footerNote=cfg.showFooterNote?`
    ${esc(cfg.texts.footerNote)}
    `:''; + let body=''; + if(templateId==='editorial-grid'){ + const editorial=editorialGroups(items); + const editorialRows=editorial.map((group,gi)=>`
    ${String(gi+1).padStart(2,'0')}
    ${esc(group.name)}${esc(group.meta)}
    × ${Math.round(group.qty)}
    `).join(''); + const editorialGallery=items.slice(0,8).map((item,i)=>photo(item,i,'ed-photo')).join(''); + body=`${brand}
    ${esc(cfg.texts.proposalLabel)}

    ${esc(eventName)}

    ${clientName?`
    ${esc(clientName)}
    `:''}
    ${visibleDate?`◷ ${esc(visibleDate)}`:''}${visibleGuests?`○ ${esc(visibleGuests)}`:''}

    ${esc(cfg.texts.heroNote)}

    ${esc(eventName)}
    ${sellingBlock}
    ${esc(cfg.texts.menuTitle)}
    ${editorialRows}
    ${cfg.showGallery?`
    ${editorialGallery}
    `:''}

    ${esc(cfg.texts.summaryTitle)}

    ${metrics}
    ${esc(cfg.texts.totalPriceTitle)}${money(p.total)}${g?`на гостя: ${money(menuPer)}`:'Финальная сумма'}${cfg.showDiscountBadge&&discountPct?`-${discountPct}% учтено`:''}
    ${breakdown}${info}${actions}`; + }else if(templateId==='light'){ + body=`${brand}${standardHero}${sellingBlock}
    ${esc(cfg.texts.menuTitle)}
    ${cfg.showGallery?``:''}

    ${esc(cfg.texts.summaryTitle)}

    ${metrics}
    ${esc(cfg.texts.totalPriceTitle)}${money(p.total)}${g?`на гостя: ${money(menuPer)}`:'Финальная сумма'}${cfg.showDiscountBadge&&discountPct?`-${discountPct}% учтено`:''}
    ${breakdown}
    ${info}${actions}`; + }else if(templateId==='midnight-glass'){ + body=`${brand}
    ${esc(cfg.texts.proposalLabel)}

    ${esc(cfg.texts.menuTitle)} на ваше событие

    ${esc(eventName)}${identityLine?` · ${esc(identityLine)}`:''}

    ${esc(cfg.texts.heroNote)}

    Разнообразие блюдСвежие ингредиентыПремиальное качество
    ${sellingBlock}

    ${esc(cfg.texts.menuTitle)}

    ${items.map(row).join('')}
    ${cfg.showGallery?``:''}

    ${esc(cfg.texts.summaryTitle)}

    ${metrics}
    ${esc(cfg.texts.totalPriceTitle)}${money(p.total)}${g?`на гостя: ${money(menuPer)}`:'Финальная сумма'}${cfg.showDiscountBadge&&discountPct?`-${discountPct}% учтено`:''}
    ${breakdown}
    ${info}${actions}`; + }else if(templateId==='midnight-compact'){ + body=`${brand}
    ${esc(cfg.texts.proposalLabel)}

    ${esc(eventName)}

    ${identityLine?`

    ${esc(identityLine)}

    `:''}
    ${esc(cfg.texts.heroNote)}
    ${groups.map(groupTable).join('')}
    ${cfg.showGallery?``:''}

    ${esc(cfg.texts.summaryTitle)}

    ${metrics}
    ${esc(cfg.texts.totalPriceTitle)}${money(p.total)}${cfg.showDiscountBadge&&discountPct?`-${discountPct}%`:''}
    ${info}${breakdown}${actions}`; + }else if(templateId==='black-gold'){ + body=`${brand}
    ${esc(cfg.texts.proposalLabel)}

    Кейтеринг
    для вашего события

    ${esc(eventName)}${identityLine?`
    ${esc(identityLine)}`:''}

    ${esc(cfg.texts.menuTitle)} для вашего мероприятия
    ${items.map((item,i)=>`
    ${esc(item.name)}${String(i+1).padStart(2,'0')}×${Math.round(num(item.qty))}

    ${esc(item.name)}

    ${esc([weightLabel(item)].filter(Boolean).join(' · ')||sectionTitle(item))}

    ${compositionText(item,', ')?`${esc(compositionText(item,', '))}`:''}
    `).join('')}

    ${esc(cfg.texts.summaryTitle)}

    ${metrics}
    ${money(p.total)}${g?`на гостя: ${money(menuPer)}`:''}
    ${blackControl}
    ${blackExtras}${breakdown}${actions}`; + }else if(templateId==='emerald-gold'){ + body=`${brand}
    ${esc(cfg.texts.proposalLabel)}

    ${esc(eventName)}

    ${esc(cfg.texts.heroNote)}

    ${egFacts.length?`
    ${egFacts.map(x=>`${esc(x)}`).join('')}
    `:''}
    ${metrics}
    ${esc(cfg.texts.totalPriceTitle)}${money(p.total)}
    ${items[1]?``:''}
    ${sellingBlock}

    ${esc(cfg.texts.menuTitle)}

    ${items.map(row).join('')}
    ${egSide}
    ${breakdown}${actions}`; + }else{ + body=`${brand}
    ${visibleDate?`${esc(visibleDate)}`:''}

    ${esc(eventName)}

    ${esc(cfg.texts.heroNote)}

    ${visibleGuests?`${g}гостей`:''}

    ${esc(cfg.texts.proposalLabel)}

    Свежие ингредиентыАвторские рецептыКонтроль качества
    ${groups.map(groupTable).join('')}

    ${esc(cfg.texts.summaryTitle)}

    ${metrics}
    ${esc(cfg.texts.totalPriceTitle)}${money(p.total)}${cfg.showDiscountBadge&&discountPct?`-${discountPct}%`:''}
    ${info}${breakdown}${actions}`; + } + const finalGalleryItems=cfg.showFinalPhoto&&Array.isArray(s.finalGallery)?s.finalGallery.filter(Boolean).slice(0,2):[]; + const finalPhoto=finalGalleryItems.length?``:''; + return `Предложение клиенту
    ${body}${footerNote}${finalPhoto}
    Солнце Кейтеринг · Санкт-Петербург+7 (921) 954-04-40 · solnce-keytering.ru
    `; + } + let offerRenderSeq=0; + let currentOffer={orderId:null,snapshot:null,prepared:null,pdfPrepared:null,pages:null,pdfBlob:null,pdfPromise:null,renderToken:0,preferDraft:false,transient:false,editTimer:null}; + function renderOfferPages(pages){const host=$('sunClientOfferPreview');if(!host)return;host.innerHTML='';for(const page of (pages||[])){page.classList.add('sun-offer-page-canvas');page.setAttribute('aria-label','Страница предложения');host.appendChild(page)}} + function offerRenderMessage(text='Обновляю предложение…'){const host=$('sunClientOfferPreview');if(host)host.innerHTML=`
    ${esc(text)}
    `} + async function renderOffer(snapshot){ + const renderToken=++offerRenderSeq;currentOffer.snapshot=snapshot;currentOffer.pdfBlob=null;currentOffer.pdfPromise=null;currentOffer.pages=null;offerRenderMessage();const pdfBtn=$('sunOfferPdf');if(pdfBtn){pdfBtn.disabled=true;pdfBtn.textContent='Готовлю просмотр…'} + const prepared=await preparedSnapshot(snapshot);prepared.offerTemplateId=offerTemplateId(); + const pdfPrepared=await preparedPdfSnapshot(prepared),pages=await renderOfferPdfPages(pdfPrepared); + if(renderToken!==offerRenderSeq)return null; + currentOffer.snapshot=snapshot;currentOffer.prepared=prepared;currentOffer.pdfPrepared=pdfPrepared;currentOffer.pages=pages;currentOffer.renderToken=renderToken; + renderOfferPages(pages);syncOfferEditor(); + if(pdfBtn){pdfBtn.disabled=false;pdfBtn.textContent='Скачать PDF'} + return pages; + } + function toggleOfferEditor(){const panel=$('sunOfferEditor'),btn=$('sunOfferEdit');if(!panel)return;const on=!panel.classList.contains('on');panel.classList.toggle('on',on);if(btn)btn.textContent=on?'Скрыть редактор':'Редактировать';if(on)syncOfferEditor()} + function offerOverrideTextValue(key){const over=currentOffer.snapshot?.offerOverrides?.texts||{},cfg=offerSettings();return Object.prototype.hasOwnProperty.call(over,key)?String(over[key]??''):String(cfg.texts?.[key]??'')} + function offerOverrideDataValue(key){const over=currentOffer.snapshot?.offerOverrides?.data||{},snap=currentOffer.snapshot||{};if(Object.prototype.hasOwnProperty.call(over,key))return over[key]??'';return key==='guests'?(snap.guests??''):(snap[key]??'')} + function syncOfferEditor(){const panel=$('sunOfferEditor');if(!panel||!currentOffer.snapshot)return;panel.querySelectorAll('[data-offer-text]').forEach(el=>{if(document.activeElement!==el)el.value=offerOverrideTextValue(el.dataset.offerText)});panel.querySelectorAll('[data-offer-data]').forEach(el=>{if(document.activeElement!==el)el.value=offerOverrideDataValue(el.dataset.offerData)});panel.querySelectorAll('[data-offer-list="controlLines"]').forEach(el=>{if(document.activeElement!==el){const over=currentOffer.snapshot?.offerOverrides?.controlLines;const lines=Array.isArray(over)?over:offerSettings().controlLines;el.value=(lines||[]).join('\n')}})} + function saveOfferOverrides(){if(!currentOffer.snapshot)return;currentOffer.snapshot.createdAt=new Date().toISOString();if(!currentOffer.transient&¤tOffer.orderId)persistSnapshot(currentOffer.orderId,currentOffer.snapshot);const meta=$('sunOfferMeta');if(meta)meta.textContent=`Изменено ${new Date(currentOffer.snapshot.createdAt).toLocaleString('ru-RU')}`} + function scheduleOfferEditRender(){clearTimeout(currentOffer.editTimer);currentOffer.editTimer=setTimeout(()=>{if(currentOffer.snapshot)renderOffer(currentOffer.snapshot).catch(err=>{console.error(err);toast('Не удалось обновить предпросмотр.','error')})},500)} + function bindOfferEditor(){const panel=$('sunOfferEditor');if(!panel||panel.dataset.bound)return;panel.dataset.bound='1';panel.addEventListener('input',e=>{const el=e.target.closest('[data-offer-text],[data-offer-data],[data-offer-list]');if(!el||!currentOffer.snapshot)return;currentOffer.snapshot.offerOverrides=currentOffer.snapshot.offerOverrides||{};if(el.dataset.offerText){currentOffer.snapshot.offerOverrides.texts=currentOffer.snapshot.offerOverrides.texts||{};currentOffer.snapshot.offerOverrides.texts[el.dataset.offerText]=el.value}else if(el.dataset.offerList==='controlLines'){currentOffer.snapshot.offerOverrides.controlLines=String(el.value||'').split(/\r?\n/).map(x=>x.trim()).filter(Boolean).slice(0,20)}else{currentOffer.snapshot.offerOverrides.data=currentOffer.snapshot.offerOverrides.data||{};currentOffer.snapshot.offerOverrides.data[el.dataset.offerData]=el.dataset.offerData==='guests'?Math.max(0,Number(el.value||0)):el.value}currentOffer.pdfBlob=null;currentOffer.pages=null;scheduleOfferEditRender()});panel.addEventListener('change',saveOfferOverrides);panel.addEventListener('focusout',e=>{if(e.target.matches?.('[data-offer-text],[data-offer-data],[data-offer-list]'))saveOfferOverrides()})} + function setMeta(order,snapshot,{transient=false,autoRefreshed=false}={}){ + const stale=snapshotIsStale(order,snapshot),notice=$('sunOfferStale'); + if(notice){ + notice.classList.toggle('on',Boolean(stale||transient)); + notice.textContent=transient + ?'В предложении учтены текущие изменения заказа. Сохраните заказ, чтобы закрепить их.' + :(stale?'Заказ изменён. Предложение обновляется по актуальным данным…':''); + } + if($('sunOfferMeta'))$('sunOfferMeta').textContent=`Версия от ${new Date(snapshot.createdAt).toLocaleString('ru-RU')}${autoRefreshed?' · обновлено автоматически':''}`; + } + + async function openOfferForOrder(orderId,{forceRefresh=false,preferDraft=false}={}){ + ensureModal(); + const saved=currentSavedOrder(orderId);if(!saved)return toast('Заказ не найден.','error'); + const order=currentOfferSourceOrder(orderId,preferDraft)||saved; + const draftDiff=order!==saved&&orderSignature(order)!==orderSignature(saved); + let snapshot=saved.clientOfferSnapshot,autoRefreshed=false; + const stale=snapshotIsStale(order,snapshot); + if(stale||forceRefresh){ + if(forceRefresh)toast('Обновляю предложение…'); + snapshot=await createSnapshot(order);autoRefreshed=stale&&!forceRefresh; + // Never freeze an unsaved draft into the saved order. Saved orders, however, + // always receive the fresh snapshot so every template/PDF opens consistently. + if(!draftDiff&&!persistSnapshot(saved.id,snapshot))return; + } + currentOffer={orderId:saved.id,snapshot,prepared:null,pdfPrepared:null,pages:null,pdfBlob:null,pdfPromise:null,renderToken:0,preferDraft:Boolean(preferDraft),transient:draftDiff,editTimer:null}; + setMeta(order,snapshot,{transient:draftDiff,autoRefreshed}); + window.modal?.('sunClientOfferModal');await renderOffer(snapshot); + if(autoRefreshed)toast('Предложение автоматически обновлено по текущему заказу.'); + } + async function openCurrentDraftOffer(){ + const d=getDraft();if(!d?.id)return alert('Сначала сохраните заказ. После сохранения появится предложение клиенту.'); + const saved=currentSavedOrder(d.id);if(!saved)return alert('Сначала сохраните заказ.'); + return openOfferForOrder(saved.id,{preferDraft:true}); + } + async function refreshCurrentOffer(){ + if(!currentOffer.orderId)return; + await openOfferForOrder(currentOffer.orderId,{forceRefresh:true,preferDraft:currentOffer.preferDraft}); + toast('Предложение обновлено.'); + } + async function ensureCurrentOfferFresh(){ + if(!currentOffer.orderId)return false; + const saved=currentSavedOrder(currentOffer.orderId);if(!saved)return false; + const order=currentOfferSourceOrder(currentOffer.orderId,currentOffer.preferDraft)||saved; + if(!snapshotIsStale(order,currentOffer.snapshot))return true; + await openOfferForOrder(currentOffer.orderId,{forceRefresh:true,preferDraft:currentOffer.preferDraft}); + return true; + } + function filename(ext){const d=currentOffer.prepared?.date||currentOffer.snapshot?.date||'';return `predlozhenie-klientu${d?'-'+d:''}.${ext}`} + + function pdfRoundRect(ctx,x,y,w,h,r,fill,stroke){ + const rr=Math.min(r,w/2,h/2);ctx.beginPath();ctx.moveTo(x+rr,y);ctx.arcTo(x+w,y,x+w,y+h,rr);ctx.arcTo(x+w,y+h,x,y+h,rr);ctx.arcTo(x,y+h,x,y,rr);ctx.arcTo(x,y,x+w,y,rr);ctx.closePath();if(fill){ctx.fillStyle=fill;ctx.fill()}if(stroke){ctx.strokeStyle=stroke;ctx.lineWidth=2;ctx.stroke()} + } + function pdfWrap(ctx,text,maxWidth){ + const input=String(text||'').replace(/\s+/g,' ').trim();if(!input)return[]; + const words=input.split(' '),lines=[];let line=''; + const pushLong=word=>{let chunk='';for(const ch of word){const next=chunk+ch;if(ctx.measureText(next).width>maxWidth&&chunk){lines.push(chunk);chunk=ch}else chunk=next}return chunk}; + for(const word of words){ + if(ctx.measureText(word).width>maxWidth){if(line){lines.push(line);line=''}line=pushLong(word);continue} + const test=line?`${line} ${word}`:word; + if(ctx.measureText(test).width<=maxWidth)line=test;else{if(line)lines.push(line);line=word} + } + if(line)lines.push(line);return lines; + } + function pdfText(ctx,text,x,y,maxWidth,lineHeight,opts={}){ + ctx.save();ctx.font=opts.font||'28px Arial';ctx.fillStyle=opts.color||'#1a1a1a';ctx.textAlign=opts.align||'left';ctx.textBaseline='top'; + let lines=pdfWrap(ctx,text,maxWidth);const maxLines=Math.max(0,Number(opts.maxLines||0)); + if(maxLines&&lines.length>maxLines){lines=lines.slice(0,maxLines);let last=lines[lines.length-1]||'';while(last&&ctx.measureText(last+'...').width>maxWidth)last=last.slice(0,-1);lines[lines.length-1]=last+'...'} + const lh=Math.max(lineHeight||0,Math.ceil(parseFloat(String(ctx.font).match(/([0-9.]+)px/)?.[1]||16)*1.18)); + lines.forEach((line,i)=>ctx.fillText(line,x,y+i*lh));ctx.restore();return lines.length*lh; + } + function pdfDrawCheck(ctx,x,y,r,fill,stroke){ctx.save();ctx.fillStyle=fill;ctx.beginPath();ctx.arc(x,y,r,0,Math.PI*2);ctx.fill();ctx.strokeStyle=stroke;ctx.lineWidth=Math.max(2,r*.22);ctx.lineCap='round';ctx.lineJoin='round';ctx.beginPath();ctx.moveTo(x-r*.45,y);ctx.lineTo(x-r*.1,y+r*.34);ctx.lineTo(x+r*.52,y-r*.38);ctx.stroke();ctx.restore()} + function pdfLoadImage(src){return new Promise(resolve=>{const safe=isSafeRasterDataUrl(src)?String(src):'';if(!safe)return resolve(null);const img=new Image();let done=false;const finish=v=>{if(done)return;done=true;clearTimeout(timer);resolve(v)};const timer=setTimeout(()=>finish(null),4500);img.onload=()=>finish(img);img.onerror=()=>finish(null);img.src=safe})} + function pdfDrawCover(ctx,img,x,y,w,h,r=24){ + if(!img)return;const scale=Math.max(w/img.naturalWidth,h/img.naturalHeight),sw=w/scale,sh=h/scale,sx=(img.naturalWidth-sw)/2,sy=(img.naturalHeight-sh)/2; + ctx.save();pdfRoundRect(ctx,x,y,w,h,r);ctx.clip();ctx.drawImage(img,sx,sy,sw,sh,x,y,w,h);ctx.restore(); + } + function pdfDrawContain(ctx,img,x,y,w,h){ + if(!img)return;const scale=Math.min(w/img.naturalWidth,h/img.naturalHeight),dw=img.naturalWidth*scale,dh=img.naturalHeight*scale;ctx.drawImage(img,x+(w-dw)/2,y+(h-dh)/2,dw,dh); + } + const pdfFromJpegs=images=>window.SunPdfEngine.fromJpegs(images); + + function pdfCanvasJpeg(canvas){ + return new Promise((resolve,reject)=>{ + let done=false;const finish=(err,value)=>{if(done)return;done=true;clearTimeout(timer);err?reject(err):resolve(value)}; + const fromDataUrl=()=>{try{const data=canvas.toDataURL('image/jpeg',0.94),base64=String(data).split(',')[1]||'';if(!base64)throw new Error('Пустое изображение страницы');const raw=atob(base64),bytes=new Uint8Array(raw.length);for(let i=0;i{if(done)return;if(!blob)return fromDataUrl();try{finish(null,{width:canvas.width,height:canvas.height,bytes:new Uint8Array(await blob.arrayBuffer())})}catch(err){fromDataUrl()}},'image/jpeg',0.94); + }catch(_){fromDataUrl()} + }) + } + + function pdfThemeFor(id){ + const themes={ + light:{BG:'#ffffff',PANEL:'#ffffff',LINE:'#e7d8bd',TEXT:'#1d211d',MUTED:'#6f746e',GOLD:'#c99b47',GOLD2:'#e2c28b',GREEN:'#476f4d',GREEN_DEEP:'#28533a',GREEN_SOFT:'#f1f5ef',FOOT:'#ffffff'}, + 'editorial-grid':{BG:'#fbfaf6',PANEL:'#fffefb',LINE:'#e8dfcf',TEXT:'#163127',MUTED:'#776f62',GOLD:'#b99355',GOLD2:'#d8c4a2',GREEN:'#245f46',GREEN_DEEP:'#1f5a42',GREEN_SOFT:'#e9f1eb',FOOT:'#fbfaf6'}, + 'midnight-glass':{BG:'#061521',PANEL:'#0b2230',LINE:'#315263',TEXT:'#f4f1e8',MUTED:'#9aaeb8',GOLD:'#d4a64b',GOLD2:'#765f35',GREEN:'#24a98f',GREEN_DEEP:'#d7ad58',GREEN_SOFT:'#123d38',FOOT:'#04111b'}, + 'midnight-compact':{BG:'#03131c',PANEL:'#0a1b25',LINE:'#3a4d57',TEXT:'#f4f3ef',MUTED:'#9daab1',GOLD:'#e0b751',GOLD2:'#5e5231',GREEN:'#62a868',GREEN_DEEP:'#f1c96c',GREEN_SOFT:'#264b37',FOOT:'#06141c'}, + 'black-gold':{BG:'#0c0c0b',PANEL:'#151513',LINE:'#453d2e',TEXT:'#f2eee6',MUTED:'#aaa49b',GOLD:'#c99a3c',GOLD2:'#57451f',GREEN:'#b98a31',GREEN_DEEP:'#d2a650',GREEN_SOFT:'#342c1d',FOOT:'#090908'}, + 'emerald-gold':{BG:'#061318',PANEL:'#0b2023',LINE:'#3b4f50',TEXT:'#f2efe7',MUTED:'#a7b0ae',GOLD:'#d7ac55',GOLD2:'#6a5930',GREEN:'#1c765d',GREEN_DEEP:'#d9b466',GREEN_SOFT:'#123b35',FOOT:'#07171a'}, + 'neon-emerald':{BG:'#020e12',PANEL:'#06191d',LINE:'#277b4d',TEXT:'#effff5',MUTED:'#9cb3aa',GOLD:'#d0a841',GOLD2:'#665322',GREEN:'#39f07c',GREEN_DEEP:'#67ff91',GREEN_SOFT:'#0c3f27',FOOT:'#020b0e'} + }; + return themes[id]||themes.light; + } + async function renderOfferPdfPages(s){ + const id=s.offerTemplateId||offerTemplateId(),W=1000,H=1414,SCALE=1.6,M=52,T=pdfThemeFor(id),{BG,PANEL,LINE,TEXT,MUTED,GOLD,GOLD2,GREEN,GREEN_DEEP,GREEN_SOFT,FOOT}=T; + const cfg=offerSettingsForSnapshot(s),items=s.items||[],imageMap=new Map(); + for(const item of items)imageMap.set(item.id,await pdfLoadImage(item.photoData||s.logo||'')); + const logo=await pdfLoadImage(s.logo||''); + const finalGallery=cfg.showFinalPhoto?(await Promise.all((Array.isArray(s.finalGallery)?s.finalGallery:[]).map(pdfLoadImage))).filter(Boolean):[]; + const heroImg=items.map(i=>imageMap.get(i.id)).find(Boolean)||finalGallery[0]||logo,p=s.pricing||{},g=num(s.guests),menuPer=perGuest(p.itemsTotal,g),boxCount=offerBoxCount(items),discountPct=p.base>0?Math.round((p.discount/p.base)*100):0; + const primaryMetric=offerPrimaryMetric(s,cfg,g),visibleDate=cfg.showDate?dateText(s.date):'',visibleClient=cfg.showClientName?String(s.client||'').trim():'',visibleGuests=cfg.showGuests&&g?`${g} ${plural(g,'гость','гостя','гостей')}`:''; + const identity=[visibleClient,visibleDate,visibleGuests].filter(Boolean).join(' · '),control=Array.isArray(cfg.controlLines)&&cfg.controlLines.length?cfg.controlLines:DELIVERY_CONTROL,extras=Array.isArray(cfg.extraServices)&&cfg.extraServices.length?cfg.extraServices:EXTRA_SERVICES.map(x=>x[0]); + const canvases=[];let canvas,ctx,y;const bottom=H-78; + function newPage(){canvas=document.createElement('canvas');canvas.width=Math.round(W*SCALE);canvas.height=Math.round(H*SCALE);ctx=canvas.getContext('2d',{alpha:false});ctx.setTransform(SCALE,0,0,SCALE,0,0);ctx.fillStyle=BG;ctx.fillRect(0,0,W,H);y=M;canvases.push(canvas);return ctx} + function space(h){if(y+h>bottom)newPage()} + function fitBlockHeight(natural,minHeight=natural,gap=0,softRatio=.84){ + const h=Math.max(1,Number(natural||0)),minH=Math.min(h,Math.max(1,Number(minHeight||h))),available=Math.max(0,bottom-y-gap); + if(h<=available)return h; + if(available>=minH&&available>=h*softRatio)return available; + newPage();return h; + } + function card(x,yy,w,h,r=20,fill=PANEL,stroke=LINE){pdfRoundRect(ctx,x,yy,w,h,r,fill,stroke)} + function line(x1,yy1,x2,yy2,color=LINE,width=1){const snap=v=>width===1?Math.round(v)+.5:v;ctx.save();ctx.strokeStyle=color;ctx.lineWidth=width;ctx.beginPath();ctx.moveTo(snap(x1),snap(yy1));ctx.lineTo(snap(x2),snap(yy2));ctx.stroke();ctx.restore()} + function wrapCount(text,width,font){ctx.save();ctx.font=font;const n=Math.max(1,pdfWrap(ctx,String(text||''),width).length);ctx.restore();return n} + function wrappedLines(text,width,font){ctx.save();ctx.font=font;const out=pdfWrap(ctx,String(text||''),width);ctx.restore();return out} + function drawWrappedLines(lines,x,yy,lineHeight,{font='12px Arial',color=TEXT,align='left'}={}){ctx.save();ctx.font=font;ctx.fillStyle=color;ctx.textAlign=align;ctx.textBaseline='top';lines.forEach((line,i)=>ctx.fillText(line,x,yy+i*lineHeight));ctx.restore()} + function compositionTextPdf(item,sep=' · '){return offerComposition(item).join(sep)} + function brandHeader(){space(82);if(logo)pdfDrawContain(ctx,logo,M,y,195,62);if(visibleDate)pdfText(ctx,visibleDate,W-M,y+24,300,24,{font:'14px Arial',color:MUTED,align:'right',maxLines:1});y+=78} + function hero(){ + const event=s.event||'Персональное предложение'; + if(id==='black-gold'){ + space(425);const h=405;card(M,y,W-2*M,h,24,'#0c0c0b',LINE);if(heroImg)pdfDrawCover(ctx,heroImg,M,y,W-2*M,h,24);const grad=ctx.createLinearGradient(M,y,M+650,y);grad.addColorStop(0,'rgba(0,0,0,.94)');grad.addColorStop(.68,'rgba(0,0,0,.48)');grad.addColorStop(1,'rgba(0,0,0,0)');ctx.save();pdfRoundRect(ctx,M,y,W-2*M,h,24);ctx.clip();ctx.fillStyle=grad;ctx.fillRect(M,y,W-2*M,h);ctx.restore();pdfText(ctx,cfg.texts.proposalLabel,M+34,y+36,430,22,{font:'700 12px Arial',color:GOLD,maxLines:1});pdfText(ctx,'Кейтеринг для вашего события',M+34,y+78,500,56,{font:'500 43px Georgia',color:'#ffffff',maxLines:3});pdfText(ctx,[event,identity].filter(Boolean).join(' · '),M+34,y+273,510,28,{font:'16px Arial',color:'#ded8cd',maxLines:3});y+=h+18;return; + } + if(id==='editorial-grid'){ + const leftW=386,rightX=M+leftW,rightW=W-M-rightX,leftTextW=leftW-56,eventLines=wrappedLines(event,leftTextW,'500 34px Georgia'),noteLines=wrappedLines(cfg.texts.heroNote,leftTextW,'12px Arial'); + const h=Math.max(350,94+eventLines.length*43+(visibleClient?31:0)+([visibleDate,visibleGuests].filter(Boolean).length?42:0)+Math.max(52,noteLines.length*19+18)); + space(h+18);card(M,y,W-2*M,h,18,PANEL,LINE);if(heroImg)pdfDrawCover(ctx,heroImg,rightX+1,y+1,rightW-1,h-2,17); + pdfText(ctx,cfg.texts.proposalLabel,M+26,y+26,leftTextW,20,{font:'700 11px Arial',color:GOLD,maxLines:2}); + drawWrappedLines(eventLines,M+26,y+62,43,{font:'500 34px Georgia',color:TEXT});let ty=y+62+eventLines.length*43+12; + if(visibleClient){pdfText(ctx,visibleClient,M+26,ty,leftTextW,25,{font:'500 17px Georgia',color:GREEN_DEEP,maxLines:1});ty+=31} + const facts=[visibleDate,visibleGuests].filter(Boolean).join(' · ');if(facts){pdfText(ctx,facts,M+26,ty,leftTextW,22,{font:'12px Arial',color:TEXT,maxLines:2});ty+=38} + line(M+26,ty,M+26+leftTextW,ty,GOLD2,1);ty+=15;if(noteLines.length)drawWrappedLines(noteLines,M+26,ty,19,{font:'12px Arial',color:MUTED}); + y+=h+18;return; + } + if(id==='midnight-compact'){ + const innerX=M+28,innerW=W-2*M-56,leftW=510,gap=28,rightX=innerX+leftW+gap,rightW=innerW-leftW-gap; + const eventLines=wrapCount(event,leftW,'700 31px Arial'),noteLines=wrapCount(cfg.texts.heroNote,rightW,'15px Arial'),h=Math.max(228,138+Math.max(eventLines*42,noteLines*21));space(h+20);card(M,y,W-2*M,h,20,PANEL,LINE); + pdfText(ctx,cfg.texts.proposalLabel,innerX,y+24,leftW,22,{font:'700 12px Arial',color:GOLD,maxLines:1}); + pdfText(ctx,event,innerX,y+57,leftW,42,{font:'700 31px Arial',color:TEXT,maxLines:3}); + line(rightX-gap/2,y+28,rightX-gap/2,y+h-28,LINE,1); + pdfText(ctx,cfg.texts.heroNote,rightX,y+55,rightW,21,{font:'15px Arial',color:GREEN_DEEP,maxLines:7}); + if(identity)pdfText(ctx,identity,innerX,y+h-39,leftW,23,{font:'13px Arial',color:MUTED,maxLines:1}); + y+=h+18;return; + } + const left=id==='neon-emerald'?500:425,rightX=M+left,rightW=W-M-rightX,leftTextW=left-56; + const eventFont=id==='neon-emerald'?'700 34px Arial':'500 38px Georgia',eventLH=48,noteFont='12px Arial',noteLH=20; + const eventLines=wrappedLines(event,leftTextW,eventFont),noteLines=wrappedLines(cfg.texts.heroNote,leftTextW,noteFont); + const identityRows=(visibleClient?31:0)+([visibleDate,visibleGuests].filter(Boolean).length?52:0); + const textNeed=68+Math.max(1,eventLines.length)*eventLH+24+identityRows+(noteLines.length?Math.max(54,noteLines.length*noteLH+14):0)+22; + const h=Math.max(id==='emerald-gold'?380:335,textNeed);space(h+20);card(M,y,W-2*M,h,24,PANEL,LINE);if(heroImg)pdfDrawCover(ctx,heroImg,rightX+1,y+1,rightW-1,h-2,22); + pdfText(ctx,cfg.texts.proposalLabel,M+28,y+29,leftTextW,22,{font:'700 12px Arial',color:id==='neon-emerald'?GREEN:GOLD,maxLines:2}); + drawWrappedLines(eventLines,M+28,y+68,eventLH,{font:eventFont,color:TEXT}); + let ty=y+68+Math.max(1,eventLines.length)*eventLH+20;if(visibleClient){pdfText(ctx,visibleClient,M+28,ty,leftTextW,27,{font:'500 18px Georgia',color:GREEN_DEEP,maxLines:1});ty+=31} + const facts=[visibleDate,visibleGuests].filter(Boolean).join(' · ');if(facts){pdfText(ctx,facts,M+28,ty,leftTextW,26,{font:'14px Arial',color:TEXT,maxLines:2});ty+=48} + if(noteLines.length){line(M+28,ty-8,M+28+leftTextW,ty-8,GOLD2,1);drawWrappedLines(noteLines,M+28,ty+6,noteLH,{font:noteFont,color:MUTED})} + y+=h+18; + } + function sellingBlock(){ + const title=String(cfg.texts.salesTitle||'').trim(),body=String(cfg.texts.salesNote||'').trim();if(!title&&!body)return; + const x=M,w=W-2*M,padX=30,titleFont='500 22px Georgia',bodyFont='14px Arial',titleLH=30,bodyLH=23,innerW=w-padX*2-12; + let bodyLines=wrappedLines(body,innerW,bodyFont),first=true; + do{ + const titleLines=first&&title?wrappedLines(title,innerW,titleFont):[]; + const headingH=titleLines.length?titleLines.length*titleLH+12:0; + const minRows=Math.min(3,Math.max(1,bodyLines.length||1)); + let minH=30+headingH+(bodyLines.length?minRows*bodyLH:0)+24; + if(bottom-y{ + const nameLines=Math.max(1,wrapCount(item.name,cw-32,'500 17px Georgia')); + const meta=[weightLabel(item),compositionTextPdf(item,', ')].filter(Boolean).join(' · '); + const metaLines=meta?Math.max(1,wrapCount(meta,cw-32,'10px Arial')):0; + const minH=252+nameLines*24+(metaLines?8+metaLines*19:0)+18; + return {item,nameLines,meta,metaLines,minH,h:Math.max(340,minH)}; + }); + const naturalRh=Math.max(...measured.map(v=>v.h),340),minRh=Math.max(...measured.map(v=>v.minH)),rh=fitBlockHeight(naturalRh,minRh,14,.88); + measured.forEach((v,j)=>{ + const {item,nameLines,meta,metaLines}=v,x=M+j*(cw+gap); + card(x,y,cw,rh,16,PANEL,LINE);const img=imageMap.get(item.id)||logo;if(img)pdfDrawCover(ctx,img,x+1,y+1,cw-2,205,15); + pdfText(ctx,String(i+j+1).padStart(2,'0'),x+12,y+12,38,20,{font:'700 11px Arial',color:GOLD,maxLines:1}); + pdfText(ctx,`x${Math.round(num(item.qty))}`,x+cw-13,y+12,40,20,{font:'700 11px Arial',color:'#ffffff',align:'right',maxLines:1}); + pdfText(ctx,item.name,x+16,y+222,cw-32,24,{font:'500 17px Georgia',color:TEXT,maxLines:nameLines}); + if(meta)pdfText(ctx,meta,x+16,y+222+nameLines*24+8,cw-32,19,{font:'10px Arial',color:MUTED,maxLines:metaLines}); + }); + y+=rh+14 + } + return; + } + if(id==='editorial-grid'){ + const groups=editorialGroups(items),gap=10,cw=(W-2*M-gap)/2; + for(let i=0;i{ + const x=M+j*(cw+gap);card(x,y,cw,rh,13,PANEL,LINE); + ctx.save();ctx.fillStyle=GREEN_DEEP;ctx.beginPath();ctx.arc(x+31,y+39,20,0,Math.PI*2);ctx.fill();ctx.restore(); + pdfText(ctx,String(i+j+1).padStart(2,'0'),x+31,y+30,30,18,{font:'700 10px Arial',color:'#ffffff',align:'center',maxLines:1}); + pdfText(ctx,group.name,x+62,y+15,cw-150,24,{font:'500 16px Georgia',color:TEXT,maxLines:2}); + if(group.meta)pdfText(ctx,group.meta,x+62,y+48,cw-150,18,{font:'10px Arial',color:GOLD,maxLines:1}); + card(x+cw-68,y+24,48,28,14,PANEL,LINE);pdfText(ctx,`x${Math.round(group.qty)}`,x+cw-44,y+29,40,18,{font:'11px Arial',color:TEXT,align:'center',maxLines:1}); + }); + y+=rh+10; + } + if(cfg.showGallery)drawGallery(4,126,false);return; + } + if(id==='midnight-compact'){ + const groups=offerGroups(items); + for(const group of groups){ + const titleLines=wrapCount(group.name,600,'700 16px Arial'),headerH=Math.max(45,18+titleLines*24);space(headerH+20); + card(M,y,W-2*M,headerH,13,PANEL,LINE);pdfText(ctx,group.name,M+18,y+12,650,24,{font:'700 16px Arial',color:GREEN_DEEP,maxLines:titleLines});y+=headerH; + for(const item of group.list){ + const nameLines=Math.max(1,wrapCount(item.name,535,'14px Arial')); + const comp=compositionTextPdf(item); + const compLines=comp?Math.max(1,wrapCount(comp,535,'10px Arial')):0; + const minH=28+nameLines*21+(compLines?5+compLines*17:0)+10,naturalH=Math.max(64,minH),h=fitBlockHeight(naturalH,minH,0,.88); + line(M,y,W-M,y,LINE,1); + pdfText(ctx,item.name,M+18,y+14,535,21,{font:'14px Arial',color:TEXT,maxLines:nameLines}); + if(comp)pdfText(ctx,comp,M+18,y+14+nameLines*21+5,535,17,{font:'10px Arial',color:MUTED,maxLines:compLines}); + pdfText(ctx,weightLabel(item)||item.weight||'—',M+588,y+14,188,36,{font:'11px Arial',color:MUTED,align:'center',maxLines:2}); + pdfText(ctx,`x${Math.round(num(item.qty))}`,W-M-25,y+14,80,21,{font:'700 13px Arial',color:TEXT,align:'right',maxLines:1}); + y+=h + } + y+=10 + } + if(cfg.showGallery)drawGallery(5,118,true);return; + } + if(id==='neon-emerald'){ + const gap=12,cw=(W-2*M-gap)/2; + for(let i=0;i{ + const nameLines=Math.max(1,wrapCount(item.name,cw-202,'700 15px Arial')); + const meta=[weightLabel(item),compositionTextPdf(item)].filter(Boolean).join(' · '); + const metaLines=meta?Math.max(1,wrapCount(meta,cw-202,'10px Arial')):0; + const minH=38+nameLines*22+(metaLines?6+metaLines*18:0)+20; + return {item,nameLines,meta,metaLines,minH,h:Math.max(124,minH)}; + }); + const naturalRh=Math.max(...measured.map(v=>v.h)),minRh=Math.max(...measured.map(v=>v.minH)),rh=fitBlockHeight(naturalRh,minRh,12,.88); + measured.forEach((v,j)=>{ + const {item,nameLines,meta,metaLines}=v,x=M+j*(cw+gap);card(x,y,cw,rh,16,PANEL,LINE); + const img=imageMap.get(item.id)||logo;if(img)pdfDrawCover(ctx,img,x+8,y+8,102,rh-16,12); + pdfText(ctx,item.name,x+122,y+17,cw-202,22,{font:'700 15px Arial',color:TEXT,maxLines:nameLines}); + if(meta)pdfText(ctx,meta,x+122,y+17+nameLines*22+6,cw-202,18,{font:'10px Arial',color:GREEN,maxLines:metaLines}); + pdfText(ctx,`x${Math.round(num(item.qty))}`,x+cw-18,y+18,55,20,{font:'700 12px Arial',color:GREEN,align:'right',maxLines:1}); + }); + y+=rh+12 + } + return; + } + if(id==='emerald-gold'){ + for(const item of items){ + const nameLines=Math.max(1,wrapCount(item.name,W-2*M-220,'500 18px Georgia')); + const meta=[weightLabel(item),compositionTextPdf(item)].filter(Boolean).join(' · '); + const metaLines=meta?Math.max(1,wrapCount(meta,W-2*M-240,'10px Arial')):0; + const minH=38+nameLines*27+(metaLines?8+metaLines*19:0)+20,naturalH=Math.max(130,minH),h=fitBlockHeight(naturalH,minH,10,.88); + card(M,y,W-2*M,h,18,PANEL,LINE);const img=imageMap.get(item.id)||logo;if(img)pdfDrawCover(ctx,img,W-M-160,y+8,150,h-16,12); + pdfText(ctx,item.name,M+22,y+18,W-2*M-220,27,{font:'500 18px Georgia',color:TEXT,maxLines:nameLines}); + if(meta)pdfText(ctx,meta,M+22,y+18+nameLines*27+8,W-2*M-240,19,{font:'10px Arial',color:MUTED,maxLines:metaLines}); + pdfText(ctx,`x${Math.round(num(item.qty))}`,W-M-176,y+18,58,22,{font:'700 12px Arial',color:GOLD,align:'right',maxLines:1});y+=h+10 + } + return; + } + const gap=14,cw=(W-2*M-gap)/2; + for(let i=0;i{ + const meta=[weightLabel(item),compositionTextPdf(item)].filter(Boolean).join(' · '); + const nameLines=Math.max(1,wrapCount(item.name,cw-224,'500 18px Georgia')); + const metaLines=meta?Math.max(1,wrapCount(meta,cw-230,'11px Arial')):0,minH=36+nameLines*27+(metaLines?8+metaLines*20:0); + return {minH,h:Math.max(150,minH)}; + }),naturalRh=Math.max(...measured.map(v=>v.h)),minRh=Math.max(...measured.map(v=>v.minH)),rh=fitBlockHeight(naturalRh,minRh,14,.88); + row.forEach((item,j)=>itemCardLight(item,M+j*(cw+gap),y,cw,rh));y+=rh+14 + } + // PDF cards already contain the position photo; a second gallery duplicated the same images and created extra sparse pages. + } + function drawGallery(cols=6,h=120,boxesOnly=false){const galleryItems=boxesOnly?items.filter(i=>[0,5].includes(Number(i.categoryId))):items;if(!galleryItems.length)return;const gap=8,shown=Math.min(galleryItems.length,cols*2),rows=Math.ceil(shown/cols),natural=rows*h+(rows-1)*gap,minCell=Math.min(h,96),minTotal=rows*minCell+(rows-1)*gap,total=fitBlockHeight(natural,minTotal,20,.86),cellH=Math.max(minCell,(total-(rows-1)*gap)/rows),w=(W-2*M-gap*(cols-1))/cols;for(let i=0;i{const x=M+22+i*mw;pdfText(ctx,it[0],x+mw/2,metricsY,mw-20,34,{font:'500 23px Georgia',color:TEXT,align:'center',maxLines:1});pdfText(ctx,it[1],x+mw/2,metricsY+43,mw-20,38,{font:'10px Arial',color:MUTED,align:'center',maxLines:2});if(isum+Math.max(minRow,wrapCount(t,width,font)*lineH+9),0)} + function drawControlCard(x,yy,w,h){card(x,yy,w,h,18,PANEL,LINE);const titleLines=wrapCount(cfg.texts.controlTitle,w-40,'500 18px Georgia');pdfText(ctx,cfg.texts.controlTitle,x+w/2,yy+20,w-40,27,{font:'500 18px Georgia',color:GREEN_DEEP,align:'center',maxLines:3});let cy=yy+34+titleLines*27;for(const t of control){const lines=wrapCount(t,w-78,'12px Arial'),rowH=Math.max(31,lines*20+9);pdfDrawCheck(ctx,x+29,cy+9,8,GREEN_SOFT,GREEN_DEEP);pdfText(ctx,t,x+48,cy,w-70,rowH,{font:'12px Arial',color:TEXT,maxLines:5});cy+=rowH}} + function drawExtrasCard(x,yy,w,h){card(x,yy,w,h,18,PANEL,LINE);const titleLines=wrapCount(cfg.texts.extrasTitle,w-40,'500 18px Georgia');pdfText(ctx,cfg.texts.extrasTitle,x+w/2,yy+20,w-40,27,{font:'500 18px Georgia',color:GREEN_DEEP,align:'center',maxLines:3});let cy=yy+36+titleLines*27;for(const name of extras){const lines=wrapCount(name,w-70,'12px Arial'),rowH=Math.max(37,lines*20+9);ctx.save();ctx.fillStyle=GREEN_SOFT;ctx.beginPath();ctx.arc(x+31,cy+8,8,0,Math.PI*2);ctx.fill();ctx.restore();pdfText(ctx,'+',x+31,cy+1,16,16,{font:'700 11px Arial',color:GOLD,align:'center',maxLines:1});pdfText(ctx,name,x+50,cy,w-70,rowH,{font:'12px Arial',color:TEXT,maxLines:4});cy+=rowH}} + function infoCards(){ + if(!cfg.showControl&&!cfg.showExtras)return;const gap=14,count=(cfg.showControl?1:0)+(cfg.showExtras?1:0),cw=count===1?W-2*M:(W-2*M-gap)/2; + const heights=[];if(cfg.showControl){const title=wrapCount(cfg.texts.controlTitle,cw-40,'500 18px Georgia');heights.push(50+title*27+measureListHeight(control,cw-78))}if(cfg.showExtras){const title=wrapCount(cfg.texts.extrasTitle,cw-40,'500 18px Georgia');heights.push(58+title*27+measureListHeight(extras,cw-70,'12px Arial',20,37))}const minH=Math.max(1,...heights),naturalH=Math.max(230,minH),h=fitBlockHeight(naturalH,minH,18,.9);let x=M;if(cfg.showControl){drawControlCard(x,y,cw,h);x+=cw+gap}if(cfg.showExtras)drawExtrasCard(x,y,cw,h);y+=h+18; + } + function footerNoteNaturalHeight(){if(!cfg.showFooterNote||!String(cfg.texts.footerNote||'').trim())return 0;const lines=wrapCount(cfg.texts.footerNote,W-2*M-34,'11px Arial');return Math.max(44,22+lines*18)+12} + function pricing(){if(!cfg.showPriceBreakdown)return;const rows=[[cfg.texts.priceItemsLabel||'Стоимость позиций',p.base],...(p.manual?[[cfg.texts.discountLabel||'Скидка',-p.manual]]:[]),...(p.promo?[[`${cfg.texts.promoLabel||'Промокод'}${s.promoCode?' '+s.promoCode:''}`,-p.promo]]:[]),[cfg.texts.menuAfterDiscountLabel||'Меню после скидок',p.itemsTotal],[cfg.texts.deliveryLabel||'Доставка',p.delivery],[cfg.texts.totalLabel||'Итого',p.total]],title=cfg.texts.pricingTitle||'Расчёт стоимости',titleLines=wrapCount(title,420,'500 18px Georgia'),naturalH=48+titleLines*27+rows.length*31,minH=44+titleLines*27+rows.length*26,footerReserve=footerNoteNaturalHeight(); + if(y+naturalH+16<=bottom&&footerReserve&&y+naturalH+16+footerReserve>bottom)newPage(); + const h=fitBlockHeight(naturalH,minH,16,.88),rowStep=Math.max(26,Math.min(31,(h-44-titleLines*27)/Math.max(1,rows.length)));card(M,y,W-2*M,h,16,PANEL,LINE);pdfText(ctx,title,M+20,y+16,420,27,{font:'500 18px Georgia',color:GREEN_DEEP,maxLines:2});let yy=y+24+titleLines*27;for(const [label,val] of rows){const grand=label===(cfg.texts.totalLabel||'Итого');pdfText(ctx,label,M+20,yy,490,21,{font:grand?'700 13px Arial':'12px Arial',color:grand?GREEN_DEEP:MUTED,maxLines:1});pdfText(ctx,(val<0?'- ':'')+money(Math.abs(val)),W-M-20,yy,250,21,{font:grand?'700 14px Arial':'12px Arial',color:grand?GREEN_DEEP:TEXT,align:'right',maxLines:1});yy+=rowStep}y+=h+16} + function footerNoteBlock(){if(!cfg.showFooterNote||!String(cfg.texts.footerNote||'').trim())return;const lines=wrapCount(cfg.texts.footerNote,W-2*M-34,'11px Arial'),naturalH=Math.max(44,22+lines*18),minH=Math.max(36,18+lines*16),h=fitBlockHeight(naturalH,minH,12,.82);card(M,y,W-2*M,h,12,PANEL,LINE);pdfText(ctx,cfg.texts.footerNote,W/2,y+Math.max(8,(h-lines*18)/2),W-2*M-34,18,{font:'11px Arial',color:MUTED,align:'center',maxLines:6});y+=h+12} + function finalGalleryFill(){ + if(!finalGallery.length)return; + const title=cfg.texts.finalGalleryTitle||'Как это выглядит на вашем столе',gap=12; + const titleLines=wrappedLines(title,W-2*M,'500 21px Georgia'),titleH=Math.max(36,titleLines.length*28+8); + const free=Math.max(0,bottom-y),minPhotoH=92; + // This block may consume only the whitespace already left on the current last page. + // It never calls newPage() and never creates an extra page. + if(free=205&&finalGallery.length>=2?2:1; + if(count===1){const img=finalGallery[1]||finalGallery[0];if(img)pdfDrawCover(ctx,img,M,y,W-2*M,available,14)} + else{const imgs=finalGallery.slice(0,2),cellW=(W-2*M-gap)/2;if(imgs[0])pdfDrawCover(ctx,imgs[0],M,y,cellW,available,14);if(imgs[1])pdfDrawCover(ctx,imgs[1],M+cellW+gap,y,cellW,available,14)} + y=bottom; + } + newPage();brandHeader();hero();sellingBlock();drawItems();summary();if(id==='editorial-grid'){pricing();infoCards()}else{infoCards();pricing()}footerNoteBlock();finalGalleryFill(); + canvases.forEach((c,i)=>{const x=c.getContext('2d');x.setTransform(SCALE,0,0,SCALE,0,0);x.save();x.fillStyle=FOOT;x.fillRect(0,H-52,W,52);x.strokeStyle=LINE;x.lineWidth=1;x.beginPath();x.moveTo(0,H-52.5);x.lineTo(W,H-52.5);x.stroke();pdfText(x,'Солнце Кейтеринг · Санкт-Петербург',M,H-36,620,22,{font:'12px Arial',color:MUTED,maxLines:1});pdfText(x,`${i+1} / ${canvases.length}`,W-M,H-36,100,22,{font:'12px Arial',color:MUTED,align:'right',maxLines:1});x.restore()}); + return canvases; + } + async function pdfBlobFromPages(canvases){const jpeg=[];for(const c of (canvases||[]))jpeg.push(await pdfCanvasJpeg(c));return pdfFromJpegs(jpeg)} + async function buildOfferPdfBlob(s){return pdfBlobFromPages(await renderOfferPdfPages(s))} + async function triggerPdfDownload(blob,name){ + const isAppleTouch=/iP(?:ad|hone|od)/i.test(navigator.userAgent||'')||(navigator.platform==='MacIntel'&&Number(navigator.maxTouchPoints||0)>1); + if(isAppleTouch&&typeof File==='function'&&navigator.share&&navigator.canShare){ + try{const file=new File([blob],name,{type:'application/pdf'});if(navigator.canShare({files:[file]})){await navigator.share({files:[file],title:'Предложение клиенту'});return 'share'}}catch(err){if(err?.name==='AbortError')return 'cancel';console.warn('PDF share fallback:',err)} + } + const url=URL.createObjectURL(blob),a=document.createElement('a');a.href=url;a.download=name;a.style.display='none';a.rel='noopener';document.body.appendChild(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(url),120000);return 'download'; + } + function withTimeout(promise,ms,label='Операция'){return Promise.race([promise,new Promise((_,reject)=>setTimeout(()=>reject(new Error(label+' превысила время ожидания')),ms))])} + function prepareCurrentOfferPdf(prepared,renderToken=currentOffer.renderToken){ + const btn=$('sunOfferPdf');if(btn){btn.disabled=true;btn.textContent='Готовлю PDF…'} + let promise; + const source=currentOffer.pages?.length?pdfBlobFromPages(currentOffer.pages):preparedPdfSnapshot(prepared).then(buildOfferPdfBlob); + promise=withTimeout(source,60000,'Подготовка PDF').then(blob=>{if(currentOffer.renderToken===renderToken){currentOffer.pdfBlob=blob;if(btn){btn.disabled=false;btn.textContent='Скачать PDF'}}return blob}).catch(err=>{console.error(err);if(currentOffer.renderToken===renderToken){currentOffer.pdfBlob=null;if(btn){btn.disabled=false;btn.textContent='Скачать PDF'}}toast(`Не удалось подготовить PDF: ${err?.message||'неизвестная ошибка'}. Нажмите «Скачать PDF», чтобы повторить.`,'error');return null}).finally(()=>{if(currentOffer.pdfPromise===promise)currentOffer.pdfPromise=null});currentOffer.pdfPromise=promise;return promise; + } + async function downloadCurrentOfferPdf(){ + if(!currentOffer.prepared&&!currentOffer.pdfBlob){toast('Сначала откройте предложение клиенту.','error');return} + const btn=$('sunOfferPdf');if(btn){btn.disabled=true;btn.textContent='Готовлю PDF…'} + try{ + if(currentOffer.editTimer){clearTimeout(currentOffer.editTimer);currentOffer.editTimer=null;saveOfferOverrides();await renderOffer(currentOffer.snapshot)} + else if(!currentOffer.pages?.length&¤tOffer.snapshot)await renderOffer(currentOffer.snapshot); + await ensureCurrentOfferFresh(); + let blob=currentOffer.pdfBlob; + if(!blob&¤tOffer.pdfPromise)blob=await currentOffer.pdfPromise; + if(!blob&¤tOffer.prepared)blob=await prepareCurrentOfferPdf(currentOffer.prepared); + // One automatic retry after a failed background preparation. + if(!blob&¤tOffer.prepared)blob=await prepareCurrentOfferPdf(currentOffer.prepared); + if(!blob)throw new Error('PDF не удалось сформировать'); + currentOffer.pdfBlob=blob; + const mode=await triggerPdfDownload(blob,filename('pdf')); + if(mode==='share')toast('PDF готов. Выберите «Сохранить в Файлы» или нужное приложение.'); + else if(mode!=='cancel')toast('PDF скачивается.'); + }catch(err){console.error(err);toast(`Не удалось скачать PDF: ${err?.message||'неизвестная ошибка'}. Попробуйте ещё раз.`,'error')} + finally{if(btn){btn.disabled=false;btn.textContent='Скачать PDF'}} + } + + + function ensureButton(){ + const actions=document.querySelector('#new .actions');if(!actions)return;let b=$('sunClientOfferButton');if(!b){b=document.createElement('button');b.id='sunClientOfferButton';b.type='button';b.className='outline';b.textContent='Предложение клиенту';b.onclick=openCurrentDraftOffer;const del=$('deleteOrderBtn');if(del)window.SunSafe.insertBefore(actions,b,del);else actions.appendChild(b);}const d=getDraft();b.title=d?.id?'Открыть клиентское предложение':'Сначала сохраните заказ'; + } + function patchOrderViewer(){qa('.sun-order-view-actions').forEach(actions=>{if(actions.querySelector('[data-sun-offer-id]'))return;const edit=actions.querySelector('[data-viewer-action="edit"]');const id=edit?.dataset?.id;if(!id)return;const b=document.createElement('button');b.className='outline';b.type='button';b.textContent='Предложение';b.dataset.sunOfferId=id;b.onclick=()=>openOfferForOrder(id);const spacer=actions.querySelector('.spacer');window.SunSafe.insertBefore(actions,b,spacer||null);});} + + async function catalogPhotoChanged(itemId){ + if(!currentOffer.snapshot||!currentOffer.orderId)return; + if(!$('sunClientOfferModal')?.classList.contains('on'))return; + if(!currentOffer.snapshot.items?.some(item=>String(item.id)===String(itemId)))return; + await renderOffer(currentOffer.snapshot); + } + + const refreshOpenOffer=()=>{if(currentOffer.snapshot&¤tOffer.orderId)renderOffer(currentOffer.snapshot).catch(err=>console.warn('Offer refresh:',err))}; + window.addEventListener('sunoffertemplatechange',refreshOpenOffer); + window.addEventListener('sunclientoffersettingschange',refreshOpenOffer); + window.sunOpenClientOffer=openOfferForOrder;window.sunOpenCurrentClientOffer=openCurrentDraftOffer;window.sunGetClientOfferPdfBlob=()=>currentOffer.pdfBlob;window.sunClientOfferDownload=downloadCurrentOfferPdf;window.sunClientOfferCatalogChanged=catalogPhotoChanged;window.sunClientOfferDebugHtml=offerHtml;window.sunClientOfferDebugPdf=async prepared=>buildOfferPdfBlob(await preparedPdfSnapshot(prepared));window.sunClientOfferDebugPdfPages=async prepared=>renderOfferPdfPages(await preparedPdfSnapshot(prepared));window.sunClientOfferDebugCreateSnapshot=createSnapshot;window.sunClientOfferDebugBoxCount=offerBoxCount; + (()=>{let queued=false;const run=()=>{if(queued)return;queued=true;setTimeout(()=>{queued=false;ensureButton();patchOrderViewer();},50);};[$('new'),$('orders')].filter(Boolean).forEach(root=>new MutationObserver(run).observe(root,{childList:true,subtree:true}));})(); + document.addEventListener('click',e=>{const b=e.target.closest('[data-sun-offer-id]');if(b){e.preventDefault();e.stopPropagation();openOfferForOrder(b.dataset.sunOfferId)}},true); + ensureModal();ensureButton();patchOrderViewer();setTimeout(ensureButton,150);setTimeout(patchOrderViewer,300); +})(); +; + +/* ===== MODULE: auto-offer.js ===== */ +(function(){ + 'use strict'; + if(window.__sunAutoOfferV1)return;window.__sunAutoOfferV1=true; + const $=id=>document.getElementById(id); + const qa=(sel,root=document)=>[...root.querySelectorAll(sel)]; + const esc=window.SunSafe.escapeHTML; + const money=v=>`${Math.round(Number(v||0)).toLocaleString('ru-RU')} \u20bd`; + const txt={ + gen:'\u0421\u0433\u0435\u043d\u0435\u0440\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u043f\u0440\u0435\u0434\u043b\u043e\u0436\u0435\u043d\u0438\u0435', + title:'\u0421\u0433\u0435\u043d\u0435\u0440\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u043f\u0440\u0435\u0434\u043b\u043e\u0436\u0435\u043d\u0438\u0435 \u043a\u043b\u0438\u0435\u043d\u0442\u0443', + hint:'\u041e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u043d\u043e \u0442\u043e\u043b\u044c\u043a\u043e \u043a\u043e\u043b\u0438\u0447\u0435\u0441\u0442\u0432\u043e \u0433\u043e\u0441\u0442\u0435\u0439. \u0411\u044e\u0434\u0436\u0435\u0442 \u0438 \u0432\u0435\u0441 \u043c\u043e\u0436\u043d\u043e \u043d\u0435 \u0437\u0430\u043f\u043e\u043b\u043d\u044f\u0442\u044c.', + guests:'\u041a\u043e\u043b\u0438\u0447\u0435\u0441\u0442\u0432\u043e \u0433\u043e\u0441\u0442\u0435\u0439', + budget:'\u0411\u044e\u0434\u0436\u0435\u0442 \u043d\u0430 \u0433\u043e\u0441\u0442\u044f, \u20bd', + weight:'\u0412\u0435\u0441 \u043d\u0430 \u0433\u043e\u0441\u0442\u044f, \u0433', + optional:'\u043d\u0435\u043e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u043d\u043e', + close:'\u0417\u0430\u043a\u0440\u044b\u0442\u044c', + regen:'\u041f\u0435\u0440\u0435\u0433\u0435\u043d\u0435\u0440\u0438\u0440\u043e\u0432\u0430\u0442\u044c', + apply:'\u041f\u0440\u0438\u043c\u0435\u043d\u0438\u0442\u044c \u043a \u0437\u0430\u043a\u0430\u0437\u0443', + total:'\u041c\u0435\u043d\u044e',per:'\u041d\u0430 \u0433\u043e\u0441\u0442\u044f',weightPer:'\u0412\u0435\u0441 / \u0433\u043e\u0441\u0442\u044f',piecesPer:'\u0417\u0430\u043a\u0443\u0441\u043e\u043a / \u0433\u043e\u0441\u0442\u044f', + empty:'\u0423\u043a\u0430\u0436\u0438\u0442\u0435 \u043a\u043e\u043b\u0438\u0447\u0435\u0441\u0442\u0432\u043e \u0433\u043e\u0441\u0442\u0435\u0439 \u0438 \u043d\u0430\u0436\u043c\u0438\u0442\u0435 \u00ab\u0421\u0433\u0435\u043d\u0435\u0440\u0438\u0440\u043e\u0432\u0430\u0442\u044c\u00bb.', + lowBudget:'\u0417\u0430\u0434\u0430\u043d\u043d\u044b\u0439 \u0431\u044e\u0434\u0436\u0435\u0442 \u0441\u043b\u0438\u0448\u043a\u043e\u043c \u043d\u0438\u0437\u043a\u0438\u0439 \u0434\u043b\u044f \u043f\u043e\u0434\u0431\u043e\u0440\u0430 \u043c\u0435\u043d\u044e.', + applied:'\u041f\u043e\u0434\u043e\u0431\u0440\u0430\u043d\u043d\u043e\u0435 \u043c\u0435\u043d\u044e \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 \u0437\u0430\u043a\u0430\u0437. \u041f\u0440\u043e\u0432\u0435\u0440\u044c\u0442\u0435 \u0435\u0433\u043e \u0438 \u043d\u0430\u0436\u043c\u0438\u0442\u0435 \u00ab\u0421\u043e\u0445\u0440\u0430\u043d\u0438\u0442\u044c\u00bb.', + noEvent:'\u2014 \u041d\u0435 \u0443\u043a\u0430\u0437\u044b\u0432\u0430\u0442\u044c \u2014',custom:'\u0421\u0432\u043e\u0451\u2026',customPh:'\u041d\u0430\u043f\u0438\u0448\u0438\u0442\u0435 \u0441\u0432\u043e\u0439 \u0444\u043e\u0440\u043c\u0430\u0442' + }; + const toast=(m,t='success')=>{try{window.SunEnterprise?.toast?.(m,t)}catch(_){};}; + const getBoxes=()=>{try{if(Array.isArray(boxes))return boxes}catch(_){}try{return JSON.parse(localStorage.getItem('sunBoxes')||'[]')}catch(_){return[]}}; + const getOrders=()=>{try{if(Array.isArray(orders))return orders}catch(_){}try{return JSON.parse(localStorage.getItem('sunOrders')||'[]')}catch(_){return[]}}; + const getDraft=()=>{try{return draft}catch(_){return null}}; + const parseWeight=v=>{const s=String(v||'').toLowerCase().replace(/,/g,'.'),m=s.match(/(\d+(?:\.\d+)?)\s*(\u043a\u0433|kg|\u0433|g)(?:\s|$)/);if(!m)return 0;const n=Number(m[1]);return m[2]==='\u043a\u0433'||m[2]==='kg'?n*1000:n}; + const guestMax=v=>{const a=String(v||'').match(/\d+/g)?.map(Number)||[];return a.length?Math.max(...a):0}; + function portions(item){ + const explicit=Math.max(0,Number(item?.pieces||0));if(explicit>0)return explicit; + let sum=0; + for(const x of item?.ingredients||[]){if(!Array.isArray(x))continue;const q=Math.max(0,Number(x[1]||0)),u=String(x[2]||'').toLowerCase();if(/\u0448\u0442|\u043f\u043e\u0440|\u043a\u0443\u0441/.test(u))sum+=q;} + if(sum>=5)return sum; + return Math.max(sum,guestMax(item?.guests),10); + } + function popularityMap(){ + const stats=new Map();let maxOrders=0,maxQty=0; + for(const o of getOrders()){ + if(String(o?.status||'').toLowerCase()==='отменён')continue; + const seen=new Set(); + for(const l of o?.lines||[]){const id=String(l?.id??'');if(!id)continue;let st=stats.get(id);if(!st){st={orders:0,qty:0};stats.set(id,st)}st.qty+=Math.max(0,Number(l?.qty||0));if(!seen.has(id)){st.orders+=1;seen.add(id)}} + } + for(const st of stats.values()){maxOrders=Math.max(maxOrders,st.orders);maxQty=Math.max(maxQty,st.qty)} + const out=new Map();for(const [id,st] of stats)out.set(id,(maxOrders?st.orders/maxOrders:0)*.78+(maxQty?st.qty/maxQty:0)*.22);return out; + } + function foodCandidates(){const pop=popularityMap();return getBoxes().filter(x=>Number(x?.category||0)===0&&x?.hidden!==true&&Number(x?.price||0)>0&&parseWeight(x?.weight)>0&&!/\u0433\u043e\u0442\u043e\u0432\u044b\u0435\s+\u043c\u0435\u043d\u044e/i.test(String(x?.catalogSection||''))).map(x=>({raw:x,id:String(x.id),name:String(x.name||''),section:String(x.catalogSection||'\u0411\u043e\u043a\u0441\u044b'),price:Number(x.price||0),grams:parseWeight(x.weight),portions:portions(x),photo:String(x.photo||'sun-logo.png'),popularity:Number(pop.get(String(x.id))||0)}));} + function hashNoise(text,seed){let h=2166136261^(seed*2654435761);for(const c of String(text)){h^=c.charCodeAt(0);h=Math.imul(h,16777619)}return ((h>>>0)%10000)/10000;} + function stateKey(s){return [...s.q.entries()].sort((a,b)=>a[0].localeCompare(b[0])).map(([id,q])=>id+':'+q).join('|')} + function score(s,t,seed){ + const pg=s.portions/t.targetPortions,pp=pg<1?(1-pg)*5:(pg-1)*1.15; + let wp=0;if(t.targetWeight){const r=s.grams/t.targetWeight;wp=r<1?(1-r)*4:(r-1)*1.1;} + let bp=0;if(t.budget){if(s.cost>t.budget)return 100+(s.cost-t.budget)/t.budget*20;bp=Math.max(0,(t.budget-s.cost)/t.budget)*.72}else bp=(s.cost/Math.max(1,t.guests*5000))*.04; + const boxes=[...s.q.values()].reduce((a,b)=>a+b,0),unique=s.q.size,sections=s.sections.size; + const minPositions=t.guests>=15?4:(t.guests>=8?3:2);const diversity=(boxes-unique)*.08+Math.max(0,Math.min(boxes,4)-sections)*.055-Math.min(sections,4)*.025+Math.max(0,minPositions-unique)*.24; + const popularityBonus=boxes?Math.min(.22,(Number(s.popularity||0)/boxes)*.18):0; + return pp+wp+bp+diversity-popularityBonus+hashNoise(stateKey(s),seed)*.012; + } + function generateMenu(t,seed){ + const cand=foodCandidates();if(!cand.length)return null; + const empty={q:new Map(),portions:0,grams:0,cost:0,sections:new Set(),popularity:0}; + let beam=[empty],all=[empty]; + const maxBoxes=Math.min(30,Math.max(4,Math.ceil(t.targetPortions/20)+4,t.targetWeight?Math.ceil(t.targetWeight/700)+4:0)); + for(let step=0;step=4)continue; + const cost=st.cost+c.price;if(t.budget&&cost>t.budget)continue; + const q=new Map(st.q);q.set(c.id,old+1);const sections=new Set(st.sections);sections.add(c.section); + const ns={q,portions:st.portions+c.portions,grams:st.grams+c.grams,cost,sections,popularity:Number(st.popularity||0)+Number(c.popularity||0)};const key=stateKey(ns),prev=map.get(key); + if(!prev||score(ns,t,seed)score(a,t,seed)-score(b,t,seed)).slice(0,130);beam=next;all.push(...next.slice(0,60)); + const best=next[0];if(best&&best.portions>=t.targetPortions*1.05&&(!t.targetWeight||best.grams>=t.targetWeight*1.03)&&step>=3)break; + } + const useful=all.filter(s=>s.q.size>0&&( !t.budget || s.cost<=t.budget));if(!useful.length)return null; + const best=useful.sort((a,b)=>score(a,t,seed)-score(b,t,seed))[0]; + const items=[...best.q.entries()].map(([id,qty])=>{const c=cand.find(x=>x.id===id);return {...c,qty,sum:c.price*qty,totalGrams:c.grams*qty,totalPortions:c.portions*qty}}).sort((a,b)=>a.section.localeCompare(b.section,'ru')||a.name.localeCompare(b.name,'ru')); + return {...best,items}; + } + function ensureStyle(){if($('sun-auto-offer-style'))return;const st=document.createElement('style');st.id='sun-auto-offer-style';st.textContent=` + #sunAutoOfferModal{z-index:100060}#sunAutoOfferModal .dialog{width:min(980px,calc(100vw - 28px));max-height:94vh;padding:0;overflow:auto;border-radius:18px;background:#f5f3fa}.sun-auto-head{position:sticky;top:0;z-index:3;display:flex;align-items:flex-start;gap:12px;padding:15px 17px;background:#fff;border-bottom:1px solid #e4e0ef}.sun-auto-head h2{margin:0;color:#241d45;font-size:20px}.sun-auto-head p{margin:4px 0 0;color:#7b748d;font-size:12px}.sun-auto-head .spacer{flex:1}.sun-auto-form{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;padding:16px 17px 5px}.sun-auto-form label{display:grid;gap:6px;color:#5d5670;font-size:11px;font-weight:800}.sun-auto-form small{font-weight:500;color:#9b94aa}.sun-auto-form input{width:100%;border:1px solid #d9d2e9;border-radius:11px;padding:10px 11px;background:#fff;color:#29243d}.sun-auto-generate{margin:10px 17px 2px}.sun-auto-result{padding:12px 17px 18px}.sun-auto-placeholder{padding:30px;text-align:center;border:1px dashed #cbc2df;border-radius:16px;color:#827a95;background:#fff}.sun-auto-summary{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:9px;margin-bottom:12px}.sun-auto-kpi{background:#241d45;color:#fff;border-radius:14px;padding:13px}.sun-auto-kpi small{display:block;color:#c9c2db;font-size:10px}.sun-auto-kpi b{display:block;margin-top:5px;font-size:18px}.sun-auto-warning{margin:0 0 12px;padding:10px 12px;border-radius:11px;background:#fff4d9;color:#77550c;font-size:12px}.sun-auto-items{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:9px}.sun-auto-item{display:grid;grid-template-columns:82px minmax(0,1fr);gap:10px;padding:9px;background:#fff;border:1px solid #e1dceb;border-radius:14px}.sun-auto-item img{width:82px;height:82px;object-fit:cover;border-radius:10px;background:#eeeaf7}.sun-auto-item h3{margin:2px 0 5px;color:#302a45;font-size:13px;line-height:1.25}.sun-auto-item p{margin:3px 0;color:#827b92;font-size:10px}.sun-auto-item b{color:#5b48a6}.sun-auto-foot{display:flex;justify-content:flex-end;gap:8px;padding:13px 17px;background:#fff;border-top:1px solid #e4e0ef;position:sticky;bottom:0}.sun-event-custom-input{display:none!important;margin-top:6px!important}.sun-event-custom-input.on{display:block!important}#sunOrderCards .sun-order-card.sun-has-prepayment .sun-order-card-date{background:linear-gradient(145deg,#238b57,#36ad72)!important;border-color:#238b57!important;box-shadow:0 5px 14px rgba(35,139,87,.24)!important} + @media(max-width:700px){#sunAutoOfferModal{padding:0}#sunAutoOfferModal .dialog{width:100vw;height:100vh;max-height:none;border-radius:0}.sun-auto-form{grid-template-columns:1fr}.sun-auto-summary{grid-template-columns:1fr 1fr}.sun-auto-items{grid-template-columns:1fr}.sun-auto-head h2{font-size:17px}} + `;document.head.appendChild(st)} + let generationSeed=1,currentResult=null,currentTargets=null; + function ensureModal(){if($('sunAutoOfferModal'))return;const m=document.createElement('div');m.className='modal';m.id='sunAutoOfferModal';m.innerHTML=`

    ${txt.title}

    ${txt.hint}

    ${txt.empty}
    `;document.body.appendChild(m);$('sunAutoClose').onclick=closeGenerator;$('sunAutoGenerate').onclick=()=>runGenerator(false);$('sunAutoRegen').onclick=()=>runGenerator(true);$('sunAutoApply').onclick=applyGenerated;} + function openGenerator(){ensureStyle();ensureModal();const d=getDraft()||{},g=Number($('eventGuests')?.value||d.guestsCount||0);$('sunAutoGuests').value=g||'';$('sunAutoResult').innerHTML=`
    ${txt.empty}
    `;$('sunAutoFoot').style.display='none';currentResult=null;$('sunAutoOfferModal').classList.add('on');} + function closeGenerator(){$('sunAutoOfferModal')?.classList.remove('on')} + function runGenerator(regen){ + const guests=Math.max(0,Math.round(Number($('sunAutoGuests')?.value||0))),perBudget=Math.max(0,Number($('sunAutoBudget')?.value||0)),perWeight=Math.max(0,Number($('sunAutoWeight')?.value||0)); + if(!guests){toast(txt.guests,'warn');$('sunAutoGuests')?.focus();return} + if(regen)generationSeed++;const targets={guests,targetPortions:guests*5,targetWeight:perWeight?guests*perWeight:0,budget:perBudget?guests*perBudget:0,perBudget,perWeight};const result=generateMenu(targets,generationSeed);currentTargets=targets;currentResult=result;renderGenerated(result,targets); + } + function renderGenerated(r,t){const root=$('sunAutoResult'),foot=$('sunAutoFoot');if(!r){root.innerHTML=`
    ${txt.lowBudget}
    `;foot.style.display='none';return} + const pp=r.cost/t.guests,wp=r.grams/t.guests,pieces=r.portions/t.guests;const warns=[];if(r.portions
    ${txt.total}${money(r.cost)}
    ${txt.per}${money(pp)}
    ${txt.weightPer}${Math.round(wp).toLocaleString('ru-RU')} \u0433
    ${txt.piecesPer}${pieces.toFixed(1)}
    ${warns.length?`
    ${warns.join('
    ')}
    `:''}
    ${r.items.map(i=>`

    ${esc(i.name)}

    ${esc(i.section)} \u00b7 \u00d7${i.qty}

    ${money(i.sum)} \u00b7 ${Math.round(i.totalGrams).toLocaleString('ru-RU')} \u0433 \u00b7 ${Math.round(i.totalPortions)} \u043f\u043e\u0440\u0446.

    `).join('')}
    `;foot.style.display='flex';} + function applyGenerated(){if(!currentResult||!currentTargets)return;const d=getDraft();if(!d)return;d.lines=currentResult.items.map(i=>({id:i.id,qty:i.qty}));d.guestsCount=currentTargets.guests;const g=$('eventGuests');if(g){g.value=currentTargets.guests;g.dispatchEvent(new Event('input',{bubbles:true}))}try{window.render?.();window.updateOrderSummary?.()}catch(_){}closeGenerator();toast(txt.applied,'success');} + function ensureGeneratorButton(){const actions=document.querySelector('#new .actions');if(!actions)return;let b=$('sunAutoOfferButton');if(!b){b=document.createElement('button');b.id='sunAutoOfferButton';b.type='button';b.className='outline';b.textContent=txt.gen;b.onclick=openGenerator;const offer=$('sunClientOfferButton');if(offer)offer.insertAdjacentElement('afterend',b);else actions.appendChild(b)}} + function decoratePaidCards(){const map=new Map(getOrders().map(o=>[String(o.id),o]));qa('#sunOrderCards .sun-order-card').forEach(card=>{const o=map.get(String(card.dataset.orderId||''));card.classList.toggle('sun-has-prepayment',Number(o?.prepayment||0)>0)})} + function ensureEventOptions(){ + const s=$('event');if(!s||s.tagName!=='SELECT')return;const keep=String(s.value||''); + qa('option',s).filter(o=>(o.textContent||'').trim()==='Новое мероприятие'||o.dataset.sunCustom==='1').forEach(o=>o.remove()); + $('sunEventCustomInput')?.remove(); + let blank=qa('option',s).find(o=>o.dataset.sunBlank==='1'||o.value===''); + if(!blank){blank=document.createElement('option');blank.value='';blank.textContent=txt.noEvent;blank.dataset.sunBlank='1';s.insertBefore(blank,s.firstChild)}else{blank.value='';if(blank.textContent!==txt.noEvent)blank.textContent=txt.noEvent;blank.dataset.sunBlank='1';if(s.firstElementChild!==blank)s.insertBefore(blank,s.firstChild)} + const opts=qa('option',s);s.value=opts.some(o=>o.value===keep)?keep:''; + if(s.dataset.sunPersistentEventBound!=='1'){s.dataset.sunPersistentEventBound='1';s.addEventListener('change',()=>{const d=getDraft();if(d)d.event=s.value||''})} + } + function syncEventFromDraft(){ensureEventOptions();const s=$('event'),d=getDraft();if(!s||!d)return;const value=String(d.event||'').trim();if(!value){s.value='';return}let opt=qa('option',s).find(o=>o.value===value);if(!opt){opt=document.createElement('option');opt.value=value;opt.textContent=value;opt.dataset.sunLegacy='1';s.appendChild(opt)}s.value=value;} + function prepareEventForSave(){ensureEventOptions();const s=$('event'),d=getDraft();if(s&&d)d.event=s.value||'';} + const oldSave=window.saveOrder;if(typeof oldSave==='function')window.saveOrder=function(...a){prepareEventForSave();const r=oldSave.apply(this,a);setTimeout(()=>{syncEventFromDraft();decoratePaidCards()},0);return r}; + const oldOpen=window.openOrder;if(typeof oldOpen==='function')window.openOrder=function(...a){const r=oldOpen.apply(this,a);setTimeout(syncEventFromDraft,0);setTimeout(syncEventFromDraft,80);return r}; + const oldReset=window.resetDraft;if(typeof oldReset==='function')window.resetDraft=function(...a){const r=oldReset.apply(this,a);setTimeout(syncEventFromDraft,0);return r}; + ensureStyle();ensureModal();ensureGeneratorButton();ensureEventOptions();syncEventFromDraft();decoratePaidCards(); + (()=>{let queued=false;const run=()=>{if(queued)return;queued=true;setTimeout(()=>{queued=false;ensureGeneratorButton();ensureEventOptions();decoratePaidCards();},50);};[$('new'),$('orders')].filter(Boolean).forEach(root=>new MutationObserver(run).observe(root,{childList:true,subtree:true}));})(); + setTimeout(()=>{ensureGeneratorButton();syncEventFromDraft();decoratePaidCards()},180);setTimeout(()=>{ensureGeneratorButton();syncEventFromDraft();decoratePaidCards()},700); +})(); +; + +/* ===== MODULE: mobile-pwa.js ===== */ +/* Solnce Catering classic PWA/sync layer - keeps the original application UI */ +(()=>{ + 'use strict'; + if(window.__sunClassicPwaV1)return; + window.__sunClassicPwaV1=true; + + const q=(s,r=document)=>r.querySelector(s); + const qa=(s,r=document)=>[...r.querySelectorAll(s)]; + const esc=window.SunSafe.escapeHTML; + + /* PWA only. No alternate mobile chrome or mobile home is created here. */ + function initPwa(){ + if('serviceWorker' in navigator && /^https?:$/.test(location.protocol)){ + navigator.serviceWorker.register('./service-worker.js').then(reg=>reg.update()).catch(()=>{}); + } + } + + /* Cross-device sync. This is deliberately UI-neutral. */ + const SYNC_META='sunCloudSyncMetaV1'; + const SYNC_TOKEN='sunCloudSyncTokenV1'; + const SYNC_WORKSPACE='sunCloudSyncWorkspaceV1'; + const sync={busy:false,status:'local',timer:null,lastError:''}; + const excludedKey=k=>k===SYNC_META||k===SYNC_TOKEN||k===SYNC_WORKSPACE||k.startsWith('sunCloudSyncTransient'); + + function hash(s){ + s=String(s??''); + let h=2166136261; + for(let i=0;i>>0).toString(16); + } + function getMeta(){try{return JSON.parse(localStorage.getItem(SYNC_META)||'{}')||{}}catch(_){return{}}} + function setMeta(m){localStorage.setItem(SYNC_META,JSON.stringify(m))} + function syncWorkspace(){return localStorage.getItem(SYNC_WORKSPACE)||'main'} + function syncHeaders(){return {'Content-Type':'application/json','X-Sun-Token':localStorage.getItem(SYNC_TOKEN)||''}} + + function captureSyncQuery(){ + try{ + const u=new URL(location.href); + const token=u.searchParams.get('syncToken'); + const workspace=u.searchParams.get('workspace'); + let changed=false; + if(token){localStorage.setItem(SYNC_TOKEN,token);u.searchParams.delete('syncToken');changed=true} + if(workspace){localStorage.setItem(SYNC_WORKSPACE,workspace);u.searchParams.delete('workspace');changed=true} + if(changed)history.replaceState(null,'',u.pathname+u.search+u.hash); + }catch(_){ } + } + + function setSyncStatus(status,msg=''){ + sync.status=status; + sync.lastError=msg; + const statusEl=q('#sunSyncSettingsStatus'); + if(statusEl){ + statusEl.textContent=status==='ok'?'Синхронизация активна':status==='error'?`Ошибка: ${msg||'нет связи'}`:'Локальный режим'; + } + } + + async function pullRemote(){ + const token=localStorage.getItem(SYNC_TOKEN); + if(!token||!/^https?:$/.test(location.protocol))return {entries:{}}; + const r=await fetch(`/api/sync?workspace=${encodeURIComponent(syncWorkspace())}`,{headers:syncHeaders(),cache:'no-store'}); + if(!r.ok)throw new Error(`HTTP ${r.status}`); + return r.json(); + } + + function scanLocalChanges(meta,force=false){ + const changes={}; + const now=Date.now(); + for(let i=0;iv.id)||null} + function refreshActiveView(){ + const active=document.activeElement; + const editing=active&&/^(INPUT|TEXTAREA|SELECT)$/.test(active.tagName); + if(editing||qa('.modal.on').length)return; + const view=activeView(); + if(!view)return; + const map={orders:'Заказы',calendar:'Календарь',shopping:'Закупки',stock:'Склад',clients:'Клиенты',mailings:'Рассылки',money:'Деньги',team:'Команда',suppliers:'Поставщики',stats:'Статистика','sun-print-center':'Печать','sun-routes-view':'Маршруты','enterprise-settings':'Настройки'}; + const label=map[view.id]; + if(!label)return; + const button=qa('header nav button').find(b=>String(b.textContent||'').trim()===label||String(b.textContent||'').trim()===(label==='Закупки'?'Закупка':label)); + if(button){try{HTMLElement.prototype.click.call(button)}catch(_){}} + } + + async function syncNow(){ + if(sync.busy)return; + const token=localStorage.getItem(SYNC_TOKEN); + if(!token||!/^https?:$/.test(location.protocol)){setSyncStatus('local');ensureSyncSettings();return} + sync.busy=true; + try{ + const meta=getMeta(); + const remote=await pullRemote(); + let remoteChanged=false; + const entries=remote.entries||{}; + Object.entries(entries).forEach(([key,item])=>{ + if(excludedKey(key)||!item)return; + const rt=Number(item.updatedAt||0),lt=Number(meta[key]?.t||0); + if(rt>lt){ + localStorage.setItem(key,String(item.value??'')); + meta[key]={h:hash(item.value??''),t:rt}; + remoteChanged=true; + } + }); + const changes=scanLocalChanges(meta,Object.keys(entries).length===0); + setMeta(meta); + await pushChanges(changes); + setSyncStatus('ok'); + ensureSyncSettings(); + if(remoteChanged){ + window.dispatchEvent(new CustomEvent('suncloudsync',{detail:{remoteChanged:true}})); + refreshActiveView(); + } + }catch(e){ + setSyncStatus('error',e?.message||'нет связи'); + ensureSyncSettings(); + }finally{sync.busy=false} + } + window.sunSyncNow=syncNow; + + function ensureSyncSettings(){ + const settings=q('#enterprise-settings'); + if(!settings||q('#sunSyncSettingsCard'))return; + const card=document.createElement('section'); + card.id='sunSyncSettingsCard'; + card.className='settings-card'; + card.style.cssText='margin-top:14px;border:1px solid #e2e4e9;border-radius:12px;background:#fff;padding:14px'; + const token=localStorage.getItem(SYNC_TOKEN)||''; + card.innerHTML=`

    Синхронизация устройств

    Одна база для компьютера и телефона. Интерфейс приложения остаётся одинаковым на всех устройствах.

    ${token?'Подключение…':'Локальный режим'}

    ${token?'':'

    Чтобы включить общую базу, запускайте приложение через server.js и открывайте на телефоне ссылку сервера.

    '}`; + const audit=[...settings.children].find(el=>(el.querySelector?.(':scope > h2')?.textContent||'').trim()==='История изменений'); + if(audit)window.SunSafe.insertBefore(settings,card,audit);else settings.appendChild(card); + q('#sunSyncWorkspaceInput',card)?.addEventListener('change',e=>{localStorage.setItem(SYNC_WORKSPACE,String(e.target.value||'main').trim()||'main');syncNow()}); + const now=q('#sunSyncNowBtn',card);if(now)now.onclick=()=>syncNow(); + const forget=q('#sunSyncForgetBtn',card);if(forget)forget.onclick=()=>{if(confirm('Отключить синхронизацию на этом устройстве? Локальные данные останутся.')){localStorage.removeItem(SYNC_TOKEN);setSyncStatus('local');card.remove();ensureSyncSettings()}}; + setSyncStatus(sync.status,sync.lastError); + } + + function initSync(){ + // Legacy same-Wi-Fi server sync is intentionally disabled in Cloud v2 builds. + // Supabase synchronization is handled by cloud-sync-v2.js. + setSyncStatus('local'); + } + + function init(){ + initPwa(); + initSync(); + document.documentElement.classList.add('sun-classic-mobile-ready'); + } + + if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init,{once:true});else init(); +})(); +; + +/* ===== MODULE: batch-update.js ===== */ +(function(){ + 'use strict'; + if(window.__sunBatchUpdate20260828)return;window.__sunBatchUpdate20260828=true; + const $=id=>document.getElementById(id); + const qa=(sel,root=document)=>[...root.querySelectorAll(sel)]; + const orderList=()=>{try{if(typeof orders!=='undefined'&&Array.isArray(orders))return orders}catch(_){}try{return JSON.parse(localStorage.getItem('sunOrders')||'[]')}catch(_){return[]}}; + const localToday=()=>{const d=new Date();return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`}; + const dateKey=o=>/^\d{4}-\d{2}-\d{2}$/.test(String(o?.date||''))?String(o.date):''; + const timeKey=o=>/^\d{2}:\d{2}$/.test(String(o?.time||''))?String(o.time):'23:59'; + let ordersMode='current'; + function splitOrders(all){ + const today=localToday(); + const current=[],done=[]; + all.forEach(o=>{const d=dateKey(o);(d&&d{const ad=dateKey(a)||'9999-12-31',bd=dateKey(b)||'9999-12-31';return (ad+timeKey(a)).localeCompare(bd+timeKey(b))||Number(a.id||0)-Number(b.id||0)}); + done.sort((a,b)=>{const ad=dateKey(a)||'0000-00-00',bd=dateKey(b)||'0000-00-00';return (bd+timeKey(b)).localeCompare(ad+timeKey(a))||Number(b.id||0)-Number(a.id||0)}); + return {current,done}; + } + function ensureOrdersTabs(){ + const view=$('orders');if(!view)return; + let bar=$('sunOrdersPeriodTabs'); + if(!bar){bar=document.createElement('div');bar.id='sunOrdersPeriodTabs';bar.className='sun-orders-period-tabs';bar.innerHTML='';const anchor=view.querySelector('.sun-orders-viewbar')||view.querySelector('.head')||view.firstElementChild?.nextSibling;const h=view.querySelector('h1');if(h)h.insertAdjacentElement('afterend',bar);else view.prepend(bar);bar.addEventListener('click',e=>{const b=e.target.closest('[data-orders-period]');if(!b)return;ordersMode=b.dataset.ordersPeriod==='done'?'done':'current';window.renderOrders?.();});} + const split=splitOrders(orderList()); + qa('[data-orders-period]',bar).forEach(b=>{const key=b.dataset.ordersPeriod;b.classList.toggle('on',key===ordersMode);const text=key==='current'?`Текущие · ${split.current.length}`:`Завершённые · ${split.done.length}`;if(b.textContent!==text)b.textContent=text}); + } + const previousRenderOrders=window.renderOrders; + if(typeof previousRenderOrders==='function')window.renderOrders=function(...args){ + ensureOrdersTabs(); + let ref=null,backup=null; + try{if(typeof orders!=='undefined'&&Array.isArray(orders)){ref=orders;backup=orders.slice();const split=splitOrders(backup),show=ordersMode==='done'?split.done:split.current;orders.length=0;orders.push(...show)}}catch(_){} + let result; + try{result=previousRenderOrders.apply(this,args)}finally{if(ref&&backup){ref.length=0;ref.push(...backup)}} + setTimeout(()=>{ensureOrdersTabs();compactSettingsLists();setDeliverySteps()},0); + return result; + }; + + let backupExpanded=false,auditExpanded=false; + function compactList(host,rowSelector,kind){ + if(!host)return;const rows=qa(rowSelector,host);const expanded=kind==='backup'?backupExpanded:auditExpanded; + rows.forEach((r,i)=>{r.style.display=(expanded||i<4)?'':'none'}); + let b=host.querySelector(':scope > .sun-compact-toggle'); + if(rows.length<=4){b?.remove();return} + if(!b){b=document.createElement('button');b.type='button';b.className='outline sun-compact-toggle';b.onclick=()=>{if(kind==='backup')backupExpanded=!backupExpanded;else auditExpanded=!auditExpanded;compactSettingsLists()};host.appendChild(b)} + const text=expanded?'Скрыть':`Показать ещё (${rows.length-4})`;if(b.textContent!==text)b.textContent=text; + } + function compactSettingsLists(){compactList($('backupList'),'.backup-row','backup');compactList($('auditList'),'.audit-row','audit')} + + function setDeliverySteps(){ + const delivery=$('deliveryCost');if(delivery)delivery.step='100'; + const transport=$('orderDeliveryCost');if(transport)transport.step='100'; + } + + function ensureCss(){if($('sun-batch-update-style'))return;const st=document.createElement('style');st.id='sun-batch-update-style';st.textContent=` + .sun-orders-period-tabs{display:flex;gap:7px;align-items:center;margin:10px 0 13px;padding:4px;width:max-content;max-width:100%;border-radius:12px;background:#f1eff8;border:1px solid #e1dced}.sun-orders-period-tabs button{border:0;background:transparent;color:#716a84;border-radius:9px;padding:7px 12px;font-size:11px;font-weight:800;cursor:pointer}.sun-orders-period-tabs button.on{background:#241d45;color:#fff;box-shadow:0 4px 12px rgba(36,29,69,.18)}.sun-compact-toggle{margin-top:9px!important}.sun-extra-services-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;margin-top:8px}.sun-extra-service{display:flex;gap:10px;align-items:flex-start;padding:12px 14px;border:1px solid #d9d2e9;border-radius:14px;background:#fff;color:#4e4961}.sun-extra-service:before{content:'✓';display:grid;place-items:center;flex:0 0 24px;height:24px;border-radius:8px;background:#241d45;color:#f2c85f;font-weight:900}.sun-extra-service b{display:block;color:#241d45;font-size:14px}.sun-extra-service small{display:block;color:#817a94;margin-top:2px;line-height:1.3}@media(max-width:620px){.sun-orders-period-tabs{width:100%}.sun-orders-period-tabs button{flex:1}.sun-extra-services-grid{grid-template-columns:1fr}} + `;document.head.appendChild(st)} + + function maintain(){ensureCss();ensureOrdersTabs();compactSettingsLists();setDeliverySteps()} + (()=>{let queued=false;const run=()=>{if(queued)return;queued=true;setTimeout(()=>{queued=false;maintain();},60);};[$('new'),$('orders'),$('enterprise-settings')].filter(Boolean).forEach(root=>new MutationObserver(run).observe(root,{childList:true,subtree:true}));})(); + document.addEventListener('click',e=>{const nav=e.target.closest('header nav button');if(nav&&String(nav.dataset.navLabel||nav.textContent||'').trim()==='Настройки')setTimeout(compactSettingsLists,80)},true); + maintain();setTimeout(maintain,200);setTimeout(maintain,900); +})(); +; + +/* ===== MODULE: cloud-sync-v2.js ===== */ +(() => { + 'use strict'; + if (window.SunCloudV2) return; + + const VERSION = '2026.09.05-cloud-rbac-v7-developer-support'; + const CONFIG_KEY = 'sunCloudV2Config'; + const CLIENT_ID_KEY = 'sunCloudClientIdV2'; + const DB_NAME = 'SunCloudV2'; + const DB_VERSION = 1; + const BUCKET = 'sun-media'; + const SUPABASE_JS = 'https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2.112.4'; + const MEDIA_PREFIX = '__sun_media__:'; + const SYNC_DEBOUNCE = 1800; + + const $ = id => document.getElementById(id); + const esc=window.SunSafe.escapeHTML; + const clone = value => value == null ? value : (typeof structuredClone === 'function' ? structuredClone(value) : JSON.parse(JSON.stringify(value))); + const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); + const toast = (text, type='info', timeout=4500) => { + try { if (window.SunEnterprise?.toast) return window.SunEnterprise.toast(text, type, timeout); } catch (_) {} + console[type === 'error' ? 'error' : 'log']('[SunCloud]', text); + }; + + const DEFAULT_SUPABASE_URL = 'https://cksuehzcimitsxmeloes.supabase.co'; + const DEFAULT_SUPABASE_KEY = 'sb_publishable_v8Z3hEBnu7zsDwAb5KCWcg_T96IejsM'; + + let config = loadConfig(); + let client = null; + let clientSignature = ''; + let session = null; + let memberships = []; + let workspace = null; + let supportMode = null; + let membershipsLoading = false; + let membershipsLoaded = false; + let realtimeChannel = null; + let syncTimer = null; + let isSyncing = false; + let dirty = false; + let suppressStorageTracking = false; + let observedSignature = ''; + let lastStatus = 'local'; + let lastError = ''; + + function loadConfig() { + let value = {}; + try { value = JSON.parse(localStorage.getItem(CONFIG_KEY) || '{}') || {}; } catch (_) {} + if (!value.url || !value.key) { + try { + const legacy = JSON.parse(localStorage.getItem('sunEnterpriseSettingsV1') || '{}') || {}; + const cloud = legacy.cloud || {}; + value.url ||= String(cloud.supabaseUrl || '').trim(); + value.key ||= String(cloud.anonKey || '').trim(); + } catch (_) {} + } + return { + url: String(value.url || DEFAULT_SUPABASE_URL || '').trim().replace(/\/$/, ''), + key: String(value.key || DEFAULT_SUPABASE_KEY || '').trim(), + autoSync: value.autoSync !== false, + workspaceId: String(value.workspaceId || ''), + migrated: value.migrated && typeof value.migrated === 'object' ? value.migrated : {}, + lastSync: String(value.lastSync || ''), + localWorkspaceId: String(value.localWorkspaceId || ''), + tenantStorageReady: value.tenantStorageReady === true, + legacyLocalWorkspaceId: String(value.legacyLocalWorkspaceId || (value.tenantStorageReady === true ? '' : (value.workspaceId || ''))) + }; + } + + function saveConfig() { + localStorage.setItem(CONFIG_KEY, JSON.stringify(config)); + } + + function disableLegacyCloud() { + try { + const raw = localStorage.getItem('sunEnterpriseSettingsV1'); + if (!raw) return; + const data = JSON.parse(raw); + if (!data || typeof data !== 'object') return; + data.cloud = Object.assign({}, data.cloud || {}, {enabled:false, autoSync:false, lastSync:data.cloud?.lastSync || ''}); + localStorage.setItem('sunEnterpriseSettingsV1', JSON.stringify(data)); + } catch (_) {} + } + + function clientId() { + let id = localStorage.getItem(CLIENT_ID_KEY); + if (!id) { + id = crypto.randomUUID ? crypto.randomUUID() : `client-${Date.now()}-${Math.random().toString(36).slice(2)}`; + localStorage.setItem(CLIENT_ID_KEY, id); + } + return id; + } + + function workspaceMigrated() { + return Boolean(workspace?.id && config.migrated?.[workspace.id]); + } + + const ROLE_LABELS = {admin:'Администратор',manager:'Менеджер',kitchen:'Кухня',courier:'Курьер',viewer:'Только просмотр'}; + function hasPermission(permission) { + if (!workspace || workspace.is_active === false) return false; + const value=workspace.permissions?.[permission]; + if (typeof value === 'boolean') return value; + return workspace.role === 'admin'; + } + function canWrite() { + if (supportMode) return false; + if (!workspace || workspace.is_active === false) return false; + const permissions=workspace.permissions || {}; + const entries=Object.entries(permissions); + if (entries.length) return entries.some(([key,value]) => value === true && (key.endsWith('.edit') || key.endsWith('.create') || key.endsWith('.delete') || key === 'users.manage' || key === 'backups.manage')); + return workspace.role === 'admin'; + } + + function roleLabel(role) { + return ROLE_LABELS[role] || role || '—'; + } + + function shouldSyncKey(key) { + key = String(key || ''); + if (!key.startsWith('sun')) return false; + if (key === CONFIG_KEY || key === CLIENT_ID_KEY) return false; + if (key === 'sunUsersV1' || key === 'sunSessionUserV1') return false; + if (key === 'sunFallbackBackupsV1') return false; + if (key === 'sunStaticMapGeocodeCacheV2') return false; + if (key === 'sunCloudSyncMetaV1' || key === 'sunCloudSyncTokenV1' || key === 'sunCloudSyncWorkspaceV1') return false; + if (key === 'sunOrdersView' || key === 'sunCalendarMode' || key === 'sunCatalogViewModeV1' || key === 'sunNewOrderSplitRatioV5') return false; + if (key.startsWith('sunRecoveryCorrupt.')) return false; + return true; + } + + function sanitizeEnterpriseSettings(raw) { + try { + const data = JSON.parse(raw); + if (!data || typeof data !== 'object') return raw; + data.rolesEnabled = false; + if (data.cloud) data.cloud = {enabled:false,supabaseUrl:'',anonKey:'',workspaceId:'',autoSync:false,lastSync:''}; + return JSON.stringify(data); + } catch (_) { return raw; } + } + + function openDb() { + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION); + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains('meta')) db.createObjectStore('meta', {keyPath:'key'}); + if (!db.objectStoreNames.contains('media')) db.createObjectStore('media', {keyPath:'path'}); + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + } + + async function idbGet(store, key) { + try { + const db = await openDb(); + return await new Promise((resolve, reject) => { + const request = db.transaction(store, 'readonly').objectStore(store).get(key); + request.onsuccess = () => resolve(request.result || null); + request.onerror = () => reject(request.error); + }); + } catch (_) { return null; } + } + + async function idbPut(store, value) { + try { + const db = await openDb(); + await new Promise((resolve, reject) => { + const tx = db.transaction(store, 'readwrite'); + tx.objectStore(store).put(value); + tx.oncomplete = resolve; + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error || new Error('IndexedDB transaction aborted')); + }); + return true; + } catch (_) { return false; } + } + + // Keep the browser working copy isolated by cloud workspace. The live app still + // reads the historical unprefixed sun* keys, so before changing workspace we + // snapshot those keys to IndexedDB, clear them, and restore only the target + // workspace. This prevents orders/clients/finance/etc. from leaking between + // accounts while avoiding a second large copy inside localStorage. + function isTenantLocalKey(key) { + key=String(key||''); + if(!key.startsWith('sun'))return false; + if(key===CONFIG_KEY||key===CLIENT_ID_KEY)return false; + if(key.startsWith('sunCloud'))return false; + if(key==='sunStaticMapGeocodeCacheV2')return false; + return true; + } + + function tenantLocalKeys() { + const out=[]; + for(let i=0;ilocalStorage.removeItem(key)); + } + + async function restoreTenantLocal(workspaceId) { + const id=String(workspaceId||'').trim(); + if(!id)return false; + const row=await idbGet('meta',`tenant-local:${id}`),storage=row?.value?.storage; + if(!storage||typeof storage!=='object')return false; + for(const [key,value] of Object.entries(storage)){ + if(isTenantLocalKey(key))localStorage.setItem(key,String(value??'')); + } + return true; + } + + async function switchTenantLocal(nextWorkspaceId) { + const next=String(nextWorkspaceId||'').trim(); + // One-time upgrade adoption. If the old config already named a workspace, + // current local data belongs to that legacy workspace only. This prevents a + // signed-out old database from being accidentally attached to a different + // account on the first login after upgrading. + if(!config.tenantStorageReady){ + const legacy=String(config.legacyLocalWorkspaceId||'').trim(); + if(legacy&&legacy!==next){ + await captureTenantLocal(legacy); + clearTenantLocal(); + config.tenantStorageReady=true; + config.localWorkspaceId=next; + config.legacyLocalWorkspaceId=''; + let restored=false; + if(next)restored=await restoreTenantLocal(next); + if(next&&!restored)config.migrated[next]=false; + saveConfig(); + return {changed:true,restored,legacyCaptured:true}; + } + if(next){ + config.tenantStorageReady=true; + config.localWorkspaceId=next; + config.legacyLocalWorkspaceId=''; + saveConfig(); + await captureTenantLocal(next); + return {changed:false,restored:true,adopted:true}; + } + // A purely local pre-cloud installation has no workspace id yet. Keep it + // untouched until the first company is selected, so migration stays safe. + return {changed:false,restored:false}; + } + const prev=String(config.localWorkspaceId||'').trim(); + if(prev===next)return {changed:false,restored:Boolean(next)}; + if(prev)await captureTenantLocal(prev); + clearTenantLocal(); + config.localWorkspaceId=next; + let restored=false; + if(next)restored=await restoreTenantLocal(next); + // If this device has no saved working copy for the target workspace, force a + // clean cloud pull on reload instead of merging an empty browser state. + if(next&&!restored)config.migrated[next]=false; + saveConfig(); + return {changed:true,restored}; + } + + async function adoptCurrentLocalAsWorkspace(workspaceId) { + const id=String(workspaceId||'').trim(); + if(!id)return false; + config.tenantStorageReady=true; + config.localWorkspaceId=id; + config.legacyLocalWorkspaceId=''; + config.workspaceId=id; + config.migrated[id]=false; + saveConfig(); + await captureTenantLocal(id); + return true; + } + + function seedOfficialCatalogForFreshWorkspace() { + clearTenantLocal(); + let seed=[]; + try{if(Array.isArray(window.SUN_OFFICIAL_CATALOG))seed=clone(window.SUN_OFFICIAL_CATALOG)}catch(_){} + localStorage.setItem('sunBoxes',JSON.stringify(Array.isArray(seed)?seed:[])); + localStorage.setItem('sunOrders','[]'); + if(window.SUN_OFFICIAL_CATALOG_VERSION)localStorage.setItem('sunOfficialCatalogVersion',String(window.SUN_OFFICIAL_CATALOG_VERSION)); + } + + let signOutInProgress=false; + + function clearLocalSupabaseSessionFallback() { + // Supabase normally clears its own persisted auth token. If the network call + // is blocked or hangs, remove only this project's auth keys so the user can + // still leave the account on the current device without touching app data. + let projectRef=''; + try{projectRef=new URL(config.url).hostname.split('.')[0]||'';}catch(_){} + const prefixes=projectRef?[`sb-${projectRef}-auth-token`]:[]; + for(const storage of [localStorage,sessionStorage]){ + try{ + const remove=[]; + for(let i=0;ikey===prefix||key.startsWith(prefix+'.')||key.startsWith(prefix+'-')))remove.push(key); + } + remove.forEach(key=>storage.removeItem(key)); + }catch(_){} + } + } + + function setSignOutUiBusy(busy) { + const selectors=['#sunCloudSignOutV2','#sunLogoutBtn','#sunGateSignOutV3','#sunCloudUsePasswordV3','[data-signout]','[data-saas-signout]']; + document.querySelectorAll(selectors.join(',')).forEach(btn=>{ + if(!(btn instanceof HTMLButtonElement))return; + if(busy){btn.dataset.sunOldText=btn.textContent||'';btn.disabled=true;btn.textContent='Выходим…';} + else{btn.disabled=false;if(btn.dataset.sunOldText){btn.textContent=btn.dataset.sunOldText;delete btn.dataset.sunOldText;}} + }); + } + + async function signOutAndReload() { + if(signOutInProgress)return; + signOutInProgress=true; + setSignOutUiBusy(true); + clearTimeout(syncTimer); + setStatus('syncing','Выходим из аккаунта…'); + try{ + // Preserve the company working copy first. Do not block logout on a cloud + // sync: the workspace snapshot remains available and will sync after the + // next login if there were unsent changes. + const snapshot=Promise.resolve().then(async()=>{if(supportMode){clearTenantLocal();supportMode=null;config.localWorkspaceId='';saveConfig();return {changed:true};}return switchTenantLocal('');}); + await Promise.race([snapshot,sleep(2200).then(()=>{throw new Error('Tenant snapshot timeout');})]); + }catch(error){ + console.warn('[SunCloud] tenant snapshot before sign-out failed',error); + try{clearTenantLocal();config.localWorkspaceId='';saveConfig();}catch(_){} + } + try{ + if(client?.auth){ + const result=await Promise.race([ + client.auth.signOut({scope:'local'}), + sleep(2500).then(()=>{throw new Error('Supabase local sign-out timeout');}) + ]); + if(result?.error)throw result.error; + }else clearLocalSupabaseSessionFallback(); + }catch(error){ + console.warn('[SunCloud] Supabase sign-out fallback',error); + try{client?.auth.stopAutoRefresh?.();}catch(_){} + clearLocalSupabaseSessionFallback(); + } + session=null;workspace=null;memberships=[];membershipsLoading=false;membershipsLoaded=true;unsubscribeRealtime(); + try{window.dispatchEvent(new CustomEvent('sun:cloud-permissions-changed'));}catch(_){} + // Reload regardless of network state so a blocked logout endpoint can never + // leave the user trapped in the previous account screen. + location.reload(); + } + + async function getBaseline() { + if (!workspace?.id) return null; + const row = await idbGet('meta', `baseline:${workspace.id}`); + return row?.value || null; + } + + async function setBaseline(row) { + if (!workspace?.id || !row) return; + await idbPut('meta', {key:`baseline:${workspace.id}`, value:{payload:clone(row.payload),revision:Number(row.revision || 0),updated_at:row.updated_at || '',client_id:row.client_id || ''}}); + } + + function stable(value) { + if (value === undefined) return '__undefined__'; + if (value === null || typeof value !== 'object') return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map(stable).join(',')}]`; + return `{${Object.keys(value).sort().map(k => `${JSON.stringify(k)}:${stable(value[k])}`).join(',')}}`; + } + + function equal(a, b) { return stable(a) === stable(b); } + + function isPlainObject(value) { + return Boolean(value && typeof value === 'object' && !Array.isArray(value)); + } + + function idArray(value) { + return Array.isArray(value) && value.every(item => isPlainObject(item) && item.id != null); + } + + function mergeById(base, local, remote, conflicts, path) { + const bm = new Map((base || []).map(x => [String(x.id), x])); + const lm = new Map((local || []).map(x => [String(x.id), x])); + const rm = new Map((remote || []).map(x => [String(x.id), x])); + const order = []; + [...(remote || []), ...(local || []), ...(base || [])].forEach(x => { const id=String(x.id); if (!order.includes(id)) order.push(id); }); + const out = []; + for (const id of order) { + const b = bm.get(id), l = lm.get(id), r = rm.get(id); + if (l === undefined && r === undefined) continue; + if (l === undefined) { + if (b !== undefined && !equal(r,b)) conflicts.push(`${path}[${id}]: удалено локально, изменено в облаке`); + if (b === undefined || !equal(r,b)) out.push(clone(r)); + continue; + } + if (r === undefined) { + if (b !== undefined && !equal(l,b)) { conflicts.push(`${path}[${id}]: изменено локально, удалено в облаке`); out.push(clone(l)); } + else if (b === undefined) out.push(clone(l)); + continue; + } + out.push(mergeNode(b, l, r, conflicts, `${path}[${id}]`)); + } + return out; + } + + function mergeNode(base, local, remote, conflicts, path='state') { + if (equal(local, remote)) return clone(local); + if (equal(local, base)) return clone(remote); + if (equal(remote, base)) return clone(local); + + if (idArray(local) && idArray(remote) && (!base || idArray(base))) { + return mergeById(Array.isArray(base)?base:[], local, remote, conflicts, path); + } + + if (isPlainObject(local) && isPlainObject(remote) && (base == null || isPlainObject(base))) { + const out = {}; + const keys = new Set([...Object.keys(base || {}), ...Object.keys(local), ...Object.keys(remote)]); + for (const key of keys) { + const b = base?.[key], l = local[key], r = remote[key]; + if (!(key in local) && !(key in remote)) continue; + if (!(key in local)) { + if (b !== undefined && !equal(r,b)) conflicts.push(`${path}.${key}: удалено локально, изменено в облаке`); + if (b === undefined || !equal(r,b)) out[key] = clone(r); + continue; + } + if (!(key in remote)) { + if (b !== undefined && !equal(l,b)) { conflicts.push(`${path}.${key}: изменено локально, удалено в облаке`); out[key]=clone(l); } + else if (b === undefined) out[key]=clone(l); + continue; + } + out[key] = mergeNode(b, l, r, conflicts, `${path}.${key}`); + } + return out; + } + + conflicts.push(path); + return clone(local); + } + + function mergePayloads(basePayload, localPayload, remotePayload) { + const conflicts = []; + const base = basePayload?.storage || {}; + const local = localPayload?.storage || {}; + const remote = remotePayload?.storage || {}; + const merged = {}; + const keys = new Set([...Object.keys(base), ...Object.keys(local), ...Object.keys(remote)]); + for (const key of keys) { + const b = base[key], l = local[key], r = remote[key]; + if (l === undefined && r === undefined) continue; + if (l === undefined) { + if (b !== undefined && !equal(r,b)) conflicts.push(key); + if (b === undefined || !equal(r,b)) merged[key] = clone(r); + continue; + } + if (r === undefined) { + if (b !== undefined && !equal(l,b)) { conflicts.push(key); merged[key] = clone(l); } + else if (b === undefined) merged[key] = clone(l); + continue; + } + merged[key] = mergeNode(b, l, r, conflicts, key); + } + return {payload:{format:'sun-cloud-v2',version:2,storage:merged}, conflicts:[...new Set(conflicts)]}; + } + + function dataUrlToBlob(dataUrl) { + const match = /^data:([^;,]+)?(;base64)?,(.*)$/s.exec(dataUrl || ''); + if (!match) throw new Error('Invalid data URL'); + const mime = match[1] || 'application/octet-stream'; + const binary = match[2] ? atob(match[3]) : decodeURIComponent(match[3]); + const bytes = new Uint8Array(binary.length); + for (let i=0;i { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result || '')); + reader.onerror = () => reject(reader.error || new Error('FileReader error')); + reader.readAsDataURL(blob); + }); + } + + async function shrinkImageIfNeeded(dataUrl, blob) { + if (blob.size <= 12 * 1024 * 1024) return blob; + const image = new Image(); + image.src = dataUrl; + await new Promise((resolve, reject) => { image.onload=resolve; image.onerror=reject; }); + const max = 2400; + const scale = Math.min(1, max / Math.max(image.naturalWidth || 1, image.naturalHeight || 1)); + const canvas = document.createElement('canvas'); + canvas.width = Math.max(1, Math.round((image.naturalWidth || 1) * scale)); + canvas.height = Math.max(1, Math.round((image.naturalHeight || 1) * scale)); + const ctx = canvas.getContext('2d'); + ctx.fillStyle = '#fff'; ctx.fillRect(0,0,canvas.width,canvas.height); ctx.drawImage(image,0,0,canvas.width,canvas.height); + return await new Promise(resolve => canvas.toBlob(value => resolve(value || blob), 'image/jpeg', .9)); + } + + async function sha256(blob) { + const digest = await crypto.subtle.digest('SHA-256', await blob.arrayBuffer()); + return [...new Uint8Array(digest)].map(b => b.toString(16).padStart(2,'0')).join(''); + } + + function extensionFor(type) { + return ({'image/jpeg':'jpg','image/png':'png','image/webp':'webp','image/gif':'gif'})[String(type||'').toLowerCase()] || 'bin'; + } + + async function uploadDataUrl(dataUrl) { + if (!client || !workspace?.id) return dataUrl; + let blob = dataUrlToBlob(dataUrl); + blob = await shrinkImageIfNeeded(dataUrl, blob); + const hash = await sha256(blob); + const ext = extensionFor(blob.type); + const path = `${workspace.id}/media/${hash.slice(0,2)}/${hash}.${ext}`; + const {error} = await client.storage.from(BUCKET).upload(path, blob, {upsert:false,contentType:blob.type || 'application/octet-stream',cacheControl:'31536000'}); + if (error && !/exist|duplicate|409/i.test(`${error.message || ''} ${error.statusCode || ''}`)) throw error; + await idbPut('media', {path, blob, cachedAt:new Date().toISOString()}); + return MEDIA_PREFIX + path; + } + + async function encodeMedia(value) { + if (typeof value === 'string') { + if (/^data:image\//i.test(value)) return await uploadDataUrl(value); + return value; + } + if (Array.isArray(value)) { + const out = []; + for (const item of value) out.push(await encodeMedia(item)); + return out; + } + if (isPlainObject(value)) { + const out = {}; + for (const [key,item] of Object.entries(value)) out[key] = await encodeMedia(item); + return out; + } + return value; + } + + async function downloadMedia(path) { + const cached = await idbGet('media', path); + if (cached?.blob) return await blobToDataUrl(cached.blob); + const {data, error} = await client.storage.from(BUCKET).download(path); + if (error) throw error; + await idbPut('media', {path, blob:data, cachedAt:new Date().toISOString()}); + return await blobToDataUrl(data); + } + + async function decodeMedia(value) { + if (typeof value === 'string' && value.startsWith(MEDIA_PREFIX)) return await downloadMedia(value.slice(MEDIA_PREFIX.length)); + if (Array.isArray(value)) { + const out=[]; for (const item of value) out.push(await decodeMedia(item)); return out; + } + if (isPlainObject(value)) { + const out={}; for (const [key,item] of Object.entries(value)) out[key]=await decodeMedia(item); return out; + } + return value; + } + + function quotaExceeded(error) {return error?.name==='QuotaExceededError'||/quota|exceeded/i.test(String(error?.message||error||''));} + + function compactLocalImageData(dataUrl,{maxSide=900,maxChars=130000,quality=.78,force=false}={}) { + return new Promise(resolve => { + if (!/^data:image\//i.test(String(dataUrl || ''))) return resolve(dataUrl); + const image = new Image(); + image.onerror = () => resolve(dataUrl); + image.onload = () => { + const iw=Math.max(1,image.naturalWidth||image.width||1), ih=Math.max(1,image.naturalHeight||image.height||1); + if (!force && String(dataUrl).length<=maxChars && Math.max(iw,ih)<=maxSide) return resolve(dataUrl); + let scale=Math.min(1,maxSide/Math.max(iw,ih)), q=quality, best=dataUrl; + for (let attempt=0; attempt<5; attempt++) { + const canvas=document.createElement('canvas'); + canvas.width=Math.max(1,Math.round(iw*scale)); canvas.height=Math.max(1,Math.round(ih*scale)); + const ctx=canvas.getContext('2d'); if(!ctx) break; + ctx.fillStyle='#fff'; ctx.fillRect(0,0,canvas.width,canvas.height); ctx.drawImage(image,0,0,canvas.width,canvas.height); + try { best=canvas.toDataURL('image/jpeg',q)||best; } catch (_) { break; } + if (best.length<=maxChars) break; + scale*=.78; q=Math.max(.52,q-.07); + } + resolve(best); + }; + image.src=dataUrl; + }); + } + + async function compactSunBoxesForStorage(value, options={}) { + if (!Array.isArray(value)) return value; + const out=[]; + for (const item of value) { + if (!isPlainObject(item)) { out.push(item); continue; } + const copy={...item}; + if (typeof copy.photo==='string' && /^data:image\//i.test(copy.photo)) copy.photo=await compactLocalImageData(copy.photo,options); + out.push(copy); + } + return out; + } + + async function collectLocalPayload() { + const storage = {}; + for (let i=0;i { if (!remoteKeys.has(key)) localStorage.removeItem(key); }); + } + for (const [key,entry] of Object.entries(payload.storage)) { + if (!shouldSyncKey(key) || !entry) continue; + let decoded = await decodeMedia(entry.v); + if (key === 'sunBoxes' && entry.t === 'j') decoded = await compactSunBoxesForStorage(decoded,{maxSide:900,maxChars:130000,quality:.78}); + let raw = entry.t === 'j' ? JSON.stringify(decoded) : String(decoded ?? ''); + try { + localStorage.setItem(key, raw); + } catch (error) { + if (key !== 'sunBoxes' || entry.t !== 'j' || !quotaExceeded(error)) throw error; + decoded = await compactSunBoxesForStorage(decoded,{maxSide:640,maxChars:80000,quality:.66,force:true}); + raw = JSON.stringify(decoded); + try { localStorage.setItem(key, raw); } + catch (retryError) { + if (quotaExceeded(retryError)) throw new Error('Каталог с фотографиями все еще превышает локальный лимит браузера даже после оптимизации фото.'); + throw retryError; + } + toast('Фотографии каталога оптимизированы, чтобы не превышать лимит браузера.','info',5200); + } + } + } finally { + suppressStorageTracking = false; + } + refreshRuntime(); + } + + function refreshRuntime() { + try { if (typeof boxes !== 'undefined') boxes = JSON.parse(localStorage.getItem('sunBoxes') || '[]'); } catch (_) {} + try { if (typeof orders !== 'undefined') orders = JSON.parse(localStorage.getItem('sunOrders') || '[]'); } catch (_) {} + for (const name of ['render','renderOrders','renderStats','renderClients','renderStock','renderManager']) { + try { if (typeof window[name] === 'function') window[name](); } catch (_) {} + } + try { window.dispatchEvent(new CustomEvent('sun:cloud-state-applied')); } catch (_) {} + } + + function payloadEmpty(payload) { + return !payload?.storage || Object.keys(payload.storage).length === 0; + } + + async function fetchRemoteRow() { + if (!client || !workspace?.id) return null; + const {data,error} = await client.rpc('sun_fetch_app_state',{p_workspace:workspace.id}); + if (error) throw error; + return Array.isArray(data) ? (data[0] || null) : (data || null); + } + + async function upsertPayload(payload, expectedRevision=null) { + const args={p_workspace:workspace.id,p_payload:payload,p_client_id:clientId(),p_expected_revision:expectedRevision==null?null:Number(expectedRevision)}; + const {data,error} = await client.rpc('sun_save_app_state_v17',args); + if (error) throw error; + return Array.isArray(data) ? data[0] : data; + } + + async function backupBeforeCloud(reason) { + try { await window.SunEnterprise?.createBackup?.(reason); } catch (_) {} + } + + async function migrateLocalToCloud() { + if (!canWrite()) return toast('Для переноса данных нужны права на изменение рабочей базы.','warn'); + if (!navigator.onLine) return toast('Нет подключения к интернету. Локальные данные сохранены и никуда не пропадут.','warn'); + setStatus('syncing','Создаю первую облачную копию…'); + try { + await backupBeforeCloud('Перед первым переносом в Supabase'); + const remote = await fetchRemoteRow(); + if (remote && !payloadEmpty(remote.payload)) { + if (!confirm('В этой рабочей базе уже есть облачные данные. Заменить их текущими данными с этого устройства?')) { + setStatus('ready','Перенос отменён.'); return; + } + } + const localPayload = await collectLocalPayload(); + const row = await upsertPayload(localPayload, remote?.revision ?? null); + await setBaseline(row); + config.migrated[workspace.id] = true; + config.lastSync = row.updated_at || new Date().toISOString(); + config.autoSync = true; + saveConfig(); dirty=false; + setStatus('ready','Все текущие данные перенесены в облако.'); + toast('Данные и загруженные фотографии перенесены в Supabase. Автосинхронизация включена.','success',6500); + subscribeRealtime(); + try{window.dispatchEvent(new CustomEvent('sun:cloud-sync-complete',{detail:{row:clone(row),payload:clone(localPayload),kind:'migrate'}}));}catch(_){} + renderCloudUI(); + } catch (error) { handleError(error, 'Не удалось перенести данные в облако.'); } + } + + async function pullRemote({replace=false,quiet=false}={}) { + if (!client || !workspace?.id) return; + setStatus('syncing','Загружаю облачные данные…'); + try { + const row = await fetchRemoteRow(); + if (!row || payloadEmpty(row.payload)) { + setStatus('ready','В облаке пока нет данных.'); return; + } + if (!quiet) await backupBeforeCloud('Перед загрузкой данных из Supabase'); + await applyPayload(row.payload, replace); + await setBaseline(row); + config.migrated[workspace.id] = true; + config.lastSync = row.updated_at || new Date().toISOString(); + saveConfig(); dirty=false; + setStatus('ready','Облачные данные загружены.'); + if (!quiet) toast('Данные загружены из облака.','success'); + try{window.dispatchEvent(new CustomEvent('sun:cloud-sync-complete',{detail:{row:clone(row),payload:clone(row.payload),kind:'pull'}}));}catch(_){} + renderCloudUI(); + } catch (error) { handleError(error,'Не удалось загрузить облачные данные.'); } + } + + async function syncNow({quiet=false}={}) { + if (isSyncing || !client || !session || !workspace?.id || !workspaceMigrated()) return; + if (!navigator.onLine) { setStatus('offline','Офлайн — изменения ждут подключения.'); return; } + isSyncing = true; + setStatus('syncing','Синхронизация…'); + try { + const [baseline, remote, localPayload] = await Promise.all([getBaseline(), fetchRemoteRow(), collectLocalPayload()]); + if (!remote || payloadEmpty(remote.payload)) { + if (!canWrite()) throw new Error('В облаке нет базы, а у пользователя нет прав на её создание.'); + const row = await upsertPayload(localPayload, remote?.revision ?? null); + await setBaseline(row); dirty=false; config.lastSync=row.updated_at||new Date().toISOString();saveConfig(); + setStatus('ready','Синхронизировано.'); if(!quiet)toast('Синхронизация завершена.','success'); return; + } + + if (!baseline) { + await applyPayload(remote.payload, true); + await setBaseline(remote); dirty=false; config.lastSync=remote.updated_at||new Date().toISOString();saveConfig(); + setStatus('ready','Подключена существующая облачная база.'); if(!quiet)toast('Загружена актуальная облачная база.','success'); return; + } + + const merged = mergePayloads(baseline.payload, localPayload, remote.payload); + let finalRow = remote; + const changedVsRemote = !equal(merged.payload, remote.payload); + const changedVsLocal = !equal(merged.payload, localPayload); + + if (changedVsRemote) { + if (!canWrite()) { + await applyPayload(remote.payload, true); + finalRow = remote; + } else { + finalRow = await upsertPayload(merged.payload, remote.revision); + if (changedVsLocal) await applyPayload(merged.payload, true); + } + } else if (changedVsLocal) { + await applyPayload(remote.payload, true); + } + + await setBaseline(finalRow); + dirty=false; + config.lastSync=finalRow.updated_at||new Date().toISOString();saveConfig(); + setStatus('ready', merged.conflicts.length ? `Синхронизировано, объединено конфликтов: ${merged.conflicts.length}.` : 'Синхронизировано.'); + if (merged.conflicts.length) toast(`Изменения с двух устройств объединены. Проверено конфликтов: ${merged.conflicts.length}.`,'warn',6500); + else if (!quiet) toast('Синхронизация завершена.','success'); + try{window.dispatchEvent(new CustomEvent('sun:cloud-sync-complete',{detail:{row:clone(finalRow),payload:clone(finalRow.payload),kind:'sync',conflicts:merged.conflicts.length}}));}catch(_){} + window.SunStabilityV17?.markSynced?.(finalRow?.revision); + renderCloudUI(); + } catch (error) { + const msg=String(error?.message||''); + if(msg.includes('SUN_CONFLICT')){ + setStatus('pending','Облачная версия изменилась. Повторяю безопасную синхронизацию…'); + try{window.SunStabilityV17?.recordConflict?.(msg);}catch(_){} + setTimeout(()=>syncNow({quiet:true}),350); + }else handleError(error,'Ошибка синхронизации.'); + } + finally { isSyncing=false; } + } + + async function handleRealtimeRow(row) { + if (!row || row.client_id === clientId() || isSyncing || !workspaceMigrated()) return; + try { + const baseline = await getBaseline(); + if (!baseline) { await applyPayload(row.payload,true);await setBaseline(row);setStatus('ready','Получены данные с другого устройства.');return; } + const localPayload = await collectLocalPayload(); + const merged = mergePayloads(baseline.payload, localPayload, row.payload); + await applyPayload(merged.payload, true); + if (!equal(merged.payload,row.payload) && canWrite()) { + const saved=await upsertPayload(merged.payload,row.revision);await setBaseline(saved); + } else await setBaseline(row); + dirty=false;config.lastSync=row.updated_at||new Date().toISOString();saveConfig(); + setStatus('ready','Получены изменения с другого устройства.'); + toast(merged.conflicts.length?'Получены облачные изменения; параллельные правки объединены.':'Данные обновлены с другого устройства.','success',3500); + renderCloudUI(); + } catch (error) { handleError(error,'Не удалось применить изменения с другого устройства.'); } + } + + function scheduleSync(reason='change') { + dirty = true; + try{window.SunStabilityV17?.markPending?.(reason);}catch(_){} + if (!config.autoSync || !workspaceMigrated() || !session || !workspace || !canWrite()) { + updatePill(); return; + } + clearTimeout(syncTimer); + syncTimer = setTimeout(() => syncNow({quiet:true}), SYNC_DEBOUNCE); + if (!navigator.onLine) setStatus('offline','Офлайн — изменения сохранены локально.'); + else setStatus('pending',`Изменения ожидают синхронизации (${reason}).`); + } + + function storageSignature() { + const parts=[]; + for(let i=0;i>>0}`); + } + return parts.sort().join('|'); + } + + function installStorageTracking() { + if (Storage.prototype.__sunCloudV2Tracked) return; + const nativeSet = Storage.prototype.setItem; + const nativeRemove = Storage.prototype.removeItem; + Object.defineProperty(Storage.prototype,'__sunCloudV2Tracked',{value:true,configurable:true}); + Storage.prototype.setItem=function(key,value){ + const old = this===localStorage ? this.getItem(key) : null; + const result = nativeSet.call(this,key,value); + if(this===localStorage&&!suppressStorageTracking&&shouldSyncKey(key)&&old!==String(value))scheduleSync(key); + return result; + }; + Storage.prototype.removeItem=function(key){ + const had=this===localStorage&&this.getItem(key)!==null; + const result=nativeRemove.call(this,key); + if(this===localStorage&&!suppressStorageTracking&&had&&shouldSyncKey(key))scheduleSync(key); + return result; + }; + observedSignature=storageSignature(); + setInterval(()=>{ + if(document.hidden||suppressStorageTracking)return; + const next=storageSignature(); + if(next!==observedSignature){observedSignature=next;scheduleSync('данные приложения');} + },9000); + ['sun:catalog-categories-changed','sun:client-offer-catalog-changed','sun:cloud-dirty'].forEach(name=>window.addEventListener(name,()=>scheduleSync(name))); + } + + function setStatus(kind, text='') { + lastStatus=kind;lastError=kind==='error'?text:''; + const el=$('sunCloudV2Status'); + if(el){el.textContent=text||statusText();el.dataset.kind=kind;} + updatePill(); + } + + function statusText() { + if(lastStatus==='error')return lastError||'Ошибка облака'; + if(!config.url||!config.key)return 'Облако не подключено'; + if(!navigator.onLine)return 'Офлайн — работаем с локальной копией'; + if(!session)return 'Нужно войти в облачный аккаунт'; + if(supportMode)return `Режим поддержки · ${supportMode.name||'Компания'} · только просмотр`; + if(!workspace)return 'Нужно выбрать или создать рабочую базу'; + if(!workspaceMigrated())return 'Нужен первый перенос данных в облако'; + if(lastStatus==='syncing')return 'Синхронизация…'; + if(dirty)return 'Есть несинхронизированные изменения'; + return config.lastSync?`Синхронизировано ${new Date(config.lastSync).toLocaleString('ru-RU')}`:'Облако готово'; + } + + // Облачный статус больше не висит поверх всех разделов. + // Управление облаком доступно только в «Настройки → Облако Supabase». + function ensurePill() { + const pill=$('sunCloudPillV2'); + if(pill) pill.remove(); + return null; + } + + function updatePill() { + ensurePill(); + } + + function injectStyles() { + if($('sunCloudV2Styles'))return; + const style=document.createElement('style');style.id='sunCloudV2Styles';style.textContent=` + .sun-cloud-v2-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.sun-cloud-v2-grid .wide{grid-column:1/-1}.sun-cloud-v2-box{border:1px solid #e0e4e6;border-radius:9px;padding:12px;background:#fbfcfc}.sun-cloud-v2-box h3{margin:0 0 8px;color:#15364c;font-size:14px}.sun-cloud-v2-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px}.sun-cloud-v2-status{margin-top:10px;border-radius:8px;padding:9px 10px;background:#edf4f6;color:#365161;font-size:12px}.sun-cloud-v2-status[data-kind="error"]{background:#fff0ee;color:#943d3d}.sun-cloud-v2-status[data-kind="ready"]{background:#eef8f2;color:#347154}.sun-cloud-v2-status[data-kind="offline"]{background:#fff7e6;color:#8b6b25}.sun-cloud-v2-note{font-size:12px;color:#68717a;margin:6px 0}.sun-cloud-v2-user{font-weight:800;color:#15364c}.sun-cloud-v2-code{font:700 13px/1.2 ui-monospace,monospace;background:#f2f4f5;border-radius:7px;padding:8px;word-break:break-all}.sun-cloud-v2-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.sun-cloud-v2-row label{flex:1;min-width:170px}.sun-cloud-v2-small{font-size:11px;color:#7b8388} + @media(max-width:760px){.sun-cloud-v2-grid{grid-template-columns:1fr}.sun-cloud-v2-grid .wide{grid-column:auto}} + `;document.head.appendChild(style); + } + + function findCloudCard() { + const cards=[...document.querySelectorAll('#enterprise-settings .enterprise-card')]; + return cards.find(card=>(card.querySelector('h2')?.textContent||'').trim()==='Облачная синхронизация') || $('sunCloudV2Card'); + } + + function ensureCloudCard() { + const card=findCloudCard();if(!card)return null; + if(card.id!=='sunCloudV2Card'){card.id='sunCloudV2Card';card.classList.add('wide');renderCloudUI();} + return card; + } + + function renderCloudUI() { + const card=findCloudCard();if(!card)return; + card.id='sunCloudV2Card';card.classList.add('wide'); + const connected=Boolean(config.url&&config.key); + const signed=Boolean(session?.user); + const selected=Boolean(workspace); + card.innerHTML=`

    Профиль и аккаунт

    Вход, выход, рабочая компания и пользователи компании.

    +
    + ${connected&&!signed?`

    Войти в профиль

    `:''} + ${!connected?`

    Подключение недоступно

    Техническое подключение настраивается только из кабинета разработчика.

    `:''} + ${signed?`

    Профиль

    ${esc(session.user.email||session.user.id)}
    `:''} + ${signed&&!selected?`

    Рабочая компания

    ${membershipsLoading?'Загружаю доступные рабочие базы…':'У этого аккаунта пока нет доступной рабочей базы.'}

    `:''} + ${signed&&selected?`

    Рабочая компания

    ${esc(workspace.name)}${supportMode?'· поддержка, только просмотр':''}
    Роль: ${supportMode?'Разработчик · поддержка':esc(roleLabel(workspace.role))}${workspace.display_name?` · ${esc(workspace.display_name)}`:''}
    ${!supportMode&&memberships.length>1?``:''}
    `:''} + ${signed&&selected&&!supportMode&&hasPermission('users.manage')?`

    Пользователи

    Управление сотрудниками и правами внутри этой компании.

    `:''} +
    ${esc(statusText())}
    `; + bindCloudUI(); + } + + function bindCloudUI() { + $('sunCloudConnectV2')?.addEventListener('click', async()=>{ + config.url=String($('sunCloudUrlV2')?.value||'').trim().replace(/\/$/,'');config.key=String($('sunCloudKeyV2')?.value||'').trim();saveConfig(); + if(!config.url||!config.key)return toast('Введите Project URL и Publishable key.','warn'); + client=null;session=null;workspace=null;memberships=[];await connectFromConfig(false);renderCloudUI(); + }); + $('sunCloudSignInV2')?.addEventListener('click',()=>signIn(false)); + $('sunCloudSignUpV2')?.addEventListener('click',()=>signIn(true)); + $('sunCloudSignOutV2')?.addEventListener('click',signOutAndReload); + $('sunCloudCreateWorkspaceV2')?.addEventListener('click',createWorkspace); + $('sunCloudReloadMembershipsV2')?.addEventListener('click',async()=>{const tenantSwitched=await loadMemberships();if(tenantSwitched){location.reload();return;}renderCloudUI();if(workspace){subscribeRealtime();await pullRemote({replace:true,quiet:true});}}); + $('sunCloudMigrateV2')?.addEventListener('click',migrateLocalToCloud); + $('sunCloudSyncNowV2')?.addEventListener('click',()=>syncNow({quiet:false})); + $('sunCloudPullV2')?.addEventListener('click',async()=>{if(confirm('Заменить локальную рабочую копию актуальными данными из облака? Перед загрузкой будет создана резервная копия.'))await pullRemote({replace:true,quiet:false});}); + $('sunCloudAutoV2')?.addEventListener('change',e=>{config.autoSync=e.target.checked;saveConfig();if(config.autoSync)scheduleSync('автосинхронизация включена');renderCloudUI();}); + $('sunCloudWorkspaceSelectV2')?.addEventListener('change',async e=>{const next=String(e.target.value||'');config.workspaceId=next;saveConfig();const switched=await switchTenantLocal(next);if(switched.changed){location.reload();return;}selectWorkspace();subscribeRealtime();renderCloudUI();if(workspaceMigrated())await syncNow({quiet:true});else await pullRemote({replace:true,quiet:true});}); + $('sunCloudManageUsersV3')?.addEventListener('click',()=>window.dispatchEvent(new CustomEvent('sun:open-cloud-users'))); + } + + async function loadSupabaseLibrary() { + if(window.supabase?.createClient)return; + const existing=$('sunSupabaseJsV2'); + if(existing){for(let i=0;i<80&&!window.supabase?.createClient;i++)await sleep(100);if(window.supabase?.createClient)return;} + await new Promise((resolve,reject)=>{ + const script=document.createElement('script');script.id='sunSupabaseJsV2';script.src=SUPABASE_JS;script.async=true;script.onload=resolve;script.onerror=()=>reject(new Error('Не удалось загрузить библиотеку Supabase. Проверьте интернет.'));document.head.appendChild(script); + }); + if(!window.supabase?.createClient)throw new Error('Supabase SDK не загрузился.'); + } + + async function connectFromConfig(silent=true) { + if(!config.url||!config.key){setStatus('local','Облако не подключено.');return;} + setStatus('syncing','Подключаю Supabase…'); + try{ + await loadSupabaseLibrary(); + const signature=`${config.url}|${config.key}`; + if(!client||signature!==clientSignature){ + client=window.supabase.createClient(config.url,config.key,{auth:{persistSession:true,autoRefreshToken:true,detectSessionInUrl:true}});clientSignature=signature; + client.auth.onAuthStateChange((_event,next)=>{session=next;membershipsLoading=Boolean(next?.user);membershipsLoaded=!next?.user;setTimeout(async()=>{try{if(!next?.user){const sw=await switchTenantLocal('');if(sw.changed){location.reload();return;}}else{const sw=await loadMemberships();if(sw){location.reload();return;}}}finally{renderCloudUI();updatePill();}},0);}); + } + const {data,error}=await client.auth.getSession();if(error)throw error;session=data.session||null; + const tenantSwitched=session?await loadMemberships():(await switchTenantLocal('')).changed; + if(tenantSwitched){location.reload();return;} + if(session&&workspace){ + subscribeRealtime(); + if(workspaceMigrated())await syncNow({quiet:true}); + else { + const remote=await fetchRemoteRow(); + if(remote&&!payloadEmpty(remote.payload)){ + await backupBeforeCloud('Перед автоматическим подключением к существующей облачной базе'); + await applyPayload(remote.payload,true);await setBaseline(remote);config.migrated[workspace.id]=true;config.lastSync=remote.updated_at||new Date().toISOString();saveConfig();dirty=false;setStatus('ready','Подключена существующая облачная база.'); + } else setStatus('setup','Готово к первому переносу данных.'); + } + } else if(session)setStatus('setup',membershipsLoading?'Загружаю рабочую базу…':'Для этого аккаунта рабочая база не найдена.'); + else setStatus('auth','Подключено. Войдите в аккаунт.'); + if(!silent)toast('Supabase подключён.','success'); + }catch(error){handleError(error,'Не удалось подключиться к Supabase.');} + finally{renderCloudUI();} + } + + async function signIn(signUp) { + const email=String($('sunCloudEmailV2')?.value||'').trim();const password=String($('sunCloudPasswordV2')?.value||''); + if(!email||password.length<6)return toast('Введите email и пароль не короче 6 символов.','warn'); + setStatus('syncing',signUp?'Создаю аккаунт…':'Выполняю вход…'); + try{ + const result=signUp?await client.auth.signUp({email,password,options:{data:{registration_source:'caterium_public_signup'}}}):await client.auth.signInWithPassword({email,password}); + if(result.error)throw result.error;session=result.data.session||null; + if(signUp&&!session){const retry=await client.auth.signInWithPassword({email,password});if(retry.error)throw retry.error;session=retry.data.session||null;} + {const tenantSwitched=await loadMemberships();if(tenantSwitched){location.reload();return;}setStatus('setup','Вход выполнен.');toast('Вход выполнен.','success');} + renderCloudUI(); + }catch(error){handleError(error,signUp?'Не удалось создать аккаунт.':'Не удалось войти.');} + } + + async function loadMemberships() { + memberships=[];workspace=null;membershipsLoaded=false; + if(!client||!session?.user)return false; + membershipsLoading=true; + try{ + let rows=[]; + const rpc=await client.rpc('sun_my_workspaces'); + if(!rpc.error&&Array.isArray(rpc.data)){ + rows=rpc.data.map(x=>({workspace_id:x.id,name:x.name,role:x.role,display_name:x.display_name,is_active:x.is_active,permissions:x.permissions})); + }else{ + // Compatibility fallback for an older server schema. + const direct=await client.from('sun_workspace_members').select('workspace_id,role,display_name,is_active,permissions').eq('user_id',session.user.id); + if(direct.error)throw rpc.error||direct.error; + const active=(direct.data||[]).filter(x=>x.is_active!==false); + if(active.length){ + const ids=active.map(x=>x.workspace_id), names={}; + const w=await client.from('sun_workspaces').select('id,name').in('id',ids);if(w.error)throw w.error;(w.data||[]).forEach(x=>names[x.id]=x.name); + rows=active.map(x=>({...x,name:names[x.workspace_id]||'Рабочая база'})); + } + } + memberships=rows.filter(x=>x.is_active!==false).map(x=>({id:x.workspace_id,name:x.name||'Рабочая база',role:x.role,display_name:x.display_name||'',is_active:x.is_active!==false,permissions:x.permissions||{}})); + if(memberships.length&&!memberships.some(x=>x.id===config.workspaceId))config.workspaceId=memberships[0].id; + if(!memberships.length)config.workspaceId=''; + saveConfig();selectWorkspace(); + const switched=await switchTenantLocal(workspace?.id||''); + membershipsLoaded=true; + if(switched.changed)return true; + try{window.dispatchEvent(new CustomEvent('sun:cloud-permissions-changed',{detail:{workspace:clone(workspace),user:clone(session.user)}}));}catch(_){} + return false; + }finally{membershipsLoading=false;} + } + + function selectWorkspace(){workspace=memberships.find(x=>x.id===config.workspaceId)||memberships[0]||null;if(workspace&&config.workspaceId!==workspace.id){config.workspaceId=workspace.id;saveConfig();}} + + async function createTrialWorkspace(companyName='Новая компания'){ + if(!client||!session?.user)throw new Error('Сначала войдите в аккаунт.'); + const name=String(companyName||'').trim()||'Новая компания'; + await loadMemberships(); + if(workspace)return clone(workspace); + setStatus('syncing','Создаю компанию и включаю 14-дневный Полный тариф…'); + const {data,error}=await client.rpc('sun_create_workspace',{p_name:name});if(error)throw error; + const newId=String(data||'');if(!newId)throw new Error('Сервер не вернул ID новой компании.'); + await switchTenantLocal(newId); + seedOfficialCatalogForFreshWorkspace(); + try{localStorage.setItem('sunCompanyProfileV1',JSON.stringify({name,createdAt:new Date().toISOString()}));}catch(_){} + await adoptCurrentLocalAsWorkspace(newId); + await loadMemberships(); + if(!workspace||String(workspace.id)!==newId){config.workspaceId=newId;saveConfig();selectWorkspace();} + if(!workspace)throw new Error('Компания создана, но не появилась в списке доступных компаний.'); + const remote=await fetchRemoteRow(); + const localPayload=await collectLocalPayload(); + const row=await upsertPayload(localPayload,remote?.revision??null); + await setBaseline(row); + config.migrated[newId]=true;config.lastSync=row?.updated_at||new Date().toISOString();config.autoSync=true;saveConfig();dirty=false; + try{await client.rpc('sun_v17_mirror_legacy',{p_workspace:newId,p_payload:localPayload,p_client_id:clientId()});}catch(_){} + subscribeRealtime(); + setStatus('ready','Компания создана · Полный тариф бесплатно 14 дней.'); + renderCloudUI(); + return clone(workspace); + } + + async function createWorkspace(){ + const name=String($('sunCloudWorkspaceNameV2')?.value||'Новая компания').trim()||'Новая компания'; + try{await createTrialWorkspace(name);toast('Компания создана: Полный тариф на 14 дней, каталог готов, заказы и клиенты пустые.','success',7000);} + catch(error){handleError(error,'Не удалось создать рабочую базу.');} + } + + async function acceptInviteToken(rawToken){ + const token=String(rawToken||'').trim();if(!token)throw new Error('Приглашение не найдено.'); + if(!client||!session?.user)throw new Error('Сначала войдите в Caterium.'); + setStatus('syncing','Подключаю компанию по приглашению…'); + const {data,error}=await client.rpc('sun_accept_invite',{p_token:token});if(error)throw error; + config.workspaceId=String(data||'');saveConfig(); + await loadMemberships(); + subscribeRealtime(); + const remote=await fetchRemoteRow(); + if(remote&&!payloadEmpty(remote.payload)){ + await backupBeforeCloud('Перед подключением к компании по приглашению'); + await applyPayload(remote.payload,true);await setBaseline(remote); + config.migrated[workspace.id]=true;config.lastSync=remote.updated_at||new Date().toISOString();saveConfig(); + } + setStatus('ready','Вы присоединились к компании.');renderCloudUI(); + try{window.dispatchEvent(new CustomEvent('sun:cloud-permissions-changed',{detail:{workspace:clone(workspace)}}));}catch(_){} + return clone(workspace); + } + + async function joinWorkspace(){ + const token=String($('sunCloudInviteCodeV2')?.value||'').trim();if(!token)return toast('Введите код приглашения.','warn'); + try{await acceptInviteToken(token);toast('Вы присоединились к компании.','success',6000);} + catch(error){handleError(error,'Не удалось принять приглашение. Возможно, ссылка истекла или уже использована.');} + } + + async function createInvite(){ + const role=String($('sunCloudInviteRoleV2')?.value||'manager'); + try{const {data,error}=await client.rpc('sun_create_invite',{p_workspace:workspace.id,p_role:role});if(error)throw error;const token=String(data||'');const host=$('sunCloudInviteResultV2');if(host)host.innerHTML=`

    Передайте сотруднику этот одноразовый код (действует 7 дней):

    ${esc(token)}
    `;$('sunCloudCopyInviteV2')?.addEventListener('click',async()=>{try{await navigator.clipboard.writeText(token);toast('Код скопирован.','success');}catch(_){toast('Скопируйте код вручную.','warn');}});} + catch(error){handleError(error,'Не удалось создать приглашение.');} + } + + function unsubscribeRealtime(){if(realtimeChannel&&client){try{client.removeChannel(realtimeChannel);}catch(_){}}realtimeChannel=null;} + function subscribeRealtime(){ + unsubscribeRealtime();if(!client||!session||!workspace?.id)return; + realtimeChannel=client.channel(`sun-state-${workspace.id}-${clientId()}`).on('postgres_changes',{event:'INSERT',schema:'public',table:'sun_sync_events',filter:`workspace_id=eq.${workspace.id}`},async payload=>{ + if(payload?.new?.client_id===clientId())return; + try{const row=await fetchRemoteRow();if(row)await handleRealtimeRow(row);}catch(error){handleError(error,'Не удалось получить изменения с другого устройства.');} + }).subscribe(status=>{if(status==='CHANNEL_ERROR')setStatus('error','Realtime не подключился. Обычная синхронизация продолжит работать.');}); + } + + function handleError(error, message) { + console.error('[SunCloudV2]',error);const detail=String(error?.message||error||'Неизвестная ошибка');setStatus('error',`${message} ${detail}`);toast(`${message} ${detail}`,'error',7500);renderCloudUI(); + } + + function observeSettings() { + const settingsRoot=$('enterprise-settings');if(settingsRoot){const observer=new MutationObserver(()=>{if(findCloudCard()&&!$('sunCloudV2Card'))ensureCloudCard();});observer.observe(settingsRoot,{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==='Настройки')setTimeout(()=>{ensureCloudCard();renderCloudUI();},80);},true); + setTimeout(()=>{ensureCloudCard();renderCloudUI();},250); + } + + function hookEnterpriseSchedule() { + const timer=setInterval(()=>{if(window.SunEnterprise){window.SunEnterprise.scheduleCloudPush=()=>scheduleSync('сохранение приложения');clearInterval(timer);}},250); + setTimeout(()=>clearInterval(timer),10000); + } + + window.addEventListener('online',()=>{setStatus('pending','Интернет восстановлен. Синхронизирую…');if(workspaceMigrated())syncNow({quiet:true});}); + window.addEventListener('offline',()=>setStatus('offline','Офлайн — изменения сохраняются на этом устройстве.')); + + + async function enterSupportWorkspace(workspaceId,name='Компания') { + const id=String(workspaceId||'').trim();if(!id)throw new Error('Не выбрана компания.'); + if(!client||!session?.user)throw new Error('Сначала войдите в аккаунт разработчика.'); + const admin=await client.rpc('sun_is_platform_admin');if(admin.error)throw admin.error;if(admin.data!==true)throw new Error('Требуется аккаунт разработчика.'); + const previousWorkspaceId=String(config.workspaceId||''); + const previousLocalWorkspaceId=String(config.localWorkspaceId||workspace?.id||''); + if(previousLocalWorkspaceId)await captureTenantLocal(previousLocalWorkspaceId); + clearTenantLocal();config.localWorkspaceId='';saveConfig();unsubscribeRealtime(); + const result=await client.rpc('sun_dev_support_snapshot',{p_workspace:id});if(result.error)throw result.error; + const row=Array.isArray(result.data)?result.data[0]:result.data;if(!row?.payload)throw new Error('У компании нет облачной рабочей копии.'); + await applyPayload(row.payload,true); + supportMode={workspaceId:id,name:String(name||'Компания'),previousWorkspaceId,previousLocalWorkspaceId,enteredAt:new Date().toISOString()}; + workspace={id,name:String(name||'Компания'),role:'platform_support',display_name:'Разработчик · поддержка',is_active:true,permissions:{}}; + dirty=false;setStatus('ready',`Режим поддержки · ${name||'Компания'} · только просмотр`); + try{window.dispatchEvent(new CustomEvent('sun:cloud-permissions-changed',{detail:{workspace:clone(workspace),support:true}}));window.dispatchEvent(new CustomEvent('sun:cloud-state-applied',{detail:{workspaceId:id,support:true}}));}catch(_){} + renderCloudUI();return clone(supportMode); + } + + async function exitSupportWorkspace() { + if(!supportMode)return false; + clearTenantLocal();supportMode=null;workspace=null;config.localWorkspaceId='';saveConfig(); + const switched=await loadMemberships();renderCloudUI(); + if(switched||workspace){location.reload();return true;} + return true; + } + + async function setTechnicalConnection(url,key) { + const admin=client&&session?.user?await client.rpc('sun_is_platform_admin'):null; + if(!admin||admin.error||admin.data!==true)throw new Error('Техническое подключение доступно только разработчику.'); + config.url=String(url||'').trim().replace(/\/$/,'');config.key=String(key||'').trim();saveConfig(); + if(!config.url||!config.key)throw new Error('Введите Project URL и Publishable key.'); + client=null;clientSignature='';session=null;workspace=null;memberships=[];supportMode=null; + await connectFromConfig(false);return true; + } + + function setAutoSyncDeveloper(enabled){config.autoSync=enabled!==false;saveConfig();if(config.autoSync&&workspace&&!supportMode)scheduleSync('автосинхронизация включена');renderCloudUI();return config.autoSync;} + window.SunCloudV2={ + VERSION, + signOut:signOutAndReload, + adoptCurrentLocalAsWorkspace, + createTrialWorkspace, + acceptInvite:acceptInviteToken, + syncNow:()=>syncNow({quiet:false}), + pull:()=>pullRemote({replace:true,quiet:false}), + migrate:migrateLocalToCloud, + hasPermission, + roleLabel, + getClient:()=>client, + getSession:()=>session?clone(session):null, + getWorkspace:()=>workspace?clone(workspace):null, + getClientId:()=>clientId(), + enterSupportWorkspace, + exitSupportWorkspace, + isSupportMode:()=>Boolean(supportMode), + getSupportMode:()=>supportMode?clone(supportMode):null, + getTechnicalConfig:()=>({url:config.url,key:config.key,autoSync:config.autoSync,lastSync:config.lastSync,workspaceId:config.workspaceId,localWorkspaceId:config.localWorkspaceId}), + setTechnicalConnection, + setAutoSyncDeveloper, + snapshotLocal:()=>collectLocalPayload(), + reloadMemberships:async()=>{await loadMemberships();renderCloudUI();return workspace?clone(workspace):null;}, + status:()=>({connected:Boolean(client),signedIn:Boolean(session),supportMode:supportMode?clone(supportMode):null,workspace:workspace?clone(workspace):null,membershipsLoading,membershipsLoaded,membershipCount:memberships.length,dirty,lastStatus,lastError}) + }; + + async function boot(){ + injectStyles();disableLegacyCloud();installStorageTracking();hookEnterpriseSchedule();observeSettings();updatePill(); + if(config.url&&config.key)await connectFromConfig(true);else setStatus('local','Облако не подключено. Откройте Настройки → Облако Supabase.'); + } + + if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',boot,{once:true});else boot(); +})(); +; + +/* ===== MODULE: admin-rbac-v3.js ===== */ +(() => { + 'use strict'; + if (window.SunAdminRBACV3) return; + + const VERSION = '2026.09.06-admin-rbac-v7-granular-admins'; + const $ = id => document.getElementById(id); + const qa = (s,r=document) => [...r.querySelectorAll(s)]; + const esc=window.SunSafe.escapeHTML; + const toast = (text,type='info',timeout=4500) => { + try { if (window.SunEnterprise?.toast) return window.SunEnterprise.toast(text,type,timeout); } catch (_) {} + alert(text); + }; + + const ROLE_LABELS = {admin:'Администратор',manager:'Менеджер',kitchen:'Кухня',courier:'Курьер',viewer:'Только просмотр'}; + const GROUPS = [ + {title:'Заказы', items:[['orders.view','Просмотр заказов'],['orders.create','Создание заказов'],['orders.edit','Изменение заказов'],['orders.delete','Удаление заказов'],['calendar.view','Календарь'],['map.view','Карта']]}, + {title:'Клиенты и каталог', items:[['clients.view','Просмотр клиентов'],['clients.edit','Изменение клиентов'],['catalog.view','Просмотр каталога'],['catalog.edit','Изменение каталога']]}, + {title:'Производство и доставка', items:[['production.view','Просмотр производства'],['production.edit','Изменение производства'],['shopping.view','Просмотр закупок'],['shopping.edit','Изменение закупок'],['stock.view','Просмотр склада'],['stock.edit','Изменение склада'],['routes.view','Просмотр маршрутов'],['routes.edit','Изменение маршрутов']]}, + {title:'Управление', items:[['mailings.view','Просмотр рассылок'],['mailings.edit','Изменение рассылок'],['money.view','Просмотр денег'],['money.edit','Изменение денег'],['stats.view','Статистика'],['team.view','Просмотр команды'],['team.edit','Изменение команды'],['suppliers.view','Просмотр поставщиков'],['suppliers.edit','Изменение поставщиков'],['print.view','Печать']]}, + {title:'Администрирование', items:[['settings.view','Просмотр настроек'],['settings.edit','Изменение настроек'],['users.manage','Пользователи и права'],['audit.view','История изменений'],['backups.manage','Резервные копии']]} + ]; + + const PRESETS = { + admin: Object.fromEntries(GROUPS.flatMap(g=>g.items).map(([k])=>[k,true])), + manager: { + 'orders.view':true,'orders.create':true,'orders.edit':true,'orders.delete':false,'calendar.view':true,'map.view':true, + 'clients.view':true,'clients.edit':true,'catalog.view':true,'catalog.edit':false, + 'production.view':false,'production.edit':false,'shopping.view':false,'shopping.edit':false,'stock.view':false,'stock.edit':false,'routes.view':true,'routes.edit':false, + 'mailings.view':true,'mailings.edit':true,'money.view':false,'money.edit':false,'stats.view':true,'team.view':true,'team.edit':false,'suppliers.view':false,'suppliers.edit':false,'print.view':true, + 'settings.view':false,'settings.edit':false,'users.manage':false,'audit.view':false,'backups.manage':false + }, + kitchen: { + 'orders.view':true,'orders.create':false,'orders.edit':false,'orders.delete':false,'calendar.view':true,'map.view':false, + 'clients.view':false,'clients.edit':false,'catalog.view':true,'catalog.edit':false, + 'production.view':true,'production.edit':true,'shopping.view':true,'shopping.edit':false,'stock.view':true,'stock.edit':true,'routes.view':false,'routes.edit':false, + 'mailings.view':false,'mailings.edit':false,'money.view':false,'money.edit':false,'stats.view':false,'team.view':true,'team.edit':false,'suppliers.view':false,'suppliers.edit':false,'print.view':true, + 'settings.view':false,'settings.edit':false,'users.manage':false,'audit.view':false,'backups.manage':false + }, + courier: { + 'orders.view':true,'orders.create':false,'orders.edit':false,'orders.delete':false,'calendar.view':true,'map.view':true, + 'clients.view':false,'clients.edit':false,'catalog.view':false,'catalog.edit':false, + 'production.view':false,'production.edit':false,'shopping.view':false,'shopping.edit':false,'stock.view':false,'stock.edit':false,'routes.view':true,'routes.edit':true, + 'mailings.view':false,'mailings.edit':false,'money.view':false,'money.edit':false,'stats.view':false,'team.view':false,'team.edit':false,'suppliers.view':false,'suppliers.edit':false,'print.view':false, + 'settings.view':false,'settings.edit':false,'users.manage':false,'audit.view':false,'backups.manage':false + }, + viewer: { + 'orders.view':true,'orders.create':false,'orders.edit':false,'orders.delete':false,'calendar.view':true,'map.view':true, + 'clients.view':true,'clients.edit':false,'catalog.view':true,'catalog.edit':false, + 'production.view':false,'production.edit':false,'shopping.view':false,'shopping.edit':false,'stock.view':false,'stock.edit':false,'routes.view':false,'routes.edit':false, + 'mailings.view':false,'mailings.edit':false,'money.view':false,'money.edit':false,'stats.view':true,'team.view':false,'team.edit':false,'suppliers.view':false,'suppliers.edit':false,'print.view':true, + 'settings.view':false,'settings.edit':false,'users.manage':false,'audit.view':false,'backups.manage':false + } + }; + + let members = []; + let editing = null; + let inviting = false; + let applying = false; + let lastWorkspaceId = ''; + + function cloud(){ return window.SunCloudV2 || null; } + function workspace(){ return cloud()?.getWorkspace?.() || null; } + function session(){ return cloud()?.getSession?.() || null; } + function client(){ return cloud()?.getClient?.() || null; } + function has(p){ const c=cloud(); return c?.hasPermission ? c.hasPermission(p) : false; } + function currentPermissions(){ return workspace()?.permissions || {}; } + + function normalizeLegacyLocalAuth(){ + try{ + const raw=localStorage.getItem('sunEnterpriseSettingsV1'); + if(!raw)return; + const s=JSON.parse(raw)||{}; + if(s.rolesEnabled){s.rolesEnabled=false;localStorage.setItem('sunEnterpriseSettingsV1',JSON.stringify(s));} + }catch(_){} + } + + function installStyle(){ + if($('sun-admin-rbac-style'))return; + const style=document.createElement('style');style.id='sun-admin-rbac-style';style.textContent=` + #sun-cloud-users-modal .dialog{max-width:980px;width:min(980px,calc(100vw - 24px));max-height:92vh;overflow:auto} + .sun-rbac-toolbar{display:flex;gap:8px;align-items:center;justify-content:space-between;flex-wrap:wrap;margin:8px 0 12px} + .sun-rbac-members{display:grid;gap:8px}.sun-rbac-member{display:grid;grid-template-columns:minmax(220px,1.4fr) minmax(150px,.7fr) auto;gap:10px;align-items:center;border:1px solid #dfe5e8;border-radius:10px;padding:10px 12px;background:#fff} + .sun-rbac-member small{display:block;color:#75808a;margin-top:3px}.sun-rbac-off{opacity:.58}.sun-rbac-perm-groups{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;margin-top:12px}.sun-rbac-group{border:1px solid #dfe5e8;border-radius:10px;padding:10px;background:#fafcfc}.sun-rbac-group h4{margin:0 0 8px;color:#17384d}.sun-rbac-check{display:flex!important;flex-direction:row!important;align-items:center!important;gap:8px!important;margin:6px 0!important}.sun-rbac-check input{width:17px!important;height:17px!important;flex:0 0 auto}.sun-rbac-role-line{display:grid;grid-template-columns:1.2fr 1fr auto;gap:10px;align-items:end}.sun-rbac-danger{margin-left:auto}.sun-rbac-section-title{display:flex;align-items:center;justify-content:space-between;gap:10px;margin:18px 0 8px}.sun-rbac-section-title h3{margin:0;color:#17384d;font-size:15px}.sun-rbac-invite{display:grid;grid-template-columns:minmax(190px,1.2fr) minmax(130px,.65fr) auto;gap:10px;align-items:center;border:1px dashed #cfd9df;border-radius:10px;padding:10px 12px;background:#fbfcfc}.sun-rbac-invite small{display:block;color:#75808a;margin-top:3px}.sun-rbac-invite-form{border:1px solid #dfe5e8;border-radius:12px;padding:14px;background:#fbfcfc}.sun-rbac-invite-grid{display:grid;grid-template-columns:1.2fr 1.5fr 1fr;gap:10px}.sun-rbac-linkbox{margin-top:12px;padding:10px;border-radius:9px;background:#f1f5f6;word-break:break-all;font:600 12px/1.45 ui-monospace,monospace}.sun-rbac-lock-note{font-size:12px;color:#6e7880;margin-top:6px}.sun-cloud-identity-v3{font-size:12px;color:#adc0cc;padding:3px 7px}.sun-cloud-pin-card{border-top:1px solid #e2e7e9;margin-top:10px;padding-top:10px} + body.sun-cloud-auth-required .view,body.sun-cloud-auth-required header{filter:blur(2px);pointer-events:none;user-select:none}.sun-cloud-auth-gate{position:fixed;inset:0;z-index:10000;background:#132f44eb;display:grid;place-items:center;padding:20px}.sun-cloud-auth-card{width:min(430px,100%);background:#fff;border-radius:16px;padding:22px;box-shadow:0 24px 80px #0005}.sun-cloud-auth-brand{display:flex;align-items:center;gap:12px;margin-bottom:16px}.sun-cloud-auth-brand img{width:58px;height:58px;object-fit:contain}.sun-cloud-auth-card h2{margin:0;color:#17384d}.sun-cloud-auth-card label{display:block;margin:10px 0}.sun-cloud-auth-card input{width:100%}.sun-auth-password{position:relative}.sun-auth-password input{padding-right:46px}.sun-auth-eye{position:absolute;right:7px;bottom:6px;width:34px;height:34px;border:0;background:transparent;border-radius:8px;cursor:pointer;font-size:17px;color:#536674}.sun-auth-eye:hover{background:#eef2f3}.sun-auth-switch{margin:14px 0 0;text-align:center;font-size:13px}.sun-auth-switch button{border:0;background:transparent;color:#245c7c;font-weight:800;cursor:pointer;text-decoration:underline;text-underline-offset:3px}.sun-cloud-auth-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:12px}.sun-cloud-auth-error{color:#9a3838;font-size:12px;margin-top:8px;min-height:18px}.sun-auth-mode-tabs{display:grid;grid-template-columns:1fr 1fr;gap:6px;background:#f2f4f5;border-radius:11px;padding:4px;margin:10px 0 14px}.sun-auth-mode-tabs button{border:0;border-radius:8px;padding:9px 10px;background:transparent;font-weight:800;cursor:pointer}.sun-auth-mode-tabs button.on{background:#fff;box-shadow:0 2px 10px #0001;color:#17384d}.sun-auth-trial{background:#eef8f2;color:#286447;border-radius:10px;padding:10px 11px;font-size:12px;font-weight:700;margin:10px 0}.sun-auth-success{background:#eef8f2;color:#286447;border-radius:10px;padding:10px 11px;font-size:12px;margin-top:10px} + @media(max-width:760px){.sun-rbac-perm-groups{grid-template-columns:1fr}.sun-rbac-member,.sun-rbac-invite{grid-template-columns:1fr}.sun-rbac-role-line,.sun-rbac-invite-grid{grid-template-columns:1fr}} + `;document.head.appendChild(style); + } + + function navPermission(label){ + const map={ + 'Главная':'app.read','Новый заказ':'orders.create','Заказы':'orders.view','Календарь':'calendar.view','Карта':'map.view', + 'Производство':'production.view','Закупки':'shopping.view','Склад':'stock.view','Клиенты':'clients.view','Рассылки':'mailings.view', + 'Деньги':'money.view','Команда':'team.view','Поставщики':'suppliers.view','Статистика':'stats.view','Печать':'print.view','Маршруты':'routes.view','Настройки':'settings.view' + }; + return map[label] || null; + } + + function canSeeSettings(){return has('settings.view')||has('users.manage')||has('backups.manage')||has('audit.view');} + + function applyNavPermissions(){ + const ws=workspace(), ss=session(); + if(!ws||!ss?.user)return; + qa('header nav button').forEach(btn=>{ + const label=String(btn.dataset.navLabel||btn.textContent||'').trim(); + let allowed=true; + if(label==='Настройки')allowed=canSeeSettings(); + else {const perm=navPermission(label);if(perm)allowed=has(perm);} + btn.hidden=!allowed;btn.style.display=allowed?'':'none'; + }); + const label=$('sunCurrentUserLabel'); + if(label)label.textContent=`${ws.display_name||ss.user.email||'Пользователь'} · ${ROLE_LABELS[ws.role]||ws.role}`; + const logout=$('sunLogoutBtn'); + if(logout){logout.style.display='flex';logout.onclick=async()=>{if(window.SunCloudV2?.signOut)return window.SunCloudV2.signOut();try{await client()?.auth.signOut();}catch(_){}location.reload();};} + applyActionPermissions(); + purgeSensitiveLocalData(); + } + + function applyActionPermissions(){ + const del=$('deleteOrderBtn');if(del)del.style.display=(del.style.display==='none'&&!draftIdSafe())?'none':(has('orders.delete')?'inline-block':'none'); + qa('#orders [data-sun-delete-order],#orders button').forEach(b=>{if(/удал/i.test(b.textContent||''))b.style.display=has('orders.delete')?'':'none';}); + const save=qa('#new button').find(b=>(b.textContent||'').trim()==='Сохранить');if(save){const existing=draftIdSafe();save.disabled=existing?!has('orders.edit'):!has('orders.create');save.title=save.disabled?'Недостаточно прав':'';} + qa('#new .catalog-head button').forEach(b=>{if((b.textContent||'').trim()==='Настроить')b.style.display=has('catalog.edit')?'':'none';}); + qa('#manager .primary,#editor .primary,#editor .danger').forEach(b=>{b.disabled=!has('catalog.edit');}); + } + function draftIdSafe(){try{return Boolean(window.draft?.id||draft?.id);}catch(_){return false;}} + + function purgeSensitiveLocalData(){ + const rules=[ + ['sunFinanceRecordsV2',()=>has('money.view')||has('money.edit')], + ['sunAuditLogV1',()=>has('audit.view')], + ['sunSuppliers',()=>has('suppliers.view')||has('suppliers.edit')||has('shopping.view')], + ['sunStockMoves',()=>has('stock.view')||has('stock.edit')||has('shopping.view')||has('production.view')], + ['sunStock',()=>has('stock.view')||has('stock.edit')||has('shopping.view')||has('production.view')], + ['sunEmployees',()=>has('team.view')||has('team.edit')||has('production.view')||has('routes.view')], + ['sunClientCommunicationV1',()=>has('clients.view')||has('clients.edit')||has('orders.create')||has('orders.edit')], + ['sunClientLoyaltyV1',()=>has('clients.view')||has('clients.edit')||has('orders.create')||has('orders.edit')] + ]; + for(const [key,ok] of rules){if(!ok())try{localStorage.removeItem(key);}catch(_){}} + } + + function guardGlobal(name, permission, mode){ + const fn=window[name];if(typeof fn!=='function'||fn.__sunRbacGuard)return; + const wrapped=function(...args){ + let perm=permission; + if(typeof permission==='function')perm=permission(...args); + if(perm&&!has(perm)){toast('У вас нет прав для этого действия.','warn');return;} + return fn.apply(this,args); + }; + wrapped.__sunRbacGuard=true;wrapped.__sunOriginal=fn;window[name]=wrapped; + } + + function installGuards(){ + guardGlobal('saveOrder',()=>draftIdSafe()?'orders.edit':'orders.create'); + guardGlobal('deleteOrder','orders.delete'); + guardGlobal('deleteOrderById','orders.delete'); + guardGlobal('saveBox','catalog.edit'); + guardGlobal('removeBox','catalog.edit'); + guardGlobal('editBox','catalog.edit'); + guardGlobal('writeOffOrderStock','stock.edit'); + guardGlobal('sunEditSupplier','suppliers.edit'); + guardGlobal('editStockProduct','stock.edit'); + } + + function hideLegacyRoleCard(){ + const view=$('enterprise-settings');if(!view)return; + qa('section',view).filter(s=>(s.querySelector(':scope > h2')?.textContent||'').trim()==='Пользователи и роли').forEach(s=>s.remove()); + if(!has('backups.manage'))qa('section',view).filter(s=>(s.querySelector(':scope > h2')?.textContent||'').includes('Резервное копирование')).forEach(s=>s.style.display='none'); + if(!has('audit.view'))qa('section',view).filter(s=>(s.querySelector(':scope > h2')?.textContent||'').includes('История изменений')).forEach(s=>s.style.display='none'); + } + + function ensureModal(){ + if($('sun-cloud-users-modal'))return; + const modal=document.createElement('div');modal.id='sun-cloud-users-modal';modal.className='modal';modal.innerHTML=`

    Пользователи и права

    `;document.body.appendChild(modal); + modal.addEventListener('click',e=>{if(e.target===modal||e.target.closest('[data-rbac-close]'))closeModal();}); + } + function openModal(){ensureModal();$('sun-cloud-users-modal').classList.add('on');loadMembers();} + function closeModal(){$('sun-cloud-users-modal')?.classList.remove('on');editing=null;inviting=false;} + + async function loadMembers(){ + const c=client(),ws=workspace(),body=$('sunRbacBody');if(!c||!ws||!body)return; + body.innerHTML='

    Загружаю сотрудников…

    '; + try{ + const memberRes=await c.rpc('sun_list_workspace_members',{p_workspace:ws.id}); + if(memberRes.error)throw memberRes.error; + members=memberRes.data||[];renderMembers(); + }catch(e){body.innerHTML=`

    ${esc(e.message||e)}

    `;} + } + + async function copyText(value,success='Данные скопированы.'){ + try{await navigator.clipboard.writeText(String(value||''));toast(success,'success');} + catch(_){prompt('Скопируйте данные:',String(value||''));} + } + + function renderMembers(){ + const body=$('sunRbacBody');if(!body)return; + if(editing){renderEditor();return;}if(inviting){renderEmployeeForm();return;} + const active=members.map(m=>`
    ${esc(m.display_name||m.email||'Сотрудник')}${esc(m.email||'')} · ${m.is_active?'активен':'доступ отключён'}
    ${esc(ROLE_LABELS[m.role]||m.role)}
    `).join('')||'

    Сотрудников пока нет.

    '; + body.innerHTML=`
    ${esc(workspace()?.name||'Рабочая база')}
    Создайте сотрудника здесь. Новый сотрудник получит временный пароль для первого входа.

    Сотрудники

    ${members.filter(m=>m.is_active).length}
    ${active}
    `; + $('sunRbacAddUser').onclick=()=>{inviting=true;renderEmployeeForm();};$('sunRbacReload').onclick=loadMembers; + qa('[data-rbac-edit]',body).forEach(b=>b.onclick=()=>{editing=members.find(m=>String(m.user_id)===String(b.dataset.rbacEdit));renderEditor();}); + } + + function renderEmployeeForm(){ + const body=$('sunRbacBody');if(!body)return; + body.innerHTML=`
    Сотруднику не нужна отдельная ссылка-приглашение.

    Добавить сотрудника

    Если аккаунта ещё нет, Caterium создаст его и покажет временный пароль. Если аккаунт уже существует, он просто будет добавлен в эту компанию.

    `; + const back=()=>{inviting=false;renderMembers();};$('sunRbacEmployeeBack').onclick=back;$('sunRbacEmployeeCancel').onclick=back;$('sunRbacCreateEmployee').onclick=createEmployeeV28; + } + + async function createEmployeeV28(){ + const c=client(),ws=workspace(),btn=$('sunRbacCreateEmployee');if(!c||!ws||!btn)return; + const name=String($('sunRbacEmployeeName')?.value||'').trim(),email=String($('sunRbacEmployeeEmail')?.value||'').trim(),role=String($('sunRbacEmployeeRole')?.value||'manager'); + if(!name)return toast('Введите имя сотрудника.','warn'); + if(!email)return toast('Введите email сотрудника.','warn'); + btn.disabled=true;btn.textContent='Создаю…'; + try{ + const prep=await c.rpc('sun_employee_prepare_v28',{p_workspace:ws.id,p_email:email,p_display_name:name,p_role:role});if(prep.error)throw prep.error; + let result=prep.data||{}; + if(result.status==='already_member'){ + toast('Этот пользователь уже добавлен в компанию.','warn');btn.disabled=false;btn.textContent='Создать сотрудника';return; + } + if(result.status==='existing'){ + const fin=await c.rpc('sun_employee_finalize_v28',{p_workspace:ws.id,p_user_id:result.user_id,p_display_name:name,p_role:role});if(fin.error)throw fin.error; + result={...(fin.data||{}),created:false}; + }else{ + const call=await c.functions.invoke('caterium-create-employee',{body:{workspace_id:ws.id,email,display_name:name,role}});if(call.error)throw call.error; + if(call.data?.error)throw new Error(call.data.error); + result=call.data||{}; + if(result.status==='existing'&&result.user_id){ + const fin=await c.rpc('sun_employee_finalize_v28',{p_workspace:ws.id,p_user_id:result.user_id,p_display_name:name,p_role:role});if(fin.error)throw fin.error; + result={...(fin.data||{}),created:false}; + } + } + const host=$('sunRbacEmployeeResult'); + if(result.created&&result.temporary_password){ + const credentials=`Caterium\nEmail: ${email}\nВременный пароль: ${result.temporary_password}`; + host.innerHTML=`
    Сотрудник создан.
    Передайте ему данные для первого входа:

    При первом входе Caterium попросит сотрудника придумать свой новый пароль.

    `; + $('sunRbacCopyEmployee').onclick=()=>copyText(credentials,'Логин и временный пароль скопированы.'); + }else{ + host.innerHTML=`
    Сотрудник добавлен.
    Аккаунт ${esc(email)} уже существовал в Caterium. Сотрудник входит своим обычным паролем.
    `; + } + $('sunRbacEmployeeDone').onclick=async()=>{inviting=false;await loadMembers();}; + toast('Сотрудник добавлен.','success'); + }catch(e){toast(e.message||String(e),'error',7000);btn.disabled=false;btn.textContent='Создать сотрудника';} + } + + function permissionHtml(perms, role){ + return GROUPS.map(g=>`

    ${esc(g.title)}

    ${g.items.map(([key,label])=>{const checked=typeof perms?.[key]==='boolean'?perms[key]:(role==='admin');return ``;}).join('')}
    `).join(''); + } + + function renderEditor(){ + const body=$('sunRbacBody'),m=editing;if(!body||!m)return; + const perms=Object.assign({},PRESETS[m.role]||PRESETS.viewer,m.permissions||{}); + const adminCount=members.filter(x=>x.role==='admin'&&x.is_active).length; + body.innerHTML=`
    ${esc(m.email||'')}
    ${permissionHtml(perms,m.role)}

    Активных администраторов: ${adminCount}. Ограничение срабатывает только если после изменения не останется ни одного активного администратора. Хотя бы у одного активного администратора должно оставаться право «Пользователи и права».

    `; + $('sunRbacBack').onclick=()=>{editing=null;renderMembers();}; + $('sunRbacRole').onchange=()=>{ + const role=$('sunRbacRole').value; + $('sunRbacPerms').innerHTML=permissionHtml(PRESETS[role]||PRESETS.viewer,role); + }; + $('sunRbacSave').onclick=saveMember; + $('sunRbacRemove').onclick=removeMember; + } + + function readEditorPermissions(){ + const out={'app.read':true}; + qa('[data-rbac-perm]',$('sunRbacPerms')).forEach(i=>out[i.dataset.rbacPerm]=i.checked); + return out; + } + + async function saveMember(){ + const c=client(),ws=workspace(),m=editing;if(!c||!ws||!m)return; + const btn=$('sunRbacSave');btn.disabled=true; + try{ + const {error}=await c.rpc('sun_admin_update_member',{p_workspace:ws.id,p_user:m.user_id,p_display_name:$('sunRbacName').value.trim(),p_role:$('sunRbacRole').value,p_is_active:$('sunRbacActive').checked,p_permissions:readEditorPermissions()}); + if(error)throw error; + toast('Права пользователя сохранены.','success');editing=null; + await cloud()?.reloadMemberships?.(); + applyNavPermissions(); + if(has('users.manage'))await loadMembers();else closeModal(); + }catch(e){toast(e.message||String(e),'error',7000);}finally{if(btn)btn.disabled=false;} + } + + async function removeMember(){ + const c=client(),ws=workspace(),m=editing;if(!c||!ws||!m)return; + if(!confirm(`Удалить доступ пользователю ${m.display_name||m.email||''}?`))return; + try{const {error}=await c.rpc('sun_admin_remove_member',{p_workspace:ws.id,p_user:m.user_id});if(error)throw error;toast('Доступ удалён.','success');editing=null;await cloud()?.reloadMemberships?.();applyNavPermissions();if(has('users.manage'))await loadMembers();else closeModal();} + catch(e){toast(e.message||String(e),'error',7000);} + } + + const hex=bytes=>[...bytes].map(x=>x.toString(16).padStart(2,'0')).join(''); + const unhex=value=>new Uint8Array(String(value||'').match(/.{1,2}/g)?.map(x=>parseInt(x,16))||[]); + async function pinDigest(pin,salt){ + const raw=new TextEncoder().encode(String(pin||'')); + if(crypto?.subtle){ + const key=await crypto.subtle.importKey('raw',raw,'PBKDF2',false,['deriveBits']); + const bits=await crypto.subtle.deriveBits({name:'PBKDF2',hash:'SHA-256',salt,iterations:120000},key,256); + return hex(new Uint8Array(bits)); + } + let v=2166136261;for(let n=0;n<5000;n++)for(const b of [...raw,...salt]){v^=b;v=Math.imul(v,16777619);}return String(v>>>0); + } + async function makePinRecord(pin){const salt=crypto.getRandomValues(new Uint8Array(16));return `v1:${hex(salt)}:${await pinDigest(pin,salt)}`;} + async function verifyPin(pin,record){const parts=String(record||'').split(':');if(parts.length!==3||parts[0]!=='v1')return false;const salt=unhex(parts[1]);return (await pinDigest(pin,salt))===parts[2];} + + function pinKey(){const id=session()?.user?.id;return id?`sunCloudQuickPinV3:${id}`:'';} + function pinUnlockKey(){const id=session()?.user?.id;return id?`sunCloudQuickPinUnlockedV3:${id}`:'';} + async function setQuickPin(){ + const id=session()?.user?.id;if(!id)return; + const p1=prompt('Введите новый PIN (4–8 цифр):','');if(p1===null)return;if(!/^\d{4,8}$/.test(p1))return toast('PIN должен содержать 4–8 цифр.','warn'); + const p2=prompt('Повторите PIN:','');if(p1!==p2)return toast('PIN-коды не совпадают.','warn'); + localStorage.setItem(pinKey(),await makePinRecord(p1));sessionStorage.setItem(pinUnlockKey(),'1');toast('Быстрый PIN включён на этом устройстве.','success');injectQuickPinCard(); + } + function removeQuickPin(){const key=pinKey();if(!key)return;localStorage.removeItem(key);sessionStorage.removeItem(pinUnlockKey());toast('Быстрый PIN отключён.','success');injectQuickPinCard();} + function injectQuickPinCard(){ + const card=$('sunCloudV2Card');if(!card||!session()?.user)return; + let box=$('sunCloudQuickPinCardV3');if(!box){box=document.createElement('div');box.id='sunCloudQuickPinCardV3';box.className='sun-cloud-v2-box wide sun-cloud-pin-card';card.appendChild(box);} + const enabled=Boolean(pinKey()&&localStorage.getItem(pinKey())); + box.innerHTML=`

    Быстрый вход на этом устройстве

    Основной аккаунт — email и пароль. PIN только быстро разблокирует уже авторизованное устройство и не меняет облачные права.

    ${enabled?'':''}
    `; + $('sunSetQuickPinV3').onclick=setQuickPin;$('sunRemoveQuickPinV3')?.addEventListener('click',removeQuickPin); + } + + function showPinLockIfNeeded(){ + const ss=session(),ws=workspace();if(!ss?.user||!ws)return; + const key=pinKey(), unlock=pinUnlockKey();if(!key||!localStorage.getItem(key)||sessionStorage.getItem(unlock)==='1'||$('sunCloudPinLockV3'))return; + const overlay=document.createElement('div');overlay.id='sunCloudPinLockV3';overlay.className='sun-cloud-auth-gate';overlay.innerHTML=`
    Caterium

    Caterium

    ${esc(ws.display_name||ss.user.email||'Пользователь')} · ${esc(ROLE_LABELS[ws.role]||ws.role)}
    `;document.body.appendChild(overlay); + const unlockFn=async()=>{const v=$('sunCloudPinInputV3').value;if(await verifyPin(v,localStorage.getItem(key))){sessionStorage.setItem(unlock,'1');overlay.remove();applyNavPermissions();}else $('sunCloudPinErrorV3').textContent='Неверный PIN.';}; + $('sunCloudPinUnlockV3').onclick=unlockFn;$('sunCloudPinInputV3').addEventListener('keydown',e=>{if(e.key==='Enter')unlockFn();});$('sunCloudUsePasswordV3').onclick=async()=>{if(window.SunCloudV2?.signOut)return window.SunCloudV2.signOut();try{await client()?.auth.signOut();}catch(_){}location.reload();};setTimeout(()=>$('sunCloudPinInputV3')?.focus(),50); + } + + const PENDING_REGISTRATION_KEY='sunPendingRegistrationV23'; + let registrationProvisioning=false; + function savePendingRegistration(email,companyName){try{localStorage.setItem(PENDING_REGISTRATION_KEY,JSON.stringify({email:String(email||'').trim().toLowerCase(),companyName:String(companyName||'').trim()||'Новая компания',createdAt:new Date().toISOString()}));}catch(_){}} + function getPendingRegistration(email=''){try{const raw=JSON.parse(localStorage.getItem(PENDING_REGISTRATION_KEY)||'null');if(!raw?.email)return null;if(email&&String(raw.email).toLowerCase()!==String(email).trim().toLowerCase())return null;return raw}catch(_){return null}} + function clearPendingRegistration(){try{localStorage.removeItem(PENDING_REGISTRATION_KEY)}catch(_){}} + async function finishPendingRegistration(gate,email){ + const pending=getPendingRegistration(email);if(!pending||registrationProvisioning)return false; + registrationProvisioning=true;const err=gate?.querySelector('#sunGateErrorV3');if(err)err.textContent='Создаю вашу компанию…'; + try{const ws=await cloud()?.createTrialWorkspace?.(pending.companyName||'Новая компания');if(!ws)throw new Error('Не удалось создать компанию.');clearPendingRegistration();if(err)err.textContent='Готово. Открываю Caterium…';setTimeout(async()=>{gate?.remove();document.body.classList.remove('sun-cloud-auth-required');try{await cloud()?.pull?.()}catch(_){}applyNavPermissions();},350);return true;} + catch(e){if(err)err.textContent=String(e?.message||e||'Не удалось завершить регистрацию.');return false} + finally{registrationProvisioning=false} + } + + const INVITE_QUERY_KEY='invite'; + let invitePreviewCache=null; + let invitePreviewLoading=false; + function currentInviteToken(){try{return String(new URL(location.href).searchParams.get(INVITE_QUERY_KEY)||'').trim()}catch(_){return ''}} + function clearInviteFromUrl(){try{const u=new URL(location.href);u.searchParams.delete(INVITE_QUERY_KEY);history.replaceState(null,'',u.toString())}catch(_){}} + function passwordField(id,label,autocomplete='current-password'){ + return ``; + } + function bindPasswordEyes(root){qa('[data-toggle-password]',root).forEach(b=>b.onclick=()=>{const i=root.querySelector(`#${b.dataset.togglePassword}`);if(!i)return;i.type=i.type==='password'?'text':'password';b.textContent=i.type==='password'?'◉':'×';b.setAttribute('aria-label',i.type==='password'?'Показать пароль':'Скрыть пароль');});} + async function getInvitePreview(token){ + if(!token)return null;if(invitePreviewCache?.token===token)return invitePreviewCache.data;if(invitePreviewLoading)return null; + invitePreviewLoading=true;try{const c=client();if(!c)return null;const {data,error}=await c.rpc('sun_invite_preview_v27',{p_token:token});if(error)throw error;const row=Array.isArray(data)?data[0]:data;invitePreviewCache={token,data:row||null};return row||null;}finally{invitePreviewLoading=false;} + } + async function finishInvite(token,gate){ + const err=gate?.querySelector('#sunGateErrorV3');if(err)err.textContent='Добавляю вас в компанию…'; + try{const ws=await cloud()?.acceptInvite?.(token);if(!ws)throw new Error('Не удалось присоединиться к компании.');clearInviteFromUrl();invitePreviewCache=null;gate?.remove();document.body.classList.remove('sun-cloud-auth-required');try{await cloud()?.pull?.()}catch(_){}applyNavPermissions();toast(`Вы присоединились к компании «${ws.name||'Компания'}».`,'success',6500);return true;} + catch(e){if(err)err.textContent=String(e?.message||e||'Не удалось принять приглашение.');return false;} + } + async function renderInviteGate(gate,token){ + let preview; + try{preview=await getInvitePreview(token);}catch(e){gate.innerHTML=`
    Caterium

    Приглашение недоступно

    Caterium

    ${esc(e?.message||e||'Не удалось проверить приглашение.')}

    `;gate.querySelector('#sunInviteGoHome').onclick=()=>{clearInviteFromUrl();gate.remove();ensureAuthGate();};return;} + if(!preview||!preview.is_valid){gate.innerHTML=`
    Caterium

    Приглашение недействительно

    Ссылка истекла, отменена или уже использована.
    `;gate.querySelector('#sunInviteGoHome').onclick=()=>{clearInviteFromUrl();gate.remove();ensureAuthGate();};return;} + const ss=session(),signedEmail=String(ss?.user?.email||'').toLowerCase(),inviteEmail=String(preview.email||'').toLowerCase(); + if(ss?.user){ + if(signedEmail===inviteEmail){gate.innerHTML=`
    Caterium

    Приглашение в ${esc(preview.workspace_name||'компанию')}

    ${esc(preview.display_name||preview.email||'Сотрудник')} · ${esc(ROLE_LABELS[preview.role]||preview.role)}

    Вы вошли как ${esc(ss.user.email||'')}. Нажмите кнопку, чтобы добавить эту компанию в ваш аккаунт.

    `;gate.querySelector('#sunInviteAcceptSigned').onclick=()=>finishInvite(token,gate);gate.querySelector('#sunInviteSignOut').onclick=async()=>{try{await window.SunCloudV2?.signOut?.()}catch(_){}};return;} + gate.innerHTML=`
    Caterium

    Приглашение в ${esc(preview.workspace_name||'компанию')}

    Для ${esc(preview.email||'другого email')}

    Сейчас вы вошли как ${esc(ss.user.email||'')}. Для принятия приглашения нужно войти под адресом ${esc(preview.email||'')}.

    `;gate.querySelector('#sunInviteSignOut').onclick=async()=>{try{await window.SunCloudV2?.signOut?.()}catch(_){}};return; + } + gate.innerHTML=`
    Caterium

    Вас пригласили в ${esc(preview.workspace_name||'компанию')}

    Роль: ${esc(ROLE_LABELS[preview.role]||preview.role)}
    ${passwordField('sunInvitePasswordV27','Пароль','new-password')}${passwordField('sunInvitePassword2V27','Подтвердите пароль','new-password')}

    Если аккаунт с этим email уже есть — введите его пароль. Если аккаунта ещё нет — придумайте новый пароль и повторите его.

    `; + bindPasswordEyes(gate);const btn=gate.querySelector('#sunInviteJoinV27');const run=async()=>{const c=client(),email=String(preview.email||'').trim(),name=String(gate.querySelector('#sunInviteNameV27')?.value||'').trim(),password=String(gate.querySelector('#sunInvitePasswordV27')?.value||''),password2=String(gate.querySelector('#sunInvitePassword2V27')?.value||''),err=gate.querySelector('#sunGateErrorV3');if(password.length<6){err.textContent='Пароль должен содержать минимум 6 символов.';return;}if(password!==password2){err.textContent='Пароли не совпадают.';return;}btn.disabled=true;err.textContent='Проверяю аккаунт…';try{let r=await c.auth.signInWithPassword({email,password});if(r.error){r=await c.auth.signUp({email,password,options:{data:{name,registration_source:'caterium_invite_signup'}}});if(r.error)throw r.error;if(!r.data.session){const retry=await c.auth.signInWithPassword({email,password});if(retry.error)throw new Error('Этот email уже зарегистрирован. Введите пароль от существующего аккаунта.');r=retry;}}const userId=r.data.user?.id||r.data.session?.user?.id||'';if(userId)sessionStorage.setItem(`sunCloudQuickPinUnlockedV3:${userId}`,'1');err.textContent='Аккаунт готов. Подключаю компанию…';setTimeout(()=>finishInvite(token,gate),180);}catch(e){err.textContent=String(e?.message||e||'Не удалось войти или создать аккаунт.');btn.disabled=false;}};btn.onclick=run;gate.querySelector('#sunInvitePassword2V27').addEventListener('keydown',e=>{if(e.key==='Enter')run()}); + } + + function mustChangePassword(user){return Boolean(user?.user_metadata?.must_change_password);} + + function showPasswordChangeGate(gate,c,user,onDone){ + gate.innerHTML=`
    Caterium

    Создайте новый пароль

    Первый вход сотрудника

    Временный пароль сработал. Теперь придумайте свой постоянный пароль.

    ${passwordField('sunEmployeeNewPasswordV28','Новый пароль','new-password')}${passwordField('sunEmployeeNewPassword2V28','Подтвердите пароль','new-password')}
    `; + bindPasswordEyes(gate); + const run=async()=>{const p1=String(gate.querySelector('#sunEmployeeNewPasswordV28')?.value||''),p2=String(gate.querySelector('#sunEmployeeNewPassword2V28')?.value||''),err=gate.querySelector('#sunGateErrorV3'),btn=gate.querySelector('#sunEmployeeSavePasswordV28');if(p1.length<8){err.textContent='Новый пароль должен содержать минимум 8 символов.';return;}if(p1!==p2){err.textContent='Пароли не совпадают.';return;}btn.disabled=true;err.textContent='Сохраняю пароль…';try{const nextData={...(user?.user_metadata||{}),must_change_password:false};const r=await c.auth.updateUser({password:p1,data:nextData});if(r.error)throw r.error;err.textContent='Пароль сохранён.';setTimeout(()=>onDone?.(),120);}catch(e){err.textContent=String(e?.message||e||'Не удалось изменить пароль.');btn.disabled=false;}}; + gate.querySelector('#sunEmployeeSavePasswordV28').onclick=run;gate.querySelector('#sunEmployeeNewPassword2V28').addEventListener('keydown',e=>{if(e.key==='Enter')run();}); + } + + function ensureAuthGate(){ + const st=cloud()?.status?.();if(!st?.connected)return; + const ws=workspace(),ss=session(),inviteToken=currentInviteToken(); + if(inviteToken){ + let gate=$('sunCloudAuthGateV3');if(!gate){document.body.classList.add('sun-cloud-auth-required');gate=document.createElement('div');gate.id='sunCloudAuthGateV3';gate.className='sun-cloud-auth-gate';gate.innerHTML='

    Проверяю приглашение…

    ';document.body.appendChild(gate);renderInviteGate(gate,inviteToken);}return; + } + if(st.signedIn&&ws){$('sunCloudAuthGateV3')?.remove();document.body.classList.remove('sun-cloud-auth-required');return;} + if($('sunCloudAuthGateV3')){if(st.signedIn&&st.membershipsLoading){const e=$('sunGateErrorV3');if(e)e.textContent='Загружаю рабочую базу…';}return;} + document.body.classList.add('sun-cloud-auth-required'); + const gate=document.createElement('div');gate.id='sunCloudAuthGateV3';gate.className='sun-cloud-auth-gate'; + if(st.signedIn&&ss?.user&&!ws){ + const pending=getPendingRegistration(ss.user.email||''); + gate.innerHTML=`
    Caterium

    ${pending?'Создаю компанию':st.membershipsLoading?'Загружаю рабочую базу':'Аккаунт готов'}

    ${esc(ss.user.email||'Аккаунт авторизован')}

    ${pending?'Регистрация завершена. Сейчас подготовим вашу рабочую компанию.':st.membershipsLoading?'Проверяю доступы этого аккаунта на сервере.':'У этого аккаунта пока нет компании. Укажите название, чтобы создать её.'}

    ${!pending&&!st.membershipsLoading?'':''}
    ${pending?'Подготавливаю компанию…':st.membershipsLoading?'Загружаю рабочую базу…':''}
    `; + document.body.appendChild(gate);if(pending&&!st.membershipsLoading)setTimeout(()=>finishPendingRegistration(gate,ss.user.email||''),80); + gate.querySelector('#sunGateRetryWorkspaceV3').onclick=async()=>{const e=gate.querySelector('#sunGateErrorV3');if(getPendingRegistration(ss.user.email||''))return finishPendingRegistration(gate,ss.user.email||'');if(!st.membershipsLoading){const companyName=String(gate.querySelector('#sunGateRecoveryCompanyV25')?.value||'').trim();if(!companyName){e.textContent='Введите название компании.';return;}savePendingRegistration(ss.user.email||'',companyName);return finishPendingRegistration(gate,ss.user.email||'');}e.textContent='Проверяю доступ…';try{const found=await cloud()?.reloadMemberships?.();if(found){gate.remove();document.body.classList.remove('sun-cloud-auth-required');await cloud()?.pull?.();setTimeout(applyNavPermissions,100);}else e.textContent='Рабочая база для этого аккаунта не найдена.';}catch(err){e.textContent=String(err?.message||err||'Не удалось проверить доступ.');}}; + gate.querySelector('#sunGateSignOutV3').onclick=async()=>{if(window.SunCloudV2?.signOut)return window.SunCloudV2.signOut();try{await client()?.auth.signOut();}catch(_){}location.reload();};return; + } + gate.innerHTML=`
    Caterium

    Вход в Caterium

    Введите данные своего аккаунта
    ${passwordField('sunGatePasswordV3','Пароль','current-password')}

    Нет аккаунта?

    `;document.body.appendChild(gate);bindPasswordEyes(gate); + const emailInput=gate.querySelector('#sunGateEmailV3'),passwordInput=gate.querySelector('#sunGatePasswordV3'),password2Input=gate.querySelector('#sunGatePassword2V27'),companyInput=gate.querySelector('#sunGateCompanyV3'),submitBtn=gate.querySelector('#sunGateSubmitV3'),gateError=gate.querySelector('#sunGateErrorV3');let registerMode=false; + const setMode=(registration)=>{registerMode=Boolean(registration);gate.querySelector('#sunGateRegisterFieldsV27').hidden=!registerMode;gate.querySelector('#sunGateConfirmWrapV27').hidden=!registerMode;gate.querySelector('#sunGateTitleV3').textContent=registerMode?'Создать аккаунт Caterium':'Вход в Caterium';gate.querySelector('#sunGateSubtitleV3').textContent=registerMode?'Заполните данные — после регистрации Caterium откроется автоматически':'Введите данные своего аккаунта';submitBtn.textContent=registerMode?'Создать аккаунт':'Войти';gate.querySelector('#sunGateSwitchTextV27').textContent=registerMode?'Уже есть аккаунт?':'Нет аккаунта?';gate.querySelector('#sunGateSwitchV27').textContent=registerMode?'Войти':'Создать аккаунт';passwordInput.autocomplete=registerMode?'new-password':'current-password';gateError.textContent='';if(registerMode)setTimeout(()=>companyInput.focus(),30);else setTimeout(()=>emailInput.focus(),30)}; + async function auth(){const c=client(),email=emailInput.value.trim(),password=passwordInput.value,password2=password2Input.value,companyName=String(companyInput.value||'').trim();if(!c){gateError.textContent='Облачный сервис не подключён.';return;}if(registerMode&&!companyName){gateError.textContent='Введите название компании.';return;}if(!email||password.length<6){gateError.textContent='Введите email и пароль минимум из 6 символов.';return;}if(registerMode&&password!==password2){gateError.textContent='Пароли не совпадают.';return;}submitBtn.disabled=true;gateError.textContent=registerMode?'Создаю аккаунт…':'Выполняю вход…';try{let r;if(registerMode){savePendingRegistration(email,companyName);const existing=await c.auth.signInWithPassword({email,password});if(!existing.error&&existing.data.session){r=existing;}else{r=await c.auth.signUp({email,password,options:{data:{company_name:companyName,registration_source:'caterium_public_signup'}}});if(r.error)throw r.error;if(!r.data.session){const retry=await c.auth.signInWithPassword({email,password});if(retry.error){clearPendingRegistration();throw new Error('Этот email уже зарегистрирован. Войдите в аккаунт или восстановите пароль.');}r=retry;}}}else r=await c.auth.signInWithPassword({email,password});if(r.error)throw r.error;const authUser=r.data.user||r.data.session?.user||null;const userId=authUser?.id||'';if(userId)sessionStorage.setItem(`sunCloudQuickPinUnlockedV3:${userId}`,'1');if(!registerMode&&mustChangePassword(authUser)){showPasswordChangeGate(gate,c,authUser,()=>{gate.remove();document.body.classList.remove('sun-cloud-auth-required');setTimeout(()=>ensureAuthGate(),80);});return;}gateError.textContent=registerMode?'Аккаунт готов. Создаю компанию…':'Вход выполнен. Загружаю рабочую базу…';setTimeout(async()=>{await cloud()?.reloadMemberships?.();if(registerMode&&workspace()){clearPendingRegistration();gate.remove();document.body.classList.remove('sun-cloud-auth-required');await cloud()?.pull?.();applyNavPermissions();return;}if(getPendingRegistration(email)&&!workspace()){await finishPendingRegistration(gate,email);return;}if(workspace()){gate.remove();document.body.classList.remove('sun-cloud-auth-required');await cloud()?.pull?.();applyNavPermissions();}else{gate.remove();ensureAuthGate();}},180);}catch(err){gateError.textContent=String(err?.message||err||'Не удалось выполнить операцию.')}finally{submitBtn.disabled=false}} + gate.querySelector('#sunGateSwitchV27').onclick=()=>setMode(!registerMode);submitBtn.onclick=auth;passwordInput.addEventListener('keydown',e=>{if(e.key==='Enter'&&!registerMode)auth()});password2Input.addEventListener('keydown',e=>{if(e.key==='Enter'&®isterMode)auth()});setMode(false); + } + + function onCloudChanged(){ + if(applying)return;applying=true; + try{ + const ws=workspace();if(ws?.id!==lastWorkspaceId){lastWorkspaceId=ws?.id||'';} + ensureAuthGate();normalizeLegacyLocalAuth();installGuards();applyNavPermissions();hideLegacyRoleCard();injectQuickPinCard();showPinLockIfNeeded(); + }finally{applying=false;} + } + + window.addEventListener('sun:open-cloud-users',openModal); + window.addEventListener('sun:cloud-permissions-changed',()=>setTimeout(onCloudChanged,0)); + document.addEventListener('click',e=>{const b=e.target.closest('header nav button');if(b&&String(b.dataset.navLabel||b.textContent||'').trim()==='Настройки')setTimeout(()=>{hideLegacyRoleCard();injectQuickPinCard();},120);},true); + (()=>{let queued=false;const run=()=>{if(queued||applying)return;queued=true;setTimeout(()=>{queued=false;if(!applying){applyNavPermissions();hideLegacyRoleCard();}},60);};[document.querySelector('header'),document.getElementById('enterprise-settings')].filter(Boolean).forEach(root=>new MutationObserver(run).observe(root,{childList:true,subtree:true}));})(); + + installStyle();normalizeLegacyLocalAuth();ensureModal(); + let tries=0;const boot=setInterval(()=>{tries++;if(window.SunCloudV2){clearInterval(boot);setTimeout(onCloudChanged,80);}else if(tries>80)clearInterval(boot);},100); + + setInterval(()=>{if(document.hidden)return;try{ensureAuthGate();if(workspace()&&session()?.user){applyNavPermissions();injectQuickPinCard();showPinLockIfNeeded();}}catch(_){}},5000); + + window.SunAdminRBACV3={VERSION,openUsers:openModal,apply:applyNavPermissions,hasPermission:has}; +})(); +; + +/* ===== MODULE: order-payment-ui-v1.js ===== */ +(()=>{ + 'use strict'; + if(window.__sunOrderPaymentUIV1)return; + window.__sunOrderPaymentUIV1=true; + + const KEY='sunOrderPaymentColorsV1'; + const DEFAULTS={ + unpaid:'#d84a4a', + prepaid:'#35a46b', + paid:'#3b82f6', + cancelled:'#9aa0aa' + }; + const LABELS={ + unpaid:'Без предоплаты', + prepaid:'Предоплата внесена', + paid:'Оплачен полностью', + cancelled:'Отменён' + }; + const $=id=>document.getElementById(id); + const hex=value=>/^#[0-9a-f]{6}$/i.test(String(value||''))?String(value).toLowerCase():null; + const readColors=()=>{ + let saved={}; + try{saved=JSON.parse(localStorage.getItem(KEY)||'{}')||{}}catch(_){saved={}} + const next={}; + Object.keys(DEFAULTS).forEach(key=>next[key]=hex(saved[key])||DEFAULTS[key]); + return next; + }; + const saveColors=colors=>{ + const safe={};Object.keys(DEFAULTS).forEach(key=>safe[key]=hex(colors[key])||DEFAULTS[key]); + localStorage.setItem(KEY,JSON.stringify(safe)); + decorateOrders(); + return safe; + }; + const liveOrders=()=>{try{return Array.isArray(orders)?orders:JSON.parse(localStorage.getItem('sunOrders')||'[]')}catch(_){return[]}}; + const byId=id=>liveOrders().find(order=>String(order?.id)===String(id)); + const orderTotal=order=>{ + try{if(typeof window.orderTotalValue==='function'){const value=Number(window.orderTotalValue(order));if(Number.isFinite(value))return Math.max(0,value)}}catch(_){} + const direct=Number(order?.total);return Number.isFinite(direct)?Math.max(0,direct):0; + }; + function paymentState(order){ + if(String(order?.status||'').trim()==='Отменён')return'cancelled'; + const paid=Math.max(0,Number(order?.prepayment||0)); + const total=orderTotal(order); + if(total>0&&paid>=total-0.5)return'paid'; + if(paid>0)return'prepaid'; + return'unpaid'; + } + window.sunOrderPaymentState=paymentState; + window.sunOrderPaymentColors=()=>({...readColors()}); + + function readableText(color){ + const value=hex(color)||'#000000'; + const r=parseInt(value.slice(1,3),16),g=parseInt(value.slice(3,5),16),b=parseInt(value.slice(5,7),16); + return ((r*299+g*587+b*114)/1000)>168?'#252a32':'#ffffff'; + } + function shadowColor(color,alpha=.24){ + const value=hex(color)||'#000000'; + const r=parseInt(value.slice(1,3),16),g=parseInt(value.slice(3,5),16),b=parseInt(value.slice(5,7),16); + return `rgba(${r},${g},${b},${alpha})`; + } + function paintDateTile(tile,order){ + if(!tile||!order)return; + const state=paymentState(order),colors=readColors(),color=colors[state],text=readableText(color); + tile.dataset.paymentState=state; + tile.title=LABELS[state]; + tile.setAttribute('aria-label',`${tile.textContent.trim()}: ${LABELS[state]}`); + tile.style.setProperty('background',color,'important'); + tile.style.setProperty('border-color',color,'important'); + tile.style.setProperty('box-shadow',`0 5px 14px ${shadowColor(color)}`,'important'); + tile.style.setProperty('color',text,'important'); + tile.querySelectorAll('strong,span,small').forEach(node=>node.style.setProperty('color',text,'important')); + } + function decorateOrders(){ + document.querySelectorAll('#ordersList .sun-order-payment-marker,#sunOrderCards .sun-order-payment-marker').forEach(marker=>marker.remove()); + document.querySelectorAll('#ordersList .sun-order-number-cell').forEach(cell=>cell.classList.remove('sun-order-number-cell')); + document.querySelectorAll('#sunOrderCards .sun-order-number-with-payment').forEach(host=>host.classList.remove('sun-order-number-with-payment')); + document.querySelectorAll('#sunOrderCards .sun-order-card').forEach(card=>{ + const order=byId(card.dataset.orderId),tile=card.querySelector('.sun-order-card-date'); + if(order&&tile)paintDateTile(tile,order); + }); + } + let decorateQueued=false; + function queueDecorate(){if(decorateQueued)return;decorateQueued=true;requestAnimationFrame(()=>{decorateQueued=false;decorateOrders()})} + + const priorRenderOrders=window.renderOrders; + if(typeof priorRenderOrders==='function')window.renderOrders=function(...args){const result=priorRenderOrders.apply(this,args);queueDecorate();return result}; + + // One authoritative deletion path for list, cards and the current order form. + window.deleteOrderById=function(id){ + const list=liveOrders(); + const order=list.find(item=>String(item?.id)===String(id)); + if(!order)return false; + if(!confirm(`Удалить заказ №${order.id}${order.event?` «${order.event}»`:''}?`))return false; + try{ + if(Array.isArray(orders)){ + const index=orders.findIndex(item=>String(item?.id)===String(id)); + if(index>=0)orders.splice(index,1); + }else{ + localStorage.setItem('sunOrders',JSON.stringify(list.filter(item=>String(item?.id)!==String(id)))); + } + if(typeof persist==='function')persist(); + else localStorage.setItem('sunOrders',JSON.stringify(Array.isArray(orders)?orders:list.filter(item=>String(item?.id)!==String(id)))); + }catch(error){ + console.error('Order deletion failed',error); + window.SunEnterprise?.toast?.('Не удалось удалить заказ.','error',5000); + return false; + } + try{window.SunEnterprise?.recordAudit?.('Удалён заказ',`№${order.id} · ${order.event||'Заказ'}`)}catch(_){} + const currentId=(()=>{try{return draft?.id}catch(_){return null}})(); + if(String(currentId)===String(id)&&typeof window.resetDraft==='function')window.resetDraft(); + try{window.renderOrders?.()}catch(_){} + try{window.renderStats?.()}catch(_){} + try{window.sunRenderOrderCards?.()}catch(_){} + window.dispatchEvent(new CustomEvent('sun:order-deleted',{detail:{id:order.id}})); + window.SunEnterprise?.toast?.(`Заказ №${order.id} удалён.`,'success'); + return true; + }; + window.deleteOrder=function(){ + const id=(()=>{try{return draft?.id}catch(_){return null}})(); + if(!id)return false; + const deleted=window.deleteOrderById(id); + if(!deleted)return false; + const button=[...document.querySelectorAll('header nav button')].find(btn=>String(btn.dataset.navLabel||btn.textContent||'').trim()==='Заказы'); + if(button)button.click(); + else if(typeof window.show==='function')try{window.show('orders',document.querySelectorAll('header nav button')[1])}catch(_){} + return true; + }; + + function settingsCard(){ + const view=$('enterprise-settings'),grid=view?.querySelector('.enterprise-grid'); + if(!grid)return null; + let card=$('sunOrderPaymentColorsCard'); + if(card)return card; + card=document.createElement('section'); + card.className='enterprise-card';card.id='sunOrderPaymentColorsCard'; + grid.appendChild(card); + renderSettingsCard(card); + return card; + } + function renderSettingsCard(card=settingsCard()){ + if(!card)return; + const colors=readColors(); + card.innerHTML=`

    Цвета оплаты заказов

    Цвет плашки с датой в разделе «Заказы» показывает состояние оплаты. Цвет каждого состояния можно выбрать самостоятельно.

    ${Object.keys(DEFAULTS).map(key=>``).join('')}

    Правила: 0 ₽ предоплаты — «Без предоплаты»; предоплата меньше суммы — «Предоплата внесена»; оплачена вся сумма — «Оплачен полностью»; статус «Отменён» — отдельный цвет.

    `; + card.querySelectorAll('[data-payment-color]').forEach(input=>input.addEventListener('input',()=>{ + const next=readColors();next[input.dataset.paymentColor]=input.value;saveColors(next); + const dot=input.closest('label')?.querySelector('.sun-payment-settings-dot');if(dot)dot.style.background=input.value; + })); + $('sunResetPaymentColors')?.addEventListener('click',()=>{localStorage.setItem(KEY,JSON.stringify(DEFAULTS));renderSettingsCard(card);decorateOrders();window.SunEnterprise?.toast?.('Стандартные цвета восстановлены.','success')}); + } + function ensureSettingsCard(){const card=settingsCard();if(card)renderSettingsCard(card)} + + const style=document.createElement('style');style.id='sun-order-payment-ui-v1-style';style.textContent=` + #orderForm .sun-order-line-price input{width:100%!important;max-width:104px!important;min-width:78px!important;padding:2px 7px!important;font-variant-numeric:tabular-nums!important;letter-spacing:.01em!important} + .sun-payment-color-settings{display:grid;gap:8px;margin-top:10px} + .sun-payment-color-settings label{display:grid;grid-template-columns:minmax(0,1fr) 52px;gap:10px;align-items:center;padding:8px 9px;border:1px solid #e3e6e9;border-radius:9px;background:#fafbfc} + .sun-payment-color-settings label>span{display:flex;align-items:center;gap:8px;color:#3d4652;font-weight:700;font-size:12.5px} + .sun-payment-color-settings input[type="color"]{width:48px;height:32px;padding:2px;border:1px solid #cfd5da;border-radius:7px;background:#fff;cursor:pointer} + .sun-payment-settings-dot{display:inline-block;width:13px;height:13px;border-radius:3px;box-shadow:inset 0 0 0 1px rgba(0,0,0,.12)} + `;document.head.appendChild(style); + + document.addEventListener('click',event=>{ + const nav=event.target.closest('header nav button'); + if(nav&&String(nav.dataset.navLabel||nav.textContent||'').trim()==='Настройки')setTimeout(ensureSettingsCard,60); + },true); + const ordersHost=$('orders');if(ordersHost)new MutationObserver(queueDecorate).observe(ordersHost,{childList:true,subtree:true}); + const settingsHost=$('enterprise-settings');if(settingsHost)new MutationObserver(()=>{if(settingsHost.classList.contains('on'))setTimeout(()=>{if(!$('sunOrderPaymentColorsCard'))ensureSettingsCard()},0)}).observe(settingsHost,{childList:true,subtree:true}); + window.addEventListener('sun:cloud-state-applied',()=>{queueDecorate();if($('enterprise-settings')?.classList.contains('on'))setTimeout(ensureSettingsCard,0)}); + window.addEventListener('storage',event=>{if(event.key===KEY)queueDecorate()}); + queueDecorate(); + setTimeout(()=>{queueDecorate();if($('enterprise-settings')?.classList.contains('on'))ensureSettingsCard()},140); +})(); +; + +/* ===== MODULE: order-crm-v1.js ===== */ +(()=>{ + 'use strict'; + if(window.__sunOrderCrmV1)return;window.__sunOrderCrmV1=true; + const $=id=>document.getElementById(id),qa=(s,r=document)=>[...r.querySelectorAll(s)]; + const esc=window.SunSafe.escapeHTML; + const SOURCE_KEY='sunLeadSourcesV1'; + const DEFAULT_SOURCES=['Заявка с сайта','CaterMe','Avito','Рекомендация','Повторный клиент','Соцсети','Другое']; + const getDraft=()=>{try{return draft}catch(_){return null}}; + const getOrders=()=>{try{return Array.isArray(orders)?orders:[]}catch(_){try{const x=JSON.parse(localStorage.getItem('sunOrders')||'[]');return Array.isArray(x)?x:[]}catch(__){return[]}}}; + const money=v=>typeof window.money==='function'?window.money(v):`${Math.round(Number(v||0)).toLocaleString('ru-RU')} ₽`; + const toast=(m,t='success')=>{try{if(window.SunEnterprise?.toast)return window.SunEnterprise.toast(m,t)}catch(_){} if(t==='warn'||t==='error')console.warn(m)}; + const style=document.createElement('style');style.id='sun-order-crm-v1-style';style.textContent=` + /* The precise-address fields existed in an older module but were hidden by a later polish layer. */ + #details .sun-address-precision-title{display:block!important;grid-column:1/-1;margin:10px 0 0;font-size:13px;font-weight:850;color:#241d45} + #details .sun-delivery-region,#details .sun-delivery-locality,#details .sun-delivery-district{display:flex!important} + #details .sun-delivery-district{grid-column:1/-1!important} + #details .sun-lead-source-field,#details .sun-known-addresses-field{grid-column:1/-1} + #details .sun-client-match{grid-column:1/-1;display:none;padding:9px 11px;border:1px solid #d7d0e7;border-radius:11px;background:#f7f5fb;color:#514b68;font-size:12px;line-height:1.35} + #details .sun-client-match.on{display:block} + #details .sun-geo-confirm{display:none;margin-top:10px;padding:11px;border:1px solid #dfd9eb;border-radius:12px;background:#faf9fd} + #details .sun-geo-confirm.on{display:block} + #details .sun-geo-confirm strong{display:block;color:#241d45;font-size:12px;margin-bottom:5px} + #details .sun-geo-confirm p{margin:0 0 9px;color:#666078;font-size:11px;line-height:1.4} + #details .sun-geo-confirm .actions{display:flex;gap:7px;flex-wrap:wrap;margin:0} + #details .sun-geo-confirm.ok{border-color:#b8dcc4;background:#f3fbf5} + #details .sun-geo-confirm.ok strong{color:#28683d} + .sun-crm-stats-table{width:100%;border-collapse:collapse;font-size:12px} + .sun-crm-stats-table th,.sun-crm-stats-table td{padding:9px 8px;border-bottom:1px solid #ece9f2;text-align:right;white-space:nowrap} + .sun-crm-stats-table th:first-child,.sun-crm-stats-table td:first-child{text-align:left;white-space:normal} + .sun-crm-stats-table th{color:#817a94;font-size:10px;text-transform:uppercase;letter-spacing:.04em} + .sun-crm-stats-table td b{color:#241d45} + .sun-lead-settings-list{display:grid;gap:7px;margin:10px 0} + .sun-lead-settings-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:7px} + @media(max-width:700px){.sun-crm-stats-table{font-size:10px}.sun-crm-stats-table th,.sun-crm-stats-table td{padding:7px 4px}.sun-crm-hide-mobile{display:none}} + `;document.head.appendChild(style); + + function readSources(){ + try{const a=JSON.parse(localStorage.getItem(SOURCE_KEY)||'null');if(Array.isArray(a)){const clean=a.map(x=>String(x||'').trim()).filter(Boolean);if(clean.length)return [...new Set(clean)]}}catch(_){} + return DEFAULT_SOURCES.slice(); + } + function saveSources(list){ + const clean=[...new Set((list||[]).map(x=>String(x||'').trim()).filter(Boolean))]; + localStorage.setItem(SOURCE_KEY,JSON.stringify(clean.length?clean:DEFAULT_SOURCES)); + refreshSourceSelect();renderCrmStats(); + } + function setSelectOptions(select,values,current=''){ + if(!select)return;const cur=String(current||select.value||''); + const all=values.slice();if(cur&&!all.includes(cur))all.push(cur); + select.innerHTML=``+all.map(v=>``).join('');select.value=cur; + } + function ensureSourceField(){ + const grid=document.querySelector('#details .sun-delivery-details');if(!grid)return null; + let select=$('orderLeadSource');if(!select){ + const label=document.createElement('label');label.className='sun-lead-source-field';label.innerHTML='Узнали из'; + const loyalty=$('orderLoyaltyCard')?.closest('label'),phone=$('phone')?.closest('label'); + (loyalty||phone)?.insertAdjacentElement('afterend',label);if(!label.isConnected)window.SunSafe.insertBefore(grid,label,grid.firstChild); + select=label.querySelector('select');select.addEventListener('change',()=>{const d=getDraft();if(d)d.leadSource=select.value}); + } + setSelectOptions(select,readSources(),getDraft()?.leadSource||select.value);return select; + } + function refreshSourceSelect(){const s=ensureSourceField();if(s)setSelectOptions(s,readSources(),getDraft()?.leadSource||s.value)} + + function ensureSettingsCard(){ + const grid=document.querySelector('#enterprise-settings .enterprise-grid');if(!grid)return; + let card=$('sunLeadSourcesSettingsCard'); + if(!card){card=document.createElement('section');card.id='sunLeadSourcesSettingsCard';card.className='enterprise-card';card.innerHTML=`

    Источники клиентов

    Список для поля «Узнали из» в деталях заказа. Эти данные используются в статистике.

    `; + const eventCard=$('sunEventTypesSettingsCard');if(eventCard)eventCard.insertAdjacentElement('afterend',card);else grid.appendChild(card); + $('sunLeadSourceAdd').onclick=()=>addSourceSettingsRow('');$('sunLeadSourcesSave').onclick=()=>saveSources(qa('#sunLeadSourcesRows input').map(x=>x.value)); + } + const rows=$('sunLeadSourcesRows');if(rows&&!rows.dataset.ready){rows.dataset.ready='1';renderSourceSettingsRows()} + } + function addSourceSettingsRow(value=''){ + const rows=$('sunLeadSourcesRows');if(!rows)return;const row=document.createElement('div');row.className='sun-lead-settings-row';row.innerHTML=``;row.querySelector('button').onclick=()=>row.remove();rows.appendChild(row);row.querySelector('input').focus(); + } + function renderSourceSettingsRows(){const rows=$('sunLeadSourcesRows');if(!rows)return;rows.innerHTML='';readSources().forEach(addSourceSettingsRow)} + + function normalizePhone(raw){ + let d=String(raw||'').replace(/\D/g,'');if(d.length===11&&d[0]==='8')d='7'+d.slice(1);else if(d.length===10)d='7'+d;return d; + } + function orderStamp(o){return `${o?.date||''}T${o?.time||''}|${String(o?.id||'').padStart(12,'0')}`} + function structuredAddress(o){ + const keys=['deliveryRegion','deliveryLocality','deliveryDistrict','deliveryStreet','deliveryHouse','deliveryCorpus','deliveryEntrance','deliveryFloor','deliveryApartment','deliveryOffice']; + const data={};keys.forEach(k=>data[k]=String(o?.[k]||'').trim());data.address=String(o?.address||'').trim(); + data.structured=keys.some(k=>data[k]);return data; + } + function profileFromOrders(list){ + const sorted=list.slice().sort((a,b)=>orderStamp(b).localeCompare(orderStamp(a)));if(!sorted.length)return null; + const latest=sorted[0],seen=new Set(),addresses=[]; + sorted.forEach(o=>{const a=structuredAddress(o),key=a.structured?JSON.stringify(a):a.address;if(key&&!seen.has(key)){seen.add(key);addresses.push(a)}}); + return {name:String(latest.contact||''),phone:String(latest.phone||''),ordersCount:sorted.length,latest,addresses}; + } + function profileByPhone(raw){const n=normalizePhone(raw);if(n.length<10)return null;return profileFromOrders(getOrders().filter(o=>normalizePhone(o.phone)===n))} + function readLoyaltyStore(){try{const v=JSON.parse(localStorage.getItem('sunClientLoyaltyV1')||'{}');return v&&typeof v==='object'?v:{}}catch(_){return{}}} + function profileByCard(raw){ + const card=String(raw||'').trim().toLowerCase();if(!card)return null; + let direct=getOrders().filter(o=>String(o.loyaltyCardNumber||o.loyaltyCard||'').trim().toLowerCase()===card);if(direct.length)return profileFromOrders(direct); + const store=readLoyaltyStore(),entry=Object.entries(store).find(([,v])=>String(v?.cardNumber||'').trim().toLowerCase()===card);if(!entry)return null; + const key=entry[0];if(key.startsWith('p:'))return profileByPhone(key.slice(2)); + if(key.startsWith('n:')){const name=key.slice(2).trim().toLowerCase();return profileFromOrders(getOrders().filter(o=>String(o.contact||'').trim().toLowerCase()===name))} + return null; + } + const addrIds=['deliveryRegion','deliveryLocality','deliveryDistrict','deliveryStreet','deliveryHouse','deliveryCorpus','deliveryEntrance','deliveryFloor','deliveryApartment','deliveryOffice']; + function setSelectValue(el,value){if(!el)return;const v=String(value||'');if(v&&el.tagName==='SELECT'&&![...el.options].some(o=>o.value===v))el.add(new Option(v,v));el.value=v} + function composeFullAddress(){ + const val=id=>String($(id)?.value||'').trim();const address=$('address');if(!address)return''; + const parts=[val('deliveryRegion'),val('deliveryLocality'),val('deliveryDistrict'),val('deliveryStreet'),val('deliveryHouse')?`д. ${val('deliveryHouse')}`:'',val('deliveryCorpus')?`корп. ${val('deliveryCorpus')}`:'',val('deliveryEntrance')?`подъезд ${val('deliveryEntrance')}`:'',val('deliveryFloor')?`этаж ${val('deliveryFloor')}`:'',val('deliveryApartment')?`кв. ${val('deliveryApartment')}`:'',val('deliveryOffice')?`офис ${val('deliveryOffice')}`:''].filter(Boolean); + const full=parts.join(', ');address.value=full;const d=getDraft();if(d){addrIds.forEach(id=>d[id]=val(id));d.address=full}return full; + } + window.sunComposePreciseDeliveryAddress=composeFullAddress; + function fillAddress(a){ + if(!a)return;const has=Boolean(a.structured); + addrIds.forEach(id=>{const el=$(id);if(!el)return;const v=has?String(a[id]||''):(id==='deliveryStreet'?String(a.address||''):'');if(id==='deliveryRegion')setSelectValue(el,v);else el.value=v}); + composeFullAddress();resetGeoConfirmation(true); + } + function ensureClientUi(){ + const grid=document.querySelector('#details .sun-delivery-details');if(!grid)return; + if(!$('sunClientMatch')){const box=document.createElement('div');box.id='sunClientMatch';box.className='sun-client-match';const lead=$('orderLeadSource')?.closest('label');(lead||grid.firstElementChild)?.insertAdjacentElement('afterend',box);if(!box.isConnected)window.SunSafe.insertBefore(grid,box,grid.firstChild)} + if(!$('sunKnownAddresses')){const label=document.createElement('label');label.className='sun-known-addresses-field';label.style.display='none';label.innerHTML='Предыдущие адреса клиента';const office=$('deliveryOffice')?.closest('label');office?.insertAdjacentElement('afterend',label);if(!label.isConnected)grid.appendChild(label);label.querySelector('select').onchange=e=>{const a=e.target._addresses?.[Number(e.target.value)];if(a)fillAddress(a)}} + } + function showProfile(profile,reason){ + ensureClientUi();const box=$('sunClientMatch'),known=$('sunKnownAddresses'),knownLabel=known?.closest('label');if(!profile){box?.classList.remove('on');if(knownLabel)knownLabel.style.display='none';return false} + const contact=$('contact'),phone=$('phone');if(contact&&profile.name)contact.value=profile.name;if(phone&&profile.phone)phone.value=profile.phone; + if(profile.addresses[0])fillAddress(profile.addresses[0]); + if(known&&knownLabel){known._addresses=profile.addresses;known.innerHTML=profile.addresses.map((a,i)=>``).join('');knownLabel.style.display=profile.addresses.length>1?'flex':'none'} + if(box){box.innerHTML=`Клиент найден по ${reason}: ${esc(profile.name||profile.phone||'клиент')} · заказов: ${profile.ordersCount}${profile.addresses.length>1?` · сохранено адресов: ${profile.addresses.length}`:''}. Данные заполнены автоматически, адрес можно изменить.`;box.classList.add('on')} + const d=getDraft();if(d){d.contact=contact?.value||'';d.phone=phone?.value||''}return true; + } + let lookupTimer=0; + function schedulePhoneLookup(){clearTimeout(lookupTimer);lookupTimer=setTimeout(()=>{const d=getDraft();if(d?.id)return;const p=profileByPhone($('phone')?.value);if(p)showProfile(p,'телефону')},260)} + function lookupCard(){const d=getDraft();if(d?.id)return;const field=$('orderLoyaltyCard'),p=profileByCard(field?.value);if(p)showProfile(p,'карте лояльности')} + + function totalFor(o){if(Number.isFinite(Number(o?.total))&&Number(o.total)>0)return Number(o.total);try{return typeof window.orderTotalValue==='function'?Number(window.orderTotalValue(o)||0):0}catch(_){return 0}} + function statsPeriodOrders(){ + const period=$('statsPeriod')?.value||'year',now=new Date();now.setHours(12,0,0,0);let from=null,to=null; + if(period==='month'){from=new Date(now.getFullYear(),now.getMonth(),1,12);to=new Date(now.getFullYear(),now.getMonth()+1,1,12)} + else if(period==='90'){from=new Date(now);from.setDate(from.getDate()-89);to=new Date(now);to.setDate(to.getDate()+1)} + else if(period==='year'){from=new Date(now.getFullYear(),now.getMonth()-11,1,12);to=new Date(now.getFullYear(),now.getMonth()+1,1,12)} + return getOrders().filter(o=>{if(String(o.status||'Новый')==='Отменён')return false;if(!from)return true;const d=o.date?new Date(`${o.date}T12:00:00`):null;return d&&!Number.isNaN(d.getTime())&&d>=from&&d{const name=String(keyFn(o)||'').trim()||'Не указано',r=m.get(name)||{name,count:0,sum:0};r.count++;r.sum+=totalFor(o);m.set(name,r)});return [...m.values()].map(r=>({...r,avg:r.count?r.sum/r.count:0,share:list.length?r.count/list.length*100:0})).sort((a,b)=>b.count-a.count||b.sum-a.sum||a.name.localeCompare(b.name,'ru')); + } + function statTable(rows){return rows.length?`
    ${rows.map(r=>``).join('')}
    КатегорияЗаказовДоляВыручкаСредний чек
    ${esc(r.name)}${r.count}${r.share.toLocaleString('ru-RU',{maximumFractionDigits:1})}%${esc(money(r.sum))}${esc(money(r.avg))}
    `:'
    Данных пока нет.
    '} + function renderCrmStats(){ + const grid=$('statsList')?.querySelector('.stats-grid');if(!grid)return;grid.querySelectorAll('.sun-crm-stats-panel').forEach(x=>x.remove());const list=statsPeriodOrders(); + const eventPanel=document.createElement('section');eventPanel.className='stats-panel wide sun-crm-stats-panel';eventPanel.innerHTML=`

    Типы мероприятий

    количество · доля · выручка · средний чек
    ${statTable(groupedStats(list,o=>o.event))}`; + const sourcePanel=document.createElement('section');sourcePanel.className='stats-panel wide sun-crm-stats-panel';sourcePanel.innerHTML=`

    Откуда приходят заказы

    по полю «Узнали из»
    ${statTable(groupedStats(list,o=>o.leadSource))}`; + grid.append(eventPanel,sourcePanel); + } + + function resetGeoConfirmation(clear=false){const d=getDraft();if(d?.geo){if(clear)d.geo=null;else{delete d.geo.confirmed;delete d.geo.confirmedAt}}syncGeoConfirmUi()} + function ensureGeoConfirmUi(){ + const box=$('orderGeoBox');if(!box)return null;let ui=$('sunGeoConfirm');if(!ui){ui=document.createElement('div');ui.id='sunGeoConfirm';ui.className='sun-geo-confirm';ui.innerHTML='Найденный адрес

    ';box.appendChild(ui);$('sunGeoConfirmOk').onclick=()=>{const d=getDraft();if(!d?.geo)return;d.geo.confirmed=true;d.geo.confirmedAt=new Date().toISOString();if(d.id){const saved=getOrders().find(o=>String(o.id)===String(d.id));if(saved)saved.geo=structuredClone(d.geo);try{persist()}catch(_){}}syncGeoConfirmUi();toast('Адрес подтверждён.','success')};$('sunGeoConfirmEdit').onclick=()=>{const d=getDraft();if(d)d.geo=null;const locality=$('deliveryLocality')||$('deliveryStreet');locality?.focus();syncGeoConfirmUi()}} + return ui; + } + function syncGeoConfirmUi(){ + const ui=ensureGeoConfirmUi(),d=getDraft();if(!ui)return;const geo=d?.geo;if(!geo||!Number.isFinite(Number(geo.lat))||!Number.isFinite(Number(geo.lon))){ui.classList.remove('on','ok');return}ui.classList.add('on');ui.classList.toggle('ok',geo.confirmed===true);$('sunGeoFoundTitle').textContent=geo.confirmed?'Адрес подтверждён':'Проверьте найденный адрес';$('sunGeoFoundText').textContent=String(geo.formatted||d.address||'');$('sunGeoConfirmOk').style.display=geo.confirmed?'none':''; + } + + function persistPreciseSaved(){ + const d=getDraft();if(!d)return;const full=composeFullAddress();if(!d.id)return;const saved=getOrders().find(o=>String(o.id)===String(d.id));if(!saved)return;addrIds.forEach(id=>saved[id]=String($(id)?.value||'').trim());saved.address=full;saved.leadSource=$('orderLeadSource')?.value||'';if(d.geo)saved.geo=structuredClone(d.geo);try{persist()}catch(_){try{localStorage.setItem('sunOrders',JSON.stringify(getOrders()))}catch(__){}} + } + + function init(){ + ensureSourceField();ensureClientUi();ensureGeoConfirmUi(); + const mapSensitive=new Set(['deliveryRegion','deliveryLocality','deliveryDistrict','deliveryStreet','deliveryHouse','deliveryCorpus']); + addrIds.forEach(id=>$(id)?.addEventListener('input',()=>setTimeout(()=>{composeFullAddress();if(mapSensitive.has(id))resetGeoConfirmation(true)},0))); + $('deliveryRegion')?.addEventListener('change',()=>setTimeout(()=>{composeFullAddress();resetGeoConfirmation(true)},0)); + $('phone')?.addEventListener('input',schedulePhoneLookup);$('phone')?.addEventListener('blur',schedulePhoneLookup); + $('orderLoyaltyCard')?.addEventListener('input',()=>{clearTimeout(lookupTimer);lookupTimer=setTimeout(lookupCard,260)});$('orderLoyaltyCard')?.addEventListener('blur',lookupCard); + $('orderGeoCreate')?.addEventListener('click',()=>setTimeout(syncGeoConfirmUi,200)); + const geoBox=$('orderGeoBox');if(geoBox)new MutationObserver(()=>setTimeout(syncGeoConfirmUi,0)).observe(geoBox,{childList:true,subtree:true,characterData:true}); + const stats=$('statsList');if(stats)new MutationObserver(()=>setTimeout(renderCrmStats,0)).observe(stats,{childList:true,subtree:false}); + $('statsPeriod')?.addEventListener('change',()=>setTimeout(renderCrmStats,0));renderCrmStats(); + } + + const priorSave=window.saveOrder; + if(typeof priorSave==='function')window.saveOrder=function(...args){ + const d=getDraft(),source=ensureSourceField();if(d&&source)d.leadSource=source.value;composeFullAddress(); + const street=String($('deliveryStreet')?.value||'').trim(),house=String($('deliveryHouse')?.value||'').trim(),locality=String($('deliveryLocality')?.value||'').trim(); + if(street&&house&&!locality&&!confirm('Не указан город / населённый пункт. Похожие адреса могут существовать в разных местах. Сохранить заказ без уточнения?'))return false; + if(street&&house&&d?.geo&&d.geo.confirmed!==true&&!confirm('Точка уже найдена на карте, но адрес не подтверждён. Сохранить без подтверждения?'))return false; + const r=priorSave.apply(this,args),finish=()=>{persistPreciseSaved();renderCrmStats();syncGeoConfirmUi();return undefined};if(r&&typeof r.then==='function')return r.then(v=>{finish();return v});finish();return r; + }; + const priorOpen=window.openOrder; + if(typeof priorOpen==='function')window.openOrder=function(...args){const r=priorOpen.apply(this,args);setTimeout(()=>{const s=ensureSourceField();if(s)s.value=String(getDraft()?.leadSource||'');composeFullAddress();ensureClientUi();syncGeoConfirmUi();$('sunClientMatch')?.classList.remove('on');const l=$('sunKnownAddresses')?.closest('label');if(l)l.style.display='none'},0);return r}; + const priorReset=window.resetDraft; + if(typeof priorReset==='function')window.resetDraft=function(...args){const r=priorReset.apply(this,args);setTimeout(()=>{const s=ensureSourceField();if(s)s.value='';$('sunClientMatch')?.classList.remove('on');const l=$('sunKnownAddresses')?.closest('label');if(l)l.style.display='none';syncGeoConfirmUi()},0);return r}; + const priorStats=window.renderStats; + if(typeof priorStats==='function')window.renderStats=function(...args){const r=priorStats.apply(this,args);setTimeout(renderCrmStats,0);return r}; + + document.addEventListener('click',e=>{const b=e.target.closest('header nav button');if(!b)return;const label=String(b.dataset.navLabel||b.textContent||'').trim();if(label==='Настройки')setTimeout(()=>{ensureSettingsCard();renderSourceSettingsRows()},90);if(label==='Статистика')setTimeout(renderCrmStats,80)},true); + const settings=$('enterprise-settings');if(settings)new MutationObserver(()=>{if(settings.classList.contains('on'))setTimeout(ensureSettingsCard,0)}).observe(settings,{childList:true,subtree:true}); + window.addEventListener('sun:cloud-state-applied',()=>setTimeout(()=>{refreshSourceSelect();renderCrmStats()},50)); + init();setTimeout(()=>{if($('enterprise-settings')?.classList.contains('on'))ensureSettingsCard();syncGeoConfirmUi()},200); +})(); +; + +/* ===== 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; + 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=window.SunSafe.escapeHTML; + 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} + 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} + .card,.page,.enterprise-card,.dialog,.sun-order-lines-table,.dash3-panel{background:var(--sun-ui-card)!important;color:var(--sun-ui-text)!important;border-color:var(--sun-ui-border)!important}.page h1,.catalog h1,.enterprise-card h2,.enterprise-card h3{color:var(--sun-ui-primary)!important}.hint,label,.enterprise-card .hint{color:var(--sun-ui-muted)!important}input,select,textarea{background:var(--sun-ui-input)!important;color:var(--sun-ui-text)!important;border-color:var(--sun-ui-border)!important} + button.primary,.primary{background:var(--sun-ui-primary)!important;border-color:var(--sun-ui-primary)!important;color:var(--sun-ui-button-text)!important}.primary svg,.primary *{color:inherit!important}.outline{border-color:var(--sun-ui-accent)!important;color:var(--sun-ui-primary)!important;background:var(--sun-ui-card)!important} + @media(min-width:901px){.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}.sun-enterprise-sidebar .sun-nav-group{border-bottom-color:color-mix(in srgb,var(--sun-sidebar-text) 10%,transparent)!important}.sun-enterprise-sidebar .sun-nav-group::before{color:var(--sun-sidebar-muted)!important}.sun-enterprise-sidebar header nav .sun-nav-button{color:var(--sun-sidebar-text)!important}.sun-enterprise-sidebar header nav .sun-nav-button::before{background:var(--sun-sidebar-icons)!important;color:var(--sun-sidebar-icons)!important}.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}.sun-enterprise-sidebar header nav .sun-nav-button.on{background:var(--sun-sidebar-active-bg)!important;color:var(--sun-sidebar-active-text)!important}.sun-enterprise-sidebar header nav .sun-nav-button.on::before{background:var(--sun-sidebar-active-icon)!important;color:var(--sun-sidebar-active-icon)!important}.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}.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}.sun-current-user,#sunLogoutBtn{color:var(--sun-sidebar-muted)!important}.sun-sidebar-collapse{background:var(--sun-sidebar-collapse-bg)!important;color:var(--sun-sidebar-collapse-text)!important;border-color:var(--sun-sidebar-border)!important}#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){.sun-enterprise-sidebar header,.sun-enterprise-sidebar header nav{background:linear-gradient(165deg,var(--sun-sidebar-bg),var(--sun-sidebar-bg2))!important;color:var(--sun-sidebar-text)!important}.sun-enterprise-sidebar header nav .sun-nav-button{color:var(--sun-sidebar-text)!important}.sun-enterprise-sidebar header nav .sun-nav-button::before{background:var(--sun-sidebar-icons)!important}.sun-enterprise-sidebar header nav .sun-nav-button.on{background:var(--sun-sidebar-active-bg)!important;color:var(--sun-sidebar-active-text)!important}.sun-enterprise-sidebar header nav .sun-nav-button.on::before{background:var(--sun-sidebar-active-icon)!important}} + .sun-brand-settings-card{overflow:hidden}.sun-theme-toolbar{display:flex;gap:8px;flex-wrap:wrap;margin:11px 0 14px}.sun-theme-sections{display:grid;gap:13px}.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))}.sun-theme-box h3{margin:0 0 9px}.sun-theme-panel-main{display:grid;grid-template-columns:minmax(250px,.8fr) minmax(340px,1.2fr);gap:14px;align-items:start}.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)}.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)}.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)}.sun-theme-preview-menu{display:grid;gap:7px;margin-top:13px}.sun-theme-preview-row{height:38px;border-radius:10px;display:flex;align-items:center;gap:10px;padding:0 10px;color:var(--sun-sidebar-text)}.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)}.sun-theme-preview-row i{width:25px;height:25px;display:grid;place-items:center;color:var(--sun-sidebar-icons);font-style:normal}.sun-theme-preview-row.on i{color:var(--sun-sidebar-active-icon)}.sun-theme-preview small{color:var(--sun-sidebar-muted)} + .sun-sidebar-core-colors{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;margin-bottom:12px}.sun-brand-setting-grid{display:grid;grid-template-columns:repeat(3,minmax(185px,1fr));gap:8px}.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)}.sun-brand-color-field label{font-size:10.5px;line-height:1.2}.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}.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} + .sun-brand-palette-toggle{margin-top:10px}.sun-brand-palette-toggle summary{cursor:pointer;font-weight:750;color:var(--sun-ui-primary)}.sun-brand-palette{display:grid;grid-template-columns:repeat(5,minmax(90px,1fr));gap:8px;margin-top:10px}.sun-brand-swatch{border:1px solid var(--sun-ui-border);border-radius:10px;background:var(--sun-ui-card);overflow:hidden}.sun-brand-swatch-top{height:38px;background:var(--swatch)}.sun-brand-swatch-body{padding:6px 7px}.sun-brand-swatch-body b{display:block;font-size:10px}.sun-brand-swatch-body small{font:700 9px ui-monospace,monospace;color:var(--sun-ui-muted)}.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)}.sun-theme-savebar .actions{display:flex;gap:8px;flex-wrap:wrap}.sun-theme-dirty{font-size:11px;color:var(--sun-ui-muted)}.sun-theme-dirty strong{color:var(--sun-ui-accent)} + header .brand.sun-brand-home-link{cursor:pointer!important;border-radius:10px;transition:background .15s ease}.sun-enterprise-sidebar header .brand.sun-brand-home-link:hover{background:rgba(255,255,255,.06)!important}.sun-brand-home-link:focus-visible{outline:2px solid var(--sun-sidebar-indicator);outline-offset:2px} + @media(max-width:1100px){.sun-brand-setting-grid{grid-template-columns:repeat(2,minmax(180px,1fr))}}@media(max-width:760px){.sun-theme-panel-main,.sun-sidebar-core-colors,.sun-brand-setting-grid{grid-template-columns:1fr}.sun-brand-palette{grid-template-columns:repeat(2,1fr)}.sun-theme-savebar{align-items:flex-start;flex-direction:column}} + `;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])); + const header=document.querySelector('header'); + if(header){ + header.style.setProperty('background',`radial-gradient(circle at 12% 0%,${hexRgba(s.sidebar.glow,.30)},transparent 31%),linear-gradient(165deg,${s.sidebar.background} 0%,${s.sidebar.background2} 100%)`,'important'); + header.style.setProperty('border-right',`1px solid ${s.sidebar.border}`,'important'); + header.style.setProperty('box-shadow',`9px 0 34px ${hexRgba(s.sidebar.background,.22)}`,'important'); + } + const nav=document.querySelector('header nav'); + if(nav&&matchMedia('(max-width:900px)').matches)nav.style.setProperty('background',`linear-gradient(165deg,${s.sidebar.background},${s.sidebar.background2})`,'important'); + else nav?.style.removeProperty('background'); + 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?'Есть несохранённые изменения · пока меняется только предпросмотр':'Все изменения сохранены'} + function field(scope,key,label,value){return`
    `} + function paletteHtml(){return Object.keys(BRAND).map(k=>`
    ${esc(BRAND_LABELS[k])}${esc(BRAND[k])}
    `).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`

    Цвета интерфейса

    Выберите готовый вариант или настройте цвета вручную. Изменения сначала видны в примере. Реальная левая панель меняется только после «Сохранить настройки».

    +
    +
    +

    Левая панель

    СолнцеПредпросмотр боковой панели
    Главная
    Заказы
    Настройки

    Главные цвета панели

    ${field('sidebar','background','Основной цвет панели',previewState.sidebar.background)}${field('sidebar','background2','Низ / второй цвет',previewState.sidebar.background2)}${field('sidebar','icons','Цвет значков',previewState.sidebar.icons)}

    Основной цвет — это фон всей большой левой панели. Для однотонной панели поставьте одинаковый цвет в первых двух полях.

    Дополнительные цвета панели
    ${Object.keys(SIDEBAR_FIRM).filter(k=>!['background','background2','icons'].includes(k)).map(k=>field('sidebar',k,SIDE_LABELS[k],previewState.sidebar[k])).join('')}
    +

    Основной интерфейс

    ${Object.keys(UI_FIRM).map(k=>field('ui',k,UI_LABELS[k],previewState.ui[k])).join('')}
    Показать фирменные цвета «Солнца»
    ${paletteHtml()}
    +
    ${dirty?'Есть несохранённые изменения · пока меняется только предпросмотр':'Все изменения сохранены'}
    `; + } + 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(){savedState=read();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('resize',()=>applyActual(savedState));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()}}; +})(); +; + +/* ===== MODULE: offer-template-v1.js ===== */ +/* Sun Catering: selectable client-offer templates */ +(()=>{ + 'use strict'; + if(window.__sunOfferTemplateV1)return; + window.__sunOfferTemplateV1=true; + + const KEY='sunOfferTemplateV1'; + const DEFAULT_ID='light'; + const TEMPLATES=[ + {id:'light',name:'Светлый фирменный',desc:'Белый фон, фирменный зелёный и золото. Основной дизайн.',thumb:'offer-templates/thumb-light.jpg'}, + {id:'editorial-grid',name:'Солнце Editorial',desc:'Макет по референсу: крупный первый экран, компактное меню, фотосетка и итоги на одной визуальной системе.',thumb:'offer-templates/thumb-editorial-grid.jpg'}, + {id:'midnight-glass',name:'Midnight Glass',desc:'Двухколоночный макет: меню списком слева, реальные фото боксов мозаикой справа.',thumb:'offer-templates/thumb-midnight-glass.jpg'}, + {id:'emerald-gold',name:'Emerald Gold',desc:'Премиальная композиция: золотой итог в первом экране, меню слева и фотоколлаж справа.',thumb:'offer-templates/thumb-emerald-gold.jpg'} + ]; + + const esc=window.SunSafe.escapeHTML; + function parse(){ + try{ + const raw=JSON.parse(localStorage.getItem(KEY)||'{}')||{}; + const id=TEMPLATES.some(x=>x.id===raw.id)?raw.id:DEFAULT_ID; + return {version:1,id}; + }catch(_){return {version:1,id:DEFAULT_ID}} + } + let state=parse(); + function selected(){return TEMPLATES.find(x=>x.id===state.id)||TEMPLATES[0]} + function save(id){ + if(!TEMPLATES.some(x=>x.id===id))id=DEFAULT_ID; + state={version:1,id}; + localStorage.setItem(KEY,JSON.stringify(state)); + window.dispatchEvent(new CustomEvent('sunoffertemplatechange',{detail:{...state,template:selected()}})); + } + + const style=document.createElement('style'); + style.id='sun-offer-template-settings-style'; + style.textContent=` + .sun-offer-template-card{grid-column:1/-1!important} + .sun-offer-template-card .hint{max-width:980px} + .sun-offer-template-grid{display:grid;grid-template-columns:repeat(4,minmax(200px,1fr));gap:14px;margin-top:14px} + .sun-offer-template-option{position:relative;border:1px solid #e3e6e9;border-radius:16px;background:#fff;padding:10px;cursor:pointer;transition:.16s ease;overflow:hidden;box-shadow:0 4px 16px rgba(30,35,40,.035)} + .sun-offer-template-option:hover{transform:translateY(-1px);box-shadow:0 8px 22px rgba(30,35,40,.08)} + .sun-offer-template-option.on{border-color:var(--sun-brand-sun,#FFC107);box-shadow:0 0 0 2px color-mix(in srgb,var(--sun-brand-sun,#FFC107) 30%,transparent),0 9px 24px rgba(30,35,40,.09)} + .sun-offer-template-thumb{display:block;width:100%;height:245px;object-fit:cover;object-position:top center;border-radius:11px;background:#eef0f2;border:1px solid #eceef0} + .sun-offer-template-info{padding:10px 3px 2px;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:7px 10px;align-items:start} + .sun-offer-template-info strong{font-size:14px;color:#24282d}.sun-offer-template-info p{grid-column:1/-1;margin:0;color:#6c737c;font-size:11px;line-height:1.42} + .sun-offer-template-check{width:25px;height:25px;border-radius:50%;border:1px solid #d8dde2;display:grid;place-items:center;color:transparent;font-weight:900;background:#fff} + .sun-offer-template-option.on .sun-offer-template-check{background:var(--sun-brand-sun,#FFC107);border-color:var(--sun-brand-sun,#FFC107);color:#1a1a1a} + .sun-offer-template-toolbar{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:14px;padding-top:12px;border-top:1px solid #eceef1} + .sun-offer-template-toolbar .current{font-size:12px;color:#66707a}.sun-offer-template-toolbar .current b{color:#263238} + @media(max-width:1100px){.sun-offer-template-grid{grid-template-columns:repeat(2,minmax(210px,1fr))}} + @media(max-width:680px){.sun-offer-template-grid{grid-template-columns:1fr}.sun-offer-template-thumb{height:290px}} + `; + document.head.appendChild(style); + + function cardHtml(){ + const cur=selected(); + return `

    Шаблон предложения клиенту

    +

    Выберите внешний вид, который будет использоваться в «Предложении клиенту» и в PDF. Реальные фотографии боксов и данные заказа остаются одинаковыми — меняется только дизайн.

    +
    ${TEMPLATES.map(t=>``).join('')}
    +
    Сейчас выбран: ${esc(cur.name)}
    `; + } + function bind(card){ + const choose=(el)=>{ + const id=el?.dataset?.offerTemplate;if(!id)return; + save(id);card.innerHTML=cardHtml();bind(card); + try{window.SunEnterprise?.toast?.(`Шаблон «${selected().name}» выбран.`)}catch(_){ } + }; + card.querySelectorAll('[data-offer-template]').forEach(el=>{ + el.addEventListener('click',()=>choose(el)); + el.addEventListener('keydown',e=>{if(e.key==='Enter'||e.key===' '){e.preventDefault();choose(el)}}); + }); + card.querySelector('[data-offer-template-reset]')?.addEventListener('click',()=>{save(DEFAULT_ID);card.innerHTML=cardHtml();bind(card)}); + } + function ensureCard(){ + const view=document.getElementById('enterprise-settings');if(!view)return; + const grid=view.querySelector('.enterprise-grid');if(!grid)return; + let card=document.getElementById('sunOfferTemplateSettingsCard'); + if(card&&grid.contains(card))return; + card=document.createElement('section');card.id='sunOfferTemplateSettingsCard';card.className='enterprise-card wide sun-offer-template-card';card.innerHTML=cardHtml(); + const brand=document.getElementById('sunBrandThemeSettingsCard'); + if(brand&&brand.parentElement===grid)brand.insertAdjacentElement('afterend',card);else{ + 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); + } + function boot(){ + ensureCard(); + const settings=document.getElementById('enterprise-settings'); + if(settings)new MutationObserver(()=>queueMicrotask(ensureCard)).observe(settings,{childList:true,subtree:true}); + document.addEventListener('click',e=>{const b=e.target.closest('header nav button');if(!b)return;const label=(b.dataset.navLabel||b.textContent||'').trim();if(label==='Настройки')setTimeout(ensureCard,30)},true); + window.addEventListener('storage',e=>{if(e.key===KEY){state=parse();ensureCard();window.dispatchEvent(new CustomEvent('sunoffertemplatechange',{detail:{...state,template:selected()}}));}}); + window.addEventListener('suncloudsync',()=>{state=parse();setTimeout(ensureCard,25);window.dispatchEvent(new CustomEvent('sunoffertemplatechange',{detail:{...state,template:selected()}}));}); + window.addEventListener('sun:cloud-state-applied',()=>{state=parse();setTimeout(ensureCard,25);window.dispatchEvent(new CustomEvent('sunoffertemplatechange',{detail:{...state,template:selected()}}));}); + } + if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',boot,{once:true});else boot(); + + window.SunOfferTemplate={ + key:KEY, + list:()=>TEMPLATES.map(x=>({...x})), + get:()=>({...state,template:{...selected()}}), + set:id=>save(id) + }; +})(); +; + +/* ===== MODULE: stability-v17.js ===== */ +(() => { + 'use strict'; + if (window.SunStabilityV17) return; + + const VERSION = '17.0.0-local-test'; + const RELEASE = 'v17 Stability · Local Test'; + const DB_NAME = 'SunStabilityV17'; + const DB_VERSION = 1; + const DAILY_KEY = 'sunV17DailyBackup'; + const $ = id => document.getElementById(id); + const esc=window.SunSafe.escapeHTML; + const toast=(t,k='info',ms=4500)=>{try{return window.SunEnterprise?.toast?.(t,k,ms)}catch(_){} console.log('[V17]',t)}; + const cloud=()=>window.SunCloudV2; + const client=()=>cloud()?.getClient?.()||null; + const workspace=()=>cloud()?.getWorkspace?.()||null; + const clientId=()=>cloud()?.getClientId?.()||'local'; + let lastMirrorAt=''; + let conflictCount=0; + let booted=false; + + function openDb(){ + return new Promise((resolve,reject)=>{ + const r=indexedDB.open(DB_NAME,DB_VERSION); + r.onupgradeneeded=()=>{ + const db=r.result; + if(!db.objectStoreNames.contains('outbox'))db.createObjectStore('outbox',{keyPath:'key'}); + if(!db.objectStoreNames.contains('errors'))db.createObjectStore('errors',{keyPath:'id'}); + if(!db.objectStoreNames.contains('meta'))db.createObjectStore('meta',{keyPath:'key'}); + }; + r.onsuccess=()=>resolve(r.result);r.onerror=()=>reject(r.error); + }); + } + async function idbPut(store,value){try{const db=await openDb();await new Promise((res,rej)=>{const tx=db.transaction(store,'readwrite');tx.objectStore(store).put(value);tx.oncomplete=res;tx.onerror=()=>rej(tx.error)});return true}catch(_){return false}} + async function idbGet(store,key){try{const db=await openDb();return await new Promise((res,rej)=>{const r=db.transaction(store,'readonly').objectStore(store).get(key);r.onsuccess=()=>res(r.result||null);r.onerror=()=>rej(r.error)})}catch(_){return null}} + async function idbDelete(store,key){try{const db=await openDb();await new Promise((res,rej)=>{const tx=db.transaction(store,'readwrite');tx.objectStore(store).delete(key);tx.oncomplete=res;tx.onerror=()=>rej(tx.error)});return true}catch(_){return false}} + async function idbAll(store){try{const db=await openDb();return await new Promise((res,rej)=>{const r=db.transaction(store,'readonly').objectStore(store).getAll();r.onsuccess=()=>res(r.result||[]);r.onerror=()=>rej(r.error)})}catch(_){return[]}} + + async function markPending(reason='change'){ + const ws=workspace()?.id||'local'; + const old=await idbGet('outbox',`sync:${ws}`); + await idbPut('outbox',{key:`sync:${ws}`,workspaceId:ws,reason:String(reason),createdAt:old?.createdAt||Date.now(),updatedAt:Date.now(),attempts:Number(old?.attempts||0)+1}); + refreshCardSoon(); + } + async function markSynced(revision){ + const ws=workspace()?.id||'local'; + await idbDelete('outbox',`sync:${ws}`); + await idbPut('meta',{key:`lastSync:${ws}`,at:Date.now(),revision:Number(revision||0)}); + refreshCardSoon(); + } + async function recordConflict(message){ + conflictCount++; + await idbPut('meta',{key:'lastConflict',at:Date.now(),message:String(message||''),count:conflictCount}); + refreshCardSoon(); + } + + function context(){ + return {url:location.href,online:navigator.onLine,userAgent:navigator.userAgent,release:RELEASE,cloudStatus:cloud()?.status?.()||null}; + } + async function queueError(message,stack='',level='error',extra={}){ + const item={id:crypto.randomUUID?crypto.randomUUID():`${Date.now()}-${Math.random()}`,workspaceId:workspace()?.id||null,clientId:clientId(),version:VERSION,level,message:String(message||'Unknown error').slice(0,4000),stack:String(stack||'').slice(0,12000),context:{...context(),...extra},createdAt:Date.now()}; + await idbPut('errors',item);flushErrors();refreshCardSoon(); + } + async function flushErrors(){ + const c=client(),ws=workspace();if(!c||!navigator.onLine)return; + const items=await idbAll('errors'); + for(const item of items.slice(0,30)){ + try{ + const {error}=await c.rpc('sun_v17_log_error',{p_workspace:item.workspaceId||ws?.id||null,p_client_id:item.clientId,p_app_version:item.version,p_level:item.level,p_message:item.message,p_stack:item.stack,p_context:item.context}); + if(!error)await idbDelete('errors',item.id); + }catch(_){} + } + } + + async function mirror(payload){ + const c=client(),ws=workspace();if(!c||!ws?.id||!payload||!navigator.onLine)return null; + try{ + const {data,error}=await c.rpc('sun_v17_mirror_legacy',{p_workspace:ws.id,p_payload:payload,p_client_id:clientId()}); + if(error)throw error;lastMirrorAt=new Date().toISOString();await idbPut('meta',{key:`mirror:${ws.id}`,at:Date.now(),result:data||{}});return data; + }catch(e){await queueError(`Ошибка зеркалирования v17: ${e.message||e}`,e.stack||'','warn');return null} + } + + async function ensureDailyBackup(){ + const c=client(),ws=workspace();if(!c||!ws?.id||!navigator.onLine)return; + const today=new Date().toISOString().slice(0,10);let state={};try{state=JSON.parse(localStorage.getItem(DAILY_KEY)||'{}')||{}}catch(_){} + if(state[ws.id]===today)return; + try{ + const {error}=await c.rpc('sun_v17_create_backup',{p_workspace:ws.id,p_kind:'daily',p_label:`Автоматическая копия ${today}`});if(error)throw error; + await c.rpc('sun_v17_prune_backups',{p_workspace:ws.id});state[ws.id]=today;localStorage.setItem(DAILY_KEY,JSON.stringify(state)); + }catch(e){await queueError(`Не удалось создать ежедневную резервную копию: ${e.message||e}`,e.stack||'','warn')} + } + + async function onCloudSync(ev){ + const payload=ev?.detail?.payload; + if(payload)await mirror(payload); + await ensureDailyBackup();await flushErrors();refreshCardSoon(); + } + + function installErrorHooks(){ + window.addEventListener('error',e=>{if(e?.error?.__sunV17Reported)return;queueError(e?.message||e?.error?.message||'Window error',e?.error?.stack||'', 'error',{source:e?.filename||'',line:e?.lineno||0,column:e?.colno||0});}); + window.addEventListener('unhandledrejection',e=>{const r=e.reason;queueError(`Unhandled promise: ${r?.message||r||'unknown'}`,r?.stack||'','error');}); + } + + async function diagnostics(){ + const ws=workspace(),c=client();const outbox=await idbGet('outbox',`sync:${ws?.id||'local'}`);const errors=(await idbAll('errors')).length; + let normalized=null,backups=[]; + if(c&&ws?.id&&navigator.onLine){ + try{const r=await c.rpc('sun_v17_entity_snapshot',{p_workspace:ws.id});if(!r.error)normalized=r.data}catch(_){} + try{const r=await c.rpc('sun_v17_list_backups',{p_workspace:ws.id,p_limit:30});if(!r.error)backups=r.data||[]}catch(_){} + } + let localOrders=0,localCatalog=0;try{localOrders=JSON.parse(localStorage.getItem('sunOrders')||'[]').length}catch(_){}try{localCatalog=JSON.parse(localStorage.getItem('sunBoxes')||'[]').length}catch(_){} + return {ws,c,outbox,errors,normalized,backups,localOrders,localCatalog,cloudStatus:cloud()?.status?.()||{}}; + } + + async function createManualBackup(){ + const c=client(),ws=workspace();if(!c||!ws?.id)return toast('Сначала войдите в облачную рабочую базу.','warn'); + try{const {error}=await c.rpc('sun_v17_create_backup',{p_workspace:ws.id,p_kind:'manual',p_label:`Ручная копия · ${new Date().toLocaleString('ru-RU')}`});if(error)throw error;toast('Резервная копия создана.','success');await renderCard()}catch(e){toast(e.message||String(e),'error')} + } + async function restoreBackup(id){ + const c=client(),ws=workspace();if(!c||!ws?.id||!id)return; + if(!confirm('Восстановить выбранную резервную копию? Перед восстановлением будет автоматически создана ещё одна копия текущего состояния.'))return; + try{const {error}=await c.rpc('sun_v17_restore_backup',{p_workspace:ws.id,p_backup:id});if(error)throw error;toast('Копия восстановлена. Загружаю данные…','success');await cloud()?.pull?.();setTimeout(()=>location.reload(),500)}catch(e){toast(e.message||String(e),'error')} + } + function emergencyExport(){ + const data={format:'sun-v17-emergency-local',version:17,createdAt:new Date().toISOString(),storage:{}}; + for(let i=0;iURL.revokeObjectURL(a.href),1000); + } + + function findSettingsGrid(){return document.querySelector('#enterprise-settings .enterprise-grid')} + function installStyle(){if($('sunV17Style'))return;const s=document.createElement('style');s.id='sunV17Style';s.textContent=` + .sun-v17-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:9px}.sun-v17-stat{border:1px solid #e1e6e8;border-radius:10px;padding:10px;background:#fbfcfc}.sun-v17-stat small{display:block;color:#77838b;margin-bottom:4px}.sun-v17-stat b{font-size:15px;color:#15364c}.sun-v17-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:12px}.sun-v17-backups{margin-top:12px;border-top:1px solid #e4e8ea;padding-top:10px}.sun-v17-backup{display:grid;grid-template-columns:1fr auto;gap:10px;align-items:center;padding:7px 0;border-bottom:1px dashed #e5e8ea}.sun-v17-backup small{display:block;color:#7a858c}.sun-v17-health{font-weight:800}.sun-v17-health.good{color:#347154}.sun-v17-health.warn{color:#9a7016}.sun-v17-health.bad{color:#a64040} + @media(max-width:900px){.sun-v17-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:600px){.sun-v17-grid{grid-template-columns:1fr}} + `;document.head.appendChild(s)} + + async function renderCard(){ + // Backup/stability controls are intentionally hidden from ordinary Settings. + $('sunV17StabilityCard')?.remove(); + } + let refreshTimer=null;function refreshCardSoon(){clearTimeout(refreshTimer);refreshTimer=setTimeout(()=>{if($('enterprise-settings')?.classList.contains('on'))renderCard()},250)} + + function observeSettings(){ + document.addEventListener('click',e=>{const b=e.target.closest('header nav button');if(!b)return;if(String(b.dataset.navLabel||b.textContent||'').trim()==='Настройки')setTimeout(renderCard,120)},true); + const root=$('enterprise-settings');if(root)new MutationObserver(()=>{if(root.classList.contains('on')&&!$('sunV17StabilityCard'))setTimeout(renderCard,80)}).observe(root,{childList:true,subtree:true,attributes:true,attributeFilter:['class']}); + } + + async function initialMirrorIfPossible(){ + const c=client(),ws=workspace();if(!c||!ws?.id||!navigator.onLine)return; + try{const payload=await cloud()?.snapshotLocal?.();if(payload)await mirror(payload);await ensureDailyBackup();await flushErrors()}catch(e){queueError(e.message||String(e),e.stack||'','warn')} + } + + function boot(){if(booted)return;booted=true;installStyle();installErrorHooks();observeSettings();window.addEventListener('sun:cloud-sync-complete',onCloudSync);window.addEventListener('online',()=>{flushErrors();setTimeout(initialMirrorIfPossible,700)});window.addEventListener('sun:cloud-permissions-changed',()=>setTimeout(initialMirrorIfPossible,600));setTimeout(initialMirrorIfPossible,2200);setInterval(()=>{if(navigator.onLine)flushErrors()},60000)} + + window.SunStabilityV17={VERSION,RELEASE,markPending,markSynced,recordConflict,queueError,renderCard,diagnostics,createBackup:createManualBackup,emergencyExport}; + if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',boot,{once:true});else boot(); +})(); +; + +/* ===== MODULE: saas-v16.js ===== */ +/* Sun Catering SaaS subscriptions, plan entitlements and platform admin v16 */ +(()=>{ + 'use strict'; + if(window.SunSaaSV16)return; + + const VERSION='2026.08.30-saas-v16'; + const $=id=>document.getElementById(id); + const qa=(s,r=document)=>[...r.querySelectorAll(s)]; + const esc=window.SunSafe.escapeHTML; + const cloud=()=>window.SunCloudV2||null; + const client=()=>cloud()?.getClient?.()||null; + const workspace=()=>cloud()?.getWorkspace?.()||null; + const session=()=>cloud()?.getSession?.()||null; + const toast=(text,type='info',timeout=4500)=>{try{return window.SunEnterprise?.toast?.(text,type,timeout)}catch(_){console.log('[SaaS]',text)}}; + + const PLAN_LABELS={basic:'Базовый',professional:'Профессиональный',full:'Полный'}; + const FEATURE_LABELS={ + orders:'Заказы',calendar:'Календарь',map:'Карта',clients:'Клиенты',catalog_view:'Каталог',catalog_edit:'Редактирование боксов', + production:'Производство',shopping:'Закупки',stock:'Склад',routes:'Маршруты',mailings:'Рассылки',money:'Деньги', + stats_basic:'Статистика',stats_advanced:'Расширенная статистика',team:'Команда',suppliers:'Поставщики',print:'Печать',settings:'Настройки', + branding:'Фирменное оформление',client_offers:'Предложения клиенту',offer_templates:'Шаблоны предложений',backups:'Резервные копии',audit:'История изменений',users_manage:'Сотрудники и права' + }; + const NAV_FEATURE={ + 'Главная':'orders','Новый заказ':'orders','Заказы':'orders','Календарь':'calendar','Карта':'map','Производство':'production','Закупки':'shopping','Закупка':'shopping', + 'Склад':'stock','Клиенты':'clients','Рассылки':'mailings','Деньги':'money','Команда':'team','Поставщики':'suppliers','Статистика':'stats_basic','Печать':'print','Маршруты':'routes','Настройки':'settings' + }; + const WRITE_FN_FEATURE={ + saveBox:'catalog_edit',removeBox:'catalog_edit',editBox:'catalog_edit',openManager:'catalog_edit', + sunOpenClientOffer:'client_offers',sunOpenCurrentClientOffer:'client_offers' + }; + const PLATFORM_FEATURES=['client_offers','offer_templates','catalog_edit','branding','users_manage','money','stock','production','routes','mailings','suppliers','audit','backups']; + + let snapshot=null; + let lastWorkspaceId=''; + let loading=false; + let guardedFns=new Map(); + let adminRows=[]; + + function hasFeature(key){ + if(!key)return true; + if(!snapshot)return true; // do not flash-lock before the server snapshot arrives + return snapshot.features?.[key]===true; + } + function isWritable(){if(cloud()?.isSupportMode?.())return false;return !snapshot||snapshot.access_mode==='full';} + function accessLabel(mode){return mode==='full'?'Активен':mode==='read_only'?'Только просмотр':'Заблокирован';} + function statusLabel(status){return ({trialing:'Пробный период',active:'Активна',past_due:'Оплата просрочена',canceled:'Отменена',expired:'Истекла'})[status]||status||'—';} + function formatDate(v){if(!v)return 'без срока';const d=new Date(v);if(Number.isNaN(d.getTime()))return '—';return d.toLocaleDateString('ru-RU');} + function endDate(){return snapshot?.status==='trialing'?snapshot?.trial_ends_at:snapshot?.current_period_end;} + function daysLeft(){const end=endDate();if(!end)return null;return Math.ceil((new Date(end).getTime()-Date.now())/86400000);} + + async function refreshSnapshot(force=false){ + const c=client(),ws=workspace(); + if(!c||!ws?.id){snapshot=null;lastWorkspaceId='';removeSubscriptionUi();return null;} + if(loading)return snapshot; + if(!force&&lastWorkspaceId===ws.id&&snapshot)return snapshot; + loading=true; + try{ + const {data,error}=await c.rpc('sun_subscription_snapshot',{p_workspace:ws.id}); + if(error)throw error; + snapshot=Array.isArray(data)?(data[0]||null):data; + lastWorkspaceId=ws.id; + applyAll(); + window.dispatchEvent(new CustomEvent('sun:subscription-changed',{detail:snapshot})); + return snapshot; + }catch(err){console.error('[SaaS] subscription snapshot',err);snapshot=null;} + finally{loading=false;} + return snapshot; + } + + function installStyle(){ + if($('sunSaaSV16Style'))return; + const s=document.createElement('style');s.id='sunSaaSV16Style';s.textContent=` + .sun-plan-locked{position:relative!important;opacity:.62!important;filter:saturate(.65)} + header nav button.sun-plan-locked::after{content:'🔒';font-size:10px;margin-left:auto;padding-left:6px} + .sun-side-collapsed #sunSaaSBadgeV16{display:none!important} + .sun-saas-plan-card-locked{position:relative;opacity:.62} + .sun-saas-plan-card-locked::after{content:'Доступно на другом тарифе';position:absolute;inset:auto 12px 12px auto;background:#241f3b;color:#fff;border-radius:999px;padding:5px 9px;font-size:11px;font-weight:800;z-index:4} + .sun-saas-badge{margin:8px 10px 0;border:1px solid #ffffff24;border-radius:11px;padding:8px 9px;color:#fff;background:#ffffff10;font-size:11px;line-height:1.35} + .sun-saas-badge b{display:block;font-size:12px}.sun-saas-badge .warn{color:#ffd36b} + .sun-saas-readonly-banner{position:fixed;top:10px;left:50%;transform:translateX(-50%);z-index:9800;background:#fff6df;border:1px solid #e2bd55;color:#6a5216;border-radius:12px;padding:9px 14px;box-shadow:0 8px 30px #0002;font-weight:800;max-width:min(680px,calc(100vw - 24px));text-align:center} + .sun-saas-blocked{position:fixed;inset:0;z-index:12000;background:#122b3cf2;display:grid;place-items:center;padding:20px}.sun-saas-blocked-card{width:min(520px,100%);background:#fff;border-radius:18px;padding:24px;box-shadow:0 30px 90px #0007;text-align:center}.sun-saas-blocked-card img{width:72px;height:72px;object-fit:contain}.sun-saas-blocked-card h2{margin:8px 0;color:#17384d}.sun-saas-blocked-card p{color:#596773;line-height:1.5} + .sun-saas-modal{position:fixed;inset:0;z-index:11500;background:#0d2130c9;display:grid;place-items:center;padding:18px}.sun-saas-modal-card{width:min(900px,100%);max-height:92vh;overflow:auto;background:#fff;border-radius:18px;padding:20px;box-shadow:0 30px 90px #0007}.sun-saas-modal-head{display:flex;align-items:center;justify-content:space-between;gap:10px}.sun-saas-modal-head h2{margin:0;color:#17384d}.sun-saas-modal-head button{border:0;background:#eef1f2;border-radius:50%;width:34px;height:34px;font-size:20px}.sun-saas-plans{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;margin-top:16px}.sun-saas-plan{border:1px solid #dfe5e8;border-radius:14px;padding:15px;background:#fafcfc}.sun-saas-plan.current{border:2px solid #ffb400;background:#fffaf0}.sun-saas-plan h3{margin:0 0 5px;color:#18384b}.sun-saas-plan ul{padding-left:18px;color:#4f5b64;font-size:12px;line-height:1.55}.sun-saas-plan .muted{color:#89939a}.sun-saas-upgrade{margin-top:12px;background:#eef4f6;border-radius:12px;padding:12px;color:#52606a} + .sun-saas-settings-card .sun-saas-status-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:9px;margin-top:12px}.sun-saas-kpi{border:1px solid #e2e7ea;border-radius:11px;padding:10px;background:#fafcfc}.sun-saas-kpi small{display:block;color:#7a858d;margin-bottom:4px}.sun-saas-kpi b{color:#203846} + #sun-saas-admin{padding:20px}.sun-saas-admin-head{display:flex;justify-content:space-between;align-items:flex-start;gap:12px;flex-wrap:wrap}.sun-saas-admin-table{display:grid;gap:10px;margin-top:16px}.sun-saas-company{border:1px solid #dfe5e8;border-radius:14px;padding:14px;background:#fff}.sun-saas-company-top{display:flex;justify-content:space-between;gap:10px;align-items:flex-start;flex-wrap:wrap}.sun-saas-company h3{margin:0;color:#18384b}.sun-saas-company small{color:#78828a}.sun-saas-company-actions{display:grid;grid-template-columns:1fr 110px auto auto;gap:8px;margin-top:11px;align-items:end}.sun-saas-company-actions label{font-size:11px;color:#64717a}.sun-saas-company-actions select{width:100%}.sun-saas-company-meta{display:flex;gap:8px;flex-wrap:wrap;margin-top:8px}.sun-saas-pill{border-radius:999px;padding:5px 8px;background:#eef3f5;font-size:11px}.sun-saas-pill.good{background:#eaf7ef;color:#347153}.sun-saas-pill.warn{background:#fff5dc;color:#8a681b}.sun-saas-pill.bad{background:#ffebe9;color:#993f3d} + .sun-saas-onboarding-tabs{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:10px}.sun-saas-onboarding-choice{border:1px solid #dfe5e8;background:#fafcfc;border-radius:12px;padding:12px;text-align:left}.sun-saas-onboarding-choice b{display:block;color:#17384d;margin-bottom:3px}.sun-saas-onboarding-choice small{color:#6f7980;line-height:1.35} + @media(max-width:760px){.sun-saas-plans{grid-template-columns:1fr}.sun-saas-settings-card .sun-saas-status-grid{grid-template-columns:1fr 1fr}.sun-saas-company-actions{grid-template-columns:1fr 1fr}.sun-saas-company-actions button{width:100%}} + `;document.head.appendChild(s); + } + + function removeSubscriptionUi(){ + $('sunSaaSBadgeV16')?.remove();$('sunSaaSReadonlyV16')?.remove();$('sunSaaSBlockedV16')?.remove();$('sunSaaSSettingsCardV16')?.remove(); + qa('[data-sun-plan-locked]').forEach(el=>{el.removeAttribute('data-sun-plan-locked');el.classList.remove('sun-plan-locked')}); + } + + function featureForNav(btn){return NAV_FEATURE[String(btn.dataset.navLabel||btn.textContent||'').trim()]||null;} + function markElement(el,feature){ + if(!el)return; + if(feature&&!hasFeature(feature)){el.dataset.sunPlanLocked=feature;el.classList.add('sun-plan-locked');el.title=`${FEATURE_LABELS[feature]||feature} недоступно на тарифе «${snapshot?.plan_name||''}»`;} + else{el.removeAttribute('data-sun-plan-locked');el.classList.remove('sun-plan-locked');} + } + + function applyFeatureGates(){ + if(!snapshot)return; + qa('header nav button').forEach(btn=>{if(btn.id==='sunSaaSAdminNavV16')return;markElement(btn,featureForNav(btn));}); + markElement($('sunClientOfferButton'),'client_offers');markElement($('sunAutoOfferButton'),'client_offers');markElement($('sunCloudManageUsersV3'),'users_manage');markElement($('sunCloudCreateInviteV2'),'users_manage'); + qa('#new .catalog-head button,#new .tile.add').forEach(el=>markElement(el,'catalog_edit')); + const cards=[ + ['sunClientOfferSettingsCard','client_offers'],['sunOfferTemplateSettingsCard','offer_templates'],['sunBrandThemeSettingsCard','branding'] + ]; + cards.forEach(([id,feature])=>{const el=$(id);if(!el)return;el.dataset.sunFeature=feature;el.classList.toggle('sun-saas-plan-card-locked',!hasFeature(feature));}); + qa('#enterprise-settings section').forEach(card=>{ + const h=(card.querySelector(':scope > h2')?.textContent||'').trim();let f=null; + if(/Резервн/i.test(h))f='backups';else if(/История изменений/i.test(h))f='audit'; + if(f){card.dataset.sunFeature=f;card.classList.toggle('sun-saas-plan-card-locked',!hasFeature(f));} + }); + } + + function guardFunctions(){ + Object.entries(WRITE_FN_FEATURE).forEach(([name,feature])=>{ + const fn=window[name];if(typeof fn!=='function')return; + if(fn.__sunSaasGuard)return; + const wrapped=function(...args){ + if(!isWritable())return showUpgrade(feature,'Подписка сейчас работает в режиме просмотра.'); + if(!hasFeature(feature))return showUpgrade(feature); + return fn.apply(this,args); + }; + wrapped.__sunSaasGuard=true;wrapped.__sunSaasOriginal=fn;window[name]=wrapped;guardedFns.set(name,wrapped); + }); + } + + function injectBadge(){ + if(!snapshot)return; + let badge=$('sunSaaSBadgeV16'); + const brand=document.querySelector('header .brand'); + if(!brand)return; + if(!badge){badge=document.createElement('div');badge.id='sunSaaSBadgeV16';badge.className='sun-saas-badge';brand.insertAdjacentElement('afterend',badge);} + const d=daysLeft(), trial=snapshot.status==='trialing'; + badge.innerHTML=`${esc(snapshot.plan_name||PLAN_LABELS[snapshot.plan_id]||'Тариф')}${trial?`Пробный период${d!=null?` · ${Math.max(0,d)} дн.`:''}`:accessLabel(snapshot.access_mode)}`; + badge.onclick=()=>showPlans();badge.title='Тариф и подписка';badge.style.cursor='pointer'; + } + + function applyAccessMode(){ + $('sunSaaSReadonlyV16')?.remove();$('sunSaaSBlockedV16')?.remove(); + if(!snapshot)return; + applyReadOnlyControls(); + if(snapshot.access_mode==='read_only'){ + const b=document.createElement('div');b.id='sunSaaSReadonlyV16';b.className='sun-saas-readonly-banner';b.textContent=`Подписка закончилась. До ${formatDate(snapshot.grace_until)} база доступна только для просмотра.`;document.body.appendChild(b); + }else if(snapshot.access_mode==='blocked'){ + const x=document.createElement('div');x.id='sunSaaSBlockedV16';x.className='sun-saas-blocked';x.innerHTML=`

    Доступ к компании приостановлен

    Подписка закончилась. Все данные сохранены и будут доступны сразу после продления.

    `;document.body.appendChild(x); + x.querySelector('[data-saas-show-plans]').onclick=()=>showPlans();x.querySelector('[data-saas-signout]').onclick=async()=>{if(window.SunCloudV2?.signOut)return window.SunCloudV2.signOut();try{await client()?.auth.signOut()}catch(_){}location.reload();}; + } + } + + + function applyReadOnlyControls(){ + qa('[data-saas-readonly-disabled]').forEach(el=>{el.disabled=false;el.removeAttribute('data-saas-readonly-disabled')}); + if(snapshot?.access_mode!=='read_only'&&!cloud()?.isSupportMode?.())return; + qa('#new input,#new textarea,#new select,#manager input,#manager textarea,#manager select,#editor input,#editor textarea,#editor select,#orders select,#enterprise-settings input,#enterprise-settings textarea,#enterprise-settings select').forEach(el=>{if(!el.disabled){el.disabled=true;el.dataset.saasReadonlyDisabled='1';}}); + } + + function showPlans(feature=null,note=''){ + $('sunSaaSPlansModalV16')?.remove(); + const m=document.createElement('div');m.id='sunSaaSPlansModalV16';m.className='sun-saas-modal'; + const cards=[ + {id:'basic',name:'Базовый',members:'1 пользователь',inc:['Заказы, календарь и клиенты','Каталог для работы с заказом','Базовая статистика и печать'],out:['Редактирование боксов','Предложения клиенту','Финансы и склад']}, + {id:'professional',name:'Профессиональный',members:'до 3 пользователей',inc:['Всё из Базового','Производство, склад и закупки','Финансы, поставщики и маршруты','Фирменное оформление'],out:['Редактирование боксов','Предложения клиенту']}, + {id:'full',name:'Полный',members:'без лимита',inc:['Все функции приложения','Редактирование каталога и боксов','Предложения клиенту и шаблоны','Команда, права и расширенные настройки'],out:[]} + ]; + m.innerHTML=`

    Тарифы

    ${feature?`Для функции «${esc(FEATURE_LABELS[feature]||feature)}» нужен другой тариф.`:'Выберите набор функций для компании.'}
    ${note?`
    ${esc(note)}
    `:''}
    ${cards.map(p=>`

    ${p.name}

    ${p.members}
      ${p.inc.map(x=>`
    • ${esc(x)}
    • `).join('')}${p.out.map(x=>`
    • — ${esc(x)}
    • `).join('')}
    ${snapshot?.plan_id===p.id?'Текущий тариф':'Подключение оплаты добавим на следующем этапе.'}
    `).join('')}
    Сейчас тариф можно изменить из вашего SaaS-кабинета. Автоматическую оплату подключим отдельным этапом.
    `; + document.body.appendChild(m);m.onclick=e=>{if(e.target===m||e.target.closest('[data-close]'))m.remove();}; + } + + function injectSettingsCard(){ + if(!snapshot)return; + const settings=$('enterprise-settings');if(!settings)return; + let card=$('sunSaaSSettingsCardV16');if(!card){card=document.createElement('section');card.id='sunSaaSSettingsCardV16';card.className='enterprise-card wide sun-saas-settings-card';const first=settings.querySelector('.enterprise-grid')||settings;first.prepend(card);} + const end=endDate(),d=daysLeft(); + card.innerHTML=`

    Тариф и подписка

    Подписка относится ко всей компании. Данные этой рабочей базы изолированы от других компаний.

    Тариф${esc(snapshot.plan_name||PLAN_LABELS[snapshot.plan_id])}
    Статус${esc(statusLabel(snapshot.status))}
    ${snapshot.status==='trialing'?'Пробный период до':'Оплачено до'}${formatDate(end)}${d!=null&&d>=0?` · ${d} дн.`:''}
    Сотрудники${Number(snapshot.member_count||0)} / ${snapshot.max_members==null?'∞':snapshot.max_members}
    ${snapshot.platform_admin?'':''}
    `; + card.querySelector('[data-saas-plans]').onclick=()=>showPlans();card.querySelector('[data-saas-admin]')?.addEventListener('click',openPlatformAdmin); + } + + function ensureAdminView(){ + if($('sun-saas-admin'))return $('sun-saas-admin'); + const v=document.createElement('section');v.id='sun-saas-admin';v.className='view page';document.body.appendChild(v);return v; + } + function ensureAdminNav(){ + const nav=document.querySelector('header nav');if(!nav||!snapshot?.platform_admin)return; + if(window.SunDeveloperV22){$('sunSaaSAdminNavV16')?.remove();window.SunDeveloperV22.ensureNav?.();return;} + let b=$('sunSaaSAdminNavV16');if(!b){b=document.createElement('button');b.id='sunSaaSAdminNavV16';b.type='button';b.dataset.navLabel='SaaS';b.textContent='SaaS';nav.appendChild(b);b.onclick=()=>openPlatformAdmin(b);} + b.hidden=false;b.style.display=''; + } + async function openPlatformAdmin(btn=null){ + if(window.SunDeveloperV22?.open)return window.SunDeveloperV22.open(btn); + if(!snapshot?.platform_admin)return; + const v=ensureAdminView();qa('.view').forEach(x=>x.classList.remove('on'));v.classList.add('on');qa('header nav button').forEach(x=>x.classList.remove('on'));(btn||$('sunSaaSAdminNavV16'))?.classList.add('on'); + v.innerHTML='

    SaaS · компании

    Тарифы, сроки и доступы всех компаний.

    Загружаю компании…

    '; + v.querySelector('[data-reload]').onclick=()=>loadAdminRows();await loadAdminRows(); + } + async function loadAdminRows(){ + const v=ensureAdminView(),c=client();if(!c)return; + const keepHead=v.querySelector('.sun-saas-admin-head')?.outerHTML||'';v.innerHTML=keepHead+'

    Загружаю компании…

    ';v.querySelector('[data-reload]')?.addEventListener('click',loadAdminRows); + const {data,error}=await c.rpc('sun_dev_list_companies');if(error){v.insertAdjacentHTML('beforeend',`

    ${esc(error.message)}

    `);return;} + adminRows=data||[]; + const list=document.createElement('div');list.className='sun-saas-admin-table';list.innerHTML=adminRows.map(r=>adminRowHtml(r)).join('')||'

    Компаний пока нет.

    ';v.appendChild(list);bindAdminRows(list); + } + function adminRowHtml(r){ + const access=r.access_mode,cls=access==='full'?'good':access==='read_only'?'warn':'bad'; + return `

    ${esc(r.workspace_name)}

    ${esc(r.workspace_id)}
    ${esc(accessLabel(access))}${esc(PLAN_LABELS[r.plan_id]||r.plan_name||'—')}${Number(r.member_count||0)} / ${r.max_members==null?'∞':r.max_members} сотрудниковдо ${formatDate(r.trial_ends_at||r.current_period_end)}
    Индивидуальная функция
    `; + } + function bindAdminRows(root){ + qa('.sun-saas-company',root).forEach(row=>{ + const ws=row.dataset.ws; + row.querySelector('[data-activate]').onclick=async()=>{const plan=row.querySelector('[data-plan]').value,days=Number(row.querySelector('[data-days]').value);await setSubscription(ws,plan,days,'active');}; + row.querySelector('[data-expire]').onclick=async()=>{if(!confirm('Заблокировать доступ этой компании? Данные не удалятся.'))return;await setSubscription(ws,row.querySelector('[data-plan]').value,0,'expired');}; + row.querySelector('[data-feature-enable]').onclick=async()=>{const feature=row.querySelector('[data-feature]').value,days=Number(row.querySelector('[data-feature-days]').value);const r=await client().rpc('sun_dev_set_feature_override',{p_workspace:ws,p_feature:feature,p_enabled:true,p_days:days,p_note:'Выдано из SaaS-кабинета'});if(r.error)return toast(r.error.message,'error');toast('Индивидуальный доступ выдан.','success');}; + }); + } + async function setSubscription(ws,plan,days,status){ + const {error}=await client().rpc('sun_dev_set_subscription',{p_workspace:ws,p_plan:plan,p_days:days,p_status:status});if(error)return toast(error.message,'error');toast('Подписка обновлена.','success');await loadAdminRows();if(workspace()?.id===ws)await refreshSnapshot(true); + } + + function clearWorkspaceLocalData(){ + const preserve=new Set(['sunCloudV2Config','sunCloudClientIdV2','sunStaticMapGeocodeCacheV2']); + const keys=[];for(let i=0;ilocalStorage.removeItem(k)); + } + function seedWorkspace(mode){ + clearWorkspaceLocalData(); + if(mode==='demo'){ + const demoBoxes=[ + {id:'demo-box-1',name:'Демо · Рыбные канапе',category:0,catalogSection:'Боксы',price:4500,weight:520,guests:10,photo:'catalog/001.jpg',composition:['лосось','креветка','крем-чиз'],ingredients:[['Канапе рыбные',30,'шт.']]}, + {id:'demo-box-2',name:'Демо · Мясные закуски',category:0,catalogSection:'Боксы',price:4200,weight:500,guests:10,photo:'catalog/002.jpg',composition:['ростбиф','пастрами','овощи'],ingredients:[['Канапе мясные',30,'шт.']]}, + {id:'demo-box-3',name:'Демо · Десертный бокс',category:0,catalogSection:'Боксы',price:3900,weight:430,guests:10,photo:'catalog/003.jpg',composition:['мини-десерты','ягоды'],ingredients:[['Мини-десерты',20,'шт.']]} + ]; + const demoOrders=[{id:1,event:'Демо · День рождения',date:new Date(Date.now()+7*86400000).toISOString().slice(0,10),time:'18:00',contact:'Анна',phone:'+7 900 000-00-00',address:'Демо-адрес',status:'Новый',guestsCount:20,prepayment:0,lines:[{id:'demo-box-1',qty:2},{id:'demo-box-2',qty:2}]}]; + localStorage.setItem('sunBoxes',JSON.stringify(demoBoxes));localStorage.setItem('sunOrders',JSON.stringify(demoOrders)); + }else{ + let starter=[];try{if(Array.isArray(window.SUN_OFFICIAL_CATALOG))starter=structuredClone(window.SUN_OFFICIAL_CATALOG)}catch(_){} + localStorage.setItem('sunBoxes',JSON.stringify(starter));localStorage.setItem('sunOrders','[]'); + if(window.SUN_OFFICIAL_CATALOG_VERSION)localStorage.setItem('sunOfficialCatalogVersion',String(window.SUN_OFFICIAL_CATALOG_VERSION)); + } + } + + function enhanceOnboardingGate(){ + const gate=$('sunCloudAuthGateV3'),ss=session(),ws=workspace(),st=cloud()?.status?.();if(window.SunDeveloperV22?.isPlatformAdmin?.())return;if(!gate||!ss?.user||ws||st?.membershipsLoading||st?.membershipsLoaded===false||gate.dataset.saasEnhanced==='1')return; + gate.dataset.saasEnhanced='1';const card=gate.querySelector('.sun-cloud-auth-card');if(!card)return; + card.innerHTML=`

    Создайте компанию

    ${esc(ss.user.email||'Аккаунт авторизован')} · 14 дней Полного тарифа бесплатно

    У этого аккаунта пока нет рабочей базы. Приглашения временно отключены на период локального тестирования.

    `; + const err=$('sunSaaSGateErrorV16'); + qa('[data-create]',card).forEach(b=>b.onclick=async()=>{ + const name=String($('sunSaaSCompanyNameV16')?.value||'').trim();if(!name){err.textContent='Введите название компании.';return;}b.disabled=true;err.textContent='Создаю рабочую базу…'; + try{ + const r=await client().rpc('sun_create_workspace',{p_name:name});if(r.error)throw r.error; + const workspaceId=String(r.data||''); + seedWorkspace(b.dataset.create);await cloud().adoptCurrentLocalAsWorkspace?.(workspaceId);await cloud().reloadMemberships();err.textContent='Загружаю стартовую базу…';await cloud().migrate();location.reload(); + }catch(e){err.textContent=e?.message||String(e);b.disabled=false;} + }); + card.querySelector('[data-signout]').onclick=async()=>{if(window.SunCloudV2?.signOut)return window.SunCloudV2.signOut();try{await client()?.auth.signOut()}catch(_){}location.reload();}; + } + + function applyAll(){installStyle();injectBadge();applyAccessMode();applyFeatureGates();guardFunctions();injectSettingsCard();ensureAdminNav();} + + document.addEventListener('click',e=>{ + const locked=e.target.closest('[data-sun-plan-locked]');if(locked){e.preventDefault();e.stopImmediatePropagation();return showUpgrade(locked.dataset.sunPlanLocked);} + const card=e.target.closest('.sun-saas-plan-card-locked');if(card){e.preventDefault();e.stopImmediatePropagation();return showUpgrade(card.dataset.sunFeature);} + if(snapshot?.access_mode==='read_only'||cloud()?.isSupportMode?.()){ + const t=e.target.closest('button');if(!t)return; + const text=(t.textContent||'').trim();const safe=/^(Закрыть|Обновить|Предварительный просмотр|Печать|Скачать|Сравнить тарифы|Выйти|Назад|Фильтр|Поиск)/i.test(text); + const mutating=/Сохран|Удал|Добав|Создать|Измен|Настро|Списать|Отправ|Активир|Заблокир|Выдать|Примен/i.test(text); + if(mutating&&!safe){e.preventDefault();e.stopImmediatePropagation();showUpgrade(null,cloud()?.isSupportMode?.()?'Режим поддержки: данные компании доступны только для просмотра.':'Льготный период: база доступна только для просмотра.');} + } + },true); + + function showUpgrade(feature,note=''){showPlans(feature,note);} + + function boot(){ + installStyle(); + const timer=setInterval(async()=>{ + if(document.hidden)return; + // v17.5.27: onboarding is handled by the unified Caterium auth gate. + const ws=workspace(); + if(ws?.id&&(ws.id!==lastWorkspaceId||!snapshot))await refreshSnapshot(true); + else if(ws?.id&&snapshot){applyFeatureGates();guardFunctions();injectSettingsCard();ensureAdminNav();} + else if(!ws){snapshot=null;lastWorkspaceId='';} + },5000); + window.addEventListener('sun:cloud-state-applied',()=>refreshSnapshot(true)); + window.addEventListener('sun:cloud-permissions-changed',()=>setTimeout(()=>{applyFeatureGates();guardFunctions();},50)); + document.addEventListener('click',e=>{const b=e.target.closest('header nav button');if(b&&String(b.dataset.navLabel||b.textContent||'').trim()==='Настройки')setTimeout(()=>{injectSettingsCard();applyFeatureGates();},100);},true); + setTimeout(()=>refreshSnapshot(true),1200); + } + + window.SunSaaSV16={VERSION,getSnapshot:()=>snapshot?structuredClone(snapshot):null,refresh:()=>refreshSnapshot(true),hasFeature,isWritable,showPlans,openAdmin:openPlatformAdmin}; + if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',boot,{once:true});else boot(); +})(); +; + +/* ===== MODULE: settings-polish-v1.js ===== */ +/* Sun Catering v17.5.4: settings layout polish */ +(()=>{ + 'use strict'; + if(window.__sunSettingsPolishV1)return; + window.__sunSettingsPolishV1=true; + + const $=id=>document.getElementById(id); + const qa=(sel,root=document)=>[...root.querySelectorAll(sel)]; + + const style=document.createElement('style'); + style.id='sun-settings-polish-v1-style'; + style.textContent=` + #enterprise-settings>.catalog-head{ + position:sticky;top:0;z-index:45; + margin:0 -4px 14px;padding:11px 4px 12px; + background:linear-gradient(180deg,rgba(255,255,255,.99) 0%,rgba(255,255,255,.96) 80%,rgba(255,255,255,0) 100%); + backdrop-filter:blur(9px); + } + #enterprise-settings .sun-settings-cloud-tail{grid-column:1/-1!important} + `; + document.head.appendChild(style); + + function settingsNavButton(){ + return qa('header nav button').find(b=>String(b.dataset.navLabel||b.textContent||'').trim()==='Настройки')||null; + } + function scrollSettingsTop(){ + const view=$('enterprise-settings'); + try{window.scrollTo({top:0,left:0,behavior:'auto'})}catch(_){window.scrollTo(0,0)} + try{document.scrollingElement&&(document.scrollingElement.scrollTop=0)}catch(_){} + try{view&&(view.scrollTop=0)}catch(_){} + view?.querySelector('.catalog-head')?.scrollIntoView?.({block:'start',inline:'nearest'}); + } + + function auditCard(grid){ + return qa(':scope > .enterprise-card',grid).find(card=>(card.querySelector(':scope > h2')?.textContent||'').trim()==='История изменений')||null; + } + function cloudCard(grid){ + return $('sunCloudV2Card')||qa(':scope > .enterprise-card',grid).find(card=>/^(Облачная синхронизация|Облако Supabase)$/.test((card.querySelector(':scope > h2')?.textContent||'').trim()))||null; + } + function moveBefore(node,anchor){ + if(!node||!anchor||node===anchor||node.parentElement!==anchor.parentElement)return false; + if(node.nextElementSibling===anchor)return false; + window.SunSafe.insertBefore(anchor.parentElement,node,anchor);return true; + } + + let ordering=false; + function orderSettingsCards(){ + if(ordering)return; + const grid=document.querySelector('#enterprise-settings .enterprise-grid');if(!grid)return; + ordering=true; + try{ + const audit=auditCard(grid); + if(!audit)return; + const requisites=$('sunReceiptSettingsCard'); + const qr=$('sunDefaultQrCard'); + const blank=$('sunBlankSettingsCardV3'); + const cloud=cloudCard(grid); + if(cloud)cloud.classList.add('sun-settings-cloud-tail','wide'); + + // Bottom operational block: company requisites -> QR -> order blank -> cloud -> history. + // Reinsert in the desired order so dynamically injected cards settle into one stable tail block. + [requisites,qr,blank,cloud].forEach(card=>{if(card&&grid.contains(card))moveBefore(card,audit)}); + }finally{ordering=false;} + } + + let timer=0; + function scheduleOrder(delay=40){clearTimeout(timer);timer=setTimeout(orderSettingsCards,delay)} + + function boot(){ + const btn=settingsNavButton(); + btn?.addEventListener('click',()=>{ + scrollSettingsTop(); + setTimeout(scrollSettingsTop,0); + setTimeout(scrollSettingsTop,100); + scheduleOrder(160); + scheduleOrder(420); + },true); + const view=$('enterprise-settings'); + if(view){ + new MutationObserver(()=>scheduleOrder(30)).observe(view,{childList:true,subtree:true}); + if(view.classList.contains('on')){scrollSettingsTop();scheduleOrder(120)} + } + window.addEventListener('sun:cloud-state-applied',()=>scheduleOrder(80)); + window.addEventListener('suncloudsync',()=>scheduleOrder(80)); + } + if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',boot,{once:true});else boot(); +})(); +; + +/* ===== MODULE: address-verify-v1.js ===== */ +/* Sun Catering v17.5.4: explicit precise-address verification */ +(()=>{ + 'use strict'; + if(window.__sunAddressVerifyV1)return; + window.__sunAddressVerifyV1=true; + + const $=id=>document.getElementById(id); + const esc=window.SunSafe.escapeHTML; + const val=id=>String($(id)?.value||'').trim(); + const CACHE_KEY='sunPreciseAddressSearchCacheV1'; + let lastRequest=0,selected=null,searchSeq=0,applying=false; + + const style=document.createElement('style'); + style.id='sun-address-verify-v1-style'; + style.textContent=` + .sun-address-verify{margin-top:10px;border-top:1px solid #e5e8eb;padding-top:10px} + .sun-address-verify-note{font-size:11px;line-height:1.45;color:#6d7782;margin:0 0 9px} + .sun-address-candidates{display:grid;gap:7px} + .sun-address-candidate{width:100%;text-align:left;border:1px solid #dfe4e8;border-radius:10px;background:#fff;padding:9px 10px;cursor:pointer;color:#2a3541} + .sun-address-candidate:hover{border-color:#b9c7d0;background:#fbfcfd} + .sun-address-candidate.on{border-color:#d2a438;box-shadow:0 0 0 2px rgba(210,164,56,.15);background:#fffdf7} + .sun-address-candidate b{display:block;font-size:12px;line-height:1.35}.sun-address-candidate small{display:block;margin-top:3px;color:#75808a;line-height:1.35} + .sun-address-candidate .sun-address-chips{display:flex;gap:5px;flex-wrap:wrap;margin-top:6px}.sun-address-candidate .sun-address-chips span{font-size:9px;font-weight:800;padding:3px 6px;border-radius:999px;background:#eef2f4;color:#53616c} + .sun-address-selected{margin-top:9px;padding:9px 10px;border:1px solid #e4e7e9;border-radius:10px;background:#fafbfc;font-size:11px;color:#59656f;line-height:1.45} + #orderGeoConfirm{display:none;margin-top:9px;width:100%;justify-content:center}.sun-address-verify.busy{opacity:.7;pointer-events:none} + `; + document.head.appendChild(style); + + function readCache(){try{return JSON.parse(localStorage.getItem(CACHE_KEY)||'{}')||{}}catch(_){return{}}} + function writeCache(cache){try{localStorage.setItem(CACHE_KEY,JSON.stringify(cache))}catch(_){} } + function cleanAddressParts(a={}){ + return { + region:String(a.state||a.region||'').trim(), + locality:String(a.city||a.town||a.village||a.municipality||a.hamlet||a.isolated_dwelling||'').trim(), + district:String(a.city_district||a.state_district||a.county||a.borough||a.suburb||'').trim(), + street:String(a.road||a.pedestrian||a.residential||a.footway||a.path||'').trim(), + house:String(a.house_number||'').trim(), + postcode:String(a.postcode||'').trim() + }; + } + function queryParts(){ + const region=val('deliveryRegion')==='Другой регион'?'':val('deliveryRegion'); + return [region,val('deliveryLocality'),val('deliveryDistrict'),val('deliveryStreet'),val('deliveryHouse')?`д. ${val('deliveryHouse')}`:'',val('deliveryCorpus')?`корп. ${val('deliveryCorpus')}`:''].filter(Boolean); + } + function queryText(){return queryParts().join(', ')} + function mapAddress(){return queryText()} + + async function fetchJson(url,timeout=6500){ + const controller=new AbortController(),timer=setTimeout(()=>controller.abort(),timeout); + try{const r=await fetch(url,{method:'GET',mode:'cors',credentials:'omit',cache:'default',signal:controller.signal});if(!r.ok)throw new Error(`HTTP ${r.status}`);return await r.json();} + finally{clearTimeout(timer)} + } + async function searchNominatim(query){ + const wait=Math.max(0,1100-(Date.now()-lastRequest));if(wait)await new Promise(r=>setTimeout(r,wait));lastRequest=Date.now(); + const region=val('deliveryRegion'); + const params=new URLSearchParams({format:'jsonv2',addressdetails:'1',limit:'5',countrycodes:'ru','accept-language':'ru',q:query}); + if(region==='Санкт-Петербург'||region==='Ленинградская область'){ + params.set('viewbox','27.3,61.4,34.5,58.0');params.set('bounded','1'); + }else if(!region){params.set('viewbox','27.3,61.4,34.5,58.0');} + const data=await fetchJson(`https://nominatim.openstreetmap.org/search?${params.toString()}`); + return (Array.isArray(data)?data:[]).filter(r=>Number.isFinite(Number(r.lat))&&Number.isFinite(Number(r.lon))).map(r=>({ + lat:Number(r.lat),lon:Number(r.lon),formatted:String(r.display_name||query),provider:'nominatim',address:r.address||{},parts:cleanAddressParts(r.address||{}),importance:Number(r.importance||0) + })); + } + async function searchPhoton(query){ + const data=await fetchJson(`https://photon.komoot.io/api/?limit=5&lang=ru&q=${encodeURIComponent(query)}`); + return (Array.isArray(data?.features)?data.features:[]).map(f=>{ + const c=f?.geometry?.coordinates,p=f?.properties||{};if(!Array.isArray(c)||!Number.isFinite(Number(c[0]))||!Number.isFinite(Number(c[1])))return null; + const address={state:p.state||'',city:p.city||'',town:p.city||'',county:p.county||'',road:p.street||p.name||'',house_number:p.housenumber||'',postcode:p.postcode||''}; + return {lat:Number(c[1]),lon:Number(c[0]),formatted:[p.name,p.housenumber,p.street,p.district,p.city,p.county,p.state,p.postcode].filter(Boolean).join(', ')||query,provider:'photon',address,parts:cleanAddressParts(address),importance:0}; + }).filter(Boolean); + } + async function searchCandidates(query){ + const key=query.toLowerCase(),cache=readCache(); + if(Array.isArray(cache[key])&&cache[key].length)return cache[key]; + let rows=[];try{rows=await searchNominatim(query)}catch(_){rows=[]} + if(!rows.length){try{rows=await searchPhoton(query)}catch(_){rows=[]}} + if(rows.length){cache[key]=rows.slice(0,5);const keys=Object.keys(cache);if(keys.length>80)keys.slice(0,keys.length-80).forEach(k=>delete cache[k]);writeCache(cache)} + return rows.slice(0,5); + } + + function candidateTitle(c){ + const p=c.parts||{}; + return [p.street,p.house?`д. ${p.house}`:'',p.locality].filter(Boolean).join(', ')||c.formatted; + } + function candidateMeta(c){ + const p=c.parts||{}; + return [p.district,p.region,p.postcode].filter(Boolean).join(' · ')||c.formatted; + } + function renderCandidates(rows){ + const host=$('sunAddressCandidates');if(!host)return; + if(!rows.length){host.innerHTML='
    Точный вариант не найден. Добавьте населённый пункт или район и попробуйте ещё раз.
    ';return} + host.innerHTML=rows.map((c,i)=>``).join(''); + host.querySelectorAll('[data-address-candidate]').forEach(btn=>btn.addEventListener('click',()=>selectCandidate(rows[Number(btn.dataset.addressCandidate)],btn))); + } + function setExistingStatus(text,kind='warn'){ + const s=$('orderGeoStatus');if(s){s.className=`geo-order-status ${kind}`;const span=s.querySelector('span');if(span)span.textContent=text} + } + function selectCandidate(candidate,button){ + selected=candidate; + document.querySelectorAll('.sun-address-candidate').forEach(x=>x.classList.toggle('on',x===button)); + const confirm=$('orderGeoConfirm');if(confirm){confirm.style.display='inline-flex';confirm.disabled=false} + const selectedBox=$('sunAddressSelected');if(selectedBox)selectedBox.innerHTML=`Выбран вариант:
    ${esc(candidate.formatted)}
    Проверьте точку ниже и нажмите «Адрес верный — использовать».`; + const mini=$('orderGeoMini');if(mini&&window.SunStaticMap?.mediaHtml)mini.innerHTML=window.SunStaticMap.mediaHtml(candidate,'Проверка точного адреса')+'
    © OpenStreetMap contributors
    '; + const precision=$('orderGeoPrecision');if(precision)precision.textContent=`${candidate.lat.toFixed(5)}, ${candidate.lon.toFixed(5)}`; + setExistingStatus('Проверьте выбранную точку','warn'); + } + function setField(id,value,{onlyIf=false}={}){const el=$(id);if(!el||!value)return;if(onlyIf&&String(el.value||'').trim())return;el.value=value;el.dispatchEvent(new Event(el.tagName==='SELECT'?'change':'input',{bubbles:true}))} + function normalizeRegion(region){ + if(/санкт[- ]?петербург/i.test(region))return 'Санкт-Петербург'; + if(/ленинград/i.test(region))return 'Ленинградская область'; + return region||''; + } + function composeFullAddress(){ + if(typeof window.sunComposeDeliveryAddress==='function')return window.sunComposeDeliveryAddress(); + const parts=[val('deliveryRegion'),val('deliveryLocality'),val('deliveryDistrict'),val('deliveryStreet'),val('deliveryHouse')?`д. ${val('deliveryHouse')}`:'',val('deliveryCorpus')?`корп. ${val('deliveryCorpus')}`:'',val('deliveryEntrance')?`подъезд ${val('deliveryEntrance')}`:'',val('deliveryFloor')?`этаж ${val('deliveryFloor')}`:'',val('deliveryApartment')?`кв. ${val('deliveryApartment')}`:'',val('deliveryOffice')?`офис ${val('deliveryOffice')}`:''].filter(Boolean); + const a=parts.join(', ');if($('address'))$('address').value=a;return a; + } + function getDraft(){try{return draft}catch(_){return null}} + function getOrders(){try{return orders}catch(_){return[]}} + function confirmCandidate(){ + if(!selected)return; + const candidate=selected,p=candidate.parts||{}; + applying=true; + const region=normalizeRegion(p.region); + if(region){ + const regionSelect=$('deliveryRegion'); + if(regionSelect&&[...regionSelect.options].some(o=>o.value===region||o.text===region)){regionSelect.value=region;regionSelect.dispatchEvent(new Event('change',{bubbles:true}))} + else if(regionSelect){const option=new Option(region,region);regionSelect.add(option);regionSelect.value=region;regionSelect.dispatchEvent(new Event('change',{bubbles:true}))} + } + const localityValue=(region==='Санкт-Петербург'&&/санкт[- ]?петербург/i.test(p.locality||''))?'':(p.locality||''); + const localityEl=$('deliveryLocality');if(localityEl){localityEl.value=localityValue;localityEl.dispatchEvent(new Event('input',{bubbles:true}))} + setField('deliveryDistrict',p.district||'',{onlyIf:false}); + setField('deliveryStreet',p.street||val('deliveryStreet'),{onlyIf:false}); + setField('deliveryHouse',p.house||val('deliveryHouse'),{onlyIf:false}); + const full=composeFullAddress(); + const d=getDraft(); + if(d){ + d.deliveryRegion=val('deliveryRegion');d.deliveryLocality=val('deliveryLocality');d.deliveryDistrict=val('deliveryDistrict');d.deliveryStreet=val('deliveryStreet');d.deliveryHouse=val('deliveryHouse');d.deliveryCorpus=val('deliveryCorpus');d.address=full; + d.geo={lat:candidate.lat,lon:candidate.lon,formatted:candidate.formatted,sourceMapAddress:mapAddress(),sourceAddress:full,verifiedAt:new Date().toISOString(),confirmedAt:new Date().toISOString(),confirmed:true,provider:candidate.provider||'nominatim',geoVersion:3}; + if(d.id){const saved=getOrders().find(o=>String(o.id)===String(d.id));if(saved){Object.assign(saved,{deliveryRegion:d.deliveryRegion,deliveryLocality:d.deliveryLocality,deliveryDistrict:d.deliveryDistrict,deliveryStreet:d.deliveryStreet,deliveryHouse:d.deliveryHouse,deliveryCorpus:d.deliveryCorpus,address:full,geo:structuredClone(d.geo)});try{persist()}catch(_){}}} + } + applying=false; + if($('address'))$('address').dispatchEvent(new Event('input',{bubbles:true})); + setTimeout(()=>{ + setExistingStatus('Адрес подтверждён','ok'); + const precision=$('orderGeoPrecision');if(precision)precision.textContent=candidate.formatted; + const confirm=$('orderGeoConfirm');if(confirm){confirm.textContent='Адрес подтверждён ✓';confirm.disabled=true} + const create=$('orderGeoCreate');if(create)create.textContent='Проверить другой адрес'; + try{window.SunEnterprise?.toast?.('Точка доставки подтверждена.','success')}catch(_){} + },20); + } + + async function runSearch(){ + const query=queryText(); + if(!val('deliveryStreet')||!val('deliveryHouse')){try{window.SunEnterprise?.toast?.('Для точной проверки укажите улицу и дом.','warn')}catch(_){alert('Для точной проверки укажите улицу и дом.')}return} + const root=$('sunAddressVerify');if(root)root.classList.add('busy'); + const seq=++searchSeq;selected=null; + const confirm=$('orderGeoConfirm');if(confirm){confirm.style.display='none';confirm.disabled=false;confirm.textContent='Адрес верный — использовать'} + const host=$('sunAddressCandidates');if(host)host.innerHTML='
    Ищу подходящие адреса…
    '; + setExistingStatus('Идёт поиск точного адреса','warn'); + try{ + const rows=await searchCandidates(query);if(seq!==searchSeq)return;renderCandidates(rows);setExistingStatus(rows.length?'Выберите точный вариант':'Адрес не найден',rows.length?'warn':'warn'); + }finally{root?.classList.remove('busy')} + } + + function resetVerification(){ + if(applying)return; + selected=null;searchSeq++; + const host=$('sunAddressCandidates');if(host)host.innerHTML=''; + const box=$('sunAddressSelected');if(box)box.innerHTML=''; + const confirm=$('orderGeoConfirm');if(confirm){confirm.style.display='none';confirm.disabled=false;confirm.textContent='Адрес верный — использовать'} + const d=getDraft();if(d?.geo?.confirmed){d.geo=null} + } + + function boot(){ + const geo=$('orderGeoBox'),create=$('orderGeoCreate');if(!geo||!create)return; + let root=$('sunAddressVerify'); + if(!root){ + root=document.createElement('div');root.id='sunAddressVerify';root.className='sun-address-verify'; + root.innerHTML='

    Чтобы не перепутать одинаковые улицы в разных населённых пунктах, нажмите «Проверить адрес», выберите полный вариант и подтвердите точку на карте.

    Поиск адреса: OpenStreetMap/Nominatim. © OpenStreetMap contributors.
    '; + const mini=$('orderGeoMini');if(mini)window.SunSafe.insertBefore(geo,root,mini);else geo.appendChild(root); + const selectedBox=$('sunAddressSelected');new MutationObserver(()=>{selectedBox.style.display=selectedBox.textContent.trim()?'block':'none'}).observe(selectedBox,{childList:true,subtree:true,characterData:true}); + } + let confirm=$('orderGeoConfirm'); + if(!confirm){confirm=document.createElement('button');confirm.id='orderGeoConfirm';confirm.type='button';confirm.className='primary';confirm.textContent='Адрес верный — использовать';$('orderGeoMini')?.insertAdjacentElement('afterend',confirm);confirm.addEventListener('click',confirmCandidate)} + create.textContent='Проверить адрес'; + // Capture before the legacy handler so the app no longer silently accepts the first geocoder result. + create.addEventListener('click',e=>{e.preventDefault();e.stopImmediatePropagation();runSearch()},true); + ['deliveryRegion','deliveryLocality','deliveryDistrict','deliveryStreet','deliveryHouse','deliveryCorpus'].forEach(id=>$(id)?.addEventListener(id==='deliveryRegion'?'change':'input',resetVerification)); + $('address')?.addEventListener('input',()=>setTimeout(()=>{const d=getDraft();if(create&&!d?.geo?.confirmed)create.textContent='Проверить адрес'},0)); + } + if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',()=>setTimeout(boot,50),{once:true});else setTimeout(boot,50); +})(); +; + + +/* ===== MODULE: catalog-photo-resolver-v17.5.14.js ===== */ +(function(){ + 'use strict'; + if(window.__sunCatalogPhotoResolver17514)return;window.__sunCatalogPhotoResolver17514=true; + const SOURCE_URLS=["https://static.tildacdn.com/stor3131-6532-4762-a534-353638663730/11f4ce48f250257ec824a12e1c541150.jpg","https://static.tildacdn.com/stor6634-3466-4635-a164-386637346334/e94521aba11d1824218728cee4312c03.jpg","https://static.tildacdn.com/stor3034-3036-4334-b233-303363326236/d72f45ce6d6a044d8d892a0718f29d89.jpg","https://static.tildacdn.com/stor3662-3435-4561-b031-663437646233/efd5e71d2a8654b0afa26a6dd7a0595a.jpg","https://static.tildacdn.com/stor6133-6537-4533-a166-666430373166/014bff38be088e524c0a4fd2ee1daab3.jpg","https://static.tildacdn.com/stor6161-3163-4032-b338-363935363839/7b9abb2bc27c854236e234383f044c20.jpg","https://static.tildacdn.com/stor3065-6462-4666-b134-376564613831/527188ad5747060eaa88cdd6b033e1b7.jpg","https://static.tildacdn.com/stor6238-3635-4936-a361-343062343130/6c2841340110a32dee16481cf5776858.jpg","https://static.tildacdn.com/stor6231-3837-4666-b564-656661306534/a9b0efe3696fa0cd6530e3100cce2447.jpg","https://static.tildacdn.com/stor3162-6131-4337-b731-376266363535/6998754c49e80fca1df239d01e91b239.jpg","https://static.tildacdn.com/stor6264-3139-4939-b331-623963306665/c62d84a2c9de7fd115f9878617709a99.jpg","https://static.tildacdn.com/stor3134-6638-4635-b032-623233383830/413674a63540f26bbcded51a2f37c3a7.jpg","https://static.tildacdn.com/stor6438-6236-4831-b038-346437643037/b7305f8e759e161407a9996e0218cf83.jpg","https://static.tildacdn.com/stor3134-6638-4635-b032-623233383830/413674a63540f26bbcded51a2f37c3a7.jpg","https://static.tildacdn.com/stor3536-6463-4535-a135-653763366634/3b6eec9a6310eea531c8cb70b00955a9.jpg","https://static.tildacdn.com/stor3339-6634-4634-b537-363366353636/7ef8a4de7cfc055f633ab29ad7e5fca4.jpg","https://static.tildacdn.com/stor3030-3766-4661-b364-313536363731/f7a5bb4329e864b12eb5aed656c13c2b.jpg","https://static.tildacdn.com/stor3865-3463-4832-a531-326663623661/0ab2f6a1959adeb68f6c64e21fc52e6c.jpg","https://static.tildacdn.com/stor3834-6339-4234-a638-656236303531/d70c6ff9606b8f29f4d0a334643c6fa5.jpg","https://static.tildacdn.com/stor3038-3736-4564-a635-333961643763/ae631a3930ee087c993f0eb5ae477388.jpg","https://static.tildacdn.com/stor3638-3139-4562-b430-356363633431/a0eae79a99b4f1ca59be8a445d975f94.jpg","https://static.tildacdn.com/stor3536-3766-4033-b235-643062373262/1e521931b938e17b0ebe397a447067bb.jpg","https://static.tildacdn.com/stor3966-6635-4139-b965-666363393466/df421e8c29457bb6a660810c14d4dbca.jpg","https://static.tildacdn.com/stor3133-6661-4839-b565-376433313839/471052a775dfa8fae813f1b043171e21.jpg","https://static.tildacdn.com/stor3539-3831-4031-b532-323963396137/235a566973dc035faa18ef9a57b4bef6.jpg","https://static.tildacdn.com/stor6362-6234-4063-b636-373636373465/6c868c53823c2c2799bacbef8b7ec5dc.jpg","https://static.tildacdn.com/stor3439-6237-4364-a566-666531346532/a368a54d5dd3ef381bdb587f81dd3953.jpg","https://static.tildacdn.com/stor6664-6363-4635-b632-383362383935/f5779eef4eaff67dec0cb8a9868fe912.jpg","https://static.tildacdn.com/stor6166-3066-4535-b333-356263356363/25951fa13b57555854635d8fe1a1ecc9.jpg","https://static.tildacdn.com/stor3131-3637-4837-b039-386331346435/c4fd5306cc629e81fe974d0e1238eb06.jpg","https://static.tildacdn.com/stor3235-3535-4639-b164-663664323936/c3dc9dc2931b0f950034495dd8cc6b46.jpg","https://static.tildacdn.com/stor3235-3661-4038-b035-613032303361/6f2c3ac522ad9d7e73d14f3fc4c47630.jpg","https://static.tildacdn.com/stor6166-3066-4535-b333-356263356363/25951fa13b57555854635d8fe1a1ecc9.jpg","https://static.tildacdn.com/stor3836-3438-4835-a132-663962396637/fac8bc386bd98959d75c6b71cd83471b.jpg","https://static.tildacdn.com/stor3334-6361-4633-a266-643132323266/74b63ae226e1ebfbd116a822be4f642e.jpg","https://static.tildacdn.com/stor3136-3834-4562-b037-396264363263/e8c1bbdff0574f52a2184a33d6f2115c.jpg","https://static.tildacdn.com/stor6334-3534-4266-a664-356162323565/c13d0896543e32d12ccad0c0a108dac5.jpg","https://static.tildacdn.com/stor3364-6237-4561-b432-326230363433/3233d197bebae6174651a7732ec8ca3e.jpg","https://static.tildacdn.com/stor3239-6231-4130-a363-643732356464/1a72939cda10b9fc447157193a2c69cf.jpg","https://static.tildacdn.com/stor3832-3732-4635-b732-636135393265/3e414b433c0aab18c0a1131d256c92e5.jpg","https://static.tildacdn.com/stor6431-3634-4935-a638-323064343638/8d7c3b547b6e5abd09fe498c2e0b3853.jpg","https://static.tildacdn.com/stor3362-3331-4631-b738-646138333763/b3932c942568e83ab0babc98b77ee5c3.jpg","https://static.tildacdn.com/stor3565-6565-4739-b638-343639336130/ce325e928513a5c7ad2a5c9328927bd0.jpg","https://static.tildacdn.com/stor3131-3335-4135-b962-356338643761/dabf8e59f8b405c2998cc725627a832a.jpg","https://static.tildacdn.com/stor6365-6464-4263-b066-653333643438/eba899d7b96c0e9f077467bb4076cf56.jpg","https://static.tildacdn.com/stor3064-6631-4061-b231-393166393931/2e792a2bd15012a2c134a17213d64706.jpg","https://static.tildacdn.com/stor3936-6435-4438-a564-643362376266/c0d42f2444a396f1b836f4f5e2d2a2b8.jpg","https://static.tildacdn.com/stor6635-6661-4361-b031-303465633437/9c824de9c9ad08c435f64d62876ca8f2.jpg","https://static.tildacdn.com/stor6530-6533-4235-b631-613831393533/2651153cdc6a396acfe9c7c1f1ffa6e3.jpg","https://static.tildacdn.com/stor3566-6437-4361-b137-343366323736/8f1f58eb858f669ca4acea85ad8bbbce.jpg","https://static.tildacdn.com/stor3363-3539-4532-b731-343434323163/3d114d5bd96795183d342a37ef5bacb7.jpg","https://static.tildacdn.com/stor3234-6630-4664-b230-616165303866/a350de68077dc437a4a7c0d1f07db97b.jpg","https://static.tildacdn.com/stor6566-6236-4038-b038-326331333761/acfb6d855c780c92c4cd6e667229a9d7.jpg","https://static.tildacdn.com/stor3662-3963-4031-b065-646235373965/f8f3bd5b10fe9142e453766305684126.jpg","https://static.tildacdn.com/stor3461-3363-4063-a530-336136633338/951f5c4bcaa35b878fd8f0d4cdf5812a.jpg","https://static.tildacdn.com/stor6335-3638-4432-b230-393163396334/d08deb92d2102c1e81002f0ff5b1cd18.jpg","https://static.tildacdn.com/stor3163-3035-4466-b435-303832663162/2a85461ba4c7132af333c481f6974334.jpg","https://static.tildacdn.com/stor3132-6233-4466-b164-376530633465/a5159b1de5f6bd3d2dde7609c1853f82.jpg","https://static.tildacdn.com/stor3165-6138-4663-b433-393039653034/4ad56410ecf6a163824061d08cb297da.jpg","https://static.tildacdn.com/stor6437-6361-4833-a537-623832376136/29ff2e5084a5f816954ea5621004b0c0.jpg","https://static.tildacdn.com/stor3765-3765-4663-a333-623738326235/02eaafb2bae5cef3edaa761aa7cea142.jpg","https://static.tildacdn.com/stor6365-3039-4931-a333-623061333865/ceb789e801d358ff78f2a4800f79f78c.jpg","https://static.tildacdn.com/stor6262-6636-4563-b332-383166313034/73a699e67040500f90edd197152cb0b9.jpg","https://static.tildacdn.com/stor3366-3663-4664-a136-633432326530/8a056937be29f8f531767a565fc2fb45.jpg","https://static.tildacdn.com/stor3566-3862-4334-b465-376164633737/7ad059a9bf198827dfd142008f81bb28.jpg","https://static.tildacdn.com/stor3532-6333-4432-b037-373031393364/3f233fc73a9932953876810c2530779b.jpg","https://static.tildacdn.com/stor6461-6235-4134-a630-393261313034/f00b5038885f425bf4463b0f070416b2.jpg","https://static.tildacdn.com/stor3963-3634-4262-b363-393536623037/7f3160b562bdb81243fcca8b07ea73ca.jpg","https://static.tildacdn.com/stor6134-6534-4032-b863-326334303439/b6299036af0105e24b66b92fc149fceb.jpg","https://static.tildacdn.com/stor6330-3331-4534-a431-616637323265/cc72f08366e2b3bb3b2cec181fcfb6b2.jpg","https://static.tildacdn.com/stor3864-6138-4939-a263-323633343831/e41074621630fb64c6551fde54a0491a.jpg","https://static.tildacdn.com/stor3530-3337-4465-a331-393235636139/6bed2df48d65e7dfdf673cc6ab1d66f0.jpg","https://static.tildacdn.com/stor3132-6432-4637-b930-353763333565/0e6665f2fddcdc6a2538ff0e4d6714e3.jpg","https://static.tildacdn.com/stor3063-6361-4562-b864-386439643866/5a0a9e8680146e4af16b0b3a71e5e344.jpg","https://static.tildacdn.com/stor3864-3766-4132-a362-336565363634/331d7a3cb423efad3851318ebc243503.jpg","https://static.tildacdn.com/stor3764-3435-4336-a638-386530643039/b66c7fb8fba795f74358798f008ff284.jpg","https://static.tildacdn.com/stor3764-3435-4336-a638-386530643039/b66c7fb8fba795f74358798f008ff284.jpg","https://static.tildacdn.com/stor3764-3435-4336-a638-386530643039/b66c7fb8fba795f74358798f008ff284.jpg","https://static.tildacdn.com/stor3764-3435-4336-a638-386530643039/b66c7fb8fba795f74358798f008ff284.jpg","https://static.tildacdn.com/stor3661-6362-4365-a166-396432366336/b3461ed10ad1d21ba6f327e14f87561a.jpg","https://static.tildacdn.com/stor6136-3763-4363-b632-613035666139/b89d5f88da96f52526ccba83cfb1e631.jpg","https://static.tildacdn.com/stor3764-3435-4336-a638-386530643039/b66c7fb8fba795f74358798f008ff284.jpg","https://static.tildacdn.com/stor3764-3435-4336-a638-386530643039/b66c7fb8fba795f74358798f008ff284.jpg","https://static.tildacdn.com/stor3530-3337-4465-a331-393235636139/6bed2df48d65e7dfdf673cc6ab1d66f0.jpg","https://static.tildacdn.com/stor3535-3432-4638-b763-636162616233/f392e1f738f4ec51be28ac85ff7f34d1.jpg","https://static.tildacdn.com/stor6263-6138-4433-a639-386465356138/915334ed7ec0a973fc528d5b1bcddeb9.jpg","https://static.tildacdn.com/stor6161-3666-4430-b437-303230613863/b6c27133c86b74e669c4d8d14cddaa98.jpg","https://static.tildacdn.com/stor6433-6530-4938-a264-366133396635/26c04c584ac3ce62893f86e3f21ef6a4.jpg","https://static.tildacdn.com/stor3438-3336-4038-b563-636464663765/26624b9790a69468292e752308984af1.jpg","https://static.tildacdn.com/stor3430-3734-4935-a666-616161393566/92e1260daf92944a35c6ce5a64ca38f3.jpg","https://static.tildacdn.com/stor3937-3731-4331-a333-333266353435/018e8f56c6b0053168f0d0cf73872966.jpg","https://static.tildacdn.com/stor3939-3434-4061-a231-636661616561/607dc1f31bc1ca66fb1a611d49407aa5.jpg","https://static.tildacdn.com/stor3836-3436-4239-a536-353332396264/232fcd6e93bf0abc918ef73529cded4b.jpg","https://static.tildacdn.com/stor3065-3632-4533-b333-636566613663/7979f348481e979905836febdf7933a6.jpg","https://static.tildacdn.com/stor3761-3162-4461-b966-323139613763/08d95a9c71b402415f9479b3f9520892.jpg","https://static.tildacdn.com/stor3033-3137-4930-b264-666532373134/69925db7e39c25877ac58869c15037a6.jpg","https://static.tildacdn.com/stor3333-3839-4537-a236-623030313665/4130c8f6bd2b58b41c4edd83eee922a9.jpg","https://static.tildacdn.com/stor6230-3133-4564-b237-646535613637/a2a99a4eeb22cb33b5b5cea2a79a17cb.jpg","https://static.tildacdn.com/stor3137-6538-4132-b739-653932333233/15a1c8db984c5e68f357b2ee12a18c2a.jpg","https://static.tildacdn.com/stor3031-6132-4334-b330-633933383437/7e711a925bef78f54c6417cfed424f7b.jpg","https://static.tildacdn.com/stor6565-3862-4536-a531-396336343530/94e933aa7cb2ca8cc2f9a6975508c69b.jpg","https://static.tildacdn.com/stor3130-6665-4166-a562-346464663365/1ace83e57a93e4bd15c4117e6ebd10f4.jpg","https://static.tildacdn.com/stor6666-6336-4235-b437-663731323432/f733be8a64d1186ac1153c3eba271d59.jpg","https://static.tildacdn.com/stor6561-6664-4163-a232-323236383637/1b4aa616a89592851de475a7633530b2.jpg","https://static.tildacdn.com/stor3364-3366-4961-a230-393136633735/bd16ccb4240c13b0408fcd3cdb263c7f.jpg","https://static.tildacdn.com/stor3133-3966-4466-a633-353062663934/19cfdd0803e1252edb0a229fe8cc10e1.jpg","https://static.tildacdn.com/stor3862-3737-4631-a637-656265333935/be0ca89c31666c8d357814c69f0c1527.jpg","https://static.tildacdn.com/stor3730-3136-4137-b861-393762656436/9b8faf6a48cff9bc8b16cef36396d144.jpg","https://static.tildacdn.com/stor6137-3965-4334-a432-316165313635/7e2aebaffa009dfc1b7a580ceac8a640.jpg","https://static.tildacdn.com/stor3937-3432-4537-a233-646339376631/d0ca8846eba6810423c5421e7060c567.jpg","https://static.tildacdn.com/stor3437-6365-4139-b134-326232643133/8d0a33ea4f3ace73aa73679aa8ac3b4d.jpg","https://static.tildacdn.com/stor6334-3463-4166-b330-643438393235/49f88576b7fb7573735b20dc4161de96.jpg","https://static.tildacdn.com/stor3139-3866-4633-a234-663139643733/5c7060058669060670ed95fb719078c3.jpg"]; + const pad=n=>String(Math.max(1,Math.min(113,Number(n)||0))).padStart(3,'0'); + const indexFrom=value=>{ + const s=String(value||''); + let m=s.match(/current-(\d{3})\.jpg/i);if(m)return Number(m[1]); + m=s.match(/catalog-20260902-(\d{3})/i);if(m)return Number(m[1]); + const clean=s.split(/[?#]/)[0],remoteIndex=SOURCE_URLS.findIndex(url=>url===clean); + return remoteIndex>=0?remoteIndex+1:0; + }; + function indexFor(itemOrPath){ + if(itemOrPath&&typeof itemOrPath==='object'){ + const byPhoto=indexFrom(itemOrPath.photo);if(byPhoto)return byPhoto; + const byId=indexFrom(itemOrPath.id);if(byId)return byId; + const byOrder=Number(itemOrPath.catalogOrder||0);if(byOrder>=1&&byOrder<=113)return byOrder; + } + return indexFrom(itemOrPath); + } + function candidates(itemOrPath){ + const idx=indexFor(itemOrPath); + if(!idx)return [String(itemOrPath&&typeof itemOrPath==='object'?itemOrPath.photo||'':itemOrPath||'')].filter(Boolean); + const local=`catalog/current-${pad(idx)}.jpg`; + const api=(location.protocol==='http:'||location.protocol==='https:')?`/api/catalog-photo?i=${idx}`:''; + const remote=SOURCE_URLS[idx-1]||''; + const requested=String(itemOrPath&&typeof itemOrPath==='object'?itemOrPath.photo||'':itemOrPath||''); + return [...new Set([requested,local,api,remote,'sun-logo.png'].filter(Boolean))]; + } + function fallbackImage(img){ + if(!(img instanceof HTMLImageElement))return; + let idx=Number(img.dataset.sunCatalogPhotoIndex||0)||indexFor(img.getAttribute('src')||img.src); + if(!idx)return; + img.dataset.sunCatalogPhotoIndex=String(idx); + const list=candidates(`catalog/current-${pad(idx)}.jpg`); + let step=Number(img.dataset.sunCatalogPhotoStep||0); + const currentAbs=(()=>{try{return new URL(img.getAttribute('src')||img.src,document.baseURI).href}catch(_){return String(img.src||'')}})(); + while(step{const img=event.target;if(img instanceof HTMLImageElement)fallbackImage(img)},true); + function repairExisting(){document.querySelectorAll('img').forEach(img=>{if(indexFor(img.getAttribute('src')||'')&&img.complete&&img.naturalWidth===0)fallbackImage(img)})} + setTimeout(repairExisting,60);setTimeout(repairExisting,650); + window.SunCatalogPhoto={sourceUrls:SOURCE_URLS,indexFor,candidates,fallbackImage,repairExisting}; +})(); + +/* ===== MODULE: settings-tabs-v21.js ===== */ +/* Sun Catering v17.5.21: grouped Settings tabs without rewriting existing settings cards. */ +(()=>{ + 'use strict'; + if(window.__sunSettingsTabsV21)return; + window.__sunSettingsTabsV21=true; + + const $=id=>document.getElementById(id); + const qa=(sel,root=document)=>[...root.querySelectorAll(sel)]; + const TAB_KEY='sunSettingsActiveTabV1'; + const TABS=[ + {id:'account',label:'Аккаунт',hint:'Профиль, компания, тариф, пользователи и история изменений.'}, + {id:'appearance',label:'Оформление',hint:'Цвета интерфейса, боковой панели и готовые цветовые варианты.'}, + {id:'offer',label:'Предложение',hint:'Шаблон и содержимое предложения клиенту.'}, + {id:'orders',label:'Заказы',hint:'Статусы заказов и цвета состояния оплаты.'}, + {id:'references',label:'Справочники',hint:'Типы мероприятий, источники клиентов и вкладки каталога.'}, + {id:'documents',label:'Документы',hint:'Реквизиты чека, QR-код и бланк заказа.'} + ]; + const VALID=new Set(TABS.map(x=>x.id)); + let active=(()=>{try{const v=localStorage.getItem(TAB_KEY);return VALID.has(v)?v:'account'}catch(_){return'account'}})(); + let scheduled=0; + + const style=document.createElement('style'); + style.id='sun-settings-tabs-v21-style'; + style.textContent=` + #enterprise-settings .sun-settings-tabs-wrap{margin:0 0 14px;min-width:0} + #enterprise-settings .sun-settings-tabs{display:flex;gap:6px;overflow-x:auto;overscroll-behavior-inline:contain;scrollbar-width:thin;padding:3px 2px 7px;white-space:nowrap} + #enterprise-settings .sun-settings-tab{flex:1 1 0;min-width:118px;border:1px solid #dce2e6;border-radius:10px;background:#fff;color:#53616c;padding:10px 12px;font-size:13px;font-weight:800;cursor:pointer;transition:background .15s ease,border-color .15s ease,color .15s ease,box-shadow .15s ease} + #enterprise-settings .sun-settings-tab:hover{background:#f7f9fa;border-color:#c8d1d7} + #enterprise-settings .sun-settings-tab.on{background:#15364c;color:#fff;border-color:#15364c;box-shadow:0 5px 14px rgba(21,54,76,.13)} + #enterprise-settings .sun-settings-tab:focus-visible{outline:3px solid rgba(212,154,42,.28);outline-offset:2px} + #enterprise-settings .sun-settings-tab-context{display:flex;gap:7px;align-items:baseline;flex-wrap:wrap;padding:2px 4px 0;color:#6a7680;font-size:12px;line-height:1.4} + #enterprise-settings .sun-settings-tab-context b{color:#15364c;font-size:13px} + #enterprise-settings .enterprise-card{order:var(--sun-settings-order,50)} + #enterprise-settings .enterprise-card[hidden]{display:none!important} + #enterprise-settings.on #sunBlankSettingsCardV3[hidden]{display:none!important} + #enterprise-settings .enterprise-grid{align-items:start} + #enterprise-settings #sunCatalogTabsSettingsCardV21 .sun-settings-catalog-summary{display:flex;gap:7px;flex-wrap:wrap;margin:10px 0 2px} + #enterprise-settings #sunCatalogTabsSettingsCardV21 .sun-settings-catalog-chip{display:inline-flex;align-items:center;border-radius:999px;background:#f2f5f6;color:#52616c;padding:5px 8px;font-size:11px;font-weight:800} + @media(max-width:900px){ + #enterprise-settings .sun-settings-tabs{margin-right:-4px;padding-right:16px} + #enterprise-settings .sun-settings-tab{flex:0 0 auto;min-width:126px} + #enterprise-settings .sun-settings-tab-context{padding-right:8px} + } + @media(max-width:520px){ + #enterprise-settings .sun-settings-tabs-wrap{margin-bottom:11px} + #enterprise-settings .sun-settings-tab{min-width:116px;padding:9px 11px;font-size:12px} + #enterprise-settings .sun-settings-tab-context span{display:block;width:100%} + } + `; + document.head.appendChild(style); + + function heading(card){return String(card?.querySelector(':scope > h2')?.textContent||card?.querySelector('h2')?.textContent||'').trim()} + function classify(card){ + const id=String(card?.id||''),h=heading(card); + if(id==='sunCloudV2Card'||id==='sunSaaSSettingsCardV16'||/^(Профиль и аккаунт|Облачная синхронизация|Тариф и подписка|Пользователи и роли|История изменений)$/.test(h)){ + const order=id==='sunCloudV2Card'||/^(Профиль и аккаунт|Облачная синхронизация)$/.test(h)?10:id==='sunSaaSSettingsCardV16'||h==='Тариф и подписка'?20:h==='Пользователи и роли'?30:90; + return['account',order]; + } + if(id==='sunBrandThemeSettingsCard'||/^(Цвета интерфейса|Настройки интерфейса|Оформление интерфейса)$/.test(h))return['appearance',10]; + if(id==='sunOfferTemplateSettingsCard'||h==='Шаблон предложения клиенту')return['offer',10]; + if(id==='sunClientOfferSettingsCard'||h==='Настройки предложения клиенту')return['offer',20]; + if(id==='sunOrderPaymentColorsCard'||h==='Цвета оплаты заказов')return['orders',10]; + if(id==='sunOrderStatusFeatureCard'||h==='Статусы заказов')return['orders',20]; + if(id==='sunEventTypesSettingsCard'||h==='Типы мероприятий')return['references',10]; + if(id==='sunLeadSourcesSettingsCard'||h==='Источники клиентов')return['references',20]; + if(id==='sunCatalogTabsSettingsCardV21'||h==='Вкладки каталога')return['references',30]; + if(id==='sunReceiptSettingsCard'||h==='Настройки реквизитов товарного чека')return['documents',10]; + if(id==='sunDefaultQrCard'||h==='QR-код для бланка заказа')return['documents',20]; + if(id==='sunBlankSettingsCardV3'||h==='Настройки бланка')return['documents',30]; + // Any future/unrecognised settings remain reachable instead of disappearing. + return['account',60]; + } + + function ensureCatalogTabsCard(){ + const view=$('enterprise-settings'),grid=view?.querySelector('.enterprise-grid'); + if(!grid||typeof window.sunOpenCategoryManager!=='function')return; + let card=$('sunCatalogTabsSettingsCardV21'); + if(!card){ + card=document.createElement('section'); + card.id='sunCatalogTabsSettingsCardV21'; + card.className='enterprise-card wide'; + grid.appendChild(card); + } + const cats=typeof window.sunCatalogCategories==='function'?window.sunCatalogCategories():[]; + const total=Array.isArray(cats)?cats.length:0,visible=Array.isArray(cats)?cats.filter(x=>!x.hidden).length:0; + card.innerHTML=`

    Вкладки каталога

    Название, порядок, цвет и видимость разделов каталога. Скрытые разделы и их товары не удаляются.

    Всего: ${total}Видимых: ${visible}
    `; + $('sunSettingsCatalogTabsBtnV21')?.addEventListener('click',()=>window.sunOpenCategoryManager?.()); + } + + function ensureBar(){ + const view=$('enterprise-settings');if(!view)return null; + let wrap=$('sunSettingsTabsWrapV21'); + if(!wrap){ + wrap=document.createElement('div');wrap.id='sunSettingsTabsWrapV21';wrap.className='sun-settings-tabs-wrap'; + wrap.innerHTML=`
    ${TABS.map(t=>``).join('')}
    `; + const head=view.querySelector(':scope > .catalog-head'); + if(head)head.insertAdjacentElement('afterend',wrap);else view.prepend(wrap); + qa('[data-settings-tab-target]',wrap).forEach(btn=>btn.addEventListener('click',()=>activate(btn.dataset.settingsTabTarget,true))); + } + return wrap; + } + + function applyCards(){ + const view=$('enterprise-settings'),grid=view?.querySelector('.enterprise-grid');if(!grid)return; + qa(':scope > .enterprise-card',grid).forEach(card=>{ + const [tab,order]=classify(card); + card.dataset.settingsTab=tab; + card.style.setProperty('--sun-settings-order',String(order)); + card.hidden=tab!==active; + card.setAttribute('aria-hidden',tab===active?'false':'true'); + }); + } + + function renderBar(){ + const wrap=ensureBar();if(!wrap)return; + const meta=TABS.find(x=>x.id===active)||TABS[0]; + qa('[data-settings-tab-target]',wrap).forEach(btn=>{ + const on=btn.dataset.settingsTabTarget===active; + btn.classList.toggle('on',on);btn.setAttribute('aria-selected',on?'true':'false');btn.tabIndex=on?0:-1; + if(on&&wrap.dataset.lastActive!==active)setTimeout(()=>btn.scrollIntoView?.({block:'nearest',inline:'nearest',behavior:'smooth'}),0); + }); + wrap.dataset.lastActive=active; + const context=$('sunSettingsTabContextV21');if(context)context.innerHTML=`${meta.label}${meta.hint}`; + } + + function activate(tab,persist=false){ + if(!VALID.has(tab))tab='account';active=tab; + if(persist){try{localStorage.setItem(TAB_KEY,active)}catch(_){}} + renderBar();applyCards(); + const view=$('enterprise-settings');if(view?.classList.contains('on')){ + try{view.querySelector('.sun-settings-tabs-wrap')?.scrollIntoView({block:'nearest',inline:'nearest'})}catch(_){} + } + window.dispatchEvent(new CustomEvent('sun:settings-tab-changed',{detail:{tab:active}})); + } + + function refresh(){ + const view=$('enterprise-settings');if(!view)return; + ensureCatalogTabsCard();ensureBar();applyCards();renderBar(); + } + function schedule(delay=30){clearTimeout(scheduled);scheduled=setTimeout(refresh,delay)} + + function boot(){ + const view=$('enterprise-settings');if(!view)return; + const observer=new MutationObserver(()=>schedule(20));observer.observe(view,{childList:true,subtree:true}); + document.addEventListener('click',e=>{const b=e.target.closest('header nav button');if(b&&String(b.dataset.navLabel||b.textContent||'').trim()==='Настройки'){schedule(0);schedule(90);schedule(240)}},true); + window.addEventListener('sun:catalog-categories-changed',()=>schedule(20)); + window.addEventListener('sun:cloud-state-applied',()=>schedule(40)); + window.addEventListener('suncloudsync',()=>schedule(40)); + refresh(); + } + if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',boot,{once:true});else boot(); +})(); + +/* ===== MODULE: developer-console-v22.js ===== */ +(()=>{ + 'use strict'; + if(window.SunDeveloperV22)return; + const VERSION='2026.09.06-developer-console-v25-auth-mfa-logo'; + const $=id=>String(id||'').startsWith('#')?document.querySelector(id):document.getElementById(id); + const qa=(sel,root=document)=>[...root.querySelectorAll(sel)]; + const esc=window.SunSafe.escapeHTML; + const clone=v=>v==null?v:(typeof structuredClone==='function'?structuredClone(v):JSON.parse(JSON.stringify(v))); + const cloud=()=>window.SunCloudV2||null; + const client=()=>cloud()?.getClient?.()||null; + const session=()=>cloud()?.getSession?.()||null; + const toast=(t,k='info',ms=4600)=>{try{return window.SunEnterprise?.toast?.(t,k,ms)}catch(_){};alert(t)}; + const fmtDate=v=>{if(!v)return '—';try{return new Date(v).toLocaleString('ru-RU',{dateStyle:'short',timeStyle:'short'})}catch(_){return String(v)}}; + const fmtDay=v=>{if(!v)return '—';try{return new Date(v).toLocaleDateString('ru-RU')}catch(_){return String(v)}}; + const TAB_KEY='sunDeveloperActiveTabV22'; + const TABS=[['overview','Обзор'],['companies','Компании'],['accounts','Аккаунты'],['plans','Тарифы'],['features','Функции'],['system','Система'],['backups','Резервные копии'],['journal','Журнал']]; + const FEATURES={orders:'Заказы',calendar:'Календарь',map:'Карта',clients:'Клиенты',catalog_view:'Каталог · просмотр',catalog_edit:'Каталог · редактирование',production:'Производство',shopping:'Закупки',stock:'Склад',routes:'Маршруты',mailings:'Рассылки',money:'Финансы',stats_basic:'Статистика',stats_advanced:'Расширенная статистика',team:'Команда',suppliers:'Поставщики',print:'Печать',settings:'Настройки',branding:'Фирменное оформление',client_offers:'Предложение клиенту',offer_templates:'Шаблоны предложения',backups:'Резервные копии',audit:'Журнал',users_manage:'Пользователи и права'}; + const PLANS={basic:'Базовый',professional:'Профессиональный',full:'Полный'}; + let platformAdmin=false,checking=false,activeTab=localStorage.getItem(TAB_KEY)||'overview',lastCompanies=[]; + + function installStyle(){if($('sunDeveloperV22Style'))return;const st=document.createElement('style');st.id='sunDeveloperV22Style';st.textContent=` + #sunDeveloperNavV22{font-weight:800}.sun-dev-view{max-width:1460px;margin:0 auto;padding:24px}.sun-dev-head{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:14px}.sun-dev-head h1{margin:0}.sun-dev-head-actions{display:flex;gap:8px;flex-wrap:wrap}.sun-dev-tabs{display:flex;gap:7px;overflow-x:auto;padding:4px 2px 12px;scrollbar-width:thin;border-bottom:1px solid #e7e8ec;margin-bottom:18px}.sun-dev-tabs button{white-space:nowrap;border:1px solid #d8dbe2;background:#fff;border-radius:999px;padding:9px 14px;font-weight:750;cursor:pointer}.sun-dev-tabs button.on{background:#171815;color:#fff;border-color:#171815}.sun-dev-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px}.sun-dev-kpi,.sun-dev-card{background:#fff;border:1px solid #e2e4e9;border-radius:16px;padding:16px;box-shadow:0 8px 28px rgba(30,32,36,.04)}.sun-dev-kpi small{color:#6f7480;display:block;margin-bottom:7px}.sun-dev-kpi b{font-size:24px}.sun-dev-card h2,.sun-dev-card h3{margin:0 0 10px}.sun-dev-toolbar{display:flex;gap:8px;align-items:end;flex-wrap:wrap;margin-bottom:12px}.sun-dev-toolbar label{min-width:180px}.sun-dev-toolbar input,.sun-dev-toolbar select,.sun-dev-card input,.sun-dev-card select,.sun-dev-card textarea{width:100%;box-sizing:border-box;padding:9px 10px;border:1px solid #d6d9df;border-radius:9px;background:#fff}.sun-dev-table-wrap{overflow:auto;border:1px solid #e2e4e9;border-radius:14px;background:#fff}.sun-dev-table{width:100%;border-collapse:collapse;min-width:900px}.sun-dev-table th,.sun-dev-table td{text-align:left;padding:11px 12px;border-bottom:1px solid #eceef2;vertical-align:top;font-size:13px}.sun-dev-table th{background:#f7f8fa;position:sticky;top:0;z-index:1}.sun-dev-actions{display:flex;gap:6px;flex-wrap:wrap}.sun-dev-pill{display:inline-flex;padding:4px 8px;border-radius:999px;background:#f0f2f5;font-size:11px;font-weight:750;margin:2px}.sun-dev-pill.ok{background:#e8f7ef;color:#14653b}.sun-dev-pill.warn{background:#fff3d9;color:#7d5300}.sun-dev-pill.bad{background:#fde8e8;color:#9d2424}.sun-dev-muted{color:#777d88;font-size:12px}.sun-dev-section-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.sun-dev-modal{position:fixed;inset:0;background:rgba(20,22,26,.52);z-index:100000;display:flex;align-items:center;justify-content:center;padding:18px}.sun-dev-modal-card{background:#fff;border-radius:18px;max-width:620px;width:min(620px,100%);max-height:90vh;overflow:auto;padding:20px;box-shadow:0 24px 80px rgba(0,0,0,.28)}.sun-dev-modal-head{display:flex;justify-content:space-between;gap:12px;align-items:center}.sun-dev-modal-head h2{margin:0}.sun-dev-modal-close{border:0;background:#f1f2f4;border-radius:10px;width:36px;height:36px;font-size:22px;cursor:pointer}.sun-dev-mfa-qr{max-width:220px;width:100%;display:block;margin:12px auto;border:10px solid #fff}.sun-dev-support-banner{position:fixed;left:50%;transform:translateX(-50%);bottom:16px;z-index:99995;background:#171815;color:#fff;border-radius:14px;padding:10px 12px;box-shadow:0 12px 36px rgba(0,0,0,.28);display:flex;align-items:center;gap:10px;max-width:calc(100vw - 30px)}.sun-dev-support-banner b{white-space:nowrap}.sun-dev-support-banner button{background:#fff;color:#171815;border:0;border-radius:9px;padding:7px 10px;font-weight:800;cursor:pointer}.sun-dev-danger{border:1px solid #efc8c8!important;color:#9f2424!important;background:#fff!important}.sun-dev-code{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;background:#f5f6f8;border-radius:8px;padding:7px;overflow-wrap:anywhere}.sun-dev-empty{padding:26px;text-align:center;color:#747a84}.sun-dev-check{display:flex;align-items:center;gap:8px}.sun-dev-check input{width:18px!important;height:18px}.sun-dev-pre{white-space:pre-wrap;word-break:break-word;font:12px/1.45 ui-monospace,SFMono-Regular,Consolas,monospace;background:#f7f8fa;padding:12px;border-radius:10px;max-height:340px;overflow:auto} + @media(max-width:950px){.sun-dev-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.sun-dev-section-grid{grid-template-columns:1fr}.sun-dev-view{padding:15px}.sun-dev-head{flex-direction:column}.sun-dev-head-actions{width:100%}} + @media(max-width:540px){.sun-dev-grid{grid-template-columns:1fr}.sun-dev-support-banner{left:15px;right:15px;transform:none;flex-wrap:wrap}.sun-dev-support-banner b{white-space:normal}} + `;document.head.appendChild(st)} + + async function rpc(name,args={}){const c=client();if(!c)throw new Error('Supabase не подключён.');const r=await c.rpc(name,args);if(r.error)throw r.error;return r.data} + async function log(action,workspace=null,user=null,details={}){try{await rpc('sun_dev_log_event',{p_action:action,p_workspace:workspace,p_user:user,p_details:details})}catch(_){}} + + async function checkPlatformAdmin(force=false){if(checking)return platformAdmin;if(platformAdmin&&!force)return true;const c=client(),ss=session();if(!c||!ss?.user){platformAdmin=false;syncChrome();return false}checking=true;try{const r=await c.rpc('sun_is_platform_admin');platformAdmin=!r.error&&r.data===true}catch(_){platformAdmin=false}finally{checking=false;syncChrome()}return platformAdmin} + function isPlatformAdmin(){return platformAdmin===true} + + function ensureNav(){installStyle();const nav=document.querySelector('header nav');if(!nav||!platformAdmin)return null;$('sunSaaSAdminNavV16')?.remove();let b=$('sunDeveloperNavV22');if(!b){b=document.createElement('button');b.id='sunDeveloperNavV22';b.type='button';b.dataset.navLabel='Разработчик';b.textContent='Разработчик';nav.appendChild(b);b.addEventListener('click',()=>open(b))}b.hidden=false;b.style.display='';return b} + function ensureView(){let v=$('sun-developer-console-v22');if(!v){v=document.createElement('section');v.id='sun-developer-console-v22';v.className='view page sun-dev-view';document.body.appendChild(v)}return v} + function showDevView(btn=null){const v=ensureView();qa('.view').forEach(x=>x.classList.remove('on'));v.classList.add('on');qa('header nav button').forEach(x=>x.classList.remove('on'));(btn||$('sunDeveloperNavV22'))?.classList.add('on');window.scrollTo({top:0,behavior:'instant'});return v} + function shell(){const v=ensureView();v.innerHTML=`

    Кабинет разработчика

    Управление платформой, компаниями и техническим состоянием приложения.

    Developer${esc(session()?.user?.email||'')}
    ${TABS.map(([id,label])=>``).join('')}
    Загрузка…
    `;qa('[data-dev-tab]',v).forEach(b=>b.onclick=()=>activateTab(b.dataset.devTab));$('sunDevRefresh').onclick=()=>activateTab(activeTab,true)} + + async function open(btn=null){if(!await checkPlatformAdmin(true))return toast('Этот аккаунт не имеет прав разработчика.','error');if(!await ensureDeveloperMfa())return;ensureNav();showDevView(btn);shell();await activateTab(activeTab,true)} + async function activateTab(tab,force=false){if(!TABS.some(x=>x[0]===tab))tab='overview';activeTab=tab;localStorage.setItem(TAB_KEY,tab);qa('[data-dev-tab]').forEach(b=>b.classList.toggle('on',b.dataset.devTab===tab));const body=$('sunDevBody');if(!body)return;body.innerHTML='
    Загрузка…
    ';try{if(tab==='overview')await renderOverview(body);else if(tab==='companies')await renderCompanies(body);else if(tab==='accounts')await renderAccounts(body);else if(tab==='plans')await renderPlans(body);else if(tab==='features')await renderFeatures(body);else if(tab==='system')await renderSystem(body);else if(tab==='backups')await renderBackups(body);else if(tab==='journal')await renderJournal(body)}catch(e){body.innerHTML=`

    Не удалось загрузить раздел

    ${esc(e?.message||e)}

    `}} + + function modal(title,html){$('sunDevModalV22')?.remove();const m=document.createElement('div');m.className='sun-dev-modal';m.id='sunDevModalV22';m.innerHTML=`

    ${esc(title)}

    ${html}
    `;document.body.appendChild(m);const close=()=>m.remove();m.addEventListener('click',e=>{if(e.target===m||e.target.closest('[data-close]'))close()});return {m,body:$('#sunDevModalBody'),close}} + function confirmDanger(text){return confirm(text)} + + async function ensureDeveloperMfa(){ + const c=client();if(!c?.auth?.mfa)throw new Error('Supabase MFA недоступна.'); + const readAal=async()=>{const a=await c.auth.mfa.getAuthenticatorAssuranceLevel();if(a.error)throw a.error;return a.data||{}}; + const waitAal2=async()=>{for(let i=0;i<12;i++){const a=await readAal();if(a.currentLevel==='aal2')return true;await new Promise(r=>setTimeout(r,180))}return false}; + const initial=await readAal();if(initial.currentLevel==='aal2')return true; + const listed=await c.auth.mfa.listFactors();if(listed.error)throw listed.error; + let factor=(listed.data?.totp||[]).find(f=>f.status==='verified');let enrollment=null; + if(!factor){ + for(const f of (listed.data?.totp||[]).filter(x=>x.status!=='verified')){ + try{const u=await c.auth.mfa.unenroll({factorId:f.id});if(u.error&&!/not found/i.test(String(u.error.message||'')))throw u.error}catch(e){console.warn('[Developer MFA] reset factor',e)} + } + const enr=await c.auth.mfa.enroll({factorType:'totp',friendlyName:'Caterium Developer'});if(enr.error)throw enr.error;factor=enr.data;enrollment=enr.data; + } + const firstSetup=Boolean(enrollment),qr=enrollment?.totp?.qr_code||'',secret=enrollment?.totp?.secret||''; + const ui=modal(firstSetup?'Подключение 2FA разработчика':'Подтверждение разработчика',firstSetup?`

    Отсканируйте этот QR-код приложением-аутентификатором. Затем введите текущие 6 цифр и нажмите Подтвердить и открыть.

    ${qr?`QR для 2FA`:''}${secret?`

    Если QR не сканируется, добавьте ключ вручную:

    ${esc(secret)}
    `:''}
    `:`

    Введите текущий 6-значный код из приложения-аутентификатора.

    `); + return await new Promise(resolve=>{ + let settled=false,busy=false; + const finish=v=>{if(settled)return;settled=true;try{ui.close()}catch(_){}resolve(v)}; + const showError=(e,notify=true)=>{const msg=e?.message||String(e||'Не удалось подтвердить код.');const box=$('sunDevMfaError');if(box)box.textContent=msg;if(notify)toast(msg,'error',6500)}; + const verify=async()=>{ + if(busy)return; + const code=String(document.getElementById('sunDevMfaCode')?.value||'').replace(/\D/g,''); + if(code.length!==6){showError(new Error('Введите ровно 6 цифр из приложения-аутентификатора.'),false);return} + busy=true;const btn=$('sunDevMfaVerify');if(btn){btn.disabled=true;btn.textContent='Проверяю…'};const box=$('sunDevMfaError');if(box)box.textContent='Проверяю код…'; + try{ + let vr; + if(typeof c.auth.mfa.challengeAndVerify==='function') vr=await c.auth.mfa.challengeAndVerify({factorId:factor.id,code}); + else {const ch=await c.auth.mfa.challenge({factorId:factor.id});if(ch.error)throw ch.error;vr=await c.auth.mfa.verify({factorId:factor.id,challengeId:ch.data.id,code})} + if(vr?.error)throw vr.error; + if(!await waitAal2()){ + try{await c.auth.refreshSession()}catch(_){} + if(!await waitAal2())throw new Error('Код принят, но сессия разработчика не получила AAL2. Выйдите из аккаунта и войдите снова, затем повторите код.'); + } + toast('2FA подтверждена. Открываю кабинет разработчика…','success',2800); + finish(true); + }catch(e){busy=false;if(btn){btn.disabled=false;btn.textContent='Подтвердить и открыть'};showError(e)} + }; + const b=document.getElementById('sunDevMfaVerify'),inp=document.getElementById('sunDevMfaCode');if(b)b.addEventListener('click',verify);if(inp)inp.addEventListener('keydown',e=>{if(e.key==='Enter'){e.preventDefault();verify()}}); + ui.m.addEventListener('click',e=>{if((e.target===ui.m||e.target.closest('[data-close]'))&&!busy)finish(false)}); + setTimeout(()=>inp?.focus(),60); + }) + } + + async function renderOverview(body){const d=await rpc('sun_dev_dashboard');body.innerHTML=`
    Компании${Number(d.companies||0)}
    Аккаунты${Number(d.accounts||0)}
    Активные подписки${Number(d.active_subscriptions||0)}
    Ограниченные компании${Number(d.restricted_companies||0)}
    Ошибки за 24 ч.${Number(d.errors_24h||0)}
    Копии за 24 ч.${Number(d.backups_24h||0)}
    Участники компаний${Number(d.memberships||0)}
    Размер БД${esc(d.database_size||'—')}

    Платформа

    Версия приложения: 17.6.0

    PostgreSQL: ${esc(d.postgres_version||'—')}

    Последнее изменение рабочей базы: ${fmtDate(d.last_state_at)}

    Безопасность

    MFA AAL2 Кабинет разработчика защищён двухфакторной авторизацией.

    Права разработчика проверяются сервером через sun_platform_admins.

    `;try{const r=await fetch('release-manifest.json',{cache:'no-store'});if(r.ok){const m=await r.json();$('#sunDevReleaseVersion').textContent=m.version||'17.6.0'}}catch(_){} } + + async function getCompanies(){let rows=[];try{rows=await rpc('sun_dev_list_companies')}catch(_){rows=await rpc('sun_dev_list_companies')}lastCompanies=Array.isArray(rows)?rows:[];return lastCompanies} + function companyOptions(rows,selected=''){return `${rows.map(r=>``).join('')}`} + async function renderCompanies(body){const rows=await getCompanies();body.innerHTML=`
    КомпанияВладелецТарифДоступСотрудникиСозданаДействия
    `;const draw=()=>{const q=String($('#sunDevCompanySearch').value||'').trim().toLowerCase();const filtered=rows.filter(r=>`${r.workspace_name||''} ${r.owner_email||''}`.toLowerCase().includes(q));$('#sunDevCompaniesTbody').innerHTML=filtered.map(r=>`${esc(r.workspace_name)}
    ${esc(r.workspace_id)}
    ${esc(r.owner_email||r.pending_owner_email||'—')}${r.pending_owner_email?'
    ожидает регистрации
    ':''}${esc(PLANS[r.plan_id]||r.plan_name||r.plan_id||'—')}
    до ${fmtDay(r.trial_ends_at||r.current_period_end)}
    ${esc(r.access_mode||r.status||'—')}${Number(r.member_count||0)} / ${r.max_members==null?'∞':r.max_members}${fmtDay(r.created_at)}
    `).join('')||'Компаний не найдено.';bindCompanyRows()};draw();$('#sunDevCompanySearch').oninput=draw;$('#sunDevCreateCompany').onclick=openCreateCompany} + function bindCompanyRows(){qa('#sunDevCompaniesTbody tr[data-ws]').forEach(tr=>{const ws=tr.dataset.ws,row=lastCompanies.find(x=>String(x.workspace_id)===ws);tr.querySelector('[data-support]').onclick=()=>enterSupport(row);tr.querySelector('[data-diag]').onclick=()=>showDiagnostics(row);tr.querySelector('[data-plan]').onclick=()=>showCompanyPlan(row);tr.querySelector('[data-block]').onclick=async()=>{const blocked=row.access_mode==='blocked';if(!blocked&&!confirmDanger(`Заблокировать компанию «${row.workspace_name}»? Данные не удалятся.`))return;await rpc('sun_dev_set_subscription',{p_workspace:ws,p_plan:row.plan_id||'full',p_days:blocked?30:0,p_status:blocked?'active':'expired'});await log(blocked?'company.unblock':'company.block',ws,null,{company:row.workspace_name});toast(blocked?'Компания разблокирована.':'Компания заблокирована.','success');activateTab('companies',true)}})} + function openCreateCompany(){const ui=modal('Создать компанию',`

    Компания получит текущий стартовый каталог (${Number(window.SUN_OFFICIAL_CATALOG?.length||0)} позиций), а заказы и клиенты будут пустыми.

    `);$('#sunDevNewCreate').onclick=async()=>{const name=String($('#sunDevNewCompanyName').value||'').trim(),email=String($('#sunDevNewOwnerEmail').value||'').trim(),plan=$('#sunDevNewPlan').value,days=Number($('#sunDevNewDays').value||30);if(!name)return $('#sunDevNewError').textContent='Введите название.';const btn=$('#sunDevNewCreate');btn.disabled=true;$('#sunDevNewError').textContent='Создаю…';try{const data=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.body.innerHTML=`

    Компания создана: ${esc(data.workspace_name||name)}

    Каталог: ${Number(data.catalog_count||0)} позиций.

    ${data.owner_status==='invite'?`

    Владелец ещё не зарегистрирован. Код приглашения сохранён на сервере.

    `:''}
    `;setTimeout(()=>activateTab('companies',true),400)}catch(e){btn.disabled=false;$('#sunDevNewError').textContent=e?.message||String(e)}}} + function showCompanyPlan(row){const ui=modal(`Тариф · ${row.workspace_name}`,`
    `);$('#sunDevPlanSave').onclick=async()=>{await rpc('sun_dev_set_subscription',{p_workspace:row.workspace_id,p_plan:$('#sunDevPlanSelect').value,p_days:Number($('#sunDevPlanDays').value),p_status:'active'});await log('company.subscription.set',row.workspace_id,null,{plan:$('#sunDevPlanSelect').value,days:Number($('#sunDevPlanDays').value)});ui.close();toast('Тариф обновлён.','success');activateTab('companies',true)}} + async function showDiagnostics(row){const d=await rpc('sun_dev_workspace_diagnostics',{p_workspace:row.workspace_id});modal(`Диагностика · ${row.workspace_name}`,`
    Заказы${Number(d.orders||0)}
    Клиенты${Number(d.clients||0)}
    Каталог${Number(d.catalog_items||0)}
    Ошибки 24 ч.${Number(d.errors_24h||0)}

    Revision: ${Number(d.revision||0)}

    Обновлено: ${fmtDate(d.state_updated_at)}

    Последняя копия: ${fmtDate(d.latest_backup)}

    Режим доступа: ${esc(d.access_mode||'—')}

    `)} + + async function enterSupport(row){if(!row)return;await cloud().enterSupportWorkspace(row.workspace_id,row.workspace_name);await log('support.ui_enter',row.workspace_id,null,{company:row.workspace_name});ensureSupportBanner();try{window.render?.();window.renderOrders?.();window.renderStats?.()}catch(_){}const orders=qa('header nav button').find(b=>String(b.dataset.navLabel||b.textContent||'').trim()==='Заказы');if(orders)orders.click();else{qa('.view').forEach(v=>v.classList.remove('on'));$('orders')?.classList.add('on')}toast('Режим поддержки открыт. Изменения не будут сохранены.','info',6500)} + function ensureSupportBanner(){let b=$('sunDevSupportBannerV22');const sm=cloud()?.getSupportMode?.();if(!sm){b?.remove();return}if(!b){b=document.createElement('div');b.id='sunDevSupportBannerV22';b.className='sun-dev-support-banner';document.body.appendChild(b)}b.innerHTML=`Поддержка:${esc(sm.name||'Компания')}только просмотр`;b.querySelector('button').onclick=async()=>{try{await log('support.exit',sm.workspaceId,null,{company:sm.name});await cloud().exitSupportWorkspace()}catch(e){toast(e?.message||String(e),'error')}}} + + async function fetchAccounts(){const rows=await rpc('sun_dev_list_users');const map=new Map();for(const r of rows||[]){const id=String(r.user_id);if(!map.has(id))map.set(id,{id,email:r.email,created_at:r.created_at,last_sign_in_at:r.last_sign_in_at,email_confirmed_at:'',is_platform_admin:r.is_platform_admin===true,is_banned:false,authAdminActions:false,workspaces:[]});if(r.workspace_id)map.get(id).workspaces.push({id:r.workspace_id,name:r.workspace_name,role:r.role,is_active:r.is_active!==false})}return [...map.values()]} + async function renderAccounts(body){const users=await fetchAccounts();body.innerHTML=`
    ${users.length} аккаунтов
    АккаунтКомпанииРегистрацияПоследний входСтатусДействия
    `;const draw=()=>{const q=String($('#sunDevUserSearch').value||'').trim().toLowerCase();const arr=users.filter(u=>`${u.email||''} ${(u.workspaces||[]).map(w=>w.name).join(' ')}`.toLowerCase().includes(q));$('#sunDevUsersTbody').innerHTML=arr.map(u=>`${esc(u.email||'—')}${u.is_platform_admin?'
    Разработчик
    ':''}
    ${esc(u.id)}
    ${(u.workspaces||[]).length?(u.workspaces||[]).map(w=>`${esc(w.name)} · ${esc(w.role||'')}`).join(''):'—'}${fmtDate(u.created_at)}${fmtDate(u.last_sign_in_at)}${u.is_banned?'заблокирован':'активен'}${u.email_confirmed_at?'
    email подтверждён
    ':''}${u.authAdminActions?`
    ${u.is_platform_admin?'':``}
    `:'Просмотр аккаунта доступен. Глобальная блокировка Auth и письмо восстановления требуют защищённой backend-функции.'}`).join('')||'Ничего не найдено.';qa('#sunDevUsersTbody tr[data-user]').forEach(tr=>{const u=users.find(x=>String(x.id)===tr.dataset.user);tr.querySelector('[data-recovery]')?.addEventListener('click',()=>authAdminAction('send_recovery',u));tr.querySelector('[data-ban]')?.addEventListener('click',()=>authAdminAction('set_ban',u,{banned:!u.is_banned}))})};draw();$('#sunDevUserSearch').oninput=draw} + async function authAdminAction(action,u,extra={}){if(action==='set_ban'&&extra.banned&&!confirmDanger(`Заблокировать вход для ${u.email}?`))return;const r=await client().functions.invoke('caterium-platform-auth-admin',{body:{action,user_id:u.id,...extra}});if(r.error)throw r.error;if(r.data?.error)throw new Error(r.data.error);toast(action==='send_recovery'?'Письмо восстановления отправлено.':extra.banned?'Аккаунт заблокирован.':'Аккаунт разблокирован.','success');activateTab('accounts',true)} + + async function renderPlans(body){const c=client();const [plansR,featuresR]=await Promise.all([c.from('sun_plans').select('id,name,description,max_members,is_active,sort_order').order('sort_order'),c.from('sun_plan_features').select('plan_id,feature_key,enabled')]);if(plansR.error)throw plansR.error;if(featuresR.error)throw featuresR.error;const plans=plansR.data||[],features=featuresR.data||[];body.innerHTML=`
    ${plans.map(p=>`

    ${esc(p.name)}

    ${esc(p.description||'')}

    Функции

    ${Object.keys(FEATURES).map(f=>{const row=features.find(x=>x.plan_id===p.id&&x.feature_key===f);return ``}).join('')}
    `).join('')}
    `;qa('[data-plan-card]').forEach(card=>{const plan=card.dataset.planCard;card.querySelector('[data-save-max]').onclick=async()=>{const raw=card.querySelector('[data-max]').value;await rpc('sun_dev_set_plan_max_members',{p_plan:plan,p_max_members:raw===''?null:Number(raw)});toast('Лимит сохранён.','success')};qa('[data-feature]',card).forEach(ch=>ch.onchange=async()=>{ch.disabled=true;try{await rpc('sun_dev_set_plan_feature',{p_plan:plan,p_feature:ch.dataset.feature,p_enabled:ch.checked});toast('Матрица тарифа обновлена.','success')}catch(e){ch.checked=!ch.checked;toast(e?.message||String(e),'error')}finally{ch.disabled=false}})})} + + async function renderFeatures(body){const rows=lastCompanies.length?lastCompanies:await getCompanies();body.innerHTML=`
    Выберите компанию.
    `;const load=async()=>{const ws=$('#sunDevFeatureCompany').value;if(!ws)return;const data=await rpc('sun_dev_list_workspace_features',{p_workspace:ws});const root=$('sunDevFeatureBody');root.className='sun-dev-table-wrap';root.innerHTML=`${(data||[]).map(f=>``).join('')}
    ФункцияПо тарифуИндивидуальноИтогДействия
    ${esc(FEATURES[f.feature_key]||f.feature_key)}${f.plan_enabled?'✓':'—'}${f.override_enabled==null?'нет':f.override_enabled?'включено':'выключено'}
    ${f.override_expires_at?`до ${fmtDay(f.override_expires_at)}`:''}
    ${f.effective_enabled?'доступна':'закрыта'}
    `;qa('tr[data-feature]',root).forEach(tr=>{const f=tr.dataset.feature;tr.querySelector('[data-enable]').onclick=()=>setOverride(ws,f,true,30,load);tr.querySelector('[data-disable]').onclick=()=>setOverride(ws,f,false,30,load);tr.querySelector('[data-reset]').onclick=async()=>{await rpc('sun_dev_reset_feature_override',{p_workspace:ws,p_feature:f});toast('Индивидуальное правило сброшено.','success');load()}})};$('#sunDevFeatureLoad').onclick=load;$('#sunDevFeatureCompany').onchange=load} + async function setOverride(ws,f,en,days,refresh){await rpc('sun_dev_set_feature_override',{p_workspace:ws,p_feature:f,p_enabled:en,p_days:days,p_note:'Developer console v17.6.0'});await log('feature.override.set',ws,null,{feature:f,enabled:en,days});toast('Индивидуальная функция обновлена.','success');await refresh()} + + async function renderSystem(body){const cfg=cloud()?.getTechnicalConfig?.()||{},d=await rpc('sun_dev_dashboard');let stability=null;try{stability=await window.SunStabilityV17?.diagnostics?.()}catch(_){}const rows=lastCompanies.length?lastCompanies:await getCompanies();body.innerHTML=`

    Подключение Supabase

    Эти параметры скрыты от обычных пользователей.

    Состояние

    База: ${esc(d.database_size||'—')}

    PostgreSQL: ${esc(d.postgres_version||'—')}

    Последняя синхронизация: ${fmtDate(cfg.lastSync)}

    Ошибки 24 ч.: ${Number(d.errors_24h||0)}

    Client ID: ${esc(cloud()?.getClientId?.()||'')}

    Стартовый каталог

    ${Number(window.SUN_OFFICIAL_CATALOG?.length||0)} позиций · версия ${esc(window.SUN_OFFICIAL_CATALOG_VERSION||'—')}

    Новые компании получают этот каталог автоматически. Существующие компании не меняются без явной команды.

    Диагностика клиента

    ${esc(stability?JSON.stringify(stability,null,2):'Диагностика stability-модуля недоступна.')}
    `;$('#sunDevAutoSync').onchange=e=>cloud().setAutoSyncDeveloper?.(e.target.checked);$('#sunDevReconnect').onclick=async()=>{if(!confirmDanger('Переподключить приложение к указанному Supabase? Неверные данные могут временно лишить приложение облака.'))return;await cloud().setTechnicalConnection($('#sunDevSupabaseUrl').value,$('#sunDevSupabaseKey').value);toast('Подключение обновлено.','success')};$('#sunDevForceCatalog').onclick=async()=>{const ws=$('#sunDevCatalogCompany').value;if(!ws)return toast('Выберите компанию.','warn');const row=rows.find(r=>r.workspace_id===ws);if(!confirmDanger(`ЗАМЕНИТЬ каталог компании «${row?.workspace_name||ws}» текущим стартовым каталогом? Собственный каталог компании будет перезаписан.`))return;const n=await rpc('sun_dev_seed_workspace_catalog',{p_workspace:ws,p_boxes:window.SUN_OFFICIAL_CATALOG||[],p_catalog_version:String(window.SUN_OFFICIAL_CATALOG_VERSION||''),p_replace:true});toast(`Каталог заменён: ${n} позиций.`,'success',6500)}} + + async function renderBackups(body){const rows=lastCompanies.length?lastCompanies:await getCompanies();body.innerHTML=`
    Выберите компанию.
    `;const load=async()=>{const ws=$('#sunDevBackupCompany').value;if(!ws)return;const data=await rpc('sun_v17_list_backups',{p_workspace:ws,p_limit:60});const root=$('sunDevBackupBody');root.className='sun-dev-table-wrap';root.innerHTML=`${(data||[]).map(b=>``).join('')||''}
    ДатаТипМеткаIDДействия
    ${fmtDate(b.created_at)}${esc(b.kind)}${esc(b.label||'—')}${esc(b.id)}
    Копий нет.
    `;qa('tr[data-backup]',root).forEach(tr=>tr.querySelector('[data-restore]').onclick=async()=>{if(!confirmDanger('Восстановить эту резервную копию? Перед восстановлением автоматически создастся новая копия текущего состояния.'))return;await rpc('sun_v17_restore_backup',{p_workspace:ws,p_backup:tr.dataset.backup});await log('backup.restore',ws,null,{backup_id:tr.dataset.backup});toast('Резервная копия восстановлена.','success',6500);load()})};$('#sunDevBackupLoad').onclick=load;$('#sunDevBackupCompany').onchange=load;$('#sunDevBackupCreate').onclick=async()=>{const ws=$('#sunDevBackupCompany').value;if(!ws)return toast('Выберите компанию.','warn');const id=await rpc('sun_v17_create_backup',{p_workspace:ws,p_kind:'manual',p_label:'Создано из кабинета разработчика'});await log('backup.create',ws,null,{backup_id:id});toast('Резервная копия создана.','success');load()}} + + async function renderJournal(body){const [activity,errors]=await Promise.all([rpc('sun_dev_list_activity',{p_limit:150}),rpc('sun_dev_list_errors',{p_workspace:null,p_limit:100})]);body.innerHTML=`

    Действия разработчика

    ${(activity||[]).map(a=>``).join('')||''}
    ДатаДействиеКомпания / аккаунтДетали
    ${fmtDate(a.created_at)}${esc(a.action)}
    ${esc(a.actor_email||'')}
    ${esc(a.workspace_name||a.target_email||'—')}${esc(JSON.stringify(a.details||{}))}
    Записей пока нет.

    Ошибки приложения

    ${(errors||[]).map(e=>``).join('')||''}
    ДатаКомпанияВерсияОшибка
    ${fmtDate(e.created_at)}${esc(e.workspace_name||'—')}${esc(e.app_version||'—')}${esc(e.level||'error')}
    ${esc(e.message||'')}
    Ошибок нет.
    `} + + function enhanceDeveloperGate(){const gate=$('sunCloudAuthGateV3'),ss=session(),ws=cloud()?.getWorkspace?.(),st=cloud()?.status?.();if(!platformAdmin||!gate||!ss?.user||ws||st?.membershipsLoading||st?.membershipsLoaded===false)return;const card=gate.querySelector('.sun-cloud-auth-card');if(!card||card.dataset.devEnhanced==='1')return;card.dataset.devEnhanced='1';gate.dataset.saasEnhanced='1';card.innerHTML=`
    Caterium

    Аккаунт разработчика

    ${esc(ss.user.email||'')}

    Этот аккаунт управляет платформой и не обязан иметь собственную рабочую компанию.

    `;card.querySelector('[data-open-dev]').onclick=()=>open();card.querySelector('[data-signout]').onclick=()=>cloud()?.signOut?.()} + function syncChrome(){if(platformAdmin){ensureNav();enhanceDeveloperGate()}else{$('sunDeveloperNavV22')?.remove()}ensureSupportBanner()} + function boot(){installStyle();let tries=0;const timer=setInterval(async()=>{tries++;if(client()&&session()?.user){await checkPlatformAdmin(tries%6===0);enhanceDeveloperGate();ensureSupportBanner()}else if(!session()?.user){platformAdmin=false;syncChrome()}if(tries>240)clearInterval(timer)},1500);window.addEventListener('sun:cloud-permissions-changed',()=>setTimeout(()=>checkPlatformAdmin(true),80));window.addEventListener('sun:cloud-state-applied',()=>ensureSupportBanner());document.addEventListener('click',e=>{const b=e.target.closest('header nav button');if(!b)return;if(String(b.dataset.navLabel||b.textContent||'').trim()==='Настройки'&&platformAdmin)setTimeout(()=>{$('sunSaaSSettingsCardV16')?.querySelector('[data-saas-admin]')?.remove()},120)},true);setTimeout(()=>checkPlatformAdmin(true),900)} + + window.SunDeveloperV22={VERSION,open,isPlatformAdmin,checkPlatformAdmin,ensureNav,activateTab,ensureSupportBanner}; + if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',boot,{once:true});else boot(); +})(); + +/* ===== MODULE: chat-v31.js ===== */ +(() => { + 'use strict'; + if (window.SunChatV31) return; + + const VERSION = '2026.09.06-chat-v31'; + const $ = id => document.getElementById(id); + const qa = (s,r=document) => [...r.querySelectorAll(s)]; + const esc=window.SunSafe.escapeHTML; + const toast = (text,type='info',timeout=4500) => { try { if(window.SunEnterprise?.toast) return window.SunEnterprise.toast(text,type,timeout); } catch (_) {} alert(text); }; + const ROLE_LABELS = {admin:'Администратор',manager:'Менеджер',kitchen:'Кухня',courier:'Курьер',viewer:'Просмотр'}; + + let threads = []; + let members = []; + let messages = []; + let activeThread = null; + let workspaceChannel = null; + let threadChannel = null; + let workspaceChannelId = ''; + let threadChannelId = ''; + let online = new Set(); + let pendingFiles = []; + let loadingThreads = false; + let loadingMessages = false; + let typingStopTimer = null; + let lastTypingSent = 0; + let typingUsers = new Map(); + let sidebarMode = 'general'; + let chatDock = 'global'; + let bootRetryTimer = null; + + function cloud(){ return window.SunCloudV2 || null; } + function client(){ return cloud()?.getClient?.() || null; } + function session(){ return cloud()?.getSession?.() || null; } + function workspace(){ return cloud()?.getWorkspace?.() || null; } + function supportMode(){ return Boolean(cloud()?.isSupportMode?.()); } + function available(){ return Boolean(client() && session()?.user && workspace()?.id && !supportMode()); } + function me(){ return session()?.user?.id || ''; } + function meName(){ const ws=workspace();return ws?.display_name || session()?.user?.email?.split('@')[0] || 'Сотрудник'; } + async function rpc(name,args={}){ const c=client();if(!c)throw new Error('Supabase не подключён.');const r=await c.rpc(name,args);if(r.error)throw r.error;return r.data; } + function fmtTime(v){ if(!v)return '';try{return new Intl.DateTimeFormat('ru-RU',{hour:'2-digit',minute:'2-digit'}).format(new Date(v))}catch(_){return ''} } + function fmtDay(v){ if(!v)return '';try{return new Intl.DateTimeFormat('ru-RU',{day:'numeric',month:'long'}).format(new Date(v))}catch(_){return ''} } + function sameDay(a,b){ try{const x=new Date(a),y=new Date(b);return x.getFullYear()===y.getFullYear()&&x.getMonth()===y.getMonth()&&x.getDate()===y.getDate()}catch(_){return false} } + function bytes(n){ n=Number(n||0);if(n<1024)return `${n} Б`;if(n<1024*1024)return `${Math.round(n/1024)} КБ`;return `${(n/1024/1024).toFixed(1)} МБ`; } + function safeName(name){ return String(name||'file').replace(/[\\/:*?"<>|\u0000-\u001f]+/g,'-').replace(/\s+/g,' ').trim().slice(0,120)||'file'; } + function uid(){ try{return window.sunUUID?.() || crypto.randomUUID()}catch(_){return `${Date.now()}-${Math.random().toString(36).slice(2)}`} } + function currentOrderId(){ try{return typeof draft!=='undefined'&&draft?.id?String(draft.id):''}catch(_){return ''} } + function savedOrders(){ try{return Array.isArray(orders)?orders.slice():[]}catch(_){return []} } + + function installStyle(){ + if($('sun-chat-style-v31'))return; + $('sun-chat-style-v29')?.remove(); + const s=document.createElement('style');s.id='sun-chat-style-v31';s.textContent=` + #sunChatNavV29{position:relative}.sun-chat-nav-badge{display:none;position:absolute;right:5px;top:5px;min-width:18px;height:18px;border-radius:999px;padding:0 5px;background:#d83b35;color:#fff;font:800 10px/18px Arial;text-align:center;box-shadow:0 0 0 2px var(--sun-sidebar-bg,#32255d)}.sun-chat-nav-badge.on{display:block} + #sun-chat-view{padding:16px;min-height:calc(100vh - 70px);background:#f3f4f7}.sun-chat-shell{max-width:1420px;margin:0 auto;height:calc(100vh - 102px);min-height:620px;background:#fff;border:1px solid #e0e4e9;border-radius:18px;overflow:hidden;display:grid;grid-template-columns:330px minmax(0,1fr);box-shadow:0 12px 40px rgba(30,40,60,.07)} + .sun-chat-side{border-right:1px solid #e6e9ed;background:#fafbfc;display:flex;flex-direction:column;min-width:0}.sun-chat-side-head{padding:17px 17px 12px;border-bottom:1px solid #e8ebef}.sun-chat-side-head h1{font-size:22px;margin:0;color:#17384d}.sun-chat-side-head p{margin:5px 0 11px;color:#7a838d;font-size:12px}.sun-chat-section-tabs{display:grid;grid-template-columns:repeat(3,1fr);gap:5px}.sun-chat-section-tab{position:relative;border:1px solid #dfe4e8;background:#fff;color:#53616d;border-radius:10px;min-height:34px;padding:6px 5px;font-size:10.5px;font-weight:850;cursor:pointer}.sun-chat-section-tab.on{background:#17384d;color:#fff;border-color:#17384d}.sun-chat-section-badge{display:none;position:absolute;right:3px;top:2px;min-width:15px;height:15px;padding:0 4px;border-radius:999px;background:#d83b35;color:#fff;font:800 8px/15px Arial}.sun-chat-section-badge.on{display:block}.sun-chat-side-scroll{overflow:auto;padding:10px;flex:1}.sun-chat-group-title{display:flex;align-items:center;justify-content:space-between;padding:10px 8px 6px;color:#8a919a;font-size:10px;text-transform:uppercase;letter-spacing:.08em;font-weight:900}.sun-chat-list{display:grid;gap:3px}.sun-chat-thread{width:100%;border:0;background:transparent;border-radius:11px;padding:9px 10px;text-align:left;display:grid;grid-template-columns:36px minmax(0,1fr) auto;gap:9px;align-items:center;cursor:pointer;color:#223342}.sun-chat-thread:hover{background:#f0f3f5}.sun-chat-thread.on{background:#eaf1f4}.sun-chat-avatar{width:36px;height:36px;border-radius:12px;background:#e3eaee;display:grid;place-items:center;font-weight:900;color:#36566b;position:relative;flex:none}.sun-chat-avatar.company{background:#1e4057;color:#fff}.sun-chat-avatar.order{background:#f3ead3;color:#7b5d1f}.sun-chat-presence-dot{position:absolute;width:10px;height:10px;border-radius:50%;right:-2px;bottom:-2px;background:#aeb5bb;border:2px solid #fff}.sun-chat-presence-dot.on{background:#28a568}.sun-chat-thread-main{min-width:0}.sun-chat-thread-title{font-size:13px;font-weight:850;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.sun-chat-thread-preview{font-size:10.5px;color:#838b94;margin-top:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.sun-chat-unread{min-width:20px;height:20px;padding:0 6px;border-radius:999px;background:#17384d;color:#fff;font:800 10px/20px Arial;text-align:center}.sun-chat-role{font-size:9px;color:#9299a1;margin-top:2px} + .sun-chat-main{display:flex;flex-direction:column;min-width:0;min-height:0;background:#fff}.sun-chat-head{min-height:68px;border-bottom:1px solid #e7eaee;padding:10px 16px;display:flex;align-items:center;gap:12px}.sun-chat-head-back{border:0;background:#f0f2f4;border-radius:10px;padding:8px 10px;cursor:pointer;font-weight:800}.sun-chat-head-text{min-width:0;flex:1}.sun-chat-head-title{font-size:16px;font-weight:900;color:#213649;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.sun-chat-head-sub{font-size:11px;color:#808891;margin-top:3px}.sun-chat-online-text{color:#22845a}.sun-chat-body{flex:1;min-height:0;overflow:auto;background:linear-gradient(#f8f9fb,#f5f7f9);padding:18px}.sun-chat-empty{height:100%;display:grid;place-items:center;text-align:center;color:#858d96;padding:30px}.sun-chat-empty b{display:block;color:#52616d;font-size:18px;margin-bottom:5px}.sun-chat-day{text-align:center;margin:10px 0 14px}.sun-chat-day span{display:inline-block;background:#e7ebee;color:#707a83;border-radius:999px;padding:4px 9px;font-size:10px}.sun-chat-msg{max-width:min(680px,82%);margin:6px 0;display:flex;gap:8px;align-items:flex-end}.sun-chat-msg.own{margin-left:auto;flex-direction:row-reverse}.sun-chat-bubble{border:1px solid #e1e5e9;background:#fff;border-radius:15px 15px 15px 4px;padding:9px 11px;box-shadow:0 3px 12px rgba(20,30,40,.035);min-width:100px}.sun-chat-msg.own .sun-chat-bubble{background:#e8f1f4;border-color:#d8e6eb;border-radius:15px 15px 4px 15px}.sun-chat-sender{font-size:10px;font-weight:850;color:#47667a;margin-bottom:4px}.sun-chat-text{font-size:13px;line-height:1.42;color:#253642;overflow-wrap:anywhere;white-space:pre-wrap}.sun-chat-meta{display:flex;gap:7px;justify-content:flex-end;align-items:center;margin-top:5px;font-size:9px;color:#8c949c}.sun-chat-read{font-weight:750;color:#718693}.sun-chat-files{display:grid;gap:6px;margin-top:7px}.sun-chat-file{border:1px solid #dfe4e8;border-radius:10px;background:rgba(255,255,255,.7);overflow:hidden;display:block;color:#2d536a;text-decoration:none;max-width:360px}.sun-chat-file-img{width:100%;max-height:260px;display:block;object-fit:cover;background:#eef1f3}.sun-chat-file-line{display:flex;align-items:center;gap:8px;padding:8px 9px}.sun-chat-file-icon{font-size:20px}.sun-chat-file-name{font-size:11px;font-weight:750;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.sun-chat-file-size{font-size:9px;color:#8a929a;margin-top:2px} + .sun-chat-typing{height:22px;padding:3px 16px;color:#71808a;font-size:10px;background:#f8f9fb}.sun-chat-composer{border-top:1px solid #e4e8eb;padding:10px 12px;background:#fff}.sun-chat-pending{display:flex;gap:6px;flex-wrap:wrap;margin:0 0 8px}.sun-chat-pending:empty{display:none}.sun-chat-pending-chip{display:flex;align-items:center;gap:6px;background:#eef2f4;border-radius:9px;padding:5px 7px;max-width:260px}.sun-chat-pending-chip span{font-size:10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.sun-chat-pending-chip button{border:0;background:transparent;cursor:pointer;font-size:15px}.sun-chat-compose-row{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:8px;align-items:end}.sun-chat-attach,.sun-chat-send{border:0;border-radius:11px;height:40px;cursor:pointer;font-weight:850}.sun-chat-attach{width:42px;background:#eef2f4;color:#476170;font-size:20px}.sun-chat-send{padding:0 17px;background:#17384d;color:#fff}.sun-chat-send:disabled{opacity:.55;cursor:default}.sun-chat-input{width:100%;min-height:40px;max-height:130px;resize:none;border:1px solid #d7dde1;border-radius:12px;padding:10px 12px;line-height:1.35;outline:none}.sun-chat-input:focus{border-color:#7896a9;box-shadow:0 0 0 3px rgba(70,110,135,.08)} + .sun-chat-order-tab{position:relative}.sun-chat-order-tab .sun-chat-mini-badge{position:absolute;top:-2px;right:-3px;background:#d83b35;color:#fff;border-radius:999px;min-width:16px;height:16px;padding:0 4px;font:800 9px/16px Arial}.sun-order-chat-panel{display:none;min-height:570px;height:min(68vh,760px);border:1px solid #e1e5e9;border-radius:14px;overflow:hidden;margin-top:10px;background:#fff}.sun-order-chat-panel.on{display:block}.sun-order-chat-panel .sun-chat-main{height:100%;min-height:0}.sun-chat-offline-note{padding:14px;border-radius:12px;background:#fff6e2;color:#85631c;margin:18px} + @media(max-width:850px){#sun-chat-view{padding:0}.sun-chat-shell{height:calc(100vh - 94px);min-height:0;border-radius:0;border-left:0;border-right:0;grid-template-columns:1fr}.sun-chat-side{display:flex}.sun-chat-main{display:none}.sun-chat-shell.thread-open .sun-chat-side{display:none}.sun-chat-shell.thread-open .sun-chat-main{display:flex}.sun-chat-msg{max-width:92%}.sun-chat-body{padding:12px}.sun-chat-section-tab{font-size:10px}.sun-order-chat-panel{height:62vh;min-height:460px}.sun-order-chat-panel .sun-chat-main{display:flex!important}.sun-order-chat-panel .sun-chat-head{min-height:58px}} + `;document.head.appendChild(s); + } + + function ensureView(){ + installStyle(); + let v=$('sun-chat-view'); + if(!v){ + v=document.createElement('section');v.id='sun-chat-view';v.className='view';v.innerHTML=`
    Выберите чатОбщий чат компании, сотрудника или обсуждение заказа.
    `; + const firstModal=document.querySelector('body > .modal');if(firstModal)firstModal.before(v);else document.body.appendChild(v); + $('sunChatAttachV29').onclick=()=>$('sunChatFilesV29').click(); + $('sunChatFilesV29').onchange=e=>addPendingFiles(e.target.files); + $('sunChatSendV29').onclick=sendMessage; + $('sunChatInputV29').addEventListener('keydown',e=>{if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();sendMessage();}}); + $('sunChatInputV29').addEventListener('input',()=>{autoGrowInput();sendTyping(true);}); + qa('[data-chat-section]',v).forEach(b=>b.addEventListener('click',()=>switchSidebarMode(b.dataset.chatSection))); + } + return v; + } + + function ensureNav(){ + const nav=document.querySelector('header nav');if(!nav)return null; + let b=$('sunChatNavV29'); + if(!available()){b?.remove();return null;} + if(!b){ + b=document.createElement('button');b.id='sunChatNavV29';b.type='button';b.dataset.navLabel='Чат';b.className='nav-chat';b.innerHTML=`Чат `; + b.addEventListener('click',()=>openMain({mode:'general'})); + } + if(typeof window.sunRegisterNavButton==='function'){ + window.sunRegisterNavButton({button:b,group:'management',beforeLabel:'Настройки',navLabel:'Чат',className:'nav-chat'}); + }else{ + const group=nav.querySelector('.sun-nav-management')||nav; + if(b.parentElement!==group)group.appendChild(b); + b.classList.add('sun-nav-button','nav-chat'); + } + return b; + } + + function setNavBadge(n){ const b=$('sunChatNavBadgeV29');if(!b)return;n=Number(n||0);b.textContent=n>99?'99+':String(n);b.classList.toggle('on',n>0); } + function setSectionBadge(mode,n){const b=document.querySelector(`[data-chat-section-badge="${mode}"]`);if(!b)return;n=Number(n||0);b.textContent=n>99?'99+':String(n);b.classList.toggle('on',n>0);} + function setActiveNav(){qa('header nav button').forEach(x=>x.classList.remove('on'));$('sunChatNavV29')?.classList.add('on');qa('.view').forEach(x=>x.classList.remove('on'));ensureView().classList.add('on');} + function threadIcon(t){if(t.kind==='company')return ['КО','company'];if(t.kind==='order')return ['№','order'];return [String(t.title||'?').trim().slice(0,1).toUpperCase(),''];} + function threadById(id){return threads.find(t=>String(t.thread_id)===String(id))||null;} + function directByUser(id){return threads.find(t=>t.kind==='direct'&&String(t.other_user_id)===String(id))||null;} + function orderThreadByOrder(id){return threads.find(t=>t.kind==='order'&&String(t.order_id)===String(id))||null;} + function memberById(id){return members.find(m=>String(m.user_id)===String(id))||null;} + + function switchSidebarMode(mode){ + sidebarMode=['general','direct','orders'].includes(mode)?mode:'general'; + qa('[data-chat-section]').forEach(b=>b.classList.toggle('on',b.dataset.chatSection===sidebarMode)); + renderSidebar(); + if(sidebarMode==='general'){ + const t=threads.find(x=>x.kind==='company'); + if(t&&(!activeThread||activeThread.kind!=='company'))openThread(t); + }else if(activeThread&&((sidebarMode==='direct'&&activeThread.kind!=='direct')||(sidebarMode==='orders'&&activeThread.kind!=='order'))){ + activeThread=null;messages=[];unsubscribeThread();$('sunChatShellV29')?.classList.remove('thread-open');$('sunChatComposerV29').hidden=true;renderHead();renderMessages(); + } + } + + function renderSidebar(){ + const root=$('sunChatSidebarV29');if(!root)return; + $('sunChatCompanyNameV29').textContent=workspace()?.name||'Компания'; + qa('[data-chat-section]').forEach(b=>b.classList.toggle('on',b.dataset.chatSection===sidebarMode)); + const general=threads.find(t=>t.kind==='company'); + const others=members.filter(m=>String(m.user_id)!==String(me())); + const localOrders=savedOrders().sort((a,b)=>Number(b?.id||0)-Number(a?.id||0)); + const row=t=>{const [letter,cls]=threadIcon(t);const isOn=activeThread&&String(activeThread.thread_id)===String(t.thread_id);return ``}; + const memberRow=m=>{const t=directByUser(m.user_id);if(t)return row(t);const isOnline=online.has(String(m.user_id));return ``}; + const orderRow=o=>{const t=orderThreadByOrder(o.id);if(t)return row({...t,title:`№${o.id}${o.event?` · ${o.event}`:''}`});return ``}; + if(sidebarMode==='general')root.innerHTML=`
    Общий чат компании
    ${general?row(general):'
    Загружаю общий чат…
    '}
    `; + if(sidebarMode==='direct')root.innerHTML=`
    Сотрудники${others.length}
    ${others.length?others.map(memberRow).join(''):'
    Добавьте сотрудников — здесь появятся личные чаты.
    '}
    `; + if(sidebarMode==='orders')root.innerHTML=`
    Чаты заказов${localOrders.length}
    ${localOrders.length?localOrders.map(orderRow).join(''):'
    Сохранённых заказов пока нет.
    '}
    `; + qa('[data-chat-thread]',root).forEach(b=>b.onclick=()=>openThread(threadById(b.dataset.chatThread))); + qa('[data-chat-user]',root).forEach(b=>b.onclick=()=>openDirect(b.dataset.chatUser)); + qa('[data-chat-order]',root).forEach(b=>b.onclick=()=>openOrderGlobal(b.dataset.chatOrder)); + updateUnreadBadges(); + } + + function updateUnreadBadges(){ + const general=threads.filter(t=>t.kind==='company').reduce((n,t)=>n+Number(t.unread_count||0),0); + const direct=threads.filter(t=>t.kind==='direct').reduce((n,t)=>n+Number(t.unread_count||0),0); + const order=threads.filter(t=>t.kind==='order').reduce((n,t)=>n+Number(t.unread_count||0),0); + setSectionBadge('general',general);setSectionBadge('direct',direct);setSectionBadge('orders',order);setNavBadge(general+direct+order);updateOrderChatBadge(); + } + + function activeSubtitle(t){ + if(!t)return ''; + if(t.kind==='direct'){const m=memberById(t.other_user_id);return online.has(String(t.other_user_id))?'онлайн':esc(ROLE_LABELS[m?.role]||'сотрудник');} + if(t.kind==='company'){const count=members.length,on=members.filter(m=>online.has(String(m.user_id))).length;return `${count} сотрудников · ${on} онлайн`;} + return 'Обсуждение заказа'; + } + + function renderHead(){ + const h=$('sunChatHeadV29');if(!h)return; + if(!activeThread){h.innerHTML='';return;} + const mobileBack=chatDock==='global'?``:''; + const orderBack=chatDock==='order'?``:''; + h.innerHTML=`${orderBack||mobileBack}
    ${esc(activeThread.title||'Чат')}
    ${activeSubtitle(activeThread)}
    `; + h.querySelector('[data-chat-mobile-back]')?.addEventListener('click',()=>{activeThread=null;messages=[];unsubscribeThread();$('sunChatShellV29')?.classList.remove('thread-open');renderSidebar();renderHead();renderMessages();$('sunChatComposerV29').hidden=true;}); + h.querySelector('[data-chat-order-back]')?.addEventListener('click',()=>$('orderTab')?.click()); + } + + function renderMessages(){ + const root=$('sunChatBodyV29');if(!root)return; + if(!activeThread){root.innerHTML='
    Выберите чатОбщий чат компании, сотрудника или обсуждение заказа.
    ';return;} + if(loadingMessages){root.innerHTML='
    Загружаю сообщения…
    ';return;} + if(!messages.length){root.innerHTML='
    Пока нет сообщенийНапишите первое сообщение.
    ';return;} + let prev=null,html=''; + for(const m of messages){ + if(!prev||!sameDay(prev,m.created_at))html+=`
    ${esc(fmtDay(m.created_at))}
    `; + prev=m.created_at;const own=String(m.sender_user_id)===String(me());const atts=Array.isArray(m.attachments)?m.attachments:[]; + const files=atts.length?``:''; + const read=own?(Number(m.read_by_count||0)>0?(activeThread.kind==='direct'?'Прочитано':`Прочитали: ${Number(m.read_by_count)}`):'Доставлено'):''; + html+=`
    ${!own&&activeThread.kind!=='direct'?`
    ${esc(m.sender_name||m.sender_email||'Сотрудник')}
    `:''}${m.body?`
    ${esc(m.body)}
    `:''}${files}
    ${esc(fmtTime(m.created_at))}${read?`${esc(read)}`:''}
    `; + } + root.innerHTML=html;hydrateAttachments();requestAnimationFrame(()=>{root.scrollTop=root.scrollHeight}); + } + + async function hydrateAttachments(){ + const c=client();if(!c)return; + const links=qa('[data-chat-attachment]',$('sunChatBodyV29')); + for(const a of links){const path=a.dataset.chatAttachment;if(!path)continue;try{const r=await c.storage.from('sun-chat').createSignedUrl(path,3600);if(r.error)throw r.error;const url=r.data?.signedUrl;if(!url)continue;a.href=url;const type=a.dataset.chatType||'';if(type.startsWith('image/')){const p=a.querySelector('.sun-chat-file-preview');if(p)p.innerHTML=`Вложение`;}}catch(e){a.onclick=ev=>{ev.preventDefault();toast('Не удалось открыть вложение.','error')}}} + } + + function renderTyping(){const root=$('sunChatTypingV29');if(!root)return;const names=[...typingUsers.values()].filter(x=>x&&x.user_id!==me()&&Date.now()-x.at<3500).map(x=>x.name||'Сотрудник');root.textContent=names.length?`${names.slice(0,2).join(', ')} ${names.length>1?'печатают':'печатает'}…`:'';} + function renderPending(){const root=$('sunChatPendingV29');if(!root)return;root.innerHTML=pendingFiles.map((f,i)=>`
    ${esc(f.name)} · ${esc(bytes(f.size))}
    `).join('');qa('[data-remove-file]',root).forEach(b=>b.onclick=()=>{pendingFiles.splice(Number(b.dataset.removeFile),1);renderPending()});} + function autoGrowInput(){const x=$('sunChatInputV29');if(!x)return;x.style.height='auto';x.style.height=Math.min(130,x.scrollHeight)+'px';} + + async function loadThreads({quiet=false}={}){ + if(!available()||loadingThreads)return; + loadingThreads=true; + try{ + const ws=workspace();await rpc('sun_chat_get_company_thread_v29',{p_workspace:ws.id}); + const [t,m]=await Promise.all([rpc('sun_chat_list_threads_v29',{p_workspace:ws.id}),rpc('sun_chat_list_members_v29',{p_workspace:ws.id})]); + threads=Array.isArray(t)?t:[];members=Array.isArray(m)?m:[]; + if(activeThread){const fresh=threadById(activeThread.thread_id);if(fresh)activeThread={...activeThread,...fresh};} + renderSidebar();renderHead();updateUnreadBadges(); + }catch(e){if(!quiet)toast(e.message||String(e),'error',6500)}finally{loadingThreads=false;} + } + + async function loadMessages({mark=true,quiet=false}={}){ + if(!activeThread||!available()||loadingMessages)return; + loadingMessages=true;renderMessages(); + try{ + const data=await rpc('sun_chat_list_messages_v29',{p_thread:activeThread.thread_id,p_limit:120,p_before:null});messages=Array.isArray(data)?data:[]; + if(mark){const t=threadById(activeThread.thread_id);if(Number(t?.unread_count||0)>0){await rpc('sun_chat_mark_read_v29',{p_thread:activeThread.thread_id});await loadThreads({quiet:true});}} + }catch(e){if(!quiet)toast(e.message||String(e),'error',6500)}finally{loadingMessages=false;renderMessages();} + } + + async function openThread(t){ + if(!t)return;activeThread=t;messages=[];pendingFiles=[];$('sunChatShellV29')?.classList.add('thread-open');$('sunChatComposerV29').hidden=false;$('sunChatInputV29').value='';renderPending();renderSidebar();renderHead();renderMessages();await subscribeThread(t.thread_id);await loadMessages({mark:true});setTimeout(()=>$('sunChatInputV29')?.focus(),80); + } + + async function openDirect(userId){ + if(!available())return;try{sidebarMode='direct';const id=await rpc('sun_chat_get_direct_thread_v29',{p_workspace:workspace().id,p_other_user:userId});await loadThreads({quiet:true});const t=threadById(id)||{thread_id:id,kind:'direct',other_user_id:userId,title:memberById(userId)?.display_name||'Сотрудник',unread_count:0};await openThread(t);}catch(e){toast(e.message||String(e),'error',6500)} + } + + async function getOrderThread(orderId){ + const id=await rpc('sun_chat_get_order_thread_v29',{p_workspace:workspace().id,p_order_id:String(orderId)});await loadThreads({quiet:true});return threadById(id)||{thread_id:id,kind:'order',order_id:String(orderId),title:`Заказ №${orderId}`,unread_count:0}; + } + + async function openOrderGlobal(orderId){ + if(!available())return toast('Войдите в Caterium, чтобы открыть обсуждение заказа.','warn');if(!orderId)return toast('Сначала сохраните заказ.','warn'); + try{dockMainGlobal();setActiveNav();sidebarMode='orders';await ensureWorkspaceChannel();const t=await getOrderThread(orderId);renderSidebar();await openThread(t);}catch(e){toast(e.message||String(e),'error',6500)} + } + + function ensureOrderChatPanel(){ + const sub=document.querySelector('#new .subnav');if(!sub)return null; + let p=$('sunOrderChatPanelV31'); + if(!p){p=document.createElement('div');p.id='sunOrderChatPanelV31';p.className='sun-order-chat-panel';sub.after(p);} + return p; + } + + function dockMainGlobal(){ + const main=$('sunChatMainV29'),shell=$('sunChatShellV29');if(main&&shell&&main.parentElement!==shell)shell.appendChild(main);chatDock='global'; + } + function dockMainOrder(){ + const main=$('sunChatMainV29'),panel=ensureOrderChatPanel();if(main&&panel&&main.parentElement!==panel)panel.appendChild(main);chatDock='order'; + } + + function setOrderChatMode(on){ + const sub=document.querySelector('#new .subnav'),card=sub?.closest('.card'),panel=ensureOrderChatPanel(),chatTab=$('sunOrderChatTabV31');if(!card||!panel)return; + const money=card.querySelector(':scope > .money-form'),summary=card.querySelector(':scope > .order-summary'),actions=card.querySelector(':scope > .actions'); + if(on){ + $('orderForm')?.style.setProperty('display','none');$('details')?.style.setProperty('display','none'); + if(money)money.style.display='none';if(summary)summary.style.display='none';if(actions)actions.style.display='none'; + panel.classList.add('on');panel.hidden=false;$('orderTab')?.classList.remove('on');$('detailTab')?.classList.remove('on');chatTab?.classList.add('on');dockMainOrder(); + }else{ + panel.classList.remove('on');panel.hidden=true;if(money)money.style.display='';if(summary)summary.style.display='';if(actions)actions.style.display='';chatTab?.classList.remove('on');dockMainGlobal(); + } + } + + async function openOrderInline(orderId){ + if(!available())return toast('Войдите в Caterium, чтобы открыть обсуждение заказа.','warn');if(!orderId)return toast('Сначала сохраните заказ — после этого появится чат.','warn'); + try{ensureView();ensureOrderDiscussionTab();setOrderChatMode(true);await ensureWorkspaceChannel();sidebarMode='orders';const t=await getOrderThread(orderId);await openThread(t);}catch(e){setOrderChatMode(false);toast(e.message||String(e),'error',6500)} + } + + async function openMain({threadId=null,mode='general'}={}){ + if(!available())return toast('Чат доступен после входа в рабочую компанию.','warn');ensureView();dockMainGlobal();ensureNav();setActiveNav();sidebarMode=['general','direct','orders'].includes(mode)?mode:'general';await ensureWorkspaceChannel();await loadThreads();let t=threadId?threadById(threadId):null;if(!t&&sidebarMode==='general')t=threads.find(x=>x.kind==='company')||null;if(t)await openThread(t);else{activeThread=null;messages=[];unsubscribeThread();$('sunChatShellV29')?.classList.remove('thread-open');$('sunChatComposerV29').hidden=true;renderSidebar();renderHead();renderMessages();} + } + + async function addPendingFiles(fileList){ + const files=[...(fileList||[])];$('sunChatFilesV29').value=''; + for(const f of files){if(pendingFiles.length>=5){toast('Можно отправить не более 5 файлов за раз.','warn');break}if(f.size>15*1024*1024){toast(`Файл «${f.name}» больше 15 МБ.`,'warn');continue}pendingFiles.push(f)}renderPending(); + } + + async function uploadPending(){ + const c=client(),ws=workspace();if(!c||!ws||!activeThread)return []; + const result=[]; + for(const f of pendingFiles){const path=`${ws.id}/${activeThread.thread_id}/${uid()}-${safeName(f.name)}`;const up=await c.storage.from('sun-chat').upload(path,f,{contentType:f.type||'application/octet-stream',upsert:false});if(up.error)throw up.error;result.push({path,name:f.name,type:f.type||'application/octet-stream',size:f.size});} + return result; + } + + async function sendMessage(){ + if(!activeThread||!available())return;const input=$('sunChatInputV29'),btn=$('sunChatSendV29');const text=String(input?.value||'').trim();if(!text&&!pendingFiles.length)return; + btn.disabled=true;btn.textContent='…'; + try{const attachments=await uploadPending();await rpc('sun_chat_send_message_v29',{p_thread:activeThread.thread_id,p_body:text,p_attachments:attachments});input.value='';input.style.height='auto';pendingFiles=[];renderPending();sendTyping(false);await Promise.all([loadThreads({quiet:true}),loadMessages({mark:false,quiet:true})]);} + catch(e){toast(e.message||String(e),'error',7000)}finally{btn.disabled=false;btn.textContent='Отправить';input?.focus();} + } + + async function ensureWorkspaceChannel(){ + if(!available())return unsubscribeWorkspace();const c=client(),ws=workspace(),ss=session();if(!c||!ws||!ss)return; + if(workspaceChannel&&workspaceChannelId===ws.id)return; + unsubscribeWorkspace();workspaceChannelId=ws.id; + try{if(c.realtime?.setAuth)await c.realtime.setAuth(ss.access_token);}catch(_){} + const ch=c.channel(`sun-chat-workspace:${ws.id}`,{config:{private:true,presence:{key:me()},broadcast:{ack:true}}});workspaceChannel=ch; + ch.on('broadcast',{event:'chat_changed'},async()=>{await loadThreads({quiet:true});if(activeThread){const fresh=threadById(activeThread.thread_id);if(fresh&&Number(fresh.unread_count||0)>0)await loadMessages({mark:true,quiet:true});else renderMessages();}}) + .on('presence',{event:'sync'},()=>{online=new Set(Object.keys(ch.presenceState()||{}));renderSidebar();renderHead();}) + .on('presence',{event:'join'},()=>{online=new Set(Object.keys(ch.presenceState()||{}));renderSidebar();renderHead();}) + .on('presence',{event:'leave'},()=>{online=new Set(Object.keys(ch.presenceState()||{}));renderSidebar();renderHead();}) + .subscribe(async(status)=>{if(status==='SUBSCRIBED'){try{await ch.track({user_id:me(),name:meName(),at:new Date().toISOString()})}catch(_){}await loadThreads({quiet:true});}}); + } + + function unsubscribeWorkspace(){if(workspaceChannel){try{client()?.removeChannel?.(workspaceChannel)}catch(_){}workspaceChannel=null;}workspaceChannelId='';online=new Set();unsubscribeThread();} + async function subscribeThread(id){ + if(!available()||!id)return;const c=client(),ss=session();if(threadChannel&&threadChannelId===id)return;unsubscribeThread();threadChannelId=id; + try{if(c.realtime?.setAuth)await c.realtime.setAuth(ss.access_token);}catch(_){} + const ch=c.channel(`sun-chat-thread:${id}`,{config:{private:true,broadcast:{ack:false,self:false}}});threadChannel=ch; + ch.on('broadcast',{event:'message'},()=>loadMessages({mark:true,quiet:true})) + .on('broadcast',{event:'read'},()=>loadMessages({mark:false,quiet:true})) + .on('broadcast',{event:'typing'},payload=>{const p=payload?.payload||{};if(!p.user_id||String(p.user_id)===String(me()))return;if(p.typing)typingUsers.set(String(p.user_id),{user_id:p.user_id,name:p.name||'Сотрудник',at:Date.now()});else typingUsers.delete(String(p.user_id));renderTyping();setTimeout(()=>renderTyping(),3600)}) + .subscribe(); + } + function unsubscribeThread(){if(threadChannel){try{client()?.removeChannel?.(threadChannel)}catch(_){}threadChannel=null;}threadChannelId='';typingUsers.clear();renderTyping();} + async function sendTyping(flag){ + if(!threadChannel||!activeThread)return;const now=Date.now();if(flag&&now-lastTypingSent<700){clearTimeout(typingStopTimer);typingStopTimer=setTimeout(()=>sendTyping(false),1800);return}lastTypingSent=now; + try{await threadChannel.send({type:'broadcast',event:'typing',payload:{user_id:me(),name:meName(),typing:Boolean(flag)}})}catch(_){} + clearTimeout(typingStopTimer);if(flag)typingStopTimer=setTimeout(()=>sendTyping(false),1800); + } + + function updateOrderChatBadge(){ + const b=$('sunOrderChatTabV31');if(!b)return;const id=currentOrderId(),t=id?orderThreadByOrder(id):null,n=Number(t?.unread_count||0);let badge=b.querySelector('.sun-chat-mini-badge');if(n>0){if(!badge){badge=document.createElement('span');badge.className='sun-chat-mini-badge';b.appendChild(badge);}badge.textContent=n>99?'99+':String(n);}else badge?.remove(); + } + + function ensureOrderDiscussionTab(){ + const sub=document.querySelector('#new .subnav');if(!sub)return; + ensureOrderChatPanel(); + let b=$('sunOrderChatTabV31'); + $('sunOrderChatTabV29')?.remove(); + if(!b){ + b=document.createElement('button');b.id='sunOrderChatTabV31';b.type='button';b.className='sun-chat-order-tab';b.textContent='Чат';sub.appendChild(b); + b.addEventListener('click',e=>{e.preventDefault();const id=currentOrderId();if(!id)return toast('Сначала сохраните заказ — после этого появится чат.','warn');openOrderInline(id);}); + } + b.title=currentOrderId()?'Чат этого заказа':'Сначала сохраните заказ';b.style.opacity=currentOrderId()?'1':'.55'; + ['orderTab','detailTab'].forEach(id=>{const x=$(id);if(x&&!x.dataset.sunChatCloseBound){x.dataset.sunChatCloseBound='1';x.addEventListener('click',()=>setOrderChatMode(false));}}); + updateOrderChatBadge(); + } + + function openOrderFromCard(id){ + if(!id)return; + try{if(typeof window.openOrder==='function')window.openOrder(Number(id));}catch(e){return toast(e.message||String(e),'error')} + setTimeout(()=>openOrderInline(String(id)),60); + } + + function decorateOrderCards(){ + qa('#sunOrderCards .sun-order-card').forEach(card=>{const actions=card.querySelector('.sun-order-card-actions');if(!actions||actions.querySelector('[data-card-chat]'))return;const b=document.createElement('button');b.className='mini';b.type='button';b.dataset.cardChat=card.dataset.orderId;b.textContent='Чат';b.onclick=e=>{e.stopPropagation();openOrderFromCard(b.dataset.cardChat)};actions.prepend(b);}); + } + + function safeStateChanged(){ + try{ + installStyle();ensureView();ensureOrderDiscussionTab();decorateOrderCards(); + if(available()){ensureNav();ensureWorkspaceChannel();loadThreads({quiet:true});} + else{setNavBadge(0);$('sunChatNavV29')?.remove();unsubscribeWorkspace();activeThread=null;threads=[];members=[];messages=[];setOrderChatMode(false);} + }catch(e){console.error('[Caterium Chat] state refresh failed',e);} + } + + function boot(){ + installStyle();ensureView();ensureOrderDiscussionTab(); + const priorOpen=window.openOrder; + if(typeof priorOpen==='function'&&!priorOpen.__sunChatV31){const wrapped=function(...args){setOrderChatMode(false);const r=priorOpen.apply(this,args);setTimeout(()=>{ensureOrderDiscussionTab();decorateOrderCards();},0);return r};wrapped.__sunChatV31=true;window.openOrder=wrapped;} + const priorRenderOrders=window.renderOrders; + if(typeof priorRenderOrders==='function'&&!priorRenderOrders.__sunChatV31){const wrapped=function(...args){const r=priorRenderOrders.apply(this,args);setTimeout(decorateOrderCards,0);return r};wrapped.__sunChatV31=true;window.renderOrders=wrapped;} + document.addEventListener('click',e=>{const nav=e.target.closest('header nav button');if(!nav)return;if(nav.id!=='sunChatNavV29')setOrderChatMode(false);setTimeout(()=>{ensureOrderDiscussionTab();decorateOrderCards();},20);},true); + window.addEventListener('sun:cloud-permissions-changed',()=>setTimeout(safeStateChanged,60)); + window.addEventListener('sun:cloud-state-applied',()=>setTimeout(safeStateChanged,60)); + window.addEventListener('online',()=>{if(available())ensureWorkspaceChannel()}); + window.addEventListener('beforeunload',()=>{try{workspaceChannel?.untrack?.()}catch(_){}}); + let tries=0;bootRetryTimer=setInterval(()=>{tries++;safeStateChanged();if(available()||tries>=24){clearInterval(bootRetryTimer);bootRetryTimer=null;}},750);setTimeout(safeStateChanged,250); + } + + window.SunChatV29={VERSION,open:openMain,openOrder:openOrderInline,openDirect,refresh:()=>loadThreads(),getThreads:()=>threads.slice(),getMembers:()=>members.slice()}; + window.SunChatV31=window.SunChatV29; + if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',boot,{once:true});else boot(); +})(); + diff --git a/public/catalog/001.jpg b/public/catalog/001.jpg new file mode 100644 index 0000000..79be2a9 Binary files /dev/null and b/public/catalog/001.jpg differ diff --git a/public/catalog/002.jpg b/public/catalog/002.jpg new file mode 100644 index 0000000..9670380 Binary files /dev/null and b/public/catalog/002.jpg differ diff --git a/public/catalog/003.jpg b/public/catalog/003.jpg new file mode 100644 index 0000000..3842f9b Binary files /dev/null and b/public/catalog/003.jpg differ diff --git a/public/catalog/004.jpg b/public/catalog/004.jpg new file mode 100644 index 0000000..807b4de Binary files /dev/null and b/public/catalog/004.jpg differ diff --git a/public/catalog/005.jpg b/public/catalog/005.jpg new file mode 100644 index 0000000..214f666 Binary files /dev/null and b/public/catalog/005.jpg differ diff --git a/public/catalog/006.jpg b/public/catalog/006.jpg new file mode 100644 index 0000000..9e71418 Binary files /dev/null and b/public/catalog/006.jpg differ diff --git a/public/catalog/007.jpg b/public/catalog/007.jpg new file mode 100644 index 0000000..2399389 Binary files /dev/null and b/public/catalog/007.jpg differ diff --git a/public/catalog/008.jpg b/public/catalog/008.jpg new file mode 100644 index 0000000..c27d6d2 Binary files /dev/null and b/public/catalog/008.jpg differ diff --git a/public/catalog/009.jpg b/public/catalog/009.jpg new file mode 100644 index 0000000..dfac398 Binary files /dev/null and b/public/catalog/009.jpg differ diff --git a/public/catalog/010.jpg b/public/catalog/010.jpg new file mode 100644 index 0000000..b3b3cd6 Binary files /dev/null and b/public/catalog/010.jpg differ diff --git a/public/catalog/011.jpg b/public/catalog/011.jpg new file mode 100644 index 0000000..7e6b57e Binary files /dev/null and b/public/catalog/011.jpg differ diff --git a/public/catalog/012.jpg b/public/catalog/012.jpg new file mode 100644 index 0000000..88a22a4 Binary files /dev/null and b/public/catalog/012.jpg differ diff --git a/public/catalog/013.jpg b/public/catalog/013.jpg new file mode 100644 index 0000000..d9ced83 Binary files /dev/null and b/public/catalog/013.jpg differ diff --git a/public/catalog/014.jpg b/public/catalog/014.jpg new file mode 100644 index 0000000..00de0fe Binary files /dev/null and b/public/catalog/014.jpg differ diff --git a/public/catalog/015.jpg b/public/catalog/015.jpg new file mode 100644 index 0000000..24de7e0 Binary files /dev/null and b/public/catalog/015.jpg differ diff --git a/public/catalog/016.jpg b/public/catalog/016.jpg new file mode 100644 index 0000000..e46cabf Binary files /dev/null and b/public/catalog/016.jpg differ diff --git a/public/catalog/017.jpg b/public/catalog/017.jpg new file mode 100644 index 0000000..d7c371d Binary files /dev/null and b/public/catalog/017.jpg differ diff --git a/public/catalog/018.jpg b/public/catalog/018.jpg new file mode 100644 index 0000000..765fed3 Binary files /dev/null and b/public/catalog/018.jpg differ diff --git a/public/catalog/019.jpg b/public/catalog/019.jpg new file mode 100644 index 0000000..03db907 Binary files /dev/null and b/public/catalog/019.jpg differ diff --git a/public/catalog/020.jpg b/public/catalog/020.jpg new file mode 100644 index 0000000..bbca6a1 Binary files /dev/null and b/public/catalog/020.jpg differ diff --git a/public/catalog/021.jpg b/public/catalog/021.jpg new file mode 100644 index 0000000..543e6a8 Binary files /dev/null and b/public/catalog/021.jpg differ diff --git a/public/catalog/022.jpg b/public/catalog/022.jpg new file mode 100644 index 0000000..4c014a1 Binary files /dev/null and b/public/catalog/022.jpg differ diff --git a/public/catalog/023.jpg b/public/catalog/023.jpg new file mode 100644 index 0000000..a5c4fe9 Binary files /dev/null and b/public/catalog/023.jpg differ diff --git a/public/catalog/024.jpg b/public/catalog/024.jpg new file mode 100644 index 0000000..240f6fc Binary files /dev/null and b/public/catalog/024.jpg differ diff --git a/public/catalog/025.jpg b/public/catalog/025.jpg new file mode 100644 index 0000000..240f6fc Binary files /dev/null and b/public/catalog/025.jpg differ diff --git a/public/catalog/026.jpg b/public/catalog/026.jpg new file mode 100644 index 0000000..51fc16d Binary files /dev/null and b/public/catalog/026.jpg differ diff --git a/public/catalog/027.jpg b/public/catalog/027.jpg new file mode 100644 index 0000000..8dc199f Binary files /dev/null and b/public/catalog/027.jpg differ diff --git a/public/catalog/028.jpg b/public/catalog/028.jpg new file mode 100644 index 0000000..8378be1 Binary files /dev/null and b/public/catalog/028.jpg differ diff --git a/public/catalog/029.jpg b/public/catalog/029.jpg new file mode 100644 index 0000000..9d77465 Binary files /dev/null and b/public/catalog/029.jpg differ diff --git a/public/catalog/030.jpg b/public/catalog/030.jpg new file mode 100644 index 0000000..b833e4b Binary files /dev/null and b/public/catalog/030.jpg differ diff --git a/public/catalog/031.jpg b/public/catalog/031.jpg new file mode 100644 index 0000000..281e6d9 Binary files /dev/null and b/public/catalog/031.jpg differ diff --git a/public/catalog/032.jpg b/public/catalog/032.jpg new file mode 100644 index 0000000..e4d6ccc Binary files /dev/null and b/public/catalog/032.jpg differ diff --git a/public/catalog/033.jpg b/public/catalog/033.jpg new file mode 100644 index 0000000..41fad5e Binary files /dev/null and b/public/catalog/033.jpg differ diff --git a/public/catalog/034.jpg b/public/catalog/034.jpg new file mode 100644 index 0000000..1a4cfd0 Binary files /dev/null and b/public/catalog/034.jpg differ diff --git a/public/catalog/035.jpg b/public/catalog/035.jpg new file mode 100644 index 0000000..248326b Binary files /dev/null and b/public/catalog/035.jpg differ diff --git a/public/catalog/036.jpg b/public/catalog/036.jpg new file mode 100644 index 0000000..f16cc3b Binary files /dev/null and b/public/catalog/036.jpg differ diff --git a/public/catalog/037.jpg b/public/catalog/037.jpg new file mode 100644 index 0000000..a33202b Binary files /dev/null and b/public/catalog/037.jpg differ diff --git a/public/catalog/038.jpg b/public/catalog/038.jpg new file mode 100644 index 0000000..a33202b Binary files /dev/null and b/public/catalog/038.jpg differ diff --git a/public/catalog/039.jpg b/public/catalog/039.jpg new file mode 100644 index 0000000..6d7bac4 Binary files /dev/null and b/public/catalog/039.jpg differ diff --git a/public/catalog/040.jpg b/public/catalog/040.jpg new file mode 100644 index 0000000..fe18187 Binary files /dev/null and b/public/catalog/040.jpg differ diff --git a/public/catalog/041.jpg b/public/catalog/041.jpg new file mode 100644 index 0000000..fe18187 Binary files /dev/null and b/public/catalog/041.jpg differ diff --git a/public/catalog/042.jpg b/public/catalog/042.jpg new file mode 100644 index 0000000..af7efc1 Binary files /dev/null and b/public/catalog/042.jpg differ diff --git a/public/catalog/043.jpg b/public/catalog/043.jpg new file mode 100644 index 0000000..3b44683 Binary files /dev/null and b/public/catalog/043.jpg differ diff --git a/public/catalog/044.jpg b/public/catalog/044.jpg new file mode 100644 index 0000000..4e2bae0 Binary files /dev/null and b/public/catalog/044.jpg differ diff --git a/public/catalog/045.jpg b/public/catalog/045.jpg new file mode 100644 index 0000000..158832e Binary files /dev/null and b/public/catalog/045.jpg differ diff --git a/public/catalog/046.jpg b/public/catalog/046.jpg new file mode 100644 index 0000000..2658e24 Binary files /dev/null and b/public/catalog/046.jpg differ diff --git a/public/catalog/047.jpg b/public/catalog/047.jpg new file mode 100644 index 0000000..5205079 Binary files /dev/null and b/public/catalog/047.jpg differ diff --git a/public/catalog/048.jpg b/public/catalog/048.jpg new file mode 100644 index 0000000..e7befd3 Binary files /dev/null and b/public/catalog/048.jpg differ diff --git a/public/catalog/049.jpg b/public/catalog/049.jpg new file mode 100644 index 0000000..307fe0c Binary files /dev/null and b/public/catalog/049.jpg differ diff --git a/public/catalog/050.jpg b/public/catalog/050.jpg new file mode 100644 index 0000000..5d6c04b Binary files /dev/null and b/public/catalog/050.jpg differ diff --git a/public/catalog/051.jpg b/public/catalog/051.jpg new file mode 100644 index 0000000..4474dfc Binary files /dev/null and b/public/catalog/051.jpg differ diff --git a/public/catalog/052.jpg b/public/catalog/052.jpg new file mode 100644 index 0000000..8237dbc Binary files /dev/null and b/public/catalog/052.jpg differ diff --git a/public/catalog/053.jpg b/public/catalog/053.jpg new file mode 100644 index 0000000..5d8a2d1 Binary files /dev/null and b/public/catalog/053.jpg differ diff --git a/public/catalog/054.jpg b/public/catalog/054.jpg new file mode 100644 index 0000000..5177ae4 Binary files /dev/null and b/public/catalog/054.jpg differ diff --git a/public/catalog/055.jpg b/public/catalog/055.jpg new file mode 100644 index 0000000..0d2acbd Binary files /dev/null and b/public/catalog/055.jpg differ diff --git a/public/catalog/056.jpg b/public/catalog/056.jpg new file mode 100644 index 0000000..7b56389 Binary files /dev/null and b/public/catalog/056.jpg differ diff --git a/public/catalog/057.jpg b/public/catalog/057.jpg new file mode 100644 index 0000000..5b4a796 Binary files /dev/null and b/public/catalog/057.jpg differ diff --git a/public/catalog/058.jpg b/public/catalog/058.jpg new file mode 100644 index 0000000..0998fc5 Binary files /dev/null and b/public/catalog/058.jpg differ diff --git a/public/catalog/059.jpg b/public/catalog/059.jpg new file mode 100644 index 0000000..2d4df2d Binary files /dev/null and b/public/catalog/059.jpg differ diff --git a/public/catalog/060.jpg b/public/catalog/060.jpg new file mode 100644 index 0000000..f63e869 Binary files /dev/null and b/public/catalog/060.jpg differ diff --git a/public/catalog/current-001.jpg b/public/catalog/current-001.jpg new file mode 100644 index 0000000..79be2a9 Binary files /dev/null and b/public/catalog/current-001.jpg differ diff --git a/public/catalog/current-002.jpg b/public/catalog/current-002.jpg new file mode 100644 index 0000000..9670380 Binary files /dev/null and b/public/catalog/current-002.jpg differ diff --git a/public/catalog/current-003.jpg b/public/catalog/current-003.jpg new file mode 100644 index 0000000..3842f9b Binary files /dev/null and b/public/catalog/current-003.jpg differ diff --git a/public/catalog/current-004.jpg b/public/catalog/current-004.jpg new file mode 100644 index 0000000..807b4de Binary files /dev/null and b/public/catalog/current-004.jpg differ diff --git a/public/catalog/current-005.jpg b/public/catalog/current-005.jpg new file mode 100644 index 0000000..bd6d058 Binary files /dev/null and b/public/catalog/current-005.jpg differ diff --git a/public/catalog/current-006.jpg b/public/catalog/current-006.jpg new file mode 100644 index 0000000..214f666 Binary files /dev/null and b/public/catalog/current-006.jpg differ diff --git a/public/catalog/current-007.jpg b/public/catalog/current-007.jpg new file mode 100644 index 0000000..9e71418 Binary files /dev/null and b/public/catalog/current-007.jpg differ diff --git a/public/catalog/current-008.jpg b/public/catalog/current-008.jpg new file mode 100644 index 0000000..60c1494 Binary files /dev/null and b/public/catalog/current-008.jpg differ diff --git a/public/catalog/current-009.jpg b/public/catalog/current-009.jpg new file mode 100644 index 0000000..2399389 Binary files /dev/null and b/public/catalog/current-009.jpg differ diff --git a/public/catalog/current-010.jpg b/public/catalog/current-010.jpg new file mode 100644 index 0000000..a5c4fe9 Binary files /dev/null and b/public/catalog/current-010.jpg differ diff --git a/public/catalog/current-011.jpg b/public/catalog/current-011.jpg new file mode 100644 index 0000000..c27d6d2 Binary files /dev/null and b/public/catalog/current-011.jpg differ diff --git a/public/catalog/current-012.jpg b/public/catalog/current-012.jpg new file mode 100644 index 0000000..240f6fc Binary files /dev/null and b/public/catalog/current-012.jpg differ diff --git a/public/catalog/current-013.jpg b/public/catalog/current-013.jpg new file mode 100644 index 0000000..fe18187 Binary files /dev/null and b/public/catalog/current-013.jpg differ diff --git a/public/catalog/current-014.jpg b/public/catalog/current-014.jpg new file mode 100644 index 0000000..240f6fc Binary files /dev/null and b/public/catalog/current-014.jpg differ diff --git a/public/catalog/current-015.jpg b/public/catalog/current-015.jpg new file mode 100644 index 0000000..7efb611 Binary files /dev/null and b/public/catalog/current-015.jpg differ diff --git a/public/catalog/current-016.jpg b/public/catalog/current-016.jpg new file mode 100644 index 0000000..dfac398 Binary files /dev/null and b/public/catalog/current-016.jpg differ diff --git a/public/catalog/current-017.jpg b/public/catalog/current-017.jpg new file mode 100644 index 0000000..b3b3cd6 Binary files /dev/null and b/public/catalog/current-017.jpg differ diff --git a/public/catalog/current-018.jpg b/public/catalog/current-018.jpg new file mode 100644 index 0000000..7e6b57e Binary files /dev/null and b/public/catalog/current-018.jpg differ diff --git a/public/catalog/current-019.jpg b/public/catalog/current-019.jpg new file mode 100644 index 0000000..0a280d9 Binary files /dev/null and b/public/catalog/current-019.jpg differ diff --git a/public/catalog/current-020.jpg b/public/catalog/current-020.jpg new file mode 100644 index 0000000..1a4cfd0 Binary files /dev/null and b/public/catalog/current-020.jpg differ diff --git a/public/catalog/current-021.jpg b/public/catalog/current-021.jpg new file mode 100644 index 0000000..8dc199f Binary files /dev/null and b/public/catalog/current-021.jpg differ diff --git a/public/catalog/current-022.jpg b/public/catalog/current-022.jpg new file mode 100644 index 0000000..fe18187 Binary files /dev/null and b/public/catalog/current-022.jpg differ diff --git a/public/catalog/current-023.jpg b/public/catalog/current-023.jpg new file mode 100644 index 0000000..8378be1 Binary files /dev/null and b/public/catalog/current-023.jpg differ diff --git a/public/catalog/current-024.jpg b/public/catalog/current-024.jpg new file mode 100644 index 0000000..248326b Binary files /dev/null and b/public/catalog/current-024.jpg differ diff --git a/public/catalog/current-025.jpg b/public/catalog/current-025.jpg new file mode 100644 index 0000000..9d77465 Binary files /dev/null and b/public/catalog/current-025.jpg differ diff --git a/public/catalog/current-026.jpg b/public/catalog/current-026.jpg new file mode 100644 index 0000000..88a22a4 Binary files /dev/null and b/public/catalog/current-026.jpg differ diff --git a/public/catalog/current-027.jpg b/public/catalog/current-027.jpg new file mode 100644 index 0000000..f16cc3b Binary files /dev/null and b/public/catalog/current-027.jpg differ diff --git a/public/catalog/current-028.jpg b/public/catalog/current-028.jpg new file mode 100644 index 0000000..51fc16d Binary files /dev/null and b/public/catalog/current-028.jpg differ diff --git a/public/catalog/current-029.jpg b/public/catalog/current-029.jpg new file mode 100644 index 0000000..a33202b Binary files /dev/null and b/public/catalog/current-029.jpg differ diff --git a/public/catalog/current-030.jpg b/public/catalog/current-030.jpg new file mode 100644 index 0000000..6d7bac4 Binary files /dev/null and b/public/catalog/current-030.jpg differ diff --git a/public/catalog/current-031.jpg b/public/catalog/current-031.jpg new file mode 100644 index 0000000..b424fe1 Binary files /dev/null and b/public/catalog/current-031.jpg differ diff --git a/public/catalog/current-032.jpg b/public/catalog/current-032.jpg new file mode 100644 index 0000000..4474dfc Binary files /dev/null and b/public/catalog/current-032.jpg differ diff --git a/public/catalog/current-033.jpg b/public/catalog/current-033.jpg new file mode 100644 index 0000000..a33202b Binary files /dev/null and b/public/catalog/current-033.jpg differ diff --git a/public/catalog/current-034.jpg b/public/catalog/current-034.jpg new file mode 100644 index 0000000..d9ced83 Binary files /dev/null and b/public/catalog/current-034.jpg differ diff --git a/public/catalog/current-035.jpg b/public/catalog/current-035.jpg new file mode 100644 index 0000000..00de0fe Binary files /dev/null and b/public/catalog/current-035.jpg differ diff --git a/public/catalog/current-036.jpg b/public/catalog/current-036.jpg new file mode 100644 index 0000000..bd92a38 Binary files /dev/null and b/public/catalog/current-036.jpg differ diff --git a/public/catalog/current-037.jpg b/public/catalog/current-037.jpg new file mode 100644 index 0000000..a8ab43c Binary files /dev/null and b/public/catalog/current-037.jpg differ diff --git a/public/catalog/current-038.jpg b/public/catalog/current-038.jpg new file mode 100644 index 0000000..ad923d6 Binary files /dev/null and b/public/catalog/current-038.jpg differ diff --git a/public/catalog/current-039.jpg b/public/catalog/current-039.jpg new file mode 100644 index 0000000..8237dbc Binary files /dev/null and b/public/catalog/current-039.jpg differ diff --git a/public/catalog/current-040.jpg b/public/catalog/current-040.jpg new file mode 100644 index 0000000..af7efc1 Binary files /dev/null and b/public/catalog/current-040.jpg differ diff --git a/public/catalog/current-041.jpg b/public/catalog/current-041.jpg new file mode 100644 index 0000000..24de7e0 Binary files /dev/null and b/public/catalog/current-041.jpg differ diff --git a/public/catalog/current-042.jpg b/public/catalog/current-042.jpg new file mode 100644 index 0000000..88ebe57 Binary files /dev/null and b/public/catalog/current-042.jpg differ diff --git a/public/catalog/current-043.jpg b/public/catalog/current-043.jpg new file mode 100644 index 0000000..e6ea545 Binary files /dev/null and b/public/catalog/current-043.jpg differ diff --git a/public/catalog/current-044.jpg b/public/catalog/current-044.jpg new file mode 100644 index 0000000..e46cabf Binary files /dev/null and b/public/catalog/current-044.jpg differ diff --git a/public/catalog/current-045.jpg b/public/catalog/current-045.jpg new file mode 100644 index 0000000..5d8a2d1 Binary files /dev/null and b/public/catalog/current-045.jpg differ diff --git a/public/catalog/current-046.jpg b/public/catalog/current-046.jpg new file mode 100644 index 0000000..d7c371d Binary files /dev/null and b/public/catalog/current-046.jpg differ diff --git a/public/catalog/current-047.jpg b/public/catalog/current-047.jpg new file mode 100644 index 0000000..4c014a1 Binary files /dev/null and b/public/catalog/current-047.jpg differ diff --git a/public/catalog/current-048.jpg b/public/catalog/current-048.jpg new file mode 100644 index 0000000..b833e4b Binary files /dev/null and b/public/catalog/current-048.jpg differ diff --git a/public/catalog/current-049.jpg b/public/catalog/current-049.jpg new file mode 100644 index 0000000..4e2bae0 Binary files /dev/null and b/public/catalog/current-049.jpg differ diff --git a/public/catalog/current-050.jpg b/public/catalog/current-050.jpg new file mode 100644 index 0000000..9e77071 Binary files /dev/null and b/public/catalog/current-050.jpg differ diff --git a/public/catalog/current-051.jpg b/public/catalog/current-051.jpg new file mode 100644 index 0000000..281e6d9 Binary files /dev/null and b/public/catalog/current-051.jpg differ diff --git a/public/catalog/current-052.jpg b/public/catalog/current-052.jpg new file mode 100644 index 0000000..158832e Binary files /dev/null and b/public/catalog/current-052.jpg differ diff --git a/public/catalog/current-053.jpg b/public/catalog/current-053.jpg new file mode 100644 index 0000000..e4d6ccc Binary files /dev/null and b/public/catalog/current-053.jpg differ diff --git a/public/catalog/current-054.jpg b/public/catalog/current-054.jpg new file mode 100644 index 0000000..2658e24 Binary files /dev/null and b/public/catalog/current-054.jpg differ diff --git a/public/catalog/current-055.jpg b/public/catalog/current-055.jpg new file mode 100644 index 0000000..08968aa Binary files /dev/null and b/public/catalog/current-055.jpg differ diff --git a/public/catalog/current-056.jpg b/public/catalog/current-056.jpg new file mode 100644 index 0000000..d92600b Binary files /dev/null and b/public/catalog/current-056.jpg differ diff --git a/public/catalog/current-057.jpg b/public/catalog/current-057.jpg new file mode 100644 index 0000000..41fad5e Binary files /dev/null and b/public/catalog/current-057.jpg differ diff --git a/public/catalog/current-058.jpg b/public/catalog/current-058.jpg new file mode 100644 index 0000000..765fed3 Binary files /dev/null and b/public/catalog/current-058.jpg differ diff --git a/public/catalog/current-059.jpg b/public/catalog/current-059.jpg new file mode 100644 index 0000000..5205079 Binary files /dev/null and b/public/catalog/current-059.jpg differ diff --git a/public/catalog/current-060.jpg b/public/catalog/current-060.jpg new file mode 100644 index 0000000..03db907 Binary files /dev/null and b/public/catalog/current-060.jpg differ diff --git a/public/catalog/current-061.jpg b/public/catalog/current-061.jpg new file mode 100644 index 0000000..5177ae4 Binary files /dev/null and b/public/catalog/current-061.jpg differ diff --git a/public/catalog/current-062.jpg b/public/catalog/current-062.jpg new file mode 100644 index 0000000..0d2acbd Binary files /dev/null and b/public/catalog/current-062.jpg differ diff --git a/public/catalog/current-063.jpg b/public/catalog/current-063.jpg new file mode 100644 index 0000000..bbca6a1 Binary files /dev/null and b/public/catalog/current-063.jpg differ diff --git a/public/catalog/current-064.jpg b/public/catalog/current-064.jpg new file mode 100644 index 0000000..543e6a8 Binary files /dev/null and b/public/catalog/current-064.jpg differ diff --git a/public/catalog/current-065.jpg b/public/catalog/current-065.jpg new file mode 100644 index 0000000..e7befd3 Binary files /dev/null and b/public/catalog/current-065.jpg differ diff --git a/public/catalog/current-066.jpg b/public/catalog/current-066.jpg new file mode 100644 index 0000000..4c014a1 Binary files /dev/null and b/public/catalog/current-066.jpg differ diff --git a/public/catalog/current-067.jpg b/public/catalog/current-067.jpg new file mode 100644 index 0000000..307fe0c Binary files /dev/null and b/public/catalog/current-067.jpg differ diff --git a/public/catalog/current-068.jpg b/public/catalog/current-068.jpg new file mode 100644 index 0000000..5d6c04b Binary files /dev/null and b/public/catalog/current-068.jpg differ diff --git a/public/catalog/current-069.jpg b/public/catalog/current-069.jpg new file mode 100644 index 0000000..5b4a796 Binary files /dev/null and b/public/catalog/current-069.jpg differ diff --git a/public/catalog/current-070.jpg b/public/catalog/current-070.jpg new file mode 100644 index 0000000..0998fc5 Binary files /dev/null and b/public/catalog/current-070.jpg differ diff --git a/public/catalog/current-071.jpg b/public/catalog/current-071.jpg new file mode 100644 index 0000000..2d4df2d Binary files /dev/null and b/public/catalog/current-071.jpg differ diff --git a/public/catalog/current-072.jpg b/public/catalog/current-072.jpg new file mode 100644 index 0000000..f63e869 Binary files /dev/null and b/public/catalog/current-072.jpg differ diff --git a/public/catalog/current-073.jpg b/public/catalog/current-073.jpg new file mode 100644 index 0000000..0450719 Binary files /dev/null and b/public/catalog/current-073.jpg differ diff --git a/public/catalog/current-074.jpg b/public/catalog/current-074.jpg new file mode 100644 index 0000000..b75d46a Binary files /dev/null and b/public/catalog/current-074.jpg differ diff --git a/public/catalog/current-075.jpg b/public/catalog/current-075.jpg new file mode 100644 index 0000000..ed68f8a Binary files /dev/null and b/public/catalog/current-075.jpg differ diff --git a/public/catalog/current-076.jpg b/public/catalog/current-076.jpg new file mode 100644 index 0000000..e7c7a66 Binary files /dev/null and b/public/catalog/current-076.jpg differ diff --git a/public/catalog/current-077.jpg b/public/catalog/current-077.jpg new file mode 100644 index 0000000..e7c7a66 Binary files /dev/null and b/public/catalog/current-077.jpg differ diff --git a/public/catalog/current-078.jpg b/public/catalog/current-078.jpg new file mode 100644 index 0000000..e7c7a66 Binary files /dev/null and b/public/catalog/current-078.jpg differ diff --git a/public/catalog/current-079.jpg b/public/catalog/current-079.jpg new file mode 100644 index 0000000..e7c7a66 Binary files /dev/null and b/public/catalog/current-079.jpg differ diff --git a/public/catalog/current-080.jpg b/public/catalog/current-080.jpg new file mode 100644 index 0000000..1ec837f Binary files /dev/null and b/public/catalog/current-080.jpg differ diff --git a/public/catalog/current-081.jpg b/public/catalog/current-081.jpg new file mode 100644 index 0000000..6517f37 Binary files /dev/null and b/public/catalog/current-081.jpg differ diff --git a/public/catalog/current-082.jpg b/public/catalog/current-082.jpg new file mode 100644 index 0000000..e7c7a66 Binary files /dev/null and b/public/catalog/current-082.jpg differ diff --git a/public/catalog/current-083.jpg b/public/catalog/current-083.jpg new file mode 100644 index 0000000..e7c7a66 Binary files /dev/null and b/public/catalog/current-083.jpg differ diff --git a/public/catalog/current-084.jpg b/public/catalog/current-084.jpg new file mode 100644 index 0000000..f63e869 Binary files /dev/null and b/public/catalog/current-084.jpg differ diff --git a/public/catalog/current-085.jpg b/public/catalog/current-085.jpg new file mode 100644 index 0000000..c6c72f2 Binary files /dev/null and b/public/catalog/current-085.jpg differ diff --git a/public/catalog/current-086.jpg b/public/catalog/current-086.jpg new file mode 100644 index 0000000..2fb3f00 Binary files /dev/null and b/public/catalog/current-086.jpg differ diff --git a/public/catalog/current-087.jpg b/public/catalog/current-087.jpg new file mode 100644 index 0000000..9805205 Binary files /dev/null and b/public/catalog/current-087.jpg differ diff --git a/public/catalog/current-088.jpg b/public/catalog/current-088.jpg new file mode 100644 index 0000000..fb83381 Binary files /dev/null and b/public/catalog/current-088.jpg differ diff --git a/public/catalog/current-089.jpg b/public/catalog/current-089.jpg new file mode 100644 index 0000000..f005d4e Binary files /dev/null and b/public/catalog/current-089.jpg differ diff --git a/public/catalog/current-090.jpg b/public/catalog/current-090.jpg new file mode 100644 index 0000000..cfa64e4 Binary files /dev/null and b/public/catalog/current-090.jpg differ diff --git a/public/catalog/current-091.jpg b/public/catalog/current-091.jpg new file mode 100644 index 0000000..e34f5d1 Binary files /dev/null and b/public/catalog/current-091.jpg differ diff --git a/public/catalog/current-092.jpg b/public/catalog/current-092.jpg new file mode 100644 index 0000000..c092182 Binary files /dev/null and b/public/catalog/current-092.jpg differ diff --git a/public/catalog/current-093.jpg b/public/catalog/current-093.jpg new file mode 100644 index 0000000..28985ac Binary files /dev/null and b/public/catalog/current-093.jpg differ diff --git a/public/catalog/current-094.jpg b/public/catalog/current-094.jpg new file mode 100644 index 0000000..7fe1806 Binary files /dev/null and b/public/catalog/current-094.jpg differ diff --git a/public/catalog/current-095.jpg b/public/catalog/current-095.jpg new file mode 100644 index 0000000..e9a39ec Binary files /dev/null and b/public/catalog/current-095.jpg differ diff --git a/public/catalog/current-096.jpg b/public/catalog/current-096.jpg new file mode 100644 index 0000000..75ba3f7 Binary files /dev/null and b/public/catalog/current-096.jpg differ diff --git a/public/catalog/current-097.jpg b/public/catalog/current-097.jpg new file mode 100644 index 0000000..6b32d7c Binary files /dev/null and b/public/catalog/current-097.jpg differ diff --git a/public/catalog/current-098.jpg b/public/catalog/current-098.jpg new file mode 100644 index 0000000..c4d7269 Binary files /dev/null and b/public/catalog/current-098.jpg differ diff --git a/public/catalog/current-099.jpg b/public/catalog/current-099.jpg new file mode 100644 index 0000000..3c2f957 Binary files /dev/null and b/public/catalog/current-099.jpg differ diff --git a/public/catalog/current-100.jpg b/public/catalog/current-100.jpg new file mode 100644 index 0000000..2c7902d Binary files /dev/null and b/public/catalog/current-100.jpg differ diff --git a/public/catalog/current-101.jpg b/public/catalog/current-101.jpg new file mode 100644 index 0000000..b95de74 Binary files /dev/null and b/public/catalog/current-101.jpg differ diff --git a/public/catalog/current-102.jpg b/public/catalog/current-102.jpg new file mode 100644 index 0000000..30c9f0a Binary files /dev/null and b/public/catalog/current-102.jpg differ diff --git a/public/catalog/current-103.jpg b/public/catalog/current-103.jpg new file mode 100644 index 0000000..9a349db Binary files /dev/null and b/public/catalog/current-103.jpg differ diff --git a/public/catalog/current-104.jpg b/public/catalog/current-104.jpg new file mode 100644 index 0000000..fe21a67 Binary files /dev/null and b/public/catalog/current-104.jpg differ diff --git a/public/catalog/current-105.jpg b/public/catalog/current-105.jpg new file mode 100644 index 0000000..faa3032 Binary files /dev/null and b/public/catalog/current-105.jpg differ diff --git a/public/catalog/current-106.jpg b/public/catalog/current-106.jpg new file mode 100644 index 0000000..108f500 Binary files /dev/null and b/public/catalog/current-106.jpg differ diff --git a/public/catalog/current-107.jpg b/public/catalog/current-107.jpg new file mode 100644 index 0000000..a82ef1e Binary files /dev/null and b/public/catalog/current-107.jpg differ diff --git a/public/catalog/current-108.jpg b/public/catalog/current-108.jpg new file mode 100644 index 0000000..679b1f1 Binary files /dev/null and b/public/catalog/current-108.jpg differ diff --git a/public/catalog/current-109.jpg b/public/catalog/current-109.jpg new file mode 100644 index 0000000..5349ee8 Binary files /dev/null and b/public/catalog/current-109.jpg differ diff --git a/public/catalog/current-110.jpg b/public/catalog/current-110.jpg new file mode 100644 index 0000000..967ded8 Binary files /dev/null and b/public/catalog/current-110.jpg differ diff --git a/public/catalog/current-111.jpg b/public/catalog/current-111.jpg new file mode 100644 index 0000000..74098a3 Binary files /dev/null and b/public/catalog/current-111.jpg differ diff --git a/public/catalog/current-112.jpg b/public/catalog/current-112.jpg new file mode 100644 index 0000000..8502ee4 Binary files /dev/null and b/public/catalog/current-112.jpg differ diff --git a/public/catalog/current-113.jpg b/public/catalog/current-113.jpg new file mode 100644 index 0000000..f8e29b8 Binary files /dev/null and b/public/catalog/current-113.jpg differ diff --git a/public/catalog/photo-sources.json b/public/catalog/photo-sources.json new file mode 100644 index 0000000..fa3722d --- /dev/null +++ b/public/catalog/photo-sources.json @@ -0,0 +1,454 @@ +[ + { + "path": "catalog/current-001.jpg", + "url": "https://static.tildacdn.com/stor3131-6532-4762-a534-353638663730/11f4ce48f250257ec824a12e1c541150.jpg" + }, + { + "path": "catalog/current-002.jpg", + "url": "https://static.tildacdn.com/stor6634-3466-4635-a164-386637346334/e94521aba11d1824218728cee4312c03.jpg" + }, + { + "path": "catalog/current-003.jpg", + "url": "https://static.tildacdn.com/stor3034-3036-4334-b233-303363326236/d72f45ce6d6a044d8d892a0718f29d89.jpg" + }, + { + "path": "catalog/current-004.jpg", + "url": "https://static.tildacdn.com/stor3662-3435-4561-b031-663437646233/efd5e71d2a8654b0afa26a6dd7a0595a.jpg" + }, + { + "path": "catalog/current-005.jpg", + "url": "https://static.tildacdn.com/stor6133-6537-4533-a166-666430373166/014bff38be088e524c0a4fd2ee1daab3.jpg" + }, + { + "path": "catalog/current-006.jpg", + "url": "https://static.tildacdn.com/stor6161-3163-4032-b338-363935363839/7b9abb2bc27c854236e234383f044c20.jpg" + }, + { + "path": "catalog/current-007.jpg", + "url": "https://static.tildacdn.com/stor3065-6462-4666-b134-376564613831/527188ad5747060eaa88cdd6b033e1b7.jpg" + }, + { + "path": "catalog/current-008.jpg", + "url": "https://static.tildacdn.com/stor6238-3635-4936-a361-343062343130/6c2841340110a32dee16481cf5776858.jpg" + }, + { + "path": "catalog/current-009.jpg", + "url": "https://static.tildacdn.com/stor6231-3837-4666-b564-656661306534/a9b0efe3696fa0cd6530e3100cce2447.jpg" + }, + { + "path": "catalog/current-010.jpg", + "url": "https://static.tildacdn.com/stor3162-6131-4337-b731-376266363535/6998754c49e80fca1df239d01e91b239.jpg" + }, + { + "path": "catalog/current-011.jpg", + "url": "https://static.tildacdn.com/stor6264-3139-4939-b331-623963306665/c62d84a2c9de7fd115f9878617709a99.jpg" + }, + { + "path": "catalog/current-012.jpg", + "url": "https://static.tildacdn.com/stor3134-6638-4635-b032-623233383830/413674a63540f26bbcded51a2f37c3a7.jpg" + }, + { + "path": "catalog/current-013.jpg", + "url": "https://static.tildacdn.com/stor6438-6236-4831-b038-346437643037/b7305f8e759e161407a9996e0218cf83.jpg" + }, + { + "path": "catalog/current-014.jpg", + "url": "https://static.tildacdn.com/stor3134-6638-4635-b032-623233383830/413674a63540f26bbcded51a2f37c3a7.jpg" + }, + { + "path": "catalog/current-015.jpg", + "url": "https://static.tildacdn.com/stor3536-6463-4535-a135-653763366634/3b6eec9a6310eea531c8cb70b00955a9.jpg" + }, + { + "path": "catalog/current-016.jpg", + "url": "https://static.tildacdn.com/stor3339-6634-4634-b537-363366353636/7ef8a4de7cfc055f633ab29ad7e5fca4.jpg" + }, + { + "path": "catalog/current-017.jpg", + "url": "https://static.tildacdn.com/stor3030-3766-4661-b364-313536363731/f7a5bb4329e864b12eb5aed656c13c2b.jpg" + }, + { + "path": "catalog/current-018.jpg", + "url": "https://static.tildacdn.com/stor3865-3463-4832-a531-326663623661/0ab2f6a1959adeb68f6c64e21fc52e6c.jpg" + }, + { + "path": "catalog/current-019.jpg", + "url": "https://static.tildacdn.com/stor3834-6339-4234-a638-656236303531/d70c6ff9606b8f29f4d0a334643c6fa5.jpg" + }, + { + "path": "catalog/current-020.jpg", + "url": "https://static.tildacdn.com/stor3038-3736-4564-a635-333961643763/ae631a3930ee087c993f0eb5ae477388.jpg" + }, + { + "path": "catalog/current-021.jpg", + "url": "https://static.tildacdn.com/stor3638-3139-4562-b430-356363633431/a0eae79a99b4f1ca59be8a445d975f94.jpg" + }, + { + "path": "catalog/current-022.jpg", + "url": "https://static.tildacdn.com/stor3536-3766-4033-b235-643062373262/1e521931b938e17b0ebe397a447067bb.jpg" + }, + { + "path": "catalog/current-023.jpg", + "url": "https://static.tildacdn.com/stor3966-6635-4139-b965-666363393466/df421e8c29457bb6a660810c14d4dbca.jpg" + }, + { + "path": "catalog/current-024.jpg", + "url": "https://static.tildacdn.com/stor3133-6661-4839-b565-376433313839/471052a775dfa8fae813f1b043171e21.jpg" + }, + { + "path": "catalog/current-025.jpg", + "url": "https://static.tildacdn.com/stor3539-3831-4031-b532-323963396137/235a566973dc035faa18ef9a57b4bef6.jpg" + }, + { + "path": "catalog/current-026.jpg", + "url": "https://static.tildacdn.com/stor6362-6234-4063-b636-373636373465/6c868c53823c2c2799bacbef8b7ec5dc.jpg" + }, + { + "path": "catalog/current-027.jpg", + "url": "https://static.tildacdn.com/stor3439-6237-4364-a566-666531346532/a368a54d5dd3ef381bdb587f81dd3953.jpg" + }, + { + "path": "catalog/current-028.jpg", + "url": "https://static.tildacdn.com/stor6664-6363-4635-b632-383362383935/f5779eef4eaff67dec0cb8a9868fe912.jpg" + }, + { + "path": "catalog/current-029.jpg", + "url": "https://static.tildacdn.com/stor6166-3066-4535-b333-356263356363/25951fa13b57555854635d8fe1a1ecc9.jpg" + }, + { + "path": "catalog/current-030.jpg", + "url": "https://static.tildacdn.com/stor3131-3637-4837-b039-386331346435/c4fd5306cc629e81fe974d0e1238eb06.jpg" + }, + { + "path": "catalog/current-031.jpg", + "url": "https://static.tildacdn.com/stor3235-3535-4639-b164-663664323936/c3dc9dc2931b0f950034495dd8cc6b46.jpg" + }, + { + "path": "catalog/current-032.jpg", + "url": "https://static.tildacdn.com/stor3235-3661-4038-b035-613032303361/6f2c3ac522ad9d7e73d14f3fc4c47630.jpg" + }, + { + "path": "catalog/current-033.jpg", + "url": "https://static.tildacdn.com/stor6166-3066-4535-b333-356263356363/25951fa13b57555854635d8fe1a1ecc9.jpg" + }, + { + "path": "catalog/current-034.jpg", + "url": "https://static.tildacdn.com/stor3836-3438-4835-a132-663962396637/fac8bc386bd98959d75c6b71cd83471b.jpg" + }, + { + "path": "catalog/current-035.jpg", + "url": "https://static.tildacdn.com/stor3334-6361-4633-a266-643132323266/74b63ae226e1ebfbd116a822be4f642e.jpg" + }, + { + "path": "catalog/current-036.jpg", + "url": "https://static.tildacdn.com/stor3136-3834-4562-b037-396264363263/e8c1bbdff0574f52a2184a33d6f2115c.jpg" + }, + { + "path": "catalog/current-037.jpg", + "url": "https://static.tildacdn.com/stor6334-3534-4266-a664-356162323565/c13d0896543e32d12ccad0c0a108dac5.jpg" + }, + { + "path": "catalog/current-038.jpg", + "url": "https://static.tildacdn.com/stor3364-6237-4561-b432-326230363433/3233d197bebae6174651a7732ec8ca3e.jpg" + }, + { + "path": "catalog/current-039.jpg", + "url": "https://static.tildacdn.com/stor3239-6231-4130-a363-643732356464/1a72939cda10b9fc447157193a2c69cf.jpg" + }, + { + "path": "catalog/current-040.jpg", + "url": "https://static.tildacdn.com/stor3832-3732-4635-b732-636135393265/3e414b433c0aab18c0a1131d256c92e5.jpg" + }, + { + "path": "catalog/current-041.jpg", + "url": "https://static.tildacdn.com/stor6431-3634-4935-a638-323064343638/8d7c3b547b6e5abd09fe498c2e0b3853.jpg" + }, + { + "path": "catalog/current-042.jpg", + "url": "https://static.tildacdn.com/stor3362-3331-4631-b738-646138333763/b3932c942568e83ab0babc98b77ee5c3.jpg" + }, + { + "path": "catalog/current-043.jpg", + "url": "https://static.tildacdn.com/stor3565-6565-4739-b638-343639336130/ce325e928513a5c7ad2a5c9328927bd0.jpg" + }, + { + "path": "catalog/current-044.jpg", + "url": "https://static.tildacdn.com/stor3131-3335-4135-b962-356338643761/dabf8e59f8b405c2998cc725627a832a.jpg" + }, + { + "path": "catalog/current-045.jpg", + "url": "https://static.tildacdn.com/stor6365-6464-4263-b066-653333643438/eba899d7b96c0e9f077467bb4076cf56.jpg" + }, + { + "path": "catalog/current-046.jpg", + "url": "https://static.tildacdn.com/stor3064-6631-4061-b231-393166393931/2e792a2bd15012a2c134a17213d64706.jpg" + }, + { + "path": "catalog/current-047.jpg", + "url": "https://static.tildacdn.com/stor3936-6435-4438-a564-643362376266/c0d42f2444a396f1b836f4f5e2d2a2b8.jpg" + }, + { + "path": "catalog/current-048.jpg", + "url": "https://static.tildacdn.com/stor6635-6661-4361-b031-303465633437/9c824de9c9ad08c435f64d62876ca8f2.jpg" + }, + { + "path": "catalog/current-049.jpg", + "url": "https://static.tildacdn.com/stor6530-6533-4235-b631-613831393533/2651153cdc6a396acfe9c7c1f1ffa6e3.jpg" + }, + { + "path": "catalog/current-050.jpg", + "url": "https://static.tildacdn.com/stor3566-6437-4361-b137-343366323736/8f1f58eb858f669ca4acea85ad8bbbce.jpg" + }, + { + "path": "catalog/current-051.jpg", + "url": "https://static.tildacdn.com/stor3363-3539-4532-b731-343434323163/3d114d5bd96795183d342a37ef5bacb7.jpg" + }, + { + "path": "catalog/current-052.jpg", + "url": "https://static.tildacdn.com/stor3234-6630-4664-b230-616165303866/a350de68077dc437a4a7c0d1f07db97b.jpg" + }, + { + "path": "catalog/current-053.jpg", + "url": "https://static.tildacdn.com/stor6566-6236-4038-b038-326331333761/acfb6d855c780c92c4cd6e667229a9d7.jpg" + }, + { + "path": "catalog/current-054.jpg", + "url": "https://static.tildacdn.com/stor3662-3963-4031-b065-646235373965/f8f3bd5b10fe9142e453766305684126.jpg" + }, + { + "path": "catalog/current-055.jpg", + "url": "https://static.tildacdn.com/stor3461-3363-4063-a530-336136633338/951f5c4bcaa35b878fd8f0d4cdf5812a.jpg" + }, + { + "path": "catalog/current-056.jpg", + "url": "https://static.tildacdn.com/stor6335-3638-4432-b230-393163396334/d08deb92d2102c1e81002f0ff5b1cd18.jpg" + }, + { + "path": "catalog/current-057.jpg", + "url": "https://static.tildacdn.com/stor3163-3035-4466-b435-303832663162/2a85461ba4c7132af333c481f6974334.jpg" + }, + { + "path": "catalog/current-058.jpg", + "url": "https://static.tildacdn.com/stor3132-6233-4466-b164-376530633465/a5159b1de5f6bd3d2dde7609c1853f82.jpg" + }, + { + "path": "catalog/current-059.jpg", + "url": "https://static.tildacdn.com/stor3165-6138-4663-b433-393039653034/4ad56410ecf6a163824061d08cb297da.jpg" + }, + { + "path": "catalog/current-060.jpg", + "url": "https://static.tildacdn.com/stor6437-6361-4833-a537-623832376136/29ff2e5084a5f816954ea5621004b0c0.jpg" + }, + { + "path": "catalog/current-061.jpg", + "url": "https://static.tildacdn.com/stor3765-3765-4663-a333-623738326235/02eaafb2bae5cef3edaa761aa7cea142.jpg" + }, + { + "path": "catalog/current-062.jpg", + "url": "https://static.tildacdn.com/stor6365-3039-4931-a333-623061333865/ceb789e801d358ff78f2a4800f79f78c.jpg" + }, + { + "path": "catalog/current-063.jpg", + "url": "https://static.tildacdn.com/stor6262-6636-4563-b332-383166313034/73a699e67040500f90edd197152cb0b9.jpg" + }, + { + "path": "catalog/current-064.jpg", + "url": "https://static.tildacdn.com/stor3366-3663-4664-a136-633432326530/8a056937be29f8f531767a565fc2fb45.jpg" + }, + { + "path": "catalog/current-065.jpg", + "url": "https://static.tildacdn.com/stor3566-3862-4334-b465-376164633737/7ad059a9bf198827dfd142008f81bb28.jpg" + }, + { + "path": "catalog/current-066.jpg", + "url": "https://static.tildacdn.com/stor3532-6333-4432-b037-373031393364/3f233fc73a9932953876810c2530779b.jpg" + }, + { + "path": "catalog/current-067.jpg", + "url": "https://static.tildacdn.com/stor6461-6235-4134-a630-393261313034/f00b5038885f425bf4463b0f070416b2.jpg" + }, + { + "path": "catalog/current-068.jpg", + "url": "https://static.tildacdn.com/stor3963-3634-4262-b363-393536623037/7f3160b562bdb81243fcca8b07ea73ca.jpg" + }, + { + "path": "catalog/current-069.jpg", + "url": "https://static.tildacdn.com/stor6134-6534-4032-b863-326334303439/b6299036af0105e24b66b92fc149fceb.jpg" + }, + { + "path": "catalog/current-070.jpg", + "url": "https://static.tildacdn.com/stor6330-3331-4534-a431-616637323265/cc72f08366e2b3bb3b2cec181fcfb6b2.jpg" + }, + { + "path": "catalog/current-071.jpg", + "url": "https://static.tildacdn.com/stor3864-6138-4939-a263-323633343831/e41074621630fb64c6551fde54a0491a.jpg" + }, + { + "path": "catalog/current-072.jpg", + "url": "https://static.tildacdn.com/stor3530-3337-4465-a331-393235636139/6bed2df48d65e7dfdf673cc6ab1d66f0.jpg" + }, + { + "path": "catalog/current-073.jpg", + "url": "https://static.tildacdn.com/stor3132-6432-4637-b930-353763333565/0e6665f2fddcdc6a2538ff0e4d6714e3.jpg" + }, + { + "path": "catalog/current-074.jpg", + "url": "https://static.tildacdn.com/stor3063-6361-4562-b864-386439643866/5a0a9e8680146e4af16b0b3a71e5e344.jpg" + }, + { + "path": "catalog/current-075.jpg", + "url": "https://static.tildacdn.com/stor3864-3766-4132-a362-336565363634/331d7a3cb423efad3851318ebc243503.jpg" + }, + { + "path": "catalog/current-076.jpg", + "url": "https://static.tildacdn.com/stor3764-3435-4336-a638-386530643039/b66c7fb8fba795f74358798f008ff284.jpg" + }, + { + "path": "catalog/current-077.jpg", + "url": "https://static.tildacdn.com/stor3764-3435-4336-a638-386530643039/b66c7fb8fba795f74358798f008ff284.jpg" + }, + { + "path": "catalog/current-078.jpg", + "url": "https://static.tildacdn.com/stor3764-3435-4336-a638-386530643039/b66c7fb8fba795f74358798f008ff284.jpg" + }, + { + "path": "catalog/current-079.jpg", + "url": "https://static.tildacdn.com/stor3764-3435-4336-a638-386530643039/b66c7fb8fba795f74358798f008ff284.jpg" + }, + { + "path": "catalog/current-080.jpg", + "url": "https://static.tildacdn.com/stor3661-6362-4365-a166-396432366336/b3461ed10ad1d21ba6f327e14f87561a.jpg" + }, + { + "path": "catalog/current-081.jpg", + "url": "https://static.tildacdn.com/stor6136-3763-4363-b632-613035666139/b89d5f88da96f52526ccba83cfb1e631.jpg" + }, + { + "path": "catalog/current-082.jpg", + "url": "https://static.tildacdn.com/stor3764-3435-4336-a638-386530643039/b66c7fb8fba795f74358798f008ff284.jpg" + }, + { + "path": "catalog/current-083.jpg", + "url": "https://static.tildacdn.com/stor3764-3435-4336-a638-386530643039/b66c7fb8fba795f74358798f008ff284.jpg" + }, + { + "path": "catalog/current-084.jpg", + "url": "https://static.tildacdn.com/stor3530-3337-4465-a331-393235636139/6bed2df48d65e7dfdf673cc6ab1d66f0.jpg" + }, + { + "path": "catalog/current-085.jpg", + "url": "https://static.tildacdn.com/stor3535-3432-4638-b763-636162616233/f392e1f738f4ec51be28ac85ff7f34d1.jpg" + }, + { + "path": "catalog/current-086.jpg", + "url": "https://static.tildacdn.com/stor6263-6138-4433-a639-386465356138/915334ed7ec0a973fc528d5b1bcddeb9.jpg" + }, + { + "path": "catalog/current-087.jpg", + "url": "https://static.tildacdn.com/stor6161-3666-4430-b437-303230613863/b6c27133c86b74e669c4d8d14cddaa98.jpg" + }, + { + "path": "catalog/current-088.jpg", + "url": "https://static.tildacdn.com/stor6433-6530-4938-a264-366133396635/26c04c584ac3ce62893f86e3f21ef6a4.jpg" + }, + { + "path": "catalog/current-089.jpg", + "url": "https://static.tildacdn.com/stor3438-3336-4038-b563-636464663765/26624b9790a69468292e752308984af1.jpg" + }, + { + "path": "catalog/current-090.jpg", + "url": "https://static.tildacdn.com/stor3430-3734-4935-a666-616161393566/92e1260daf92944a35c6ce5a64ca38f3.jpg" + }, + { + "path": "catalog/current-091.jpg", + "url": "https://static.tildacdn.com/stor3937-3731-4331-a333-333266353435/018e8f56c6b0053168f0d0cf73872966.jpg" + }, + { + "path": "catalog/current-092.jpg", + "url": "https://static.tildacdn.com/stor3939-3434-4061-a231-636661616561/607dc1f31bc1ca66fb1a611d49407aa5.jpg" + }, + { + "path": "catalog/current-093.jpg", + "url": "https://static.tildacdn.com/stor3836-3436-4239-a536-353332396264/232fcd6e93bf0abc918ef73529cded4b.jpg" + }, + { + "path": "catalog/current-094.jpg", + "url": "https://static.tildacdn.com/stor3065-3632-4533-b333-636566613663/7979f348481e979905836febdf7933a6.jpg" + }, + { + "path": "catalog/current-095.jpg", + "url": "https://static.tildacdn.com/stor3761-3162-4461-b966-323139613763/08d95a9c71b402415f9479b3f9520892.jpg" + }, + { + "path": "catalog/current-096.jpg", + "url": "https://static.tildacdn.com/stor3033-3137-4930-b264-666532373134/69925db7e39c25877ac58869c15037a6.jpg" + }, + { + "path": "catalog/current-097.jpg", + "url": "https://static.tildacdn.com/stor3333-3839-4537-a236-623030313665/4130c8f6bd2b58b41c4edd83eee922a9.jpg" + }, + { + "path": "catalog/current-098.jpg", + "url": "https://static.tildacdn.com/stor6230-3133-4564-b237-646535613637/a2a99a4eeb22cb33b5b5cea2a79a17cb.jpg" + }, + { + "path": "catalog/current-099.jpg", + "url": "https://static.tildacdn.com/stor3137-6538-4132-b739-653932333233/15a1c8db984c5e68f357b2ee12a18c2a.jpg" + }, + { + "path": "catalog/current-100.jpg", + "url": "https://static.tildacdn.com/stor3031-6132-4334-b330-633933383437/7e711a925bef78f54c6417cfed424f7b.jpg" + }, + { + "path": "catalog/current-101.jpg", + "url": "https://static.tildacdn.com/stor6565-3862-4536-a531-396336343530/94e933aa7cb2ca8cc2f9a6975508c69b.jpg" + }, + { + "path": "catalog/current-102.jpg", + "url": "https://static.tildacdn.com/stor3130-6665-4166-a562-346464663365/1ace83e57a93e4bd15c4117e6ebd10f4.jpg" + }, + { + "path": "catalog/current-103.jpg", + "url": "https://static.tildacdn.com/stor6666-6336-4235-b437-663731323432/f733be8a64d1186ac1153c3eba271d59.jpg" + }, + { + "path": "catalog/current-104.jpg", + "url": "https://static.tildacdn.com/stor6561-6664-4163-a232-323236383637/1b4aa616a89592851de475a7633530b2.jpg" + }, + { + "path": "catalog/current-105.jpg", + "url": "https://static.tildacdn.com/stor3364-3366-4961-a230-393136633735/bd16ccb4240c13b0408fcd3cdb263c7f.jpg" + }, + { + "path": "catalog/current-106.jpg", + "url": "https://static.tildacdn.com/stor3133-3966-4466-a633-353062663934/19cfdd0803e1252edb0a229fe8cc10e1.jpg" + }, + { + "path": "catalog/current-107.jpg", + "url": "https://static.tildacdn.com/stor3862-3737-4631-a637-656265333935/be0ca89c31666c8d357814c69f0c1527.jpg" + }, + { + "path": "catalog/current-108.jpg", + "url": "https://static.tildacdn.com/stor3730-3136-4137-b861-393762656436/9b8faf6a48cff9bc8b16cef36396d144.jpg" + }, + { + "path": "catalog/current-109.jpg", + "url": "https://static.tildacdn.com/stor6137-3965-4334-a432-316165313635/7e2aebaffa009dfc1b7a580ceac8a640.jpg" + }, + { + "path": "catalog/current-110.jpg", + "url": "https://static.tildacdn.com/stor3937-3432-4537-a233-646339376631/d0ca8846eba6810423c5421e7060c567.jpg" + }, + { + "path": "catalog/current-111.jpg", + "url": "https://static.tildacdn.com/stor3437-6365-4139-b134-326232643133/8d0a33ea4f3ace73aa73679aa8ac3b4d.jpg" + }, + { + "path": "catalog/current-112.jpg", + "url": "https://static.tildacdn.com/stor6334-3463-4166-b330-643438393235/49f88576b7fb7573735b20dc4161de96.jpg" + }, + { + "path": "catalog/current-113.jpg", + "url": "https://static.tildacdn.com/stor3139-3866-4633-a234-663139643733/5c7060058669060670ed95fb719078c3.jpg" + } +] \ No newline at end of file diff --git a/public/caterium-login-logo.png b/public/caterium-login-logo.png new file mode 100644 index 0000000..ee98dc8 Binary files /dev/null and b/public/caterium-login-logo.png differ diff --git a/public/core/pdf-engine.js b/public/core/pdf-engine.js new file mode 100644 index 0000000..7030035 --- /dev/null +++ b/public/core/pdf-engine.js @@ -0,0 +1,40 @@ +(()=>{ + 'use strict'; + const PAGE_W=595.28, PAGE_H=841.89; + const enc=new TextEncoder(); + const ascii=s=>enc.encode(s); + const concat=parts=>{const len=parts.reduce((n,p)=>n+p.length,0),out=new Uint8Array(len);let at=0;for(const p of parts){out.set(p,at);at+=p.length}return out}; + const object=(id,body)=>{const b=body instanceof Uint8Array?body:ascii(body);return concat([ascii(`${id} 0 obj\n`),b,ascii('\nendobj\n')])}; + function normalize(images){ + if(!Array.isArray(images)||!images.length)throw new Error('PDF: нет страниц для экспорта.'); + return images.map((img,index)=>{ + const width=Math.max(1,Math.trunc(Number(img?.width)||0)),height=Math.max(1,Math.trunc(Number(img?.height)||0)); + const bytes=img?.bytes instanceof Uint8Array?img.bytes:null; + if(!bytes?.length||!width||!height)throw new Error(`PDF: некорректная страница ${index+1}.`); + const ratio=width/height,target=PAGE_W/PAGE_H; + if(Math.abs(ratio-target)>.015)console.warn(`PDF page ${index+1}: aspect ratio ${ratio.toFixed(4)} differs from A4 ${target.toFixed(4)}`); + return {width,height,bytes}; + }); + } + function fromJpegs(input){ + const images=normalize(input),objCount=2+images.length*3,objects=new Array(objCount+1); + objects[1]=object(1,'<< /Type /Catalog /Pages 2 0 R >>'); + const kids=images.map((_,i)=>`${3+i*3} 0 R`).join(' '); + objects[2]=object(2,`<< /Type /Pages /Count ${images.length} /Kids [${kids}] >>`); + images.forEach((img,i)=>{ + const pageId=3+i*3,imageId=4+i*3,contentId=5+i*3; + const content=`q\n${PAGE_W} 0 0 ${PAGE_H} 0 0 cm\n/Im1 Do\nQ\n`; + objects[pageId]=object(pageId,`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${PAGE_W} ${PAGE_H}] /Resources << /XObject << /Im1 ${imageId} 0 R >> >> /Contents ${contentId} 0 R >>`); + objects[imageId]=object(imageId,concat([ascii(`<< /Type /XObject /Subtype /Image /Width ${img.width} /Height ${img.height} /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length ${img.bytes.length} >>\nstream\n`),img.bytes,ascii('\nendstream')])); + const cb=ascii(content);objects[contentId]=object(contentId,concat([ascii(`<< /Length ${cb.length} >>\nstream\n`),cb,ascii('endstream')])); + }); + const header=concat([ascii('%PDF-1.4\n%'),new Uint8Array([255,255,255,255]),ascii('\n')]); + const offsets=new Array(objCount+1).fill(0),parts=[header];let pos=header.length; + for(let id=1;id<=objCount;id++){offsets[id]=pos;parts.push(objects[id]);pos+=objects[id].length} + const xrefPos=pos;let xref=`xref\n0 ${objCount+1}\n0000000000 65535 f \n`; + for(let id=1;id<=objCount;id++)xref+=String(offsets[id]).padStart(10,'0')+' 00000 n \n'; + parts.push(ascii(xref+`trailer\n<< /Size ${objCount+1} /Root 1 0 R >>\nstartxref\n${xrefPos}\n%%EOF\n`)); + return new Blob(parts,{type:'application/pdf'}); + } + window.SunPdfEngine=Object.freeze({PAGE_W,PAGE_H,fromJpegs}); +})(); diff --git a/public/core/performance.js b/public/core/performance.js new file mode 100644 index 0000000..ef022f3 --- /dev/null +++ b/public/core/performance.js @@ -0,0 +1,21 @@ +(()=>{ + 'use strict'; + 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 scan=root=>{ + if(root instanceof HTMLImageElement)tune(root); + root?.querySelectorAll?.('img').forEach(tune); + }; + const start=()=>{ + scan(document); + const mo=new MutationObserver(records=>records.forEach(r=>r.addedNodes.forEach(n=>{if(n.nodeType===1)scan(n)}))); + mo.observe(document.documentElement,{childList:true,subtree:true}); + window.SunPerformance={scanImages:()=>scan(document),disconnect:()=>mo.disconnect()}; + }; + if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',start,{once:true});else start(); +})(); diff --git a/public/core/stability-v1760.css b/public/core/stability-v1760.css new file mode 100644 index 0000000..400fb31 --- /dev/null +++ b/public/core/stability-v1760.css @@ -0,0 +1,15 @@ +/* Caterium v17.6.0 mobile stability refinements. */ +@media (max-width:900px){ + html,body{max-width:100%;overflow-x:hidden} + .work,.card,.catalog,.sun-catalog-shell,.sun-catalog-main{min-width:0!important;max-width:100%!important} + .cats{display:flex!important;width:100%!important;max-width:100%!important;min-width:0!important;overflow-x:auto!important;overflow-y:hidden!important;flex-wrap:nowrap!important;overscroll-behavior-x:contain;scrollbar-width:thin} + .cats>button{flex:0 0 auto!important;min-width:132px!important} + .sun-catalog-view-switch,.sun-catalog-top-toolbar,.sun-settings-tabs{max-width:100%!important;min-width:0!important;overflow-x:auto!important;overflow-y:hidden!important;justify-content:flex-start!important;overscroll-behavior-x:contain} + .sun-catalog-view-switch>*{flex:0 0 auto!important} + .enterprise-card,.sun-chat-shell,.sun-chat-main,.sun-chat-sidebar{min-width:0!important;max-width:100%!important} +} +@media (max-width:420px){ + .catalog-head{gap:8px;flex-wrap:wrap} + .catalog-head h1{flex:1 1 auto} + .sun-catalog-top-toolbar{gap:6px!important} +} diff --git a/public/core/sun-safe.js b/public/core/sun-safe.js new file mode 100644 index 0000000..7f8896f --- /dev/null +++ b/public/core/sun-safe.js @@ -0,0 +1,23 @@ +(()=>{ + 'use strict'; + const escapeHTML=value=>String(value??'').replace(/[&<>"']/g,ch=>({ + '&':'&','<':'<','>':'>','"':'"',"'":''' + })[ch]); + const escapeAttr=escapeHTML; + const idToken=value=>String(value??'').replace(/[^a-zA-Z0-9_-]/g,''); + const safeImageSrc=value=>{ + const s=String(value??'').trim(); + if(!s)return ''; + 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(/^blob:[a-z0-9-]+:/i.test(s))return s; + return ''; + }; + const setText=(node,value)=>{if(node)node.textContent=String(value??'');return node}; + const insertBefore=(parent,node,reference=null)=>{ + if(!parent||!node)return node; + if(reference&&reference.parentNode===parent)parent.insertBefore(node,reference);else parent.appendChild(node); + return node; + }; + window.SunSafe=Object.freeze({escapeHTML,escapeAttr,idToken,safeImageSrc,setText,insertBefore}); +})(); diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..09d8425 --- /dev/null +++ b/public/index.html @@ -0,0 +1,8423 @@ +Солнце Кейтеринг + + +
    Солнце Кейтеринг

    Стоимость позиций0 ₽

    Предоплата0 ₽

    К оплате0 ₽

    Заказы

    МероприятиеДата и времяАдресСуммаСтатусДействия

    Нажмите «Изменить», чтобы открыть заказ. Сумма, предоплата и остаток сохраняются вместе с заказом.

    Склад

    В этой версии каталог продуктов пополняется из составов боксов. Остатки и цены добавим следующим шагом.

    Статистика

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/legacy/bootstrap.js b/public/legacy/bootstrap.js new file mode 100644 index 0000000..3b72e75 --- /dev/null +++ b/public/legacy/bootstrap.js @@ -0,0 +1 @@ +const sunEsc=window.SunSafe.escapeHTML,sunAttr=window.SunSafe.escapeAttr,sunId=window.SunSafe.idToken,sunImg=window.SunSafe.safeImageSrc,sunInt=v=>Number.isFinite(Number(v))?Math.trunc(Number(v)):0;let boxes=JSON.parse(localStorage.sunBoxes||'null')||[{id:'1',name:'Фуршетный бокс №1 — канапе мясные',ingredients:[['Хлеб',25,'шт.'],['Салями модеро',10,'кус.'],['Мортаделла',10,'кус.'],['Шейка коппа',10,'кус.'],['Ветчина пармская',10,'кус.'],['Салями в сыре',10,'кус.'],['Фрилис',.33,'пачки'],['Томаты черри жёлтые',2.5,'шт.'],['Томаты черри красные',2.5,'шт.'],['Огурец консервированный',5,'кус.'],['Маслины',2.5,'шт.'],['Слайсы огурца свежего',5,'шт.'],['Фисташка дроблёная',1,'г']]},{id:'2',name:'Фуршетный бокс №2 — канапе рыбные',ingredients:[['Хлеб',15,'шт.'],['Креветка',10,'шт.'],['Тарталетка',10,'шт.'],['Икра красная',20,'г'],['Крем-чиз',30,'г'],['Лосось кубиком',35,'г']]},{id:'3',name:'Фуршетный бокс №3 — микс тарталеток',ingredients:[['Тарталетка',25,'шт.'],['Креветка',5,'шт.'],['Икра щучья',25,'г'],['Икра красная',25,'г'],['Мортаделла',5,'кус.'],['Груша',5,'кус.'],['Крем-чиз',25,'г']]},{id:'4',name:'Фуршетный бокс №4 — премиум морепродукты',ingredients:[['Тарталетка',10,'шт.'],['Ролл из зелёного блинчика',5,'шт.'],['Ролл из чёрного блинчика',5,'шт.'],['Тунец обожжённый',5,'шт.'],['Креветка',5,'шт.'],['Слайс лосося',10,'шт.'],['Икра красная',25,'г']]},{id:'5',name:'Фуршетный бокс №5 — брускетты мясные',ingredients:[['Хлеб',25,'шт.'],['Салями в сыре',10,'шт.'],['Ростбиф',10,'шт.'],['Шейка коппа',10,'шт.'],['Прошутто',10,'шт.'],['Салями модеро',10,'шт.']]},{id:'6',name:'Фуршетный бокс №6 — сыры в шотах',ingredients:[['Моцарелла',15,'шариков'],['Дорблю',15,'кубиков'],['Камамбер',10,'кус.'],['Пармезан',15,'кус.'],['Чеддер',15,'кус.'],['Груша',5,'кус.'],['Виноград',5,'шт.']]}],orders=JSON.parse(localStorage.sunOrders||'[]'),draft={id:0,lines:[]},edited=null;date.value=new Date().toISOString().slice(0,10);for(let i=0;i<48;i++){let t=String(i>>1).padStart(2,'0')+':'+(i%2?'30':'00');time.add(new Option(t,t));}time.value='12:00';function persist(){localStorage.sunBoxes=JSON.stringify(boxes);localStorage.sunOrders=JSON.stringify(orders)}function show(id,b){document.querySelectorAll('.view').forEach(x=>x.classList.remove('on'));document.getElementById(id).classList.add('on');document.querySelectorAll('nav button').forEach(x=>x.classList.remove('on'));b.classList.add('on');renderOrders();renderStats()}function money(n){return Number(n||0).toLocaleString('ru-RU')+' ₽'}function calcOrderTotal(){return draft.lines.reduce((sum,l)=>{let b=boxes.find(x=>x.id==l.id);return sum+(Number(b?.price||0)*Number(l.qty||0))},0)}function updateOrderSummary(){let total=calcOrderTotal(),paid=Math.max(0,Number(prepayment.value||0)),rest=Math.max(0,total-paid);orderTotal.value=total;balance.value=rest;summaryTotal.textContent=money(total);summaryPrepayment.textContent=money(paid);summaryBalance.textContent=money(rest)}function render(){tiles.innerHTML=''+boxes.map(b=>``).join('');lines.innerHTML=draft.lines.length?draft.lines.map(l=>{let b=boxes.find(x=>x.id==l.id);return `
    ${sunEsc(b.name)}${money(b.price||0)} × ${l.qty} = ${money(Number(b.price||0)*l.qty)}
    `}).join(''):'

    Выберите боксы слева.

    ';updateOrderSummary()}function add(id){let l=draft.lines.find(x=>x.id==id);l?l.qty++:draft.lines.push({id,qty:1});render()}function qty(id,n){if(n<1)draft.lines=draft.lines.filter(x=>x.id!=id);else draft.lines.find(x=>x.id==id).qty=n;render()}function details(on){orderForm.style.display=on?'none':'block';const detailsPanel=document.getElementById('details');detailsPanel.style.display=on?'block':'none';orderTab.classList.toggle('on',!on);detailTab.classList.toggle('on',on)}function saveOrder(){if(!draft.lines.length)return alert('Добавьте хотя бы один бокс.');draft.event=document.getElementById('event').value;draft.date=date.value;draft.time=time.value;draft.contact=contact.value;draft.phone=phone.value;draft.address=address.value;draft.note=note.value;draft.total=calcOrderTotal();draft.prepayment=Math.max(0,Number(prepayment.value||0));draft.balance=Math.max(0,draft.total-draft.prepayment);if(!draft.status)draft.status='Новый';if(!draft.id)draft.id=Math.max(0,...orders.map(x=>x.id))+1;let n=orders.findIndex(x=>x.id==draft.id);n<0?orders.push(structuredClone(draft)):orders[n]=structuredClone(draft);persist();deleteOrderBtn.style.display='inline-block';renderOrders();updateOrderSummary();alert('Заказ сохранён.');}function orderTotalValue(o){if(Number.isFinite(Number(o.total)))return Number(o.total);return (o.lines||[]).reduce((sum,l)=>{let b=boxes.find(x=>x.id==l.id);return sum+Number(b?.price||0)*Number(l.qty||0)},0)}function renderOrders(){ordersList.innerHTML=orders.length?orders.map(o=>`
    № ${sunInt(o.id)}${sunEsc(o.event)}${sunEsc(o.date)} · ${sunEsc(o.time)}${sunEsc(o.address||'—')}${money(orderTotalValue(o))}
    `).join(''):'

    Сохранённых заказов пока нет.

    '}function status(id,s){orders.find(x=>x.id==id).status=s;persist()}function openOrder(id){draft=structuredClone(orders.find(x=>x.id==id));document.getElementById('event').value=draft.event;date.value=draft.date;time.value=draft.time;contact.value=draft.contact||'';phone.value=draft.phone||'';address.value=draft.address||'';note.value=draft.note||'';prepayment.value=Number(draft.prepayment||0);deleteOrderBtn.style.display='inline-block';show('new',[...document.querySelectorAll('nav button')].find(x=>x.textContent.trim()==='Новый заказ')||document.querySelector('nav button'));render()}function resetDraft(){draft={id:0,lines:[]};document.getElementById('event').value='';date.value=new Date().toISOString().slice(0,10);time.value='12:00';contact.value='';phone.value='';address.value='';note.value='';prepayment.value=0;deleteOrderBtn.style.display='none';render()}function deleteOrderById(id){let o=orders.find(x=>x.id==id);if(!o||!confirm(`Удалить заказ №${id} «${o.event}»?`))return;orders=orders.filter(x=>x.id!=id);persist();renderOrders();if(draft.id==id)resetDraft()}function deleteOrder(){if(!draft.id)return;deleteOrderById(draft.id);show('orders',[...document.querySelectorAll('nav button')].find(x=>x.textContent.trim()==='Заказы')||document.querySelectorAll('nav button')[1])}function renderStats(){statsList.innerHTML=boxes.map(b=>`

    ${sunEsc(b.name)} — ${orders.filter(o=>o.status!='Отменён').reduce((n,o)=>n+(o.lines.find(l=>l.id==b.id)?.qty||0),0)} заказано

    `).join('')}function modal(x,on=true){document.getElementById(x).classList.toggle('on',on)}function closeModal(x){modal(x,false)}function openManager(){modal('manager');managerList.innerHTML=boxes.map(b=>``).join('')}function editBox(id){edited=id?structuredClone(boxes.find(x=>x.id==id)):{id:'',name:'Новый бокс',price:0,ingredients:[]};editTitle.textContent=edited.id?'Изменить бокс':'Новый бокс';boxName.value=edited.name;boxPrice.value=Number(edited.price||0);editPhoto.style.display=edited.photo?'block':'none';editPhoto.src=sunImg(edited.photo)||'';deleteBox.style.display=edited.id?'block':'none';modal('editor');renderIngredients()}function readPhoto(x){let r=new FileReader();r.onload=()=>{edited.photo=sunImg(r.result);editPhoto.src=edited.photo||'';editPhoto.style.display='block'};r.readAsDataURL(x.files[0])}function renderIngredients(){ingredients.innerHTML=edited.ingredients.map((x,i)=>`
    `).join('')}function addIngredient(){edited.ingredients.push(['Новый продукт',1,'шт.']);renderIngredients()}function saveBox(){edited.name=boxName.value.trim();edited.price=Math.max(0,Number(boxPrice.value||0));if(!edited.name)return alert('Введите название.');if(!edited.id){edited.id=sunUUID();boxes.push(edited)}else boxes[boxes.findIndex(x=>x.id==edited.id)]=edited;persist();closeModal('editor');closeModal('manager');render();renderOrders();window.sunClientOfferCatalogChanged?.(edited.id)}function removeBox(){if(confirm('Удалить бокс?')){boxes=boxes.filter(x=>x.id!=edited.id);persist();closeModal('editor');closeModal('manager');render()}}function openPreview(){let m={};draft.lines.forEach(l=>boxes.find(b=>b.id==l.id).ingredients.forEach(x=>{let k=x[0]+'|'+x[2];m[k]=(m[k]||0)+x[1]*l.qty}));prepTitle.textContent='Заготовки для: '+document.getElementById('event').value;prep.innerHTML=Object.entries(m).map(([k,v])=>{let [p,u]=k.split('|');return `

    ${sunEsc(p)}${sunEsc(v)} ${sunEsc(u)}

    `}).join('');modal('preview')}render();updateOrderSummary(); \ No newline at end of file diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest new file mode 100644 index 0000000..cf43bca --- /dev/null +++ b/public/manifest.webmanifest @@ -0,0 +1,16 @@ +{ + "name": "Солнце Кейтеринг", + "short_name": "Солнце", + "description": "Заказы, календарь, клиенты, склад, рассылки и документы Солнце Кейтеринг", + "start_url": "./", + "scope": "./", + "display": "standalone", + "background_color": "#f5f6fa", + "theme_color": "#29204e", + "lang": "ru", + "orientation": "any", + "icons": [ + {"src":"pwa-icon-192.png","sizes":"192x192","type":"image/png","purpose":"any maskable"}, + {"src":"pwa-icon-512.png","sizes":"512x512","type":"image/png","purpose":"any maskable"} + ] +} diff --git a/public/offer-gallery/001.jpg b/public/offer-gallery/001.jpg new file mode 100644 index 0000000..9bb1441 Binary files /dev/null and b/public/offer-gallery/001.jpg differ diff --git a/public/offer-gallery/002.jpg b/public/offer-gallery/002.jpg new file mode 100644 index 0000000..645ccf3 Binary files /dev/null and b/public/offer-gallery/002.jpg differ diff --git a/public/offer-templates/thumb-editorial-grid.jpg b/public/offer-templates/thumb-editorial-grid.jpg new file mode 100644 index 0000000..98ae1fe Binary files /dev/null and b/public/offer-templates/thumb-editorial-grid.jpg differ diff --git a/public/offer-templates/thumb-emerald-gold.jpg b/public/offer-templates/thumb-emerald-gold.jpg new file mode 100644 index 0000000..5eb72a5 Binary files /dev/null and b/public/offer-templates/thumb-emerald-gold.jpg differ diff --git a/public/offer-templates/thumb-light.jpg b/public/offer-templates/thumb-light.jpg new file mode 100644 index 0000000..517e462 Binary files /dev/null and b/public/offer-templates/thumb-light.jpg differ diff --git a/public/offer-templates/thumb-midnight-glass.jpg b/public/offer-templates/thumb-midnight-glass.jpg new file mode 100644 index 0000000..498c9d7 Binary files /dev/null and b/public/offer-templates/thumb-midnight-glass.jpg differ diff --git a/public/pwa-icon-192.png b/public/pwa-icon-192.png new file mode 100644 index 0000000..539d02c Binary files /dev/null and b/public/pwa-icon-192.png differ diff --git a/public/pwa-icon-512.png b/public/pwa-icon-512.png new file mode 100644 index 0000000..d9a404a Binary files /dev/null and b/public/pwa-icon-512.png differ diff --git a/public/service-worker.js b/public/service-worker.js new file mode 100644 index 0000000..bb6587d --- /dev/null +++ b/public/service-worker.js @@ -0,0 +1,32 @@ +const CACHE='sun-catering-pwa-v65-20260907-v17-6-0-stability-security'; +const VERSION='20260907-v17-6-0-stability-security'; +const CORE=[ + './','./index.html', + `./core/sun-safe.js?v=${VERSION}`,`./core/performance.js?v=${VERSION}`,`./core/pdf-engine.js?v=${VERSION}`,`./legacy/bootstrap.js?v=${VERSION}`,`./app-runtime.js?v=${VERSION}`, + './offer-gallery/001.jpg','./offer-gallery/002.jpg', + './catalog/001.jpg','./catalog/002.jpg','./catalog/003.jpg', + './sun-logo.png','./caterium-login-logo.png','./pwa-icon-192.png','./pwa-icon-512.png','./manifest.webmanifest', + './offer-templates/thumb-light.jpg','./offer-templates/thumb-editorial-grid.jpg','./offer-templates/thumb-midnight-glass.jpg','./offer-templates/thumb-emerald-gold.jpg' +]; +self.addEventListener('install',event=>{ + event.waitUntil(caches.open(CACHE).then(cache=>cache.addAll(CORE)).then(()=>self.skipWaiting())); +}); +self.addEventListener('activate',event=>{ + event.waitUntil(caches.keys().then(keys=>Promise.all(keys.filter(k=>k.startsWith('sun-catering-pwa-')&&k!==CACHE).map(k=>caches.delete(k)))).then(()=>self.clients.claim())); +}); +function cachePut(req,res){ + if(res&&res.ok){const clone=res.clone();caches.open(CACHE).then(c=>c.put(req,clone)).catch(()=>{});}return res; +} +function networkFirst(req,fallback){ + return fetch(req).then(res=>cachePut(req,res)).catch(()=>caches.match(req).then(hit=>hit||caches.match(fallback||req))); +} +self.addEventListener('fetch',event=>{ + const req=event.request;if(req.method!=='GET')return; + const url=new URL(req.url);if(url.pathname.startsWith('/api/'))return; + if(req.mode==='navigate'){ + event.respondWith(fetch(req).then(res=>{const clone=res.clone();caches.open(CACHE).then(c=>c.put('./index.html',clone)).catch(()=>{});return res}).catch(()=>caches.match('./index.html')));return; + } + const freshAsset=/\.(?:js|css|webmanifest)$/i.test(url.pathname); + if(freshAsset){event.respondWith(networkFirst(req));return;} + event.respondWith(caches.match(req).then(cached=>cached||fetch(req).then(res=>cachePut(req,res)))); +}); diff --git a/public/sun-logo.png b/public/sun-logo.png new file mode 100644 index 0000000..187daf5 Binary files /dev/null and b/public/sun-logo.png differ diff --git a/supabase/functions/caterium-create-employee/README.md b/supabase/functions/caterium-create-employee/README.md new file mode 100644 index 0000000..f108a6e --- /dev/null +++ b/supabase/functions/caterium-create-employee/README.md @@ -0,0 +1,10 @@ +# caterium-create-employee + +Production Edge Function used by the Caterium admin UI to create an Auth user with a temporary password and attach that user to a workspace. + +Security properties: +- JWT verification must remain enabled at deployment. +- `SUPABASE_SERVICE_ROLE_KEY` exists only in Edge Function secrets; never expose it in browser code or archives. +- Caller authorization and plan/member limits are checked through `sun_employee_prepare_v28` / `sun_employee_finalize_v28`. + +The source in this folder mirrors the deployed production function so releases are auditable and recoverable. diff --git a/supabase/functions/caterium-create-employee/deno.json b/supabase/functions/caterium-create-employee/deno.json new file mode 100644 index 0000000..15d0809 --- /dev/null +++ b/supabase/functions/caterium-create-employee/deno.json @@ -0,0 +1,5 @@ +{ + "imports": { + "@supabase/supabase-js": "npm:@supabase/supabase-js@2" + } +} diff --git a/supabase/functions/caterium-create-employee/index.ts b/supabase/functions/caterium-create-employee/index.ts new file mode 100644 index 0000000..5e31f54 --- /dev/null +++ b/supabase/functions/caterium-create-employee/index.ts @@ -0,0 +1,68 @@ +import "jsr:@supabase/functions-js/edge-runtime.d.ts"; +import { createClient } from "npm:@supabase/supabase-js@2"; + +const cors = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type", + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Content-Type": "application/json", +}; +const reply = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status, headers: cors }); + +Deno.serve(async (req: Request) => { + if (req.method === "OPTIONS") return new Response("ok", { headers: cors }); + if (req.method !== "POST") return reply({ error: "Method not allowed" }, 405); + try { + const auth = req.headers.get("Authorization") || ""; + const url = Deno.env.get("SUPABASE_URL") || ""; + const anon = Deno.env.get("SUPABASE_ANON_KEY") || ""; + const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") || ""; + if (!url || !anon || !serviceKey) return reply({ error: "Server auth configuration is incomplete" }, 500); + const caller = createClient(url, anon, { + global: { headers: { Authorization: auth } }, + auth: { persistSession: false, autoRefreshToken: false }, + }); + const service = createClient(url, serviceKey, { auth: { persistSession: false, autoRefreshToken: false } }); + const body = await req.json(); + const workspaceId = String(body.workspace_id || ""); + const email = String(body.email || "").trim().toLowerCase(); + const displayName = String(body.display_name || "").trim(); + const role = String(body.role || "manager"); + const prep = await caller.rpc("sun_employee_prepare_v28", { + p_workspace: workspaceId, + p_email: email, + p_display_name: displayName, + p_role: role, + }); + if (prep.error) return reply({ error: prep.error.message }, 400); + if (prep.data?.status !== "new") { + return reply({ status: prep.data?.status, user_id: prep.data?.user_id, email, display_name: displayName, role }, 200); + } + const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789"; + const bytes = crypto.getRandomValues(new Uint8Array(12)); + let password = ""; + for (const b of bytes) password += alphabet[b % alphabet.length]; + password = password.slice(0, 6) + "-" + password.slice(6); + const created = await service.auth.admin.createUser({ + email, + password, + email_confirm: true, + user_metadata: { + name: displayName || email.split("@")[0], + must_change_password: true, + registration_source: "caterium_employee_admin", + }, + }); + if (created.error || !created.data.user) return reply({ error: created.error?.message || "Create failed" }, 400); + const fin = await caller.rpc("sun_employee_finalize_v28", { + p_workspace: workspaceId, + p_user_id: created.data.user.id, + p_display_name: displayName, + p_role: role, + }); + if (fin.error) return reply({ error: fin.error.message }, 400); + return reply({ ...fin.data, created: true, temporary_password: password, must_change_password: true }); + } catch (e) { + return reply({ error: e instanceof Error ? e.message : String(e) }, 500); + } +}); diff --git a/supabase/functions/caterium-platform-auth-admin/deno.json b/supabase/functions/caterium-platform-auth-admin/deno.json new file mode 100644 index 0000000..d29ef02 --- /dev/null +++ b/supabase/functions/caterium-platform-auth-admin/deno.json @@ -0,0 +1 @@ +{"compilerOptions":{"strict":true}} diff --git a/supabase/functions/caterium-platform-auth-admin/index.ts b/supabase/functions/caterium-platform-auth-admin/index.ts new file mode 100644 index 0000000..b8d44d3 --- /dev/null +++ b/supabase/functions/caterium-platform-auth-admin/index.ts @@ -0,0 +1,79 @@ +import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.57.4' + +const cors = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', + 'Access-Control-Allow-Methods': 'POST, OPTIONS', +} +const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), {status, headers:{...cors,'Content-Type':'application/json'}}) + +Deno.serve(async (req) => { + if (req.method === 'OPTIONS') return new Response('ok', {headers:cors}) + if (req.method !== 'POST') return json({error:'Method not allowed'},405) + try { + const url = Deno.env.get('SUPABASE_URL')! + const anon = Deno.env.get('SUPABASE_ANON_KEY')! + const service = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')! + const authHeader = req.headers.get('Authorization') || '' + const userClient = createClient(url, anon, {global:{headers:{Authorization:authHeader}},auth:{persistSession:false,autoRefreshToken:false}}) + const {data:{user},error:userError} = await userClient.auth.getUser() + if (userError || !user) return json({error:'Unauthorized'},401) + const {data:isAdmin,error:adminError} = await userClient.rpc('sun_is_platform_admin') + if (adminError || isAdmin !== true) return json({error:'Platform administrator required'},403) + + const body = await req.json().catch(()=>({})) as Record + const action = String(body.action || 'list') + const admin = createClient(url, service, {auth:{persistSession:false,autoRefreshToken:false}}) + + if (action === 'list') { + const users:any[] = [] + for (let page=1; page<=20; page++) { + const {data,error} = await admin.auth.admin.listUsers({page,perPage:1000}) + if (error) throw error + users.push(...(data.users||[])) + if ((data.users||[]).length < 1000) break + } + const {data:directory,error:dirError} = await userClient.rpc('sun_platform_list_users') + if (dirError) throw dirError + const byId = new Map() + for (const row of (directory||[])) { + const id=String(row.user_id) + if(!byId.has(id))byId.set(id,{is_platform_admin:row.is_platform_admin===true,workspaces:[]}) + if(row.workspace_id)byId.get(id).workspaces.push({id:row.workspace_id,name:row.workspace_name,role:row.role,display_name:row.display_name,is_active:row.is_active!==false}) + } + return json({users:users.map(u=>({ + id:u.id,email:u.email||'',created_at:u.created_at||'',last_sign_in_at:u.last_sign_in_at||'', + email_confirmed_at:u.email_confirmed_at||u.confirmed_at||'',banned_until:u.banned_until||'', + is_banned:Boolean(u.banned_until&&new Date(u.banned_until)>new Date()), + is_platform_admin:byId.get(String(u.id))?.is_platform_admin===true, + workspaces:byId.get(String(u.id))?.workspaces||[] + }))}) + } + + const targetId = String(body.user_id || '') + if (!targetId) return json({error:'user_id required'},400) + if (targetId === user.id && action === 'set_ban') return json({error:'Нельзя заблокировать собственный аккаунт разработчика.'},400) + const {data:{user:target},error:targetError}=await admin.auth.admin.getUserById(targetId) + if(targetError||!target)return json({error:'User not found'},404) + + if (action === 'set_ban') { + const banned = body.banned === true + const {error}=await admin.auth.admin.updateUserById(targetId,{ban_duration:banned?'876000h':'none'}) + if(error)throw error + await userClient.rpc('sun_platform_log_event',{p_action:banned?'account.ban':'account.unban',p_workspace:null,p_user:targetId,p_details:{email:target.email||''}}) + return json({ok:true,banned}) + } + if (action === 'send_recovery') { + if(!target.email)return json({error:'У пользователя нет email.'},400) + const publicClient=createClient(url,anon,{auth:{persistSession:false,autoRefreshToken:false}}) + const {error}=await publicClient.auth.resetPasswordForEmail(target.email) + if(error)throw error + await userClient.rpc('sun_platform_log_event',{p_action:'account.recovery_sent',p_workspace:null,p_user:targetId,p_details:{email:target.email}}) + return json({ok:true}) + } + return json({error:'Unknown action'},400) + } catch (e) { + console.error(e) + return json({error:e instanceof Error?e.message:String(e)},500) + } +}) diff --git a/tests/app.spec.mjs b/tests/app.spec.mjs new file mode 100644 index 0000000..debe7aa --- /dev/null +++ b/tests/app.spec.mjs @@ -0,0 +1,26 @@ +import { test, expect } from '@playwright/test'; +test('legacy localStorage payload cannot execute XSS', async ({ page }) => { + const pageErrors=[]; page.on('pageerror',e=>pageErrors.push(String(e))); + await page.addInitScript(() => { + localStorage.setItem('sunBoxes', JSON.stringify([{id:'1',name:'',price:100,photo:'javascript:alert(1)',ingredients:[['',1,'шт.']]}])); + localStorage.setItem('sunOrders', JSON.stringify([{id:1,event:'',date:'2026-09-07',time:'12:00',address:'',status:'Новый',lines:[{id:'1',qty:1}]}])); + }); + await page.goto('/index.html', { waitUntil:'domcontentloaded' }); await page.waitForTimeout(300); + expect(await page.evaluate(()=>window.__cateriumXss)).toBeUndefined(); + expect(pageErrors.filter(x=>!x.includes('supabase'))).toEqual([]); +}); +test('safe insert helper handles foreign reference node', async ({ page }) => { + await page.goto('/index.html', { waitUntil:'domcontentloaded' }); + const result=await page.evaluate(()=>{const a=document.createElement('div'),b=document.createElement('div'),n=document.createElement('span'),foreign=document.createElement('i');a.appendChild(foreign);document.body.append(a,b);try{window.SunSafe.insertBefore(b,n,foreign);return {ok:true,parent:n.parentNode===b}}catch(e){return {ok:false,error:String(e)}}}); + expect(result).toEqual({ok:true,parent:true}); +}); +test('shared PDF engine produces A4 PDF blob', async ({ page }) => { + await page.goto('/index.html', { waitUntil:'domcontentloaded' }); + const result=await page.evaluate(async()=>{const fake=new Uint8Array([255,216,255,217]);const blob=window.SunPdfEngine.fromJpegs([{width:1000,height:1414,bytes:fake}]);const head=new TextDecoder().decode(new Uint8Array(await blob.arrayBuffer()).slice(0,8));return {type:blob.type,size:blob.size,head,w:window.SunPdfEngine.PAGE_W,h:window.SunPdfEngine.PAGE_H}}); + expect(result.type).toBe('application/pdf'); expect(result.size).toBeGreaterThan(150); expect(result.head.startsWith('%PDF-1.4')).toBeTruthy(); expect(result.w/result.h).toBeCloseTo(1/Math.sqrt(2),3); +}); +test('mobile body does not overflow viewport', async ({ page }, testInfo) => { + test.skip(testInfo.project.name!=='mobile-390'); await page.goto('/index.html', { waitUntil:'domcontentloaded' }); await page.waitForTimeout(500); + const dims=await page.evaluate(()=>({innerWidth,scrollWidth:document.documentElement.scrollWidth,bodyWidth:document.body.scrollWidth})); + expect(dims.scrollWidth).toBeLessThanOrEqual(dims.innerWidth+2); expect(dims.bodyWidth).toBeLessThanOrEqual(dims.innerWidth+2); +}); diff --git a/tests/playwright.config.mjs b/tests/playwright.config.mjs new file mode 100644 index 0000000..aa6234c --- /dev/null +++ b/tests/playwright.config.mjs @@ -0,0 +1,12 @@ +import { defineConfig, devices } from '@playwright/test'; +export default defineConfig({ + testDir:'.', + testMatch:'app.spec.mjs', + timeout:30000, + use:{baseURL:'http://127.0.0.1:4173'}, + webServer:{command:'npx http-server public -p 4173 -c-1',port:4173,reuseExistingServer:true}, + projects:[ + {name:'desktop',use:{...devices['Desktop Chrome']}}, + {name:'mobile-390',use:{viewport:{width:390,height:844},isMobile:true,hasTouch:true}} + ] +}); diff --git a/tests/release-check.mjs b/tests/release-check.mjs new file mode 100644 index 0000000..30de7dc --- /dev/null +++ b/tests/release-check.mjs @@ -0,0 +1,16 @@ +import fs from 'node:fs'; +import path from 'node:path'; +const root=process.cwd(), pub=path.join(root,'public'); +const read=p=>fs.readFileSync(path.join(pub,p),'utf8'); +let bad=0;const check=(v,m)=>{console.log(`${v?'OK':'FAIL'}: ${m}`);if(!v)bad++}; +const index=read('index.html'),runtime=read('app-runtime.js'),sw=read('service-worker.js'),css=read('core/stability-v1760.css'); +check(index.includes('core/stability-v1760.css'),'mobile stability stylesheet loaded'); +check(css.includes('overflow-x:hidden')&&css.includes('.cats'),'mobile overflow guard present'); +check(!index.includes('offer-gallery-data.js'),'blocking Base64 gallery absent'); +check((runtime.match(/\/Type \/Catalog/g)||[]).length===0,'runtime contains no PDF binary writer'); +check(read('core/pdf-engine.js').includes('595.28')&&read('core/pdf-engine.js').includes('841.89'),'PDF engine uses A4 MediaBox'); +check([...index.matchAll(/@page\{([^}]*)\}/g)].every(m=>/size:A4/i.test(m[1])),'compact @page rules use A4'); +check(sw.includes('v17-6-0-stability-security'),'service worker cache is v17.6.0'); +check(fs.existsSync(path.join(root,'supabase/functions/caterium-create-employee/index.ts')),'employee Edge Function source is versioned'); +check(!/sb_secret_[A-Za-z0-9_-]{20,}|service_role\s*[:=]\s*["'][A-Za-z0-9._-]{30,}/i.test(index+runtime),'no client secret-like token'); +if(bad)process.exit(1); diff --git a/tests/static-security.mjs b/tests/static-security.mjs new file mode 100644 index 0000000..e187b3e --- /dev/null +++ b/tests/static-security.mjs @@ -0,0 +1,31 @@ +import fs from 'node:fs'; +import path from 'node:path'; +const root=process.cwd(); +const pub=path.join(root,'public'); +const readPub=p=>fs.readFileSync(path.join(pub,p),'utf8'); +const readRoot=p=>fs.readFileSync(path.join(root,p),'utf8'); +const fail=m=>{console.error('FAIL:',m);process.exitCode=1}; +const ok=m=>console.log('OK:',m); +const html=readPub('index.html'),legacy=readPub('legacy/bootstrap.js'),runtime=readPub('app-runtime.js'),safe=readPub('core/sun-safe.js'),sw=readPub('service-worker.js'); +if(!html.includes('core/sun-safe.js'))fail('SunSafe must load before legacy modules');else ok('shared SunSafe loaded'); +if(html.includes('offer-gallery-data.js')||fs.existsSync(path.join(pub,'offer-gallery-data.js')))fail('blocking offer-gallery-data.js still present');else ok('base64 gallery removed'); +for(const raw of ['${b.name}','${o.event}','${o.address||','value="${x[0]}"','value="${x[2]}"']) if(legacy.includes(raw)) fail(`legacy bootstrap contains raw HTML interpolation ${raw}`); +if(!legacy.includes('sunEsc(')||!legacy.includes('sunAttr('))fail('legacy bootstrap is not using shared escaping');else ok('legacy bootstrap escapes text and attributes'); +const duplicateEsc=[html,runtime].join('\n').split('\n').filter(line=>/\b(?:const|let)\s+esc\b/.test(line)&&line.includes('.replace(')); +if(duplicateEsc.length)fail(`duplicate esc implementations remain: ${duplicateEsc.length}`);else ok('local esc aliases delegate to SunSafe'); +if(runtime.includes('/Type /Catalog'))fail('manual PDF writer remains in app-runtime.js');else ok('PDF packing removed from runtime'); +if(!readPub('core/pdf-engine.js').includes('/Type /Catalog'))fail('shared PDF engine missing');else ok('single shared PDF engine present'); +const employeeEdge=path.join(root,'supabase/functions/caterium-create-employee/index.ts'); +if(!fs.existsSync(employeeEdge))fail('production employee Edge Function source missing');else ok('production employee Edge Function versioned'); +const edge=readRoot('supabase/functions/caterium-create-employee/index.ts'); +if(/service_role\s*[:=]\s*['"][A-Za-z0-9._-]{20,}/i.test(edge)||/eyJ[a-zA-Z0-9_-]{30,}/.test(edge))fail('possible hard-coded secret in Edge Function');else ok('no hard-coded service key detected'); +if(!safe.includes('reference.parentNode===parent'))fail('safe insertBefore guard missing');else ok('safe DOM insertion guard present'); +const files=fs.readdirSync(path.join(pub,'catalog')); +const current=files.filter(x=>/^current-\d{3}\.jpg$/i.test(x)).length; +const legacyCount=files.filter(x=>/^\d{3}\.jpg$/i.test(x)).length; +if(current!==113)fail(`current catalog photo count ${current}, expected 113`);else ok('113 current catalog photos'); +if(legacyCount!==60)fail(`legacy catalog photo count ${legacyCount}, expected 60`);else ok('60 legacy catalog photos'); +const gallery=fs.readdirSync(path.join(pub,'offer-gallery')).filter(x=>/\.jpg$/i.test(x)); +if(gallery.length!==2)fail(`offer gallery contains ${gallery.length} jpg files, expected 2`);else ok('offer gallery trimmed'); +if(!sw.includes('v17-6-0-stability-security')||sw.includes('offer-gallery-data.js'))fail('service worker cache is stale');else ok('PWA cache updated'); +if(process.exitCode)process.exit(process.exitCode); diff --git a/wrangler.jsonc b/wrangler.jsonc new file mode 100644 index 0000000..da53ecc --- /dev/null +++ b/wrangler.jsonc @@ -0,0 +1,10 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "ancient-sound-04ab", + "compatibility_date": "2026-09-07", + "assets": { + "directory": "./public", + "not_found_handling": "single-page-application", + "html_handling": "auto-trailing-slash" + } +}