1068 lines
47 KiB
TypeScript
1068 lines
47 KiB
TypeScript
'use client';
|
||
|
||
import React, { useState, useEffect } from 'react';
|
||
import {
|
||
Tag,
|
||
Ticket,
|
||
Percent,
|
||
DollarSign,
|
||
Calendar,
|
||
Clock,
|
||
CheckCircle2,
|
||
AlertCircle,
|
||
Plus,
|
||
Search,
|
||
Filter,
|
||
Trash2,
|
||
Edit2,
|
||
Copy,
|
||
Check,
|
||
Share2,
|
||
ExternalLink,
|
||
ShieldCheck,
|
||
Sparkles,
|
||
RefreshCw,
|
||
Power,
|
||
BarChart3,
|
||
TrendingUp,
|
||
Flame,
|
||
} from 'lucide-react';
|
||
import { Coupon } from '@/types';
|
||
import { formatCOP } from '@/lib/pricing-engine';
|
||
|
||
export default function AdminCuponesPage() {
|
||
const [coupons, setCoupons] = useState<Coupon[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [searchTerm, setSearchTerm] = useState('');
|
||
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'inactive' | 'expired' | 'exhausted'>('all');
|
||
|
||
// Modal State
|
||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||
const [editingCoupon, setEditingCoupon] = useState<Coupon | null>(null);
|
||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||
const [copiedCode, setCopiedCode] = useState<string | null>(null);
|
||
const [copiedLink, setCopiedLink] = useState<string | null>(null);
|
||
|
||
// Form Fields
|
||
const [code, setCode] = useState('');
|
||
const [description, setDescription] = useState('');
|
||
const [discountType, setDiscountType] = useState<'percentage' | 'fixed' | 'per_item'>('percentage');
|
||
const [discountValue, setDiscountValue] = useState<number>(10);
|
||
const [minSpend, setMinSpend] = useState<number>(0);
|
||
const [maxDiscount, setMaxDiscount] = useState<number>(0);
|
||
|
||
// Expiration config
|
||
const [hasExpiry, setHasExpiry] = useState(true);
|
||
const [expiresAt, setExpiresAt] = useState('');
|
||
|
||
// Quantity limit config
|
||
const [hasUsageLimit, setHasUsageLimit] = useState(true);
|
||
const [usageLimit, setUsageLimit] = useState<number>(50);
|
||
|
||
const [isActive, setIsActive] = useState(true);
|
||
const [formError, setFormError] = useState<string | null>(null);
|
||
|
||
const fetchCoupons = async () => {
|
||
setLoading(true);
|
||
try {
|
||
const res = await fetch('/api/coupons');
|
||
const data = await res.json();
|
||
if (data.success && Array.isArray(data.coupons)) {
|
||
setCoupons(data.coupons);
|
||
}
|
||
} catch (err) {
|
||
console.error('Error cargando cupones:', err);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
fetchCoupons();
|
||
}, []);
|
||
|
||
const openCreateModal = () => {
|
||
setEditingCoupon(null);
|
||
setCode('');
|
||
setDescription('');
|
||
setDiscountType('percentage');
|
||
setDiscountValue(10);
|
||
setMinSpend(0);
|
||
setMaxDiscount(0);
|
||
setHasUsageLimit(true);
|
||
setUsageLimit(50);
|
||
|
||
// Default expiration: in 15 days
|
||
setHasExpiry(true);
|
||
const in15Days = new Date();
|
||
in15Days.setDate(in15Days.getDate() + 15);
|
||
in15Days.setHours(23, 59, 0, 0);
|
||
setExpiresAt(in15Days.toISOString().slice(0, 16));
|
||
|
||
setIsActive(true);
|
||
setFormError(null);
|
||
setIsModalOpen(true);
|
||
};
|
||
|
||
const openEditModal = (c: Coupon) => {
|
||
setEditingCoupon(c);
|
||
setCode(c.code);
|
||
setDescription(c.description || '');
|
||
setDiscountType(c.discountType);
|
||
setDiscountValue(c.discountValue);
|
||
setMinSpend(c.minSpend || 0);
|
||
setMaxDiscount(c.maxDiscount || 0);
|
||
|
||
if (c.usageLimit && c.usageLimit > 0) {
|
||
setHasUsageLimit(true);
|
||
setUsageLimit(c.usageLimit);
|
||
} else {
|
||
setHasUsageLimit(false);
|
||
setUsageLimit(0);
|
||
}
|
||
|
||
if (c.expiresAt) {
|
||
setHasExpiry(true);
|
||
try {
|
||
const d = new Date(c.expiresAt);
|
||
setExpiresAt(d.toISOString().slice(0, 16));
|
||
} catch (_) {
|
||
setExpiresAt('');
|
||
}
|
||
} else {
|
||
setHasExpiry(false);
|
||
setExpiresAt('');
|
||
}
|
||
|
||
setIsActive(c.isActive !== false);
|
||
setFormError(null);
|
||
setIsModalOpen(true);
|
||
};
|
||
|
||
const handleSetExpiryPreset = (days: number) => {
|
||
setHasExpiry(true);
|
||
const target = new Date();
|
||
target.setDate(target.getDate() + days);
|
||
target.setHours(23, 59, 0, 0);
|
||
setExpiresAt(target.toISOString().slice(0, 16));
|
||
};
|
||
|
||
const handleSetEndOfMonth = () => {
|
||
setHasExpiry(true);
|
||
const now = new Date();
|
||
const lastDay = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
||
lastDay.setHours(23, 59, 0, 0);
|
||
setExpiresAt(lastDay.toISOString().slice(0, 16));
|
||
};
|
||
|
||
const handleSubmit = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
setFormError(null);
|
||
|
||
const cleanCode = code.toUpperCase().trim();
|
||
if (!cleanCode) {
|
||
setFormError('El código del cupón es obligatorio.');
|
||
return;
|
||
}
|
||
|
||
if (discountValue <= 0) {
|
||
setFormError('El valor del descuento debe ser mayor a 0.');
|
||
return;
|
||
}
|
||
|
||
if (discountType === 'percentage' && discountValue > 100) {
|
||
setFormError('El porcentaje de descuento no puede ser superior al 100%.');
|
||
return;
|
||
}
|
||
|
||
if (hasExpiry && !expiresAt) {
|
||
setFormError('Debes seleccionar una fecha y hora de expiración.');
|
||
return;
|
||
}
|
||
|
||
if (hasUsageLimit && usageLimit <= 0) {
|
||
setFormError('El límite de usos debe ser al menos 1.');
|
||
return;
|
||
}
|
||
|
||
setIsSubmitting(true);
|
||
|
||
const payload: Partial<Coupon> = {
|
||
code: cleanCode,
|
||
description,
|
||
discountType,
|
||
discountValue: Number(discountValue),
|
||
minSpend: Number(minSpend) || 0,
|
||
maxDiscount: Number(maxDiscount) || 0,
|
||
usageLimit: hasUsageLimit ? Number(usageLimit) : 0,
|
||
expiresAt: hasExpiry && expiresAt ? new Date(expiresAt).toISOString() : undefined,
|
||
isActive,
|
||
};
|
||
|
||
try {
|
||
if (editingCoupon) {
|
||
const res = await fetch('/api/coupons', {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ id: editingCoupon.id, ...payload }),
|
||
});
|
||
const data = await res.json();
|
||
if (!data.success) {
|
||
throw new Error(data.error || 'Error al actualizar cupón.');
|
||
}
|
||
} else {
|
||
const res = await fetch('/api/coupons', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload),
|
||
});
|
||
const data = await res.json();
|
||
if (!data.success) {
|
||
throw new Error(data.error || 'Error al crear cupón.');
|
||
}
|
||
}
|
||
|
||
setIsModalOpen(false);
|
||
await fetchCoupons();
|
||
} catch (err: any) {
|
||
setFormError(err.message || 'Error guardando cupón.');
|
||
} finally {
|
||
setIsSubmitting(false);
|
||
}
|
||
};
|
||
|
||
const handleToggleActive = async (c: Coupon) => {
|
||
try {
|
||
const nextState = !c.isActive;
|
||
// Optimistic update
|
||
setCoupons((prev) =>
|
||
prev.map((item) => (item.id === c.id ? { ...item, isActive: nextState } : item))
|
||
);
|
||
|
||
await fetch('/api/coupons', {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ id: c.id, isActive: nextState }),
|
||
});
|
||
} catch (err) {
|
||
console.error('Error alternando estado del cupón:', err);
|
||
fetchCoupons();
|
||
}
|
||
};
|
||
|
||
const handleDelete = async (c: Coupon) => {
|
||
if (!confirm(`¿Estás seguro de eliminar el cupón "${c.code}"? Esta acción no se puede deshacer.`)) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
setCoupons((prev) => prev.filter((item) => item.id !== c.id));
|
||
await fetch(`/api/coupons?id=${c.id}`, { method: 'DELETE' });
|
||
} catch (err) {
|
||
console.error('Error eliminando cupón:', err);
|
||
fetchCoupons();
|
||
}
|
||
};
|
||
|
||
const handleCopyCode = (couponCode: string) => {
|
||
navigator.clipboard.writeText(couponCode);
|
||
setCopiedCode(couponCode);
|
||
setTimeout(() => setCopiedCode(null), 2500);
|
||
};
|
||
|
||
const handleCopyLink = (couponCode: string) => {
|
||
const origin = typeof window !== 'undefined' ? window.location.origin : 'https://drip-zone.online';
|
||
const link = `${origin}/checkout?coupon=${couponCode}`;
|
||
navigator.clipboard.writeText(link);
|
||
setCopiedLink(couponCode);
|
||
setTimeout(() => setCopiedLink(null), 2500);
|
||
};
|
||
|
||
// Helper status calculations
|
||
const isCouponExpired = (c: Coupon) => {
|
||
if (!c.expiresAt) return false;
|
||
return new Date(c.expiresAt).getTime() < Date.now();
|
||
};
|
||
|
||
const isCouponExhausted = (c: Coupon) => {
|
||
if (!c.usageLimit || c.usageLimit <= 0) return false;
|
||
return (c.usageCount || 0) >= c.usageLimit;
|
||
};
|
||
|
||
const filteredCoupons = coupons.filter((c) => {
|
||
const matchesSearch =
|
||
c.code.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||
(c.description || '').toLowerCase().includes(searchTerm.toLowerCase());
|
||
|
||
if (!matchesSearch) return false;
|
||
|
||
const expired = isCouponExpired(c);
|
||
const exhausted = isCouponExhausted(c);
|
||
|
||
if (statusFilter === 'active') return c.isActive && !expired && !exhausted;
|
||
if (statusFilter === 'inactive') return !c.isActive;
|
||
if (statusFilter === 'expired') return expired;
|
||
if (statusFilter === 'exhausted') return exhausted;
|
||
|
||
return true;
|
||
});
|
||
|
||
// KPI Calculations
|
||
const totalCoupons = coupons.length;
|
||
const activeCoupons = coupons.filter((c) => c.isActive && !isCouponExpired(c) && !isCouponExhausted(c)).length;
|
||
const totalRedemptions = coupons.reduce((sum, c) => sum + (c.usageCount || 0), 0);
|
||
const expiringSoonCount = coupons.filter((c) => {
|
||
if (!c.expiresAt || !c.isActive || isCouponExpired(c)) return false;
|
||
const diffDays = (new Date(c.expiresAt).getTime() - Date.now()) / (1000 * 3600 * 24);
|
||
return diffDays <= 7;
|
||
}).length;
|
||
|
||
return (
|
||
<div className="space-y-6 max-w-7xl mx-auto pb-12 font-sans">
|
||
{/* Header */}
|
||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 bg-gradient-to-r from-neutral-900 via-neutral-900/90 to-purple-950/30 p-6 rounded-3xl border border-purple-500/20 shadow-xl">
|
||
<div className="space-y-1">
|
||
<div className="flex items-center gap-2">
|
||
<div className="w-9 h-9 rounded-xl bg-purple-500/20 text-purple-400 flex items-center justify-center border border-purple-500/30">
|
||
<Ticket className="w-5 h-5" />
|
||
</div>
|
||
<h1 className="text-xl sm:text-2xl font-black text-white tracking-tight">
|
||
Cupones de Descuento & Promociones
|
||
</h1>
|
||
</div>
|
||
<p className="text-xs text-neutral-400 max-w-2xl leading-relaxed">
|
||
Administra campañas promocionales para redes sociales, WhatsApp y fechas especiales. Configura límites de
|
||
canje por cantidad de usos o fecha de vencimiento.
|
||
</p>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
onClick={fetchCoupons}
|
||
className="bg-neutral-800 hover:bg-neutral-700 text-neutral-300 p-2.5 rounded-xl border border-neutral-700 transition-all cursor-pointer"
|
||
title="Actualizar listado"
|
||
>
|
||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin text-purple-400' : ''}`} />
|
||
</button>
|
||
|
||
<button
|
||
onClick={openCreateModal}
|
||
className="bg-gradient-to-r from-purple-600 via-fuchsia-600 to-pink-600 hover:opacity-95 text-white font-bold text-xs py-2.5 px-4 rounded-xl flex items-center gap-2 shadow-lg shadow-purple-600/30 transition-all cursor-pointer active:scale-95 shrink-0"
|
||
>
|
||
<Plus className="w-4 h-4" />
|
||
<span>+ Crear Nuevo Cupón</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* KPI Cards */}
|
||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4">
|
||
<div className="bg-neutral-900/80 border border-neutral-800 rounded-2xl p-4 space-y-1 shadow-sm">
|
||
<div className="flex items-center justify-between text-neutral-400 text-xs">
|
||
<span>Total Cupones</span>
|
||
<Tag className="w-4 h-4 text-purple-400" />
|
||
</div>
|
||
<p className="text-2xl font-black text-white font-mono">{totalCoupons}</p>
|
||
<p className="text-[10px] text-neutral-500">Campañas configuradas</p>
|
||
</div>
|
||
|
||
<div className="bg-neutral-900/80 border border-neutral-800 rounded-2xl p-4 space-y-1 shadow-sm">
|
||
<div className="flex items-center justify-between text-neutral-400 text-xs">
|
||
<span>Cupones Activos</span>
|
||
<Sparkles className="w-4 h-4 text-emerald-400" />
|
||
</div>
|
||
<p className="text-2xl font-black text-emerald-400 font-mono">{activeCoupons}</p>
|
||
<p className="text-[10px] text-emerald-500/80">Listos para canjear en la tienda</p>
|
||
</div>
|
||
|
||
<div className="bg-neutral-900/80 border border-neutral-800 rounded-2xl p-4 space-y-1 shadow-sm">
|
||
<div className="flex items-center justify-between text-neutral-400 text-xs">
|
||
<span>Canjes Totales</span>
|
||
<TrendingUp className="w-4 h-4 text-fuchsia-400" />
|
||
</div>
|
||
<p className="text-2xl font-black text-fuchsia-400 font-mono">{totalRedemptions}</p>
|
||
<p className="text-[10px] text-neutral-500">Usos aplicados en pedidos</p>
|
||
</div>
|
||
|
||
<div className="bg-neutral-900/80 border border-neutral-800 rounded-2xl p-4 space-y-1 shadow-sm">
|
||
<div className="flex items-center justify-between text-neutral-400 text-xs">
|
||
<span>Por Vencer (7 días)</span>
|
||
<Clock className="w-4 h-4 text-amber-400" />
|
||
</div>
|
||
<p className="text-2xl font-black text-amber-400 font-mono">{expiringSoonCount}</p>
|
||
<p className="text-[10px] text-neutral-500">Campañas próximas a caducar</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Filters & Search */}
|
||
<div className="bg-neutral-900/70 border border-neutral-800 rounded-2xl p-4 flex flex-col md:flex-row gap-3 items-stretch md:items-center justify-between">
|
||
<div className="relative flex-1">
|
||
<Search className="w-4 h-4 text-neutral-400 absolute left-3.5 top-1/2 -translate-y-1/2" />
|
||
<input
|
||
type="text"
|
||
value={searchTerm}
|
||
onChange={(e) => setSearchTerm(e.target.value)}
|
||
placeholder="Buscar por código (ej. DRIPZONE10) o campaña..."
|
||
className="w-full bg-neutral-950 border border-neutral-800 focus:border-purple-500 rounded-xl pl-9 pr-4 py-2 text-xs text-white placeholder:text-neutral-500 outline-none transition-all"
|
||
/>
|
||
</div>
|
||
|
||
<div className="flex flex-wrap items-center gap-1.5 bg-neutral-950 p-1 rounded-xl border border-neutral-800 text-xs">
|
||
{[
|
||
{ id: 'all', label: 'Todos' },
|
||
{ id: 'active', label: 'Activos' },
|
||
{ id: 'inactive', label: 'Pausados' },
|
||
{ id: 'expired', label: 'Vencidos' },
|
||
{ id: 'exhausted', label: 'Agotados' },
|
||
].map((tab) => (
|
||
<button
|
||
key={tab.id}
|
||
onClick={() => setStatusFilter(tab.id as any)}
|
||
className={`px-3 py-1.5 rounded-lg font-bold transition-all cursor-pointer ${
|
||
statusFilter === tab.id
|
||
? 'bg-purple-600 text-white shadow-sm'
|
||
: 'text-neutral-400 hover:text-white hover:bg-neutral-900'
|
||
}`}
|
||
>
|
||
{tab.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Coupons List */}
|
||
{loading ? (
|
||
<div className="py-20 text-center space-y-3">
|
||
<div className="w-8 h-8 border-2 border-purple-500 border-t-transparent rounded-full animate-spin mx-auto" />
|
||
<p className="text-xs text-neutral-400">Cargando cupones de promoción...</p>
|
||
</div>
|
||
) : filteredCoupons.length === 0 ? (
|
||
<div className="bg-neutral-900/40 border border-dashed border-neutral-800 rounded-3xl p-12 text-center space-y-4">
|
||
<div className="w-14 h-14 rounded-2xl bg-neutral-800 text-neutral-500 flex items-center justify-center mx-auto">
|
||
<Ticket className="w-7 h-7" />
|
||
</div>
|
||
<div className="space-y-1">
|
||
<h3 className="font-bold text-white text-sm">No se encontraron cupones</h3>
|
||
<p className="text-xs text-neutral-500 max-w-sm mx-auto">
|
||
{searchTerm || statusFilter !== 'all'
|
||
? 'No hay cupones que coincidan con los filtros aplicados.'
|
||
: 'Aún no has creado campañas ni cupones de descuento. Crea el primero para tus promociones.'}
|
||
</p>
|
||
</div>
|
||
<button
|
||
onClick={openCreateModal}
|
||
className="bg-purple-600 hover:bg-purple-500 text-white text-xs font-bold py-2.5 px-4 rounded-xl transition-all cursor-pointer"
|
||
>
|
||
+ Crear Mi Primer Cupón
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||
{filteredCoupons.map((coupon) => {
|
||
const expired = isCouponExpired(coupon);
|
||
const exhausted = isCouponExhausted(coupon);
|
||
const isFullyActive = coupon.isActive && !expired && !exhausted;
|
||
|
||
const usagePercent =
|
||
coupon.usageLimit && coupon.usageLimit > 0
|
||
? Math.min(100, Math.round(((coupon.usageCount || 0) / coupon.usageLimit) * 100))
|
||
: null;
|
||
|
||
return (
|
||
<div
|
||
key={coupon.id}
|
||
className={`bg-neutral-900 border rounded-3xl p-5 space-y-4 transition-all flex flex-col justify-between ${
|
||
!coupon.isActive
|
||
? 'border-neutral-800/80 opacity-70 bg-neutral-950/60'
|
||
: expired
|
||
? 'border-rose-900/30 bg-rose-950/10'
|
||
: exhausted
|
||
? 'border-amber-900/30 bg-amber-950/10'
|
||
: 'border-purple-500/30 hover:border-purple-500/60 shadow-lg shadow-purple-950/10'
|
||
}`}
|
||
>
|
||
<div className="space-y-3">
|
||
{/* Top Bar: Code and Status */}
|
||
<div className="flex items-start justify-between gap-2">
|
||
<div className="space-y-1">
|
||
<div className="flex items-center gap-1.5">
|
||
<span className="font-mono font-black text-lg text-white tracking-wider bg-neutral-950 border border-purple-500/30 px-2.5 py-0.5 rounded-xl shadow-xs">
|
||
{coupon.code}
|
||
</span>
|
||
<button
|
||
onClick={() => handleCopyCode(coupon.code)}
|
||
className="text-neutral-400 hover:text-purple-300 p-1 rounded-lg hover:bg-neutral-800 transition-colors cursor-pointer"
|
||
title="Copiar código"
|
||
>
|
||
{copiedCode === coupon.code ? (
|
||
<Check className="w-3.5 h-3.5 text-emerald-400" />
|
||
) : (
|
||
<Copy className="w-3.5 h-3.5" />
|
||
)}
|
||
</button>
|
||
</div>
|
||
|
||
<p className="text-xs text-neutral-300 line-clamp-2">
|
||
{coupon.description || 'Promoción general de la tienda'}
|
||
</p>
|
||
</div>
|
||
|
||
{/* Status Pill */}
|
||
<span
|
||
className={`text-[10px] font-bold px-2 py-0.5 rounded-full shrink-0 uppercase tracking-wider border ${
|
||
!coupon.isActive
|
||
? 'bg-neutral-800 text-neutral-400 border-neutral-700'
|
||
: expired
|
||
? 'bg-rose-950/80 text-rose-300 border-rose-500/30'
|
||
: exhausted
|
||
? 'bg-amber-950/80 text-amber-300 border-amber-500/30'
|
||
: 'bg-emerald-950/80 text-emerald-300 border-emerald-500/30'
|
||
}`}
|
||
>
|
||
{!coupon.isActive ? 'Pausado' : expired ? 'Expirado' : exhausted ? 'Agotado' : 'Activo'}
|
||
</span>
|
||
</div>
|
||
|
||
{/* Discount Value Badge */}
|
||
<div className="bg-neutral-950/80 border border-neutral-800/80 rounded-2xl p-3 flex items-center justify-between">
|
||
<div className="flex items-center gap-2">
|
||
<div className="w-8 h-8 rounded-xl bg-purple-500/20 text-purple-400 flex items-center justify-center font-black text-xs">
|
||
{coupon.discountType === 'percentage' ? '%' : coupon.discountType === 'per_item' ? '📦' : '$'}
|
||
</div>
|
||
<div>
|
||
<p className="text-sm font-black text-white">
|
||
{coupon.discountType === 'percentage'
|
||
? `${coupon.discountValue}% de Descuento`
|
||
: coupon.discountType === 'per_item'
|
||
? `${formatCOP(coupon.discountValue)} / cada producto`
|
||
: `${formatCOP(coupon.discountValue)} de Descuento`}
|
||
</p>
|
||
<p className="text-[10px] text-neutral-400">
|
||
{coupon.discountType === 'per_item'
|
||
? `Descuenta ${formatCOP(coupon.discountValue)} por cada producto al saldo contraentrega`
|
||
: coupon.minSpend && coupon.minSpend > 0
|
||
? `Compra mín: ${formatCOP(coupon.minSpend)}`
|
||
: 'Sin monto mínimo'}
|
||
{coupon.maxDiscount && coupon.maxDiscount > 0
|
||
? ` • Tope: ${formatCOP(coupon.maxDiscount)}`
|
||
: ''}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Quantity Limit Progress */}
|
||
<div className="space-y-1.5 pt-1">
|
||
<div className="flex justify-between items-center text-xs">
|
||
<span className="text-neutral-400 text-[11px] flex items-center gap-1">
|
||
<BarChart3 className="w-3 h-3 text-purple-400" />
|
||
<span>Límite de Canjes:</span>
|
||
</span>
|
||
<span className="font-mono font-bold text-white text-[11px]">
|
||
{coupon.usageLimit && coupon.usageLimit > 0 ? (
|
||
<>
|
||
{coupon.usageCount || 0} / {coupon.usageLimit}{' '}
|
||
<span className="text-neutral-400">({usagePercent}%)</span>
|
||
</>
|
||
) : (
|
||
<span>{coupon.usageCount || 0} canjes (Ilimitado)</span>
|
||
)}
|
||
</span>
|
||
</div>
|
||
|
||
{usagePercent !== null && (
|
||
<div className="w-full h-1.5 bg-neutral-950 rounded-full overflow-hidden border border-neutral-800">
|
||
<div
|
||
className={`h-full rounded-full transition-all ${
|
||
usagePercent >= 100
|
||
? 'bg-amber-500'
|
||
: usagePercent >= 75
|
||
? 'bg-purple-400'
|
||
: 'bg-emerald-500'
|
||
}`}
|
||
style={{ width: `${usagePercent}%` }}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Expiration Details */}
|
||
<div className="flex items-center gap-1.5 text-[11px] pt-1 border-t border-neutral-800 text-neutral-400">
|
||
<Calendar className="w-3.5 h-3.5 text-purple-400 shrink-0" />
|
||
<span>
|
||
{coupon.expiresAt ? (
|
||
expired ? (
|
||
<span className="text-rose-400 font-bold">
|
||
Expiró el{' '}
|
||
{new Date(coupon.expiresAt).toLocaleDateString('es-CO', {
|
||
day: '2-digit',
|
||
month: 'short',
|
||
year: 'numeric',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
})}
|
||
</span>
|
||
) : (
|
||
<span>
|
||
Vence el{' '}
|
||
<strong className="text-neutral-200">
|
||
{new Date(coupon.expiresAt).toLocaleDateString('es-CO', {
|
||
day: '2-digit',
|
||
month: 'short',
|
||
year: 'numeric',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
})}
|
||
</strong>
|
||
</span>
|
||
)
|
||
) : (
|
||
<span className="text-neutral-400 font-medium">Sin fecha de caducidad (Indefinido)</span>
|
||
)}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Bottom Actions */}
|
||
<div className="pt-3 border-t border-neutral-800 flex items-center justify-between gap-2 mt-2">
|
||
<div className="flex items-center gap-1">
|
||
<button
|
||
onClick={() => handleToggleActive(coupon)}
|
||
className={`text-xs px-2.5 py-1.5 rounded-xl font-bold flex items-center gap-1 transition-all cursor-pointer ${
|
||
coupon.isActive
|
||
? 'bg-neutral-800 hover:bg-neutral-700 text-neutral-300'
|
||
: 'bg-emerald-950 text-emerald-300 border border-emerald-500/30'
|
||
}`}
|
||
title={coupon.isActive ? 'Pausar cupón' : 'Reanudar cupón'}
|
||
>
|
||
<Power className="w-3.5 h-3.5" />
|
||
<span>{coupon.isActive ? 'Pausar' : 'Activar'}</span>
|
||
</button>
|
||
|
||
<button
|
||
onClick={() => handleCopyLink(coupon.code)}
|
||
className="text-xs px-2.5 py-1.5 rounded-xl font-bold bg-neutral-800 hover:bg-neutral-700 text-neutral-300 flex items-center gap-1 transition-all cursor-pointer"
|
||
title="Copiar enlace directo con cupón aplicado"
|
||
>
|
||
{copiedLink === coupon.code ? (
|
||
<Check className="w-3.5 h-3.5 text-emerald-400" />
|
||
) : (
|
||
<Share2 className="w-3.5 h-3.5" />
|
||
)}
|
||
<span>{copiedLink === coupon.code ? '¡Copiado!' : 'Link'}</span>
|
||
</button>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-1">
|
||
<button
|
||
onClick={() => openEditModal(coupon)}
|
||
className="p-1.5 text-neutral-400 hover:text-white rounded-lg hover:bg-neutral-800 transition-colors cursor-pointer"
|
||
title="Editar cupón"
|
||
>
|
||
<Edit2 className="w-4 h-4" />
|
||
</button>
|
||
|
||
<button
|
||
onClick={() => handleDelete(coupon)}
|
||
className="p-1.5 text-neutral-400 hover:text-rose-400 rounded-lg hover:bg-neutral-800 transition-colors cursor-pointer"
|
||
title="Eliminar cupón"
|
||
>
|
||
<Trash2 className="w-4 h-4" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
{/* Modal: Crear / Editar Cupón */}
|
||
{isModalOpen && (
|
||
<div className="fixed inset-0 z-50 bg-black/80 backdrop-blur-sm flex items-center justify-center p-4 overflow-y-auto animate-in fade-in">
|
||
<div className="bg-neutral-900 border border-purple-500/30 rounded-3xl max-w-xl w-full p-6 text-white space-y-5 shadow-2xl my-8">
|
||
<div className="flex justify-between items-center border-b border-neutral-800 pb-3">
|
||
<div className="flex items-center gap-2">
|
||
<div className="w-8 h-8 rounded-lg bg-purple-500/20 text-purple-400 flex items-center justify-center">
|
||
<Tag className="w-4 h-4" />
|
||
</div>
|
||
<h3 className="text-base font-black text-white">
|
||
{editingCoupon ? `Editar Cupón: ${editingCoupon.code}` : 'Crear Nuevo Cupón de Descuento'}
|
||
</h3>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => setIsModalOpen(false)}
|
||
className="text-neutral-400 hover:text-white p-1 rounded-lg cursor-pointer"
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
|
||
<form onSubmit={handleSubmit} className="space-y-4 text-xs">
|
||
{formError && (
|
||
<div className="bg-rose-950/40 border border-rose-500/30 text-rose-300 p-3 rounded-xl flex items-center gap-2">
|
||
<AlertCircle className="w-4 h-4 shrink-0" />
|
||
<span>{formError}</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* Código y Nombre */}
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||
<div className="space-y-1">
|
||
<label className="font-bold text-neutral-300 block">Código del Cupón *</label>
|
||
<input
|
||
type="text"
|
||
required
|
||
value={code}
|
||
onChange={(e) => setCode(e.target.value.toUpperCase().replace(/\s/g, ''))}
|
||
placeholder="Ej. LANZAMIENTO20"
|
||
className="w-full bg-neutral-950 border border-neutral-700 focus:border-purple-500 rounded-xl px-3 py-2.5 font-mono font-bold tracking-wider text-white uppercase outline-none"
|
||
/>
|
||
<p className="text-[10px] text-neutral-500">Sin espacios, en mayúsculas.</p>
|
||
</div>
|
||
|
||
<div className="space-y-1">
|
||
<label className="font-bold text-neutral-300 block">Campaña / Descripción</label>
|
||
<input
|
||
type="text"
|
||
value={description}
|
||
onChange={(e) => setDescription(e.target.value)}
|
||
placeholder="Ej. Promoción Black Friday Instagram"
|
||
className="w-full bg-neutral-950 border border-neutral-700 focus:border-purple-500 rounded-xl px-3 py-2.5 text-white outline-none"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Tipo de Descuento */}
|
||
<div className="space-y-2">
|
||
<label className="font-bold text-neutral-300 block">Tipo de Descuento *</label>
|
||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setDiscountType('percentage');
|
||
if (discountValue > 100) setDiscountValue(10);
|
||
}}
|
||
className={`py-2.5 px-3 rounded-xl font-bold flex items-center justify-center gap-1.5 border transition-all cursor-pointer text-xs ${
|
||
discountType === 'percentage'
|
||
? 'bg-purple-600 text-white border-purple-400 shadow-md'
|
||
: 'bg-neutral-950 text-neutral-400 border-neutral-800 hover:text-white'
|
||
}`}
|
||
>
|
||
<Percent className="w-3.5 h-3.5" />
|
||
<span>Porcentaje (%)</span>
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setDiscountType('fixed');
|
||
if (discountValue <= 100) setDiscountValue(20000);
|
||
}}
|
||
className={`py-2.5 px-3 rounded-xl font-bold flex items-center justify-center gap-1.5 border transition-all cursor-pointer text-xs ${
|
||
discountType === 'fixed'
|
||
? 'bg-purple-600 text-white border-purple-400 shadow-md'
|
||
: 'bg-neutral-950 text-neutral-400 border-neutral-800 hover:text-white'
|
||
}`}
|
||
>
|
||
<DollarSign className="w-3.5 h-3.5" />
|
||
<span>Monto Fijo Global</span>
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setDiscountType('per_item');
|
||
if (discountValue <= 100) setDiscountValue(10000);
|
||
}}
|
||
className={`py-2.5 px-3 rounded-xl font-bold flex items-center justify-center gap-1.5 border transition-all cursor-pointer text-xs ${
|
||
discountType === 'per_item'
|
||
? 'bg-gradient-to-r from-emerald-600 to-teal-500 text-white border-emerald-400 shadow-md'
|
||
: 'bg-neutral-950 text-neutral-400 border-neutral-800 hover:text-white'
|
||
}`}
|
||
>
|
||
<span className="text-sm">📦</span>
|
||
<span>Por Cada Producto</span>
|
||
</button>
|
||
</div>
|
||
|
||
{discountType === 'per_item' && (
|
||
<div className="bg-emerald-950/30 border border-emerald-500/20 rounded-xl p-2.5 text-[11px] text-emerald-300 space-y-1">
|
||
<p className="font-bold flex items-center gap-1 text-emerald-400">
|
||
<span>⚡ Descuento Progresivo por Unidades:</span>
|
||
</p>
|
||
<p className="text-neutral-300 leading-relaxed">
|
||
Si el cliente compra <strong>1 par</strong> descuenta {formatCOP(discountValue || 10000)}, si compra <strong>2 pares</strong> descuenta {formatCOP((discountValue || 10000) * 2)}, si compra <strong>3 pares</strong> {formatCOP((discountValue || 10000) * 3)} y así sucesivamente.
|
||
</p>
|
||
<p className="text-emerald-400 font-semibold text-[10px]">
|
||
🛡️ El descuento se aplica 100% sobre el saldo pendiente por pagar (contraentrega), protegiendo intacto el anticipo de separación.
|
||
</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Valor del Descuento y Atajos */}
|
||
<div className="space-y-1.5">
|
||
<label className="font-bold text-neutral-300 block">
|
||
Valor a Descontar *{' '}
|
||
{discountType === 'percentage'
|
||
? '(1% al 100%)'
|
||
: discountType === 'per_item'
|
||
? '(Pesos COP por cada unidad/par comprado)'
|
||
: '(Pesos Colombianos COP)'}
|
||
</label>
|
||
<div className="relative">
|
||
<input
|
||
type="number"
|
||
min="1"
|
||
max={discountType === 'percentage' ? 100 : 999999999}
|
||
required
|
||
value={discountValue || ''}
|
||
onChange={(e) => setDiscountValue(Number(e.target.value))}
|
||
className="w-full bg-neutral-950 border border-neutral-700 focus:border-purple-500 rounded-xl px-3 py-2.5 text-white font-mono font-bold text-sm outline-none"
|
||
/>
|
||
<span className="absolute right-3 top-1/2 -translate-y-1/2 font-bold text-neutral-400">
|
||
{discountType === 'percentage' ? '%' : 'COP'}
|
||
</span>
|
||
</div>
|
||
|
||
{/* Quick Presets */}
|
||
<div className="flex flex-wrap items-center gap-1.5 pt-1">
|
||
<span className="text-[10px] text-neutral-500 font-semibold">Atajos:</span>
|
||
{discountType === 'percentage'
|
||
? [5, 10, 15, 20, 25, 30].map((v) => (
|
||
<button
|
||
key={v}
|
||
type="button"
|
||
onClick={() => setDiscountValue(v)}
|
||
className="px-2 py-0.5 bg-neutral-800 hover:bg-neutral-700 text-neutral-300 rounded-md text-[10px] font-mono cursor-pointer transition-colors"
|
||
>
|
||
{v}%
|
||
</button>
|
||
))
|
||
: discountType === 'per_item'
|
||
? [5000, 10000, 15000, 20000, 25000, 30000].map((v) => (
|
||
<button
|
||
key={v}
|
||
type="button"
|
||
onClick={() => setDiscountValue(v)}
|
||
className="px-2 py-0.5 bg-neutral-800 hover:bg-neutral-700 text-neutral-300 rounded-md text-[10px] font-mono cursor-pointer transition-colors"
|
||
>
|
||
{formatCOP(v)}/par
|
||
</button>
|
||
))
|
||
: [10000, 15000, 20000, 30000, 50000].map((v) => (
|
||
<button
|
||
key={v}
|
||
type="button"
|
||
onClick={() => setDiscountValue(v)}
|
||
className="px-2 py-0.5 bg-neutral-800 hover:bg-neutral-700 text-neutral-300 rounded-md text-[10px] font-mono cursor-pointer transition-colors"
|
||
>
|
||
{formatCOP(v)}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* CONDICIONES CLAVE: VENCIMIENTO Y CANTIDAD */}
|
||
<div className="bg-neutral-950 border border-purple-500/20 rounded-2xl p-4 space-y-4">
|
||
<h4 className="font-bold text-purple-300 flex items-center gap-1.5 text-xs">
|
||
<Clock className="w-3.5 h-3.5" />
|
||
<span>Control de Vigencia y Límite de Usos</span>
|
||
</h4>
|
||
|
||
{/* 1. TIEMPO DE CADUCIDAD */}
|
||
<div className="space-y-2 border-b border-neutral-800 pb-3">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<label className="font-bold text-white block">Establecer Tiempo de Caducidad</label>
|
||
<p className="text-[10px] text-neutral-400">
|
||
El cupón dejará de funcionar automáticamente después de esta fecha.
|
||
</p>
|
||
</div>
|
||
<input
|
||
type="checkbox"
|
||
checked={hasExpiry}
|
||
onChange={(e) => setHasExpiry(e.target.checked)}
|
||
className="w-4 h-4 accent-purple-600 rounded cursor-pointer"
|
||
/>
|
||
</div>
|
||
|
||
{hasExpiry && (
|
||
<div className="space-y-2 pt-1">
|
||
<input
|
||
type="datetime-local"
|
||
required={hasExpiry}
|
||
value={expiresAt}
|
||
onChange={(e) => setExpiresAt(e.target.value)}
|
||
className="w-full bg-neutral-900 border border-neutral-700 focus:border-purple-500 rounded-xl px-3 py-2 text-white font-mono text-xs outline-none"
|
||
/>
|
||
|
||
<div className="flex flex-wrap items-center gap-1.5">
|
||
<span className="text-[10px] text-neutral-500 font-semibold">Vence en:</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => handleSetExpiryPreset(2)}
|
||
className="px-2 py-0.5 bg-neutral-800 hover:bg-neutral-700 text-neutral-300 rounded-md text-[10px] cursor-pointer"
|
||
>
|
||
+48 Horas
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => handleSetExpiryPreset(7)}
|
||
className="px-2 py-0.5 bg-neutral-800 hover:bg-neutral-700 text-neutral-300 rounded-md text-[10px] cursor-pointer"
|
||
>
|
||
+7 Días
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => handleSetExpiryPreset(15)}
|
||
className="px-2 py-0.5 bg-neutral-800 hover:bg-neutral-700 text-neutral-300 rounded-md text-[10px] cursor-pointer"
|
||
>
|
||
+15 Días
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={handleSetEndOfMonth}
|
||
className="px-2 py-0.5 bg-neutral-800 hover:bg-neutral-700 text-neutral-300 rounded-md text-[10px] cursor-pointer"
|
||
>
|
||
Fin de Mes
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 2. LÍMITE POR CANTIDAD DE USOS */}
|
||
<div className="space-y-2">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<label className="font-bold text-white block">Limitar por Cantidad de Canjes</label>
|
||
<p className="text-[10px] text-neutral-400">
|
||
Cantidad máxima de clientes o compras que pueden redimir este cupón.
|
||
</p>
|
||
</div>
|
||
<input
|
||
type="checkbox"
|
||
checked={hasUsageLimit}
|
||
onChange={(e) => setHasUsageLimit(e.target.checked)}
|
||
className="w-4 h-4 accent-purple-600 rounded cursor-pointer"
|
||
/>
|
||
</div>
|
||
|
||
{hasUsageLimit && (
|
||
<div className="space-y-2 pt-1">
|
||
<div className="relative">
|
||
<input
|
||
type="number"
|
||
min="1"
|
||
required={hasUsageLimit}
|
||
value={usageLimit || ''}
|
||
onChange={(e) => setUsageLimit(Number(e.target.value))}
|
||
placeholder="Ej. 50"
|
||
className="w-full bg-neutral-900 border border-neutral-700 focus:border-purple-500 rounded-xl px-3 py-2 text-white font-mono text-xs outline-none"
|
||
/>
|
||
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-neutral-400 font-semibold text-[11px]">
|
||
Canjes máximos
|
||
</span>
|
||
</div>
|
||
|
||
<div className="flex flex-wrap items-center gap-1.5">
|
||
<span className="text-[10px] text-neutral-500 font-semibold">Cupo:</span>
|
||
{[10, 25, 50, 100, 200].map((lim) => (
|
||
<button
|
||
key={lim}
|
||
type="button"
|
||
onClick={() => setUsageLimit(lim)}
|
||
className="px-2 py-0.5 bg-neutral-800 hover:bg-neutral-700 text-neutral-300 rounded-md text-[10px] font-mono cursor-pointer"
|
||
>
|
||
{lim} usos
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Condiciones Opcionales: Compra Mínima y Tope de Descuento */}
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 pt-1">
|
||
<div className="space-y-1">
|
||
<label className="font-bold text-neutral-300 block">Compra Mínima en Subtotal (COP)</label>
|
||
<input
|
||
type="number"
|
||
min="0"
|
||
step="5000"
|
||
value={minSpend || ''}
|
||
onChange={(e) => setMinSpend(Number(e.target.value))}
|
||
placeholder="0 = Sin mínimo"
|
||
className="w-full bg-neutral-950 border border-neutral-700 focus:border-purple-500 rounded-xl px-3 py-2 text-white font-mono outline-none"
|
||
/>
|
||
<p className="text-[10px] text-neutral-500">Ej. $100.000 para pedidos mayores.</p>
|
||
</div>
|
||
|
||
{discountType === 'percentage' && (
|
||
<div className="space-y-1">
|
||
<label className="font-bold text-neutral-300 block">Descuento Máximo / Tope (COP)</label>
|
||
<input
|
||
type="number"
|
||
min="0"
|
||
step="5000"
|
||
value={maxDiscount || ''}
|
||
onChange={(e) => setMaxDiscount(Number(e.target.value))}
|
||
placeholder="0 = Sin tope"
|
||
className="w-full bg-neutral-950 border border-neutral-700 focus:border-purple-500 rounded-xl px-3 py-2 text-white font-mono outline-none"
|
||
/>
|
||
<p className="text-[10px] text-neutral-500">Tope máximo en pesos para cuidar márgenes.</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Estado Activo */}
|
||
<div className="pt-2 flex items-center justify-between bg-neutral-950 p-3 rounded-xl border border-neutral-800">
|
||
<div>
|
||
<span className="font-bold text-white block">Estado del Cupón</span>
|
||
<span className="text-[10px] text-neutral-400">
|
||
{isActive ? 'El cupón estará activo y listo para ser redimido.' : 'El cupón estará pausado.'}
|
||
</span>
|
||
</div>
|
||
<input
|
||
type="checkbox"
|
||
checked={isActive}
|
||
onChange={(e) => setIsActive(e.target.checked)}
|
||
className="w-5 h-5 accent-emerald-500 rounded cursor-pointer"
|
||
/>
|
||
</div>
|
||
|
||
{/* Submit Buttons */}
|
||
<div className="pt-3 flex gap-2 justify-end">
|
||
<button
|
||
type="button"
|
||
onClick={() => setIsModalOpen(false)}
|
||
className="px-4 py-2.5 bg-neutral-800 hover:bg-neutral-700 text-neutral-300 font-bold rounded-xl transition-colors cursor-pointer"
|
||
>
|
||
Cancelar
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
disabled={isSubmitting}
|
||
className="px-5 py-2.5 bg-gradient-to-r from-purple-600 to-fuchsia-600 hover:opacity-95 text-white font-bold rounded-xl shadow-lg shadow-purple-600/30 transition-all cursor-pointer disabled:opacity-50 flex items-center gap-1.5"
|
||
>
|
||
{isSubmitting ? (
|
||
<span>Guardando...</span>
|
||
) : editingCoupon ? (
|
||
<span>Actualizar Cupón</span>
|
||
) : (
|
||
<span>Crear Cupón</span>
|
||
)}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|