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,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,32 +583,101 @@ function SummaryCard({
</AnimatePresence>
{/* Pay button */}
<button
type="button"
onClick={onSubmit}
disabled={!canSubmit || 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>
{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={() => {
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
? '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',
)}
{formatPrice(currentPrice)}
</>
)}
</button>
>
{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>
</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>