feat(landing): английская версия сайта с переключателем языков
Нужна для проверки компании иностранными партнёрами и магазинами приложений: проверяющий должен понять, кто мы и чем заняты, не зная русского. - переводы лежат в тех же строках БД (products.*_en, approach_items.*_en, sections.content_json_en, settings.company_name_en/company_address_en), админка остаётся одна; - фронт откатывается на русский ПО КАЖДОМУ ПОЛЮ отдельно — недопереведённая секция не оставляет пустого места на странице; - выбор языка запоминается в localStorage, проставляется <html lang>.
This commit is contained in:
@@ -7,20 +7,22 @@ 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
|
||||
`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, site_url
|
||||
status, audience, icon_key, tags, sort_order, site_url,
|
||||
title_en, subtitle_en, description_en
|
||||
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,
|
||||
|
||||
Generated
+2727
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,9 @@ export interface Product {
|
||||
icon_key: string | null;
|
||||
tags: string[];
|
||||
sort_order: number;
|
||||
title_en?: string | null;
|
||||
subtitle_en?: string | null;
|
||||
description_en?: string | null;
|
||||
}
|
||||
|
||||
export interface ApproachItem {
|
||||
@@ -20,10 +23,13 @@ export interface ApproachItem {
|
||||
description: string;
|
||||
icon_key: string | null;
|
||||
sort_order: number;
|
||||
title_en?: string | null;
|
||||
description_en?: string | null;
|
||||
}
|
||||
|
||||
export interface LandingContent {
|
||||
sections: Record<string, Record<string, unknown>>;
|
||||
sections_en?: Record<string, Record<string, unknown>>;
|
||||
products: Product[];
|
||||
approach: ApproachItem[];
|
||||
settings: Record<string, string>;
|
||||
|
||||
@@ -8,9 +8,39 @@ 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: 'Генеральный директор',
|
||||
},
|
||||
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() {
|
||||
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 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 +64,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;
|
||||
|
||||
return (
|
||||
@@ -48,11 +89,22 @@ export function Landing() {
|
||||
<Logo size={32} />
|
||||
<span className="text-base font-medium text-brand-900 tracking-tight">Умный Байт</span>
|
||||
</a>
|
||||
<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>
|
||||
<div className="flex items-center gap-4 md:gap-6 text-sm text-slate-600">
|
||||
<a href="#products" className="hidden md:inline hover:text-brand-900 transition-colors">{t('products')}</a>
|
||||
<a href="#approach" className="hidden md:inline hover:text-brand-900 transition-colors">{t('approach')}</a>
|
||||
<a href="#contact" className="hidden md:inline 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="hidden md:inline btn-primary text-sm py-2 px-4">{t('discuss')}</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -107,11 +159,14 @@ 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>
|
||||
<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>
|
||||
)}
|
||||
@@ -121,13 +176,13 @@ export function Landing() {
|
||||
<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>
|
||||
@@ -141,7 +196,7 @@ export function Landing() {
|
||||
className="inline-flex items-center gap-1.5 mt-5 text-sm font-medium text-brand-900 border-b border-brand-900/30 hover:border-brand-900 transition-colors"
|
||||
>
|
||||
{p.site_url.replace(/^https?:\/\//, '')}
|
||||
<ArrowRightIcon size={14} />
|
||||
<ArrowRightIcon width={14} height={14} />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
@@ -167,8 +222,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>
|
||||
@@ -199,7 +254,7 @@ export function Landing() {
|
||||
<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()} {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'} · {settings.region || 'Россия'}</div>
|
||||
</div>
|
||||
@@ -208,14 +263,16 @@ export function Landing() {
|
||||
по ним проверяют организацию — от контрагентов до магазинов приложений. */}
|
||||
{(settings.company_inn || settings.company_ogrn) && (
|
||||
<div className="mt-5 pt-5 border-t border-slate-200 text-xs text-slate-500 leading-relaxed">
|
||||
<div className="text-slate-600 font-medium mb-1">{settings.company_name_full || 'ООО «Умный Байт»'}</div>
|
||||
<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 && <div className="mt-1">{settings.company_address}</div>}
|
||||
{settings.company_director && <div className="mt-1">Генеральный директор — {settings.company_director}</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 && <div className="mt-1">{t('director')} — {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>
|
||||
|
||||
@@ -1 +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"],"errors":true,"version":"6.0.3"}
|
||||
{"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