diff --git a/src/api/landings.ts b/src/api/landings.ts index 28f6a05..db99399 100644 --- a/src/api/landings.ts +++ b/src/api/landings.ts @@ -15,6 +15,9 @@ export interface LandingTariffPeriod { label: string; price_kopeks: number; price_label: string; + original_price_kopeks: number | null; + original_price_label: string | null; + discount_percent: number | null; } export interface LandingTariff { @@ -69,6 +72,12 @@ export type EditableMethodField = | 'currency' | 'return_url'; +export interface LandingDiscountInfo { + percent: number; + ends_at: string; // ISO datetime + badge_text: string | null; +} + export interface LandingConfig { slug: string; title: string; @@ -81,6 +90,7 @@ export interface LandingConfig { custom_css: string | null; meta_title: string | null; meta_description: string | null; + discount: LandingDiscountInfo | null; } export interface PurchaseRequest { @@ -165,6 +175,7 @@ export interface LandingListItem { }; created_at: string | null; updated_at: string | null; + has_active_discount: boolean; } export interface LandingDetail { @@ -185,6 +196,11 @@ export interface LandingDetail { display_order: number; created_at: string | null; updated_at: string | null; + discount_percent: number | null; + discount_overrides: Record | null; + discount_starts_at: string | null; + discount_ends_at: string | null; + discount_badge_text: LocaleDict | null; } export interface LandingCreateRequest { @@ -201,6 +217,11 @@ export interface LandingCreateRequest { custom_css?: string; meta_title?: LocaleDict; meta_description?: LocaleDict; + discount_percent?: number | null; + discount_overrides?: Record | null; + discount_starts_at?: string | null; + discount_ends_at?: string | null; + discount_badge_text?: LocaleDict | null; } export type LandingUpdateRequest = Partial; @@ -278,12 +299,12 @@ export const adminLandingsApi = { return response.data; }, - delete: async (id: number): Promise<{ message: string }> => { + delete: async (id: number): Promise<{ success: boolean }> => { const response = await apiClient.delete(`/cabinet/admin/landings/${id}`); return response.data; }, - toggle: async (id: number): Promise<{ id: number; is_active: boolean; message: string }> => { + toggle: async (id: number): Promise => { const response = await apiClient.post(`/cabinet/admin/landings/${id}/toggle`); return response.data; }, diff --git a/src/locales/en.json b/src/locales/en.json index 1f8fdec..a5065b9 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -3215,7 +3215,18 @@ "loadingPeriods": "Loading...", "periodDaySuffix": "d", "localeTab": "Language", - "localeHint": "Switch language to edit text in that language" + "localeHint": "Switch language to edit text in that language", + "discount": "Discount", + "discountEnabled": "Enable discount", + "discountPercent": "Discount %", + "discountStartsAt": "Start date", + "discountEndsAt": "End date", + "discountBadge": "Banner text (optional)", + "discountBadgePlaceholder": "e.g. Spring sale!", + "discountOverrides": "Per-tariff overrides", + "discountOverridesHint": "Leave empty to use global discount", + "discountPreview": "Preview", + "discountActive": "Discount" } }, "adminUpdates": { @@ -4003,6 +4014,12 @@ "giftPendingActivationDesc": "The recipient already has an active subscription. They will receive a link to activate the gift.", "autoLoginFailed": "Auto-login failed", "autoLoginProcessing": "Signing in...", + "discount": { + "days": "d", + "hours": "h", + "minutes": "m", + "seconds": "s" + }, "periodLabels": { "d1": "1 day", "d2": "2 days", diff --git a/src/locales/ru.json b/src/locales/ru.json index 96d9915..4ddc25a 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -3766,7 +3766,18 @@ "loadingPeriods": "Загрузка...", "periodDaySuffix": "д", "localeTab": "Язык", - "localeHint": "Переключите язык для редактирования текста на этом языке" + "localeHint": "Переключите язык для редактирования текста на этом языке", + "discount": "Скидка", + "discountEnabled": "Включить скидку", + "discountPercent": "Скидка %", + "discountStartsAt": "Дата начала", + "discountEndsAt": "Дата окончания", + "discountBadge": "Текст баннера (необязательно)", + "discountBadgePlaceholder": "напр. Весенняя распродажа!", + "discountOverrides": "Индивидуальные скидки по тарифам", + "discountOverridesHint": "Оставьте пустым для использования общей скидки", + "discountPreview": "Предпросмотр", + "discountActive": "Скидка" } }, "adminUpdates": { @@ -4562,6 +4573,12 @@ "giftPendingActivationDesc": "У получателя уже есть активная подписка. Ему будет отправлена ссылка для активации подарка.", "autoLoginFailed": "Не удалось выполнить автоматический вход", "autoLoginProcessing": "Выполняется вход...", + "discount": { + "days": "д", + "hours": "ч", + "minutes": "м", + "seconds": "с" + }, "periodLabels": { "d1": "1 день", "d2": "2 дня", diff --git a/src/pages/AdminLandingEditor.tsx b/src/pages/AdminLandingEditor.tsx index 5e09214..961aaa2 100644 --- a/src/pages/AdminLandingEditor.tsx +++ b/src/pages/AdminLandingEditor.tsx @@ -41,6 +41,12 @@ import { import { cn } from '../lib/utils'; import type { PaymentMethodSubOptionInfo } from '../types'; +function isoToDatetimeLocal(iso: string): string { + const d = new Date(iso); + const pad = (n: number) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; +} + const ChevronDownIcon = ({ open }: { open: boolean }) => ( ({}); const [customCss, setCustomCss] = useState(''); + // Discount state + const [discountPercent, setDiscountPercent] = useState(null); + const [discountOverrides, setDiscountOverrides] = useState>({}); + const [discountStartsAt, setDiscountStartsAt] = useState(''); + const [discountEndsAt, setDiscountEndsAt] = useState(''); + const [discountBadgeText, setDiscountBadgeText] = useState({}); + // DnD sensors const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), @@ -231,6 +245,15 @@ export default function AdminLandingEditor() { setGiftEnabled(landingData.gift_enabled); setFooterText(toLocaleDict(landingData.footer_text)); setCustomCss(landingData.custom_css ?? ''); + setDiscountPercent(landingData.discount_percent ?? null); + setDiscountOverrides(landingData.discount_overrides ?? {}); + setDiscountStartsAt( + landingData.discount_starts_at ? isoToDatetimeLocal(landingData.discount_starts_at) : '', + ); + setDiscountEndsAt( + landingData.discount_ends_at ? isoToDatetimeLocal(landingData.discount_ends_at) : '', + ); + setDiscountBadgeText(toLocaleDict(landingData.discount_badge_text)); }, [landingData]); // Create mutation @@ -328,6 +351,19 @@ export default function AdminLandingEditor() { custom_css: customCss || undefined, meta_title: nonEmptyDict(metaTitle), meta_description: nonEmptyDict(metaDescription), + discount_percent: discountPercent ?? null, + discount_overrides: + discountPercent !== null && Object.keys(discountOverrides).length > 0 + ? discountOverrides + : null, + discount_starts_at: + discountPercent !== null && discountStartsAt + ? new Date(discountStartsAt).toISOString() + : null, + discount_ends_at: + discountPercent !== null && discountEndsAt ? new Date(discountEndsAt).toISOString() : null, + discount_badge_text: + discountPercent !== null ? (nonEmptyDict(discountBadgeText) ?? null) : null, }; if (isEdit) { @@ -448,13 +484,19 @@ export default function AdminLandingEditor() { const toggleTariff = (tariffId: number) => { setSelectedTariffIds((prev) => { if (prev.includes(tariffId)) { - // Clean up allowedPeriods for unchecked tariff const key = String(tariffId); + // Clean up allowedPeriods for unchecked tariff setAllowedPeriods((ap) => { if (!(key in ap)) return ap; const { [key]: _, ...rest } = ap; return rest; }); + // Clean up discount override for unchecked tariff + setDiscountOverrides((overrides) => { + if (!(key in overrides)) return overrides; + const { [key]: _, ...rest } = overrides; + return rest; + }); return prev.filter((id) => id !== tariffId); } return [...prev, tariffId]; @@ -537,6 +579,7 @@ export default function AdminLandingEditor() { metaTitle, metaDescription, footerText, + discountBadgeText, ...features.flatMap((f) => [f.title, f.description]), ]} className="mb-4" @@ -729,6 +772,204 @@ export default function AdminLandingEditor() { + {/* Discount Section */} +
toggleSection('discount')} + > +
+ {/* Enable/disable discount */} +
+ + { + if (discountPercent !== null) { + setDiscountPercent(null); + setDiscountOverrides({}); + setDiscountStartsAt(''); + setDiscountEndsAt(''); + setDiscountBadgeText({}); + } else { + setDiscountPercent(10); + } + }} + /> +
+ + {discountPercent !== null && ( +
+ {/* Global percent */} +
+ +
+ setDiscountPercent(Number(e.target.value))} + className="h-2 flex-1 cursor-pointer appearance-none rounded-lg bg-dark-700 accent-accent-500" + /> +
+ { + const v = Math.min(99, Math.max(1, Number(e.target.value) || 1)); + setDiscountPercent(v); + }} + className="w-full bg-transparent px-2 py-1.5 text-center text-sm text-dark-100 outline-none" + /> + % +
+
+
+ + {/* Date range */} +
+
+ + setDiscountStartsAt(e.target.value)} + 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 [&::-webkit-calendar-picker-indicator]:invert" + /> +
+
+ + setDiscountEndsAt(e.target.value)} + 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 [&::-webkit-calendar-picker-indicator]:invert" + /> +
+
+ + {/* Badge text */} +
+ + +
+ + {/* Per-tariff overrides */} + {selectedTariffIds.length > 0 && ( +
+ +

+ {t( + 'admin.landings.discountOverridesHint', + 'Leave empty to use global discount', + )} +

+
+ {selectedTariffIds.map((tariffId) => { + const tariff = allTariffs.find((tr) => tr.id === tariffId); + if (!tariff) return null; + const override = discountOverrides[String(tariffId)]; + const hasOverride = override !== undefined; + return ( +
+ + {tariff.name} + + + {hasOverride ? `${override}%` : `${discountPercent}%`} + + { + const val = e.target.value; + setDiscountOverrides((prev) => { + if (!val) { + const { [String(tariffId)]: _, ...rest } = prev; + return rest; + } + return { + ...prev, + [String(tariffId)]: Math.min(99, Math.max(1, Number(val))), + }; + }); + }} + className="w-16 rounded border border-dark-600 bg-dark-700 px-2 py-1 text-center text-sm text-dark-100 outline-none focus:border-accent-500" + /> +
+ ); + })} +
+
+ )} + + {/* Preview */} + {selectedTariffIds.length > 0 && ( +
+

+ {t('admin.landings.discountPreview', 'Preview')} +

+ {selectedTariffIds.slice(0, 3).map((tariffId) => { + const tariff = allTariffs.find((tr) => tr.id === tariffId); + if (!tariff) return null; + const override = discountOverrides[String(tariffId)]; + const pct = override ?? discountPercent ?? 0; + const periods = tariffPeriodsMap[tariffId]; + if (!periods || periods.length === 0) return null; + const firstPeriod = periods[0]; + const discounted = Math.max( + 1, + firstPeriod.price_kopeks - + Math.floor((firstPeriod.price_kopeks * pct) / 100), + ); + return ( +
+ {tariff.name}: + + {formatPrice(firstPeriod.price_kopeks)} + + + {formatPrice(discounted)} + + + -{pct}% + +
+ ); + })} +
+ )} +
+ )} +
+ + {/* Payment Methods Section */}
)} + {landing.has_active_discount && ( + + {t('admin.landings.discountActive', 'Discount')} + + )}
diff --git a/src/pages/QuickPurchase.tsx b/src/pages/QuickPurchase.tsx index 8c95659..730c5e2 100644 --- a/src/pages/QuickPurchase.tsx +++ b/src/pages/QuickPurchase.tsx @@ -1,6 +1,6 @@ -import { useState, useEffect, useMemo, useRef } from 'react'; +import { useState, useEffect, useMemo, useRef, useCallback } from 'react'; import { useParams } from 'react-router'; -import { useQuery, useMutation } from '@tanstack/react-query'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { motion, AnimatePresence } from 'framer-motion'; import DOMPurify from 'dompurify'; @@ -338,9 +338,24 @@ function TariffCard({ {/* Price */} {selectedPeriod && (
- - {formatPrice(selectedPeriod.price_kopeks)} - +
+ + {formatPrice(selectedPeriod.price_kopeks)} + + {selectedPeriod.original_price_kopeks != null && + selectedPeriod.original_price_kopeks > selectedPeriod.price_kopeks && ( + <> + + {formatPrice(selectedPeriod.original_price_kopeks)} + + {selectedPeriod.discount_percent != null && ( + + -{selectedPeriod.discount_percent}% + + )} + + )} +
)} @@ -496,7 +511,22 @@ function SummaryCard({

{t('landing.total', 'Total')}

-

{formatPrice(currentPrice)}

+
+ {formatPrice(currentPrice)} + {selectedPeriod?.original_price_kopeks != null && + selectedPeriod.original_price_kopeks > selectedPeriod.price_kopeks && ( + <> + + {formatPrice(selectedPeriod.original_price_kopeks)} + + {selectedPeriod.discount_percent != null && ( + + -{selectedPeriod.discount_percent}% + + )} + + )} +
@@ -555,7 +585,14 @@ function SummaryCard({
) : ( <> - {t('landing.pay', 'Pay')} {formatPrice(currentPrice)} + {t('landing.pay', 'Pay')}{' '} + {selectedPeriod?.original_price_kopeks != null && + selectedPeriod.original_price_kopeks > selectedPeriod.price_kopeks && ( + + {formatPrice(selectedPeriod.original_price_kopeks)} + + )} + {formatPrice(currentPrice)} )} @@ -571,6 +608,110 @@ function SummaryCard({ ); } +// ============================================================ +// Discount Countdown +// ============================================================ + +function TimeUnit({ value, label }: { value: number; label: string }) { + return ( +
+ + {String(value).padStart(2, '0')} + + {label} +
+ ); +} + +function calcTimeLeft(endTime: number) { + const diff = Math.max(0, endTime - Date.now()); + return { + diff, + days: Math.floor(diff / 86_400_000), + hours: Math.floor((diff % 86_400_000) / 3_600_000), + minutes: Math.floor((diff % 3_600_000) / 60_000), + seconds: Math.floor((diff % 60_000) / 1_000), + }; +} + +function DiscountBanner({ + discount, + onExpired, +}: { + discount: { percent: number; ends_at: string; badge_text: string | null }; + onExpired: () => void; +}) { + const { t } = useTranslation(); + const endTime = useMemo(() => new Date(discount.ends_at).getTime(), [discount.ends_at]); + const initial = useMemo(() => calcTimeLeft(endTime), [endTime]); + const [timeLeft, setTimeLeft] = useState(initial); + const intervalRef = useRef>(undefined); + const expiredRef = useRef(false); + + useEffect(() => { + expiredRef.current = false; + const fresh = calcTimeLeft(endTime); + setTimeLeft(fresh); + if (fresh.diff === 0) { + if (!expiredRef.current) { + expiredRef.current = true; + onExpired(); + } + return; + } + + intervalRef.current = setInterval(() => { + const tl = calcTimeLeft(endTime); + setTimeLeft(tl); + if (tl.diff === 0) { + clearInterval(intervalRef.current); + if (!expiredRef.current) { + expiredRef.current = true; + onExpired(); + } + } + }, 1000); + + return () => clearInterval(intervalRef.current); + }, [endTime, onExpired]); + + return ( + +
+ {/* Left: badge + text */} +
+ + -{discount.percent}% + + {discount.badge_text && ( + {discount.badge_text} + )} +
+ + {/* Right: countdown */} +
+ {timeLeft.days > 0 && ( + <> + + : + + )} + + : + + : + +
+
+
+ ); +} + // ============================================================ // Main Component // ============================================================ @@ -578,6 +719,7 @@ function SummaryCard({ export default function QuickPurchase() { const { slug } = useParams<{ slug: string }>(); const { t, i18n } = useTranslation(); + const queryClient = useQueryClient(); // Fetch config const { @@ -592,6 +734,18 @@ export default function QuickPurchase() { retry: 1, }); + const [discountExpired, setDiscountExpired] = useState(false); + + const handleDiscountExpired = useCallback(() => { + setDiscountExpired(true); + queryClient.invalidateQueries({ queryKey: ['landing-config', slug] }); + }, [queryClient, slug]); + + // Reset expired flag when config changes (e.g. new discount scheduled) + useEffect(() => { + if (config?.discount) setDiscountExpired(false); + }, [config?.discount]); + // Selection state const [selectedTariffId, setSelectedTariffId] = useState(null); const [selectedPeriodDays, setSelectedPeriodDays] = useState(null); @@ -830,6 +984,13 @@ export default function QuickPurchase() { )} + {/* Discount banner */} + + {config.discount && !discountExpired && ( + + )} + + {/* Two-column layout */}
{/* Left column */}