1 Commits

Author SHA1 Message Date
mguschin
854c912a88 Migrate web app from Python/FastAPI to Node.js/TypeScript
Replace the entire Python/FastAPI backend with a Node.js/TypeScript stack:
- Framework: Hono + @hono/node-server
- Templates: Nunjucks (.njk) replacing Jinja2 (.html)
- ORM: Drizzle ORM with mysql2 (same MariaDB schema, no migrations needed)
- Sessions: hono-sessions with CookieStore
- CSS: Pico CSS v2 replacing Bootstrap 5 (Bootstrap Icons CDN kept)
- Dev: tsx watch; Prod: tsc + node dist/index.js

Original Python app preserved in web-python/ as backup.
Updated Dockerfile.web and docker-compose.yml for Node.js deployment.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 19:33:32 +03:00
100 changed files with 5770 additions and 39 deletions

View File

@@ -1,13 +1,10 @@
FROM python:3.12-slim
FROM node:20-alpine
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY web/package*.json ./
RUN npm ci
COPY web/ ./web/
COPY alembic.ini .
COPY docker-entrypoint.sh .
RUN chmod +x docker-entrypoint.sh
COPY web/ .
RUN npm run build && cp -r src/templates dist/templates && cp -r static dist/static
CMD ["./docker-entrypoint.sh"]
CMD ["node", "dist/index.js"]

View File

@@ -6,19 +6,19 @@ services:
context: .
dockerfile: Dockerfile.web
ports:
- "8080:8000"
- "8080:3000"
environment:
- DATABASE_URL=mysql+pymysql://${DB_USER}:${DB_PASSWORD}@host.docker.internal:3306/${DB_NAME}
- DATABASE_URL=mysql://${DB_USER}:${DB_PASSWORD}@host.docker.internal:3306/${DB_NAME}
- SECRET_KEY=${SECRET_KEY:-change-me-in-production}
- BASE_URL=${BASE_URL:-https://evosync.ru}
- EVOTOR_APP_ID=${EVOTOR_APP_ID}
- EVOTOR_WEBHOOK_SECRET=${EVOTOR_WEBHOOK_SECRET}
- VK_CLIENT_ID=${VK_CLIENT_ID}
- VK_CLIENT_SECRET=${VK_CLIENT_SECRET}
- JIVOSITE_WIDGET_ID=${JIVOSITE_WIDGET_ID}
- NODE_ENV=production
- VK_DEFAULT_PHOTO_PATH=/app/default_product.png
volumes:
- ./web:/app/web
- ./alembic.ini:/app/alembic.ini
- ./docker-entrypoint.sh:/app/docker-entrypoint.sh
- ./5393364294319597854.png:/app/default_product.png:ro
restart: unless-stopped
extra_hosts:

View File

@@ -0,0 +1,39 @@
/* Brand overrides */
:root {
--bs-primary: #F05023;
--bs-primary-rgb: 240, 80, 35;
--bs-link-color: #0986E2;
--bs-link-hover-color: #0670c0;
}
.brand-logo {
font-size: 22px;
font-weight: 700;
color: #F05023 !important;
}
.brand-border {
border-color: #F05023 !important;
}
.btn-primary {
--bs-btn-bg: #F05023;
--bs-btn-border-color: #F05023;
--bs-btn-hover-bg: #d44420;
--bs-btn-hover-border-color: #d44420;
--bs-btn-active-bg: #c03d1c;
--bs-btn-active-border-color: #c03d1c;
}
.btn-secondary {
--bs-btn-bg: #0986E2;
--bs-btn-border-color: #0986E2;
--bs-btn-hover-bg: #0770c0;
--bs-btn-hover-border-color: #0770c0;
--bs-btn-active-bg: #065fa3;
--bs-btn-active-border-color: #065fa3;
}
.nav-link:hover {
color: #F05023 !important;
}

2
web/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
node_modules/
dist/

10
web/drizzle.config.ts Normal file
View File

@@ -0,0 +1,10 @@
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./src/db/schema.ts",
out: "./drizzle",
dialect: "mysql",
dbCredentials: {
url: process.env.DATABASE_URL ?? "mysql://evosync:evosync@localhost:3306/evosync",
},
});

2001
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
web/package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "evosync-web",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
},
"dependencies": {
"@hono/node-server": "^1.13.7",
"bcryptjs": "^2.4.3",
"drizzle-orm": "^0.41.0",
"hono": "^4.7.4",
"hono-sessions": "^0.5.5",
"mysql2": "^3.14.0",
"nunjucks": "^3.2.4"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/node": "^22.13.13",
"@types/nunjucks": "^3.2.6",
"drizzle-kit": "^0.30.4",
"tsx": "^4.19.3",
"typescript": "^5.8.2"
}
}

22
web/src/config.ts Normal file
View File

@@ -0,0 +1,22 @@
export const config = {
DATABASE_URL: process.env.DATABASE_URL ?? "mysql://evosync:evosync@localhost:3306/evosync",
SECRET_KEY: process.env.SECRET_KEY ?? "change-me-in-production",
BASE_URL: process.env.BASE_URL ?? "http://localhost:8080",
PORT: parseInt(process.env.PORT ?? "3000", 10),
PASSWORD_RESET_EXPIRE_MINUTES: parseInt(process.env.PASSWORD_RESET_EXPIRE_MINUTES ?? "60", 10),
EVOTOR_APP_ID: process.env.EVOTOR_APP_ID ?? "",
EVOTOR_WEBHOOK_SECRET: process.env.EVOTOR_WEBHOOK_SECRET ?? "",
JIVOSITE_WIDGET_ID: process.env.JIVOSITE_WIDGET_ID ?? "",
HEALTH_CHECK_INTERVAL_SECONDS: parseInt(process.env.HEALTH_CHECK_INTERVAL_SECONDS ?? "600", 10),
CATALOG_REFRESH_INTERVAL_SECONDS: parseInt(process.env.CATALOG_REFRESH_INTERVAL_SECONDS ?? "3600", 10),
SYNC_INTERVAL_SECONDS: parseInt(process.env.SYNC_INTERVAL_SECONDS ?? "3600", 10),
VK_DEFAULT_PHOTO_PATH: process.env.VK_DEFAULT_PHOTO_PATH ?? "/app/default_product.png",
VK_CLIENT_ID: process.env.VK_CLIENT_ID ?? "",
VK_CLIENT_SECRET: process.env.VK_CLIENT_SECRET ?? "",
VK_API_VERSION: process.env.VK_API_VERSION ?? "5.131",
} as const;

13
web/src/db/client.ts Normal file
View File

@@ -0,0 +1,13 @@
import { drizzle } from "drizzle-orm/mysql2";
import mysql from "mysql2/promise";
import { config } from "../config.js";
import * as schema from "./schema.js";
const pool = mysql.createPool({
uri: config.DATABASE_URL,
waitForConnections: true,
connectionLimit: 10,
decimalNumbers: true,
});
export const db = drizzle(pool, { schema, mode: "default" });

140
web/src/db/schema.ts Normal file
View File

@@ -0,0 +1,140 @@
import {
mysqlTable,
int,
varchar,
text,
boolean,
datetime,
decimal,
uniqueIndex,
index,
} from "drizzle-orm/mysql-core";
import { sql } from "drizzle-orm";
export const users = mysqlTable("users", {
id: int("id").autoincrement().primaryKey(),
first_name: varchar("first_name", { length: 100 }).notNull(),
last_name: varchar("last_name", { length: 100 }).notNull(),
email: varchar("email", { length: 255 }).notNull(),
phone: varchar("phone", { length: 20 }).notNull(),
password_hash: varchar("password_hash", { length: 255 }).notNull(),
is_email_confirmed: boolean("is_email_confirmed").notNull().default(false),
email_confirm_token: varchar("email_confirm_token", { length: 255 }),
password_reset_token: varchar("password_reset_token", { length: 255 }),
password_reset_expires: datetime("password_reset_expires"),
created_at: datetime("created_at").notNull().default(sql`CURRENT_TIMESTAMP`),
updated_at: datetime("updated_at").notNull().default(sql`CURRENT_TIMESTAMP`),
}, (t) => [
uniqueIndex("ix_users_email").on(t.email),
uniqueIndex("ix_users_phone").on(t.phone),
]);
export const evotor_connections = mysqlTable("evotor_connections", {
id: int("id").autoincrement().primaryKey(),
user_id: int("user_id").references(() => users.id, { onDelete: "cascade" }),
evotor_user_id: varchar("evotor_user_id", { length: 255 }),
access_token: text("access_token").notNull(),
store_id: varchar("store_id", { length: 255 }),
store_name: varchar("store_name", { length: 255 }),
refresh_token: text("refresh_token"),
token_expires_at: datetime("token_expires_at"),
is_online: boolean("is_online").notNull().default(false),
last_checked_at: datetime("last_checked_at"),
connected_at: datetime("connected_at").notNull().default(sql`CURRENT_TIMESTAMP`),
updated_at: datetime("updated_at").notNull().default(sql`CURRENT_TIMESTAMP`),
}, (t) => [
uniqueIndex("ix_evotor_connections_user_id").on(t.user_id),
uniqueIndex("ix_evotor_connections_evotor_user_id").on(t.evotor_user_id),
]);
export const vk_connections = mysqlTable("vk_connections", {
id: int("id").autoincrement().primaryKey(),
user_id: int("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
access_token: text("access_token").notNull(),
vk_user_id: varchar("vk_user_id", { length: 50 }),
first_name: varchar("first_name", { length: 255 }),
last_name: varchar("last_name", { length: 255 }),
is_online: boolean("is_online").notNull().default(false),
last_checked_at: datetime("last_checked_at"),
connected_at: datetime("connected_at").notNull().default(sql`CURRENT_TIMESTAMP`),
updated_at: datetime("updated_at").notNull().default(sql`CURRENT_TIMESTAMP`),
}, (t) => [
uniqueIndex("ix_vk_connections_user_id").on(t.user_id),
]);
export const sync_configs = mysqlTable("sync_configs", {
id: int("id").autoincrement().primaryKey(),
user_id: int("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
is_enabled: boolean("is_enabled").notNull().default(false),
confirmed_at: datetime("confirmed_at"),
created_at: datetime("created_at").notNull().default(sql`CURRENT_TIMESTAMP`),
updated_at: datetime("updated_at").notNull().default(sql`CURRENT_TIMESTAMP`),
}, (t) => [
uniqueIndex("ix_sync_configs_user_id").on(t.user_id),
]);
export const sync_filters = mysqlTable("sync_filters", {
id: int("id").autoincrement().primaryKey(),
sync_config_id: int("sync_config_id").notNull().references(() => sync_configs.id, { onDelete: "cascade" }),
entity_type: varchar("entity_type", { length: 20 }).notNull(),
entity_id: varchar("entity_id", { length: 255 }).notNull(),
entity_name: varchar("entity_name", { length: 255 }),
filter_mode: varchar("filter_mode", { length: 10 }).notNull(),
parent_entity_id: varchar("parent_entity_id", { length: 255 }),
created_at: datetime("created_at").notNull().default(sql`CURRENT_TIMESTAMP`),
}, (t) => [
uniqueIndex("uq_sync_filters_config_type_entity").on(t.sync_config_id, t.entity_type, t.entity_id),
]);
export const cached_stores = mysqlTable("cached_stores", {
id: int("id").autoincrement().primaryKey(),
user_id: int("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
evotor_id: varchar("evotor_id", { length: 255 }).notNull(),
name: varchar("name", { length: 255 }).notNull(),
address: varchar("address", { length: 500 }),
fetched_at: datetime("fetched_at").notNull(),
}, (t) => [
uniqueIndex("uq_cached_stores_user_evotor").on(t.user_id, t.evotor_id),
index("ix_cached_stores_user_id").on(t.user_id),
]);
export const cached_groups = mysqlTable("cached_groups", {
id: int("id").autoincrement().primaryKey(),
user_id: int("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
evotor_id: varchar("evotor_id", { length: 255 }).notNull(),
store_evotor_id: varchar("store_evotor_id", { length: 255 }).notNull(),
name: varchar("name", { length: 255 }).notNull(),
fetched_at: datetime("fetched_at").notNull(),
}, (t) => [
uniqueIndex("uq_cached_groups_user_evotor").on(t.user_id, t.evotor_id),
index("ix_cached_groups_user_store").on(t.user_id, t.store_evotor_id),
]);
export const cached_products = mysqlTable("cached_products", {
id: int("id").autoincrement().primaryKey(),
user_id: int("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
evotor_id: varchar("evotor_id", { length: 255 }).notNull(),
store_evotor_id: varchar("store_evotor_id", { length: 255 }).notNull(),
group_evotor_id: varchar("group_evotor_id", { length: 255 }),
name: varchar("name", { length: 255 }).notNull(),
price: decimal("price", { precision: 12, scale: 2 }),
quantity: decimal("quantity", { precision: 12, scale: 3 }),
measure_name: varchar("measure_name", { length: 20 }),
article_number: varchar("article_number", { length: 100 }),
allow_to_sell: boolean("allow_to_sell"),
fetched_at: datetime("fetched_at").notNull(),
synced_at: datetime("synced_at"),
}, (t) => [
uniqueIndex("uq_cached_products_user_evotor").on(t.user_id, t.evotor_id),
index("ix_cached_products_user_store_group").on(t.user_id, t.store_evotor_id, t.group_evotor_id),
]);
// Convenience types
export type User = typeof users.$inferSelect;
export type EvotorConnection = typeof evotor_connections.$inferSelect;
export type VkConnection = typeof vk_connections.$inferSelect;
export type SyncConfig = typeof sync_configs.$inferSelect;
export type SyncFilter = typeof sync_filters.$inferSelect;
export type CachedStore = typeof cached_stores.$inferSelect;
export type CachedGroup = typeof cached_groups.$inferSelect;
export type CachedProduct = typeof cached_products.$inferSelect;

71
web/src/index.ts Normal file
View File

@@ -0,0 +1,71 @@
import { serve } from "@hono/node-server";
import { serveStatic } from "@hono/node-server/serve-static";
import { Hono } from "hono";
import { sessionMiddleware, CookieStore } from "hono-sessions";
import path from "path";
import { fileURLToPath } from "url";
import { config } from "./config.js";
import { getSessionUserId, type AppEnv } from "./lib/auth.js";
import { db } from "./db/client.js";
import { users } from "./db/schema.js";
import { eq } from "drizzle-orm";
import { startHealthCheckLoop } from "./lib/healthChecker.js";
import { startSyncLoop } from "./lib/syncEngine.js";
import { authRouter } from "./routes/auth.js";
import { resetRouter } from "./routes/reset.js";
import { profileRouter } from "./routes/profile.js";
import { connectionsRouter } from "./routes/connections.js";
import { evotorRouter } from "./routes/evotor.js";
import { vkRouter } from "./routes/vk.js";
import { catalogRouter } from "./routes/catalog.js";
import { syncRouter } from "./routes/sync.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const app = new Hono<AppEnv>();
// Session middleware
const store = new CookieStore();
app.use("*", sessionMiddleware({
store,
encryptionKey: config.SECRET_KEY.padEnd(32, "0").slice(0, 32),
expireAfterSeconds: 86400 * 30,
cookieOptions: {
sameSite: "Lax",
path: "/",
httpOnly: true,
secure: process.env.NODE_ENV === "production",
},
sessionCookieName: "session",
}));
// Static files
app.use("/static/*", serveStatic({ root: path.join(__dirname, "../") }));
// Routes
app.route("/", authRouter);
app.route("/", resetRouter);
app.route("/", profileRouter);
app.route("/", connectionsRouter);
app.route("/", evotorRouter);
app.route("/", vkRouter);
app.route("/", catalogRouter);
app.route("/", syncRouter);
// Home redirect
app.get("/", async (c) => {
const userId = getSessionUserId(c);
if (userId) {
const [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1);
if (user) return c.redirect("/profile", 302);
}
return c.redirect("/login", 302);
});
serve({ fetch: app.fetch, port: config.PORT }, () => {
console.log(`Server running on port ${config.PORT}`);
startHealthCheckLoop(config.HEALTH_CHECK_INTERVAL_SECONDS * 1000);
startSyncLoop(config.SYNC_INTERVAL_SECONDS * 1000);
});

21
web/src/lib/auth.ts Normal file
View File

@@ -0,0 +1,21 @@
import bcrypt from "bcryptjs";
import type { Context } from "hono";
import type { Session } from "hono-sessions";
// Hono env type that includes our session variable
export type AppEnv = { Variables: { session: Session } };
export function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, 12);
}
export function verifyPassword(plain: string, hashed: string): Promise<boolean> {
return bcrypt.compare(plain, hashed);
}
export function getSessionUserId(c: Context<AppEnv>): number | null {
const session = c.get("session");
if (!session) return null;
const id = session.get("user_id");
return typeof id === "number" ? id : null;
}

106
web/src/lib/evotorApi.ts Normal file
View File

@@ -0,0 +1,106 @@
import { db } from "../db/client.js";
import { cached_stores, cached_groups, cached_products } from "../db/schema.js";
import { eq } from "drizzle-orm";
const EVOTOR_API_BASE = "https://api.evotor.ru";
async function fetchJson(url: string, token: string, allowStatuses: number[] = []): Promise<unknown> {
const resp = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(15000),
});
if (allowStatuses.includes(resp.status)) return null;
if (!resp.ok) throw new Error(`Evotor API error ${resp.status}: ${url}`);
return resp.json();
}
export async function fetchStores(token: string): Promise<Array<{ id: string; name: string; address?: string }>> {
const data = await fetchJson(`${EVOTOR_API_BASE}/stores`, token) as unknown;
const items = (typeof data === "object" && data !== null && "items" in data)
? (data as { items: unknown[] }).items
: (Array.isArray(data) ? data : []);
return (items as Array<Record<string, unknown>>).map((s) => ({
id: (s.uuid as string) ?? (s.id as string),
name: (s.name as string) ?? "",
address: s.address as string | undefined,
}));
}
export async function fetchGroups(token: string, storeId: string): Promise<Array<{ id: string; name: string }>> {
const data = await fetchJson(`${EVOTOR_API_BASE}/stores/${storeId}/product-groups`, token, [402]);
if (!data) return [];
const items = (typeof data === "object" && data !== null && "items" in data)
? (data as { items: unknown[] }).items
: (Array.isArray(data) ? data : []);
return (items as Array<Record<string, unknown>>).map((g) => ({
id: (g.uuid as string) ?? (g.id as string),
name: (g.name as string) ?? "",
}));
}
export async function fetchProducts(token: string, storeId: string): Promise<Array<Record<string, unknown>>> {
const data = await fetchJson(`${EVOTOR_API_BASE}/stores/${storeId}/products`, token, [402]);
if (!data) return [];
const items = (typeof data === "object" && data !== null && "items" in data)
? (data as { items: unknown[] }).items
: (Array.isArray(data) ? data : []);
return (items as Array<Record<string, unknown>>).map((p) => ({
id: (p.uuid as string) ?? (p.id as string),
name: (p.name as string) ?? "",
parent_id: (p.parentUuid as string) ?? (p.parent_id as string) ?? null,
price: (p.price as number) ?? null,
quantity: (p.quantity as number) ?? null,
measure_name: (p.measureName as string) ?? (p.measure_name as string) ?? null,
article_number: (p.code as string) ?? (p.article_number as string) ?? null,
allow_to_sell: p.allowToSell !== undefined ? (p.allowToSell as boolean) : (p.allow_to_sell as boolean | null) ?? null,
}));
}
export async function refreshCatalogCache(userId: number, accessToken: string): Promise<void> {
const now = new Date();
await db.delete(cached_products).where(eq(cached_products.user_id, userId));
await db.delete(cached_groups).where(eq(cached_groups.user_id, userId));
await db.delete(cached_stores).where(eq(cached_stores.user_id, userId));
const stores = await fetchStores(accessToken);
for (const store of stores) {
await db.insert(cached_stores).values({
user_id: userId,
evotor_id: store.id,
name: store.name,
address: store.address ?? null,
fetched_at: now,
});
}
for (const store of stores) {
const groups = await fetchGroups(accessToken, store.id);
for (const group of groups) {
await db.insert(cached_groups).values({
user_id: userId,
evotor_id: group.id,
store_evotor_id: store.id,
name: group.name,
fetched_at: now,
});
}
const products = await fetchProducts(accessToken, store.id);
for (const product of products) {
await db.insert(cached_products).values({
user_id: userId,
evotor_id: product.id as string,
store_evotor_id: store.id,
group_evotor_id: (product.parent_id as string | null) ?? null,
name: product.name as string,
price: product.price !== null ? String(product.price) : null,
quantity: product.quantity !== null ? String(product.quantity) : null,
measure_name: (product.measure_name as string | null) ?? null,
article_number: (product.article_number as string | null) ?? null,
allow_to_sell: (product.allow_to_sell as boolean | null) ?? null,
fetched_at: now,
});
}
}
}

View File

@@ -0,0 +1,138 @@
import { db } from "../db/client.js";
import { evotor_connections, vk_connections, cached_stores } from "../db/schema.js";
import { eq } from "drizzle-orm";
import { refreshCatalogCache } from "./evotorApi.js";
import { config } from "../config.js";
const EVOTOR_STORES_URL = "https://api.evotor.ru/stores";
const EVOTOR_TOKEN_URL = "https://oauth.evotor.ru/oauth/token";
const VK_GROUPS_GET_URL = "https://api.vk.com/method/groups.getById";
const REFRESH_BEFORE_EXPIRY_MS = 2 * 60 * 60 * 1000; // 2 hours
async function checkEvotorConnection(token: string): Promise<boolean> {
try {
const resp = await fetch(EVOTOR_STORES_URL, {
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(15000),
});
return resp.ok;
} catch {
return false;
}
}
async function checkVkConnection(token: string): Promise<boolean> {
try {
const params = new URLSearchParams({ access_token: token, v: "5.131" });
const resp = await fetch(`${VK_GROUPS_GET_URL}?${params}`, { signal: AbortSignal.timeout(10000) });
if (!resp.ok) return false;
const data = await resp.json() as { error?: unknown };
return !data.error;
} catch {
return false;
}
}
async function refreshEvotorToken(conn: typeof evotor_connections.$inferSelect): Promise<{
access_token: string; refresh_token?: string; expires_in?: number;
} | null> {
if (!conn.refresh_token) return null;
try {
const body = new URLSearchParams({
grant_type: "refresh_token",
refresh_token: conn.refresh_token,
});
const resp = await fetch(EVOTOR_TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: body.toString(),
signal: AbortSignal.timeout(15000),
});
if (!resp.ok) return null;
const data = await resp.json() as { access_token?: string; refresh_token?: string; expires_in?: number };
return data.access_token ? data as { access_token: string; refresh_token?: string; expires_in?: number } : null;
} catch {
return null;
}
}
export async function runHealthChecks(): Promise<void> {
const now = new Date();
console.log("[health] running health checks");
try {
const evotorConns = await db.select().from(evotor_connections);
for (const conn of evotorConns) {
let token = conn.access_token;
// Proactive refresh if token expires soon
const needsRefresh = conn.refresh_token &&
conn.token_expires_at &&
conn.token_expires_at.getTime() - now.getTime() < REFRESH_BEFORE_EXPIRY_MS;
if (needsRefresh) {
const tokenData = await refreshEvotorToken(conn);
if (tokenData) {
token = tokenData.access_token;
const expires = tokenData.expires_in ? new Date(now.getTime() + tokenData.expires_in * 1000) : null;
await db.update(evotor_connections)
.set({ access_token: token, refresh_token: tokenData.refresh_token ?? conn.refresh_token, token_expires_at: expires })
.where(eq(evotor_connections.id, conn.id));
}
}
let isOnline = await checkEvotorConnection(token);
// If offline and not yet tried refresh, attempt it now
if (!isOnline && conn.refresh_token && !needsRefresh) {
const tokenData = await refreshEvotorToken(conn);
if (tokenData) {
token = tokenData.access_token;
const expires = tokenData.expires_in ? new Date(now.getTime() + tokenData.expires_in * 1000) : null;
await db.update(evotor_connections)
.set({ access_token: token, refresh_token: tokenData.refresh_token ?? conn.refresh_token, token_expires_at: expires })
.where(eq(evotor_connections.id, conn.id));
isOnline = await checkEvotorConnection(token);
}
}
await db.update(evotor_connections)
.set({ is_online: isOnline, last_checked_at: now })
.where(eq(evotor_connections.id, conn.id));
}
const vkConns = await db.select().from(vk_connections);
for (const conn of vkConns) {
const isOnline = await checkVkConnection(conn.access_token);
await db.update(vk_connections)
.set({ is_online: isOnline, last_checked_at: now })
.where(eq(vk_connections.id, conn.id));
}
// Refresh catalog cache for online Evotor connections
let refreshed = 0;
for (const conn of evotorConns) {
if (!conn.is_online || !conn.user_id) continue;
const [cached] = await db.select().from(cached_stores).where(eq(cached_stores.user_id, conn.user_id)).limit(1);
const ageSeconds = cached ? (now.getTime() - cached.fetched_at.getTime()) / 1000 : Infinity;
if (!cached || ageSeconds >= config.CATALOG_REFRESH_INTERVAL_SECONDS) {
try {
await refreshCatalogCache(conn.user_id, conn.access_token);
refreshed++;
} catch (err) {
console.error(`[health] catalog refresh failed for user_id=${conn.user_id}:`, err);
}
}
}
console.log(`[health] done: ${evotorConns.length} evotor, ${vkConns.length} vk, ${refreshed} catalogs refreshed`);
} catch (err) {
console.error("[health] error:", err);
}
}
export function startHealthCheckLoop(intervalMs: number): NodeJS.Timeout {
return setInterval(() => {
runHealthChecks().catch((err) => console.error("[health] uncaught error:", err));
}, intervalMs);
}

