Compare commits
28
Commits
Security-recovery
...
1.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74dcc8e5ac | ||
|
|
0d47156071 | ||
|
|
bd1043a3f4 | ||
|
|
75e317d73e | ||
|
|
107cd9753e | ||
|
|
194604004e | ||
|
|
bb920ce6f0 | ||
|
|
c0315a705d | ||
|
|
fbc437a670 | ||
|
|
e0f0700398 | ||
|
|
defd9685e0 | ||
|
|
1ed62b0d3f | ||
|
|
91de6df04d | ||
|
|
a5d97d65e1 | ||
|
|
f0128f66b0 | ||
|
|
b33c68feb0 | ||
|
|
15655e3987 | ||
|
|
d8b3864411 | ||
|
|
194cbd6ca8 | ||
|
|
d5445c2282 | ||
|
|
dcfefc8371 | ||
|
|
b5f14335c4 | ||
|
|
d44ac949b5 | ||
|
|
a5e939221d | ||
|
|
833e3e5a9c | ||
|
|
ba1975dd6d | ||
|
|
3025ef3d21 | ||
|
|
8564f8af83 |
@@ -31,11 +31,19 @@ const createTicketSchema = z.object({
|
|||||||
preferredLanguage: z.enum(['en', 'es']).optional(),
|
preferredLanguage: z.enum(['en', 'es']).optional(),
|
||||||
// 'bancard' intentionally excluded: no checkout integration exists for it
|
// 'bancard' intentionally excluded: no checkout integration exists for it
|
||||||
paymentMethod: z.enum(['lightning', 'cash', 'bank_transfer', 'tpago']).default('cash'),
|
paymentMethod: z.enum(['lightning', 'cash', 'bank_transfer', 'tpago']).default('cash'),
|
||||||
ruc: z.string().regex(/^\d{6,10}$/, 'Invalid RUC format').optional().or(z.literal('')),
|
// Base + optional "-" + check digit; digits-only kept for older clients, normalized to dashed form on save
|
||||||
|
ruc: z.string().regex(/^(\d{6,10}|\d{5,8}-\d)$/, 'Invalid RUC format').optional().or(z.literal('')),
|
||||||
// Optional: array of attendees for multi-ticket booking (capped at MAX_TICKETS_PER_BOOKING)
|
// Optional: array of attendees for multi-ticket booking (capped at MAX_TICKETS_PER_BOOKING)
|
||||||
attendees: z.array(attendeeSchema).min(1).max(MAX_TICKETS_PER_BOOKING).optional(),
|
attendees: z.array(attendeeSchema).min(1).max(MAX_TICKETS_PER_BOOKING).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Canonical stored RUC form is "base-checkdigit" (e.g. 1234567-9); older clients send digits only
|
||||||
|
function normalizeRuc(ruc: string | undefined): string | null {
|
||||||
|
if (!ruc) return null;
|
||||||
|
if (ruc.includes('-')) return ruc;
|
||||||
|
return `${ruc.slice(0, -1)}-${ruc.slice(-1)}`;
|
||||||
|
}
|
||||||
|
|
||||||
// Maps a payment provider to the merged payment-option flag that enables it
|
// Maps a payment provider to the merged payment-option flag that enables it
|
||||||
function isPaymentMethodEnabled(method: string, merged: Record<string, any>): boolean {
|
function isPaymentMethodEnabled(method: string, merged: Record<string, any>): boolean {
|
||||||
const truthy = (v: any) => v === true || v === 1;
|
const truthy = (v: any) => v === true || v === 1;
|
||||||
@@ -76,7 +84,8 @@ const adminCreateTicketSchema = z.object({
|
|||||||
// Book a ticket (public) - supports single or multi-ticket bookings
|
// Book a ticket (public) - supports single or multi-ticket bookings
|
||||||
ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
||||||
const data = c.req.valid('json');
|
const data = c.req.valid('json');
|
||||||
|
const rucNumber = normalizeRuc(data.ruc);
|
||||||
|
|
||||||
// Determine attendees list (use attendees array if provided, otherwise single attendee from main fields)
|
// Determine attendees list (use attendees array if provided, otherwise single attendee from main fields)
|
||||||
const attendeesList = data.attendees && data.attendees.length > 0
|
const attendeesList = data.attendees && data.attendees.length > 0
|
||||||
? data.attendees
|
? data.attendees
|
||||||
@@ -168,19 +177,19 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
|||||||
phone: data.phone || null,
|
phone: data.phone || null,
|
||||||
role: 'user',
|
role: 'user',
|
||||||
languagePreference: null,
|
languagePreference: null,
|
||||||
rucNumber: data.ruc || null,
|
rucNumber,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
await (db as any).insert(users).values(user);
|
await (db as any).insert(users).values(user);
|
||||||
} else if (data.ruc) {
|
} else if (rucNumber) {
|
||||||
// Keep the user's saved RUC up to date for future bookings, but never blank
|
// Keep the user's saved RUC up to date for future bookings, but never blank
|
||||||
// out an existing value if this booking didn't include one.
|
// out an existing value if this booking didn't include one.
|
||||||
await (db as any)
|
await (db as any)
|
||||||
.update(users)
|
.update(users)
|
||||||
.set({ rucNumber: data.ruc, updatedAt: now })
|
.set({ rucNumber, updatedAt: now })
|
||||||
.where(eq((users as any).id, user.id));
|
.where(eq((users as any).id, user.id));
|
||||||
user.rucNumber = data.ruc;
|
user.rucNumber = rucNumber;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for duplicate booking (unless allowDuplicateBookings is enabled)
|
// Check for duplicate booking (unless allowDuplicateBookings is enabled)
|
||||||
@@ -253,7 +262,7 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
|||||||
attendeeLastName: attendee.lastName && attendee.lastName.trim() ? attendee.lastName.trim() : null,
|
attendeeLastName: attendee.lastName && attendee.lastName.trim() ? attendee.lastName.trim() : null,
|
||||||
attendeeEmail: data.email,
|
attendeeEmail: data.email,
|
||||||
attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null,
|
attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null,
|
||||||
attendeeRuc: data.ruc || null,
|
attendeeRuc: rucNumber,
|
||||||
preferredLanguage: data.preferredLanguage || null,
|
preferredLanguage: data.preferredLanguage || null,
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
qrCode,
|
qrCode,
|
||||||
@@ -314,7 +323,7 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
|||||||
attendeeLastName: attendee.lastName && attendee.lastName.trim() ? attendee.lastName.trim() : null,
|
attendeeLastName: attendee.lastName && attendee.lastName.trim() ? attendee.lastName.trim() : null,
|
||||||
attendeeEmail: data.email,
|
attendeeEmail: data.email,
|
||||||
attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null,
|
attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null,
|
||||||
attendeeRuc: data.ruc || null,
|
attendeeRuc: rucNumber,
|
||||||
preferredLanguage: data.preferredLanguage || null,
|
preferredLanguage: data.preferredLanguage || null,
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
qrCode,
|
qrCode,
|
||||||
|
|||||||
@@ -8,11 +8,17 @@ import {
|
|||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
import type { PaymentMethod, BookingResult } from '../_types';
|
import type { PaymentMethod, BookingResult } from '../_types';
|
||||||
|
|
||||||
export const rucPattern = /^\d{6,10}$/;
|
// Paraguayan RUC: 5-8 digit base + "-" + 1 check digit (DV), e.g. 1234567-9 or 80012345-0
|
||||||
|
export const rucPattern = /^\d{5,8}-\d$/;
|
||||||
|
|
||||||
/** Format RUC input: digits only, max 10. */
|
/** Sanitize RUC input: digits and a single user-typed dash, max 10 chars. No dash is auto-inserted. */
|
||||||
export function formatRuc(value: string): string {
|
export function formatRuc(value: string): string {
|
||||||
return value.replace(/\D/g, '').slice(0, 10);
|
const cleaned = value.replace(/[^\d-]/g, '');
|
||||||
|
const firstDash = cleaned.indexOf('-');
|
||||||
|
const oneDash = firstDash === -1
|
||||||
|
? cleaned
|
||||||
|
: cleaned.slice(0, firstDash + 1) + cleaned.slice(firstDash + 1).replace(/-/g, '');
|
||||||
|
return oneDash.slice(0, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Truncate a long invoice string for display. */
|
/** Truncate a long invoice string for display. */
|
||||||
|
|||||||
@@ -226,25 +226,10 @@ export function BookingFormStep({
|
|||||||
onBlur={handleRucBlur}
|
onBlur={handleRucBlur}
|
||||||
placeholder={t('booking.form.rucPlaceholder')}
|
placeholder={t('booking.form.rucPlaceholder')}
|
||||||
error={errors.ruc}
|
error={errors.ruc}
|
||||||
inputMode="numeric"
|
|
||||||
maxLength={10}
|
maxLength={10}
|
||||||
aria-label={t('booking.form.ruc')}
|
aria-label={t('booking.form.ruc')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
{t('booking.form.preferredLanguage')}
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={formData.preferredLanguage}
|
|
||||||
onChange={(e) => setFormData({ ...formData, preferredLanguage: e.target.value as 'en' | 'es' })}
|
|
||||||
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
|
||||||
>
|
|
||||||
<option value="en">English</option>
|
|
||||||
<option value="es">Español</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ export interface BookingFormData {
|
|||||||
lastName: string;
|
lastName: string;
|
||||||
email: string;
|
email: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
preferredLanguage: 'en' | 'es';
|
|
||||||
// Empty until the user explicitly picks a method (no default selection).
|
// Empty until the user explicitly picks a method (no default selection).
|
||||||
paymentMethod: PaymentMethod | '';
|
paymentMethod: PaymentMethod | '';
|
||||||
ruc: string;
|
ruc: string;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useParams, useRouter, useSearchParams } from 'next/navigation';
|
|||||||
import { useLanguage } from '@/context/LanguageContext';
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
import { useAuth } from '@/context/AuthContext';
|
import { useAuth } from '@/context/AuthContext';
|
||||||
import { eventsApi, ticketsApi, paymentOptionsApi, Event, PaymentOptionsConfig } from '@/lib/api';
|
import { eventsApi, ticketsApi, paymentOptionsApi, Event, PaymentOptionsConfig } from '@/lib/api';
|
||||||
import { formatDateLong, formatTime } from '@/lib/utils';
|
import { formatDateLong, formatTime, formatRucDisplay } from '@/lib/utils';
|
||||||
import { isSafeExternalUrl } from '@/lib/safeRedirect';
|
import { isSafeExternalUrl } from '@/lib/safeRedirect';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import type {
|
import type {
|
||||||
@@ -56,7 +56,6 @@ export default function BookingPage() {
|
|||||||
lastName: '',
|
lastName: '',
|
||||||
email: '',
|
email: '',
|
||||||
phone: '',
|
phone: '',
|
||||||
preferredLanguage: locale as 'en' | 'es',
|
|
||||||
paymentMethod: '',
|
paymentMethod: '',
|
||||||
ruc: '',
|
ruc: '',
|
||||||
});
|
});
|
||||||
@@ -78,11 +77,10 @@ export default function BookingPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Validate RUC on blur (optional field: 6–10 digits)
|
// Validate RUC on blur (optional field: base + "-" + check digit)
|
||||||
const handleRucBlur = () => {
|
const handleRucBlur = () => {
|
||||||
if (!formData.ruc) return;
|
if (!formData.ruc) return;
|
||||||
const digits = formData.ruc.replace(/\D/g, '');
|
if (!rucPattern.test(formData.ruc)) {
|
||||||
if (digits.length > 0 && !rucPattern.test(digits)) {
|
|
||||||
setErrors({ ...errors, ruc: t('booking.form.errors.rucInvalidFormat') });
|
setErrors({ ...errors, ruc: t('booking.form.errors.rucInvalidFormat') });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -152,8 +150,7 @@ export default function BookingPage() {
|
|||||||
lastName: prev.lastName || lastName,
|
lastName: prev.lastName || lastName,
|
||||||
email: prev.email || user.email || '',
|
email: prev.email || user.email || '',
|
||||||
phone: prev.phone || user.phone || '',
|
phone: prev.phone || user.phone || '',
|
||||||
preferredLanguage: (user.languagePreference as 'en' | 'es') || prev.preferredLanguage,
|
ruc: prev.ruc || formatRucDisplay(user.rucNumber),
|
||||||
ruc: prev.ruc || user.rucNumber || '',
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -197,12 +194,9 @@ export default function BookingPage() {
|
|||||||
newErrors.phone = t('booking.form.errors.phoneTooShort');
|
newErrors.phone = t('booking.form.errors.phoneTooShort');
|
||||||
}
|
}
|
||||||
|
|
||||||
// RUC validation (optional field - 6–10 digits if filled)
|
// RUC validation (optional field - base + "-" + check digit if filled)
|
||||||
if (formData.ruc.trim()) {
|
if (formData.ruc.trim() && !rucPattern.test(formData.ruc.trim())) {
|
||||||
const digits = formData.ruc.replace(/\D/g, '');
|
newErrors.ruc = t('booking.form.errors.rucInvalidFormat');
|
||||||
if (!/^\d{6,10}$/.test(digits)) {
|
|
||||||
newErrors.ruc = t('booking.form.errors.rucInvalidFormat');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Payment method must be explicitly chosen and currently enabled
|
// Payment method must be explicitly chosen and currently enabled
|
||||||
@@ -301,9 +295,9 @@ export default function BookingPage() {
|
|||||||
lastName: formData.lastName,
|
lastName: formData.lastName,
|
||||||
email: formData.email,
|
email: formData.email,
|
||||||
phone: formData.phone,
|
phone: formData.phone,
|
||||||
preferredLanguage: formData.preferredLanguage,
|
preferredLanguage: locale as 'en' | 'es',
|
||||||
paymentMethod: formData.paymentMethod as PaymentMethod,
|
paymentMethod: formData.paymentMethod as PaymentMethod,
|
||||||
...(formData.ruc.trim() && { ruc: formData.ruc.replace(/\D/g, '') }),
|
...(formData.ruc.trim() && { ruc: formData.ruc.trim() }),
|
||||||
// Include attendees array for multi-ticket bookings
|
// Include attendees array for multi-ticket bookings
|
||||||
...(allAttendees.length > 1 && { attendees: allAttendees }),
|
...(allAttendees.length > 1 && { attendees: allAttendees }),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useLanguage } from '@/context/LanguageContext';
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
import { faqApi, FaqItem } from '@/lib/api';
|
import { faqApi, FaqItem } from '@/lib/api';
|
||||||
|
import { Skeleton, FaqListSkeleton } from '@/components/ui/Skeleton';
|
||||||
import { ChevronDownIcon } from '@heroicons/react/24/outline';
|
import { ChevronDownIcon } from '@heroicons/react/24/outline';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
@@ -23,7 +24,20 @@ export default function HomepageFaqSection() {
|
|||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (loading || faqs.length === 0) {
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<section className="section-padding bg-secondary-gray" aria-labelledby="homepage-faq-title">
|
||||||
|
<div className="container-page">
|
||||||
|
<div className="max-w-2xl mx-auto">
|
||||||
|
<Skeleton className="h-9 w-72 max-w-full mx-auto mb-8" />
|
||||||
|
<FaqListSkeleton count={4} compact />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (faqs.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import Link from 'next/link';
|
|||||||
import { useLanguage } from '@/context/LanguageContext';
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
import { eventsApi, Event } from '@/lib/api';
|
import { eventsApi, Event } from '@/lib/api';
|
||||||
import { formatPrice, formatDateLong, formatTime } from '@/lib/utils';
|
import { formatPrice, formatDateLong, formatTime } from '@/lib/utils';
|
||||||
|
import { FeaturedEventSkeleton } from '@/components/ui/Skeleton';
|
||||||
import { CalendarIcon, MapPinIcon, ClockIcon } from '@heroicons/react/24/outline';
|
import { CalendarIcon, MapPinIcon, ClockIcon } from '@heroicons/react/24/outline';
|
||||||
|
|
||||||
interface NextEventSectionProps {
|
interface NextEventSectionProps {
|
||||||
@@ -51,11 +52,7 @@ export default function NextEventSection({ initialEvent }: NextEventSectionProps
|
|||||||
: '';
|
: '';
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return <FeaturedEventSkeleton />;
|
||||||
<div className="text-center py-12">
|
|
||||||
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full mx-auto" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!nextEvent) {
|
if (!nextEvent) {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
UserPayment,
|
UserPayment,
|
||||||
} from '@/lib/api';
|
} from '@/lib/api';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
import { CardListSkeleton } from '@/components/ui/Skeleton';
|
||||||
|
|
||||||
import OverviewTab from './components/OverviewTab';
|
import OverviewTab from './components/OverviewTab';
|
||||||
import TicketsTab from './components/TicketsTab';
|
import TicketsTab from './components/TicketsTab';
|
||||||
@@ -104,9 +105,7 @@ export default function DashboardPage() {
|
|||||||
|
|
||||||
{/* Tab content */}
|
{/* Tab content */}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex justify-center py-12">
|
<CardListSkeleton count={3} />
|
||||||
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-primary-yellow" />
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{activeTab === 'overview' && (
|
{activeTab === 'overview' && (
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { Skeleton, ArticleSkeleton } from '@/components/ui/Skeleton';
|
||||||
|
|
||||||
|
// Route-level skeleton for the event detail page: back link, banner, article
|
||||||
|
// body on the left and the booking card in the sidebar.
|
||||||
|
export default function EventDetailLoading() {
|
||||||
|
return (
|
||||||
|
<div className="section-padding">
|
||||||
|
<div className="container-page">
|
||||||
|
<Skeleton className="h-5 w-36" />
|
||||||
|
<div className="mt-6 grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<ArticleSkeleton withBanner />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="bg-white rounded-card shadow-card p-6" aria-hidden="true">
|
||||||
|
<div className="text-center">
|
||||||
|
<Skeleton className="h-4 w-16 mx-auto" />
|
||||||
|
<Skeleton className="mt-3 h-10 w-32 mx-auto" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="mt-6 h-12 w-full rounded-btn" />
|
||||||
|
<Skeleton className="mt-3 h-4 w-3/4 mx-auto" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { Skeleton, EventGridSkeleton } from '@/components/ui/Skeleton';
|
||||||
|
|
||||||
|
// Route-level skeleton shown during navigation while the server fetches the
|
||||||
|
// event list. Mirrors the EventsClient shell: title, filter tabs, card grid.
|
||||||
|
export default function EventsLoading() {
|
||||||
|
return (
|
||||||
|
<div className="section-padding">
|
||||||
|
<div className="container-page">
|
||||||
|
<Skeleton className="h-10 w-64" />
|
||||||
|
<div className="mt-8 flex gap-2">
|
||||||
|
<Skeleton className="h-10 w-32 rounded-btn" />
|
||||||
|
<Skeleton className="h-10 w-28 rounded-btn" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-8">
|
||||||
|
<EventGridSkeleton count={6} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { Skeleton, FaqListSkeleton } from '@/components/ui/Skeleton';
|
||||||
|
|
||||||
|
// Route-level skeleton for the FAQ page: centered header + accordion rows.
|
||||||
|
export default function FaqLoading() {
|
||||||
|
return (
|
||||||
|
<div className="section-padding">
|
||||||
|
<div className="container-page max-w-3xl">
|
||||||
|
<div className="text-center mb-12">
|
||||||
|
<Skeleton className="h-10 w-80 max-w-full mx-auto" />
|
||||||
|
<Skeleton className="mt-4 h-4 w-96 max-w-full mx-auto" />
|
||||||
|
</div>
|
||||||
|
<FaqListSkeleton count={6} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { Skeleton, SkeletonGroup, SkeletonText } from '@/components/ui/Skeleton';
|
||||||
|
|
||||||
|
// Route-level skeleton for legal pages: back link, bordered header, prose body.
|
||||||
|
export default function LegalPageLoading() {
|
||||||
|
return (
|
||||||
|
<div className="section-padding">
|
||||||
|
<div className="container-page max-w-4xl">
|
||||||
|
<SkeletonGroup>
|
||||||
|
<Skeleton className="h-5 w-32 mb-8" />
|
||||||
|
<div className="mb-8 pb-6 border-b border-gray-200">
|
||||||
|
<Skeleton className="h-10 w-2/3" />
|
||||||
|
<Skeleton className="mt-3 h-4 w-44" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<SkeletonText lines={4} />
|
||||||
|
<Skeleton className="h-6 w-1/3" />
|
||||||
|
<SkeletonText lines={5} />
|
||||||
|
<Skeleton className="h-6 w-2/5" />
|
||||||
|
<SkeletonText lines={4} />
|
||||||
|
</div>
|
||||||
|
</SkeletonGroup>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,9 +3,10 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useLanguage } from '@/context/LanguageContext';
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
import { ticketsApi, eventsApi, paymentsApi, Ticket, Event } from '@/lib/api';
|
import { ticketsApi, eventsApi, paymentsApi, Ticket, Event } from '@/lib/api';
|
||||||
import { parseDate } from '@/lib/utils';
|
import { parseDate, formatRucDisplay } from '@/lib/utils';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
|
import { AdminPageSkeleton } from '@/components/ui/Skeleton';
|
||||||
import { BottomSheet, MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
import { BottomSheet, MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
||||||
import {
|
import {
|
||||||
TicketIcon,
|
TicketIcon,
|
||||||
@@ -246,11 +247,7 @@ export default function AdminBookingsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return <AdminPageSkeleton cols={7} />;
|
||||||
<div className="flex items-center justify-center py-12">
|
|
||||||
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -420,7 +417,7 @@ export default function AdminBookingsPage() {
|
|||||||
<p className="text-xs text-gray-500 truncate max-w-[200px]">{ticket.attendeeEmail || 'N/A'}</p>
|
<p className="text-xs text-gray-500 truncate max-w-[200px]">{ticket.attendeeEmail || 'N/A'}</p>
|
||||||
{ticket.attendeePhone && <p className="text-xs text-gray-400">{ticket.attendeePhone}</p>}
|
{ticket.attendeePhone && <p className="text-xs text-gray-400">{ticket.attendeePhone}</p>}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-sm text-gray-600">{ticket.attendeeRuc || '-'}</td>
|
<td className="px-4 py-3 text-sm text-gray-600">{formatRucDisplay(ticket.attendeeRuc) || '-'}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<span className="text-sm truncate max-w-[150px] block">
|
<span className="text-sm truncate max-w-[150px] block">
|
||||||
{ticket.event?.title || events.find(e => e.id === ticket.eventId)?.title || 'Unknown'}
|
{ticket.event?.title || events.find(e => e.id === ticket.eventId)?.title || 'Unknown'}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useLanguage } from '@/context/LanguageContext';
|
|||||||
import { contactsApi, Contact } from '@/lib/api';
|
import { contactsApi, Contact } from '@/lib/api';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
|
import { Skeleton, SkeletonText, CardListSkeleton } from '@/components/ui/Skeleton';
|
||||||
import { EnvelopeIcon, EnvelopeOpenIcon, CheckIcon } from '@heroicons/react/24/outline';
|
import { EnvelopeIcon, EnvelopeOpenIcon, CheckIcon } from '@heroicons/react/24/outline';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import { parseDate } from '@/lib/utils';
|
import { parseDate } from '@/lib/utils';
|
||||||
@@ -65,8 +66,21 @@ export default function AdminContactsPage() {
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div>
|
||||||
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full" />
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<Skeleton className="h-8 w-48" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
<div className="lg:col-span-1">
|
||||||
|
<CardListSkeleton count={5} />
|
||||||
|
</div>
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<div className="bg-white rounded-card shadow-card p-6">
|
||||||
|
<Skeleton className="h-6 w-1/3" />
|
||||||
|
<SkeletonText lines={4} className="mt-6" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { emailsApi, EmailTemplate, EmailLog, EmailStats } from '@/lib/api';
|
|||||||
import { parseDate } from '@/lib/utils';
|
import { parseDate } from '@/lib/utils';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
|
import { AdminPageSkeleton } from '@/components/ui/Skeleton';
|
||||||
import Input from '@/components/ui/Input';
|
import Input from '@/components/ui/Input';
|
||||||
import { MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
import { MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
||||||
import {
|
import {
|
||||||
@@ -418,11 +419,7 @@ export default function AdminEmailsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return <AdminPageSkeleton cols={5} />;
|
||||||
<div className="flex items-center justify-center py-12">
|
|
||||||
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { ticketsApi, emailsApi, adminApi, paymentsApi, siteSettingsApi, Ticket }
|
|||||||
import { formatDateLong, formatDateCompact, formatTime } from '@/lib/utils';
|
import { formatDateLong, formatDateCompact, formatTime } from '@/lib/utils';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
|
import { Skeleton, TableSkeleton } from '@/components/ui/Skeleton';
|
||||||
import { Dropdown, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
import { Dropdown, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
||||||
import {
|
import {
|
||||||
ArrowLeftIcon,
|
ArrowLeftIcon,
|
||||||
@@ -420,8 +421,12 @@ export default function AdminEventDetailPage() {
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div>
|
||||||
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full" />
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<Skeleton className="h-8 w-64" />
|
||||||
|
<Skeleton className="h-10 w-36 rounded-btn hidden md:block" />
|
||||||
|
</div>
|
||||||
|
<TableSkeleton rows={5} cols={4} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useLanguage } from '@/context/LanguageContext';
|
|||||||
import { eventsApi, siteSettingsApi, Event } from '@/lib/api';
|
import { eventsApi, siteSettingsApi, Event } from '@/lib/api';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
|
import { AdminPageSkeleton } from '@/components/ui/Skeleton';
|
||||||
import { MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
import { MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
||||||
import { PlusIcon, PencilIcon, TrashIcon, EyeIcon, PhotoIcon, DocumentDuplicateIcon, ArchiveBoxIcon, StarIcon, LinkIcon } from '@heroicons/react/24/outline';
|
import { PlusIcon, PencilIcon, TrashIcon, EyeIcon, PhotoIcon, DocumentDuplicateIcon, ArchiveBoxIcon, StarIcon, LinkIcon } from '@heroicons/react/24/outline';
|
||||||
import { StarIcon as StarIconSolid } from '@heroicons/react/24/solid';
|
import { StarIcon as StarIconSolid } from '@heroicons/react/24/solid';
|
||||||
@@ -148,11 +149,7 @@ export default function AdminEventsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return <AdminPageSkeleton cols={5} />;
|
||||||
<div className="flex items-center justify-center py-12">
|
|
||||||
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useLanguage } from '@/context/LanguageContext';
|
|||||||
import { faqApi, FaqItemAdmin } from '@/lib/api';
|
import { faqApi, FaqItemAdmin } from '@/lib/api';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
|
import { AdminPageSkeleton } from '@/components/ui/Skeleton';
|
||||||
import Input from '@/components/ui/Input';
|
import Input from '@/components/ui/Input';
|
||||||
import { MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
import { MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
@@ -185,11 +186,7 @@ export default function AdminFaqPage() {
|
|||||||
const handleDragEnd = () => { setDraggedId(null); setDragOverId(null); };
|
const handleDragEnd = () => { setDraggedId(null); setDragOverId(null); };
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return <AdminPageSkeleton cols={5} />;
|
||||||
<div className="flex items-center justify-center min-h-[400px]">
|
|
||||||
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { mediaApi, Media } from '@/lib/api';
|
|||||||
import { parseDate } from '@/lib/utils';
|
import { parseDate } from '@/lib/utils';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
|
import { Skeleton, ImageGridSkeleton } from '@/components/ui/Skeleton';
|
||||||
import {
|
import {
|
||||||
PhotoIcon,
|
PhotoIcon,
|
||||||
TrashIcon,
|
TrashIcon,
|
||||||
@@ -126,8 +127,12 @@ export default function AdminGalleryPage() {
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div>
|
||||||
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full" />
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<Skeleton className="h-8 w-48" />
|
||||||
|
<Skeleton className="h-10 w-36 rounded-btn hidden md:block" />
|
||||||
|
</div>
|
||||||
|
<ImageGridSkeleton />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { legalPagesApi, LegalPage } from '@/lib/api';
|
|||||||
import { parseDate } from '@/lib/utils';
|
import { parseDate } from '@/lib/utils';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
|
import { AdminPageSkeleton } from '@/components/ui/Skeleton';
|
||||||
import Input from '@/components/ui/Input';
|
import Input from '@/components/ui/Input';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
@@ -173,11 +174,7 @@ export default function AdminLegalPagesPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return <AdminPageSkeleton cols={5} />;
|
||||||
<div className="flex items-center justify-center min-h-[400px]">
|
|
||||||
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Editor view
|
// Editor view
|
||||||
|
|||||||
@@ -3,9 +3,10 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useLanguage } from '@/context/LanguageContext';
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
import { paymentsApi, adminApi, eventsApi, PaymentWithDetails, Event, ExportedPayment, FinancialSummary } from '@/lib/api';
|
import { paymentsApi, adminApi, eventsApi, PaymentWithDetails, Event, ExportedPayment, FinancialSummary } from '@/lib/api';
|
||||||
import { parseDate } from '@/lib/utils';
|
import { parseDate, formatRucDisplay } from '@/lib/utils';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
|
import { AdminPageSkeleton } from '@/components/ui/Skeleton';
|
||||||
import Input from '@/components/ui/Input';
|
import Input from '@/components/ui/Input';
|
||||||
import { BottomSheet, MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
import { BottomSheet, MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
||||||
import {
|
import {
|
||||||
@@ -204,7 +205,7 @@ export default function AdminPaymentsPage() {
|
|||||||
p.createdAt,
|
p.createdAt,
|
||||||
`${p.attendeeFirstName} ${p.attendeeLastName || ''}`.trim(),
|
`${p.attendeeFirstName} ${p.attendeeLastName || ''}`.trim(),
|
||||||
p.attendeeEmail || '',
|
p.attendeeEmail || '',
|
||||||
p.attendeeRuc || '',
|
formatRucDisplay(p.attendeeRuc) || '',
|
||||||
p.eventTitle,
|
p.eventTitle,
|
||||||
p.eventDate,
|
p.eventDate,
|
||||||
]);
|
]);
|
||||||
@@ -364,11 +365,7 @@ export default function AdminPaymentsPage() {
|
|||||||
const onHoldBookingsCount = getUniqueBookingsCount(onHoldPayments);
|
const onHoldBookingsCount = getUniqueBookingsCount(onHoldPayments);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return <AdminPageSkeleton cols={7} />;
|
||||||
<div className="flex items-center justify-center py-12">
|
|
||||||
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -434,7 +431,7 @@ export default function AdminPaymentsPage() {
|
|||||||
<p className="text-sm text-gray-600">{selectedPayment.ticket.attendeePhone}</p>
|
<p className="text-sm text-gray-600">{selectedPayment.ticket.attendeePhone}</p>
|
||||||
)}
|
)}
|
||||||
{selectedPayment.ticket.attendeeRuc && (
|
{selectedPayment.ticket.attendeeRuc && (
|
||||||
<p className="text-sm text-gray-600">RUC: {selectedPayment.ticket.attendeeRuc}</p>
|
<p className="text-sm text-gray-600">RUC: {formatRucDisplay(selectedPayment.ticket.attendeeRuc)}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { ticketsApi, eventsApi, Ticket, Event } from '@/lib/api';
|
|||||||
import { parseDate } from '@/lib/utils';
|
import { parseDate } from '@/lib/utils';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
|
import { AdminPageSkeleton } from '@/components/ui/Skeleton';
|
||||||
import Input from '@/components/ui/Input';
|
import Input from '@/components/ui/Input';
|
||||||
import { BottomSheet, MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
import { BottomSheet, MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
||||||
import { CheckCircleIcon, XCircleIcon, PlusIcon, FunnelIcon, XMarkIcon } from '@heroicons/react/24/outline';
|
import { CheckCircleIcon, XCircleIcon, PlusIcon, FunnelIcon, XMarkIcon } from '@heroicons/react/24/outline';
|
||||||
@@ -135,11 +136,7 @@ export default function AdminTicketsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return <AdminPageSkeleton cols={5} />;
|
||||||
<div className="flex items-center justify-center py-12">
|
|
||||||
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -3,9 +3,10 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useLanguage } from '@/context/LanguageContext';
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
import { usersApi, eventsApi, User, Event } from '@/lib/api';
|
import { usersApi, eventsApi, User, Event } from '@/lib/api';
|
||||||
import { parseDate } from '@/lib/utils';
|
import { parseDate, formatRucDisplay } from '@/lib/utils';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
|
import { AdminPageSkeleton } from '@/components/ui/Skeleton';
|
||||||
import Input from '@/components/ui/Input';
|
import Input from '@/components/ui/Input';
|
||||||
import { MoreMenu, DropdownItem, BottomSheet, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
import { MoreMenu, DropdownItem, BottomSheet, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
||||||
import { TrashIcon, PencilSquareIcon, FunnelIcon, XMarkIcon, MagnifyingGlassIcon } from '@heroicons/react/24/outline';
|
import { TrashIcon, PencilSquareIcon, FunnelIcon, XMarkIcon, MagnifyingGlassIcon } from '@heroicons/react/24/outline';
|
||||||
@@ -166,11 +167,7 @@ export default function AdminUsersPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return <AdminPageSkeleton cols={6} />;
|
||||||
<div className="flex items-center justify-center py-12">
|
|
||||||
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -317,7 +314,7 @@ export default function AdminUsersPage() {
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-sm text-gray-600">{user.phone || '-'}</td>
|
<td className="px-4 py-3 text-sm text-gray-600">{user.phone || '-'}</td>
|
||||||
<td className="px-4 py-3 text-sm text-gray-600">{user.rucNumber || '-'}</td>
|
<td className="px-4 py-3 text-sm text-gray-600">{formatRucDisplay(user.rucNumber) || '-'}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<select value={user.role} onChange={(e) => handleRoleChange(user.id, e.target.value)}
|
<select value={user.role} onChange={(e) => handleRoleChange(user.id, e.target.value)}
|
||||||
className="px-2 py-1 rounded border border-secondary-light-gray text-sm">
|
className="px-2 py-1 rounded border border-secondary-light-gray text-sm">
|
||||||
@@ -366,7 +363,7 @@ export default function AdminUsersPage() {
|
|||||||
<p className="font-medium text-sm truncate">{user.name}</p>
|
<p className="font-medium text-sm truncate">{user.name}</p>
|
||||||
<p className="text-xs text-gray-500 truncate">{user.email}</p>
|
<p className="text-xs text-gray-500 truncate">{user.email}</p>
|
||||||
{user.phone && <p className="text-[10px] text-gray-400">{user.phone}</p>}
|
{user.phone && <p className="text-[10px] text-gray-400">{user.phone}</p>}
|
||||||
{user.rucNumber && <p className="text-[10px] text-gray-400">RUC: {user.rucNumber}</p>}
|
{user.rucNumber && <p className="text-[10px] text-gray-400">RUC: {formatRucDisplay(user.rucNumber)}</p>}
|
||||||
</div>
|
</div>
|
||||||
{getRoleBadge(user.role)}
|
{getRoleBadge(user.role)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -73,8 +73,22 @@ export default function LinktreePage() {
|
|||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="bg-white/10 backdrop-blur-sm rounded-2xl p-6 text-center">
|
<div
|
||||||
<div className="animate-spin w-6 h-6 border-2 border-primary-yellow border-t-transparent rounded-full mx-auto" />
|
className="bg-white/10 backdrop-blur-sm rounded-2xl p-5 border border-white/10 animate-pulse"
|
||||||
|
role="status"
|
||||||
|
aria-busy="true"
|
||||||
|
>
|
||||||
|
<span className="sr-only">Loading…</span>
|
||||||
|
<div className="h-6 w-2/3 rounded-lg bg-white/15" />
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
<div className="h-4 w-3/4 rounded-lg bg-white/15" />
|
||||||
|
<div className="h-4 w-1/2 rounded-lg bg-white/15" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex items-center justify-between">
|
||||||
|
<div className="h-5 w-20 rounded-lg bg-white/15" />
|
||||||
|
<div className="h-4 w-24 rounded-lg bg-white/15" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 h-12 rounded-xl bg-white/15" />
|
||||||
</div>
|
</div>
|
||||||
) : nextEvent ? (
|
) : nextEvent ? (
|
||||||
<Link href={`/events/${nextEvent.slug}`} className="block group">
|
<Link href={`/events/${nextEvent.slug}`} className="block group">
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
// Skeleton loading previews. The base block uses Tailwind's animate-pulse and
|
||||||
|
// the light-gray surface tokens so placeholders match real card/table styling.
|
||||||
|
// Composites below mirror the layouts they stand in for (event cards, admin
|
||||||
|
// tables, FAQ accordions, article bodies) to avoid layout shift when data lands.
|
||||||
|
|
||||||
|
interface SkeletonProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Skeleton({ className }: SkeletonProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className={clsx('animate-pulse rounded-lg bg-secondary-light-gray/70', className)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Screen-reader announcement wrapper for a group of skeleton blocks.
|
||||||
|
export function SkeletonGroup({ className, children }: SkeletonProps & { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div role="status" aria-busy="true" className={className}>
|
||||||
|
<span className="sr-only">Loading…</span>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paragraph-style stack of text lines with a shorter last line.
|
||||||
|
export function SkeletonText({ lines = 3, className }: SkeletonProps & { lines?: number }) {
|
||||||
|
return (
|
||||||
|
<div className={clsx('space-y-2.5', className)} aria-hidden="true">
|
||||||
|
{Array.from({ length: lines }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className={clsx('h-4', i === lines - 1 ? 'w-2/3' : 'w-full')} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirrors the event card in EventsClient: banner, title, meta rows, price + button.
|
||||||
|
export function EventCardSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-card overflow-hidden shadow-card h-full" aria-hidden="true">
|
||||||
|
<Skeleton className="h-40 w-full rounded-none" />
|
||||||
|
<div className="p-6">
|
||||||
|
<Skeleton className="h-6 w-3/4" />
|
||||||
|
<div className="mt-4 space-y-2">
|
||||||
|
<Skeleton className="h-4 w-2/3" />
|
||||||
|
<Skeleton className="h-4 w-1/2" />
|
||||||
|
<Skeleton className="h-4 w-2/5" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 flex items-center justify-between">
|
||||||
|
<Skeleton className="h-7 w-24" />
|
||||||
|
<Skeleton className="h-9 w-24 rounded-btn" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EventGridSkeleton({ count = 6 }: { count?: number }) {
|
||||||
|
return (
|
||||||
|
<SkeletonGroup className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{Array.from({ length: count }).map((_, i) => (
|
||||||
|
<EventCardSkeleton key={i} />
|
||||||
|
))}
|
||||||
|
</SkeletonGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirrors the featured "next event" hero card on the homepage: side banner + info column.
|
||||||
|
export function FeaturedEventSkeleton() {
|
||||||
|
return (
|
||||||
|
<SkeletonGroup>
|
||||||
|
<div className="bg-gray-50 border border-gray-200 rounded-2xl overflow-hidden shadow-lg">
|
||||||
|
<div className="flex flex-col md:flex-row">
|
||||||
|
<Skeleton className="w-full md:w-2/5 flex-shrink-0 h-48 md:h-auto md:min-h-[280px] rounded-none" />
|
||||||
|
<div className="flex-1 p-5 md:p-8">
|
||||||
|
<Skeleton className="h-7 w-2/3" />
|
||||||
|
<Skeleton className="mt-3 h-4 w-full" />
|
||||||
|
<Skeleton className="mt-2 h-4 w-5/6" />
|
||||||
|
<div className="mt-4 md:mt-5 space-y-2.5">
|
||||||
|
<Skeleton className="h-4 w-48" />
|
||||||
|
<Skeleton className="h-4 w-32" />
|
||||||
|
<Skeleton className="h-4 w-56" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-5 md:mt-6 flex items-center justify-between gap-4">
|
||||||
|
<Skeleton className="h-9 w-28" />
|
||||||
|
<Skeleton className="h-10 w-28 rounded-xl" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SkeletonGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accordion-style rows for FAQ lists. `compact` matches the homepage variant
|
||||||
|
// (rounded-btn, px-5); the default matches the /faq page cards (px-6, more spacing).
|
||||||
|
export function FaqListSkeleton({ count = 5, compact = false }: { count?: number; compact?: boolean }) {
|
||||||
|
return (
|
||||||
|
<SkeletonGroup className={compact ? 'space-y-3' : 'space-y-4'}>
|
||||||
|
{Array.from({ length: count }).map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={clsx(
|
||||||
|
'bg-white border border-gray-200 flex items-center justify-between',
|
||||||
|
compact ? 'rounded-btn px-5 py-4' : 'rounded-card px-6 py-4 shadow-card border-0'
|
||||||
|
)}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<Skeleton className={clsx('h-5', i % 2 === 0 ? 'w-3/5' : 'w-2/5')} />
|
||||||
|
<Skeleton className="h-5 w-5 rounded-full flex-shrink-0" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</SkeletonGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Long-form content: heading, meta line, then paragraph blocks.
|
||||||
|
export function ArticleSkeleton({ withBanner = false }: { withBanner?: boolean }) {
|
||||||
|
return (
|
||||||
|
<SkeletonGroup>
|
||||||
|
{withBanner && <Skeleton className="h-56 md:h-80 w-full rounded-card mb-8" />}
|
||||||
|
<Skeleton className="h-9 w-3/4 md:w-1/2" />
|
||||||
|
<Skeleton className="mt-3 h-4 w-40" />
|
||||||
|
<div className="mt-8 space-y-6">
|
||||||
|
<SkeletonText lines={4} />
|
||||||
|
<SkeletonText lines={3} />
|
||||||
|
<Skeleton className="h-6 w-1/3" />
|
||||||
|
<SkeletonText lines={4} />
|
||||||
|
</div>
|
||||||
|
</SkeletonGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stacked list of card rows (dashboard tickets/payments, contact lists).
|
||||||
|
export function CardListSkeleton({ count = 3 }: { count?: number }) {
|
||||||
|
return (
|
||||||
|
<SkeletonGroup className="space-y-4">
|
||||||
|
{Array.from({ length: count }).map((_, i) => (
|
||||||
|
<div key={i} className="bg-white rounded-card shadow-card p-5" aria-hidden="true">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Skeleton className="h-5 w-1/3" />
|
||||||
|
<Skeleton className="mt-2.5 h-4 w-1/2" />
|
||||||
|
<Skeleton className="mt-2 h-4 w-2/5" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-6 w-20 rounded-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</SkeletonGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin data tables: desktop table inside a Card, mobile stacked cards —
|
||||||
|
// mirrors the two-breakpoint layout every admin list page renders.
|
||||||
|
export function TableSkeleton({ rows = 6, cols = 5 }: { rows?: number; cols?: number }) {
|
||||||
|
return (
|
||||||
|
<SkeletonGroup>
|
||||||
|
{/* Desktop: table */}
|
||||||
|
<div className="bg-white rounded-card overflow-hidden shadow-card hidden md:block" aria-hidden="true">
|
||||||
|
<div className="bg-secondary-gray px-4 py-3 flex gap-4">
|
||||||
|
{Array.from({ length: cols }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-3.5 flex-1 bg-secondary-light-gray" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-secondary-light-gray">
|
||||||
|
{Array.from({ length: rows }).map((_, r) => (
|
||||||
|
<div key={r} className="px-4 py-3.5 flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-3 flex-1">
|
||||||
|
<Skeleton className="w-10 h-10 rounded-lg flex-shrink-0" />
|
||||||
|
<Skeleton className="h-4 w-2/3" />
|
||||||
|
</div>
|
||||||
|
{Array.from({ length: cols - 1 }).map((_, c) => (
|
||||||
|
<Skeleton key={c} className={clsx('h-4 flex-1', c === cols - 2 && 'max-w-[80px]')} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Mobile: card list */}
|
||||||
|
<div className="md:hidden space-y-3" aria-hidden="true">
|
||||||
|
{Array.from({ length: Math.min(rows, 4) }).map((_, i) => (
|
||||||
|
<div key={i} className="bg-white rounded-card shadow-card p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Skeleton className="w-12 h-12 rounded-lg flex-shrink-0" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<Skeleton className="h-4 w-3/4" />
|
||||||
|
<Skeleton className="mt-2 h-3.5 w-1/2" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-6 w-16 rounded-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SkeletonGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full admin page placeholder: title bar + table. Used by the `if (loading)`
|
||||||
|
// gates that replace the entire page, so it includes the header row.
|
||||||
|
export function AdminPageSkeleton({ rows = 6, cols = 5 }: { rows?: number; cols?: number }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<Skeleton className="h-8 w-48" />
|
||||||
|
<Skeleton className="h-10 w-36 rounded-btn hidden md:block" />
|
||||||
|
</div>
|
||||||
|
<TableSkeleton rows={rows} cols={cols} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thumbnail grid (admin gallery/media pages).
|
||||||
|
export function ImageGridSkeleton({ count = 12 }: { count?: number }) {
|
||||||
|
return (
|
||||||
|
<SkeletonGroup className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||||
|
{Array.from({ length: count }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="aspect-square w-full rounded-card" />
|
||||||
|
))}
|
||||||
|
</SkeletonGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -166,6 +166,18 @@ export function formatCurrency(amount: number, currency: string = 'PYG'): string
|
|||||||
return formatPrice(amount, currency);
|
return formatPrice(amount, currency);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a Paraguayan RUC for display as "base-checkdigit" (e.g. 1234567-9).
|
||||||
|
* Legacy records stored digits only; insert the dash before the check digit.
|
||||||
|
*/
|
||||||
|
export function formatRucDisplay(ruc: string | null | undefined): string {
|
||||||
|
if (!ruc) return '';
|
||||||
|
if (ruc.includes('-')) return ruc;
|
||||||
|
const digits = ruc.replace(/\D/g, '');
|
||||||
|
if (digits.length < 2) return ruc;
|
||||||
|
return `${digits.slice(0, -1)}-${digits.slice(-1)}`;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Payment helpers
|
// Payment helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user