From 80059681da8655daa4b79cf3451b87df661b9d1d Mon Sep 17 00:00:00 2001 From: Fringg Date: Wed, 29 Apr 2026 08:59:19 +0300 Subject: [PATCH] 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 --- src/api/auth.ts | 9 +- src/api/branding.ts | 24 ++- src/api/landings.ts | 10 ++ src/components/admin/AnalyticsTab.tsx | 59 ++++++++ src/hooks/useAnalyticsCounters.ts | 92 ++++++++++++ src/locales/en.json | 4 +- src/locales/fa.json | 4 +- src/locales/ru.json | 4 +- src/locales/zh.json | 4 +- src/pages/QuickPurchase.tsx | 203 ++++++++++++++++++++++---- src/utils/yandexCid.ts | 25 ++++ 11 files changed, 404 insertions(+), 34 deletions(-) create mode 100644 src/utils/yandexCid.ts diff --git a/src/api/auth.ts b/src/api/auth.ts index 7bfd3fa..4e7db24 100644 --- a/src/api/auth.ts +++ b/src/api/auth.ts @@ -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 => { const response = await apiClient.post( '/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; diff --git a/src/api/branding.ts b/src/api/branding.ts index ec3b9c8..c2aa0da 100644 --- a/src/api/branding.ts +++ b/src/api/branding.ts @@ -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('/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('/cabinet/branding/analytics', data); return response.data; }, + + // Store Yandex Metrika ClientID for the authenticated cabinet user + storeYandexCid: async (cid: string): Promise => { + await apiClient.post('/cabinet/branding/analytics/yandex-cid', { cid }); + }, }; diff --git a/src/api/landings.ts b/src/api/landings.ts index 83437c5..cae1c1f 100644 --- a/src/api/landings.ts +++ b/src/api/landings.ts @@ -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 { diff --git a/src/components/admin/AnalyticsTab.tsx b/src/components/admin/AnalyticsTab.tsx index 033dbcd..c592ffd 100644 --- a/src/components/admin/AnalyticsTab.tsx +++ b/src/components/admin/AnalyticsTab.tsx @@ -161,6 +161,65 @@ export function AnalyticsTab() { )}

{t('admin.settings.yandexIdHint')}

+ {/* Offline Conversions (inside Yandex block) */} + {analytics?.offline_conv_enabled && ( + <> +
+
+
+ + + + + {t('admin.settings.offlineConv', 'Offline Conversions')} + +
+ + + {t('admin.settings.counterActive')} + +
+ {analytics.offline_conv_counter_id && ( +
+ {t('admin.settings.counterId')}: + + {analytics.offline_conv_counter_id} + +
+ )} + {analytics.offline_conv_measurement_secret_masked && ( +
+ + {t('admin.settings.apiKey', 'API Key')}: + + + {analytics.offline_conv_measurement_secret_masked} + +
+ )} + {analytics.offline_conv_goals && analytics.offline_conv_goals.length > 0 && ( +
+ {analytics.offline_conv_goals.map((goal) => ( +
+
+ + {goal.name} +
+
+ + {goal.event_id} + + {goal.dedup} +
+
+ ))} +
+ )} + + )}
{/* Google Ads */} diff --git a/src/hooks/useAnalyticsCounters.ts b/src/hooks/useAnalyticsCounters.ts index fdf4660..8d02a2e 100644 --- a/src/hooks/useAnalyticsCounters.ts +++ b/src/hooks/useAnalyticsCounters.ts @@ -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; + 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; + 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) { + const w = window as unknown as Record; + 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'; diff --git a/src/locales/en.json b/src/locales/en.json index 12ed0e2..c28819d 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -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", diff --git a/src/locales/fa.json b/src/locales/fa.json index 63d5f36..ad3ab4e 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -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": "رنگ دکمه", diff --git a/src/locales/ru.json b/src/locales/ru.json index 6d34673..74cb79f 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -2533,7 +2533,9 @@ "TIMEZONE": "Часовой пояс", "REFERRAL": "Реферальная система", "MODERATION": "Модерация" - } + }, + "offlineConv": "Офлайн-конверсии", + "apiKey": "API-ключ" }, "buttons": { "color": "Цвет кнопки", diff --git a/src/locales/zh.json b/src/locales/zh.json index 6c04819..644db0f 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -1709,7 +1709,9 @@ "telegramOidcEnabled": "启用 OIDC", "telegramOidcClientId": "Client ID(机器人 ID)", "telegramOidcClientSecret": "Client Secret" - } + }, + "offlineConv": "\u79bb\u7ebf\u8f6c\u5316", + "apiKey": "API \u5bc6\u94a5" }, "buttons": { "color": "按钮颜色", diff --git a/src/pages/QuickPurchase.tsx b/src/pages/QuickPurchase.tsx index 8dc3877..5a680d6 100644 --- a/src/pages/QuickPurchase.tsx +++ b/src/pages/QuickPurchase.tsx @@ -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 (
{/* Summary */} @@ -569,32 +583,101 @@ function SummaryCard({ {/* Pay button */} - + > + {isSubmitting ? ( +
+ ) : ( + <> + {t('landing.pay', 'Pay')}{' '} + {selectedPeriod?.original_price_kopeks != null && + selectedPeriod.original_price_kopeks > selectedPeriod.price_kopeks && ( + + {formatPrice(selectedPeriod.original_price_kopeks)} + + )} + {formatPrice(currentPrice)} + + )} + +
, + document.body, + ) + ) : ( + + )} {/* 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; + 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(null); const [selectedPeriodDays, setSelectedPeriodDays] = useState(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).subid = subid; + + // Fire landing-specific click goal + if (config?.analytics_click_enabled && config?.analytics_click_goal) { + try { + const w = window as unknown as Record; + 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', + )} >
diff --git a/src/utils/yandexCid.ts b/src/utils/yandexCid.ts new file mode 100644 index 0000000..1c01a7d --- /dev/null +++ b/src/utils/yandexCid.ts @@ -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 */ + } +}