39
web/src/lib/render.ts Normal file
View File

@@ -0,0 +1,39 @@
import nunjucks from "nunjucks";
import path from "path";
import { config } from "../config.js";
// In dev (tsx): templates are in src/templates/
// In prod (node dist/): Dockerfile copies src/templates/ to dist/templates/
const isDev = process.env.NODE_ENV !== "production";
const templatesDir = isDev
? path.join(process.cwd(), "src", "templates")
: path.join(process.cwd(), "dist", "templates");
const env = nunjucks.configure(templatesDir, {
autoescape: true,
noCache: process.env.NODE_ENV !== "production",
});
env.addGlobal("jivosite_widget_id", config.JIVOSITE_WIDGET_ID);
// Format a JS Date to Russian date string
env.addFilter("datefmt", (d: Date | null | undefined, fmt?: string) => {
if (!d) return "";
const dd = String(d.getDate()).padStart(2, "0");
const mm = String(d.getMonth() + 1).padStart(2, "0");
const yyyy = d.getFullYear();
const hh = String(d.getHours()).padStart(2, "0");
const min = String(d.getMinutes()).padStart(2, "0");
if (fmt === "%Y%m%d") return `${yyyy}${mm}${dd}`;
return `${dd}.${mm}.${yyyy} ${hh}:${min}`;
});
// Format decimal price to 2 decimal places
env.addFilter("price", (v: number | string | null | undefined) => {
if (v == null) return "";
return Number(v).toFixed(2);
});
export function render(template: string, ctx: Record<string, unknown> = {}): string {
return env.render(template, ctx);
}

361
web/src/lib/syncEngine.ts Normal file
View File

