Compare commits
15 Commits
3d4bed91ec
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| acf929ed8c | |||
| 8ebc42dd09 | |||
| c4e5a532a1 | |||
| 913b66a9cb | |||
| 4d01a65859 | |||
| 0dbbc7487e | |||
| f8730d9d09 | |||
| 29d1386dd9 | |||
| 18a0888c13 | |||
| 3199b681a2 | |||
| b117f3a740 | |||
| e2eee024a6 | |||
| ae71f9c64b | |||
| 0c295386fa | |||
| ad901c1ee4 |
+11
-1
@@ -15,6 +15,11 @@ RUN npm run 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
|
||||
COPY backend/package.json backend/package-lock.json* ./
|
||||
RUN npm install
|
||||
@@ -33,7 +38,12 @@ WORKDIR /app
|
||||
|
||||
# Backend production deps
|
||||
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
|
||||
COPY --from=backend-build /app/backend/dist ./backend/dist
|
||||
|
||||
@@ -6,21 +6,23 @@ export const publicRouter = Router();
|
||||
publicRouter.get('/content', async (_req, res) => {
|
||||
try {
|
||||
const [sections, products, approach, settings] = await Promise.all([
|
||||
query<{ key: string; title: string; content_json: unknown; sort_order: number }>(
|
||||
`SELECT key, title, content_json, sort_order
|
||||
query<{ key: string; title: string; content_json: unknown; content_json_en: unknown; sort_order: number }>(
|
||||
`SELECT key, title, content_json, content_json_en, sort_order
|
||||
FROM sections
|
||||
WHERE is_active = true
|
||||
ORDER BY sort_order ASC`
|
||||
),
|
||||
query<unknown>(
|
||||
`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
|
||||
WHERE is_published = true
|
||||
ORDER BY sort_order ASC`
|
||||
),
|
||||
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
|
||||
WHERE is_active = true
|
||||
ORDER BY sort_order ASC`
|
||||
@@ -30,9 +32,13 @@ publicRouter.get('/content', async (_req, res) => {
|
||||
),
|
||||
]);
|
||||
|
||||
// Секции отдаём двумя наборами: русский и английский. Фронт выбирает
|
||||
// язык на лету и откатывается на русский там, где перевода ещё нет.
|
||||
const sectionsMap: Record<string, unknown> = {};
|
||||
const sectionsMapEn: Record<string, unknown> = {};
|
||||
for (const s of sections) {
|
||||
sectionsMap[s.key] = s.content_json;
|
||||
sectionsMapEn[s.key] = s.content_json_en || {};
|
||||
}
|
||||
|
||||
const settingsMap: Record<string, string> = {};
|
||||
@@ -41,6 +47,7 @@ publicRouter.get('/content', async (_req, res) => {
|
||||
}
|
||||
|
||||
res.json({
|
||||
sections_en: sectionsMapEn,
|
||||
sections: sectionsMap,
|
||||
products,
|
||||
approach,
|
||||
|
||||
@@ -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();
|
||||
@@ -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
@@ -10,12 +10,13 @@
|
||||
<meta property="og:description" content="Технологии, которые решают реальные задачи" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://umbyte.ru" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<!-- ШРИФТЫ ЛОКАЛЬНЫЕ: раньше тянулись с fonts.googleapis.com и при
|
||||
ограничениях доступа не приезжали. Подмножества cyrillic+latin. -->
|
||||
<link href="/fonts/fonts.css" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
<script src="/cookies.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Generated
+2
-2
@@ -17,12 +17,12 @@
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.14",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.10"
|
||||
"vite": "^5.4.21"
|
||||
}
|
||||
},
|
||||
"node_modules/@alloc/quick-lru": {
|
||||
|
||||
@@ -18,11 +18,11 @@
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.14",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.10"
|
||||
"vite": "^5.4.21"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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.
@@ -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 |
@@ -3,6 +3,7 @@ const API_BASE = import.meta.env.VITE_API_URL || '';
|
||||
export interface Product {
|
||||
id: number;
|
||||
slug: string;
|
||||
site_url?: string | null;
|
||||
title: string;
|
||||
subtitle: string | null;
|
||||
description: string;
|
||||
@@ -11,6 +12,11 @@ export interface Product {
|
||||
icon_key: string | null;
|
||||
tags: string[];
|
||||
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 {
|
||||
@@ -19,6 +25,8 @@ export interface ApproachItem {
|
||||
description: string;
|
||||
icon_key: string | null;
|
||||
sort_order: number;
|
||||
title_en?: string | null;
|
||||
description_en?: string | null;
|
||||
}
|
||||
|
||||
export interface BusinessCard {
|
||||
@@ -40,6 +48,7 @@ export interface BusinessCard {
|
||||
|
||||
export interface LandingContent {
|
||||
sections: Record<string, Record<string, unknown>>;
|
||||
sections_en?: Record<string, Record<string, unknown>>;
|
||||
products: Product[];
|
||||
approach: ApproachItem[];
|
||||
settings: Record<string, string>;
|
||||
|
||||
@@ -45,6 +45,16 @@ export const ProductIcon: FC<{ name: string; size?: number }> = ({
|
||||
<path d="M2 12l10 5 10-5" />
|
||||
</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':
|
||||
return (
|
||||
<svg {...common}>
|
||||
|
||||
@@ -176,7 +176,7 @@ export function CardPage() {
|
||||
}
|
||||
|
||||
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);
|
||||
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)}}
|
||||
|
||||
+200
-35
@@ -8,9 +8,42 @@ const STATUS_LABELS: Record<string, { text: string; cls: string }> = {
|
||||
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() {
|
||||
const [content, setContent] = useState<LandingContent | 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(() => {
|
||||
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 cta = content.sections.cta as Record<string, any>;
|
||||
// Английский набор с откатом на русский по каждому полю отдельно:
|
||||
// недопереведённая секция не должна оставлять пустое место на странице.
|
||||
const sec = (key: string): 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;
|
||||
|
||||
// Реальное количество продуктов из БД + русское склонение
|
||||
@@ -57,13 +101,60 @@ export function Landing() {
|
||||
<Logo size={32} />
|
||||
<span className="text-base font-medium text-brand-900 tracking-tight">Умный Байт</span>
|
||||
</a>
|
||||
{/* Desktop nav */}
|
||||
<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="#approach" className="hover:text-brand-900 transition-colors">Подход</a>
|
||||
<a href="#contact" className="hover:text-brand-900 transition-colors">Контакты</a>
|
||||
<a href="#contact" className="btn-primary text-sm py-2 px-4">Обсудить проект</a>
|
||||
<a href="#products" className="hover:text-brand-900 transition-colors">{t('products')}</a>
|
||||
<a href="#approach" className="hover:text-brand-900 transition-colors">{t('approach')}</a>
|
||||
<a href="#contact" className="hover:text-brand-900 transition-colors">{t('contacts')}</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>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<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 && (
|
||||
<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) => {
|
||||
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 label = isProd ? pluralProducts(productCount) : stat.label;
|
||||
return (
|
||||
@@ -121,32 +213,68 @@ export function Landing() {
|
||||
<div className="space-y-4">
|
||||
{content.products.map((p) => {
|
||||
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 (
|
||||
<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 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 && (
|
||||
<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 className="flex items-center gap-3 mb-3">
|
||||
<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} />
|
||||
</div>
|
||||
{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">
|
||||
<ProductIcon name={p.icon_key || ''} size={24} />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="text-2xl font-medium text-brand-900 tracking-tight">{p.title}</div>
|
||||
{p.subtitle && (
|
||||
<div className="text-xs text-brand-500 font-medium">{p.subtitle}</div>
|
||||
<div className="text-2xl font-medium text-brand-900 tracking-tight">{pick(p, 'title')}</div>
|
||||
{pick(p, 'subtitle') && (
|
||||
<div className="text-xs text-brand-500 font-medium">{pick(p, 'subtitle')}</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">
|
||||
{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>
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
@@ -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">
|
||||
<ApproachIcon name={item.icon_key || ''} />
|
||||
</div>
|
||||
<div className="font-medium mb-2">{item.title}</div>
|
||||
<div className="text-sm text-brand-200 leading-relaxed">{item.description}</div>
|
||||
<div className="font-medium mb-2">{pick(item, 'title')}</div>
|
||||
<div className="text-sm text-brand-200 leading-relaxed">{pick(item, 'description')}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -182,36 +310,73 @@ export function Landing() {
|
||||
<div className="container-x">
|
||||
<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>
|
||||
{/* Кнопка Telegram убрана: адрес вёл в никуда, а нерабочая
|
||||
кнопка на сайте хуже, чем её отсутствие. Главное действие —
|
||||
почта, она одна и та же на обоих сайтах компании. */}
|
||||
<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 && (
|
||||
<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"
|
||||
>
|
||||
{settings.contact_phone}
|
||||
</button>
|
||||
)}
|
||||
{settings.contact_email && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { window.location.href = 'mailto:' + settings.contact_email; }}
|
||||
className="btn-secondary"
|
||||
>
|
||||
{settings.contact_email}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{settings.contact_email && (
|
||||
<div className="mt-4 text-sm text-slate-500">{settings.contact_email}</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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="flex items-center gap-2">
|
||||
<Logo size={20} />
|
||||
<span className="text-xs text-slate-600">© {new Date().getFullYear()} {settings.company_name_full || 'ООО «Умный Байт»'}</span>
|
||||
<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">
|
||||
<Logo size={20} />
|
||||
<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 className="text-xs text-slate-500">{settings.domain || 'umbyte.ru'} · {settings.region || 'Россия'}</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
@@ -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"}
|
||||
Reference in New Issue
Block a user