feat(landing): первое и последнее касание
This commit is contained in:
@@ -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() });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user