@@ -0,0 +1,361 @@
import { db } from "../db/client.js";
import { evotor_connections, vk_connections, sync_configs, sync_filters, cached_products } from "../db/schema.js";
import { eq, and } from "drizzle-orm";
import { config } from "../config.js";
import type { SyncFilter } from "../db/schema.js";
const VK_API_HOST = "https://api.vk.ru/method";
const VK_API_VERSION = "5.199";
const EVOTOR_API_BASE = "https://api.evotor.ru";
const VK_CATEGORY_ID = 40932;
const VK_STOCK_AMOUNT = 1000;
const WEIGHT_PRICE_MULTIPLIER = 10;
const WEIGHT_MEASURES = new Set(["г", "г.", "грамм", "граммов", "гр", "гр."]);
function isWeightMeasure(measure: string | null | undefined): boolean {
return !!measure && WEIGHT_MEASURES.has(measure.trim().toLowerCase());
}
function normalizeName(name: string): string {
return name.trim().replace(/;/g, ",");
}
function calcPrice(priceKopecks: number | null, measure: string | null): [number, string] {
const base = Math.round(Number(priceKopecks) || 0);
if (isWeightMeasure(measure)) {
return [base * WEIGHT_PRICE_MULTIPLIER, `${WEIGHT_PRICE_MULTIPLIER}${measure}`];
}
return [base, measure ?? ""];
}
function buildDescription(name: string, priceInfo: string, extraDesc: string | null): string {
let desc = `${name} (цена за ${priceInfo}.)\n\n`;
if (extraDesc) desc += extraDesc;
return desc.trim();
}
function getIncludedStoreIds(filters: SyncFilter[]): string[] {
return filters.filter((f) => f.entity_type === "store" && f.filter_mode === "include").map((f) => f.entity_id);
}
function isGroupIncluded(groupId: string | null, filters: SyncFilter[]): boolean {
const groupFilters = Object.fromEntries(
filters.filter((f) => f.entity_type === "group").map((f) => [f.entity_id, f.filter_mode])
);
if (Object.keys(groupFilters).length === 0) return true;
const mode = groupId ? groupFilters[groupId] : undefined;
if (mode === "exclude") return false;
if (mode === "include") return true;
return !Object.values(groupFilters).some((v) => v === "include");
}
function isProductIncluded(productId: string, filters: SyncFilter[]): boolean {
const productFilters = Object.fromEntries(
filters.filter((f) => f.entity_type === "product").map((f) => [f.entity_id, f.filter_mode])
);
if (Object.keys(productFilters).length === 0) return true;
const mode = productFilters[productId];
if (mode === "exclude") return false;
if (mode === "include") return true;
return !Object.values(productFilters).some((v) => v === "include");
}
// ---------------------------------------------------------------------------
// Evotor API helpers
// ---------------------------------------------------------------------------
async function evoFetchProducts(token: string, storeId: string): Promise<Array<Record<string, unknown>>> {
const resp = await fetch(`${EVOTOR_API_BASE}/stores/${storeId}/products`, {
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(30000),
});
if ([402, 404].includes(resp.status)) return [];
if (!resp.ok) throw new Error(`Evotor products ${resp.status}`);
const data = await resp.json() as { items?: unknown[] } | unknown[];
return (Array.isArray(data) ? data : (data as { items?: unknown[] }).items ?? []) as Array<Record<string, unknown>>;
}
async function evoFetchGroups(token: string, storeId: string): Promise<Array<Record<string, unknown>>> {
const resp = await fetch(`${EVOTOR_API_BASE}/stores/${storeId}/product-groups`, {
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(30000),
});
if ([402, 404].includes(resp.status)) return [];
if (!resp.ok) throw new Error(`Evotor groups ${resp.status}`);
const data = await resp.json() as { items?: unknown[] } | unknown[];
return (Array.isArray(data) ? data : (data as { items?: unknown[] }).items ?? []) as Array<Record<string, unknown>>;
}
// ---------------------------------------------------------------------------
// VK API helpers
// ---------------------------------------------------------------------------
function vkParams(token: string, extra: Record<string, unknown>): URLSearchParams {
const p: Record<string, string> = { access_token: token, v: VK_API_VERSION };
for (const [k, v] of Object.entries(extra)) p[k] = String(v);
return new URLSearchParams(p);
}
async function vkGet(token: string, method: string, params: Record<string, unknown>): Promise<unknown> {
const resp = await fetch(`${VK_API_HOST}/${method}?${vkParams(token, params)}`, {
signal: AbortSignal.timeout(30000),
});
if (!resp.ok) throw new Error(`VK ${method} ${resp.status}`);
return resp.json();
}
async function vkPost(token: string, method: string, params: Record<string, unknown>): Promise<unknown> {
const resp = await fetch(`${VK_API_HOST}/${method}`, {
method: "POST",
body: vkParams(token, params),
signal: AbortSignal.timeout(30000),
});
if (!resp.ok) throw new Error(`VK ${method} ${resp.status}`);
return resp.json();
}
async function vkGetAlbums(token: string, ownerId: string): Promise<Array<Record<string, unknown>>> {
const data = await vkGet(token, "market.getAlbums", { owner_id: ownerId, count: 200 }) as { response?: { items?: unknown[] } };
return (data.response?.items ?? []) as Array<Record<string, unknown>>;
}
async function vkCreateAlbum(token: string, ownerId: string, title: string): Promise<number | null> {
const data = await vkPost(token, "market.addAlbum", { owner_id: ownerId, title }) as { error?: unknown; response?: { market_album_id?: number } };
if (data.error) { console.error("[sync] VK create album error:", data.error); return null; }
return data.response?.market_album_id ?? null;
}
async function vkGetProducts(token: string, ownerId: string): Promise<Array<Record<string, unknown>>> {
const items: Array<Record<string, unknown>> = [];
let offset = 0;
const count = 200;
while (true) {
const data = await vkGet(token, "market.get", { owner_id: ownerId, extended: 1, with_disabled: 1, count, offset }) as { response?: { items?: unknown[] } };
const batch = (data.response?.items ?? []) as Array<Record<string, unknown>>;
items.push(...batch);
if (batch.length < count) break;
offset += count;
}
return items;
}
async function vkUploadPhoto(token: string, groupId: string, photoPath: string): Promise<string | null> {
try {
const serverData = await vkGet(token, "market.getProductPhotoUploadServer", { group_id: groupId }) as { error?: unknown; response?: { upload_url?: string } };
if (serverData.error) return null;
const uploadUrl = serverData.response?.upload_url;
if (!uploadUrl) return null;
const fs = await import("fs");
const form = new FormData();
form.append("file", new Blob([fs.readFileSync(photoPath)]), "photo.jpg");
const uploadResp = await fetch(uploadUrl, { method: "POST", body: form as BodyInit, signal: AbortSignal.timeout(60000) });
if (!uploadResp.ok) return null;
const uploadText = await uploadResp.text();
const saveData = await vkPost(token, "market.saveProductPhoto", { upload_response: uploadText }) as { error?: unknown; response?: { photo_id?: string } };
if (saveData.error) return null;
return saveData.response?.photo_id ?? null;
} catch (err) {
console.error("[sync] photo upload error:", err);
return null;
}
}
async function vkCreateProduct(
token: string, ownerId: string, name: string, description: string,
price: number, stockAmount: number, photoId: string, albumId: number | null,
): Promise<number | null> {
const data = await vkPost(token, "market.add", {
owner_id: ownerId, name, description, category_id: VK_CATEGORY_ID,
price, main_photo_id: photoId, stock_amount: stockAmount,
}) as { error?: unknown; response?: { market_item_id?: number } };
if (data.error) { console.error("[sync] VK create product error:", data.error); return null; }
const productId = data.response?.market_item_id ?? null;
if (productId && albumId) {
await vkGet(token, "market.addToAlbum", { owner_id: ownerId, item_ids: productId, album_ids: albumId }).catch(() => {});
}
return productId;
}
async function vkEditProduct(
token: string, ownerId: string, itemId: number, name: string,
description: string, price: number, stockAmount: number,
): Promise<void> {
const data = await vkPost(token, "market.edit", {
owner_id: ownerId, item_id: itemId, name, description, category_id: VK_CATEGORY_ID, price, stock_amount: stockAmount,
}) as { error?: unknown };
if (data.error) console.error("[sync] VK edit product error:", data.error);
}
async function vkDeleteProduct(token: string, ownerId: string, itemId: number): Promise<void> {
await vkPost(token, "market.delete", { owner_id: ownerId, item_id: itemId }).catch(() => {});
}
// ---------------------------------------------------------------------------
// Main sync logic per user
// ---------------------------------------------------------------------------
async function stampSynced(userId: number, evoId: string, now: Date): Promise<void> {
await db.update(cached_products)
.set({ synced_at: now })
.where(and(eq(cached_products.user_id, userId), eq(cached_products.evotor_id, evoId)));
}
async function syncUser(
userId: number,
evoToken: string,
vkToken: string,
vkGroupId: string,
filters: SyncFilter[],
photoPath: string,
): Promise<void> {
const ownerId = `-${vkGroupId}`;
const now = new Date();
console.log(`[sync] start user_id=${userId} vk_group=${vkGroupId}`);
const storeIds = getIncludedStoreIds(filters);
if (!storeIds.length) {
console.log(`[sync] skip user_id=${userId} — no stores in filters`);
return;
}
const evoProducts: Array<Record<string, unknown>> = [];
const groupsById: Record<string, Record<string, unknown>> = {};
for (const storeId of storeIds) {
const rawGroups = await evoFetchGroups(evoToken, storeId);
for (const g of rawGroups) {
const gid = (g.uuid ?? g.id) as string;
if (gid) groupsById[gid] = g;
}
const rawProducts = await evoFetchProducts(evoToken, storeId);
for (const p of rawProducts) {
const pid = (p.uuid ?? p.id) as string;
const gid = (p.parentUuid ?? p.parent_id) as string | null;
if (!isGroupIncluded(gid, filters)) continue;
if (!isProductIncluded(pid, filters)) continue;
evoProducts.push(p);
}
}
const evoByName: Record<string, Record<string, unknown>> = {};
for (const p of evoProducts) {
const norm = normalizeName(((p.name as string) ?? "").trim());
evoByName[norm] = p;
}
const vkProducts = await vkGetProducts(vkToken, ownerId);
const vkAlbums = await vkGetAlbums(vkToken, ownerId);
const vkAlbumByTitle: Record<string, Record<string, unknown>> = {};
for (const a of vkAlbums) vkAlbumByTitle[a.title as string] = a;
// Ensure albums exist for included groups
for (const [gid, group] of Object.entries(groupsById)) {
if (!isGroupIncluded(gid, filters)) continue;
const title = (group.name as string) ?? "";
if (title && !vkAlbumByTitle[title]) {
const newId = await vkCreateAlbum(vkToken, ownerId, title);
if (newId) {
vkAlbumByTitle[title] = { id: newId, title };
console.log(`[sync] created VK album '${title}' user_id=${userId}`);
}
}
}
const vkByName: Record<string, Array<Record<string, unknown>>> = {};
for (const item of vkProducts) {
const norm = normalizeName((item.title as string) ?? "");
(vkByName[norm] ??= []).push(item);
}
// Update / create
for (const [normName, evoP] of Object.entries(evoByName)) {
const evoId = (evoP.uuid ?? evoP.id) as string;
const rawName = ((evoP.name as string) ?? "").trim();
const nameForVk = normalizeName(rawName);
const measure = (evoP.measureName ?? evoP.measure_name) as string | null;
const rawPrice = (evoP.price as number) ?? 0;
const [price, priceInfo] = calcPrice(rawPrice, measure);
const allowToSell = (evoP.allowToSell !== undefined ? evoP.allowToSell : evoP.allow_to_sell) as boolean | null;
const stockAmount = allowToSell ? VK_STOCK_AMOUNT : 0;
const extraDesc = (evoP.description as string) ?? "";
const description = buildDescription(rawName, priceInfo, extraDesc);
const gid = (evoP.parentUuid ?? evoP.parent_id) as string | null;
const groupName = gid ? (groupsById[gid]?.name as string) ?? null : null;
const album = groupName ? vkAlbumByTitle[groupName] : null;
const albumId = album ? (album.id as number) : null;
if (vkByName[normName]) {
const vkItem = vkByName[normName][0];
const vkId = vkItem.id as number;
const origPrice = parseInt(String((vkItem.price as Record<string, unknown>)?.amount ?? 0));
const origDesc = ((vkItem.description as string) ?? "").trim();
const origStock = (vkItem.stock_amount as number) ?? 0;
if (price !== origPrice || description !== origDesc || stockAmount !== origStock) {
console.log(`[sync] updating '${nameForVk}' user_id=${userId}`);
await vkEditProduct(vkToken, ownerId, vkId, nameForVk, description, price, stockAmount);
}
await stampSynced(userId, evoId, now);
} else {
if (!allowToSell) continue;
const photoId = await vkUploadPhoto(vkToken, vkGroupId, photoPath);
if (!photoId) { console.error(`[sync] skip '${nameForVk}' — photo upload failed`); continue; }
console.log(`[sync] creating '${nameForVk}' user_id=${userId}`);
const created = await vkCreateProduct(vkToken, ownerId, nameForVk, description, price, stockAmount, photoId, albumId);
if (created) await stampSynced(userId, evoId, now);
}
}
// Delete VK products not in Evotor
for (const [normName, vkItems] of Object.entries(vkByName)) {
if (evoByName[normName]) {
for (const dup of vkItems.slice(1)) {
console.log(`[sync] deleting duplicate '${normName}' id=${dup.id} user_id=${userId}`);
await vkDeleteProduct(vkToken, ownerId, dup.id as number);
}
} else {
for (const item of vkItems) {
console.log(`[sync] deleting removed '${normName}' id=${item.id} user_id=${userId}`);
await vkDeleteProduct(vkToken, ownerId, item.id as number);
}
}
}
console.log(`[sync] complete user_id=${userId}`);
}
export async function runSync(): Promise<void> {
try {
const configs = await db.select().from(sync_configs)
.where(and(eq(sync_configs.is_enabled, true)));
for (const cfg of configs) {
if (!cfg.confirmed_at) continue;
const [evo] = await db.select().from(evotor_connections).where(eq(evotor_connections.user_id, cfg.user_id)).limit(1);
const [vk] = await db.select().from(vk_connections).where(eq(vk_connections.user_id, cfg.user_id)).limit(1);
if (!evo?.access_token || !vk?.access_token || !vk.vk_user_id) continue;
const filters = await db.select().from(sync_filters).where(eq(sync_filters.sync_config_id, cfg.id));
try {
await syncUser(cfg.user_id, evo.access_token, vk.access_token, vk.vk_user_id, filters, config.VK_DEFAULT_PHOTO_PATH);
} catch (err) {
console.error(`[sync] failed for user_id=${cfg.user_id}:`, err);
}
}
} catch (err) {
console.error("[sync] runner error:", err);
}
}
export function startSyncLoop(intervalMs: number): NodeJS.Timeout {
return setInterval(() => {
runSync().catch((err) => console.error("[sync] uncaught error:", err));
}, intervalMs);
}

40
web/src/lib/validate.ts Normal file
View File

@@ -0,0 +1,40 @@
const PHONE_RE = /^\+7 \(\d{3}\) \d{3}-\d{2}-\d{2}$/;
const EMAIL_RE = /^[^@]+@[^@]+\.[^@]+$/;
export function validateRegistration(data: Record<string, string>): string[] {
const errors: string[] = [];
if (!data.first_name?.trim()) errors.push("Введите имя");
if (!data.last_name?.trim()) errors.push("Введите фамилию");
const email = data.email?.trim() ?? "";
if (!email || !EMAIL_RE.test(email)) errors.push("Введите корректный email");
const phone = data.phone?.trim() ?? "";
if (!phone || !PHONE_RE.test(phone)) errors.push("Введите телефон в формате +7 (XXX) XXX-XX-XX");
const password = data.password ?? "";
if (password.length < 8) errors.push("Пароль должен быть не менее 8 символов");
if (password !== data.password_confirm) errors.push("Пароли не совпадают");
return errors;
}
export function validateLogin(data: Record<string, string>): string[] {
const errors: string[] = [];
if (!data.email?.trim()) errors.push("Введите email");
if (!data.password) errors.push("Введите пароль");
return errors;
}
export function validateResetPassword(data: Record<string, string>): string[] {
const errors: string[] = [];
const password = data.password ?? "";
if (password.length < 8) errors.push("Пароль должен быть не менее 8 символов");
if (password !== data.password_confirm) errors.push("Пароли не совпадают");
return errors;
}
export function validateProfile(data: Record<string, string>): string[] {
const errors: string[] = [];
if (!data.first_name?.trim()) errors.push("Введите имя");
if (!data.last_name?.trim()) errors.push("Введите фамилию");
const phone = data.phone?.trim() ?? "";
if (!phone || !PHONE_RE.test(phone)) errors.push("Введите телефон в формате +7 (XXX) XXX-XX-XX");
return errors;
}

124
web/src/routes/auth.ts Normal file
View File

@@ -0,0 +1,124 @@
import { Hono } from "hono";
import { getCookie } from "hono/cookie";
import { db } from "../db/client.js";
import { users } from "../db/schema.js";
import { eq, or } from "drizzle-orm";
import { hashPassword, verifyPassword, getSessionUserId, type AppEnv } from "../lib/auth.js";
import { validateRegistration, validateLogin } from "../lib/validate.js";
import { render } from "../lib/render.js";
import { config } from "../config.js";
import { randomUUID } from "crypto";
export const authRouter = new Hono<AppEnv>();
authRouter.get("/register", async (c) => {
const userId = getSessionUserId(c);
if (userId) return c.redirect("/profile", 303);
return c.html(render("register.njk", { user: null }));
});
authRouter.post("/register", async (c) => {
const form = await c.req.formData();
const data: Record<string, string> = {};
form.forEach((v, k) => { data[k] = String(v); });
const errors = validateRegistration(data);
if (!errors.length) {
const existing = await db.select().from(users).where(
or(eq(users.email, data.email.trim()), eq(users.phone, data.phone.trim()))
).limit(1);
if (existing.length) {
if (existing[0].email === data.email.trim()) {
errors.push("Пользователь с таким email уже существует");
} else {
errors.push("Пользователь с таким телефоном уже существует");
}
}
}
if (errors.length) {
return c.html(render("register.njk", { user: null, errors, form: data }));
}
const token = randomUUID().replace(/-/g, "");
await db.insert(users).values({
first_name: data.first_name.trim(),
last_name: data.last_name.trim(),
email: data.email.trim(),
phone: data.phone.trim(),
password_hash: await hashPassword(data.password),
email_confirm_token: token,
});
const confirmUrl = `${config.BASE_URL}/confirm-email?token=${token}`;
console.log("=".repeat(40));
console.log("ПОДТВЕРЖДЕНИЕ EMAIL");
console.log(`Пользователь: ${data.email.trim()}`);
console.log(`Ссылка: ${confirmUrl}`);
console.log("=".repeat(40));
return c.html(render("confirm_email.njk", { user: null }));
});
authRouter.get("/confirm-email", async (c) => {
const token = c.req.query("token");
if (!token) {
return c.html(render("message.njk", {
user: null, title: "Ошибка", message: "Неверная или устаревшая ссылка.",
}));
}
const [user] = await db.select().from(users).where(eq(users.email_confirm_token, token)).limit(1);
if (!user) {
return c.html(render("message.njk", {
user: null, title: "Ошибка", message: "Неверная или устаревшая ссылка.",
}));
}
await db.update(users)
.set({ is_email_confirmed: true, email_confirm_token: null })
.where(eq(users.id, user.id));
return c.html(render("email_confirmed.njk", { user: null }));
});
authRouter.get("/login", async (c) => {
const userId = getSessionUserId(c);
if (userId) return c.redirect("/profile", 303);
return c.html(render("login.njk", { user: null }));
});
authRouter.post("/login", async (c) => {
const form = await c.req.formData();
const data: Record<string, string> = {};
form.forEach((v, k) => { data[k] = String(v); });
const errors = validateLogin(data);
if (errors.length) {
return c.html(render("login.njk", { user: null, errors, form: data }));
}
const [user] = await db.select().from(users).where(eq(users.email, data.email.trim())).limit(1);
if (!user || !(await verifyPassword(data.password, user.password_hash))) {
return c.html(render("login.njk", {
user: null, errors: ["Неверный email или пароль"], form: data,
}));
}
if (!user.is_email_confirmed) {
return c.html(render("login.njk", {
user: null, errors: ["Пожалуйста, подтвердите ваш email"], form: data,
}));
}
const session = c.get("session") as { set: (k: string, v: unknown) => void };
session.set("user_id", user.id);
return c.redirect("/profile", 303);
});
authRouter.get("/logout", (c) => {
const session = c.get("session") as { deleteSession: () => void };
session.deleteSession();
return c.redirect("/login", 303);
});

260
web/src/routes/catalog.ts Normal file
View File

