diff --git a/src/pages/QuickPurchase.tsx b/src/pages/QuickPurchase.tsx index 37d3f45..7b1165a 100644 --- a/src/pages/QuickPurchase.tsx +++ b/src/pages/QuickPurchase.tsx @@ -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(null); const [selectedPeriodDays, setSelectedPeriodDays] = useState(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(''); diff --git a/src/utils/contactPrefill.test.ts b/src/utils/contactPrefill.test.ts new file mode 100644 index 0000000..2413104 --- /dev/null +++ b/src/utils/contactPrefill.test.ts @@ -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(); + 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()'); + }); +}); diff --git a/src/utils/contactPrefill.ts b/src/utils/contactPrefill.ts new file mode 100644 index 0000000..8a034c3 --- /dev/null +++ b/src/utils/contactPrefill.ts @@ -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 {} +}