import { mkdirSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; const __dirname = dirname(fileURLToPath(import.meta.url)); const outDir = join(__dirname, '..', 'public', 'prototypes'); const W = 1440; const C = { page: '#f4f4f4', paper: '#ffffff', panel: '#ffffff', soft: '#f7f7f7', line: '#d5d5d5', dark: '#181818', mid: '#545454', muted: '#777777', fill: '#e8e8e8', fill2: '#f0f0f0', }; const font = '"Times New Roman", Times, serif'; function esc(value) { return String(value) .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"'); } function attrs(values) { return Object.entries(values) .filter(([, value]) => value !== undefined && value !== null) .map(([key, value]) => `${key}="${esc(value)}"`) .join(' '); } function rect(x, y, width, height, options = {}) { const { rx = 18, fill = C.panel, stroke = C.line, sw = 1.5, } = options; return ``; } function line(x1, y1, x2, y2, options = {}) { return ``; } function text(x, y, value, options = {}) { const { size = 16, weight = 500, fill = C.dark, anchor = 'start', } = options; return `${esc(value)}`; } function circle(cx, cy, r, options = {}) { return ``; } function chip(x, y, value, options = {}) { const width = options.width ?? Math.max(76, value.length * 9 + 28); const selected = options.selected ?? false; return [ rect(x, y, width, 34, { rx: 17, fill: selected ? C.dark : C.soft, stroke: selected ? C.dark : C.line, }), text(x + width / 2, y + 22, value, { size: 13, weight: 700, fill: selected ? '#ffffff' : C.mid, anchor: 'middle', }), ].join(''); } function input(x, y, width, label) { return [ text(x, y - 12, label, { size: 13, weight: 700, fill: C.mid }), rect(x, y, width, 48, { rx: 16, fill: C.paper }), ].join(''); } function button(x, y, width, label, options = {}) { const dark = options.dark ?? false; return [ rect(x, y, width, 44, { rx: 22, fill: dark ? C.dark : C.fill, stroke: dark ? C.dark : C.line, }), text(x + width / 2, y + 28, label, { size: 14, weight: 700, fill: dark ? '#ffffff' : C.mid, anchor: 'middle', }), ].join(''); } function topShell(label, nav = [], active = '') { const parts = [ rect(24, 24, 1392, 56, { rx: 28, fill: '#fafafa' }), rect(24, 52, 1392, 28, { rx: 0, fill: '#fafafa', stroke: '#fafafa' }), circle(58, 52, 7, { fill: '#b7b7b7' }), circle(82, 52, 7, { fill: '#d2d2d2' }), circle(106, 52, 7, { fill: '#d4d4d4' }), text(136, 58, label, { size: 17, weight: 700 }), ]; let x = 820; for (const item of nav) { parts.push(chip(x, 36, item, { width: Math.max(88, item.length * 10 + 34), selected: item === active })); x += Math.max(88, item.length * 10 + 34) + 12; } return parts.join(''); } function page(label, height, body, options = {}) { const nav = options.nav ?? ['Каталог', 'Мои заказы', 'Корзина', 'Профиль']; const active = options.active ?? ''; return `${rect(24, 24, 1392, height - 48, { rx: 28, fill: C.paper })}${topShell(label, nav, active)}${body.join('')}`; } function titleBlock(title, y = 132, x = 72) { return text(x, y, title, { size: 32, weight: 800 }); } function searchHero(title, placeholder, controls = []) { const parts = [ titleBlock(title), rect(72, 168, 600, 54, { rx: 27, fill: C.paper }), text(98, 201, placeholder, { size: 15, weight: 500, fill: C.muted }), ]; let x = 700; for (const control of controls) { parts.push(chip(x, 178, control, { width: Math.max(120, control.length * 9 + 34), selected: control === controls[0] })); x += Math.max(120, control.length * 9 + 34) + 12; } return parts.join(''); } function catalogCards(y = 260) { const cards = ['Стретч-пленка', 'Скотч', 'Пакеты', 'Пленка ПВД', 'Воздушно-пузырьковая', 'Картон']; const parts = []; cards.forEach((name, index) => { const col = index % 3; const row = Math.floor(index / 3); const x = 72 + col * 432; const yy = y + row * 238; parts.push(rect(x, yy, 396, 202, { rx: 26 })); parts.push(rect(x + 20, yy + 20, 356, 118, { rx: 22, fill: C.fill2 })); parts.push(text(x + 28, yy + 170, name, { size: 22, weight: 800 })); }); return parts.join(''); } function orderRows(x, y, width, rows, options = {}) { const parts = []; const rowH = options.rowH ?? 72; rows.forEach((row, index) => { const yy = y + index * (rowH + 12); parts.push(rect(x, yy, width, rowH, { rx: 20, fill: index % 2 ? '#fbfbfb' : C.paper })); parts.push(text(x + 24, yy + 30, row[0], { size: 17, weight: 800 })); parts.push(text(x + 24, yy + 54, row[1], { size: 14, weight: 500, fill: C.mid })); if (row[2]) { parts.push(chip(x + width - 210, yy + 18, row[2], { width: 150 })); } }); return parts.join(''); } function cardGrid(x, y, labels, columns = 3) { const parts = []; labels.forEach((label, index) => { const col = index % columns; const row = Math.floor(index / columns); const w = columns === 4 ? 300 : 388; const xx = x + col * (w + 24); const yy = y + row * 128; parts.push(rect(xx, yy, w, 104, { rx: 24 })); parts.push(circle(xx + 44, yy + 52, 24, { fill: C.fill2 })); parts.push(text(xx + 82, yy + 48, label[0], { size: 17, weight: 800 })); if (label[1]) { parts.push(text(xx + 82, yy + 72, label[1], { size: 14, weight: 500, fill: C.mid })); } }); return parts.join(''); } const pages = { 'dashboard.svg': page('Главная страница клиента', 900, [ searchHero('Каталог', 'Поиск по типу товара', []), catalogCards(260), ], { active: 'Каталог' }), 'catalog-grid.svg': page('Каталог продукции', 900, [ searchHero('Каталог', 'Поиск по типу товара', []), catalogCards(260), ], { active: 'Каталог' }), 'product-card.svg': page('Карточка товара', 1040, [ button(72, 116, 110, 'Назад'), titleBlock('Алюминиевый скотч', 166), rect(72, 220, 400, 330, { rx: 32 }), rect(102, 252, 340, 228, { rx: 26, fill: C.fill2 }), text(272, 510, 'Изображение товара', { size: 16, weight: 700, fill: C.mid, anchor: 'middle' }), rect(504, 220, 536, 330, { rx: 32 }), text(536, 258, 'Параметры', { size: 22, weight: 800 }), text(536, 304, 'Ширина', { size: 14, weight: 700, fill: C.mid }), chip(536, 320, '48 мм', { selected: true }), chip(628, 320, '75 мм'), text(780, 304, 'Длина', { size: 14, weight: 700, fill: C.mid }), chip(780, 320, '25 м', { selected: true }), chip(862, 320, '50 м'), chip(944, 320, '100 м'), text(536, 386, 'Толщина', { size: 14, weight: 700, fill: C.mid }), chip(536, 402, '43 мкм', { selected: true }), chip(638, 402, '45 мкм'), text(780, 386, 'Втулка', { size: 14, weight: 700, fill: C.mid }), chip(780, 402, 'Стандарт', { selected: true, width: 112 }), chip(904, 402, 'Логотип', { width: 104 }), text(536, 468, 'Цвет', { size: 14, weight: 700, fill: C.mid }), chip(536, 484, 'Серебристый', { selected: true, width: 126 }), text(780, 468, 'Надпись', { size: 14, weight: 700, fill: C.mid }), chip(780, 484, 'Без надписи', { selected: true, width: 136 }), rect(1072, 220, 296, 330, { rx: 32 }), text(1100, 262, 'FRG-ALU-48-50', { size: 20, weight: 800 }), text(1100, 310, 'В наличии', { size: 16, weight: 700, fill: C.mid }), text(1100, 342, '2 140', { size: 38, weight: 800 }), button(1100, 394, 220, 'В корзину', { dark: true }), text(72, 624, 'Доступные варианты', { size: 24, weight: 800 }), rect(72, 652, 1296, 258, { rx: 24 }), text(104, 698, 'SKU', { size: 14, weight: 800, fill: C.mid }), text(312, 698, 'Ширина', { size: 14, weight: 800, fill: C.mid }), text(470, 698, 'Длина', { size: 14, weight: 800, fill: C.mid }), text(620, 698, 'Толщина', { size: 14, weight: 800, fill: C.mid }), text(790, 698, 'Втулка', { size: 14, weight: 800, fill: C.mid }), text(970, 698, 'Остаток', { size: 14, weight: 800, fill: C.mid }), text(1160, 698, 'Действие', { size: 14, weight: 800, fill: C.mid }), line(96, 716, 1344, 716), orderRows(96, 738, 1248, [ ['FRG-ALU-48-50', '48 мм · 50 м · 43 мкм · стандарт', 'В корзину'], ['FRG-ALU-75-50', '75 мм · 50 м · 45 мкм · стандарт', 'В корзину'], ], { rowH: 64 }), ], { active: 'Каталог' }), 'cart.svg': page('Корзина', 900, [ titleBlock('Корзина'), rect(72, 168, 1296, 68, { rx: 24, fill: C.soft }), text(102, 210, 'Заполните карточку контрагента перед оформлением заявки', { size: 16, weight: 700, fill: C.mid }), text(72, 284, 'Состав заказа', { size: 24, weight: 800 }), rect(72, 312, 760, 330, { rx: 28 }), orderRows(104, 344, 696, [ ['Стретч-пленка', '48 мм · 50 м · 43 мкм', '2 шт'], ['Скотч упаковочный', '75 мм · 66 м', '4 шт'], ['Пакет ПВД', '300 x 400 мм', '1 шт'], ], { rowH: 72 }), rect(872, 312, 496, 330, { rx: 28 }), text(904, 354, 'Информация о доставке', { size: 22, weight: 800 }), chip(904, 390, 'Склад клиента', { selected: true, width: 160 }), chip(904, 444, 'Новый адрес', { width: 148 }), input(904, 532, 380, 'Комментарий'), button(904, 610, 260, 'Оформить заявку', { dark: true }), ], { active: 'Корзина' }), 'client-order.svg': page('Карточка заказа клиента', 860, [ button(72, 116, 190, 'Назад к моим заказам'), titleBlock('Заказ FRG-1024', 170), rect(72, 220, 1296, 118, { rx: 28 }), text(104, 262, 'Статус заказа', { size: 16, weight: 700, fill: C.mid }), chip(104, 282, 'Предложение', { selected: true, width: 148 }), chip(282, 282, 'Подтвердить', { width: 150 }), chip(456, 282, 'Отклонить', { width: 130 }), text(72, 394, 'Состав заказа', { size: 24, weight: 800 }), rect(72, 426, 1296, 260, { rx: 28 }), orderRows(104, 458, 1232, [ ['Стретч-пленка', '48 мм · 50 м · количество 2', 'Цена задана'], ['Скотч упаковочный', '75 мм · 66 м · количество 4', 'Цена задана'], ], { rowH: 76 }), rect(72, 720, 1296, 80, { rx: 24, fill: C.soft }), text(104, 754, 'Доставка', { size: 17, weight: 800 }), text(260, 754, 'Адрес, срок и стоимость доставки показываются в одной строке', { size: 15, weight: 500, fill: C.mid }), ], { active: 'Мои заказы' }), 'login.svg': page('Логин', 760, [ rect(450, 130, 540, 520, { rx: 32 }), text(720, 196, 'Фрегат', { size: 14, weight: 800, fill: C.mid, anchor: 'middle' }), text(720, 244, 'Вход', { size: 36, weight: 800, anchor: 'middle' }), input(510, 304, 420, 'E-mail'), button(510, 386, 420, 'Получить код', { dark: true }), line(510, 464, 930, 464), text(720, 492, 'или войти через', { size: 13, weight: 700, fill: C.mid, anchor: 'middle' }), button(510, 526, 196, 'Telegram'), button(734, 526, 196, 'Max'), ], { nav: [] }), 'bonus-cabinet.svg': page('Бонусный кабинет', 940, [ titleBlock('Чёрный кабинет бонусной программы'), rect(72, 178, 820, 250, { rx: 30 }), text(112, 230, 'Аккаунт', { size: 15, weight: 700, fill: C.mid }), text(112, 280, 'Клиент бонусной программы', { size: 32, weight: 800, fill: C.dark }), text(112, 354, 'Доступный баланс', { size: 15, weight: 700, fill: C.mid }), text(112, 398, '12 400', { size: 48, weight: 800, fill: C.dark }), rect(928, 178, 440, 250, { rx: 30 }), text(968, 230, 'Вывод бонусов', { size: 20, weight: 800, fill: C.dark }), input(968, 292, 320, 'Сумма заявки'), button(968, 370, 280, 'Подать заявку', { dark: false }), rect(72, 472, 620, 300, { rx: 30 }), text(112, 522, 'История бонусов', { size: 24, weight: 800, fill: C.dark }), orderRows(112, 552, 540, [ ['+1 500', 'Начисление по заказу', ''], ['+900', 'Реферальное начисление', ''], ], { rowH: 68 }), rect(748, 472, 620, 300, { rx: 30 }), text(788, 522, 'Вознаграждения', { size: 24, weight: 800, fill: C.dark }), button(788, 566, 170, 'Ozon 3000'), button(980, 566, 210, 'Wildberries 4000'), button(788, 634, 190, 'М.Видео 5000'), ], { active: 'Профиль' }), 'client-list.svg': page('Клиенты', 900, [ searchHero('Клиенты', 'Имя, компания или email', ['Пригласить']), cardGrid(72, 270, [ ['Иван Петров', 'ООО Альфа'], ['Мария Соколова', 'ИП Соколова'], ['Дмитрий Иванов', 'ООО Север'], ['Анна Смирнова', 'ООО Вектор'], ['Павел Морозов', 'Завод Мир'], ['Елена Орлова', 'ТД Орлова'], ], 3), ], { nav: ['Заказы', 'Бонусы', 'Настройки'], active: 'Заказы' }), 'client-card.svg': page('Карточка клиента', 880, [ button(72, 116, 170, 'Назад к клиентам'), titleBlock('Клиент Иван Петров', 170), cardGrid(72, 224, [ ['Email', 'client@company.ru'], ['Telegram', 'Подключен'], ['Компания', 'ООО Альфа'], ['ИНН', '7700000000'], ], 4), text(72, 500, 'Заказы пользователя', { size: 24, weight: 800 }), rect(72, 532, 1296, 240, { rx: 28 }), orderRows(104, 564, 1232, [ ['FRG-1024', 'Стретч-пленка · Москва', 'В работе'], ['FRG-1017', 'Скотч · Санкт-Петербург', 'Завершен'], ], { rowH: 72 }), ], { nav: ['Заказы', 'Бонусы', 'Настройки'], active: 'Заказы' }), 'manager-order.svg': page('Обработка заявки', 900, [ button(72, 116, 170, 'Назад к заказам'), titleBlock('Заказ FRG-1024', 170), rect(72, 220, 1296, 118, { rx: 28 }), text(104, 262, 'Статус заказа', { size: 16, weight: 700, fill: C.mid }), chip(104, 282, 'В обработке', { selected: true, width: 148 }), chip(282, 282, 'Предложение', { width: 150 }), rect(72, 382, 920, 300, { rx: 28 }), text(104, 426, 'Состав заказа', { size: 24, weight: 800 }), orderRows(104, 460, 856, [ ['Стретч-пленка', 'Количество 2 · цена редактируется', 'Цена'], ['Скотч упаковочный', 'Количество 4 · цена редактируется', 'Цена'], ], { rowH: 76 }), rect(1028, 382, 340, 300, { rx: 28 }), text(1060, 426, 'Условия', { size: 24, weight: 800 }), input(1060, 484, 250, 'Срок доставки'), input(1060, 578, 250, 'Стоимость доставки'), button(1060, 650, 230, 'Сохранить', { dark: true }), ], { nav: ['Заказы', 'Бонусы', 'Настройки'], active: 'Заказы' }), 'manager-orders.svg': page('Заказы менеджера', 920, [ searchHero('Заказы', 'Номер заказа, клиент, адрес или товар', ['Список', 'Календарь']), chip(72, 250, 'Все', { selected: true, width: 88 }), chip(174, 250, 'Заявки', { width: 112 }), chip(300, 250, 'Предложения', { width: 150 }), chip(464, 250, 'В работе', { width: 126 }), chip(604, 250, 'Закрытые', { width: 126 }), rect(72, 320, 1296, 430, { rx: 30 }), orderRows(104, 356, 1232, [ ['FRG-1024', 'Иван Петров · Стретч-пленка · Москва', 'Заявка'], ['FRG-1025', 'Мария Соколова · Скотч · Казань', 'Предложение'], ['FRG-1026', 'Дмитрий Иванов · Пакеты · СПб', 'В работе'], ['FRG-1027', 'Анна Смирнова · Пленка ПВД · Москва', 'Закрыт'], ], { rowH: 76 }), ], { nav: ['Заказы', 'Бонусы', 'Настройки'], active: 'Заказы' }), 'catalog-settings.svg': page('Настройки каталога', 980, [ titleBlock('Каталог'), rect(72, 184, 1296, 104, { rx: 28 }), text(104, 226, 'Стретч-пленка', { size: 22, weight: 800 }), text(104, 256, '6 параметров, 3 кастомные возможности', { size: 15, weight: 500, fill: C.mid }), rect(72, 318, 1296, 446, { rx: 28 }), text(104, 362, 'Кастомные возможности', { size: 22, weight: 800 }), chip(104, 390, 'Любая длина', { selected: true, width: 140 }), chip(262, 390, 'Логотип на втулке', { width: 190 }), chip(470, 390, 'Нанесение надписи', { width: 200 }), text(104, 478, 'Диапазон длины', { size: 18, weight: 800 }), input(104, 520, 240, 'Мин. длина, м'), input(378, 520, 240, 'Макс. длина, м'), input(652, 520, 240, 'Шаг, м'), text(104, 638, 'Параметры', { size: 18, weight: 800 }), chip(104, 664, 'Ширина', { width: 110 }), chip(232, 664, 'Длина', { width: 100 }), chip(350, 664, 'Толщина', { width: 120 }), chip(488, 664, 'Втулка', { width: 108 }), chip(614, 664, 'Цвет', { width: 96 }), chip(728, 664, 'Надпись', { width: 120 }), button(1100, 804, 190, 'Сохранить', { dark: true }), ], { nav: ['Заказы', 'Бонусы', 'Настройки'], active: 'Настройки' }), 'sync-settings.svg': page('Настройки синхронизации', 900, [ titleBlock('1С'), text(72, 168, 'Статус загрузки файлов обмена', { size: 16, weight: 500, fill: C.mid }), cardGrid(72, 230, [ ['catalog_snapshot', 'Каталог и остатки'], ['balances_snapshot', 'Задолженность клиентов'], ['orders_snapshot', 'Заказы клиентов'], ], 3), rect(72, 450, 1296, 250, { rx: 28 }), text(104, 494, 'Последние загрузки', { size: 24, weight: 800 }), orderRows(104, 530, 1232, [ ['Каталог и остатки', 'Загружено 2 418 записей · последний run сегодня', 'Работает'], ['Задолженность клиентов', 'Баланс по клиентам с личным кабинетом', 'Работает'], ['Заказы клиентов', 'Статусы заказов за рабочий период', 'Работает'], ], { rowH: 62 }), ], { nav: ['Заказы', 'Бонусы', 'Настройки'], active: 'Настройки' }), 'profile.svg': page('Профиль клиента', 820, [ titleBlock('Профиль'), cardGrid(72, 210, [ ['Карточка контрагента', 'Реквизиты и ИНН'], ['Уведомления', 'Telegram и Max'], ['Адреса доставки', 'Список адресов'], ], 3), ], { active: 'Профиль' }), 'bonus-manager.svg': page('Бонусная система менеджера', 920, [ searchHero('Бонусы', 'Клиент, связанный клиент или email', ['Добавить']), chip(72, 250, 'Балансы', { selected: true, width: 120 }), chip(208, 250, 'Заявки', { width: 110 }), chip(334, 250, 'Награды', { width: 116 }), cardGrid(72, 320, [ ['Иван Петров', '12 400 ₽'], ['Мария Соколова', '8 250 ₽'], ['Дмитрий Иванов', '5 100 ₽'], ['Анна Смирнова', '2 900 ₽'], ], 4), rect(72, 610, 1296, 170, { rx: 28 }), text(104, 654, 'Заявки на выплату', { size: 24, weight: 800 }), orderRows(104, 686, 1232, [ ['WD-01A23F', 'Иван Петров · на проверке', '12 000 ₽'], ], { rowH: 68 }), ], { nav: ['Заказы', 'Бонусы', 'Настройки'], active: 'Бонусы' }), }; mkdirSync(outDir, { recursive: true }); for (const [fileName, content] of Object.entries(pages)) { writeFileSync(join(outDir, fileName), `${content}\n`, 'utf8'); }