@@ -0,0 +1,260 @@
import { Hono } from "hono";
import { db } from "../db/client.js";
import { users, evotor_connections, sync_configs, sync_filters, cached_stores, cached_groups, cached_products } from "../db/schema.js";
import { eq, and, sql } from "drizzle-orm";
import { getSessionUserId, type AppEnv } from "../lib/auth.js";
import { render } from "../lib/render.js";
import { refreshCatalogCache } from "../lib/evotorApi.js";
export const catalogRouter = new Hono<AppEnv>();
async function requireUser(c: Parameters<typeof getSessionUserId>[0]) {
const id = getSessionUserId(c);
if (!id) return null;
const [user] = await db.select().from(users).where(eq(users.id, id)).limit(1);
return user ?? null;
}
async function getOrCreateSyncConfig(userId: number) {
const [existing] = await db.select().from(sync_configs).where(eq(sync_configs.user_id, userId)).limit(1);
if (existing) return existing;
await db.insert(sync_configs).values({ user_id: userId, is_enabled: false });
const [created] = await db.select().from(sync_configs).where(eq(sync_configs.user_id, userId)).limit(1);
return created!;
}
async function getFilterMap(configId: number): Promise<Record<string, string>> {
const filters = await db.select().from(sync_filters).where(eq(sync_filters.sync_config_id, configId));
return Object.fromEntries(filters.map((f) => [f.entity_id, f.filter_mode]));
}
catalogRouter.get("/catalog", async (c) => {
const user = await requireUser(c);
if (!user) return c.redirect("/login", 303);
const [evotor] = await db.select().from(evotor_connections).where(eq(evotor_connections.user_id, user.id)).limit(1);
if (!evotor) {
return c.html(render("catalog_stores.njk", { user, evotor: null, stores: [], filter_map: {}, fetched_at: null }));
}
let stores = await db.select().from(cached_stores)
.where(eq(cached_stores.user_id, user.id))
.orderBy(cached_stores.name);
if (!stores.length) {
await refreshCatalogCache(user.id, evotor.access_token);
stores = await db.select().from(cached_stores)
.where(eq(cached_stores.user_id, user.id))
.orderBy(cached_stores.name);
}
const config = await getOrCreateSyncConfig(user.id);
const filter_map = await getFilterMap(config.id);
const fetched_at = stores[0]?.fetched_at ?? null;
return c.html(render("catalog_stores.njk", { user, evotor, stores, filter_map, fetched_at }));
});
catalogRouter.get("/catalog/groups", async (c) => {
const user = await requireUser(c);
if (!user) return c.redirect("/login", 303);
const store_id = c.req.query("store_id") ?? "";
const [store] = await db.select().from(cached_stores)
.where(and(eq(cached_stores.user_id, user.id), eq(cached_stores.evotor_id, store_id))).limit(1);
if (!store) return c.redirect("/catalog", 303);
const groups = await db.select().from(cached_groups)
.where(and(eq(cached_groups.user_id, user.id), eq(cached_groups.store_evotor_id, store_id)))
.orderBy(cached_groups.name);
const product_counts: Record<string, number> = {};
for (const g of groups) {
const [row] = await db.select({ count: sql<number>`count(*)` }).from(cached_products)
.where(and(eq(cached_products.user_id, user.id), eq(cached_products.group_evotor_id, g.evotor_id)));
product_counts[g.evotor_id] = Number(row?.count ?? 0);
}
const config = await getOrCreateSyncConfig(user.id);
const filter_map = await getFilterMap(config.id);
return c.html(render("catalog_groups.njk", { user, store, groups, product_counts, filter_map }));
});
catalogRouter.get("/catalog/products", async (c) => {
const user = await requireUser(c);
if (!user) return c.redirect("/login", 303);
const store_id = c.req.query("store_id") ?? "";
const group_id = c.req.query("group_id") ?? null;
const [store] = await db.select().from(cached_stores)
.where(and(eq(cached_stores.user_id, user.id), eq(cached_stores.evotor_id, store_id))).limit(1);
if (!store) return c.redirect("/catalog", 303);
let group = null;
let products;
if (group_id) {
[group] = await db.select().from(cached_groups)
.where(and(eq(cached_groups.user_id, user.id), eq(cached_groups.evotor_id, group_id))).limit(1);
products = await db.select().from(cached_products)
.where(and(
eq(cached_products.user_id, user.id),
eq(cached_products.store_evotor_id, store_id),
eq(cached_products.group_evotor_id, group_id),
))
.orderBy(cached_products.name);
} else {
products = await db.select().from(cached_products)
.where(and(eq(cached_products.user_id, user.id), eq(cached_products.store_evotor_id, store_id)))
.orderBy(cached_products.name);
}
const config = await getOrCreateSyncConfig(user.id);
const filter_map = await getFilterMap(config.id);
return c.html(render("catalog_products.njk", { user, store, group: group ?? null, products, filter_map }));
});
catalogRouter.post("/catalog/filter", async (c) => {
const user = await requireUser(c);
if (!user) return c.redirect("/login", 303);
const form = await c.req.formData();
const entity_type = String(form.get("entity_type") ?? "");
const entity_id = String(form.get("entity_id") ?? "");
const entity_name = String(form.get("entity_name") ?? "") || null;
const filter_mode = String(form.get("filter_mode") ?? "");
const parent_entity_id = String(form.get("parent_entity_id") ?? "") || null;
const redirect_to = String(form.get("redirect_to") ?? "/catalog");
const config = await getOrCreateSyncConfig(user.id);
const [existing] = await db.select().from(sync_filters)
.where(and(
eq(sync_filters.sync_config_id, config.id),
eq(sync_filters.entity_type, entity_type),
eq(sync_filters.entity_id, entity_id),
)).limit(1);
if (filter_mode === "none") {
if (existing) await db.delete(sync_filters).where(eq(sync_filters.id, existing.id));
} else if (existing) {
await db.update(sync_filters)
.set({ filter_mode, entity_name })
.where(eq(sync_filters.id, existing.id));
} else {
await db.insert(sync_filters).values({
sync_config_id: config.id,
entity_type,
entity_id,
entity_name,
filter_mode,
parent_entity_id,
});
}
return c.redirect(redirect_to, 303);
});
catalogRouter.post("/catalog/refresh", async (c) => {
const user = await requireUser(c);
if (!user) return c.redirect("/login", 303);
const [evotor] = await db.select().from(evotor_connections).where(eq(evotor_connections.user_id, user.id)).limit(1);
if (evotor) await refreshCatalogCache(user.id, evotor.access_token);
return c.redirect("/catalog", 303);
});
catalogRouter.get("/catalog/export", async (c) => {
const user = await requireUser(c);
if (!user) return c.redirect("/login", 303);
const type = c.req.query("type") ?? "products";
const store_id = c.req.query("store_id") ?? null;
const group_id = c.req.query("group_id") ?? null;
const syncConfig = await getOrCreateSyncConfig(user.id);
const fmap = await getFilterMap(syncConfig.id);
const filterLabel = (eid: string) => {
const m = fmap[eid];
if (m === "include") return "Включено";
if (m === "exclude") return "Исключено";
return "Нет правила";
};
const today = new Date().toISOString().slice(0, 10).replace(/-/g, "");
let filename: string;
const rows: string[][] = [];
const BOM = "\uFEFF";
if (type === "stores") {
filename = `stores_${today}.csv`;
rows.push(["Название", "Адрес", "ID", "Фильтр"]);
const stores = await db.select().from(cached_stores)
.where(eq(cached_stores.user_id, user.id))
.orderBy(cached_stores.name);
for (const s of stores) {
rows.push([s.name, s.address ?? "", s.evotor_id, filterLabel(s.evotor_id)]);
}
} else if (type === "groups") {
filename = `groups_${today}.csv`;
rows.push(["Магазин", "Название", "ID", "Фильтр"]);
const storeMap: Record<string, string> = {};
const allStores = await db.select().from(cached_stores).where(eq(cached_stores.user_id, user.id));
for (const s of allStores) storeMap[s.evotor_id] = s.name;
const q = db.select().from(cached_groups).where(
store_id
? and(eq(cached_groups.user_id, user.id), eq(cached_groups.store_evotor_id, store_id))
: eq(cached_groups.user_id, user.id)
).orderBy(cached_groups.name);
const groups = await q;
for (const g of groups) {
rows.push([storeMap[g.store_evotor_id] ?? "", g.name, g.evotor_id, filterLabel(g.evotor_id)]);
}
} else {
filename = `products_${today}.csv`;
rows.push(["Магазин", "Группа", "Название", "Артикул", "Цена", "Количество", "Ед. измерения", "В продаже", "ID", "Фильтр"]);
const storeMap: Record<string, string> = {};
const groupMap: Record<string, string> = {};
const allStores = await db.select().from(cached_stores).where(eq(cached_stores.user_id, user.id));
for (const s of allStores) storeMap[s.evotor_id] = s.name;
const allGroups = await db.select().from(cached_groups).where(eq(cached_groups.user_id, user.id));
for (const g of allGroups) groupMap[g.evotor_id] = g.name;
const conditions = [eq(cached_products.user_id, user.id)];
if (store_id) conditions.push(eq(cached_products.store_evotor_id, store_id));
if (group_id) conditions.push(eq(cached_products.group_evotor_id, group_id));
const products = await db.select().from(cached_products).where(and(...conditions)).orderBy(cached_products.name);
for (const p of products) {
rows.push([
storeMap[p.store_evotor_id] ?? "",
p.group_evotor_id ? (groupMap[p.group_evotor_id] ?? "") : "",
p.name,
p.article_number ?? "",
p.price ? String(p.price) : "",
p.quantity ? String(p.quantity) : "",
p.measure_name ?? "",
p.allow_to_sell === true ? "Да" : p.allow_to_sell === false ? "Нет" : "",
p.evotor_id,
filterLabel(p.evotor_id),
]);
}
}
const csv = BOM + rows.map((row) =>
row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(",")
).join("\r\n");
return new Response(csv, {
headers: {
"Content-Type": "text/csv; charset=utf-8",
"Content-Disposition": `attachment; filename="${filename}"`,
},
});
});

View File

@@ -0,0 +1,83 @@
import { Hono } from "hono";
import { db } from "../db/client.js";
import { users, evotor_connections, vk_connections } from "../db/schema.js";
import { eq } from "drizzle-orm";
import { getSessionUserId, type AppEnv } from "../lib/auth.js";
import { render } from "../lib/render.js";
export const connectionsRouter = new Hono<AppEnv>();
const SERVICE_TYPES = [
{
type: "evotor",
name: "Эвотор",
icon: "bi-shop",
description: "Подключите кассу Эвотор для синхронизации каталога товаров.",
configure_url: "/evotor",
connect_url: "/evotor",
},
{
type: "vk",
name: "ВКонтакте",
icon: "bi-bag",
description: "Подключите аккаунт ВКонтакте для публикации товаров в вашу группу.",
configure_url: "/vk",
connect_url: "/vk",
},
];
async function requireUser(c: Parameters<typeof getSessionUserId>[0]) {
const id = getSessionUserId(c);
if (!id) return null;
const [user] = await db.select().from(users).where(eq(users.id, id)).limit(1);
return user ?? null;
}
connectionsRouter.get("/connections", async (c) => {
const user = await requireUser(c);
if (!user) return c.redirect("/login", 303);
const [evotor] = await db.select().from(evotor_connections).where(eq(evotor_connections.user_id, user.id)).limit(1);
const [vk] = await db.select().from(vk_connections).where(eq(vk_connections.user_id, user.id)).limit(1);
const connections = SERVICE_TYPES
.map((svc) => {
const conn = svc.type === "evotor" ? evotor : vk;
if (!conn) return null;
const details = svc.type === "evotor"
? (evotor?.store_name ?? null)
: (vk?.first_name ? `${vk.first_name} ${vk.last_name ?? ""}`.trim() : null);
return { ...svc, is_online: conn.is_online, last_checked_at: conn.last_checked_at, details };
})
.filter(Boolean);
return c.html(render("connections.njk", { user, connections }));
});
connectionsRouter.get("/connections/add", async (c) => {
const user = await requireUser(c);
if (!user) return c.redirect("/login", 303);
const [evotor] = await db.select().from(evotor_connections).where(eq(evotor_connections.user_id, user.id)).limit(1);
const [vk] = await db.select().from(vk_connections).where(eq(vk_connections.user_id, user.id)).limit(1);
const available = SERVICE_TYPES.filter((svc) => {
return svc.type === "evotor" ? !evotor : !vk;
});
return c.html(render("connections_add.njk", { user, available }));
});
connectionsRouter.post("/connections/delete", async (c) => {
const user = await requireUser(c);
if (!user) return c.redirect("/login", 303);
const svcType = c.req.query("type");
if (svcType === "evotor") {
await db.delete(evotor_connections).where(eq(evotor_connections.user_id, user.id));
} else if (svcType === "vk") {
await db.delete(vk_connections).where(eq(vk_connections.user_id, user.id));
}
return c.redirect("/connections", 303);
});

147
web/src/routes/evotor.ts Normal file
View File

@@ -0,0 +1,147 @@
import { Hono } from "hono";
import { db } from "../db/client.js";
import { users, evotor_connections } from "../db/schema.js";
import { eq } from "drizzle-orm";
import { getSessionUserId, type AppEnv } from "../lib/auth.js";
import { render } from "../lib/render.js";
import { config } from "../config.js";
export const evotorRouter = new Hono<AppEnv>();
const EVOTOR_APP_URL = "https://market.evotor.ru/store/apps/{app_id}";
const EVOTOR_STORES_URL = "https://api.evotor.ru/stores";
async function requireUser(c: Parameters<typeof getSessionUserId>[0]) {
const id = getSessionUserId(c);
if (!id) return null;
const [user] = await db.select().from(users).where(eq(users.id, id)).limit(1);
return user ?? null;
}
async function fetchStoreInfo(token: string): Promise<{ store_id: string | null; store_name: string | null }> {
try {
const resp = await fetch(EVOTOR_STORES_URL, {
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(15000),
});
if (resp.ok) {
const data = await resp.json() as unknown;
const stores = (typeof data === "object" && data !== null && "items" in data)
? (data as { items: unknown[] }).items
: (Array.isArray(data) ? data : []);
if (Array.isArray(stores) && stores.length > 0) {
const s = stores[0] as Record<string, unknown>;
return {
store_id: (s.uuid as string | null) ?? (s.id as string | null) ?? null,
store_name: (s.name as string | null) ?? null,
};
}
}
return { store_id: null, store_name: null };
} catch {
return { store_id: null, store_name: null };
}
}
evotorRouter.get("/evotor", async (c) => {
const user = await requireUser(c);
if (!user) return c.redirect("/login", 303);
const [connection] = await db.select().from(evotor_connections)
.where(eq(evotor_connections.user_id, user.id)).limit(1);
const error = c.req.query("error") ?? null;
const app_url = config.EVOTOR_APP_ID
? EVOTOR_APP_URL.replace("{app_id}", config.EVOTOR_APP_ID)
: null;
return c.html(render("evotor.njk", { user, connection: connection ?? null, error, app_url }));
});
// Webhook endpoint: Evotor POSTs {"userId": "...", "token": "..."}
evotorRouter.post("/evotor/callback", async (c) => {
if (config.EVOTOR_WEBHOOK_SECRET) {
const authHeader = c.req.header("Authorization") ?? "";
if (authHeader !== `Bearer ${config.EVOTOR_WEBHOOK_SECRET}`) {
return c.json({ error: "Unauthorized" }, 401);
}
}
const payload = await c.req.json() as { userId?: string; token?: string };
if (!payload.userId || !payload.token) {
return c.json({ error: "Invalid payload" }, 400);
}
const now = new Date();
const { store_id, store_name } = await fetchStoreInfo(payload.token);
const [existing] = await db.select().from(evotor_connections)
.where(eq(evotor_connections.evotor_user_id, payload.userId)).limit(1);
if (existing) {
await db.update(evotor_connections)
.set({ access_token: payload.token, store_id, store_name, is_online: true, last_checked_at: now, updated_at: now })
.where(eq(evotor_connections.id, existing.id));
} else {
await db.insert(evotor_connections).values({
evotor_user_id: payload.userId,
access_token: payload.token,
store_id,
store_name,
is_online: true,
last_checked_at: now,
});
}
return c.json({ status: "ok" });
});
evotorRouter.post("/evotor/token", async (c) => {
const user = await requireUser(c);
if (!user) return c.redirect("/login", 303);
const form = await c.req.formData();
const token = String(form.get("token") ?? "").trim();
if (!token) return c.redirect("/evotor?error=empty_token", 303);
const now = new Date();
// Validate token by fetching stores
let storeInfo: { store_id: string | null; store_name: string | null };
try {
const resp = await fetch(EVOTOR_STORES_URL, {
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(15000),
});
if (resp.status === 401) return c.redirect("/evotor?error=invalid_token", 303);
storeInfo = await fetchStoreInfo(token);
} catch {
storeInfo = { store_id: null, store_name: null };
}
const [existing] = await db.select().from(evotor_connections)
.where(eq(evotor_connections.user_id, user.id)).limit(1);
if (existing) {
await db.update(evotor_connections)
.set({ access_token: token, store_id: storeInfo.store_id, store_name: storeInfo.store_name, is_online: true, last_checked_at: now, updated_at: now })
.where(eq(evotor_connections.id, existing.id));
} else {
await db.insert(evotor_connections).values({
user_id: user.id,
access_token: token,
store_id: storeInfo.store_id,
store_name: storeInfo.store_name,
is_online: true,
last_checked_at: now,
});
}
return c.redirect("/connections", 303);
});
evotorRouter.post("/evotor/disconnect", async (c) => {
const user = await requireUser(c);
if (!user) return c.redirect("/login", 303);
await db.delete(evotor_connections).where(eq(evotor_connections.user_id, user.id));
return c.redirect("/connections", 303);
});

116
web/src/routes/profile.ts Normal file
View File

