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