diff --git a/apps/landing/src/app/layout.tsx b/apps/landing/src/app/layout.tsx
index caf83e3..78ea58b 100644
--- a/apps/landing/src/app/layout.tsx
+++ b/apps/landing/src/app/layout.tsx
@@ -1,4 +1,5 @@
import type { Metadata } from 'next';
+import { TouchRecorder } from '@/components/TouchRecorder';
import './globals.css';
export const metadata: Metadata = {
@@ -9,7 +10,10 @@ export const metadata: Metadata = {
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
-
{children}
+
+ {children}
+
+
);
}
diff --git a/apps/landing/src/components/TouchRecorder.tsx b/apps/landing/src/components/TouchRecorder.tsx
new file mode 100644
index 0000000..83ce8b3
--- /dev/null
+++ b/apps/landing/src/components/TouchRecorder.tsx
@@ -0,0 +1,11 @@
+'use client';
+// Фиксирует касание при загрузке страницы. Ничего не рисует.
+import { useEffect } from 'react';
+import { recordBrowserVisit } from '@/lib/attribution';
+
+export function TouchRecorder() {
+ useEffect(() => {
+ recordBrowserVisit();
+ }, []);
+ return null;
+}
diff --git a/apps/landing/src/lib/attribution.test.ts b/apps/landing/src/lib/attribution.test.ts
new file mode 100644
index 0000000..49e4698
--- /dev/null
+++ b/apps/landing/src/lib/attribution.test.ts
@@ -0,0 +1,190 @@
+import { describe, expect, it } from 'vitest';
+import {
+ FIRST_TOUCH_KEY,
+ LAST_TOUCH_KEY,
+ recordVisit,
+ readTouch,
+ touchFromVisit,
+ touchesForSubmit,
+ withMemoryFallback,
+ type StorageLike,
+ type TouchStores,
+} from './attribution';
+
+const SITE = 'https://kvarta.unuchak.com';
+const DAY = 24 * 60 * 60 * 1000;
+const t0 = new Date('2026-10-01T10:00:00.000Z');
+const later = (days: number) => new Date(t0.getTime() + days * DAY);
+
+class FakeStorage implements StorageLike {
+ data = new Map();
+ getItem(key: string) {
+ return this.data.get(key) ?? null;
+ }
+ setItem(key: string, value: string) {
+ this.data.set(key, value);
+ }
+}
+
+const throwing: StorageLike = {
+ getItem() {
+ throw new DOMException('denied', 'SecurityError');
+ },
+ setItem() {
+ throw new DOMException('quota', 'QuotaExceededError');
+ },
+};
+
+function stores(): TouchStores & { session: FakeStorage; local: FakeStorage } {
+ return { session: new FakeStorage(), local: new FakeStorage() };
+}
+
+const ad = `${SITE}/?utm_source=yandex&utm_medium=cpc&utm_campaign=osen&yclid=123`;
+
+describe('touchFromVisit', () => {
+ it('собирает метки, страницу входа и время', () => {
+ const touch = touchFromVisit({
+ url: `${SITE}/beans?utm_source=instagram&utm_medium=social&utm_campaign=autumn&utm_content=reel&utm_term=coffee&fbclid=FB1&foo=bar#lead`,
+ referrer: '',
+ now: t0,
+ });
+ expect(touch).toEqual({
+ utm_source: 'instagram',
+ utm_medium: 'social',
+ utm_campaign: 'autumn',
+ utm_content: 'reel',
+ utm_term: 'coffee',
+ fbclid: 'FB1',
+ landingPage: '/beans?utm_source=instagram&utm_medium=social&utm_campaign=autumn&utm_content=reel&utm_term=coffee&fbclid=FB1&foo=bar',
+ at: '2026-10-01T10:00:00.000Z',
+ });
+ });
+
+ it('пустые метки не пишет, длинные обрезает до 500', () => {
+ const touch = touchFromVisit({ url: `${SITE}/?utm_source=&utm_campaign=${'x'.repeat(600)}`, referrer: '', now: t0 });
+ expect(touch.utm_source).toBeUndefined();
+ expect(touch.utm_campaign).toHaveLength(500);
+ });
+
+ it('внешний referrer сохраняет', () => {
+ const touch = touchFromVisit({ url: `${SITE}/`, referrer: 'https://www.instagram.com/', now: t0 });
+ expect(touch.referrer).toBe('https://www.instagram.com/');
+ });
+
+ it('свой домен referrer\'ом не считает', () => {
+ const touch = touchFromVisit({ url: `${SITE}/`, referrer: `${SITE}/beans`, now: t0 });
+ expect(touch.referrer).toBeUndefined();
+ });
+
+ it('битый referrer отбрасывает', () => {
+ expect(touchFromVisit({ url: `${SITE}/`, referrer: 'not a url', now: t0 }).referrer).toBeUndefined();
+ });
+});
+
+describe('последнее касание', () => {
+ it('пишется при первом заходе в сессию', () => {
+ const s = stores();
+ const { lastTouch } = recordVisit({ url: ad, referrer: 'https://yandex.by/', now: t0 }, s);
+ expect(lastTouch.utm_source).toBe('yandex');
+ expect(readTouch(s.session, LAST_TOUCH_KEY)).toEqual(lastTouch);
+ });
+
+ it('переход без меток в той же сессии его не меняет', () => {
+ const s = stores();
+ recordVisit({ url: ad, referrer: '', now: t0 }, s);
+ const { lastTouch } = recordVisit({ url: `${SITE}/`, referrer: `${SITE}/`, now: later(0.01) }, s);
+ expect(lastTouch).toMatchObject({ utm_source: 'yandex', at: t0.toISOString() });
+ });
+
+ it('новая ссылка с другими метками в той же сессии — обновляет', () => {
+ const s = stores();
+ recordVisit({ url: ad, referrer: '', now: t0 }, s);
+ const { lastTouch } = recordVisit({ url: `${SITE}/?utm_source=instagram`, referrer: '', now: later(0.01) }, s);
+ expect(lastTouch.utm_source).toBe('instagram');
+ expect(lastTouch.utm_campaign).toBeUndefined();
+ });
+
+ it('перезагрузка с теми же метками не сдвигает время', () => {
+ const s = stores();
+ recordVisit({ url: ad, referrer: '', now: t0 }, s);
+ const { lastTouch } = recordVisit({ url: ad, referrer: '', now: later(0.01) }, s);
+ expect(lastTouch.at).toBe(t0.toISOString());
+ });
+
+ it('при отправке получает submitPage, первое касание — нет', () => {
+ const s = stores();
+ recordVisit({ url: ad, referrer: '', now: t0 }, s);
+ const touches = touchesForSubmit({ url: `${SITE}/#lead`, referrer: '', now: later(0.01) }, s, '/#hero');
+ expect(touches.lastTouch).toMatchObject({ utm_source: 'yandex', submitPage: '/#hero' });
+ expect(touches.firstTouch?.submitPage).toBeUndefined();
+ expect(readTouch(s.session, LAST_TOUCH_KEY)?.submitPage).toBeUndefined();
+ });
+});
+
+describe('первое касание', () => {
+ it('прямой визит без меток тоже фиксируется', () => {
+ const s = stores();
+ const { firstTouch } = recordVisit({ url: `${SITE}/`, referrer: '', now: t0 }, s);
+ expect(firstTouch).toEqual({ landingPage: '/', at: t0.toISOString() });
+ expect(readTouch(s.local, FIRST_TOUCH_KEY)).toEqual(firstTouch);
+ });
+
+ it('прямой визит не перезаписывается рекламным', () => {
+ const s = stores();
+ recordVisit({ url: `${SITE}/`, referrer: '', now: t0 }, s);
+ const { firstTouch, lastTouch } = recordVisit({ url: ad, referrer: '', now: later(3) }, s);
+ expect(firstTouch).toEqual({ landingPage: '/', at: t0.toISOString() });
+ expect(lastTouch.utm_source).toBe('yandex');
+ });
+
+ it('на 29-й день ещё живо', () => {
+ const s = stores();
+ recordVisit({ url: ad, referrer: '', now: t0 }, s);
+ s.session.data.clear(); // новая сессия
+ const { firstTouch } = recordVisit({ url: `${SITE}/?utm_source=instagram`, referrer: '', now: later(29) }, s);
+ expect(firstTouch).toMatchObject({ utm_source: 'yandex', at: t0.toISOString() });
+ });
+
+ it('на 31-й день заменяется текущим визитом', () => {
+ const s = stores();
+ recordVisit({ url: ad, referrer: '', now: t0 }, s);
+ s.session.data.clear();
+ const { firstTouch } = recordVisit({ url: `${SITE}/?utm_source=instagram`, referrer: '', now: later(31) }, s);
+ expect(firstTouch).toMatchObject({ utm_source: 'instagram', at: later(31).toISOString() });
+ expect(readTouch(s.local, FIRST_TOUCH_KEY)?.utm_source).toBe('instagram');
+ });
+
+ it('битая запись считается отсутствующей', () => {
+ const s = stores();
+ s.local.setItem(FIRST_TOUCH_KEY, '{oops');
+ const { firstTouch } = recordVisit({ url: ad, referrer: '', now: t0 }, s);
+ expect(firstTouch.utm_source).toBe('yandex');
+ });
+});
+
+describe('хранилище бросает исключение', () => {
+ it('касания всё равно собираются из текущего визита', () => {
+ const touches = touchesForSubmit(
+ { url: ad, referrer: 'https://yandex.by/', now: t0 },
+ { session: throwing, local: throwing },
+ '/',
+ );
+ expect(touches.lastTouch).toMatchObject({ utm_source: 'yandex', referrer: 'https://yandex.by/', submitPage: '/' });
+ expect(touches.firstTouch?.utm_source).toBe('yandex');
+ });
+
+ it('хранилищ нет совсем', () => {
+ expect(() => recordVisit({ url: ad, referrer: '', now: t0 }, { session: null, local: null })).not.toThrow();
+ });
+
+ it('запасная память держит касание, пока страница открыта', () => {
+ const session = withMemoryFallback(() => throwing);
+ const local = withMemoryFallback(() => {
+ throw new DOMException('denied', 'SecurityError');
+ });
+ recordVisit({ url: ad, referrer: '', now: t0 }, { session, local });
+ const touches = touchesForSubmit({ url: `${SITE}/`, referrer: '', now: later(0.01) }, { session, local }, '/');
+ expect(touches.lastTouch).toMatchObject({ utm_source: 'yandex', at: t0.toISOString() });
+ expect(touches.firstTouch).toMatchObject({ utm_source: 'yandex', at: t0.toISOString() });
+ });
+});
diff --git a/apps/landing/src/lib/attribution.ts b/apps/landing/src/lib/attribution.ts
new file mode 100644
index 0000000..4bbc56e
--- /dev/null
+++ b/apps/landing/src/lib/attribution.ts
@@ -0,0 +1,168 @@
+// Первое и последнее касание (см. GLOSSARY.md, docs/plan.md «Касания»).
+// Чистые функции принимают URL, referrer, время и хранилища параметрами — тестируются без DOM.
+// Внизу — тонкая обёртка для браузера.
+import { LIMITS, type Touch } from '@kvarta/shared/lead';
+
+export const FIRST_TOUCH_KEY = 'kvarta:first-touch';
+export const LAST_TOUCH_KEY = 'kvarta:last-touch';
+export const FIRST_TOUCH_TTL_MS = 30 * 24 * 60 * 60 * 1000;
+
+export const MARK_KEYS = [
+ 'utm_source',
+ 'utm_medium',
+ 'utm_campaign',
+ 'utm_content',
+ 'utm_term',
+ 'yclid',
+ 'fbclid',
+] as const;
+
+export type StorageLike = Pick;
+export type TouchStores = { session: StorageLike | null; local: StorageLike | null };
+export type Visit = { url: string; referrer: string; now: Date };
+export type Touches = { firstTouch: Touch | null; lastTouch: Touch };
+
+const clip = (s: string) => s.slice(0, LIMITS.touchValue);
+
+/** Касание текущего визита: метки из URL, внешний referrer, страница входа. */
+export function touchFromVisit({ url, referrer, now }: Visit): Touch {
+ const page = new URL(url);
+ const touch: Touch = { landingPage: clip(page.pathname + page.search), at: now.toISOString() };
+ for (const key of MARK_KEYS) {
+ const value = page.searchParams.get(key)?.trim();
+ if (value) touch[key] = clip(value);
+ }
+ const ref = externalReferrer(referrer, page.host);
+ if (ref) touch.referrer = clip(ref);
+ return touch;
+}
+
+/** Свой домен referrer'ом не считаем: это переход внутри лендинга, а не источник. */
+function externalReferrer(referrer: string, ownHost: string): string | null {
+ if (!referrer) return null;
+ try {
+ return new URL(referrer).host === ownHost ? null : referrer;
+ } catch {
+ return null;
+ }
+}
+
+export function hasMarks(touch: Touch): boolean {
+ return MARK_KEYS.some((key) => touch[key]);
+}
+
+const sameMarks = (a: Touch, b: Touch) => MARK_KEYS.every((key) => a[key] === b[key]);
+
+/** Последнее касание сессии: первое за сессию, либо новый заход по ссылке с другими метками. */
+export function nextLastTouch(stored: Touch | null, visit: Touch): Touch {
+ if (!stored) return visit;
+ return hasMarks(visit) && !sameMarks(stored, visit) ? visit : stored;
+}
+
+/** Первое касание: живёт 30 дней и до истечения не перезаписывается — даже рекламным визитом. */
+export function nextFirstTouch(stored: Touch | null, visit: Touch, now: Date): Touch {
+ if (!stored) return visit;
+ const age = now.getTime() - Date.parse(stored.at);
+ return Number.isFinite(age) && age < FIRST_TOUCH_TTL_MS ? stored : visit;
+}
+
+export function readTouch(storage: StorageLike | null, key: string): Touch | null {
+ try {
+ const raw = storage?.getItem(key);
+ if (!raw) return null;
+ const parsed: unknown = JSON.parse(raw);
+ if (!parsed || typeof parsed !== 'object') return null;
+ const touch = parsed as Partial;
+ return typeof touch.landingPage === 'string' && typeof touch.at === 'string' ? (touch as Touch) : null;
+ } catch {
+ return null;
+ }
+}
+
+export function writeTouch(storage: StorageLike | null, key: string, touch: Touch): void {
+ try {
+ storage?.setItem(key, JSON.stringify(touch));
+ } catch {
+ // хранилище недоступно или переполнено — касание просто не запомнится
+ }
+}
+
+/** Зафиксировать визит в хранилищах; вернуть актуальные первое и последнее касания. */
+export function recordVisit(visit: Visit, stores: TouchStores): { firstTouch: Touch; lastTouch: Touch } {
+ const current = touchFromVisit(visit);
+
+ const lastTouch = nextLastTouch(readTouch(stores.session, LAST_TOUCH_KEY), current);
+ if (lastTouch === current) writeTouch(stores.session, LAST_TOUCH_KEY, lastTouch);
+
+ const firstTouch = nextFirstTouch(readTouch(stores.local, FIRST_TOUCH_KEY), current, visit.now);
+ if (firstTouch === current) writeTouch(stores.local, FIRST_TOUCH_KEY, firstTouch);
+
+ return { firstTouch, lastTouch };
+}
+
+/** Касания для тела заявки: последнее — со страницей отправки. */
+export function touchesForSubmit(visit: Visit, stores: TouchStores, submitPage: string): Touches {
+ const { firstTouch, lastTouch } = recordVisit(visit, stores);
+ return { firstTouch, lastTouch: { ...lastTouch, submitPage: clip(submitPage) } };
+}
+
+/**
+ * Хранилище с запасной памятью: если настоящее бросает (приватный режим, запрет cookies, квота),
+ * касания живут хотя бы до перезагрузки страницы.
+ */
+export function withMemoryFallback(getStorage: () => StorageLike | null): StorageLike {
+ const memory = new Map();
+ const storage = () => {
+ try {
+ return getStorage();
+ } catch {
+ return null;
+ }
+ };
+ return {
+ getItem(key) {
+ try {
+ const value = storage()?.getItem(key);
+ if (value != null) return value;
+ } catch {
+ // падаем на память
+ }
+ return memory.get(key) ?? null;
+ },
+ setItem(key, value) {
+ memory.set(key, value);
+ try {
+ storage()?.setItem(key, value);
+ } catch {
+ // память уже записана
+ }
+ },
+ };
+}
+
+// ——— браузер ———
+
+let browserStores: TouchStores | null = null;
+
+function getBrowserStores(): TouchStores {
+ browserStores ??= {
+ session: withMemoryFallback(() => window.sessionStorage),
+ local: withMemoryFallback(() => window.localStorage),
+ };
+ return browserStores;
+}
+
+function currentVisit(): Visit {
+ return { url: window.location.href, referrer: document.referrer, now: new Date() };
+}
+
+/** Вызвать при загрузке страницы. */
+export function recordBrowserVisit(): void {
+ recordVisit(currentVisit(), getBrowserStores());
+}
+
+/** Касания для отправки; section — id секции, откуда пришла CTA, если известно. */
+export function getBrowserTouches(section?: string): Touches {
+ const submitPage = window.location.pathname + (section ? `#${section}` : '');
+ return touchesForSubmit(currentVisit(), getBrowserStores(), submitPage);
+}
diff --git a/apps/landing/src/lib/useLeadForm.ts b/apps/landing/src/lib/useLeadForm.ts
index 5a2fd21..a4fac8a 100644
--- a/apps/landing/src/lib/useLeadForm.ts
+++ b/apps/landing/src/lib/useLeadForm.ts
@@ -3,6 +3,7 @@
import { formatBelarusPhone } from '@kvarta/shared/phone';
import { validateLeadFields, type FieldErrors, type LeadField } from '@kvarta/shared/lead';
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 { sendLead as defaultSendLead, type SendLead, type SubmitOutcome } from './sendLead';
@@ -61,14 +62,7 @@ export function useLeadForm({ sendLead = defaultSendLead }: { sendLead?: SendLea
if (Object.keys(validateLeadFields(values)).length > 0) return;
setStatus('submitting');
- const body = buildLeadRequest(values, {
- firstTouch: null,
- lastTouch: {
- landingPage: window.location.pathname + window.location.search,
- submitPage: window.location.pathname + (source.current ? `#${source.current}` : ''),
- at: new Date().toISOString(),
- },
- });
+ const body = buildLeadRequest(values, getBrowserTouches(source.current));
const outcome: SubmitOutcome = await sendLead(body);
if (outcome.kind === 'invalid') {
setServerErrors(outcome.fieldErrors);