@@ -0,0 +1,116 @@
import { Hono } from "hono";
import { db } from "../db/client.js";
import { users } from "../db/schema.js";
import { eq, and, ne } from "drizzle-orm";
import { hashPassword, verifyPassword, getSessionUserId, type AppEnv } from "../lib/auth.js";
import { validateProfile, validateResetPassword } from "../lib/validate.js";
import { render } from "../lib/render.js";
export const profileRouter = new Hono<AppEnv>();
async function requireUser(c: Parameters<typeof getSessionUserId>[0]) {
const id = getSessionUserId(c);
if (!id) return null;
const [user] = await db.select().from(users).where(eq(users.id, id)).limit(1);
return user ?? null;
}
profileRouter.get("/profile", async (c) => {
const user = await requireUser(c);
if (!user) return c.redirect("/login", 303);
return c.html(render("profile_view.njk", { user }));
});
profileRouter.get("/profile/edit", async (c) => {
const user = await requireUser(c);
if (!user) return c.redirect("/login", 303);
return c.html(render("profile_edit.njk", { user }));
});
profileRouter.post("/profile/edit", async (c) => {
const user = await requireUser(c);
if (!user) return c.redirect("/login", 303);
const form = await c.req.formData();
const data: Record<string, string> = {};
form.forEach((v, k) => { data[k] = String(v); });
const errors = validateProfile(data);
if (!errors.length) {
const existing = await db.select().from(users).where(
and(eq(users.phone, data.phone.trim()), ne(users.id, user.id))
).limit(1);
if (existing.length) errors.push("Пользователь с таким телефоном уже существует");
}
if (errors.length) {
return c.html(render("profile_edit.njk", { user, errors, form: data }));
}
await db.update(users)
.set({ first_name: data.first_name.trim(), last_name: data.last_name.trim(), phone: data.phone.trim() })
.where(eq(users.id, user.id));
const [updated] = await db.select().from(users).where(eq(users.id, user.id)).limit(1);
return c.html(render("profile_edit.njk", { user: updated, success: "Профиль обновлен" }));
});
profileRouter.get("/profile/change-password", async (c) => {
const user = await requireUser(c);
if (!user) return c.redirect("/login", 303);
return c.html(render("profile_change_password.njk", { user }));
});
profileRouter.post("/profile/change-password", async (c) => {
const user = await requireUser(c);
if (!user) return c.redirect("/login", 303);
const form = await c.req.formData();
const data: Record<string, string> = {};
form.forEach((v, k) => { data[k] = String(v); });
const errors: string[] = [];
const currentPassword = data.current_password ?? "";
if (!currentPassword) {
errors.push("Введите текущий пароль");
} else if (!(await verifyPassword(currentPassword, user.password_hash))) {
errors.push("Неверный текущий пароль");
}
errors.push(...validateResetPassword(data));
if (errors.length) {
return c.html(render("profile_change_password.njk", { user, errors }));
}
await db.update(users)
.set({ password_hash: await hashPassword(data.password) })
.where(eq(users.id, user.id));
return c.html(render("profile_change_password.njk", { user, success: "Пароль изменен" }));
});
profileRouter.get("/profile/delete", async (c) => {
const user = await requireUser(c);
if (!user) return c.redirect("/login", 303);
return c.html(render("profile_delete.njk", { user }));
});
profileRouter.post("/profile/delete", async (c) => {
const user = await requireUser(c);
if (!user) return c.redirect("/login", 303);
const form = await c.req.formData();
const password = String(form.get("password") ?? "");
if (!password) {
return c.html(render("profile_delete.njk", { user, errors: ["Введите пароль для подтверждения"] }));
}
if (!(await verifyPassword(password, user.password_hash))) {
return c.html(render("profile_delete.njk", { user, errors: ["Неверный пароль"] }));
}
await db.delete(users).where(eq(users.id, user.id));
const session = c.get("session") as { deleteSession: () => void };
session.deleteSession();
return c.redirect("/", 303);
});

100
web/src/routes/reset.ts Normal file
View File

@@ -0,0 +1,100 @@
import { Hono } from "hono";
import { db } from "../db/client.js";
import { users } from "../db/schema.js";
import { eq } from "drizzle-orm";
import { hashPassword, type AppEnv } from "../lib/auth.js";
import { validateResetPassword } from "../lib/validate.js";
import { render } from "../lib/render.js";
import { config } from "../config.js";
import { randomUUID } from "crypto";
export const resetRouter = new Hono<AppEnv>();
resetRouter.get("/forgot-password", (c) => {
return c.html(render("forgot_password.njk", { user: null }));
});
resetRouter.post("/forgot-password", async (c) => {
const form = await c.req.formData();
const email = (String(form.get("email") ?? "")).trim();
if (email) {
const [user] = await db.select().from(users).where(eq(users.email, email)).limit(1);
if (user) {
const token = randomUUID().replace(/-/g, "");
const expires = new Date(Date.now() + config.PASSWORD_RESET_EXPIRE_MINUTES * 60 * 1000);
await db.update(users)
.set({ password_reset_token: token, password_reset_expires: expires })
.where(eq(users.id, user.id));
const resetUrl = `${config.BASE_URL}/reset-password?token=${token}`;
console.log("=".repeat(40));
console.log("СБРОС ПАРОЛЯ");
console.log(`Пользователь: ${user.email}`);
console.log(`Ссылка: ${resetUrl}`);
console.log(`Действительна: ${config.PASSWORD_RESET_EXPIRE_MINUTES} мин.`);
console.log("=".repeat(40));
}
}
return c.html(render("message.njk", {
user: null,
title: "Сброс пароля",
message: "Если аккаунт с таким email существует, мы отправили письмо со ссылкой для сброса пароля.",
}));
});
resetRouter.get("/reset-password", async (c) => {
const token = c.req.query("token") ?? "";
const [user] = await db.select().from(users).where(eq(users.password_reset_token, token)).limit(1);
if (!user || !user.password_reset_expires) {
return c.html(render("message.njk", {
user: null, title: "Ошибка", message: "Неверная или устаревшая ссылка.",
}));
}
if (new Date() > user.password_reset_expires) {
return c.html(render("message.njk", {
user: null, title: "Ошибка", message: "Срок действия ссылки истек.",
}));
}
return c.html(render("reset_password.njk", { user: null, token }));
});
resetRouter.post("/reset-password", async (c) => {
const token = c.req.query("token") ?? "";
const [user] = await db.select().from(users).where(eq(users.password_reset_token, token)).limit(1);
if (!user || !user.password_reset_expires) {
return c.html(render("message.njk", {
user: null, title: "Ошибка", message: "Неверная или устаревшая ссылка.",
}));
}
if (new Date() > user.password_reset_expires) {
return c.html(render("message.njk", {
user: null, title: "Ошибка", message: "Срок действия ссылки истек.",
}));
}
const form = await c.req.formData();
const data: Record<string, string> = {};
form.forEach((v, k) => { data[k] = String(v); });
const errors = validateResetPassword(data);
if (errors.length) {
return c.html(render("reset_password.njk", { user: null, token, errors }));
}
await db.update(users)
.set({ password_hash: await hashPassword(data.password), password_reset_token: null, password_reset_expires: null })
.where(eq(users.id, user.id));
return c.html(render("message.njk", {
user: null,
title: "Пароль изменен",
message: "Ваш пароль успешно изменен. Теперь вы можете войти.",
link: "/login",
link_text: "Войти",
}));
});

88
web/src/routes/sync.ts Normal file
View File

@@ -0,0 +1,88 @@
import { Hono } from "hono";
import { db } from "../db/client.js";
import { users, evotor_connections, vk_connections, sync_configs, sync_filters } from "../db/schema.js";
import { eq } from "drizzle-orm";
import { getSessionUserId, type AppEnv } from "../lib/auth.js";
import { render } from "../lib/render.js";
export const syncRouter = new Hono<AppEnv>();
async function requireUser(c: Parameters<typeof getSessionUserId>[0]) {
const id = getSessionUserId(c);
if (!id) return null;
const [user] = await db.select().from(users).where(eq(users.id, id)).limit(1);
return user ?? null;
}
async function getOrCreateSyncConfig(userId: number) {
const [existing] = await db.select().from(sync_configs).where(eq(sync_configs.user_id, userId)).limit(1);
if (existing) return existing;
await db.insert(sync_configs).values({ user_id: userId, is_enabled: false });
const [created] = await db.select().from(sync_configs).where(eq(sync_configs.user_id, userId)).limit(1);
return created!;
}
syncRouter.get("/sync", async (c) => {
const user = await requireUser(c);
if (!user) return c.redirect("/login", 303);
const [evotor] = await db.select().from(evotor_connections).where(eq(evotor_connections.user_id, user.id)).limit(1);
const [vk] = await db.select().from(vk_connections).where(eq(vk_connections.user_id, user.id)).limit(1);
const config = await getOrCreateSyncConfig(user.id);
const filters = await db.select().from(sync_filters).where(eq(sync_filters.sync_config_id, config.id));
const summary = {
stores: filters.filter((f) => f.entity_type === "store").length,
groups: filters.filter((f) => f.entity_type === "group").length,
products: filters.filter((f) => f.entity_type === "product").length,
total: filters.length,
};
let status: string;
if (config.confirmed_at && config.is_enabled) {
status = "active";
} else if (config.confirmed_at && !config.is_enabled) {
status = "paused";
} else if (summary.total > 0) {
status = "pending";
} else {
status = "unconfigured";
}
return c.html(render("sync.njk", {
user,
evotor: evotor ?? null,
vk: vk ?? null,
config,
summary,
status,
}));
});
syncRouter.post("/sync/toggle", async (c) => {
const user = await requireUser(c);
if (!user) return c.redirect("/login", 303);
const config = await getOrCreateSyncConfig(user.id);
await db.update(sync_configs)
.set({ is_enabled: !config.is_enabled })
.where(eq(sync_configs.id, config.id));
return c.redirect("/sync", 303);
});
syncRouter.post("/sync/confirm", async (c) => {
const user = await requireUser(c);
if (!user) return c.redirect("/login", 303);
const config = await getOrCreateSyncConfig(user.id);
const filters = await db.select().from(sync_filters).where(eq(sync_filters.sync_config_id, config.id));
if (config.is_enabled && filters.length > 0) {
await db.update(sync_configs)
.set({ confirmed_at: new Date() })
.where(eq(sync_configs.id, config.id));
}
return c.redirect("/sync", 303);
});

120
web/src/routes/vk.ts Normal file
View File

@@ -0,0 +1,120 @@
import { Hono } from "hono";
import { db } from "../db/client.js";
import { users, vk_connections } from "../db/schema.js";
import { eq } from "drizzle-orm";
import { getSessionUserId, type AppEnv } from "../lib/auth.js";
import { render } from "../lib/render.js";
import { config } from "../config.js";
export const vkRouter = new Hono<AppEnv>();
const VK_API_URL = "https://api.vk.com/method";
const VK_OAUTH_URL = "https://oauth.vk.com/authorize";
async function requireUser(c: Parameters<typeof getSessionUserId>[0]) {
const id = getSessionUserId(c);
if (!id) return null;
const [user] = await db.select().from(users).where(eq(users.id, id)).limit(1);
return user ?? null;
}
async function fetchGroupInfo(token: string): Promise<{ groupId: string | null; groupName: string | null }> {
try {
const params = new URLSearchParams({
access_token: token,
v: config.VK_API_VERSION,
filter: "admin",
extended: "1",
count: "1",
});
const resp = await fetch(`${VK_API_URL}/groups.get?${params}`, {
signal: AbortSignal.timeout(15000),
});
if (!resp.ok) return { groupId: null, groupName: null };
const data = await resp.json() as { error?: unknown; response?: { items?: Array<{ id: number; name: string }> } };
if (data.error) return { groupId: null, groupName: null };
const items = data.response?.items ?? [];
if (items.length === 0) return { groupId: null, groupName: null };
return { groupId: String(items[0].id), groupName: items[0].name };
} catch {
return { groupId: null, groupName: null };
}
}
vkRouter.get("/vk", async (c) => {
const user = await requireUser(c);
if (!user) return c.redirect("/login", 303);
const [connection] = await db.select().from(vk_connections).where(eq(vk_connections.user_id, user.id)).limit(1);
const error = c.req.query("error") ?? null;
return c.html(render("vk.njk", {
user,
connection: connection ?? null,
error,
vk_client_id: config.VK_CLIENT_ID,
callback_url: `${config.BASE_URL}/vk/callback`,
}));
});
vkRouter.get("/vk/connect", async (c) => {
const user = await requireUser(c);
if (!user) return c.redirect("/login", 303);
if (!config.VK_CLIENT_ID) return c.redirect("/vk?error=no_client_id", 303);
const params = new URLSearchParams({
client_id: config.VK_CLIENT_ID,
scope: "market,groups",
redirect_uri: `${config.BASE_URL}/vk/callback`,
display: "page",
response_type: "token",
v: config.VK_API_VERSION,
});
return c.redirect(`${VK_OAUTH_URL}?${params}`, 302);
});
vkRouter.get("/vk/callback", async (c) => {
const user = await requireUser(c);
if (!user) return c.redirect("/login", 303);
return c.html(render("vk_callback.njk", { user }));
});
vkRouter.post("/vk/token", async (c) => {
const user = await requireUser(c);
if (!user) return c.redirect("/login", 303);
const form = await c.req.formData();
const token = String(form.get("token") ?? "").trim();
if (!token) return c.redirect("/vk?error=empty_token", 303);
const { groupId, groupName } = await fetchGroupInfo(token);
if (!groupId) return c.redirect("/vk?error=invalid_token", 303);
const now = new Date();
const [existing] = await db.select().from(vk_connections).where(eq(vk_connections.user_id, user.id)).limit(1);
if (existing) {
await db.update(vk_connections)
.set({ access_token: token, vk_user_id: groupId, first_name: groupName, last_name: null, is_online: true, last_checked_at: now, updated_at: now })
.where(eq(vk_connections.id, existing.id));
} else {
await db.insert(vk_connections).values({
user_id: user.id,
access_token: token,
vk_user_id: groupId,
first_name: groupName,
last_name: null,
is_online: true,
last_checked_at: now,
});
}
return c.redirect("/connections", 303);
});
vkRouter.post("/vk/disconnect", async (c) => {
const user = await requireUser(c);
if (!user) return c.redirect("/login", 303);
await db.delete(vk_connections).where(eq(vk_connections.user_id, user.id));
return c.redirect("/connections", 303);
});

View File

@@ -0,0 +1,99 @@
<!DOCTYPE html>
<html lang="ru" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}ЭВОСИНК{% endblock %}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<header class="site-header">
<nav class="container">
<ul>
<li><a href="/" class="brand-logo">ЭВОСИНК</a></li>
</ul>
<ul class="nav-links">
{% if user %}
<li><a href="/connections">Подключения</a></li>
<li><a href="/catalog">Каталог</a></li>
<li><a href="/sync">Синхронизация</a></li>
<li><a href="/profile"><i class="bi bi-person-circle"></i> Личный кабинет</a></li>
<li><a href="/logout" class="secondary">Выход</a></li>
{% else %}
<li><a href="/login">Вход</a></li>
<li><a href="/register">Регистрация</a></li>
{% endif %}
</ul>
{% if user %}
<details class="mobile-menu">
<summary role="button" class="outline secondary icon-btn"><i class="bi bi-list"></i></summary>
<ul>
<li><a href="/connections">Подключения</a></li>
<li><a href="/catalog">Каталог</a></li>
<li><a href="/sync">Синхронизация</a></li>
<li><a href="/profile">Личный кабинет</a></li>
<li><a href="/logout">Выход</a></li>
</ul>
</details>
{% else %}
<details class="mobile-menu">
<summary role="button" class="outline secondary icon-btn"><i class="bi bi-list"></i></summary>
<ul>
<li><a href="/login">Вход</a></li>
<li><a href="/register">Регистрация</a></li>
</ul>
</details>
{% endif %}
</nav>
</header>
<main class="container py-4">
{% if errors %}
<div role="alert" class="alert alert-danger">
{% for error in errors %}
<p>{{ error }}</p>
{% endfor %}
</div>
{% endif %}
{% if success %}
<div role="alert" class="alert alert-success">
<p>{{ success }}</p>
</div>
{% endif %}
{% block content %}{% endblock %}
</main>
{% if jivosite_widget_id %}
<script src="//code.jivosite.com/widget/{{ jivosite_widget_id }}" async></script>
{% endif %}
<script src="https://cdn.jsdelivr.net/npm/inputmask@5.0.9/dist/inputmask.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
var phoneInputs = document.querySelectorAll('input[name="phone"]');
if (phoneInputs.length) {
Inputmask('+7 (999) 999-99-99', {
placeholder: '_',
showMaskOnHover: false,
clearMaskOnLostFocus: false
}).mask(phoneInputs);
}
});
</script>
<script>
document.addEventListener('invalid', function(e) {
if (e.target.validity.valueMissing) {
e.target.setCustomValidity('Пожалуйста, заполните это поле');
} else if (e.target.validity.typeMismatch) {
e.target.setCustomValidity('Пожалуйста, введите корректное значение');
}
}, true);
document.addEventListener('input', function(e) {
if (e.target.required) e.target.setCustomValidity('');
}, true);
</script>
</body>
</html>

View File

