From 38526f17b59c49dcc53110d386c2586971c779cd Mon Sep 17 00:00:00 2001 From: Michilis Date: Mon, 29 Jun 2026 07:39:52 +0000 Subject: [PATCH] Redesign user dashboard with overview tab and i18n payment copy. Consolidate profile and security into AccountTab, add shared dashboard components, and move awaiting-approval payment messages to translations. Co-authored-by: Cursor --- .../dashboard/components/AccountTab.tsx | 533 ++++++++++++++++++ .../dashboard/components/OverviewTab.tsx | 394 +++++++++++++ .../dashboard/components/PaymentsTab.tsx | 269 ++++----- .../dashboard/components/ProfileTab.tsx | 211 ------- .../dashboard/components/SecurityTab.tsx | 382 ------------- .../dashboard/components/TicketsTab.tsx | 359 ++++++------ .../components/_shared/AttentionBanner.tsx | 93 +++ .../components/_shared/Countdown.tsx | 54 ++ .../components/_shared/PayActions.tsx | 136 +++++ .../components/_shared/QrTicketModal.tsx | 187 ++++++ .../dashboard/components/_shared/helpers.ts | 148 +++++ .../dashboard/components/_shared/status.tsx | 95 ++++ frontend/src/app/(public)/dashboard/page.tsx | 359 ++---------- frontend/src/i18n/locales/en.json | 8 + frontend/src/i18n/locales/es.json | 8 + 15 files changed, 1999 insertions(+), 1237 deletions(-) create mode 100644 frontend/src/app/(public)/dashboard/components/AccountTab.tsx create mode 100644 frontend/src/app/(public)/dashboard/components/OverviewTab.tsx delete mode 100644 frontend/src/app/(public)/dashboard/components/ProfileTab.tsx delete mode 100644 frontend/src/app/(public)/dashboard/components/SecurityTab.tsx create mode 100644 frontend/src/app/(public)/dashboard/components/_shared/AttentionBanner.tsx create mode 100644 frontend/src/app/(public)/dashboard/components/_shared/Countdown.tsx create mode 100644 frontend/src/app/(public)/dashboard/components/_shared/PayActions.tsx create mode 100644 frontend/src/app/(public)/dashboard/components/_shared/QrTicketModal.tsx create mode 100644 frontend/src/app/(public)/dashboard/components/_shared/helpers.ts create mode 100644 frontend/src/app/(public)/dashboard/components/_shared/status.tsx diff --git a/frontend/src/app/(public)/dashboard/components/AccountTab.tsx b/frontend/src/app/(public)/dashboard/components/AccountTab.tsx new file mode 100644 index 0000000..011d48c --- /dev/null +++ b/frontend/src/app/(public)/dashboard/components/AccountTab.tsx @@ -0,0 +1,533 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useLanguage } from '@/context/LanguageContext'; +import { useAuth } from '@/context/AuthContext'; +import Card from '@/components/ui/Card'; +import Button from '@/components/ui/Button'; +import Input from '@/components/ui/Input'; +import { + dashboardApi, + authApi, + UserProfile, + UserSession, +} from '@/lib/api'; +import { parseDate } from '@/lib/utils'; +import toast from 'react-hot-toast'; + +interface AccountTabProps { + onUpdate?: () => void; +} + +/** + * Merged Profile + Security tab: account info, edit-profile form, authentication + * methods, change/set password, active sessions and account deletion. + */ +export default function AccountTab({ onUpdate }: AccountTabProps) { + const { locale } = useLanguage(); + const { user, updateUser, logout } = useAuth(); + + const [profile, setProfile] = useState(null); + const [sessions, setSessions] = useState([]); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [changingPassword, setChangingPassword] = useState(false); + const [settingPassword, setSettingPassword] = useState(false); + + const [formData, setFormData] = useState({ + name: '', + phone: '', + languagePreference: '', + rucNumber: '', + }); + const [passwordForm, setPasswordForm] = useState({ + currentPassword: '', + newPassword: '', + confirmPassword: '', + }); + const [newPasswordForm, setNewPasswordForm] = useState({ + password: '', + confirmPassword: '', + }); + + useEffect(() => { + loadData(); + }, []); + + const loadData = async () => { + try { + const [profileRes, sessionsRes] = await Promise.all([ + dashboardApi.getProfile(), + dashboardApi.getSessions(), + ]); + setProfile(profileRes.profile); + setSessions(sessionsRes.sessions); + setFormData({ + name: profileRes.profile.name || '', + phone: profileRes.profile.phone || '', + languagePreference: profileRes.profile.languagePreference || '', + rucNumber: profileRes.profile.rucNumber || '', + }); + } catch (error) { + toast.error(locale === 'es' ? 'Error al cargar datos' : 'Failed to load data'); + } finally { + setLoading(false); + } + }; + + const handleSaveProfile = async (e: React.FormEvent) => { + e.preventDefault(); + setSaving(true); + try { + await dashboardApi.updateProfile(formData); + toast.success(locale === 'es' ? 'Perfil actualizado' : 'Profile updated'); + if (user) { + updateUser({ + ...user, + name: formData.name, + phone: formData.phone, + languagePreference: formData.languagePreference, + rucNumber: formData.rucNumber, + }); + } + onUpdate?.(); + } catch (error: any) { + toast.error(error.message || (locale === 'es' ? 'Error al actualizar' : 'Update failed')); + } finally { + setSaving(false); + } + }; + + const handleChangePassword = async (e: React.FormEvent) => { + e.preventDefault(); + if (passwordForm.newPassword !== passwordForm.confirmPassword) { + toast.error(locale === 'es' ? 'Las contraseñas no coinciden' : 'Passwords do not match'); + return; + } + if (passwordForm.newPassword.length < 10) { + toast.error( + locale === 'es' + ? 'La contraseña debe tener al menos 10 caracteres' + : 'Password must be at least 10 characters' + ); + return; + } + setChangingPassword(true); + try { + await authApi.changePassword(passwordForm.currentPassword, passwordForm.newPassword); + toast.success(locale === 'es' ? 'Contraseña actualizada' : 'Password updated'); + setPasswordForm({ currentPassword: '', newPassword: '', confirmPassword: '' }); + } catch (error: any) { + toast.error( + error.message || (locale === 'es' ? 'Error al cambiar contraseña' : 'Failed to change password') + ); + } finally { + setChangingPassword(false); + } + }; + + const handleSetPassword = async (e: React.FormEvent) => { + e.preventDefault(); + if (newPasswordForm.password !== newPasswordForm.confirmPassword) { + toast.error(locale === 'es' ? 'Las contraseñas no coinciden' : 'Passwords do not match'); + return; + } + if (newPasswordForm.password.length < 10) { + toast.error( + locale === 'es' + ? 'La contraseña debe tener al menos 10 caracteres' + : 'Password must be at least 10 characters' + ); + return; + } + setSettingPassword(true); + try { + await dashboardApi.setPassword(newPasswordForm.password); + toast.success(locale === 'es' ? 'Contraseña establecida' : 'Password set'); + setNewPasswordForm({ password: '', confirmPassword: '' }); + loadData(); + } catch (error: any) { + toast.error( + error.message || (locale === 'es' ? 'Error al establecer contraseña' : 'Failed to set password') + ); + } finally { + setSettingPassword(false); + } + }; + + const handleUnlinkGoogle = async () => { + if ( + !confirm( + locale === 'es' + ? '¿Estás seguro de que quieres desvincular tu cuenta de Google?' + : 'Are you sure you want to unlink your Google account?' + ) + ) + return; + try { + await dashboardApi.unlinkGoogle(); + toast.success(locale === 'es' ? 'Google desvinculado' : 'Google unlinked'); + loadData(); + } catch (error: any) { + toast.error(error.message || (locale === 'es' ? 'Error' : 'Failed')); + } + }; + + const handleRevokeSession = async (sessionId: string) => { + try { + await dashboardApi.revokeSession(sessionId); + setSessions((prev) => prev.filter((s) => s.id !== sessionId)); + toast.success(locale === 'es' ? 'Sesión cerrada' : 'Session revoked'); + } catch (error) { + toast.error(locale === 'es' ? 'Error' : 'Failed'); + } + }; + + const handleRevokeAllSessions = async () => { + if ( + !confirm( + locale === 'es' + ? '¿Cerrar todas las sesiones? Serás desconectado.' + : 'Log out of all sessions? You will be logged out.' + ) + ) + return; + try { + await dashboardApi.revokeAllSessions(); + toast.success(locale === 'es' ? 'Todas las sesiones cerradas' : 'All sessions revoked'); + logout(); + } catch (error) { + toast.error(locale === 'es' ? 'Error' : 'Failed'); + } + }; + + const formatDateTime = (dateStr: string) => + parseDate(dateStr).toLocaleString(locale === 'es' ? 'es-ES' : 'en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + timeZone: 'America/Asuncion', + }); + + if (loading) { + return ( +
+
+
+ ); + } + + const isActive = profile?.accountStatus === 'active'; + + return ( +
+ {/* Account info */} + +

+ {locale === 'es' ? 'Información de la cuenta' : 'Account information'} +

+
+
+ {locale === 'es' ? 'Correo' : 'Email'} + {profile?.email} +
+
+ {locale === 'es' ? 'Estado' : 'Status'} + + {isActive + ? locale === 'es' + ? 'Activa' + : 'Active' + : profile?.accountStatus} + +
+
+ {locale === 'es' ? 'Miembro desde' : 'Member since'} + + {profile?.memberSince + ? parseDate(profile.memberSince).toLocaleDateString( + locale === 'es' ? 'es-ES' : 'en-US', + { year: 'numeric', month: 'long', day: 'numeric', timeZone: 'America/Asuncion' } + ) + : '-'} + +
+
+
+ + {/* Edit profile */} + +

+ {locale === 'es' ? 'Editar perfil' : 'Edit profile'} +

+
+ setFormData({ ...formData, name: e.target.value })} + required + /> + setFormData({ ...formData, phone: e.target.value })} + /> +
+ + +
+
+ setFormData({ ...formData, rucNumber: e.target.value })} + placeholder={locale === 'es' ? 'Opcional' : 'Optional'} + /> +

