Merge pull request 'Redesign user dashboard with overview tab and i18n payment copy.' (#24) from dashboard-update into main
Reviewed-on: #24
This commit was merged in pull request #24.
This commit is contained in:
@@ -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<UserProfile | null>(null);
|
||||
const [sessions, setSessions] = useState<UserSession[]>([]);
|
||||
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 (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-primary-yellow" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isActive = profile?.accountStatus === 'active';
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl space-y-6">
|
||||
{/* Account info */}
|
||||
<Card className="p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">
|
||||
{locale === 'es' ? 'Información de la cuenta' : 'Account information'}
|
||||
</h3>
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="flex justify-between border-b py-2">
|
||||
<span className="text-gray-600">{locale === 'es' ? 'Correo' : 'Email'}</span>
|
||||
<span className="font-medium">{profile?.email}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-b py-2">
|
||||
<span className="text-gray-600">{locale === 'es' ? 'Estado' : 'Status'}</span>
|
||||
<span
|
||||
className={`inline-flex rounded-full px-3 py-1 text-xs font-medium ${
|
||||
isActive ? 'bg-green-100 text-green-800' : 'bg-amber-100 text-amber-800'
|
||||
}`}
|
||||
>
|
||||
{isActive
|
||||
? locale === 'es'
|
||||
? 'Activa'
|
||||
: 'Active'
|
||||
: profile?.accountStatus}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between py-2">
|
||||
<span className="text-gray-600">{locale === 'es' ? 'Miembro desde' : 'Member since'}</span>
|
||||
<span className="font-medium">
|
||||
{profile?.memberSince
|
||||
? parseDate(profile.memberSince).toLocaleDateString(
|
||||
locale === 'es' ? 'es-ES' : 'en-US',
|
||||
{ year: 'numeric', month: 'long', day: 'numeric', timeZone: 'America/Asuncion' }
|
||||
)
|
||||
: '-'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Edit profile */}
|
||||
<Card className="p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">
|
||||
{locale === 'es' ? 'Editar perfil' : 'Edit profile'}
|
||||
</h3>
|
||||
<form onSubmit={handleSaveProfile} className="space-y-4">
|
||||
<Input
|
||||
id="name"
|
||||
label={locale === 'es' ? 'Nombre' : 'Name'}
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
id="phone"
|
||||
type="tel"
|
||||
label={locale === 'es' ? 'Teléfono' : 'Phone'}
|
||||
value={formData.phone}
|
||||
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
|
||||
/>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-primary-dark">
|
||||
{locale === 'es' ? 'Idioma preferido' : 'Preferred language'}
|
||||
</label>
|
||||
<select
|
||||
value={formData.languagePreference}
|
||||
onChange={(e) => setFormData({ ...formData, languagePreference: e.target.value })}
|
||||
className="w-full rounded-btn border border-secondary-light-gray px-4 py-3 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
>
|
||||
<option value="">{locale === 'es' ? 'Seleccionar' : 'Select'}</option>
|
||||
<option value="en">English</option>
|
||||
<option value="es">Español</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Input
|
||||
id="rucNumber"
|
||||
label={locale === 'es' ? 'Número de RUC (para facturas)' : 'RUC number (for invoices)'}
|
||||
value={formData.rucNumber}
|
||||
onChange={(e) => setFormData({ ...formData, rucNumber: e.target.value })}
|
||||
placeholder={locale === 'es' ? 'Opcional' : 'Optional'}
|
||||
/>
|
||||
<p className="mt-1.5 text-xs text-gray-500">
|
||||
{locale === 'es'
|
||||
? 'Tu número de RUC paraguayo para facturas fiscales'
|
||||
: 'Your Paraguayan RUC number for fiscal invoices'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="pt-2">
|
||||
<Button type="submit" isLoading={saving}>
|
||||
{locale === 'es' ? 'Guardar cambios' : 'Save changes'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
<p className="mt-4 border-t pt-4 text-xs text-gray-500">
|
||||
{locale === 'es'
|
||||
? 'Para cambiar tu correo, contacta al soporte.'
|
||||
: 'To change your email, please contact support.'}
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
{/* Authentication methods */}
|
||||
<Card className="p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">
|
||||
{locale === 'es' ? 'Métodos de autenticación' : 'Authentication methods'}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between rounded-card bg-secondary-gray p-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`flex h-10 w-10 items-center justify-center rounded-full ${
|
||||
profile?.hasPassword ? 'bg-green-100 text-green-600' : 'bg-gray-200 text-gray-500'
|
||||
}`}
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{locale === 'es' ? 'Contraseña' : 'Password'}</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{profile?.hasPassword
|
||||
? locale === 'es' ? 'Configurada' : 'Set'
|
||||
: locale === 'es' ? 'No configurada' : 'Not set'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-card bg-secondary-gray p-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`flex h-10 w-10 items-center justify-center rounded-full ${
|
||||
profile?.hasGoogleLinked ? 'bg-red-100 text-red-600' : 'bg-gray-200 text-gray-500'
|
||||
}`}
|
||||
>
|
||||
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" />
|
||||
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" />
|
||||
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" />
|
||||
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Google</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{profile?.hasGoogleLinked
|
||||
? locale === 'es' ? 'Vinculado' : 'Linked'
|
||||
: locale === 'es' ? 'No vinculado' : 'Not linked'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{profile?.hasGoogleLinked && profile?.hasPassword && (
|
||||
<Button variant="outline" size="sm" onClick={handleUnlinkGoogle}>
|
||||
{locale === 'es' ? 'Desvincular' : 'Unlink'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Password management */}
|
||||
<Card className="p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">
|
||||
{profile?.hasPassword
|
||||
? locale === 'es' ? 'Cambiar contraseña' : 'Change password'
|
||||
: locale === 'es' ? 'Establecer contraseña' : 'Set password'}
|
||||
</h3>
|
||||
{profile?.hasPassword ? (
|
||||
<form onSubmit={handleChangePassword} className="space-y-4">
|
||||
<Input
|
||||
id="currentPassword"
|
||||
type="password"
|
||||
label={locale === 'es' ? 'Contraseña actual' : 'Current password'}
|
||||
value={passwordForm.currentPassword}
|
||||
onChange={(e) => setPasswordForm({ ...passwordForm, currentPassword: e.target.value })}
|
||||
required
|
||||
/>
|
||||
<div>
|
||||
<Input
|
||||
id="newPassword"
|
||||
type="password"
|
||||
label={locale === 'es' ? 'Nueva contraseña' : 'New password'}
|
||||
value={passwordForm.newPassword}
|
||||
onChange={(e) => setPasswordForm({ ...passwordForm, newPassword: e.target.value })}
|
||||
required
|
||||
/>
|
||||
<p className="mt-1.5 text-xs text-gray-500">
|
||||
{locale === 'es' ? 'Mínimo 10 caracteres' : 'Minimum 10 characters'}
|
||||
</p>
|
||||
</div>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
label={locale === 'es' ? 'Confirmar contraseña' : 'Confirm password'}
|
||||
value={passwordForm.confirmPassword}
|
||||
onChange={(e) => setPasswordForm({ ...passwordForm, confirmPassword: e.target.value })}
|
||||
required
|
||||
/>
|
||||
<Button type="submit" isLoading={changingPassword}>
|
||||
{locale === 'es' ? 'Cambiar contraseña' : 'Change password'}
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={handleSetPassword} className="space-y-4">
|
||||
<p className="mb-2 text-sm text-gray-600">
|
||||
{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.'}
|
||||
</p>
|
||||
<div>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
label={locale === 'es' ? 'Nueva contraseña' : 'New password'}
|
||||
value={newPasswordForm.password}
|
||||
onChange={(e) => setNewPasswordForm({ ...newPasswordForm, password: e.target.value })}
|
||||
required
|
||||
/>
|
||||
<p className="mt-1.5 text-xs text-gray-500">
|
||||
{locale === 'es' ? 'Mínimo 10 caracteres' : 'Minimum 10 characters'}
|
||||
</p>
|
||||
</div>
|
||||
<Input
|
||||
id="confirmNewPassword"
|
||||
type="password"
|
||||
label={locale === 'es' ? 'Confirmar contraseña' : 'Confirm password'}
|
||||
value={newPasswordForm.confirmPassword}
|
||||
onChange={(e) => setNewPasswordForm({ ...newPasswordForm, confirmPassword: e.target.value })}
|
||||
required
|
||||
/>
|
||||
<Button type="submit" isLoading={settingPassword}>
|
||||
{locale === 'es' ? 'Establecer contraseña' : 'Set password'}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Active sessions */}
|
||||
<Card className="p-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">
|
||||
{locale === 'es' ? 'Sesiones activas' : 'Active sessions'}
|
||||
</h3>
|
||||
{sessions.length > 1 && (
|
||||
<Button variant="outline" size="sm" onClick={handleRevokeAllSessions}>
|
||||
{locale === 'es' ? 'Cerrar todas' : 'Log out all'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{sessions.length === 0 ? (
|
||||
<p className="text-sm text-gray-600">
|
||||
{locale === 'es' ? 'No hay sesiones activas' : 'No active sessions'}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{sessions.map((session, index) => (
|
||||
<div
|
||||
key={session.id}
|
||||
className="flex items-center justify-between rounded-card bg-secondary-gray p-3"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium">
|
||||
{session.userAgent
|
||||
? session.userAgent.substring(0, 50) + (session.userAgent.length > 50 ? '…' : '')
|
||||
: locale === 'es' ? 'Dispositivo desconocido' : 'Unknown device'}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{locale === 'es' ? 'Última actividad:' : 'Last active:'}{' '}
|
||||
{formatDateTime(session.lastActiveAt)}
|
||||
{session.ipAddress && ` • ${session.ipAddress}`}
|
||||
</p>
|
||||
</div>
|
||||
{index === 0 ? (
|
||||
<span className="ml-3 whitespace-nowrap text-xs font-medium text-green-600">
|
||||
{locale === 'es' ? 'Esta sesión' : 'This session'}
|
||||
</span>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" onClick={() => handleRevokeSession(session.id)}>
|
||||
{locale === 'es' ? 'Cerrar' : 'Revoke'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Danger zone */}
|
||||
<Card className="border border-red-200 bg-red-50 p-6">
|
||||
<h3 className="mb-2 text-lg font-semibold text-red-800">
|
||||
{locale === 'es' ? 'Zona de peligro' : 'Danger zone'}
|
||||
</h3>
|
||||
<p className="mb-4 text-sm text-red-700">
|
||||
{locale === 'es'
|
||||
? 'Si deseas eliminar tu cuenta, contacta al soporte.'
|
||||
: 'If you want to delete your account, please contact support.'}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-red-300 text-red-700 hover:bg-red-100"
|
||||
disabled
|
||||
>
|
||||
{locale === 'es' ? 'Eliminar cuenta' : 'Delete account'}
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<UserTicket[] | null>(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<BookingGroup | null>(() => {
|
||||
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<BookingGroup[]>(() => {
|
||||
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 (
|
||||
<Card className="p-8 text-center">
|
||||
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-primary-yellow/15">
|
||||
<TicketIcon className="h-7 w-7 text-primary-yellow" />
|
||||
</div>
|
||||
<h2 className="mb-1 text-xl font-semibold">
|
||||
{locale === 'es' ? `¡Hola, ${userName}!` : `Welcome, ${userName}!`}
|
||||
</h2>
|
||||
<p className="mx-auto mb-6 max-w-sm text-gray-600">
|
||||
{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."}
|
||||
</p>
|
||||
<Link href="/events">
|
||||
<Button size="lg">
|
||||
{locale === 'es' ? 'Explorar eventos' : 'Browse events'}
|
||||
</Button>
|
||||
</Link>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 1 — Attention banner (only when money is owed / awaiting). */}
|
||||
{attentionTicket && (
|
||||
<AttentionBanner
|
||||
ticket={attentionTicket}
|
||||
locale={locale}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 2 — Next event hero. */}
|
||||
{heroGroup && heroGroup.tickets[0].event && (
|
||||
<HeroCard
|
||||
group={heroGroup}
|
||||
locale={locale}
|
||||
onChange={onChange}
|
||||
onShowQr={() => setQrTickets(heroGroup.tickets)}
|
||||
onShare={handleShare}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 3 — Also coming up. */}
|
||||
{upcomingGroups.length > 0 && (
|
||||
<Card className="p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">
|
||||
{locale === 'es' ? 'También se viene' : 'Also coming up'}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{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 (
|
||||
<div
|
||||
key={group.bookingId}
|
||||
className="flex flex-col gap-3 rounded-card bg-secondary-gray p-4 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="truncate font-medium">{title}</p>
|
||||
{group.tickets.length > 1 && (
|
||||
<span className="text-xs text-gray-500">
|
||||
×{group.tickets.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-0.5 text-sm text-gray-600">
|
||||
{t.event && formatDateLong(t.event.startDatetime, locale as 'en' | 'es')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<StatusPill status={status} locale={locale} />
|
||||
{isUnpaid(t) ? (
|
||||
<PayActions
|
||||
ticketId={t.id}
|
||||
amount={amount}
|
||||
currency={currency}
|
||||
destination={title}
|
||||
locale={locale}
|
||||
onPaid={onChange}
|
||||
layout="inline"
|
||||
size="sm"
|
||||
/>
|
||||
) : (
|
||||
(status === 'confirmed' || status === 'attended') && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setQrTickets(group.tickets)}
|
||||
>
|
||||
{locale === 'es' ? 'Ver entrada' : 'View ticket'}
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 6 — Browse events. */}
|
||||
<div className="flex justify-center pt-2">
|
||||
<Link href="/events">
|
||||
<Button variant="outline" size="lg">
|
||||
{locale === 'es' ? 'Explorar eventos' : 'Browse events'}
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{qrTickets && (
|
||||
<QrTicketModal
|
||||
tickets={qrTickets}
|
||||
locale={locale}
|
||||
onClose={() => setQrTickets(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Card className="overflow-hidden">
|
||||
{/* Image with status pill + today badge overlay. */}
|
||||
<div className="relative h-44 w-full bg-secondary-gray sm:h-56">
|
||||
{event.bannerUrl ? (
|
||||
<img
|
||||
src={event.bannerUrl}
|
||||
alt={title}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<TicketIcon className="h-12 w-12 text-gray-300" />
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute right-3 top-3 flex items-center gap-2">
|
||||
{today && (
|
||||
<span className="rounded-full bg-primary-yellow px-3 py-1 text-xs font-semibold text-primary-dark shadow-sm">
|
||||
{locale === 'es' ? 'Hoy' : 'Today'}
|
||||
</span>
|
||||
)}
|
||||
<StatusPill status={status} locale={locale} className="shadow-sm" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<p className="mb-1 text-xs font-semibold uppercase tracking-wider text-primary-yellow">
|
||||
{locale === 'es' ? 'Tu próximo evento' : 'Your next event'}
|
||||
</p>
|
||||
<div className="mb-4 flex items-start justify-between gap-3">
|
||||
<h3 className="text-2xl font-bold leading-tight">{title}</h3>
|
||||
{multi && (
|
||||
<span className="whitespace-nowrap rounded-full bg-secondary-gray px-3 py-1 text-sm font-medium text-gray-700">
|
||||
{locale === 'es'
|
||||
? `${group.tickets.length} entradas`
|
||||
: `${group.tickets.length} tickets`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-5 space-y-2 text-gray-600">
|
||||
<p className="flex items-center gap-3">
|
||||
<CalendarIcon className="h-5 w-5 text-primary-yellow" />
|
||||
{formatDateLong(event.startDatetime, locale as 'en' | 'es')}
|
||||
</p>
|
||||
<p className="flex items-center gap-3">
|
||||
<ClockIcon className="h-5 w-5 text-primary-yellow" />
|
||||
{formatTime(event.startDatetime, locale as 'en' | 'es')}
|
||||
</p>
|
||||
<p className="flex items-center gap-3">
|
||||
<MapPinIcon className="h-5 w-5 text-primary-yellow" />
|
||||
<span className="flex-1">{event.location}</span>
|
||||
{event.locationUrl && (
|
||||
<a
|
||||
href={event.locationUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 whitespace-nowrap text-sm font-medium text-secondary-blue hover:underline"
|
||||
>
|
||||
{locale === 'es' ? 'Cómo llegar' : 'Get directions'}
|
||||
<ArrowTopRightOnSquareIcon className="h-4 w-4" />
|
||||
</a>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Today + ready → show the QR prominently for check-in. */}
|
||||
{today && showQrReady && (
|
||||
<div className="mb-4 rounded-card bg-primary-yellow/10 p-4 text-center">
|
||||
<p className="mb-3 text-sm font-medium text-primary-dark">
|
||||
{locale === 'es'
|
||||
? 'Muestra este código en la entrada'
|
||||
: 'Show this code at the door'}
|
||||
</p>
|
||||
<Button onClick={onShowQr} size="lg" className="w-full sm:w-auto">
|
||||
<QrCodeIcon className="mr-2 h-5 w-5" />
|
||||
{locale === 'es' ? 'Mostrar código QR' : 'Show QR code'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Smart primary action. */}
|
||||
{isUnpaid(ticket) ? (
|
||||
<PayActions
|
||||
ticketId={ticket.id}
|
||||
amount={amount}
|
||||
currency={currency}
|
||||
destination={title}
|
||||
locale={locale}
|
||||
onPaid={onChange}
|
||||
layout="inline"
|
||||
size="md"
|
||||
/>
|
||||
) : isAwaitingApproval(ticket) ? (
|
||||
<p className="rounded-card bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||
{t('dashboard.payment.receivedConfirmShortly')}
|
||||
</p>
|
||||
) : (
|
||||
!today && (
|
||||
<Button onClick={onShowQr} size="md">
|
||||
<TicketIcon className="mr-2 h-5 w-5" />
|
||||
{locale === 'es' ? 'Ver entrada' : 'View ticket'}
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Multi-ticket guest reminder. */}
|
||||
{multi && showQrReady && (
|
||||
<div className="mt-4 flex flex-col gap-2 rounded-card border border-secondary-light-gray p-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-sm text-gray-600">
|
||||
{locale === 'es'
|
||||
? 'Tienes una entrada de más para un invitado.'
|
||||
: 'You have a spare ticket for a guest.'}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onShare(group.tickets[1])}
|
||||
>
|
||||
<UserPlusIcon className="mr-2 h-4 w-4" />
|
||||
{locale === 'es' ? 'Compartir con un invitado' : 'Share with a guest'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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<Filter>('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<string, string> = {
|
||||
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<string, Record<string, string>> = {
|
||||
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 (
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${styles[status] || 'bg-gray-100 text-gray-800'}`}>
|
||||
{labels[language]?.[status] || status}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const getProviderLabel = (provider: string) => {
|
||||
const labels: Record<string, Record<string, string>> = {
|
||||
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<string, { en: string; es: string }> = {
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
{/* Summary Cards */}
|
||||
{/* Totals */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Card className="p-4">
|
||||
<p className="text-sm text-gray-600 mb-1">
|
||||
{language === 'es' ? 'Total Pagado' : 'Total Paid'}
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-green-600">
|
||||
{formatCurrency(totalPaid)}
|
||||
<Card className="p-5">
|
||||
<p className="mb-1 text-sm text-gray-600">
|
||||
{locale === 'es' ? 'Total pagado' : 'Total paid'}
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-green-700">{pyg(totalPaid)}</p>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<p className="text-sm text-gray-600 mb-1">
|
||||
{language === 'es' ? 'Pendiente' : 'Pending'}
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-yellow-600">
|
||||
{formatCurrency(totalPending)}
|
||||
<Card className="p-5">
|
||||
<p className="mb-1 text-sm text-gray-600">
|
||||
{locale === 'es' ? 'Pendiente' : 'Pending'}
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-red-700">{pyg(totalPending)}</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Filter Buttons */}
|
||||
<div className="flex gap-2">
|
||||
{(['all', 'paid', 'pending'] as const).map((f) => (
|
||||
{/* Filter chips */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{chips.map((chip) => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
filter === f
|
||||
? 'bg-secondary-blue text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
key={chip.id}
|
||||
onClick={() => setFilter(chip.id)}
|
||||
className={`rounded-full px-4 py-2 text-sm font-medium transition-colors ${
|
||||
filter === chip.id
|
||||
? 'bg-primary-yellow text-primary-dark'
|
||||
: 'bg-secondary-gray text-gray-700 hover:bg-secondary-light-gray'
|
||||
}`}
|
||||
>
|
||||
{f === 'all' && (language === 'es' ? 'Todos' : 'All')}
|
||||
{f === 'paid' && (language === 'es' ? 'Pagados' : 'Paid')}
|
||||
{f === 'pending' && (language === 'es' ? 'Pendientes' : 'Pending')}
|
||||
{locale === 'es' ? chip.label.es : chip.label.en}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Payments List */}
|
||||
{filteredPayments.length === 0 ? (
|
||||
{/* Records */}
|
||||
{filtered.length === 0 ? (
|
||||
<Card className="p-8 text-center">
|
||||
<p className="text-gray-600">
|
||||
{language === 'es' ? 'No hay pagos que mostrar' : 'No payments to display'}
|
||||
{locale === 'es' ? 'No hay pagos que mostrar' : 'No payments to show'}
|
||||
</p>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{filteredPayments.map((payment) => (
|
||||
<Card key={payment.id} className="p-4">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-3">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-semibold">
|
||||
{formatCurrency(Number(payment.amount), payment.currency)}
|
||||
</span>
|
||||
{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 (
|
||||
<Card key={payment.id} className="p-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<span className="font-semibold">
|
||||
{pyg(Number(payment.amount), payment.currency)}
|
||||
</span>
|
||||
<StatusPill status={status} locale={locale} />
|
||||
</div>
|
||||
<div className="space-y-0.5 text-sm text-gray-600">
|
||||
<p className="font-medium text-gray-800">{eventTitle}</p>
|
||||
<p>
|
||||
{providerLabel(payment.provider)} ·{' '}
|
||||
{formatDateLong(payment.createdAt, locale as 'en' | 'es')}
|
||||
</p>
|
||||
{payment.reference && (
|
||||
<p className="text-xs text-gray-500">
|
||||
{locale === 'es' ? 'Ref' : 'Ref'}: {payment.reference}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-gray-600 space-y-1">
|
||||
{payment.event && (
|
||||
<p>
|
||||
{language === 'es' && payment.event.titleEs
|
||||
? payment.event.titleEs
|
||||
: payment.event.title}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-shrink-0 flex-col items-stretch gap-2 sm:items-end">
|
||||
{canMarkPaid && (
|
||||
<PayActions
|
||||
ticketId={payment.ticketId}
|
||||
amount={Number(payment.amount)}
|
||||
currency={payment.currency}
|
||||
destination={eventTitle}
|
||||
locale={locale}
|
||||
onPaid={onChange}
|
||||
layout="inline"
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
<p>
|
||||
<span className="font-medium">
|
||||
{language === 'es' ? 'Método:' : 'Method:'}
|
||||
</span>{' '}
|
||||
{getProviderLabel(payment.provider)}
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-medium">
|
||||
{language === 'es' ? 'Fecha:' : 'Date:'}
|
||||
</span>{' '}
|
||||
{formatDate(payment.createdAt)}
|
||||
</p>
|
||||
{payment.reference && (
|
||||
<p>
|
||||
<span className="font-medium">
|
||||
{language === 'es' ? 'Referencia:' : 'Reference:'}
|
||||
</span>{' '}
|
||||
{payment.reference}
|
||||
</p>
|
||||
{payment.invoice && (
|
||||
<a
|
||||
href={payment.invoice.pdfUrl || '#'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Button variant="outline" size="sm">
|
||||
{locale === 'es' ? 'Factura' : 'Invoice'}
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{payment.invoice && (
|
||||
<a
|
||||
href={payment.invoice.pdfUrl || '#'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Button variant="outline" size="sm">
|
||||
{language === 'es' ? 'Descargar Factura' : 'Download Invoice'}
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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<UserProfile | null>(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 (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-secondary-blue"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl space-y-6">
|
||||
{/* Account Info Card */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
{language === 'es' ? 'Información de la Cuenta' : 'Account Information'}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="flex justify-between py-2 border-b">
|
||||
<span className="text-gray-600">Email</span>
|
||||
<span className="font-medium">{profile?.email}</span>
|
||||
</div>
|
||||
<div className="flex justify-between py-2 border-b">
|
||||
<span className="text-gray-600">
|
||||
{language === 'es' ? 'Estado de Cuenta' : 'Account Status'}
|
||||
</span>
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${
|
||||
profile?.accountStatus === 'active'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-yellow-100 text-yellow-800'
|
||||
}`}>
|
||||
{profile?.accountStatus === 'active'
|
||||
? (language === 'es' ? 'Activo' : 'Active')
|
||||
: profile?.accountStatus}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between py-2 border-b">
|
||||
<span className="text-gray-600">
|
||||
{language === 'es' ? 'Miembro Desde' : 'Member Since'}
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{profile?.memberSince
|
||||
? parseDate(profile.memberSince).toLocaleDateString(
|
||||
language === 'es' ? 'es-ES' : 'en-US',
|
||||
{ year: 'numeric', month: 'long', day: 'numeric', timeZone: 'America/Asuncion' }
|
||||
)
|
||||
: '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between py-2">
|
||||
<span className="text-gray-600">
|
||||
{language === 'es' ? 'Días de Membresía' : 'Membership Days'}
|
||||
</span>
|
||||
<span className="font-medium">{profile?.membershipDays || 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Edit Profile Form */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
{language === 'es' ? 'Editar Perfil' : 'Edit Profile'}
|
||||
</h3>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<Input
|
||||
id="name"
|
||||
label={language === 'es' ? 'Nombre' : 'Name'}
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
required
|
||||
/>
|
||||
|
||||
<Input
|
||||
id="phone"
|
||||
label={language === 'es' ? 'Teléfono' : 'Phone'}
|
||||
type="tel"
|
||||
value={formData.phone}
|
||||
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{language === 'es' ? 'Idioma Preferido' : 'Preferred Language'}
|
||||
</label>
|
||||
<select
|
||||
value={formData.languagePreference}
|
||||
onChange={(e) => setFormData({ ...formData, languagePreference: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-secondary-blue"
|
||||
>
|
||||
<option value="">{language === 'es' ? 'Seleccionar' : 'Select'}</option>
|
||||
<option value="en">English</option>
|
||||
<option value="es">Español</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
id="rucNumber"
|
||||
label={language === 'es' ? 'Número de RUC (para facturas)' : 'RUC Number (for invoices)'}
|
||||
value={formData.rucNumber}
|
||||
onChange={(e) => setFormData({ ...formData, rucNumber: e.target.value })}
|
||||
placeholder={language === 'es' ? 'Opcional' : 'Optional'}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 -mt-2">
|
||||
{language === 'es'
|
||||
? 'Tu número de RUC paraguayo para facturas fiscales'
|
||||
: 'Your Paraguayan RUC number for fiscal invoices'}
|
||||
</p>
|
||||
|
||||
<div className="pt-4">
|
||||
<Button type="submit" isLoading={saving}>
|
||||
{language === 'es' ? 'Guardar Cambios' : 'Save Changes'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
{/* Email Change Notice */}
|
||||
<Card className="p-6 bg-gray-50">
|
||||
<h3 className="text-lg font-semibold mb-2">
|
||||
{language === 'es' ? 'Cambiar Email' : 'Change Email'}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
{language === 'es'
|
||||
? 'Para cambiar tu dirección de email, por favor contacta al soporte.'
|
||||
: 'To change your email address, please contact support.'}
|
||||
</p>
|
||||
<Button variant="outline" size="sm" disabled>
|
||||
{language === 'es' ? 'Contactar Soporte' : 'Contact Support'}
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<UserProfile | null>(null);
|
||||
const [sessions, setSessions] = useState<UserSession[]>([]);
|
||||
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 (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-secondary-blue"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl space-y-6">
|
||||
{/* Authentication Methods */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
{language === 'es' ? 'Métodos de Autenticación' : 'Authentication Methods'}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Password Status */}
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${
|
||||
profile?.hasPassword ? 'bg-green-100 text-green-600' : 'bg-gray-200 text-gray-500'
|
||||
}`}>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{language === 'es' ? 'Contraseña' : 'Password'}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{profile?.hasPassword
|
||||
? (language === 'es' ? 'Configurada' : 'Set')
|
||||
: (language === 'es' ? 'No configurada' : 'Not set')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{profile?.hasPassword && (
|
||||
<span className="text-green-600 text-sm font-medium">
|
||||
{language === 'es' ? 'Activo' : 'Active'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Google Status */}
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${
|
||||
profile?.hasGoogleLinked ? 'bg-red-100 text-red-600' : 'bg-gray-200 text-gray-500'
|
||||
}`}>
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
|
||||
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
|
||||
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
|
||||
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Google</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{profile?.hasGoogleLinked
|
||||
? (language === 'es' ? 'Vinculado' : 'Linked')
|
||||
: (language === 'es' ? 'No vinculado' : 'Not linked')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{profile?.hasGoogleLinked && profile?.hasPassword && (
|
||||
<Button variant="outline" size="sm" onClick={handleUnlinkGoogle}>
|
||||
{language === 'es' ? 'Desvincular' : 'Unlink'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Password Management */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
{profile?.hasPassword
|
||||
? (language === 'es' ? 'Cambiar Contraseña' : 'Change Password')
|
||||
: (language === 'es' ? 'Establecer Contraseña' : 'Set Password')}
|
||||
</h3>
|
||||
|
||||
{profile?.hasPassword ? (
|
||||
<form onSubmit={handleChangePassword} className="space-y-4">
|
||||
<Input
|
||||
id="currentPassword"
|
||||
type="password"
|
||||
label={language === 'es' ? 'Contraseña Actual' : 'Current Password'}
|
||||
value={passwordForm.currentPassword}
|
||||
onChange={(e) => setPasswordForm({ ...passwordForm, currentPassword: e.target.value })}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
id="newPassword"
|
||||
type="password"
|
||||
label={language === 'es' ? 'Nueva Contraseña' : 'New Password'}
|
||||
value={passwordForm.newPassword}
|
||||
onChange={(e) => setPasswordForm({ ...passwordForm, newPassword: e.target.value })}
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-gray-500 -mt-2">
|
||||
{language === 'es' ? 'Mínimo 10 caracteres' : 'Minimum 10 characters'}
|
||||
</p>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
label={language === 'es' ? 'Confirmar Contraseña' : 'Confirm Password'}
|
||||
value={passwordForm.confirmPassword}
|
||||
onChange={(e) => setPasswordForm({ ...passwordForm, confirmPassword: e.target.value })}
|
||||
required
|
||||
/>
|
||||
<Button type="submit" isLoading={changingPassword}>
|
||||
{language === 'es' ? 'Cambiar Contraseña' : 'Change Password'}
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={handleSetPassword} className="space-y-4">
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
{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.'}
|
||||
</p>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
label={language === 'es' ? 'Nueva Contraseña' : 'New Password'}
|
||||
value={newPasswordForm.password}
|
||||
onChange={(e) => setNewPasswordForm({ ...newPasswordForm, password: e.target.value })}
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-gray-500 -mt-2">
|
||||
{language === 'es' ? 'Mínimo 10 caracteres' : 'Minimum 10 characters'}
|
||||
</p>
|
||||
<Input
|
||||
id="confirmNewPassword"
|
||||
type="password"
|
||||
label={language === 'es' ? 'Confirmar Contraseña' : 'Confirm Password'}
|
||||
value={newPasswordForm.confirmPassword}
|
||||
onChange={(e) => setNewPasswordForm({ ...newPasswordForm, confirmPassword: e.target.value })}
|
||||
required
|
||||
/>
|
||||
<Button type="submit" isLoading={settingPassword}>
|
||||
{language === 'es' ? 'Establecer Contraseña' : 'Set Password'}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Active Sessions */}
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold">
|
||||
{language === 'es' ? 'Sesiones Activas' : 'Active Sessions'}
|
||||
</h3>
|
||||
{sessions.length > 1 && (
|
||||
<Button variant="outline" size="sm" onClick={handleRevokeAllSessions}>
|
||||
{language === 'es' ? 'Cerrar Todas' : 'Logout All'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{sessions.length === 0 ? (
|
||||
<p className="text-sm text-gray-600">
|
||||
{language === 'es' ? 'No hay sesiones activas' : 'No active sessions'}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{sessions.map((session, index) => (
|
||||
<div
|
||||
key={session.id}
|
||||
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-sm">
|
||||
{session.userAgent
|
||||
? session.userAgent.substring(0, 50) + (session.userAgent.length > 50 ? '...' : '')
|
||||
: (language === 'es' ? 'Dispositivo desconocido' : 'Unknown device')}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{language === 'es' ? 'Última actividad:' : 'Last active:'} {formatDate(session.lastActiveAt)}
|
||||
{session.ipAddress && ` • ${session.ipAddress}`}
|
||||
</p>
|
||||
</div>
|
||||
{index !== 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleRevokeSession(session.id)}
|
||||
>
|
||||
{language === 'es' ? 'Cerrar' : 'Revoke'}
|
||||
</Button>
|
||||
)}
|
||||
{index === 0 && (
|
||||
<span className="text-xs text-green-600 font-medium">
|
||||
{language === 'es' ? 'Esta sesión' : 'This session'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Danger Zone */}
|
||||
<Card className="p-6 border-red-200 bg-red-50">
|
||||
<h3 className="text-lg font-semibold text-red-800 mb-4">
|
||||
{language === 'es' ? 'Zona de Peligro' : 'Danger Zone'}
|
||||
</h3>
|
||||
<p className="text-sm text-red-700 mb-4">
|
||||
{language === 'es'
|
||||
? 'Si deseas eliminar tu cuenta, contacta al soporte.'
|
||||
: 'If you want to delete your account, please contact support.'}
|
||||
</p>
|
||||
<Button variant="outline" size="sm" className="border-red-300 text-red-700 hover:bg-red-100" disabled>
|
||||
{language === 'es' ? 'Eliminar Cuenta' : 'Delete Account'}
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<Filter>('all');
|
||||
const [qrTickets, setQrTickets] = useState<UserTicket[] | null>(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<BookingGroup[]>(() => {
|
||||
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<string, string> = {
|
||||
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<string, Record<string, string>> = {
|
||||
en: {
|
||||
confirmed: 'Confirmed',
|
||||
checked_in: 'Checked In',
|
||||
pending: 'Pending',
|
||||
cancelled: 'Cancelled',
|
||||
},
|
||||
es: {
|
||||
confirmed: 'Confirmado',
|
||||
checked_in: 'Registrado',
|
||||
pending: 'Pendiente',
|
||||
cancelled: 'Cancelado',
|
||||
},
|
||||
};
|
||||
return (
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${styles[status] || 'bg-gray-100 text-gray-800'}`}>
|
||||
{labels[language]?.[status] || status}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
{/* Filter Buttons */}
|
||||
<div className="flex gap-2">
|
||||
{(['all', 'upcoming', 'past'] as const).map((f) => (
|
||||
{/* Filter chips */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{chips.map((chip) => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
filter === f
|
||||
? 'bg-secondary-blue text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
key={chip.id}
|
||||
onClick={() => setFilter(chip.id)}
|
||||
className={`rounded-full px-4 py-2 text-sm font-medium transition-colors ${
|
||||
filter === chip.id
|
||||
? 'bg-primary-yellow text-primary-dark'
|
||||
: 'bg-secondary-gray text-gray-700 hover:bg-secondary-light-gray'
|
||||
}`}
|
||||
>
|
||||
{f === 'all' && (language === 'es' ? 'Todas' : 'All')}
|
||||
{f === 'upcoming' && (language === 'es' ? 'Próximas' : 'Upcoming')}
|
||||
{f === 'past' && (language === 'es' ? 'Pasadas' : 'Past')}
|
||||
{locale === 'es' ? chip.label.es : chip.label.en}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tickets List */}
|
||||
{filteredTickets.length === 0 ? (
|
||||
{groups.length === 0 ? (
|
||||
<Card className="p-8 text-center">
|
||||
<p className="text-gray-600 mb-4">
|
||||
{language === 'es' ? 'No tienes entradas' : 'You have no tickets'}
|
||||
<p className="mb-4 text-gray-600">
|
||||
{locale === 'es' ? 'No tienes entradas' : 'You have no tickets'}
|
||||
</p>
|
||||
<Link href="/events">
|
||||
<Button>
|
||||
{language === 'es' ? 'Explorar Eventos' : 'Explore Events'}
|
||||
</Button>
|
||||
<Button>{locale === 'es' ? 'Explorar eventos' : 'Browse events'}</Button>
|
||||
</Link>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{filteredTickets.map((ticket) => (
|
||||
<Card key={ticket.id} className="p-4">
|
||||
<div className="flex flex-col md:flex-row md:items-center gap-4">
|
||||
{/* Event Image */}
|
||||
{ticket.event?.bannerUrl && (
|
||||
<div className="w-full md:w-32 h-24 rounded-lg overflow-hidden flex-shrink-0">
|
||||
<img
|
||||
src={ticket.event.bannerUrl}
|
||||
alt={ticket.event.title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ticket Info */}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h3 className="font-semibold">
|
||||
{language === 'es' && ticket.event?.titleEs
|
||||
? ticket.event.titleEs
|
||||
: ticket.event?.title || 'Event'}
|
||||
</h3>
|
||||
{getStatusBadge(ticket.status)}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 space-y-1 text-sm text-gray-600">
|
||||
{ticket.event?.startDatetime && (
|
||||
<p>
|
||||
<span className="font-medium">
|
||||
{language === 'es' ? 'Fecha:' : 'Date:'}
|
||||
</span>{' '}
|
||||
{formatDate(ticket.event.startDatetime)}
|
||||
</p>
|
||||
)}
|
||||
{ticket.event?.location && (
|
||||
<p>
|
||||
<span className="font-medium">
|
||||
{language === 'es' ? 'Lugar:' : 'Location:'}
|
||||
</span>{' '}
|
||||
{ticket.event.location}
|
||||
</p>
|
||||
)}
|
||||
{ticket.payment && (
|
||||
<p>
|
||||
<span className="font-medium">
|
||||
{language === 'es' ? 'Pago:' : 'Payment:'}
|
||||
</span>{' '}
|
||||
{formatCurrency(ticket.payment.amount, ticket.payment.currency)} -
|
||||
<span className={`ml-1 ${
|
||||
ticket.payment.status === 'paid' ? 'text-green-600' : 'text-yellow-600'
|
||||
}`}>
|
||||
{ticket.payment.status === 'paid'
|
||||
? (language === 'es' ? 'Pagado' : 'Paid')
|
||||
: (language === 'es' ? 'Pendiente' : 'Pending')}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Link href={`/booking/success/${ticket.id}`}>
|
||||
<Button size="sm" className="w-full">
|
||||
{language === 'es' ? 'Ver Entrada' : 'View Ticket'}
|
||||
</Button>
|
||||
</Link>
|
||||
{(ticket.status === 'confirmed' || ticket.status === 'checked_in') && (
|
||||
<a
|
||||
href={ticket.bookingId
|
||||
? `/api/tickets/booking/${ticket.bookingId}/pdf`
|
||||
: `/api/tickets/${ticket.id}/pdf`
|
||||
}
|
||||
download
|
||||
className="text-center"
|
||||
>
|
||||
<Button variant="outline" size="sm" className="w-full">
|
||||
{language === 'es' ? 'Descargar Ticket(s)' : 'Download Ticket(s)'}
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
{ticket.invoice && (
|
||||
<a
|
||||
href={ticket.invoice.pdfUrl || '#'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-center"
|
||||
>
|
||||
<Button variant="outline" size="sm" className="w-full">
|
||||
{language === 'es' ? 'Descargar Factura' : 'Download Invoice'}
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
{groups.map((group) => (
|
||||
<BookingCard
|
||||
key={group.bookingId}
|
||||
group={group}
|
||||
locale={locale}
|
||||
onChange={onChange}
|
||||
onShowQr={() => setQrTickets(group.tickets)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{qrTickets && (
|
||||
<QrTicketModal
|
||||
tickets={qrTickets}
|
||||
locale={locale}
|
||||
onClose={() => setQrTickets(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Card className="p-4">
|
||||
<div className="flex flex-col gap-4 sm:flex-row">
|
||||
{/* Image */}
|
||||
<div className="h-32 w-full flex-shrink-0 overflow-hidden rounded-card bg-secondary-gray sm:h-24 sm:w-32">
|
||||
{event?.bannerUrl ? (
|
||||
<img src={event.bannerUrl} alt={title} className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<TicketIcon className="h-8 w-8 text-gray-300" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<h3 className="flex items-center gap-2 font-semibold">
|
||||
<span className="truncate">{title}</span>
|
||||
{multi && (
|
||||
<span className="whitespace-nowrap rounded-full bg-secondary-gray px-2 py-0.5 text-xs font-medium text-gray-700">
|
||||
{locale === 'es'
|
||||
? `${group.tickets.length} entradas`
|
||||
: `${group.tickets.length} tickets`}
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
</div>
|
||||
<StatusPill status={status} locale={locale} />
|
||||
</div>
|
||||
|
||||
<div className="mt-2 space-y-1 text-sm text-gray-600">
|
||||
{event?.startDatetime && (
|
||||
<p className="flex items-center gap-2">
|
||||
<CalendarIcon className="h-4 w-4 text-primary-yellow" />
|
||||
{formatDateLong(event.startDatetime, locale as 'en' | 'es')}
|
||||
</p>
|
||||
)}
|
||||
{event?.location && (
|
||||
<p className="flex items-center gap-2">
|
||||
<MapPinIcon className="h-4 w-4 text-primary-yellow" />
|
||||
<span className="truncate">{event.location}</span>
|
||||
</p>
|
||||
)}
|
||||
<p className="text-gray-500">
|
||||
{pyg(amount, currency)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col gap-2 sm:w-44">
|
||||
{isUnpaid(ticket) ? (
|
||||
<PayActions
|
||||
ticketId={ticket.id}
|
||||
amount={amount}
|
||||
currency={currency}
|
||||
destination={title}
|
||||
locale={locale}
|
||||
onPaid={onChange}
|
||||
layout="stack"
|
||||
size="sm"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{ready && (
|
||||
<Button size="sm" className="w-full" onClick={onShowQr}>
|
||||
{locale === 'es' ? 'Ver entrada' : 'View ticket'}
|
||||
</Button>
|
||||
)}
|
||||
{ready && (
|
||||
<a href={ticketPdfUrl(ticket)} download className="w-full">
|
||||
<Button variant="outline" size="sm" className="w-full">
|
||||
<ArrowDownTrayIcon className="mr-1.5 h-4 w-4" />
|
||||
{locale === 'es' ? 'Descargar' : 'Download'}
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
{ticket.invoice && (
|
||||
<a
|
||||
href={ticket.invoice.pdfUrl || '#'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="w-full"
|
||||
>
|
||||
<Button variant="ghost" size="sm" className="w-full">
|
||||
{locale === 'es' ? 'Factura' : 'Invoice'}
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex items-start gap-3 rounded-card border border-amber-200 bg-amber-50 p-4">
|
||||
<CheckCircleIcon className="mt-0.5 h-6 w-6 flex-shrink-0 text-amber-600" />
|
||||
<div>
|
||||
<p className="font-semibold text-amber-900">
|
||||
{t('dashboard.payment.received')}
|
||||
</p>
|
||||
<p className="text-sm text-amber-800">
|
||||
{t('dashboard.payment.confirmForEvent', {
|
||||
amount: pyg(amount, currency),
|
||||
event: eventTitle,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const expiry = holdExpiry(ticket);
|
||||
|
||||
return (
|
||||
<div className="rounded-card border border-red-200 bg-red-50 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<ExclamationTriangleIcon className="mt-0.5 h-6 w-6 flex-shrink-0 text-red-600" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-semibold text-red-900">
|
||||
{locale === 'es'
|
||||
? `Debes ${pyg(amount, currency)} para ${eventTitle}`
|
||||
: `You owe ${pyg(amount, currency)} for ${eventTitle}`}
|
||||
</p>
|
||||
{expiry && (
|
||||
<p className="mt-0.5 text-sm text-red-700">
|
||||
<HoldCountdown target={expiry} locale={locale} />
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 sm:pl-9">
|
||||
<PayActions
|
||||
ticketId={ticket.id}
|
||||
amount={amount}
|
||||
currency={currency}
|
||||
destination={eventTitle}
|
||||
locale={locale}
|
||||
onPaid={onChange}
|
||||
layout="inline"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<span>
|
||||
{locale === 'es'
|
||||
? 'Paga pronto para mantener tu lugar'
|
||||
: 'Pay soon to keep your spot'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<span>
|
||||
{locale === 'es'
|
||||
? `Paga en ${remaining} o tu lugar se libera`
|
||||
: `Pay within ${remaining} or your spot is released`}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<>
|
||||
<div className={`${containerClass} ${className || ''}`}>
|
||||
<Link href={`/booking/${ticketId}`} className={btnWidth}>
|
||||
<Button size={size} className={btnWidth}>
|
||||
{locale === 'es' ? 'Pagar ahora' : 'Pay now'}
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
variant="outline"
|
||||
size={size}
|
||||
className={btnWidth}
|
||||
onClick={() => setConfirming(true)}
|
||||
>
|
||||
{locale === 'es' ? 'Ya pagué' : "I've paid"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{confirming && (
|
||||
<div
|
||||
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
onClick={() => !marking && setConfirming(false)}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-sm rounded-card bg-white p-6 shadow-card-hover"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="mb-4 flex justify-center">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-amber-100">
|
||||
<CheckCircleIcon className="h-7 w-7 text-amber-600" />
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="mb-2 text-center text-lg font-semibold text-primary-dark">
|
||||
{locale === 'es' ? '¿Confirmar pago?' : 'Confirm payment?'}
|
||||
</h3>
|
||||
<p className="mb-6 text-center text-sm text-gray-600">
|
||||
{locale === 'es'
|
||||
? `¿Ya enviaste los ${pyg(amount, currency)} para ${destination}?`
|
||||
: `Did you already send the ${pyg(amount, currency)} for ${destination}?`}
|
||||
</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button isLoading={marking} className="w-full" onClick={handleConfirm}>
|
||||
{locale === 'es' ? 'Sí, ya pagué' : "Yes, I've paid"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full"
|
||||
disabled={marking}
|
||||
onClick={() => setConfirming(false)}
|
||||
>
|
||||
{locale === 'es' ? 'Todavía no' : 'Not yet'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="fixed inset-0 z-[70] overflow-y-auto bg-white">
|
||||
{/* Top bar */}
|
||||
<div className="sticky top-0 z-10 flex items-center justify-between border-b border-secondary-light-gray bg-white px-4 py-3">
|
||||
<span className="text-sm font-semibold text-primary-dark">
|
||||
{isMulti
|
||||
? locale === 'es'
|
||||
? `${tickets.length} entradas`
|
||||
: `${tickets.length} tickets`
|
||||
: locale === 'es'
|
||||
? 'Tu entrada'
|
||||
: 'Your ticket'}
|
||||
</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label={locale === 'es' ? 'Cerrar' : 'Close'}
|
||||
className="rounded-full p-2 text-gray-500 hover:bg-gray-100"
|
||||
>
|
||||
<XMarkIcon className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto max-w-md px-4 pb-12 pt-6">
|
||||
{/* Event details */}
|
||||
<div className="mb-6 text-center">
|
||||
<h2 className="text-xl font-bold text-primary-dark">{eventTitle}</h2>
|
||||
{event && (
|
||||
<div className="mt-3 space-y-1.5 text-sm text-gray-600">
|
||||
<p className="flex items-center justify-center gap-2">
|
||||
<CalendarIcon className="h-4 w-4 text-primary-yellow" />
|
||||
{formatDateLong(event.startDatetime, locale as 'en' | 'es')}
|
||||
</p>
|
||||
<p className="flex items-center justify-center gap-2">
|
||||
<ClockIcon className="h-4 w-4 text-primary-yellow" />
|
||||
{formatTime(event.startDatetime, locale as 'en' | 'es')}
|
||||
</p>
|
||||
<p className="flex items-center justify-center gap-2">
|
||||
<MapPinIcon className="h-4 w-4 text-primary-yellow" />
|
||||
{event.location}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* One QR per ticket */}
|
||||
<div className="space-y-5">
|
||||
{tickets.map((ticket, idx) => {
|
||||
const attendee = [ticket.attendeeFirstName, ticket.attendeeLastName]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
const isSpare = isMulti && idx > 0;
|
||||
return (
|
||||
<div
|
||||
key={ticket.id}
|
||||
className="rounded-card border border-secondary-light-gray p-5 text-center"
|
||||
>
|
||||
{isMulti && (
|
||||
<p className="mb-3 text-xs font-semibold uppercase tracking-wider text-gray-500">
|
||||
{locale === 'es' ? 'Entrada' : 'Ticket'} {idx + 1}
|
||||
{attendee ? ` • ${attendee}` : ''}
|
||||
{isSpare
|
||||
? ` • ${locale === 'es' ? 'Invitado' : 'Guest'}`
|
||||
: ''}
|
||||
</p>
|
||||
)}
|
||||
<div className="inline-block rounded-lg bg-white p-4 shadow-inner">
|
||||
<QRCodeSVG
|
||||
value={ticketScanUrl(ticket.id)}
|
||||
size={220}
|
||||
level="M"
|
||||
includeMargin={false}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-3 font-mono text-sm font-bold tracking-wide text-primary-dark">
|
||||
{ticket.qrCode}
|
||||
</p>
|
||||
|
||||
{isSpare && (
|
||||
<button
|
||||
onClick={() => handleShare(ticket.id)}
|
||||
className="mx-auto mt-3 flex items-center gap-2 rounded-btn border border-secondary-light-gray px-4 py-2 text-sm font-medium text-primary-dark hover:bg-gray-50"
|
||||
>
|
||||
<UserPlusIcon className="h-4 w-4" />
|
||||
{locale === 'es' ? 'Compartir con un invitado' : 'Share with a guest'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Download + calendar */}
|
||||
<div className="mt-6 flex flex-col gap-2">
|
||||
<a href={ticketPdfUrl(first)} download>
|
||||
<Button className="w-full">
|
||||
<ArrowDownTrayIcon className="mr-2 h-5 w-5" />
|
||||
{isMulti
|
||||
? locale === 'es'
|
||||
? 'Descargar entradas'
|
||||
: 'Download tickets'
|
||||
: locale === 'es'
|
||||
? 'Descargar'
|
||||
: 'Download'}
|
||||
</Button>
|
||||
</a>
|
||||
{event && (
|
||||
<a
|
||||
href={googleCalendarUrl(event, locale)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Button variant="outline" className="w-full">
|
||||
<CalendarDaysIcon className="mr-2 h-5 w-5" />
|
||||
{locale === 'es' ? 'Agregar al calendario' : 'Add to calendar'}
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<UserTicket, 'status'> & { 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<string, UserTicket[]>();
|
||||
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());
|
||||
}
|
||||
@@ -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<DashStatus, { en: string; es: string }> = {
|
||||
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<DashStatus, string> = {
|
||||
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 (
|
||||
<span
|
||||
className={clsx(
|
||||
'inline-flex items-center rounded-full px-3 py-1 text-xs font-medium whitespace-nowrap',
|
||||
PILL_STYLES[status],
|
||||
className
|
||||
)}
|
||||
>
|
||||
{statusLabel(status, locale)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Convenience: derive + render a pill straight from a ticket. */
|
||||
export function TicketStatusPill({
|
||||
ticket,
|
||||
locale,
|
||||
className,
|
||||
}: {
|
||||
ticket: Pick<UserTicket, 'status'> & { payment?: Pick<Payment, 'status'> | null };
|
||||
locale: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const status = deriveTicketStatus(ticket.status, ticket.payment?.status);
|
||||
return <StatusPill status={status} locale={locale} className={className} />;
|
||||
}
|
||||
@@ -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<Tab>('overview');
|
||||
const [summary, setSummary] = useState<DashboardSummary | null>(null);
|
||||
const [nextEvent, setNextEvent] = useState<NextEventInfo | null>(null);
|
||||
const [tickets, setTickets] = useState<UserTicket[]>([]);
|
||||
const [payments, setPayments] = useState<UserPayment[]>([]);
|
||||
@@ -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 (
|
||||
<div className="section-padding min-h-[70vh] flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-secondary-blue"></div>
|
||||
<div className="section-padding flex min-h-[70vh] items-center justify-center">
|
||||
<div className="h-12 w-12 animate-spin rounded-full border-b-2 border-primary-yellow" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const formatDate = (dateStr: string) => formatDateLong(dateStr, language as 'en' | 'es');
|
||||
const fmtTime = (dateStr: string) => formatTime(dateStr, language as 'en' | 'es');
|
||||
|
||||
return (
|
||||
<div className="section-padding min-h-[70vh]">
|
||||
<div className="container-page">
|
||||
{/* Welcome Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold mb-2">
|
||||
{language === 'es' ? `Hola, ${user.name}!` : `Welcome, ${user.name}!`}
|
||||
</h1>
|
||||
{summary && (
|
||||
<p className="text-gray-600">
|
||||
{language === 'es'
|
||||
? `Miembro desde hace ${summary.user.membershipDays} días`
|
||||
: `Member for ${summary.user.membershipDays} days`
|
||||
}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{/* Welcome header */}
|
||||
<h1 className="mb-6 text-3xl font-bold">
|
||||
{locale === 'es' ? `¡Hola, ${user.name}!` : `Welcome, ${user.name}!`}
|
||||
</h1>
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<div className="border-b border-gray-200 mb-6">
|
||||
<nav className="flex gap-4 -mb-px overflow-x-auto">
|
||||
{/* Tab navigation */}
|
||||
<div className="mb-6 border-b border-secondary-light-gray">
|
||||
<nav className="-mb-px flex gap-6 overflow-x-auto">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`whitespace-nowrap pb-3 px-1 border-b-2 font-medium text-sm transition-colors ${
|
||||
className={`whitespace-nowrap border-b-2 px-1 pb-3 text-sm font-semibold transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'border-secondary-blue text-secondary-blue'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
? 'border-primary-yellow text-primary-dark'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
{locale === 'es' ? tab.label.es : tab.label.en}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
{/* Tab content */}
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-secondary-blue"></div>
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-primary-yellow" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{activeTab === 'overview' && (
|
||||
<OverviewTab
|
||||
summary={summary}
|
||||
nextEvent={nextEvent}
|
||||
tickets={tickets}
|
||||
language={language}
|
||||
formatDate={formatDate}
|
||||
formatTime={formatTime}
|
||||
locale={locale}
|
||||
userName={user.name}
|
||||
onChange={loadDashboardData}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'tickets' && (
|
||||
<TicketsTab tickets={tickets} language={language} />
|
||||
<TicketsTab tickets={tickets} language={locale} onChange={loadDashboardData} />
|
||||
)}
|
||||
|
||||
{activeTab === 'payments' && (
|
||||
<PaymentsTab payments={payments} language={language} />
|
||||
)}
|
||||
|
||||
{activeTab === 'profile' && (
|
||||
<ProfileTab onUpdate={loadDashboardData} />
|
||||
)}
|
||||
|
||||
{activeTab === 'security' && (
|
||||
<SecurityTab />
|
||||
<PaymentsTab payments={payments} language={locale} onChange={loadDashboardData} />
|
||||
)}
|
||||
{activeTab === 'account' && <AccountTab onUpdate={loadDashboardData} />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div className="space-y-6">
|
||||
{/* Stats Cards */}
|
||||
{summary && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<Card className="p-4 text-center">
|
||||
<div className="text-3xl font-bold text-secondary-blue">{summary.stats.totalTickets}</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
{language === 'es' ? 'Total Entradas' : 'Total Tickets'}
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="p-4 text-center">
|
||||
<div className="text-3xl font-bold text-green-600">{summary.stats.confirmedTickets}</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
{language === 'es' ? 'Confirmadas' : 'Confirmed'}
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="p-4 text-center">
|
||||
<div className="text-3xl font-bold text-purple-600">{summary.stats.upcomingEvents}</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
{language === 'es' ? 'Próximos' : 'Upcoming'}
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="p-4 text-center">
|
||||
<div className="text-3xl font-bold text-orange-500">{summary.stats.pendingPayments}</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
{language === 'es' ? 'Pagos Pendientes' : 'Pending Payments'}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Next Event Card */}
|
||||
{nextEvent ? (
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
{language === 'es' ? 'Tu Próximo Evento' : 'Your Next Event'}
|
||||
</h3>
|
||||
<div className="flex flex-col md:flex-row gap-6">
|
||||
{nextEvent.event.bannerUrl && (
|
||||
<div className="w-full md:w-48 h-32 rounded-lg overflow-hidden">
|
||||
<img
|
||||
src={nextEvent.event.bannerUrl}
|
||||
alt={nextEvent.event.title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<h4 className="text-xl font-bold mb-2">
|
||||
{language === 'es' && nextEvent.event.titleEs
|
||||
? nextEvent.event.titleEs
|
||||
: nextEvent.event.title}
|
||||
</h4>
|
||||
<div className="space-y-2 text-gray-600">
|
||||
<p>
|
||||
<span className="font-medium">
|
||||
{language === 'es' ? 'Fecha:' : 'Date:'}
|
||||
</span>{' '}
|
||||
{formatDate(nextEvent.event.startDatetime)}
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-medium">
|
||||
{language === 'es' ? 'Hora:' : 'Time:'}
|
||||
</span>{' '}
|
||||
{formatTime(nextEvent.event.startDatetime)}
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-medium">
|
||||
{language === 'es' ? 'Lugar:' : 'Location:'}
|
||||
</span>{' '}
|
||||
{nextEvent.event.location}
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-medium">
|
||||
{language === 'es' ? 'Estado:' : 'Status:'}
|
||||
</span>{' '}
|
||||
<span className={`inline-flex px-2 py-1 text-xs rounded-full ${
|
||||
nextEvent.payment?.status === 'paid'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-yellow-100 text-yellow-800'
|
||||
}`}>
|
||||
{nextEvent.payment?.status === 'paid'
|
||||
? (language === 'es' ? 'Pagado' : 'Paid')
|
||||
: (language === 'es' ? 'Pendiente' : 'Pending')}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4 flex gap-2">
|
||||
<Link href={`/booking/success/${nextEvent.ticket.id}`}>
|
||||
<Button size="sm">
|
||||
{language === 'es' ? 'Ver Entrada' : 'View Ticket'}
|
||||
</Button>
|
||||
</Link>
|
||||
{nextEvent.event.locationUrl && (
|
||||
<a
|
||||
href={nextEvent.event.locationUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Button variant="outline" size="sm">
|
||||
{language === 'es' ? 'Ver Mapa' : 'View Map'}
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<Card className="p-6 text-center">
|
||||
<p className="text-gray-600 mb-4">
|
||||
{language === 'es'
|
||||
? 'No tienes eventos próximos'
|
||||
: 'You have no upcoming events'}
|
||||
</p>
|
||||
<Link href="/events">
|
||||
<Button>
|
||||
{language === 'es' ? 'Explorar Eventos' : 'Explore Events'}
|
||||
</Button>
|
||||
</Link>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Recent Tickets */}
|
||||
{tickets.length > 0 && (
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
{language === 'es' ? 'Entradas Recientes' : 'Recent Tickets'}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{tickets.slice(0, 3).map((ticket) => (
|
||||
<div
|
||||
key={ticket.id}
|
||||
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{language === 'es' && ticket.event?.titleEs
|
||||
? ticket.event.titleEs
|
||||
: ticket.event?.title || 'Event'}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
{ticket.event?.startDatetime
|
||||
? formatDate(ticket.event.startDatetime)
|
||||
: ''}
|
||||
</p>
|
||||
</div>
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${
|
||||
ticket.status === 'confirmed' || ticket.status === 'checked_in'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: ticket.status === 'cancelled'
|
||||
? 'bg-red-100 text-red-800'
|
||||
: 'bg-yellow-100 text-yellow-800'
|
||||
}`}>
|
||||
{ticket.status}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{tickets.length > 3 && (
|
||||
<div className="mt-4 text-center">
|
||||
<Button variant="outline" size="sm" onClick={() => {}}>
|
||||
{language === 'es' ? 'Ver Todas' : 'View All'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Community Links */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
{language === 'es' ? 'Comunidad' : 'Community'}
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{getWhatsAppUrl(socialConfig.whatsapp) && (
|
||||
<a
|
||||
href={getWhatsAppUrl(socialConfig.whatsapp)!}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-3 p-3 bg-green-50 rounded-lg hover:bg-green-100 transition-colors"
|
||||
>
|
||||
<div className="w-10 h-10 bg-green-500 rounded-full flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span className="font-medium">WhatsApp</span>
|
||||
</a>
|
||||
)}
|
||||
{getInstagramUrl(socialConfig.instagram) && (
|
||||
<a
|
||||
href={getInstagramUrl(socialConfig.instagram)!}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-3 p-3 bg-pink-50 rounded-lg hover:bg-pink-100 transition-colors"
|
||||
>
|
||||
<div className="w-10 h-10 bg-gradient-to-r from-purple-500 to-pink-500 rounded-full flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span className="font-medium">Instagram</span>
|
||||
</a>
|
||||
)}
|
||||
{getTelegramUrl(socialConfig.telegram) && (
|
||||
<a
|
||||
href={getTelegramUrl(socialConfig.telegram)!}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-3 p-3 bg-blue-50 rounded-lg hover:bg-blue-100 transition-colors"
|
||||
>
|
||||
<div className="w-10 h-10 bg-blue-500 rounded-full flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span className="font-medium">Telegram</span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user