mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-30 02:23:47 +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,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>
|
||||
|
||||
Reference in New Issue
Block a user