+ {locale === 'es' + ? 'Tu número de RUC paraguayo para facturas fiscales' + : 'Your Paraguayan RUC number for fiscal invoices'} +

+
+
+ +
+
+

+ {locale === 'es' + ? 'Para cambiar tu correo, contacta al soporte.' + : 'To change your email, please contact support.'} +

+
+ + {/* Authentication methods */} + +

+ {locale === 'es' ? 'Métodos de autenticación' : 'Authentication methods'} +

+
+
+
+
+ + + +
+
+

{locale === 'es' ? 'Contraseña' : 'Password'}

+

+ {profile?.hasPassword + ? locale === 'es' ? 'Configurada' : 'Set' + : locale === 'es' ? 'No configurada' : 'Not set'} +

+
+
+
+ +
+
+
+ + + + + + +
+
+

Google

+

+ {profile?.hasGoogleLinked + ? locale === 'es' ? 'Vinculado' : 'Linked' + : locale === 'es' ? 'No vinculado' : 'Not linked'} +

+
+
+ {profile?.hasGoogleLinked && profile?.hasPassword && ( + + )} +
+
+
+ + {/* Password management */} + +

+ {profile?.hasPassword + ? locale === 'es' ? 'Cambiar contraseña' : 'Change password' + : locale === 'es' ? 'Establecer contraseña' : 'Set password'} +

+ {profile?.hasPassword ? ( +
+ setPasswordForm({ ...passwordForm, currentPassword: e.target.value })} + required + /> +
+ setPasswordForm({ ...passwordForm, newPassword: e.target.value })} + required + /> +

+ {locale === 'es' ? 'Mínimo 10 caracteres' : 'Minimum 10 characters'} +

+
+ setPasswordForm({ ...passwordForm, confirmPassword: e.target.value })} + required + /> + +
+ ) : ( +
+

+ {locale === 'es' + ? 'Actualmente inicias sesión con Google. Establece una contraseña para más opciones de acceso.' + : 'You currently sign in with Google. Set a password for more access options.'} +

+
+ setNewPasswordForm({ ...newPasswordForm, password: e.target.value })} + required + /> +

+ {locale === 'es' ? 'Mínimo 10 caracteres' : 'Minimum 10 characters'} +

+
+ setNewPasswordForm({ ...newPasswordForm, confirmPassword: e.target.value })} + required + /> + +
+ )} +
+ + {/* Active sessions */} + +
+

+ {locale === 'es' ? 'Sesiones activas' : 'Active sessions'} +

+ {sessions.length > 1 && ( + + )} +
+ {sessions.length === 0 ? ( +

+ {locale === 'es' ? 'No hay sesiones activas' : 'No active sessions'} +

+ ) : ( +
+ {sessions.map((session, index) => ( +
+
+

+ {session.userAgent + ? session.userAgent.substring(0, 50) + (session.userAgent.length > 50 ? '…' : '') + : locale === 'es' ? 'Dispositivo desconocido' : 'Unknown device'} +

+

+ {locale === 'es' ? 'Última actividad:' : 'Last active:'}{' '} + {formatDateTime(session.lastActiveAt)} + {session.ipAddress && ` • ${session.ipAddress}`} +

+
+ {index === 0 ? ( + + {locale === 'es' ? 'Esta sesión' : 'This session'} + + ) : ( + + )} +
+ ))} +
+ )} +
+ + {/* Danger zone */} + +

+ {locale === 'es' ? 'Zona de peligro' : 'Danger zone'} +

+

+ {locale === 'es' + ? 'Si deseas eliminar tu cuenta, contacta al soporte.' + : 'If you want to delete your account, please contact support.'} +

