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>
This commit is contained in:
@@ -1,13 +1,10 @@
|
|||||||
FROM python:3.12-slim
|
FROM node:20-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY requirements.txt .
|
COPY web/package*.json ./
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN npm ci
|
||||||
|
|
||||||
COPY web/ ./web/
|
COPY web/ .
|
||||||
COPY alembic.ini .
|
RUN npm run build && cp -r src/templates dist/templates && cp -r static dist/static
|
||||||
COPY docker-entrypoint.sh .
|
|
||||||
RUN chmod +x docker-entrypoint.sh
|
|
||||||
|
|
||||||
CMD ["./docker-entrypoint.sh"]
|
CMD ["node", "dist/index.js"]
|
||||||
|
|||||||
@@ -6,19 +6,19 @@ services:
|
|||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile.web
|
dockerfile: Dockerfile.web
|
||||||
ports:
|
ports:
|
||||||
- "8080:8000"
|
- "8080:3000"
|
||||||
environment:
|
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}
|
- SECRET_KEY=${SECRET_KEY:-change-me-in-production}
|
||||||
- BASE_URL=${BASE_URL:-https://evosync.ru}
|
- BASE_URL=${BASE_URL:-https://evosync.ru}
|
||||||
- EVOTOR_APP_ID=${EVOTOR_APP_ID}
|
- EVOTOR_APP_ID=${EVOTOR_APP_ID}
|
||||||
- EVOTOR_WEBHOOK_SECRET=${EVOTOR_WEBHOOK_SECRET}
|
- EVOTOR_WEBHOOK_SECRET=${EVOTOR_WEBHOOK_SECRET}
|
||||||
- VK_CLIENT_ID=${VK_CLIENT_ID}
|
- VK_CLIENT_ID=${VK_CLIENT_ID}
|
||||||
- VK_CLIENT_SECRET=${VK_CLIENT_SECRET}
|
- VK_CLIENT_SECRET=${VK_CLIENT_SECRET}
|
||||||
|
- JIVOSITE_WIDGET_ID=${JIVOSITE_WIDGET_ID}
|
||||||
|
- NODE_ENV=production
|
||||||
|
- VK_DEFAULT_PHOTO_PATH=/app/default_product.png
|
||||||
volumes:
|
volumes:
|
||||||
- ./web:/app/web
|
|
||||||
- ./alembic.ini:/app/alembic.ini
|
|
||||||
- ./docker-entrypoint.sh:/app/docker-entrypoint.sh
|
|
||||||
- ./5393364294319597854.png:/app/default_product.png:ro
|
- ./5393364294319597854.png:/app/default_product.png:ro
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
|
|||||||
39
web-python/static/style.css
Normal file
39
web-python/static/style.css
Normal 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
2
web/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
10
web/drizzle.config.ts
Normal file
10
web/drizzle.config.ts
Normal 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
2001
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
web/package.json
Normal file
28
web/package.json
Normal 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
22
web/src/config.ts
Normal 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
13
web/src/db/client.ts
Normal 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
140
web/src/db/schema.ts
Normal 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
71
web/src/index.ts
Normal 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
21
web/src/lib/auth.ts
Normal 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
106
web/src/lib/evotorApi.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
138
web/src/lib/healthChecker.ts
Normal file
138
web/src/lib/healthChecker.ts
Normal 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
39
web/src/lib/render.ts
Normal 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
361
web/src/lib/syncEngine.ts
Normal 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
40
web/src/lib/validate.ts
Normal 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
124
web/src/routes/auth.ts
Normal 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
260
web/src/routes/catalog.ts
Normal 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}"`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
83
web/src/routes/connections.ts
Normal file
83
web/src/routes/connections.ts
Normal 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
147
web/src/routes/evotor.ts
Normal 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
116
web/src/routes/profile.ts
Normal 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
100
web/src/routes/reset.ts
Normal 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
88
web/src/routes/sync.ts
Normal 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
120
web/src/routes/vk.ts
Normal 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);
|
||||||
|
});
|
||||||
99
web/src/templates/base.njk
Normal file
99
web/src/templates/base.njk
Normal 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>
|
||||||
119
web/src/templates/catalog_groups.njk
Normal file
119
web/src/templates/catalog_groups.njk
Normal 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 %}
|
||||||
144
web/src/templates/catalog_products.njk
Normal file
144
web/src/templates/catalog_products.njk
Normal 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 %}
|
||||||
127
web/src/templates/catalog_stores.njk
Normal file
127
web/src/templates/catalog_stores.njk
Normal 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 %}
|
||||||
16
web/src/templates/confirm_email.njk
Normal file
16
web/src/templates/confirm_email.njk
Normal 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 %}
|
||||||
63
web/src/templates/connections.njk
Normal file
63
web/src/templates/connections.njk
Normal 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 %}
|
||||||
39
web/src/templates/connections_add.njk
Normal file
39
web/src/templates/connections_add.njk
Normal 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 %}
|
||||||
17
web/src/templates/email_confirmed.njk
Normal file
17
web/src/templates/email_confirmed.njk
Normal 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 %}
|
||||||
95
web/src/templates/evotor.njk
Normal file
95
web/src/templates/evotor.njk
Normal 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 %}
|
||||||
24
web/src/templates/forgot_password.njk
Normal file
24
web/src/templates/forgot_password.njk
Normal 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 %}
|
||||||
27
web/src/templates/login.njk
Normal file
27
web/src/templates/login.njk
Normal 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 %}
|
||||||
18
web/src/templates/message.njk
Normal file
18
web/src/templates/message.njk
Normal 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 %}
|
||||||
31
web/src/templates/profile_change_password.njk
Normal file
31
web/src/templates/profile_change_password.njk
Normal 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 %}
|
||||||
31
web/src/templates/profile_delete.njk
Normal file
31
web/src/templates/profile_delete.njk
Normal 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 %}
|
||||||
43
web/src/templates/profile_edit.njk
Normal file
43
web/src/templates/profile_edit.njk
Normal 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 %}
|
||||||
46
web/src/templates/profile_view.njk
Normal file
46
web/src/templates/profile_view.njk
Normal 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 %}
|
||||||
44
web/src/templates/register.njk
Normal file
44
web/src/templates/register.njk
Normal 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 %}
|
||||||
23
web/src/templates/reset_password.njk
Normal file
23
web/src/templates/reset_password.njk
Normal 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
101
web/src/templates/sync.njk
Normal 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
104
web/src/templates/vk.njk
Normal 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 %}
|
||||||
47
web/src/templates/vk_callback.njk
Normal file
47
web/src/templates/vk_callback.njk
Normal 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 %}
|
||||||
@@ -1,39 +1,431 @@
|
|||||||
/* Brand overrides */
|
/* Brand colors */
|
||||||
:root {
|
:root {
|
||||||
--bs-primary: #F05023;
|
--pico-primary: #F05023;
|
||||||
--bs-primary-rgb: 240, 80, 35;
|
--pico-primary-hover: #d44420;
|
||||||
--bs-link-color: #0986E2;
|
--pico-primary-focus: rgba(240, 80, 35, 0.25);
|
||||||
--bs-link-hover-color: #0670c0;
|
--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 {
|
.brand-logo {
|
||||||
font-size: 22px;
|
font-size: 1.3rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #F05023 !important;
|
color: var(--brand-primary) !important;
|
||||||
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-border {
|
.nav-links {
|
||||||
border-color: #F05023 !important;
|
flex: 1;
|
||||||
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.nav-links a {
|
||||||
--bs-btn-bg: #F05023;
|
color: var(--pico-color);
|
||||||
--bs-btn-border-color: #F05023;
|
text-decoration: none;
|
||||||
--bs-btn-hover-bg: #d44420;
|
padding: 0.25rem 0.5rem;
|
||||||
--bs-btn-hover-border-color: #d44420;
|
border-radius: 4px;
|
||||||
--bs-btn-active-bg: #c03d1c;
|
|
||||||
--bs-btn-active-border-color: #c03d1c;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary {
|
.nav-links a:hover {
|
||||||
--bs-btn-bg: #0986E2;
|
color: var(--brand-primary);
|
||||||
--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 {
|
.nav-links a.secondary {
|
||||||
color: #F05023 !important;
|
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
15
web/tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user