Bookings/payments/linktree: fix payment method display, event filter, logo, search

- Bookings: align payment method labels with payments page (bank_transfer, tpago, etc), add sibling fallback
- Payments: add event filter (single/multi), add search by name/email/event
- Linktree: use Spanglish logo instead of icon
- API: payments getAll supports eventId/eventIds

Made-with: Cursor
This commit is contained in:
Michilis
2026-03-07 18:06:35 +00:00
parent bbfaa1172a
commit 25b7018743
5 changed files with 159 additions and 31 deletions

View File

@@ -30,6 +30,8 @@ paymentsRouter.get('/', requireAuth(['admin']), async (c) => {
const status = c.req.query('status'); const status = c.req.query('status');
const provider = c.req.query('provider'); const provider = c.req.query('provider');
const pendingApproval = c.req.query('pendingApproval'); const pendingApproval = c.req.query('pendingApproval');
const eventId = c.req.query('eventId');
const eventIds = c.req.query('eventIds');
// Get all payments with their associated tickets // Get all payments with their associated tickets
let allPayments = await dbAll<any>( let allPayments = await dbAll<any>(
@@ -55,7 +57,7 @@ paymentsRouter.get('/', requireAuth(['admin']), async (c) => {
} }
// Enrich with ticket and event data // Enrich with ticket and event data
const enrichedPayments = await Promise.all( let enrichedPayments = await Promise.all(
allPayments.map(async (payment: any) => { allPayments.map(async (payment: any) => {
const ticket = await dbGet<any>( const ticket = await dbGet<any>(
(db as any) (db as any)
@@ -94,6 +96,16 @@ paymentsRouter.get('/', requireAuth(['admin']), async (c) => {
}) })
); );
// Filter by event(s)
if (eventId) {
enrichedPayments = enrichedPayments.filter((p: any) => p.event?.id === eventId);
} else if (eventIds) {
const ids = eventIds.split(',').map((s: string) => s.trim()).filter(Boolean);
if (ids.length > 0) {
enrichedPayments = enrichedPayments.filter((p: any) => p.event && ids.includes(p.event.id));
}
}
return c.json({ payments: enrichedPayments }); return c.json({ payments: enrichedPayments });
}); });

View File

@@ -154,12 +154,23 @@ export default function AdminBookingsPage() {
}; };
const getPaymentMethodLabel = (provider: string) => { const getPaymentMethodLabel = (provider: string) => {
switch (provider) { const labels: Record<string, string> = {
case 'bancard': return 'TPago / Card'; cash: locale === 'es' ? 'Efectivo en el Evento' : 'Cash at Event',
case 'lightning': return 'Bitcoin Lightning'; bank_transfer: locale === 'es' ? 'Transferencia Bancaria' : 'Bank Transfer',
case 'cash': return 'Cash at Event'; lightning: 'Lightning',
default: return provider; tpago: 'TPago',
bancard: 'Bancard',
};
return labels[provider] || provider;
};
const getDisplayProvider = (ticket: TicketWithDetails) => {
if (ticket.payment?.provider) return ticket.payment.provider;
if (ticket.bookingId) {
const sibling = tickets.find(t => t.bookingId === ticket.bookingId && t.payment?.provider);
return sibling?.payment?.provider ?? 'cash';
} }
return 'cash';
}; };
const filteredTickets = tickets.filter((ticket) => { const filteredTickets = tickets.filter((ticket) => {
@@ -394,7 +405,7 @@ export default function AdminBookingsPage() {
<span className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${getPaymentStatusColor(ticket.payment?.status || 'pending')}`}> <span className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${getPaymentStatusColor(ticket.payment?.status || 'pending')}`}>
{ticket.payment?.status || 'pending'} {ticket.payment?.status || 'pending'}
</span> </span>
<p className="text-xs text-gray-500 mt-0.5">{getPaymentMethodLabel(ticket.payment?.provider || 'cash')}</p> <p className="text-xs text-gray-500 mt-0.5">{getPaymentMethodLabel(getDisplayProvider(ticket))}</p>
{ticket.payment && ( {ticket.payment && (
<p className="text-xs font-medium mt-0.5">{bookingInfo.bookingTotal.toLocaleString()} {ticket.payment.currency}</p> <p className="text-xs font-medium mt-0.5">{bookingInfo.bookingTotal.toLocaleString()} {ticket.payment.currency}</p>
)} )}

View File

@@ -22,6 +22,7 @@ import {
CreditCardIcon, CreditCardIcon,
EnvelopeIcon, EnvelopeIcon,
FunnelIcon, FunnelIcon,
MagnifyingGlassIcon,
XMarkIcon, XMarkIcon,
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
@@ -38,6 +39,8 @@ export default function AdminPaymentsPage() {
const [activeTab, setActiveTab] = useState<Tab>('pending_approval'); const [activeTab, setActiveTab] = useState<Tab>('pending_approval');
const [statusFilter, setStatusFilter] = useState<string>(''); const [statusFilter, setStatusFilter] = useState<string>('');
const [providerFilter, setProviderFilter] = useState<string>(''); const [providerFilter, setProviderFilter] = useState<string>('');
const [eventFilter, setEventFilter] = useState<string[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [mobileFilterOpen, setMobileFilterOpen] = useState(false); const [mobileFilterOpen, setMobileFilterOpen] = useState(false);
// Modal state // Modal state
@@ -59,7 +62,7 @@ export default function AdminPaymentsPage() {
useEffect(() => { useEffect(() => {
loadData(); loadData();
}, [statusFilter, providerFilter]); }, [statusFilter, providerFilter, eventFilter]);
const loadData = async () => { const loadData = async () => {
try { try {
@@ -68,7 +71,8 @@ export default function AdminPaymentsPage() {
paymentsApi.getPendingApproval(), paymentsApi.getPendingApproval(),
paymentsApi.getAll({ paymentsApi.getAll({
status: statusFilter || undefined, status: statusFilter || undefined,
provider: providerFilter || undefined provider: providerFilter || undefined,
eventIds: eventFilter.length > 0 ? eventFilter : undefined,
}), }),
eventsApi.getAll(), eventsApi.getAll(),
]); ]);
@@ -751,11 +755,40 @@ export default function AdminPaymentsPage() {
)} )}
{/* All Payments Tab */} {/* All Payments Tab */}
{activeTab === 'all' && ( {activeTab === 'all' && (() => {
const q = searchQuery.trim().toLowerCase();
const filteredPayments = q
? payments.filter((p) => {
const name = `${p.ticket?.attendeeFirstName || ''} ${p.ticket?.attendeeLastName || ''}`.trim().toLowerCase();
const email = (p.ticket?.attendeeEmail || '').toLowerCase();
const phone = (p.ticket?.attendeePhone || '').toLowerCase();
const eventTitle = (p.event?.title || '').toLowerCase();
const payerName = (p.payerName || '').toLowerCase();
const reference = (p.reference || '').toLowerCase();
const id = (p.id || '').toLowerCase();
return name.includes(q) || email.includes(q) || phone.includes(q) ||
eventTitle.includes(q) || payerName.includes(q) || reference.includes(q) || id.includes(q);
})
: payments;
return (
<> <>
{/* Desktop Filters */} {/* Desktop Filters */}
<Card className="p-4 mb-6 hidden md:block"> <Card className="p-4 mb-6 hidden md:block">
<div className="flex flex-wrap gap-4"> <div className="flex flex-wrap gap-4">
<div className="flex-1 min-w-[200px]">
<label className="block text-sm font-medium mb-1">{locale === 'es' ? 'Buscar' : 'Search'}</label>
<div className="relative">
<MagnifyingGlassIcon className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
type="text"
placeholder={locale === 'es' ? 'Nombre, email, evento...' : 'Name, email, event...'}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-9 pr-3 py-2 rounded-btn border border-secondary-light-gray text-sm focus:outline-none focus:ring-2 focus:ring-primary-yellow min-w-[200px]"
/>
</div>
</div>
<div> <div>
<label className="block text-sm font-medium mb-1">Status</label> <label className="block text-sm font-medium mb-1">Status</label>
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)} <select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}
@@ -779,20 +812,65 @@ export default function AdminPaymentsPage() {
<option value="tpago">TPago</option> <option value="tpago">TPago</option>
</select> </select>
</div> </div>
<div className="flex-1 min-w-[200px]">
<label className="block text-sm font-medium mb-1">{locale === 'es' ? 'Evento(s)' : 'Event(s)'}</label>
<select
value=""
onChange={(e) => {
const id = e.target.value;
if (id && !eventFilter.includes(id)) setEventFilter([...eventFilter, id]);
e.target.value = '';
}}
className="px-4 py-2 rounded-btn border border-secondary-light-gray w-full text-sm"
>
<option value="">{locale === 'es' ? 'Agregar evento...' : 'Add event...'}</option>
{events.filter(e => !eventFilter.includes(e.id)).map((event) => (
<option key={event.id} value={event.id}>{event.title}</option>
))}
</select>
{eventFilter.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-2">
{eventFilter.map((id) => {
const ev = events.find(e => e.id === id);
return (
<span key={id} className="inline-flex items-center gap-1 px-2 py-0.5 bg-primary-yellow/20 rounded text-xs">
{ev?.title || id}
<button type="button" onClick={() => setEventFilter(eventFilter.filter(x => x !== id))} className="hover:text-red-600">×</button>
</span>
);
})}
<button type="button" onClick={() => setEventFilter([])} className="text-xs text-gray-500 hover:text-primary-dark">
{locale === 'es' ? 'Limpiar' : 'Clear'}
</button>
</div>
)}
</div>
</div> </div>
</Card> </Card>
{/* Mobile Filter Toolbar */} {/* Mobile Search & Filter Toolbar */}
<div className="md:hidden mb-4 flex items-center gap-2"> <div className="md:hidden mb-4 space-y-2">
<button onClick={() => setMobileFilterOpen(true)} <div className="relative">
className={clsx('flex items-center gap-1.5 px-3 py-2 rounded-btn border text-sm min-h-[44px]', <MagnifyingGlassIcon className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
(statusFilter || providerFilter) ? 'border-primary-yellow bg-yellow-50 text-primary-dark' : 'border-secondary-light-gray text-gray-600')}> <input
<FunnelIcon className="w-4 h-4" /> Filters type="text"
</button> placeholder={locale === 'es' ? 'Nombre, email, evento...' : 'Name, email, event...'}
{(statusFilter || providerFilter) && ( value={searchQuery}
<button onClick={() => { setStatusFilter(''); setProviderFilter(''); }} onChange={(e) => setSearchQuery(e.target.value)}
className="text-xs text-primary-yellow min-h-[44px] flex items-center">Clear</button> className="w-full pl-9 pr-3 py-2.5 rounded-btn border border-secondary-light-gray text-sm focus:outline-none focus:ring-2 focus:ring-primary-yellow"
)} />
</div>
<div className="flex items-center gap-2">
<button onClick={() => setMobileFilterOpen(true)}
className={clsx('flex items-center gap-1.5 px-3 py-2 rounded-btn border text-sm min-h-[44px]',
(statusFilter || providerFilter || eventFilter.length > 0) ? 'border-primary-yellow bg-yellow-50 text-primary-dark' : 'border-secondary-light-gray text-gray-600')}>
<FunnelIcon className="w-4 h-4" /> Filters
</button>
{(statusFilter || providerFilter || eventFilter.length > 0 || searchQuery) && (
<button onClick={() => { setStatusFilter(''); setProviderFilter(''); setEventFilter([]); setSearchQuery(''); }}
className="text-xs text-primary-yellow min-h-[44px] flex items-center">Clear</button>
)}
</div>
</div> </div>
{/* Desktop: Table */} {/* Desktop: Table */}
@@ -810,10 +888,10 @@ export default function AdminPaymentsPage() {
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-secondary-light-gray"> <tbody className="divide-y divide-secondary-light-gray">
{payments.length === 0 ? ( {filteredPayments.length === 0 ? (
<tr><td colSpan={6} className="px-4 py-12 text-center text-gray-500 text-sm">{locale === 'es' ? 'No se encontraron pagos' : 'No payments found'}</td></tr> <tr><td colSpan={6} className="px-4 py-12 text-center text-gray-500 text-sm">{locale === 'es' ? 'No se encontraron pagos' : 'No payments found'}</td></tr>
) : ( ) : (
payments.map((payment) => { filteredPayments.map((payment) => {
const bookingInfo = getBookingInfo(payment); const bookingInfo = getBookingInfo(payment);
return ( return (
<tr key={payment.id} className="hover:bg-gray-50"> <tr key={payment.id} className="hover:bg-gray-50">
@@ -858,13 +936,18 @@ export default function AdminPaymentsPage() {
</table> </table>
</div> </div>
</Card> </Card>
{(searchQuery || filteredPayments.length !== payments.length) && (
<p className="hidden md:block text-sm text-gray-500 mb-2">
{locale === 'es' ? 'Mostrando' : 'Showing'} {filteredPayments.length} {locale === 'es' ? 'de' : 'of'} {payments.length}
</p>
)}
{/* Mobile: Card List */} {/* Mobile: Card List */}
<div className="md:hidden space-y-2"> <div className="md:hidden space-y-2">
{payments.length === 0 ? ( {filteredPayments.length === 0 ? (
<div className="text-center py-10 text-gray-500 text-sm">{locale === 'es' ? 'No se encontraron pagos' : 'No payments found'}</div> <div className="text-center py-10 text-gray-500 text-sm">{locale === 'es' ? 'No se encontraron pagos' : 'No payments found'}</div>
) : ( ) : (
payments.map((payment) => { filteredPayments.map((payment) => {
const bookingInfo = getBookingInfo(payment); const bookingInfo = getBookingInfo(payment);
return ( return (
<Card key={payment.id} className="p-3"> <Card key={payment.id} className="p-3">
@@ -911,6 +994,25 @@ export default function AdminPaymentsPage() {
{/* Mobile Filter BottomSheet */} {/* Mobile Filter BottomSheet */}
<BottomSheet open={mobileFilterOpen} onClose={() => setMobileFilterOpen(false)} title={locale === 'es' ? 'Filtros' : 'Filters'}> <BottomSheet open={mobileFilterOpen} onClose={() => setMobileFilterOpen(false)} title={locale === 'es' ? 'Filtros' : 'Filters'}>
<div className="space-y-4"> <div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{locale === 'es' ? 'Evento(s)' : 'Event(s)'}</label>
<div className="max-h-40 overflow-y-auto border border-secondary-light-gray rounded-btn p-2 space-y-1">
{events.map((event) => (
<label key={event.id} className="flex items-center gap-2 py-1.5 cursor-pointer">
<input
type="checkbox"
checked={eventFilter.includes(event.id)}
onChange={(e) => {
if (e.target.checked) setEventFilter([...eventFilter, event.id]);
else setEventFilter(eventFilter.filter(id => id !== event.id));
}}
className="w-4 h-4 rounded border-gray-300 text-primary-yellow focus:ring-primary-yellow"
/>
<span className="text-sm">{event.title}</span>
</label>
))}
</div>
</div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">Status</label> <label className="block text-sm font-medium text-gray-700 mb-1">Status</label>
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)} <select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}
@@ -935,13 +1037,14 @@ export default function AdminPaymentsPage() {
</select> </select>
</div> </div>
<div className="flex gap-3 pt-2"> <div className="flex gap-3 pt-2">
<Button variant="outline" onClick={() => { setStatusFilter(''); setProviderFilter(''); setMobileFilterOpen(false); }} className="flex-1 min-h-[44px]">Clear</Button> <Button variant="outline" onClick={() => { setStatusFilter(''); setProviderFilter(''); setEventFilter([]); setSearchQuery(''); setMobileFilterOpen(false); }} className="flex-1 min-h-[44px]">Clear</Button>
<Button onClick={() => setMobileFilterOpen(false)} className="flex-1 min-h-[44px]">Apply</Button> <Button onClick={() => setMobileFilterOpen(false)} className="flex-1 min-h-[44px]">Apply</Button>
</div> </div>
</div> </div>
</BottomSheet> </BottomSheet>
</> </>
)} );
})()}
<AdminMobileStyles /> <AdminMobileStyles />
</div> </div>

View File

@@ -2,13 +2,13 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import Image from 'next/image';
import { useLanguage } from '@/context/LanguageContext'; import { useLanguage } from '@/context/LanguageContext';
import { eventsApi, Event } from '@/lib/api'; import { eventsApi, Event } from '@/lib/api';
import { formatPrice, formatDateShort, formatTime } from '@/lib/utils'; import { formatPrice, formatDateShort, formatTime } from '@/lib/utils';
import { import {
CalendarIcon, CalendarIcon,
MapPinIcon, MapPinIcon,
ChatBubbleLeftRightIcon,
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
export default function LinktreePage() { export default function LinktreePage() {
@@ -59,8 +59,8 @@ export default function LinktreePage() {
<div className="max-w-md mx-auto px-4 py-8 pb-16"> <div className="max-w-md mx-auto px-4 py-8 pb-16">
{/* Profile Header */} {/* Profile Header */}
<div className="text-center mb-8"> <div className="text-center mb-8">
<div className="w-24 h-24 mx-auto bg-primary-yellow rounded-full flex items-center justify-center mb-4 shadow-lg"> <div className="w-24 h-24 mx-auto rounded-full overflow-hidden flex items-center justify-center mb-4 shadow-lg bg-white">
<ChatBubbleLeftRightIcon className="w-12 h-12 text-primary-dark" /> <Image src="/images/spanglish-icon.png" alt="Spanglish" width={96} height={96} className="object-contain" />
</div> </div>
<h1 className="text-2xl font-bold text-white">Spanglish</h1> <h1 className="text-2xl font-bold text-white">Spanglish</h1>
<p className="text-gray-400 mt-1">{t('linktree.tagline')}</p> <p className="text-gray-400 mt-1">{t('linktree.tagline')}</p>

View File

@@ -236,11 +236,13 @@ export const usersApi = {
// Payments API // Payments API
export const paymentsApi = { export const paymentsApi = {
getAll: (params?: { status?: string; provider?: string; pendingApproval?: boolean }) => { getAll: (params?: { status?: string; provider?: string; pendingApproval?: boolean; eventId?: string; eventIds?: string[] }) => {
const query = new URLSearchParams(); const query = new URLSearchParams();
if (params?.status) query.set('status', params.status); if (params?.status) query.set('status', params.status);
if (params?.provider) query.set('provider', params.provider); if (params?.provider) query.set('provider', params.provider);
if (params?.pendingApproval) query.set('pendingApproval', 'true'); if (params?.pendingApproval) query.set('pendingApproval', 'true');
if (params?.eventId) query.set('eventId', params.eventId);
if (params?.eventIds && params.eventIds.length > 0) query.set('eventIds', params.eventIds.join(','));
return fetchApi<{ payments: PaymentWithDetails[] }>(`/api/payments?${query}`); return fetchApi<{ payments: PaymentWithDetails[] }>(`/api/payments?${query}`);
}, },