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