15 Commits

Author SHA1 Message Date
admin acf929ed8c fix(landing): в английской версии подвала Russia и подписи INN/KPP/OGRN
Подвал выводил регион и подписи реквизитов только по-русски, хотя
остальная страница переключается на английский.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvcrP3ruGY4v6RDvSjf7xQ
2026-10-09 10:02:08 +03:00
admin 8ebc42dd09 fix(landing): счётчик продуктов подменяет только числовую плашку, а не «AI — в каждом продукте» 2026-09-25 19:18:34 +03:00
Claude c4e5a532a1 fix(docker): собирать bcrypt из исходников
Готовый бинарник с github внутри сборки не скачивается — запрос к
release-assets обрывается, и node-pre-gyp уходит в компиляцию, а в
node:20-alpine нет python3/make/g++. Ставим их временно в обеих
стадиях и сносим вместе с кэшем node-gyp, образ остаётся ~260 МБ.
2026-08-30 11:46:09 +03:00
Claude 913b66a9cb feat(fonts): локальные шрифты вместо fonts.googleapis.com
Inter (лендинг), Unbounded и Onest (страница визитки) — подмножества
cyrillic+latin. Под ограничениями доступа внешние шрифты не приезжали:
у визиток, выданных клиентам, съезжала вёрстка.
2026-08-30 11:34:13 +03:00
zeroday 4d01a65859 docs: реестр операторов и privacy@ в уведомлении о куки
Приведение всех четырёх сайтов ООО к единому виду. Полноценная политика здесь
по-прежнему не нужна — форм и сбора данных на сайте нет, — но реквизиты
дополнены записью в реестре операторов (рег. номер 35-26-021324, приказ № 127
от 07.08.2026) и единым адресом privacy@umbyte.ru для вопросов по обработке.
2026-08-26 11:00:13 +03:00
zeroday 0dbbc7487e feat(landing): уведомление о куки и баннер
Баннер сделан по образцу АгроТО, но БЕЗ выбора категорий. На umbyte.ru нет
ни аналитики, ни рекламных пикселей, ни форм — только техническая сессия
админки. Окно настроек с переключателями, которые ничего не переключают,
показывало бы пользователю выбор, которого нет. Две кнопки: «Хорошо» и
«Отклонить», выбор помнится 180 дней.

Рядом /cookies.html — короткое уведомление: что за куки, откуда, на какой
срок, как отказаться, и прямо сказано, что персональные данные посетителей
сайт не собирает (кнопка связи открывает почтовый клиент, форм нет). Поэтому
полноценная политика обработки ПДн здесь не нужна — в отношении посетителей
компания не выступает оператором.

Если появится Метрика или форма обратной связи — берём полный вариант с
категориями из АгроТО, механика хранения совпадает.
2026-08-25 22:40:34 +03:00
zeroday f8730d9d09 fix: тип запроса секций и догоняющая миграция английских колонок
Оба дефекта вскрылись сборкой образа после слияния веток.

1. public.ts: SQL выбирает content_json_en, а описание типа запроса пришло из
   ветки визиток и этой колонки не знало — tsc отказывался собирать. След
   слияния: запрос из одной ветки, тип из другой.

2. Английские колонки (sections.content_json_en, products.title_en/subtitle_en/
   description_en) существуют на боевой базе, но их не создаёт ни одна
   миграция — когда-то добавили руками. На бою работало, на любой ЧИСТОЙ базе
   приложение упало бы на первом запросе /api/content. Заведена 005 с
   IF NOT EXISTS: на бою пусто, на чистой базе создаст.

Проверено на живом сайте: образ собран, контейнер healthy, миграции 004 и 005
применились сами, /api/content отдаёт 4 раздела на двух языках, 2 продукта,
полные реквизиты; страница визитки отвечает 200.
2026-08-25 22:35:20 +03:00
zeroday 29d1386dd9 merge: свести ветку визиток из Gitea с локальной работой по лендингу
Две линии разработки разъехались и месяц жили порознь: в Gitea уехали
электронные визитки с QR-кодом, а в рабочей копии на fortress остались шесть
коммитов, которые до репозитория не дошли и, значит, не попали на сайт —
английская версия с переключателем языков, карточка Бонитировки, логотипы
продуктов, реквизиты компании в подвале, убранная кнопка Telegram, имя
директора латиницей. Плюс незакоммиченное бургер-меню.

Что разрешено вручную:

1. Landing.tsx, блок контактов. Столкнулись наша расстановка (почта главной
   кнопкой с подписью из cta_primary, телефон второй) и правка из Gitea,
   переводящая оба контакта с href на onClick, чтобы адрес и номер не лежали
   в разметке и их не собирали боты. Взято и то, и другое: защита от ботов
   сохранена, порядок и оформление наши, адрес почты в подписи не светится.

2. Коллизия номеров миграций: обе стороны завели номер 003. На живой базе
   применены 001, 002 и 003_business_cards, а 003_product_url_and_requisites
   не выполнялась никогда — она и переименована в 004, чтобы раннер (он
   отслеживает миграции по имени файла) отработал её штатно.

3. package-lock пересобран под слитый манифест, а не выбран со стороны.

Проверено: tsc чист, vite build проходит, 118 модулей, 241 КБ.
2026-08-25 22:27:25 +03:00
zeroday 18a0888c13 feat(landing): бургер-меню и переключатель языка
Работа лежала незакоммиченной в рабочей копии на fortress. Фиксирую как есть,
до слияния с веткой визиток, чтобы не потерять при разборе расхождения.
Заодно подняты vite 5.4.10 -> 5.4.21 и plugin-react 4.3.3 -> 4.7.0.
2026-08-25 22:23:49 +03:00
admin 3199b681a2 feat(landing): настоящие логотипы продуктов и карточки в их фирменных цветах
Две одинаковые серые плитки не давали понять, что это разные приложения,
а у Бонитировки стояла служебная заглушка вместо логотипа.

