refactor: simplify — мёртвые поля контента, общие хелперы api и тестов, липкая CTA без site в бандле
landing: - site.ts: убраны неиспользуемые roast, brew, priceDisplay, hero.eyebrow, footer.phone (Footer берёт CONTACT_PHONE) - form.ts: убраны FORM_TEXT.title/lead, CTAS, CtaPreset - StickyCta получает тексты пропсами от layout: клиентский чанк больше не тащит все тексты страницы - LeadForm/useLeadForm: без неиспользуемой подмены sendLead и className; leadPrefill без счётчика seq - Stamp без мёртвого tone; убраны data-interest, data-hot, класс lead-form (ничем не читались) - одно LEAD_FIELDS на sendLead и leadFormModel; регэксп typo вынесен из вызова; дубль фона body api: - changedFields и Version общие для leads и outbox; countLeads и leadStamps без копий SQL - errorMessage в system.ts вместо четырёх копий; take() для ожидающих подтверждений - backup: один Intl.DateTimeFormat на модуль, без невызываемых параметров timeZone/hour - без мёртвых LOOPBACK '::ffff:127.0.0.1', client.call, Pick<BackupCommands> со всеми ключами - testing.ts: validLead, addLead, count, leadIds вместо копий в тестах deploy.sh: deployed_field и цикл по REPOS вместо повторов имён
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { MAX_BODY_BYTES } from './app.js';
|
||||
import { flush, setup, T0, validBody } from './testing.js';
|
||||
import { count, flush, setup, T0, validBody } from './testing.js';
|
||||
|
||||
describe('GET /api/health', () => {
|
||||
it('отдаёт версию', async () => {
|
||||
@@ -56,7 +56,7 @@ describe('POST /api/lead', () => {
|
||||
const res = await post(validBody());
|
||||
await flush();
|
||||
expect(res.status).toBe(201);
|
||||
expect(db.prepare('SELECT count(*) AS n FROM leads').get()).toEqual({ n: 1 });
|
||||
expect(count(db, 'leads')).toBe(1);
|
||||
});
|
||||
|
||||
it('400 validation: ошибки по полям, ничего не пишет', async () => {
|
||||
@@ -68,7 +68,7 @@ describe('POST /api/lead', () => {
|
||||
expect(Object.keys(body.fieldErrors).sort()).toEqual(['name', 'phone']);
|
||||
await flush();
|
||||
expect(events).toEqual([]);
|
||||
expect(db.prepare('SELECT count(*) AS n FROM leads').get()).toEqual({ n: 0 });
|
||||
expect(count(db, 'leads')).toBe(0);
|
||||
});
|
||||
|
||||
it('400 bad_request: битый JSON', async () => {
|
||||
@@ -96,7 +96,7 @@ describe('POST /api/lead', () => {
|
||||
const res = await post(validBody({ comment: 'а'.repeat(MAX_BODY_BYTES) }));
|
||||
expect(res.status).toBe(413);
|
||||
expect(await res.json()).toEqual({ ok: false, error: 'bad_request' });
|
||||
expect(db.prepare('SELECT count(*) AS n FROM leads').get()).toEqual({ n: 0 });
|
||||
expect(count(db, 'leads')).toBe(0);
|
||||
});
|
||||
|
||||
it('honeypot: 201 как на обычную заявку, ничего не пишет, notifier молчит', async () => {
|
||||
@@ -105,7 +105,7 @@ describe('POST /api/lead', () => {
|
||||
await flush();
|
||||
expect(res.status).toBe(201);
|
||||
expect(await res.json()).toEqual({ ok: true, duplicate: false });
|
||||
expect(db.prepare('SELECT count(*) AS n FROM leads').get()).toEqual({ n: 0 });
|
||||
expect(count(db, 'leads')).toBe(0);
|
||||
expect(events).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -115,7 +115,7 @@ describe('POST /api/lead', () => {
|
||||
const res = await post(validBody());
|
||||
expect(res.status).toBe(503);
|
||||
expect(await res.json()).toEqual({ ok: false, error: 'unavailable' });
|
||||
expect(db.prepare('SELECT count(*) AS n FROM leads').get()).toEqual({ n: 0 });
|
||||
expect(count(db, 'leads')).toBe(0);
|
||||
|
||||
paused = false;
|
||||
expect((await post(validBody())).status).toBe(201);
|
||||
|
||||
+16
-27
@@ -4,7 +4,6 @@ import { join } from 'node:path';
|
||||
import { gzipSync } from 'node:zlib';
|
||||
import Database from 'better-sqlite3';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { validateLead } from '@kvarta/shared/lead';
|
||||
import {
|
||||
backupDirFor,
|
||||
backupFileName,
|
||||
@@ -23,8 +22,7 @@ import {
|
||||
} from './backup.js';
|
||||
import { openDb, type DbHolder } from './db/index.js';
|
||||
import { MIGRATIONS } from './db/migrations.js';
|
||||
import { saveLead } from './leads.js';
|
||||
import { validBody } from './testing.js';
|
||||
import { addLead, leadIds } from './testing.js';
|
||||
import { fakeTelegram } from './telegram/fake.js';
|
||||
import type { SystemReporter } from './telegram/system.js';
|
||||
|
||||
@@ -41,15 +39,6 @@ function tempDb() {
|
||||
return { root, dbPath, holder, dir: backupDirFor(dbPath) };
|
||||
}
|
||||
|
||||
let phoneSeq = 1000000;
|
||||
function addLead(holder: DbHolder, at: Date) {
|
||||
const r = validateLead(validBody({ phone: `+37529${phoneSeq++}` }));
|
||||
if (!r.ok) throw new Error('invalid');
|
||||
return saveLead(holder.db, r.data, { now: at, userAgent: null });
|
||||
}
|
||||
|
||||
const leadIds = (holder: DbHolder) => (holder.db.prepare('SELECT id FROM leads ORDER BY id').all() as { id: number }[]).map((r) => r.id);
|
||||
|
||||
// 18.10.2026 03:00 по Минску = 00:00 UTC
|
||||
const NIGHT = new Date('2026-10-18T00:00:00.000Z');
|
||||
|
||||
@@ -81,8 +70,8 @@ describe('расписание и имена', () => {
|
||||
describe('createBackup и ротация', () => {
|
||||
it('gzip-копия с данными, без временных файлов', async () => {
|
||||
const { holder, dir } = tempDb();
|
||||
addLead(holder, NIGHT);
|
||||
addLead(holder, NIGHT);
|
||||
addLead(holder.db, NIGHT);
|
||||
addLead(holder.db, NIGHT);
|
||||
const result = await createBackup(holder.db, dir, NIGHT);
|
||||
|
||||
expect(result).toMatchObject({ file: 'kvarta-2026-10-18T0300.sqlite.gz', kind: 'daily', leads: 2 });
|
||||
@@ -117,7 +106,7 @@ describe('createBackup и ротация', () => {
|
||||
|
||||
it('runBackupAndReport: файл админу, отчёт в «Систему»', async () => {
|
||||
const { holder, dir } = tempDb();
|
||||
addLead(holder, NIGHT);
|
||||
addLead(holder.db, NIGHT);
|
||||
const tg = fakeTelegram();
|
||||
const reports: string[] = [];
|
||||
const system: SystemReporter = { report: async (_k, t) => void reports.push(t) };
|
||||
@@ -141,11 +130,11 @@ describe('createBackup и ротация', () => {
|
||||
describe('восстановление', () => {
|
||||
it('подменяет данные, создаёт pre-restore, пауза на время подмены', async () => {
|
||||
const { holder, dbPath, dir } = tempDb();
|
||||
addLead(holder, NIGHT);
|
||||
addLead(holder.db, NIGHT);
|
||||
const backup = await createBackup(holder.db, dir, NIGHT);
|
||||
const later = new Date(NIGHT.getTime() + 60 * 60 * 1000);
|
||||
addLead(holder, later);
|
||||
addLead(holder, later);
|
||||
addLead(holder.db, later);
|
||||
addLead(holder.db, later);
|
||||
|
||||
const inspected = await inspectBackup(join(dir, backup.file), dir);
|
||||
expect(leadsMissingFrom(holder.db, inspected.leads)).toEqual([2, 3]);
|
||||
@@ -164,7 +153,7 @@ describe('восстановление', () => {
|
||||
expect(holder.paused).toBe(false);
|
||||
expect(holder.db).not.toBe(oldDb);
|
||||
expect(oldDb.open).toBe(false);
|
||||
expect(leadIds(holder)).toEqual([1]);
|
||||
expect(leadIds(holder.db)).toEqual([1]);
|
||||
expect(result.leads).toBe(1);
|
||||
expect(result.preRestore.file).toBe('pre-restore-2026-10-18T0400.sqlite.gz');
|
||||
const pre = await inspectBackup(join(dir, result.preRestore.file), dir);
|
||||
@@ -174,13 +163,13 @@ describe('восстановление', () => {
|
||||
expect(readdirSync(dir).sort()).toEqual([backup.file, result.preRestore.file].sort());
|
||||
expect(existsSync(`${dbPath}.rollback`)).toBe(false);
|
||||
// Новая база рабочая: пишем дальше.
|
||||
addLead(holder, later);
|
||||
expect(leadIds(holder)).toEqual([1, 2]);
|
||||
addLead(holder.db, later);
|
||||
expect(leadIds(holder.db)).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('id после отката переиспользуются — пропавшие считаем по id и created_at', async () => {
|
||||
const { holder, dir } = tempDb();
|
||||
addLead(holder, NIGHT);
|
||||
addLead(holder.db, NIGHT);
|
||||
const inspected = await inspectBackup(join(dir, (await createBackup(holder.db, dir, NIGHT)).file), dir);
|
||||
// Заявку #1 пересоздали в другое время (как после прошлого восстановления).
|
||||
holder.db.prepare('UPDATE leads SET created_at = ? WHERE id = 1').run('2026-10-19T00:00:00.000Z');
|
||||
@@ -189,10 +178,10 @@ describe('восстановление', () => {
|
||||
|
||||
it('битый файл отклоняется, база не тронута', async () => {
|
||||
const { holder, dir } = tempDb();
|
||||
addLead(holder, NIGHT);
|
||||
addLead(holder.db, NIGHT);
|
||||
await expect(inspectBackup(new Uint8Array([1, 2, 3]), dir)).rejects.toBeInstanceOf(RestoreRejected);
|
||||
await expect(inspectBackup(gzipSync(Buffer.from('not sqlite at all')), dir)).rejects.toThrow(/не база SQLite/);
|
||||
expect(leadIds(holder)).toEqual([1]);
|
||||
expect(leadIds(holder.db)).toEqual([1]);
|
||||
expect(readdirSync(dir).filter((f) => f.startsWith('.'))).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -208,8 +197,8 @@ describe('восстановление', () => {
|
||||
|
||||
it('сбой после снимка — возвращаем старую базу и снимаем паузу', async () => {
|
||||
const { holder, dbPath, dir } = tempDb();
|
||||
addLead(holder, NIGHT);
|
||||
addLead(holder, NIGHT);
|
||||
addLead(holder.db, NIGHT);
|
||||
addLead(holder.db, NIGHT);
|
||||
const inspected = await inspectBackup(join(dir, (await createBackup(holder.db, dir, NIGHT)).file), dir);
|
||||
// Портим распакованную копию уже после проверки: openDb на ней упадёт.
|
||||
writeFileSync(inspected.path, 'garbage');
|
||||
@@ -217,7 +206,7 @@ describe('восстановление', () => {
|
||||
await expect(restoreFromInspected({ holder, dbPath, inspected, now: NIGHT })).rejects.toThrow();
|
||||
expect(holder.paused).toBe(false);
|
||||
expect(holder.db.open).toBe(true);
|
||||
expect(leadIds(holder)).toEqual([1, 2]);
|
||||
expect(leadIds(holder.db)).toEqual([1, 2]);
|
||||
expect(existsSync(`${dbPath}.rollback`)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
+28
-23
@@ -19,12 +19,13 @@ import Database from 'better-sqlite3';
|
||||
import { openDb, type Db, type DbHolder } from './db/index.js';
|
||||
import { MIGRATIONS } from './db/migrations.js';
|
||||
import type { TelegramClient } from './telegram/client.js';
|
||||
import { shortError, type SystemReporter } from './telegram/system.js';
|
||||
import { errorMessage, shortError, type SystemReporter } from './telegram/system.js';
|
||||
|
||||
export const TIME_ZONE = 'Europe/Minsk';
|
||||
export const KEEP_DAILY = 14;
|
||||
export const KEEP_PRE_RESTORE_DAYS = 30;
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const DAILY_HOUR = 3;
|
||||
|
||||
export type BackupKind = 'daily' | 'pre-restore';
|
||||
export type BackupFile = { file: string; kind: BackupKind; at: Date; bytes: number };
|
||||
@@ -37,17 +38,19 @@ export function backupDirFor(dbPath: string): string {
|
||||
|
||||
type LocalParts = { year: number; month: number; day: number; hour: number; minute: number };
|
||||
|
||||
const MINSK_PARTS = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: TIME_ZONE,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
});
|
||||
|
||||
/** Часы и дата по Минску. Через Intl, а не +3: правило могут поменять, tzdata — обновится. */
|
||||
export function localParts(date: Date, timeZone = TIME_ZONE): LocalParts {
|
||||
const parts = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
}).formatToParts(date);
|
||||
export function localParts(date: Date): LocalParts {
|
||||
const parts = MINSK_PARTS.formatToParts(date);
|
||||
const get = (type: string) => Number(parts.find((p) => p.type === type)!.value);
|
||||
return { year: get('year'), month: get('month'), day: get('day'), hour: get('hour'), minute: get('minute') };
|
||||
}
|
||||
@@ -67,9 +70,9 @@ export function backupFileName(date: Date, kind: BackupKind): string {
|
||||
}
|
||||
|
||||
/** Минское локальное время → UTC-момент (смещение берём на этот самый момент). */
|
||||
function fromLocal(p: LocalParts, timeZone = TIME_ZONE): Date {
|
||||
function fromLocal(p: LocalParts): Date {
|
||||
const guess = Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute);
|
||||
const shown = localParts(new Date(guess), timeZone);
|
||||
const shown = localParts(new Date(guess));
|
||||
const offset = Date.UTC(shown.year, shown.month - 1, shown.day, shown.hour, shown.minute) - guess;
|
||||
return new Date(guess - offset);
|
||||
}
|
||||
@@ -85,12 +88,12 @@ function parseName(file: string): { kind: BackupKind; at: Date } | null {
|
||||
}
|
||||
|
||||
/** Следующий запуск ежедневного бэкапа: ближайшие 03:00 по Минску строго после now. */
|
||||
export function nextDailyRun(now: Date, hour = 3): Date {
|
||||
export function nextDailyRun(now: Date): Date {
|
||||
const today = localParts(now);
|
||||
let run = fromLocal({ ...today, hour, minute: 0 });
|
||||
let run = fromLocal({ ...today, hour: DAILY_HOUR, minute: 0 });
|
||||
if (run.getTime() <= now.getTime()) {
|
||||
const tomorrow = localParts(new Date(run.getTime() + DAY_MS));
|
||||
run = fromLocal({ ...tomorrow, hour, minute: 0 });
|
||||
run = fromLocal({ ...tomorrow, hour: DAILY_HOUR, minute: 0 });
|
||||
}
|
||||
return run;
|
||||
}
|
||||
@@ -125,7 +128,7 @@ export function rotateBackups(dir: string, now: Date): string[] {
|
||||
return removed;
|
||||
}
|
||||
|
||||
function countLeads(db: Db): number {
|
||||
export function countLeads(db: Db): number {
|
||||
return (db.prepare('SELECT count(*) AS n FROM leads').get() as { n: number }).n;
|
||||
}
|
||||
|
||||
@@ -199,8 +202,7 @@ export async function inspectBackup(source: string | Uint8Array, dir: string): P
|
||||
}
|
||||
const hasLeads = copy.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'leads'").get();
|
||||
if (!hasLeads) throw new RestoreRejected('В копии нет таблицы заявок.');
|
||||
const leads = copy.prepare('SELECT id, created_at FROM leads ORDER BY id').all() as InspectedBackup['leads'];
|
||||
return { path, userVersion, leads };
|
||||
return { path, userVersion, leads: leadStamps(copy) };
|
||||
} finally {
|
||||
copy.close();
|
||||
}
|
||||
@@ -220,8 +222,11 @@ export function discardInspected(inspected: InspectedBackup): void {
|
||||
/** Заявки текущей базы, которых нет в копии (по паре id + created_at: id после отката переиспользуются). */
|
||||
export function leadsMissingFrom(db: Db, backupLeads: InspectedBackup['leads']): number[] {
|
||||
const inCopy = new Set(backupLeads.map((l) => `${l.id}|${l.created_at}`));
|
||||
const current = db.prepare('SELECT id, created_at FROM leads ORDER BY id').all() as InspectedBackup['leads'];
|
||||
return current.filter((l) => !inCopy.has(`${l.id}|${l.created_at}`)).map((l) => l.id);
|
||||
return leadStamps(db).filter((l) => !inCopy.has(`${l.id}|${l.created_at}`)).map((l) => l.id);
|
||||
}
|
||||
|
||||
function leadStamps(db: Db): InspectedBackup['leads'] {
|
||||
return db.prepare('SELECT id, created_at FROM leads ORDER BY id').all() as InspectedBackup['leads'];
|
||||
}
|
||||
|
||||
function removeSidecars(path: string) {
|
||||
@@ -347,7 +352,7 @@ export async function runBackupAndReport({
|
||||
}): Promise<BackupResult | undefined> {
|
||||
let result: BackupResult;
|
||||
try {
|
||||
result = await createBackup(holder.db, dir, now, 'daily');
|
||||
result = await createBackup(holder.db, dir, now);
|
||||
rotateBackups(dir, now);
|
||||
} catch (err) {
|
||||
await system.report('backup failed', `Бэкап ${shortLabel(now)} не удался ❌\n${shortError(err)}`);
|
||||
@@ -363,7 +368,7 @@ export async function runBackupAndReport({
|
||||
caption: summary,
|
||||
});
|
||||
} catch (err) {
|
||||
delivery = `, но в личку не отправлен: ${err instanceof Error ? err.message : String(err)}`;
|
||||
delivery = `, но в личку не отправлен: ${errorMessage(err)}`;
|
||||
}
|
||||
}
|
||||
await system.report('backup', `${summary} ✅${delivery}`);
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CLARIFY_WINDOW_MS } from './leads.js';
|
||||
import { flush, setup, validBody } from './testing.js';
|
||||
import { count, flush, setup, validBody } from './testing.js';
|
||||
|
||||
const MINUTE = 60 * 1000;
|
||||
|
||||
function count(db: ReturnType<typeof setup>['db'], table: 'leads' | 'lead_clarifications') {
|
||||
return (db.prepare(`SELECT count(*) AS n FROM ${table}`).get() as { n: number }).n;
|
||||
}
|
||||
|
||||
describe('уточнения в окне 10 минут', () => {
|
||||
it('повтор без изменений: clarified:false, ничего не пишем, notifier молчит', async () => {
|
||||
const { post, db, events, advance } = setup();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ValidLead } from '@kvarta/shared/lead';
|
||||
import type { Interest, ValidLead } from '@kvarta/shared/lead';
|
||||
import type { Db } from './db/index.js';
|
||||
import type { ClarifiedField, LeadEvent } from './notifier.js';
|
||||
|
||||
@@ -12,10 +12,15 @@ export type SaveResult =
|
||||
| { kind: 'clarified'; event: LeadEvent }
|
||||
| { kind: 'unchanged'; leadId: number };
|
||||
|
||||
type Version = { name: string; interest: string; comment: string | null };
|
||||
/** Версия заявки: сама заявка или уточнение к ней. */
|
||||
export type Version = { name: string; interest: Interest; comment: string | null };
|
||||
|
||||
const CLARIFIED_FIELDS: readonly ClarifiedField[] = ['name', 'interest', 'comment'];
|
||||
|
||||
export function changedFields(previous: Version, next: Version): ClarifiedField[] {
|
||||
return CLARIFIED_FIELDS.filter((field) => previous[field] !== next[field]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Новая заявка, уточнение к заявке из окна или повтор без изменений.
|
||||
* Поиск и запись в одной IMMEDIATE-транзакции: два одновременных запроса с одного телефона
|
||||
@@ -63,7 +68,7 @@ export function saveLead(db: Db, lead: ValidLead, meta: SaveMeta): SaveResult {
|
||||
)
|
||||
.get(open.id) as Version | undefined) ?? open;
|
||||
|
||||
const changed = CLARIFIED_FIELDS.filter((field) => previous[field] !== lead[field]);
|
||||
const changed = changedFields(previous, lead);
|
||||
if (changed.length === 0) return { kind: 'unchanged', leadId: open.id };
|
||||
|
||||
const { lastInsertRowid } = db
|
||||
|
||||
@@ -35,7 +35,8 @@ export function createRateLimiter({ limit = 5, windowMs = 10 * 60 * 1000 } = {})
|
||||
};
|
||||
}
|
||||
|
||||
export const LOOPBACK = ['127.0.0.1', '::1', '::ffff:127.0.0.1'] as const;
|
||||
// «::ffff:127.0.0.1» отдельно не нужен: clientIp снимает префикс до сверки
|
||||
export const LOOPBACK = ['127.0.0.1', '::1'] as const;
|
||||
|
||||
/**
|
||||
* IP посетителя. X-Real-IP ставит nginx на той же машине; верим заголовку только от доверенного
|
||||
|
||||
@@ -2,11 +2,9 @@ import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { validateLead } from '@kvarta/shared/lead';
|
||||
import { backupDirFor, createBackup, createBackupScheduler, listBackups } from '../backup.js';
|
||||
import { openDb, type DbHolder } from '../db/index.js';
|
||||
import { saveLead } from '../leads.js';
|
||||
import { validBody } from '../testing.js';
|
||||
import { addLead as addLeadTo, leadIds as leadIdsOf } from '../testing.js';
|
||||
import { createBackupCommands, CONFIRM_TTL_MS } from './backupCommands.js';
|
||||
import { createBot } from './bot.js';
|
||||
import type { TgUpdate } from './client.js';
|
||||
@@ -21,7 +19,6 @@ afterEach(() => {
|
||||
|
||||
// 18.10.2026 03:00 по Минску
|
||||
const NIGHT = new Date('2026-10-18T00:00:00.000Z');
|
||||
let phone = 2000000;
|
||||
|
||||
function setup() {
|
||||
const root = mkdtempSync(join(tmpdir(), 'kvarta-bot-'));
|
||||
@@ -39,11 +36,7 @@ function setup() {
|
||||
const backups = createBackupCommands({ client: tg.client, adminId: ADMIN, holder, dbPath, now: () => clock.now, system });
|
||||
const bot = createBot({ client: tg.client, telegram: { adminId: ADMIN }, system, backups });
|
||||
|
||||
const addLead = () => {
|
||||
const r = validateLead(validBody({ phone: `+37529${phone++}` }));
|
||||
if (!r.ok) throw new Error('invalid');
|
||||
saveLead(holder.db, r.data, { now: clock.now, userAgent: null });
|
||||
};
|
||||
const addLead = () => void addLeadTo(holder.db, clock.now);
|
||||
const chat = { id: ADMIN, type: 'private' as const };
|
||||
const from = (id: number) => ({ id, is_bot: false, first_name: 'A' });
|
||||
const text = (t: string, fromId = ADMIN): TgUpdate => ({
|
||||
@@ -57,7 +50,7 @@ function setup() {
|
||||
const lastText = () => tg.sent().at(-1)?.text as string;
|
||||
const lastButtons = () => tg.sent().at(-1)?.reply_markup?.inline_keyboard.flat() as { text: string; callback_data: string }[];
|
||||
const edits = () => tg.sent('editMessageText').map((p) => p.text as string);
|
||||
const leadIds = () => (holder.db.prepare('SELECT id FROM leads ORDER BY id').all() as { id: number }[]).map((r) => r.id);
|
||||
const leadIds = () => leadIdsOf(holder.db);
|
||||
|
||||
return { tg, bot, holder, dbPath, clock, reports, uploads, addLead, text, press, lastText, lastButtons, edits, leadIds };
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
backupDirFor,
|
||||
countLeads,
|
||||
discardInspected,
|
||||
inspectBackup,
|
||||
leadsMissingFrom,
|
||||
@@ -15,7 +16,7 @@ import {
|
||||
} from '../backup.js';
|
||||
import type { DbHolder } from '../db/index.js';
|
||||
import type { InlineKeyboard, TelegramClient, TgCallbackQuery, TgMessage } from './client.js';
|
||||
import { shortError, type SystemReporter } from './system.js';
|
||||
import { errorMessage, shortError, type SystemReporter } from './system.js';
|
||||
|
||||
export const MAX_UPLOAD_BYTES = 20 * 1024 * 1024;
|
||||
export const CONFIRM_TTL_MS = 10 * 60 * 1000;
|
||||
@@ -56,6 +57,13 @@ export function createBackupCommands({
|
||||
|
||||
const say = (text: string, reply_markup?: InlineKeyboard) => client.sendMessage({ chat_id: adminId, text, reply_markup });
|
||||
|
||||
/** Достать подтверждение из ожидающих: каждое срабатывает один раз. */
|
||||
function take(id: string): Pending | undefined {
|
||||
const p = pending.get(id);
|
||||
pending.delete(id);
|
||||
return p;
|
||||
}
|
||||
|
||||
function sweep() {
|
||||
const t = now().getTime();
|
||||
for (const [id, p] of pending) {
|
||||
@@ -103,7 +111,7 @@ export function createBackupCommands({
|
||||
async function offerRestore(inspected: InspectedBackup, label: string) {
|
||||
sweep();
|
||||
const missing = leadsMissingFrom(holder.db, inspected.leads);
|
||||
const total = (holder.db.prepare('SELECT count(*) AS n FROM leads').get() as { n: number }).n;
|
||||
const total = countLeads(holder.db);
|
||||
const id = String(++seq);
|
||||
pending.set(id, { inspected, label, expiresAt: now().getTime() + CONFIRM_TTL_MS });
|
||||
|
||||
@@ -135,8 +143,7 @@ export function createBackupCommands({
|
||||
|
||||
async function confirm(id: string, query: TgCallbackQuery) {
|
||||
sweep();
|
||||
const p = pending.get(id);
|
||||
pending.delete(id);
|
||||
const p = take(id);
|
||||
const edit = (text: string) =>
|
||||
query.message
|
||||
? client.editMessageText({ chat_id: query.message.chat.id, message_id: query.message.message_id, text })
|
||||
@@ -153,7 +160,7 @@ export function createBackupCommands({
|
||||
);
|
||||
await system.report('restore', `База восстановлена из ${p.label} администратором`);
|
||||
} catch (err) {
|
||||
await say(`Не удалось восстановить: ${err instanceof Error ? err.message : String(err)}. База осталась прежней.`);
|
||||
await say(`Не удалось восстановить: ${errorMessage(err)}. База осталась прежней.`);
|
||||
await system.report('restore failed', `Восстановление из ${p.label} не удалось, база прежняя\n${shortError(err)}`);
|
||||
}
|
||||
}
|
||||
@@ -186,7 +193,8 @@ export function createBackupCommands({
|
||||
async onCallback(query: TgCallbackQuery) {
|
||||
await client.answerCallbackQuery(query.id);
|
||||
const data = query.data ?? '';
|
||||
const [action, arg = ''] = [data.slice(0, 1), data.slice(2)];
|
||||
const action = data.slice(0, 1);
|
||||
const arg = data.slice(2);
|
||||
if (action === 'r') {
|
||||
// Имя проверяем по списку, а не склеиваем путь из callback_data: никакого ../
|
||||
const backup = listBackups(dir).find((b) => b.file === arg);
|
||||
@@ -195,9 +203,8 @@ export function createBackupCommands({
|
||||
} else if (action === 'y') {
|
||||
await confirm(arg, query);
|
||||
} else if (action === 'n') {
|
||||
const p = pending.get(arg);
|
||||
const p = take(arg);
|
||||
if (p) discardInspected(p.inspected);
|
||||
pending.delete(arg);
|
||||
if (query.message) {
|
||||
await client.editMessageText({ chat_id: query.message.chat.id, message_id: query.message.message_id, text: 'Восстановление отменено.' });
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import type { TelegramConfig } from '../config.js';
|
||||
import { TelegramError, type TelegramClient, type TgMessage, type TgUpdate, type TgUser } from './client.js';
|
||||
import type { BackupCommands } from './backupCommands.js';
|
||||
import { shortError, type SystemReporter } from './system.js';
|
||||
import { errorMessage, shortError, type SystemReporter } from './system.js';
|
||||
|
||||
export const LEADS_TOPIC = 'Kvarta Cafe: Заявки';
|
||||
export const SYSTEM_TOPIC = 'Kvarta Cafe: Система';
|
||||
@@ -41,7 +41,7 @@ export function createBot({
|
||||
client: Client;
|
||||
telegram: TelegramConfig;
|
||||
system: SystemReporter;
|
||||
backups?: Pick<BackupCommands, 'onCommand' | 'onDocument' | 'onCallback'>;
|
||||
backups?: BackupCommands;
|
||||
retryPauseMs?: number;
|
||||
}): Bot {
|
||||
let me: TgUser | undefined;
|
||||
@@ -161,7 +161,7 @@ export function createBot({
|
||||
} else if (Date.now() - lastNetworkWarnAt >= NETWORK_WARN_EVERY_MS) {
|
||||
// Сеть до Telegram недоступна — в «Систему» не сообщить, остаётся лог контейнера.
|
||||
lastNetworkWarnAt = Date.now();
|
||||
console.warn(`[bot] getUpdates не удался: ${err instanceof Error ? err.message : String(err)}`);
|
||||
console.warn(`[bot] getUpdates не удался: ${errorMessage(err)}`);
|
||||
}
|
||||
await pause(err instanceof TelegramError && err.retryAfterSec ? err.retryAfterSec * 1000 : retryPauseMs);
|
||||
}
|
||||
|
||||
@@ -105,7 +105,6 @@ export function createTelegramClient({
|
||||
}
|
||||
|
||||
return {
|
||||
call,
|
||||
getMe: () => call<TgUser>('getMe'),
|
||||
sendMessage: (params: SendMessageParams) => call<TgMessage>('sendMessage', params),
|
||||
sendDocument(params: {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Фейковый Telegram для тестов: записывает вызовы, ответы и сбои задаются по методу.
|
||||
import type { TelegramClient } from './client.js';
|
||||
|
||||
type Method = Exclude<keyof TelegramClient, 'call'>;
|
||||
type Method = keyof TelegramClient;
|
||||
type Handler = (params: any) => unknown;
|
||||
|
||||
export function fakeTelegram(handlers: Partial<Record<Method, Handler>> = {}) {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { validateLead, type ValidLead } from '@kvarta/shared/lead';
|
||||
import type { ValidLead } from '@kvarta/shared/lead';
|
||||
import { openDb } from '../db/index.js';
|
||||
import { saveLead } from '../leads.js';
|
||||
import { validBody } from '../testing.js';
|
||||
import { validLead as lead } from '../testing.js';
|
||||
import { TelegramError } from './client.js';
|
||||
import { fakeTelegram } from './fake.js';
|
||||
import { backoffMs, createOutbox, MAX_ATTEMPTS } from './outbox.js';
|
||||
@@ -10,12 +10,6 @@ import type { SystemReporter } from './system.js';
|
||||
|
||||
const MINUTE = 60 * 1000;
|
||||
|
||||
function lead(overrides: Parameters<typeof validBody>[0] = {}): ValidLead {
|
||||
const r = validateLead(validBody(overrides));
|
||||
if (!r.ok) throw new Error('invalid');
|
||||
return r.data;
|
||||
}
|
||||
|
||||
function setup({ failures = [] as unknown[], chatId = -100 as number | null, paused = { value: false } } = {}) {
|
||||
const db = openDb(':memory:');
|
||||
const clock = { now: new Date('2026-10-17T12:00:00.000Z') };
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
// поэтому рестарт или потерянное событие ничего не теряют: следующий тик доберёт pending.
|
||||
import type { Interest, Touch } from '@kvarta/shared/lead';
|
||||
import type { Db } from '../db/index.js';
|
||||
import type { ClarifiedField, LeadEvent, Notifier } from '../notifier.js';
|
||||
import { changedFields, type Version } from '../leads.js';
|
||||
import type { Notifier } from '../notifier.js';
|
||||
import { TelegramError, type TelegramClient } from './client.js';
|
||||
import { formatClarification, formatLead } from './format.js';
|
||||
import type { SystemReporter } from './system.js';
|
||||
@@ -22,7 +23,6 @@ type LeadRow = {
|
||||
first_touch: string | null;
|
||||
last_touch: string;
|
||||
};
|
||||
type VersionRow = { name: string; interest: Interest; comment: string | null };
|
||||
|
||||
export type Outbox = Notifier & {
|
||||
/** Отправить всё, что пора. Вызовы не пересекаются: следующий ждёт предыдущий. */
|
||||
@@ -102,7 +102,7 @@ export function createOutbox({
|
||||
}
|
||||
const current = db
|
||||
.prepare('SELECT name, interest, comment FROM lead_clarifications WHERE id = ?')
|
||||
.get(item.id) as VersionRow;
|
||||
.get(item.id) as Version;
|
||||
// Изменения считаем по базе, а не из события: тик после рестарта события не видел.
|
||||
const previous =
|
||||
(db
|
||||
@@ -110,9 +110,9 @@ export function createOutbox({
|
||||
`SELECT name, interest, comment FROM lead_clarifications
|
||||
WHERE lead_id = ? AND id < ? ORDER BY id DESC LIMIT 1`,
|
||||
)
|
||||
.get(item.lead_id, item.id) as VersionRow | undefined) ??
|
||||
(db.prepare('SELECT name, interest, comment FROM leads WHERE id = ?').get(item.lead_id) as VersionRow);
|
||||
const changed = (['name', 'interest', 'comment'] as ClarifiedField[]).filter((f) => current[f] !== previous[f]);
|
||||
.get(item.lead_id, item.id) as Version | undefined) ??
|
||||
(db.prepare('SELECT name, interest, comment FROM leads WHERE id = ?').get(item.lead_id) as Version);
|
||||
const changed = changedFields(previous, current);
|
||||
return formatClarification({ leadId: item.lead_id, ...current, changed });
|
||||
}
|
||||
|
||||
@@ -170,9 +170,7 @@ export function createOutbox({
|
||||
}
|
||||
|
||||
return {
|
||||
leadSaved(_event: LeadEvent) {
|
||||
return tick();
|
||||
},
|
||||
leadSaved: tick,
|
||||
tick,
|
||||
start() {
|
||||
if (!enabled || timer) return;
|
||||
|
||||
@@ -45,12 +45,16 @@ export function createSystemReporter({
|
||||
text: count > 1 ? `×${count} за период · ${text}` : text,
|
||||
});
|
||||
} catch (err) {
|
||||
log(`[system] не отправлено в Telegram: ${err instanceof Error ? err.message : String(err)}`);
|
||||
log(`[system] не отправлено в Telegram: ${errorMessage(err)}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function errorMessage(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
/** Короткий stack без путей до node_modules: в «Систему» и stdout. */
|
||||
export function shortError(err: unknown): string {
|
||||
if (!(err instanceof Error)) return String(err);
|
||||
|
||||
+23
-2
@@ -1,7 +1,8 @@
|
||||
// Общие заготовки для тестов api: база в памяти, управляемые часы, notifier-шпион.
|
||||
import type { LeadRequest } from '@kvarta/shared/lead';
|
||||
import { validateLead, type LeadRequest, type ValidLead } from '@kvarta/shared/lead';
|
||||
import { createApp, type AppDeps } from './app.js';
|
||||
import { openDb } from './db/index.js';
|
||||
import { openDb, type Db } from './db/index.js';
|
||||
import { saveLead } from './leads.js';
|
||||
import type { LeadEvent, Notifier } from './notifier.js';
|
||||
import type { RateLimiter } from './rateLimit.js';
|
||||
|
||||
@@ -19,6 +20,26 @@ export function validBody(overrides: Partial<LeadRequest> = {}): LeadRequest {
|
||||
};
|
||||
}
|
||||
|
||||
export function validLead(overrides: Partial<LeadRequest> = {}): ValidLead {
|
||||
const r = validateLead(validBody(overrides));
|
||||
if (!r.ok) throw new Error('invalid');
|
||||
return r.data;
|
||||
}
|
||||
|
||||
let phoneSeq = 1000000;
|
||||
/** Новая заявка с уникальным телефоном — не попадёт в уточнения к предыдущей. */
|
||||
export function addLead(db: Db, now: Date) {
|
||||
return saveLead(db, validLead({ phone: `+37529${phoneSeq++}` }), { now, userAgent: null });
|
||||
}
|
||||
|
||||
export function count(db: Db, table: 'leads' | 'lead_clarifications'): number {
|
||||
return (db.prepare(`SELECT count(*) AS n FROM ${table}`).get() as { n: number }).n;
|
||||
}
|
||||
|
||||
export function leadIds(db: Db): number[] {
|
||||
return (db.prepare('SELECT id FROM leads ORDER BY id').all() as { id: number }[]).map((r) => r.id);
|
||||
}
|
||||
|
||||
export function setup(overrides: Partial<AppDeps> = {}) {
|
||||
const db = openDb(':memory:');
|
||||
const clock = { now: T0 };
|
||||
|
||||
@@ -80,7 +80,6 @@
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-body);
|
||||
line-height: 1.55;
|
||||
background: var(--color-cream);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
font-synthesis: none;
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
<body>
|
||||
<StampInkFilter />
|
||||
{children}
|
||||
<StickyCta />
|
||||
<StickyCta {...site.stickyCta} />
|
||||
<TouchRecorder />
|
||||
<RevealObserver />
|
||||
</body>
|
||||
|
||||
@@ -19,7 +19,6 @@ export function CtaButton({ interest, comment, source, className, children }: Ct
|
||||
<button
|
||||
type="button"
|
||||
className={className}
|
||||
data-interest={interest}
|
||||
onClick={(e) => {
|
||||
const from = source ?? e.currentTarget.parentElement?.closest('[id]')?.id;
|
||||
prefillLead({ interest, comment, source: from || undefined });
|
||||
|
||||
@@ -6,15 +6,9 @@ import { useEffect, useRef, useState } from 'react';
|
||||
import { CONTACT_PHONE, FORM_TEXT } from '@/content/form';
|
||||
import { LEAD_NAME_INPUT_ID } from '@/lib/leadPrefill';
|
||||
import { Stamp } from './Stamp';
|
||||
import type { SendLead, SubmitNotice } from '@/lib/sendLead';
|
||||
import type { SubmitNotice } from '@/lib/sendLead';
|
||||
import { useLeadForm } from '@/lib/useLeadForm';
|
||||
|
||||
export type LeadFormProps = {
|
||||
/** подмена отправки — для тестов и витрины состояний */
|
||||
sendLead?: SendLead;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const ids = {
|
||||
name: LEAD_NAME_INPUT_ID,
|
||||
phone: 'lead-phone',
|
||||
@@ -94,8 +88,8 @@ function Notice({ notice }: { notice: SubmitNotice | null }) {
|
||||
}
|
||||
}
|
||||
|
||||
export function LeadForm({ sendLead, className }: LeadFormProps) {
|
||||
const form = useLeadForm({ sendLead });
|
||||
export function LeadForm() {
|
||||
const form = useLeadForm();
|
||||
const { values, errors, status, notice } = form;
|
||||
const submitting = status === 'submitting';
|
||||
const sendAnotherRef = useRef<HTMLButtonElement>(null);
|
||||
@@ -131,7 +125,7 @@ export function LeadForm({ sendLead, className }: LeadFormProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`lead-form relative flex flex-col gap-6 border-2 border-ink bg-paper p-5 lg:p-10 ${className ?? ''}`}
|
||||
className="relative flex flex-col gap-6 border-2 border-ink bg-paper p-5 lg:p-10"
|
||||
data-state={status}
|
||||
data-notice={notice ?? undefined}
|
||||
>
|
||||
|
||||
@@ -10,7 +10,6 @@ export type StampProps = {
|
||||
width?: number | string;
|
||||
/** поворот, градусы */
|
||||
rotate?: number;
|
||||
tone?: 'cherry' | 'cherry-on-dark';
|
||||
/** 'load' — оттиск при загрузке, 'event' — когда появился в DOM (успех формы), иначе статичен */
|
||||
animate?: 'load' | 'event';
|
||||
/** бумажная подложка — когда штамп лежит на фото */
|
||||
@@ -25,11 +24,11 @@ const LINE_H = { sm: 26, lg: 46 } as const;
|
||||
const FONT = { sm: 15, lg: 34 } as const;
|
||||
const PAD = 16;
|
||||
|
||||
export function Stamp({ lines, width = 168, rotate = -6, tone = 'cherry', animate, backing = false, offsetX = 0, className }: StampProps) {
|
||||
export function Stamp({ lines, width = 168, rotate = -6, animate, backing = false, offsetX = 0, className }: StampProps) {
|
||||
const heights = lines.map((l) => LINE_H[l.size ?? 'sm']);
|
||||
const H = PAD * 2 + heights.reduce((a, b) => a + b, 0);
|
||||
const tops = heights.map((_, i) => PAD + heights.slice(0, i).reduce((a, b) => a + b, 0));
|
||||
const color = `var(--color-${tone})`;
|
||||
const color = 'var(--color-cherry)';
|
||||
// maxWidth: на 320 этикетка уже штампа — штамп сжимается, а не выталкивает страницу вбок
|
||||
const style = {
|
||||
'--stamp-rotate': `${rotate}deg`,
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
// Липкая CTA на мобиле: видна, когда кнопки первого экрана ушли из кадра,
|
||||
// и прячется у формы, при фокусе в форме и при открытой клавиатуре.
|
||||
import { useEffect, useState } from 'react';
|
||||
import { site } from '@/content/site';
|
||||
import type { Interest } from '@kvarta/shared/lead';
|
||||
import { LEAD_SECTION_ID } from '@/lib/leadPrefill';
|
||||
import { CtaButton } from './CtaButton';
|
||||
|
||||
export function StickyCta() {
|
||||
// тексты приходят пропсами от серверного layout: импорт site утянул бы в клиентский бандл все тексты страницы
|
||||
export function StickyCta({ label, note, interest }: { label: string; note: string; interest: Interest }) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -50,7 +51,6 @@ export function StickyCta() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const { stickyCta } = site;
|
||||
return (
|
||||
<div
|
||||
className="sticky-cta fixed inset-x-0 bottom-0 z-40 flex items-center gap-4 border-t border-ink bg-cream px-5 pt-2 pb-[calc(8px+env(safe-area-inset-bottom))] md:hidden"
|
||||
@@ -58,9 +58,9 @@ export function StickyCta() {
|
||||
aria-hidden={visible ? undefined : true}
|
||||
inert={!visible}
|
||||
>
|
||||
<p className="min-w-0 flex-1 text-small font-medium text-muted">{stickyCta.note}</p>
|
||||
<CtaButton interest={stickyCta.interest} source="sticky" className="btn btn-primary flex-none">
|
||||
{stickyCta.label}
|
||||
<p className="min-w-0 flex-1 text-small font-medium text-muted">{note}</p>
|
||||
<CtaButton interest={interest} source="sticky" className="btn btn-primary flex-none">
|
||||
{label}
|
||||
</CtaButton>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Лента недели: дни «буква + число», ЧТ (обжарка) и ПТ (доставка) красным, скобка ПТ–ВС «1–3 день — в чашке».
|
||||
// 390 — столбиком, ≥1024 — строкой. Анимация — в CSS по [data-inview] (WeekStripObserver).
|
||||
// 390 — столбиком, ≥1024 — строкой. Анимация — в CSS по [data-inview] (RevealObserver).
|
||||
import { site } from '@/content/site';
|
||||
import type { RoastWeek } from '@/lib/roastWeek';
|
||||
import { typo } from '@/lib/typo';
|
||||
@@ -15,7 +15,7 @@ export function WeekStrip({ week }: { week: RoastWeek }) {
|
||||
const note = NOTES[i];
|
||||
const hot = note !== undefined;
|
||||
return (
|
||||
<li key={day.iso} className="week-day" data-hot={hot ? '' : undefined}>
|
||||
<li key={day.iso} className="week-day">
|
||||
<div className="week-head">
|
||||
<span className={`week-letter ${hot ? 'text-cherry' : 'text-muted'}`}>{day.letter}</span>
|
||||
<time dateTime={day.iso} className={`week-date mono-narrow ${hot ? 'text-cherry' : 'text-ink'}`}>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CONTACT_PHONE } from '@/content/form';
|
||||
import { site } from '@/content/site';
|
||||
import { typo } from '@/lib/typo';
|
||||
import { Logo } from '../Logo';
|
||||
@@ -16,8 +17,8 @@ export function Footer() {
|
||||
<p className="mt-1 text-muted-on-dark">{footer.hours}</p>
|
||||
</div>
|
||||
<div className="text-small">
|
||||
<a href={footer.phone.href} className="mono inline-flex min-h-11 items-center text-ui font-medium">
|
||||
{footer.phone.display}
|
||||
<a href={CONTACT_PHONE.href} className="mono inline-flex min-h-11 items-center text-ui font-medium">
|
||||
{CONTACT_PHONE.display}
|
||||
</a>
|
||||
<p className="text-muted-on-dark">{typo(footer.phoneNote)}</p>
|
||||
</div>
|
||||
|
||||
@@ -40,7 +40,7 @@ export function Subscription() {
|
||||
);
|
||||
}
|
||||
|
||||
/** Две пачки: силуэты с клапаном, на второй — знак. */
|
||||
/** Две пачки: силуэты с клапаном и знаком. */
|
||||
function Packs() {
|
||||
const pack = (x: number) => (
|
||||
<g transform={`translate(${x} 0)`}>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// Все тексты формы заявки и CTA в одном месте — для вычитки.
|
||||
import type { Interest } from '@kvarta/shared/lead';
|
||||
// Все тексты формы заявки в одном месте — для вычитки.
|
||||
|
||||
export const CONTACT_PHONE = {
|
||||
display: '+375 29 000-00-00',
|
||||
@@ -7,8 +6,6 @@ export const CONTACT_PHONE = {
|
||||
} as const;
|
||||
|
||||
export const FORM_TEXT = {
|
||||
title: 'Оставьте заявку',
|
||||
lead: 'Перезвоним в рабочее время и всё уточним.',
|
||||
name: { label: 'Имя', placeholder: 'Как к вам обращаться' },
|
||||
phone: { label: 'Телефон', placeholder: '+375 29 123-45-67' },
|
||||
interest: { legend: 'Что вас интересует' },
|
||||
@@ -26,11 +23,3 @@ export const FORM_TEXT = {
|
||||
errorTag: 'Не отправлено',
|
||||
stamp: { created: 'Принято', clarified: 'Дописали' },
|
||||
} as const;
|
||||
|
||||
export type CtaPreset = { label: string; interest: Interest; comment?: string };
|
||||
|
||||
export const CTAS = {
|
||||
subscription: { label: 'Оформить подписку', interest: 'subscription' },
|
||||
ethiopia: { label: 'Заказать пачку Эфиопии', interest: 'one_time', comment: 'Эфиопия Иргачеффе, 250 г' },
|
||||
question: { label: 'Задать вопрос', interest: 'question' },
|
||||
} as const satisfies Record<string, CtaPreset>;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// Все тексты и факты лендинга по блокам — для вычитки. Факты только из «Фактов из ТЗ» (docs/plan.md).
|
||||
// Тексты формы — в ./form.ts.
|
||||
import type { Interest } from '@kvarta/shared/lead';
|
||||
import { CONTACT_PHONE } from './form';
|
||||
|
||||
type Cta = { label: string; interest: Interest; comment?: string };
|
||||
|
||||
@@ -31,7 +30,6 @@ export const site = {
|
||||
|
||||
hero: {
|
||||
stamp: 'ЧТ · обжарка',
|
||||
eyebrow: 'Обжарка кофе в Минске',
|
||||
title: 'Кофе, обжаренный в четверг',
|
||||
subtitle:
|
||||
'В пятницу привозим по Минску, и пачка попадает к вам на 1–3 день после обжарки.',
|
||||
@@ -61,49 +59,37 @@ export const site = {
|
||||
id: 'ethiopia',
|
||||
name: 'Эфиопия Иргачеффе',
|
||||
notes: ['цветочный', 'бергамот'],
|
||||
roast: 'светлая обжарка',
|
||||
brew: null,
|
||||
// поле этикетки (итерация критика 1, нужна вычитка автора)
|
||||
spec: { label: 'Обжарка', value: 'светлая' },
|
||||
size: '250 г',
|
||||
price: 32,
|
||||
priceDisplay: '32 BYN',
|
||||
cta: { label: 'Заказать', interest: 'one_time', comment: 'Эфиопия Иргачеффе, 250 г' } satisfies Cta,
|
||||
},
|
||||
{
|
||||
id: 'colombia',
|
||||
name: 'Колумбия Уила',
|
||||
notes: ['карамель', 'красное яблоко'],
|
||||
roast: null,
|
||||
brew: null,
|
||||
spec: null,
|
||||
size: '250 г',
|
||||
price: 29,
|
||||
priceDisplay: '29 BYN',
|
||||
cta: { label: 'Заказать', interest: 'one_time', comment: 'Колумбия Уила, 250 г' } satisfies Cta,
|
||||
},
|
||||
{
|
||||
id: 'brazil',
|
||||
name: 'Бразилия Серрадо',
|
||||
notes: ['орех', 'шоколад'],
|
||||
roast: null,
|
||||
brew: 'под эспрессо',
|
||||
spec: { label: 'Для', value: 'эспрессо' },
|
||||
size: '250 г',
|
||||
price: 26,
|
||||
priceDisplay: '26 BYN',
|
||||
cta: { label: 'Заказать', interest: 'one_time', comment: 'Бразилия Серрадо, 250 г' } satisfies Cta,
|
||||
},
|
||||
{
|
||||
id: 'drip',
|
||||
name: 'Дрип-пакеты ассорти',
|
||||
notes: [],
|
||||
roast: null,
|
||||
brew: 'заваривается прямо в чашке',
|
||||
spec: { label: 'Как', value: 'заваривается прямо в чашке' },
|
||||
size: '10 шт',
|
||||
price: 18,
|
||||
priceDisplay: '18 BYN',
|
||||
cta: { label: 'Заказать', interest: 'one_time', comment: 'Дрип-пакеты ассорти, 10 шт' } satisfies Cta,
|
||||
},
|
||||
],
|
||||
@@ -113,7 +99,6 @@ export const site = {
|
||||
title: 'Две пачки каждый месяц',
|
||||
text: 'Раз в месяц привозим две пачки по 250 г. Доставка бесплатно.',
|
||||
price: 54,
|
||||
priceDisplay: '54 BYN',
|
||||
priceUnit: 'в месяц',
|
||||
note: 'Подробности менеджер расскажет по телефону.',
|
||||
fields: [
|
||||
@@ -176,7 +161,6 @@ export const site = {
|
||||
tagline: 'Обжарка кофе в Минске с 2019 года',
|
||||
address: 'Минск, ул. Короля, 9',
|
||||
hours: 'Пн–пт 8:00–20:00, сб–вс 10:00–18:00',
|
||||
phone: CONTACT_PHONE,
|
||||
phoneNote: 'Номер демонстрационный.',
|
||||
photoCredit: 'Фото: Mona Jain, Tina Guina, Scott Soltys-Curry / Unsplash',
|
||||
disclaimer: 'Учебный проект. Обжарка «Кварта» вымышленная.',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Чистая модель формы заявки: значения, видимые ошибки, тело запроса.
|
||||
import { validateLeadFields, type FieldErrors, type Interest, type LeadField, type LeadRequest, type Touch } from '@kvarta/shared/lead';
|
||||
import { LEAD_FIELDS } from './sendLead';
|
||||
|
||||
export type LeadFormValues = {
|
||||
name: string;
|
||||
@@ -26,7 +27,7 @@ export function visibleErrors(
|
||||
): FieldErrors {
|
||||
const client = validateLeadFields(values);
|
||||
const out: FieldErrors = {};
|
||||
for (const field of ['name', 'phone', 'interest', 'comment'] as const) {
|
||||
for (const field of LEAD_FIELDS) {
|
||||
const message = submitAttempted || touched[field] ? client[field] : undefined;
|
||||
const shown = message ?? serverErrors[field];
|
||||
if (shown) out[field] = shown;
|
||||
|
||||
@@ -13,14 +13,12 @@ export type LeadPrefillInput = {
|
||||
source?: string;
|
||||
};
|
||||
|
||||
export type LeadPrefill = LeadPrefillInput & { seq: number };
|
||||
type Listener = (prefill: LeadPrefillInput) => void;
|
||||
|
||||
type Listener = (prefill: LeadPrefill) => void;
|
||||
|
||||
let current: LeadPrefill | null = null;
|
||||
let current: LeadPrefillInput | null = null;
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
export function getLeadPrefill(): LeadPrefill | null {
|
||||
export function getLeadPrefill(): LeadPrefillInput | null {
|
||||
return current;
|
||||
}
|
||||
|
||||
@@ -33,8 +31,8 @@ export function subscribeLeadPrefill(listener: Listener): () => void {
|
||||
|
||||
/** Выставить значения формы, плавно доскроллить до #lead и поставить фокус в поле имени. */
|
||||
export function prefillLead(input: LeadPrefillInput): void {
|
||||
current = { ...input, seq: (current?.seq ?? 0) + 1 };
|
||||
for (const listener of listeners) listener(current);
|
||||
current = input;
|
||||
for (const listener of listeners) listener(input);
|
||||
if (typeof document === 'undefined') return;
|
||||
|
||||
const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
|
||||
|
||||
@@ -15,7 +15,7 @@ export type SubmitResult = {
|
||||
|
||||
export type SendLead = (body: LeadRequest) => Promise<SubmitResult>;
|
||||
|
||||
const FIELDS: readonly LeadField[] = ['name', 'phone', 'interest', 'comment'];
|
||||
export const LEAD_FIELDS = ['name', 'phone', 'interest', 'comment'] as const satisfies readonly LeadField[];
|
||||
|
||||
const success = (notice: 'created' | 'clarified'): SubmitResult => ({ status: 'success', notice, fieldErrors: {} });
|
||||
const error = (notice: SubmitNotice, fieldErrors: FieldErrors = {}): SubmitResult => ({
|
||||
@@ -31,7 +31,7 @@ function pickFieldErrors(value: unknown): FieldErrors {
|
||||
if (!value || typeof value !== 'object') return {};
|
||||
const src = value as Record<string, unknown>;
|
||||
const out: FieldErrors = {};
|
||||
for (const field of FIELDS) {
|
||||
for (const field of LEAD_FIELDS) {
|
||||
const message = src[field];
|
||||
if (typeof message === 'string' && message !== '') out[field] = message;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
const NBSP = ' ';
|
||||
const SHORT = 'а|в|во|и|к|ко|на|не|но|о|об|от|до|по|с|со|у|за|из|для|как|или';
|
||||
const SHORT_RE = new RegExp(`(^|[\\s(«—–-])(${SHORT}) `, 'giu');
|
||||
const SHORT_AFTER_NBSP_RE = new RegExp(`(${NBSP})(${SHORT}) `, 'giu');
|
||||
const UNIT_RE = /(\d) (кг|г|шт|BYN|мин|дн\.?|день|дня)(?=$|[\s.,:;!?)»])/gu;
|
||||
const NAME_HYPHEN_RE = /([A-Z][a-z]+)-([A-Z][a-z]+)/g;
|
||||
// имя и фамилия латиницей не разрываются: «Scott Soltys-Curry»
|
||||
@@ -11,7 +12,7 @@ const NAME_PAIR_RE = /\b([A-Z][a-z]+) (?=[A-Z][a-z]+)/g;
|
||||
export function typo(text: string): string {
|
||||
// дважды: «и в Минске» — после замены первого слова второе стоит уже после NBSP
|
||||
let out = text.replace(SHORT_RE, `$1$2${NBSP}`);
|
||||
out = out.replace(new RegExp(`(${NBSP})(${SHORT}) `, 'giu'), `$1$2${NBSP}`);
|
||||
out = out.replace(SHORT_AFTER_NBSP_RE, `$1$2${NBSP}`);
|
||||
return out
|
||||
.replace(UNIT_RE, `$1${NBSP}$2`)
|
||||
.replace(NAME_HYPHEN_RE, '$1\u2011$2')
|
||||
|
||||
@@ -5,14 +5,14 @@ import { validateLeadFields, type FieldErrors, type LeadField } from '@kvarta/sh
|
||||
import { useEffect, useRef, useState, type FormEvent } from 'react';
|
||||
import { getBrowserTouches } from './attribution';
|
||||
import { EMPTY_VALUES, buildLeadRequest, visibleErrors, type LeadFormValues, type Touched } from './leadFormModel';
|
||||
import { applyPrefill, getLeadPrefill, subscribeLeadPrefill, type LeadPrefillInput } from './leadPrefill';
|
||||
import { FAILED, sendLead as defaultSendLead, type SendLead, type SubmitNotice } from './sendLead';
|
||||
import { applyPrefill, getLeadPrefill, subscribeLeadPrefill } from './leadPrefill';
|
||||
import { FAILED, sendLead, type SubmitNotice } from './sendLead';
|
||||
|
||||
export type LeadFormStatus = 'idle' | 'submitting' | 'success' | 'error';
|
||||
type LeadFormStatus = 'idle' | 'submitting' | 'success' | 'error';
|
||||
|
||||
type FormState = { values: LeadFormValues; prefilledComment: string | null };
|
||||
|
||||
export function useLeadForm({ sendLead = defaultSendLead }: { sendLead?: SendLead } = {}) {
|
||||
export function useLeadForm() {
|
||||
// prefilledComment живёт рядом со значениями: updater должен быть чистым (StrictMode вызывает его дважды)
|
||||
const [form, setForm] = useState<FormState>(() => {
|
||||
const prefill = getLeadPrefill();
|
||||
@@ -32,7 +32,7 @@ export function useLeadForm({ sendLead = defaultSendLead }: { sendLead?: SendLea
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
subscribeLeadPrefill((prefill: LeadPrefillInput) => {
|
||||
subscribeLeadPrefill((prefill) => {
|
||||
source.current = prefill.source;
|
||||
setForm((prev) => applyPrefill(prev.values, prefill, prev.prefilledComment));
|
||||
// CTA после подтверждения — посетитель хочет новую заявку: возвращаем форму
|
||||
@@ -105,5 +105,3 @@ export function useLeadForm({ sendLead = defaultSendLead }: { sendLead?: SendLea
|
||||
|
||||
return { values, errors, status, notice, setField, blurField, submit, reset };
|
||||
}
|
||||
|
||||
export type LeadFormController = ReturnType<typeof useLeadForm>;
|
||||
|
||||
+13
-11
@@ -84,6 +84,9 @@ read_deployed() {
|
||||
remote "cat $REMOTE_DIR/.deployed 2>/dev/null || true"
|
||||
}
|
||||
|
||||
# значение поля из вывода read_deployed: deployed_field current "$state"
|
||||
deployed_field() { sed -n "s/^$1=//p" <<<"$2"; }
|
||||
|
||||
write_deployed() {
|
||||
local current=$1 previous=$2
|
||||
remote "printf 'current=%s\nprevious=%s\n' '$current' '$previous' > $REMOTE_DIR/.deployed"
|
||||
@@ -97,8 +100,8 @@ rollback() {
|
||||
step "Чтение $REMOTE_DIR/.deployed"
|
||||
local state current previous repo
|
||||
state=$(read_deployed)
|
||||
current=$(sed -n 's/^current=//p' <<<"$state")
|
||||
previous=$(sed -n 's/^previous=//p' <<<"$state")
|
||||
current=$(deployed_field current "$state")
|
||||
previous=$(deployed_field previous "$state")
|
||||
[[ -n $previous ]] || die "в .deployed нет предыдущего тега — откатываться некуда"
|
||||
info "сейчас $current, откатываемся на $previous"
|
||||
for repo in "${REPOS[@]}"; do
|
||||
@@ -154,20 +157,19 @@ REMOTE
|
||||
local hint="если в логе 'exec format error' — на этой машине нет эмуляции amd64:"
|
||||
hint+=" Docker Desktop → Settings → General → «Use Rosetta for x86_64/amd64 emulation»"
|
||||
hint+=" или docker run --privileged --rm tonistiigi/binfmt --install amd64"
|
||||
local name
|
||||
for name in landing api; do
|
||||
info "kvarta-$name"
|
||||
for repo in "${REPOS[@]}"; do
|
||||
info "$repo"
|
||||
docker buildx build --platform linux/amd64 --load \
|
||||
--provenance=false --sbom=false \
|
||||
-f "deploy/Dockerfile.$name" \
|
||||
-f "deploy/Dockerfile.${repo#kvarta-}" \
|
||||
--build-arg "APP_VERSION=$sha" \
|
||||
-t "kvarta-$name:$sha" -t "kvarta-$name:latest" \
|
||||
. || die "сборка kvarta-$name упала; $hint"
|
||||
-t "$repo:$sha" -t "$repo:latest" \
|
||||
. || die "сборка $repo упала; $hint"
|
||||
done
|
||||
docker image ls --format ' {{.Repository}}:{{.Tag}} {{.Size}}' | grep -E "^ kvarta-(landing|api):$sha " || true
|
||||
|
||||
step "Передача образов на сервер (docker save | gzip | ssh docker load)"
|
||||
docker save "kvarta-landing:$sha" "kvarta-api:$sha" | gzip | remote 'gunzip | docker load'
|
||||
docker save "${REPOS[@]/%/:$sha}" | gzip | remote 'gunzip | docker load'
|
||||
else
|
||||
step "Образы $sha уже есть на сервере — сборка и передача пропущены"
|
||||
fi
|
||||
@@ -187,10 +189,10 @@ REMOTE
|
||||
|
||||
local state previous
|
||||
state=$(read_deployed)
|
||||
previous=$(sed -n 's/^current=//p' <<<"$state")
|
||||
previous=$(deployed_field current "$state")
|
||||
# повторный деплой того же коммита не должен затирать предыдущий тег
|
||||
if [[ $previous == "$sha" ]]; then
|
||||
previous=$(sed -n 's/^previous=//p' <<<"$state")
|
||||
previous=$(deployed_field previous "$state")
|
||||
fi
|
||||
|
||||
compose_up "$sha"
|
||||
|
||||
Reference in New Issue
Block a user