mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-09-13 16:23:08 +00:00
fix(landing): не оставлять контакт из ссылки в адресной строке
Лендинг поднимает Яндекс.Метрику с webvisor, а она пишет URL страницы и запись сессии. Личный email или @username из ?contact= уезжал бы в аналитику, в Referer при переходе на оплату, в историю браузера и в логи сервера. Читаем параметр и сразу убираем его через history.replaceState, сохраняя остальные параметры и хеш, — тем же приёмом, что применяет captureCampaignFromUrl(). Чтение и очистка вынесены в src/utils/contactPrefill.ts: в компоненте это были бы непокрытые строки, а обращение с персональными данными хочется иметь под тестами.
This commit is contained in:
@@ -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('');
|
||||
|
||||
127
src/utils/contactPrefill.test.ts
Normal file
127
src/utils/contactPrefill.test.ts
Normal 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()');
|
||||
});
|
||||
});
|
||||
41
src/utils/contactPrefill.ts
Normal file
41
src/utils/contactPrefill.ts
Normal 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 {}
|
||||
}
|
||||
Reference in New Issue
Block a user