Initial commit: ASPEKTER — визуальный конструктор email-рассылок

- z51-pug-builder: Svelte 5 SPA, визуальный редактор Pug-писем
- email-gen: Node.js рендерер Pug→HTML через email-templates + Juice
- email-gen-api: HTTP сервер рендеринга (порт 8787)
- coin-scout: сервис подбора монет из фидов
- Docker Compose для dev/prod
- Nginx конфиг с SSL для app.aspekter.ru
This commit is contained in:
2026-04-13 11:36:39 +05:00
commit 718821fdd6
282 changed files with 64697 additions and 0 deletions

2
.env.prod.example Normal file
View File

@@ -0,0 +1,2 @@
# Host port for nginx in production compose
HTTP_PORT=80

9
.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
node_modules/
.DS_Store
z51-pug-builder/data/
z51-pug-builder/data-dev/
data/
*.log
.env
z51-pug-builder/data/uploads/
coin-scout/data/

213
Block.pug Normal file
View File

@@ -0,0 +1,213 @@
//Заголовок зеленый
tr
td.paddingWrapperBig
+defaultTable("100%")
tr
td(align="center")
span.text.smallHeader.bold.greenText Конитива, герой!
//Заголовок серый
tr
td.paddingWrapperBig
+defaultTable("100%")
tr
td(align="center")
span.text.smallHeader.bold.groceryText Конитива, герой!
//Текст
tr
td.paddingWrapperBig
+defaultTable("100%")
tr
td
span.text.groceryText Пока одни вспоминают старую «Якудзу-3» с ее бесконечными блоками и симулятором няньки, студия Ryu Ga Gotoku Studio готовит полноценную революцию!
//Доп. текст
+spacerLine(20)
tr
td
span.text.groceryText <span style="font-weight: 700;">Минимальные</span> 1080p&nbsp;/ 30&nbsp;FPS (с&nbsp;FSR)<br>Проц: Intel i3-8100&nbsp;/ AMD Ryzen 3 2300X<br>Видяха: NVIDIA GTX 1650&nbsp;/ AMD&nbsp;RX 6400<br>Оперативочка: 8&nbsp;ГБ<br>
//Отступ 20
+spacerLine(20)
//Отступ 40
+spacerLine(40)
//3 товара в ряд
+products3inRow({
'144839': {
imageSrc: '',
name: 'BAD BUNNY',
category: 'Осторожно, этот кролик плохой)',
},
'142672': {
imageSrc: '',
name: 'MONARCH',
category: 'Ролекс среди кресел',
},
'140228': {
imageSrc: '',
name: 'Kitty Meow',
category: 'Кошечка делает мур-р-р!',
},
})
//Разделитель
+dividerZ(525, 2)
+spacerLine(40)
//Банер
tr
td(align="center")
a(href="https://z51.ru" target="_blank")
img(src="https://z51.ru/upload/email/newsletter-2026/20-01-2026/1.jpg" alt="pic" style="display: block" width="600")
//Кнопка
tr
td(align="center").paddingWrapper
+buttonRounded("Смотреть топовые ПК", "https://z51.ru/catalog/gaming-pc/", 525, 42, "#c9e905", 18, "#000000", 4, "#c9e905").bold.text
//Две кнопки
tr
td.paddingWrapper
+defaultTable("100%")
tr
td(width="250")
+defaultTable("250")
tr
td(align="center")
+buttonRounded("Игровые кресла", "https://z51.ru/catalog/kresla/", 240, 42, "#c9e905", 18, "#000000", 4, "#c9e905").bold.text
+tdFixed(36)
td(width="250")
+defaultTable("250")
tr
td(align="center")
+buttonRounded("Эргономичные кресла", "https://z51.ru/catalog/ergonomic-office-chairs/", 240, 42, "#c9e905", 18, "#000000", 4, "#c9e905", 4).bold.text
//Блок преимуществ
tr
td.paddingWrapperBig
+defaultTable("100%")
tr
td(align="center")
span.text.smallHeader.bold.greenText Почему выбирают товары у Баззи?
+spacerLine(40)
tr
td.paddingWrapper
+defaultTable("100%")
tr
td(width="250" valign="top")
+defaultTable("250")
//Unordered List
tr
td
+defaultTable("100%")
tr
+tdFixed(12, "center", "top").markerPadding
img(src="https://z51.ru/upload/email/master-template/markers/marker.png" alt="pic" width="12")
+tdFixed(10)
td
span.groceryText <span style="font-weight: 700; color: #DAFD04;">Официальный магазин</span><br />В наличии всё самое вкусное от ZONE&nbsp;51 — кресла, столы, периферия и аксессуары
+spacerLine(20)
tr
+tdFixed(12, "center", "top").markerPadding
img(src="https://z51.ru/upload/email/master-template/markers/marker.png" alt="pic" width="12")
+tdFixed(10)
td
span.groceryText <span style="font-weight: 700; color: #DAFD04;">Первоклассные и&nbsp;надежные продукты</span><br />Из качественных, инопланетных и <span style="text-decoration: line-through;">вроде как</span> безопасных материалов для себя, родных и&nbsp;друзей. Не&nbsp;понравилось? Можешь вернуть в&nbsp;течение 28 дней с&nbsp;даты приобретения
+tdFixed(36)
td(width="250" valign="top")
+defaultTable("250")
tr
td
+defaultTable("100%")
tr
+tdFixed(12, "center", "top").markerPadding
img(src="https://z51.ru/upload/email/master-template/markers/marker.png" alt="pic" width="12")
+tdFixed(10)
td
span.groceryText <span style="font-weight: 700; color: #DAFD04;">Новинки и эксклюзивы</span><br />Я постоянно потею над&nbsp;новыми товарами, которые можно приобрести только здесь
+spacerLine(20)
tr
+tdFixed(12, "center", "top").markerPadding
img(src="https://z51.ru/upload/email/master-template/markers/marker.png" alt="pic" width="12")
+tdFixed(10)
td
span.groceryText <span style="font-weight: 700; color: #DAFD04;">Клиенто-ориентированность</span><br />Даю до 3 лет гарантии на&nbsp;свой товар +1 год за&nbsp;покупку в&nbsp;фирменном магазине ZONE&nbsp;51 (онлайн и&nbsp;офлайн), а&nbsp;человеки у&nbsp;трубки помогут быстро обкашлять любые вопросы
+spacerLine(40)
tr
td(align="center").paddingWrapper
+buttonRounded("Залетай к нам!", "https://z51.ru/", 300, 42, "#c9e905", 18, "#000000", 4, "#c9e905").bold.text
//Нумерованный список
+spacerLine(20)
tr
td.paddingWrapper
+defaultTable("100%")
tr
+tdFixed(12, "center", "top").markerPadding
img(src="https://z51.ru/upload/email/master-template/markers/1.png" alt="" width="14")
+tdFixed(16)
td
span.text БРЕНД ZONE 51 результат работы профессионалов со всего мира
tr
+tdFixed(12, "center", "top").markerPadding
img(src="https://z51.ru/upload/email/master-template/markers/2.png" alt="" width="14")
+tdFixed(16)
td
span.text БРЕНД ZONE 51 результат работы профессионалов со всего мира
tr
+tdFixed(12, "center", "top").markerPadding
img(src="https://z51.ru/upload/email/master-template/markers/3.png" alt="" width="14")
+tdFixed(16)
td
span.text БРЕНД ZONE 51 результат работы профессионалов со всего
tr
+tdFixed(12, "center", "top").markerPadding
img(src="https://z51.ru/upload/email/master-template/markers/4.png" alt="" width="14")
+tdFixed(16)
td
span.text БРЕНД ZONE 51 результат работы профессионалов со всего
//Маркированный список
+spacerLine(25)
tr
td.paddingWrapper
+defaultTable("100%")
tr
+tdFixed(12, "center", "top").markerPadding
img(src="https://z51.ru/upload/email/master-template/markers/marker.png" alt="" width="12")
+tdFixed(16)
td
span.text БРЕНД ZONE 51 результат работы профессионалов со всего мира
tr
+tdFixed(12, "center", "top").markerPadding
img(src="https://z51.ru/upload/email/master-template/markers/marker.png" alt="" width="12")
+tdFixed(16)
td
span.text БРЕНД ZONE 51 результат работы профессионалов со всего мира
tr
+tdFixed(12, "center", "top").markerPadding
img(src="https://z51.ru/upload/email/master-template/markers/marker.png" alt="" width="12")
+tdFixed(16)
td
span.text БРЕНД ZONE 51 результат работы профессионалов со всего
tr
+tdFixed(12, "center", "top").markerPadding
img(src="https://z51.ru/upload/email/master-template/markers/marker.png" alt="" width="12")
+tdFixed(16)
td
span.text БРЕНД ZONE 51 результат работы профессионалов со всего

117
DOCKER-DEPLOY.md Normal file
View File

@@ -0,0 +1,117 @@
# Docker deploy: z51-pug-builder + email-gen
## Что есть
- `docker-compose.yml` — локальный/дев запуск (builder на `localhost:5173`)
- `docker-compose.prod.yml` — прод запуск через nginx
Сервисы:
- `builder` — Svelte-конструктор + локальный API (`/api/*`)
- `email-gen-api` — мост к `email-gen` (рендер PUG -> HTML)
- `nginx` (только в prod compose) — входная точка на 80 порту
---
## 1) Локальный запуск (dev)
```bash
cd /Users/sergeyzotov/Documents/GENERATOR_Z51
docker compose up -d --build
docker compose ps
```
Открыть: `http://localhost:5173`
Остановить:
```bash
docker compose down
```
---
## 2) Прод запуск (nginx + docker)
1. Подготовь env:
```bash
cd /Users/sergeyzotov/Documents/GENERATOR_Z51
cp .env.prod.example .env
```
2. Запусти:
```bash
docker compose -f docker-compose.prod.yml up -d --build
```
3. Проверка:
```bash
docker compose -f docker-compose.prod.yml ps
```
Открыть: `http://<VPS_IP>` (или домен, если DNS уже настроен).
Остановить:
```bash
docker compose -f docker-compose.prod.yml down
```
---
## 3) Как работает генерация HTML
1. В конструкторе собирается PUG.
2. Нажимаешь `Превью -> Обновить`.
3. Builder вызывает `POST /api/project/:name/render-email`.
4. Запрос уходит в `email-gen-api`.
5. `email-gen-api`:
- пишет PUG в `email-gen/emails/<project>/letters/let.pug`
- запускает `gulp pug --project <project>`
- читает `email-gen/public/index.html`
- возвращает HTML обратно в конструктор.
Важно: в `Настройки -> Текущий проект` заполняй поле `Папка проекта в email-gen` (например `numizmat`).
---
## 4) Обновление email-gen без ручной пересборки всего
Скрипт:
```bash
./deploy/scripts/update-email-gen.sh
```
Или с веткой:
```bash
./deploy/scripts/update-email-gen.sh main
```
Что делает скрипт:
- `git fetch`/`git pull --ff-only` в `email-gen`
- пересобирает и перезапускает только `email-gen-api` контейнер
Это удобно, если `email-gen` обновляется через git и перезаписывается.
---
## 5) Логи
Prod:
```bash
docker compose -f docker-compose.prod.yml logs -f nginx
docker compose -f docker-compose.prod.yml logs -f builder
docker compose -f docker-compose.prod.yml logs -f email-gen-api
```
Dev:
```bash
docker compose logs -f builder
docker compose logs -f email-gen-api
```
---
## 6) Данные
- Данные конструктора: `z51-pug-builder/data`
- Репозиторий генератора: `email-gen` (bind mount в контейнер)
Оба каталога остаются на хосте и не теряются при пересоздании контейнеров.

47
Untitled-1 Normal file
View File

@@ -0,0 +1,47 @@
cd /Users/sergeyzotov/Documents/GENERATOR_Z51/z51-pug-builder
npm run dev
//Текст
tr
td.paddingWrapperBig
+defaultTable("100%")
tr
td
span.text.groceryText Запоминай дату: <span style="font-weight: 700;">13 февраля 2026 года. </span>
+spacerLine(20)
tr
td
span.text.groceryText В&nbsp;этот день ты&nbsp;точно поменяешь
+spacerLine(20)
tr
td
span.text.groceryText Говорят, лучший способ пережить
+spacerLine(20)
tr
td
span.text.groceryText Но сначала в
отлично, только сделай кнопку пиктограммой в виде папки, и перенеси рядом с кнопкой сохранения. и добавь всем кнопкам и полям описание которое показывается при наведении мыши
cd /Users/sergeyzotov/Documents/GENERATOR_Z51
docker compose up -d --build --force-recreate builder
кб
https://email-images.mindbox.ru/numizmat/1371ccef-c936-48cd-b0b1-410beb7f04b5/38e24939-55d1-49cf-8932-561a78176448.jpg
РУ
https://email-images.mindbox.ru/numizmat/1371ccef-c936-48cd-b0b1-410beb7f04b5/6dfa2b76-6f67-4be3-8ecc-ac88e5ff0a03.png
cd /Users/sergeyzotov/Documents/GENERATOR_Z51
# stable
docker compose -p z51_stable up -d --build
# dev
docker compose -p z51_dev -f docker-compose.yml -f docker-compose.dev.yml up -d --build

BIN
aspekter.zip Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,3 @@
{
"git.ignoreLimitWarning": true
}

View File

@@ -0,0 +1,74 @@
# Конструктор писем VipAvenue (Svelte)
Подробная инструкция по использованию редактора для сборки Pug-кода писем.
## Запуск проекта
1. Установите зависимости: `npm install`
2. Запустите dev-сервер: `npm run dev`
3. Откройте ссылку из терминала (обычно `http://localhost:5173`)
## Сохранение в email-gen
Для записи `let.pug` и `html.pug` нужен локальный сервер. Автосохранение работает только для `html.pug`, а `let.pug` сохраняется кнопкой «Сохранить».
### Сборка и запуск
1. Соберите фронт: `npm run build`
2. Запустите сервер: `npm run serve`
3. Откройте интерфейс: `http://localhost:4173`
### Переменные окружения
- `EMAIL_GEN_ROOT` — путь к корню `email-gen`
- `EMAIL_GEN_PROJECT` — имя проекта (по умолчанию `vipavenue`)
- `EMAIL_GEN_HTML_PATH` — путь к `html.pug` внутри `email-gen` (опционально)
- `EMAIL_GEN_LETTERS_DIR` — путь к папке `letters` внутри `email-gen` (опционально)
- `EMAIL_GEN_PUBLIC_INDEX` — путь к `public/index.html` (опционально)
- `PORT` — порт сервера (по умолчанию `4173`)
Кнопка `HTML` в конструкторе копирует содержимое `public/index.html` через `/api/html`.
### Dev-режим (опционально)
Если запускаете `npm run dev`, укажите `VITE_API_BASE=http://localhost:4173`,
чтобы кнопка сохранения обращалась к backend.
## Общие элементы
- **Тёмная тема** — переключатель в топбаре. Сохраняется в `localStorage`.
- **Папка изображений** — глобальный префикс для всех картинок. В блоках указывается только имя файла и расширение.
- **Цены в товарах** — один глобальный флажок. Управляет всеми товарными блоками (включая «3 товара + картинка»).
- **Собрать мужское / Собрать женское** — переставляет сегменты относительно разделителя (`dividerVA`), сохраняя общие блоки до и после.
- **Сохранить как пресет** — сохраняет текущее состояние блоков и настроек.
- **Сбросить** — очищает блоки и Pug.
## Работа с блоками
- Добавляйте блок через выпадающий список «Тип блока».
- Каждый блок имеет сегмент: **O** (общий), **Ж**, **М**. Сегментный бейдж меняется при клике.
- Перетаскивайте блоки за «ручку» или кнопками ↑/↓.
- Параметры блока сразу попадают в Pug (панель справа).
- Кнопки «Скопировать код» / «Экспорт .pug» работают сверху и снизу панели кода.
### Сегменты и сборка версий
- Общие блоки **до** разделителя `dividerVA` всегда остаются сверху.
- При сборке **мужской** версии: сегмент М становится над разделителем, Ж — под ним; общие блоки вокруг разделителя остаются на месте.
- При возвращении на **женскую** версию порядок восстанавливается.
## Пресеты
- Вкладка «Пресеты»: Новинки, Акция, Новые коллекции, Мои пресеты.
- Для Новинок/Акции/Новых коллекций введите женские и мужские ID (до 16), при необходимости цены.
- Кнопки «Женская версия» / «Мужская версия» создают набор блоков и переходят в конструктор.
- «Мои пресеты» — сохранённые состояния конструктора. Доступны загрузка/удаление.
## Поля и картинки
- В блоках с картинками указывайте только имя файла и расширение; базовый путь — в «Папка изображений».
- Расширение по умолчанию: `.png`.
## Хранение данных
- `localStorage` ключи:
- `vip_letter_editor_blocks_v1`
- `vip_letter_editor_settings_v1` (включая `imageBaseUrl`, `showPrices`, тема)
- `vip_letter_editor_theme`
- `vip_letter_editor_custom_presets_v1`
## Частые действия
- **Показать превью HTML для текстового блока** — чекбокс внутри текстового блока (если нужен рендер HTML).
- **Убрать отступ после блока** — флажок внутри блока.
- **Ширина/высота, отступы** — в доп. настройках (кнопка «Доп. настройки» там, где она есть).
Если что-то пошло не так: проверьте, что глобальный флажок цен включён/выключен как нужно, и убедитесь, что разделитель `dividerVA` стоит на месте, если используете сборку мужской/женской версии.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="5" width="18" height="14" rx="2.2" />
<path d="M4 7l8 6 8-6" />
</svg>

