feat: landing analytics goals, daily bar chart, referrer tracking, contact persistence

- Add per-landing analytics goals (view/click) with admin editor toggle
- Add sticky pay button option for mobile landing pages
- Add daily purchases bar chart (created vs paid) to landing stats
- Replace single purchase count with created/paid split in stats summary
- Add referrer tracking to purchases with hostname display in stats
- Add time display to purchase cards alongside date
- Pass user timezone to stats API for correct daily grouping
- Clamp referrer (500 chars) and subid (255 chars) to backend limits
- Persist contact value per-landing-slug in localStorage
- Fire buy_success analytics goal on successful delivery
- Export USER_TIMEZONE from format utils
- Add analytics/stats translations for fa.json and zh.json locales

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Fringg
2026-04-29 09:17:52 +03:00
parent a50bd39df2
commit 020f4c95e2
11 changed files with 482 additions and 79 deletions

View File

@@ -106,6 +106,7 @@ export default function AdminLandingEditor() {
methods: false,
gifts: false,
background: false,
analytics: false,
footer: false,
});
@@ -137,6 +138,13 @@ export default function AdminLandingEditor() {
enabled: false,
});
// Analytics goals state
const [analyticsViewEnabled, setAnalyticsViewEnabled] = useState(false);
const [analyticsViewGoal, setAnalyticsViewGoal] = useState('landing_view');
const [analyticsClickEnabled, setAnalyticsClickEnabled] = useState(false);
const [analyticsClickGoal, setAnalyticsClickGoal] = useState('landing_pay');
const [stickyPayButton, setStickyPayButton] = useState(false);
// Discount state
const [discountPercent, setDiscountPercent] = useState<number | null>(null);
const [discountOverrides, setDiscountOverrides] = useState<Record<string, number>>({});
@@ -267,6 +275,11 @@ export default function AdminLandingEditor() {
landingData.discount_ends_at ? isoToDatetimeLocal(landingData.discount_ends_at) : '',
);
setDiscountBadgeText(toLocaleDict(landingData.discount_badge_text));
setAnalyticsViewEnabled(landingData.analytics_view_enabled ?? false);
setAnalyticsViewGoal(landingData.analytics_view_goal || 'landing_view');
setAnalyticsClickEnabled(landingData.analytics_click_enabled ?? false);
setAnalyticsClickGoal(landingData.analytics_click_goal || 'landing_pay');
setStickyPayButton(landingData.sticky_pay_button ?? false);
}, [landingData]);
// Create mutation
@@ -378,6 +391,11 @@ export default function AdminLandingEditor() {
discount_badge_text:
discountPercent !== null ? (nonEmptyDict(discountBadgeText) ?? null) : null,
background_config: backgroundConfig.enabled ? backgroundConfig : null,
analytics_view_enabled: analyticsViewEnabled,
analytics_view_goal: analyticsViewGoal,
analytics_click_enabled: analyticsClickEnabled,
analytics_click_goal: analyticsClickGoal,
sticky_pay_button: stickyPayButton,
};
if (isEdit) {
@@ -1079,6 +1097,72 @@ export default function AdminLandingEditor() {
<BackgroundConfigEditor value={backgroundConfig} onChange={setBackgroundConfig} />
</Section>
{/* Analytics Goals Section */}
<Section
title={t('admin.landings.analytics', 'Analytics')}
open={openSections.analytics}
onToggle={() => toggleSection('analytics')}
>
<div className="space-y-4">
{/* View Goal */}
<div className="flex items-center justify-between gap-4">
<div className="flex-1">
<label className="mb-1 block text-sm text-dark-400">
{t('admin.landings.viewGoal', 'View goal')}
</label>
<input
type="text"
value={analyticsViewGoal}
onChange={(e) => setAnalyticsViewGoal(e.target.value)}
disabled={!analyticsViewEnabled}
className="w-full rounded-lg border border-dark-700 bg-dark-800 px-3 py-2 text-sm text-dark-100 outline-none focus:border-accent-500 disabled:opacity-50"
placeholder="landing_view"
/>
</div>
<Toggle
checked={analyticsViewEnabled}
onChange={() => setAnalyticsViewEnabled((v) => !v)}
/>
</div>
{/* Click Goal */}
<div className="flex items-center justify-between gap-4">
<div className="flex-1">
<label className="mb-1 block text-sm text-dark-400">
{t('admin.landings.clickGoal', 'Payment click goal')}
</label>
<input
type="text"
value={analyticsClickGoal}
onChange={(e) => setAnalyticsClickGoal(e.target.value)}
disabled={!analyticsClickEnabled}
className="w-full rounded-lg border border-dark-700 bg-dark-800 px-3 py-2 text-sm text-dark-100 outline-none focus:border-accent-500 disabled:opacity-50"
placeholder="landing_pay"
/>
</div>
<Toggle
checked={analyticsClickEnabled}
onChange={() => setAnalyticsClickEnabled((v) => !v)}
/>
</div>
{/* Sticky pay button on mobile */}
<div className="flex items-center justify-between gap-4 border-t border-dark-800 pt-4">
<div>
<p className="text-sm text-dark-300">
{t('admin.landings.stickyPayButton', 'Sticky pay button (mobile)')}
</p>
<p className="text-xs text-dark-500">
{t(
'admin.landings.stickyPayButtonHint',
'Button pinned to bottom of screen on mobile',
)}
</p>
</div>
<Toggle checked={stickyPayButton} onChange={() => setStickyPayButton((v) => !v)} />
</div>
</div>
</Section>
{/* Footer & Custom CSS Section */}
<Section
title={t('admin.landings.content')}

View File

@@ -151,6 +151,19 @@ function PurchaseCard({ item, formatPrice, lang, t }: PurchaseCardProps) {
month: 'short',
year: 'numeric',
});
const timeStr = new Date(item.created_at).toLocaleTimeString(lang, {
hour: '2-digit',
minute: '2-digit',
});
const referrerHost = item.referrer
? (() => {
try {
return new URL(item.referrer).hostname;
} catch {
return item.referrer;
}
})()
: null;
return (
<div className="rounded-xl border border-dark-700/50 bg-dark-800/40 p-3 transition-colors hover:border-dark-600 sm:p-4">
@@ -205,8 +218,19 @@ function PurchaseCard({ item, formatPrice, lang, t }: PurchaseCardProps) {
</div>
)}
{/* Date */}
<div className="shrink-0 text-xs text-dark-500">{dateStr}</div>
{/* Referrer */}
{referrerHost && (
<div
className="max-w-[140px] shrink-0 truncate rounded bg-accent-500/20 px-1.5 py-0.5 text-xs font-medium text-accent-400"
title={item.referrer || ''}
>
{referrerHost}
</div>
)}
{/* Date + Time */}
<div className="shrink-0 text-xs text-dark-500">
{dateStr} <span className="text-dark-600">{timeStr}</span>
</div>
</div>
</div>
);
@@ -276,15 +300,16 @@ export default function AdminLandingStats() {
const dailyData = useMemo(() => {
if (!stats) return [];
return stats.daily_stats.map((item) => ({
label: new Date(item.date + 'T00:00:00').toLocaleDateString(i18n.language, {
month: 'short',
day: 'numeric',
}),
label: (() => {
const d = new Date(item.date + 'T00:00:00');
return `${d.getDate()}.${String(d.getMonth() + 1).padStart(2, '0')}`;
})(),
created: item.created,
purchases: item.purchases,
revenue: item.revenue_kopeks / CHART_COMMON.KOPEKS_DIVISOR,
gifts: item.gifts,
}));
}, [stats, i18n.language]);
}, [stats]);
// Prepare tariff chart data
const tariffData = useMemo(() => {
@@ -377,13 +402,18 @@ export default function AdminLandingStats() {
{/* Summary Cards */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4 text-center">
<div className="text-xl font-bold text-accent-400 sm:text-2xl">
{stats.total_purchases}
<div className="text-xl font-bold sm:text-2xl">
<span className="text-warning-400">{stats.total_created}</span>
<span className="mx-1 text-dark-600">/</span>
<span className="text-success-400">{stats.total_successful}</span>
</div>
<div className="text-xs text-dark-500">
{t('admin.landings.stats.created', 'Created')} /{' '}
{t('admin.landings.stats.paid', 'paid')}
</div>
<div className="text-xs text-dark-500">{t('admin.landings.stats.totalPurchases')}</div>
</div>
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4 text-center">
<div className="truncate text-xl font-bold text-success-400 sm:text-2xl">
<div className="truncate text-xl font-bold text-accent-400 sm:text-2xl">
{formatWithCurrency(stats.total_revenue_kopeks / CHART_COMMON.KOPEKS_DIVISOR)}
</div>
<div className="text-xs text-dark-500">{t('admin.landings.stats.revenue')}</div>
@@ -393,7 +423,7 @@ export default function AdminLandingStats() {
<div className="text-xs text-dark-500">{t('admin.landings.stats.giftPurchases')}</div>
</div>
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4 text-center">
<div className="text-xl font-bold text-warning-400 sm:text-2xl">
<div className="text-xl font-bold text-dark-200 sm:text-2xl">
{stats.conversion_rate}%
</div>
<div className="text-xs text-dark-500">{t('admin.landings.stats.conversionRate')}</div>
@@ -451,6 +481,24 @@ export default function AdminLandingStats() {
stopOpacity={CHART_COMMON.GRADIENT.END_OPACITY}
/>
</linearGradient>
<linearGradient
id={`landingCreatedGrad-${numericId}`}
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset={CHART_COMMON.GRADIENT.START_OFFSET}
stopColor="#f59e0b"
stopOpacity={CHART_COMMON.GRADIENT.START_OPACITY}
/>
<stop
offset={CHART_COMMON.GRADIENT.END_OFFSET}
stopColor="#f59e0b"
stopOpacity={CHART_COMMON.GRADIENT.END_OPACITY}
/>
</linearGradient>
</defs>
<CartesianGrid strokeDasharray={CHART_COMMON.GRID_DASH} stroke={colors.grid} />
<XAxis
@@ -482,6 +530,15 @@ export default function AdminLandingStats() {
labelStyle={{ color: colors.label }}
itemStyle={{ color: colors.label }}
/>
<Area
yAxisId="left"
type="monotone"
dataKey="created"
name={t('admin.landings.stats.created', 'Created')}
stroke="#f59e0b"
fill={`url(#landingCreatedGrad-${numericId})`}
strokeWidth={CHART_COMMON.STROKE_WIDTH}
/>
<Area
yAxisId="left"
type="monotone"
@@ -505,65 +562,120 @@ export default function AdminLandingStats() {
)}
</div>
{/* Tariff Distribution */}
{/* Daily Purchases Bar Chart */}
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
<h3 className="mb-4 font-medium text-dark-200">
{t('admin.landings.stats.tariffChart')}
{t('admin.landings.stats.dailyPurchases', 'Daily purchases')}
</h3>
{tariffData.length === 0 ? (
{dailyData.length === 0 ? (
<div className="flex h-[220px] items-center justify-center text-sm text-dark-500">
{t('admin.landings.stats.noPurchases')}
</div>
) : (
<ResponsiveContainer width="100%" height={barChartHeight}>
<BarChart
data={tariffData}
layout="vertical"
margin={{ ...CHART_COMMON.CHART.MARGIN, left: 10 }}
>
<CartesianGrid strokeDasharray={CHART_COMMON.GRID_DASH} stroke={colors.grid} />
<XAxis
type="number"
tick={{ fontSize: CHART_COMMON.AXIS.TICK_FONT_SIZE, fill: colors.tick }}
stroke={colors.grid}
allowDecimals={false}
/>
<YAxis
type="category"
dataKey="name"
tick={{ fontSize: CHART_COMMON.AXIS.TICK_FONT_SIZE, fill: colors.tick }}
stroke={colors.grid}
width={100}
/>
<Tooltip
contentStyle={{
backgroundColor: colors.tooltipBg,
border: `1px solid ${colors.tooltipBorder}`,
borderRadius: CHART_COMMON.TOOLTIP.BORDER_RADIUS,
fontSize: CHART_COMMON.TOOLTIP.FONT_SIZE,
color: colors.label,
}}
labelStyle={{ color: colors.label }}
itemStyle={{ color: colors.label }}
formatter={(value: number | undefined) => {
return [value ?? 0, t('admin.landings.stats.purchases')];
}}
/>
<Bar
dataKey="purchases"
name={t('admin.landings.stats.purchases')}
radius={[0, 4, 4, 0]}
>
{tariffData.map((_, index) => (
<Cell key={index} fill={TARIFF_PALETTE[index % TARIFF_PALETTE.length]} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
<div className="space-y-2">
{[...dailyData]
.slice(-7)
.reverse()
.map((day, i) => {
const purchasedPct =
(day.created || 0) > 0
? ((day.purchases || 0) / (day.created || 1)) * 100
: 0;
return (
<div key={i} className="flex items-center gap-2">
<span className="w-10 shrink-0 text-right text-xs text-dark-500">
{day.label}
</span>
<div
className="group relative h-5 flex-1 overflow-hidden rounded-full bg-amber-500/80"
title={`${t('admin.landings.stats.created', 'Created')}: ${day.created || 0}\n${t('admin.landings.stats.paid', 'paid')}: ${day.purchases || 0}\n${t('admin.landings.stats.revenueLabel', 'Revenue')}: ${day.revenue?.toFixed(0) || 0} ${t('common.currency', '\u20BD')}\nCR: ${Math.round(purchasedPct)}%`}
>
<div
className="absolute inset-y-0 left-0 rounded-full bg-accent-500"
style={{ width: `${purchasedPct}%` }}
/>
</div>
<span className="w-12 shrink-0 text-xs text-dark-400">
<span className="text-amber-400">{day.created || 0}</span>
<span className="text-dark-600">/</span>
<span className="text-accent-400">{day.purchases || 0}</span>
</span>
</div>
);
})}
<div className="mt-2 flex items-center gap-4 text-xs text-dark-500">
<div className="flex items-center gap-1">
<div className="h-2 w-2 rounded-full bg-amber-500/80" />
<span>{t('admin.landings.stats.created', 'Created')}</span>
</div>
<div className="flex items-center gap-1">
<div className="h-2 w-2 rounded-full bg-accent-500" />
<span>{t('admin.landings.stats.paid', 'paid')}</span>
</div>
</div>
</div>
)}
</div>
</div>
{/* Tariff Distribution -- full width */}
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
<h3 className="mb-4 font-medium text-dark-200">
{t('admin.landings.stats.tariffChart')}
</h3>
{tariffData.length === 0 ? (
<div className="flex h-[220px] items-center justify-center text-sm text-dark-500">
{t('admin.landings.stats.noPurchases')}
</div>
) : (
<ResponsiveContainer width="100%" height={barChartHeight}>
<BarChart
data={tariffData}
layout="vertical"
margin={{ ...CHART_COMMON.CHART.MARGIN, left: 10 }}
>
<CartesianGrid strokeDasharray={CHART_COMMON.GRID_DASH} stroke={colors.grid} />
<XAxis
type="number"
tick={{ fontSize: CHART_COMMON.AXIS.TICK_FONT_SIZE, fill: colors.tick }}
stroke={colors.grid}
allowDecimals={false}
/>
<YAxis
type="category"
dataKey="name"
tick={{ fontSize: CHART_COMMON.AXIS.TICK_FONT_SIZE, fill: colors.tick }}
stroke={colors.grid}
width={100}
/>
<Tooltip
contentStyle={{
backgroundColor: colors.tooltipBg,
border: `1px solid ${colors.tooltipBorder}`,
borderRadius: CHART_COMMON.TOOLTIP.BORDER_RADIUS,
fontSize: CHART_COMMON.TOOLTIP.FONT_SIZE,
color: colors.label,
}}
labelStyle={{ color: colors.label }}
itemStyle={{ color: colors.label }}
formatter={(value: number | undefined) => {
return [value ?? 0, t('admin.landings.stats.purchases')];
}}
/>
<Bar
dataKey="purchases"
name={t('admin.landings.stats.purchases')}
radius={[0, 4, 4, 0]}
>
{tariffData.map((_, index) => (
<Cell key={index} fill={TARIFF_PALETTE[index % TARIFF_PALETTE.length]} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
)}
</div>
{/* Additional Stats Row */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
@@ -587,7 +699,9 @@ export default function AdminLandingStats() {
<span className="text-sm text-dark-500">{t('admin.landings.stats.created')}</span>
{' / '}
{stats.total_successful}{' '}
<span className="text-sm text-dark-500">{t('admin.landings.stats.successful')}</span>
<span className="text-sm text-dark-500">
{t('admin.landings.stats.paid', 'paid')}
</span>
</div>
</div>
</div>

View File

@@ -180,7 +180,19 @@ function SortableLandingCard({
</div>
<div className="text-sm text-dark-400">
<span>
{landing.purchase_stats.total} {t('admin.landings.purchaseCount')}
{landing.purchase_stats.total}
<span className="ml-1 text-dark-600">
{t('admin.landings.stats.created', 'created')}
</span>
<span className="mx-1 text-dark-600">/</span>
<span className="text-success-400">
{landing.purchase_stats.paid +
landing.purchase_stats.delivered +
landing.purchase_stats.pending_activation}
</span>
<span className="ml-1 text-dark-600">
{t('admin.landings.stats.paid', 'paid')}
</span>
</span>
</div>
</div>

View File

@@ -665,6 +665,33 @@ export default function PurchaseSuccess() {
}, [token, queryClient]);
const isSuccess = purchaseStatus?.status === 'delivered';
// Fire analytics goal on successful delivery (once per purchase).
// Idempotency keyed by token so a page refresh doesn't double-count.
useEffect(() => {
if (!isSuccess || !token) return;
const FIRED_KEY = `ym_buy_success_${token}`;
try {
if (localStorage.getItem(FIRED_KEY)) return;
} catch {
/* ignore */
}
try {
const counterId = localStorage.getItem('ym_counter_id');
const w = window as unknown as Record<string, unknown>;
if (counterId && typeof w.ym === 'function') {
(w.ym as (...args: unknown[]) => void)(Number(counterId), 'reachGoal', 'buy_success');
try {
localStorage.setItem(FIRED_KEY, '1');
} catch {
/* ignore */
}
}
} catch {
/* analytics error */
}
}, [isSuccess, token]);
const isPendingActivation = purchaseStatus?.status === 'pending_activation';
const isFailed = purchaseStatus?.status === 'failed' || purchaseStatus?.status === 'expired';

View File

@@ -820,15 +820,17 @@ export default function QuickPurchase() {
if (config?.discount) setDiscountExpired(false);
}, [config?.discount]);
// Save document.referrer on mount (before SPA navigation loses it)
// Save document.referrer on mount (before SPA navigation loses it).
// Clamp to 500 chars -- backend `referrer` column is max_length=500 and would
// otherwise reject long ad-click referrers (gclid+gbraid+params) with 422.
useEffect(() => {
if (document.referrer && !sessionStorage.getItem('landing_referrer')) {
sessionStorage.setItem('landing_referrer', document.referrer);
sessionStorage.setItem('landing_referrer', document.referrer.slice(0, 500));
}
// Save subid from URL
// Save subid from URL (also clamped to backend limit of 255)
const urlSubid = new URLSearchParams(window.location.search).get('subid');
if (urlSubid) {
sessionStorage.setItem('landing_subid', urlSubid);
sessionStorage.setItem('landing_subid', urlSubid.slice(0, 255));
}
}, []);
@@ -854,7 +856,14 @@ export default function QuickPurchase() {
// Selection state
const [selectedTariffId, setSelectedTariffId] = useState<number | null>(null);
const [selectedPeriodDays, setSelectedPeriodDays] = useState<number | null>(null);
const [contactValue, setContactValue] = useState(() => localStorage.getItem('lp_contact') || '');
const contactKey = `lp_contact_${slug ?? ''}`;
const [contactValue, setContactValue] = useState(() => {
try {
return localStorage.getItem(contactKey) || '';
} catch {
return '';
}
});
const [isGift, setIsGift] = useState(false);
const [giftRecipient, setGiftRecipient] = useState('');
const [giftMessage, setGiftMessage] = useState('');
@@ -1157,7 +1166,11 @@ export default function QuickPurchase() {
contactValue={contactValue}
onContactChange={(v) => {
setContactValue(v);
localStorage.setItem('lp_contact', v);
try {
localStorage.setItem(contactKey, v);
} catch {
/* */
}
setSubmitError(null);
}}
isGift={isGift}