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) => {
|
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
|
||||||
|
|||||||
@@ -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 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>
|
||||||
|
|||||||
Reference in New Issue
Block a user