mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
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:
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 дня",
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
Reference in New Issue
Block a user