@@ -0,0 +1,119 @@
{% extends "base.njk" %}
{% block title %}Группы — {{ store.name }} — ЭВОСИНК{% endblock %}
{% block content %}
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/catalog">Каталог</a></li>
<li class="breadcrumb-item active">{{ store.name }}</li>
</ol>
<div class="d-flex align-center justify-between mb-3">
<h1 style="font-size:1.3rem; margin:0;">Группы товаров</h1>
<a href="/catalog/export?type=groups&store_id={{ store.evotor_id }}" role="button" class="outline secondary sm">
<i class="bi bi-download me-1"></i>Экспорт CSV
</a>
</div>
{% if not groups %}
<div class="empty-state">
<i class="bi bi-folder empty-icon"></i>
<p>Группы не найдены в этом магазине.</p>
<a href="/catalog/products?store_id={{ store.evotor_id }}" role="button" class="outline sm">
Посмотреть все товары магазина
</a>
</div>
{% else %}
<div class="table-scroll">
<table class="align-middle">
<thead>
<tr>
<th>Название</th>
<th>Кол-во товаров</th>
<th>Фильтр</th>
<th></th>
</tr>
</thead>
<tbody>
{% for group in groups %}
{% set mode = filter_map[group.evotor_id] %}
<tr>
<td>{{ group.name }}</td>
<td class="text-muted">{{ product_counts[group.evotor_id] or 0 }}</td>
<td>
{% if mode == "include" %}
<span class="badge badge-success">✓ Включено</span>
{% elif mode == "exclude" %}
<span class="badge badge-danger">✗ Исключено</span>
{% else %}
<span class="badge badge-light">— Нет правила</span>
{% endif %}
</td>
<td>
<div class="d-flex gap-1 justify-end">
<a href="/catalog/products?store_id={{ store.evotor_id }}&group_id={{ group.evotor_id }}"
role="button" class="outline secondary sm" title="Товары">
<i class="bi bi-arrow-right"></i>
</a>
<div class="dropdown" data-dropdown>
<button type="button" class="outline secondary sm" data-dropdown-toggle>
<i class="bi bi-funnel"></i>
</button>
<ul class="dropdown-menu">
<li>
<form method="post" action="/catalog/filter">
<input type="hidden" name="entity_type" value="group">
<input type="hidden" name="entity_id" value="{{ group.evotor_id }}">
<input type="hidden" name="entity_name" value="{{ group.name }}">
<input type="hidden" name="filter_mode" value="include">
<input type="hidden" name="parent_entity_id" value="{{ store.evotor_id }}">
<input type="hidden" name="redirect_to" value="/catalog/groups?store_id={{ store.evotor_id }}">
<button type="submit" class="dropdown-item">✓ Включить в синхронизацию</button>
</form>
</li>
<li>
<form method="post" action="/catalog/filter">
<input type="hidden" name="entity_type" value="group">
<input type="hidden" name="entity_id" value="{{ group.evotor_id }}">
<input type="hidden" name="entity_name" value="{{ group.name }}">
<input type="hidden" name="filter_mode" value="exclude">
<input type="hidden" name="parent_entity_id" value="{{ store.evotor_id }}">
<input type="hidden" name="redirect_to" value="/catalog/groups?store_id={{ store.evotor_id }}">
<button type="submit" class="dropdown-item">✗ Исключить из синхронизации</button>
</form>
</li>
<li><hr class="dropdown-divider"></li>
<li>
<form method="post" action="/catalog/filter">
<input type="hidden" name="entity_type" value="group">
<input type="hidden" name="entity_id" value="{{ group.evotor_id }}">
<input type="hidden" name="entity_name" value="{{ group.name }}">
<input type="hidden" name="filter_mode" value="none">
<input type="hidden" name="parent_entity_id" value="{{ store.evotor_id }}">
<input type="hidden" name="redirect_to" value="/catalog/groups?store_id={{ store.evotor_id }}">
<button type="submit" class="dropdown-item muted">— Убрать правило</button>
</form>
</li>
</ul>
</div>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
<script>
document.querySelectorAll('[data-dropdown]').forEach(function(dd) {
dd.querySelector('[data-dropdown-toggle]').addEventListener('click', function(e) {
e.stopPropagation();
dd.classList.toggle('open');
});
});
document.addEventListener('click', function() {
document.querySelectorAll('[data-dropdown].open').forEach(function(dd) { dd.classList.remove('open'); });
});
</script>
{% endblock %}

View File

@@ -0,0 +1,144 @@
{% extends "base.njk" %}
{% block title %}Товары — ЭВОСИНК{% endblock %}
{% block content %}
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/catalog">Каталог</a></li>
<li class="breadcrumb-item"><a href="/catalog/groups?store_id={{ store.evotor_id }}">{{ store.name }}</a></li>
{% if group %}
<li class="breadcrumb-item active">{{ group.name }}</li>
{% else %}
<li class="breadcrumb-item active">Все товары</li>
{% endif %}
</ol>
<div class="d-flex align-center justify-between mb-3">
<h1 style="font-size:1.3rem; margin:0;">Товары{% if group %}: {{ group.name }}{% endif %}</h1>
<a href="/catalog/export?type=products&store_id={{ store.evotor_id }}{% if group %}&group_id={{ group.evotor_id }}{% endif %}"
role="button" class="outline secondary sm">
<i class="bi bi-download me-1"></i>Экспорт CSV
</a>
</div>
{% set redirect_back %}/catalog/products?store_id={{ store.evotor_id }}{% if group %}&group_id={{ group.evotor_id }}{% endif %}{% endset %}
{% if not products %}
<div class="empty-state">
<i class="bi bi-box empty-icon"></i>
<p>Товары не найдены.</p>
</div>
{% else %}
<div class="table-scroll">
<table class="align-middle small">
<thead>
<tr>
<th>Название</th>
<th>Артикул</th>
<th>Цена</th>
<th>Кол-во</th>
<th>Ед. изм.</th>
<th>В продаже</th>
<th>Синхронизирован</th>
<th>Фильтр</th>
<th></th>
</tr>
</thead>
<tbody>
{% for product in products %}
{% set mode = filter_map[product.evotor_id] %}
<tr>
<td>{{ product.name }}</td>
<td class="text-muted font-monospace">{{ product.article_number or "—" }}</td>
<td>{% if product.price %}{{ product.price | price }} ₽{% else %}—{% endif %}</td>
<td>{% if product.quantity != null %}{{ product.quantity }}{% else %}—{% endif %}</td>
<td class="text-muted">{{ product.measure_name or "—" }}</td>
<td>
{% if product.allow_to_sell == null %}
<span class="text-muted">—</span>
{% elif product.allow_to_sell %}
<i class="bi bi-check-circle-fill text-success"></i>
{% else %}
<i class="bi bi-x-circle-fill text-danger"></i>
{% endif %}
</td>
<td>
{% if product.synced_at %}
<span title="{{ product.synced_at | datefmt }}">
<i class="bi bi-check-circle-fill text-success"></i>
</span>
{% else %}
<span class="text-muted">—</span>
{% endif %}
</td>
<td>
{% if mode == "include" %}
<span class="badge badge-success">✓ Включено</span>
{% elif mode == "exclude" %}
<span class="badge badge-danger">✗ Исключено</span>
{% else %}
<span class="badge badge-light">— Нет правила</span>
{% endif %}
</td>
<td>
<div class="dropdown" data-dropdown>
<button type="button" class="outline secondary sm" data-dropdown-toggle>
<i class="bi bi-funnel"></i>
</button>
<ul class="dropdown-menu">
<li>
<form method="post" action="/catalog/filter">
<input type="hidden" name="entity_type" value="product">
<input type="hidden" name="entity_id" value="{{ product.evotor_id }}">
<input type="hidden" name="entity_name" value="{{ product.name }}">
<input type="hidden" name="filter_mode" value="include">
<input type="hidden" name="parent_entity_id" value="{{ product.group_evotor_id or '' }}">
<input type="hidden" name="redirect_to" value="{{ redirect_back }}">
<button type="submit" class="dropdown-item">✓ Включить в синхронизацию</button>
</form>
</li>
<li>
<form method="post" action="/catalog/filter">
<input type="hidden" name="entity_type" value="product">
<input type="hidden" name="entity_id" value="{{ product.evotor_id }}">
<input type="hidden" name="entity_name" value="{{ product.name }}">
<input type="hidden" name="filter_mode" value="exclude">
<input type="hidden" name="parent_entity_id" value="{{ product.group_evotor_id or '' }}">
<input type="hidden" name="redirect_to" value="{{ redirect_back }}">
<button type="submit" class="dropdown-item">✗ Исключить из синхронизации</button>
</form>
</li>
<li><hr class="dropdown-divider"></li>
<li>
<form method="post" action="/catalog/filter">
<input type="hidden" name="entity_type" value="product">
<input type="hidden" name="entity_id" value="{{ product.evotor_id }}">
<input type="hidden" name="entity_name" value="{{ product.name }}">
<input type="hidden" name="filter_mode" value="none">
<input type="hidden" name="parent_entity_id" value="{{ product.group_evotor_id or '' }}">
<input type="hidden" name="redirect_to" value="{{ redirect_back }}">
<button type="submit" class="dropdown-item muted">— Убрать правило</button>
</form>
</li>
</ul>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
<script>
document.querySelectorAll('[data-dropdown]').forEach(function(dd) {
dd.querySelector('[data-dropdown-toggle]').addEventListener('click', function(e) {
e.stopPropagation();
dd.classList.toggle('open');
});
});
document.addEventListener('click', function() {
document.querySelectorAll('[data-dropdown].open').forEach(function(dd) { dd.classList.remove('open'); });
});
</script>
{% endblock %}

View File

@@ -0,0 +1,127 @@
{% extends "base.njk" %}
{% block title %}Каталог — ЭВОСИНК{% endblock %}
{% block content %}
<div class="d-flex align-center justify-between mb-3">
<h1 style="font-size:1.3rem; margin:0;">Каталог</h1>
<div class="d-flex gap-2">
{% if evotor %}
<form method="post" action="/catalog/refresh">
<button type="submit" class="outline secondary sm">
<i class="bi bi-arrow-clockwise me-1"></i>Обновить
</button>
</form>
<a href="/catalog/export?type=stores" role="button" class="outline secondary sm">
<i class="bi bi-download me-1"></i>Экспорт CSV
</a>
{% endif %}
</div>
</div>
{% if not evotor %}
<div role="alert" class="alert alert-warning d-flex align-center gap-2">
<i class="bi bi-exclamation-triangle-fill"></i>
<span>Эвотор не подключён. <a href="/evotor">Подключить Эвотор</a></span>
</div>
{% elif not stores %}
<div class="empty-state">
<i class="bi bi-shop empty-icon"></i>
<p>Магазины не найдены в вашем аккаунте Эвотор.</p>
</div>
{% else %}
{% if fetched_at %}
<p class="text-muted small mb-3">Последнее обновление: {{ fetched_at | datefmt }}</p>
{% endif %}
<div class="table-scroll">
<table class="align-middle">
<thead>
<tr>
<th>Название</th>
<th>Адрес</th>
<th>Фильтр</th>
<th></th>
</tr>
</thead>
<tbody>
{% for store in stores %}
{% set mode = filter_map[store.evotor_id] %}
<tr>
<td>{{ store.name }}</td>
<td class="text-muted small">{{ store.address or "—" }}</td>
<td>
{% if mode == "include" %}
<span class="badge badge-success">✓ Включено</span>
{% elif mode == "exclude" %}
<span class="badge badge-danger">✗ Исключено</span>
{% else %}
<span class="badge badge-light">— Нет правила</span>
{% endif %}
</td>
<td>
<div class="d-flex gap-1 justify-end">
<a href="/catalog/groups?store_id={{ store.evotor_id }}"
role="button" class="outline secondary sm" title="Группы">
<i class="bi bi-arrow-right"></i>
</a>
<div class="dropdown" data-dropdown>
<button type="button" class="outline secondary sm" data-dropdown-toggle>
<i class="bi bi-funnel"></i>
</button>
<ul class="dropdown-menu">
<li>
<form method="post" action="/catalog/filter">
<input type="hidden" name="entity_type" value="store">
<input type="hidden" name="entity_id" value="{{ store.evotor_id }}">
<input type="hidden" name="entity_name" value="{{ store.name }}">
<input type="hidden" name="filter_mode" value="include">
<input type="hidden" name="redirect_to" value="/catalog">
<button type="submit" class="dropdown-item">✓ Включить в синхронизацию</button>
</form>
</li>
<li>
<form method="post" action="/catalog/filter">
<input type="hidden" name="entity_type" value="store">
<input type="hidden" name="entity_id" value="{{ store.evotor_id }}">
<input type="hidden" name="entity_name" value="{{ store.name }}">
<input type="hidden" name="filter_mode" value="exclude">
<input type="hidden" name="redirect_to" value="/catalog">
<button type="submit" class="dropdown-item">✗ Исключить из синхронизации</button>
</form>
</li>
<li><hr class="dropdown-divider"></li>
<li>
<form method="post" action="/catalog/filter">
<input type="hidden" name="entity_type" value="store">
<input type="hidden" name="entity_id" value="{{ store.evotor_id }}">
<input type="hidden" name="entity_name" value="{{ store.name }}">
<input type="hidden" name="filter_mode" value="none">
<input type="hidden" name="redirect_to" value="/catalog">
<button type="submit" class="dropdown-item muted">— Убрать правило</button>
</form>
</li>
</ul>
</div>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
<script>
document.querySelectorAll('[data-dropdown]').forEach(function(dd) {
dd.querySelector('[data-dropdown-toggle]').addEventListener('click', function(e) {
e.stopPropagation();
dd.classList.toggle('open');
});
});
document.addEventListener('click', function() {
document.querySelectorAll('[data-dropdown].open').forEach(function(dd) { dd.classList.remove('open'); });
});
</script>
{% endblock %}

View File

