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
This commit is contained in:
c0mrade
2026-02-04 04:08:35 +03:00
parent 6d5ee3856e
commit b2a6b959c1
2 changed files with 282 additions and 55 deletions

View File

@@ -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 = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9.75 9.75l4.5 4.5m0-4.5l-4.5 4.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
);
const WarningIcon = () => (
<svg
className="h-12 w-12"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
/>
</svg>
);
// ------- 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 = (
<div
className="fixed inset-0 z-[100] flex items-center justify-center"
role="dialog"
aria-modal="true"
aria-labelledby="deactivate-modal-title"
>
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/70 backdrop-blur-sm"
onClick={isDeactivating ? undefined : onCancel}
/>
{/* Modal */}
<div
className="relative mx-4 w-full max-w-sm overflow-hidden rounded-2xl border border-dark-700/50 bg-dark-900 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
{/* Warning header */}
<div className="flex flex-col items-center bg-gradient-to-br from-warning-500/20 to-red-500/20 px-6 pb-6 pt-8">
<div className="mb-3 text-warning-400">
<WarningIcon />
</div>
<h2 id="deactivate-modal-title" className="text-center text-lg font-bold text-dark-100">
{t('promo.deactivate.confirmTitle')}
</h2>
<p className="mt-2 text-center text-sm text-dark-300">
{t('promo.deactivate.confirmDescription', { percent: discountPercent })}
</p>
</div>
{/* Warning text */}
<div className="px-6 py-4">
<div className="rounded-lg border border-warning-500/20 bg-warning-500/10 px-4 py-3">
<p className="text-center text-sm text-warning-400">{t('promo.deactivate.warning')}</p>
</div>
</div>
{/* Action buttons */}
<div className="flex gap-3 px-6 pb-6">
<button
onClick={onCancel}
disabled={isDeactivating}
className="flex-1 rounded-xl bg-dark-800 py-3 font-semibold text-dark-300 transition-colors hover:bg-dark-700 hover:text-dark-100 disabled:opacity-50"
>
{t('common.cancel')}
</button>
<button
onClick={onConfirm}
disabled={isDeactivating}
className="flex-1 rounded-xl bg-gradient-to-r from-red-500 to-red-600 py-3 font-semibold text-white shadow-lg shadow-red-500/25 transition-all hover:from-red-400 hover:to-red-500 active:from-red-600 active:to-red-700 disabled:opacity-50"
>
{isDeactivating ? (
<span className="flex items-center justify-center gap-2">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
{t('promo.deactivate.deactivating')}
</span>
) : (
t('promo.deactivate.confirm')
)}
</button>
</div>
</div>
</div>
);
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<number | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(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 (
<div className={`space-y-4 ${className}`}>
{/* Active Discount Banner - clickable, navigates to subscription */}
{/* Active Discount Banner with actions */}
{activeDiscount && activeDiscount.is_active && activeDiscount.discount_percent > 0 && (
<Link
to="/subscription"
state={{ scrollToExtend: true }}
className="card group block cursor-pointer border-success-500/30 bg-gradient-to-br from-success-500/10 to-accent-500/5 transition-all duration-200 hover:scale-[1.01] hover:border-success-500/50 hover:from-success-500/20 hover:to-accent-500/10 hover:shadow-lg hover:shadow-success-500/10 active:scale-[0.99]"
>
<div className="flex items-center gap-4">
<div className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl bg-success-500/20 text-success-400 transition-transform duration-200 group-hover:scale-110">
<span className="text-2xl">🏷</span>
</div>
<div className="min-w-0 flex-1">
<div className="mb-1 flex items-center gap-2">
<h3 className="font-semibold text-dark-100">
{t('promo.offers.discountActiveTitle', {
percent: activeDiscount.discount_percent,
})}
</h3>
<span className="rounded bg-success-500/20 px-2 py-0.5 text-xs font-bold text-success-400">
-{activeDiscount.discount_percent}%
</span>
<div className="card border-success-500/30 bg-gradient-to-br from-success-500/10 to-accent-500/5">
<div className="flex flex-col gap-4">
{/* Header */}
<div className="flex items-center gap-4">
<div className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl bg-success-500/20 text-success-400">
<span className="text-2xl">🏷</span>
</div>
<div className="flex items-center gap-4 text-sm text-dark-400">
{activeDiscount.expires_at && (
<div className="flex items-center gap-1">
<ClockIcon />
<span>
{t('promo.offers.expires', {
time: formatTimeLeft(activeDiscount.expires_at, t),
})}
</span>
</div>
)}
<div className="min-w-0 flex-1">
<div className="mb-1 flex flex-wrap items-center gap-2">
<h3 className="font-semibold text-dark-100">
{t('promo.offers.discountActiveTitle', {
percent: activeDiscount.discount_percent,
})}
</h3>
<span className="rounded bg-success-500/20 px-2 py-0.5 text-xs font-bold text-success-400">
-{activeDiscount.discount_percent}%
</span>
</div>
<div className="flex items-center gap-4 text-sm text-dark-400">
{activeDiscount.expires_at && (
<div className="flex items-center gap-1">
<ClockIcon />
<span>
{t('promo.offers.expires', {
time: formatTimeLeft(activeDiscount.expires_at, t),
})}
</span>
</div>
)}
</div>
</div>
</div>
{/* Arrow indicator */}
<div className="flex-shrink-0 text-dark-500 transition-all duration-200 group-hover:translate-x-1 group-hover:text-success-400">
<svg
className="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
{/* Action Buttons */}
<div className="flex flex-col gap-2 sm:flex-row">
<button
onClick={handleUseNow}
className="flex-1 rounded-xl bg-gradient-to-r from-success-500 to-success-600 px-4 py-2.5 font-semibold text-white shadow-lg shadow-success-500/25 transition-all hover:from-success-400 hover:to-success-500 active:from-success-600 active:to-success-700"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
{t('promo.useNow')}
</button>
<button
onClick={handleDeactivateClick}
className="flex items-center justify-center gap-1.5 rounded-xl border border-dark-600/50 bg-dark-900/50 px-4 py-2.5 text-sm text-dark-400 transition-colors hover:border-red-500/30 hover:bg-red-500/10 hover:text-red-400"
>
<XCircleIcon />
<span>{t('promo.deactivate.button')}</span>
</button>
</div>
</div>
</Link>
</div>
)}
{/* Success/Error Messages */}
@@ -266,6 +485,17 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio
</div>
</div>
)}
{/* Deactivation Confirmation Modal */}
{activeDiscount && (
<DeactivateConfirmModal
isOpen={showConfirmModal}
discountPercent={activeDiscount.discount_percent || 0}
isDeactivating={deactivateMutation.isPending}
onConfirm={handleConfirmDeactivate}
onCancel={handleCloseConfirmModal}
/>
)}
</div>
);
}

