Files
Spanglish/frontend/src/app/admin/payment-options/page.tsx
T
MichilisandClaude Opus 5.5 b51c1360e2 Add a per-event walk-in price and a POS tender to the door screen.
Events can now set an optional walk-in price (events.walk_in_price):
null falls back to the ticket price, 0 is a free walk-in. It is set in
the Create/Edit Event modal (EN/ES) and carried through create, update
and duplicate, but it is internal: public event responses and the
attendee-facing ticket/dashboard endpoints drop it, and only admin,
organizer and staff callers receive it.

The door screen no longer trusts the amount the client sends. It sends
a quantity and the server prices the charge from the event record:
walk-ins pay the walk-in price, existing tickets the ticket price. A
typed amount is only honoured with amountOverride from admin/organizer
and is written to audit_logs in the same transaction. The charged
amount stays snapshotted on the payment row.

POS is a new door tender for the physical card terminal. Staff pick
POS, see the amount to key in, charge the card, then confirm with
"Mark as paid"; it is then recorded like any other door payment
(provider and method 'pos'). It can be switched off globally or per
event via payment options, and shows up in the payments filters,
bookings, revenue summaries and door takings. PosChargePanel is where
an automatic terminal push would go later; nothing calls a terminal
today.

tickets.booking_source (online | walk_in | admin) records how a booking
was made. The migration backfills walk-ins from the door screen's
idempotency records; everything else stays online.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 23:10:50 +00:00

594 lines
25 KiB
TypeScript

