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

@@ -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')}