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