From b2a6b959c18dea6d9fc9a7287c1edeab2cc9c4c8 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Wed, 4 Feb 2026 04:08:35 +0300 Subject: [PATCH] Restore drag-and-drop and add promo deactivation with Telegram popup Payment methods drag-and-drop fixes: - Restore original working drag-and-drop from commit 84c6d26 - Use position: relative instead of opacity for dragged items - Remove closestCenter collision detection - Use PointerSensor with distance: 5 without TouchSensor - Restore touch-none and responsive padding on drag handles - Restore original shadow and background colors Promo deactivation feature: - Add deactivation button to active discount banner in PromoOffersSection - Use Telegram native popup (showPopup) in Mini App environment - Fallback to modal for web browsers - Add confirmation modal with warning about permanent action - Integrate with useTelegramSDK for proper platform detection --- src/components/PromoOffersSection.tsx | 318 ++++++++++++++++++++++---- src/pages/AdminPaymentMethods.tsx | 19 +- 2 files changed, 282 insertions(+), 55 deletions(-) diff --git a/src/components/PromoOffersSection.tsx b/src/components/PromoOffersSection.tsx index 2f60104..a000b20 100644 --- a/src/components/PromoOffersSection.tsx +++ b/src/components/PromoOffersSection.tsx @@ -1,9 +1,11 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; -import { Link } from 'react-router-dom'; +import { useNavigate } from 'react-router-dom'; +import { createPortal } from 'react-dom'; import { promoApi, PromoOffer } from '../api/promo'; import { ClockIcon, CheckIcon } from './icons'; +import { useTelegramSDK } from '../hooks/useTelegramSDK'; // Helper functions const formatTimeLeft = ( @@ -68,16 +70,166 @@ const getOfferDescription = ( return t('promo.offers.activateDiscountHint'); }; +// Icons for deactivation +const XCircleIcon = () => ( + + + +); + +const WarningIcon = () => ( + + + +); + +// ------- Confirmation Modal ------- + +interface DeactivateConfirmModalProps { + isOpen: boolean; + discountPercent: number; + isDeactivating: boolean; + onConfirm: () => void; + onCancel: () => void; +} + +function DeactivateConfirmModal({ + isOpen, + discountPercent, + isDeactivating, + onConfirm, + onCancel, +}: DeactivateConfirmModalProps) { + const { t } = useTranslation(); + + // Escape key to close + useEffect(() => { + if (!isOpen) return; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape' && !isDeactivating) { + e.preventDefault(); + onCancel(); + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [isOpen, isDeactivating, onCancel]); + + // Scroll lock + useEffect(() => { + if (!isOpen) return; + + document.body.style.overflow = 'hidden'; + return () => { + document.body.style.overflow = ''; + }; + }, [isOpen]); + + if (!isOpen) return null; + + const modalContent = ( +
+ {/* Backdrop */} +
+ + {/* Modal */} +
e.stopPropagation()} + > + {/* Warning header */} +
+
+ +
+

+ {t('promo.deactivate.confirmTitle')} +

+

+ {t('promo.deactivate.confirmDescription', { percent: discountPercent })} +

+
+ + {/* Warning text */} +
+
+

{t('promo.deactivate.warning')}