- logo_url и brand_color у продукта; логотипы взяты из самих приложений;
- фон и рамка карточки — мягкий градиент фирменного цвета, ссылка тоже;
- в шапке было «1 — ФЛАГМАНСКИЙ ПРОДУКТ», стало «2 — ПРОДУКТА В PRODUCTION».
2026-08-20 00:15:21 +03:00
admin b117f3a740 feat(landing): второй продукт — Бонитировка
Компания с одним продуктом читается как случайный проект; два продукта в
смежных нишах агропрома показывают специализацию. Бонитировка работает на
живых хозяйствах и имеет свой сайт — по статусу это тоже production.
Добавлена иконка оценочного листа.
2026-08-20 00:10:26 +03:00
admin e2eee024a6 fix(landing): убрана нерабочая кнопка Telegram
Адрес вёл в никуда — нерабочая кнопка на сайте хуже, чем её отсутствие.
Главным действием стала почта, рядом телефон. Оба контакта совпадают с теми,
что указаны на agroto.ru и в договорах.
2026-08-20 00:05:45 +03:00
admin ae71f9c64b feat(landing): имя директора латиницей в английской версии 2026-08-19 23:58:15 +03:00
admin 0c295386fa feat(landing): английская версия сайта с переключателем языков
Нужна для проверки компании иностранными партнёрами и магазинами приложений:
проверяющий должен понять, кто мы и чем заняты, не зная русского.

- переводы лежат в тех же строках БД (products.*_en, approach_items.*_en,
  sections.content_json_en, settings.company_name_en/company_address_en),
  админка остаётся одна;
- фронт откатывается на русский ПО КАЖДОМУ ПОЛЮ отдельно — недопереведённая
  секция не оставляет пустого места на странице;
- выбор языка запоминается в localStorage, проставляется <html lang>.
2026-08-19 23:55:45 +03:00
admin ad901c1ee4 feat(landing): ссылка на сайт продукта и реквизиты компании в подвале
Нужно для подтверждения принадлежности сайта компании: по реквизитам
организацию проверяют контрагенты и магазины приложений, а ссылка связывает
ООО «Умный Байт» с его продуктом АгроТО.

- products.site_url (миграция 003), отдаётся в /api/content;
- в карточке продукта — ссылка на сайт;
- в подвале — ИНН, КПП, ОГРН, юр. адрес, генеральный директор, почта и телефон.
  Значения лежат в settings, правятся через админку без пересборки.
