initial site
No files matched your search
@@ -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()
|
||||