feat: add featured event with automatic fallback

- Add featured_event_id to site_settings (schema + migration)
- Backend: featured event logic in /events/next/upcoming with auto-unset when event ends
- Site settings: PUT supports featuredEventId, add PUT /featured-event for admin
- Admin events: Set as featured checkbox in editor, star toggle in list, featured badge
- Admin settings: Featured Event section with current event and remove/change links
- API: siteSettingsApi.setFeaturedEvent(), Event.isFeatured, SiteSettings.featuredEventId
- Homepage/linktree unchanged: still use getNextUpcoming (now returns featured or fallback)
This commit is contained in:
Michilis
2026-02-03 19:24:00 +00:00
parent 0fd8172e04
commit 0c142884c7
9 changed files with 421 additions and 78 deletions

View File

@@ -110,43 +110,12 @@ export default function BookingPage() {
const [errors, setErrors] = useState<Partial<Record<keyof BookingFormData, string>>>({});
// RUC validation using modulo 11 algorithm
const validateRucCheckDigit = (ruc: string): boolean => {
const match = ruc.match(/^(\d{6,8})-(\d)$/);
if (!match) return false;
const baseNumber = match[1];
const checkDigit = parseInt(match[2], 10);
// Modulo 11 algorithm for Paraguayan RUC
const weights = [2, 3, 4, 5, 6, 7, 2, 3];
let sum = 0;
const digits = baseNumber.split('').reverse();
for (let i = 0; i < digits.length; i++) {
sum += parseInt(digits[i], 10) * weights[i];
}
const remainder = sum % 11;
const expectedCheckDigit = remainder < 2 ? 0 : 11 - remainder;
return checkDigit === expectedCheckDigit;
};
const rucPattern = /^\d{6,10}$/;
// Format RUC input: auto-insert hyphen before last digit
// Format RUC input: digits only, max 10
const formatRuc = (value: string): string => {
// Remove non-numeric characters
const digits = value.replace(/\D/g, '');
// Limit to 9 digits (8 base + 1 check)
const limited = digits.slice(0, 9);
// Auto-insert hyphen before last digit if we have more than 6 digits
if (limited.length > 6) {
return `${limited.slice(0, -1)}-${limited.slice(-1)}`;
}
return limited;
const digits = value.replace(/\D/g, '').slice(0, 10);
return digits;
};
// Handle RUC input change
@@ -160,19 +129,12 @@ export default function BookingPage() {
}
};
// Validate RUC on blur
// Validate RUC on blur (optional field: 610 digits)
const handleRucBlur = () => {
if (!formData.ruc) return; // Optional field, no validation if empty
const rucPattern = /^[0-9]{6,8}-[0-9]{1}$/;
if (!rucPattern.test(formData.ruc)) {
if (!formData.ruc) return;
const digits = formData.ruc.replace(/\D/g, '');
if (digits.length > 0 && !rucPattern.test(digits)) {
setErrors({ ...errors, ruc: t('booking.form.errors.rucInvalidFormat') });
return;
}
if (!validateRucCheckDigit(formData.ruc)) {
setErrors({ ...errors, ruc: t('booking.form.errors.rucInvalidCheckDigit') });
}
};
@@ -275,13 +237,11 @@ export default function BookingPage() {
newErrors.phone = t('booking.form.errors.phoneTooShort');
}
// RUC validation (optional field - only validate if filled)
// RUC validation (optional field - 610 digits if filled)
if (formData.ruc.trim()) {
const rucPattern = /^[0-9]{6,8}-[0-9]{1}$/;
if (!rucPattern.test(formData.ruc)) {
const digits = formData.ruc.replace(/\D/g, '');
if (!/^\d{6,10}$/.test(digits)) {
newErrors.ruc = t('booking.form.errors.rucInvalidFormat');
} else if (!validateRucCheckDigit(formData.ruc)) {
newErrors.ruc = t('booking.form.errors.rucInvalidCheckDigit');
}
}
@@ -429,7 +389,7 @@ export default function BookingPage() {
phone: formData.phone,
preferredLanguage: formData.preferredLanguage,
paymentMethod: formData.paymentMethod,
...(formData.ruc.trim() && { ruc: formData.ruc }),
...(formData.ruc.trim() && { ruc: formData.ruc.replace(/\D/g, '') }),
// Include attendees array for multi-ticket bookings
...(allAttendees.length > 1 && { attendees: allAttendees }),
});

View File

@@ -3,12 +3,13 @@
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { useLanguage } from '@/context/LanguageContext';
import { eventsApi, Event } from '@/lib/api';
import { eventsApi, siteSettingsApi, Event } from '@/lib/api';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
import MediaPicker from '@/components/MediaPicker';
import { PlusIcon, PencilIcon, TrashIcon, EyeIcon, PhotoIcon, DocumentDuplicateIcon, ArchiveBoxIcon } from '@heroicons/react/24/outline';
import { PlusIcon, PencilIcon, TrashIcon, EyeIcon, PhotoIcon, DocumentDuplicateIcon, ArchiveBoxIcon, StarIcon } from '@heroicons/react/24/outline';
import { StarIcon as StarIconSolid } from '@heroicons/react/24/solid';
import toast from 'react-hot-toast';
import clsx from 'clsx';
@@ -19,6 +20,8 @@ export default function AdminEventsPage() {
const [showForm, setShowForm] = useState(false);
const [editingEvent, setEditingEvent] = useState<Event | null>(null);
const [saving, setSaving] = useState(false);
const [featuredEventId, setFeaturedEventId] = useState<string | null>(null);
const [settingFeatured, setSettingFeatured] = useState<string | null>(null);
const [formData, setFormData] = useState<{
title: string;
@@ -60,6 +63,7 @@ export default function AdminEventsPage() {
useEffect(() => {
loadEvents();
loadFeaturedEvent();
}, []);
const loadEvents = async () => {
@@ -73,6 +77,28 @@ export default function AdminEventsPage() {
}
};
const loadFeaturedEvent = async () => {
try {
const { settings } = await siteSettingsApi.get();
setFeaturedEventId(settings.featuredEventId || null);
} catch (error) {
// Ignore error - settings may not exist yet
}
};
const handleSetFeatured = async (eventId: string | null) => {
setSettingFeatured(eventId || 'clearing');
try {
await siteSettingsApi.setFeaturedEvent(eventId);
setFeaturedEventId(eventId);
toast.success(eventId ? 'Event set as featured' : 'Featured event removed');
} catch (error: any) {
toast.error(error.message || 'Failed to update featured event');
} finally {
setSettingFeatured(null);
}
};
const resetForm = () => {
setFormData({
title: '',
@@ -454,6 +480,44 @@ export default function AdminEventsPage() {
relatedId={editingEvent?.id}
relatedType="event"
/>
{/* Featured Event Section - Only show for published events when editing */}
{editingEvent && editingEvent.status === 'published' && (
<div className="border border-secondary-light-gray rounded-lg p-4 space-y-4 bg-amber-50">
<div className="flex items-center justify-between">
<div>
<label className="block text-sm font-medium text-gray-700 flex items-center gap-2">
<StarIcon className="w-5 h-5 text-amber-500" />
Featured Event
</label>
<p className="text-xs text-gray-500">
Featured events are prominently displayed on the homepage and linktree
</p>
</div>
<button
type="button"
disabled={settingFeatured !== null}
onClick={() => handleSetFeatured(
featuredEventId === editingEvent.id ? null : editingEvent.id
)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-amber-500 focus:ring-offset-2 disabled:opacity-50 ${
featuredEventId === editingEvent.id ? 'bg-amber-500' : 'bg-gray-200'
}`}
>
<span
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
featuredEventId === editingEvent.id ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
</div>
{featuredEventId && featuredEventId !== editingEvent.id && (
<p className="text-xs text-amber-700 bg-amber-100 p-2 rounded">
Note: Another event is currently featured. Setting this event as featured will replace it.
</p>
)}
</div>
)}
<div className="flex gap-3 pt-4">
<Button type="submit" isLoading={saving}>
@@ -494,7 +558,7 @@ export default function AdminEventsPage() {
</tr>
) : (
events.map((event) => (
<tr key={event.id} className="hover:bg-gray-50">
<tr key={event.id} className={clsx("hover:bg-gray-50", featuredEventId === event.id && "bg-amber-50")}>
<td className="px-6 py-4">
<div className="flex items-center gap-3">
{event.bannerUrl ? (
@@ -509,7 +573,15 @@ export default function AdminEventsPage() {
</div>
)}
<div>
<p className="font-medium">{event.title}</p>
<div className="flex items-center gap-2">
<p className="font-medium">{event.title}</p>
{featuredEventId === event.id && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800">
<StarIconSolid className="w-3 h-3" />
Featured
</span>
)}
</div>
<p className="text-sm text-gray-500 truncate max-w-xs">{event.location}</p>
</div>
</div>
@@ -534,6 +606,25 @@ export default function AdminEventsPage() {
Publish
</Button>
)}
{event.status === 'published' && (
<button
onClick={() => handleSetFeatured(featuredEventId === event.id ? null : event.id)}
disabled={settingFeatured !== null}
className={clsx(
"p-2 rounded-btn disabled:opacity-50",
featuredEventId === event.id
? "bg-amber-100 text-amber-600 hover:bg-amber-200"
: "hover:bg-amber-100 text-gray-400 hover:text-amber-600"
)}
title={featuredEventId === event.id ? "Remove from featured" : "Set as featured"}
>
{featuredEventId === event.id ? (
<StarIconSolid className="w-4 h-4" />
) : (
<StarIcon className="w-4 h-4" />
)}
</button>
)}
<Link
href={`/admin/events/${event.id}`}
className="p-2 hover:bg-primary-yellow/20 text-primary-dark rounded-btn"

View File

@@ -1,8 +1,9 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { useLanguage } from '@/context/LanguageContext';
import { siteSettingsApi, SiteSettings, TimezoneOption } from '@/lib/api';
import { siteSettingsApi, eventsApi, SiteSettings, TimezoneOption, Event } from '@/lib/api';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
@@ -13,6 +14,7 @@ import {
EnvelopeIcon,
WrenchScrewdriverIcon,
CheckCircleIcon,
StarIcon,
} from '@heroicons/react/24/outline';
import toast from 'react-hot-toast';
@@ -21,6 +23,8 @@ export default function AdminSettingsPage() {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [timezones, setTimezones] = useState<TimezoneOption[]>([]);
const [featuredEvent, setFeaturedEvent] = useState<Event | null>(null);
const [clearingFeatured, setClearingFeatured] = useState(false);
const [settings, setSettings] = useState<SiteSettings>({
timezone: 'America/Asuncion',
@@ -33,6 +37,7 @@ export default function AdminSettingsPage() {
instagramUrl: null,
twitterUrl: null,
linkedinUrl: null,
featuredEventId: null,
maintenanceMode: false,
maintenanceMessage: null,
maintenanceMessageEs: null,
@@ -50,6 +55,17 @@ export default function AdminSettingsPage() {
]);
setSettings(settingsRes.settings);
setTimezones(timezonesRes.timezones);
// Load featured event details if one is set
if (settingsRes.settings.featuredEventId) {
try {
const { event } = await eventsApi.getById(settingsRes.settings.featuredEventId);
setFeaturedEvent(event);
} catch {
// Featured event may no longer exist
setFeaturedEvent(null);
}
}
} catch (error) {
toast.error('Failed to load settings');
} finally {
@@ -57,6 +73,20 @@ export default function AdminSettingsPage() {
}
};
const handleClearFeatured = async () => {
setClearingFeatured(true);
try {
await siteSettingsApi.setFeaturedEvent(null);
setSettings(prev => ({ ...prev, featuredEventId: null }));
setFeaturedEvent(null);
toast.success(locale === 'es' ? 'Evento destacado eliminado' : 'Featured event removed');
} catch (error: any) {
toast.error(error.message || 'Failed to clear featured event');
} finally {
setClearingFeatured(false);
}
};
const handleSave = async () => {
setSaving(true);
try {
@@ -146,6 +176,93 @@ export default function AdminSettingsPage() {
</div>
</Card>
{/* Featured Event */}
<Card>
<div className="p-6">
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 bg-amber-100 rounded-full flex items-center justify-center">
<StarIcon className="w-5 h-5 text-amber-600" />
</div>
<div>
<h3 className="font-semibold text-lg">
{locale === 'es' ? 'Evento Destacado' : 'Featured Event'}
</h3>
<p className="text-sm text-gray-500">
{locale === 'es'
? 'El evento destacado aparece en la página de inicio y linktree'
: 'The featured event is displayed on the homepage and linktree'}
</p>
</div>
</div>
{featuredEvent ? (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
<div className="flex items-start justify-between gap-4">
<div className="flex items-center gap-3">
{featuredEvent.bannerUrl && (
<img
src={featuredEvent.bannerUrl}
alt={featuredEvent.title}
className="w-16 h-16 rounded-lg object-cover"
/>
)}
<div>
<p className="font-medium text-amber-900">{featuredEvent.title}</p>
<p className="text-sm text-amber-700">
{new Date(featuredEvent.startDatetime).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',
})}
</p>
<p className="text-xs text-amber-600 mt-1">
{locale === 'es' ? 'Estado:' : 'Status:'} {featuredEvent.status}
</p>
</div>
</div>
<div className="flex gap-2">
<Link
href="/admin/events"
className="text-sm text-amber-700 hover:text-amber-900 underline"
>
{locale === 'es' ? 'Cambiar' : 'Change'}
</Link>
<button
onClick={handleClearFeatured}
disabled={clearingFeatured}
className="text-sm text-red-600 hover:text-red-800 underline disabled:opacity-50"
>
{clearingFeatured
? (locale === 'es' ? 'Eliminando...' : 'Removing...')
: (locale === 'es' ? 'Eliminar' : 'Remove')}
</button>
</div>
</div>
</div>
) : (
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
<p className="text-gray-600 mb-3">
{locale === 'es'
? 'No hay evento destacado. El próximo evento publicado se mostrará automáticamente.'
: 'No featured event set. The next upcoming published event will be shown automatically.'}
</p>
<Link
href="/admin/events"
className="text-sm text-primary-yellow hover:underline font-medium"
>
{locale === 'es' ? 'Ir a Eventos para destacar uno' : 'Go to Events to feature one'}
</Link>
</div>
)}
<p className="text-xs text-gray-400 mt-3">
{locale === 'es'
? 'Cuando el evento destacado termine o se despublique, el sistema mostrará automáticamente el próximo evento.'
: 'When the featured event ends or is unpublished, the system will automatically show the next upcoming event.'}
</p>
</div>
</Card>
{/* Site Information */}
<Card>
<div className="p-6">

View File

@@ -439,6 +439,7 @@ export interface Event {
externalBookingUrl?: string;
bookedCount?: number;
availableSeats?: number;
isFeatured?: boolean;
createdAt: string;
updatedAt: string;
}
@@ -955,6 +956,7 @@ export interface SiteSettings {
instagramUrl?: string | null;
twitterUrl?: string | null;
linkedinUrl?: string | null;
featuredEventId?: string | null;
maintenanceMode: boolean;
maintenanceMessage?: string | null;
maintenanceMessageEs?: string | null;
@@ -978,6 +980,12 @@ export const siteSettingsApi = {
getTimezones: () =>
fetchApi<{ timezones: TimezoneOption[] }>('/api/site-settings/timezones'),
setFeaturedEvent: (eventId: string | null) =>
fetchApi<{ featuredEventId: string | null; message: string }>('/api/site-settings/featured-event', {
method: 'PUT',
body: JSON.stringify({ eventId }),
}),
};
// ==================== Legal Pages Types ====================