@@ -0,0 +1,16 @@
{% extends "base.njk" %}
{% block title %}Подтверждение email — ЭВОСИНК{% endblock %}
{% block content %}
<div class="row justify-center">
<div class="col-sm-10 col-md-6 col-lg-5">
<article class="card mt-5 text-center">
<div class="card-body" style="padding: 2.5rem;">
<i class="bi bi-envelope-check fs-1 text-primary mb-3 d-block"></i>
<h1 style="font-size:1.3rem;" class="mb-3">Подтвердите ваш email</h1>
<p class="text-muted">Проверьте почту и нажмите на ссылку для подтверждения.</p>
</div>
</article>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,63 @@
{% extends "base.njk" %}
{% block title %}Подключения — ЭВОСИНК{% endblock %}
{% block content %}
<div class="d-flex align-center justify-between mb-4">
<h1 style="font-size:1.3rem; margin:0;">Подключения</h1>
<a href="/connections/add" role="button" class="sm">
<i class="bi bi-plus-lg me-1"></i>Добавить
</a>
</div>
{% if connections %}
<div class="row gap-3">
{% for conn in connections %}
<div class="col-sm-6 col-lg-4">
<article class="card h-100">
<div class="card-body d-flex flex-col" style="gap: 0.75rem;">
<div class="d-flex align-center" style="gap: 0.75rem;">
<i class="bi {{ conn.icon }} fs-2 text-secondary"></i>
<div class="flex-1">
<strong>{{ conn.name }}</strong>
{% if conn.details %}
<br><small class="text-muted">{{ conn.details }}</small>
{% endif %}
</div>
{% if conn.is_online %}
<i class="bi bi-circle-fill text-success fs-5" title="Онлайн"></i>
{% else %}
<i class="bi bi-circle-fill text-danger fs-5" title="Офлайн"></i>
{% endif %}
</div>
<div class="d-flex gap-2">
<a href="{{ conn.configure_url }}" role="button" class="outline sm flex-fill">Настроить</a>
<form method="post" action="/connections/delete?type={{ conn.type }}">
<button type="submit" class="outline danger sm"
onclick="return confirm('Вы уверены, что хотите отключить {{ conn.name }}?')">
Отключить
</button>
</form>
</div>
</div>
<footer class="text-muted small">
{% if conn.last_checked_at %}
Проверено: {{ conn.last_checked_at | datefmt }}
{% else %}
Статус ещё не проверялся
{% endif %}
</footer>
</article>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<i class="bi bi-plug empty-icon"></i>
<p class="mb-3">Нет подключённых сервисов</p>
<a href="/connections/add" role="button">
<i class="bi bi-plus-lg me-1"></i>Добавить подключение
</a>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,39 @@
{% extends "base.njk" %}
{% block title %}Добавить подключение — ЭВОСИНК{% endblock %}
{% block content %}
<div class="d-flex align-center mb-4" style="gap: 0.75rem;">
<a href="/connections" class="text-muted"><i class="bi bi-arrow-left fs-5"></i></a>
<h1 style="font-size:1.3rem; margin:0;">Добавить подключение</h1>
</div>
{% if available %}
<div class="row gap-3">
{% for svc in available %}
<div class="col-sm-6 col-lg-4">
<article class="card h-100">
<div class="card-body d-flex flex-col" style="gap: 0.75rem;">
<div class="d-flex align-center" style="gap: 0.75rem;">
<i class="bi {{ svc.icon }} fs-2 text-secondary"></i>
<strong>{{ svc.name }}</strong>
</div>
<p class="text-muted small flex-1">{{ svc.description }}</p>
<a href="{{ svc.connect_url }}" role="button" class="sm" style="margin-top: auto;">
Подключить <i class="bi bi-arrow-right ms-auto"></i>
</a>
</div>
</article>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<i class="bi bi-check-circle empty-icon text-success"></i>
<p class="mb-3">Все доступные сервисы подключены</p>
<a href="/connections" role="button" class="outline secondary sm">
<i class="bi bi-arrow-left me-1"></i>Вернуться к подключениям
</a>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,17 @@
{% extends "base.njk" %}
{% block title %}Email подтвержден — ЭВОСИНК{% endblock %}
{% block content %}
<div class="row justify-center">
<div class="col-sm-10 col-md-6 col-lg-5">
<article class="card mt-5 text-center">
<div class="card-body" style="padding: 2.5rem;">
<i class="bi bi-check-circle fs-1 text-success mb-3 d-block"></i>
<h1 style="font-size:1.3rem;" class="mb-3">Email подтвержден!</h1>
<p class="text-muted">Ваш email успешно подтвержден. Теперь вы можете войти в систему.</p>
<a href="/login" role="button" class="mt-2">Войти</a>
</div>
</article>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,95 @@
{% extends "base.njk" %}
{% block title %}Подключение Эвотор — ЭВОСИНК{% endblock %}
{% block content %}
<div class="row justify-center">
<div class="col-sm-10 col-md-7 col-lg-6">
{% if error %}
<div role="alert" class="alert alert-danger mt-4">
{% if error == "invalid_token" %}
<i class="bi bi-exclamation-triangle me-2"></i>Токен недействителен. Проверьте правильность и попробуйте снова.
{% elif error == "empty_token" %}
<i class="bi bi-exclamation-triangle me-2"></i>Введите токен.
{% else %}
<i class="bi bi-exclamation-triangle me-2"></i>Произошла ошибка при подключении: {{ error }}
{% endif %}
</div>
{% endif %}
<article class="card mt-4">
<header>
<h1>Подключение Эвотор</h1>
</header>
{% if connection %}
<ul class="list-group">
<li class="list-group-item">
<span class="text-muted small">Статус</span>
<span class="badge badge-success"><i class="bi bi-check-circle me-1"></i>Подключено</span>
</li>
{% if connection.store_name %}
<li class="list-group-item">
<span class="text-muted small">Магазин</span>
<span>{{ connection.store_name }}</span>
</li>
{% endif %}
{% if connection.store_id %}
<li class="list-group-item">
<span class="text-muted small">ID магазина</span>
<span class="font-monospace small text-muted">{{ connection.store_id }}</span>
</li>
{% endif %}
<li class="list-group-item">
<span class="text-muted small">Подключено</span>
<span class="small">{{ connection.connected_at | datefmt }}</span>
</li>
</ul>
<footer>
<p class="text-muted small mb-2">Обновить токен (Личный кабинет Эвотор → <strong>Приложения → ЭвоСинк → Настройки</strong>):</p>
<form method="post" action="/evotor/token">
<div class="input-group">
<input type="text" name="token" class="font-monospace" placeholder="Новый токен" required>
<button type="submit" class="outline secondary">Обновить</button>
</div>
</form>
</footer>
<div class="card-body">
<form method="post" action="/evotor/disconnect">
<button type="submit" class="outline danger w-100">Отключить аккаунт Эвотор</button>
</form>
</div>
{% else %}
<div class="card-body">
<p class="text-muted mb-3">
Для подключения вам нужно установить приложение <strong>ЭвоСинк</strong> в личном кабинете Эвотор
и скопировать токен доступа из его настроек.
</p>
<ol class="text-muted small mb-4">
{% if app_url %}
<li class="mb-1">Откройте <a href="{{ app_url }}" target="_blank" rel="noopener">приложение ЭвоСинк в магазине Эвотор <i class="bi bi-box-arrow-up-right small"></i></a> и установите его.</li>
{% else %}
<li class="mb-1">Найдите приложение <strong>ЭвоСинк</strong> в магазине Эвотор и установите его.</li>
{% endif %}
<li class="mb-1">Перейдите в раздел <strong>Приложения → ЭвоСинк → Настройки</strong>.</li>
<li class="mb-1">Скопируйте токен доступа и вставьте его в поле ниже.</li>
</ol>
<form method="post" action="/evotor/token">
<label class="small text-muted">Токен доступа
<input type="text" name="token" class="font-monospace" placeholder="Вставьте токен Эвотор" required autofocus>
</label>
<button type="submit" class="w-100">Подключить</button>
</form>
</div>
{% endif %}
</article>
<div class="mt-3 text-center">
<a href="/connections" class="text-muted small">
<i class="bi bi-arrow-left me-1"></i>Вернуться к подключениям
</a>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,24 @@
{% extends "base.njk" %}
{% block title %}Забыли пароль — ЭВОСИНК{% endblock %}
{% block content %}
<div class="row justify-center">
<div class="col-sm-10 col-md-6 col-lg-5">
<article class="card mt-4">
<div class="card-body">
<h1 style="font-size:1.3rem;" class="mb-2">Забыли пароль?</h1>
<p class="text-muted small mb-4">Введите email, указанный при регистрации.</p>
<form method="post" action="/forgot-password">
<label for="email">Email
<input type="email" id="email" name="email" required>
</label>
<button type="submit" class="w-100">Отправить ссылку для сброса</button>
</form>
<div class="text-center small mt-3">
<a href="/login">Вернуться ко входу</a>
</div>
</div>
</article>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,27 @@
{% extends "base.njk" %}
{% block title %}Вход — ЭВОСИНК{% endblock %}
{% block content %}
<div class="row justify-center">
<div class="col-sm-10 col-md-6 col-lg-5">
<article class="card mt-4">
<div class="card-body">
<h1 class="mb-4" style="font-size:1.3rem;">Вход</h1>
<form method="post" action="/login">
<label for="email">Email
<input type="email" id="email" name="email" value="{{ form.email if form else '' }}" required>
</label>
<label for="password">Пароль
<input type="password" id="password" name="password" required>
</label>
<button type="submit" class="w-100">Войти</button>
</form>
<div class="text-center small mt-3">
<a href="/forgot-password">Забыли пароль?</a><br>
<a href="/register">Зарегистрироваться</a>
</div>
</div>
</article>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,18 @@
{% extends "base.njk" %}
{% block title %}{{ title }} — ЭВОСИНК{% endblock %}
{% block content %}
<div class="row justify-center">
<div class="col-sm-10 col-md-6 col-lg-5">
<article class="card mt-5 text-center">
<div class="card-body" style="padding: 2.5rem;">
<h1 style="font-size:1.3rem;" class="mb-3">{{ title }}</h1>
<p class="text-muted">{{ message }}</p>
{% if link %}
<a href="{{ link }}" role="button" class="mt-2">{{ link_text }}</a>
{% endif %}
</div>
</article>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,31 @@
{% extends "base.njk" %}
{% block title %}Изменить пароль — ЭВОСИНК{% endblock %}
{% block content %}
<div class="row justify-center">
<div class="col-sm-10 col-md-6 col-lg-5">
<article class="card mt-4">
<header>
<h1><i class="bi bi-key me-2"></i>Изменить пароль</h1>
</header>
<div class="card-body">
<form method="post" action="/profile/change-password">
<label for="current_password">Текущий пароль
<input type="password" id="current_password" name="current_password" required>
</label>
<label for="password">Новый пароль
<input type="password" id="password" name="password" required>
</label>
<label for="password_confirm">Подтвердить пароль
<input type="password" id="password_confirm" name="password_confirm" required>
</label>
<div class="d-flex gap-2">
<button type="submit">Изменить пароль</button>
<a href="/profile" role="button" class="outline secondary">Отмена</a>
</div>
</form>
</div>
</article>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,31 @@
{% extends "base.njk" %}
{% block title %}Удалить аккаунт — ЭВОСИНК{% endblock %}
{% block content %}
<div class="row justify-center">
<div class="col-sm-10 col-md-6 col-lg-5">
<article class="card mt-4" style="border-color: #dc2626;">
<header class="bg-danger-header">
<h1><i class="bi bi-trash me-2"></i>Удалить аккаунт</h1>
</header>
<div class="card-body">
<div role="alert" class="alert alert-warning mb-3">
<i class="bi bi-exclamation-triangle me-1"></i>
<strong>Внимание!</strong> Это действие необратимо. Все ваши данные будут удалены.
</div>
<form method="post" action="/profile/delete">
<label for="password">Введите пароль для подтверждения
<input type="password" id="password" name="password" required>
</label>
<div class="d-flex gap-2">
<button type="submit" class="danger">
<i class="bi bi-trash me-1"></i>Удалить мой аккаунт
</button>
<a href="/profile" role="button" class="outline secondary">Отмена</a>
</div>
</form>
</div>
</article>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,43 @@
{% extends "base.njk" %}
{% block title %}Редактировать профиль — ЭВОСИНК{% endblock %}
{% block content %}
<div class="row justify-center">
<div class="col-sm-10 col-md-7 col-lg-6">
<article class="card mt-4">
<header>
<h1><i class="bi bi-pencil me-2"></i>Редактировать профиль</h1>
</header>
<div class="card-body">
<form method="post" action="/profile/edit">
<div class="row gap-2 mb-2">
<div class="col">
<label for="first_name">Имя
<input type="text" id="first_name" name="first_name"
value="{{ form.first_name if form else user.first_name }}" required>
</label>
</div>
<div class="col">
<label for="last_name">Фамилия
<input type="text" id="last_name" name="last_name"
value="{{ form.last_name if form else user.last_name }}" required>
</label>
</div>
</div>
<label>Email
<input type="email" value="{{ user.email }}" disabled>
</label>
<label for="phone">Телефон
<input type="tel" id="phone" name="phone"
value="{{ form.phone if form else user.phone }}" required>
</label>
<div class="d-flex gap-2">
<button type="submit">Сохранить</button>
<a href="/profile" role="button" class="outline secondary">Отмена</a>
</div>
</form>
</div>
</article>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,46 @@
{% extends "base.njk" %}
{% block title %}Личный кабинет — ЭВОСИНК{% endblock %}
{% block content %}
<div class="row justify-center">
<div class="col-sm-10 col-md-7 col-lg-6">
<article class="card mt-4">
<header>
<h1><i class="bi bi-person-circle me-2"></i>Личный кабинет</h1>
</header>
<ul class="list-group">
<li class="list-group-item">
<span class="text-muted small">Имя</span>
<span>{{ user.first_name }}</span>
</li>
<li class="list-group-item">
<span class="text-muted small">Фамилия</span>
<span>{{ user.last_name }}</span>
</li>
<li class="list-group-item">
<span class="text-muted small">Email</span>
<span>{{ user.email }}</span>
</li>
<li class="list-group-item">
<span class="text-muted small">Телефон</span>
<span>{{ user.phone }}</span>
</li>
</ul>
<div class="card-body d-grid gap-2">
<a href="/profile/edit" role="button">
<i class="bi bi-pencil me-1"></i>Редактировать профиль
</a>
<a href="/profile/change-password" role="button" class="secondary">
<i class="bi bi-key me-1"></i>Изменить пароль
</a>
<a href="/logout" role="button" class="outline secondary">
<i class="bi bi-box-arrow-right me-1"></i>Выход
</a>
<a href="/profile/delete" role="button" class="outline danger sm mt-2">
<i class="bi bi-trash me-1"></i>Удалить аккаунт
</a>
</div>
</article>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,44 @@
{% extends "base.njk" %}
{% block title %}Регистрация — ЭВОСИНК{% endblock %}
{% block content %}
<div class="row justify-center">
<div class="col-sm-10 col-md-7 col-lg-6">
<article class="card mt-4">
<div class="card-body">
<h1 class="mb-4" style="font-size:1.3rem;">Регистрация</h1>
<form method="post" action="/register">
<div class="row gap-2 mb-2">
<div class="col">
<label for="first_name">Имя
<input type="text" id="first_name" name="first_name" value="{{ form.first_name if form else '' }}">
</label>
</div>
<div class="col">
<label for="last_name">Фамилия
<input type="text" id="last_name" name="last_name" value="{{ form.last_name if form else '' }}">
</label>
</div>
</div>
<label for="email">Email <span class="text-danger">*</span>
<input type="email" id="email" name="email" value="{{ form.email if form else '' }}" required>
</label>
<label for="phone">Телефон <span class="text-danger">*</span>
<input type="tel" id="phone" name="phone" value="{{ form.phone if form else '' }}" required>
</label>
<label for="password">Пароль <span class="text-danger">*</span>
<input type="password" id="password" name="password" required>
</label>
<label for="password_confirm">Подтверждение пароля <span class="text-danger">*</span>
<input type="password" id="password_confirm" name="password_confirm" required>
</label>
<button type="submit" class="w-100">Зарегистрироваться</button>
</form>
<div class="text-center small mt-3">
<a href="/login">Уже есть аккаунт? Войти</a>
</div>
</div>
</article>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,23 @@
{% extends "base.njk" %}
{% block title %}Новый пароль — ЭВОСИНК{% endblock %}
{% block content %}
<div class="row justify-center">
<div class="col-sm-10 col-md-6 col-lg-5">
<article class="card mt-4">
<div class="card-body">
<h1 style="font-size:1.3rem;" class="mb-4">Новый пароль</h1>
<form method="post" action="/reset-password?token={{ token }}">
<label for="password">Новый пароль
<input type="password" id="password" name="password" required>
</label>
<label for="password_confirm">Подтверждение пароля
<input type="password" id="password_confirm" name="password_confirm" required>
</label>
<button type="submit" class="w-100">Сменить пароль</button>
</form>
</div>
</article>
</div>
</div>
{% endblock %}

101
web/src/templates/sync.njk Normal file
View File

@@ -0,0 +1,101 @@
{% extends "base.njk" %}
{% block title %}Синхронизация — ЭВОСИНК{% endblock %}
{% block content %}
<h1 style="font-size:1.3rem;" class="mb-4">Синхронизация</h1>
{% if not evotor %}
<div role="alert" class="alert alert-warning d-flex align-center gap-2">
<i class="bi bi-exclamation-triangle-fill"></i>
<span>Эвотор не подключён. <a href="/evotor">Подключить Эвотор</a></span>
</div>
{% endif %}
{% if not vk %}
<div role="alert" class="alert alert-warning d-flex align-center gap-2">
<i class="bi bi-exclamation-triangle-fill"></i>
<span>ВКонтакте не подключён. <a href="/vk">Подключить ВКонтакте</a></span>
</div>
{% endif %}
<div class="row gap-3">
<div class="col-12 col-md-6-auto">
<article class="card h-100">
<div class="card-body">
<h5 class="mb-3">Статус</h5>
<div class="mb-3">
{% if status == "active" %}
<span class="badge badge-success fs-6"><i class="bi bi-play-fill me-1"></i>Активна</span>
{% elif status == "paused" %}
<span class="badge badge-secondary fs-6"><i class="bi bi-pause-fill me-1"></i>Приостановлена</span>
{% elif status == "pending" %}
<span class="badge badge-warning fs-6"><i class="bi bi-clock me-1"></i>Ожидает подтверждения</span>
{% else %}
<span class="badge badge-light fs-6"><i class="bi bi-gear me-1"></i>Не настроено</span>
{% endif %}
</div>
{% if config.confirmed_at %}
<p class="text-muted small mb-3">
Запущена: {{ config.confirmed_at | datefmt }}
</p>
{% endif %}
<div class="d-flex gap-2 flex-wrap">
<form method="post" action="/sync/toggle">
{% if config.is_enabled %}
<button type="submit" class="outline secondary sm">
<i class="bi bi-pause-fill me-1"></i>Приостановить
</button>
{% else %}
<button type="submit" class="outline sm" {% if not evotor or not vk %}disabled{% endif %}>
<i class="bi bi-play-fill me-1"></i>Включить
</button>
{% endif %}
</form>
{% if config.is_enabled and summary.total > 0 %}
<form method="post" action="/sync/confirm">
<button type="submit" class="sm" style="--pico-background-color: #15803d; --pico-border-color: #15803d;">
<i class="bi bi-check-lg me-1"></i>Подтвердить и запустить
</button>
</form>
{% endif %}
</div>
{% if config.is_enabled and summary.total == 0 %}
<p class="text-muted small mt-2 mb-0">
<i class="bi bi-info-circle me-1"></i>Настройте фильтры, чтобы подтвердить запуск.
</p>
{% endif %}
</div>
</article>
</div>
<div class="col-12 col-md-6-auto">
<article class="card h-100">
<div class="card-body">
<h5 class="mb-3">Фильтры</h5>
{% if summary.total > 0 %}
<ul style="list-style: none; padding: 0; margin-bottom: 0.75rem;">
{% if summary.stores > 0 %}
<li class="text-muted small"><i class="bi bi-shop me-1"></i>Магазины: {{ summary.stores }} правил</li>
{% endif %}
{% if summary.groups > 0 %}
<li class="text-muted small"><i class="bi bi-folder me-1"></i>Группы: {{ summary.groups }} правил</li>
{% endif %}
{% if summary.products > 0 %}
<li class="text-muted small"><i class="bi bi-box me-1"></i>Товары: {{ summary.products }} правил</li>
{% endif %}
</ul>
{% else %}
<p class="text-muted small mb-3">Фильтры не настроены — будут синхронизированы все товары.</p>
{% endif %}
<a href="/catalog" role="button" class="outline sm" {% if not evotor %}disabled{% endif %}>
<i class="bi bi-sliders me-1"></i>Настроить фильтры
</a>
</div>
</article>
</div>
</div>
{% endblock %}