'use client';
import { useState, useEffect } from 'react';
import { useLanguage } from '@/context/LanguageContext';
import { paymentOptionsApi, PaymentOptionsConfig } from '@/lib/api';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
import {
CreditCardIcon,
BanknotesIcon,
BoltIcon,
BuildingLibraryIcon,
CheckCircleIcon,
XCircleIcon,
ArrowPathIcon,
TicketIcon,
Cog6ToothIcon,
} from '@heroicons/react/24/outline';
import toast from 'react-hot-toast';
export default function PaymentOptionsPage() {
const { t, locale } = useLanguage();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [options, setOptions] = useState<PaymentOptionsConfig>({
tpagoEnabled: false,
tpagoLink: null,
tpagoLink2: null,
tpagoLink3: null,
tpagoLink4: null,
tpagoLink5: null,
tpagoInstructions: null,
tpagoInstructionsEs: null,
bankTransferEnabled: false,
bankName: null,
bankAccountHolder: null,
bankAccountNumber: null,
bankAlias: null,
bankPhone: null,
bankNotes: null,
bankNotesEs: null,
lightningEnabled: true,
cashEnabled: true,
posEnabled: true,
cashInstructions: null,
cashInstructionsEs: null,
allowDuplicateBookings: false,
});
useEffect(() => {
loadOptions();
}, []);
const loadOptions = async () => {
try {
setLoading(true);
const { paymentOptions } = await paymentOptionsApi.getGlobal();
setOptions(paymentOptions);
} catch (error) {
console.error('Failed to load payment options:', error);
toast.error('Failed to load payment options');
} finally {
setLoading(false);
}
};
const handleSave = async () => {
try {
setSaving(true);
await paymentOptionsApi.updateGlobal(options);
toast.success('Payment options saved successfully');
} catch (error: any) {
toast.error(error.message || 'Failed to save payment options');
} finally {
setSaving(false);
}
};
const updateOption = <K extends keyof PaymentOptionsConfig>(
key: K,
value: PaymentOptionsConfig[K]
) => {
setOptions((prev) => ({ ...prev, [key]: value }));
};
// Stored as 0/1 on Postgres; on unless explicitly switched off.
const posEnabled = !!(options.posEnabled ?? true);
if (loading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full" />
</div>
);
}
return (
<div className="max-w-4xl">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-primary-dark">
{locale === 'es' ? 'Opciones de Pago' : 'Payment Options'}
</h1>
<p className="text-gray-600 mt-1">
{locale === 'es'
? 'Configura los métodos de pago disponibles para todos los eventos'
: 'Configure payment methods available for all events'}
</p>
</div>
<Button onClick={handleSave} isLoading={saving}>
{locale === 'es' ? 'Guardar Cambios' : 'Save Changes'}
</Button>
</div>
{/* TPago / International Card */}
<Card className="mb-6">
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center">
<CreditCardIcon className="w-5 h-5 text-blue-600" />
</div>
<div>
<h3 className="font-semibold text-lg">
{locale === 'es' ? 'TPago / Tarjeta Internacional' : 'TPago / International Card'}
</h3>
<p className="text-sm text-gray-500">
{locale === 'es' ? 'Pago manual - requiere aprobación' : 'Manual payment - requires approval'}
</p>
</div>
</div>
<button
onClick={() => updateOption('tpagoEnabled', !options.tpagoEnabled)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
options.tpagoEnabled ? 'bg-primary-yellow' : 'bg-gray-300'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
options.tpagoEnabled ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
{options.tpagoEnabled && (
<div className="space-y-4 pt-4 border-t">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{locale === 'es' ? 'Enlaces de Pago TPago (por cantidad de tickets)' : 'TPago Payment Links (per ticket quantity)'}
</label>
<p className="text-xs text-gray-500 mb-2">
{locale === 'es'
? 'Cada enlace tiene un monto fijo. Usá un enlace distinto para cada cantidad de tickets.'
: 'Each link has a fixed amount. Use a different link for each ticket quantity.'}
</p>
<div className="space-y-2">
{([1, 2, 3, 4, 5] as const).map((qty) => {
const key = (qty === 1 ? 'tpagoLink' : `tpagoLink${qty}`) as keyof PaymentOptionsConfig;
return (
<div key={qty} className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-600 w-24 flex-shrink-0">
{qty} {locale === 'es' ? (qty === 1 ? 'ticket' : 'tickets') : (qty === 1 ? 'ticket' : 'tickets')}
</span>
<Input
value={(options[key] as string | null) || ''}
onChange={(e) => updateOption(key, (e.target.value || null) as any)}
placeholder="https://www.tpago.com.py/links?alias=..."
className="flex-1"
/>
</div>
);
})}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Instructions (English)
</label>
<textarea
value={options.tpagoInstructions || ''}
onChange={(e) => updateOption('tpagoInstructions', e.target.value || null)}
rows={3}
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
placeholder="Instructions for users..."
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Instrucciones (Español)
</label>
<textarea
value={options.tpagoInstructionsEs || ''}
onChange={(e) => updateOption('tpagoInstructionsEs', e.target.value || null)}
rows={3}
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
placeholder="Instrucciones para usuarios..."
/>
</div>
</div>
</div>
)}
</div>
</Card>
{/* Bank Transfer */}
<Card className="mb-6">
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center">
<BuildingLibraryIcon className="w-5 h-5 text-green-600" />
</div>
<div>
<h3 className="font-semibold text-lg">
{locale === 'es' ? 'Transferencia Bancaria' : 'Bank Transfer'}
</h3>
<p className="text-sm text-gray-500">
{locale === 'es' ? 'Pago manual - requiere aprobación' : 'Manual payment - requires approval'}
</p>
</div>
</div>
<button
onClick={() => updateOption('bankTransferEnabled', !options.bankTransferEnabled)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
options.bankTransferEnabled ? 'bg-primary-yellow' : 'bg-gray-300'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
options.bankTransferEnabled ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
{options.bankTransferEnabled && (
<div className="space-y-4 pt-4 border-t">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{locale === 'es' ? 'Nombre del Banco' : 'Bank Name'}
</label>
<Input
value={options.bankName || ''}
onChange={(e) => updateOption('bankName', e.target.value || null)}
placeholder="e.g., Banco Itaú"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{locale === 'es' ? 'Titular de la Cuenta' : 'Account Holder'}
</label>
<Input
value={options.bankAccountHolder || ''}
onChange={(e) => updateOption('bankAccountHolder', e.target.value || null)}
placeholder="e.g., Juan Pérez"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{locale === 'es' ? 'Número de Cuenta' : 'Account Number'}
</label>
<Input
value={options.bankAccountNumber || ''}
onChange={(e) => updateOption('bankAccountNumber', e.target.value || null)}
placeholder="e.g., 1234567890"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Alias
</label>
<Input
value={options.bankAlias || ''}
onChange={(e) => updateOption('bankAlias', e.target.value || null)}
placeholder="e.g., spanglish.pagos"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{locale === 'es' ? 'Teléfono' : 'Phone Number'}
</label>
<Input
value={options.bankPhone || ''}
onChange={(e) => updateOption('bankPhone', e.target.value || null)}
placeholder="e.g., +595 981 123456"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Additional Notes (English)
</label>
<textarea
value={options.bankNotes || ''}
onChange={(e) => updateOption('bankNotes', e.target.value || null)}
rows={3}
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
placeholder="Additional notes for users..."
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Notas Adicionales (Español)
</label>
<textarea
value={options.bankNotesEs || ''}
onChange={(e) => updateOption('bankNotesEs', e.target.value || null)}
rows={3}
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
placeholder="Notas adicionales para usuarios..."
/>
</div>
</div>
</div>
)}
</div>
</Card>
{/* Bitcoin Lightning */}
<Card className="mb-6">
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-orange-100 rounded-full flex items-center justify-center">
<BoltIcon className="w-5 h-5 text-orange-600" />
</div>
<div>
<h3 className="font-semibold text-lg">Bitcoin Lightning</h3>
<p className="text-sm text-gray-500">
{locale === 'es' ? 'Pago instantáneo - confirmación automática' : 'Instant payment - automatic confirmation'}
</p>
</div>
</div>
<button
onClick={() => updateOption('lightningEnabled', !options.lightningEnabled)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
options.lightningEnabled ? 'bg-primary-yellow' : 'bg-gray-300'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
options.lightningEnabled ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
{options.lightningEnabled && (
<div className="pt-4 border-t">
<div className="bg-orange-50 border border-orange-200 rounded-lg p-4">
<p className="text-sm text-orange-800">
{locale === 'es'
? 'Lightning está configurado a través de las variables de entorno de LNbits. Verifica que LNBITS_URL y LNBITS_API_KEY estén configurados correctamente.'
: 'Lightning is configured via LNbits environment variables. Make sure LNBITS_URL and LNBITS_API_KEY are properly set.'}
</p>
</div>
</div>
)}
</div>
</Card>
{/* Cash at Door */}
<Card className="mb-6">
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-yellow-100 rounded-full flex items-center justify-center">
<BanknotesIcon className="w-5 h-5 text-yellow-600" />
</div>
<div>
<h3 className="font-semibold text-lg">
{locale === 'es' ? 'Efectivo en el Evento' : 'Cash at the Door'}
</h3>
<p className="text-sm text-gray-500">
{locale === 'es' ? 'Pago manual - requiere aprobación' : 'Manual payment - requires approval'}
</p>
</div>
</div>
<button
onClick={() => updateOption('cashEnabled', !options.cashEnabled)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
options.cashEnabled ? 'bg-primary-yellow' : 'bg-gray-300'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
options.cashEnabled ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
{options.cashEnabled && (
<div className="space-y-4 pt-4 border-t">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Instructions (English)
</label>
<textarea
value={options.cashInstructions || ''}
onChange={(e) => updateOption('cashInstructions', e.target.value || null)}
rows={3}
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
placeholder="Instructions for cash payments..."
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Instrucciones (Español)
</label>
<textarea
value={options.cashInstructionsEs || ''}
onChange={(e) => updateOption('cashInstructionsEs', e.target.value || null)}
rows={3}
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
placeholder="Instrucciones para pagos en efectivo..."
/>
</div>
</div>
</div>
)}
</div>
</Card>
{/* POS terminal at the door */}
<Card className="mb-6">
<div className="p-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-violet-100 rounded-full flex items-center justify-center">
<CreditCardIcon className="w-5 h-5 text-violet-600" />
</div>
<div>
<h3 className="font-semibold text-lg">POS</h3>
<p className="text-sm text-gray-500">
{locale === 'es'
? 'Terminal de tarjetas en la puerta. Solo en el Escáner; el personal confirma el pago manualmente.'
: 'Card terminal at the door. Scanner only; staff confirm the payment by hand.'}
</p>
</div>
</div>
<button
onClick={() => updateOption('posEnabled', !posEnabled)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${
posEnabled ? 'bg-primary-yellow' : 'bg-gray-300'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
posEnabled ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
</div>
</Card>
{/* Booking Settings */}
<Card className="mb-6">
<div className="p-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 bg-purple-100 rounded-full flex items-center justify-center">
<Cog6ToothIcon className="w-5 h-5 text-purple-600" />
</div>
<div>
<h3 className="font-semibold text-lg">
{locale === 'es' ? 'Configuración de Reservas' : 'Booking Settings'}
</h3>
<p className="text-sm text-gray-500">
{locale === 'es' ? 'Opciones adicionales para el proceso de reserva' : 'Additional options for the booking process'}
</p>
</div>
</div>
<div className="space-y-4 pt-4 border-t">
{/* Allow Duplicate Bookings */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<TicketIcon className="w-5 h-5 text-gray-400" />
<div>
<p className="font-medium text-gray-900">
{locale === 'es' ? 'Permitir Reservas Múltiples' : 'Allow Multiple Bookings'}
</p>
<p className="text-sm text-gray-500">
{locale === 'es'
? 'Permitir que un usuario reserve varios tickets para el mismo evento con el mismo email'
: 'Allow a user to book multiple tickets for the same event with the same email'}
</p>
</div>
</div>
<button
onClick={() => updateOption('allowDuplicateBookings', !options.allowDuplicateBookings)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
options.allowDuplicateBookings ? 'bg-primary-yellow' : 'bg-gray-300'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
options.allowDuplicateBookings ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
{options.allowDuplicateBookings && (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 ml-8">
<p className="text-sm text-yellow-800">
{locale === 'es'
? '⚠️ Cuando está habilitado, los usuarios pueden crear múltiples reservas para el mismo evento. Esto es útil para reservar en nombre de amigos o familiares.'
: '⚠️ When enabled, users can create multiple bookings for the same event. This is useful for booking on behalf of friends or family.'}
</p>
</div>
)}
</div>
</div>
</Card>
{/* Summary */}
<Card>
<div className="p-6">
<h3 className="font-semibold text-lg mb-4">
{locale === 'es' ? 'Resumen de Métodos Activos' : 'Active Methods Summary'}
</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="flex items-center gap-2">
{options.tpagoEnabled ? (
<CheckCircleIcon className="w-5 h-5 text-green-500" />
) : (
<XCircleIcon className="w-5 h-5 text-gray-300" />
)}
<span className={options.tpagoEnabled ? 'text-gray-900' : 'text-gray-400'}>
TPago
</span>
</div>
<div className="flex items-center gap-2">
{options.bankTransferEnabled ? (
<CheckCircleIcon className="w-5 h-5 text-green-500" />
) : (
<XCircleIcon className="w-5 h-5 text-gray-300" />
)}
<span className={options.bankTransferEnabled ? 'text-gray-900' : 'text-gray-400'}>
{locale === 'es' ? 'Transferencia' : 'Bank Transfer'}
</span>
</div>
<div className="flex items-center gap-2">
{options.lightningEnabled ? (
<CheckCircleIcon className="w-5 h-5 text-green-500" />
) : (
<XCircleIcon className="w-5 h-5 text-gray-300" />
)}
<span className={options.lightningEnabled ? 'text-gray-900' : 'text-gray-400'}>
Lightning
</span>
</div>
<div className="flex items-center gap-2">
{options.cashEnabled ? (
<CheckCircleIcon className="w-5 h-5 text-green-500" />
) : (
<XCircleIcon className="w-5 h-5 text-gray-300" />
)}
<span className={options.cashEnabled ? 'text-gray-900' : 'text-gray-400'}>
{locale === 'es' ? 'Efectivo' : 'Cash'}
</span>
</div>
<div className="flex items-center gap-2">
{posEnabled ? (
<CheckCircleIcon className="w-5 h-5 text-green-500" />
) : (
<XCircleIcon className="w-5 h-5 text-gray-300" />
)}
<span className={posEnabled ? 'text-gray-900' : 'text-gray-400'}>
POS
</span>
</div>
</div>
</div>
</Card>
{/* Save button (bottom) */}
<div className="mt-6 flex justify-end">
<Button onClick={handleSave} isLoading={saving} size="lg">
{locale === 'es' ? 'Guardar Cambios' : 'Save Changes'}
</Button>
</div>
</div>
);
}