After

Width:  |  Height:  |  Size: 246 B

View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>aspekter — конструктор писем</title>
<script type="module" crossorigin src="/assets/index-BUFzIg5U.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CvxZHvQj.css">
</head>
<body>
<div id="app"></div>
</body>
</html>

View File

@@ -0,0 +1,149 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Инструкция по конструктору писем VipAvenue</title>
<style>
body {
margin: 24px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: #0f1115;
color: #e5e5e5;
line-height: 1.6;
}
h1, h2, h3 {
color: #dcdcaa;
margin-top: 1.2em;
margin-bottom: 0.4em;
}
a { color: #9abcf9; }
code { background: #1e1e1e; padding: 2px 4px; border-radius: 4px; }
pre { background: #1e1e1e; padding: 12px; border-radius: 6px; overflow-x: auto; }
ul { margin: 0.4em 0 0.8em 1.4em; }
.section { margin-bottom: 1.6em; }
</style>
</head>
<body>
<h1>Конструктор писем VipAvenue (Svelte)</h1>
<p>Максимально подробная инструкция: запуск, глобальные настройки, блоки, сегменты, пресеты, экспорт и хранение.</p>
<div class="section">
<h2>1. Запуск и окружение</h2>
<ol>
<li><code>npm install</code> — установка зависимостей.</li>
<li><code>npm run dev</code> — запуск dev-сервера.</li>
<li>Открыть адрес из терминала (обычно <code>http://localhost:5173</code>).</li>
</ol>
<p>Все состояния (блоки, настройки, тема, кастомные пресеты) хранятся в <code>localStorage</code>.</p>
</div>
<div class="section">
<h2>2. Макет интерфейса</h2>
<ul>
<li><strong>Топбар</strong>: переключатель темы (тёмная/светлая, сохраняется).</li>
<li><strong>Левая колонка — Конструктор</strong>:
<ul>
<li>Папка изображений — глобальный префикс URL.</li>
<li>Цены в товарах — общий флажок для всех товарных блоков.</li>
<li>Тип блока — добавление нового блока.</li>
<li>Собрать мужское / Собрать женское — обмен сегментов относительно разделителя.</li>
<li>Сохранить как пресет — сохраняет текущее состояние (блоки + настройки).</li>
<li>Сбросить — удаляет все блоки и код.</li>
</ul>
</li>
<li><strong>Левая колонка — Пресеты</strong>: Новинки, Акция, Новые коллекции, Мои пресеты.</li>
<li><strong>Правая колонка</strong>: живой Pug-код, кнопки копирования/экспорта (дублируются сверху и снизу).</li>
<li><strong>Футер</strong>: цитата и ссылка на инструкцию.</li>
</ul>
</div>
<div class="section">
<h2>3. Блоки и сегменты</h2>
<ul>
<li>Сегменты: <strong>O</strong> — общий, <strong>Ж</strong> — женский, <strong>М</strong> — мужской. Меняются кликом по бейджу в заголовке.</li>
<li>Перетаскивание: за «ручку» или кнопки ↑/↓.</li>
<li>Все поля блока моментально обновляют Pug справа.</li>
<li>Опция «Убрать отступ после блока» позволяет стыковать плотнее.</li>
<li>Глобальный флажок «Цены в товарах» управляет всеми товарными блоками, включая «3 товара + картинка».</li>
</ul>
</div>
<div class="section">
<h2>4. Сборка женской/мужской версии</h2>
<ul>
<li><code>dividerVA</code> — центр обмена сегментов.</li>
<li>Общие блоки до/после разделителя остаются на местах.</li>
<li>При «Собрать мужское» блоки М поднимаются над разделителем, блоки Ж опускаются под него.</li>
<li>При «Собрать женское» порядок возвращается (запоминается при переключении на мужскую).</li>
</ul>
</div>
<div class="section">
<h2>5. Перечень блоков</h2>
<ul>
<li><strong>Заголовки/текст</strong>: АКТУАЛЬНЫЙ заголовок, Текстовый блок (поддержка <code>&lt;br&gt;</code>, <code>&nbsp;</code>, <code>&mdash;</code>, жирный через <code>&lt;span style="font-weight:700;"&gt;</code>). Есть HTML-превью.</li>
<li><strong>Кнопки</strong>: одиночная, двойная, тройная. Настройки ширины/высоты/цветов/шрифта/отступов.</li>
<li><strong>Баннеры</strong>: с ссылкой/без, 2/3 баннера (с/без текста). Имя файла + расширение, префикс берётся из «Папка изображений».</li>
<li><strong>Товары</strong>:
<ul>
<li>4 в ряд, 3 в ряд.</li>
<li>Товары + картинка слева/справа; 3 товара + картинка слева/справа.</li>
<li>Цены регулируются глобальным флажком.</li>
</ul>
</li>
<li><strong>Текст + картинка</strong>: картинка слева/справа, кнопка под текстом.</li>
<li><strong>Сервис</strong>: Отступ, Разделитель (<code>dividerVA</code>), Размерная сетка, Промокод.</li>
</ul>
</div>
<div class="section">
<h2>6. Картинки</h2>
<ul>
<li>Заполните «Папка изображений» (например, <code>https://email-files.vipavenue.ru/newsletter_2025/2025_12_07/</code>).</li>
<li>В блоках — только имя файла и расширение (<code>.png</code> по умолчанию). При необходимости есть поля расширения и прямой URL (fallback).</li>
<li>URL собирается автоматически: префикс + имя файла + расширение.</li>
</ul>
</div>
<div class="section">
<h2>7. Пресеты</h2>
<ul>
<li><strong>Новинки</strong>: 16 женских + 16 мужских ID, заголовок, подзаголовок, кнопки, ссылки размерных сеток, кнопки «Больше новинок». Цены — глобальным флажком.</li>
<li><strong>Акция</strong>: баннер, два блока товаров (жен/муж), разделитель, кнопки «Подборка для неё/него».</li>
<li><strong>Новые коллекции</strong>: похожий набор с отдельными текстами/ссылками/баннером.</li>
<li><strong>Мои пресеты</strong>: сохранённые состояния конструктора (блоки + настройки). Доступны загрузка/удаление.</li>
<li>Кнопки «Женская версия» / «Мужская версия» применяют пресет и возвращают на вкладку Конструктор.</li>
</ul>
</div>
<div class="section">
<h2>8. Экспорт Pug</h2>
<ul>
<li>Поле справа всегда содержит актуальный Pug.</li>
<li>«Скопировать код» — в буфер; «Экспорт .pug» — скачивает файл <code>letter.pug</code>.</li>
<li>Кнопки копирования/экспорта продублированы сверху и снизу панели кода.</li>
</ul>
</div>
<div class="section">
<h2>9. Хранение данных (localStorage)</h2>
<ul>
<li><code>vip_letter_editor_blocks_v1</code> — блоки.</li>
<li><code>vip_letter_editor_settings_v1</code> — настройки (включая <code>imageBaseUrl</code>, <code>showPrices</code>).</li>
<li><code>vip_letter_editor_theme</code> — тема.</li>
<li><code>vip_letter_editor_custom_presets_v1</code> — пользовательские пресеты.</li>
</ul>
</div>
<div class="section">
<h2>10. Практические советы</h2>
<ul>
<li>Следите, чтобы <code>dividerVA</code> стоял на месте, если используете сборку мужской/женской версии.</li>
<li>После «Собрать мужское» вернуться на женскую можно той же кнопкой (текст меняется).</li>
<li>Для плотного стыка блоков снимайте «Отступ после блока».</li>
<li>В текстовых блоках включайте HTML-превью, если хотите увидеть рендер <code>&lt;br&gt;</code>/<code>&nbsp;</code>/<code>&mdash;</code> и жирного текста.</li>
</ul>
</div>
</body>
</html>

View File

@@ -0,0 +1,17 @@
extends layout/layout.pug
//- Для задания класса основной таблицы в которой находится весь контент письма
//- В данном случае в комментарии установлен класс для темного письма
//- prepend wrapper
//- -
//- var wrapperClass = "blackMainBackground";
block header
include ./parts/header/header-man
block preheader
+preheader("Премиальные шубы и дубленки <vk-snippet-end/>")
block content
include ./letters/let/let
block footer
include ./parts/footer/footer-man

View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>aspekter — конструктор писем</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,22 @@
{
"name": "editor-svelte",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"serve": "node server/index.js",
"start": "node server/index.js"
},
"dependencies": {
"svelte-dnd-action": "^0.9.25",
"uuid": "^9.0.1"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^3.0.2",
"svelte": "^4.2.12",
"vite": "^5.2.0"
}
}

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="5" width="18" height="14" rx="2.2" />
<path d="M4 7l8 6 8-6" />
</svg>

After

Width:  |  Height:  |  Size: 246 B

View File

@@ -0,0 +1,149 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Инструкция по конструктору писем VipAvenue</title>
<style>
body {
margin: 24px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: #0f1115;
color: #e5e5e5;
line-height: 1.6;
}
h1, h2, h3 {
color: #dcdcaa;
margin-top: 1.2em;
margin-bottom: 0.4em;
}
a { color: #9abcf9; }
code { background: #1e1e1e; padding: 2px 4px; border-radius: 4px; }
pre { background: #1e1e1e; padding: 12px; border-radius: 6px; overflow-x: auto; }
ul { margin: 0.4em 0 0.8em 1.4em; }
.section { margin-bottom: 1.6em; }
</style>
</head>
<body>
<h1>Конструктор писем VipAvenue (Svelte)</h1>
<p>Максимально подробная инструкция: запуск, глобальные настройки, блоки, сегменты, пресеты, экспорт и хранение.</p>
<div class="section">
<h2>1. Запуск и окружение</h2>
<ol>
<li><code>npm install</code> — установка зависимостей.</li>
<li><code>npm run dev</code> — запуск dev-сервера.</li>
<li>Открыть адрес из терминала (обычно <code>http://localhost:5173</code>).</li>
</ol>
<p>Все состояния (блоки, настройки, тема, кастомные пресеты) хранятся в <code>localStorage</code>.</p>
</div>
<div class="section">
<h2>2. Макет интерфейса</h2>
<ul>
<li><strong>Топбар</strong>: переключатель темы (тёмная/светлая, сохраняется).</li>
<li><strong>Левая колонка — Конструктор</strong>:
<ul>
<li>Папка изображений — глобальный префикс URL.</li>
<li>Цены в товарах — общий флажок для всех товарных блоков.</li>
<li>Тип блока — добавление нового блока.</li>
<li>Собрать мужское / Собрать женское — обмен сегментов относительно разделителя.</li>
<li>Сохранить как пресет — сохраняет текущее состояние (блоки + настройки).</li>
<li>Сбросить — удаляет все блоки и код.</li>
</ul>
</li>
<li><strong>Левая колонка — Пресеты</strong>: Новинки, Акция, Новые коллекции, Мои пресеты.</li>
<li><strong>Правая колонка</strong>: живой Pug-код, кнопки копирования/экспорта (дублируются сверху и снизу).</li>
<li><strong>Футер</strong>: цитата и ссылка на инструкцию.</li>
</ul>
</div>
<div class="section">
<h2>3. Блоки и сегменты</h2>
<ul>
<li>Сегменты: <strong>O</strong> — общий, <strong>Ж</strong> — женский, <strong>М</strong> — мужской. Меняются кликом по бейджу в заголовке.</li>
<li>Перетаскивание: за «ручку» или кнопки ↑/↓.</li>
<li>Все поля блока моментально обновляют Pug справа.</li>
<li>Опция «Убрать отступ после блока» позволяет стыковать плотнее.</li>
<li>Глобальный флажок «Цены в товарах» управляет всеми товарными блоками, включая «3 товара + картинка».</li>
</ul>
</div>
<div class="section">
<h2>4. Сборка женской/мужской версии</h2>
<ul>
<li><code>dividerVA</code> — центр обмена сегментов.</li>
<li>Общие блоки до/после разделителя остаются на местах.</li>
<li>При «Собрать мужское» блоки М поднимаются над разделителем, блоки Ж опускаются под него.</li>
<li>При «Собрать женское» порядок возвращается (запоминается при переключении на мужскую).</li>
</ul>
</div>
<div class="section">
<h2>5. Перечень блоков</h2>
<ul>
<li><strong>Заголовки/текст</strong>: АКТУАЛЬНЫЙ заголовок, Текстовый блок (поддержка <code>&lt;br&gt;</code>, <code>&nbsp;</code>, <code>&mdash;</code>, жирный через <code>&lt;span style="font-weight:700;"&gt;</code>). Есть HTML-превью.</li>
<li><strong>Кнопки</strong>: одиночная, двойная, тройная. Настройки ширины/высоты/цветов/шрифта/отступов.</li>
<li><strong>Баннеры</strong>: с ссылкой/без, 2/3 баннера (с/без текста). Имя файла + расширение, префикс берётся из «Папка изображений».</li>
<li><strong>Товары</strong>:
<ul>
<li>4 в ряд, 3 в ряд.</li>
<li>Товары + картинка слева/справа; 3 товара + картинка слева/справа.</li>
<li>Цены регулируются глобальным флажком.</li>
</ul>
</li>
<li><strong>Текст + картинка</strong>: картинка слева/справа, кнопка под текстом.</li>
<li><strong>Сервис</strong>: Отступ, Разделитель (<code>dividerVA</code>), Размерная сетка, Промокод.</li>
</ul>
</div>
<div class="section">
<h2>6. Картинки</h2>
<ul>
<li>Заполните «Папка изображений» (например, <code>https://email-files.vipavenue.ru/newsletter_2025/2025_12_07/</code>).</li>
<li>В блоках — только имя файла и расширение (<code>.png</code> по умолчанию). При необходимости есть поля расширения и прямой URL (fallback).</li>
<li>URL собирается автоматически: префикс + имя файла + расширение.</li>
</ul>
</div>
<div class="section">
<h2>7. Пресеты</h2>
<ul>
<li><strong>Новинки</strong>: 16 женских + 16 мужских ID, заголовок, подзаголовок, кнопки, ссылки размерных сеток, кнопки «Больше новинок». Цены — глобальным флажком.</li>
<li><strong>Акция</strong>: баннер, два блока товаров (жен/муж), разделитель, кнопки «Подборка для неё/него».</li>
<li><strong>Новые коллекции</strong>: похожий набор с отдельными текстами/ссылками/баннером.</li>
<li><strong>Мои пресеты</strong>: сохранённые состояния конструктора (блоки + настройки). Доступны загрузка/удаление.</li>
<li>Кнопки «Женская версия» / «Мужская версия» применяют пресет и возвращают на вкладку Конструктор.</li>
</ul>
</div>
<div class="section">
<h2>8. Экспорт Pug</h2>
<ul>
<li>Поле справа всегда содержит актуальный Pug.</li>
<li>«Скопировать код» — в буфер; «Экспорт .pug» — скачивает файл <code>letter.pug</code>.</li>
<li>Кнопки копирования/экспорта продублированы сверху и снизу панели кода.</li>
</ul>
</div>
<div class="section">
<h2>9. Хранение данных (localStorage)</h2>
<ul>
<li><code>vip_letter_editor_blocks_v1</code> — блоки.</li>
<li><code>vip_letter_editor_settings_v1</code> — настройки (включая <code>imageBaseUrl</code>, <code>showPrices</code>).</li>
<li><code>vip_letter_editor_theme</code> — тема.</li>
<li><code>vip_letter_editor_custom_presets_v1</code> — пользовательские пресеты.</li>
</ul>
</div>
<div class="section">
<h2>10. Практические советы</h2>
<ul>
<li>Следите, чтобы <code>dividerVA</code> стоял на месте, если используете сборку мужской/женской версии.</li>
<li>После «Собрать мужское» вернуться на женскую можно той же кнопкой (текст меняется).</li>
<li>Для плотного стыка блоков снимайте «Отступ после блока».</li>
<li>В текстовых блоках включайте HTML-превью, если хотите увидеть рендер <code>&lt;br&gt;</code>/<code>&nbsp;</code>/<code>&mdash;</code> и жирного текста.</li>
</ul>
</div>
</body>
</html>

View File

@@ -0,0 +1,279 @@
import http from "http";
import { promises as fs } from "fs";
import path from "path";
import { fileURLToPath } from "url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const PORT = Number(process.env.PORT) || 4173;
const distDir = path.resolve(__dirname, "..", "dist");
const emailGenRoot = path.resolve(process.env.EMAIL_GEN_ROOT || path.resolve(__dirname, "..", "..", "email-gen"));
const emailGenProject = process.env.EMAIL_GEN_PROJECT || "vipavenue";
const htmlRelPath = process.env.EMAIL_GEN_HTML_PATH || `emails/${emailGenProject}/html.pug`;
const lettersRelDir = process.env.EMAIL_GEN_LETTERS_DIR || `emails/${emailGenProject}/letters`;
const publicIndexRel = process.env.EMAIL_GEN_PUBLIC_INDEX || "public/index.html";
const allowOrigin = process.env.API_ALLOW_ORIGIN || "*";
const contentTypes = {
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".svg": "image/svg+xml",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".ico": "image/x-icon",
".json": "application/json; charset=utf-8",
".map": "application/json; charset=utf-8"
};
const json = (res, status, payload) => {
res.statusCode = status;
res.setHeader("Content-Type", "application/json; charset=utf-8");
res.end(JSON.stringify(payload));
};
const text = (res, status, payload) => {
res.statusCode = status;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end(payload);
};
const safeResolve = (root, rel) => {
const target = path.resolve(root, rel);
const safeRoot = root.endsWith(path.sep) ? root : root + path.sep;
if (!target.startsWith(safeRoot)) {
throw new Error("Path escapes root");
}
return target;
};
const normalizeContentPath = (value = "") =>
value
.replace(/^(\.\/)?letters\//i, "")
.replace(/^\/+/, "")
.replace(/\.pug$/i, "")
.trim();
const readBody = (req) =>
new Promise((resolve, reject) => {
let data = "";
req.on("data", (chunk) => {
data += chunk;
if (data.length > 5 * 1024 * 1024) {
reject(new Error("Payload too large"));
req.destroy();
}
});
req.on("end", () => resolve(data));
req.on("error", reject);
});
const server = http.createServer(async (req, res) => {
try {
const url = new URL(req.url, `http://${req.headers.host}`);
const pathname = decodeURIComponent(url.pathname);
if (pathname === "/api/save") {
res.setHeader("Access-Control-Allow-Origin", allowOrigin);
res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
if (req.method === "OPTIONS") {
res.statusCode = 204;
res.end();
return;
}
if (req.method !== "POST") {
text(res, 405, "Method Not Allowed");
return;
}
const raw = await readBody(req);
let payload;
try {
payload = JSON.parse(raw);
} catch (e) {
json(res, 400, { ok: false, error: "Invalid JSON" });
return;
}
const { pugCode, htmlPug, contentPath } = payload || {};
if (typeof pugCode !== "string" || !pugCode.trim()) {
json(res, 400, { ok: false, error: "Missing pugCode" });
return;
}
if (typeof htmlPug !== "string" || !htmlPug.trim()) {
json(res, 400, { ok: false, error: "Missing htmlPug" });
return;
}
if (typeof contentPath !== "string" || !contentPath.trim()) {
json(res, 400, { ok: false, error: "Missing contentPath" });
return;
}
const normalizedPath = normalizeContentPath(contentPath);
if (!normalizedPath || normalizedPath.includes("..")) {
json(res, 400, { ok: false, error: "Invalid contentPath" });
return;
}
const letterRelPath = path.posix.join(lettersRelDir, `${normalizedPath}.pug`);
const htmlTarget = safeResolve(emailGenRoot, htmlRelPath);
const letterTarget = safeResolve(emailGenRoot, letterRelPath);
await fs.mkdir(path.dirname(letterTarget), { recursive: true });
await fs.writeFile(letterTarget, pugCode, "utf8");
await fs.writeFile(htmlTarget, htmlPug, "utf8");
json(res, 200, {
ok: true,
saved: {
letter: letterRelPath,
html: htmlRelPath
}
});
return;
}
if (pathname === "/api/save-html") {
res.setHeader("Access-Control-Allow-Origin", allowOrigin);
res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
if (req.method === "OPTIONS") {
res.statusCode = 204;
res.end();
return;
}
if (req.method !== "POST") {
text(res, 405, "Method Not Allowed");
return;
}
const raw = await readBody(req);
let payload;
try {
payload = JSON.parse(raw);
} catch (e) {
json(res, 400, { ok: false, error: "Invalid JSON" });
return;
}
const { htmlPug } = payload || {};
if (typeof htmlPug !== "string" || !htmlPug.trim()) {
json(res, 400, { ok: false, error: "Missing htmlPug" });
return;
}
const htmlTarget = safeResolve(emailGenRoot, htmlRelPath);
await fs.writeFile(htmlTarget, htmlPug, "utf8");
json(res, 200, {
ok: true,
saved: {
html: htmlRelPath
}
});
return;
}
if (pathname === "/api/html") {
res.setHeader("Access-Control-Allow-Origin", allowOrigin);
res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
if (req.method === "OPTIONS") {
res.statusCode = 204;
res.end();
return;
}
if (req.method !== "GET" && req.method !== "HEAD") {
text(res, 405, "Method Not Allowed");
return;
}
const previewPath = safeResolve(emailGenRoot, publicIndexRel);
try {
const data = await fs.readFile(previewPath);
res.statusCode = 200;
res.setHeader("Content-Type", "text/html; charset=utf-8");
if (req.method === "HEAD") {
res.end();
} else {
res.end(data);
}
} catch (e) {
text(res, 404, "Preview not found. Run the email generator.");
}
return;
}
if (pathname === "/preview") {
if (req.method !== "GET" && req.method !== "HEAD") {
text(res, 405, "Method Not Allowed");
return;
}
const previewPath = safeResolve(emailGenRoot, publicIndexRel);
try {
const data = await fs.readFile(previewPath);
res.statusCode = 200;
res.setHeader("Content-Type", "text/html; charset=utf-8");
if (req.method === "HEAD") {
res.end();
} else {
res.end(data);
}
} catch (e) {
text(res, 404, "Preview not found. Run the email generator.");
}
return;
}
if (pathname.startsWith("/api/")) {
text(res, 404, "Not Found");
return;
}
if (req.method !== "GET" && req.method !== "HEAD") {
text(res, 405, "Method Not Allowed");
return;
}
const safePath = path.resolve(distDir, "." + pathname);
const safeRoot = distDir.endsWith(path.sep) ? distDir : distDir + path.sep;
if (!safePath.startsWith(safeRoot)) {
text(res, 403, "Forbidden");
return;
}
let filePath = safePath;
try {
const stat = await fs.stat(filePath);
if (!stat.isFile()) {
throw new Error("Not a file");
}
} catch (e) {
filePath = path.join(distDir, "index.html");
}
const ext = path.extname(filePath).toLowerCase();
const contentType = contentTypes[ext] || "application/octet-stream";
let data;
try {
data = await fs.readFile(filePath);
} catch (e) {
text(res, 404, "Not Found");
return;
}
res.statusCode = 200;
res.setHeader("Content-Type", contentType);
if (req.method === "HEAD") {
res.end();
} else {
res.end(data);
}
} catch (e) {
console.error(e);
text(res, 500, "Internal Server Error");
}
});
server.listen(PORT, () => {
console.log(`editor-svelte server running on http://localhost:${PORT}`);
console.log(`email-gen root: ${emailGenRoot}`);
});

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,479 @@
<script>
import SpacerBlock from "./blocks/SpacerBlock.svelte";
import TitleActualBlock from "./blocks/TitleActualBlock.svelte";
import ParagraphBlock from "./blocks/ParagraphBlock.svelte";
import ButtonSingleBlock from "./blocks/ButtonSingleBlock.svelte";
import ButtonDoubleBlock from "./blocks/ButtonDoubleBlock.svelte";
import ButtonTripleBlock from "./blocks/ButtonTripleBlock.svelte";
import BannerBlock from "./blocks/BannerBlock.svelte";
import BannerNoLinkBlock from "./blocks/BannerNoLinkBlock.svelte";
import TwoBannersWithTextBlock from "./blocks/TwoBannersWithTextBlock.svelte";
import TwoBannersNoTextBlock from "./blocks/TwoBannersNoTextBlock.svelte";
import ThreeBannersNoTextBlock from "./blocks/ThreeBannersNoTextBlock.svelte";
import ProductsRowBlock from "./blocks/ProductsRowBlock.svelte";
import ProductsImageBlock from "./blocks/ProductsImageBlock.svelte";
import TextImageBlock from "./blocks/TextImageBlock.svelte";
import SizeGridBlock from "./blocks/SizeGridBlock.svelte";
import PromocodeBlock from "./blocks/PromocodeBlock.svelte";
import DividerBlock from "./blocks/DividerBlock.svelte";
export let block;
export let index;
export let femaleIndex = 0;
export let maleIndex = 0;
export let onChange;
export let onRemove;
export let forceCollapse = null;
export let colorizeTitles = false;
export let onHandlePointerDown = () => {};
export let isDragging = false;
const handleChange = (data) => onChange(block.id, data);
const segmentLabelFor = (seg, fIdx, mIdx) => {
if (seg === "female") return ${fIdx || ""}`;
if (seg === "male") return `М${mIdx || ""}`;
return "О";
};
let segLocal = block.segment || "common";
let segBadge = segmentLabelFor(segLocal, femaleIndex, maleIndex);
$: segLocal = block.segment || "common";
$: segBadge = segmentLabelFor(segLocal, femaleIndex, maleIndex);
let collapsed = false;
$: if (forceCollapse !== null) collapsed = forceCollapse;
const setSegment = (value) => {
segLocal = value;
segBadge = segmentLabelFor(value);
onChange(block.id, { segment: value });
};
const getBlockLabel = (type) => {
switch (type) {
case "titleActual":
return "АКТУАЛЬНЫЙ заголовок";
case "paragraph":
return "Текстовый блок";
case "buttonSingle":
return "Кнопка по центру";
case "buttonDouble":
return "Две кнопки";
case "buttonTriple":
return "Три кнопки";
case "banner":
return "Баннер с ссылкой";
case "bannerNoLink":
return "Баннер без ссылки";
case "twoBannersWithText":
return "Два баннера с текстом";
case "twoBannersNoText":
return "Два баннера без текста";
case "threeBannersNoText":
return "Три баннера без текста";
case "products4Row":
return "4 товара в ряд";
case "products3Row":
return "3 товара в ряд";
case "productsImageLeft":
return "Товары + картинка слева";
case "productsImageRight":
return "Товары + картинка справа";
case "productsImageLeft3":
return "3 товара + картинка слева";
case "productsImageRight3":
return "3 товара + картинка справа";
case "textImageLeft":
return "Текст справа, картинка слева";
case "textImageRight":
return "Текст слева, картинка справа";
case "sizeGrid":
return "Размерная сетка";
case "promocode":
return "Промокод";
case "spacer":
return "Отступ";
case "dividerVA":
return "Разделитель";
default:
return type;
}
};
const toggleCollapse = () => (collapsed = !collapsed);
const handleKeyToggle = (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
toggleCollapse();
}
};
const typeColors = {
titleActual: "#e85a5a",
paragraph: "#b388ff",
buttonSingle: "#2ab27b",
buttonDouble: "#26a69a",
buttonTriple: "#0097a7",
banner: "#ffb74d",
bannerNoLink: "#ff9800",
twoBannersWithText: "#c27447",
twoBannersNoText: "#bf6d3f",
threeBannersNoText: "#b0552c",
products4Row: "#4dabf5",
products3Row: "#42a5f5",
productsImageLeft: "#ec6a7d",
productsImageRight: "#ec6a7d",
productsImageLeft3: "#ec6a7d",
productsImageRight3: "#ec6a7d",
textImageLeft: "#7fd1b9",
textImageRight: "#7fd1b9",
sizeGrid: "#90a4ae",
promocode: "#f06292",
spacer: "#cfd8dc",
dividerVA: "#8bc34a"
};
const computeTitleColor = (blk, enabled) => {
if (!enabled) return null;
return typeColors[blk?.type] || "#9aa0a6";
};
// пересчитываем при смене палитры или изменении блока
$: titleColor = computeTitleColor(block, colorizeTitles);
const PRODUCT_REQUIREMENTS = {
products4Row: { field: "productIds", count: 4 },
products3Row: { field: "productIds", count: 3 },
productsImageLeft: { field: "productIds", count: 4 },
productsImageRight: { field: "productIds", count: 4 },
productsImageLeft3: { field: "productIds", count: 3 },
productsImageRight3: { field: "productIds", count: 3 }
};
const cleanText = (value = "") => {
const stripped = value.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
return stripped;
};
const textPreview = (value = "", limit = 80) => {
const text = cleanText(value);
if (!text) return "";
return text.length > limit ? `${text.slice(0, limit - 1).trim()}…` : text;
};
const shortLink = (value = "", limit = 32) => {
if (!value) return "";
const trimmed = value.trim().replace(/^https?:\/\//i, "");
return trimmed.length > limit ? `${trimmed.slice(0, limit - 1)}…` : trimmed;
};
const splitList = (value = "") =>
value
.split(/[\n,]/)
.map((part) => part.trim())
.filter(Boolean);
const formatButtons = (buttons) =>
buttons
.map((btn) => `${btn.label}: ${textPreview(btn.text || btn.image || "—", 18) || "—"}${shortLink(btn.href) || "нет"}`)
.join(" • ");
const getProductInfo = (blk) => {
const requirement = PRODUCT_REQUIREMENTS[blk?.type];
if (!requirement) return null;
const raw = blk?.data?.[requirement.field] || "";
const ids = splitList(raw);
return {
summary: `ID ${ids.length}/${requirement.count}`,
count: ids.length,
expected: requirement.count
};
};
const computeSummary = (blk) => {
if (!blk) return "";
const data = blk.data || {};
switch (blk.type) {
case "titleActual":
case "paragraph":
return textPreview(data.text);
case "buttonSingle":
return `${textPreview(data.text, 26) || "Кнопка"}${shortLink(data.href) || "нет ссылки"}`;
case "buttonDouble":
return formatButtons([
{ label: "Л", text: data.leftText, href: data.leftHref },
{ label: "П", text: data.rightText, href: data.rightHref }
]);
case "buttonTriple":
return formatButtons([
{ label: "Л", text: data.leftText, href: data.leftHref },
{ label: "С", text: data.centerText, href: data.centerHref },
{ label: "П", text: data.rightText, href: data.rightHref }
]);
case "banner":
return `${data.imageBaseName ? `${data.imageBaseName}${data.imageExtension || ".png"}` : "Файл не задан"}${
shortLink(data.href) || "нет ссылки"
}`;
case "bannerNoLink":
return data.imageBaseName ? `${data.imageBaseName}${data.imageExtension || ".png"}` : "";
case "twoBannersWithText":
return formatButtons([
{ label: "Л", text: data.leftText || data.leftImageBaseName, href: data.leftHref },
{ label: "П", text: data.rightText || data.rightImageBaseName, href: data.rightHref }
]);
case "twoBannersNoText":
return formatButtons([
{ label: "Л", text: data.leftImageBaseName, href: data.leftHref },
{ label: "П", text: data.rightImageBaseName, href: data.rightHref }
]);
case "threeBannersNoText":
return [1, 2, 3]
.map((num, idx) => {
const image = data[`imgBaseName${num}`];
const href = data[`href${num}`];
const label = ["Л", "Ц", "П"][idx];
return `${label}: ${image || "—"}${href ? ` → ${shortLink(href)}` : ""}`;
})
.join(" • ");
case "products4Row":
case "products3Row":
case "productsImageLeft":
case "productsImageRight":
case "productsImageLeft3":
case "productsImageRight3":
return getProductInfo(blk)?.summary || "";
case "textImageLeft":
case "textImageRight":
return `${textPreview(data.header, 32) || "Без заголовка"}${data.buttonText || "кнопка"}`;
case "sizeGrid":
return `Размеры ${splitList(data.sizes).length}${data.links ? ` • Ссылки ${splitList(data.links).length}` : ""}`;
case "promocode":
return data.code ? `Код: ${data.code}` : "";
case "spacer":
return `${data.height ?? 40}px`;
case "dividerVA":
return `${data.width ?? 300}×${data.height ?? 1}px`;
default:
return "";
}
};
const pushIssue = (arr, issue) => {
if (issue && !arr.includes(issue)) arr.push(issue);
};
const computeIssues = (blk) => {
const issues = [];
if (!blk) return issues;
const data = blk.data || {};
const summaryInfo = getProductInfo(blk);
if (summaryInfo && summaryInfo.count !== summaryInfo.expected) {
issues.push(`ID ${summaryInfo.count}/${summaryInfo.expected}`);
}
const ensureText = (value, label) => {
if (!value || !value.trim()) pushIssue(issues, label ? `Нет текста (${label})` : "Нет текста");
};
const ensureHref = (value, label) => {
if (!value || !value.trim()) pushIssue(issues, label ? `Нет ссылки (${label})` : "Нет ссылки");
};
const ensureImage = (baseName, directUrl, label) => {
const hasBase = baseName && baseName.trim();
const hasDirect = directUrl && directUrl.trim();
if (!hasBase && !hasDirect) {
pushIssue(issues, label ? `Нет изображения (${label})` : "Нет изображения");
}
};
switch (blk.type) {
case "titleActual":
case "paragraph":
ensureText(data.text);
break;
case "buttonSingle":
ensureText(data.text);
ensureHref(data.href);
break;
case "buttonDouble":
ensureText(data.leftText, "Л");
ensureHref(data.leftHref, "Л");
ensureText(data.rightText, "П");
ensureHref(data.rightHref, "П");
break;
case "buttonTriple":
ensureText(data.leftText, "Л");
ensureHref(data.leftHref, "Л");
ensureText(data.centerText, "С");
ensureHref(data.centerHref, "С");
ensureText(data.rightText, "П");
ensureHref(data.rightHref, "П");
break;
case "banner":
ensureImage(data.imageBaseName, data.imageUrl);
ensureHref(data.href);
break;
case "bannerNoLink":
ensureImage(data.imageBaseName, data.imageUrl);
break;
case "twoBannersWithText":
ensureImage(data.leftImageBaseName, data.leftImage, "Л");
ensureHref(data.leftHref, "Л");
ensureImage(data.rightImageBaseName, data.rightImage, "П");
ensureHref(data.rightHref, "П");
break;
case "twoBannersNoText":
ensureImage(data.leftImageBaseName, data.leftImage, "Л");
ensureHref(data.leftHref, "Л");
ensureImage(data.rightImageBaseName, data.rightImage, "П");
ensureHref(data.rightHref, "П");
break;
case "threeBannersNoText":
[1, 2, 3].forEach((num, idx) => {
const label = ["Л", "Ц", "П"][idx];
ensureImage(data[`imgBaseName${num}`], data[`img${num}`], label);
ensureHref(data[`href${num}`], label);
});
break;
case "textImageLeft":
case "textImageRight":
ensureText(data.header);
ensureText(data.buttonText, "Кнопка");
ensureHref(data.buttonHref, "Кнопка");
ensureHref(data.link, "Картинка");
ensureImage(data.imageBaseName, data.imageUrl, "Картинка");
break;
case "sizeGrid":
if (!splitList(data.sizes).length) pushIssue(issues, "Нет размеров");
if (data.links && splitList(data.sizes).length !== splitList(data.links).length) {
pushIssue(issues, "Размеры ≠ ссылки");
}
break;
case "promocode":
ensureText(data.code, "Промокод");
break;
default:
break;
}
return issues;
};
$: blockSummary = computeSummary(block);
$: blockIssues = computeIssues(block);
</script>
<div
class="block-card"
class:collapsed={collapsed}
class:marker-female={segLocal === "female"}
class:marker-male={segLocal === "male"}
class:marker-common={segLocal === "common"}
class:marker-center={block.type === "dividerVA" && block.data?.swapCenter}
class:dragging={isDragging}
>
<div class="block-header">
<div
class="block-title clickable"
role="button"
aria-expanded={!collapsed}
tabindex="0"
on:click={toggleCollapse}
on:keydown={handleKeyToggle}
>
<span class="segment-badge" title="Назначение блока">{segBadge}</span>
<span class="block-index">{index + 1}</span>
<span class="block-name" style:color={titleColor}>{getBlockLabel(block.type)}</span>
</div>
<div class="block-actions">
<div class="segment-toggle compact">
<button type="button" class:active={segLocal === "common"} on:click={() => setSegment("common")}>О</button>
<button type="button" class:active={segLocal === "female"} on:click={() => setSegment("female")}>Ж</button>
<button type="button" class:active={segLocal === "male"} on:click={() => setSegment("male")}>М</button>
</div>
<span
class="block-drag-handle"
title="Перетащить"
role="button"
tabindex="0"
on:pointerdown|stopPropagation={onHandlePointerDown}
>
<svg class="icon" viewBox="0 0 16 16" aria-hidden="true">
<circle cx="6" cy="4.5" r="0.85" />
<circle cx="10" cy="4.5" r="0.85" />
<circle cx="6" cy="8" r="0.85" />
<circle cx="10" cy="8" r="0.85" />
<circle cx="6" cy="11.5" r="0.85" />
<circle cx="10" cy="11.5" r="0.85" />
</svg>
</span>
<button class="icon-btn danger" aria-label="Удалить" title="Удалить" on:click={() => onRemove(block.id)}>
<svg class="icon" viewBox="0 0 16 16" aria-hidden="true">
<path d="M5 5 11 11M11 5 5 11" />
</svg>
</button>
</div>
</div>
{#if (collapsed && blockSummary) || blockIssues.length}
<div class="block-meta">
{#if collapsed && blockSummary}
<div class="block-summary">{blockSummary}</div>
{/if}
{#if blockIssues.length}
<div class="block-issues">
{#each blockIssues as issue}
<span class="issue-badge">{issue}</span>
{/each}
</div>
{/if}
</div>
{/if}
{#if !collapsed}
<div class="block-body">
{#if block.type === "spacer"}
<SpacerBlock {block} onChange={handleChange} />
{:else if block.type === "titleActual"}
<TitleActualBlock {block} onChange={handleChange} />
{:else if block.type === "paragraph"}
<ParagraphBlock {block} onChange={handleChange} />
{:else if block.type === "buttonSingle"}
<ButtonSingleBlock {block} onChange={handleChange} />
{:else if block.type === "buttonDouble"}
<ButtonDoubleBlock {block} onChange={handleChange} />
{:else if block.type === "buttonTriple"}
<ButtonTripleBlock {block} onChange={handleChange} />
{:else if block.type === "banner"}
<BannerBlock {block} onChange={handleChange} />
{:else if block.type === "bannerNoLink"}
<BannerNoLinkBlock {block} onChange={handleChange} />
{:else if block.type === "twoBannersWithText"}
<TwoBannersWithTextBlock {block} onChange={handleChange} />
{:else if block.type === "twoBannersNoText"}
<TwoBannersNoTextBlock {block} onChange={handleChange} />
{:else if block.type === "threeBannersNoText"}
<ThreeBannersNoTextBlock {block} onChange={handleChange} />
{:else if block.type === "products4Row" || block.type === "products3Row"}
<ProductsRowBlock {block} onChange={handleChange} />
{:else if block.type.startsWith("productsImageLeft") || block.type.startsWith("productsImageRight")}
<ProductsImageBlock {block} onChange={handleChange} />
{:else if block.type === "textImageLeft" || block.type === "textImageRight"}
<TextImageBlock {block} onChange={handleChange} />
{:else if block.type === "sizeGrid"}
<SizeGridBlock {block} onChange={handleChange} />
{:else if block.type === "promocode"}
<PromocodeBlock {block} onChange={handleChange} />
{:else if block.type === "dividerVA"}
<DividerBlock {block} onChange={handleChange} />
{:else}
<div>Редактор для "{block.type}" ещё не реализован</div>
{/if}
</div>
{/if}
</div>
<style>
.block-index { font-weight: 700; color: var(--muted, var(--text)); }
.block-name { font-weight: 700; }
</style>

View File

@@ -0,0 +1,364 @@
<script>
import { onDestroy } from "svelte";
import BlockCard from "./BlockCard.svelte";
export let blocks = [];
export let onChange;
export let onRemove;
export let onReorder;
export let onAdd = null;
export let blockGroups = [];
export let colorizeTitles = false;
export let collapseAll = null;
let draggingId = null;
let placeholderIndex = -1;
let addValue = "";
const rowRefs = new Map();
let pointerListenersAttached = false;
const SEGMENT_LABELS = {
common: "Общие блоки",
female: "Женский сегмент",
male: "Мужской сегмент"
};
const findIndexById = (id) => blocks.findIndex((b) => b.id === id);
$: draggingItem = draggingId ? decoratedBlocks.find((item) => item.block.id === draggingId) : null;
// Подсчитываем порядковые номера женских/мужских блоков
let blocksWithSegments = [];
$: {
let female = 0;
let male = 0;
blocksWithSegments = blocks.map((b) => {
const seg = b.segment || "common";
const info = { block: b, femaleIndex: 0, maleIndex: 0 };
if (seg === "female") info.femaleIndex = ++female;
if (seg === "male") info.maleIndex = ++male;
return info;
});
}
let decoratedBlocks = [];
$: {
decoratedBlocks = blocksWithSegments.map((item, idx) => {
const prevSegment = idx > 0 ? (blocks[idx - 1]?.segment || "common") : null;
const currentSegment = item.block.segment || "common";
const dividerAllowed = currentSegment === "common" ? idx === 0 : true;
return {
...item,
segment: currentSegment,
showDivider: dividerAllowed && prevSegment !== currentSegment,
dividerLabel: SEGMENT_LABELS[currentSegment] || "Блоки"
};
});
}
function trackRow(node, id) {
if (!id) return;
rowRefs.set(id, node);
return {
update(newId) {
if (newId === id) return;
rowRefs.delete(id);
id = newId;
if (newId) {
rowRefs.set(newId, node);
}
},
destroy() {
rowRefs.delete(id);
}
};
}
const attachPointerListeners = () => {
if (pointerListenersAttached) return;
pointerListenersAttached = true;
window.addEventListener("pointermove", handlePointerMove, { passive: false });
window.addEventListener("pointerup", handlePointerRelease, { passive: false });
window.addEventListener("pointercancel", handlePointerRelease, { passive: false });
};
const detachPointerListeners = () => {
if (!pointerListenersAttached) return;
pointerListenersAttached = false;
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", handlePointerRelease);
window.removeEventListener("pointercancel", handlePointerRelease);
};
onDestroy(detachPointerListeners);
const getPointerY = (event) => {
if (typeof event.clientY === "number") return event.clientY;
if (event.touches?.length) return event.touches[0].clientY;
if (event.changedTouches?.length) return event.changedTouches[0].clientY;
return 0;
};
const beginPointerDrag = (id) => (event) => {
if (event.button !== undefined && event.button !== 0) return;
draggingId = id;
const currentIndex = findIndexById(id);
placeholderIndex = Math.min(blocks.length, currentIndex + 1);
updatePlaceholderPosition(getPointerY(event));
event.preventDefault();
event.stopPropagation();
attachPointerListeners();
};
const handlePointerMove = (event) => {
if (!draggingId) return;
event.preventDefault();
updatePlaceholderPosition(getPointerY(event));
};
const handlePointerRelease = (event) => {
if (!draggingId) return;
event.preventDefault();
detachPointerListeners();
finalizePointerDrag();
};
function updatePlaceholderPosition(y) {
const entries = [];
blocks.forEach((block, idx) => {
if (draggingId && block.id === draggingId) return;
const node = rowRefs.get(block.id);
if (!node) return;
const rect = node.getBoundingClientRect();
entries.push({ idx, mid: rect.top + rect.height / 2 });
});
if (!entries.length) {
placeholderIndex = 0;
return;
}
let target = entries[entries.length - 1].idx + 1;
for (const entry of entries) {
if (y < entry.mid) {
target = entry.idx;
break;
}
}
placeholderIndex = target;
}
function finalizePointerDrag() {
if (!draggingId || placeholderIndex < 0) {
resetDragState();
return;
}
const from = findIndexById(draggingId);
const to = placeholderIndex;
if (from === -1 || to === -1 || from === to) {
resetDragState();
return;
}
const copy = [...blocks];
const [item] = copy.splice(from, 1);
copy.splice(to > from ? to - 1 : to, 0, item);
onReorder(copy);
resetDragState();
}
const resetDragState = () => {
draggingId = null;
placeholderIndex = -1;
};
</script>
<div class="blocks-container" role="list">
{#if blocks.length === 0}
<div class="hint">Добавляй блоки в нужном порядке.</div>
{/if}
{#each decoratedBlocks as item, index (item.block.id)}
{#if item.showDivider}
<div class="segment-divider" data-segment={item.segment}>
<span>{item.dividerLabel}</span>
</div>
{/if}
{#if draggingId && placeholderIndex === index && draggingItem}
<div role="listitem" use:trackRow={draggingItem.block.id}>
<BlockCard
block={draggingItem.block}
index={index}
femaleIndex={draggingItem.femaleIndex}
maleIndex={draggingItem.maleIndex}
onChange={onChange}
onRemove={onRemove}
onHandlePointerDown={beginPointerDrag(draggingItem.block.id)}
forceCollapse={collapseAll}
{colorizeTitles}
isDragging={true}
/>
</div>
{/if}
{#if item.block.id !== draggingId}
<div
role="listitem"
use:trackRow={item.block.id}
>
<BlockCard
block={item.block}
{index}
femaleIndex={item.femaleIndex}
maleIndex={item.maleIndex}
onChange={onChange}
onRemove={onRemove}
onHandlePointerDown={beginPointerDrag(item.block.id)}
forceCollapse={collapseAll}
{colorizeTitles}
isDragging={false}
/>
</div>
{/if}
{/each}
{#if draggingId && draggingItem && placeholderIndex === decoratedBlocks.length}
<div role="listitem" use:trackRow={draggingItem.block.id}>
<BlockCard
block={draggingItem.block}
index={placeholderIndex - 1}
femaleIndex={draggingItem.femaleIndex}
maleIndex={draggingItem.maleIndex}
onChange={onChange}
onRemove={onRemove}
onHandlePointerDown={beginPointerDrag(draggingItem.block.id)}
forceCollapse={collapseAll}
{colorizeTitles}
isDragging={true}
/>
</div>
{/if}
<div class="add-bottom">
<div class="quick-add">
<button type="button" class="btn icon-only" title="Баннер с ссылкой" on:click={() => onAdd && onAdd("banner")}>
<svg class="icon" viewBox="0 0 16 16" aria-hidden="true">
<rect x="2.5" y="3.5" width="11" height="9" rx="1.4" />
<path d="M3.5 5.5h9" />
<path d="M4.3 10.5 6.6 8l2.1 2.3 1.5-1.3 1.5 1.5" />
<circle cx="5.4" cy="6.8" r="0.6" />
</svg>
</button>
<button type="button" class="btn icon-only" title="Текстовый блок" on:click={() => onAdd && onAdd("paragraph")}>
<svg class="icon" viewBox="0 0 16 16" aria-hidden="true">
<path d="M4 4.5h8" />
<path d="M8 4.5v7.5" />
</svg>
</button>
<button type="button" class="btn icon-only" title="4 товара в ряд" on:click={() => onAdd && onAdd("products4Row")}>
<svg class="icon" viewBox="0 0 16 16" aria-hidden="true">
<rect x="2" y="6" width="2.8" height="4" rx="0.5" />
<rect x="5" y="6" width="2.8" height="4" rx="0.5" />
<rect x="8" y="6" width="2.8" height="4" rx="0.5" />
<rect x="11" y="6" width="2.8" height="4" rx="0.5" />
</svg>
</button>
<button type="button" class="btn icon-only" title="3 товара + картинка слева" on:click={() => onAdd && onAdd("productsImageLeft3")}>
<svg class="icon" viewBox="0 0 16 16" aria-hidden="true">
<rect x="2" y="3" width="5.5" height="10" rx="0.8" />
<rect x="9" y="3.5" width="3" height="2.5" rx="0.5" />
<rect x="9" y="6.5" width="3" height="2.5" rx="0.5" />
<rect x="9" y="9.5" width="3" height="2.5" rx="0.5" />
</svg>
</button>
<button type="button" class="btn icon-only" title="3 товара + картинка справа" on:click={() => onAdd && onAdd("productsImageRight3")}>
<svg class="icon" viewBox="0 0 16 16" aria-hidden="true">
<rect x="9" y="3" width="5.5" height="10" rx="0.8" />
<rect x="4" y="3.5" width="3" height="2.5" rx="0.5" />
<rect x="4" y="6.5" width="3" height="2.5" rx="0.5" />
<rect x="4" y="9.5" width="3" height="2.5" rx="0.5" />
</svg>
</button>
<button type="button" class="btn icon-only" title="Кнопка по центру" on:click={() => onAdd && onAdd("buttonSingle")}>
<svg class="icon" viewBox="0 0 16 16" aria-hidden="true">
<rect x="3" y="6.5" width="10" height="3" rx="1.2" />
</svg>
</button>
</div>
<select
class="btn add-bottom-select"
bind:value={addValue}
on:change={(e) => {
const val = e.target.value;
if (val && onAdd) onAdd(val);
addValue = "";
}}
>
<option value="">+ Добавить блок</option>
{#each blockGroups as group}
<optgroup label={group.label}>
{#each group.options as option}
<option value={option.value}>{option.label}</option>
{/each}
</optgroup>
{/each}
</select>
</div>
</div>
<style>
.add-bottom {
padding: 12px 4px 18px;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 6px;
}
.add-bottom-select {
width: auto;
min-width: 180px;
max-width: 220px;
justify-content: center;
font-weight: 600;
letter-spacing: 0.01em;
appearance: none;
text-align: center;
padding-inline: 10px;
}
.quick-add {
display: inline-flex;
align-items: center;
gap: 6px;
}
.quick-add .btn.icon-only {
display: flex;
align-items: center;
justify-content: center;
gap: 0;
width: 34px;
min-width: 34px;
height: 32px;
padding: 0;
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 10px;
transition: border-color 140ms ease, background 140ms ease, color 140ms ease;
}
.quick-add .btn.icon-only .icon {
width: 22px;
height: 22px;
display: block;
}
.quick-add .btn.icon-only .icon rect,
.quick-add .btn.icon-only .icon path {
stroke: currentColor;
stroke-width: 0.6;
fill: none;
}
.quick-add .btn.icon-only:hover {
border-color: var(--accent);
background: var(--pill);
color: var(--text);
}
</style>

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 130 KiB

View File

@@ -0,0 +1,44 @@
<script>
export let block;
export let onChange;
const update = (patch) => onChange({ ...block.data, ...patch });
let showAdv = false;
</script>
<label>Ссылка (href)
<input type="text" value={block.data.href ?? ""} on:input={(e) => update({ href: e.target.value })} />
</label>
<label>Имя файла изображения (без расширения)
<input type="text" value={block.data.imageBaseName ?? ""} on:input={(e) => update({ imageBaseName: e.target.value })} />
</label>
<div class="advanced-wrapper">
<button class="btn-advanced-toggle" class:is-open={showAdv} type="button" on:click={() => (showAdv = !showAdv)}>
Доп. настройки
</button>
{#if showAdv}
<div class="advanced-panel">
<label>Расширение файла
<input type="text" value={block.data.imageExtension ?? ".png"} on:input={(e) => update({ imageExtension: e.target.value })} />
</label>
<label>
Отступ снизу (px)
<input
type="number"
value={block.data.bottomSpacing ?? 40}
on:input={(e) => update({ bottomSpacing: parseInt(e.target.value, 10) || 0 })}
/>
</label>
<label class="inline">
<input
type="checkbox"
checked={block.data.removeBottomSpacing}
on:change={(e) => update({ removeBottomSpacing: e.target.checked })}
/>
Убрать отступ после блока
</label>
</div>
{/if}
</div>

View File

@@ -0,0 +1,58 @@
<script>
export let block;
export let onChange;
const update = (patch) => onChange({ ...block.data, ...patch });
let showAdv = false;
</script>
<label>Имя файла (без расширения)
<input type="text" value={block.data.imageBaseName ?? ""} on:input={(e) => update({ imageBaseName: e.target.value })} />
</label>
<div class="advanced-wrapper">
<button class="btn-advanced-toggle" class:is-open={showAdv} type="button" on:click={() => (showAdv = !showAdv)}>
Доп. настройки
</button>
{#if showAdv}
<div class="advanced-panel">
<label>Расширение файла
<input type="text" value={block.data.imageExtension ?? ".png"} on:input={(e) => update({ imageExtension: e.target.value })} />
</label>
<label>Высота баннера (px)
<input
type="number"
value={block.data.height ?? 293}
on:input={(e) => update({ height: parseInt(e.target.value, 10) || 0 })}
/>
</label>
<label>Отступ сверху (px)
<input
type="number"
value={block.data.topSpacing ?? 40}
on:input={(e) => update({ topSpacing: parseInt(e.target.value, 10) || 0 })}
/>
</label>
<label>
Отступ снизу (px)
<input
type="number"
value={block.data.bottomSpacing ?? 0}
on:input={(e) => update({ bottomSpacing: parseInt(e.target.value, 10) || 0 })}
/>
</label>
<label class="inline">
<input
type="checkbox"
checked={block.data.removeBottomSpacing}
on:change={(e) => update({ removeBottomSpacing: e.target.checked })}
/>
Убрать отступ после блока
</label>
</div>
{/if}
</div>

View File

@@ -0,0 +1,81 @@
<script>
export let block;
export let onChange;
let showAdv = false;
const update = (patch) => onChange({ ...block.data, ...patch });
</script>
<label>Левая кнопка — текст
<input type="text" value={block.data.leftText ?? ""} on:input={(e) => update({ leftText: e.target.value })} />
</label>
<label>Левая кнопка — ссылка
<input type="text" value={block.data.leftHref ?? ""} on:input={(e) => update({ leftHref: e.target.value })} />
</label>
<label>Правая кнопка — текст
<input type="text" value={block.data.rightText ?? ""} on:input={(e) => update({ rightText: e.target.value })} />
</label>
<label>Правая кнопка — ссылка
<input type="text" value={block.data.rightHref ?? ""} on:input={(e) => update({ rightHref: e.target.value })} />
</label>
<label class="inline">
<input
type="checkbox"
checked={block.data.removeBottomSpacing}
on:change={(e) => update({ removeBottomSpacing: e.target.checked })}
/>
Убрать отступ после блока
</label>
<div class="advanced-wrapper">
<button class="btn-advanced-toggle" class:is-open={showAdv} type="button" on:click={() => (showAdv = !showAdv)}>
Доп. настройки
</button>
{#if showAdv}
<div class="advanced-panel">
<label>Ширина (px)
<input
type="number"
value={block.data.width ?? 275}
on:input={(e) => update({ width: parseInt(e.target.value, 10) || 0 })}
/>
</label>
<label>Высота (px)
<input
type="number"
value={block.data.height ?? 45}
on:input={(e) => update({ height: parseInt(e.target.value, 10) || 0 })}
/>
</label>
<label>Отступ между кнопками (px)
<input
type="number"
value={block.data.gap ?? 20}
on:input={(e) => update({ gap: parseInt(e.target.value, 10) || 0 })}
/>
</label>
<label>Цвет фона (#hex)
<input type="text" value={block.data.bgColor ?? "#242424"} on:input={(e) => update({ bgColor: e.target.value })} />
</label>
<label>Цвет текста (#hex)
<input type="text" value={block.data.textColor ?? "#ffffff"} on:input={(e) => update({ textColor: e.target.value })} />
</label>
<label>Размер шрифта (px)
<input
type="number"
value={block.data.fontSize ?? 16}
on:input={(e) => update({ fontSize: parseInt(e.target.value, 10) || 0 })}
/>
</label>
<label>
Отступ снизу (px)
<input
type="number"
value={block.data.bottomSpacing ?? 40}
on:input={(e) => update({ bottomSpacing: parseInt(e.target.value, 10) || 0 })}
/>
</label>
</div>
{/if}
</div>

View File

@@ -0,0 +1,76 @@
<script>
export let block;
export let onChange;
let showAdv = false;
const update = (patch) => onChange({ ...block.data, ...patch });
</script>
<label>
Текст кнопки
<input type="text" value={block.data.text ?? ""} on:input={(e) => update({ text: e.target.value })} />
</label>
<label>
Ссылка (href)
<input type="text" value={block.data.href ?? ""} on:input={(e) => update({ href: e.target.value })} />
</label>
<label class="inline">
<input
type="checkbox"
checked={block.data.removeBottomSpacing}
on:change={(e) => update({ removeBottomSpacing: e.target.checked })}
/>
Убрать отступ после блока
</label>
<div class="advanced-wrapper">
<button class="btn-advanced-toggle" class:is-open={showAdv} type="button" on:click={() => (showAdv = !showAdv)}>
Доп. настройки
</button>
{#if showAdv}
<div class="advanced-panel">
<label>
Ширина (px)
<input
type="number"
value={block.data.width ?? 340}
on:input={(e) => update({ width: parseInt(e.target.value, 10) || 0 })}
/>
</label>
<label>
Высота (px)
<input
type="number"
value={block.data.height ?? 45}
on:input={(e) => update({ height: parseInt(e.target.value, 10) || 0 })}
/>
</label>
<label>
Цвет фона (#hex)
<input type="text" value={block.data.bgColor ?? "#242424"} on:input={(e) => update({ bgColor: e.target.value })} />
</label>
<label>
Цвет текста (#hex)
<input type="text" value={block.data.textColor ?? "#ffffff"} on:input={(e) => update({ textColor: e.target.value })} />
</label>
<label>
Размер шрифта (px)
<input
type="number"
value={block.data.fontSize ?? 16}
on:input={(e) => update({ fontSize: parseInt(e.target.value, 10) || 0 })}
/>
</label>
<label>
Отступ снизу (px)
<input
type="number"
value={block.data.bottomSpacing ?? 40}
on:input={(e) => update({ bottomSpacing: parseInt(e.target.value, 10) || 0 })}
/>
</label>
</div>
{/if}
</div>

View File

@@ -0,0 +1,87 @@
<script>
export let block;
export let onChange;
let showAdv = false;
const update = (patch) => onChange({ ...block.data, ...patch });
</script>
<label>Левая кнопка — текст
<input type="text" value={block.data.leftText ?? ""} on:input={(e) => update({ leftText: e.target.value })} />
</label>
<label>Левая кнопка — ссылка
<input type="text" value={block.data.leftHref ?? ""} on:input={(e) => update({ leftHref: e.target.value })} />
</label>
<label>Средняя кнопка — текст
<input type="text" value={block.data.centerText ?? ""} on:input={(e) => update({ centerText: e.target.value })} />
</label>
<label>Средняя кнопка — ссылка
<input type="text" value={block.data.centerHref ?? ""} on:input={(e) => update({ centerHref: e.target.value })} />
</label>
<label>Правая кнопка — текст
<input type="text" value={block.data.rightText ?? ""} on:input={(e) => update({ rightText: e.target.value })} />
</label>
<label>Правая кнопка — ссылка
<input type="text" value={block.data.rightHref ?? ""} on:input={(e) => update({ rightHref: e.target.value })} />
</label>
<label class="inline">
<input
type="checkbox"
checked={block.data.removeBottomSpacing}
on:change={(e) => update({ removeBottomSpacing: e.target.checked })}
/>
Убрать отступ после блока
</label>
<div class="advanced-wrapper">
<button class="btn-advanced-toggle" class:is-open={showAdv} type="button" on:click={() => (showAdv = !showAdv)}>
Доп. настройки
</button>
{#if showAdv}
<div class="advanced-panel">
<label>Ширина (px)
<input
type="number"
value={block.data.width ?? 174}
on:input={(e) => update({ width: parseInt(e.target.value, 10) || 0 })}
/>
</label>
<label>Высота (px)
<input
type="number"
value={block.data.height ?? 45}
on:input={(e) => update({ height: parseInt(e.target.value, 10) || 0 })}
/>
</label>
<label>Отступ между кнопками (px)
<input
type="number"
value={block.data.gap ?? 24}
on:input={(e) => update({ gap: parseInt(e.target.value, 10) || 0 })}
/>
</label>
<label>Цвет фона (#hex)
<input type="text" value={block.data.bgColor ?? "#242424"} on:input={(e) => update({ bgColor: e.target.value })} />
</label>
<label>Цвет текста (#hex)
<input type="text" value={block.data.textColor ?? "#ffffff"} on:input={(e) => update({ textColor: e.target.value })} />
</label>
<label>Размер шрифта (px)
<input
type="number"
value={block.data.fontSize ?? 16}
on:input={(e) => update({ fontSize: parseInt(e.target.value, 10) || 0 })}
/>
</label>
<label>
Отступ снизу (px)
<input
type="number"
value={block.data.bottomSpacing ?? 40}
on:input={(e) => update({ bottomSpacing: parseInt(e.target.value, 10) || 0 })}
/>
</label>
</div>
{/if}
</div>

View File

@@ -0,0 +1,56 @@
<script>
export let block;
export let onChange;
const update = (patch) => onChange({ ...block.data, ...patch });
let showAdv = false;
</script>
<label>Ширина (px)
<input
type="number"
value={block.data.width ?? 300}
on:input={(e) => update({ width: parseInt(e.target.value, 10) || 0 })}
/>
</label>
<div class="advanced-wrapper">
<button class="btn-advanced-toggle" class:is-open={showAdv} type="button" on:click={() => (showAdv = !showAdv)}>
Доп. настройки
</button>
{#if showAdv}
<div class="advanced-panel">
<label>Высота (px)
<input
type="number"
value={block.data.height ?? 1}
on:input={(e) => update({ height: parseInt(e.target.value, 10) || 0 })}
/>
</label>
<label class="inline">
<input
type="checkbox"
checked={block.data.swapCenter}
on:change={(e) => update({ swapCenter: e.target.checked })}
/>
Использовать как центральный разделитель
</label>
<label>
Отступ снизу (px)
<input
type="number"
value={block.data.bottomSpacing ?? 40}
on:input={(e) => update({ bottomSpacing: parseInt(e.target.value, 10) || 0 })}
/>
</label>
<label class="inline">
<input
type="checkbox"
checked={block.data.removeBottomSpacing}
on:change={(e) => update({ removeBottomSpacing: e.target.checked })}
/>
Убрать отступ после блока
</label>
</div>
{/if}
</div>

View File

@@ -0,0 +1,419 @@
<script>
import { onMount } from "svelte";
export let block;
export let onChange;
let textareaEl;
let showPreview = false;
let showAdv = false;
const update = (patch) => onChange({ ...block.data, ...patch });
let measureCtx;
const PREPOSITIONS = [
"в",
"во",
"без",
"до",
"для",
"за",
"из",
"изо",
"к",
"ко",
"на",
"над",
"о",
"об",
"обо",
"от",
"ото",
"по",
"под",
"подо",
"при",
"про",
"ради",
"с",
"со",
"у",
"через",
"черезо",
"между",
"перед",
"пред",
"около",
"после",
"вне"
];
const PARTICLES = ["не"];
const CONJUNCTIONS = ["и", "а", "но", "да", "или"];
const HANG_BREAK = ["и", "а", "но"]; // только короткие союзы для переноса строки
const HANG_WORDS = [...CONJUNCTIONS, ...PARTICLES, ...PREPOSITIONS];
function ensureMeasureCtx() {
if (!measureCtx) {
const canvas = document.createElement("canvas");
measureCtx = canvas.getContext("2d");
}
measureCtx.font = "18px Helvetica, Arial, sans-serif";
}
function resizeTextarea() {
if (!textareaEl) return;
textareaEl.style.height = "auto";
textareaEl.style.height = `${textareaEl.scrollHeight}px`;
}
function handleInput(e) {
const ta = e.target;
const raw = ta.value;
const newlineBeforePos = (raw.slice(0, ta.selectionStart ?? raw.length).match(/\n/g) || []).length;
const normalized = raw.replace(/\r?\n/g, "<br>");
if (normalized !== raw) {
ta.value = normalized;
const pos = (ta.selectionStart ?? normalized.length) + newlineBeforePos * 3;
ta.selectionStart = ta.selectionEnd = pos;
}
update({ text: normalized });
resizeTextarea();
}
function insertBr() {
if (!textareaEl) return;
const ta = textareaEl;
const insert = "<br>";
const start = ta.selectionStart ?? ta.value.length;
const end = ta.selectionEnd ?? start;
ta.value = ta.value.slice(0, start) + insert + ta.value.slice(end);
const pos = start + insert.length;
ta.selectionStart = ta.selectionEnd = pos;
ta.focus();
update({ text: ta.value });
resizeTextarea();
}
function insertMdash() {
if (!textareaEl) return;
const ta = textareaEl;
const insert = "&mdash;";
const start = ta.selectionStart ?? ta.value.length;
const end = ta.selectionEnd ?? start;
ta.value = ta.value.slice(0, start) + insert + ta.value.slice(end);
const pos = start + insert.length;
ta.selectionStart = ta.selectionEnd = pos;
ta.focus();
update({ text: ta.value });
resizeTextarea();
}
function makeBold() {
if (!textareaEl) return;
const ta = textareaEl;
const start = ta.selectionStart ?? 0;
const end = ta.selectionEnd ?? 0;
const before = ta.value.slice(0, start);
const selected = ta.value.slice(start, end);
const after = ta.value.slice(end);
const open = '<span style="font-weight:700;">';
const close = "</span>";
let next;
if (start !== end) {
next = before + open + selected + close + after;
ta.value = next;
ta.selectionStart = start + open.length;
ta.selectionEnd = start + open.length + selected.length;
} else {
const insert = open + close;
next = before + insert + after;
ta.value = next;
const pos = start + open.length;
ta.selectionStart = ta.selectionEnd = pos;
}
ta.focus();
update({ text: ta.value });
resizeTextarea();
}
function applyTypograph() {
if (!textareaEl) return;
const ta = textareaEl;
let text = ta.value;
text = text
.replace(/\.\.\./g, "…")
.replace(/(^|[\s(])"([^"]+)"/g, '$1«$2»')
.replace(/--/g, "—")
.replace(/\s-\s/g, " &mdash; ")
.replace(
new RegExp(`(^|[\\s(])(${[...PREPOSITIONS, ...PARTICLES, ...CONJUNCTIONS].join("|")})\\s+`, "giu"),
(_, prefix, word) => `${prefix}${word}&nbsp;`
)
.replace(/\u00A0\s+/g, "\u00A0")
.replace(/ {2,}/g, " ")
.replace(/\s+,/g, ",")
.replace(/\s+\./g, ".")
.replace(/\s+!/g, "!")
.replace(/\s+\?/g, "?")
.replace(/\s+:/g, ":");
ta.value = text;
const pos = ta.selectionEnd ?? text.length;
ta.selectionStart = ta.selectionEnd = pos;
ta.focus();
update({ text });
resizeTextarea();
}
function autoWrapBr() {
if (!textareaEl) return;
ensureMeasureCtx();
const NBSP = "\u00A0";
const limit = 520; // preview width minus padding
const spaceWidth = measureCtx.measureText(" ").width;
const raw = textareaEl.value
.replace(/&nbsp;/g, NBSP)
.replace(/\u00A0\s+/g, NBSP)
.replace(/ {2,}/g, " ")
.trim();
if (!raw) return;
const tokens = raw.split(/(<[^>]+>)/).filter(Boolean);
const lines = [];
const linesTokens = [];
let currentTokens = [];
let currentWidth = 0;
const pushLine = () => {
const lineStr = currentTokens.join("").trimEnd().replace(new RegExp(NBSP, "g"), "&nbsp;");
lines.push(lineStr);
linesTokens.push([...currentTokens]);
currentTokens = [];
currentWidth = 0;
};
const isTag = (t) => /^<[^>]+>$/.test(t);
const hasTrailingSpace = () => {
for (let i = currentTokens.length - 1; i >= 0; i--) {
const t = currentTokens[i];
if (isTag(t)) continue;
return /[ \u00A0]$/.test(t);
}
return false;
};
const getLastWordToken = () => {
for (let i = currentTokens.length - 1; i >= 0; i--) {
const t = currentTokens[i];
if (isTag(t)) continue;
const trimmed = t.trim();
if (trimmed) {
return { index: i, token: t };
}
}
return null;
};
tokens.forEach((token) => {
const isBr = /^<\s*br\s*\/?\s*>$/i.test(token);
if (isBr) {
if (currentTokens.length) pushLine();
return;
}
if (isTag(token)) {
currentTokens.push(token);
return;
}
const textChunk = token;
// Разбиваем только по обычным пробелам/переводам строк, NBSP остаётся внутри слов
const words = textChunk.split(/[ \t\r\n]+/).filter(Boolean);
words.forEach((word) => {
const plain = word.replace(new RegExp(NBSP, "g"), " ");
const wordWidth = measureCtx.measureText(plain).width;
// Знаки препинания присоединяем к предыдущему слову без пробела
const isPunct = /^[-–—!?.,:;]+$/.test(plain.trim());
if (isPunct) {
if (currentTokens.length) {
currentTokens.push(word);
currentWidth += wordWidth;
return;
}
if (lines.length) {
const last = lines.pop() || "";
lines.push((last + word).replace(new RegExp(NBSP, "g"), "&nbsp;"));
return;
}
currentTokens.push(word);
currentWidth += wordWidth;
return;
}
const needSpace = currentTokens.length > 0 && !hasTrailingSpace();
const nextWidth = currentWidth + (needSpace ? spaceWidth : 0) + wordWidth;
if (nextWidth > limit && currentTokens.length) {
// если последним словом был висячий — переносим его на новую строку
const lastWord = getLastWordToken();
let carried = null;
if (lastWord) {
const lastPlain = lastWord.token.replace(new RegExp(NBSP, "g"), " ").trim().toLowerCase();
if (HANG_WORDS.includes(lastPlain)) {
// убираем последний токен и возможный пробел перед ним
currentTokens.splice(lastWord.index, 1);
currentWidth -= measureCtx.measureText(lastPlain).width;
if (currentTokens.length && currentTokens[currentTokens.length - 1] === " ") {
currentTokens.pop();
currentWidth -= spaceWidth;
}
carried = lastWord.token;
}
}
pushLine();
if (carried) {
currentTokens.push(carried);
currentWidth += measureCtx.measureText(
carried.replace(new RegExp(NBSP, "g"), " ")
).width;
}
}
if (needSpace && currentTokens.length) {
currentTokens.push(" ");
currentWidth += spaceWidth;
}
currentTokens.push(word);
currentWidth += wordWidth;
});
});
if (currentTokens.length) pushLine();
const countWords = (tokens) =>
tokens.reduce((acc, t) => {
if (!isTag(t) && t.trim()) return acc + 1;
return acc;
}, 0);
const popLastWord = (tokens) => {
let collected = [];
while (tokens.length) {
const t = tokens.pop();
if (isTag(t)) {
collected.unshift(t);
continue;
}
if (!t.trim()) {
continue;
}
collected.unshift(t);
break;
}
while (tokens.length && !tokens[tokens.length - 1].trim()) {
tokens.pop();
}
return collected.length ? collected : null;
};
if (linesTokens.length >= 2) {
const lastTokens = linesTokens[linesTokens.length - 1];
const prevTokens = linesTokens[linesTokens.length - 2];
if (countWords(lastTokens) === 1 && countWords(prevTokens) > 1) {
const moved = popLastWord(prevTokens);
if (moved) {
if (prevTokens.length && !/[ \u00A0]$/.test(prevTokens[prevTokens.length - 1] || "")) {
moved.unshift(" ");
}
lastTokens.unshift(...moved);
}
}
for (let i = 0; i < linesTokens.length; i++) {
lines[i] = linesTokens[i].join("").trimEnd().replace(new RegExp(NBSP, "g"), "&nbsp;");
}
}
const wrapped = lines.join("<br>");
const clean = wrapped.replace(/\s*<br\s*\/?>\s*/gi, "<br>");
textareaEl.value = clean;
const pos = textareaEl.value.length;
textareaEl.selectionStart = textareaEl.selectionEnd = pos;
textareaEl.focus();
update({ text: clean });
resizeTextarea();
}
onMount(() => {
resizeTextarea();
});
</script>
<label>
Текст блока
<textarea
bind:this={textareaEl}
class="paragraph-textarea"
value={block.data.text ?? ""}
on:input={handleInput}
/>
</label>
<div class="btn-inline-row">
<button class="btn-inline" type="button" on:click={insertBr}>&lt;br&gt;</button>
<button class="btn-inline" type="button" on:click={makeBold}>Жирный</button>
<button class="btn-inline" type="button" on:click={insertMdash}>&mdash;</button>
<button class="btn-inline" type="button" on:click={applyTypograph}>Типограф</button>
<button class="btn-inline" type="button" on:click={autoWrapBr}>Авто &lt;br&gt;</button>
<button
class="btn-inline push-right"
class:active={showPreview}
type="button"
aria-pressed={showPreview}
on:click={() => (showPreview = !showPreview)}
>
HTML
</button>
</div>
{#if showPreview}
<div class="block-preview paragraph-preview" aria-live="polite">
{@html block.data.text || ""}
</div>
{/if}
<div class="advanced-wrapper">
<button class="btn-advanced-toggle" class:is-open={showAdv} type="button" on:click={() => (showAdv = !showAdv)}>
Доп. настройки
</button>
{#if showAdv}
<div class="advanced-panel">
<label>
Отступ снизу (px)
<input
type="number"
value={block.data.bottomSpacing ?? 40}
on:input={(e) => update({ bottomSpacing: parseInt(e.target.value, 10) || 0 })}
/>
</label>
</div>
{/if}
</div>
<label class="inline">
<input
type="checkbox"
checked={block.data.removeBottomSpacing}
on:change={(e) => update({ removeBottomSpacing: e.target.checked })}
/>
Убрать отступ после блока
</label>

View File

@@ -0,0 +1,55 @@
<script>
export let block;
export let onChange;
const update = (patch) => onChange({ ...block.data, ...patch });
let showAdv = false;
</script>
<label>ID товаров (через запятую)
<input type="text" value={block.data.productIds ?? ""} on:input={(e) => update({ productIds: e.target.value })} />
</label>
<label>Ссылка на картинку (href)
<input type="text" value={block.data.link ?? ""} on:input={(e) => update({ link: e.target.value })} />
</label>
<label>Имя файла картинки
<input type="text" value={block.data.imageBaseName ?? ""} on:input={(e) => update({ imageBaseName: e.target.value })} />
</label>
<div class="advanced-wrapper">
<button class="btn-advanced-toggle" class:is-open={showAdv} type="button" on:click={() => (showAdv = !showAdv)}>
Доп. настройки
</button>
{#if showAdv}
<div class="advanced-panel">
<label>Расширение файла
<input type="text" value={block.data.imageExtension ?? ".png"} on:input={(e) => update({ imageExtension: e.target.value })} />
</label>
<label>Ширина картинки (px)
<input
type="number"
value={block.data.imgWidth ?? 275}
on:input={(e) => update({ imgWidth: parseInt(e.target.value, 10) || 0 })}
/>
</label>
<label>
Отступ снизу (px)
<input
type="number"
value={block.data.bottomSpacing ?? 40}
on:input={(e) => update({ bottomSpacing: parseInt(e.target.value, 10) || 0 })}
/>
</label>
</div>
{/if}
</div>
<label class="inline">
<input
type="checkbox"
checked={block.data.removeBottomSpacing}
on:change={(e) => update({ removeBottomSpacing: e.target.checked })}
/>
Убрать отступ после блока
</label>

View File

@@ -0,0 +1,38 @@
<script>
export let block;
export let onChange;
const update = (patch) => onChange({ ...block.data, ...patch });
let showAdv = false;
</script>
<label>ID товаров (через запятую)
<input type="text" value={block.data.productIds ?? ""} on:input={(e) => update({ productIds: e.target.value })} />
</label>
<div class="advanced-wrapper">
<button class="btn-advanced-toggle" class:is-open={showAdv} type="button" on:click={() => (showAdv = !showAdv)}>
Доп. настройки
</button>
{#if showAdv}
<div class="advanced-panel">
<label>
Отступ снизу (px)
<input
type="number"
value={block.data.bottomSpacing ?? 40}
on:input={(e) => update({ bottomSpacing: parseInt(e.target.value, 10) || 0 })}
/>
</label>
</div>
{/if}
</div>
<label class="inline">
<input
type="checkbox"
checked={block.data.removeBottomSpacing}
on:change={(e) => update({ removeBottomSpacing: e.target.checked })}
/>
Убрать отступ после блока
</label>

View File

@@ -0,0 +1,38 @@
<script>
export let block;
export let onChange;
const update = (patch) => onChange({ ...block.data, ...patch });
let showAdv = false;
</script>
<label>Промокод
<input type="text" value={block.data.code ?? ""} on:input={(e) => update({ code: e.target.value })} />
</label>
<div class="advanced-wrapper">
<button class="btn-advanced-toggle" class:is-open={showAdv} type="button" on:click={() => (showAdv = !showAdv)}>
Доп. настройки
</button>
{#if showAdv}
<div class="advanced-panel">
<label>
Отступ снизу (px)
<input
type="number"
value={block.data.bottomSpacing ?? 40}
on:input={(e) => update({ bottomSpacing: parseInt(e.target.value, 10) || 0 })}
/>
</label>
</div>
{/if}
</div>
<label class="inline">
<input
type="checkbox"
checked={block.data.removeBottomSpacing}
on:change={(e) => update({ removeBottomSpacing: e.target.checked })}
/>
Убрать отступ после блока
</label>

View File

@@ -0,0 +1,42 @@
<script>
export let block;
export let onChange;
const update = (patch) => onChange({ ...block.data, ...patch });
let showAdv = false;
</script>
<label>Размеры (через запятую)
<input type="text" value={block.data.sizes ?? ""} on:input={(e) => update({ sizes: e.target.value })} />
</label>
<label>Ссылки (через запятую)
<input type="text" value={block.data.links ?? ""} on:input={(e) => update({ links: e.target.value })} />
</label>
<div class="advanced-wrapper">
<button class="btn-advanced-toggle" class:is-open={showAdv} type="button" on:click={() => (showAdv = !showAdv)}>
Доп. настройки
</button>
{#if showAdv}
<div class="advanced-panel">
<label>
Отступ снизу (px)
<input
type="number"
value={block.data.bottomSpacing ?? 20}
on:input={(e) => update({ bottomSpacing: parseInt(e.target.value, 10) || 0 })}
/>
</label>
</div>
{/if}
</div>
<label class="inline">
<input
type="checkbox"
checked={block.data.removeBottomSpacing}
on:change={(e) => update({ removeBottomSpacing: e.target.checked })}
/>
Убрать отступ после блока
</label>

View File

@@ -0,0 +1,15 @@
<script>
export let block;
export let onChange;
const update = (patch) => onChange({ ...block.data, ...patch });
</script>
<label>
Высота (px)
<input
type="number"
value={block.data.height ?? 40}
on:input={(e) => update({ height: parseInt(e.target.value, 10) || 0 })}
/>
</label>

View File

@@ -0,0 +1,77 @@
<script>
export let block;
export let onChange;
const update = (patch) => onChange({ ...block.data, ...patch });
let showAdv = false;
</script>
<label>Ссылка (href)
<input type="text" value={block.data.link ?? ""} on:input={(e) => update({ link: e.target.value })} />
</label>
<label>Имя файла картинки
<input type="text" value={block.data.imageBaseName ?? ""} on:input={(e) => update({ imageBaseName: e.target.value })} />
</label>
<div class="advanced-wrapper">
<button class="btn-advanced-toggle" class:is-open={showAdv} type="button" on:click={() => (showAdv = !showAdv)}>
Доп. настройки
</button>
{#if showAdv}
<div class="advanced-panel">
<label>Расширение файла
<input type="text" value={block.data.imageExtension ?? ".png"} on:input={(e) => update({ imageExtension: e.target.value })} />
</label>
<label>Ширина картинки (px)
<input
type="number"
value={block.data.imgWidth ?? 264}
on:input={(e) => update({ imgWidth: parseInt(e.target.value, 10) || 0 })}
/>
</label>
<label>Высота картинки (px)
<input
type="number"
value={block.data.imgHeight ?? 330}
on:input={(e) => update({ imgHeight: parseInt(e.target.value, 10) || 0 })}
/>
</label>
<label>
Отступ снизу (px)
<input
type="number"
value={block.data.bottomSpacing ?? 20}
on:input={(e) => update({ bottomSpacing: parseInt(e.target.value, 10) || 0 })}
/>
</label>
</div>
{/if}
</div>
<label>Заголовок внутри блока
<input type="text" value={block.data.header ?? ""} on:input={(e) => update({ header: e.target.value })} />
</label>
<label>
Текст внутри блока
<textarea value={block.data.text ?? ""} on:input={(e) => update({ text: e.target.value })}></textarea>
</label>
<label>Текст кнопки
<input type="text" value={block.data.buttonText ?? ""} on:input={(e) => update({ buttonText: e.target.value })} />
</label>
<label>Ссылка кнопки
<input type="text" value={block.data.buttonHref ?? ""} on:input={(e) => update({ buttonHref: e.target.value })} />
</label>
<label class="inline">
<input
type="checkbox"
checked={block.data.removeBottomSpacing}
on:change={(e) => update({ removeBottomSpacing: e.target.checked })}
/>
Убрать отступ после блока
</label>

View File

@@ -0,0 +1,80 @@
<script>
export let block;
export let onChange;
const update = (patch) => onChange({ ...block.data, ...patch });
let showAdv = false;
</script>
<label>Баннер 1 — ссылка
<input type="text" value={block.data.href1 ?? ""} on:input={(e) => update({ href1: e.target.value })} />
</label>
<label>Баннер 1 — имя файла
<input type="text" value={block.data.imgBaseName1 ?? ""} on:input={(e) => update({ imgBaseName1: e.target.value })} />
</label>
<label>Баннер 2 — ссылка
<input type="text" value={block.data.href2 ?? ""} on:input={(e) => update({ href2: e.target.value })} />
</label>
<label>Баннер 2 — имя файла
<input type="text" value={block.data.imgBaseName2 ?? ""} on:input={(e) => update({ imgBaseName2: e.target.value })} />
</label>
<label>Баннер 3 — ссылка
<input type="text" value={block.data.href3 ?? ""} on:input={(e) => update({ href3: e.target.value })} />
</label>
<label>Баннер 3 — имя файла
<input type="text" value={block.data.imgBaseName3 ?? ""} on:input={(e) => update({ imgBaseName3: e.target.value })} />
</label>
<div class="advanced-wrapper">
<button class="btn-advanced-toggle" class:is-open={showAdv} type="button" on:click={() => (showAdv = !showAdv)}>
Доп. настройки
</button>
{#if showAdv}
<div class="advanced-panel">
<label>Баннер 1 — расширение
<input type="text" value={block.data.imgExtension1 ?? ".png"} on:input={(e) => update({ imgExtension1: e.target.value })} />
</label>
<label>Баннер 2 — расширение
<input type="text" value={block.data.imgExtension2 ?? ".png"} on:input={(e) => update({ imgExtension2: e.target.value })} />
</label>
<label>Баннер 3 — расширение
<input type="text" value={block.data.imgExtension3 ?? ".png"} on:input={(e) => update({ imgExtension3: e.target.value })} />
</label>
<label>Ширина баннеров (px)
<input
type="number"
value={block.data.width ?? 170}
on:input={(e) => update({ width: parseInt(e.target.value, 10) || 0 })}
/>
</label>
<label>Отступ между баннерами (px)
<input
type="number"
value={block.data.gap ?? 30}
on:input={(e) => update({ gap: parseInt(e.target.value, 10) || 0 })}
/>
</label>
<label>
Отступ снизу (px)
<input
type="number"
value={block.data.bottomSpacing ?? 40}
on:input={(e) => update({ bottomSpacing: parseInt(e.target.value, 10) || 0 })}
/>
</label>
<label class="inline">
<input
type="checkbox"
checked={block.data.removeBottomSpacing}
on:change={(e) => update({ removeBottomSpacing: e.target.checked })}
/>
Убрать отступ после блока
</label>
</div>
{/if}
</div>

View File

@@ -0,0 +1,62 @@
<script>
export let block;
export let onChange;
let textareaEl;
let showAdv = false;
const update = (patch) => onChange({ ...block.data, ...patch });
function insertBr() {
if (!textareaEl) return;
const ta = textareaEl;
const insert = "<br>";
const start = ta.selectionStart ?? ta.value.length;
const end = ta.selectionEnd ?? start;
ta.value = ta.value.slice(0, start) + insert + ta.value.slice(end);
const pos = start + insert.length;
ta.selectionStart = ta.selectionEnd = pos;
ta.focus();
update({ text: ta.value });
}
</script>
<label>
Текст актуального заголовка
<textarea
class="singleline-textarea"
rows="1"
bind:this={textareaEl}
value={block.data.text ?? ""}
on:input={(e) => update({ text: e.target.value })}
/>
</label>
<button class="btn-inline" type="button" on:click={insertBr}>&lt;br&gt;</button>
<div class="advanced-wrapper">
<button class="btn-advanced-toggle" class:is-open={showAdv} type="button" on:click={() => (showAdv = !showAdv)}>
Доп. настройки
</button>
{#if showAdv}
<div class="advanced-panel">
<label>
Отступ снизу (px)
<input
type="number"
bind:value={block.data.bottomSpacing}
on:input={(e) => update({ bottomSpacing: parseInt(e.target.value, 10) || 0 })}
/>
</label>
</div>
{/if}
</div>
<label class="inline">
<input
type="checkbox"
checked={block.data.removeBottomSpacing}
on:change={(e) => update({ removeBottomSpacing: e.target.checked })}
/>
Убрать отступ после блока
</label>

View File

@@ -0,0 +1,70 @@
<script>
export let block;
export let onChange;
const update = (patch) => onChange({ ...block.data, ...patch });
let showAdv = false;
</script>
<label>Левый баннер — ссылка
<input type="text" value={block.data.leftHref ?? ""} on:input={(e) => update({ leftHref: e.target.value })} />
</label>
<label>Левый баннер — имя файла
<input type="text" value={block.data.leftImageBaseName ?? ""} on:input={(e) => update({ leftImageBaseName: e.target.value })} />
</label>
<label>Правый баннер — ссылка
<input type="text" value={block.data.rightHref ?? ""} on:input={(e) => update({ rightHref: e.target.value })} />
</label>
<label>Правый баннер — имя файла
<input type="text" value={block.data.rightImageBaseName ?? ""} on:input={(e) => update({ rightImageBaseName: e.target.value })} />
</label>
<div class="advanced-wrapper">
<button class="btn-advanced-toggle" class:is-open={showAdv} type="button" on:click={() => (showAdv = !showAdv)}>
Доп. настройки
</button>
{#if showAdv}
<div class="advanced-panel">
<label>Левый баннер — расширение
<input type="text" value={block.data.leftImageExtension ?? ".png"} on:input={(e) => update({ leftImageExtension: e.target.value })} />
</label>
<label>Правый баннер — расширение
<input type="text" value={block.data.rightImageExtension ?? ".png"} on:input={(e) => update({ rightImageExtension: e.target.value })} />
</label>
<label>Ширина баннеров (px)
<input
type="number"
value={block.data.width ?? 270}
on:input={(e) => update({ width: parseInt(e.target.value, 10) || 0 })}
/>
</label>
<label>Отступ между баннерами (px)
<input
type="number"
value={block.data.gap ?? 30}
on:input={(e) => update({ gap: parseInt(e.target.value, 10) || 0 })}
/>
</label>
<label>
Отступ снизу (px)
<input
type="number"
value={block.data.bottomSpacing ?? 40}
on:input={(e) => update({ bottomSpacing: parseInt(e.target.value, 10) || 0 })}
/>
</label>
<label class="inline">
<input
type="checkbox"
checked={block.data.removeBottomSpacing}
on:change={(e) => update({ removeBottomSpacing: e.target.checked })}
/>
Убрать отступ после блока
</label>
</div>
{/if}
</div>

View File

@@ -0,0 +1,77 @@
<script>
export let block;
export let onChange;
const update = (patch) => onChange({ ...block.data, ...patch });
let showAdv = false;
</script>
<label>Левый баннер — ссылка
<input type="text" value={block.data.leftHref ?? ""} on:input={(e) => update({ leftHref: e.target.value })} />
</label>
<label>Левый баннер — имя файла
<input type="text" value={block.data.leftImageBaseName ?? ""} on:input={(e) => update({ leftImageBaseName: e.target.value })} />
</label>
<label>Левый баннер — текст
<input type="text" value={block.data.leftText ?? ""} on:input={(e) => update({ leftText: e.target.value })} />
</label>
<label>Правый баннер — ссылка
<input type="text" value={block.data.rightHref ?? ""} on:input={(e) => update({ rightHref: e.target.value })} />
</label>
<label>Правый баннер — имя файла
<input type="text" value={block.data.rightImageBaseName ?? ""} on:input={(e) => update({ rightImageBaseName: e.target.value })} />
</label>
<label>Правый баннер — текст
<input type="text" value={block.data.rightText ?? ""} on:input={(e) => update({ rightText: e.target.value })} />
</label>
<div class="advanced-wrapper">
<button class="btn-advanced-toggle" class:is-open={showAdv} type="button" on:click={() => (showAdv = !showAdv)}>
Доп. настройки
</button>
{#if showAdv}
<div class="advanced-panel">
<label>Левый баннер — расширение
<input type="text" value={block.data.leftImageExtension ?? ".png"} on:input={(e) => update({ leftImageExtension: e.target.value })} />
</label>
<label>Правый баннер — расширение
<input type="text" value={block.data.rightImageExtension ?? ".png"} on:input={(e) => update({ rightImageExtension: e.target.value })} />
</label>
<label>Ширина баннеров (px)
<input
type="number"
value={block.data.width ?? 270}
on:input={(e) => update({ width: parseInt(e.target.value, 10) || 0 })}
/>
</label>
<label>Отступ между баннерами (px)
<input
type="number"
value={block.data.gap ?? 30}
on:input={(e) => update({ gap: parseInt(e.target.value, 10) || 0 })}
/>
</label>
<label>
Отступ снизу (px)
<input
type="number"
value={block.data.bottomSpacing ?? 40}
on:input={(e) => update({ bottomSpacing: parseInt(e.target.value, 10) || 0 })}
/>
</label>
<label class="inline">
<input
type="checkbox"
checked={block.data.removeBottomSpacing}
on:change={(e) => update({ removeBottomSpacing: e.target.checked })}
/>
Убрать отступ после блока
</label>
</div>
{/if}
</div>

View File

@@ -0,0 +1,613 @@
export function buildImageUrl(baseName, ext, fallbackUrl, base) {
if (baseName) {
const safeBase = base ? base.replace(/\/?$/, "/") : "";
const safeExt = ext && ext.trim() ? ext.trim() : ".png";
const finalExt = safeExt.startsWith(".") ? safeExt : "." + safeExt;
return safeBase + baseName + finalExt;
}
if (fallbackUrl) return fallbackUrl;
return "";
}
export function renderBlockToPug(block, settings) {
const d = block.data || {};
switch (block.type) {
case "spacer": {
const h = d.height ?? 40;
return `+spacerLine(${h})`;
}
case "titleActual": {
const text = d.text || "";
const top = d.topSpacing ?? 40;
const bottom = d.bottomSpacing ?? 20;
const addTop = !d.removeTopSpacing;
const addBottom = !d.removeBottomSpacing;
let res = "";
if (addTop) {
res += `+spacerLine(${top})\n`;
}
res +=
"tr\n" +
" td.paddingWrapper \n" +
' +defaultTable("100%")\n' +
" tr \n" +
' td(align="center")\n' +
` span.font.h2.blackText.uppercase ${text}`;
if (addBottom) {
res += `\n+spacerLine(${bottom})`;
}
return res;
}
case "paragraph": {
const text = d.text || "";
const bottom = d.bottomSpacing ?? 40;
const addSpacing = !d.removeBottomSpacing;
let res =
"tr \n" +
" td.paddingWrapper \n" +
' +defaultTable("100%")\n' +
" tr \n" +
' td(align="center")\n' +
` span.font.h3.blackText ${text}`;
if (addSpacing) {
res += `\n+spacerLine(${bottom})`;
}
return res;
}
case "buttonSingle": {
const t = d.text || "";
const href = d.href || "#";
const w = d.width ?? 340;
const h = d.height ?? 45;
const bg = d.bgColor || "#242424";
const fs = d.fontSize ?? 16;
const color = d.textColor || "#ffffff";
const bottom = d.bottomSpacing ?? 40;
const addSpacing = !d.removeBottomSpacing;
let res =
"////Блок с кнопкой посередине\n" +
"tr\n" +
" td.headerWrapper\n" +
' +defaultTable("100%")\n' +
" tr\n" +
' td(align="center")\n' +
` +buttonRounded("${t}", "${href}", ${w}, ${h}, "${bg}", ${fs}, "${color}", 3, "#000000").bold.font.uppercase.letter`;
if (addSpacing) {
res += `\n+spacerLine(${bottom})`;
}
return res;
}
case "buttonDouble": {
const {
leftText,
leftHref,
rightText,
rightHref,
width,
height,
gap,
bgColor,
fontSize,
textColor,
bottomSpacing,
removeBottomSpacing
} = d;
const w = width ?? 275;
const h = height ?? 45;
const g = gap ?? 20;
const bg = bgColor || "#242424";
const fs = fontSize ?? 16;
const color = textColor || "#ffffff";
const bottom = bottomSpacing ?? 40;
const addSpacing = !removeBottomSpacing;
let res =
"//Блок с 2мя кнопками\n\n" +
"tr \n" +
" td.paddingWrapper\n" +
' +defaultTable("100%")\n' +
" tr \n" +
' td(align="right") \n' +
` +buttonRounded("${leftText || ""}", "${leftHref ||
"#"}", ${w}, ${h}, "${bg}", ${fs}, "${color}", 3, "#000000").bold.font.uppercase.letter\n` +
` +tdFixed(${g})\n` +
' td(align="left") \n' +
` +buttonRounded("${rightText || ""}", "${rightHref ||
"#"}", ${w}, ${h}, "${bg}", ${fs}, "${color}", 3, "#000000").bold.font.uppercase.letter`;
if (addSpacing) {
res += `\n+spacerLine(${bottom})`;
}
return res;
}
case "buttonTriple": {
const {
leftText,
leftHref,
centerText,
centerHref,
rightText,
rightHref,
width,
height,
gap,
bgColor,
fontSize,
textColor,
bottomSpacing,
removeBottomSpacing
} = d;
const w = width ?? 174;
const h = height ?? 45;
const g = gap ?? 24;
const bg = bgColor || "#242424";
const fs = fontSize ?? 16;
const color = textColor || "#ffffff";
const bottom = bottomSpacing ?? 40;
const addSpacing = !removeBottomSpacing;
let res =
"//Блок с 3мя кнопками\n\n" +
"tr \n" +
" td.paddingWrapper \n" +
' +defaultTable("100%")\n' +
" tr \n" +
" td \n" +
` +buttonRounded("${leftText || ""}", "${leftHref ||
"#"}", ${w}, ${h}, "${bg}", ${fs}, "${color}", 3, "#000000").bold.font.uppercase.letter\n` +
` +tdFixed(${g})\n` +
" td \n" +
` +buttonRounded("${centerText || ""}", "${centerHref ||
"#"}", ${w}, ${h}, "${bg}", ${fs}, "${color}", 3, "#000000").bold.font.uppercase.letter\n` +
` +tdFixed(${g})\n` +
" td \n" +
` +buttonRounded("${rightText || ""}", "${rightHref ||
"#"}", ${w}, ${h}, "${bg}", ${fs}, "${color}", 3, "#000000").bold.font.uppercase.letter`;
if (addSpacing) {
res += `\n+spacerLine(${bottom})`;
}
return res;
}
case "banner": {
const href = d.href || "#";
const baseName = d.imageBaseName || "";
const ext = d.imageExtension || ".png";
let img = "";
if (baseName) {
img = buildImageUrl(baseName, ext, d.imageUrl, settings.imageBaseUrl);
} else if (d.imageUrl) {
img = d.imageUrl;
}
const bottom = d.bottomSpacing ?? 40;
const addSpacing = !d.removeBottomSpacing;
let res =
"////Блок с баннером и ссылкой\n" +
`+bannerWLink("${href}", "${img}")`;
if (addSpacing) {
res += `\n+spacerLine(${bottom})`;
}
return res;
}
case "bannerNoLink": {
const img = buildImageUrl(
d.imageBaseName || "",
d.imageExtension || ".png",
d.imageUrl,
settings.imageBaseUrl
);
const h = d.height ?? 293;
const top = d.topSpacing ?? 40;
const bottom = d.bottomSpacing ?? 0;
const addSpacing = !d.removeBottomSpacing;
let res = `+spacerLine(${top})\n+bannerWithoutLink("${img}", ${h})`;
if (addSpacing && bottom > 0) {
res += `\n+spacerLine(${bottom})`;
}
return res;
}
case "twoBannersWithText": {
const {
leftHref,
leftImage,
leftImageBaseName,
leftImageExtension,
leftText,
rightHref,
rightImage,
rightImageBaseName,
rightImageExtension,
rightText,
width,
gap,
bottomSpacing,
removeBottomSpacing
} = d;
const leftImg = buildImageUrl(
leftImageBaseName || "",
leftImageExtension || ".png",
leftImage,
settings.imageBaseUrl
);
const rightImg = buildImageUrl(
rightImageBaseName || "",
rightImageExtension || ".png",
rightImage,
settings.imageBaseUrl
);
const w = width ?? 270;
const g = gap ?? 30;
const bottom = bottomSpacing ?? 40;
const addSpacing = !removeBottomSpacing;
let res =
"tr \n" +
' td(align="center").paddingWrapper\n' +
' +defaultTable("", "center")\n' +
" tr \n" +
" td \n" +
" //- (Ссылка, изображение, ширина картинки = 270, текст под баннером)\n" +
` +bannerWithLink("${leftHref ||
"#"}", "${leftImg || ""}", ${w}, "${leftText || ""}")\n` +
` +tdFixed(${g})\n` +
" td \n" +
` +bannerWithLink("${rightHref ||
"#"}", "${rightImg || ""}", ${w}, "${rightText || ""}")`;
if (addSpacing) {
res += `\n+spacerLine(${bottom})`;
}
return res;
}
case "twoBannersNoText": {
const {
leftHref,
leftImage,
leftImageBaseName,
leftImageExtension,
rightHref,
rightImage,
rightImageBaseName,
rightImageExtension,
width,
gap,
bottomSpacing,
removeBottomSpacing
} = d;
const leftImg = buildImageUrl(
leftImageBaseName || "",
leftImageExtension || ".jpg",
leftImage,
settings.imageBaseUrl
);
const rightImg = buildImageUrl(
rightImageBaseName || "",
rightImageExtension || ".jpg",
rightImage,
settings.imageBaseUrl
);
const w = width ?? 270;
const g = gap ?? 30;
const bottom = bottomSpacing ?? 40;
const addSpacing = !removeBottomSpacing;
let res =
"tr \n" +
' td(align="center").paddingWrapper\n' +
' +defaultTable("", "center")\n' +
" tr \n" +
" td \n" +
" //- (Ссылка, изображение, ширина изображения = 270)\n" +
` +bannerWithLink("${leftHref ||
"#"}", "${leftImg || ""}", ${w})\n` +
` +tdFixed(${g})\n` +
" td \n" +
` +bannerWithLink("${rightHref ||
"#"}", "${rightImg || ""}", ${w})`;
if (addSpacing) {
res += `\n+spacerLine(${bottom})`;
}
return res;
}
case "threeBannersNoText": {
const {
href1,
img1,
imgBaseName1,
imgExtension1,
href2,
img2,
imgBaseName2,
imgExtension2,
href3,
img3,
imgBaseName3,
imgExtension3,
width,
gap,
bottomSpacing,
removeBottomSpacing
} = d;
const imgOne = buildImageUrl(
imgBaseName1 || "",
imgExtension1 || ".png",
img1,
settings.imageBaseUrl
);
const imgTwo = buildImageUrl(
imgBaseName2 || "",
imgExtension2 || ".png",
img2,
settings.imageBaseUrl
);
const imgThree = buildImageUrl(
imgBaseName3 || "",
imgExtension3 || ".png",
img3,
settings.imageBaseUrl
);
const w = width ?? 170;
const g = gap ?? 30;
const bottom = bottomSpacing ?? 40;
const addSpacing = !removeBottomSpacing;
let res =
"tr \n" +
' td(align="center").paddingWrapper\n' +
' +defaultTable("", "center")\n' +
" tr \n" +
" td \n" +
" //- (Ссылка, изображение)\n" +
` +bannerWithLink("${href1 ||
"#"}", "${imgOne || ""}", ${w})\n` +
` +tdFixed(${g})\n` +
" td \n" +
` +bannerWithLink("${href2 ||
"#"}", "${imgTwo || ""}", ${w})\n` +
` +tdFixed(${g})\n` +
" td \n" +
` +bannerWithLink("${href3 ||
"#"}", "${imgThree || ""}", ${w})`;
if (addSpacing) {
res += `\n+spacerLine(${bottom})`;
}
return res;
}
case "products4Row":
case "products3Row": {
const idsRaw = d.productIds || "";
const ids = idsRaw
.split(",")
.map((s) => s.trim())
.filter(Boolean)
.join(",");
const showPrices = settings.showPrices !== undefined ? !!settings.showPrices : d.showPrices ?? true;
const bottom = d.bottomSpacing ?? 40;
const addSpacing = !d.removeBottomSpacing;
const mixinName = block.type === "products4Row" ? "products4Row" : "products3Row";
let params = `"${ids}"`;
if (!showPrices) {
params += ", {showPrices : false}";
}
let res = `+${mixinName}(${params})`;
if (addSpacing) {
res += `\n+spacerLine(${bottom})`;
}
return res;
}
case "productsImageLeft":
case "productsImageRight":
case "productsImageLeft3":
case "productsImageRight3": {
const idsRaw = d.productIds || "";
const ids = idsRaw
.split(",")
.map((s) => s.trim())
.filter(Boolean)
.join(",");
const link = d.link || "#";
const img = buildImageUrl(
d.imageBaseName || "",
d.imageExtension || ".png",
d.imageUrl,
settings.imageBaseUrl
);
const w = d.imgWidth ?? 275;
const isThree = block.type === "productsImageLeft3" || block.type === "productsImageRight3";
const showPrices =
settings.showPrices !== undefined ? !!settings.showPrices : d.showPrices ?? true;
const bottom = d.bottomSpacing ?? 40;
const addSpacing = !d.removeBottomSpacing;
let mixinName;
if (block.type === "productsImageLeft" || block.type === "productsImageLeft3") {
mixinName = isThree ? "productsColumnImageLeft" : "productsImageLeft";
} else {
mixinName = isThree ? "productsColumnImageRight" : "productsImageRight";
}
let options = showPrices === false ? ", {showPrices : false}" : "";
let res;
if (isThree) {
res = `+${mixinName}("${ids}", "${link}", "${img}"${options})`;
} else {
res = `+${mixinName}("${ids}", "${link}", "${img}", ${w}${options})`;
}
if (addSpacing) {
res += `\n+spacerLine(${bottom})`;
}
return res;
}
case "textImageLeft":
case "textImageRight": {
const link = d.link || "#";
const img = buildImageUrl(
d.imageBaseName || "",
d.imageExtension || ".png",
d.imageUrl,
settings.imageBaseUrl
);
const w = d.imgWidth ?? 264;
const h = d.imgHeight ?? 330;
const header = d.header || "";
const text = d.text || "";
const btnText = d.buttonText || "";
const btnHref = d.buttonHref || "#";
const bottom = d.bottomSpacing ?? 20;
const addSpacing = !d.removeBottomSpacing;
const mixinName = block.type === "textImageLeft" ? "textImageLeft" : "textImageRight";
let res =
`//Текст ${block.type === "textImageLeft" ? "справа изображение слева" : "слева изображение справа"}\n` +
`+${mixinName}("${link}", "${img}", ${w}, ${h})\n` +
' +defaultTable("100%")\n' +
" tr \n" +
" td\n" +
` span.imageSideHeader.font.bold ${header}\n` +
" +spacerLine(18)\n" +
" tr \n" +
" td \n" +
` span.font.imageSideText.font ${text}\n` +
" +spacerLine(18)\n" +
" tr \n" +
" td \n" +
` +buttonRounded("${btnText}", "${btnHref}", 160, 45, "#ffffff", 16, "#000000", 3).bold.font`;
if (addSpacing) {
res += `\n\n+spacerLine(${bottom})`;
}
return res;
}
case "sizeGrid": {
const sizesRaw = d.sizes || "";
const linksRaw = d.links || "";
const sizesArr = sizesRaw
.split(",")
.map((s) => s.trim())
.filter(Boolean);
const linksArr = linksRaw
.split(",")
.map((s) => s.trim())
.filter(Boolean);
const sizesString = "[" + sizesArr.map((s) => s).join(", ") + "]";
const linksString = "[" + linksArr.map((s) => `"${s}"`).join(", ") + "]";
const bottom = d.bottomSpacing ?? 20;
const addSpacing = !d.removeBottomSpacing;
let res = `+sizes(${sizesString}, ${linksString})`;
if (addSpacing) {
res += `\n+spacerLine(${bottom})`;
}
return res;
}
case "promocode": {
const code = d.code || "";
const bottom = d.bottomSpacing ?? 40;
const addSpacing = !d.removeBottomSpacing;
let res = `+promocode("${code}")`;
if (addSpacing) {
res += `\n+spacerLine(${bottom})`;
}
return res;
}
case "dividerVA": {
const w = d.width ?? 300;
const h = d.height ?? 1;
const bottom = d.bottomSpacing ?? 40;
const addSpacing = !d.removeBottomSpacing;
const top = d.topSpacing ?? 40;
const addTop = !d.removeTopSpacing;
let res = "";
if (addTop) {
res += `+spacerLine(${top})\n`;
}
// divider mixin ожидает третьего необязательного аргумента, поэтому оставляем завершающую запятую
res += `+dividerVA(${w}, ${h},)`;
if (addSpacing) {
res += `\n+spacerLine(${bottom})`;
}
return res;
}
default:
return `// TODO: неизвестный тип блока "${block.type}"`;
}
}
export function generatePug(blocks, settings) {
return blocks
.map((b) => renderBlockToPug(b, settings))
.filter(Boolean)
.join("\n\n")
.trim();
}

View File

@@ -0,0 +1,8 @@
import "./app.css";
import App from "./App.svelte";
const app = new App({
target: document.getElementById("app")
});
export default app;

View File

@@ -0,0 +1,90 @@
import { writable, derived } from "svelte/store";
import { generatePug } from "./lib/pug";
export const BLOCKS_KEY = "vip_letter_editor_blocks_v1";
export const SETTINGS_KEY = "vip_letter_editor_settings_v1";
export const THEME_KEY = "vip_letter_editor_theme";
const loadJson = (key, fallback) => {
try {
const raw = localStorage.getItem(key);
if (!raw) return fallback;
const parsed = JSON.parse(raw);
return parsed ?? fallback;
} catch (e) {
console.warn("Failed to load from localStorage", e);
return fallback;
}
};
const persist = (key, value) => {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch (e) {
console.warn("Failed to save to localStorage", e);
}
};
function createBlocksStore() {
const initial = loadJson(BLOCKS_KEY, []);
const { subscribe, update, set } = writable(initial);
return {
subscribe,
set: (value) => {
persist(BLOCKS_KEY, value);
set(value);
},
update: (fn) =>
update((prev) => {
const next = fn(prev);
persist(BLOCKS_KEY, next);
return next;
})
};
}
function createSettingsStore() {
const defaults = {
imageBaseUrl: "",
showPrices: true,
projectName: "aspekter",
templateName: "let.pug"
};
const saved = loadJson(SETTINGS_KEY, defaults);
const initial = { ...defaults, ...saved };
const { subscribe, update, set } = writable(initial);
return {
subscribe,
set: (value) => {
persist(SETTINGS_KEY, value);
set(value);
},
update: (fn) =>
update((prev) => {
const next = fn(prev);
persist(SETTINGS_KEY, next);
return next;
})
};
}
function createThemeStore() {
const initial = loadJson(THEME_KEY, "dark");
const { subscribe, set } = writable(initial);
return {
subscribe,
set: (value) => {
persist(THEME_KEY, value);
set(value);
}
};
}
export const blocks = createBlocksStore();
export const settings = createSettingsStore();
export const theme = createThemeStore();
export const pugCode = derived([blocks, settings], ([$blocks, $settings]) =>
generatePug($blocks, $settings)
);

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