From a5ee348df0900d7211124a618b2279021818cc02 Mon Sep 17 00:00:00 2001 From: Nikolay Unuchek Date: Sat, 17 Oct 2026 15:41:59 +0300 Subject: [PATCH] =?UTF-8?q?feat(landing):=20=D0=BF=D0=B5=D1=80=D0=B2=D0=BE?= =?UTF-8?q?=D0=B5=20=D0=B8=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=D0=B4=D0=BD?= =?UTF-8?q?=D0=B5=D0=B5=20=D0=BA=D0=B0=D1=81=D0=B0=D0=BD=D0=B8=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/landing/src/app/layout.tsx | 6 +- apps/landing/src/components/TouchRecorder.tsx | 11 + apps/landing/src/lib/attribution.test.ts | 190 ++++++++++++++++++ apps/landing/src/lib/attribution.ts | 168 ++++++++++++++++ apps/landing/src/lib/useLeadForm.ts | 10 +- 5 files changed, 376 insertions(+), 9 deletions(-) create mode 100644 apps/landing/src/components/TouchRecorder.tsx create mode 100644 apps/landing/src/lib/attribution.test.ts create mode 100644 apps/landing/src/lib/attribution.ts 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);