+
+
+ + {/* Action buttons */} +
+ + +
+
+
+ ); + + if (typeof document !== 'undefined') { + return createPortal(modalContent, document.body); + } + return modalContent; +} + +// ------- Main Component ------- + interface PromoOffersSectionProps { className?: string; } export default function PromoOffersSection({ className = '' }: PromoOffersSectionProps) { const { t } = useTranslation(); + const navigate = useNavigate(); const queryClient = useQueryClient(); + const { isTelegramWebApp } = useTelegramSDK(); const [claimingId, setClaimingId] = useState(null); const [successMessage, setSuccessMessage] = useState(null); const [errorMessage, setErrorMessage] = useState(null); + const [showConfirmModal, setShowConfirmModal] = useState(false); // Fetch available offers const { data: offers = [], isLoading: offersLoading } = useQuery({ @@ -112,6 +264,26 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio }, }); + // Deactivate discount mutation + const deactivateMutation = useMutation({ + mutationFn: promoApi.deactivateDiscount, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['active-discount'] }); + queryClient.invalidateQueries({ queryKey: ['promo-offers'] }); + queryClient.invalidateQueries({ queryKey: ['subscription'] }); + queryClient.invalidateQueries({ queryKey: ['purchase-options'] }); + setShowConfirmModal(false); + setSuccessMessage(t('promo.deactivate.success')); + setTimeout(() => setSuccessMessage(null), 5000); + }, + onError: (error: unknown) => { + const axiosErr = error as { response?: { data?: { detail?: string } } }; + setErrorMessage(axiosErr.response?.data?.detail || t('promo.deactivate.error')); + setShowConfirmModal(false); + setTimeout(() => setErrorMessage(null), 5000); + }, + }); + const handleClaim = (offerId: number) => { setClaimingId(offerId); setErrorMessage(null); @@ -119,6 +291,49 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio claimMutation.mutate(offerId); }; + const handleUseNow = () => { + navigate('/subscription', { state: { scrollToExtend: true } }); + }; + + const handleDeactivateClick = () => { + setErrorMessage(null); + setSuccessMessage(null); + + // Use Telegram native popup in Mini App + if (isTelegramWebApp && window.Telegram?.WebApp?.showPopup) { + window.Telegram.WebApp.showPopup( + { + title: t('promo.deactivate.confirmTitle'), + message: t('promo.deactivate.confirmDescription', { + percent: activeDiscount?.discount_percent || 0, + }), + buttons: [ + { id: 'cancel', type: 'cancel' }, + { id: 'confirm', type: 'destructive', text: t('promo.deactivate.confirm') }, + ], + }, + (button_id: string) => { + if (button_id === 'confirm') { + deactivateMutation.mutate(); + } + }, + ); + } else { + // Fallback to modal for web + setShowConfirmModal(true); + } + }; + + const handleConfirmDeactivate = () => { + deactivateMutation.mutate(); + }; + + const handleCloseConfirmModal = () => { + if (!deactivateMutation.isPending) { + setShowConfirmModal(false); + } + }; + // Filter unclaimed and active offers const availableOffers = offers.filter((o) => o.is_active && !o.is_claimed); @@ -133,55 +348,59 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio return (
- {/* Active Discount Banner - clickable, navigates to subscription */} + {/* Active Discount Banner with actions */} {activeDiscount && activeDiscount.is_active && activeDiscount.discount_percent > 0 && ( - -
-
- 🏷️ -
-
-
-

- {t('promo.offers.discountActiveTitle', { - percent: activeDiscount.discount_percent, - })} -

- - -{activeDiscount.discount_percent}% - +
+
+ {/* Header */} +
+
+ 🏷️
-
- {activeDiscount.expires_at && ( -
- - - {t('promo.offers.expires', { - time: formatTimeLeft(activeDiscount.expires_at, t), - })} - -
- )} +
+
+

+ {t('promo.offers.discountActiveTitle', { + percent: activeDiscount.discount_percent, + })} +

+ + -{activeDiscount.discount_percent}% + +
+
+ {activeDiscount.expires_at && ( +
+ + + {t('promo.offers.expires', { + time: formatTimeLeft(activeDiscount.expires_at, t), + })} + +
+ )} +
- {/* Arrow indicator */} -
- + + {t('promo.useNow')} + +
- +
)} {/* Success/Error Messages */} @@ -266,6 +485,17 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio
)} + + {/* Deactivation Confirmation Modal */} + {activeDiscount && ( + + )}
); } diff --git a/src/pages/AdminPaymentMethods.tsx b/src/pages/AdminPaymentMethods.tsx index c23e30e..ed44c2f 100644 --- a/src/pages/AdminPaymentMethods.tsx +++ b/src/pages/AdminPaymentMethods.tsx @@ -4,10 +4,8 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { DndContext, - closestCenter, KeyboardSensor, PointerSensor, - TouchSensor, useSensor, useSensors, type DragEndEvent, @@ -101,11 +99,11 @@ function SortablePaymentCard({ config, onClick }: SortableCardProps) { id: config.method_id, }); - const style = { + const style: React.CSSProperties = { transform: CSS.Transform.toString(transform), transition, zIndex: isDragging ? 50 : undefined, - opacity: isDragging ? 0.85 : 1, + position: isDragging ? 'relative' : undefined, }; const displayName = config.display_name || config.default_display_name; @@ -140,19 +138,20 @@ function SortablePaymentCard({ config, onClick }: SortableCardProps) {
{/* Drag handle */} + {/* Drag handle - larger touch target for mobile */}