commit 9e21350defdbb8afec926e653d779bae513dc68d Author: Алексей Date: Wed May 13 09:32:45 2026 +0300 Initial commit — Умный Байт landing diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..65922cc --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +node_modules +*/node_modules +dist +*/dist +build +*/build +.git +.gitignore +.env +*.env +.DS_Store +.vscode +.idea +README.md +docker-compose.yml diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0b818b2 --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +# PostgreSQL +DB_HOST=localhost +DB_PORT=5432 +DB_USER=umbyte +DB_PASSWORD=umbyte_dev_password +DB_NAME=umbyte + +# Backend +NODE_ENV=development +PORT=3000 +JWT_SECRET=change-me-in-production-please-use-long-random-string +ADMIN_INITIAL_PASSWORD=admin + +# Frontend (build-time) +VITE_API_URL=http://localhost:3000 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..21d061f --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +node_modules/ +*/node_modules/ +dist/ +build/ +*/dist/ +*/build/ +.env +.env.local +.env.*.local +*.env +*.log +npm-debug.log* +.vscode/ +.idea/ +*.swp +.DS_Store +coverage/ +.cache/ +tmp/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c9425a4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,58 @@ +# ===================================================== +# Stage 1: build frontend +# ===================================================== +FROM node:20-alpine AS frontend-build + +WORKDIR /app/frontend +COPY frontend/package.json frontend/package-lock.json* ./ +RUN npm install + +COPY frontend/ ./ +RUN npm run build + +# ===================================================== +# Stage 2: build backend +# ===================================================== +FROM node:20-alpine AS backend-build + +WORKDIR /app/backend +COPY backend/package.json backend/package-lock.json* ./ +RUN npm install + +COPY backend/ ./ +RUN npm run build + +# ===================================================== +# Stage 3: production image +# ===================================================== +FROM node:20-alpine AS runtime + +RUN apk add --no-cache nginx supervisor curl + +WORKDIR /app + +# Backend production deps +COPY backend/package.json backend/package-lock.json* ./backend/ +RUN cd backend && npm install --omit=dev + +# Built backend +COPY --from=backend-build /app/backend/dist ./backend/dist + +# DB migrations +COPY db ./db + +# Frontend static +COPY --from=frontend-build /app/frontend/dist /var/www/umbyte + +# Nginx config +COPY nginx.conf /etc/nginx/http.d/default.conf + +# Supervisor config +COPY supervisord.conf /etc/supervisord.conf + +EXPOSE 80 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s \ + CMD curl -f http://localhost/api/health || exit 1 + +CMD ["supervisord", "-c", "/etc/supervisord.conf"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..c428616 --- /dev/null +++ b/README.md @@ -0,0 +1,32 @@ +# Умный Байт — лендинг компании + +Сайт компании ООО «Умный Байт» (umbyte.ru) с админ-панелью для управления контентом. + +## Стек + +- **Frontend**: Vite + React + TypeScript + Tailwind CSS +- **Backend**: Node.js + Express + TypeScript +- **БД**: PostgreSQL 16 +- **Auth**: JWT +- **Deploy**: Docker + Coolify на fortress.zeroday.su + +## Структура + +``` +umbyte-landing/ +├── frontend/ # Публичный лендинг + админка +├── backend/ # REST API +├── db/ # SQL миграции +├── Dockerfile # production билд (multi-stage) +└── docker-compose.yml # для локальной разработки +``` + +## Production deploy + +Push в `main` → Coolify автоматически собирает Dockerfile и деплоит. + +Переменные окружения (в Coolify): +- `DB_HOST=postgres-` — полное имя контейнера БД +- `DB_USER`, `DB_PASSWORD`, `DB_NAME` +- `JWT_SECRET` — случайная строка +- `ADMIN_INITIAL_PASSWORD` — пароль первого админа при инициализации diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..4068b74 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,34 @@ +{ + "name": "umbyte-backend", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc", + "start": "node dist/index.js", + "migrate": "tsx src/db/migrate.ts", + "create-admin": "tsx src/scripts/create-admin.ts" + }, + "dependencies": { + "bcrypt": "^5.1.1", + "cookie-parser": "^1.4.7", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.21.1", + "jsonwebtoken": "^9.0.2", + "pg": "^8.13.1", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/bcrypt": "^5.0.2", + "@types/cookie-parser": "^1.4.7", + "@types/cors": "^2.8.17", + "@types/express": "^5.0.0", + "@types/jsonwebtoken": "^9.0.7", + "@types/node": "^22.9.0", + "@types/pg": "^8.11.10", + "tsx": "^4.19.2", + "typescript": "^5.6.3" + } +} diff --git a/backend/src/auth/jwt.ts b/backend/src/auth/jwt.ts new file mode 100644 index 0000000..9667292 --- /dev/null +++ b/backend/src/auth/jwt.ts @@ -0,0 +1,69 @@ +import jwt from 'jsonwebtoken'; +import bcrypt from 'bcrypt'; +import type { Request, Response, NextFunction } from 'express'; + +const JWT_SECRET = process.env.JWT_SECRET || 'change-me-please'; +const COOKIE_NAME = 'umbyte_session'; +const TOKEN_TTL_SEC = 60 * 60 * 24 * 7; + +export interface AdminPayload { + id: number; + login: string; +} + +declare global { + namespace Express { + interface Request { + admin?: AdminPayload; + } + } +} + +export function signToken(payload: AdminPayload): string { + return jwt.sign(payload, JWT_SECRET, { expiresIn: TOKEN_TTL_SEC }); +} + +export function setAuthCookie(res: Response, token: string): void { + res.cookie(COOKIE_NAME, token, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: TOKEN_TTL_SEC * 1000, + path: '/', + }); +} + +export function clearAuthCookie(res: Response): void { + res.clearCookie(COOKIE_NAME, { path: '/' }); +} + +export function requireAdmin( + req: Request, + res: Response, + next: NextFunction +): void { + const token = req.cookies?.[COOKIE_NAME]; + if (!token) { + res.status(401).json({ error: 'unauthorized' }); + return; + } + + try { + const payload = jwt.verify(token, JWT_SECRET) as AdminPayload; + req.admin = payload; + next(); + } catch { + res.status(401).json({ error: 'unauthorized' }); + } +} + +export async function hashPassword(plain: string): Promise { + return bcrypt.hash(plain, 12); +} + +export async function verifyPassword( + plain: string, + hash: string +): Promise { + return bcrypt.compare(plain, hash); +} diff --git a/backend/src/db/migrate.ts b/backend/src/db/migrate.ts new file mode 100644 index 0000000..c513509 --- /dev/null +++ b/backend/src/db/migrate.ts @@ -0,0 +1,46 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { pool } from './pool.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const MIGRATIONS_DIR = path.resolve(__dirname, '../../../db'); + +export async function runMigrations(): Promise { + console.log('[migrate] Запуск миграций из', MIGRATIONS_DIR); + + if (!fs.existsSync(MIGRATIONS_DIR)) { + console.warn('[migrate] Папка миграций не найдена, пропускаю'); + return; + } + + const files = fs + .readdirSync(MIGRATIONS_DIR) + .filter((f) => f.endsWith('.sql')) + .sort(); + + for (const file of files) { + const fullPath = path.join(MIGRATIONS_DIR, file); + const sql = fs.readFileSync(fullPath, 'utf-8'); + console.log(`[migrate] → ${file}`); + try { + await pool.query(sql); + } catch (err) { + console.error(`[migrate] Ошибка в ${file}:`, err); + throw err; + } + } + + console.log('[migrate] Все миграции применены'); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + runMigrations() + .then(() => process.exit(0)) + .catch((err) => { + console.error(err); + process.exit(1); + }); +} diff --git a/backend/src/db/pool.ts b/backend/src/db/pool.ts new file mode 100644 index 0000000..981b9ea --- /dev/null +++ b/backend/src/db/pool.ts @@ -0,0 +1,37 @@ +import pg from 'pg'; +import dotenv from 'dotenv'; + +dotenv.config(); + +const { Pool } = pg; + +export const pool = new Pool({ + host: process.env.DB_HOST || 'localhost', + port: parseInt(process.env.DB_PORT || '5432', 10), + user: process.env.DB_USER || 'umbyte', + password: process.env.DB_PASSWORD || 'umbyte_dev_password', + database: process.env.DB_NAME || 'umbyte', + max: 10, + idleTimeoutMillis: 30000, + connectionTimeoutMillis: 5000, +}); + +pool.on('error', (err) => { + console.error('Unexpected PostgreSQL error', err); +}); + +export async function query( + text: string, + params?: unknown[] +): Promise { + const result = await pool.query(text, params); + return result.rows as T[]; +} + +export async function queryOne( + text: string, + params?: unknown[] +): Promise { + const rows = await query(text, params); + return rows[0] ?? null; +} diff --git a/backend/src/index.ts b/backend/src/index.ts new file mode 100644 index 0000000..817e9de --- /dev/null +++ b/backend/src/index.ts @@ -0,0 +1,54 @@ +import express from 'express'; +import cors from 'cors'; +import cookieParser from 'cookie-parser'; +import dotenv from 'dotenv'; +import { publicRouter } from './routes/public.js'; +import { adminRouter } from './routes/admin.js'; +import { runMigrations } from './db/migrate.js'; +import { initFirstAdmin } from './scripts/init-admin.js'; + +dotenv.config(); + +const app = express(); +const PORT = parseInt(process.env.PORT || '3000', 10); + +app.use(express.json({ limit: '1mb' })); +app.use(cookieParser()); + +app.use( + cors({ + origin: + process.env.NODE_ENV === 'production' + ? false + : ['http://localhost:5173', 'http://127.0.0.1:5173'], + credentials: true, + }) +); + +app.get('/api/health', (_req, res) => { + res.json({ ok: true, service: 'umbyte-backend' }); +}); + +app.use('/api', publicRouter); +app.use('/api/admin', adminRouter); + +app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => { + console.error('[error]', err); + res.status(500).json({ error: 'internal_error' }); +}); + +async function start() { + try { + await runMigrations(); + await initFirstAdmin(); + + app.listen(PORT, () => { + console.log(`[umbyte-backend] listening on :${PORT}`); + }); + } catch (err) { + console.error('[startup] fatal error:', err); + process.exit(1); + } +} + +start(); diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts new file mode 100644 index 0000000..e5223a1 --- /dev/null +++ b/backend/src/routes/admin.ts @@ -0,0 +1,233 @@ +import { Router } from 'express'; +import { z } from 'zod'; +import { query, queryOne } from '../db/pool.js'; +import { + signToken, + setAuthCookie, + clearAuthCookie, + requireAdmin, + verifyPassword, +} from '../auth/jwt.js'; + +export const adminRouter = Router(); + +const loginSchema = z.object({ + login: z.string().min(1).max(64), + password: z.string().min(1).max(256), +}); + +adminRouter.post('/login', async (req, res) => { + const parsed = loginSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: 'invalid_payload' }); + return; + } + + const { login, password } = parsed.data; + + try { + const user = await queryOne<{ + id: number; + login: string; + password_hash: string; + }>( + `SELECT id, login, password_hash FROM admin_users WHERE login = $1`, + [login] + ); + + if (!user) { + res.status(401).json({ error: 'invalid_credentials' }); + return; + } + + const ok = await verifyPassword(password, user.password_hash); + if (!ok) { + res.status(401).json({ error: 'invalid_credentials' }); + return; + } + + await query( + `UPDATE admin_users SET last_login = now() WHERE id = $1`, + [user.id] + ); + + const token = signToken({ id: user.id, login: user.login }); + setAuthCookie(res, token); + res.json({ ok: true, login: user.login }); + } catch (err) { + console.error('[POST /admin/login]', err); + res.status(500).json({ error: 'internal_error' }); + } +}); + +adminRouter.post('/logout', (_req, res) => { + clearAuthCookie(res); + res.json({ ok: true }); +}); + +adminRouter.get('/me', requireAdmin, (req, res) => { + res.json({ admin: req.admin }); +}); + +const productSchema = z.object({ + slug: z.string().min(1).max(64), + title: z.string().min(1).max(128), + subtitle: z.string().max(256).nullable().optional(), + description: z.string().min(1), + status: z.enum(['production', 'development', 'planned']), + audience: z.enum(['B2B', 'B2C', 'global']).nullable().optional(), + icon_key: z.string().max(64).nullable().optional(), + tags: z.array(z.string()), + is_published: z.boolean().optional(), + sort_order: z.number().int().optional(), +}); + +adminRouter.get('/products', requireAdmin, async (_req, res) => { + const products = await query( + `SELECT * FROM products ORDER BY sort_order ASC` + ); + res.json({ products }); +}); + +adminRouter.post('/products', requireAdmin, async (req, res) => { + const parsed = productSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: 'invalid_payload' }); + return; + } + const p = parsed.data; + const created = await queryOne( + `INSERT INTO products (slug, title, subtitle, description, status, + audience, icon_key, tags, is_published, sort_order) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) + RETURNING *`, + [ + p.slug, p.title, p.subtitle ?? null, p.description, p.status, + p.audience ?? null, p.icon_key ?? null, JSON.stringify(p.tags), + p.is_published ?? true, p.sort_order ?? 0, + ] + ); + res.status(201).json({ product: created }); +}); + +adminRouter.put('/products/:id', requireAdmin, async (req, res) => { + const id = parseInt(req.params.id, 10); + if (Number.isNaN(id)) { + res.status(400).json({ error: 'invalid_id' }); + return; + } + const parsed = productSchema.partial().safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: 'invalid_payload' }); + return; + } + const p = parsed.data; + const updated = await queryOne( + `UPDATE products SET + slug = COALESCE($2, slug), + title = COALESCE($3, title), + subtitle = COALESCE($4, subtitle), + description = COALESCE($5, description), + status = COALESCE($6, status), + audience = COALESCE($7, audience), + icon_key = COALESCE($8, icon_key), + tags = COALESCE($9, tags), + is_published = COALESCE($10, is_published), + sort_order = COALESCE($11, sort_order), + updated_at = now() + WHERE id = $1 + RETURNING *`, + [ + id, p.slug, p.title, p.subtitle, p.description, p.status, + p.audience, p.icon_key, + p.tags ? JSON.stringify(p.tags) : null, + p.is_published, p.sort_order, + ] + ); + if (!updated) { + res.status(404).json({ error: 'not_found' }); + return; + } + res.json({ product: updated }); +}); + +adminRouter.delete('/products/:id', requireAdmin, async (req, res) => { + const id = parseInt(req.params.id, 10); + await query(`DELETE FROM products WHERE id = $1`, [id]); + res.json({ ok: true }); +}); + +adminRouter.get('/sections', requireAdmin, async (_req, res) => { + const sections = await query( + `SELECT * FROM sections ORDER BY sort_order ASC` + ); + res.json({ sections }); +}); + +adminRouter.put('/sections/:key', requireAdmin, async (req, res) => { + const { key } = req.params; + const { content_json, title, is_active } = req.body; + const updated = await queryOne( + `UPDATE sections SET + content_json = COALESCE($2, content_json), + title = COALESCE($3, title), + is_active = COALESCE($4, is_active), + updated_at = now() + WHERE key = $1 + RETURNING *`, + [key, content_json ?? null, title ?? null, is_active ?? null] + ); + if (!updated) { + res.status(404).json({ error: 'not_found' }); + return; + } + res.json({ section: updated }); +}); + +adminRouter.get('/approach', requireAdmin, async (_req, res) => { + const items = await query( + `SELECT * FROM approach_items ORDER BY sort_order ASC` + ); + res.json({ items }); +}); + +adminRouter.put('/approach/:id', requireAdmin, async (req, res) => { + const id = parseInt(req.params.id, 10); + const { title, description, icon_key, sort_order, is_active } = req.body; + const updated = await queryOne( + `UPDATE approach_items SET + title = COALESCE($2, title), + description = COALESCE($3, description), + icon_key = COALESCE($4, icon_key), + sort_order = COALESCE($5, sort_order), + is_active = COALESCE($6, is_active) + WHERE id = $1 + RETURNING *`, + [id, title, description, icon_key, sort_order, is_active] + ); + res.json({ item: updated }); +}); + +adminRouter.get('/settings', requireAdmin, async (_req, res) => { + const rows = await query<{ key: string; value: string }>( + `SELECT key, value FROM settings` + ); + const settings: Record = {}; + for (const r of rows) settings[r.key] = r.value; + res.json({ settings }); +}); + +adminRouter.put('/settings/:key', requireAdmin, async (req, res) => { + const { key } = req.params; + const { value } = req.body; + if (typeof value !== 'string') { + res.status(400).json({ error: 'invalid_payload' }); + return; + } + await query( + `INSERT INTO settings (key, value) VALUES ($1, $2) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`, + [key, value] + ); + res.json({ ok: true }); +}); diff --git a/backend/src/routes/public.ts b/backend/src/routes/public.ts new file mode 100644 index 0000000..d291b87 --- /dev/null +++ b/backend/src/routes/public.ts @@ -0,0 +1,53 @@ +import { Router } from 'express'; +import { query } from '../db/pool.js'; + +export const publicRouter = Router(); + +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 + FROM sections + WHERE is_active = true + ORDER BY sort_order ASC` + ), + query( + `SELECT id, slug, title, subtitle, description, + status, audience, icon_key, tags, sort_order + FROM products + WHERE is_published = true + ORDER BY sort_order ASC` + ), + query( + `SELECT id, title, description, icon_key, sort_order + FROM approach_items + WHERE is_active = true + ORDER BY sort_order ASC` + ), + query<{ key: string; value: string }>( + `SELECT key, value FROM settings` + ), + ]); + + const sectionsMap: Record = {}; + for (const s of sections) { + sectionsMap[s.key] = s.content_json; + } + + const settingsMap: Record = {}; + for (const s of settings) { + settingsMap[s.key] = s.value; + } + + res.json({ + sections: sectionsMap, + products, + approach, + settings: settingsMap, + }); + } catch (err) { + console.error('[GET /content]', err); + res.status(500).json({ error: 'internal_error' }); + } +}); diff --git a/backend/src/scripts/init-admin.ts b/backend/src/scripts/init-admin.ts new file mode 100644 index 0000000..29dbeb4 --- /dev/null +++ b/backend/src/scripts/init-admin.ts @@ -0,0 +1,28 @@ +import { queryOne, query } from '../db/pool.js'; +import { hashPassword } from '../auth/jwt.js'; + +export async function initFirstAdmin(): Promise { + const existing = await queryOne<{ count: string }>( + `SELECT COUNT(*)::text AS count FROM admin_users` + ); + + const count = parseInt(existing?.count || '0', 10); + if (count > 0) { + return; + } + + const password = process.env.ADMIN_INITIAL_PASSWORD || 'admin'; + const hash = await hashPassword(password); + + await query( + `INSERT INTO admin_users (login, password_hash) VALUES ($1, $2)`, + ['admin', hash] + ); + + console.log('[init-admin] Создан первый админ: admin'); + if (password === 'admin') { + console.warn( + '[init-admin] ВНИМАНИЕ: используется дефолтный пароль "admin". Смените его сразу!' + ); + } +} diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..999d0ea --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "allowImportingTsExtensions": false, + "verbatimModuleSyntax": false + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/db/001_initial.sql b/db/001_initial.sql new file mode 100644 index 0000000..e9c6ec1 --- /dev/null +++ b/db/001_initial.sql @@ -0,0 +1,59 @@ +-- ===================================================== +-- 001_initial.sql — начальная схема БД для лендинга +-- ===================================================== + +CREATE TABLE IF NOT EXISTS admin_users ( + id SERIAL PRIMARY KEY, + login VARCHAR(64) UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login TIMESTAMPTZ +); + +CREATE TABLE IF NOT EXISTS sections ( + id SERIAL PRIMARY KEY, + key VARCHAR(64) UNIQUE NOT NULL, + title TEXT NOT NULL, + content_json JSONB NOT NULL DEFAULT '{}'::jsonb, + is_active BOOLEAN NOT NULL DEFAULT true, + sort_order INTEGER NOT NULL DEFAULT 0, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS products ( + id SERIAL PRIMARY KEY, + slug VARCHAR(64) UNIQUE NOT NULL, + title VARCHAR(128) NOT NULL, + subtitle VARCHAR(256), + description TEXT NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'production', + audience VARCHAR(32), + icon_key VARCHAR(64), + tags JSONB NOT NULL DEFAULT '[]'::jsonb, + is_published BOOLEAN NOT NULL DEFAULT true, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS approach_items ( + id SERIAL PRIMARY KEY, + title VARCHAR(128) NOT NULL, + description TEXT NOT NULL, + icon_key VARCHAR(64), + sort_order INTEGER NOT NULL DEFAULT 0, + is_active BOOLEAN NOT NULL DEFAULT true +); + +CREATE TABLE IF NOT EXISTS settings ( + key VARCHAR(64) PRIMARY KEY, + value TEXT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_products_published_sorted + ON products(is_published, sort_order) WHERE is_published = true; +CREATE INDEX IF NOT EXISTS idx_sections_active_sorted + ON sections(is_active, sort_order) WHERE is_active = true; +CREATE INDEX IF NOT EXISTS idx_approach_active_sorted + ON approach_items(is_active, sort_order) WHERE is_active = true; diff --git a/db/002_seed.sql b/db/002_seed.sql new file mode 100644 index 0000000..3370bec --- /dev/null +++ b/db/002_seed.sql @@ -0,0 +1,70 @@ +-- ===================================================== +-- 002_seed.sql — начальный контент лендинга +-- ===================================================== + +INSERT INTO sections (key, title, content_json, sort_order) VALUES +('hero', 'Hero', '{ + "badge": "Принимаем проекты на 2026 год", + "title_line_1": "Технологии,", + "title_line_2": "которые решают", + "title_accent": "реальные задачи", + "description": "Разрабатываем цифровые продукты с AI для сельского хозяйства, частных домовладельцев, бизнеса и предпринимателей. От идеи до production.", + "cta_primary": "Посмотреть продукты", + "cta_secondary": "Обсудить проект", + "stats": [ + {"value": "2", "label": "ФЛАГМАНСКИХ ПРОДУКТА"}, + {"value": "AI", "label": "В КАЖДОМ ПРОДУКТЕ"}, + {"value": "B2B", "label": "И B2C НАПРАВЛЕНИЯ"}, + {"value": "24/7", "label": "AI-АССИСТЕНТ"} + ] +}'::jsonb, 1), +('products_intro', 'Заголовок секции продуктов', '{ + "eyebrow": "НАШИ ПРОДУКТЫ", + "title_line_1": "Решения, которые", + "title_line_2": "уже работают", + "description": "Каждый продукт — это ответ на реальный запрос рынка, проверенный нами в боевых условиях." +}'::jsonb, 2), +('approach_intro', 'Заголовок секции подхода', '{ + "eyebrow": "НАШ ПОДХОД", + "title_line_1": "Не делаем «как у всех».", + "title_line_2": "Делаем как надо." +}'::jsonb, 3), +('cta', 'CTA блок', '{ + "title": "Есть проект или идея?", + "description": "Расскажите о задаче — обсудим как её решить и сколько это займёт.", + "cta_primary": "Написать в Telegram", + "cta_secondary": "hi@umbyte.ru" +}'::jsonb, 4) +ON CONFLICT (key) DO NOTHING; + +INSERT INTO products (slug, title, subtitle, description, status, audience, icon_key, tags, sort_order) VALUES +('agroto', 'АгроТО', 'CMMS для агропромышленности', + 'Учёт оборудования, регламентов ТО и истории обслуживания на агропредприятии. AI-ассистент «Тоша» помогает агроинженерам и автоматизирует заявки на закупку.', + 'production', 'B2B', 'agroto', + '["Web SPA", "PostgreSQL", "AI Tools"]'::jsonb, 1), +('smart-home', 'Умный Дом', 'ТО оборудования для частных лиц', + 'Мобильное приложение: сфотографировал наклейку — AI распознал модель, нашёл регламент и настроил напоминания. Котёл, фильтры, скважина, септик — всё в одном месте.', + 'development', 'B2C', 'smart-home', + '["iOS", "Android", "AI Vision", "PWA"]'::jsonb, 2) +ON CONFLICT (slug) DO NOTHING; + +INSERT INTO approach_items (title, description, icon_key, sort_order) VALUES +('От идеи до production', + 'Берём проект целиком: исследование, дизайн, разработка, инфраструктура, поддержка.', + 'clock', 1), +('AI везде где нужно', + 'Не AI ради AI. Используем там, где он реально упрощает жизнь пользователю.', + 'sparkle', 2), +('Реальный сектор', + 'Знаем как устроены сельское хозяйство, производство, частный быт — и решаем настоящие боли.', + 'grid', 3) +ON CONFLICT DO NOTHING; + +INSERT INTO settings (key, value) VALUES +('contact_email', 'hi@umbyte.ru'), +('contact_telegram', 'https://t.me/umbyte_bot'), +('company_name_short', 'Умный Байт'), +('company_name_full', 'ООО «Умный Байт»'), +('domain', 'umbyte.ru'), +('region', 'Россия') +ON CONFLICT (key) DO NOTHING; diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e61ca09 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,21 @@ +services: + postgres: + image: postgres:16-alpine + container_name: umbyte-postgres + environment: + POSTGRES_USER: umbyte + POSTGRES_PASSWORD: umbyte_dev_password + POSTGRES_DB: umbyte + ports: + - '5432:5432' + volumes: + - umbyte_pg_data:/var/lib/postgresql/data + restart: unless-stopped + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U umbyte'] + interval: 5s + timeout: 5s + retries: 5 + +volumes: + umbyte_pg_data: diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..1d5f046 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,21 @@ + + + + + + + Умный Байт — технологии, которые решают реальные задачи + + + + + + + + + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..9952813 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,26 @@ +{ + "name": "umbyte-frontend", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.28.0" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.3", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.14", + "typescript": "^5.6.3", + "vite": "^5.4.10" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..49c0612 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..9366d82 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/frontend/src/admin/AdminLayout.tsx b/frontend/src/admin/AdminLayout.tsx new file mode 100644 index 0000000..4e5fb77 --- /dev/null +++ b/frontend/src/admin/AdminLayout.tsx @@ -0,0 +1,80 @@ +import { useEffect, useState } from 'react'; +import { Routes, Route, Link, NavLink, useNavigate } from 'react-router-dom'; +import { api } from '../api/client'; +import { Logo } from '../components/Icons'; +import { ProductsAdmin } from './ProductsAdmin'; +import { SectionsAdmin } from './SectionsAdmin'; +import { SettingsAdmin } from './SettingsAdmin'; + +export function AdminLayout() { + const navigate = useNavigate(); + const [authChecked, setAuthChecked] = useState(false); + const [admin, setAdmin] = useState<{ login: string } | null>(null); + + useEffect(() => { + api.me() + .then(({ admin }) => setAdmin(admin)) + .catch(() => navigate('/admin/login')) + .finally(() => setAuthChecked(true)); + }, [navigate]); + + if (!authChecked) { + return ( +
загрузка…
+ ); + } + + async function handleLogout() { + await api.logout(); + navigate('/admin/login'); + } + + return ( +
+
+
+ + +
+
Умный Байт
+
Админ-панель
+
+ +
+ {admin?.login} + +
+
+
+ +
+ + + + } /> + } /> + } /> + } /> + +
+
+ ); +} diff --git a/frontend/src/admin/AdminLogin.tsx b/frontend/src/admin/AdminLogin.tsx new file mode 100644 index 0000000..2dfb70d --- /dev/null +++ b/frontend/src/admin/AdminLogin.tsx @@ -0,0 +1,75 @@ +import { useState, type FormEvent } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { api } from '../api/client'; +import { Logo } from '../components/Icons'; + +export function AdminLogin() { + const navigate = useNavigate(); + const [login, setLogin] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + async function handleSubmit(e: FormEvent) { + e.preventDefault(); + setError(null); + setLoading(true); + try { + await api.login(login, password); + navigate('/admin'); + } catch (err: any) { + setError(err.message === 'invalid_credentials' ? 'Неверный логин или пароль' : 'Ошибка входа'); + } finally { + setLoading(false); + } + } + + return ( +
+
+
+ +
+
Умный Байт
+
Админ-панель
+
+
+ +
e.key === 'Enter' && handleSubmit(e as any)}> +
+ + setLogin(e.target.value)} + className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm focus:outline-none focus:border-brand-500" + autoFocus + /> +
+ +
+ + setPassword(e.target.value)} + className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm focus:outline-none focus:border-brand-500" + /> +
+ + {error && ( +
{error}
+ )} + + +
+
+
+ ); +} diff --git a/frontend/src/admin/ProductsAdmin.tsx b/frontend/src/admin/ProductsAdmin.tsx new file mode 100644 index 0000000..8be2c15 --- /dev/null +++ b/frontend/src/admin/ProductsAdmin.tsx @@ -0,0 +1,127 @@ +import { useEffect, useState } from 'react'; +import { api, type Product } from '../api/client'; + +export function ProductsAdmin() { + const [products, setProducts] = useState([]); + const [editing, setEditing] = useState(null); + const [loading, setLoading] = useState(true); + + async function load() { + const { products } = await api.listProducts(); + setProducts(products); + setLoading(false); + } + + useEffect(() => { load(); }, []); + + async function handleSave() { + if (!editing) return; + await api.updateProduct(editing.id, editing); + setEditing(null); + await load(); + } + + if (loading) return
загрузка…
; + + return ( +
+
+

Продукты

+
+ +
+ + + + + + + + + + + + {products.map((p) => ( + + + + + + + + ))} + +
НазваниеСтатусАудиторияПорядок
+ {p.title} +
{p.subtitle}
+
{p.status}{p.audience}{p.sort_order} + +
+
+ + {editing && ( +
setEditing(null)}> +
e.stopPropagation()}> +

Редактировать продукт

+ +
+ + setEditing({ ...editing, title: e.target.value })} className="input" /> + + + setEditing({ ...editing, subtitle: e.target.value })} className="input" /> + + +