Compare commits
8 Commits
3d4bed91ec
...
29d1386dd9
| Author | SHA1 | Date | |
|---|---|---|---|
| 29d1386dd9 | |||
| 18a0888c13 | |||
| 3199b681a2 | |||
| b117f3a740 | |||
| e2eee024a6 | |||
| ae71f9c64b | |||
| 0c295386fa | |||
| ad901c1ee4 |
@@ -7,20 +7,22 @@ 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; 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,
|
||||||
|
|||||||
@@ -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();
|
||||||
Generated
+2
-2
@@ -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": {
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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 {
|
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>;
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
+190
-28
@@ -8,9 +8,40 @@ 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: 'Генеральный директор',
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
products: 'Products', approach: 'Approach', contacts: 'Contacts',
|
||||||
|
discuss: 'Discuss a project', director: 'CEO',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
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 +65,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 +99,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">
|
||||||
@@ -121,32 +210,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 +295,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,37 +307,74 @@ 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>ИНН {settings.company_inn}</span>}
|
||||||
|
{settings.company_kpp && <span>КПП {settings.company_kpp}</span>}
|
||||||
|
{settings.company_ogrn && <span>ОГРН {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>
|
||||||
<div className="text-xs text-slate-500">{settings.domain || 'umbyte.ru'} · {settings.region || 'Россия'}</div>
|
<div className="text-xs text-slate-500">{settings.domain || 'umbyte.ru'} · {settings.region || 'Россия'}</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</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