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:
Fringg
2026-04-29 08:59:19 +03:00
parent 6d3010b621
commit 80059681da
11 changed files with 404 additions and 34 deletions

View File

@@ -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;

View File

@@ -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 });
},
};

View File

@@ -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 {

View File

@@ -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 */}

View File

@@ -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';

View File

@@ -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",

View File

@@ -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": "رنگ دکمه",

View File

@@ -2533,7 +2533,9 @@
"TIMEZONE": "Часовой пояс",
"REFERRAL": "Реферальная система",
"MODERATION": "Модерация"
}
},
"offlineConv": "Офлайн-конверсии",
"apiKey": "API-ключ"
},
"buttons": {
"color": "Цвет кнопки",

View File

@@ -1709,7 +1709,9 @@
"telegramOidcEnabled": "启用 OIDC",
"telegramOidcClientId": "Client ID机器人 ID",
"telegramOidcClientSecret": "Client Secret"
}
},
"offlineConv": "\u79bb\u7ebf\u8f6c\u5316",
"apiKey": "API \u5bc6\u94a5"
},
"buttons": {
"color": "按钮颜色",

View File

@@ -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
View 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 */
}
}