+ +
+
+ ); +} diff --git a/frontend/src/app/(public)/dashboard/components/OverviewTab.tsx b/frontend/src/app/(public)/dashboard/components/OverviewTab.tsx new file mode 100644 index 0000000..83dd86a --- /dev/null +++ b/frontend/src/app/(public)/dashboard/components/OverviewTab.tsx @@ -0,0 +1,394 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import Link from 'next/link'; +import { + CalendarIcon, + ClockIcon, + MapPinIcon, + ArrowTopRightOnSquareIcon, + TicketIcon, + UserPlusIcon, + QrCodeIcon, +} from '@heroicons/react/24/outline'; +import toast from 'react-hot-toast'; +import Card from '@/components/ui/Card'; +import Button from '@/components/ui/Button'; +import type { UserTicket, NextEventInfo } from '@/lib/api'; +import { useLanguage } from '@/context/LanguageContext'; +import { formatDateLong, formatTime, parseDate } from '@/lib/utils'; +import { StatusPill, deriveTicketStatus } from './_shared/status'; +import { + groupByBooking, + isUnpaid, + isAwaitingApproval, + ticketAmount, + shareTicket, + isToday, + type BookingGroup, +} from './_shared/helpers'; +import PayActions from './_shared/PayActions'; +import AttentionBanner from './_shared/AttentionBanner'; +import QrTicketModal from './_shared/QrTicketModal'; + +interface OverviewTabProps { + nextEvent: NextEventInfo | null; + tickets: UserTicket[]; + locale: string; + userName: string; + onChange: () => void; +} + +export default function OverviewTab({ + nextEvent, + tickets, + locale, + userName, + onChange, +}: OverviewTabProps) { + const [qrTickets, setQrTickets] = useState(null); + + const activeTickets = useMemo( + () => tickets.filter((t) => t.status !== 'cancelled'), + [tickets] + ); + + // Banner: the single booking that most needs attention (unpaid first, then + // awaiting approval), ordered by soonest event. + const attentionTicket = useMemo(() => { + const candidates = activeTickets.filter( + (t) => isUnpaid(t) || isAwaitingApproval(t) + ); + candidates.sort((a, b) => { + const aUnpaid = isUnpaid(a) ? 0 : 1; + const bUnpaid = isUnpaid(b) ? 0 : 1; + if (aUnpaid !== bUnpaid) return aUnpaid - bUnpaid; + const aStart = a.event?.startDatetime + ? parseDate(a.event.startDatetime).getTime() + : Infinity; + const bStart = b.event?.startDatetime + ? parseDate(b.event.startDatetime).getTime() + : Infinity; + return aStart - bStart; + }); + return candidates[0] || null; + }, [activeTickets]); + + // Hero: full tickets for the next event's booking. + const heroGroup = useMemo(() => { + if (!nextEvent) return null; + const primary = + activeTickets.find((t) => t.id === nextEvent.ticket.id) || null; + if (!primary) return null; + const groupTickets = primary.bookingId + ? activeTickets.filter((t) => t.bookingId === primary.bookingId) + : [primary]; + return { bookingId: primary.bookingId || primary.id, tickets: groupTickets }; + }, [nextEvent, activeTickets]); + + // "Also coming up": upcoming booking groups other than the hero. + const upcomingGroups = useMemo(() => { + const now = Date.now(); + const groups = groupByBooking( + activeTickets.filter( + (t) => t.event && parseDate(t.event.startDatetime).getTime() > now + ) + ); + return groups.filter((g) => g.bookingId !== heroGroup?.bookingId); + }, [activeTickets, heroGroup]); + + const handleShare = async (ticket: UserTicket) => { + const title = + (locale === 'es' && ticket.event?.titleEs + ? ticket.event.titleEs + : ticket.event?.title) || 'Event'; + const result = await shareTicket(ticket.id, title, locale); + if (result === 'copied') + toast.success(locale === 'es' ? 'Enlace copiado' : 'Link copied'); + else if (result === 'failed') + toast.error(locale === 'es' ? 'No se pudo compartir' : 'Could not share'); + }; + + // Empty state. + if (activeTickets.length === 0) { + return ( + +
+ +
+

+ {locale === 'es' ? `¡Hola, ${userName}!` : `Welcome, ${userName}!`} +

+

+ {locale === 'es' + ? 'Todavía no tienes reservas. Encuentra tu primer evento de intercambio de idiomas.' + : "You have no bookings yet. Find your first language exchange event."} +

+ + + +
+ ); + } + + return ( +
+ {/* 1 — Attention banner (only when money is owed / awaiting). */} + {attentionTicket && ( + + )} + + {/* 2 — Next event hero. */} + {heroGroup && heroGroup.tickets[0].event && ( + setQrTickets(heroGroup.tickets)} + onShare={handleShare} + /> + )} + + {/* 3 — Also coming up. */} + {upcomingGroups.length > 0 && ( + +

+ {locale === 'es' ? 'También se viene' : 'Also coming up'} +

+
+ {upcomingGroups.map((group) => { + const t = group.tickets[0]; + const status = deriveTicketStatus(t.status, t.payment?.status); + const title = + (locale === 'es' && t.event?.titleEs + ? t.event.titleEs + : t.event?.title) || 'Event'; + const { amount, currency } = ticketAmount(t); + return ( +
+
+
+

{title}

+ {group.tickets.length > 1 && ( + + ×{group.tickets.length} + + )} +
+

+ {t.event && formatDateLong(t.event.startDatetime, locale as 'en' | 'es')} +

+
+
+ + {isUnpaid(t) ? ( + + ) : ( + (status === 'confirmed' || status === 'attended') && ( + + ) + )} +
+
+ ); + })} +
+
+ )} + + {/* 6 — Browse events. */} +
+ + + +
+ + {qrTickets && ( + setQrTickets(null)} + /> + )} +
+ ); +} + +function HeroCard({ + group, + locale, + onChange, + onShowQr, + onShare, +}: { + group: BookingGroup; + locale: string; + onChange: () => void; + onShowQr: () => void; + onShare: (ticket: UserTicket) => void; +}) { + const { t } = useLanguage(); + const ticket = group.tickets[0]; + const event = ticket.event!; + const title = + (locale === 'es' && event.titleEs ? event.titleEs : event.title) || 'Event'; + const status = deriveTicketStatus(ticket.status, ticket.payment?.status); + const { amount, currency } = ticketAmount(ticket); + const multi = group.tickets.length > 1; + const today = isToday(event.startDatetime); + const showQrReady = status === 'confirmed' || status === 'attended'; + + return ( + + {/* Image with status pill + today badge overlay. */} +
+ {event.bannerUrl ? ( + {title} + ) : ( +
+ +
+ )} +
+ {today && ( + + {locale === 'es' ? 'Hoy' : 'Today'} + + )} + +
+
+ +
+

+ {locale === 'es' ? 'Tu próximo evento' : 'Your next event'} +

+
+

{title}

+ {multi && ( + + {locale === 'es' + ? `${group.tickets.length} entradas` + : `${group.tickets.length} tickets`} + + )} +
+ +
+

+ + {formatDateLong(event.startDatetime, locale as 'en' | 'es')} +

+

+ + {formatTime(event.startDatetime, locale as 'en' | 'es')} +

+

+ + {event.location} + {event.locationUrl && ( + + {locale === 'es' ? 'Cómo llegar' : 'Get directions'} + + + )} +

+
+ + {/* Today + ready → show the QR prominently for check-in. */} + {today && showQrReady && ( +
+

+ {locale === 'es' + ? 'Muestra este código en la entrada' + : 'Show this code at the door'} +

+ +
+ )} + + {/* Smart primary action. */} + {isUnpaid(ticket) ? ( + + ) : isAwaitingApproval(ticket) ? ( +

+ {t('dashboard.payment.receivedConfirmShortly')} +

+ ) : ( + !today && ( + + ) + )} + + {/* Multi-ticket guest reminder. */} + {multi && showQrReady && ( +
+

+ {locale === 'es' + ? 'Tienes una entrada de más para un invitado.' + : 'You have a spare ticket for a guest.'} +

+ +
+ )} +
+
+ ); +} diff --git a/frontend/src/app/(public)/dashboard/components/PaymentsTab.tsx b/frontend/src/app/(public)/dashboard/components/PaymentsTab.tsx index 4a2e5ee..f66dead 100644 --- a/frontend/src/app/(public)/dashboard/components/PaymentsTab.tsx +++ b/frontend/src/app/(public)/dashboard/components/PaymentsTab.tsx @@ -1,94 +1,35 @@ 'use client'; -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import Card from '@/components/ui/Card'; import Button from '@/components/ui/Button'; import { UserPayment } from '@/lib/api'; -import { parseDate } from '@/lib/utils'; +import { formatDateLong } from '@/lib/utils'; +import { StatusPill, derivePaymentStatus } from './_shared/status'; +import { pyg, isManualProvider } from './_shared/helpers'; +import PayActions from './_shared/PayActions'; interface PaymentsTabProps { payments: UserPayment[]; language: string; + onChange: () => void; } -export default function PaymentsTab({ payments, language }: PaymentsTabProps) { - const [filter, setFilter] = useState<'all' | 'paid' | 'pending'>('all'); +type Filter = 'all' | 'paid' | 'pending'; - const filteredPayments = payments.filter((payment) => { - if (filter === 'all') return true; - if (filter === 'paid') return payment.status === 'paid'; - if (filter === 'pending') return payment.status !== 'paid' && payment.status !== 'refunded'; - return true; - }); +export default function PaymentsTab({ payments, language: locale, onChange }: PaymentsTabProps) { + const [filter, setFilter] = useState('all'); - const formatDate = (dateStr: string) => { - return parseDate(dateStr).toLocaleDateString(language === 'es' ? 'es-ES' : 'en-US', { - year: 'numeric', - month: 'short', - day: 'numeric', - timeZone: 'America/Asuncion', - }); - }; + const filtered = useMemo( + () => + payments.filter((p) => { + if (filter === 'all') return true; + if (filter === 'paid') return p.status === 'paid'; + return p.status !== 'paid' && p.status !== 'refunded'; + }), + [payments, filter] + ); - const formatCurrency = (amount: number, currency: string = 'PYG') => { - if (currency === 'PYG') { - return `${amount.toLocaleString('es-PY')} PYG`; - } - return `$${amount.toFixed(2)} ${currency}`; - }; - - const getStatusBadge = (status: string) => { - const styles: Record = { - paid: 'bg-green-100 text-green-800', - pending: 'bg-yellow-100 text-yellow-800', - pending_approval: 'bg-orange-100 text-orange-800', - refunded: 'bg-purple-100 text-purple-800', - failed: 'bg-red-100 text-red-800', - }; - const labels: Record> = { - en: { - paid: 'Paid', - pending: 'Pending', - pending_approval: 'Awaiting Approval', - refunded: 'Refunded', - failed: 'Failed', - }, - es: { - paid: 'Pagado', - pending: 'Pendiente', - pending_approval: 'Esperando Aprobación', - refunded: 'Reembolsado', - failed: 'Fallido', - }, - }; - return ( - - {labels[language]?.[status] || status} - - ); - }; - - const getProviderLabel = (provider: string) => { - const labels: Record> = { - en: { - lightning: 'Lightning (Bitcoin)', - cash: 'Cash', - bank_transfer: 'Bank Transfer', - tpago: 'TPago', - bancard: 'Card', - }, - es: { - lightning: 'Lightning (Bitcoin)', - cash: 'Efectivo', - bank_transfer: 'Transferencia Bancaria', - tpago: 'TPago', - bancard: 'Tarjeta', - }, - }; - return labels[language]?.[provider] || provider; - }; - - // Summary calculations const totalPaid = payments .filter((p) => p.status === 'paid') .reduce((sum, p) => sum + Number(p.amount), 0); @@ -96,112 +37,128 @@ export default function PaymentsTab({ payments, language }: PaymentsTabProps) { .filter((p) => p.status !== 'paid' && p.status !== 'refunded') .reduce((sum, p) => sum + Number(p.amount), 0); + const providerLabel = (provider: string) => { + const labels: Record = { + tpago: { en: 'TPago', es: 'TPago' }, + bank_transfer: { en: 'Bank transfer', es: 'Transferencia bancaria' }, + lightning: { en: 'Lightning (Bitcoin)', es: 'Lightning (Bitcoin)' }, + cash: { en: 'Cash', es: 'Efectivo' }, + bancard: { en: 'Card', es: 'Tarjeta' }, + }; + return labels[provider]?.[locale === 'es' ? 'es' : 'en'] || provider; + }; + + const chips: { id: Filter; label: { en: string; es: string } }[] = [ + { id: 'all', label: { en: 'All', es: 'Todos' } }, + { id: 'paid', label: { en: 'Paid', es: 'Pagados' } }, + { id: 'pending', label: { en: 'Pending', es: 'Pendientes' } }, + ]; + return (
- {/* Summary Cards */} + {/* Totals */}
- -

- {language === 'es' ? 'Total Pagado' : 'Total Paid'} -

-

- {formatCurrency(totalPaid)} + +

+ {locale === 'es' ? 'Total pagado' : 'Total paid'}

+

{pyg(totalPaid)}

- -

- {language === 'es' ? 'Pendiente' : 'Pending'} -

-

- {formatCurrency(totalPending)} + +

+ {locale === 'es' ? 'Pendiente' : 'Pending'}

+

{pyg(totalPending)}

- {/* Filter Buttons */} -
- {(['all', 'paid', 'pending'] as const).map((f) => ( + {/* Filter chips */} +
+ {chips.map((chip) => ( ))}
- {/* Payments List */} - {filteredPayments.length === 0 ? ( + {/* Records */} + {filtered.length === 0 ? (

- {language === 'es' ? 'No hay pagos que mostrar' : 'No payments to display'} + {locale === 'es' ? 'No hay pagos que mostrar' : 'No payments to show'}

) : (
- {filteredPayments.map((payment) => ( - -
-
-
- - {formatCurrency(Number(payment.amount), payment.currency)} - - {getStatusBadge(payment.status)} + {filtered.map((payment) => { + const status = derivePaymentStatus(payment.status); + const eventTitle = + (locale === 'es' && payment.event?.titleEs + ? payment.event.titleEs + : payment.event?.title) || (locale === 'es' ? 'Evento' : 'Event'); + const canMarkPaid = + payment.status === 'pending' && isManualProvider(payment.provider); + return ( + +
+
+
+ + {pyg(Number(payment.amount), payment.currency)} + + +
+
+

{eventTitle}

+

+ {providerLabel(payment.provider)} ·{' '} + {formatDateLong(payment.createdAt, locale as 'en' | 'es')} +

+ {payment.reference && ( +

+ {locale === 'es' ? 'Ref' : 'Ref'}: {payment.reference} +

+ )} +
- -
- {payment.event && ( -

- {language === 'es' && payment.event.titleEs - ? payment.event.titleEs - : payment.event.title} -

+ +
+ {canMarkPaid && ( + )} -

- - {language === 'es' ? 'Método:' : 'Method:'} - {' '} - {getProviderLabel(payment.provider)} -

-

- - {language === 'es' ? 'Fecha:' : 'Date:'} - {' '} - {formatDate(payment.createdAt)} -

- {payment.reference && ( -

- - {language === 'es' ? 'Referencia:' : 'Reference:'} - {' '} - {payment.reference} -

+ {payment.invoice && ( + + + )}
- - {payment.invoice && ( - - - - )} -
-
- ))} + + ); + })}
)}
diff --git a/frontend/src/app/(public)/dashboard/components/ProfileTab.tsx b/frontend/src/app/(public)/dashboard/components/ProfileTab.tsx deleted file mode 100644 index 3894eb8..0000000 --- a/frontend/src/app/(public)/dashboard/components/ProfileTab.tsx +++ /dev/null @@ -1,211 +0,0 @@ -'use client'; - -import { useState, useEffect } from 'react'; -import { useLanguage } from '@/context/LanguageContext'; -import { useAuth } from '@/context/AuthContext'; -import Card from '@/components/ui/Card'; -import Button from '@/components/ui/Button'; -import Input from '@/components/ui/Input'; -import { dashboardApi, UserProfile } from '@/lib/api'; -import { parseDate } from '@/lib/utils'; -import toast from 'react-hot-toast'; - -interface ProfileTabProps { - onUpdate?: () => void; -} - -export default function ProfileTab({ onUpdate }: ProfileTabProps) { - const { locale: language } = useLanguage(); - const { updateUser, user } = useAuth(); - - const [profile, setProfile] = useState(null); - const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); - const [formData, setFormData] = useState({ - name: '', - phone: '', - languagePreference: '', - rucNumber: '', - }); - - useEffect(() => { - loadProfile(); - }, []); - - const loadProfile = async () => { - try { - const res = await dashboardApi.getProfile(); - setProfile(res.profile); - setFormData({ - name: res.profile.name || '', - phone: res.profile.phone || '', - languagePreference: res.profile.languagePreference || '', - rucNumber: res.profile.rucNumber || '', - }); - } catch (error) { - toast.error(language === 'es' ? 'Error al cargar perfil' : 'Failed to load profile'); - } finally { - setLoading(false); - } - }; - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setSaving(true); - - try { - const res = await dashboardApi.updateProfile(formData); - toast.success(language === 'es' ? 'Perfil actualizado' : 'Profile updated'); - - // Update auth context - if (user) { - updateUser({ - ...user, - name: formData.name, - phone: formData.phone, - languagePreference: formData.languagePreference, - rucNumber: formData.rucNumber, - }); - } - - if (onUpdate) onUpdate(); - } catch (error: any) { - toast.error(error.message || (language === 'es' ? 'Error al actualizar' : 'Update failed')); - } finally { - setSaving(false); - } - }; - - if (loading) { - return ( -
-
-
- ); - } - - return ( -
- {/* Account Info Card */} - -

- {language === 'es' ? 'Información de la Cuenta' : 'Account Information'} -

- -
-
- Email - {profile?.email} -
-
- - {language === 'es' ? 'Estado de Cuenta' : 'Account Status'} - - - {profile?.accountStatus === 'active' - ? (language === 'es' ? 'Activo' : 'Active') - : profile?.accountStatus} - -
-
- - {language === 'es' ? 'Miembro Desde' : 'Member Since'} - - - {profile?.memberSince - ? parseDate(profile.memberSince).toLocaleDateString( - language === 'es' ? 'es-ES' : 'en-US', - { year: 'numeric', month: 'long', day: 'numeric', timeZone: 'America/Asuncion' } - ) - : '-'} - -
-
- - {language === 'es' ? 'Días de Membresía' : 'Membership Days'} - - {profile?.membershipDays || 0} -
-
-
- - {/* Edit Profile Form */} - -

- {language === 'es' ? 'Editar Perfil' : 'Edit Profile'} -

- -
- setFormData({ ...formData, name: e.target.value })} - required - /> - - setFormData({ ...formData, phone: e.target.value })} - /> - -
- - -
- - setFormData({ ...formData, rucNumber: e.target.value })} - placeholder={language === 'es' ? 'Opcional' : 'Optional'} - /> -

- {language === 'es' - ? 'Tu número de RUC paraguayo para facturas fiscales' - : 'Your Paraguayan RUC number for fiscal invoices'} -

- -
- -
-
-
- - {/* Email Change Notice */} - -

- {language === 'es' ? 'Cambiar Email' : 'Change Email'} -

-

- {language === 'es' - ? 'Para cambiar tu dirección de email, por favor contacta al soporte.' - : 'To change your email address, please contact support.'} -

- -
-
- ); -} diff --git a/frontend/src/app/(public)/dashboard/components/SecurityTab.tsx b/frontend/src/app/(public)/dashboard/components/SecurityTab.tsx deleted file mode 100644 index 380ed31..0000000 --- a/frontend/src/app/(public)/dashboard/components/SecurityTab.tsx +++ /dev/null @@ -1,382 +0,0 @@ -'use client'; - -import { useState, useEffect } from 'react'; -import { useLanguage } from '@/context/LanguageContext'; -import { useAuth } from '@/context/AuthContext'; -import Card from '@/components/ui/Card'; -import Button from '@/components/ui/Button'; -import Input from '@/components/ui/Input'; -import { dashboardApi, authApi, UserProfile, UserSession } from '@/lib/api'; -import { parseDate } from '@/lib/utils'; -import toast from 'react-hot-toast'; - -export default function SecurityTab() { - const { locale: language } = useLanguage(); - const { logout } = useAuth(); - - const [profile, setProfile] = useState(null); - const [sessions, setSessions] = useState([]); - const [loading, setLoading] = useState(true); - const [changingPassword, setChangingPassword] = useState(false); - const [settingPassword, setSettingPassword] = useState(false); - - const [passwordForm, setPasswordForm] = useState({ - currentPassword: '', - newPassword: '', - confirmPassword: '', - }); - - const [newPasswordForm, setNewPasswordForm] = useState({ - password: '', - confirmPassword: '', - }); - - useEffect(() => { - loadData(); - }, []); - - const loadData = async () => { - try { - const [profileRes, sessionsRes] = await Promise.all([ - dashboardApi.getProfile(), - dashboardApi.getSessions(), - ]); - setProfile(profileRes.profile); - setSessions(sessionsRes.sessions); - } catch (error) { - toast.error(language === 'es' ? 'Error al cargar datos' : 'Failed to load data'); - } finally { - setLoading(false); - } - }; - - const handleChangePassword = async (e: React.FormEvent) => { - e.preventDefault(); - - if (passwordForm.newPassword !== passwordForm.confirmPassword) { - toast.error(language === 'es' ? 'Las contraseñas no coinciden' : 'Passwords do not match'); - return; - } - - if (passwordForm.newPassword.length < 10) { - toast.error(language === 'es' ? 'La contraseña debe tener al menos 10 caracteres' : 'Password must be at least 10 characters'); - return; - } - - setChangingPassword(true); - - try { - await authApi.changePassword(passwordForm.currentPassword, passwordForm.newPassword); - toast.success(language === 'es' ? 'Contraseña actualizada' : 'Password updated'); - setPasswordForm({ currentPassword: '', newPassword: '', confirmPassword: '' }); - } catch (error: any) { - toast.error(error.message || (language === 'es' ? 'Error al cambiar contraseña' : 'Failed to change password')); - } finally { - setChangingPassword(false); - } - }; - - const handleSetPassword = async (e: React.FormEvent) => { - e.preventDefault(); - - if (newPasswordForm.password !== newPasswordForm.confirmPassword) { - toast.error(language === 'es' ? 'Las contraseñas no coinciden' : 'Passwords do not match'); - return; - } - - if (newPasswordForm.password.length < 10) { - toast.error(language === 'es' ? 'La contraseña debe tener al menos 10 caracteres' : 'Password must be at least 10 characters'); - return; - } - - setSettingPassword(true); - - try { - await dashboardApi.setPassword(newPasswordForm.password); - toast.success(language === 'es' ? 'Contraseña establecida' : 'Password set'); - setNewPasswordForm({ password: '', confirmPassword: '' }); - loadData(); // Reload to update profile - } catch (error: any) { - toast.error(error.message || (language === 'es' ? 'Error al establecer contraseña' : 'Failed to set password')); - } finally { - setSettingPassword(false); - } - }; - - const handleUnlinkGoogle = async () => { - if (!confirm(language === 'es' - ? '¿Estás seguro de que quieres desvincular tu cuenta de Google?' - : 'Are you sure you want to unlink your Google account?' - )) { - return; - } - - try { - await dashboardApi.unlinkGoogle(); - toast.success(language === 'es' ? 'Google desvinculado' : 'Google unlinked'); - loadData(); - } catch (error: any) { - toast.error(error.message || (language === 'es' ? 'Error' : 'Failed')); - } - }; - - const handleRevokeSession = async (sessionId: string) => { - try { - await dashboardApi.revokeSession(sessionId); - setSessions(sessions.filter((s) => s.id !== sessionId)); - toast.success(language === 'es' ? 'Sesión cerrada' : 'Session revoked'); - } catch (error) { - toast.error(language === 'es' ? 'Error' : 'Failed'); - } - }; - - const handleRevokeAllSessions = async () => { - if (!confirm(language === 'es' - ? '¿Cerrar todas las sesiones? Serás desconectado.' - : 'Log out of all sessions? You will be logged out.' - )) { - return; - } - - try { - await dashboardApi.revokeAllSessions(); - toast.success(language === 'es' ? 'Todas las sesiones cerradas' : 'All sessions revoked'); - logout(); - } catch (error) { - toast.error(language === 'es' ? 'Error' : 'Failed'); - } - }; - - const formatDate = (dateStr: string) => { - return parseDate(dateStr).toLocaleString(language === 'es' ? 'es-ES' : 'en-US', { - year: 'numeric', - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - timeZone: 'America/Asuncion', - }); - }; - - if (loading) { - return ( -
-
-
- ); - } - - return ( -
- {/* Authentication Methods */} - -

- {language === 'es' ? 'Métodos de Autenticación' : 'Authentication Methods'} -

- -
- {/* Password Status */} -
-
-
- - - -
-
-

- {language === 'es' ? 'Contraseña' : 'Password'} -

-

- {profile?.hasPassword - ? (language === 'es' ? 'Configurada' : 'Set') - : (language === 'es' ? 'No configurada' : 'Not set')} -

-
-
- {profile?.hasPassword && ( - - {language === 'es' ? 'Activo' : 'Active'} - - )} -
- - {/* Google Status */} -
-
-
- - - - - - -
-
-

Google

-

- {profile?.hasGoogleLinked - ? (language === 'es' ? 'Vinculado' : 'Linked') - : (language === 'es' ? 'No vinculado' : 'Not linked')} -

-
-
- {profile?.hasGoogleLinked && profile?.hasPassword && ( - - )} -
-
-
- - {/* Password Management */} - -

- {profile?.hasPassword - ? (language === 'es' ? 'Cambiar Contraseña' : 'Change Password') - : (language === 'es' ? 'Establecer Contraseña' : 'Set Password')} -

- - {profile?.hasPassword ? ( -
- setPasswordForm({ ...passwordForm, currentPassword: e.target.value })} - required - /> - setPasswordForm({ ...passwordForm, newPassword: e.target.value })} - required - /> -

- {language === 'es' ? 'Mínimo 10 caracteres' : 'Minimum 10 characters'} -

- setPasswordForm({ ...passwordForm, confirmPassword: e.target.value })} - required - /> - -
- ) : ( -
-

- {language === 'es' - ? 'Actualmente inicias sesión con Google. Establece una contraseña para más opciones de acceso.' - : 'You currently sign in with Google. Set a password for more access options.'} -

- setNewPasswordForm({ ...newPasswordForm, password: e.target.value })} - required - /> -

- {language === 'es' ? 'Mínimo 10 caracteres' : 'Minimum 10 characters'} -

- setNewPasswordForm({ ...newPasswordForm, confirmPassword: e.target.value })} - required - /> - -
- )} -
- - {/* Active Sessions */} - -
-

- {language === 'es' ? 'Sesiones Activas' : 'Active Sessions'} -

- {sessions.length > 1 && ( - - )} -
- - {sessions.length === 0 ? ( -

- {language === 'es' ? 'No hay sesiones activas' : 'No active sessions'} -

- ) : ( -
- {sessions.map((session, index) => ( -
-
-

- {session.userAgent - ? session.userAgent.substring(0, 50) + (session.userAgent.length > 50 ? '...' : '') - : (language === 'es' ? 'Dispositivo desconocido' : 'Unknown device')} -

-

- {language === 'es' ? 'Última actividad:' : 'Last active:'} {formatDate(session.lastActiveAt)} - {session.ipAddress && ` • ${session.ipAddress}`} -

-
- {index !== 0 && ( - - )} - {index === 0 && ( - - {language === 'es' ? 'Esta sesión' : 'This session'} - - )} -
- ))} -
- )} -
- - {/* Danger Zone */} - -

- {language === 'es' ? 'Zona de Peligro' : 'Danger Zone'} -

-

- {language === 'es' - ? 'Si deseas eliminar tu cuenta, contacta al soporte.' - : 'If you want to delete your account, please contact support.'} -

- -
-
- ); -} diff --git a/frontend/src/app/(public)/dashboard/components/TicketsTab.tsx b/frontend/src/app/(public)/dashboard/components/TicketsTab.tsx index 8e7dee2..2ba2a15 100644 --- a/frontend/src/app/(public)/dashboard/components/TicketsTab.tsx +++ b/frontend/src/app/(public)/dashboard/components/TicketsTab.tsx @@ -1,209 +1,228 @@ 'use client'; -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import Link from 'next/link'; +import { + CalendarIcon, + MapPinIcon, + ArrowDownTrayIcon, + TicketIcon, +} from '@heroicons/react/24/outline'; import Card from '@/components/ui/Card'; import Button from '@/components/ui/Button'; import { UserTicket } from '@/lib/api'; -import { parseDate } from '@/lib/utils'; +import { formatDateLong, parseDate } from '@/lib/utils'; +import { StatusPill, deriveTicketStatus } from './_shared/status'; +import { + groupByBooking, + isUnpaid, + ticketAmount, + ticketPdfUrl, + pyg, + type BookingGroup, +} from './_shared/helpers'; +import PayActions from './_shared/PayActions'; +import QrTicketModal from './_shared/QrTicketModal'; interface TicketsTabProps { tickets: UserTicket[]; language: string; + onChange: () => void; } -export default function TicketsTab({ tickets, language }: TicketsTabProps) { - const [filter, setFilter] = useState<'all' | 'upcoming' | 'past'>('all'); +type Filter = 'all' | 'upcoming' | 'past'; - const now = new Date(); - const filteredTickets = tickets.filter((ticket) => { - if (filter === 'all') return true; - const eventDate = ticket.event?.startDatetime - ? new Date(ticket.event.startDatetime) - : null; - if (filter === 'upcoming') return eventDate && eventDate > now; - if (filter === 'past') return eventDate && eventDate <= now; - return true; - }); +export default function TicketsTab({ tickets, language: locale, onChange }: TicketsTabProps) { + const [filter, setFilter] = useState('all'); + const [qrTickets, setQrTickets] = useState(null); - const formatDate = (dateStr: string) => { - return parseDate(dateStr).toLocaleDateString(language === 'es' ? 'es-ES' : 'en-US', { - year: 'numeric', - month: 'short', - day: 'numeric', - timeZone: 'America/Asuncion', + // Group multi-ticket bookings so both QRs sit together, then filter by the + // group's event date. + const groups = useMemo(() => { + const now = Date.now(); + return groupByBooking(tickets).filter((group) => { + if (filter === 'all') return true; + const start = group.tickets[0].event?.startDatetime; + if (!start) return false; // undated tickets only show under "All" + const isUpcoming = parseDate(start).getTime() > now; + return filter === 'upcoming' ? isUpcoming : !isUpcoming; }); - }; + }, [tickets, filter]); - const formatCurrency = (amount: number, currency: string = 'PYG') => { - if (currency === 'PYG') { - return `${amount.toLocaleString('es-PY')} PYG`; - } - return `$${amount.toFixed(2)} ${currency}`; - }; - - const getStatusBadge = (status: string) => { - const styles: Record = { - confirmed: 'bg-green-100 text-green-800', - checked_in: 'bg-blue-100 text-blue-800', - pending: 'bg-yellow-100 text-yellow-800', - cancelled: 'bg-red-100 text-red-800', - }; - const labels: Record> = { - en: { - confirmed: 'Confirmed', - checked_in: 'Checked In', - pending: 'Pending', - cancelled: 'Cancelled', - }, - es: { - confirmed: 'Confirmado', - checked_in: 'Registrado', - pending: 'Pendiente', - cancelled: 'Cancelado', - }, - }; - return ( - - {labels[language]?.[status] || status} - - ); - }; + const chips: { id: Filter; label: { en: string; es: string } }[] = [ + { id: 'all', label: { en: 'All', es: 'Todas' } }, + { id: 'upcoming', label: { en: 'Upcoming', es: 'Próximas' } }, + { id: 'past', label: { en: 'Past', es: 'Pasadas' } }, + ]; return (
- {/* Filter Buttons */} -
- {(['all', 'upcoming', 'past'] as const).map((f) => ( + {/* Filter chips */} +
+ {chips.map((chip) => ( ))}
- {/* Tickets List */} - {filteredTickets.length === 0 ? ( + {groups.length === 0 ? ( -

- {language === 'es' ? 'No tienes entradas' : 'You have no tickets'} +

+ {locale === 'es' ? 'No tienes entradas' : 'You have no tickets'}

- +
) : (
- {filteredTickets.map((ticket) => ( - -
- {/* Event Image */} - {ticket.event?.bannerUrl && ( -
- {ticket.event.title} -
- )} - - {/* Ticket Info */} -
-
-

- {language === 'es' && ticket.event?.titleEs - ? ticket.event.titleEs - : ticket.event?.title || 'Event'} -

- {getStatusBadge(ticket.status)} -
- -
- {ticket.event?.startDatetime && ( -

- - {language === 'es' ? 'Fecha:' : 'Date:'} - {' '} - {formatDate(ticket.event.startDatetime)} -

- )} - {ticket.event?.location && ( -

- - {language === 'es' ? 'Lugar:' : 'Location:'} - {' '} - {ticket.event.location} -

- )} - {ticket.payment && ( -

- - {language === 'es' ? 'Pago:' : 'Payment:'} - {' '} - {formatCurrency(ticket.payment.amount, ticket.payment.currency)} - - - {ticket.payment.status === 'paid' - ? (language === 'es' ? 'Pagado' : 'Paid') - : (language === 'es' ? 'Pendiente' : 'Pending')} - -

- )} -
-
- - {/* Actions */} -
- - - - {(ticket.status === 'confirmed' || ticket.status === 'checked_in') && ( - - - - )} - {ticket.invoice && ( - - - - )} -
-
-
+ {groups.map((group) => ( + setQrTickets(group.tickets)} + /> ))}
)} + + {qrTickets && ( + setQrTickets(null)} + /> + )}
); } + +function BookingCard({ + group, + locale, + onChange, + onShowQr, +}: { + group: BookingGroup; + locale: string; + onChange: () => void; + onShowQr: () => void; +}) { + const ticket = group.tickets[0]; + const event = ticket.event; + const title = + (locale === 'es' && event?.titleEs ? event.titleEs : event?.title) || 'Event'; + const status = deriveTicketStatus(ticket.status, ticket.payment?.status); + const { amount, currency } = ticketAmount(ticket); + const multi = group.tickets.length > 1; + const ready = status === 'confirmed' || status === 'attended'; + + return ( + +
+ {/* Image */} +
+ {event?.bannerUrl ? ( + {title} + ) : ( +
+ +
+ )} +
+ + {/* Info */} +
+
+
+

+ {title} + {multi && ( + + {locale === 'es' + ? `${group.tickets.length} entradas` + : `${group.tickets.length} tickets`} + + )} +

+
+ +
+ +
+ {event?.startDatetime && ( +

+ + {formatDateLong(event.startDatetime, locale as 'en' | 'es')} +

+ )} + {event?.location && ( +

+ + {event.location} +

+ )} +

+ {pyg(amount, currency)} +

+
+
+ + {/* Actions */} +
+ {isUnpaid(ticket) ? ( + + ) : ( + <> + {ready && ( + + )} + {ready && ( + + + + )} + {ticket.invoice && ( + + + + )} + + )} +
+
+
+ ); +} diff --git a/frontend/src/app/(public)/dashboard/components/_shared/AttentionBanner.tsx b/frontend/src/app/(public)/dashboard/components/_shared/AttentionBanner.tsx new file mode 100644 index 0000000..ca4ec08 --- /dev/null +++ b/frontend/src/app/(public)/dashboard/components/_shared/AttentionBanner.tsx @@ -0,0 +1,93 @@ +'use client'; + +import { + ExclamationTriangleIcon, + CheckCircleIcon, +} from '@heroicons/react/24/outline'; +import type { UserTicket } from '@/lib/api'; +import { useLanguage } from '@/context/LanguageContext'; +import PayActions from './PayActions'; +import { HoldCountdown } from './Countdown'; +import { + holdExpiry, + ticketAmount, + pyg, + isAwaitingApproval, +} from './helpers'; + +/** + * The very-top Overview banner. Shown only when a booking needs attention: + * • unpaid -> red banner naming event + amount, a hold-expiry countdown, + * and the "Pay now" / "I've paid" actions. + * • awaiting -> calm amber "payment received, we will confirm it + * shortly" state with no further action. + */ +export default function AttentionBanner({ + ticket, + locale, + onChange, +}: { + ticket: UserTicket; + locale: string; + onChange: () => void; +}) { + const { t } = useLanguage(); + const eventTitle = + (locale === 'es' && ticket.event?.titleEs + ? ticket.event.titleEs + : ticket.event?.title) || (locale === 'es' ? 'tu evento' : 'your event'); + const { amount, currency } = ticketAmount(ticket); + + if (isAwaitingApproval(ticket)) { + return ( +
+ +
+

+ {t('dashboard.payment.received')} +

+

+ {t('dashboard.payment.confirmForEvent', { + amount: pyg(amount, currency), + event: eventTitle, + })} +

+
+
+ ); + } + + const expiry = holdExpiry(ticket); + + return ( +
+
+ +
+

+ {locale === 'es' + ? `Debes ${pyg(amount, currency)} para ${eventTitle}` + : `You owe ${pyg(amount, currency)} for ${eventTitle}`} +

+ {expiry && ( +

+ +

+ )} +
+
+
+ +
+
+ ); +} diff --git a/frontend/src/app/(public)/dashboard/components/_shared/Countdown.tsx b/frontend/src/app/(public)/dashboard/components/_shared/Countdown.tsx new file mode 100644 index 0000000..5bb4355 --- /dev/null +++ b/frontend/src/app/(public)/dashboard/components/_shared/Countdown.tsx @@ -0,0 +1,54 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +/** + * Live, human countdown to a target time. Renders a short message such as + * "Pay within 23h or your spot is released". Once the target passes it shows a + * calm "expired" message instead of a negative timer. + */ +export function HoldCountdown({ + target, + locale, +}: { + target: Date; + locale: string; +}) { + const [now, setNow] = useState(() => Date.now()); + + useEffect(() => { + const id = setInterval(() => setNow(Date.now()), 60 * 1000); + return () => clearInterval(id); + }, []); + + const msLeft = target.getTime() - now; + + if (msLeft <= 0) { + return ( + + {locale === 'es' + ? 'Paga pronto para mantener tu lugar' + : 'Pay soon to keep your spot'} + + ); + } + + const totalMinutes = Math.floor(msLeft / (60 * 1000)); + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + + let remaining: string; + if (hours >= 1) { + remaining = `${hours}h${minutes > 0 ? ` ${minutes}m` : ''}`; + } else { + remaining = `${minutes}m`; + } + + return ( + + {locale === 'es' + ? `Paga en ${remaining} o tu lugar se libera` + : `Pay within ${remaining} or your spot is released`} + + ); +} diff --git a/frontend/src/app/(public)/dashboard/components/_shared/PayActions.tsx b/frontend/src/app/(public)/dashboard/components/_shared/PayActions.tsx new file mode 100644 index 0000000..0729d3d --- /dev/null +++ b/frontend/src/app/(public)/dashboard/components/_shared/PayActions.tsx @@ -0,0 +1,136 @@ +'use client'; + +import { useState } from 'react'; +import Link from 'next/link'; +import toast from 'react-hot-toast'; +import { CheckCircleIcon } from '@heroicons/react/24/outline'; +import Button from '@/components/ui/Button'; +import { ticketsApi } from '@/lib/api'; +import { useLanguage } from '@/context/LanguageContext'; +import { pyg } from './helpers'; + +interface PayActionsProps { + ticketId: string; + /** Amount owed, used in the confirm copy. */ + amount: number; + currency?: string; + /** Where the money is going — event name or account label. */ + destination: string; + locale: string; + /** Called after the payment is successfully marked as sent. */ + onPaid?: () => void; + size?: 'sm' | 'md' | 'lg'; + /** Stack the two buttons full-width (cards) vs inline (rows). */ + layout?: 'stack' | 'inline'; + className?: string; +} + +/** + * The two unpaid-ticket actions used across Overview, Tickets and Payments: + * • "Pay now" -> opens the existing manual-payment page (TPago / bank details) + * • "I've paid" -> short confirm step, then marks the payment as sent + * (moves the ticket to "Awaiting approval"). + */ +export default function PayActions({ + ticketId, + amount, + currency = 'PYG', + destination, + locale, + onPaid, + size = 'sm', + layout = 'stack', + className, +}: PayActionsProps) { + const { t } = useLanguage(); + const [confirming, setConfirming] = useState(false); + const [marking, setMarking] = useState(false); + + const handleConfirm = async () => { + setMarking(true); + try { + await ticketsApi.markPaymentSent(ticketId); + toast.success(t('dashboard.payment.receivedConfirmSoon')); + setConfirming(false); + onPaid?.(); + } catch (error: any) { + // Idempotent backend: if already marked, treat as success. + if (error?.message?.includes('already')) { + setConfirming(false); + onPaid?.(); + return; + } + toast.error( + error?.message || + (locale === 'es' ? 'No se pudo marcar el pago' : 'Could not mark payment') + ); + } finally { + setMarking(false); + } + }; + + const containerClass = + layout === 'stack' ? 'flex flex-col gap-2' : 'flex flex-wrap gap-2'; + const btnWidth = layout === 'stack' ? 'w-full' : ''; + + return ( + <> +
+ + + + +
+ + {confirming && ( +
!marking && setConfirming(false)} + > +
e.stopPropagation()} + > +
+
+ +
+
+

+ {locale === 'es' ? '¿Confirmar pago?' : 'Confirm payment?'} +

+

+ {locale === 'es' + ? `¿Ya enviaste los ${pyg(amount, currency)} para ${destination}?` + : `Did you already send the ${pyg(amount, currency)} for ${destination}?`} +

+
+ + +
+
+
+ )} + + ); +} diff --git a/frontend/src/app/(public)/dashboard/components/_shared/QrTicketModal.tsx b/frontend/src/app/(public)/dashboard/components/_shared/QrTicketModal.tsx new file mode 100644 index 0000000..2e50728 --- /dev/null +++ b/frontend/src/app/(public)/dashboard/components/_shared/QrTicketModal.tsx @@ -0,0 +1,187 @@ +'use client'; + +import { useEffect } from 'react'; +import { QRCodeSVG } from 'qrcode.react'; +import toast from 'react-hot-toast'; +import { + XMarkIcon, + ArrowDownTrayIcon, + CalendarDaysIcon, + CalendarIcon, + MapPinIcon, + ClockIcon, + UserPlusIcon, +} from '@heroicons/react/24/outline'; +import Button from '@/components/ui/Button'; +import type { UserTicket } from '@/lib/api'; +import { formatDateLong, formatTime } from '@/lib/utils'; +import { + ticketScanUrl, + ticketPdfUrl, + googleCalendarUrl, + shareTicket, +} from './helpers'; + +/** + * Full-screen, scannable QR view for the door. Handles single tickets and + * multi-ticket bookings (one QR per ticket, with "Share with a guest" on the + * spares so guests can be let in with their own code). + */ +export default function QrTicketModal({ + tickets, + locale, + onClose, +}: { + tickets: UserTicket[]; + locale: string; + onClose: () => void; +}) { + useEffect(() => { + const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose(); + document.addEventListener('keydown', onKey); + document.body.style.overflow = 'hidden'; + return () => { + document.removeEventListener('keydown', onKey); + document.body.style.overflow = ''; + }; + }, [onClose]); + + if (tickets.length === 0) return null; + + const first = tickets[0]; + const event = first.event; + const eventTitle = + (locale === 'es' && event?.titleEs ? event.titleEs : event?.title) || 'Event'; + const isMulti = tickets.length > 1; + + const handleShare = async (ticketId: string) => { + const result = await shareTicket(ticketId, eventTitle, locale); + if (result === 'copied') { + toast.success(locale === 'es' ? 'Enlace copiado' : 'Link copied'); + } else if (result === 'failed') { + toast.error(locale === 'es' ? 'No se pudo compartir' : 'Could not share'); + } + }; + + return ( +
+ {/* Top bar */} +
+ + {isMulti + ? locale === 'es' + ? `${tickets.length} entradas` + : `${tickets.length} tickets` + : locale === 'es' + ? 'Tu entrada' + : 'Your ticket'} + + +
+ +
+ {/* Event details */} +
+

{eventTitle}

+ {event && ( +
+

+ + {formatDateLong(event.startDatetime, locale as 'en' | 'es')} +

+

+ + {formatTime(event.startDatetime, locale as 'en' | 'es')} +

+

+ + {event.location} +

+
+ )} +
+ + {/* One QR per ticket */} +
+ {tickets.map((ticket, idx) => { + const attendee = [ticket.attendeeFirstName, ticket.attendeeLastName] + .filter(Boolean) + .join(' '); + const isSpare = isMulti && idx > 0; + return ( +
+ {isMulti && ( +

+ {locale === 'es' ? 'Entrada' : 'Ticket'} {idx + 1} + {attendee ? ` • ${attendee}` : ''} + {isSpare + ? ` • ${locale === 'es' ? 'Invitado' : 'Guest'}` + : ''} +

+ )} +
+ +
+

+ {ticket.qrCode} +

+ + {isSpare && ( + + )} +
+ ); + })} +
+ + {/* Download + calendar */} + +
+
+ ); +} diff --git a/frontend/src/app/(public)/dashboard/components/_shared/helpers.ts b/frontend/src/app/(public)/dashboard/components/_shared/helpers.ts new file mode 100644 index 0000000..b1782fc --- /dev/null +++ b/frontend/src/app/(public)/dashboard/components/_shared/helpers.ts @@ -0,0 +1,148 @@ +import type { UserTicket, Event } from '@/lib/api'; +import { formatPrice, parseDate, EVENT_TIMEZONE } from '@/lib/utils'; + +// Hours a booking holds a seat before the spot is released. There is no +// server-side enforcement field, so the countdown is derived from when the +// ticket was created, capped at the event start time. +export const PAYMENT_HOLD_HOURS = 24; + +/** Currency is Guarani with no decimals, e.g. "21 PYG". */ +export function pyg(amount: number, currency: string = 'PYG'): string { + return formatPrice(Number(amount) || 0, currency || 'PYG'); +} + +export function isManualProvider(provider?: string): boolean { + return provider === 'bank_transfer' || provider === 'tpago'; +} + +/** A ticket is unpaid when its payment is still pending (not approved/paid). */ +export function isUnpaid(ticket: Pick & { payment?: { status?: string } | null }): boolean { + const ps = ticket.payment?.status; + return ticket.status !== 'cancelled' && (ps === 'pending' || ps === 'failed'); +} + +export function isAwaitingApproval(ticket: { payment?: { status?: string } | null }): boolean { + return ticket.payment?.status === 'pending_approval'; +} + +/** + * The moment the seat hold expires. null when paid/awaiting/cancelled or when + * the data needed to compute it is missing. + */ +export function holdExpiry(ticket: UserTicket): Date | null { + if (!isUnpaid(ticket)) return null; + const createdStr = ticket.payment?.createdAt || ticket.createdAt; + if (!createdStr) return null; + const created = parseDate(createdStr); + let expiry = new Date(created.getTime() + PAYMENT_HOLD_HOURS * 60 * 60 * 1000); + // Never hold past the event itself. + const eventStart = ticket.event?.startDatetime ? parseDate(ticket.event.startDatetime) : null; + if (eventStart && eventStart < expiry) expiry = eventStart; + return expiry; +} + +/** Amount owed for a ticket (payment amount preferred, falls back to event price). */ +export function ticketAmount(ticket: UserTicket): { amount: number; currency: string } { + const amount = ticket.payment?.amount ?? ticket.event?.price ?? 0; + const currency = ticket.payment?.currency ?? ticket.event?.currency ?? 'PYG'; + return { amount: Number(amount) || 0, currency }; +} + +export interface BookingGroup { + bookingId: string; + tickets: UserTicket[]; +} + +/** + * Group tickets that belong to the same booking (multi-ticket / guest tickets). + * Tickets without a bookingId become single-ticket groups keyed by their id. + * Order of first appearance is preserved. + */ +export function groupByBooking(tickets: UserTicket[]): BookingGroup[] { + const groups = new Map(); + const order: string[] = []; + for (const ticket of tickets) { + const key = ticket.bookingId || ticket.id; + if (!groups.has(key)) { + groups.set(key, []); + order.push(key); + } + groups.get(key)!.push(ticket); + } + return order.map((bookingId) => ({ bookingId, tickets: groups.get(bookingId)! })); +} + +/** Public URL embedded in the scannable QR (matches the PDF + scanner contract). */ +export function ticketScanUrl(ticketId: string): string { + const origin = typeof window !== 'undefined' ? window.location.origin : ''; + return `${origin}/ticket/${ticketId}`; +} + +/** Download URL for a ticket / whole booking PDF. */ +export function ticketPdfUrl(ticket: UserTicket): string { + return ticket.bookingId + ? `/api/tickets/booking/${ticket.bookingId}/pdf` + : `/api/tickets/${ticket.id}/pdf`; +} + +/** Build a Google Calendar "add event" URL — no backend needed. */ +export function googleCalendarUrl(event: Event, locale: string): string { + const start = parseDate(event.startDatetime); + const end = event.endDatetime + ? parseDate(event.endDatetime) + : new Date(start.getTime() + 2 * 60 * 60 * 1000); + const fmt = (d: Date) => d.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, ''); + const title = (locale === 'es' && event.titleEs ? event.titleEs : event.title) || 'Spanglish'; + const params = new URLSearchParams({ + action: 'TEMPLATE', + text: title, + dates: `${fmt(start)}/${fmt(end)}`, + location: event.location || '', + ctz: EVENT_TIMEZONE, + }); + return `https://www.google.com/calendar/render?${params.toString()}`; +} + +/** + * Share a single (spare) ticket's QR with a guest. Uses the Web Share API when + * available, otherwise copies the ticket link to the clipboard. + */ +export async function shareTicket( + ticketId: string, + eventTitle: string, + locale: string +): Promise<'shared' | 'copied' | 'failed'> { + const url = ticketScanUrl(ticketId); + const text = + locale === 'es' + ? `Tu entrada para ${eventTitle}` + : `Your ticket for ${eventTitle}`; + try { + if (typeof navigator !== 'undefined' && (navigator as any).share) { + await (navigator as any).share({ title: eventTitle, text, url }); + return 'shared'; + } + if (typeof navigator !== 'undefined' && navigator.clipboard) { + await navigator.clipboard.writeText(url); + return 'copied'; + } + return 'failed'; + } catch (err: any) { + // User cancelled the native share sheet. + if (err?.name === 'AbortError') return 'shared'; + return 'failed'; + } +} + +/** True when the event's calendar date is today (Asunción time). */ +export function isToday(dateStr?: string): boolean { + if (!dateStr) return false; + const opts: Intl.DateTimeFormatOptions = { + year: 'numeric', + month: '2-digit', + day: '2-digit', + timeZone: EVENT_TIMEZONE, + }; + const fmt = (d: Date) => d.toLocaleDateString('en-CA', opts); + return fmt(parseDate(dateStr)) === fmt(new Date()); +} diff --git a/frontend/src/app/(public)/dashboard/components/_shared/status.tsx b/frontend/src/app/(public)/dashboard/components/_shared/status.tsx new file mode 100644 index 0000000..f24a5a6 --- /dev/null +++ b/frontend/src/app/(public)/dashboard/components/_shared/status.tsx @@ -0,0 +1,95 @@ +'use client'; + +import clsx from 'clsx'; +import type { UserTicket, Payment } from '@/lib/api'; + +// One logical status per meaning, mapped to a single pale colour everywhere. +// confirmed -> pale green +// awaiting -> pale amber (user said they paid, admin checking) +// unpaid -> pale red +// attended -> pale blue (replaces the raw "checked_in" value) +// cancelled -> pale gray +export type DashStatus = + | 'confirmed' + | 'awaiting' + | 'unpaid' + | 'attended' + | 'cancelled'; + +/** + * Collapse a ticket status + payment status into a single user-facing status. + * Never surface raw values like "checked_in" or "pending_approval". + */ +export function deriveTicketStatus( + ticketStatus?: string, + paymentStatus?: string +): DashStatus { + if (ticketStatus === 'checked_in') return 'attended'; + if (ticketStatus === 'cancelled') return 'cancelled'; + if (paymentStatus === 'paid' || ticketStatus === 'confirmed') return 'confirmed'; + if (paymentStatus === 'pending_approval') return 'awaiting'; + return 'unpaid'; +} + +/** Status for a standalone payment record (Payments tab). */ +export function derivePaymentStatus(paymentStatus?: string): DashStatus { + if (paymentStatus === 'paid') return 'confirmed'; + if (paymentStatus === 'pending_approval') return 'awaiting'; + if (paymentStatus === 'refunded') return 'cancelled'; + return 'unpaid'; +} + +export function statusLabel(status: DashStatus, locale: string): string { + const labels: Record = { + confirmed: { en: 'Confirmed', es: 'Confirmado' }, + awaiting: { en: 'Awaiting approval', es: 'Esperando aprobación' }, + unpaid: { en: 'Unpaid', es: 'No pagado' }, + attended: { en: 'Attended', es: 'Asistió' }, + cancelled: { en: 'Cancelled', es: 'Cancelado' }, + }; + return locale === 'es' ? labels[status].es : labels[status].en; +} + +const PILL_STYLES: Record = { + confirmed: 'bg-green-100 text-green-800', + awaiting: 'bg-amber-100 text-amber-800', + unpaid: 'bg-red-100 text-red-700', + attended: 'bg-blue-100 text-blue-800', + cancelled: 'bg-gray-100 text-gray-600', +}; + +export function StatusPill({ + status, + locale, + className, +}: { + status: DashStatus; + locale: string; + className?: string; +}) { + return ( + + {statusLabel(status, locale)} + + ); +} + +/** Convenience: derive + render a pill straight from a ticket. */ +export function TicketStatusPill({ + ticket, + locale, + className, +}: { + ticket: Pick & { payment?: Pick | null }; + locale: string; + className?: string; +}) { + const status = deriveTicketStatus(ticket.status, ticket.payment?.status); + return ; +} diff --git a/frontend/src/app/(public)/dashboard/page.tsx b/frontend/src/app/(public)/dashboard/page.tsx index 3a16e1f..db57175 100644 --- a/frontend/src/app/(public)/dashboard/page.tsx +++ b/frontend/src/app/(public)/dashboard/page.tsx @@ -4,34 +4,27 @@ import { useState, useEffect } from 'react'; import { useRouter } from 'next/navigation'; import { useLanguage } from '@/context/LanguageContext'; import { useAuth } from '@/context/AuthContext'; -import Card from '@/components/ui/Card'; -import Button from '@/components/ui/Button'; -import { dashboardApi, DashboardSummary, NextEventInfo, UserTicket, UserPayment } from '@/lib/api'; -import { formatDateLong, formatTime } from '@/lib/utils'; +import { + dashboardApi, + NextEventInfo, + UserTicket, + UserPayment, +} from '@/lib/api'; import toast from 'react-hot-toast'; -import Link from 'next/link'; -import { - socialConfig, - getWhatsAppUrl, - getInstagramUrl, - getTelegramUrl -} from '@/lib/socialLinks'; -// Tab components +import OverviewTab from './components/OverviewTab'; import TicketsTab from './components/TicketsTab'; import PaymentsTab from './components/PaymentsTab'; -import ProfileTab from './components/ProfileTab'; -import SecurityTab from './components/SecurityTab'; +import AccountTab from './components/AccountTab'; -type Tab = 'overview' | 'tickets' | 'payments' | 'profile' | 'security'; +type Tab = 'overview' | 'tickets' | 'payments' | 'account'; export default function DashboardPage() { const router = useRouter(); - const { t, locale: language } = useLanguage(); + const { locale } = useLanguage(); const { user, isLoading: authLoading, token } = useAuth(); - + const [activeTab, setActiveTab] = useState('overview'); - const [summary, setSummary] = useState(null); const [nextEvent, setNextEvent] = useState(null); const [tickets, setTickets] = useState([]); const [payments, setPayments] = useState([]); @@ -42,369 +35,99 @@ export default function DashboardPage() { router.push('/login'); return; } - if (user && token) { loadDashboardData(); } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [user, authLoading, token]); const loadDashboardData = async () => { setLoading(true); try { - const [summaryRes, nextEventRes, ticketsRes, paymentsRes] = await Promise.all([ - dashboardApi.getSummary(), + const [nextEventRes, ticketsRes, paymentsRes] = await Promise.all([ dashboardApi.getNextEvent(), dashboardApi.getTickets(), dashboardApi.getPayments(), ]); - - setSummary(summaryRes.summary); setNextEvent(nextEventRes.nextEvent); setTickets(ticketsRes.tickets); setPayments(paymentsRes.payments); - } catch (error: any) { + } catch (error) { console.error('Failed to load dashboard:', error); - toast.error('Failed to load dashboard data'); + toast.error(locale === 'es' ? 'Error al cargar el panel' : 'Failed to load dashboard data'); } finally { setLoading(false); } }; - const tabs: { id: Tab; label: string }[] = [ - { id: 'overview', label: language === 'es' ? 'Resumen' : 'Overview' }, - { id: 'tickets', label: language === 'es' ? 'Mis Entradas' : 'My Tickets' }, - { id: 'payments', label: language === 'es' ? 'Pagos y Facturas' : 'Payments & Invoices' }, - { id: 'profile', label: language === 'es' ? 'Perfil' : 'Profile' }, - { id: 'security', label: language === 'es' ? 'Seguridad' : 'Security' }, + const tabs: { id: Tab; label: { en: string; es: string } }[] = [ + { id: 'overview', label: { en: 'Overview', es: 'Resumen' } }, + { id: 'tickets', label: { en: 'Tickets', es: 'Entradas' } }, + { id: 'payments', label: { en: 'Payments', es: 'Pagos' } }, + { id: 'account', label: { en: 'Account', es: 'Cuenta' } }, ]; if (authLoading || !user) { return ( -
-
+
+
); } - const formatDate = (dateStr: string) => formatDateLong(dateStr, language as 'en' | 'es'); - const fmtTime = (dateStr: string) => formatTime(dateStr, language as 'en' | 'es'); - return (
- {/* Welcome Header */} -
-

- {language === 'es' ? `Hola, ${user.name}!` : `Welcome, ${user.name}!`} -

- {summary && ( -

- {language === 'es' - ? `Miembro desde hace ${summary.user.membershipDays} días` - : `Member for ${summary.user.membershipDays} days` - } -

- )} -
+ {/* Welcome header */} +

+ {locale === 'es' ? `¡Hola, ${user.name}!` : `Welcome, ${user.name}!`} +

- {/* Tab Navigation */} -
-
); } - -// Overview Tab Component -function OverviewTab({ - summary, - nextEvent, - tickets, - language, - formatDate, - formatTime, -}: { - summary: DashboardSummary | null; - nextEvent: NextEventInfo | null; - tickets: UserTicket[]; - language: string; - formatDate: (date: string) => string; - formatTime: (date: string) => string; -}) { - return ( -
- {/* Stats Cards */} - {summary && ( -
- -
{summary.stats.totalTickets}
-
- {language === 'es' ? 'Total Entradas' : 'Total Tickets'} -
-
- -
{summary.stats.confirmedTickets}
-
- {language === 'es' ? 'Confirmadas' : 'Confirmed'} -
-
- -
{summary.stats.upcomingEvents}
-
- {language === 'es' ? 'Próximos' : 'Upcoming'} -
-
- -
{summary.stats.pendingPayments}
-
- {language === 'es' ? 'Pagos Pendientes' : 'Pending Payments'} -
-
-
- )} - - {/* Next Event Card */} - {nextEvent ? ( - -

- {language === 'es' ? 'Tu Próximo Evento' : 'Your Next Event'} -

-
- {nextEvent.event.bannerUrl && ( -
- {nextEvent.event.title} -
- )} -
-

- {language === 'es' && nextEvent.event.titleEs - ? nextEvent.event.titleEs - : nextEvent.event.title} -

-
-

- - {language === 'es' ? 'Fecha:' : 'Date:'} - {' '} - {formatDate(nextEvent.event.startDatetime)} -

-

- - {language === 'es' ? 'Hora:' : 'Time:'} - {' '} - {formatTime(nextEvent.event.startDatetime)} -

-

- - {language === 'es' ? 'Lugar:' : 'Location:'} - {' '} - {nextEvent.event.location} -

-

- - {language === 'es' ? 'Estado:' : 'Status:'} - {' '} - - {nextEvent.payment?.status === 'paid' - ? (language === 'es' ? 'Pagado' : 'Paid') - : (language === 'es' ? 'Pendiente' : 'Pending')} - -

-
-
- - - - {nextEvent.event.locationUrl && ( - - - - )} -
-
-
-
- ) : ( - -

- {language === 'es' - ? 'No tienes eventos próximos' - : 'You have no upcoming events'} -

- - - -
- )} - - {/* Recent Tickets */} - {tickets.length > 0 && ( - -

- {language === 'es' ? 'Entradas Recientes' : 'Recent Tickets'} -

-
- {tickets.slice(0, 3).map((ticket) => ( -
-
-

- {language === 'es' && ticket.event?.titleEs - ? ticket.event.titleEs - : ticket.event?.title || 'Event'} -

-

- {ticket.event?.startDatetime - ? formatDate(ticket.event.startDatetime) - : ''} -

-
- - {ticket.status} - -
- ))} -
- {tickets.length > 3 && ( -
- -
- )} -
- )} - - {/* Community Links */} - -

- {language === 'es' ? 'Comunidad' : 'Community'} -

-
- {getWhatsAppUrl(socialConfig.whatsapp) && ( - -
- - - -
- WhatsApp -
- )} - {getInstagramUrl(socialConfig.instagram) && ( - -
- - - -
- Instagram -
- )} - {getTelegramUrl(socialConfig.telegram) && ( - -
- - - -
- Telegram -
- )} -
-
-
- ); -} diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index d728c65..35feb62 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -164,6 +164,14 @@ "backHome": "Back to Home" } }, + "dashboard": { + "payment": { + "received": "Payment received", + "receivedConfirmShortly": "Payment received. We will confirm it shortly.", + "receivedConfirmSoon": "Payment received. We will confirm it shortly.", + "confirmForEvent": "We will confirm your {amount} payment for {event} shortly. Nothing else needed." + } + }, "community": { "title": "Join Our Community", "subtitle": "Connect with us on social media and stay updated", diff --git a/frontend/src/i18n/locales/es.json b/frontend/src/i18n/locales/es.json index c85409a..6953e89 100644 --- a/frontend/src/i18n/locales/es.json +++ b/frontend/src/i18n/locales/es.json @@ -164,6 +164,14 @@ "backHome": "Volver al Inicio" } }, + "dashboard": { + "payment": { + "received": "Pago recibido", + "receivedConfirmShortly": "Pago recibido. Lo confirmaremos en breve.", + "receivedConfirmSoon": "Pago recibido. Lo confirmaremos pronto.", + "confirmForEvent": "Confirmaremos tu pago de {amount} para {event} en breve. No necesitas hacer nada más." + } + }, "community": { "title": "Únete a Nuestra Comunidad", "subtitle": "Conéctate con nosotros en redes sociales",