feat: sistema de cupones de descuento configurables por caducidad y limite de usos en admin, checkout y app android

This commit is contained in:
AI Bot 2026-10-09 08:45:28 -05:00
parent c7827cbbf1
commit 7a3aa95126
8 changed files with 156 additions and 31 deletions

View File

@ -46,7 +46,7 @@ export default function AdminCuponesPage() {
// Form Fields
const [code, setCode] = useState('');
const [description, setDescription] = useState('');
const [discountType, setDiscountType] = useState<'percentage' | 'fixed'>('percentage');
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);
@ -526,20 +526,24 @@ export default function AdminCuponesPage() {
{/* 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">
{coupon.discountType === 'percentage' ? '%' : '$'}
<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.minSpend && coupon.minSpend > 0
{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.discountType === 'percentage' && coupon.maxDiscount && coupon.maxDiscount > 0
{coupon.maxDiscount && coupon.maxDiscount > 0
? ` • Tope: ${formatCOP(coupon.maxDiscount)}`
: ''}
</p>
@ -733,20 +737,20 @@ export default function AdminCuponesPage() {
{/* 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-2 gap-2">
<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-2 border transition-all cursor-pointer ${
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-4 h-4" />
<Percent className="w-3.5 h-3.5" />
<span>Porcentaje (%)</span>
</button>
@ -756,22 +760,57 @@ export default function AdminCuponesPage() {
setDiscountType('fixed');
if (discountValue <= 100) setDiscountValue(20000);
}}
className={`py-2.5 px-3 rounded-xl font-bold flex items-center justify-center gap-2 border transition-all cursor-pointer ${
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-4 h-4" />
<span>Monto Fijo (COP $)</span>
<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%)' : '(Pesos Colombianos COP)'}
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
@ -802,6 +841,17 @@ export default function AdminCuponesPage() {
{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}

View File

@ -99,13 +99,28 @@ export async function POST(request: Request) {
);
}
// 7. Calcular descuento aplicable
// 7. Calcular descuento aplicable (porcentaje, fijo global o por unidad de producto)
const itemsCount = Math.max(
1,
Number(body.itemsCount) ||
(Array.isArray(body.items)
? body.items.reduce((acc: number, it: any) => acc + (it.quantity || 1), 0)
: 1)
);
let discountAmount = 0;
if (coupon.discountType === 'percentage') {
discountAmount = Math.round(subtotalNum * (coupon.discountValue / 100));
if (coupon.maxDiscount && coupon.maxDiscount > 0) {
discountAmount = Math.min(discountAmount, coupon.maxDiscount);
}
} else if (coupon.discountType === 'per_item') {
// Descuento progresivo por cantidad de productos comprados (ej. $10.000 por 1, $20.000 por 2, etc.)
discountAmount = coupon.discountValue * itemsCount;
if (coupon.maxDiscount && coupon.maxDiscount > 0) {
discountAmount = Math.min(discountAmount, coupon.maxDiscount);
}
discountAmount = Math.min(discountAmount, subtotalNum);
} else {
// Monto fijo en pesos COP
discountAmount = Math.min(coupon.discountValue, subtotalNum);
@ -114,6 +129,8 @@ export async function POST(request: Request) {
const discountSummary =
coupon.discountType === 'percentage'
? `${coupon.discountValue}% OFF (${formatCOP(discountAmount)})`
: coupon.discountType === 'per_item'
? `${formatCOP(coupon.discountValue)} x ${itemsCount} ${itemsCount === 1 ? 'producto' : 'productos'} (${formatCOP(discountAmount)} OFF al saldo)`
: `${formatCOP(discountAmount)} OFF`;
return NextResponse.json({

View File

@ -226,6 +226,7 @@ export default function CheckoutPage() {
const activeCity = cities.find((c) => c.id === selectedCityId) || cities[0];
const activePayment = paymentMethods.find((p) => p.id === selectedPaymentId) || paymentMethods[0];
const totalQuantity = checkoutItems.reduce((acc, it) => acc + it.quantity, 0);
const subtotal = checkoutItems.reduce((acc, it) => acc + it.price * it.quantity, 0);
const shippingCost = activeCity.shippingCost;
const finalDiscount = appliedCoupon ? Math.min(discountAmount, subtotal) : 0;
@ -244,7 +245,16 @@ export default function CheckoutPage() {
const res = await fetch('/api/coupons/validate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, subtotal }),
body: JSON.stringify({
code,
subtotal,
itemsCount: totalQuantity,
items: checkoutItems.map((it) => ({
productId: it.productId,
quantity: it.quantity,
price: it.price,
})),
}),
});
const data = await res.json();
if (data.success && data.valid) {
@ -278,27 +288,31 @@ export default function CheckoutPage() {
setCouponSuccessMessage(null);
};
// Revalidar el cupón si cambia el subtotal de los productos
// Revalidar el cupón si cambia el subtotal o la cantidad de productos
useEffect(() => {
if (appliedCoupon && subtotal > 0) {
handleApplyCoupon(appliedCoupon.code);
}
}, [subtotal]);
}, [subtotal, totalQuantity]);
// Lógica de Cobro:
// - Si el método es Bold 100% en línea (PSE, Tarjetas, Nequi, etc. sin separación): cobra el totalAmount.
// - Si el método requiere separación (ej. $10.000 COP) o el usuario la tiene obligatoria: cobra el anticipo ($10.000 COP) y el saldo queda contraentrega.
// - Si es 100% contraentrega en efectivo (ej. Cali): depósito $0 y saldo total contraentrega.
// - REGLA CLAVE: El valor del cupón de descuento se aplica exclusivamente al saldo pendiente por pagar (contraentrega) y NUNCA descuenta el anticipo de separación.
const is100PercentOnline = activePayment?.type === 'bold' && !activePayment?.requiresDeposit;
const isSeparation = Boolean(activePayment?.requiresDeposit || userRequiresDeposit);
const isMandatoryDeposit = isSeparation;
const depositAmount = is100PercentOnline
const rawDepositAmount = is100PercentOnline
? totalAmount
: isSeparation
? (activePayment?.depositAmount || 10000)
: 0;
// El anticipo de separación se mantiene fijo en $10.000 COP (o tope totalAmount si totalAmount < 10000)
const depositAmount = Math.min(rawDepositAmount, totalAmount);
// Todo el descuento del cupón se absorbe directamente en el saldo contraentrega:
const balanceDue = Math.max(0, totalAmount - depositAmount);
const officialWhatsapp = storeSettings?.whatsappNumber || '573106390296';
@ -1016,17 +1030,23 @@ export default function CheckoutPage() {
<div className="bg-emerald-950/40 border border-emerald-500/30 rounded-xl p-2.5 flex items-center justify-between text-xs">
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded-lg bg-emerald-500/20 text-emerald-400 flex items-center justify-center font-bold text-xs">
%
{appliedCoupon.discountType === 'per_item' ? '📦' : '%'}
</div>
<div>
<p className="font-bold text-white flex items-center gap-1">
<span className="font-mono text-emerald-400">{appliedCoupon.code}</span>
<span className="text-[10px] text-neutral-400">
({appliedCoupon.discountType === 'percentage' ? `${appliedCoupon.discountValue}% OFF` : `${formatCOP(appliedCoupon.discountValue)} OFF`})
({appliedCoupon.discountType === 'percentage'
? `${appliedCoupon.discountValue}% OFF`
: appliedCoupon.discountType === 'per_item'
? `${formatCOP(appliedCoupon.discountValue)} x par`
: `${formatCOP(appliedCoupon.discountValue)} OFF`})
</span>
</p>
<p className="text-[10px] text-emerald-300/80">
Ahorras {formatCOP(finalDiscount)} en esta compra
{appliedCoupon.discountType === 'per_item'
? `Ahorras ${formatCOP(finalDiscount)} (${formatCOP(appliedCoupon.discountValue)} x ${totalQuantity} ${totalQuantity === 1 ? 'par' : 'pares'}) aplicados al saldo contraentrega`
: `Ahorras ${formatCOP(finalDiscount)} aplicados a tu saldo contraentrega`}
</p>
</div>
</div>
@ -1067,7 +1087,10 @@ export default function CheckoutPage() {
<div className="flex justify-between text-emerald-400 font-bold bg-emerald-950/30 border border-emerald-500/20 px-2.5 py-1.5 rounded-lg">
<span className="flex items-center gap-1.5">
<Tag className="w-3.5 h-3.5" />
<span>Descuento Cupón ({appliedCoupon.code}):</span>
<span>
Descuento Cupón ({appliedCoupon.code}
{appliedCoupon.discountType === 'per_item' ? ` • ${totalQuantity}x` : ''}):
</span>
</span>
<span className="font-mono">-{formatCOP(finalDiscount)}</span>
</div>
@ -1089,7 +1112,7 @@ export default function CheckoutPage() {
<div className="bg-neutral-950 border border-white/10 rounded-2xl p-3.5 space-y-2 text-xs">
<div className="flex justify-between items-center text-xs">
<span className="text-purple-300 font-bold">
{isMandatoryDeposit ? '⚡ Valor Separación (1er Pedido):' : '⭐ Separación (Cliente Frecuente):'}
{isMandatoryDeposit ? '⚡ Valor Separación Fija (Garantía):' : '⭐ Separación (Cliente Frecuente):'}
</span>
<span className="font-black text-white font-mono">
{isMandatoryDeposit ? formatCOP(depositAmount) : '$0 COP (Exento)'}
@ -1101,9 +1124,20 @@ export default function CheckoutPage() {
<span className="font-mono font-black text-emerald-400 text-sm">{formatCOP(balanceDue)}</span>
</div>
{appliedCoupon && finalDiscount > 0 && (
<div className="bg-emerald-950/40 border border-emerald-500/30 rounded-xl p-2.5 text-[11px] text-emerald-300 leading-tight space-y-1">
<p className="font-bold text-emerald-200 flex items-center gap-1">
<span>🎉 Descuento aplicado al saldo contraentrega:</span>
</p>
<p className="text-[10px] text-emerald-300/90">
El ahorro de <strong className="text-white font-mono">{formatCOP(finalDiscount)}</strong> se descuenta directamente de lo que pagas al recibir tu paquete en casa. El valor de separación permanece fijo en <strong className="text-white font-mono">{formatCOP(depositAmount)}</strong> como flete de garantía.
</p>
</div>
)}
<p className="text-[10px] text-neutral-400 pt-1 leading-relaxed">
{isMandatoryDeposit
? `Para tu primer pedido aseguras el despacho de tu orden (${checkoutItems.reduce((acc, it) => acc + it.quantity, 0)} items) con una separación fija única de $10.000 COP vía Bold y cancelas el saldo restante en efectivo al recibir tu paquete.`
? `Para tu pedido aseguras el despacho (${totalQuantity} ${totalQuantity === 1 ? 'par' : 'pares'}) con una separación fija única de $10.000 COP vía Bold y cancelas el saldo restante en efectivo o transferencia al recibir tu paquete.`
: '¡Gracias por tu confianza! Como cliente frecuente, cancelas el 100% del pedido contraentrega al recibir.'}
</p>
</div>

View File

@ -300,6 +300,22 @@ export const INITIAL_COUPONS: Coupon[] = [
applicableCategories: [],
createdAt: new Date().toISOString(),
},
{
id: 'cup-multidrip',
code: 'MULTIDRIP10K',
description: '$10.000 COP de descuento por cada producto comprado (1=$10.000, 2=$20.000, etc.)',
discountType: 'per_item',
discountValue: 10000,
minSpend: 0,
maxDiscount: 0,
usageLimit: 100,
usageCount: 0,
expiresAt: new Date(Date.now() + 30 * 86400000).toISOString(),
startDate: new Date().toISOString(),
isActive: true,
applicableCategories: [],
createdAt: new Date().toISOString(),
},
];
export function isCarlosAdmin(identifier?: string | null): boolean {

View File

@ -440,9 +440,13 @@ export function buildOrderConfirmationMessage(order: any): string {
.join('\n\n');
const settings = dataService.getSettings();
const couponSummary = (order.couponCode && order.discountAmount > 0)
? `\n🎟️ *Cupón Aplicado (${order.couponCode}):* -${formatCOP(order.discountAmount)} (aplicado a tu saldo contraentrega)`
: '';
const paymentCondition = order.depositAmount > 0
? `⚡ *Separación / Anticipo:* ${formatCOP(order.depositAmount)}\n🤝 *Saldo a Pagar Contraentrega:* ${formatCOP(order.balanceDue)}`
: '🤝 *Pago 100% Contraentrega al recibir en tu casa.*';
? `⚡ *Separación / Anticipo:* ${formatCOP(order.depositAmount)} (Fijo)\n🤝 *Saldo a Pagar Contraentrega:* ${formatCOP(order.balanceDue)}${couponSummary}`
: `🤝 *Pago 100% Contraentrega al recibir en tu casa:* ${formatCOP(order.totalAmount)}${couponSummary}`;
return dataService.renderMessageTemplate('order_confirmation_customer', {
tienda_nombre: settings?.storeName || 'DripZone Luxury',

View File

@ -1240,7 +1240,7 @@ export async function getCouponsFromAppwrite(): Promise<Coupon[]> {
id: d.$id,
code: (d.code || '').toUpperCase().trim(),
description: d.description || '',
discountType: (d.discountType || 'percentage') as 'percentage' | 'fixed',
discountType: (d.discountType || 'percentage') as 'percentage' | 'fixed' | 'per_item',
discountValue: Number(d.discountValue) || 0,
minSpend: Number(d.minSpend) || 0,
maxDiscount: Number(d.maxDiscount) || 0,

View File

@ -166,7 +166,7 @@ export interface Coupon {
id: string;
code: string;
description?: string;
discountType: 'percentage' | 'fixed';
discountType: 'percentage' | 'fixed' | 'per_item';
discountValue: number;
minSpend?: number;
maxDiscount?: number;

View File

@ -140,13 +140,17 @@ def main():
"• Barra de progreso visual del cupo consumido (ej. 24 / 50 canjes - 48%).\n"
"• Al alcanzar el límite, el cupón pasa automáticamente a estado 'Agotado' y rechaza canjes adicionales.\n\n"
"💰 *4. Tipos de Descuento & Reglas de Margen:*\n"
"• *📦 Por Cada Producto (Progresivo):* ¡NUEVO! Descuenta $10.000 COP si compra 1 par, $20.000 COP si compra 2 pares, $30.000 COP si compra 3 pares, etc.\n"
"• *Porcentaje (%):* Ej. 10%, 15%, 20% con opción de tope máximo en COP para proteger márgenes.\n"
"• *Monto Fijo ($ COP):* Ej. $20.000 COP directo en el pedido.\n"
"• *Compra Mínima:* Monto mínimo en subtotal para activar el beneficio (ej. compras superiores a $120.000).\n\n"
"🛒 *5. Integración en Carrito & Checkout:*\n"
"🛡️ *5. Protección Financiera de la Separación:*\n"
"• El valor del cupón se descuenta 100% del *saldo pendiente por pagar contraentrega*.\n"
"• La *separación fija de $10.000 COP* permanece intacta como garantía de flete de despacho.\n\n"
"🛒 *6. Integración en Carrito & Checkout:*\n"
"• Caja de cupón interactiva en `/checkout` con validación en tiempo real y animación de confeti.\n"
"• Desglose transparente en el resumen de compra: Subtotal, Descuento de Cupón (-$XX.XXX) y Total.\n"
"• Registro del cupón en la base de datos Appwrite y aumento automático del contador de usos al crear el pedido.\n"
"• Desglose transparente en el resumen de compra: Subtotal, Descuento de Cupón (-$XX.XXX), Separación ($10.000 fija) y Saldo Contraentrega.\n"
"• Registro del cupón en Appwrite y aumento automático del contador de usos al crear el pedido.\n"
"• Alerta de nuevo pedido a tu WhatsApp ahora destaca el cupón utilizado por el cliente.\n\n"
f"🔗 *Descarga Directa del APK:* {APK_URL}\n\n"
"Te adjunto a continuación el archivo APK instalador actualizado con soporte completo para la app Android."