landing: динамический счётчик продуктов, телефон, защита email/телефона от ботов

This commit is contained in:
Nik
2026-06-07 19:37:02 +03:00
parent f2d7a6cf4c
commit b22bf3dee3
3 changed files with 104 additions and 11 deletions
+3 -3
View File
@@ -111,7 +111,7 @@ adminRouter.post('/products', requireAdmin, async (req, res) => {
}); });
adminRouter.put('/products/:id', requireAdmin, async (req, res) => { adminRouter.put('/products/:id', requireAdmin, async (req, res) => {
const id = parseInt(req.params.id, 10); const id = parseInt(String(req.params.id), 10);
if (Number.isNaN(id)) { if (Number.isNaN(id)) {
res.status(400).json({ error: 'invalid_id' }); res.status(400).json({ error: 'invalid_id' });
return; return;
@@ -152,7 +152,7 @@ adminRouter.put('/products/:id', requireAdmin, async (req, res) => {
}); });
adminRouter.delete('/products/:id', requireAdmin, async (req, res) => { adminRouter.delete('/products/:id', requireAdmin, async (req, res) => {
const id = parseInt(req.params.id, 10); const id = parseInt(String(req.params.id), 10);
await query(`DELETE FROM products WHERE id = $1`, [id]); await query(`DELETE FROM products WHERE id = $1`, [id]);
res.json({ ok: true }); res.json({ ok: true });
}); });
@@ -192,7 +192,7 @@ adminRouter.get('/approach', requireAdmin, async (_req, res) => {
}); });
adminRouter.put('/approach/:id', requireAdmin, async (req, res) => { adminRouter.put('/approach/:id', requireAdmin, async (req, res) => {
const id = parseInt(req.params.id, 10); const id = parseInt(String(req.params.id), 10);
const { title, description, icon_key, sort_order, is_active } = req.body; const { title, description, icon_key, sort_order, is_active } = req.body;
const updated = await queryOne( const updated = await queryOne(
`UPDATE approach_items SET `UPDATE approach_items SET
+66
View File
@@ -0,0 +1,66 @@
services:
app:
image: 'umbyte-landing:latest'
container_name: app-lwpkqvcz4vvmw93elb7s8mgh
restart: unless-stopped
depends_on:
- postgres
expose:
- '80'
networks:
coolify:
aliases:
- app-lwpkqvcz4vvmw93elb7s8mgh
environment:
NODE_ENV: production
PORT: 3000
DB_HOST: postgres-lwpkqvcz4vvmw93elb7s8mgh
DB_PORT: 5432
DB_USER: umbyte
DB_PASSWORD: 5816e45c1052d6b0b2c7f9be3103069d
DB_NAME: umbyte
JWT_SECRET: 138767b918eefb472dd8a8e884b1e8dda2d06646ebd03a9ef33a0cdbbf382d84
ADMIN_INITIAL_PASSWORD: 80c106e53b6a0b75e74aa619
labels:
- traefik.enable=true
- traefik.http.middlewares.gzip.compress=true
- traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https
- traefik.http.routers.http-0-umbyte.entryPoints=http
- traefik.http.routers.http-0-umbyte.middlewares=redirect-to-https
- 'traefik.http.routers.http-0-umbyte.rule=Host(`umbyte.ru`) && PathPrefix(`/`)'
- traefik.http.routers.http-0-umbyte.service=http-0-umbyte
- traefik.http.routers.https-0-umbyte.entryPoints=https
- traefik.http.routers.https-0-umbyte.middlewares=gzip
- 'traefik.http.routers.https-0-umbyte.rule=Host(`umbyte.ru`) && PathPrefix(`/`)'
- traefik.http.routers.https-0-umbyte.service=https-0-umbyte
- traefik.http.routers.https-0-umbyte.tls.certresolver=letsencrypt
- traefik.http.routers.https-0-umbyte.tls=true
- traefik.http.services.http-0-umbyte.loadbalancer.server.port=80
- traefik.http.services.https-0-umbyte.loadbalancer.server.port=80
postgres:
image: postgres:16-alpine
container_name: postgres-lwpkqvcz4vvmw93elb7s8mgh
restart: unless-stopped
environment:
POSTGRES_DB: umbyte
POSTGRES_USER: umbyte
POSTGRES_PASSWORD: 5816e45c1052d6b0b2c7f9be3103069d
volumes:
- umbyte-pgdata:/var/lib/postgresql/data
networks:
coolify:
aliases:
- postgres-lwpkqvcz4vvmw93elb7s8mgh
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U umbyte']
interval: 10s
timeout: 5s
retries: 5
networks:
coolify:
external: true
volumes:
umbyte-pgdata:
+35 -8
View File
@@ -40,6 +40,15 @@ export function Landing() {
const cta = content.sections.cta as Record<string, any>; const cta = content.sections.cta as Record<string, any>;
const settings = content.settings; const settings = content.settings;
// Реальное количество продуктов из БД + русское склонение
const productCount = content.products.length;
const pluralProducts = (n: number) => {
const m10 = n % 10, m100 = n % 100;
if (m10 === 1 && m100 !== 11) return 'ФЛАГМАНСКИЙ ПРОДУКТ';
if (m10 >= 2 && m10 <= 4 && !(m100 >= 12 && m100 <= 14)) return 'ФЛАГМАНСКИХ ПРОДУКТА';
return 'ФЛАГМАНСКИХ ПРОДУКТОВ';
};
return ( return (
<div className="bg-white text-slate-900"> <div className="bg-white text-slate-900">
<nav className="bg-white border-b border-slate-200"> <nav className="bg-white border-b border-slate-200">
@@ -83,12 +92,17 @@ 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) => {
<div key={idx}> const isProd = typeof stat.label === 'string' && stat.label.toUpperCase().includes('ПРОДУКТ');
<div className="text-3xl font-medium text-brand-800 tracking-tight">{stat.value}</div> const value = isProd ? String(productCount) : stat.value;
<div className="text-xs text-slate-500 tracking-wider mt-1">{stat.label}</div> const label = isProd ? pluralProducts(productCount) : stat.label;
</div> return (
))} <div key={idx}>
<div className="text-3xl font-medium text-brand-800 tracking-tight">{value}</div>
<div className="text-xs text-slate-500 tracking-wider mt-1">{label}</div>
</div>
);
})}
</div> </div>
)} )}
</div> </div>
@@ -169,10 +183,23 @@ export function Landing() {
<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>
<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_phone && (
<button
type="button"
onClick={() => { window.location.href = 'tel:' + settings.contact_phone.replace(/[^+\d]/g, ''); }}
className="btn-secondary"
>
{settings.contact_phone}
</button>
)}
{settings.contact_email && ( {settings.contact_email && (
<a href={`mailto:${settings.contact_email}`} className="btn-secondary"> <button
type="button"
onClick={() => { window.location.href = 'mailto:' + settings.contact_email; }}
className="btn-secondary"
>
{settings.contact_email} {settings.contact_email}
</a> </button>
)} )}
</div> </div>
</div> </div>