2026-08-19 23:43:48 +03:00
39 changed files with 673 additions and 48 deletions
+11 -1
View File
@@ -15,6 +15,11 @@ RUN npm run build
# ===================================================== # =====================================================
FROM node:20-alpine AS backend-build FROM node:20-alpine AS backend-build
# bcrypt — нативный модуль. Готовый бинарник с github внутри сборки не
# скачивается (запрос к release-assets обрывается), поэтому собираем из
# исходников: для этого нужны python3/make/g++.
RUN apk add --no-cache python3 make g++
WORKDIR /app/backend WORKDIR /app/backend
COPY backend/package.json backend/package-lock.json* ./ COPY backend/package.json backend/package-lock.json* ./
RUN npm install RUN npm install
@@ -33,7 +38,12 @@ WORKDIR /app
# Backend production deps # Backend production deps
COPY backend/package.json backend/package-lock.json* ./backend/ COPY backend/package.json backend/package-lock.json* ./backend/
RUN cd backend && npm install --omit=dev # То же самое для боевых зависимостей: компилятор ставим временно и сносим
# сразу после установки, чтобы не тащить его в итоговый образ.
RUN apk add --no-cache --virtual .build-deps python3 make g++ \
&& cd backend && npm install --omit=dev \
&& apk del .build-deps \
&& rm -rf /root/.cache
# Built backend # Built backend
COPY --from=backend-build /app/backend/dist ./backend/dist COPY --from=backend-build /app/backend/dist ./backend/dist
+11 -4
View File
@@ -6,21 +6,23 @@ export const publicRouter = Router();
publicRouter.get('/content', async (_req, res) => { publicRouter.get('/content', async (_req, res) => {
try { try {
const [sections, products, approach, settings] = await Promise.all([ const [sections, products, approach, settings] = await Promise.all([
query<{ key: string; title: string; content_json: unknown; sort_order: number }>( query<{ key: string; title: string; content_json: unknown; content_json_en: unknown; sort_order: number }>(
`SELECT key, title, content_json, sort_order `SELECT key, title, content_json, content_json_en, sort_order
FROM sections FROM sections
WHERE is_active = true WHERE is_active = true
ORDER BY sort_order ASC` ORDER BY sort_order ASC`
), ),
query<unknown>( query<unknown>(
`SELECT id, slug, title, subtitle, description, `SELECT id, slug, title, subtitle, description,
status, audience, icon_key, tags, sort_order status, audience, icon_key, tags, sort_order, site_url,
title_en, subtitle_en, description_en, logo_url, brand_color
FROM products FROM products
WHERE is_published = true WHERE is_published = true
ORDER BY sort_order ASC` ORDER BY sort_order ASC`
), ),
query<unknown>( query<unknown>(
`SELECT id, title, description, icon_key, sort_order `SELECT id, title, description, icon_key, sort_order,
title_en, description_en
FROM approach_items FROM approach_items
WHERE is_active = true WHERE is_active = true
ORDER BY sort_order ASC` ORDER BY sort_order ASC`
@@ -30,9 +32,13 @@ publicRouter.get('/content', async (_req, res) => {
), ),
]); ]);
// Секции отдаём двумя наборами: русский и английский. Фронт выбирает
// язык на лету и откатывается на русский там, где перевода ещё нет.
const sectionsMap: Record<string, unknown> = {}; const sectionsMap: Record<string, unknown> = {};
const sectionsMapEn: Record<string, unknown> = {};
for (const s of sections) { for (const s of sections) {
sectionsMap[s.key] = s.content_json; sectionsMap[s.key] = s.content_json;
sectionsMapEn[s.key] = s.content_json_en || {};
} }
const settingsMap: Record<string, string> = {}; const settingsMap: Record<string, string> = {};
@@ -41,6 +47,7 @@ publicRouter.get('/content', async (_req, res) => {
} }
res.json({ res.json({
sections_en: sectionsMapEn,
sections: sectionsMap, sections: sectionsMap,
products, products,
approach, approach,
+15
View File
@@ -0,0 +1,15 @@
-- Ссылка на сайт продукта и реквизиты компании в подвале.
-- Зачем: Apple при регистрации организации проверяет, что сайт принадлежит
-- компании (реквизиты) и что продукт с ней связан (ссылка на agroto.ru).
ALTER TABLE products ADD COLUMN IF NOT EXISTS site_url VARCHAR(256);
UPDATE products SET site_url = 'https://agroto.ru' WHERE slug = 'agroto' AND site_url IS NULL;
INSERT INTO settings (key, value) VALUES
('company_inn', '3528347856'),
('company_kpp', '352801001'),
('company_ogrn', '1263500003667'),
('company_address', '162620, Вологодская обл., г. Череповец, ул. Центральная, д. 12'),
('company_director', 'Блюма Сергей Николаевич')
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now();
+16
View File
@@ -0,0 +1,16 @@
-- Английские колонки для переключателя языка.
--
-- Колонки уже существуют на боевой базе — их когда-то добавили вручную, минуя
-- миграции. Найдено 25.08.2026 при слиянии веток: код их читает, а ни один
-- файл в db/ не создаёт, то есть на любой ЧИСТОЙ базе (новый стенд, локальная
-- разработка, восстановление из пустого) приложение падало бы на первом же
-- запросе /api/content.
--
-- IF NOT EXISTS делает миграцию безопасной для боевой базы: там она ничего не
-- изменит, только зафиксирует факт в _migrations.
ALTER TABLE sections ADD COLUMN IF NOT EXISTS content_json_en JSONB;
ALTER TABLE products ADD COLUMN IF NOT EXISTS title_en VARCHAR(256);
ALTER TABLE products ADD COLUMN IF NOT EXISTS subtitle_en VARCHAR(512);
ALTER TABLE products ADD COLUMN IF NOT EXISTS description_en TEXT;
+4 -3
View File
@@ -10,12 +10,13 @@
<meta property="og:description" content="Технологии, которые решают реальные задачи" /> <meta property="og:description" content="Технологии, которые решают реальные задачи" />
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
<meta property="og:url" content="https://umbyte.ru" /> <meta property="og:url" content="https://umbyte.ru" />
<link rel="preconnect" href="https://fonts.googleapis.com" /> <!-- ШРИФТЫ ЛОКАЛЬНЫЕ: раньше тянулись с fonts.googleapis.com и при
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> ограничениях доступа не приезжали. Подмножества cyrillic+latin. -->
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" /> <link href="/fonts/fonts.css" rel="stylesheet" />
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
<script src="/cookies.js" defer></script>
</body> </body>
</html> </html>
+2 -2
View File
@@ -17,12 +17,12 @@
"@types/qrcode": "^1.5.5", "@types/qrcode": "^1.5.5",
"@types/react": "^18.3.12", "@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3", "@vitejs/plugin-react": "^4.7.0",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"postcss": "^8.4.49", "postcss": "^8.4.49",
"tailwindcss": "^3.4.14", "tailwindcss": "^3.4.14",
"typescript": "^5.6.3", "typescript": "^5.6.3",
"vite": "^5.4.10" "vite": "^5.4.21"
} }
}, },
"node_modules/@alloc/quick-lru": { "node_modules/@alloc/quick-lru": {
+2 -2
View File
@@ -18,11 +18,11 @@
"@types/qrcode": "^1.5.5", "@types/qrcode": "^1.5.5",
"@types/react": "^18.3.12", "@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3", "@vitejs/plugin-react": "^4.7.0",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"postcss": "^8.4.49", "postcss": "^8.4.49",
"tailwindcss": "^3.4.14", "tailwindcss": "^3.4.14",
"typescript": "^5.6.3", "typescript": "^5.6.3",
"vite": "^5.4.10" "vite": "^5.4.21"
} }
} }
+82
View File
@@ -0,0 +1,82 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Уведомление о файлах cookie — Умный Байт</title>
<meta name="robots" content="index, follow">
<style>
:root { --ink:#0f172a; --muted:#475569; --line:#e2e8f0; }
*{box-sizing:border-box}
body{margin:0;background:#f8fafc;color:var(--ink);
font-family:Inter,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
line-height:1.6;padding:32px 20px}
.wrap{max-width:720px;margin:0 auto;background:#fff;border:1px solid var(--line);
border-radius:16px;padding:36px}
h1{font-size:1.6rem;margin:0 0 6px;letter-spacing:-.02em}
.sub{color:var(--muted);font-size:14px;margin:0 0 28px}
h2{font-size:1.05rem;margin:28px 0 8px}
p,li{color:var(--muted);font-size:15px}
table{width:100%;border-collapse:collapse;margin-top:10px;font-size:14px}
th,td{text-align:left;padding:9px 10px;border-bottom:1px solid var(--line);vertical-align:top}
th{color:var(--ink);font-weight:600}
.back{display:inline-block;margin-top:28px;font-size:14px;color:var(--ink)}
.req{margin-top:28px;padding-top:20px;border-top:1px solid var(--line);font-size:13px;color:var(--muted)}
</style>
</head>
<body>
<div class="wrap">
<h1>Уведомление о файлах cookie</h1>
<p class="sub">Сайт umbyte.ru. Обновлено 25 августа 2026 года.</p>
<p>Куки — небольшие файлы, которые сайт сохраняет в браузере. Ниже честно
описано, какие из них использует этот сайт и зачем.</p>
<h2>Что мы используем</h2>
<p>Только технические куки. На сайте <strong>нет систем веб-аналитики</strong>
(ни Яндекс Метрики, ни Google Analytics), <strong>нет рекламных пикселей</strong>
и виджетов сторонних сервисов, которые следили бы за посетителями.</p>
<table>
<tr><th>Назначение</th><th>Когда появляется</th><th>Срок</th></tr>
<tr>
<td>Сессия входа в панель управления сайтом</td>
<td>Только у сотрудников после входа в закрытый раздел</td>
<td>До выхода или окончания срока сессии</td>
</tr>
<tr>
<td>Запоминание выбора в уведомлении о куки</td>
<td>После нажатия кнопки в баннере</td>
<td>180 дней</td>
</tr>
</table>
<h2>Персональные данные</h2>
<p>Через сайт мы не собираем персональные данные посетителей: форм обратной
связи и подписки здесь нет. Кнопка связи открывает ваш почтовый клиент —
письмо уходит напрямую, минуя наш сервер. Поэтому в отношении посетителей
сайта мы не выступаем оператором персональных данных.</p>
<h2>Как отказаться</h2>
<p>Нажмите «Отклонить» в баннере. Кроме того, куки можно удалить или запретить
в настройках браузера — в разделе конфиденциальности. Учтите, что при полном
запрете технических куки вход в закрытый раздел работать не будет.</p>
<h2>Изменения</h2>
<p>Если на сайте появятся аналитика или формы сбора данных, это уведомление
будет дополнено, а в баннере появится выбор категорий куки.</p>
<div class="req">
ООО «Умный Байт», ИНН 3528347856, КПП 352801001, ОГРН 1263500003667.<br>
162620, Вологодская область, город Череповец, улица Центральная, дом 12.<br>
Регистрационный номер в реестре операторов персональных данных: 35-26-021324
(приказ Роскомнадзора № 127 от 07.08.2026).<br>
Вопросы по обработке данных: <a href="mailto:privacy@umbyte.ru">privacy@umbyte.ru</a>
</div>
<a class="back" href="/">← На главную</a>
</div>
</body>
</html>
+111
View File
@@ -0,0 +1,111 @@
/* Куки-баннер сайта «Умный Байт».
*
* Сделан по образцу баннера АгроТО (там он живёт в public/landing/cookies.js),
* но без выбора категорий: на umbyte.ru нет ни аналитики, ни рекламных
* пикселей, ни форм — только техническая сессия админки. Городить окно
* настроек с переключателями, которые ничего не переключают, честнее не
* делать: пользователю показывают выбор, которого нет.
*
* Если на сайте появится Метрика или форма обратной связи — берём полный
* вариант с категориями из АгроТО, механика хранения совпадает.
*
* Хранение: localStorage["umbyte_cookie_consent_v1"] = {accepted, ts}
* Программно: window.umbyteCookies.show() / .get() / .reset()
*/
(function () {
'use strict';
var STORAGE_KEY = 'umbyte_cookie_consent_v1';
var TTL_DAYS = 180;
function read() {
try {
var raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
var p = JSON.parse(raw);
if (!p || !p.ts) return null;
if ((Date.now() - new Date(p.ts).getTime()) / 86400000 > TTL_DAYS) return null;
return p;
} catch (e) { return null; }
}
function write(accepted) {
var data = { accepted: !!accepted, ts: new Date().toISOString() };
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); } catch (e) {}
return data;
}
var css = [
'.umb-cc{position:fixed;inset:auto 0 0 0;z-index:9999;display:flex;justify-content:center;padding:16px;pointer-events:none;font-family:Inter,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif}',
'.umb-cc-bar{pointer-events:auto;background:#fff;border:1px solid #e2e8f0;border-radius:14px;box-shadow:0 12px 40px rgba(15,23,42,.12);padding:16px 20px;max-width:860px;width:100%;display:flex;gap:16px;align-items:center;animation:umbUp .25s ease-out}',
'@keyframes umbUp{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:none}}',
'.umb-cc-text{flex:1;margin:0;font-size:13.5px;line-height:1.5;color:#475569}',
'.umb-cc-text a{color:#0f172a;text-decoration:underline;text-underline-offset:2px}',
'.umb-cc-actions{display:flex;gap:8px;flex-shrink:0}',
'.umb-cc-btn{font-family:inherit;font-size:13px;font-weight:500;padding:9px 18px;border-radius:9px;border:1.5px solid #e2e8f0;background:#fff;color:#475569;cursor:pointer;transition:all .15s;white-space:nowrap}',
'.umb-cc-btn:hover{border-color:#94a3b8;color:#0f172a}',
'.umb-cc-btn-primary{background:#0f172a;color:#fff;border-color:#0f172a}',
'.umb-cc-btn-primary:hover{background:#1e293b;border-color:#1e293b;color:#fff}',
/* На телефоне: узкие отступы, мелкий шрифт, кнопки в один ряд с переносом
текста внутри — иначе длинная подпись обрезается многоточием. */
'@media(max-width:640px){',
'.umb-cc{padding:8px}',
'.umb-cc-bar{flex-direction:column;align-items:stretch;gap:10px;padding:12px 14px;border-radius:12px}',
'.umb-cc-text{font-size:12.5px;line-height:1.4}',
'.umb-cc-actions{width:100%;gap:6px}',
'.umb-cc-btn{flex:1;padding:8px 6px;font-size:11.5px;white-space:normal;line-height:1.2;display:flex;align-items:center;justify-content:center}',
'}'
].join('');
function injectCss() {
if (document.getElementById('umb-cc-style')) return;
var s = document.createElement('style');
s.id = 'umb-cc-style';
s.textContent = css;
document.head.appendChild(s);
}
function show() {
if (document.getElementById('umb-cc-bar')) return;
injectCss();
var el = document.createElement('div');
el.id = 'umb-cc-bar';
el.className = 'umb-cc';
el.innerHTML = ''
+ '<div class="umb-cc-bar" role="dialog" aria-label="Файлы cookie">'
+ '<p class="umb-cc-text">Мы используем только технические куки — они нужны, чтобы сайт работал. '
+ 'Аналитики и рекламных сервисов на сайте нет. Подробнее в <a href="/cookies.html">уведомлении о куки</a>.</p>'
+ '<div class="umb-cc-actions">'
+ '<button type="button" class="umb-cc-btn" data-act="no">Отклонить</button>'
+ '<button type="button" class="umb-cc-btn umb-cc-btn-primary" data-act="yes">Хорошо</button>'
+ '</div>'
+ '</div>';
el.addEventListener('click', function (e) {
var act = e.target && e.target.getAttribute && e.target.getAttribute('data-act');
if (!act) return;
write(act === 'yes');
var b = document.getElementById('umb-cc-bar');
if (b) b.remove();
});
document.body.appendChild(el);
}
window.umbyteCookies = {
show: show,
get: read,
reset: function () {
try { localStorage.removeItem(STORAGE_KEY); } catch (e) {}
var b = document.getElementById('umb-cc-bar');
if (b) b.remove();
show();
}
};
function init() { if (!read()) show(); }
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
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.
+198
View File
@@ -0,0 +1,198 @@
/* cyrillic */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/Inter-400-cyrillic.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* latin */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/Inter-400-latin.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(/fonts/Inter-500-cyrillic.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* latin */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(/fonts/Inter-500-latin.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(/fonts/Inter-600-cyrillic.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* latin */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(/fonts/Inter-600-latin.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(/fonts/Inter-700-cyrillic.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* latin */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(/fonts/Inter-700-latin.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic */
@font-face {
font-family: 'Onest';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/Onest-400-cyrillic.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* latin */
@font-face {
font-family: 'Onest';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/Onest-400-latin.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic */
@font-face {
font-family: 'Onest';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(/fonts/Onest-500-cyrillic.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* latin */
@font-face {
font-family: 'Onest';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(/fonts/Onest-500-latin.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic */
@font-face {
font-family: 'Onest';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(/fonts/Onest-600-cyrillic.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* latin */
@font-face {
font-family: 'Onest';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(/fonts/Onest-600-latin.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic */
@font-face {
font-family: 'Unbounded';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/Unbounded-400-cyrillic.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* latin */
@font-face {
font-family: 'Unbounded';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/Unbounded-400-latin.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic */
@font-face {
font-family: 'Unbounded';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(/fonts/Unbounded-500-cyrillic.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* latin */
@font-face {
font-family: 'Unbounded';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(/fonts/Unbounded-500-latin.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic */
@font-face {
font-family: 'Unbounded';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(/fonts/Unbounded-600-cyrillic.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* latin */
@font-face {
font-family: 'Unbounded';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(/fonts/Unbounded-600-latin.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic */
@font-face {
font-family: 'Unbounded';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(/fonts/Unbounded-700-cyrillic.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* latin */
@font-face {
font-family: 'Unbounded';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(/fonts/Unbounded-700-latin.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 828 B

+9
View File
@@ -3,6 +3,7 @@ const API_BASE = import.meta.env.VITE_API_URL || '';
export interface Product { export interface Product {
id: number; id: number;
slug: string; slug: string;
site_url?: string | null;
title: string; title: string;
subtitle: string | null; subtitle: string | null;
description: string; description: string;
@@ -11,6 +12,11 @@ export interface Product {
icon_key: string | null; icon_key: string | null;
tags: string[]; tags: string[];
sort_order: number; sort_order: number;
title_en?: string | null;
subtitle_en?: string | null;
description_en?: string | null;
logo_url?: string | null;
brand_color?: string | null;
} }
export interface ApproachItem { export interface ApproachItem {
@@ -19,6 +25,8 @@ export interface ApproachItem {
description: string; description: string;
icon_key: string | null; icon_key: string | null;
sort_order: number; sort_order: number;
title_en?: string | null;
description_en?: string | null;
} }
export interface BusinessCard { export interface BusinessCard {
@@ -40,6 +48,7 @@ export interface BusinessCard {
export interface LandingContent { export interface LandingContent {
sections: Record<string, Record<string, unknown>>; sections: Record<string, Record<string, unknown>>;
sections_en?: Record<string, Record<string, unknown>>;
products: Product[]; products: Product[];
approach: ApproachItem[]; approach: ApproachItem[];
settings: Record<string, string>; settings: Record<string, string>;
+10
View File
@@ -45,6 +45,16 @@ export const ProductIcon: FC<{ name: string; size?: number }> = ({
<path d="M2 12l10 5 10-5" /> <path d="M2 12l10 5 10-5" />
</svg> </svg>
); );
// Бонитировка — оценочный лист с галочками: планшет с отметками.
case 'bonitirovka':
return (
<svg {...common}>
<rect x="4" y="3" width="16" height="18" rx="2" />
<path d="M9 3h6v3H9z" />
<path d="M8 11l1.6 1.6L13 9" />
<path d="M8 16.5h8" />
</svg>
);
case 'smart-home': case 'smart-home':
return ( return (
<svg {...common}> <svg {...common}>
+1 -1
View File
@@ -176,7 +176,7 @@ export function CardPage() {
} }
const cardCss = ` const cardCss = `
@import url('https://fonts.googleapis.com/css2?family=Unbounded:wght@400;500;600;700&family=Onest:wght@400;500;600&display=swap'); @import url('/fonts/fonts.css');
.cardpage{--bg:#E9EDF7;--bg2:#DDE3F2;--card:#FFFFFF;--card2:#FBFCFF;--ink:#15132E;--ink2:#5C5984;--line:#E7E9F4;--indigo:#312E81;--indigo2:#4F4BC4;--cyan:#06B6D4;--cyan-glow:#22D3EE;--violet:#7C5CFF;--shadow:0 30px 70px -24px rgba(49,46,129,.32),0 10px 30px -18px rgba(6,182,212,.28); .cardpage{--bg:#E9EDF7;--bg2:#DDE3F2;--card:#FFFFFF;--card2:#FBFCFF;--ink:#15132E;--ink2:#5C5984;--line:#E7E9F4;--indigo:#312E81;--indigo2:#4F4BC4;--cyan:#06B6D4;--cyan-glow:#22D3EE;--violet:#7C5CFF;--shadow:0 30px 70px -24px rgba(49,46,129,.32),0 10px 30px -18px rgba(6,182,212,.28);
font-family:'Onest',sans-serif;color:var(--ink);min-height:100vh;display:flex;align-items:center;justify-content:center;padding:30px 18px;position:relative;overflow:hidden;perspective:1200px;background:linear-gradient(165deg,var(--bg),var(--bg2))} font-family:'Onest',sans-serif;color:var(--ink);min-height:100vh;display:flex;align-items:center;justify-content:center;padding:30px 18px;position:relative;overflow:hidden;perspective:1200px;background:linear-gradient(165deg,var(--bg),var(--bg2))}
@media (prefers-color-scheme:dark){.cardpage{--bg:#0C0B22;--bg2:#141233;--card:#17163a;--card2:#1c1a44;--ink:#F4F4FF;--ink2:#A6A3D9;--line:rgba(255,255,255,.12);--indigo:#4F4BC4;--indigo2:#6A66E0;--cyan:#22D3EE;--cyan-glow:#67E8F9;--shadow:0 30px 80px -24px rgba(0,0,0,.7),0 0 40px -10px rgba(34,211,238,.25)}} @media (prefers-color-scheme:dark){.cardpage{--bg:#0C0B22;--bg2:#141233;--card:#17163a;--card2:#1c1a44;--ink:#F4F4FF;--ink2:#A6A3D9;--line:rgba(255,255,255,.12);--indigo:#4F4BC4;--indigo2:#6A66E0;--cyan:#22D3EE;--cyan-glow:#67E8F9;--shadow:0 30px 80px -24px rgba(0,0,0,.7),0 0 40px -10px rgba(34,211,238,.25)}}
+195 -30
View File
@@ -8,9 +8,42 @@ const STATUS_LABELS: Record<string, { text: string; cls: string }> = {
planned: { text: 'ПЛАНИРУЕТСЯ', cls: 'bg-slate-100 text-slate-600' }, planned: { text: 'ПЛАНИРУЕТСЯ', cls: 'bg-slate-100 text-slate-600' },
}; };
// Английская версия нужна не для трафика, а для проверяющих: партнёры и
// магазины приложений смотрят сайт компании и должны понять, кто мы и чем
// заняты. Переводы лежат в тех же строках БД с суффиксом _en; где перевода
// нет — показываем русский, страница не ломается.
type Lang = 'ru' | 'en';
const UI: Record<Lang, Record<string, string>> = {
ru: {
products: 'Продукты', approach: 'Подход', contacts: 'Контакты',
discuss: 'Обсудить проект', director: 'Генеральный директор',
inn: 'ИНН', kpp: 'КПП', ogrn: 'ОГРН', region: 'Россия',
},
en: {
products: 'Products', approach: 'Approach', contacts: 'Contacts',
discuss: 'Discuss a project', director: 'CEO',
inn: 'INN', kpp: 'KPP', ogrn: 'OGRN', region: 'Russia',
},
};
const STATUS_LABELS_EN: Record<string, string> = {
production: 'IN PRODUCTION', development: 'IN DEVELOPMENT', planned: 'PLANNED',
};
export function Landing() { export function Landing() {
const [content, setContent] = useState<LandingContent | null>(null); const [content, setContent] = useState<LandingContent | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [lang, setLang] = useState<Lang>(() => {
try { return (localStorage.getItem('umbyte_lang') as Lang) || 'ru'; } catch { return 'ru'; }
});
const [menuOpen, setMenuOpen] = useState(false);
const t = (key: string) => UI[lang][key] || key;
const switchLang = (next: Lang) => {
setLang(next);
try { localStorage.setItem('umbyte_lang', next); } catch { /* приватный режим */ }
document.documentElement.lang = next;
};
useEffect(() => { useEffect(() => {
api.getContent() api.getContent()
@@ -34,10 +67,21 @@ export function Landing() {
); );
} }
const hero = content.sections.hero as Record<string, any>; // Английский набор с откатом на русский по каждому полю отдельно:
const productsIntro = content.sections.products_intro as Record<string, any>; // недопереведённая секция не должна оставлять пустое место на странице.
const approachIntro = content.sections.approach_intro as Record<string, any>; const sec = (key: string): Record<string, any> => {
const cta = content.sections.cta as Record<string, any>; const ru = (content.sections[key] || {}) as Record<string, any>;
if (lang !== 'en') return ru;
const en = ((content.sections_en || {})[key] || {}) as Record<string, any>;
return { ...ru, ...en };
};
const pick = (obj: Record<string, any>, field: string) =>
(lang === 'en' && obj[field + '_en']) || obj[field];
const hero = sec('hero');
const productsIntro = sec('products_intro');
const approachIntro = sec('approach_intro');
const cta = sec('cta');
const settings = content.settings; const settings = content.settings;
// Реальное количество продуктов из БД + русское склонение // Реальное количество продуктов из БД + русское склонение
@@ -57,13 +101,60 @@ export function Landing() {
<Logo size={32} /> <Logo size={32} />
<span className="text-base font-medium text-brand-900 tracking-tight">Умный Байт</span> <span className="text-base font-medium text-brand-900 tracking-tight">Умный Байт</span>
</a> </a>
{/* Desktop nav */}
<div className="hidden md:flex items-center gap-6 text-sm text-slate-600"> <div className="hidden md:flex items-center gap-6 text-sm text-slate-600">
<a href="#products" className="hover:text-brand-900 transition-colors">Продукты</a> <a href="#products" className="hover:text-brand-900 transition-colors">{t('products')}</a>
<a href="#approach" className="hover:text-brand-900 transition-colors">Подход</a> <a href="#approach" className="hover:text-brand-900 transition-colors">{t('approach')}</a>
<a href="#contact" className="hover:text-brand-900 transition-colors">Контакты</a> <a href="#contact" className="hover:text-brand-900 transition-colors">{t('contacts')}</a>
<a href="#contact" className="btn-primary text-sm py-2 px-4">Обсудить проект</a> <div className="flex items-center rounded-lg border border-slate-200 overflow-hidden text-xs font-medium">
{(['ru', 'en'] as Lang[]).map((l) => (
<button
key={l}
onClick={() => switchLang(l)}
className={`px-2.5 py-1.5 transition-colors ${lang === l ? 'bg-brand-900 text-white' : 'text-slate-600 hover:bg-slate-50'}`}
>
{l.toUpperCase()}
</button>
))}
</div>
<a href="#contact" className="btn-primary text-sm py-2 px-4">{t('discuss')}</a>
</div>
{/* Burger button (mobile) */}
<button
className="md:hidden flex flex-col justify-center items-center w-10 h-10 gap-1.5"
onClick={() => setMenuOpen(!menuOpen)}
aria-label="Menu"
>
<span className={`block w-6 h-0.5 bg-slate-700 transition-all duration-300 ${menuOpen ? 'rotate-45 translate-y-[4px]' : ''}`} />
<span className={`block w-6 h-0.5 bg-slate-700 transition-all duration-300 ${menuOpen ? 'opacity-0' : ''}`} />
<span className={`block w-6 h-0.5 bg-slate-700 transition-all duration-300 ${menuOpen ? '-rotate-45 -translate-y-[4px]' : ''}`} />
</button>
</div>
{/* Mobile menu */}
{menuOpen && (
<div className="md:hidden border-t border-slate-200 bg-white px-6 py-5 space-y-4 text-sm">
<a href="#products" onClick={() => setMenuOpen(false)} className="block text-slate-700 hover:text-brand-900 py-1">{t('products')}</a>
<a href="#approach" onClick={() => setMenuOpen(false)} className="block text-slate-700 hover:text-brand-900 py-1">{t('approach')}</a>
<a href="#contact" onClick={() => setMenuOpen(false)} className="block text-slate-700 hover:text-brand-900 py-1">{t('contacts')}</a>
<a href="#contact" onClick={() => setMenuOpen(false)} className="block btn-primary text-center text-sm py-2.5 mt-2">{t('discuss')}</a>
<div className="flex items-center gap-2 pt-2 border-t border-slate-100">
<span className="text-xs text-slate-400">{lang === 'ru' ? 'Язык' : 'Language'}:</span>
<div className="flex items-center rounded-lg border border-slate-200 overflow-hidden text-xs font-medium">
{(['ru', 'en'] as Lang[]).map((l) => (
<button
key={l}
onClick={() => { switchLang(l); setMenuOpen(false); }}
className={`px-2.5 py-1.5 transition-colors ${lang === l ? 'bg-brand-900 text-white' : 'text-slate-600 hover:bg-slate-50'}`}
>
{l.toUpperCase()}
</button>
))}
</div> </div>
</div> </div>
</div>
)}
</nav> </nav>
<section className="bg-slate-50 py-20 relative overflow-hidden"> <section className="bg-slate-50 py-20 relative overflow-hidden">
@@ -93,7 +184,8 @@ export function Landing() {
{Array.isArray(hero?.stats) && hero.stats.length > 0 && ( {Array.isArray(hero?.stats) && hero.stats.length > 0 && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-14 pt-7 border-t border-slate-200"> <div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-14 pt-7 border-t border-slate-200">
{hero.stats.map((stat: any, idx: number) => { {hero.stats.map((stat: any, idx: number) => {
const isProd = typeof stat.label === 'string' && stat.label.toUpperCase().includes('ПРОДУКТ'); // Подменяем только счётчик продуктов (число), а не любую подпись со словом «продукт»
const isProd = typeof stat.label === 'string' && /^\d+$/.test(String(stat.value).trim()) && stat.label.toUpperCase().includes('ПРОДУКТ');
const value = isProd ? String(productCount) : stat.value; const value = isProd ? String(productCount) : stat.value;
const label = isProd ? pluralProducts(productCount) : stat.label; const label = isProd ? pluralProducts(productCount) : stat.label;
return ( return (
@@ -121,32 +213,68 @@ export function Landing() {
<div className="space-y-4"> <div className="space-y-4">
{content.products.map((p) => { {content.products.map((p) => {
const statusBadge = STATUS_LABELS[p.status] || STATUS_LABELS.planned; const statusBadge = STATUS_LABELS[p.status] || STATUS_LABELS.planned;
const badgeText = lang === 'en'
? (STATUS_LABELS_EN[p.status] || STATUS_LABELS_EN.planned)
: statusBadge.text;
return ( return (
<div key={p.id} className="bg-gradient-to-br from-slate-50 to-brand-50 border border-slate-200 rounded-3xl p-8 grid md:grid-cols-2 gap-7 items-center"> <div
key={p.id}
className="border rounded-3xl p-8 grid md:grid-cols-2 gap-7 items-center transition-shadow hover:shadow-md"
style={{
// Карточка в цвете самого продукта: две одинаковые серые
// плитки не давали понять, что это разные приложения.
background: p.brand_color
? `linear-gradient(135deg, ${p.brand_color}0D 0%, ${p.brand_color}1A 100%)`
: undefined,
borderColor: p.brand_color ? `${p.brand_color}33` : undefined,
}}
>
<div> <div>
<div className="flex gap-2 mb-4"> <div className="flex gap-2 mb-4">
<span className={`text-xs px-2.5 py-1 rounded font-medium ${statusBadge.cls}`}>{statusBadge.text}</span> <span className={`text-xs px-2.5 py-1 rounded font-medium ${statusBadge.cls}`}>{badgeText}</span>
{p.audience && ( {p.audience && (
<span className="text-xs bg-white border border-slate-200 px-2.5 py-1 rounded text-slate-600 font-medium">{p.audience}</span> <span className="text-xs bg-white border border-slate-200 px-2.5 py-1 rounded text-slate-600 font-medium">{p.audience}</span>
)} )}
</div> </div>
<div className="flex items-center gap-3 mb-3"> <div className="flex items-center gap-3 mb-3">
{p.logo_url ? (
<img
src={p.logo_url}
alt={String(pick(p, 'title') || '')}
width={48}
height={48}
className="w-12 h-12 rounded-xl object-contain bg-white border border-slate-200/70 p-1"
/>
) : (
<div className="w-12 h-12 bg-brand-800 rounded-xl flex items-center justify-center text-white"> <div className="w-12 h-12 bg-brand-800 rounded-xl flex items-center justify-center text-white">
<ProductIcon name={p.icon_key || ''} size={24} /> <ProductIcon name={p.icon_key || ''} size={24} />
</div> </div>
)}
<div> <div>
<div className="text-2xl font-medium text-brand-900 tracking-tight">{p.title}</div> <div className="text-2xl font-medium text-brand-900 tracking-tight">{pick(p, 'title')}</div>
{p.subtitle && ( {pick(p, 'subtitle') && (
<div className="text-xs text-brand-500 font-medium">{p.subtitle}</div> <div className="text-xs text-brand-500 font-medium">{pick(p, 'subtitle')}</div>
)} )}
</div> </div>
</div> </div>
<p className="text-sm text-slate-600 leading-relaxed mb-4">{p.description}</p> <p className="text-sm text-slate-600 leading-relaxed mb-4">{pick(p, 'description')}</p>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{p.tags.map((tag) => ( {p.tags.map((tag) => (
<span key={tag} className="text-xs bg-white border border-slate-200 px-2 py-1 rounded text-slate-600">{tag}</span> <span key={tag} className="text-xs bg-white border border-slate-200 px-2 py-1 rounded text-slate-600">{tag}</span>
))} ))}
</div> </div>
{p.site_url && (
<a
href={p.site_url}
target="_blank"
rel="noopener"
className="inline-flex items-center gap-1.5 mt-5 text-sm font-medium border-b transition-colors"
style={{ color: p.brand_color || undefined, borderColor: p.brand_color ? `${p.brand_color}55` : undefined }}
>
{p.site_url.replace(/^https?:\/\//, '')}
<ArrowRightIcon width={14} height={14} />
</a>
)}
</div> </div>
</div> </div>
); );
@@ -170,8 +298,8 @@ export function Landing() {
<div className="w-9 h-9 bg-accent rounded-lg flex items-center justify-center text-brand-900 mb-4"> <div className="w-9 h-9 bg-accent rounded-lg flex items-center justify-center text-brand-900 mb-4">
<ApproachIcon name={item.icon_key || ''} /> <ApproachIcon name={item.icon_key || ''} />
</div> </div>
<div className="font-medium mb-2">{item.title}</div> <div className="font-medium mb-2">{pick(item, 'title')}</div>
<div className="text-sm text-brand-200 leading-relaxed">{item.description}</div> <div className="text-sm text-brand-200 leading-relaxed">{pick(item, 'description')}</div>
</div> </div>
))} ))}
</div> </div>
@@ -182,36 +310,73 @@ export function Landing() {
<div className="container-x"> <div className="container-x">
<h2 className="text-3xl font-medium text-brand-900 tracking-tight mb-3">{cta?.title}</h2> <h2 className="text-3xl font-medium text-brand-900 tracking-tight mb-3">{cta?.title}</h2>
<p className="text-sm text-slate-600 max-w-md mx-auto mb-6">{cta?.description}</p> <p className="text-sm text-slate-600 max-w-md mx-auto mb-6">{cta?.description}</p>
{/* Кнопка Telegram убрана: адрес вёл в никуда, а нерабочая
кнопка на сайте хуже, чем её отсутствие. Главное действие —
почта, она одна и та же на обоих сайтах компании. */}
<div className="inline-flex gap-2 items-center flex-wrap justify-center"> <div className="inline-flex gap-2 items-center flex-wrap justify-center">
{settings.contact_email && (
<button
type="button"
onClick={() => { window.location.href = 'mailto:' + settings.contact_email; }}
className="btn-primary"
>
{cta?.cta_primary || 'Написать нам'}
</button>
)}
{settings.contact_phone && ( {settings.contact_phone && (
<button <button
type="button" type="button"
onClick={() => { window.location.href = 'tel:' + settings.contact_phone.replace(/[^+\d]/g, ''); }} onClick={() => { window.location.href = 'tel:' + String(settings.contact_phone).replace(/[^+\d]/g, ''); }}
className="btn-secondary" className="btn-secondary"
> >
{settings.contact_phone} {settings.contact_phone}
</button> </button>
)} )}
{settings.contact_email && (
<button
type="button"
onClick={() => { window.location.href = 'mailto:' + settings.contact_email; }}
className="btn-secondary"
>
{settings.contact_email}
</button>
)}
</div> </div>
{settings.contact_email && (
<div className="mt-4 text-sm text-slate-500">{settings.contact_email}</div>
)}
</div> </div>
</section> </section>
<footer className="bg-slate-50 py-7 border-t border-slate-200"> <footer className="bg-slate-50 py-7 border-t border-slate-200">
<div className="container-x flex flex-wrap gap-3 justify-between items-center"> <div className="container-x">
{/* Реквизиты. Нужны, чтобы сайт подтверждал принадлежность компании:
по ним проверяют организацию — от контрагентов до магазинов приложений. */}
{(settings.company_inn || settings.company_ogrn) && (
<div className="text-xs text-slate-500 leading-relaxed mb-5 pb-5 border-b border-slate-200">
<div className="text-slate-600 font-medium mb-1">{(lang === 'en' && settings.company_name_en) || settings.company_name_full || 'ООО «Умный Байт»'}</div>
<div className="flex flex-wrap gap-x-4 gap-y-1">
{settings.company_inn && <span>{t('inn')} {settings.company_inn}</span>}
{settings.company_kpp && <span>{t('kpp')} {settings.company_kpp}</span>}
{settings.company_ogrn && <span>{t('ogrn')} {settings.company_ogrn}</span>}
</div>
{(settings.company_address || settings.company_address_en) && (
<div className="mt-1">{(lang === 'en' && settings.company_address_en) || settings.company_address}</div>
)}
{(settings.company_director || settings.company_director_en) && (
<div className="mt-1">
{t('director')} — {(lang === 'en' && settings.company_director_en) || settings.company_director}
</div>
)}
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-1">
{settings.contact_email && (
<a href={`mailto:${settings.contact_email}`} className="hover:text-brand-900 transition-colors">{settings.contact_email}</a>
)}
{settings.contact_phone && (
<a href={`tel:${String(settings.contact_phone).replace(/[^+\d]/g, '')}`} className="hover:text-brand-900 transition-colors">{settings.contact_phone}</a>
)}
</div>
</div>
)}
<div className="flex flex-wrap gap-3 justify-between items-center">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Logo size={20} /> <Logo size={20} />
<span className="text-xs text-slate-600">© {new Date().getFullYear()} {settings.company_name_full || 'ООО «Умный Байт»'}</span> <span className="text-xs text-slate-600">© {new Date().getFullYear()} {(lang === 'en' && settings.company_name_en) || settings.company_name_full || 'ООО «Умный Байт»'}</span>
</div>
<div className="text-xs text-slate-500">{settings.domain || 'umbyte.ru'} · {(lang === 'en' ? settings.region_en : settings.region) || t('region')}</div>
</div> </div>
<div className="text-xs text-slate-500">{settings.domain || 'umbyte.ru'} · {settings.region || 'Россия'}</div>
</div> </div>
</footer> </footer>
</div> </div>
+1
View File
@@ -0,0 +1 @@
{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/admin/AdminLayout.tsx","./src/admin/AdminLogin.tsx","./src/admin/ProductsAdmin.tsx","./src/admin/SectionsAdmin.tsx","./src/admin/SettingsAdmin.tsx","./src/api/client.ts","./src/components/Icons.tsx","./src/pages/Landing.tsx"],"version":"5.9.3"}