feat: add discount UI for landing pages

Add discount configuration in admin editor and countdown timer
display on public purchase page.

- Add discount section to AdminLandingEditor with toggle, percent
  slider, datetime pickers, per-tariff overrides, live preview
- Add DiscountBanner with countdown timer to QuickPurchase page
  using AnimatePresence exit animation and proper interval cleanup
- Add strikethrough prices and discount badges to tariff cards,
  summary card and pay button with null/equality guards
- Add discount translations for ru and en locales
- Fix delete/toggle API return types to match backend contracts
- Fix isoToDatetimeLocal to preserve seconds on re-edit
- Fix preview rounding to match backend integer division
This commit is contained in:
Fringg
2026-03-07 07:01:46 +03:00
parent c3cf805827
commit f7afa002f0
6 changed files with 474 additions and 12 deletions

View File

@@ -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<string, number> | 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<string, number> | null;
discount_starts_at?: string | null;
discount_ends_at?: string | null;
discount_badge_text?: LocaleDict | null;
}
export type LandingUpdateRequest = Partial<LandingCreateRequest>;
@@ -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<LandingDetail> => {
const response = await apiClient.post(`/cabinet/admin/landings/${id}/toggle`);
return response.data;
},

View File

@@ -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",

View File

@@ -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 дня",

View File

