diff --git a/src/api/landings.ts b/src/api/landings.ts index cae1c1f..17e7d69 100644 --- a/src/api/landings.ts +++ b/src/api/landings.ts @@ -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; @@ -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 => { - 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; }, diff --git a/src/locales/en.json b/src/locales/en.json index c28819d..9b10375 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -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", diff --git a/src/locales/fa.json b/src/locales/fa.json index ad3ab4e..4bdc6ee 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -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": "صفحات اطلاعات", diff --git a/src/locales/ru.json b/src/locales/ru.json index 74cb79f..c381d48 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -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": "Покупки", diff --git a/src/locales/zh.json b/src/locales/zh.json index 644db0f..9993ce9 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -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": "信息页面", diff --git a/src/pages/AdminLandingEditor.tsx b/src/pages/AdminLandingEditor.tsx index 39dc68f..ef9f395 100644 --- a/src/pages/AdminLandingEditor.tsx +++ b/src/pages/AdminLandingEditor.tsx @@ -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(null); const [discountOverrides, setDiscountOverrides] = useState>({}); @@ -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() { + {/* Analytics Goals Section */} +
toggleSection('analytics')} + > +
+ {/* View Goal */} +
+
+ + 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" + /> +
+ setAnalyticsViewEnabled((v) => !v)} + /> +
+ + {/* Click Goal */} +
+
+ + 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" + /> +
+ setAnalyticsClickEnabled((v) => !v)} + /> +
+ {/* Sticky pay button on mobile */} +
+
+

+ {t('admin.landings.stickyPayButton', 'Sticky pay button (mobile)')} +

+

+ {t( + 'admin.landings.stickyPayButtonHint', + 'Button pinned to bottom of screen on mobile', + )} +

+
+ setStickyPayButton((v) => !v)} /> +
+
+
+ {/* Footer & Custom CSS Section */}
{ + try { + return new URL(item.referrer).hostname; + } catch { + return item.referrer; + } + })() + : null; return (
@@ -205,8 +218,19 @@ function PurchaseCard({ item, formatPrice, lang, t }: PurchaseCardProps) {
)} - {/* Date */} -
{dateStr}
+ {/* Referrer */} + {referrerHost && ( +
+ {referrerHost} +
+ )} + {/* Date + Time */} +
+ {dateStr} {timeStr} +
); @@ -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 */}
-
- {stats.total_purchases} +
+ {stats.total_created} + / + {stats.total_successful} +
+
+ {t('admin.landings.stats.created', 'Created')} /{' '} + {t('admin.landings.stats.paid', 'paid')}
-
{t('admin.landings.stats.totalPurchases')}
-
+
{formatWithCurrency(stats.total_revenue_kopeks / CHART_COMMON.KOPEKS_DIVISOR)}
{t('admin.landings.stats.revenue')}
@@ -393,7 +423,7 @@ export default function AdminLandingStats() {
{t('admin.landings.stats.giftPurchases')}
-
+
{stats.conversion_rate}%
{t('admin.landings.stats.conversionRate')}
@@ -451,6 +481,24 @@ export default function AdminLandingStats() { stopOpacity={CHART_COMMON.GRADIENT.END_OPACITY} /> + + + + + - {/* Tariff Distribution */} + {/* Daily Purchases Bar Chart */}

- {t('admin.landings.stats.tariffChart')} + {t('admin.landings.stats.dailyPurchases', 'Daily purchases')}

- {tariffData.length === 0 ? ( + {dailyData.length === 0 ? (
{t('admin.landings.stats.noPurchases')}
) : ( - - - - - - { - return [value ?? 0, t('admin.landings.stats.purchases')]; - }} - /> - - {tariffData.map((_, index) => ( - - ))} - - - +
+ {[...dailyData] + .slice(-7) + .reverse() + .map((day, i) => { + const purchasedPct = + (day.created || 0) > 0 + ? ((day.purchases || 0) / (day.created || 1)) * 100 + : 0; + return ( +
+ + {day.label} + +
+
+
+ + {day.created || 0} + / + {day.purchases || 0} + +
+ ); + })} +
+
+
+ {t('admin.landings.stats.created', 'Created')} +
+
+
+ {t('admin.landings.stats.paid', 'paid')} +
+
+
)}
+ {/* Tariff Distribution -- full width */} +
+

+ {t('admin.landings.stats.tariffChart')} +

+ {tariffData.length === 0 ? ( +
+ {t('admin.landings.stats.noPurchases')} +
+ ) : ( + + + + + + { + return [value ?? 0, t('admin.landings.stats.purchases')]; + }} + /> + + {tariffData.map((_, index) => ( + + ))} + + + + )} +
+ {/* Additional Stats Row */}
@@ -587,7 +699,9 @@ export default function AdminLandingStats() { {t('admin.landings.stats.created')} {' / '} {stats.total_successful}{' '} - {t('admin.landings.stats.successful')} + + {t('admin.landings.stats.paid', 'paid')} +
diff --git a/src/pages/AdminLandings.tsx b/src/pages/AdminLandings.tsx index 568e9b7..ffcadee 100644 --- a/src/pages/AdminLandings.tsx +++ b/src/pages/AdminLandings.tsx @@ -180,7 +180,19 @@ function SortableLandingCard({
- {landing.purchase_stats.total} {t('admin.landings.purchaseCount')} + {landing.purchase_stats.total} + + {t('admin.landings.stats.created', 'created')} + + / + + {landing.purchase_stats.paid + + landing.purchase_stats.delivered + + landing.purchase_stats.pending_activation} + + + {t('admin.landings.stats.paid', 'paid')} +
diff --git a/src/pages/PurchaseSuccess.tsx b/src/pages/PurchaseSuccess.tsx index cc9deb5..0f27b1a 100644 --- a/src/pages/PurchaseSuccess.tsx +++ b/src/pages/PurchaseSuccess.tsx @@ -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; + 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'; diff --git a/src/pages/QuickPurchase.tsx b/src/pages/QuickPurchase.tsx index 5a680d6..3288833 100644 --- a/src/pages/QuickPurchase.tsx +++ b/src/pages/QuickPurchase.tsx @@ -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(null); const [selectedPeriodDays, setSelectedPeriodDays] = useState(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} diff --git a/src/utils/format.ts b/src/utils/format.ts index ee42e2f..a116038 100644 --- a/src/utils/format.ts +++ b/src/utils/format.ts @@ -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);