104
web/src/templates/vk.njk Normal file
View File

@@ -0,0 +1,104 @@
{% extends "base.njk" %}
{% block title %}Подключение ВКонтакте — ЭВОСИНК{% endblock %}
{% block content %}
<div class="row justify-center">
<div class="col-sm-10 col-md-7 col-lg-6">
{% if error %}
<div role="alert" class="alert alert-danger mt-4">
{% if error == "invalid_token" %}
<i class="bi bi-exclamation-triangle me-2"></i>Токен недействителен или у него нет прав администратора сообщества.
{% elif error == "empty_token" %}
<i class="bi bi-exclamation-triangle me-2"></i>Введите токен.
{% elif error == "no_client_id" %}
<i class="bi bi-exclamation-triangle me-2"></i>Автоматическое подключение не настроено. Введите токен вручную.
{% else %}
<i class="bi bi-exclamation-triangle me-2"></i>Произошла ошибка при подключении: {{ error }}
{% endif %}
</div>
{% endif %}
<article class="card mt-4">
<header>
<h1>Подключение ВКонтакте</h1>
</header>
{% if connection %}
<ul class="list-group">
<li class="list-group-item">
<span class="text-muted small">Статус</span>
<span class="badge badge-success"><i class="bi bi-check-circle me-1"></i>Подключено</span>
</li>
{% if connection.first_name %}
<li class="list-group-item">
<span class="text-muted small">Сообщество</span>
<span>{{ connection.first_name }}</span>
</li>
{% endif %}
{% if connection.vk_user_id %}
<li class="list-group-item">
<span class="text-muted small">ID сообщества</span>
<span class="font-monospace small text-muted">{{ connection.vk_user_id }}</span>
</li>
{% endif %}
<li class="list-group-item">
<span class="text-muted small">Подключено</span>
<span class="small">{{ connection.connected_at | datefmt }}</span>
</li>
</ul>
<footer>
<p class="text-muted small mb-2">Обновить токен пользователя:</p>
<form method="post" action="/vk/token">
<div class="input-group">
<input type="text" name="token" class="font-monospace" placeholder="Новый токен пользователя" required>
<button type="submit" class="outline secondary">Обновить</button>
</div>
</form>
</footer>
<div class="card-body">
<form method="post" action="/vk/disconnect">
<button type="submit" class="outline danger w-100">Отключить ВКонтакте</button>
</form>
</div>
{% else %}
<div class="card-body">
{% if vk_client_id %}
<p class="text-muted mb-4">
Нажмите кнопку ниже, чтобы авторизоваться через ВКонтакте и выдать доступ к управлению товарами вашего сообщества.
</p>
<a href="/vk/connect" role="button" class="w-100 mb-3" style="display: block; text-align: center;">
<i class="bi bi-box-arrow-in-right me-2"></i>Подключить ВКонтакте
</a>
<hr>
<p class="text-muted small mb-2">Или введите токен вручную:</p>
{% else %}
<p class="text-muted mb-3">
Для синхронизации товаров необходим <strong>токен пользователя</strong> ВКонтакте
с правами на управление товарами сообщества.
</p>
{% endif %}
<form method="post" action="/vk/token">
<label class="small text-muted">Токен пользователя ВКонтакте
<input type="text" name="token" class="font-monospace" placeholder="Вставьте токен пользователя" required {% if not vk_client_id %}autofocus{% endif %}>
</label>
{% if vk_client_id %}
<button type="submit" class="w-100 outline secondary">Подключить</button>
{% else %}
<button type="submit" class="w-100">Подключить</button>
{% endif %}
</form>
</div>
{% endif %}
</article>
<div class="mt-3 text-center">
<a href="/connections" class="text-muted small">
<i class="bi bi-arrow-left me-1"></i>Вернуться к подключениям
</a>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,47 @@
{% extends "base.njk" %}
{% block title %}Подключение ВКонтакте — ЭВОСИНК{% endblock %}
{% block content %}
<div class="row justify-center">
<div class="col-sm-10 col-md-7 col-lg-6">
<article class="card mt-4 text-center">
<div class="card-body" style="padding: 2.5rem;">
<div id="state-loading">
<div class="spinner mb-3" style="margin: 0 auto;"></div>
<p class="text-muted mb-0">Подключение ВКонтакте…</p>
</div>
<div id="state-error" class="d-none">
<i class="bi bi-exclamation-triangle-fill text-danger fs-1 mb-3 d-block"></i>
<p class="text-muted mb-3" id="error-message">Не удалось получить токен от ВКонтакте.</p>
<a href="/vk" role="button" class="outline secondary">Попробовать снова</a>
</div>
</div>
</article>
</div>
</div>
<form id="token-form" method="post" action="/vk/token" class="d-none">
<input type="hidden" name="token" id="token-input">
</form>
<script>
(function () {
var hash = window.location.hash.slice(1);
var params = {};
hash.split("&").forEach(function (part) {
var kv = part.split("=");
params[decodeURIComponent(kv[0])] = decodeURIComponent(kv[1] || "");
});
if (params.access_token) {
document.getElementById("token-input").value = params.access_token;
document.getElementById("token-form").submit();
} else {
var msg = params.error_description || params.error || "Авторизация отклонена.";
document.getElementById("error-message").textContent = msg;
document.getElementById("state-loading").classList.add("d-none");
document.getElementById("state-error").classList.remove("d-none");
}
})();
</script>
{% endblock %}

View File

@@ -1,39 +1,431 @@
/* Brand overrides */
/* Brand colors */
:root {
--bs-primary: #F05023;
--bs-primary-rgb: 240, 80, 35;
--bs-link-color: #0986E2;
--bs-link-hover-color: #0670c0;
--pico-primary: #F05023;
--pico-primary-hover: #d44420;
--pico-primary-focus: rgba(240, 80, 35, 0.25);
--pico-primary-inverse: #fff;
--brand-primary: #F05023;
--brand-secondary: #0986E2;
--brand-secondary-hover: #0770c0;
}
/* Header / nav */
.site-header {
background: #fff;
border-bottom: 2px solid var(--brand-primary);
padding: 0;
margin-bottom: 0;
}
.site-header nav {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: 0.75rem;
padding-bottom: 0.75rem;
gap: 1rem;
}
.site-header nav > ul {
margin: 0;
padding: 0;
list-style: none;
display: flex;
align-items: center;
gap: 0.25rem;
}
.brand-logo {
font-size: 22px;
font-size: 1.3rem;
font-weight: 700;
color: #F05023 !important;
color: var(--brand-primary) !important;
text-decoration: none;
}
.brand-border {
border-color: #F05023 !important;
.nav-links {
flex: 1;
justify-content: flex-end;
}
.btn-primary {
--bs-btn-bg: #F05023;
--bs-btn-border-color: #F05023;
--bs-btn-hover-bg: #d44420;
--bs-btn-hover-border-color: #d44420;
--bs-btn-active-bg: #c03d1c;
--bs-btn-active-border-color: #c03d1c;
.nav-links a {
color: var(--pico-color);
text-decoration: none;
padding: 0.25rem 0.5rem;
border-radius: 4px;
}
.btn-secondary {
--bs-btn-bg: #0986E2;
--bs-btn-border-color: #0986E2;
--bs-btn-hover-bg: #0770c0;
--bs-btn-hover-border-color: #0770c0;
--bs-btn-active-bg: #065fa3;
--bs-btn-active-border-color: #065fa3;
.nav-links a:hover {
color: var(--brand-primary);
}
.nav-link:hover {
color: #F05023 !important;
.nav-links a.secondary {
color: var(--pico-muted-color);
}
.mobile-menu {
display: none;
}
.mobile-menu summary {
padding: 0.25rem 0.5rem;
font-size: 1.25rem;
}
.mobile-menu > ul {
position: absolute;
right: 1rem;
background: var(--pico-background-color);
border: 1px solid var(--pico-border-color);
border-radius: var(--pico-border-radius);
padding: 0.5rem 0;
list-style: none;
margin: 0;
z-index: 100;
min-width: 180px;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.mobile-menu > ul li a {
display: block;
padding: 0.5rem 1rem;
text-decoration: none;
color: var(--pico-color);
}
.mobile-menu > ul li a:hover {
background: var(--pico-muted-background-color);
}
@media (max-width: 768px) {
.nav-links { display: none; }
.mobile-menu { display: block; }
}
/* Page spacing */
.py-4 {
padding-top: 1.5rem;
padding-bottom: 1.5rem;
}
/* Alerts */
.alert {
border-radius: var(--pico-border-radius);
padding: 0.75rem 1rem;
margin-bottom: 1rem;
}
.alert p { margin: 0; }
.alert p + p { margin-top: 0.25rem; }
.alert-danger {
background: #fef2f2;
border: 1px solid #fecaca;
color: #b91c1c;
}
.alert-success {
background: #f0fdf4;
border: 1px solid #bbf7d0;
color: #15803d;
}
.alert-warning {
background: #fffbeb;
border: 1px solid #fde68a;
color: #b45309;
}
/* Cards (using <article>) */
article.card {
margin: 0;
padding: 0;
overflow: hidden;
}
article.card > header {
padding: 0.75rem 1rem;
background: var(--pico-muted-background-color);
border-bottom: 1px solid var(--pico-border-color);
margin: 0;
}
article.card > header h1,
article.card > header h2,
article.card > header h5 {
margin: 0;
font-size: 1rem;
font-weight: 600;
}
article.card > .card-body {
padding: 1.25rem;
}
article.card > footer {
padding: 0.75rem 1rem;
background: var(--pico-muted-background-color);
border-top: 1px solid var(--pico-border-color);
margin: 0;
}
/* List groups */
.list-group {
list-style: none;
padding: 0;
margin: 0;
}
.list-group-item {
padding: 0.6rem 1rem;
border-bottom: 1px solid var(--pico-border-color);
display: flex;
justify-content: space-between;
align-items: center;
}
.list-group-item:last-child { border-bottom: none; }
/* Badges */
.badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.2rem 0.5rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
line-height: 1;
}
.badge-success { background: #dcfce7; color: #15803d; }
.badge-danger { background: #fee2e2; color: #b91c1c; }
.badge-warning { background: #fef3c7; color: #b45309; }
.badge-secondary { background: var(--pico-muted-background-color); color: var(--pico-muted-color); }
.badge-light { background: var(--pico-muted-background-color); color: var(--pico-muted-color); border: 1px solid var(--pico-border-color); }
/* Buttons */
button.secondary, a[role="button"].secondary {
--pico-background-color: var(--brand-secondary);
--pico-border-color: var(--brand-secondary);
--pico-color: #fff;
}
button.outline.danger, a[role="button"].outline.danger {
--pico-color: #dc2626;
--pico-border-color: #dc2626;
}
button.danger, a[role="button"].danger {
--pico-background-color: #dc2626;
--pico-border-color: #dc2626;
--pico-color: #fff;
}
button.sm, a[role="button"].sm {
padding: 0.25rem 0.6rem;
font-size: 0.875rem;
}
/* Layout helpers */
.row {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
.col { flex: 1 1 0; }
.col-auto { flex: 0 0 auto; }
.col-sm-6 { flex: 0 0 calc(50% - 0.5rem); min-width: 200px; }
.col-sm-10 { flex: 0 0 calc(83.33% - 0.5rem); }
.col-md-6 { flex: 0 0 calc(50% - 0.5rem); }
.col-md-7 { flex: 0 0 calc(58.33% - 0.5rem); }
.col-lg-4 { flex: 0 0 calc(33.33% - 0.67rem); }
.col-lg-5 { flex: 0 0 calc(41.67% - 0.5rem); }
.col-lg-6 { flex: 0 0 calc(50% - 0.5rem); }
.col-12 { flex: 0 0 100%; }
.col-md-6-auto { flex: 0 0 calc(50% - 0.5rem); }
@media (max-width: 768px) {
.col-sm-6, .col-md-6, .col-md-7, .col-lg-4, .col-lg-5, .col-lg-6, .col-md-6-auto { flex: 0 0 100%; }
}
.justify-center { justify-content: center; }
.justify-between { justify-content: space-between; }
.justify-end { justify-content: flex-end; }
.align-center { align-items: center; }
.flex-wrap { flex-wrap: wrap; }
.flex-col { flex-direction: column; }
.flex-1 { flex: 1; }
.flex-fill { flex: 1 1 0; }
.gap-1 { gap: 0.25rem; }
.gap-2 { gap: 0.5rem; }
.gap-3 { gap: 0.75rem; }
.mt-2 { margin-top: 0.5rem; }
.mt-3 { margin-top: 0.75rem; }
.mt-4 { margin-top: 1.5rem; }
.mt-5 { margin-top: 3rem; }
.mb-0 { margin-bottom: 0; }
.mb-1 { margin-bottom: 0.25rem; }
.mb-2 { margin-bottom: 0.5rem; }
.mb-3 { margin-bottom: 0.75rem; }
.mb-4 { margin-bottom: 1.5rem; }
.ms-auto { margin-left: auto; }
.me-1 { margin-right: 0.25rem; }
.me-2 { margin-right: 0.5rem; }
.me-3 { margin-right: 0.75rem; }
.d-flex { display: flex; }
.d-grid { display: grid; }
.d-none { display: none; }
.d-block { display: block; }
.text-center { text-align: center; }
.text-end { text-align: right; }
.text-muted { color: var(--pico-muted-color); }
.small { font-size: 0.875rem; }
.fs-1 { font-size: 2rem; }
.fs-2 { font-size: 1.5rem; }
.fs-5 { font-size: 1.15rem; }
.fs-6 { font-size: 0.875rem; }
.text-success { color: #15803d; }
.text-danger { color: #dc2626; }
.text-warning { color: #b45309; }
.text-primary { color: var(--brand-primary); }
.text-secondary { color: var(--brand-secondary); }
.text-white { color: #fff; }
.bg-danger-header {
background: #dc2626;
color: #fff;
}
.font-monospace { font-family: monospace; }
.w-100 { width: 100%; }
.h-100 { height: 100%; }
/* Table */
.table-scroll {
overflow-x: auto;
}
table.align-middle td,
table.align-middle th {
vertical-align: middle;
}
/* Breadcrumb */
.breadcrumb {
display: flex;
align-items: center;
gap: 0.25rem;
list-style: none;
padding: 0;
margin: 0 0 1rem;
font-size: 0.9rem;
color: var(--pico-muted-color);
}
.breadcrumb-item + .breadcrumb-item::before {
content: "/";
margin-right: 0.25rem;
color: var(--pico-muted-color);
}
.breadcrumb-item.active { color: var(--pico-color); }
/* Dropdown */
.dropdown {
position: relative;
display: inline-block;
}
.dropdown-menu {
display: none;
position: absolute;
right: 0;
top: calc(100% + 4px);
background: var(--pico-background-color);
border: 1px solid var(--pico-border-color);
border-radius: var(--pico-border-radius);
box-shadow: 0 4px 12px rgba(0,0,0,0.12);
z-index: 200;
min-width: 220px;
padding: 0.25rem 0;
list-style: none;
margin: 0;
}
.dropdown.open .dropdown-menu { display: block; }
.dropdown-item {
display: block;
width: 100%;
padding: 0.45rem 1rem;
background: none;
border: none;
text-align: left;
cursor: pointer;
color: var(--pico-color);
font-size: 0.9rem;
text-decoration: none;
}
.dropdown-item:hover {
background: var(--pico-muted-background-color);
}
.dropdown-item.muted { color: var(--pico-muted-color); }
.dropdown-divider {
border: none;
border-top: 1px solid var(--pico-border-color);
margin: 0.25rem 0;
}
/* Spinner */
.spinner {
display: inline-block;
width: 2rem;
height: 2rem;
border: 3px solid var(--pico-muted-background-color);
border-top-color: var(--brand-primary);
border-radius: 50%;
animation: spin 0.75s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* Input group */
.input-group {
display: flex;
gap: 0;
}
.input-group input {
border-radius: var(--pico-border-radius) 0 0 var(--pico-border-radius);
margin: 0;
flex: 1;
}
.input-group button {
border-radius: 0 var(--pico-border-radius) var(--pico-border-radius) 0;
margin: 0;
white-space: nowrap;
}
/* Empty state */
.empty-state {
text-align: center;
padding: 3rem 1rem;
color: var(--pico-muted-color);
}
.empty-state .empty-icon {
font-size: 3rem;
display: block;
margin-bottom: 0.75rem;
}

15
web/tsconfig.json Normal file
View File

@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}