View File

@@ -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) {
<div
ref={setNodeRef}
style={style}
className={`group flex items-center gap-3 rounded-xl border p-4 transition-all ${
className={`group flex items-center gap-3 rounded-xl border p-4 ${
isDragging
? 'border-accent-500/50 bg-dark-700/80 shadow-xl shadow-accent-500/10'
? 'border-accent-500/50 bg-dark-800 shadow-xl shadow-accent-500/20'
: config.is_enabled
? 'border-dark-700/50 bg-dark-800/50 hover:border-dark-600'
: 'border-dark-800/50 bg-dark-900/30 opacity-60'
}`}
>
{/* Drag handle */}
{/* Drag handle - larger touch target for mobile */}
<button
{...attributes}
{...listeners}
className="flex-shrink-0 cursor-grab touch-manipulation rounded-lg p-1.5 text-dark-500 hover:bg-dark-700/50 hover:text-dark-300 active:cursor-grabbing"
className="flex-shrink-0 cursor-grab touch-none rounded-lg p-2.5 text-dark-500 hover:bg-dark-700/50 hover:text-dark-300 active:cursor-grabbing sm:p-1.5"
title={t('admin.paymentMethods.dragToReorder')}
>
<GripIcon />
@@ -267,10 +266,9 @@ export default function AdminPaymentMethods() {
},
});
// DnD sensors
// DnD sensors - PointerSensor handles both mouse and touch
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 5 } }),
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
@@ -362,7 +360,6 @@ export default function AdminPaymentMethods() {
) : methods.length > 0 ? (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}