fix(landing): не оставлять контакт из ссылки в адресной строке

Лендинг поднимает Яндекс.Метрику с webvisor, а она пишет URL страницы и
запись сессии. Личный email или @username из ?contact= уезжал бы в
аналитику, в Referer при переходе на оплату, в историю браузера и в логи
сервера. Читаем параметр и сразу убираем его через history.replaceState,
сохраняя остальные параметры и хеш, — тем же приёмом, что применяет
captureCampaignFromUrl().

Чтение и очистка вынесены в src/utils/contactPrefill.ts: в компоненте это
были бы непокрытые строки, а обращение с персональными данными хочется
иметь под тестами.
This commit is contained in:
Fringg
2026-08-18 15:11:49 +03:00
parent 8d0c33bb08
commit acbffa4e17
3 changed files with 175 additions and 8 deletions

View File

@@ -30,6 +30,7 @@ import LanguageSwitcher from '../components/LanguageSwitcher';
import { cn } from '../lib/utils';
import { getApiErrorMessage } from '../utils/api-error';
import { getPendingCampaignSlug } from '../utils/campaign';
import { readContactPrefill, stripContactFromUrl } from '../utils/contactPrefill';
import { formatPrice } from '../utils/format';
import { setFavicon, letterFaviconDataUri, roundedFaviconDataUri } from '../utils/favicon';
import { useCurrency } from '../hooks/useCurrency';
@@ -869,14 +870,12 @@ export default function QuickPurchase() {
const [selectedTariffId, setSelectedTariffId] = useState<number | null>(null);
const [selectedPeriodDays, setSelectedPeriodDays] = useState<number | null>(null);
const contactKey = `lp_contact_${slug ?? ''}`;
const [contactValue, setContactValue] = useState(() => {
try {
const urlContact = new URLSearchParams(window.location.search).get('contact');
return urlContact || localStorage.getItem(contactKey) || '';
} catch {
return '';
}
});
const [contactValue, setContactValue] = useState(() => readContactPrefill(contactKey));
// Контакт уже в состоянии — вычищаем его из адресной строки, чтобы личный
// email не уехал в Метрику, Referer и историю браузера.
useEffect(() => {
stripContactFromUrl();
}, []);
const [isGift, setIsGift] = useState(false);
const [giftRecipient, setGiftRecipient] = useState('');
const [giftMessage, setGiftMessage] = useState('');

View File

@@ -0,0 +1,127 @@
import { readFileSync } from 'node:fs';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { readContactPrefill, stripContactFromUrl } from './contactPrefill';
const STORAGE_KEY = 'lp_contact_promo';
let replaced: string[] = [];
function fakeLocalStorage(): Storage {
const store = new Map<string, string>();
return {
get length() {
return store.size;
},
clear: () => store.clear(),
getItem: (key: string) => store.get(key) ?? null,
key: (index: number) => [...store.keys()][index] ?? null,
removeItem: (key: string) => void store.delete(key),
setItem: (key: string, value: string) => void store.set(key, value),
} as Storage;
}
function stubLocation(search: string, pathname = '/buy/promo', hash = ''): void {
vi.stubGlobal('window', {
location: { search, pathname, hash },
history: {
replaceState: (_state: unknown, _title: string, url: string) => replaced.push(url),
},
});
}
beforeEach(() => {
replaced = [];
vi.stubGlobal('localStorage', fakeLocalStorage());
stubLocation('');
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe('readContactPrefill', () => {
it('takes the contact from the URL', () => {
stubLocation('?contact=client%40example.com');
expect(readContactPrefill(STORAGE_KEY)).toBe('client@example.com');
});
it('keeps the @ of a telegram username', () => {
stubLocation('?contact=%40durov');
expect(readContactPrefill(STORAGE_KEY)).toBe('@durov');
});
it('prefers the URL over the remembered value', () => {
localStorage.setItem(STORAGE_KEY, 'old@example.com');
stubLocation('?contact=new%40example.com');
expect(readContactPrefill(STORAGE_KEY)).toBe('new@example.com');
});
it('falls back to the remembered value', () => {
localStorage.setItem(STORAGE_KEY, 'old@example.com');
expect(readContactPrefill(STORAGE_KEY)).toBe('old@example.com');
});
it('returns an empty string when there is nothing to prefill', () => {
expect(readContactPrefill(STORAGE_KEY)).toBe('');
});
});
describe('stripContactFromUrl', () => {
// Лендинг поднимает Яндекс.Метрику с webvisor: оставленный в адресе email
// уедет в аналитику, в Referer при переходе на оплату и в историю браузера.
it('removes the contact from the address bar', () => {
stubLocation('?contact=client%40example.com');
stripContactFromUrl();
expect(replaced).toEqual(['/buy/promo']);
});
it('keeps the other query params', () => {
stubLocation('?campaign=summer&contact=client%40example.com&subid=42');
stripContactFromUrl();
expect(replaced).toHaveLength(1);
const params = new URLSearchParams(replaced[0].split('?')[1]);
expect(params.get('campaign')).toBe('summer');
expect(params.get('subid')).toBe('42');
expect(params.has('contact')).toBe(false);
});
it('keeps the hash', () => {
stubLocation('?contact=client%40example.com', '/buy/promo', '#tariffs');
stripContactFromUrl();
expect(replaced).toEqual(['/buy/promo#tariffs']);
});
it('does not touch the URL when there is no contact param', () => {
stubLocation('?campaign=summer');
stripContactFromUrl();
expect(replaced).toEqual([]);
});
});
// Компоненты здесь не рендерятся (vitest на node, без jsdom), поэтому вызовы
// фиксируем по исходнику: чтение без очистки оставит контакт в адресе, а это
// вся суть второй функции.
describe('QuickPurchase source', () => {
const source = readFileSync(new URL('../pages/QuickPurchase.tsx', import.meta.url), 'utf8');
it('prefills the contact field from the URL', () => {
expect(source).toContain('readContactPrefill(contactKey)');
});
it('cleans the contact out of the address bar', () => {
expect(source).toContain('stripContactFromUrl()');
});
});

View File

@@ -0,0 +1,41 @@
/**
* Предзаполнение поля контакта на странице быстрой покупки.
*
* Персональную ссылку вида `/buy/slug?contact=@username` формирует бот или
* Happ, чтобы клиенту не пришлось вводить контакт вручную.
*/
const CONTACT_PARAM = 'contact';
/**
* Контакт для формы: сначала параметр URL, иначе последнее введённое значение.
*/
export function readContactPrefill(storageKey: string): string {
try {
const fromUrl = new URLSearchParams(window.location.search).get(CONTACT_PARAM);
return fromUrl || localStorage.getItem(storageKey) || '';
} catch {
return '';
}
}
/**
* Убирает `contact` из адресной строки, сохраняя остальные параметры.
*
* Вызывать сразу после чтения. В параметре лежит личный email или @username, а
* лендинг инициализирует Яндекс.Метрику (та пишет URL страницы, а с
* `webvisor` — ещё и запись сессии). Без очистки контакт уезжает в аналитику,
* в `Referer` при переходе на оплату, в историю браузера и в логи. Тот же приём
* применяет `captureCampaignFromUrl()`.
*/
export function stripContactFromUrl(): void {
try {
const params = new URLSearchParams(window.location.search);
if (!params.has(CONTACT_PARAM)) return;
params.delete(CONTACT_PARAM);
const search = params.toString();
const url = window.location.pathname + (search ? `?${search}` : '') + window.location.hash;
window.history.replaceState(null, '', url);
} catch {}
}