@@ -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 }) => (
<svg
className={cn('h-5 w-5 transition-transform', open && 'rotate-180')}
@@ -93,6 +99,7 @@ export default function AdminLandingEditor() {
general: true,
features: false,
tariffs: false,
discount: false,
methods: false,
gifts: false,
footer: false,
@@ -120,6 +127,13 @@ export default function AdminLandingEditor() {
const [footerText, setFooterText] = useState<LocaleDict>({});
const [customCss, setCustomCss] = useState('');
// Discount state
const [discountPercent, setDiscountPercent] = useState<number | null>(null);
const [discountOverrides, setDiscountOverrides] = useState<Record<string, number>>({});
const [discountStartsAt, setDiscountStartsAt] = useState('');
const [discountEndsAt, setDiscountEndsAt] = useState('');
const [discountBadgeText, setDiscountBadgeText] = useState<LocaleDict>({});
// 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() {
</div>
</Section>
{/* Discount Section */}
<Section
title={t('admin.landings.discount', 'Discount')}
open={openSections.discount}
onToggle={() => toggleSection('discount')}
>
<div className="space-y-4">
{/* Enable/disable discount */}
<div className="flex items-center justify-between">
<label className="text-sm text-dark-400">
{t('admin.landings.discountEnabled', 'Enable discount')}
</label>
<Toggle
checked={discountPercent !== null}
onChange={() => {
if (discountPercent !== null) {
setDiscountPercent(null);
setDiscountOverrides({});
setDiscountStartsAt('');
setDiscountEndsAt('');
setDiscountBadgeText({});
} else {
setDiscountPercent(10);
}
}}
/>
</div>
{discountPercent !== null && (
<div className="space-y-4">
{/* Global percent */}
<div>
<label className="mb-1 block text-sm text-dark-400">
{t('admin.landings.discountPercent', 'Discount %')}
</label>
<div className="flex items-center gap-3">
<input
type="range"
min={1}
max={99}
value={discountPercent}
onChange={(e) => setDiscountPercent(Number(e.target.value))}
className="h-2 flex-1 cursor-pointer appearance-none rounded-lg bg-dark-700 accent-accent-500"
/>
<div className="flex w-20 items-center rounded-lg border border-dark-700 bg-dark-800">
<input
type="number"
min={1}
max={99}
value={discountPercent}
onChange={(e) => {
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"
/>
<span className="pr-2 text-sm text-dark-400">%</span>
</div>
</div>
</div>
{/* Date range */}
<div className="grid gap-3 sm:grid-cols-2">
<div>
<label className="mb-1 block text-sm text-dark-400">
{t('admin.landings.discountStartsAt', 'Start date')}
</label>
<input
type="datetime-local"
value={discountStartsAt}
onChange={(e) => 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"
/>
</div>
<div>
<label className="mb-1 block text-sm text-dark-400">
{t('admin.landings.discountEndsAt', 'End date')}
</label>
<input
type="datetime-local"
value={discountEndsAt}
onChange={(e) => 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"
/>
</div>
</div>
{/* Badge text */}
<div>
<label className="mb-1 block text-sm text-dark-400">
{t('admin.landings.discountBadge', 'Banner text (optional)')}
</label>
<LocalizedInput
value={discountBadgeText}
onChange={setDiscountBadgeText}
locale={editingLocale}
placeholder={t('admin.landings.discountBadgePlaceholder', 'e.g. Spring sale!')}
/>
</div>
{/* Per-tariff overrides */}
{selectedTariffIds.length > 0 && (
<div>
<label className="mb-2 block text-sm text-dark-400">
{t('admin.landings.discountOverrides', 'Per-tariff overrides')}
</label>
<p className="mb-2 text-xs text-dark-500">
{t(
'admin.landings.discountOverridesHint',
'Leave empty to use global discount',
)}
</p>
<div className="space-y-2">
{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 (
<div
key={tariffId}
className="flex items-center gap-3 rounded-lg border border-dark-700 bg-dark-800/50 px-3 py-2"
>
<span className="min-w-0 flex-1 truncate text-sm text-dark-200">
{tariff.name}
</span>
<span className="text-xs text-dark-500">
{hasOverride ? `${override}%` : `${discountPercent}%`}
</span>
<input
type="number"
min={1}
max={99}
value={hasOverride ? override : ''}
placeholder={String(discountPercent)}
onChange={(e) => {
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"
/>
</div>
);
})}
</div>
</div>
)}
{/* Preview */}
{selectedTariffIds.length > 0 && (
<div className="rounded-lg border border-dark-600 bg-dark-800/30 p-3">
<p className="mb-2 text-xs font-medium uppercase tracking-wider text-dark-500">
{t('admin.landings.discountPreview', 'Preview')}
</p>
{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 (
<div key={tariffId} className="flex items-center gap-2 py-1">
<span className="text-sm text-dark-300">{tariff.name}:</span>
<span className="text-xs text-dark-500 line-through">
{formatPrice(firstPeriod.price_kopeks)}
</span>
<span className="text-sm font-semibold text-accent-400">
{formatPrice(discounted)}
</span>
<span className="rounded-full bg-accent-500/20 px-1.5 py-0.5 text-[10px] font-medium text-accent-400">
-{pct}%
</span>
</div>
);
})}
</div>
)}
</div>
)}
</div>
</Section>
{/* Payment Methods Section */}
<Section
title={t('admin.landings.paymentMethods')}

View File

@@ -160,6 +160,11 @@ function SortableLandingCard({
<GiftIcon />
</span>
)}
{landing.has_active_discount && (
<span className="rounded-full bg-accent-500/20 px-2 py-0.5 text-[10px] font-medium text-accent-400">
{t('admin.landings.discountActive', 'Discount')}
</span>
)}
</div>
<div className="text-sm text-dark-400">
<span>

View File

@@ -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 && (
<div className="mt-3 border-t border-dark-800/30 pt-3">
<span className="text-lg font-bold text-accent-400">
{formatPrice(selectedPeriod.price_kopeks)}
</span>
<div className="flex items-center gap-2">
<span className="text-lg font-bold text-accent-400">
{formatPrice(selectedPeriod.price_kopeks)}
</span>
{selectedPeriod.original_price_kopeks != null &&
selectedPeriod.original_price_kopeks > selectedPeriod.price_kopeks && (
<>
<span className="text-sm text-dark-500 line-through">
{formatPrice(selectedPeriod.original_price_kopeks)}
</span>
{selectedPeriod.discount_percent != null && (
<span className="rounded-full bg-accent-500/20 px-1.5 py-0.5 text-[10px] font-bold text-accent-400">
-{selectedPeriod.discount_percent}%
</span>
)}
</>
)}
</div>
</div>
)}
</button>
@@ -496,7 +511,22 @@ function SummaryCard({
<p className="text-xs font-medium uppercase tracking-wider text-dark-500">
{t('landing.total', 'Total')}
</p>
<p className="mt-1 text-2xl font-bold text-accent-400">{formatPrice(currentPrice)}</p>
<div className="mt-1 flex items-center gap-2">
<span className="text-2xl font-bold text-accent-400">{formatPrice(currentPrice)}</span>
{selectedPeriod?.original_price_kopeks != null &&
selectedPeriod.original_price_kopeks > selectedPeriod.price_kopeks && (
<>
<span className="text-base text-dark-500 line-through">
{formatPrice(selectedPeriod.original_price_kopeks)}
</span>
{selectedPeriod.discount_percent != null && (
<span className="rounded-full bg-accent-500/20 px-2 py-0.5 text-xs font-bold text-accent-400">
-{selectedPeriod.discount_percent}%
</span>
)}
</>
)}
</div>
</div>
</div>
@@ -555,7 +585,14 @@ function SummaryCard({
<div className="h-5 w-5 animate-spin rounded-full border-2 border-white/30 border-t-white" />
) : (
<>
{t('landing.pay', 'Pay')} {formatPrice(currentPrice)}
{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>
@@ -571,6 +608,110 @@ function SummaryCard({
);
}
// ============================================================
// Discount Countdown
// ============================================================
function TimeUnit({ value, label }: { value: number; label: string }) {
return (
<div className="flex flex-col items-center">
<span className="flex h-10 w-10 items-center justify-center rounded-lg bg-dark-800/80 text-lg font-bold tabular-nums text-dark-50 sm:h-12 sm:w-12 sm:text-xl">
{String(value).padStart(2, '0')}
</span>
<span className="mt-1 text-[10px] uppercase tracking-wider text-dark-500">{label}</span>
</div>
);
}
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<ReturnType<typeof setInterval>>(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 (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="mb-8 overflow-hidden rounded-2xl border border-accent-500/30 bg-gradient-to-r from-accent-500/10 via-accent-500/5 to-transparent"
>
<div className="flex flex-col items-center gap-4 px-5 py-5 sm:flex-row sm:justify-between">
{/* Left: badge + text */}
<div className="flex items-center gap-3">
<span className="shrink-0 rounded-full bg-accent-500 px-3 py-1 text-sm font-bold text-white shadow-lg shadow-accent-500/25">
-{discount.percent}%
</span>
{discount.badge_text && (
<span className="text-sm font-medium text-dark-100">{discount.badge_text}</span>
)}
</div>
{/* Right: countdown */}
<div className="flex items-center gap-1.5">
{timeLeft.days > 0 && (
<>
<TimeUnit value={timeLeft.days} label={t('landing.discount.days', 'd')} />
<span className="text-lg font-bold text-dark-500">:</span>
</>
)}
<TimeUnit value={timeLeft.hours} label={t('landing.discount.hours', 'h')} />
<span className="text-lg font-bold text-dark-500">:</span>
<TimeUnit value={timeLeft.minutes} label={t('landing.discount.minutes', 'm')} />
<span className="text-lg font-bold text-dark-500">:</span>
<TimeUnit value={timeLeft.seconds} label={t('landing.discount.seconds', 's')} />
</div>
</div>
</motion.div>
);
}
// ============================================================
// 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<number | null>(null);
const [selectedPeriodDays, setSelectedPeriodDays] = useState<number | null>(null);
@@ -830,6 +984,13 @@ export default function QuickPurchase() {
)}
</motion.div>
{/* Discount banner */}
<AnimatePresence>
{config.discount && !discountExpired && (
<DiscountBanner discount={config.discount} onExpired={handleDiscountExpired} />
)}
</AnimatePresence>
{/* Two-column layout */}
<div className="grid gap-8 lg:grid-cols-[1fr_380px]">
{/* Left column */}