mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
feat: Yandex Metrika CID tracking, offline conversions UI, sticky pay button
- Pass yandex_cid to all auth endpoints (telegram, email, OIDC, OAuth) - Add OfflineConvGoal interface and offline_conv_* fields to AnalyticsCounters - Add storeYandexCid API method for server-side CID persistence - Add analytics fields to LandingConfig (view/click goals, sticky_pay_button) - Add yandex_cid/referrer/subid to PurchaseRequest - Add offline conversions UI block in admin AnalyticsTab - Add cacheYandexCid, syncYandexCid, fireAnalyticsEvent to analytics hook - Create yandexCid.ts utility (localStorage get/set helpers) - Add sticky pay button with portal on mobile in QuickPurchase - Fire view/click analytics goals on landing pages - Persist contact value and referrer/subid in session/localStorage - Add i18n keys for offlineConv and apiKey in all 4 locales
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import apiClient from './client';
|
||||
import { getYandexCid } from '../utils/yandexCid';
|
||||
import type {
|
||||
AuthResponse,
|
||||
LinkCallbackResponse,
|
||||
@@ -22,6 +23,7 @@ export const authApi = {
|
||||
init_data: initData,
|
||||
campaign_slug: campaignSlug || undefined,
|
||||
referral_code: referralCode || undefined,
|
||||
yandex_cid: getYandexCid() || undefined,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
@@ -43,6 +45,7 @@ export const authApi = {
|
||||
...data,
|
||||
campaign_slug: campaignSlug || undefined,
|
||||
referral_code: referralCode || undefined,
|
||||
yandex_cid: getYandexCid() || undefined,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
@@ -56,6 +59,7 @@ export const authApi = {
|
||||
id_token: idToken,
|
||||
campaign_slug: campaignSlug || undefined,
|
||||
referral_code: referralCode || undefined,
|
||||
yandex_cid: getYandexCid() || undefined,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
@@ -71,6 +75,7 @@ export const authApi = {
|
||||
password,
|
||||
campaign_slug: campaignSlug || undefined,
|
||||
referral_code: referralCode || undefined,
|
||||
yandex_cid: getYandexCid() || undefined,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
@@ -87,6 +92,7 @@ export const authApi = {
|
||||
const response = await apiClient.post('/cabinet/auth/email/register', {
|
||||
email,
|
||||
password,
|
||||
yandex_cid: getYandexCid() || undefined,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
@@ -101,7 +107,7 @@ export const authApi = {
|
||||
}): Promise<RegisterResponse> => {
|
||||
const response = await apiClient.post<RegisterResponse>(
|
||||
'/cabinet/auth/email/register/standalone',
|
||||
data,
|
||||
{ ...data, yandex_cid: getYandexCid() || undefined },
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
@@ -198,6 +204,7 @@ export const authApi = {
|
||||
device_id: deviceId || undefined,
|
||||
campaign_slug: campaignSlug || undefined,
|
||||
referral_code: referralCode || undefined,
|
||||
yandex_cid: getYandexCid() || undefined,
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
|
||||
@@ -38,10 +38,20 @@ export interface TelegramWidgetConfig {
|
||||
oidc_client_id: string;
|
||||
}
|
||||
|
||||
export interface OfflineConvGoal {
|
||||
name: string;
|
||||
event_id: string;
|
||||
dedup: string;
|
||||
}
|
||||
|
||||
export interface AnalyticsCounters {
|
||||
yandex_metrika_id: string;
|
||||
google_ads_id: string;
|
||||
google_ads_label: string;
|
||||
offline_conv_enabled?: boolean;
|
||||
offline_conv_counter_id?: string;
|
||||
offline_conv_measurement_secret_masked?: string;
|
||||
offline_conv_goals?: OfflineConvGoal[];
|
||||
}
|
||||
|
||||
const BRANDING_CACHE_KEY = 'cabinet_branding';
|
||||
@@ -274,7 +284,14 @@ export const brandingApi = {
|
||||
const response = await apiClient.get<AnalyticsCounters>('/cabinet/branding/analytics');
|
||||
return response.data;
|
||||
} catch {
|
||||
return { yandex_metrika_id: '', google_ads_id: '', google_ads_label: '' };
|
||||
return {
|
||||
yandex_metrika_id: '',
|
||||
google_ads_id: '',
|
||||
google_ads_label: '',
|
||||
offline_conv_enabled: false,
|
||||
offline_conv_counter_id: '',
|
||||
offline_conv_goals: [],
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
@@ -303,4 +320,9 @@ export const brandingApi = {
|
||||
const response = await apiClient.patch<AnalyticsCounters>('/cabinet/branding/analytics', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Store Yandex Metrika ClientID for the authenticated cabinet user
|
||||
storeYandexCid: async (cid: string): Promise<void> => {
|
||||
await apiClient.post('/cabinet/branding/analytics/yandex-cid', { cid });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -89,6 +89,12 @@ export interface LandingConfig {
|
||||
meta_description: string | null;
|
||||
discount: LandingDiscountInfo | null;
|
||||
background_config: AnimationConfig | null;
|
||||
// Per-landing analytics goals + sticky pay button (bot PR #2852 backend fields)
|
||||
analytics_view_enabled?: boolean;
|
||||
analytics_view_goal?: string | null;
|
||||
analytics_click_enabled?: boolean;
|
||||
analytics_click_goal?: string | null;
|
||||
sticky_pay_button?: boolean;
|
||||
}
|
||||
|
||||
export interface PurchaseRequest {
|
||||
@@ -102,6 +108,10 @@ export interface PurchaseRequest {
|
||||
gift_recipient_value?: string;
|
||||
gift_message?: string;
|
||||
language?: string;
|
||||
// Yandex offline-conversions linkage (bot PR #2851 backend fields)
|
||||
yandex_cid?: string;
|
||||
referrer?: string;
|
||||
subid?: string;
|
||||
}
|
||||
|
||||
export interface PurchaseResponse {
|
||||
|
||||
@@ -161,6 +161,65 @@ export function AnalyticsTab() {
|
||||
)}
|
||||
<p className="text-xs text-dark-500">{t('admin.settings.yandexIdHint')}</p>
|
||||
</div>
|
||||
{/* Offline Conversions (inside Yandex block) */}
|
||||
{analytics?.offline_conv_enabled && (
|
||||
<>
|
||||
<div className="my-5 border-t border-dark-700/30" />
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="h-4 w-4 text-orange-400" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M16 6l2.29 2.29-4.88 4.88-4-4L2 16.59 3.41 18l6-6 4 4 6.3-6.29L22 12V6z" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium text-dark-200">
|
||||
{t('admin.settings.offlineConv', 'Offline Conversions')}
|
||||
</span>
|
||||
</div>
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-success-500/15 px-2 py-0.5 text-xs font-medium text-success-400">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-success-400" />
|
||||
{t('admin.settings.counterActive')}
|
||||
</span>
|
||||
</div>
|
||||
{analytics.offline_conv_counter_id && (
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className="text-sm text-dark-400">{t('admin.settings.counterId')}:</span>
|
||||
<span className="font-mono text-sm text-dark-200">
|
||||
{analytics.offline_conv_counter_id}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{analytics.offline_conv_measurement_secret_masked && (
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<span className="text-sm text-dark-400">
|
||||
{t('admin.settings.apiKey', 'API Key')}:
|
||||
</span>
|
||||
<code className="rounded-md bg-dark-700/50 px-2 py-0.5 font-mono text-sm text-dark-300">
|
||||
{analytics.offline_conv_measurement_secret_masked}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
{analytics.offline_conv_goals && analytics.offline_conv_goals.length > 0 && (
|
||||
<div className="grid gap-2">
|
||||
{analytics.offline_conv_goals.map((goal) => (
|
||||
<div
|
||||
key={goal.event_id}
|
||||
className="flex items-center justify-between rounded-xl border border-dark-700/30 bg-dark-900/40 px-4 py-2.5"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="inline-flex h-2 w-2 rounded-full bg-success-400" />
|
||||
<span className="text-sm text-dark-200">{goal.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<code className="rounded-md bg-dark-700/50 px-2 py-0.5 text-xs text-dark-400">
|
||||
{goal.event_id}
|
||||
</code>
|
||||
<span className="text-xs text-dark-500">{goal.dedup}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Google Ads */}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { brandingApi } from '../api/branding';
|
||||
import { setYandexCid } from '../utils/yandexCid';
|
||||
|
||||
const YM_SCRIPT_ID = 'ym-counter-script';
|
||||
const GTAG_LOADER_ID = 'gtag-loader-script';
|
||||
@@ -11,6 +12,11 @@ function removeElement(id: string) {
|
||||
}
|
||||
|
||||
function injectYandexMetrika(counterId: string) {
|
||||
try {
|
||||
localStorage.setItem('ym_counter_id', counterId);
|
||||
} catch {
|
||||
/* sandboxed / private */
|
||||
}
|
||||
if (document.getElementById(YM_SCRIPT_ID)) return;
|
||||
|
||||
const script = document.createElement('script');
|
||||
@@ -70,6 +76,8 @@ export function useAnalyticsCounters() {
|
||||
// Yandex Metrika
|
||||
if (data.yandex_metrika_id) {
|
||||
injectYandexMetrika(data.yandex_metrika_id);
|
||||
cacheYandexCid(data.yandex_metrika_id);
|
||||
syncYandexCid(data.yandex_metrika_id);
|
||||
} else {
|
||||
removeElement(YM_SCRIPT_ID);
|
||||
}
|
||||
@@ -83,3 +91,87 @@ export function useAnalyticsCounters() {
|
||||
}
|
||||
}, [data]);
|
||||
}
|
||||
|
||||
function cacheYandexCid(counterId: string) {
|
||||
const w = window as unknown as Record<string, unknown>;
|
||||
const ym = w.ym as ((...args: unknown[]) => void) | undefined;
|
||||
if (typeof ym !== 'function') return;
|
||||
setTimeout(() => {
|
||||
try {
|
||||
(w.ym as (...args: unknown[]) => void)(Number(counterId), 'getClientID', (cid: string) => {
|
||||
if (cid) setYandexCid(cid);
|
||||
});
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function syncYandexCid(counterId: string) {
|
||||
const SENT_KEY = 'ym_cid_sent';
|
||||
try {
|
||||
if (localStorage.getItem(SENT_KEY)) return;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const w = window as unknown as Record<string, unknown>;
|
||||
const ym = w.ym as ((...args: unknown[]) => void) | undefined;
|
||||
if (typeof ym !== 'function') return;
|
||||
setTimeout(() => {
|
||||
try {
|
||||
(w.ym as (...args: unknown[]) => void)(Number(counterId), 'getClientID', (cid: string) => {
|
||||
if (!cid) return;
|
||||
setYandexCid(cid);
|
||||
// Only POST when the user is authenticated. Guest sessions cache the
|
||||
// CID locally; it gets synced after login by the next mount of this
|
||||
// hook on the cabinet shell.
|
||||
let token: string | null = null;
|
||||
try {
|
||||
token = localStorage.getItem('access_token');
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
if (!token) return;
|
||||
// Route through brandingApi (apiClient) so baseURL, auth refresh, and
|
||||
// error handling all flow through the same interceptors as every other
|
||||
// cabinet API call.
|
||||
import('../api/branding').then(({ brandingApi: api }) => {
|
||||
api
|
||||
.storeYandexCid(cid)
|
||||
.then(() => {
|
||||
try {
|
||||
localStorage.setItem(SENT_KEY, '1');
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
/* swallow -- non-critical, will retry on next login */
|
||||
});
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
export function fireAnalyticsEvent(goalName: string, params?: Record<string, unknown>) {
|
||||
const w = window as unknown as Record<string, unknown>;
|
||||
const ym = w.ym as ((...args: unknown[]) => void) | undefined;
|
||||
if (typeof ym === 'function') {
|
||||
try {
|
||||
const counterId = localStorage.getItem('ym_counter_id');
|
||||
if (counterId && /^\d{1,15}$/.test(counterId)) {
|
||||
ym(Number(counterId), 'reachGoal', goalName, params);
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export the canonical CID accessor from utils for back-compat with
|
||||
// existing imports inside this hook file. New code should import from
|
||||
// '../utils/yandexCid' directly to avoid pulling React into api/.
|
||||
export { getYandexCid } from '../utils/yandexCid';
|
||||
|
||||
@@ -2021,7 +2021,9 @@
|
||||
"telegramOidcEnabled": "OIDC Enabled",
|
||||
"telegramOidcClientId": "Client ID (Bot ID)",
|
||||
"telegramOidcClientSecret": "Client Secret"
|
||||
}
|
||||
},
|
||||
"offlineConv": "Offline Conversions",
|
||||
"apiKey": "API Key"
|
||||
},
|
||||
"bulkActions": {
|
||||
"title": "Bulk Actions",
|
||||
|
||||
@@ -1671,7 +1671,9 @@
|
||||
"telegramOidcEnabled": "فعالسازی OIDC",
|
||||
"telegramOidcClientId": "Client ID (شناسه ربات)",
|
||||
"telegramOidcClientSecret": "Client Secret"
|
||||
}
|
||||
},
|
||||
"offlineConv": "\u062a\u0628\u062f\u06cc\u0644\u200c\u0647\u0627\u06cc \u0622\u0641\u0644\u0627\u06cc\u0646",
|
||||
"apiKey": "\u06a9\u0644\u06cc\u062f API"
|
||||
},
|
||||
"buttons": {
|
||||
"color": "رنگ دکمه",
|
||||
|
||||
@@ -2533,7 +2533,9 @@
|
||||
"TIMEZONE": "Часовой пояс",
|
||||
"REFERRAL": "Реферальная система",
|
||||
"MODERATION": "Модерация"
|
||||
}
|
||||
},
|
||||
"offlineConv": "Офлайн-конверсии",
|
||||
"apiKey": "API-ключ"
|
||||
},
|
||||
"buttons": {
|
||||
"color": "Цвет кнопки",
|
||||
|
||||
@@ -1709,7 +1709,9 @@
|
||||
"telegramOidcEnabled": "启用 OIDC",
|
||||
"telegramOidcClientId": "Client ID(机器人 ID)",
|
||||
"telegramOidcClientSecret": "Client Secret"
|
||||
}
|
||||
},
|
||||
"offlineConv": "\u79bb\u7ebf\u8f6c\u5316",
|
||||
"apiKey": "API \u5bc6\u94a5"
|
||||
},
|
||||
"buttons": {
|
||||
"color": "按钮颜色",
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useParams } from 'react-router';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { fireAnalyticsEvent, getYandexCid } from '../hooks/useAnalyticsCounters';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { landingApi } from '../api/landings';
|
||||
@@ -470,6 +472,7 @@ function SummaryCard({
|
||||
currentPrice,
|
||||
isSubmitting,
|
||||
canSubmit,
|
||||
stickyPayButton = false,
|
||||
submitError,
|
||||
onSubmit,
|
||||
}: {
|
||||
@@ -479,11 +482,22 @@ function SummaryCard({
|
||||
currentPrice: number;
|
||||
isSubmitting: boolean;
|
||||
canSubmit: boolean;
|
||||
stickyPayButton?: boolean;
|
||||
submitError: string | null;
|
||||
onSubmit: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Responsive: track mobile for sticky pay button
|
||||
const [isMobile, setIsMobile] = useState(
|
||||
() => typeof window !== 'undefined' && window.innerWidth < 1024,
|
||||
);
|
||||
useEffect(() => {
|
||||
const onResize = () => setIsMobile(window.innerWidth < 1024);
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => window.removeEventListener('resize', onResize);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Summary */}
|
||||
@@ -569,10 +583,32 @@ function SummaryCard({
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Pay button */}
|
||||
{stickyPayButton && isMobile ? (
|
||||
createPortal(
|
||||
<div
|
||||
className="fixed bottom-0 left-0 right-0 z-50 p-3"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(to top, rgba(0,0,0,0.9) 0%, rgba(0,0,0,0.6) 70%, transparent 100%)',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSubmit}
|
||||
disabled={!canSubmit || isSubmitting}
|
||||
onClick={() => {
|
||||
if (!canSubmit || isSubmitting) {
|
||||
const el = document.getElementById('contact-input');
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
el.classList.add('!border-red-500', '!ring-2', '!ring-red-500/50');
|
||||
setTimeout(() => {
|
||||
el.classList.remove('!border-red-500', '!ring-2', '!ring-red-500/50');
|
||||
}, 2000);
|
||||
}
|
||||
return;
|
||||
}
|
||||
onSubmit();
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
className={cn(
|
||||
'flex w-full items-center justify-center gap-2 rounded-2xl px-6 py-4 text-base font-semibold transition-all duration-200',
|
||||
canSubmit && !isSubmitting
|
||||
@@ -595,6 +631,53 @@ function SummaryCard({
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!canSubmit || isSubmitting) {
|
||||
const el = document.getElementById('contact-input');
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
el.classList.add('!border-red-500', '!ring-2', '!ring-red-500/50');
|
||||
setTimeout(() => {
|
||||
el.focus();
|
||||
}, 300);
|
||||
setTimeout(() => {
|
||||
el.classList.remove('!border-red-500', '!ring-2', '!ring-red-500/50');
|
||||
}, 2000);
|
||||
}
|
||||
return;
|
||||
}
|
||||
onSubmit();
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
className={cn(
|
||||
'flex w-full items-center justify-center gap-2 rounded-2xl px-6 py-4 text-base font-semibold transition-all duration-200',
|
||||
canSubmit && !isSubmitting
|
||||
? 'bg-accent-500 text-white shadow-lg shadow-accent-500/25 hover:bg-accent-400 hover:shadow-accent-500/40 active:scale-[0.98]'
|
||||
: 'cursor-not-allowed bg-dark-800 text-dark-500',
|
||||
)}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<div className="h-5 w-5 animate-spin rounded-full border-2 border-white/30 border-t-white" />
|
||||
) : (
|
||||
<>
|
||||
{t('landing.pay', 'Pay')}{' '}
|
||||
{selectedPeriod?.original_price_kopeks != null &&
|
||||
selectedPeriod.original_price_kopeks > selectedPeriod.price_kopeks && (
|
||||
<span className="mr-1 text-sm font-normal text-white/50 line-through">
|
||||
{formatPrice(selectedPeriod.original_price_kopeks)}
|
||||
</span>
|
||||
)}
|
||||
{formatPrice(currentPrice)}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
{config.footer_text && (
|
||||
@@ -737,10 +820,41 @@ export default function QuickPurchase() {
|
||||
if (config?.discount) setDiscountExpired(false);
|
||||
}, [config?.discount]);
|
||||
|
||||
// Save document.referrer on mount (before SPA navigation loses it)
|
||||
useEffect(() => {
|
||||
if (document.referrer && !sessionStorage.getItem('landing_referrer')) {
|
||||
sessionStorage.setItem('landing_referrer', document.referrer);
|
||||
}
|
||||
// Save subid from URL
|
||||
const urlSubid = new URLSearchParams(window.location.search).get('subid');
|
||||
if (urlSubid) {
|
||||
sessionStorage.setItem('landing_subid', urlSubid);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Fire landing-specific view goal on mount
|
||||
useEffect(() => {
|
||||
if (config?.analytics_view_enabled && config?.analytics_view_goal) {
|
||||
try {
|
||||
const w = window as unknown as Record<string, unknown>;
|
||||
const counterId = localStorage.getItem('ym_counter_id');
|
||||
if (counterId && typeof w.ym === 'function') {
|
||||
(w.ym as (...args: unknown[]) => void)(
|
||||
Number(counterId),
|
||||
'reachGoal',
|
||||
config.analytics_view_goal,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}, [config?.analytics_view_enabled, config?.analytics_view_goal]);
|
||||
|
||||
// Selection state
|
||||
const [selectedTariffId, setSelectedTariffId] = useState<number | null>(null);
|
||||
const [selectedPeriodDays, setSelectedPeriodDays] = useState<number | null>(null);
|
||||
const [contactValue, setContactValue] = useState('');
|
||||
const [contactValue, setContactValue] = useState(() => localStorage.getItem('lp_contact') || '');
|
||||
const [isGift, setIsGift] = useState(false);
|
||||
const [giftRecipient, setGiftRecipient] = useState('');
|
||||
const [giftMessage, setGiftMessage] = useState('');
|
||||
@@ -844,7 +958,9 @@ export default function QuickPurchase() {
|
||||
if (!config?.custom_css) return;
|
||||
|
||||
let css = config.custom_css;
|
||||
// Strip all at-rules (including @font-face, @import, @charset, @namespace, @keyframes, @media)
|
||||
// Strip ALL @-rules (block + inline). The previous full-strip regex was
|
||||
// intentionally broad: @media / @keyframes / @supports / @container can
|
||||
// smuggle behaviour the rest of the sanitizer doesn't catch.
|
||||
css = css.replace(/@[a-zA-Z-]+\s*[^{}]*\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}/g, '');
|
||||
css = css.replace(/@[a-zA-Z-]+\s*[^;{}]+;/g, '');
|
||||
// Strip ALL url() including data: URIs
|
||||
@@ -911,6 +1027,8 @@ export default function QuickPurchase() {
|
||||
const handleSubmit = () => {
|
||||
if (!canSubmit || !slug || isSubmitting) return;
|
||||
|
||||
fireAnalyticsEvent('purchase_click');
|
||||
|
||||
setIsSubmitting(true);
|
||||
setSubmitError(null);
|
||||
|
||||
@@ -929,6 +1047,7 @@ export default function QuickPurchase() {
|
||||
payment_method: paymentMethod,
|
||||
language: i18n.language,
|
||||
is_gift: isGift,
|
||||
referrer: sessionStorage.getItem('landing_referrer') || undefined,
|
||||
};
|
||||
|
||||
if (isGift && giftRecipient) {
|
||||
@@ -937,6 +1056,29 @@ export default function QuickPurchase() {
|
||||
data.gift_message = giftMessage.trim() || undefined;
|
||||
}
|
||||
|
||||
// Get Yandex CID for offline conversions (sync from localStorage)
|
||||
const ymCid = getYandexCid();
|
||||
if (ymCid) data.yandex_cid = ymCid;
|
||||
const subid = sessionStorage.getItem('landing_subid');
|
||||
if (subid) (data as unknown as Record<string, unknown>).subid = subid;
|
||||
|
||||
// Fire landing-specific click goal
|
||||
if (config?.analytics_click_enabled && config?.analytics_click_goal) {
|
||||
try {
|
||||
const w = window as unknown as Record<string, unknown>;
|
||||
const counterId = localStorage.getItem('ym_counter_id');
|
||||
if (counterId && typeof w.ym === 'function') {
|
||||
(w.ym as (...args: unknown[]) => void)(
|
||||
Number(counterId),
|
||||
'reachGoal',
|
||||
config.analytics_click_goal,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
purchaseMutation.mutate(data);
|
||||
};
|
||||
|
||||
@@ -1015,6 +1157,7 @@ export default function QuickPurchase() {
|
||||
contactValue={contactValue}
|
||||
onContactChange={(v) => {
|
||||
setContactValue(v);
|
||||
localStorage.setItem('lp_contact', v);
|
||||
setSubmitError(null);
|
||||
}}
|
||||
isGift={isGift}
|
||||
@@ -1095,7 +1238,10 @@ export default function QuickPurchase() {
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.2 }}
|
||||
className="min-w-0 lg:sticky lg:top-8 lg:self-start"
|
||||
className={cn(
|
||||
'min-w-0 lg:sticky lg:top-8 lg:self-start',
|
||||
config?.sticky_pay_button && 'mb-20 lg:mb-0',
|
||||
)}
|
||||
>
|
||||
<SummaryCard
|
||||
config={config}
|
||||
@@ -1106,6 +1252,7 @@ export default function QuickPurchase() {
|
||||
canSubmit={canSubmit}
|
||||
submitError={submitError}
|
||||
onSubmit={handleSubmit}
|
||||
stickyPayButton={config?.sticky_pay_button}
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
25
src/utils/yandexCid.ts
Normal file
25
src/utils/yandexCid.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Yandex Metrika ClientID helpers.
|
||||
*
|
||||
* Lives in `utils/` (not `hooks/`) so that both the `api/` layer
|
||||
* (auth.ts, oauth.ts) and React hooks can read the cached CID
|
||||
* without `api/` accidentally importing from `hooks/`.
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = 'ym_client_id';
|
||||
|
||||
export function getYandexCid(): string | null {
|
||||
try {
|
||||
return localStorage.getItem(STORAGE_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setYandexCid(cid: string): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, cid);
|
||||
} catch {
|
||||
/* sandboxed iframe / private mode -- ignore */
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user