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,
|
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 (
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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: 6–10 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 - 6–10 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 }),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 ====================
|
||||||
|
|||||||
Reference in New Issue
Block a user