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:
@@ -388,6 +388,7 @@ async function migrate() {
|
||||
instagram_url TEXT,
|
||||
twitter_url TEXT,
|
||||
linkedin_url TEXT,
|
||||
featured_event_id TEXT REFERENCES events(id),
|
||||
maintenance_mode INTEGER NOT NULL DEFAULT 0,
|
||||
maintenance_message 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
|
||||
await (db as any).run(sql`
|
||||
CREATE TABLE IF NOT EXISTS legal_pages (
|
||||
@@ -748,6 +754,7 @@ async function migrate() {
|
||||
instagram_url VARCHAR(500),
|
||||
twitter_url VARCHAR(500),
|
||||
linkedin_url VARCHAR(500),
|
||||
featured_event_id UUID REFERENCES events(id),
|
||||
maintenance_mode INTEGER NOT NULL DEFAULT 0,
|
||||
maintenance_message 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
|
||||
await (db as any).execute(sql`
|
||||
CREATE TABLE IF NOT EXISTS legal_pages (
|
||||
|
||||
@@ -283,6 +283,8 @@ export const sqliteSiteSettings = sqliteTable('site_settings', {
|
||||
instagramUrl: text('instagram_url'),
|
||||
twitterUrl: text('twitter_url'),
|
||||
linkedinUrl: text('linkedin_url'),
|
||||
// Featured event - manually promoted event shown on homepage/linktree
|
||||
featuredEventId: text('featured_event_id').references(() => sqliteEvents.id),
|
||||
// Other settings
|
||||
maintenanceMode: integer('maintenance_mode', { mode: 'boolean' }).notNull().default(false),
|
||||
maintenanceMessage: text('maintenance_message'),
|
||||
@@ -563,6 +565,8 @@ export const pgSiteSettings = pgTable('site_settings', {
|
||||
instagramUrl: varchar('instagram_url', { length: 500 }),
|
||||
twitterUrl: varchar('twitter_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
|
||||
maintenanceMode: pgInteger('maintenance_mode').notNull().default(0),
|
||||
maintenanceMessage: pgText('maintenance_message'),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Hono } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
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 { requireAuth, getAuthUser } from '../lib/auth.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) => {
|
||||
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>(
|
||||
(db as any)
|
||||
.select()
|
||||
@@ -220,26 +302,14 @@ eventsRouter.get('/next/upcoming', async (c) => {
|
||||
return c.json({ event: null });
|
||||
}
|
||||
|
||||
// Count confirmed AND checked_in tickets (checked_in were previously confirmed)
|
||||
// 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 bookedCount = await getEventTicketCount(event.id);
|
||||
const normalized = normalizeEvent(event);
|
||||
return c.json({
|
||||
event: {
|
||||
...normalized,
|
||||
bookedCount: ticketCount?.count || 0,
|
||||
availableSeats: normalized.capacity - (ticketCount?.count || 0),
|
||||
bookedCount,
|
||||
availableSeats: normalized.capacity - bookedCount,
|
||||
isFeatured: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Hono } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { z } from 'zod';
|
||||
import { db, dbGet, siteSettings } from '../db/index.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db, dbGet, siteSettings, events } from '../db/index.js';
|
||||
import { eq, and, gte } from 'drizzle-orm';
|
||||
import { requireAuth } from '../lib/auth.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('')),
|
||||
twitterUrl: 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(),
|
||||
maintenanceMessage: z.string().optional().nullable(),
|
||||
maintenanceMessageEs: z.string().optional().nullable(),
|
||||
@@ -52,6 +53,7 @@ siteSettingsRouter.get('/', async (c) => {
|
||||
instagramUrl: null,
|
||||
twitterUrl: null,
|
||||
linkedinUrl: null,
|
||||
featuredEventId: null,
|
||||
maintenanceMode: false,
|
||||
maintenanceMessage: null,
|
||||
maintenanceMessageEs: null,
|
||||
@@ -104,6 +106,17 @@ siteSettingsRouter.put('/', requireAuth(['admin']), zValidator('json', updateSit
|
||||
if (!existing) {
|
||||
// Create new settings record
|
||||
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 = {
|
||||
id,
|
||||
timezone: data.timezone || 'America/Asuncion',
|
||||
@@ -116,6 +129,7 @@ siteSettingsRouter.put('/', requireAuth(['admin']), zValidator('json', updateSit
|
||||
instagramUrl: data.instagramUrl || null,
|
||||
twitterUrl: data.twitterUrl || null,
|
||||
linkedinUrl: data.linkedinUrl || null,
|
||||
featuredEventId: data.featuredEventId || null,
|
||||
maintenanceMode: toDbBool(data.maintenanceMode || false),
|
||||
maintenanceMessage: data.maintenanceMessage || 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);
|
||||
}
|
||||
|
||||
// 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
|
||||
const updateData: Record<string, any> = {
|
||||
...data,
|
||||
@@ -151,4 +175,61 @@ siteSettingsRouter.put('/', requireAuth(['admin']), zValidator('json', updateSit
|
||||
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;
|
||||
|
||||
@@ -25,7 +25,7 @@ const createTicketSchema = z.object({
|
||||
phone: z.string().min(6).optional().or(z.literal('')),
|
||||
preferredLanguage: z.enum(['en', 'es']).optional(),
|
||||
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
|
||||
attendees: z.array(attendeeSchema).optional(),
|
||||
});
|
||||
|
||||
@@ -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 rucPattern = /^\d{6,10}$/;
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
// 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: 6–10 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 - 6–10 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 }),
|
||||
});
|
||||
|
||||
@@ -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: '',
|
||||
@@ -455,6 +481,44 @@ export default function AdminEventsPage() {
|
||||
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}>
|
||||
{editingEvent ? 'Update Event' : 'Create Event'}
|
||||
@@ -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>
|
||||
<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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 ====================
|
||||
|
||||
Reference in New Issue
Block a user