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

@@ -388,6 +388,7 @@ async function migrate() {
instagram_url TEXT, instagram_url TEXT,
twitter_url TEXT, twitter_url TEXT,
linkedin_url TEXT, linkedin_url TEXT,
featured_event_id TEXT REFERENCES events(id),
maintenance_mode INTEGER NOT NULL DEFAULT 0, maintenance_mode INTEGER NOT NULL DEFAULT 0,
maintenance_message TEXT, maintenance_message TEXT,
maintenance_message_es TEXT, maintenance_message_es TEXT,
@@ -396,6 +397,11 @@ async function migrate() {
) )
`); `);
// Add featured_event_id column to site_settings if it doesn't exist
try {
await (db as any).run(sql`ALTER TABLE site_settings ADD COLUMN featured_event_id TEXT REFERENCES events(id)`);
} catch (e) { /* column may already exist */ }
// Legal pages table for admin-editable legal content // Legal pages table for admin-editable legal content
await (db as any).run(sql` await (db as any).run(sql`
CREATE TABLE IF NOT EXISTS legal_pages ( CREATE TABLE IF NOT EXISTS legal_pages (
@@ -748,6 +754,7 @@ async function migrate() {
instagram_url VARCHAR(500), instagram_url VARCHAR(500),
twitter_url VARCHAR(500), twitter_url VARCHAR(500),
linkedin_url VARCHAR(500), linkedin_url VARCHAR(500),
featured_event_id UUID REFERENCES events(id),
maintenance_mode INTEGER NOT NULL DEFAULT 0, maintenance_mode INTEGER NOT NULL DEFAULT 0,
maintenance_message TEXT, maintenance_message TEXT,
maintenance_message_es TEXT, maintenance_message_es TEXT,
@@ -756,6 +763,11 @@ async function migrate() {
) )
`); `);
// Add featured_event_id column to site_settings if it doesn't exist
try {
await (db as any).execute(sql`ALTER TABLE site_settings ADD COLUMN featured_event_id UUID REFERENCES events(id)`);
} catch (e) { /* column may already exist */ }
// Legal pages table for admin-editable legal content // Legal pages table for admin-editable legal content
await (db as any).execute(sql` await (db as any).execute(sql`
CREATE TABLE IF NOT EXISTS legal_pages ( CREATE TABLE IF NOT EXISTS legal_pages (

View File

@@ -283,6 +283,8 @@ export const sqliteSiteSettings = sqliteTable('site_settings', {
instagramUrl: text('instagram_url'), instagramUrl: text('instagram_url'),
twitterUrl: text('twitter_url'), twitterUrl: text('twitter_url'),
linkedinUrl: text('linkedin_url'), linkedinUrl: text('linkedin_url'),
// Featured event - manually promoted event shown on homepage/linktree
featuredEventId: text('featured_event_id').references(() => sqliteEvents.id),
// Other settings // Other settings
maintenanceMode: integer('maintenance_mode', { mode: 'boolean' }).notNull().default(false), maintenanceMode: integer('maintenance_mode', { mode: 'boolean' }).notNull().default(false),
maintenanceMessage: text('maintenance_message'), maintenanceMessage: text('maintenance_message'),
@@ -563,6 +565,8 @@ export const pgSiteSettings = pgTable('site_settings', {
instagramUrl: varchar('instagram_url', { length: 500 }), instagramUrl: varchar('instagram_url', { length: 500 }),
twitterUrl: varchar('twitter_url', { length: 500 }), twitterUrl: varchar('twitter_url', { length: 500 }),
linkedinUrl: varchar('linkedin_url', { length: 500 }), linkedinUrl: varchar('linkedin_url', { length: 500 }),
// Featured event - manually promoted event shown on homepage/linktree
featuredEventId: uuid('featured_event_id').references(() => pgEvents.id),
// Other settings // Other settings
maintenanceMode: pgInteger('maintenance_mode').notNull().default(0), maintenanceMode: pgInteger('maintenance_mode').notNull().default(0),
maintenanceMessage: pgText('maintenance_message'), maintenanceMessage: pgText('maintenance_message'),

View File

@@ -1,7 +1,7 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator'; import { zValidator } from '@hono/zod-validator';
import { z } from 'zod'; import { z } from 'zod';
import { db, dbGet, dbAll, events, tickets, payments, eventPaymentOverrides, emailLogs, invoices } from '../db/index.js'; import { db, dbGet, dbAll, events, tickets, payments, eventPaymentOverrides, emailLogs, invoices, siteSettings } from '../db/index.js';
import { eq, desc, and, gte, sql } from 'drizzle-orm'; import { eq, desc, and, gte, sql } from 'drizzle-orm';
import { requireAuth, getAuthUser } from '../lib/auth.js'; import { requireAuth, getAuthUser } from '../lib/auth.js';
import { generateId, getNow, convertBooleansForDb, toDbDate } from '../lib/utils.js'; import { generateId, getNow, convertBooleansForDb, toDbDate } from '../lib/utils.js';
@@ -198,10 +198,92 @@ eventsRouter.get('/:id', async (c) => {
}); });
}); });
// Get next upcoming event (public) // Helper function to get ticket count for an event
async function getEventTicketCount(eventId: string): Promise<number> {
const ticketCount = await dbGet<any>(
(db as any)
.select({ count: sql<number>`count(*)` })
.from(tickets)
.where(
and(
eq((tickets as any).eventId, eventId),
sql`${(tickets as any).status} IN ('confirmed', 'checked_in')`
)
)
);
return ticketCount?.count || 0;
}
// Get next upcoming event (public) - returns featured event if valid, otherwise next upcoming
eventsRouter.get('/next/upcoming', async (c) => { eventsRouter.get('/next/upcoming', async (c) => {
const now = getNow(); const now = getNow();
// First, check if there's a featured event in site settings
const settings = await dbGet<any>(
(db as any).select().from(siteSettings).limit(1)
);
let featuredEvent = null;
let shouldUnsetFeatured = false;
if (settings?.featuredEventId) {
// Get the featured event
featuredEvent = await dbGet<any>(
(db as any)
.select()
.from(events)
.where(eq((events as any).id, settings.featuredEventId))
);
if (featuredEvent) {
// Check if featured event is still valid:
// 1. Must be published
// 2. Must not have ended (endDatetime >= now, or startDatetime >= now if no endDatetime)
const eventEndTime = featuredEvent.endDatetime || featuredEvent.startDatetime;
const isPublished = featuredEvent.status === 'published';
const hasNotEnded = eventEndTime >= now;
if (!isPublished || !hasNotEnded) {
// Featured event is no longer valid - mark for unsetting
shouldUnsetFeatured = true;
featuredEvent = null;
}
} else {
// Featured event no longer exists
shouldUnsetFeatured = true;
}
}
// If we need to unset the featured event, do it asynchronously
if (shouldUnsetFeatured && settings) {
// Unset featured event in background (don't await to avoid blocking response)
(db as any)
.update(siteSettings)
.set({ featuredEventId: null, updatedAt: now })
.where(eq((siteSettings as any).id, settings.id))
.then(() => {
console.log('Featured event auto-cleared (event ended or unpublished)');
})
.catch((err: any) => {
console.error('Failed to clear featured event:', err);
});
}
// If we have a valid featured event, return it
if (featuredEvent) {
const bookedCount = await getEventTicketCount(featuredEvent.id);
const normalized = normalizeEvent(featuredEvent);
return c.json({
event: {
...normalized,
bookedCount,
availableSeats: normalized.capacity - bookedCount,
isFeatured: true,
},
});
}
// Fallback: get the next upcoming published event
const event = await dbGet<any>( const event = await dbGet<any>(
(db as any) (db as any)
.select() .select()
@@ -220,26 +302,14 @@ eventsRouter.get('/next/upcoming', async (c) => {
return c.json({ event: null }); return c.json({ event: null });
} }
// Count confirmed AND checked_in tickets (checked_in were previously confirmed) const bookedCount = await getEventTicketCount(event.id);
// This ensures check-in doesn't affect capacity/spots_left
const ticketCount = await dbGet<any>(
(db as any)
.select({ count: sql<number>`count(*)` })
.from(tickets)
.where(
and(
eq((tickets as any).eventId, event.id),
sql`${(tickets as any).status} IN ('confirmed', 'checked_in')`
)
)
);
const normalized = normalizeEvent(event); const normalized = normalizeEvent(event);
return c.json({ return c.json({
event: { event: {
...normalized, ...normalized,
bookedCount: ticketCount?.count || 0, bookedCount,
availableSeats: normalized.capacity - (ticketCount?.count || 0), availableSeats: normalized.capacity - bookedCount,
isFeatured: false,
}, },
}); });
}); });

View File

@@ -1,8 +1,8 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator'; import { zValidator } from '@hono/zod-validator';
import { z } from 'zod'; import { z } from 'zod';
import { db, dbGet, siteSettings } from '../db/index.js'; import { db, dbGet, siteSettings, events } from '../db/index.js';
import { eq } from 'drizzle-orm'; import { eq, and, gte } from 'drizzle-orm';
import { requireAuth } from '../lib/auth.js'; import { requireAuth } from '../lib/auth.js';
import { generateId, getNow, toDbBool } from '../lib/utils.js'; import { generateId, getNow, toDbBool } from '../lib/utils.js';
@@ -27,6 +27,7 @@ const updateSiteSettingsSchema = z.object({
instagramUrl: z.string().url().optional().nullable().or(z.literal('')), instagramUrl: z.string().url().optional().nullable().or(z.literal('')),
twitterUrl: z.string().url().optional().nullable().or(z.literal('')), twitterUrl: z.string().url().optional().nullable().or(z.literal('')),
linkedinUrl: z.string().url().optional().nullable().or(z.literal('')), linkedinUrl: z.string().url().optional().nullable().or(z.literal('')),
featuredEventId: z.string().optional().nullable(),
maintenanceMode: z.boolean().optional(), maintenanceMode: z.boolean().optional(),
maintenanceMessage: z.string().optional().nullable(), maintenanceMessage: z.string().optional().nullable(),
maintenanceMessageEs: z.string().optional().nullable(), maintenanceMessageEs: z.string().optional().nullable(),
@@ -52,6 +53,7 @@ siteSettingsRouter.get('/', async (c) => {
instagramUrl: null, instagramUrl: null,
twitterUrl: null, twitterUrl: null,
linkedinUrl: null, linkedinUrl: null,
featuredEventId: null,
maintenanceMode: false, maintenanceMode: false,
maintenanceMessage: null, maintenanceMessage: null,
maintenanceMessageEs: null, maintenanceMessageEs: null,
@@ -104,6 +106,17 @@ siteSettingsRouter.put('/', requireAuth(['admin']), zValidator('json', updateSit
if (!existing) { if (!existing) {
// Create new settings record // Create new settings record
const id = generateId(); const id = generateId();
// Validate featured event if provided
if (data.featuredEventId) {
const featuredEvent = await dbGet<any>(
(db as any).select().from(events).where(eq((events as any).id, data.featuredEventId))
);
if (!featuredEvent || featuredEvent.status !== 'published') {
return c.json({ error: 'Featured event must exist and be published' }, 400);
}
}
const newSettings = { const newSettings = {
id, id,
timezone: data.timezone || 'America/Asuncion', timezone: data.timezone || 'America/Asuncion',
@@ -116,6 +129,7 @@ siteSettingsRouter.put('/', requireAuth(['admin']), zValidator('json', updateSit
instagramUrl: data.instagramUrl || null, instagramUrl: data.instagramUrl || null,
twitterUrl: data.twitterUrl || null, twitterUrl: data.twitterUrl || null,
linkedinUrl: data.linkedinUrl || null, linkedinUrl: data.linkedinUrl || null,
featuredEventId: data.featuredEventId || null,
maintenanceMode: toDbBool(data.maintenanceMode || false), maintenanceMode: toDbBool(data.maintenanceMode || false),
maintenanceMessage: data.maintenanceMessage || null, maintenanceMessage: data.maintenanceMessage || null,
maintenanceMessageEs: data.maintenanceMessageEs || null, maintenanceMessageEs: data.maintenanceMessageEs || null,
@@ -128,6 +142,16 @@ siteSettingsRouter.put('/', requireAuth(['admin']), zValidator('json', updateSit
return c.json({ settings: newSettings, message: 'Settings created successfully' }, 201); return c.json({ settings: newSettings, message: 'Settings created successfully' }, 201);
} }
// Validate featured event if provided
if (data.featuredEventId) {
const featuredEvent = await dbGet<any>(
(db as any).select().from(events).where(eq((events as any).id, data.featuredEventId))
);
if (!featuredEvent || featuredEvent.status !== 'published') {
return c.json({ error: 'Featured event must exist and be published' }, 400);
}
}
// Update existing settings // Update existing settings
const updateData: Record<string, any> = { const updateData: Record<string, any> = {
...data, ...data,
@@ -151,4 +175,61 @@ siteSettingsRouter.put('/', requireAuth(['admin']), zValidator('json', updateSit
return c.json({ settings: updated, message: 'Settings updated successfully' }); return c.json({ settings: updated, message: 'Settings updated successfully' });
}); });
// Set featured event (admin only) - convenience endpoint for event editor
siteSettingsRouter.put('/featured-event', requireAuth(['admin']), zValidator('json', z.object({
eventId: z.string().nullable(),
})), async (c) => {
const { eventId } = c.req.valid('json');
const user = c.get('user');
const now = getNow();
// Validate event if provided
if (eventId) {
const event = await dbGet<any>(
(db as any).select().from(events).where(eq((events as any).id, eventId))
);
if (!event) {
return c.json({ error: 'Event not found' }, 404);
}
if (event.status !== 'published') {
return c.json({ error: 'Event must be published to be featured' }, 400);
}
}
// Get or create settings
const existing = await dbGet<any>(
(db as any).select().from(siteSettings).limit(1)
);
if (!existing) {
// Create new settings record with featured event
const id = generateId();
const newSettings = {
id,
timezone: 'America/Asuncion',
siteName: 'Spanglish',
featuredEventId: eventId,
maintenanceMode: 0,
updatedAt: now,
updatedBy: user.id,
};
await (db as any).insert(siteSettings).values(newSettings);
return c.json({ featuredEventId: eventId, message: eventId ? 'Event set as featured' : 'Featured event removed' });
}
// Update existing settings
await (db as any)
.update(siteSettings)
.set({
featuredEventId: eventId,
updatedAt: now,
updatedBy: user.id,
})
.where(eq((siteSettings as any).id, existing.id));
return c.json({ featuredEventId: eventId, message: eventId ? 'Event set as featured' : 'Featured event removed' });
});
export default siteSettingsRouter; export default siteSettingsRouter;

View File

@@ -25,7 +25,7 @@ const createTicketSchema = z.object({
phone: z.string().min(6).optional().or(z.literal('')), phone: z.string().min(6).optional().or(z.literal('')),
preferredLanguage: z.enum(['en', 'es']).optional(), preferredLanguage: z.enum(['en', 'es']).optional(),
paymentMethod: z.enum(['bancard', 'lightning', 'cash', 'bank_transfer', 'tpago']).default('cash'), paymentMethod: z.enum(['bancard', 'lightning', 'cash', 'bank_transfer', 'tpago']).default('cash'),
ruc: z.string().regex(/^[0-9]{6,8}-[0-9]{1}$/, 'Invalid RUC format').optional(), ruc: z.string().regex(/^\d{6,10}$/, 'Invalid RUC format').optional().or(z.literal('')),
// Optional: array of attendees for multi-ticket booking // Optional: array of attendees for multi-ticket booking
attendees: z.array(attendeeSchema).optional(), attendees: z.array(attendeeSchema).optional(),
}); });

View File

@@ -110,43 +110,12 @@ export default function BookingPage() {
const [errors, setErrors] = useState<Partial<Record<keyof BookingFormData, string>>>({}); const [errors, setErrors] = useState<Partial<Record<keyof BookingFormData, string>>>({});
// RUC validation using modulo 11 algorithm const rucPattern = /^\d{6,10}$/;
const validateRucCheckDigit = (ruc: string): boolean => {
const match = ruc.match(/^(\d{6,8})-(\d)$/);
if (!match) return false;
const baseNumber = match[1]; // Format RUC input: digits only, max 10
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;
};
// Format RUC input: auto-insert hyphen before last digit
const formatRuc = (value: string): string => { const formatRuc = (value: string): string => {
// Remove non-numeric characters const digits = value.replace(/\D/g, '').slice(0, 10);
const digits = value.replace(/\D/g, ''); return digits;
// 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;
}; };
// Handle RUC input change // 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 = () => { const handleRucBlur = () => {
if (!formData.ruc) return; // Optional field, no validation if empty if (!formData.ruc) return;
const digits = formData.ruc.replace(/\D/g, '');
const rucPattern = /^[0-9]{6,8}-[0-9]{1}$/; if (digits.length > 0 && !rucPattern.test(digits)) {
if (!rucPattern.test(formData.ruc)) {
setErrors({ ...errors, ruc: t('booking.form.errors.rucInvalidFormat') }); 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'); 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()) { if (formData.ruc.trim()) {
const rucPattern = /^[0-9]{6,8}-[0-9]{1}$/; const digits = formData.ruc.replace(/\D/g, '');
if (!rucPattern.test(formData.ruc)) { if (!/^\d{6,10}$/.test(digits)) {
newErrors.ruc = t('booking.form.errors.rucInvalidFormat'); 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, phone: formData.phone,
preferredLanguage: formData.preferredLanguage, preferredLanguage: formData.preferredLanguage,
paymentMethod: formData.paymentMethod, 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 // Include attendees array for multi-ticket bookings
...(allAttendees.length > 1 && { attendees: allAttendees }), ...(allAttendees.length > 1 && { attendees: allAttendees }),
}); });

View File

@@ -3,12 +3,13 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useLanguage } from '@/context/LanguageContext'; 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 Card from '@/components/ui/Card';
import Button from '@/components/ui/Button'; import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input'; import Input from '@/components/ui/Input';
import MediaPicker from '@/components/MediaPicker'; 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 toast from 'react-hot-toast';
import clsx from 'clsx'; import clsx from 'clsx';
@@ -19,6 +20,8 @@ export default function AdminEventsPage() {
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
const [editingEvent, setEditingEvent] = useState<Event | null>(null); const [editingEvent, setEditingEvent] = useState<Event | null>(null);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [featuredEventId, setFeaturedEventId] = useState<string | null>(null);
const [settingFeatured, setSettingFeatured] = useState<string | null>(null);
const [formData, setFormData] = useState<{ const [formData, setFormData] = useState<{
title: string; title: string;
@@ -60,6 +63,7 @@ export default function AdminEventsPage() {
useEffect(() => { useEffect(() => {
loadEvents(); loadEvents();
loadFeaturedEvent();
}, []); }, []);
const loadEvents = async () => { 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 = () => { const resetForm = () => {
setFormData({ setFormData({
title: '', title: '',
@@ -455,6 +481,44 @@ export default function AdminEventsPage() {
relatedType="event" 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"> <div className="flex gap-3 pt-4">
<Button type="submit" isLoading={saving}> <Button type="submit" isLoading={saving}>
{editingEvent ? 'Update Event' : 'Create Event'} {editingEvent ? 'Update Event' : 'Create Event'}
@@ -494,7 +558,7 @@ export default function AdminEventsPage() {
</tr> </tr>
) : ( ) : (
events.map((event) => ( 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"> <td className="px-6 py-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{event.bannerUrl ? ( {event.bannerUrl ? (
@@ -509,7 +573,15 @@ export default function AdminEventsPage() {
</div> </div>
)} )}
<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> <p className="text-sm text-gray-500 truncate max-w-xs">{event.location}</p>
</div> </div>
</div> </div>
@@ -534,6 +606,25 @@ export default function AdminEventsPage() {
Publish Publish
</Button> </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 <Link
href={`/admin/events/${event.id}`} href={`/admin/events/${event.id}`}
className="p-2 hover:bg-primary-yellow/20 text-primary-dark rounded-btn" className="p-2 hover:bg-primary-yellow/20 text-primary-dark rounded-btn"

View File

@@ -1,8 +1,9 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import Link from 'next/link';
import { useLanguage } from '@/context/LanguageContext'; 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 Card from '@/components/ui/Card';
import Button from '@/components/ui/Button'; import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input'; import Input from '@/components/ui/Input';
@@ -13,6 +14,7 @@ import {
EnvelopeIcon, EnvelopeIcon,
WrenchScrewdriverIcon, WrenchScrewdriverIcon,
CheckCircleIcon, CheckCircleIcon,
StarIcon,
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
@@ -21,6 +23,8 @@ export default function AdminSettingsPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [timezones, setTimezones] = useState<TimezoneOption[]>([]); const [timezones, setTimezones] = useState<TimezoneOption[]>([]);
const [featuredEvent, setFeaturedEvent] = useState<Event | null>(null);
const [clearingFeatured, setClearingFeatured] = useState(false);
const [settings, setSettings] = useState<SiteSettings>({ const [settings, setSettings] = useState<SiteSettings>({
timezone: 'America/Asuncion', timezone: 'America/Asuncion',
@@ -33,6 +37,7 @@ export default function AdminSettingsPage() {
instagramUrl: null, instagramUrl: null,
twitterUrl: null, twitterUrl: null,
linkedinUrl: null, linkedinUrl: null,
featuredEventId: null,
maintenanceMode: false, maintenanceMode: false,
maintenanceMessage: null, maintenanceMessage: null,
maintenanceMessageEs: null, maintenanceMessageEs: null,
@@ -50,6 +55,17 @@ export default function AdminSettingsPage() {
]); ]);
setSettings(settingsRes.settings); setSettings(settingsRes.settings);
setTimezones(timezonesRes.timezones); 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) { } catch (error) {
toast.error('Failed to load settings'); toast.error('Failed to load settings');
} finally { } 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 () => { const handleSave = async () => {
setSaving(true); setSaving(true);
try { try {
@@ -146,6 +176,93 @@ export default function AdminSettingsPage() {
</div> </div>
</Card> </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 */} {/* Site Information */}
<Card> <Card>
<div className="p-6"> <div className="p-6">

View File

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