mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
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:
@@ -89,12 +89,11 @@ 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;
|
||||
analytics_view_enabled: boolean;
|
||||
analytics_view_goal: string;
|
||||
analytics_click_enabled: boolean;
|
||||
analytics_click_goal: string;
|
||||
sticky_pay_button: boolean;
|
||||
}
|
||||
|
||||
export interface PurchaseRequest {
|
||||
@@ -167,6 +166,8 @@ export interface LandingListItem {
|
||||
gift_enabled: boolean;
|
||||
tariff_count: number;
|
||||
method_count: number;
|
||||
analytics_view_enabled: boolean;
|
||||
analytics_click_enabled: boolean;
|
||||
purchase_stats: {
|
||||
total: number;
|
||||
pending: number;
|
||||
@@ -205,6 +206,11 @@ export interface LandingDetail {
|
||||
discount_ends_at: string | null;
|
||||
discount_badge_text: LocaleDict | null;
|
||||
background_config: AnimationConfig | null;
|
||||
analytics_view_enabled: boolean;
|
||||
analytics_view_goal: string;
|
||||
analytics_click_enabled: boolean;
|
||||
analytics_click_goal: string;
|
||||
sticky_pay_button: boolean;
|
||||
}
|
||||
|
||||
export interface LandingCreateRequest {
|
||||
@@ -227,6 +233,11 @@ export interface LandingCreateRequest {
|
||||
discount_ends_at?: string | null;
|
||||
discount_badge_text?: LocaleDict | null;
|
||||
background_config?: AnimationConfig | null;
|
||||
analytics_view_enabled?: boolean;
|
||||
analytics_view_goal?: string;
|
||||
analytics_click_enabled?: boolean;
|
||||
analytics_click_goal?: string;
|
||||
sticky_pay_button?: boolean;
|
||||
}
|
||||
|
||||
export type LandingUpdateRequest = Partial<LandingCreateRequest>;
|
||||
@@ -272,6 +283,7 @@ export const landingApi = {
|
||||
|
||||
export interface LandingDailyStat {
|
||||
date: string;
|
||||
created: number;
|
||||
purchases: number;
|
||||
revenue_kopeks: number;
|
||||
gifts: number;
|
||||
@@ -321,6 +333,7 @@ export interface LandingPurchaseItem {
|
||||
status: PurchaseItemStatus;
|
||||
created_at: string;
|
||||
paid_at: string | null;
|
||||
referrer: string | null;
|
||||
}
|
||||
|
||||
export interface LandingPurchaseListResponse {
|
||||
@@ -364,7 +377,10 @@ export const adminLandingsApi = {
|
||||
},
|
||||
|
||||
getStats: async (id: number): Promise<LandingStatsResponse> => {
|
||||
const response = await apiClient.get(`/cabinet/admin/landings/${id}/stats`);
|
||||
const { USER_TIMEZONE } = await import('../utils/format');
|
||||
const response = await apiClient.get(`/cabinet/admin/landings/${id}/stats`, {
|
||||
params: { tz: USER_TIMEZONE },
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
|
||||
@@ -3776,6 +3776,10 @@
|
||||
"discountOverridesHint": "Leave empty to use global discount",
|
||||
"discountPreview": "Preview",
|
||||
"discountActive": "Discount",
|
||||
"background": "Animated background",
|
||||
"analytics": "Analytics",
|
||||
"viewGoal": "View goal",
|
||||
"clickGoal": "Payment click goal",
|
||||
"statistics": "Statistics",
|
||||
"stats": {
|
||||
"title": "Landing Statistics",
|
||||
@@ -3796,7 +3800,8 @@
|
||||
"successful": "Successful",
|
||||
"funnel": "Funnel",
|
||||
"loadError": "Failed to load statistics",
|
||||
"noPurchases": "No purchases"
|
||||
"noPurchases": "No purchases",
|
||||
"paid": "paid"
|
||||
},
|
||||
"purchases": {
|
||||
"title": "Purchases",
|
||||
|
||||
@@ -3381,7 +3381,70 @@
|
||||
"loadingPeriods": "در حال بارگذاری...",
|
||||
"periodDaySuffix": "روز",
|
||||
"localeTab": "زبان",
|
||||
"localeHint": "زبان را تغییر دهید تا متن را به آن زبان ویرایش کنید"
|
||||
"localeHint": "زبان را تغییر دهید تا متن را به آن زبان ویرایش کنید",
|
||||
"discount": "تخفیف",
|
||||
"discountEnabled": "فعالسازی تخفیف",
|
||||
"discountPercent": "تخفیف %",
|
||||
"discountStartsAt": "تاریخ شروع",
|
||||
"discountEndsAt": "تاریخ پایان",
|
||||
"discountBadge": "متن بنر (اختیاری)",
|
||||
"discountBadgePlaceholder": "مثلاً حراج بهاره!",
|
||||
"discountOverrides": "تخفیفهای جداگانه برای هر طرح",
|
||||
"discountOverridesHint": "خالی بگذارید تا از تخفیف عمومی استفاده شود",
|
||||
"discountPreview": "پیشنمایش",
|
||||
"discountActive": "تخفیف",
|
||||
"background": "پسزمینه متحرک",
|
||||
"analytics": "تحلیلها",
|
||||
"viewGoal": "هدف بازدید",
|
||||
"clickGoal": "هدف کلیک پرداخت",
|
||||
"statistics": "آمار",
|
||||
"stats": {
|
||||
"title": "آمار صفحه فرود",
|
||||
"totalPurchases": "خریدها",
|
||||
"revenue": "درآمد",
|
||||
"giftPurchases": "هدایا",
|
||||
"regularPurchases": "عادی",
|
||||
"conversionRate": "نرخ تبدیل",
|
||||
"avgPurchase": "میانگین خرید",
|
||||
"dailyChart": "خرید و درآمد روزانه",
|
||||
"tariffChart": "توزیع طرحها",
|
||||
"giftBreakdown": "هدایا در مقابل عادی",
|
||||
"purchases": "خریدها",
|
||||
"revenueLabel": "درآمد",
|
||||
"gifts": "هدایا",
|
||||
"regular": "عادی",
|
||||
"created": "ایجاد شده",
|
||||
"successful": "موفق",
|
||||
"funnel": "قیف",
|
||||
"loadError": "بارگذاری آمار ناموفق بود",
|
||||
"noPurchases": "خریدی وجود ندارد",
|
||||
"paid": "پرداخت شده"
|
||||
},
|
||||
"purchases": {
|
||||
"title": "خریدها",
|
||||
"contact": "مخاطب",
|
||||
"recipient": "گیرنده",
|
||||
"tariff": "طرح",
|
||||
"period": "دوره",
|
||||
"days": "روز",
|
||||
"price": "قیمت",
|
||||
"method": "روش",
|
||||
"date": "تاریخ",
|
||||
"gift": "هدیه",
|
||||
"forSelf": "برای خود",
|
||||
"allStatuses": "همه وضعیتها",
|
||||
"status_pending": "در انتظار",
|
||||
"status_paid": "پرداخت شده",
|
||||
"status_delivered": "تحویل شده",
|
||||
"status_pending_activation": "در انتظار فعالسازی",
|
||||
"status_failed": "ناموفق",
|
||||
"status_expired": "منقضی شده",
|
||||
"noPurchases": "خریدی وجود ندارد",
|
||||
"showing": "نمایش {{from}}–{{to}} از {{total}}",
|
||||
"page": "صفحه {{current}} از {{total}}",
|
||||
"prev": "قبلی",
|
||||
"next": "بعدی"
|
||||
}
|
||||
},
|
||||
"infoPages": {
|
||||
"title": "صفحات اطلاعات",
|
||||
|
||||
@@ -4320,6 +4320,9 @@
|
||||
"discountPreview": "Предпросмотр",
|
||||
"discountActive": "Скидка",
|
||||
"background": "Анимированный фон",
|
||||
"analytics": "Аналитика",
|
||||
"viewGoal": "Цель просмотра",
|
||||
"clickGoal": "Цель клика оплаты",
|
||||
"statistics": "Статистика",
|
||||
"stats": {
|
||||
"title": "Статистика лендинга",
|
||||
@@ -4340,7 +4343,8 @@
|
||||
"successful": "Успешных",
|
||||
"funnel": "Воронка",
|
||||
"loadError": "Не удалось загрузить статистику",
|
||||
"noPurchases": "Нет покупок"
|
||||
"noPurchases": "Нет покупок",
|
||||
"paid": "оплачено"
|
||||
},
|
||||
"purchases": {
|
||||
"title": "Покупки",
|
||||
|
||||
@@ -3380,7 +3380,70 @@
|
||||
"loadingPeriods": "加载中...",
|
||||
"periodDaySuffix": "天",
|
||||
"localeTab": "语言",
|
||||
"localeHint": "切换语言以编辑该语言的文本"
|
||||
"localeHint": "切换语言以编辑该语言的文本",
|
||||
"discount": "折扣",
|
||||
"discountEnabled": "启用折扣",
|
||||
"discountPercent": "折扣 %",
|
||||
"discountStartsAt": "开始日期",
|
||||
"discountEndsAt": "结束日期",
|
||||
"discountBadge": "横幅文本(可选)",
|
||||
"discountBadgePlaceholder": "例如 春季特卖!",
|
||||
"discountOverrides": "各套餐独立折扣",
|
||||
"discountOverridesHint": "留空则使用全局折扣",
|
||||
"discountPreview": "预览",
|
||||
"discountActive": "折扣",
|
||||
"background": "动态背景",
|
||||
"analytics": "分析",
|
||||
"viewGoal": "浏览目标",
|
||||
"clickGoal": "支付点击目标",
|
||||
"statistics": "统计",
|
||||
"stats": {
|
||||
"title": "落地页统计",
|
||||
"totalPurchases": "购买次数",
|
||||
"revenue": "收入",
|
||||
"giftPurchases": "礼物",
|
||||
"regularPurchases": "普通",
|
||||
"conversionRate": "转化率",
|
||||
"avgPurchase": "平均客单价",
|
||||
"dailyChart": "每日购买与收入",
|
||||
"tariffChart": "套餐分布",
|
||||
"giftBreakdown": "礼物 vs 普通",
|
||||
"purchases": "购买",
|
||||
"revenueLabel": "收入",
|
||||
"gifts": "礼物",
|
||||
"regular": "普通",
|
||||
"created": "已创建",
|
||||
"successful": "成功",
|
||||
"funnel": "漏斗",
|
||||
"loadError": "加载统计失败",
|
||||
"noPurchases": "无购买记录",
|
||||
"paid": "已支付"
|
||||
},
|
||||
"purchases": {
|
||||
"title": "购买记录",
|
||||
"contact": "联系方式",
|
||||
"recipient": "收件人",
|
||||
"tariff": "套餐",
|
||||
"period": "周期",
|
||||
"days": "天",
|
||||
"price": "价格",
|
||||
"method": "方式",
|
||||
"date": "日期",
|
||||
"gift": "礼物",
|
||||
"forSelf": "自用",
|
||||
"allStatuses": "全部状态",
|
||||
"status_pending": "待处理",
|
||||
"status_paid": "已支付",
|
||||
"status_delivered": "已交付",
|
||||
"status_pending_activation": "待激活",
|
||||
"status_failed": "失败",
|
||||
"status_expired": "已过期",
|
||||
"noPurchases": "无购买记录",
|
||||
"showing": "显示第 {{from}}–{{to}} 条,共 {{total}} 条",
|
||||
"page": "第 {{current}} 页,共 {{total}} 页",
|
||||
"prev": "上一页",
|
||||
"next": "下一页"
|
||||
}
|
||||
},
|
||||
"infoPages": {
|
||||
"title": "信息页面",
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export const USER_TIMEZONE = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
||||
|
||||
export function formatUptime(seconds: number): string {
|
||||
const days = Math.floor(seconds / 86400);
|
||||
const hours = Math.floor((seconds % 86400) / 3600);
|
||||
|
||||
Reference in New Issue
Block a user