landing: динамический счётчик продуктов, телефон, защита email/телефона от ботов
This commit is contained in:
@@ -111,7 +111,7 @@ adminRouter.post('/products', 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)) {
|
||||
res.status(400).json({ error: 'invalid_id' });
|
||||
return;
|
||||
@@ -152,7 +152,7 @@ adminRouter.put('/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]);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
@@ -192,7 +192,7 @@ adminRouter.get('/approach', 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 updated = await queryOne(
|
||||
`UPDATE approach_items SET
|
||||
|
||||
@@ -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:
|
||||
@@ -40,6 +40,15 @@ export function Landing() {
|
||||
const cta = content.sections.cta as Record<string, any>;
|
||||
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 (
|
||||
<div className="bg-white text-slate-900">
|
||||
<nav className="bg-white border-b border-slate-200">
|
||||
@@ -83,12 +92,17 @@ 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) => (
|
||||
<div key={idx}>
|
||||
<div className="text-3xl font-medium text-brand-800 tracking-tight">{stat.value}</div>
|
||||
<div className="text-xs text-slate-500 tracking-wider mt-1">{stat.label}</div>
|
||||
</div>
|
||||
))}
|
||||
{hero.stats.map((stat: any, idx: number) => {
|
||||
const isProd = typeof stat.label === 'string' && stat.label.toUpperCase().includes('ПРОДУКТ');
|
||||
const value = isProd ? String(productCount) : stat.value;
|
||||
const label = isProd ? pluralProducts(productCount) : stat.label;
|
||||
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>
|
||||
@@ -169,10 +183,23 @@ export function Landing() {
|
||||
<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>
|
||||
<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 && (
|
||||
<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}
|
||||
</a>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user