diff --git a/src/locales/en.json b/src/locales/en.json index 8922cc4..e25c87f 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -3188,7 +3188,12 @@ "invalidSlug": "Slug can only contain lowercase letters, numbers, and hyphens", "titleRequired": "Title is required", "noTariffs": "Select at least one tariff", - "noPaymentMethods": "Add at least one payment method" + "noPaymentMethods": "Add at least one payment method", + "selectMethods": "Select available methods", + "noSystemMethods": "No payment methods configured in the system", + "methodOrder": "Drag to reorder", + "loadingPeriods": "Loading...", + "periodDaySuffix": "d" } }, "adminUpdates": { diff --git a/src/locales/fa.json b/src/locales/fa.json index 82cf1ee..c89577b 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -2914,7 +2914,12 @@ "invalidSlug": "Slug can only contain lowercase letters, numbers, and hyphens", "titleRequired": "Title is required", "noTariffs": "Select at least one tariff", - "noPaymentMethods": "Add at least one payment method" + "noPaymentMethods": "Add at least one payment method", + "selectMethods": "انتخاب روش‌های پرداخت موجود", + "noSystemMethods": "هیچ روش پرداختی در سیستم پیکربندی نشده است", + "methodOrder": "برای تغییر ترتیب بکشید", + "loadingPeriods": "در حال بارگذاری...", + "periodDaySuffix": "روز" } }, "adminUpdates": { diff --git a/src/locales/ru.json b/src/locales/ru.json index 250e706..db7764f 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -3743,7 +3743,12 @@ "invalidSlug": "Slug может содержать только строчные буквы, цифры и дефисы", "titleRequired": "Заголовок обязателен", "noTariffs": "Выберите хотя бы один тариф", - "noPaymentMethods": "Добавьте хотя бы один способ оплаты" + "noPaymentMethods": "Добавьте хотя бы один способ оплаты", + "selectMethods": "Выберите доступные методы", + "noSystemMethods": "В системе не настроено ни одного способа оплаты", + "methodOrder": "Перетащите для изменения порядка", + "loadingPeriods": "Загрузка...", + "periodDaySuffix": "д" } }, "adminUpdates": { diff --git a/src/locales/zh.json b/src/locales/zh.json index ac80a4d..5b4cb7c 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -2913,7 +2913,12 @@ "invalidSlug": "Slug can only contain lowercase letters, numbers, and hyphens", "titleRequired": "Title is required", "noTariffs": "Select at least one tariff", - "noPaymentMethods": "Add at least one payment method" + "noPaymentMethods": "Add at least one payment method", + "selectMethods": "选择可用的支付方式", + "noSystemMethods": "系统中未配置任何支付方式", + "methodOrder": "拖动以调整顺序", + "loadingPeriods": "加载中...", + "periodDaySuffix": "天" } }, "adminUpdates": { diff --git a/src/pages/AdminLandingEditor.tsx b/src/pages/AdminLandingEditor.tsx index a714087..fb94fda 100644 --- a/src/pages/AdminLandingEditor.tsx +++ b/src/pages/AdminLandingEditor.tsx @@ -1,6 +1,6 @@ -import { useState, useCallback, useEffect, useRef } from 'react'; +import { useState, useCallback, useEffect, useRef, useMemo } from 'react'; import { useNavigate, useParams } from 'react-router'; -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useQuery, useQueries, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { adminLandingsApi, @@ -8,7 +8,8 @@ import { LandingFeature, LandingPaymentMethod, } from '../api/landings'; -import { tariffsApi, TariffListItem } from '../api/tariffs'; +import { tariffsApi, TariffListItem, PeriodPrice } from '../api/tariffs'; +import { adminPaymentMethodsApi } from '../api/adminPaymentMethods'; import { Toggle } from '../components/admin'; import { useNotify } from '@/platform'; import { usePlatform } from '../platform/hooks/usePlatform'; @@ -48,6 +49,10 @@ const ChevronDownIcon = ({ open }: { open: boolean }) => ( ); +function formatPrice(kopeks: number): string { + return `${(kopeks / 100).toLocaleString('ru-RU')} \u20BD`; +} + // ============ Sortable Feature Item ============ interface SortableFeatureProps { @@ -121,17 +126,14 @@ function SortableFeatureItem({ feature, index, onUpdate, onRemove }: SortableFea ); } -// ============ Sortable Payment Method ============ +// ============ Sortable Selected Payment Method Card ============ -interface SortableMethodProps { +interface SortableSelectedMethodProps { method: MethodWithId; - index: number; - onUpdate: (index: number, field: keyof LandingPaymentMethod, value: string) => void; - onRemove: (index: number) => void; + onRemove: (methodId: string) => void; } -function SortableMethodItem({ method, index, onUpdate, onRemove }: SortableMethodProps) { - const { t } = useTranslation(); +function SortableSelectedMethodCard({ method, onRemove }: SortableSelectedMethodProps) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: method._id, }); @@ -148,54 +150,21 @@ function SortableMethodItem({ method, index, onUpdate, onRemove }: SortableMetho ref={setNodeRef} style={style} className={cn( - 'flex items-start gap-2 rounded-lg border p-3', + 'flex items-center gap-2 rounded-lg border px-3 py-2', isDragging ? 'border-accent-500/50 bg-dark-700' : 'border-dark-700 bg-dark-800/50', )} > -
-
- onUpdate(index, 'method_id', e.target.value)} - placeholder={t('admin.landings.methodId')} - className="rounded-lg border border-dark-700 bg-dark-800 px-3 py-1.5 text-sm text-dark-100 outline-none focus:border-accent-500" - /> - onUpdate(index, 'display_name', e.target.value)} - placeholder={t('admin.landings.methodName')} - className="rounded-lg border border-dark-700 bg-dark-800 px-3 py-1.5 text-sm text-dark-100 outline-none focus:border-accent-500" - /> -
-
- onUpdate(index, 'description', e.target.value)} - placeholder={t('admin.landings.methodDesc')} - className="rounded-lg border border-dark-700 bg-dark-800 px-3 py-1.5 text-sm text-dark-100 outline-none focus:border-accent-500" - /> - onUpdate(index, 'icon_url', e.target.value)} - placeholder={t('admin.landings.methodIcon')} - className="rounded-lg border border-dark-700 bg-dark-800 px-3 py-1.5 text-sm text-dark-100 outline-none focus:border-accent-500" - /> -
-
+ {method.display_name} @@ -282,6 +251,38 @@ export default function AdminLandingEditor() { const allTariffs = tariffsData?.tariffs ?? []; + // Fetch system payment methods + const { data: systemMethods } = useQuery({ + queryKey: ['admin-payment-methods'], + queryFn: () => adminPaymentMethodsApi.getAll(), + staleTime: 30_000, + }); + + const availablePaymentMethods = useMemo( + () => (systemMethods ?? []).filter((m) => m.is_enabled && m.is_provider_configured), + [systemMethods], + ); + + // Fetch tariff details for period info + const tariffDetailQueries = useQueries({ + queries: selectedTariffIds.map((tariffId) => ({ + queryKey: ['admin-tariff-detail', tariffId], + queryFn: () => tariffsApi.getTariff(tariffId), + staleTime: 60_000, + enabled: selectedTariffIds.includes(tariffId), + })), + }); + + const tariffPeriodsMap = useMemo(() => { + const map: Record = {}; + tariffDetailQueries.forEach((q, i) => { + if (q.data) { + map[selectedTariffIds[i]] = q.data.period_prices; + } + }); + return map; + }, [tariffDetailQueries, selectedTariffIds]); + // Fetch landing for editing const { data: landingData } = useQuery({ queryKey: ['admin-landing', id], @@ -431,26 +432,30 @@ export default function AdminLandingEditor() { }, []); // ---- Payment methods helpers ---- - const addMethod = () => { - setPaymentMethods((prev) => [ - ...prev, - { - _id: crypto.randomUUID(), - method_id: '', - display_name: '', - description: '', - icon_url: '', - sort_order: prev.length, - }, - ]); + const togglePaymentMethod = (methodId: string) => { + setPaymentMethods((prev) => { + const exists = prev.find((m) => m.method_id === methodId); + if (exists) { + return prev.filter((m) => m.method_id !== methodId); + } + const systemMethod = availablePaymentMethods.find((m) => m.method_id === methodId); + if (!systemMethod) return prev; + return [ + ...prev, + { + _id: crypto.randomUUID(), + method_id: systemMethod.method_id, + display_name: systemMethod.display_name ?? systemMethod.default_display_name, + description: '', + icon_url: '', + sort_order: prev.length, + }, + ]; + }); }; - const updateMethod = (index: number, field: keyof LandingPaymentMethod, value: string) => { - setPaymentMethods((prev) => prev.map((m, i) => (i === index ? { ...m, [field]: value } : m))); - }; - - const removeMethod = (index: number) => { - setPaymentMethods((prev) => prev.filter((_, i) => i !== index)); + const removePaymentMethod = (methodId: string) => { + setPaymentMethods((prev) => prev.filter((m) => m.method_id !== methodId)); }; const handleMethodDragEnd = useCallback((event: DragEndEvent) => { @@ -472,13 +477,27 @@ export default function AdminLandingEditor() { ); }; - const togglePeriod = (tariffId: number, days: number) => { + const togglePeriodFromTariff = (tariffId: number, days: number, allPeriods: PeriodPrice[]) => { const key = String(tariffId); + const allDays = allPeriods.map((p) => p.days); + setAllowedPeriods((prev) => { - const current = prev[key] ?? []; - const updated = current.includes(days) - ? current.filter((d) => d !== days) - : [...current, days]; + const current = prev[key]; + if (!current) { + // No override yet -- all periods allowed. Remove this one. + const updated = allDays.filter((d) => d !== days); + return { ...prev, [key]: updated }; + } + + const hasDay = current.includes(days); + const updated = hasDay ? current.filter((d) => d !== days) : [...current, days]; + + // If all periods are selected again, remove the override + if (updated.length === allDays.length) { + const { [key]: _, ...rest } = prev; + return rest; + } + return { ...prev, [key]: updated }; }); }; @@ -664,23 +683,44 @@ export default function AdminLandingEditor() { )} - {/* Period checkboxes if tariff is selected and is not daily */} + {/* Period checkboxes from tariff detail */} {selectedTariffIds.includes(tariff.id) && !tariff.is_daily && ( -
+
{t('admin.landings.periods')}: - {/* We show known periods from the tariff detail. Since TariffListItem doesn't have period_prices, - we let the user type periods or rely on the backend returning allowed_periods. - For simplicity, show the allowed_periods for this tariff if any are set. */} - {(allowedPeriods[String(tariff.id)] ?? []).map((days) => ( - - ))} - + {tariffPeriodsMap[tariff.id] ? ( +
+ {tariffPeriodsMap[tariff.id].map((period) => { + const override = allowedPeriods[String(tariff.id)]; + const isAllowed = !override || override.includes(period.days); + return ( + + ); + })} +
+ ) : ( + + {t('admin.landings.loadingPeriods')} + + )}
)}
@@ -694,27 +734,62 @@ export default function AdminLandingEditor() { open={openSections.methods} onToggle={() => toggleSection('methods')} > -
- - - {paymentMethods.map((method, index) => ( - - ))} - - - +
+ {/* Available system methods as toggleable list */} +
+

{t('admin.landings.selectMethods')}

+
+ {availablePaymentMethods.map((sysMethod) => { + const isSelected = paymentMethods.some( + (m) => m.method_id === sysMethod.method_id, + ); + return ( + + ); + })} + {availablePaymentMethods.length === 0 && ( +

{t('admin.landings.noSystemMethods')}

+ )} +
+
+ + {/* Selected methods with drag-to-reorder */} + {paymentMethods.length > 0 && ( +
+

{t('admin.landings.methodOrder')}

+ + +
+ {paymentMethods.map((method) => ( + + ))} +
+
+
+
+ )}
@@ -765,60 +840,3 @@ export default function AdminLandingEditor() {
); } - -// ============ Tiny helper for adding a period ============ - -function AddPeriodButton({ - tariffId, - onAdd, -}: { - tariffId: number; - onAdd: (tariffId: number, days: number) => void; -}) { - const [value, setValue] = useState(''); - const [showInput, setShowInput] = useState(false); - - if (!showInput) { - return ( - - ); - } - - return ( - setValue(e.target.value)} - onBlur={() => { - const days = parseInt(value, 10); - if (days > 0) { - onAdd(tariffId, days); - } - setValue(''); - setShowInput(false); - }} - onKeyDown={(e) => { - if (e.key === 'Enter') { - const days = parseInt(value, 10); - if (days > 0) { - onAdd(tariffId, days); - } - setValue(''); - setShowInput(false); - } - if (e.key === 'Escape') { - setValue(''); - setShowInput(false); - } - }} - placeholder="30" - className="w-16 rounded border border-dark-600 bg-dark-800 px-2 py-0.5 text-xs text-dark-100 outline-none focus:border-accent-500" - /> - ); -} diff --git a/src/pages/QuickPurchase.tsx b/src/pages/QuickPurchase.tsx index 64029d5..85453fe 100644 --- a/src/pages/QuickPurchase.tsx +++ b/src/pages/QuickPurchase.tsx @@ -569,22 +569,53 @@ export default function QuickPurchase() { }; }, []); + // Collect ALL unique periods across ALL tariffs + const allPeriods = useMemo(() => { + if (!config) return []; + const periodMap = new Map(); + for (const tariff of config.tariffs) { + for (const period of tariff.periods) { + if (!periodMap.has(period.days)) { + periodMap.set(period.days, period); + } + } + } + return Array.from(periodMap.values()).sort((a, b) => a.days - b.days); + }, [config]); + + // Filter tariffs to only those that have the selected period + const visibleTariffs = useMemo(() => { + if (!config || !selectedPeriodDays) return config?.tariffs ?? []; + return config.tariffs.filter((t) => t.periods.some((p) => p.days === selectedPeriodDays)); + }, [config, selectedPeriodDays]); + // Auto-select first tariff, period, method on config load useEffect(() => { if (!config) return; - if (config.tariffs.length > 0 && selectedTariffId === null) { - const firstTariff = config.tariffs[0]; - setSelectedTariffId(firstTariff.id); - if (firstTariff.periods.length > 0 && selectedPeriodDays === null) { - setSelectedPeriodDays(firstTariff.periods[0].days); - } + // Auto-select first period from all available periods + if (allPeriods.length > 0 && selectedPeriodDays === null) { + setSelectedPeriodDays(allPeriods[0].days); + } + + // Auto-select first visible tariff + if (visibleTariffs.length > 0 && selectedTariffId === null) { + setSelectedTariffId(visibleTariffs[0].id); } if (config.payment_methods.length > 0 && selectedMethod === null) { setSelectedMethod(config.payment_methods[0].method_id); } - }, [config, selectedTariffId, selectedPeriodDays, selectedMethod]); + }, [config, allPeriods, visibleTariffs, selectedTariffId, selectedPeriodDays, selectedMethod]); + + // When period changes, auto-select first visible tariff if current is hidden + useEffect(() => { + if (!visibleTariffs.length) return; + const currentVisible = visibleTariffs.find((t) => t.id === selectedTariffId); + if (!currentVisible) { + setSelectedTariffId(visibleTariffs[0].id); + } + }, [visibleTariffs, selectedTariffId]); // SEO: set document title useEffect(() => { @@ -647,24 +678,13 @@ export default function QuickPurchase() { [config?.tariffs, selectedTariffId], ); - const availablePeriods = useMemo(() => selectedTariff?.periods ?? [], [selectedTariff]); - const selectedPeriod = useMemo( - () => availablePeriods.find((p) => p.days === selectedPeriodDays), - [availablePeriods, selectedPeriodDays], + () => selectedTariff?.periods.find((p) => p.days === selectedPeriodDays), + [selectedTariff, selectedPeriodDays], ); const currentPrice = selectedPeriod?.price_kopeks ?? 0; - // When tariff changes, reset period to first available if current is invalid - useEffect(() => { - if (!selectedTariff) return; - const hasCurrent = selectedTariff.periods.some((p) => p.days === selectedPeriodDays); - if (!hasCurrent && selectedTariff.periods.length > 0) { - setSelectedPeriodDays(selectedTariff.periods[0].days); - } - }, [selectedTariff, selectedPeriodDays]); - // Validation const canSubmit = useMemo(() => { if (!selectedTariffId || !selectedPeriodDays || !selectedMethod) return false; @@ -727,7 +747,7 @@ export default function QuickPurchase() { return ; } - const showTariffCards = config.tariffs.length > 1; + const showTariffCards = visibleTariffs.length > 1; return (
@@ -757,13 +777,13 @@ export default function QuickPurchase() { className="space-y-6" > {/* Period tabs */} - {availablePeriods.length > 0 && ( + {allPeriods.length > 0 && (

{t('landing.choosePeriod', 'Choose period')}

@@ -801,7 +821,7 @@ export default function QuickPurchase() { aria-label={t('landing.chooseTariff', 'Choose tariff')} className="grid gap-3 sm:grid-cols-2" > - {config.tariffs.map((tariff) => { + {visibleTariffs.map((tariff) => { const period = tariff.periods.find((p) => p.days === selectedPeriodDays); return (