feat(landing): первое и последнее касание

This commit is contained in:
2026-10-17 15:47:24 +03:00
parent 02da483747
commit a5ee348df0
5 changed files with 376 additions and 9 deletions
+5 -1
View File
@@ -1,4 +1,5 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { TouchRecorder } from '@/components/TouchRecorder';
import './globals.css'; import './globals.css';
export const metadata: Metadata = { export const metadata: Metadata = {
@@ -9,7 +10,10 @@ export const metadata: Metadata = {
export default function RootLayout({ children }: { children: React.ReactNode }) { export default function RootLayout({ children }: { children: React.ReactNode }) {
return ( return (
<html lang="ru"> <html lang="ru">
<body>{children}</body> <body>
{children}
<TouchRecorder />
</body>
</html> </html>
); );
} }
@@ -0,0 +1,11 @@
'use client';
// Фиксирует касание при загрузке страницы. Ничего не рисует.
import { useEffect } from 'react';
import { recordBrowserVisit } from '@/lib/attribution';
export function TouchRecorder() {
useEffect(() => {
recordBrowserVisit();
}, []);
return null;
}
+190
View File
@@ -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<string, string>();
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() });
});
});
+168
View File
@@ -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<Storage, 'getItem' | 'setItem'>;
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<Touch>;
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<string, string>();
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);
}
+2 -8
View File
@@ -3,6 +3,7 @@
import { formatBelarusPhone } from '@kvarta/shared/phone'; import { formatBelarusPhone } from '@kvarta/shared/phone';
import { validateLeadFields, type FieldErrors, type LeadField } from '@kvarta/shared/lead'; import { validateLeadFields, type FieldErrors, type LeadField } from '@kvarta/shared/lead';
import { useEffect, useRef, useState, type FormEvent } from 'react'; import { useEffect, useRef, useState, type FormEvent } from 'react';
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, type LeadPrefillInput } from './leadPrefill';
import { sendLead as defaultSendLead, type SendLead, type SubmitOutcome } from './sendLead'; 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; if (Object.keys(validateLeadFields(values)).length > 0) return;
setStatus('submitting'); setStatus('submitting');
const body = buildLeadRequest(values, { const body = buildLeadRequest(values, getBrowserTouches(source.current));
firstTouch: null,
lastTouch: {
landingPage: window.location.pathname + window.location.search,
submitPage: window.location.pathname + (source.current ? `#${source.current}` : ''),
at: new Date().toISOString(),
},
});
const outcome: SubmitOutcome = await sendLead(body); const outcome: SubmitOutcome = await sendLead(body);
if (outcome.kind === 'invalid') { if (outcome.kind === 'invalid') {
setServerErrors(outcome.fieldErrors); setServerErrors(outcome.fieldErrors);