initial site
No files matched your search
@@ -0,0 +1,218 @@
|
||||
---
|
||||
name: fix-finding
|
||||
description: Исправление и верификация конкретной security-находки в проекте НЕОН (site/crm/db). Использовать, когда пользователь явно просит исправить, закрыть или залечить уязвимость или finding из аудита — «исправь F-3», «закрой находку из AUDIT/FINDINGS.md», «почини IDOR из аудита и проверь». НЕ использовать для обычных багов, рефакторинга, ревью кода/дизайна, полного аудита (полный аудит делает скилл security-audit — он только документирует) или деплоя.
|
||||
---
|
||||
|
||||
# Fix Finding — минимальный проверенный фикс security-находки в НЕОН
|
||||
|
||||
Превратить текущую security-находку в минимальный, проверенный на живом стенде
|
||||
код-фикс. Если код уже безопасен — доказать это и вернуть `no_change`.
|
||||
Аудит (поиск и документирование) делает скилл `security-audit`; этот скилл —
|
||||
вторая стадия над его результатами.
|
||||
|
||||
## Источник находки
|
||||
|
||||
Найди ID и контекст в одном из мест:
|
||||
|
||||
- `AUDIT/FINDINGS.md` — вывод аудит-скилла `security-audit`;
|
||||
- `АУДИТ-ПЕРЕД-РЕЛИЗОМ.md` — предрелизный аудит, нумерация 1–38
|
||||
(пункт с пометкой «вопрос к владельцу» — не фикси, это `blocked`);
|
||||
- F-N — сквозная нумерация security-фиксов в комментариях кода
|
||||
(`docker-compose.yml`, `crm/server.py`) и в `crm/_security_smoke.mjs`;
|
||||
- прямое описание от пользователя (файл/эндпоинт + путь атаки).
|
||||
|
||||
Если в находке нет файла/эндпоинта — сначала локализуй путь атаки по коду.
|
||||
Фиксить можно только явно запрошенную находку; соседние находки — доложить,
|
||||
не чинить.
|
||||
|
||||
## Порядок свойств результата
|
||||
|
||||
Оценивай результат в этом порядке; более раннее никогда не меняется на более
|
||||
позднее. «Минимальный» = наименьшее проектно-нативное изменение, закрывающее
|
||||
все более ранние свойства, а не наименьшее число строк:
|
||||
|
||||
1. текущее состояние корректно классифицировано: уязвимо / уже безопасно / недоказано
|
||||
2. фикс полностью закрывает нарушенную security-границу
|
||||
3. легитимное поведение и совместимость сохранены
|
||||
4. проверки проекта (см. гейты ниже) пройдены
|
||||
5. реализация следует конвенциям проекта
|
||||
6. патч содержит только необходимый охват
|
||||
|
||||
## Карта проекта
|
||||
|
||||
- `crm/` — Python 3, stdlib + psycopg, без фреймворков. `server.py` — HTTP +
|
||||
REST + роли; `notes_api.py` — доски заметок (примешан в Handler);
|
||||
`storage.py` — PostgreSQL (боевой), `storage_sqlite.py` — dev-двойник
|
||||
(ограничен: игры/доски/брони/миксы сайта/сброс нумерации → 501);
|
||||
`js/app.js` + `index.html` + `css/` — панель персонала; `seed/*.json` —
|
||||
каталог; `migrate/notes_boards.sql` — канонический DDL досок.
|
||||
- `site/` — React + Vite + Tailwind, собирается в статику под nginx.
|
||||
`nginx.conf` — edge: лимит `POST /api/orders` (10/мин с IP), real_ip из
|
||||
X-Forwarded-For, `/media/` отдаёт фото CRM; `src/lib/crm.ts` — мост к API.
|
||||
- `docker-compose.yml` — site (наружу `127.0.0.1:8080`), crm
|
||||
(`127.0.0.1:47613`), db (PostgreSQL 17, не публикуется). Секреты только
|
||||
из `.env`.
|
||||
- Роли CRM: владелец / администратор / официант / кальянщик — матрица в
|
||||
README; пять слоёв защиты заказов (nginx-лимит → лимит на гостя →
|
||||
дедупликация → глобальный тормоз → honeypot).
|
||||
- Git в проекте НЕТ: перед правкой делай снимок (см. ниже), diff собирай
|
||||
по снимкам.
|
||||
|
||||
Фикс может жить на любом из трёх слоёв (nginx → server.py → storage).
|
||||
Логика storage продублирована в двух бэкендах: фикс в `storage.py` почти
|
||||
всегда должен зеркалиться в `storage_sqlite.py` (и наоборот) — аудит не раз
|
||||
писал «в обоих бэкендах». Учитывай и панель (`crm/js/app.js`): что она
|
||||
ожидает от изменённого эндпоинта.
|
||||
|
||||
## Патч-контракт (до правки)
|
||||
|
||||
Изучи саму реализацию, её прямых вызователей, соседние хелперы и разметку
|
||||
роль×эндпоинт в README. Установи из кода, а не из находки:
|
||||
|
||||
- атакующий вход и конкретный путь источник → sink (или сломанный контроль);
|
||||
- security-инвариант и узкую общую границу, где его enforce;
|
||||
- легитимное поведение, API-контракты, семантику ошибок, совместимость;
|
||||
- ближайшие прецеденты в этом же коде (экранирование `esc()` в панели,
|
||||
параметризованный SQL `%s`, whitelist статики, лимитеры).
|
||||
|
||||
Находка — задача о потоке данных и границе, а не о примере входа из отчёта.
|
||||
Проверь эквивалентные кодировки, alternate-формы парсера, алиасы, всех
|
||||
вызователей, оба бэкенда и каждую копию security-чувствительного состояния,
|
||||
которая может обойти фикс. Небезопасное состояние обрабатывай явно — не
|
||||
принимай, не обрезай и не переинтерпретируй его молча.
|
||||
|
||||
## Предпатчевое расследование
|
||||
|
||||
До правки запусти один свежий read-only субагент (Agent tool, general-purpose,
|
||||
с инструкцией не менять файлы). Если делегирование недоступно — сделай тот же
|
||||
проход сам отдельно, свежим взглядом:
|
||||
|
||||
- **Исследователь границы и совместимости:** независимо трассирует путь
|
||||
источник → sink, находит общую границу enforcement, затронутые входы,
|
||||
альтернативные представления и состояния, переходы валидация →
|
||||
использование, конкретные sibling-пути и обходы. Фиксирует легитимные
|
||||
сценарии и публичное поведение, которые обязаны остаться, и проверяет
|
||||
вызователей, режимы, ошибки, побочные эффекты, конвенции, готовые хелперы
|
||||
и применимые команды проверки.
|
||||
|
||||
Расследование требует ссылок на файлы/строки проекта и разделения фактов,
|
||||
выводов и открытых вопросов. По завершении сверь с собственной трассировкой
|
||||
и выбери границу фикса.
|
||||
|
||||
## Снимки вместо git
|
||||
|
||||
Перед первым изменением скопируй оригиналы изменяемых файлов в
|
||||
`AUDIT/FIXES/.snapshots/<ID>/` (создай каталог). По снимкам собери diff для
|
||||
ревью и отчёта (PowerShell `Compare-Object`, `fc` или `git diff --no-index`,
|
||||
если git есть на хосте). Не оставляй временных файлов-двойников вида `.bak`
|
||||
в рабочем дереве после завершения.
|
||||
|
||||
## Реализация
|
||||
|
||||
1. Протрассируй путь и верни `no_change`, если код уже безопасен — без
|
||||
спекулятивных правок.
|
||||
2. Где возможно, прогони через границу минимальный высокосигнальный repro
|
||||
и один легитимный контрольный запрос по тому же пути (живой стенд ниже).
|
||||
3. Сделай наименьший фикс на общей границе. Предпочитай соседние хелперы и
|
||||
установленные API. Не расширяйся на редизайн, уборку и соседние находки.
|
||||
4. До верификации атакуй свой патч, а не защищай: проверь каждого прямого
|
||||
вызователя изменённого хелпера и обе ветки каждого изменённого условия.
|
||||
Найди (а) один sibling-путь, представление или копию, которые всё ещё
|
||||
доходят до sink, (б) один обычный/дефолтный вход, который патч начал
|
||||
отвергать или переинтерпретировать. Если хоть одно есть — переделай.
|
||||
5. Верифицируй по гейтам (порядок обязателен).
|
||||
|
||||
## Гейты верификации
|
||||
|
||||
1. **Синтаксис/сборка** — только изменённое:
|
||||
- Python: `python -m py_compile crm\server.py crm\storage.py crm\storage_sqlite.py crm\notes_api.py` (в crm/);
|
||||
- сайт (если тронут `site/`): из `site/` — `npx tsc --noEmit`, затем `npm run build`;
|
||||
- движок миксов (если тронут `site/src` миксы/стоп-лист): `npm run test:engine`.
|
||||
2. **Security-триггер** — repro находки на живом стенде больше не
|
||||
воспроизводится + один альтернативный класс вредоносного входа.
|
||||
3. **Легитимный контроль** — обычный сценарий по тому же пути работает;
|
||||
соседние роли не сломаны (токены owner/admin/waiter — матрица в README).
|
||||
|
||||
### Живой стенд
|
||||
|
||||
- **Postgres-стек** (нужен для игр, досок, броней, миксов, сброса нумерации —
|
||||
в sqlite это 501): `docker compose up -d --build`, CRM на
|
||||
`127.0.0.1:47613`, сайт на `127.0.0.1:8080`.
|
||||
- **SQLite-стенд** без Docker: `start-crm.bat` (`CRM_DB=sqlite`,
|
||||
`CRM_ALLOW_DEFAULT_PASSWORDS=1`, порт 47613).
|
||||
- Сайт-дев: в `site/` `npm run dev`; если docker уже держит 8080 —
|
||||
`npm run dev -- --port 8090 --strictPort`.
|
||||
- Перед стартом проверь, кто слушает порт (`netstat -ano | findstr :47613`):
|
||||
8080/47613 держит docker-стек, **8081 — чужой процесс, его не трогать**.
|
||||
- Доступы стенда — из `.env` (SMOKE_* нет в .env — задавай при запуске скрипта).
|
||||
|
||||
### Регрессионный артефакт
|
||||
|
||||
Проект верифицирует фиксы smoke-скриптами по образцу `crm/_security_smoke.mjs`:
|
||||
`fetch` против `127.0.0.1:47613`, PASS/FAIL, ненулевой exit при провале,
|
||||
пароли через env (`SMOKE_OWNER_PW`, `SMOKE_WAITER_PW`). Добавь проверки
|
||||
фикса в `_security_smoke.mjs` или создай сфокусированный `crm/_fix_<ID>.mjs`
|
||||
по тому же образцу. Помни побочные эффекты: lockout-тест закрывает
|
||||
`waiter.mark` на 15 минут (порядок проверок важен), тест смены пароля обязан
|
||||
возвращать пароль обратно.
|
||||
|
||||
## Ревью кандидата
|
||||
|
||||
После реализации и фокусных проверок запусти одного свежего read-only
|
||||
субагента (Agent tool; инструкции не редактировать и не делегировать). Дай
|
||||
ему только находку, корень проекта, авторизованный объём, конвенции и текущий
|
||||
diff кандидата — без обоснований, отчёта исследователя и заявлений, что
|
||||
тесты прошли. Если делегирование недоступно — сделай этот проход сам,
|
||||
отдельно, до финальной верификации:
|
||||
|
||||
- **Ревьюер обходов и регрессий:** реконструирует инвариант и ищет конкретный
|
||||
выживший маршрут через затронутые входы, эквивалентные представления,
|
||||
границы парсеров, алиасы, оба бэкенда, слой nginx и щели
|
||||
валидация → использование; трассирует изменённые условия и вызователей на
|
||||
предмет конкретной поломки легитимных входов, контрактов, ошибок,
|
||||
побочных эффектов и конвенций.
|
||||
|
||||
Находки ревьюера — гипотезы: подтверждай их по коду или фокусным запуском,
|
||||
правь только подтверждённые в рамках находки и совместимости. Затем
|
||||
прогони затронутые гейты заново и убедись, что временных и посторонних
|
||||
изменений не осталось. Только один цикл ревью.
|
||||
|
||||
Верни `blocked`, если находка может быть реальной, но не хватает существенных
|
||||
данных, инструментария, доступа или это продуктовое решение владельца
|
||||
(пример: №38 «waiter вне смены видит телефоны» из предрелизного аудита) —
|
||||
тогда безопасный фикс невозможно завершить или проверить.
|
||||
|
||||
## Отчёт
|
||||
|
||||
Напиши `AUDIT/FIXES/<ID>.md` и проставь в источнике находки (`AUDIT/FINDINGS.md`
|
||||
или `АУДИТ-ПЕРЕД-РЕЛИЗОМ.md`) статус фикса со ссылкой на отчёт. В финальном
|
||||
ответе в чате обязательно:
|
||||
|
||||
- outcome: `fixed` / `no_change` / `blocked`
|
||||
- конкретный уязвимый путь, security-инвариант и легитимное поведение, которое
|
||||
обязано было сохраниться
|
||||
- выбранная стратегия и почему это самый узкий полный вариант (либо — какое
|
||||
решение владельца отсутствует при `blocked`)
|
||||
- изменённые файлы и добавленные тесты/артефакты
|
||||
- команды по гейтам с результатами pass/fail/unknown
|
||||
- как показано, что исходная находка больше не воспроизводится
|
||||
- как показано, что легитимное поведение сохранилось
|
||||
- оставшиеся пробелы и пропущенные проверки, если есть
|
||||
|
||||
## Жёсткие правила
|
||||
|
||||
- Не докладывай `fixed`, пока не пройдены все гейты по порядку. Проверку
|
||||
можно пропустить только с обоснованием по коду; недоступная существенная
|
||||
проверка = `blocked` и явное указание, чего не хватило.
|
||||
- Не полагайся только на чтение кода, когда возможен фокусный repro на стенде.
|
||||
- Не расширяй патч на уборку, соседние находки и редизайн без доказательства,
|
||||
что без этого граница не закрывается.
|
||||
- Не удаляй и не затирай правки пользователя — git нет, снимки единственная
|
||||
страховка; не трогай `crm/data/`, `crm/media/`, `.env` и `site/dist`.
|
||||
- Не ослабляй авторизацию, ролевую матрицу, пять слоёв защиты заказов,
|
||||
валидацию входа, fail-fast на дефолтных паролях и лимитеры ради зелёных
|
||||
проверок.
|
||||
- Не деплой на прод (`docker compose up -d --build site crm`) без явной
|
||||
команды владельца — верификация идёт на локальном стенде.
|
||||
- Не прячь пробелы доказательств: если среда не дала проверить — назови
|
||||
команду и чего не хватает.
|
||||
@@ -0,0 +1,41 @@
|
||||
# ===== PostgreSQL =====
|
||||
# СЕКРЕТЫ: заполняйте своими значениями. Файл .env (с реальными паролями)
|
||||
# не должен попадать в поставку/архив — только этот .env.example.
|
||||
# ВНИМАНИЕ: если база уже создана, смена PG_PASSWORD в .env не действует —
|
||||
# сначала ALTER USER в postgres, затем синхронизируйте .env.
|
||||
PG_DB=neon_crm
|
||||
PG_USER=neon
|
||||
PG_PASSWORD=придумайте-длинный-случайный-пароль
|
||||
|
||||
# ===== Учётки CRM (вход в панель) =====
|
||||
# Пароли применяются при ПЕРВОМ создании учётки (минимум 8 символов).
|
||||
# На живой базе пароль каждому сотруднику меняется в панели: кнопка 🔑
|
||||
# рядом с именем. Старт с дефолтными паролями запрещён — сервер не поднимется,
|
||||
# пока все учётки не получат собственные пароли (см. CRM_ALLOW_DEFAULT_PASSWORDS).
|
||||
CRM_OWNER_LOGIN=owner
|
||||
CRM_OWNER_PASSWORD=придумайте-случайный-пароль-1
|
||||
CRM_OWNER_NAME=Нурлан
|
||||
CRM_ADMIN_LOGIN=admin
|
||||
CRM_ADMIN_PASSWORD=придумайте-случайный-пароль-2
|
||||
CRM_ADMIN_NAME=Ирина
|
||||
CRM_WAITER_LOGIN=waiter.mark
|
||||
CRM_WAITER_PASSWORD=придумайте-случайный-пароль-3
|
||||
CRM_WAITER_NAME=Марк
|
||||
|
||||
# ===== CRM =====
|
||||
# 1 — наполнить базу демо-заказами при первом старте (для показа заказчику).
|
||||
# В бою держи 0.
|
||||
CRM_SEED_DEMO_ORDERS=0
|
||||
# Часовой пояс бара: по нему считаются расписание смен, сутки в отчётах
|
||||
# и «новинки». По умолчанию Asia/Yekaterinburg (Нягань, UTC+5).
|
||||
#CRM_TZ=Asia/Yekaterinburg
|
||||
# 1 — разрешить старт с дефолтными паролями. ТОЛЬКО для локального dev-стенда,
|
||||
# который не смотрит в интернет; боевой сервер так запускать нельзя.
|
||||
#CRM_ALLOW_DEFAULT_PASSWORDS=0
|
||||
|
||||
# ===== Сайт =====
|
||||
# На каком адресе и порте сайт смотрит наружу.
|
||||
# За Caddy/nginx на хосте: 127.0.0.1:8080 (как есть).
|
||||
# Голый VPS без реверс-прокси: SITE_BIND=0.0.0.0 и SITE_PORT=80.
|
||||
SITE_BIND=127.0.0.1
|
||||
SITE_PORT=8080
|
||||
@@ -0,0 +1,31 @@
|
||||
# Секреты и живые данные не должны попадать в поставку (аудит F-1/F-4)
|
||||
.env
|
||||
*.env.local
|
||||
|
||||
# Локальные базы CRM: хэши паролей и сессионные токены (F-4)
|
||||
crm/data/
|
||||
*.db
|
||||
*.sqlite
|
||||
|
||||
# Медиа владельца (фото позиций, обложки игр)
|
||||
crm/media/
|
||||
|
||||
# Зависимости и сборки
|
||||
node_modules/
|
||||
site/dist/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Временные файлы
|
||||
*.tmp
|
||||
*.log
|
||||
.DS_Store
|
||||
|
||||
# Dev-мусор и бэкапы исходников (аудит NF-09/NF-19)
|
||||
*.bak*
|
||||
crm/*.mjs
|
||||
crm/_*.py
|
||||
crm/i
|
||||
|
||||
# Результат make-distrib.ps1
|
||||
distrib/
|
||||
@@ -0,0 +1,13 @@
|
||||
__pycache__
|
||||
*.pyc
|
||||
data/
|
||||
migrate/
|
||||
.dockerignore
|
||||
Dockerfile
|
||||
*.log
|
||||
|
||||
# NF-09/NF-19: dev-бэкапы, dev-скрипты и секреты не должны попадать в образ,
|
||||
# даже если Dockerfile когда-нибудь начнёт копировать каталог целиком
|
||||
*.bak*
|
||||
*.mjs
|
||||
.env
|
||||
@@ -0,0 +1,28 @@
|
||||
# NEON CRM — Python + PostgreSQL, без веб-фреймворков.
|
||||
# NF-20: дайджест-пин базового образа (воспроизводимая сборка).
|
||||
FROM python:3.12-slim@sha256:a6e34c598f2467ed0e9a8d349809fcd8b5c603269512df273a0bb1784edc11b1
|
||||
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY server.py storage.py storage_sqlite.py notes_api.py ./
|
||||
COPY seed/ ./seed/
|
||||
COPY index.html ./
|
||||
COPY css/ ./css/
|
||||
COPY js/ ./js/
|
||||
|
||||
RUN groupadd --system neon && useradd --system --gid neon --create-home neon \
|
||||
&& chown -R neon:neon /app
|
||||
USER neon
|
||||
|
||||
EXPOSE 47613
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||
CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:47613/api/healthz', timeout=3).status == 200 else 1)"
|
||||
|
||||
CMD ["python", "server.py"]
|
||||
@@ -0,0 +1,118 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>НЕОН CRM — панель управления</title>
|
||||
<meta name="description" content="CRM кальянного лаунжа НЕОН: стоп-лист табаков и меню, управление заказами.">
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='14' fill='%230A0A0F'/%3E%3Ctext x='32' y='45' font-family='Arial Black, Arial' font-size='34' font-weight='900' text-anchor='middle' fill='%2300F0FF'%3EН%3C/text%3E%3C/svg%3E">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Unbounded:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="css/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ЭКРАН ВХОДА -->
|
||||
<section class="login" id="loginScreen">
|
||||
<div class="glow glow-cyan"></div>
|
||||
<div class="glow glow-purple"></div>
|
||||
|
||||
<div class="login-card">
|
||||
<div class="login-logo">
|
||||
<span class="logo-neon">Н</span>
|
||||
<div>
|
||||
<h1>НЕОН <em>CRM</em></h1>
|
||||
<p>Панель управления лаунджем · Нягань</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="loginForm" autocomplete="off">
|
||||
<label class="field">
|
||||
<span>Логин</span>
|
||||
<input type="text" id="loginUser" placeholder="admin" value="" autocomplete="username">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Пароль</span>
|
||||
<input type="password" id="loginPass" placeholder="••••••••" value="" autocomplete="current-password">
|
||||
</label>
|
||||
<button class="btn btn-neon btn-block" type="submit">Войти</button>
|
||||
</form>
|
||||
|
||||
<p class="login-error hidden" id="loginError" role="alert"></p>
|
||||
</div>
|
||||
|
||||
<p class="login-foot">neon-bar86.ru · внутренняя система</p>
|
||||
</section>
|
||||
|
||||
<!-- ПРИЛОЖЕНИЕ -->
|
||||
<div class="app hidden" id="app">
|
||||
<aside class="sidebar">
|
||||
<div class="side-top">
|
||||
<div class="brand">
|
||||
<span class="logo-neon">Н</span>
|
||||
<div class="brand-txt">
|
||||
<strong>НЕОН <em>CRM</em></strong>
|
||||
<small id="brandRole">—</small>
|
||||
</div>
|
||||
</div>
|
||||
<!-- карточка аккаунта перед списком вкладок: имя, роль и выход всегда сверху -->
|
||||
<div class="user-card">
|
||||
<span class="avatar" id="userAvatar">М</span>
|
||||
<div>
|
||||
<strong id="userName">—</strong>
|
||||
<small id="userRole">—</small>
|
||||
</div>
|
||||
<button class="icon-btn" id="passwordBtn" title="Сменить пароль" type="button">🔑</button>
|
||||
<button class="icon-btn" id="logoutBtn" title="Выйти" type="button">✕</button>
|
||||
</div>
|
||||
<nav class="nav" id="nav"></nav>
|
||||
</div>
|
||||
|
||||
<div class="side-bottom">
|
||||
<button class="btn btn-ghost btn-sm btn-block offline-retry hidden" id="offlineRetry" type="button">⟳ Переподключиться</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="main">
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<h2 id="pageTitle">—</h2>
|
||||
<p id="pageSub">—</p>
|
||||
</div>
|
||||
<div class="topbar-right">
|
||||
<span class="shift" id="shift">—</span>
|
||||
<span class="topbar-actions" id="shiftActions"></span>
|
||||
<button class="icon-btn bell" id="bellBtn" title="Новые заказы" type="button">
|
||||
🔔<span class="bell-count hidden" id="bellCount">0</span>
|
||||
</button>
|
||||
<span class="clock" id="clock">--:--</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="view" id="viewRoot"></div>
|
||||
</main>
|
||||
|
||||
<!-- автопрокрутка страницы: появляется, когда список не влезает в экран -->
|
||||
<div class="scroll-nav hidden" id="scrollNav">
|
||||
<button class="scroll-btn" id="scrollUp" title="Прокрутить вверх" type="button">▲</button>
|
||||
<button class="scroll-btn" id="scrollDown" title="Прокрутить вниз" type="button">▼</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toast-wrap" id="toasts" role="status" aria-live="polite"></div>
|
||||
|
||||
<!-- модальные окна подтверждения и ввода: живут вне viewRoot, чтобы
|
||||
перерисовка (опрос заказов каждые 10 с) не закрывала открытое окно -->
|
||||
<div class="modal-overlay hidden" id="modalOverlay">
|
||||
<div class="modal" id="modalBox" role="dialog" aria-modal="true"></div>
|
||||
</div>
|
||||
|
||||
<!-- скрытый выбор фото позиции: живёт вне viewRoot, чтобы перерисовка
|
||||
списка (опрос заказов каждые 10 с) не отваливала элемент во время
|
||||
открытого диалога выбора файла -->
|
||||
<input type="file" id="photoInput" accept="image/avif,image/jpeg,image/png,image/webp,image/gif" style="display:none">
|
||||
|
||||
<script src="js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,84 @@
|
||||
-- -*- coding: utf-8 -*-
|
||||
-- ============================================================================
|
||||
-- НЕОН CRM — доски заметок/задач (общая + приватные «владелец ↔ админ»).
|
||||
--
|
||||
-- Идемпотентна: повторный запуск не падает и не меняет данные.
|
||||
-- CRM применяет этот же DDL автоматически при старте (SCHEMA в storage.py,
|
||||
-- сид общей доски — boards_seed_shared()); файл нужен для ручного применения
|
||||
-- и как канонический список объектов.
|
||||
--
|
||||
-- Применить вручную:
|
||||
-- docker compose exec -T db psql -U neon -d neon_crm \
|
||||
-- -v ON_ERROR_STOP=1 < crm/migrate/notes_boards.sql
|
||||
--
|
||||
-- Откат (теряются все заметки и история!):
|
||||
-- docker compose exec -T db psql -U neon -d neon_crm \
|
||||
-- -c "DROP TABLE IF EXISTS note_activity, notes, board_members, boards CASCADE"
|
||||
--
|
||||
-- Отступление от исходного ТЗ: пользователи в этой базе — crm_users с текстовым
|
||||
-- PK login (целочисленного users.id не существует), поэтому все ссылки идут
|
||||
-- на crm_users(login).
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS boards (
|
||||
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('shared', 'private')),
|
||||
title TEXT NOT NULL,
|
||||
created_by_login TEXT NOT NULL REFERENCES crm_users(login),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- участники приватных досок; для kind='shared' не используется —
|
||||
-- общая доска доступна всем ролям без проверки
|
||||
CREATE TABLE IF NOT EXISTS board_members (
|
||||
board_id INTEGER NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
|
||||
user_login TEXT NOT NULL REFERENCES crm_users(login) ON DELETE CASCADE,
|
||||
PRIMARY KEY (board_id, user_login)
|
||||
);
|
||||
|
||||
-- заметка = задача: заголовок, текст, исполнитель, срок, «важно», статус.
|
||||
-- К сменам не привязана — доступна в любое время по факту логина.
|
||||
-- Логины автора/исполнителя без FK (как orders.waiter в storage.py):
|
||||
-- удаление сотрудника не должно ломать его задачи.
|
||||
CREATE TABLE IF NOT EXISTS notes (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
board_id INTEGER NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
|
||||
author_login TEXT NOT NULL,
|
||||
assignee_login TEXT,
|
||||
title TEXT NOT NULL,
|
||||
body TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'in_progress', 'done')),
|
||||
important BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
due_date DATE,
|
||||
done_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_notes_board ON notes (board_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_notes_assignee ON notes (assignee_login) WHERE status <> 'done';
|
||||
|
||||
-- журнал действий: created/status/assigned/edited/deleted/board_created.
|
||||
-- details: для assigned — логин исполнителя, для deleted — id заметки.
|
||||
-- note_id REFERENCES ... ON DELETE SET NULL: сама запись переживает удаление
|
||||
-- заметки, id удалённой сохраняется в details (для синхронизации клиентов).
|
||||
-- user_login без FK — журнал переживает удаление сотрудника.
|
||||
CREATE TABLE IF NOT EXISTS note_activity (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
board_id INTEGER NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
|
||||
note_id BIGINT REFERENCES notes(id) ON DELETE SET NULL,
|
||||
user_login TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
details TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- одна общая доска на базу; владелец = первый по логину owner (база после
|
||||
-- seed CRM уже содержит учётки). Повторный запуск ничего не добавляет.
|
||||
INSERT INTO boards (kind, title, created_by_login)
|
||||
SELECT 'shared', 'Общая', u.login
|
||||
FROM crm_users u
|
||||
WHERE u.role = 'owner'
|
||||
AND NOT EXISTS (SELECT 1 FROM boards WHERE kind = 'shared')
|
||||
ORDER BY u.login
|
||||
LIMIT 1;
|
||||
@@ -0,0 +1,227 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
НЕОН CRM — доски заметок/задач (HTTP-хендлеры).
|
||||
|
||||
Маршруты (все требуют X-Auth-Token):
|
||||
GET /api/boards — доступные доски
|
||||
POST /api/boards — приватная доска «владелец ↔ админ»
|
||||
PATCH /api/boards/<id> — переименовать (owner/admin)
|
||||
DELETE /api/boards/<id> — удалить (только owner)
|
||||
GET /api/boards/<id>/notes — заметки (?since= — только изменённые)
|
||||
POST /api/boards/<id>/notes — создать заметку
|
||||
GET /api/boards/<id>/activity — лента событий доски
|
||||
PATCH /api/notes/<id> — правка заметки
|
||||
DELETE /api/notes/<id> — удаление (owner/admin/автор)
|
||||
|
||||
Хендлеры собраны в NotesRoutes — миксин для Handler из server.py, чтобы
|
||||
точка интеграции маршрутов была одной строкой на метод. Доступ к доске
|
||||
проверяет storage.board_guard(): чужая приватная доска неотличима от
|
||||
несуществующей — оба случая отдаются как 404 {"error": "not found"}.
|
||||
"""
|
||||
|
||||
import re
|
||||
from datetime import datetime
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
import storage
|
||||
from storage import clean_guest_text # NF-14: staff-тексты без NUL/bidi-мусора
|
||||
|
||||
BOARD_ID_RE = re.compile(r"^/api/boards/(\d+)$")
|
||||
BOARD_NOTES_RE = re.compile(r"^/api/boards/(\d+)/notes$")
|
||||
BOARD_ACTIVITY_RE = re.compile(r"^/api/boards/(\d+)/activity$")
|
||||
NOTE_ID_RE = re.compile(r"^/api/notes/(\d+)$")
|
||||
|
||||
NOTE_STATUSES = ("open", "in_progress", "done")
|
||||
# роли с правами администратора — как ADMIN_ROLES в server.py
|
||||
# (оттуда импортировать нельзя: циклический импорт)
|
||||
ADMIN_ROLES = ("owner", "admin")
|
||||
DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
||||
TITLE_MAX = 200
|
||||
BODY_MAX = 4000
|
||||
ACTIVITY_LIMIT_MAX = 200
|
||||
|
||||
|
||||
class NotesRoutes:
|
||||
"""Хендлеры досок; подключаются к Handler в server.py."""
|
||||
|
||||
def _empty(self, code: int = 204):
|
||||
self._drain_body() # NF-13: 204 без чтения тела ломал бы keep-alive
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Length", "0")
|
||||
self.end_headers()
|
||||
|
||||
# ---------------- доски ----------------
|
||||
|
||||
def _handle_boards_get(self, user: dict):
|
||||
self._json({"boards": storage.boards_list(user)})
|
||||
|
||||
def _handle_board_create(self, user: dict):
|
||||
if user["role"] != "owner":
|
||||
self._json({"error": "owner only"}, 403)
|
||||
return
|
||||
body = self._read_body() or {}
|
||||
title = clean_guest_text(str(body.get("title") or ""), TITLE_MAX).strip()
|
||||
admin_login = str(body.get("admin_login") or "").strip()
|
||||
if not title:
|
||||
self._json({"error": "укажите название доски"}, 400)
|
||||
return
|
||||
users = {u["login"]: u for u in storage.list_users()}
|
||||
admin = users.get(admin_login)
|
||||
if admin is None or admin["role"] != "admin":
|
||||
self._json({"error": "выберите администратора — участника доски"}, 400)
|
||||
return
|
||||
board = storage.board_create(title, admin_login, user["login"])
|
||||
self._json({"board": board}, 201)
|
||||
|
||||
def _handle_board_patch(self, user: dict, board_id: int):
|
||||
board = storage.board_guard(board_id, user)
|
||||
if board is None:
|
||||
self._json({"error": "not found"}, 404)
|
||||
return
|
||||
if user["role"] not in ADMIN_ROLES:
|
||||
self._json({"error": "admin only"}, 403)
|
||||
return
|
||||
body = self._read_body() or {}
|
||||
title = clean_guest_text(str(body.get("title") or ""), TITLE_MAX).strip()
|
||||
if not title:
|
||||
self._json({"error": "укажите название доски"}, 400)
|
||||
return
|
||||
self._json({"board": storage.board_rename(board_id, title)})
|
||||
|
||||
def _handle_board_delete(self, user: dict, board_id: int):
|
||||
board = storage.board_guard(board_id, user)
|
||||
if board is None:
|
||||
self._json({"error": "not found"}, 404)
|
||||
return
|
||||
if user["role"] != "owner":
|
||||
self._json({"error": "owner only"}, 403)
|
||||
return
|
||||
if not storage.board_delete(board_id):
|
||||
self._json({"error": "not found"}, 404)
|
||||
return
|
||||
self._json({"ok": True})
|
||||
|
||||
# ---------------- заметки ----------------
|
||||
|
||||
def _handle_board_notes_get(self, user: dict, board_id: int):
|
||||
if storage.board_guard(board_id, user) is None:
|
||||
self._json({"error": "not found"}, 404)
|
||||
return
|
||||
since = None
|
||||
raw_since = (parse_qs(urlsplit(self.path).query).get("since") or [""])[0]
|
||||
if raw_since:
|
||||
# '+' в зоне вида +05:00 при разборе query превращается в пробел
|
||||
raw_since = raw_since.replace(" ", "+")
|
||||
try:
|
||||
since = datetime.fromisoformat(raw_since)
|
||||
except ValueError:
|
||||
self._json({"error": "bad since"}, 400)
|
||||
return
|
||||
self._json(storage.notes_list(board_id, since))
|
||||
|
||||
def _handle_note_create(self, user: dict, board_id: int):
|
||||
board = storage.board_guard(board_id, user)
|
||||
if board is None:
|
||||
self._json({"error": "not found"}, 404)
|
||||
return
|
||||
body = self._read_body() or {}
|
||||
title = clean_guest_text(str(body.get("title") or ""), TITLE_MAX).strip()
|
||||
if not title:
|
||||
self._json({"error": "укажите заголовок задачи"}, 400)
|
||||
return
|
||||
text = clean_guest_text(str(body.get("body") or ""), BODY_MAX)
|
||||
important = bool(body.get("important"))
|
||||
assignee = body.get("assignee")
|
||||
if assignee is not None:
|
||||
assignee = str(assignee).strip() or None
|
||||
if assignee and not storage.note_assignee_ok(board, assignee):
|
||||
self._json({"error": "исполнитель — не участник этой доски"}, 400)
|
||||
return
|
||||
due = None
|
||||
if body.get("due_date"):
|
||||
if not DATE_RE.match(str(body["due_date"])):
|
||||
self._json({"error": "срок в формате ГГГГ-ММ-ДД"}, 400)
|
||||
return
|
||||
due = body["due_date"]
|
||||
note = storage.note_create(board, user, title, text, assignee, important, due)
|
||||
self._json({"note": note}, 201)
|
||||
|
||||
def _handle_note_patch(self, user: dict, note_id: int):
|
||||
note = storage.note_get(note_id)
|
||||
board = storage.board_guard(note["board_id"], user) if note else None
|
||||
if note is None or board is None:
|
||||
self._json({"error": "not found"}, 404)
|
||||
return
|
||||
body = self._read_body() or {}
|
||||
if not body:
|
||||
self._json({"error": "нечего изменять"}, 400)
|
||||
return
|
||||
|
||||
fields: dict = {}
|
||||
if "title" in body:
|
||||
title = clean_guest_text(str(body["title"] or ""), TITLE_MAX).strip()
|
||||
if not title:
|
||||
self._json({"error": "укажите заголовок задачи"}, 400)
|
||||
return
|
||||
fields["title"] = title
|
||||
if "body" in body:
|
||||
fields["body"] = clean_guest_text(str(body["body"] or ""), BODY_MAX)
|
||||
if "important" in body:
|
||||
if not isinstance(body["important"], bool):
|
||||
self._json({"error": "important (bool) required"}, 400)
|
||||
return
|
||||
fields["important"] = body["important"]
|
||||
if "status" in body:
|
||||
if body["status"] not in NOTE_STATUSES:
|
||||
self._json({"error": "статус: open, in_progress или done"}, 400)
|
||||
return
|
||||
fields["status"] = body["status"]
|
||||
if "due_date" in body:
|
||||
if body["due_date"] is None:
|
||||
fields["due_date"] = None
|
||||
elif DATE_RE.match(str(body["due_date"])):
|
||||
fields["due_date"] = str(body["due_date"])
|
||||
else:
|
||||
self._json({"error": "срок в формате ГГГГ-ММ-ДД"}, 400)
|
||||
return
|
||||
if "assignee" in body:
|
||||
assignee = body["assignee"]
|
||||
if assignee is not None:
|
||||
assignee = str(assignee).strip() or None
|
||||
if assignee and not storage.note_assignee_ok(board, assignee):
|
||||
self._json({"error": "исполнитель — не участник этой доски"}, 400)
|
||||
return
|
||||
fields["assignee_login"] = assignee
|
||||
|
||||
updated = storage.note_update(note_id, user, board, fields)
|
||||
if updated is None:
|
||||
self._json({"error": "not found"}, 404)
|
||||
return
|
||||
self._json({"note": updated})
|
||||
|
||||
def _handle_note_delete(self, user: dict, note_id: int):
|
||||
note = storage.note_get(note_id)
|
||||
board = storage.board_guard(note["board_id"], user) if note else None
|
||||
if note is None or board is None:
|
||||
self._json({"error": "not found"}, 404)
|
||||
return
|
||||
if user["role"] not in ADMIN_ROLES and note["author"]["id"] != user["login"]:
|
||||
self._json({"error": "удалять может автор, админ или владелец"}, 403)
|
||||
return
|
||||
if storage.note_delete(note_id, user["login"]) is None:
|
||||
self._json({"error": "not found"}, 404)
|
||||
return
|
||||
self._empty(204)
|
||||
|
||||
# ---------------- журнал ----------------
|
||||
|
||||
def _handle_board_activity(self, user: dict, board_id: int):
|
||||
if storage.board_guard(board_id, user) is None:
|
||||
self._json({"error": "not found"}, 404)
|
||||
return
|
||||
qs = parse_qs(urlsplit(self.path).query)
|
||||
try:
|
||||
limit = min(ACTIVITY_LIMIT_MAX, max(1, int((qs.get("limit") or ["50"])[0])))
|
||||
except ValueError:
|
||||
limit = 50
|
||||
self._json({"events": storage.board_activity(board_id, limit)})
|
||||
@@ -0,0 +1 @@
|
||||
psycopg[binary,pool]>=3.2,<4
|
||||
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 198 KiB |
|
After Width: | Height: | Size: 127 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 204 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 74 KiB |
@@ -0,0 +1,238 @@
|
||||
[
|
||||
{
|
||||
"seed_id": "fifa-25",
|
||||
"kind": "ps5",
|
||||
"title": "FIFA 25",
|
||||
"note": "Футбольный симулятор · до 4 игроков",
|
||||
"cover": "fifa25.jpg"
|
||||
},
|
||||
{
|
||||
"seed_id": "ufc-5",
|
||||
"kind": "ps5",
|
||||
"title": "UFC 5",
|
||||
"note": "Файтинг · до 2 игроков",
|
||||
"cover": "ufc5.jpg"
|
||||
},
|
||||
{
|
||||
"seed_id": "mortal-kombat-1",
|
||||
"kind": "ps5",
|
||||
"title": "Mortal Kombat 1",
|
||||
"note": "Файтинг · до 2 игроков",
|
||||
"cover": "mk1.jpg"
|
||||
},
|
||||
{
|
||||
"seed_id": "move-or-die",
|
||||
"kind": "ps5",
|
||||
"title": "Move or Die",
|
||||
"note": "Кооперативная аркада · до 4 игроков",
|
||||
"cover": "mod.jpg"
|
||||
},
|
||||
{
|
||||
"seed_id": "overcooked",
|
||||
"kind": "ps5",
|
||||
"title": "Overcooked",
|
||||
"note": "Кооперативная аркада · до 4 игроков",
|
||||
"cover": "over.jpg"
|
||||
},
|
||||
{
|
||||
"seed_id": "god-of-war-ragnarok",
|
||||
"kind": "ps5",
|
||||
"title": "God of War Ragnarök",
|
||||
"note": "Экшен-приключение · до 1 игрока",
|
||||
"cover": "gowr.jpg"
|
||||
},
|
||||
{
|
||||
"seed_id": "god-of-war",
|
||||
"kind": "ps5",
|
||||
"title": "God of War",
|
||||
"note": "Экшен-приключение · до 1 игрока",
|
||||
"cover": "gow.jpg"
|
||||
},
|
||||
{
|
||||
"seed_id": "spider-man-remastered",
|
||||
"kind": "ps5",
|
||||
"title": "Marvel's Spider-Man Remastered",
|
||||
"note": "Экшен-приключение · до 1 игрока",
|
||||
"cover": "smr.jpg"
|
||||
},
|
||||
{
|
||||
"seed_id": "board-mafia",
|
||||
"kind": "board",
|
||||
"title": "Мафия",
|
||||
"note": "Компанейские · 6–16 игроков · 40–90 мин · простая · хит",
|
||||
"cover": null,
|
||||
"difficulty": 1,
|
||||
"icon": "eye",
|
||||
"accent": "#b026ff"
|
||||
},
|
||||
{
|
||||
"seed_id": "board-bunker",
|
||||
"kind": "board",
|
||||
"title": "Бункер",
|
||||
"note": "Компанейские · 4–16 игроков · 40–60 мин · простая · хит",
|
||||
"cover": null,
|
||||
"difficulty": 1,
|
||||
"icon": "skull",
|
||||
"accent": "#00f0ff"
|
||||
},
|
||||
{
|
||||
"seed_id": "board-cards",
|
||||
"kind": "board",
|
||||
"title": "Игральные карты",
|
||||
"note": "Карточные · 2–8 игроков · 10–60 мин · простая · хит",
|
||||
"cover": null,
|
||||
"difficulty": 1,
|
||||
"icon": "spade",
|
||||
"accent": "#ff4d6d"
|
||||
},
|
||||
{
|
||||
"seed_id": "board-chess",
|
||||
"kind": "board",
|
||||
"title": "Шахматы",
|
||||
"note": "Классика · 2–2 игроков · 20–60 мин · сложная",
|
||||
"cover": null,
|
||||
"difficulty": 3,
|
||||
"icon": "crown",
|
||||
"accent": "#e0e0e0"
|
||||
},
|
||||
{
|
||||
"seed_id": "board-backgammon",
|
||||
"kind": "board",
|
||||
"title": "Нарды",
|
||||
"note": "Классика · 2–2 игроков · 20–40 мин · средняя · хит",
|
||||
"cover": null,
|
||||
"difficulty": 2,
|
||||
"icon": "dices",
|
||||
"accent": "#f4a261"
|
||||
},
|
||||
{
|
||||
"seed_id": "board-domino",
|
||||
"kind": "board",
|
||||
"title": "Домино",
|
||||
"note": "Классика · 2–4 игроков · 15–30 мин · простая",
|
||||
"cover": null,
|
||||
"difficulty": 1,
|
||||
"icon": "grid",
|
||||
"accent": "#ffd23d"
|
||||
},
|
||||
{
|
||||
"seed_id": "board-checkers",
|
||||
"kind": "board",
|
||||
"title": "Шашки",
|
||||
"note": "Классика · 2–2 игроков · 10–30 мин · простая",
|
||||
"cover": null,
|
||||
"difficulty": 1,
|
||||
"icon": "circle",
|
||||
"accent": "#4dff88"
|
||||
},
|
||||
{
|
||||
"seed_id": "board-uno",
|
||||
"kind": "board",
|
||||
"title": "UNO",
|
||||
"note": "Карточные · 2–10 игроков · 15–30 мин · простая · хит",
|
||||
"cover": null,
|
||||
"difficulty": 1,
|
||||
"icon": "hash",
|
||||
"accent": "#ff3636"
|
||||
},
|
||||
{
|
||||
"seed_id": "board-monopoly",
|
||||
"kind": "board",
|
||||
"title": "Монополия",
|
||||
"note": "Стратегия · 2–6 игроков · 60–120 мин · средняя",
|
||||
"cover": null,
|
||||
"difficulty": 2,
|
||||
"icon": "landmark",
|
||||
"accent": "#2ec27e"
|
||||
},
|
||||
{
|
||||
"seed_id": "board-jenga",
|
||||
"kind": "board",
|
||||
"title": "Дженга",
|
||||
"note": "Компанейские · 2–8 игроков · 10–20 мин · простая",
|
||||
"cover": null,
|
||||
"difficulty": 1,
|
||||
"icon": "layout",
|
||||
"accent": "#d4af37"
|
||||
},
|
||||
{
|
||||
"seed_id": "board-alias",
|
||||
"kind": "board",
|
||||
"title": "Alias",
|
||||
"note": "Компанейские · 4–12 игроков · 30–60 мин · простая",
|
||||
"cover": null,
|
||||
"difficulty": 1,
|
||||
"icon": "brain",
|
||||
"accent": "#ff8a00"
|
||||
},
|
||||
{
|
||||
"seed_id": "board-imaginarium",
|
||||
"kind": "board",
|
||||
"title": "Имаджинариум",
|
||||
"note": "Компанейские · 4–7 игроков · 30–60 мин · простая",
|
||||
"cover": null,
|
||||
"difficulty": 1,
|
||||
"icon": "puzzle",
|
||||
"accent": "#c77dff"
|
||||
},
|
||||
{
|
||||
"seed_id": "board-crocodile",
|
||||
"kind": "board",
|
||||
"title": "Крокодил",
|
||||
"note": "Компанейские · 4–16 игроков · 20–60 мин · простая",
|
||||
"cover": null,
|
||||
"difficulty": 1,
|
||||
"icon": "users",
|
||||
"accent": "#00c2a8"
|
||||
},
|
||||
{
|
||||
"seed_id": "board-poker",
|
||||
"kind": "board",
|
||||
"title": "Покерный набор",
|
||||
"note": "Карточные · 2–9 игроков · 60–180 мин · средняя",
|
||||
"cover": null,
|
||||
"difficulty": 2,
|
||||
"icon": "club",
|
||||
"accent": "#1fa37a"
|
||||
},
|
||||
{
|
||||
"seed_id": "board-exploding-kittens",
|
||||
"kind": "board",
|
||||
"title": "Взрывные котята",
|
||||
"note": "Карточные · 2–5 игроков · 15 мин · простая",
|
||||
"cover": null,
|
||||
"difficulty": 1,
|
||||
"icon": "ghost",
|
||||
"accent": "#ff6b9d"
|
||||
},
|
||||
{
|
||||
"seed_id": "board-catan",
|
||||
"kind": "board",
|
||||
"title": "Колонизаторы (Catan)",
|
||||
"note": "Стратегия · 3–4 игроков · 60–90 мин · средняя",
|
||||
"cover": null,
|
||||
"difficulty": 2,
|
||||
"icon": "castle",
|
||||
"accent": "#e9c46a"
|
||||
},
|
||||
{
|
||||
"seed_id": "board-dixit",
|
||||
"kind": "board",
|
||||
"title": "Диксит",
|
||||
"note": "Компанейские · 3–6 игроков · 30 мин · простая",
|
||||
"cover": null,
|
||||
"difficulty": 1,
|
||||
"icon": "heart",
|
||||
"accent": "#f77f00"
|
||||
},
|
||||
{
|
||||
"seed_id": "board-codenames",
|
||||
"kind": "board",
|
||||
"title": "Кодовые имена",
|
||||
"note": "На логику · 4–8 игроков · 15–30 мин · средняя",
|
||||
"cover": null,
|
||||
"difficulty": 2,
|
||||
"icon": "target",
|
||||
"accent": "#4895ef"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,359 @@
|
||||
[
|
||||
{
|
||||
"id": "blackberry-mint",
|
||||
"name": "Ежевика-мята",
|
||||
"category": "АВТОРСКИЙ ЧАЙ",
|
||||
"price": 350,
|
||||
"portion": "900 мл"
|
||||
},
|
||||
{
|
||||
"id": "raspberry-mint",
|
||||
"name": "Малина-мята",
|
||||
"category": "АВТОРСКИЙ ЧАЙ",
|
||||
"price": 350,
|
||||
"portion": "900 мл"
|
||||
},
|
||||
{
|
||||
"id": "seabuckthorn-orange",
|
||||
"name": "Облепиха-апельсин",
|
||||
"category": "АВТОРСКИЙ ЧАЙ",
|
||||
"price": 350,
|
||||
"portion": "900 мл"
|
||||
},
|
||||
{
|
||||
"id": "seabuckthorn-cinnamon",
|
||||
"name": "Облепиха-корица",
|
||||
"category": "АВТОРСКИЙ ЧАЙ",
|
||||
"price": 350,
|
||||
"portion": "900 мл"
|
||||
},
|
||||
{
|
||||
"id": "forest-berries",
|
||||
"name": "Лесные ягоды",
|
||||
"category": "АВТОРСКИЙ ЧАЙ",
|
||||
"price": 350,
|
||||
"portion": "900 мл"
|
||||
},
|
||||
{
|
||||
"id": "mango-seabuckthorn",
|
||||
"name": "Манго-облепиха",
|
||||
"category": "АВТОРСКИЙ ЧАЙ",
|
||||
"price": 350,
|
||||
"portion": "900 мл"
|
||||
},
|
||||
{
|
||||
"id": "raspberry-ginger",
|
||||
"name": "Малина-имбирь",
|
||||
"category": "АВТОРСКИЙ ЧАЙ",
|
||||
"price": 350,
|
||||
"portion": "900 мл"
|
||||
},
|
||||
{
|
||||
"id": "bud",
|
||||
"name": "Bud",
|
||||
"category": "ПИВНАЯ КАРТА",
|
||||
"price": 250,
|
||||
"portion": "0,44 л"
|
||||
},
|
||||
{
|
||||
"id": "kozel-dark",
|
||||
"name": "Kozel темный",
|
||||
"category": "ПИВНАЯ КАРТА",
|
||||
"price": 220,
|
||||
"portion": "0,45 л"
|
||||
},
|
||||
{
|
||||
"id": "stary-melnik-light",
|
||||
"name": "Старый мельник светлое",
|
||||
"category": "ПИВНАЯ КАРТА",
|
||||
"price": 200,
|
||||
"portion": "0,45 л"
|
||||
},
|
||||
{
|
||||
"id": "krone-blanche",
|
||||
"name": "Krone Blanche",
|
||||
"category": "ПИВНАЯ КАРТА",
|
||||
"price": 220,
|
||||
"portion": "0,45 л"
|
||||
},
|
||||
{
|
||||
"id": "erzmann",
|
||||
"name": "Erzmann",
|
||||
"category": "ПИВНАЯ КАРТА",
|
||||
"price": 220,
|
||||
"portion": "0,45 л"
|
||||
},
|
||||
{
|
||||
"id": "el-capulco",
|
||||
"name": "El Capulco",
|
||||
"category": "ПИВНАЯ КАРТА",
|
||||
"price": 250,
|
||||
"portion": "0,45 л"
|
||||
},
|
||||
{
|
||||
"id": "stella",
|
||||
"name": "Stella",
|
||||
"category": "ПИВНАЯ КАРТА",
|
||||
"price": 250,
|
||||
"portion": "0,44 л"
|
||||
},
|
||||
{
|
||||
"id": "kozel-light",
|
||||
"name": "Kozel светлый",
|
||||
"category": "ПИВНАЯ КАРТА",
|
||||
"price": 220,
|
||||
"portion": "0,45 л"
|
||||
},
|
||||
{
|
||||
"id": "stary-melnik-dark",
|
||||
"name": "Старый мельник темное",
|
||||
"category": "ПИВНАЯ КАРТА",
|
||||
"price": 200,
|
||||
"portion": "1 шт"
|
||||
},
|
||||
{
|
||||
"id": "pablos-lime",
|
||||
"name": "Pablo's с соком лайма",
|
||||
"category": "ПИВНАЯ КАРТА",
|
||||
"price": 200,
|
||||
"portion": "1 шт"
|
||||
},
|
||||
{
|
||||
"id": "redds",
|
||||
"name": "Redd's",
|
||||
"category": "ПИВНАЯ КАРТА",
|
||||
"price": 200,
|
||||
"portion": "1 шт"
|
||||
},
|
||||
{
|
||||
"id": "spaten",
|
||||
"name": "Spatten",
|
||||
"category": "ПИВНАЯ КАРТА",
|
||||
"price": 250,
|
||||
"portion": "0,45 л"
|
||||
},
|
||||
{
|
||||
"id": "hoegaarden",
|
||||
"name": "Hoegaarden",
|
||||
"category": "ПИВНАЯ КАРТА",
|
||||
"price": 220,
|
||||
"portion": "0,45 л"
|
||||
},
|
||||
{
|
||||
"id": "corona-extra",
|
||||
"name": "Corona Extra",
|
||||
"category": "ПИВНАЯ КАРТА",
|
||||
"price": 250,
|
||||
"portion": "1 шт"
|
||||
},
|
||||
{
|
||||
"id": "onion-rings",
|
||||
"name": "Луковые кольца",
|
||||
"category": "ЗАКУСКИ",
|
||||
"price": 220,
|
||||
"portion": "8 шт"
|
||||
},
|
||||
{
|
||||
"id": "fries",
|
||||
"name": "Картофель Фри",
|
||||
"category": "ЗАКУСКИ",
|
||||
"price": 210,
|
||||
"portion": "130 гр"
|
||||
},
|
||||
{
|
||||
"id": "buffalo-wings",
|
||||
"name": "Крылышки Баффало",
|
||||
"category": "ЗАКУСКИ",
|
||||
"price": 350,
|
||||
"portion": "6 шт"
|
||||
},
|
||||
{
|
||||
"id": "cheese-sticks",
|
||||
"name": "Сырные палочки",
|
||||
"category": "ЗАКУСКИ",
|
||||
"price": 310,
|
||||
"portion": "6 шт"
|
||||
},
|
||||
{
|
||||
"id": "strips",
|
||||
"name": "Стрипсы",
|
||||
"category": "ЗАКУСКИ",
|
||||
"price": 320,
|
||||
"portion": "6 шт"
|
||||
},
|
||||
{
|
||||
"id": "nuggets",
|
||||
"name": "Нагетсы",
|
||||
"category": "ЗАКУСКИ",
|
||||
"price": 320,
|
||||
"portion": "8 шт"
|
||||
},
|
||||
{
|
||||
"id": "black-tea",
|
||||
"name": "Черный чай",
|
||||
"category": "НАПИТКИ И ДЕСЕРТЫ",
|
||||
"price": 250,
|
||||
"portion": "900 мл"
|
||||
},
|
||||
{
|
||||
"id": "black-tea-thyme",
|
||||
"name": "Черный чай с чабрецом",
|
||||
"category": "НАПИТКИ И ДЕСЕРТЫ",
|
||||
"price": 250,
|
||||
"portion": "900 мл"
|
||||
},
|
||||
{
|
||||
"id": "milk-oolong",
|
||||
"name": "Молочный Улун",
|
||||
"category": "НАПИТКИ И ДЕСЕРТЫ",
|
||||
"price": 350,
|
||||
"portion": "900 мл"
|
||||
},
|
||||
{
|
||||
"id": "americano",
|
||||
"name": "Американо",
|
||||
"category": "НАПИТКИ И ДЕСЕРТЫ",
|
||||
"price": 150,
|
||||
"portion": "150 мл"
|
||||
},
|
||||
{
|
||||
"id": "cappuccino",
|
||||
"name": "Капучино",
|
||||
"category": "НАПИТКИ И ДЕСЕРТЫ",
|
||||
"price": 200,
|
||||
"portion": "200 мл"
|
||||
},
|
||||
{
|
||||
"id": "cheesecake-ny",
|
||||
"name": "Чизкейк Нью-Йорк",
|
||||
"category": "НАПИТКИ И ДЕСЕРТЫ",
|
||||
"price": 250,
|
||||
"portion": "120 гр"
|
||||
},
|
||||
{
|
||||
"id": "red-velvet",
|
||||
"name": "Торт Красный бархат",
|
||||
"category": "НАПИТКИ И ДЕСЕРТЫ",
|
||||
"price": 250,
|
||||
"portion": "120 гр"
|
||||
},
|
||||
{
|
||||
"id": "medovik",
|
||||
"name": "Торт Медовик",
|
||||
"category": "НАПИТКИ И ДЕСЕРТЫ",
|
||||
"price": 250,
|
||||
"portion": "120 гр"
|
||||
},
|
||||
{
|
||||
"id": "cheesecake-strawberry",
|
||||
"name": "Чизкейк Нью-Йорк клубничный",
|
||||
"category": "НАПИТКИ И ДЕСЕРТЫ",
|
||||
"price": 250,
|
||||
"portion": "120 гр"
|
||||
},
|
||||
{
|
||||
"id": "cheesecake-raspberry",
|
||||
"name": "Чизкейк Нью-Йорк Малина",
|
||||
"category": "НАПИТКИ И ДЕСЕРТЫ",
|
||||
"price": 250,
|
||||
"portion": "120 гр"
|
||||
},
|
||||
{
|
||||
"id": "alpen-gold",
|
||||
"name": "Alpen Gold",
|
||||
"category": "СНЕКИ И СЛАДОСТИ",
|
||||
"price": 210,
|
||||
"portion": null
|
||||
},
|
||||
{
|
||||
"id": "milka",
|
||||
"name": "Milka",
|
||||
"category": "СНЕКИ И СЛАДОСТИ",
|
||||
"price": 230,
|
||||
"portion": null
|
||||
},
|
||||
{
|
||||
"id": "leys",
|
||||
"name": "Чипсы Leys",
|
||||
"category": "СНЕКИ И СЛАДОСТИ",
|
||||
"price": 210,
|
||||
"portion": "90 гр"
|
||||
},
|
||||
{
|
||||
"id": "korochki",
|
||||
"name": "3 Корочки",
|
||||
"category": "СНЕКИ И СЛАДОСТИ",
|
||||
"price": 150,
|
||||
"portion": "60 гр"
|
||||
},
|
||||
{
|
||||
"id": "hrustim",
|
||||
"name": "Хрустим team",
|
||||
"category": "СНЕКИ И СЛАДОСТИ",
|
||||
"price": 100,
|
||||
"portion": "40 гр"
|
||||
},
|
||||
{
|
||||
"id": "natakhtari-pear",
|
||||
"name": "НАТАХТАРИ ГРУША",
|
||||
"category": "ЛИМОНАДЫ И ЭНЕРГЕТИКИ",
|
||||
"price": 200,
|
||||
"portion": "1 шт"
|
||||
},
|
||||
{
|
||||
"id": "strawberry-kiwi",
|
||||
"name": "Клубника Киви",
|
||||
"category": "ЛИМОНАДЫ И ЭНЕРГЕТИКИ",
|
||||
"price": 150,
|
||||
"portion": "1 шт"
|
||||
},
|
||||
{
|
||||
"id": "feijoa",
|
||||
"name": "Фейхоа",
|
||||
"category": "ЛИМОНАДЫ И ЭНЕРГЕТИКИ",
|
||||
"price": 150,
|
||||
"portion": "1 шт"
|
||||
},
|
||||
{
|
||||
"id": "natakhtari-feijoa",
|
||||
"name": "НАТАХТАРИ ФЕЙХОА",
|
||||
"category": "ЛИМОНАДЫ И ЭНЕРГЕТИКИ",
|
||||
"price": 200,
|
||||
"portion": "1 шт"
|
||||
},
|
||||
{
|
||||
"id": "natakhtari-saperavi",
|
||||
"name": "НАТАХТАРИ САПЕРАВИ",
|
||||
"category": "ЛИМОНАДЫ И ЭНЕРГЕТИКИ",
|
||||
"price": 200,
|
||||
"portion": "1 шт"
|
||||
},
|
||||
{
|
||||
"id": "burn-original",
|
||||
"name": "BURN Оригинальный",
|
||||
"category": "ЛИМОНАДЫ И ЭНЕРГЕТИКИ",
|
||||
"price": 250,
|
||||
"portion": "1 шт"
|
||||
},
|
||||
{
|
||||
"id": "adrenaline-rush",
|
||||
"name": "Adrenaline RUSH Абсолютная ЭНЕРГИЯ",
|
||||
"category": "ЛИМОНАДЫ И ЭНЕРГЕТИКИ",
|
||||
"price": 250,
|
||||
"portion": "1 шт"
|
||||
},
|
||||
{
|
||||
"id": "burn-apple-kiwi",
|
||||
"name": "BURN Яблоко Киви",
|
||||
"category": "ЛИМОНАДЫ И ЭНЕРГЕТИКИ",
|
||||
"price": 250,
|
||||
"portion": "1 шт"
|
||||
},
|
||||
{
|
||||
"id": "burn-mango",
|
||||
"name": "BURN Со вкусом МАНГО",
|
||||
"category": "ЛИМОНАДЫ И ЭНЕРГЕТИКИ",
|
||||
"price": 250,
|
||||
"portion": "1 шт"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,556 @@
|
||||
[
|
||||
{
|
||||
"id": "showcase-1",
|
||||
"name": "Кристальный лайм",
|
||||
"note": "Лайм держит основу, Помело добавляет характер — свежесть до утра.",
|
||||
"strength": 1,
|
||||
"cooling": 0,
|
||||
"flavor_tags": [
|
||||
"Цитрус"
|
||||
],
|
||||
"price": "1 400 ₽",
|
||||
"featured": true
|
||||
},
|
||||
{
|
||||
"id": "showcase-2",
|
||||
"name": "Морозная клубника",
|
||||
"note": "Клубника держит основу, Лайм добавляет характер — свежесть до утра.",
|
||||
"strength": 1,
|
||||
"cooling": 0,
|
||||
"flavor_tags": [
|
||||
"Ягоды",
|
||||
"Цитрус"
|
||||
],
|
||||
"price": "1 400 ₽",
|
||||
"featured": true
|
||||
},
|
||||
{
|
||||
"id": "showcase-3",
|
||||
"name": "Морозное помело",
|
||||
"note": "Помело держит основу, Лайм добавляет характер — прохлада с первой затяжки.",
|
||||
"strength": 1,
|
||||
"cooling": 2,
|
||||
"flavor_tags": [
|
||||
"Цитрус",
|
||||
"лёд"
|
||||
],
|
||||
"price": "1 400 ₽",
|
||||
"featured": true
|
||||
},
|
||||
{
|
||||
"id": "showcase-4",
|
||||
"name": "Морозная клубника · с холодком",
|
||||
"note": "Клубника держит основу, Лайм добавляет характер — прохлада с первой затяжки.",
|
||||
"strength": 1,
|
||||
"cooling": 2,
|
||||
"flavor_tags": [
|
||||
"Ягоды",
|
||||
"Цитрус",
|
||||
"лёд"
|
||||
],
|
||||
"price": "1 400 ₽",
|
||||
"featured": true
|
||||
},
|
||||
{
|
||||
"id": "showcase-5",
|
||||
"name": "Морозная груша",
|
||||
"note": "Груша держит основу, Яблоко добавляет характер — свежесть до утра.",
|
||||
"strength": 3,
|
||||
"cooling": 0,
|
||||
"flavor_tags": [
|
||||
"Цитрус",
|
||||
"Фрукты"
|
||||
],
|
||||
"price": "1 400 ₽",
|
||||
"featured": true
|
||||
},
|
||||
{
|
||||
"id": "showcase-6",
|
||||
"name": "Кристальная клубника",
|
||||
"note": "Клубника держит основу, Груша добавляет характер — ледяной и чистый финиш.",
|
||||
"strength": 3,
|
||||
"cooling": 0,
|
||||
"flavor_tags": [
|
||||
"Ягоды",
|
||||
"Цитрус",
|
||||
"Фрукты"
|
||||
],
|
||||
"price": "1 400 ₽",
|
||||
"featured": false
|
||||
},
|
||||
{
|
||||
"id": "showcase-7",
|
||||
"name": "Морозная груша · с холодком",
|
||||
"note": "Груша держит основу, Клубника добавляет характер — свежесть до утра.",
|
||||
"strength": 3,
|
||||
"cooling": 2,
|
||||
"flavor_tags": [
|
||||
"Цитрус",
|
||||
"Ягоды",
|
||||
"лёд"
|
||||
],
|
||||
"price": "1 400 ₽",
|
||||
"featured": false
|
||||
},
|
||||
{
|
||||
"id": "showcase-8",
|
||||
"name": "Полярное яблоко",
|
||||
"note": "Яблоко держит основу, Клубника добавляет характер — прохлада с первой затяжки.",
|
||||
"strength": 3,
|
||||
"cooling": 2,
|
||||
"flavor_tags": [
|
||||
"Фрукты",
|
||||
"Ягоды",
|
||||
"лёд"
|
||||
],
|
||||
"price": "1 400 ₽",
|
||||
"featured": false
|
||||
},
|
||||
{
|
||||
"id": "showcase-9",
|
||||
"name": "Полярная голубика",
|
||||
"note": "Голубика держит основу, Фисташковое мороженое добавляет характер — свежесть до утра.",
|
||||
"strength": 5,
|
||||
"cooling": 0,
|
||||
"flavor_tags": [
|
||||
"Ягоды",
|
||||
"Десерты",
|
||||
"Пряности"
|
||||
],
|
||||
"price": "1 800 ₽",
|
||||
"featured": false
|
||||
},
|
||||
{
|
||||
"id": "showcase-10",
|
||||
"name": "Кристальная медовая дыня",
|
||||
"note": "Медовая дыня держит основу, Ультракислый лимон добавляет характер — свежесть до утра.",
|
||||
"strength": 5,
|
||||
"cooling": 0,
|
||||
"flavor_tags": [
|
||||
"Фрукты",
|
||||
"Цитрус"
|
||||
],
|
||||
"price": "1 800 ₽",
|
||||
"featured": false
|
||||
},
|
||||
{
|
||||
"id": "showcase-11",
|
||||
"name": "Ледяной ультракислый лимон",
|
||||
"note": "Ультракислый лимон держит основу, Медовая дыня добавляет характер — ледяной и чистый финиш.",
|
||||
"strength": 5,
|
||||
"cooling": 2,
|
||||
"flavor_tags": [
|
||||
"Цитрус",
|
||||
"Фрукты",
|
||||
"лёд"
|
||||
],
|
||||
"price": "1 800 ₽",
|
||||
"featured": false
|
||||
},
|
||||
{
|
||||
"id": "showcase-12",
|
||||
"name": "Ледяная медовая дыня",
|
||||
"note": "Медовая дыня держит основу, Сочный ананас добавляет характер — свежесть до утра.",
|
||||
"strength": 5,
|
||||
"cooling": 2,
|
||||
"flavor_tags": [
|
||||
"Фрукты",
|
||||
"Тропики",
|
||||
"лёд"
|
||||
],
|
||||
"price": "1 800 ₽",
|
||||
"featured": false
|
||||
},
|
||||
{
|
||||
"id": "showcase-13",
|
||||
"name": "Сливочная манго-ромашка",
|
||||
"note": "Манго-ромашка держит основу, Помело добавляет характер — мягкий десертный дым.",
|
||||
"strength": 1,
|
||||
"cooling": 0,
|
||||
"flavor_tags": [
|
||||
"Тропики",
|
||||
"Цитрус",
|
||||
"Ягоды"
|
||||
],
|
||||
"price": "1 400 ₽",
|
||||
"featured": false
|
||||
},
|
||||
{
|
||||
"id": "showcase-14",
|
||||
"name": "Сливочная клубника",
|
||||
"note": "Клубника держит основу, Помело добавляет характер — мягкий десертный дым.",
|
||||
"strength": 1,
|
||||
"cooling": 0,
|
||||
"flavor_tags": [
|
||||
"Ягоды",
|
||||
"Цитрус"
|
||||
],
|
||||
"price": "1 400 ₽",
|
||||
"featured": false
|
||||
},
|
||||
{
|
||||
"id": "showcase-15",
|
||||
"name": "Сливочная клубника · с холодком",
|
||||
"note": "Клубника держит основу, Помело добавляет характер — мягкий десертный дым.",
|
||||
"strength": 1,
|
||||
"cooling": 2,
|
||||
"flavor_tags": [
|
||||
"Ягоды",
|
||||
"Цитрус",
|
||||
"лёд"
|
||||
],
|
||||
"price": "1 400 ₽",
|
||||
"featured": false
|
||||
},
|
||||
{
|
||||
"id": "showcase-16",
|
||||
"name": "Бархатная манго-ромашка",
|
||||
"note": "Манго-ромашка держит основу, Лайм добавляет характер — мягкий десертный дым.",
|
||||
"strength": 1,
|
||||
"cooling": 2,
|
||||
"flavor_tags": [
|
||||
"Тропики",
|
||||
"Цитрус",
|
||||
"лёд"
|
||||
],
|
||||
"price": "1 400 ₽",
|
||||
"featured": false
|
||||
},
|
||||
{
|
||||
"id": "showcase-17",
|
||||
"name": "Бархатная клубника",
|
||||
"note": "Клубника держит основу, Груша добавляет характер — мягкий десертный дым.",
|
||||
"strength": 3,
|
||||
"cooling": 0,
|
||||
"flavor_tags": [
|
||||
"Ягоды",
|
||||
"Цитрус"
|
||||
],
|
||||
"price": "1 400 ₽",
|
||||
"featured": false
|
||||
},
|
||||
{
|
||||
"id": "showcase-18",
|
||||
"name": "Вельветовое печенье",
|
||||
"note": "Печенье держит основу, Яблоко добавляет характер — для долгих разговоров.",
|
||||
"strength": 3,
|
||||
"cooling": 0,
|
||||
"flavor_tags": [
|
||||
"Десерты",
|
||||
"Фрукты"
|
||||
],
|
||||
"price": "1 400 ₽",
|
||||
"featured": false
|
||||
},
|
||||
{
|
||||
"id": "showcase-19",
|
||||
"name": "Вельветовое печенье · с холодком",
|
||||
"note": "Печенье держит основу, Клубника добавляет характер — мягкий десертный дым.",
|
||||
"strength": 3,
|
||||
"cooling": 2,
|
||||
"flavor_tags": [
|
||||
"Десерты",
|
||||
"Ягоды",
|
||||
"лёд"
|
||||
],
|
||||
"price": "1 400 ₽",
|
||||
"featured": false
|
||||
},
|
||||
{
|
||||
"id": "showcase-20",
|
||||
"name": "Ночной шоколадный брауни",
|
||||
"note": "Шоколадный брауни держит основу, Базилик добавляет характер — густое сладкое послевкусие.",
|
||||
"strength": 5,
|
||||
"cooling": 0,
|
||||
"flavor_tags": [
|
||||
"Десерты",
|
||||
"Пряности"
|
||||
],
|
||||
"price": "1 800 ₽",
|
||||
"featured": false
|
||||
},
|
||||
{
|
||||
"id": "showcase-21",
|
||||
"name": "Сливочный сочный ананас",
|
||||
"note": "Сочный ананас держит основу, Медовая дыня добавляет характер — для долгих разговоров.",
|
||||
"strength": 5,
|
||||
"cooling": 0,
|
||||
"flavor_tags": [
|
||||
"Тропики",
|
||||
"Фрукты",
|
||||
"Пряности"
|
||||
],
|
||||
"price": "1 800 ₽",
|
||||
"featured": false
|
||||
},
|
||||
{
|
||||
"id": "showcase-22",
|
||||
"name": "Вельветовый шоколадный брауни",
|
||||
"note": "Шоколадный брауни держит основу, Фисташковое мороженое добавляет характер — мягкий десертный дым.",
|
||||
"strength": 5,
|
||||
"cooling": 2,
|
||||
"flavor_tags": [
|
||||
"Десерты",
|
||||
"лёд"
|
||||
],
|
||||
"price": "1 800 ₽",
|
||||
"featured": false
|
||||
},
|
||||
{
|
||||
"id": "showcase-23",
|
||||
"name": "Вельветовый банан",
|
||||
"note": "Банан держит основу, Фисташковое мороженое добавляет характер — для долгих разговоров.",
|
||||
"strength": 5,
|
||||
"cooling": 2,
|
||||
"flavor_tags": [
|
||||
"Тропики",
|
||||
"Десерты",
|
||||
"лёд"
|
||||
],
|
||||
"price": "1 800 ₽",
|
||||
"featured": false
|
||||
},
|
||||
{
|
||||
"id": "showcase-24",
|
||||
"name": "Солнечная манго-ромашка · клубника",
|
||||
"note": "Манго-ромашка держит основу, Клубника добавляет характер — волна спелых фруктов.",
|
||||
"strength": 1,
|
||||
"cooling": 0,
|
||||
"flavor_tags": [
|
||||
"Тропики",
|
||||
"Ягоды",
|
||||
"Цитрус"
|
||||
],
|
||||
"price": "1 400 ₽",
|
||||
"featured": false
|
||||
},
|
||||
{
|
||||
"id": "showcase-25",
|
||||
"name": "Закатная манго-ромашка",
|
||||
"note": "Манго-ромашка держит основу, Лайм добавляет характер — как закат у бара.",
|
||||
"strength": 1,
|
||||
"cooling": 0,
|
||||
"flavor_tags": [
|
||||
"Тропики",
|
||||
"Цитрус"
|
||||
],
|
||||
"price": "1 400 ₽",
|
||||
"featured": false
|
||||
},
|
||||
{
|
||||
"id": "showcase-26",
|
||||
"name": "Тропическая манго-ромашка · клубника",
|
||||
"note": "Манго-ромашка держит основу, Клубника добавляет характер — волна спелых фруктов.",
|
||||
"strength": 1,
|
||||
"cooling": 2,
|
||||
"flavor_tags": [
|
||||
"Тропики",
|
||||
"Ягоды",
|
||||
"лёд"
|
||||
],
|
||||
"price": "1 400 ₽",
|
||||
"featured": false
|
||||
},
|
||||
{
|
||||
"id": "showcase-27",
|
||||
"name": "Тропическая манго-ромашка · помело",
|
||||
"note": "Манго-ромашка держит основу, Помело добавляет характер — волна спелых фруктов.",
|
||||
"strength": 1,
|
||||
"cooling": 2,
|
||||
"flavor_tags": [
|
||||
"Тропики",
|
||||
"Цитрус",
|
||||
"лёд"
|
||||
],
|
||||
"price": "1 400 ₽",
|
||||
"featured": false
|
||||
},
|
||||
{
|
||||
"id": "showcase-28",
|
||||
"name": "Закатное яблоко",
|
||||
"note": "Яблоко держит основу, Клубника добавляет характер — волна спелых фруктов.",
|
||||
"strength": 3,
|
||||
"cooling": 0,
|
||||
"flavor_tags": [
|
||||
"Фрукты",
|
||||
"Ягоды"
|
||||
],
|
||||
"price": "1 400 ₽",
|
||||
"featured": false
|
||||
},
|
||||
{
|
||||
"id": "showcase-29",
|
||||
"name": "Закатное яблоко · с холодком",
|
||||
"note": "Яблоко держит основу, Груша добавляет характер — волна спелых фруктов.",
|
||||
"strength": 3,
|
||||
"cooling": 2,
|
||||
"flavor_tags": [
|
||||
"Фрукты",
|
||||
"Цитрус",
|
||||
"лёд"
|
||||
],
|
||||
"price": "1 400 ₽",
|
||||
"featured": false
|
||||
},
|
||||
{
|
||||
"id": "showcase-30",
|
||||
"name": "Тропическое сочное манго",
|
||||
"note": "Сочное манго держит основу, Базилик добавляет характер — волна спелых фруктов.",
|
||||
"strength": 5,
|
||||
"cooling": 0,
|
||||
"flavor_tags": [
|
||||
"Тропики",
|
||||
"Пряности",
|
||||
"Ягоды"
|
||||
],
|
||||
"price": "1 800 ₽",
|
||||
"featured": false
|
||||
},
|
||||
{
|
||||
"id": "showcase-31",
|
||||
"name": "Спелое сочное манго",
|
||||
"note": "Сочное манго держит основу, Медовая дыня добавляет характер — сочный тропический характер.",
|
||||
"strength": 5,
|
||||
"cooling": 0,
|
||||
"flavor_tags": [
|
||||
"Тропики",
|
||||
"Фрукты",
|
||||
"Цитрус"
|
||||
],
|
||||
"price": "1 800 ₽",
|
||||
"featured": false
|
||||
},
|
||||
{
|
||||
"id": "showcase-32",
|
||||
"name": "Спелый сочный ананас",
|
||||
"note": "Сочный ананас держит основу, Фисташковое мороженое добавляет характер — сочный тропический характер.",
|
||||
"strength": 5,
|
||||
"cooling": 2,
|
||||
"flavor_tags": [
|
||||
"Тропики",
|
||||
"Десерты",
|
||||
"лёд"
|
||||
],
|
||||
"price": "1 800 ₽",
|
||||
"featured": false
|
||||
},
|
||||
{
|
||||
"id": "showcase-33",
|
||||
"name": "Солнечное сочное манго",
|
||||
"note": "Сочное манго держит основу, Фисташковое мороженое добавляет характер — как закат у бара.",
|
||||
"strength": 5,
|
||||
"cooling": 2,
|
||||
"flavor_tags": [
|
||||
"Тропики",
|
||||
"Десерты",
|
||||
"лёд"
|
||||
],
|
||||
"price": "1 800 ₽",
|
||||
"featured": false
|
||||
},
|
||||
{
|
||||
"id": "showcase-34",
|
||||
"name": "Ягодная клубника",
|
||||
"note": "Клубника держит основу, Лайм добавляет характер — сочная ягодная кислинка.",
|
||||
"strength": 1,
|
||||
"cooling": 0,
|
||||
"flavor_tags": [
|
||||
"Ягоды",
|
||||
"Цитрус"
|
||||
],
|
||||
"price": "1 400 ₽",
|
||||
"featured": false
|
||||
},
|
||||
{
|
||||
"id": "showcase-35",
|
||||
"name": "Полночный виноград",
|
||||
"note": "Белый виноград держит основу, Голубика добавляет характер — сочная ягодная кислинка.",
|
||||
"strength": 1,
|
||||
"cooling": 2,
|
||||
"flavor_tags": [
|
||||
"Ягоды"
|
||||
],
|
||||
"price": "1 400 ₽",
|
||||
"featured": false
|
||||
},
|
||||
{
|
||||
"id": "showcase-36",
|
||||
"name": "Ягодная голубика",
|
||||
"note": "Голубика держит основу, Медовая дыня добавляет характер — спелые ягоды в каждом вдохе.",
|
||||
"strength": 5,
|
||||
"cooling": 0,
|
||||
"flavor_tags": [
|
||||
"Ягоды",
|
||||
"Фрукты"
|
||||
],
|
||||
"price": "1 800 ₽",
|
||||
"featured": false
|
||||
},
|
||||
{
|
||||
"id": "showcase-37",
|
||||
"name": "Ягодный белый виноград",
|
||||
"note": "Белый виноград держит основу, Сочный ананас добавляет характер — сочная ягодная кислинка.",
|
||||
"strength": 5,
|
||||
"cooling": 0,
|
||||
"flavor_tags": [
|
||||
"Ягоды",
|
||||
"Тропики"
|
||||
],
|
||||
"price": "1 800 ₽",
|
||||
"featured": false
|
||||
},
|
||||
{
|
||||
"id": "showcase-38",
|
||||
"name": "Пурпурный белый виноград",
|
||||
"note": "Белый виноград держит основу, Голубика добавляет характер — спелые ягоды в каждом вдохе.",
|
||||
"strength": 5,
|
||||
"cooling": 2,
|
||||
"flavor_tags": [
|
||||
"Ягоды",
|
||||
"лёд"
|
||||
],
|
||||
"price": "1 800 ₽",
|
||||
"featured": false
|
||||
},
|
||||
{
|
||||
"id": "showcase-39",
|
||||
"name": "Ягодная голубика · с холодком",
|
||||
"note": "Голубика держит основу, Медовая дыня добавляет характер — яркая ягодная волна.",
|
||||
"strength": 5,
|
||||
"cooling": 2,
|
||||
"flavor_tags": [
|
||||
"Ягоды",
|
||||
"Фрукты",
|
||||
"лёд"
|
||||
],
|
||||
"price": "1 800 ₽",
|
||||
"featured": false
|
||||
},
|
||||
{
|
||||
"id": "showcase-40",
|
||||
"name": "Искрящийся ультракислый лимон",
|
||||
"note": "Ультракислый лимон держит основу, Базилик добавляет характер — звонкая кислинка на выдохе.",
|
||||
"strength": 5,
|
||||
"cooling": 0,
|
||||
"flavor_tags": [
|
||||
"Цитрус",
|
||||
"Пряности"
|
||||
],
|
||||
"price": "1 800 ₽",
|
||||
"featured": false
|
||||
},
|
||||
{
|
||||
"id": "showcase-41",
|
||||
"name": "Искрящийся ультракислый лимон · медовая дыня",
|
||||
"note": "Ультракислый лимон держит основу, Медовая дыня добавляет характер — бодрая цитрусовая искра.",
|
||||
"strength": 5,
|
||||
"cooling": 0,
|
||||
"flavor_tags": [
|
||||
"Цитрус",
|
||||
"Фрукты",
|
||||
"Ягоды"
|
||||
],
|
||||
"price": "1 800 ₽",
|
||||
"featured": false
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,706 @@
|
||||
[
|
||||
{
|
||||
"id": "add-north",
|
||||
"brand": "Добавка",
|
||||
"flavor": "Север",
|
||||
"note": "Бустер",
|
||||
"strength": 1,
|
||||
"cooling": true
|
||||
},
|
||||
{
|
||||
"id": "add-cooling",
|
||||
"brand": "Добавка",
|
||||
"flavor": "Холодок",
|
||||
"note": null,
|
||||
"strength": 1,
|
||||
"cooling": true
|
||||
},
|
||||
{
|
||||
"id": "sarma-drunk-pear",
|
||||
"brand": "Сарма",
|
||||
"flavor": "Дачная груша",
|
||||
"note": null,
|
||||
"strength": 2,
|
||||
"cooling": false
|
||||
},
|
||||
{
|
||||
"id": "sarma-corn-paprika",
|
||||
"brand": "Сарма",
|
||||
"flavor": "Кукуруза-паприка",
|
||||
"note": null,
|
||||
"strength": 2,
|
||||
"cooling": false
|
||||
},
|
||||
{
|
||||
"id": "north-elderberry",
|
||||
"brand": "Северный",
|
||||
"flavor": "Бузина",
|
||||
"note": null,
|
||||
"strength": 3,
|
||||
"cooling": false
|
||||
},
|
||||
{
|
||||
"id": "north-feijoa",
|
||||
"brand": "Северный",
|
||||
"flavor": "Финдеперцовый фейхоа",
|
||||
"note": null,
|
||||
"strength": 3,
|
||||
"cooling": false
|
||||
},
|
||||
{
|
||||
"id": "north-flower",
|
||||
"brand": "Северный",
|
||||
"flavor": "Цветочный",
|
||||
"note": null,
|
||||
"strength": 3,
|
||||
"cooling": false
|
||||
},
|
||||
{
|
||||
"id": "north-sorbet",
|
||||
"brand": "Северный",
|
||||
"flavor": "Sorbet",
|
||||
"note": "Фруктовый",
|
||||
"strength": 3,
|
||||
"cooling": false
|
||||
},
|
||||
{
|
||||
"id": "hooligan-flash",
|
||||
"brand": "Хулиган",
|
||||
"flavor": "«Флеш»",
|
||||
"note": "Медовая малина",
|
||||
"strength": 2,
|
||||
"cooling": false
|
||||
},
|
||||
{
|
||||
"id": "hooligan-grapefruit",
|
||||
"brand": "Хулиган",
|
||||
"flavor": "Грейпфрутовый сок",
|
||||
"note": null,
|
||||
"strength": 2,
|
||||
"cooling": false
|
||||
},
|
||||
{
|
||||
"id": "wood-berry-ninja",
|
||||
"brand": "Хулиган / WOOD",
|
||||
"flavor": "Берри Ниндзя",
|
||||
"note": "Лесные ягоды",
|
||||
"strength": 2,
|
||||
"cooling": false
|
||||
},
|
||||
{
|
||||
"id": "wood-begemot",
|
||||
"brand": "Хулиган / WOOD",
|
||||
"flavor": "Begemot",
|
||||
"note": "Бергамот-мандарин",
|
||||
"strength": 2,
|
||||
"cooling": false
|
||||
},
|
||||
{
|
||||
"id": "wood-boo",
|
||||
"brand": "Хулиган / WOOD",
|
||||
"flavor": "Boo",
|
||||
"note": "Яблоко-гранат",
|
||||
"strength": 2,
|
||||
"cooling": false
|
||||
},
|
||||
{
|
||||
"id": "wood-old",
|
||||
"brand": "Хулиган / WOOD",
|
||||
"flavor": "Old",
|
||||
"note": "Лимон с мёдом",
|
||||
"strength": 2,
|
||||
"cooling": false
|
||||
},
|
||||
{
|
||||
"id": "wood-sir-hard",
|
||||
"brand": "Хулиган / WOOD",
|
||||
"flavor": "Sir Hard",
|
||||
"note": "Воздушный рис",
|
||||
"strength": 3,
|
||||
"cooling": false
|
||||
},
|
||||
{
|
||||
"id": "banger-black-white",
|
||||
"brand": "Banger",
|
||||
"flavor": "Black and White",
|
||||
"note": "Печенье Oreo",
|
||||
"strength": 2,
|
||||
"cooling": false
|
||||
},
|
||||
{
|
||||
"id": "banger-cola-bella",
|
||||
"brand": "Banger",
|
||||
"flavor": "Cola Bella",
|
||||
"note": "Ванильная кола",
|
||||
"strength": 2,
|
||||
"cooling": false
|
||||
},
|
||||
{
|
||||
"id": "banger-cream-soda",
|
||||
"brand": "Banger",
|
||||
"flavor": "Cream Soda",
|
||||
"note": "Крем-сода",
|
||||
"strength": 2,
|
||||
"cooling": false
|
||||
},
|
||||
{
|
||||
"id": "banger-grapefruit",
|
||||
"brand": "Banger",
|
||||
"flavor": "Grapefruit",
|
||||
"note": "Грейпфрут",
|
||||
"strength": 2,
|
||||
"cooling": false
|
||||
},
|
||||
{
|
||||
"id": "banger-green-day",
|
||||
"brand": "Banger",
|
||||
"flavor": "Green Day",
|
||||
"note": "Яблоко, киви, кислинка",
|
||||
"strength": 2,
|
||||
"cooling": false
|
||||
},
|
||||
{
|
||||
"id": "banger-sexy",
|
||||
"brand": "Banger",
|
||||
"flavor": "Sexy",
|
||||
"note": "Грейпфрут, клубника, малина",
|
||||
"strength": 2,
|
||||
"cooling": false
|
||||
},
|
||||
{
|
||||
"id": "bb-brownie",
|
||||
"brand": "Blackburn",
|
||||
"flavor": "Брауни",
|
||||
"note": null,
|
||||
"strength": 3,
|
||||
"cooling": false
|
||||
},
|
||||
{
|
||||
"id": "bb-grape-lollipop",
|
||||
"brand": "Blackburn",
|
||||
"flavor": "Виноградный чупа-чупс",
|
||||
"note": null,
|
||||
"strength": 3,
|
||||
"cooling": false
|
||||
},
|
||||
{
|
||||
"id": "bb-fir",
|
||||
"brand": "Blackburn",
|
||||
"flavor": "Ёлка",
|
||||
"note": null,
|
||||
"strength": 3,
|
||||
"cooling": true
|
||||
},
|
||||
{
|
||||
"id": "bb-cola",
|
||||
"brand": "Blackburn",
|
||||
"flavor": "Классическая кола",
|
||||
"note": null,
|
||||
"strength": 3,
|
||||
"cooling": false
|
||||
},
|
||||
{
|
||||
"id": "bb-red-orange",
|
||||
"brand": "Blackburn",
|
||||
"flavor": "Красный апельсин",
|
||||
"note": null,
|
||||
"strength": 3,
|
||||
"cooling": false
|
||||
},
|
||||
{
|
||||
"id": "bb-forest-raspberry",
|
||||
"brand": "Blackburn",
|
||||
"flavor": "Лесная малина",
|
||||
"note": null,
|
||||
"strength": 3,
|
||||
"cooling": false
|
||||
},
|
||||
{
|
||||
"id": "bb-honey-melon",
|
||||
"brand": "Blackburn",
|
||||
"flavor": "Медовая дыня",
|
||||
"note": null,
|
||||
"strength": 3,
|
||||
"cooling": false
|
||||
},
|
||||
{
|
||||
"id": "bb-juicy-mango",
|
||||
"brand": "Blackburn",
|
||||
"flavor": "Сочное манго",
|
||||
"note": null,
|
||||
"strength": 3,
|
||||
"cooling": false
|
||||
},
|
||||
{
|
||||
"id": "bb-berry-sorbet",
|
||||
"brand": "Blackburn",
|
||||
"flavor": "Ягодный сорбет",
|
||||
"note": "С грейпфрутом",
|
||||
"strength": 3,
|
||||
"cooling": false
|
||||
},
|
||||
{
|
||||
"id": "bb-after-8",
|
||||
"brand": "Blackburn",
|
||||
"flavor": "After 8",
|
||||
"note": "Шоколад-мята",
|
||||
"strength": 3,
|
||||
"cooling": true
|
||||
},
|
||||
{
|
||||
"id": "bb-ananas-shock",
|
||||
"brand": "Blackburn",
|
||||
"flavor": "Ananas Shock",
|
||||
"note": "Кислый ананас",
|
||||
"strength": 3,
|
||||
"cooling": false
|
||||
},
|
||||
{
|
||||
"id": "bb-apple-shock",
|
||||
"brand": "Blackburn",
|
||||
"flavor": "Apple Shock",
|
||||
"note": "Кислое зелёное яблоко",
|
||||
"strength": 3,
|
||||
"cooling": false
|
||||
},
|
||||
{
|
||||
"id": "bb-bananini",
|
||||
"brand": "Blackburn",
|
||||
"flavor": "Bananini",
|
||||
"note": "Банан",
|
||||
"strength": 3,
|
||||
"cooling": false
|
||||
},
|
||||
{
|
||||
"id": "bb-basilic",
|
||||
"brand": "Blackburn",
|
||||
"flavor": "Basilic",
|
||||
"note": "Базилик",
|
||||
"strength": 3,
|
||||
"cooling": false
|
||||
},
|
||||
{
|
||||
"id": "bb-blackberry-lemonade",
|
||||
"brand": "Blackburn",
|
||||
"flavor": "Blackberry Lemonade",
|
||||
"note": "Ежевичный лимонад",
|
||||
"strength": 3,
|
||||
"cooling": false
|
||||
},
|
||||
{
|
||||
"id": "bb-blueberry",
|
||||
"brand": "Blackburn",
|
||||
"flavor": "Blueberry",
|
||||
"note": "Голубика",
|
||||
"strength": 3,
|
||||
"cooling": false
|
||||
},
|
||||
{
|
||||
"id": "bb-kiwi-stoner",
|
||||
"brand": "Blackburn",
|
||||
"flavor": "Kiwi Stoner",
|
||||
"note": "Смузи из киви",
|
||||
"strength": 3,
|
||||
"cooling": false
|
||||
},
|
||||
{
|
||||
"id": "bb-lemon-shock",
|
||||
"brand": "Blackburn",
|
||||
"flavor": "Lemon Shock",
|
||||
"note": "Ультракислый лимон",
|
||||
"strength": 3,
|
||||
"cooling": false
|
||||
},
|
||||
{
|
||||
"id": "bb-lulo",
|
||||
"brand": "Blackburn",
|
||||
"flavor": "Lulo",
|
||||
"note": null,
|
||||
"strength": 3,
|
||||
"cooling": false
|
||||
},
|
||||
{
|
||||
"id": "bb-overdose",
|
||||
"brand": "Blackburn",
|
||||
"flavor": "Overdose",
|
||||
"note": "Лимон-лайм",
|
||||
"strength": 3,
|
||||
"cooling": false
|
||||
},
|
||||
{
|
||||
"id": "bb-pear-lemonade",
|
||||
"brand": "Blackburn",
|
||||
"flavor": "Pear Lemonade",
|
||||
"note": "Грушевый лимонад",
|
||||
"strength": 3,
|
||||
"cooling": false
|
||||
},
|
||||
{
|
||||
"id": "bb-pina-colada",
|
||||
"brand": "Blackburn",
|
||||
"flavor": "Pina Colada",
|
||||
"note": null,
|
||||
"strength": 3,
|
||||
"cooling": false
|
||||
},
|
||||
{
|
||||
"id": "bb-pineapple",
|
||||
"brand": "Blackburn",
|
||||
"flavor": "Pineapple",
|
||||
"note": "Сочный ананас",
|
||||
"strength": 3,
|
||||
"cooling": false
|
||||
},
|
||||
{
|
||||
"id": "bb-pistachio-ice",
|
||||
"brand": "Blackburn",
|
||||
"flavor": "Pistachio Ice Snow",
|
||||
"note": "Фисташковое мороженое",
|
||||
"strength": 3,
|
||||
"cooling": true
|
||||
},
|
||||
{
|
||||
"id": "bb-pomelo",
|
||||
"brand": "Blackburn",
|
||||
"flavor": "Pomelo",
|
||||
"note": null,
|
||||
"strength": 3,
|
||||
"cooling": false
|
||||
},
|
||||
{
|
||||
"id": "bb-real-pf",
|
||||
"brand": "Blackburn",
|
||||
"flavor": "Real P.F.",
|
||||
"note": "Маракуйя",
|
||||
"strength": 3,
|
||||
"cooling": false
|
||||
},
|
||||
{
|
||||
"id": "bb-red-energy",
|
||||
"brand": "Blackburn",
|
||||
"flavor": "Red Energy",
|
||||
"note": "Энергетик",
|
||||
"strength": 3,
|
||||
"cooling": false
|
||||
},
|
||||
{
|
||||
"id": "bb-white-grape",
|
||||
"brand": "Blackburn",
|
||||
"flavor": "White Grape",
|
||||
"note": "Белый виноград",
|
||||
"strength": 3,
|
||||
"cooling": false
|
||||
},
|
||||
{
|
||||
"id": "bliss-strawberry",
|
||||
"brand": "Bliss",
|
||||
"flavor": "Клубника",
|
||||
"note": null,
|
||||
"strength": 1,
|
||||
"cooling": false
|
||||
},
|
||||
{
|
||||
"id": "bliss-lime",
|
||||
"brand": "Bliss",
|
||||
"flavor": "Лайм",
|
||||
"note": null,
|
||||
"strength": 1,
|
||||
"cooling": false
|
||||
},
|
||||
{
|
||||
"id": "bliss-mango-chamomile",
|
||||
"brand": "Bliss",
|
||||
"flavor": "Манго-ромашка",
|
||||
"note": null,
|
||||
"strength": 1,
|
||||
"cooling": false
|
||||
},
|
||||
{
|
||||
"id": "bliss-peach-yogurt",
|
||||
"brand": "Bliss",
|
||||
"flavor": "Персик-йогурт",
|
||||
"note": null,
|
||||
"strength": 1,
|
||||
"cooling": false
|
||||
},
|
||||
{
|
||||
"id": "bliss-love-shot",
|
||||
"brand": "Bliss",
|
||||
"flavor": "Love Shot",
|
||||
"note": "Маракуйя, арбуз, лимон, холодок",
|
||||
"strength": 1,
|
||||
"cooling": true
|
||||
},
|
||||
{
|
||||
"id": "chabacco-strawberry-milksake",
|
||||
"brand": "Chabacco",
|
||||
"flavor": "Mix",
|
||||
"note": "Клубничный милкшейк",
|
||||
"strength": 2,
|
||||
"cooling": false
|
||||
},
|
||||
{
|
||||
"id": "ds-alpine-trip",
|
||||
"brand": "Darkside",
|
||||
"flavor": "Алтайский трип",
|
||||
"note": "Хвоя, фейхоа, эвкалипт",
|
||||
"strength": 2,
|
||||
"cooling": true
|
||||
},
|
||||
{
|
||||
"id": "deus-base",
|
||||
"brand": "DEUS",
|
||||
"flavor": "Base",
|
||||
"note": "Табачный лист",
|
||||
"strength": 2,
|
||||
"cooling": false
|
||||
},
|
||||
{
|
||||
"id": "deus-blackberry-wine",
|
||||
"brand": "DEUS",
|
||||
"flavor": "Blackberry Wine",
|
||||
"note": "Ежевичное вино",
|
||||
"strength": 2,
|
||||
"cooling": false
|
||||
},
|
||||
{
|
||||
"id": "deus-blueberry-yogurt",
|
||||
"brand": "DEUS",
|
||||
"flavor": "Blueberry Yogurt",
|
||||
"note": "Черничный йогурт",
|
||||
"strength": 2,
|
||||
"cooling": false
|
||||
},
|
||||
{
|
||||
"id": "deus-champagne",
|
||||
"brand": "DEUS",
|
||||
"flavor": "Champagne",
|
||||
"note": "Игристое вино",
|
||||
"strength": 2,
|
||||
"cooling": false
|
||||
},
|
||||
{
|
||||
"id": "deus-cherry-jelly",
|
||||
"brand": "DEUS",
|
||||
"flavor": "Cherry Jelly",
|
||||
"note": "Вишнёвое желе",
|
||||
"strength": 2,
|
||||
"cooling": false
|
||||
},
|
||||
{
|
||||
"id": "deus-guinness",
|
||||
"brand": "DEUS",
|
||||
"flavor": "Guinness",
|
||||
"note": "Тёмное пиво",
|
||||
"strength": 2,
|
||||
"cooling": false
|
||||
},
|
||||
{
|
||||
"id": "deus-peonies",
|
||||
"brand": "DEUS",
|
||||
"flavor": "Peonies",
|
||||
"note": "Пионы",
|
||||
"strength": 2,
|
||||
"cooling": false
|
||||
},
|
||||
{
|
||||
"id": "deus-pumpkin-pie",
|
||||
"brand": "DEUS",
|
||||
"flavor": "Pumpkin Pie",
|
||||
"note": "Тыквенный пирог с миндалём",
|
||||
"strength": 2,
|
||||
"cooling": false
|
||||
},
|
||||
{
|
||||
"id": "deus-violet-zephyr",
|
||||
"brand": "DEUS",
|
||||
"flavor": "Violet Zephyr",
|
||||
"note": "Фиалковый зефир",
|
||||
"strength": 2,
|
||||
"cooling": false
|
||||
},
|
||||
{
|
||||
"id": "hupreme-guava",
|
||||
"brand": "Hupreme",
|
||||
"flavor": "Гуава",
|
||||
"note": null,
|
||||
"strength": 2,
|
||||
"cooling": false
|
||||
},
|
||||
{
|
||||
"id": "hupreme-gooseberry",
|
||||
"brand": "Hupreme",
|
||||
"flavor": "Крыжовник",
|
||||
"note": null,
|
||||
"strength": 2,
|
||||
"cooling": false
|
||||
},
|
||||
{
|
||||
"id": "hupreme-milk-tea",
|
||||
"brand": "Hupreme",
|
||||
"flavor": "Чай с молоком",
|
||||
"note": null,
|
||||
"strength": 2,
|
||||
"cooling": false
|
||||
},
|
||||
{
|
||||
"id": "jent-blue-agave",
|
||||
"brand": "JENT Alcohol Line",
|
||||
"flavor": "Blue Agave",
|
||||
"note": "«Текила Бум»",
|
||||
"strength": 2,
|
||||
"cooling": false
|
||||
},
|
||||
{
|
||||
"id": "jent-cuba-libre",
|
||||
"brand": "JENT Alcohol Line",
|
||||
"flavor": "Cuba Libre",
|
||||
"note": null,
|
||||
"strength": 2,
|
||||
"cooling": false
|
||||
},
|
||||
{
|
||||
"id": "jent-havana",
|
||||
"brand": "JENT Alcohol Line",
|
||||
"flavor": "Havana 290",
|
||||
"note": "Мохито",
|
||||
"strength": 2,
|
||||
"cooling": true
|
||||
},
|
||||
{
|
||||
"id": "joy-kiwichanga",
|
||||
"brand": "Joy Kiwichanga",
|
||||
"flavor": "Киви, фейхоа",
|
||||
"note": null,
|
||||
"strength": 2,
|
||||
"cooling": false
|
||||
},
|
||||
{
|
||||
"id": "muassel-cinnamon-gum",
|
||||
"brand": "Muassel",
|
||||
"flavor": "Жвачка с корицей",
|
||||
"note": null,
|
||||
"strength": 3,
|
||||
"cooling": false
|
||||
},
|
||||
{
|
||||
"id": "od-black-banano",
|
||||
"brand": "Overdose",
|
||||
"flavor": "Black Banano",
|
||||
"note": "Амбровый банан",
|
||||
"strength": 3,
|
||||
"cooling": false
|
||||
},
|
||||
{
|
||||
"id": "od-frustyle",
|
||||
"brand": "Overdose",
|
||||
"flavor": "Frustyle",
|
||||
"note": "Кактус-лайм",
|
||||
"strength": 3,
|
||||
"cooling": false
|
||||
},
|
||||
{
|
||||
"id": "od-gin-cucumber",
|
||||
"brand": "Overdose",
|
||||
"flavor": "Gin Cucumber",
|
||||
"note": "Огуречный джин",
|
||||
"strength": 3,
|
||||
"cooling": false
|
||||
},
|
||||
{
|
||||
"id": "od-lost-futura",
|
||||
"brand": "Overdose",
|
||||
"flavor": "Lost Futura",
|
||||
"note": "Мускусная вишня",
|
||||
"strength": 3,
|
||||
"cooling": false
|
||||
},
|
||||
{
|
||||
"id": "uc-pear-tarragon",
|
||||
"brand": "Undercoal",
|
||||
"flavor": "Груша, лимон и тархун",
|
||||
"note": null,
|
||||
"strength": 2,
|
||||
"cooling": false
|
||||
},
|
||||
{
|
||||
"id": "uc-cactus-kiwi",
|
||||
"brand": "Undercoal",
|
||||
"flavor": "Кактус, киви и абсент",
|
||||
"note": null,
|
||||
"strength": 2,
|
||||
"cooling": false
|
||||
},
|
||||
{
|
||||
"id": "uc-cranberry",
|
||||
"brand": "Undercoal",
|
||||
"flavor": "Клюква",
|
||||
"note": null,
|
||||
"strength": 2,
|
||||
"cooling": false
|
||||
},
|
||||
{
|
||||
"id": "uc-coconut-shake",
|
||||
"brand": "Undercoal",
|
||||
"flavor": "Кокосовый шейк",
|
||||
"note": null,
|
||||
"strength": 2,
|
||||
"cooling": false
|
||||
},
|
||||
{
|
||||
"id": "uc-mango-rose",
|
||||
"brand": "Undercoal",
|
||||
"flavor": "Манго, маракуйя, личи и роза",
|
||||
"note": null,
|
||||
"strength": 2,
|
||||
"cooling": false
|
||||
},
|
||||
{
|
||||
"id": "uc-tropical-juice",
|
||||
"brand": "Undercoal",
|
||||
"flavor": "Тропический сок",
|
||||
"note": null,
|
||||
"strength": 2,
|
||||
"cooling": false
|
||||
},
|
||||
{
|
||||
"id": "uc-milky-oolong",
|
||||
"brand": "Undercoal",
|
||||
"flavor": "Чай «Молочный улун»",
|
||||
"note": null,
|
||||
"strength": 2,
|
||||
"cooling": false
|
||||
},
|
||||
{
|
||||
"id": "uc-caribbean-rum",
|
||||
"brand": "Undercoal",
|
||||
"flavor": "Caribbean Rum",
|
||||
"note": null,
|
||||
"strength": 2,
|
||||
"cooling": false
|
||||
},
|
||||
{
|
||||
"id": "uc-cinnamon-roll",
|
||||
"brand": "Undercoal",
|
||||
"flavor": "Cinnamon Roll",
|
||||
"note": null,
|
||||
"strength": 2,
|
||||
"cooling": false
|
||||
},
|
||||
{
|
||||
"id": "uc-cookie",
|
||||
"brand": "Undercoal",
|
||||
"flavor": "Cookie",
|
||||
"note": "Печенье",
|
||||
"strength": 2,
|
||||
"cooling": false
|
||||
},
|
||||
{
|
||||
"id": "uc-pinkman",
|
||||
"brand": "Undercoal",
|
||||
"flavor": "Pinkman",
|
||||
"note": "Клубника, малина, грейпфрут",
|
||||
"strength": 2,
|
||||
"cooling": false
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Разовая утилита: задать ВСЕМ действующим учёткам CRM один пароль.
|
||||
|
||||
Использование:
|
||||
python set_all_passwords.py neon2026
|
||||
|
||||
Обновляет dev-базу SQLite (crm/data/crm.db) и, если контейнер neon-db
|
||||
запущен, базу PostgreSQL в docker-стеке. Только для тестового стенда:
|
||||
на живом сервере пароли меняют в панели (свой — кнопка 🔑, чужой —
|
||||
владелец в разделе «Сотрудники»). После смены пароля в .env не забудь:
|
||||
он действует только при ПЕРВОМ создании учётки.
|
||||
"""
|
||||
import hashlib
|
||||
import secrets
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
# тот же формат, что storage.hash_password: pbkdf2_sha256$iter$salt$hex
|
||||
salt = secrets.token_hex(16)
|
||||
digest = hashlib.pbkdf2_hmac("sha256", password.encode(), bytes.fromhex(salt), 200_000)
|
||||
return f"pbkdf2_sha256$200000${salt}${digest.hex()}"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if len(sys.argv) != 2 or len(sys.argv[1]) < 8:
|
||||
print("Использование: python set_all_passwords.py <пароль-минимум-8-символов>")
|
||||
sys.exit(2)
|
||||
password = sys.argv[1]
|
||||
new_hash = hash_password(password)
|
||||
|
||||
# 1) dev-база SQLite рядом со скриптом
|
||||
sqlite_path = Path(__file__).resolve().parent / "data" / "crm.db"
|
||||
if sqlite_path.exists():
|
||||
with sqlite3.connect(sqlite_path) as conn:
|
||||
cur = conn.execute(
|
||||
"UPDATE crm_users SET password_hash = ? WHERE NOT deleted", (new_hash,)
|
||||
)
|
||||
print(f"SQLite ({sqlite_path.name}): пароль изменён у {cur.rowcount} учёток")
|
||||
else:
|
||||
print(f"SQLite не найдена ({sqlite_path}) — пропускаю")
|
||||
|
||||
# 2) PostgreSQL в docker-стеке (контейнер neon-db)
|
||||
sql = f"UPDATE crm_users SET password_hash = '{new_hash}' WHERE NOT deleted;"
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["docker", "exec", "neon-db", "psql", "-U", "neon", "-d", "neon_crm", "-c", sql],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
print("docker или контейнер neon-db недоступен — postgres-базу не трогал")
|
||||
return
|
||||
if proc.returncode == 0:
|
||||
print("PostgreSQL (neon-db): пароль изменён у всех действующих учёток")
|
||||
else:
|
||||
print(f"postgres UPDATE не прошёл:\n{proc.stderr.strip()}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,93 @@
|
||||
name: neon-bar
|
||||
|
||||
# Три контейнера: сайт (nginx + статика), CRM (Python API), PostgreSQL.
|
||||
# Сеть внутренняя: наружу смотрит только сайт; CRM отдаёт ему /api/.
|
||||
|
||||
services:
|
||||
db:
|
||||
# NF-20: дайджест-пин базового образа — пересборка в другой день даёт
|
||||
# те же слои (обновление пина — осознанное действие: новый digest + тест)
|
||||
image: postgres:17-alpine@sha256:b0f9560a2de083e2cc7382e75f808c7381a32852a7ec49117deedb300e552b24
|
||||
container_name: neon-db
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 10s
|
||||
mem_limit: 512m
|
||||
logging: &logging
|
||||
driver: json-file
|
||||
options: { max-size: "10m", max-file: "3" }
|
||||
environment:
|
||||
# Пароли БЕЗ дефолтов (F-1/F-2): compose упадёт с понятной ошибкой,
|
||||
# если .env не задан — дефолтные креды в контейнер не попадут никогда.
|
||||
POSTGRES_DB: ${PG_DB:?Задайте PG_DB в .env}
|
||||
POSTGRES_USER: ${PG_USER:?Задайте PG_USER в .env}
|
||||
POSTGRES_PASSWORD: ${PG_PASSWORD:?Задайте PG_PASSWORD в .env}
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${PG_USER} -d ${PG_DB}"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 20
|
||||
|
||||
crm:
|
||||
build: ./crm
|
||||
container_name: neon-crm
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 10s
|
||||
mem_limit: 256m
|
||||
logging: *logging
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
DATABASE_URL: postgresql://${PG_USER}:${PG_PASSWORD}@db:5432/${PG_DB}
|
||||
CRM_PORT: "47613"
|
||||
# расписание смен, границы суток в отчётах и «новинках» считаются
|
||||
# по барному времени (фиксированный UTC+5) — TZ важен для SQL-окон
|
||||
TZ: ${CRM_TZ:-Asia/Yekaterinburg}
|
||||
CRM_OWNER_LOGIN: ${CRM_OWNER_LOGIN:-owner}
|
||||
# Пароли только из .env (F-2): дефолтных neon2026 больше не существует.
|
||||
# Первый старт создаёт учётки с этими паролями; смена .env на живой базе
|
||||
# не действует — каждому сотруднику меняет пароль в панели (кнопка 🔑).
|
||||
CRM_OWNER_PASSWORD: ${CRM_OWNER_PASSWORD:?Задайте CRM_OWNER_PASSWORD в .env}
|
||||
CRM_OWNER_NAME: ${CRM_OWNER_NAME:-Нурлан}
|
||||
CRM_ADMIN_LOGIN: ${CRM_ADMIN_LOGIN:-admin}
|
||||
CRM_ADMIN_PASSWORD: ${CRM_ADMIN_PASSWORD:?Задайте CRM_ADMIN_PASSWORD в .env}
|
||||
CRM_ADMIN_NAME: ${CRM_ADMIN_NAME:-Ирина}
|
||||
CRM_WAITER_LOGIN: ${CRM_WAITER_LOGIN:-waiter.mark}
|
||||
CRM_WAITER_PASSWORD: ${CRM_WAITER_PASSWORD:?Задайте CRM_WAITER_PASSWORD в .env}
|
||||
CRM_WAITER_NAME: ${CRM_WAITER_NAME:-Марк}
|
||||
CRM_SEED_DEMO_ORDERS: ${CRM_SEED_DEMO_ORDERS:-0}
|
||||
# 1 — разрешить старт с дефолтными паролями (только dev-стенд!)
|
||||
CRM_ALLOW_DEFAULT_PASSWORDS: ${CRM_ALLOW_DEFAULT_PASSWORDS:-0}
|
||||
# Публикуем только на localhost: посетителям CRM не нужна,
|
||||
# а админ может зайти с сервера через SSH-туннель при желании.
|
||||
ports:
|
||||
- "127.0.0.1:47613:47613"
|
||||
volumes:
|
||||
# фото позиций, загружаемые владельцем, переживают пересборку
|
||||
- ./crm/media:/app/media
|
||||
|
||||
site:
|
||||
build: ./site
|
||||
container_name: neon-site
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 10s
|
||||
mem_limit: 64m
|
||||
logging: *logging
|
||||
depends_on:
|
||||
- crm
|
||||
ports:
|
||||
- "${SITE_BIND:-127.0.0.1}:${SITE_PORT:-8080}:80"
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
|
||||
# NF-10: фиксированная подсеть сети compose — site/nginx.conf доверяет
|
||||
# X-Forwarded-For только ей и 127.0.0.1 (set_real_ip_from). Меняете здесь —
|
||||
# меняйте и там.
|
||||
networks:
|
||||
default:
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.18.0.0/16
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
dist
|
||||
.dockerignore
|
||||
Dockerfile
|
||||
.git
|
||||
*.log
|
||||
@@ -0,0 +1,65 @@
|
||||
# Аудит и изменения проекта НЕОН
|
||||
|
||||
## Что проверено
|
||||
|
||||
- Исходная структура проекта, `package.json`, TypeScript-конфигурация и Vite-конфигурация.
|
||||
- Страница меню и все три набора данных: авторский чай, пивная карта, барное меню.
|
||||
- Все 57 файлов в `public/menu` и `public/menu/manifest.json`.
|
||||
- Поиск по проекту на предмет старого реестра изображений и неиспользуемых ссылок.
|
||||
- TypeScript-проверка и самотест движка миксов.
|
||||
|
||||
## Что изменено
|
||||
|
||||
### Фотографии меню
|
||||
|
||||
Создан единый `src/data/menuPhotos.ts` с привязкой по `id` позиции меню.
|
||||
|
||||
- 51 из 51 текущих позиций меню имеют привязанные фотографии.
|
||||
- Все 51 файла реально существуют в `public/menu`.
|
||||
- Для авторского чая использованы соответствующие изображения из того же набора; названия файлов исторически отличаются от текущих названий позиций.
|
||||
- 6 файлов из `public/menu` намеренно не привязаны: это изображения, которым в текущем каталоге нет соответствующей позиции (`photo`, два кальяна и три прочих позиции/сборы).
|
||||
|
||||
Старый `src/data/menuImages.ts` удалён: он ссылался на несуществующие пути `images/tea` и `images/beer` и содержал неиспользуемые fallback-цепочки.
|
||||
|
||||
### Карточки меню
|
||||
|
||||
- Фото добавлены непосредственно в существующие карточки без смены базовой стилистики.
|
||||
- Добавлена lazy-loading загрузка изображений.
|
||||
- При ошибке загрузки карточка автоматически возвращается к исходной иконке.
|
||||
- Кнопка `В корзину` интегрирована в существующую карточку.
|
||||
|
||||
### Корзина и управление количеством
|
||||
|
||||
`src/components/MenuCart.tsx` и `src/pages/MenuPage.tsx` получили косметический рефакторинг UX.
|
||||
|
||||
- На карточке позиции вместо однонаправленной кнопки теперь используется `В корзину`, а после добавления — полноценный степпер `− / количество / +`.
|
||||
- Уменьшение количества из меню сразу действует на выбранного гостя и удаляет позицию при снижении количества до нуля.
|
||||
- Выбор активного гостя в меню переведён с выпадающего списка на компактные переключатели.
|
||||
- В корзине количество гостей задаётся степпером 1–12, без громоздкого select.
|
||||
- Активный гость в корзине выбирается отдельными кнопками; новые позиции из меню попадают выбранному гостю.
|
||||
- Позиции по-прежнему группируются по гостям, но карточки стали компактнее и понятнее; перераспределение позиции между гостями сохранено.
|
||||
- Итоговая сумма, копирование заказа, очистка, `Esc`, блокировка прокрутки и хранение в `localStorage` сохранены.
|
||||
- Заказ никуда автоматически не отправляется — это рабочий список для оформления у официанта.
|
||||
|
||||
### Очистка проекта
|
||||
|
||||
- Из моделей `teaMenu`, `beerMenu`, `barMenu` убраны старые поля/импорты изображений, которые больше не нужны.
|
||||
- Удалена дублирующаяся папка `public/images/menu`: приложение её не использовало, а сами актуальные фото находятся в `public/menu`.
|
||||
- Контейнерный Caddy удалён: Caddy работает на уровне хоста.
|
||||
- Docker-инфраструктура приложения возвращена отдельным `docker-compose.yml` + `Dockerfile`; HTTPS и доменная маршрутизация остаются на хостовом Caddy.
|
||||
- В контейнере используется nginx только как HTTP-сервер для собранного Vite `dist`.
|
||||
- `src/index.css`, `package.json` и `package-lock.json` не изменялись.
|
||||
|
||||
## Проверка
|
||||
|
||||
- Статически проверены изменённые файлы и связи между карточками меню, активным гостем и корзиной.
|
||||
- `docker-compose.yml` и `Dockerfile` физически присутствуют в финальном архиве.
|
||||
- Полный `vite build` в этой среде не удалось выполнить: пакетные зависимости из `package-lock.json` не удалось восстановить через npm из-за недоступного registry/cache-пакета, поэтому результат сборки здесь не выдаётся за проверенный.
|
||||
- Финальный архив не содержит `node_modules` и `dist`; зависимости и production-сборка должны создаваться в целевой среде.
|
||||
|
||||
## Docker runtime
|
||||
- `docker-compose.yml` restored for the frontend container; Caddy is not containerized.
|
||||
- `Dockerfile` restored as a multi-stage build: Node builds the Vite app, nginx serves the resulting static files.
|
||||
- The app is published only on `127.0.0.1:8080`, so host-level Caddy can reverse-proxy to `http://127.0.0.1:8080` without exposing the app directly to the network.
|
||||
- `nginx.conf` contains SPA fallback and a `/healthz` endpoint.
|
||||
- `.dockerignore` excludes build/dependency artifacts from the Docker build context.
|
||||
@@ -0,0 +1,23 @@
|
||||
# Build the Vite app in Node, then serve the static bundle with a minimal web server.
|
||||
# NF-20: дайджест-пины базовых образов (воспроизводимая сборка).
|
||||
FROM node:22-alpine@sha256:0a7108bf6c7bf5de370ffb1a3ed6be93d405b43ff159f681a8d18c0e2bc2e402 AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:1.29-alpine@sha256:5616878291a2eed594aee8db4dade5878cf7edcb475e59193904b198d9b830de AS runtime
|
||||
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
|
||||
CMD wget --spider --quiet http://127.0.0.1/ || exit 1
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -0,0 +1,147 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
name="description"
|
||||
content="НЕОН — кальянный лаунж в Нягани. Авторские миксы, PlayStation 5, авторский чай и кофе. Атмосфера, которую видно в темноте."
|
||||
/>
|
||||
<meta name="theme-color" content="#0a0a0f" />
|
||||
<title>НЕОН — кальянный лаунж · Атмосфера, которую видно в темноте</title>
|
||||
<link rel="canonical" href="https://neon-bar86.ru/" />
|
||||
|
||||
<!-- Open Graph: превью в соцсетях и мессенджерах -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content="НЕОН — кальянный лаунж" />
|
||||
<meta property="og:title" content="НЕОН — кальянный лаунж в Нягани" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Авторские миксы, PlayStation 5, авторский чай и кофе. Проспект Нефтяников, 15. Бронь: +7 (912) 901-72-17."
|
||||
/>
|
||||
<meta property="og:url" content="https://neon-bar86.ru/" />
|
||||
<meta property="og:image" content="https://neon-bar86.ru/images/og-cover.jpg" />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
<meta property="og:locale" content="ru_RU" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
|
||||
<!-- JSON-LD: карточка организации для Яндекс/Google/Bing.
|
||||
BarOrPub — подтип LocalBusiness (DrinkingEstablishment): для лаунжа
|
||||
точнее ресторанных типов, все локальные поля наследуются. -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BarOrPub",
|
||||
"name": "НЕОН — кальянный лаунж",
|
||||
"url": "https://neon-bar86.ru/",
|
||||
"telephone": "+7 912 901-72-17",
|
||||
"image": "https://neon-bar86.ru/images/og-cover.jpg",
|
||||
"description": "Кальянный лаунж-бар НЕОН в Нягани: авторские кальяны и миксы, PlayStation 5, настольные игры, авторский чай и кофе. Бронь столов и аренда VIP-зала.",
|
||||
"priceRange": "₽₽",
|
||||
"currenciesAccepted": "RUB",
|
||||
"address": {
|
||||
"@type": "PostalAddress",
|
||||
"streetAddress": "проспект Нефтяников, 15",
|
||||
"addressLocality": "Нягань",
|
||||
"addressRegion": "Ханты-Мансийский автономный округ — Югра",
|
||||
"addressCountry": "RU"
|
||||
},
|
||||
"geo": {
|
||||
"@type": "GeoCoordinates",
|
||||
"latitude": "[ВПИСАТЬ: широта, напр. 60.9449]",
|
||||
"longitude": "[ВПИСАТЬ: долгота, напр. 65.6510]"
|
||||
},
|
||||
"openingHoursSpecification": [
|
||||
{
|
||||
"@type": "OpeningHoursSpecification",
|
||||
"dayOfWeek": ["Tuesday", "Wednesday", "Thursday", "Sunday"],
|
||||
"opens": "19:00",
|
||||
"closes": "01:00"
|
||||
},
|
||||
{
|
||||
"@type": "OpeningHoursSpecification",
|
||||
"dayOfWeek": ["Friday", "Saturday"],
|
||||
"opens": "19:00",
|
||||
"closes": "03:00"
|
||||
}
|
||||
],
|
||||
"sameAs": ["https://vk.ru/shelby_nyagan"]
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Верификация вебмастер-панелей: раскомментируй строку и впиши код,
|
||||
полученный в панели (какой код откуда — см. SEO-REGISTRATIONS.md).
|
||||
До активации панель верификацию не пройдёт — это ожидаемо. -->
|
||||
<!-- <meta name="yandex-verification" content="[ВПИСАТЬ_КОД_ЯНДЕКС]" /> -->
|
||||
<!-- <meta name="google-site-verification" content="[ВПИСАТЬ_КОД_GOOGLE]" /> -->
|
||||
<!-- <meta name="msvalidate.01" content="[ВПИСАТЬ_КОД_BING]" /> -->
|
||||
<!-- <meta name="mailru-verification" content="[ВПИСАТЬ_КОД_MAILRU]" /> -->
|
||||
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Unbounded:wght@400;500;600;700;800;900&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<!-- Favicon реальными файлами, а не data-URI: Яндекс и Google забирают
|
||||
иконку по адресу /favicon.ico (data-URI поисковики не читают —
|
||||
в выдаче вместо иконки серый глобус). Файлы генерит
|
||||
scripts/make-favicon.py, лежат в public/ и попадают в корень сборки. -->
|
||||
<link rel="icon" href="/favicon.ico" sizes="32x32" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
</head>
|
||||
<body>
|
||||
<!-- Статический H1 + SEO-абзац: виден поисковику и гостю без JS.
|
||||
React (createRoot) полностью заменяет содержимое #root при
|
||||
монтировании, поэтому на живой странице блока не будет.
|
||||
Маркеры SEO:FALLBACK_* нужны scripts/prerender.tsx: между ними
|
||||
в собранный index.html вставляется пререндер-снапшот лендинга. -->
|
||||
<div id="root"><!--SEO:FALLBACK_START-->
|
||||
<section
|
||||
style="max-width: 52rem; margin: 0 auto; padding: 96px 24px; font-family: system-ui, sans-serif; color: #f2f3f8; background: #0a0a0f; min-height: 100vh"
|
||||
>
|
||||
<h1>Кальянный лаунж НЕОН в Нягани</h1>
|
||||
<p>
|
||||
Кальянный лаунж-бар НЕОН (Нягань, проспект Нефтяников, 15) — авторские
|
||||
миксы и кальяны, PlayStation 5, настольные игры, авторский чай и кофе.
|
||||
Бронь столов и аренда VIP-зала: +7 (912) 901-72-17. Часы работы:
|
||||
вт–чт и вс 19:00–01:00, пт–сб 19:00–03:00, понедельник — выходной.
|
||||
</p>
|
||||
<noscript>
|
||||
<p>Для интерактивного меню и заказа включите JavaScript. Телефон: +7 (912) 901-72-17.</p>
|
||||
</noscript>
|
||||
</section>
|
||||
<!--SEO:FALLBACK_END--></div>
|
||||
|
||||
<!-- Счётчики аналитики: раскомментируй после получения идентификатора.
|
||||
Яндекс.Метрика: metrika.yandex.ru → создать счётчик → заменить
|
||||
[ВПИСАТЬ_ИД_МЕТРИКИ] на номер счётчика (число). Не добавляй два
|
||||
одинаковых счётчика: один сайт = один код на страницу.
|
||||
<script type="text/javascript">
|
||||
(function (m, e, t, r, i, k, a) {
|
||||
m[i] = m[i] || function () { (m[i].a = m[i].a || []).push(arguments); };
|
||||
m[i].l = 1 * new Date();
|
||||
for (var j = 0; j < document.scripts.length; j++) { if (document.scripts[j].src === r) { return; } }
|
||||
k = e.createElement(t), a = e.getElementsByTagName(t)[0], k.async = 1, k.src = r, a.parentNode.insertBefore(k, a);
|
||||
})(window, document, "script", "https://mc.yandex.ru/metrika/tag.js", "ym");
|
||||
ym([ВПИСАТЬ_ИД_МЕТРИКИ], "init", { clickmap: true, trackLinks: true, accurateTrackBounce: true, webvisor: true });
|
||||
</script>
|
||||
<noscript><div><img src="https://mc.yandex.ru/watch/[ВПИСАТЬ_ИД_МЕТРИКИ]" style="position:absolute; left:-9999px;" alt="" /></div></noscript>
|
||||
-->
|
||||
|
||||
<!-- Google Analytics 4: analytics.google.com → Администратор → Потоки
|
||||
данных → Идентификатор G-XXXXXXX. Вставить вместо [ВПИСАТЬ_ИД_GA4].
|
||||
<script async src="https://www.googletagmanager.com/gtag/js?id=[ВПИСАТЬ_ИД_GA4]"></script>
|
||||
<script>
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag() { dataLayer.push(arguments); }
|
||||
gtag("js", new Date());
|
||||
gtag("config", "[ВПИСАТЬ_ИД_GA4]");
|
||||
</script>
|
||||
-->
|
||||
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,140 @@
|
||||
# Брауз-тормоз на флуд заказами: с одного IP не чаще 10 заказов в минуту
|
||||
# (burst 10 сглаживает «двойной клик»). Работает ДО запроса в CRM.
|
||||
limit_req_zone $binary_remote_addr zone=orders_zone:1m rate=10r/m;
|
||||
|
||||
# Общий тормоз на прочее /api/ (NF-04): флуд catalog/stoplist выедал пул БД
|
||||
# CRM. Легитимный сайт опрашивает эти эндпоинты раз в минуту-десять,
|
||||
# 120r/m с burst 60 — с большим запасом. Панель персонала ходит на CRM
|
||||
# напрямую (127.0.0.1:47613) и этот лимит не замечает.
|
||||
limit_req_zone $binary_remote_addr zone=api_zone:1m rate=120r/m;
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Реальный IP гостя для лимитеров CRM. Доверие к X-Forwarded-For —
|
||||
# только 127.0.0.1 и docker-подсеть compose (NF-10): раньше верили всем
|
||||
# RFC1918-пирам, и LAN-клиент при экспоузе сайта мог вращать per-IP
|
||||
# лимитеры подделкой заголовка. Подсеть зафиксирована в
|
||||
# docker-compose.yml (networks.default.ipam) — при смене её там
|
||||
# обновите и здесь.
|
||||
set_real_ip_from 127.0.0.1;
|
||||
set_real_ip_from 172.18.0.0/16;
|
||||
real_ip_header X-Forwarded-For;
|
||||
real_ip_recursive on;
|
||||
|
||||
# single-file сборка (~750 KB html) без gzip = лишний трафик гостям
|
||||
gzip on;
|
||||
gzip_comp_level 5;
|
||||
gzip_min_length 1024;
|
||||
gzip_vary on;
|
||||
gzip_types text/css application/javascript application/json image/svg+xml application/xml text/plain;
|
||||
|
||||
server_tokens off;
|
||||
|
||||
# Базовые заголовки безопасности. Внимание nginx: location со своим
|
||||
# add_header (например /media/) НЕ наследует их — там заголовки повторены.
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
add_header X-Frame-Options DENY always;
|
||||
add_header Referrer-Policy strict-origin-when-cross-origin always;
|
||||
# CSP: бандл инлайнен в single-file index.html (поэтому 'unsafe-inline' у
|
||||
# script-src), плюс Метрика/GA и Google Fonts. object/base/frame-ancestors
|
||||
# наглухо закрыты — внедрённый в страницу тег не подгрузит и не отправит.
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://mc.yandex.ru https://yastatic.net https://www.googletagmanager.com https://www.google-analytics.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' data: https://fonts.gstatic.com; img-src 'self' data: https://mc.yandex.ru https://www.google-analytics.com https://www.googletagmanager.com; connect-src 'self' https://mc.yandex.ru https://www.google-analytics.com https://analytics.google.com; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'" always;
|
||||
|
||||
# Заказы и стоп-лист живут в CRM-контейнере (см. docker-compose.yml).
|
||||
# Точный location = /api/orders ниже переопределяет этот для POST заказов
|
||||
# со своей зоной (приоритет nginx: точный бьёт префиксный).
|
||||
location /api/ {
|
||||
limit_req zone=api_zone burst=60 nodelay;
|
||||
limit_req_status 429;
|
||||
proxy_pass http://crm:47613;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_connect_timeout 5s;
|
||||
proxy_read_timeout 15s;
|
||||
}
|
||||
|
||||
# Точка создания заказа — отдельный тормоз поверх лимитеров CRM
|
||||
# (nginx отвечает 429 раньше, чем запрос дойдёт до Python).
|
||||
location = /api/orders {
|
||||
limit_req zone=orders_zone burst=10 nodelay;
|
||||
limit_req_status 429;
|
||||
proxy_pass http://crm:47613;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_connect_timeout 5s;
|
||||
proxy_read_timeout 15s;
|
||||
}
|
||||
|
||||
# Входная точка SPA: ТОЛЬКО no-cache — в single-file сборке внутри
|
||||
# index.html инлайнен весь бандл, закэшировать его на 7 дней = гости
|
||||
# не увидят обновлений сайта до конца срока кэша. (Точное совпадение
|
||||
# бьёт регэксп-локейшен расширений ниже; прочие .html честно 404.)
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-cache";
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
add_header X-Frame-Options DENY always;
|
||||
add_header Referrer-Policy strict-origin-when-cross-origin always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://mc.yandex.ru https://yastatic.net https://www.googletagmanager.com https://www.google-analytics.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' data: https://fonts.gstatic.com; img-src 'self' data: https://mc.yandex.ru https://www.google-analytics.com https://www.googletagmanager.com; connect-src 'self' https://mc.yandex.ru https://www.google-analytics.com https://analytics.google.com; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'" always;
|
||||
}
|
||||
|
||||
# SEO-файлы: точный Content-Type и кэш не больше часа
|
||||
# (types {} сбрасывает mime.types — иначе .xml ушёл бы как text/xml)
|
||||
location = /robots.txt {
|
||||
types { }
|
||||
default_type text/plain;
|
||||
expires 1h;
|
||||
}
|
||||
location = /sitemap.xml {
|
||||
types { }
|
||||
default_type application/xml;
|
||||
expires 1h;
|
||||
}
|
||||
|
||||
# Загруженные владельцем фото позиций — из CRM (/media/<id>.<ext>).
|
||||
# ^~ обязателен: без него регэксп-локейшен расширений (*.jpg, *.avif…)
|
||||
# перехватывал бы запросы, и все фото отдавали 404 вместо прокси в CRM.
|
||||
location ^~ /media/ {
|
||||
proxy_pass http://crm:47613;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
expires 7d;
|
||||
add_header Cache-Control "public, max-age=604800";
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
add_header X-Frame-Options DENY always;
|
||||
add_header Referrer-Policy strict-origin-when-cross-origin always;
|
||||
add_header Content-Security-Policy "default-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'" always;
|
||||
}
|
||||
|
||||
# Vite SPA fallback: client-side routes should resolve to index.html.
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location = /healthz {
|
||||
access_log off;
|
||||
add_header Content-Type text/plain;
|
||||
return 200 'ok\n';
|
||||
}
|
||||
|
||||
# Файлы с расширениями отдаются только с диска: несуществующие — честный
|
||||
# 404, а не index.html (soft-404 вреден для поисковиков). SPA-fallback
|
||||
# выше работает только для путей без расширения.
|
||||
location ~* \.(?:css|js|mjs|cjs|json|map|webmanifest|txt|xml|svg|ico|png|jpg|jpeg|gif|webp|avif|jfif|woff2?|ttf|otf|eot|mp4|webm|mp3|wav|ogg|pdf|html)$ {
|
||||
expires 7d;
|
||||
add_header Cache-Control "public, max-age=604800";
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
add_header X-Frame-Options DENY always;
|
||||
add_header Referrer-Policy strict-origin-when-cross-origin always;
|
||||
add_header Content-Security-Policy "default-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'" always;
|
||||
try_files $uri =404;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "react-vite-tailwind",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build && tsx scripts/prerender.tsx",
|
||||
"prerender": "tsx scripts/prerender.tsx",
|
||||
"preview": "vite preview",
|
||||
"export:crm": "tsx scripts/export-crm-seed.ts",
|
||||
"test:engine": "tsx scripts/engine-selftest.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"clsx": "2.1.1",
|
||||
"embla-carousel-autoplay": "^8.6.0",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"lucide-react": "^1.34.0",
|
||||
"react": "19.2.6",
|
||||
"react-dom": "19.2.6",
|
||||
"tailwind-merge": "3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "4.1.17",
|
||||
"@types/node": "22.19.17",
|
||||
"@types/react": "19.2.7",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@vitejs/plugin-react": "5.1.1",
|
||||
"tailwindcss": "4.1.17",
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "5.9.3",
|
||||
"vite": "7.3.7",
|
||||
"vite-plugin-singlefile": "2.3.3"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 649 B |
|
After Width: | Height: | Size: 427 B |
|
After Width: | Height: | Size: 814 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'><rect width='64' height='64' rx='14' fill='#0A0A0F'/><text x='32' y='45' font-family='Arial Black, Arial' font-size='34' font-weight='900' text-anchor='middle' fill='#00F0FF'>Н</text></svg>
|
||||
|
After Width: | Height: | Size: 250 B |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 198 KiB |
|
After Width: | Height: | Size: 127 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 204 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 247 KiB |
|
After Width: | Height: | Size: 159 KiB |
|
After Width: | Height: | Size: 242 KiB |
|
After Width: | Height: | Size: 218 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 157 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 192 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 192 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 192 KiB |
|
After Width: | Height: | Size: 192 KiB |
|
After Width: | Height: | Size: 192 KiB |
|
After Width: | Height: | Size: 192 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 192 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 192 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 192 KiB |
|
After Width: | Height: | Size: 192 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 192 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 192 KiB |
|
After Width: | Height: | Size: 192 KiB |
@@ -0,0 +1,287 @@
|
||||
[
|
||||
{
|
||||
"file": "menu/photo.avif",
|
||||
"title": "photo",
|
||||
"source": "https://media.dodostatic.com/image/11eeb86b25350d0eadad0aec31155793.avif"
|
||||
},
|
||||
{
|
||||
"file": "menu/kalyan-lyuboy-kreposti.avif",
|
||||
"title": "Кальян любой крепости",
|
||||
"source": "https://media.dodostatic.com/image/019c413a46397891b34db8e49083915b.avif"
|
||||
},
|
||||
{
|
||||
"file": "menu/retro-kalyan-khalil-mamoon.avif",
|
||||
"title": "Ретро кальян: khalil mamoon",
|
||||
"source": "https://media.dodostatic.com/image/019c4813920f7666900e24d50a0a9ea2.avif"
|
||||
},
|
||||
{
|
||||
"file": "menu/pryanyy-apelsin.avif",
|
||||
"title": "Пряный апельсин",
|
||||
"source": "https://media.dodostatic.com/image/019c413cfc6071fea8a2c4ec8b641fb9.avif"
|
||||
},
|
||||
{
|
||||
"file": "menu/myatnaya-ezhevika.avif",
|
||||
"title": "Мятная ежевика",
|
||||
"source": "https://media.dodostatic.com/image/019c2b9f74757209a2aa263b65df27e5.avif"
|
||||
},
|
||||
{
|
||||
"file": "menu/malinovyy-s-imbirem.avif",
|
||||
"title": "Малиновый с имбирем",
|
||||
"source": "https://media.dodostatic.com/image/019c2b9fdc00768ba05ff316f8d622cd.avif"
|
||||
},
|
||||
{
|
||||
"file": "menu/myatnaya-malina.avif",
|
||||
"title": "Мятная малина",
|
||||
"source": "https://media.dodostatic.com/image/019c2f32f55071f0ba37559a8e3b0206.avif"
|
||||
},
|
||||
{
|
||||
"file": "menu/osennyaya-oblepikha.avif",
|
||||
"title": "Осенняя облепиха",
|
||||
"source": "https://media.dodostatic.com/image/019c281096fe7710809269443a7225fd.avif"
|
||||
},
|
||||
{
|
||||
"file": "menu/yagodnyy-vzryv.avif",
|
||||
"title": "Ягодный взрыв",
|
||||
"source": "https://media.dodostatic.com/image/019c20ad560979c1b2074a1e1acf60a3.avif"
|
||||
},
|
||||
{
|
||||
"file": "menu/vishnevyy-sad.avif",
|
||||
"title": "Вишневый сад",
|
||||
"source": "https://media.dodostatic.com/image/019c2f31bcf875acbceb7a6f61ae0194.avif"
|
||||
},
|
||||
{
|
||||
"file": "menu/mangovyy-ray.avif",
|
||||
"title": "Манговый рай",
|
||||
"source": "https://media.dodostatic.com/image/019c41528cfc78dd81f72f40ce843aae.avif"
|
||||
},
|
||||
{
|
||||
"file": "menu/staryy-melnik-svetloe.avif",
|
||||
"title": "Старый мельник светлое",
|
||||
"source": "https://media.dodostatic.com/image/019c20aedd9d7883abc396bff921d760.avif"
|
||||
},
|
||||
{
|
||||
"file": "menu/staryy-melnik-temnoe.avif",
|
||||
"title": "Старый мельник темное",
|
||||
"source": "https://media.dodostatic.com/image/019c20af48177999b63855e09d42230e.avif"
|
||||
},
|
||||
{
|
||||
"file": "menu/kozel-svetlyy.avif",
|
||||
"title": "Kozel светлый",
|
||||
"source": "https://media.dodostatic.com/image/019c20afbec970edb4dab7fcbe931230.avif"
|
||||
},
|
||||
{
|
||||
"file": "menu/kozel-temnyy.avif",
|
||||
"title": "Kozel темный",
|
||||
"source": "https://media.dodostatic.com/image/019c20b0aed478129986b27612ce598b.avif"
|
||||
},
|
||||
{
|
||||
"file": "menu/bud.avif",
|
||||
"title": "Bud",
|
||||
"source": "https://media.dodostatic.com/image/019c20b1083d72cc939ad919afe83989.avif"
|
||||
},
|
||||
{
|
||||
"file": "menu/stella.avif",
|
||||
"title": "Stella",
|
||||
"source": "https://media.dodostatic.com/image/019c20ae14e3769b9cef85f063c0ea80.avif"
|
||||
},
|
||||
{
|
||||
"file": "menu/krone-blanche.avif",
|
||||
"title": "Krone Blanche",
|
||||
"source": "https://media.dodostatic.com/image/019c20b1705470b699f9622d41cbbe21.avif"
|
||||
},
|
||||
{
|
||||
"file": "menu/erzmann.avif",
|
||||
"title": "Erzmann",
|
||||
"source": "https://media.dodostatic.com/image/019c5205b1fb7623b40922be846d1be5.avif"
|
||||
},
|
||||
{
|
||||
"file": "menu/el-capulco.avif",
|
||||
"title": "El Capulco",
|
||||
"source": "https://media.dodostatic.com/image/019c21d0b739773e812f5335fd98e5c5.avif"
|
||||
},
|
||||
{
|
||||
"file": "menu/spatten.avif",
|
||||
"title": "Spatten",
|
||||
"source": "https://media.dodostatic.com/image/019c20b2fa6a72fca73c653d52e9d1c6.avif"
|
||||
},
|
||||
{
|
||||
"file": "menu/hoegaarden.avif",
|
||||
"title": "Hoegaarden",
|
||||
"source": "https://media.dodostatic.com/image/019c20b37d0278ef88419f1879912261.avif"
|
||||
},
|
||||
{
|
||||
"file": "menu/corona-extra.avif",
|
||||
"title": "Corona Extra",
|
||||
"source": "https://media.dodostatic.com/image/019c20b3d8677935bd60a789260a7144.avif"
|
||||
},
|
||||
{
|
||||
"file": "menu/redd-s.avif",
|
||||
"title": "Redd's",
|
||||
"source": "https://media.dodostatic.com/image/019c20b66c7a7948a337569742166027.avif"
|
||||
},
|
||||
{
|
||||
"file": "menu/pablo-s-s-sokom-layma.avif",
|
||||
"title": "Pablo's с соком лайма",
|
||||
"source": "https://media.dodostatic.com/image/019c388bd52575f5841f61a9d7b324a7.avif"
|
||||
},
|
||||
{
|
||||
"file": "menu/lukovye-koltsa.avif",
|
||||
"title": "Луковые кольца",
|
||||
"source": "https://media.dodostatic.com/image/019c5cf1ac96718980e6e71591fc11d7.avif"
|
||||
},
|
||||
{
|
||||
"file": "menu/kartofel-fri.avif",
|
||||
"title": "Картофель Фри",
|
||||
"source": "https://media.dodostatic.com/image/019c41687d447256a1d04a957f905d4d.avif"
|
||||
},
|
||||
{
|
||||
"file": "menu/krylyshki-baffalo.avif",
|
||||
"title": "Крылышки Баффало",
|
||||
"source": "https://media.dodostatic.com/image/019c48143b66725cb9a3680801523673.avif"
|
||||
},
|
||||
{
|
||||
"file": "menu/syrnye-palochki.avif",
|
||||
"title": "Сырные палочки",
|
||||
"source": "https://media.dodostatic.com/image/019c48148e8073bab97dc8caa6100c9b.avif"
|
||||
},
|
||||
{
|
||||
"file": "menu/stripsy.avif",
|
||||
"title": "Стрипсы",
|
||||
"source": "https://media.dodostatic.com/image/019c4814f0ed7520802b4f729d2236ee.avif"
|
||||
},
|
||||
{
|
||||
"file": "menu/nagetsy.avif",
|
||||
"title": "Нагетсы",
|
||||
"source": "https://media.dodostatic.com/image/019c4815a75175099263d2b610c0ba71.avif"
|
||||
},
|
||||
{
|
||||
"file": "menu/chernyy-chay.avif",
|
||||
"title": "Черный чай",
|
||||
"source": "https://media.dodostatic.com/image/019c20b4459874dab1f68c8e2dab7bab.avif"
|
||||
},
|
||||
{
|
||||
"file": "menu/chernyy-chay-s-chabretsom.avif",
|
||||
"title": "Черный чай с чабрецом",
|
||||
"source": "https://media.dodostatic.com/image/019c4816ce22743ea114cce7cba1c18d.avif"
|
||||
},
|
||||
{
|
||||
"file": "menu/molochnyy-ulun.avif",
|
||||
"title": "Молочный Улун",
|
||||
"source": "https://media.dodostatic.com/image/019c20b4bcbb729d865667412da59cea.avif"
|
||||
},
|
||||
{
|
||||
"file": "menu/amerikano.avif",
|
||||
"title": "Американо",
|
||||
"source": "https://media.dodostatic.com/image/019c9f5ef3d2785a9728fdf2e30663a3.avif"
|
||||
},
|
||||
{
|
||||
"file": "menu/kapuchino.avif",
|
||||
"title": "Капучино",
|
||||
"source": "https://media.dodostatic.com/image/019c9f60418470678bccabd479e205fd.avif"
|
||||
},
|
||||
{
|
||||
"file": "menu/chizkeyk-nyu-york.avif",
|
||||
"title": "Чизкейк Нью-Йорк",
|
||||
"source": "https://media.dodostatic.com/image/019c28113de37748aaf16491fc5cb061.avif"
|
||||
},
|
||||
{
|
||||
"file": "menu/chizkeyk-nyu-york-klubnichnyy.avif",
|
||||
"title": "Чизкейк Нью-Йорк клубничный",
|
||||
"source": "https://media.dodostatic.com/image/019c335d507571838b4855a5f5e6c79a.avif"
|
||||
},
|
||||
{
|
||||
"file": "menu/chizkeyk-nyu-york-malina.avif",
|
||||
"title": "Чизкейк Нью-Йорк Малина",
|
||||
"source": "https://media.dodostatic.com/image/019c2811f09e70d6b6bff47e85e69808.avif"
|
||||
},
|
||||
{
|
||||
"file": "menu/tort-krasnyy-barkhat.avif",
|
||||
"title": "Торт Красный бархат",
|
||||
"source": "https://media.dodostatic.com/image/019c20b57c56743896b879e290de3a9a.avif"
|
||||
},
|
||||
{
|
||||
"file": "menu/tort-medovik.avif",
|
||||
"title": "Торт Медовик",
|
||||
"source": "https://media.dodostatic.com/image/019c20b5ef56790ebeb1f747e79d02f5.avif"
|
||||
},
|
||||
{
|
||||
"file": "menu/alpen-gold.avif",
|
||||
"title": "Alpen gold",
|
||||
"source": "https://media.dodostatic.com/image/019c5cf23ec977548b241b9ac6c14df5.avif"
|
||||
},
|
||||
{
|
||||
"file": "menu/milka.avif",
|
||||
"title": "Milka",
|
||||
"source": "https://media.dodostatic.com/image/019c5cf2aa3f71eeb997c74b4cea9f9d.avif"
|
||||
},
|
||||
{
|
||||
"file": "menu/chipsy-leys.avif",
|
||||
"title": "Чипсы Leys",
|
||||
"source": "https://media.dodostatic.com/image/019d61b7f4247566a6e64be7736f3389.avif"
|
||||
},
|
||||
{
|
||||
"file": "menu/3-korochki.avif",
|
||||
"title": "3 Корочки",
|
||||
"source": "https://media.dodostatic.com/image/019c52063b3177debd91e3afab378ba5.avif"
|
||||
},
|
||||
{
|
||||
"file": "menu/khrustim-team.avif",
|
||||
"title": "Хрустим team",
|
||||
"source": "https://media.dodostatic.com/image/019c9f943dbf72be84d4e741078dd41c.avif"
|
||||
},
|
||||
{
|
||||
"file": "menu/natakhtari-grusha.avif",
|
||||
"title": "НАТАХТАРИ ГРУША",
|
||||
"source": "https://media.dodostatic.com/image/019c38923a277682909a25bf8749f841.avif"
|
||||
},
|
||||
{
|
||||
"file": "menu/natakhtari-feykhoa.avif",
|
||||
"title": "НАТАХТАРИ ФЕЙХОА",
|
||||
"source": "https://media.dodostatic.com/image/019c41407e7b7804bff17d671c715123.avif"
|
||||
},
|
||||
{
|
||||
"file": "menu/natakhtari-saperavi.avif",
|
||||
"title": "НАТАХТАРИ САПЕРАВИ",
|
||||
"source": "https://media.dodostatic.com/image/019c414a38e07168bd4b646b9fa2ed4e.avif"
|
||||
},
|
||||
{
|
||||
"file": "menu/klubnika-kivi.avif",
|
||||
"title": "Клубника Киви",
|
||||
"source": "https://media.dodostatic.com/image/019c4817cf2e73f2bfacb43c0706eabf.avif"
|
||||
},
|
||||
{
|
||||
"file": "menu/feykhoa.avif",
|
||||
"title": "Фейхоа",
|
||||
"source": "https://media.dodostatic.com/image/019c4818bd6e76779f4a8df44cb1d8b2.avif"
|
||||
},
|
||||
{
|
||||
"file": "menu/burn-originalnyy.avif",
|
||||
"title": "BURN Оригинальный",
|
||||
"source": "https://media.dodostatic.com/image/019c481b4eb27671ac6f73da19a0c793.avif"
|
||||
},
|
||||
{
|
||||
"file": "menu/burn-yabloko-kivi.avif",
|
||||
"title": "BURN Яблоко Киви",
|
||||
"source": "https://media.dodostatic.com/image/019c520715f07505919d92eab9d0c67d.avif"
|
||||
},
|
||||
{
|
||||
"file": "menu/burn-so-vkusom-mango.avif",
|
||||
"title": "BURN Со вкусом МАНГО",
|
||||
"source": "https://media.dodostatic.com/image/019c5208afea73c3ada99f755b97c9b2.avif"
|
||||
},
|
||||
{
|
||||
"file": "menu/adrenaline-rush-absolyutnaya-energiya.avif",
|
||||
"title": "Adrenaline RUSH Абсолютная ЭНЕРГИЯ",
|
||||
"source": "https://media.dodostatic.com/image/019c520a306d769e9e0592c5c087567f.avif"
|
||||
},
|
||||
{
|
||||
"file": "menu/probkovyy-sbor.avif",
|
||||
"title": "Пробковый сбор",
|
||||
"source": "https://media.dodostatic.com/image/019c9f56a5df7489ac257e70bd892a4b.avif"
|
||||
},
|
||||
{
|
||||
"file": "menu/porcha-imuschestva.avif",
|
||||
"title": "Порча имущества",
|
||||
"source": "https://media.dodostatic.com/image/019c9f570847798ebf8992ddd71b59c7.avif"
|
||||
}
|
||||
]
|
||||
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 192 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 192 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 463 KiB |