diff --git a/android/app/capacitor.build.gradle b/android/app/capacitor.build.gradle index f4e3fa4..f4fc69c 100644 --- a/android/app/capacitor.build.gradle +++ b/android/app/capacitor.build.gradle @@ -2,8 +2,8 @@ android { compileOptions { - sourceCompatibility JavaVersion.VERSION_21 - targetCompatibility JavaVersion.VERSION_21 + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 } } diff --git a/backend/api/package.json b/backend/api/package.json index 87da25c..03178dc 100644 --- a/backend/api/package.json +++ b/backend/api/package.json @@ -1,7 +1,7 @@ { "name": "@compass/api", "description": "Backend API endpoints", - "version": "1.7.2", + "version": "1.8.0", "private": true, "scripts": { "watch:serve": "tsx watch src/serve.ts", diff --git a/backend/api/src/app.ts b/backend/api/src/app.ts index 696e116..c78872f 100644 --- a/backend/api/src/app.ts +++ b/backend/api/src/app.ts @@ -80,6 +80,12 @@ import {hideProfile} from "api/hide-profile"; import {unhideProfile} from "api/unhide-profile"; import {getHiddenProfiles} from "api/get-hidden-profiles"; import {getUserDataExport} from "./get-user-data-export"; +import {getEvents} from "./get-events"; +import {createEvent} from "./create-event"; +import {rsvpEvent} from "./rsvp-event"; +import {cancelRsvp} from "./cancel-rsvp"; +import {cancelEvent} from "./cancel-event"; +import {updateEvent} from "./update-event"; // const corsOptions: CorsOptions = { // origin: ['*'], // Only allow requests from this domain @@ -376,6 +382,12 @@ const handlers: { [k in APIPath]: APIHandler } = { // 'user/:username': getUser, // 'user/:username/lite': getDisplayUser, // 'user/by-id/:id/lite': getDisplayUser, + 'cancel-event': cancelEvent, + 'cancel-rsvp': cancelRsvp, + 'create-event': createEvent, + 'get-events': getEvents, + 'rsvp-event': rsvpEvent, + 'update-event': updateEvent, health: health, me: getMe, report: report, diff --git a/backend/api/src/cancel-event.ts b/backend/api/src/cancel-event.ts new file mode 100644 index 0000000..8bae9d2 --- /dev/null +++ b/backend/api/src/cancel-event.ts @@ -0,0 +1,46 @@ +import {APIError, APIHandler} from 'api/helpers/endpoint' +import {createSupabaseDirectClient} from 'shared/supabase/init' +import {update} from 'shared/supabase/utils' +import {tryCatch} from 'common/util/try-catch' + +export const cancelEvent: APIHandler<'cancel-event'> = async (body, auth) => { + const pg = createSupabaseDirectClient() + + // Check if event exists and user is the creator + const event = await pg.oneOrNone<{ + id: string + creator_id: string + status: string + }>( + `SELECT id, creator_id, status + FROM events + WHERE id = $1`, + [body.eventId] + ) + + if (!event) { + throw new APIError(404, 'Event not found') + } + + if (event.creator_id !== auth.uid) { + throw new APIError(403, 'Only the event creator can cancel this event') + } + + if (event.status === 'cancelled') { + throw new APIError(400, 'Event is already cancelled') + } + + // Update event status to cancelled + const {error} = await tryCatch( + update(pg, 'events', 'id', { + status: 'cancelled', + id: body.eventId, + }) + ) + + if (error) { + throw new APIError(500, 'Failed to cancel event: ' + error.message) + } + + return {success: true} +} diff --git a/backend/api/src/cancel-rsvp.ts b/backend/api/src/cancel-rsvp.ts new file mode 100644 index 0000000..74b7b07 --- /dev/null +++ b/backend/api/src/cancel-rsvp.ts @@ -0,0 +1,38 @@ +import {APIError, APIHandler} from 'api/helpers/endpoint' +import {createSupabaseDirectClient} from 'shared/supabase/init' +import {tryCatch} from 'common/util/try-catch' + +export const cancelRsvp: APIHandler<'cancel-rsvp'> = async (body, auth) => { + const pg = createSupabaseDirectClient() + + // Check if RSVP exists + const rsvp = await pg.oneOrNone<{ + id: string + }>( + `SELECT id + FROM events_participants + WHERE event_id = $1 + AND user_id = $2`, + [body.eventId, auth.uid] + ) + + if (!rsvp) { + throw new APIError(404, 'RSVP not found') + } + + // Delete the RSVP + const {error} = await tryCatch( + pg.none( + `DELETE + FROM events_participants + WHERE id = $1`, + [rsvp.id] + ) + ) + + if (error) { + throw new APIError(500, 'Failed to cancel RSVP: ' + error.message) + } + + return {success: true} +} diff --git a/backend/api/src/create-event.ts b/backend/api/src/create-event.ts new file mode 100644 index 0000000..982503b --- /dev/null +++ b/backend/api/src/create-event.ts @@ -0,0 +1,49 @@ +import {APIError, APIHandler} from 'api/helpers/endpoint' +import {createSupabaseDirectClient} from 'shared/supabase/init' +import {insert} from 'shared/supabase/utils' +import {tryCatch} from 'common/util/try-catch' + +export const createEvent: APIHandler<'create-event'> = async (body, auth) => { + const pg = createSupabaseDirectClient() + + // Validate location + if (body.locationType === 'in_person' && !body.locationAddress) { + throw new APIError(400, 'In-person events require a location address') + } + if (body.locationType === 'online' && !body.locationUrl) { + throw new APIError(400, 'Online events require a location URL') + } + + // Validate dates + const startTime = new Date(body.eventStartTime) + if (startTime < new Date()) { + throw new APIError(400, 'Event start time must be in the future') + } + + if (body.eventEndTime) { + const endTime = new Date(body.eventEndTime) + if (endTime <= startTime) { + throw new APIError(400, 'Event end time must be after start time') + } + } + + const {data, error} = await tryCatch( + insert(pg, 'events', { + creator_id: auth.uid, + title: body.title, + description: body.description, + location_type: body.locationType, + location_address: body.locationAddress, + location_url: body.locationUrl, + event_start_time: body.eventStartTime, + event_end_time: body.eventEndTime, + max_participants: body.maxParticipants, + }) + ) + + if (error) { + throw new APIError(500, 'Failed to create event: ' + error.message) + } + + return {success: true, event: data} +} diff --git a/backend/api/src/get-events.ts b/backend/api/src/get-events.ts new file mode 100644 index 0000000..31f4c72 --- /dev/null +++ b/backend/api/src/get-events.ts @@ -0,0 +1,87 @@ +import {APIHandler} from 'api/helpers/endpoint' +import {createSupabaseDirectClient} from 'shared/supabase/init' + +export const getEvents: APIHandler<'get-events'> = async () => { + const pg = createSupabaseDirectClient() + + const events = await pg.manyOrNone<{ + id: string + created_time: string + creator_id: string + title: string + description: string | null + location_type: 'in_person' | 'online' + location_address: string | null + location_url: string | null + event_start_time: object + event_end_time: object | null + is_public: boolean + max_participants: number | null + status: 'active' | 'cancelled' | 'completed' + }>( + `SELECT * + FROM events + WHERE is_public = true + AND status = 'active' + ORDER BY event_start_time` + ) + + // Get participants for each event + const eventIds = events.map(e => e.id) + const participants = eventIds.length > 0 + ? await pg.manyOrNone<{ + event_id: string + user_id: string + status: 'going' | 'maybe' | 'not_going' + }>( + `SELECT event_id, user_id, status + FROM events_participants + WHERE event_id = ANY ($1)`, + [eventIds] + ) + : [] + + // Get creator info for each event + const creatorIds = [...new Set(events.map(e => e.creator_id))] + const creators = creatorIds.length > 0 + ? await pg.manyOrNone<{ + id: string + name: string + username: string + avatar_url: string | null + }>( + `SELECT id, name, username, data ->> 'avatarUrl' as avatar_url + FROM users + WHERE id = ANY ($1)`, + [creatorIds] + ) + : [] + + const now = new Date() + + const eventsWithDetails = events.map(event => ({ + ...event, + participants: participants + .filter(p => p.event_id === event.id && p.status === 'going') + .map(p => p.user_id), + maybe: participants + .filter(p => p.event_id === event.id && p.status === 'maybe') + .map(p => p.user_id), + creator: creators.find(c => c.id === event.creator_id), + })) + + const upcoming: typeof eventsWithDetails = [] + const past: typeof eventsWithDetails = [] + + for (const e of eventsWithDetails) { + if ((e.event_end_time ?? e.event_start_time) > now) { + upcoming.push(e) + } else { + past.push(e) + } + } + + // console.debug({events, eventsWithDetails, upcoming, past, now}) + + return {upcoming, past} +} diff --git a/backend/api/src/rsvp-event.ts b/backend/api/src/rsvp-event.ts new file mode 100644 index 0000000..783afb5 --- /dev/null +++ b/backend/api/src/rsvp-event.ts @@ -0,0 +1,83 @@ +import {APIError, APIHandler} from 'api/helpers/endpoint' +import {createSupabaseDirectClient} from 'shared/supabase/init' +import {insert, update} from 'shared/supabase/utils' +import {tryCatch} from 'common/util/try-catch' + +export const rsvpEvent: APIHandler<'rsvp-event'> = async (body, auth) => { + const pg = createSupabaseDirectClient() + + // Check if event exists and is active + const event = await pg.oneOrNone<{ + id: string + status: string + max_participants: number | null + }>( + `SELECT id, status, max_participants + FROM events + WHERE id = $1`, + [body.eventId] + ) + + if (!event) { + throw new APIError(404, 'Event not found') + } + + if (event.status !== 'active') { + throw new APIError(400, 'Cannot RSVP to a cancelled or completed event') + } + + // Check if already RSVPed + const existingRsvp = await pg.oneOrNone<{ + id: string + }>( + `SELECT id + FROM events_participants + WHERE event_id = $1 + AND user_id = $2`, + [body.eventId, auth.uid] + ) + + if (existingRsvp) { + // Update existing RSVP + const {error} = await tryCatch( + update(pg, 'events_participants', 'id', { + status: body.status, + id: existingRsvp.id, + }) + ) + + if (error) { + throw new APIError(500, 'Failed to update RSVP: ' + error.message) + } + } else { + // Check max participants limit + if (event.max_participants && body.status === 'going') { + const count = await pg.one<{ count: number }>( + `SELECT COUNT(*) + FROM events_participants + WHERE event_id = $1 + AND status = 'going'`, + [body.eventId] + ) + + if (Number(count.count) >= event.max_participants) { + throw new APIError(400, 'Event is at maximum capacity') + } + } + + // Create new RSVP + const {error} = await tryCatch( + insert(pg, 'events_participants', { + event_id: body.eventId, + user_id: auth.uid, + status: body.status, + }) + ) + + if (error) { + throw new APIError(500, 'Failed to RSVP: ' + error.message) + } + } + + return {success: true} +} diff --git a/backend/api/src/update-event.ts b/backend/api/src/update-event.ts new file mode 100644 index 0000000..4d9e018 --- /dev/null +++ b/backend/api/src/update-event.ts @@ -0,0 +1,53 @@ +import {APIError, APIHandler} from 'api/helpers/endpoint' +import {createSupabaseDirectClient} from 'shared/supabase/init' +import {update} from 'shared/supabase/utils' +import {tryCatch} from 'common/util/try-catch' + +export const updateEvent: APIHandler<'update-event'> = async (body, auth) => { + const pg = createSupabaseDirectClient() + + // Check if event exists and user is the creator + const event = await pg.oneOrNone<{ + id: string + creator_id: string + status: string + }>( + `SELECT id, creator_id, status + FROM events + WHERE id = $1`, + [body.eventId] + ) + + if (!event) { + throw new APIError(404, 'Event not found') + } + + if (event.creator_id !== auth.uid) { + throw new APIError(403, 'Only the event creator can edit this event') + } + + if (event.status !== 'active') { + throw new APIError(400, 'Cannot edit a cancelled or completed event') + } + + // Update event + const {error} = await tryCatch( + update(pg, 'events', 'id', { + title: body.title, + description: body.description, + location_type: body.locationType, + location_address: body.locationAddress, + location_url: body.locationUrl, + event_start_time: body.eventStartTime, + event_end_time: body.eventEndTime, + max_participants: body.maxParticipants, + id: body.eventId, + }) + ) + + if (error) { + throw new APIError(500, 'Failed to update event: ' + error.message) + } + + return {success: true} +} diff --git a/backend/supabase/migration.sql b/backend/supabase/migration.sql index de8c548..279557b 100644 --- a/backend/supabase/migration.sql +++ b/backend/supabase/migration.sql @@ -46,4 +46,5 @@ BEGIN; \i backend/supabase/migrations/20251110_add_languages_to_profiles.sql \i backend/supabase/migrations/20251112_add_mbti_to_profiles.sql \i backend/supabase/migrations/20260213_add_big_5_to_profiles.sql +\i backend/supabase/migrations/20260218_add_events.sql COMMIT; diff --git a/backend/supabase/migrations/20260218_add_events.sql b/backend/supabase/migrations/20260218_add_events.sql new file mode 100644 index 0000000..5e664ba --- /dev/null +++ b/backend/supabase/migrations/20260218_add_events.sql @@ -0,0 +1,58 @@ +-- Create events table +CREATE TABLE IF NOT EXISTS events +( + id text default uuid_generate_v4() not null primary key, + created_time TIMESTAMPTZ NOT NULL DEFAULT NOW(), + creator_id TEXT REFERENCES users (id) ON DELETE SET NULL, + title TEXT NOT NULL, + description TEXT, + location_type TEXT NOT NULL CHECK (location_type IN ('in_person', 'online')), + location_address TEXT, + location_url TEXT, + event_start_time TIMESTAMPTZ NOT NULL, + event_end_time TIMESTAMPTZ, + is_public BOOLEAN NOT NULL DEFAULT TRUE, + max_participants INTEGER, + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'cancelled', 'completed')) +); + +-- Create events_participants table +CREATE TABLE IF NOT EXISTS events_participants +( + id text default uuid_generate_v4() not null primary key, + event_id TEXT NOT NULL REFERENCES events (id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE, + rsvp_time TIMESTAMPTZ NOT NULL DEFAULT NOW(), + status TEXT NOT NULL DEFAULT 'going' CHECK (status IN ('going', 'maybe', 'not_going')), + UNIQUE (event_id, user_id) +); + +-- Create indexes for performance +CREATE INDEX IF NOT EXISTS idx_events_creator ON events (creator_id); +CREATE INDEX IF NOT EXISTS idx_events_start_time ON events (event_start_time); +CREATE INDEX IF NOT EXISTS idx_events_status ON events (status); +CREATE INDEX IF NOT EXISTS idx_events_participants_event ON events_participants (event_id); +CREATE INDEX IF NOT EXISTS idx_events_participants_user ON events_participants (user_id); + +-- Enable RLS +ALTER TABLE events + ENABLE ROW LEVEL SECURITY; +ALTER TABLE events_participants + ENABLE ROW LEVEL SECURITY; + +-- Events policies +-- Anyone can view public events +DROP POLICY IF EXISTS "events select" ON events; +CREATE POLICY "events select" ON events + FOR SELECT USING (is_public = TRUE); + +-- Events participants policies +-- Anyone can view participants for public events +DROP POLICY IF EXISTS "events_participants select" ON events_participants; +CREATE POLICY "events_participants select" ON events_participants + FOR SELECT USING ( + EXISTS (SELECT 1 + FROM events e + WHERE e.id = events_participants.event_id + AND e.is_public = TRUE) + ); diff --git a/common/src/api/schema.ts b/common/src/api/schema.ts index 0235e6d..a213ccc 100644 --- a/common/src/api/schema.ts +++ b/common/src/api/schema.ts @@ -893,17 +893,91 @@ export const API = (_apiTypeCheck = { summary: 'Delete a bookmarked search by ID', tag: 'Searches', }, - // 'auth-google': { - // method: 'GET', - // authed: false, - // rateLimited: true, - // returns: {} as any, - // props: z.object({ - // code: z.string(), - // }), - // summary: 'Google Auth', - // tag: 'Tokens', - // }, + 'cancel-event': { + method: 'POST', + authed: true, + rateLimited: true, + returns: {} as { success: boolean }, + props: z.object({ + eventId: z.string(), + }), + summary: 'Cancel an event (creator only)', + tag: 'Events', + }, + 'rsvp-event': { + method: 'POST', + authed: true, + rateLimited: true, + returns: {} as { success: boolean }, + props: z.object({ + eventId: z.string(), + status: z.enum(['going', 'maybe', 'not_going']), + }), + summary: 'RSVP to an event', + tag: 'Events', + }, + 'cancel-rsvp': { + method: 'POST', + authed: true, + rateLimited: true, + returns: {} as { success: boolean }, + props: z.object({ + eventId: z.string(), + }), + summary: 'Cancel RSVP to an event', + tag: 'Events', + }, + 'create-event': { + method: 'POST', + authed: true, + rateLimited: true, + returns: {} as any, + props: z.object({ + title: z.string().min(1).max(200), + description: z.string().max(2000).optional(), + locationType: z.enum(['in_person', 'online']), + locationAddress: z.string().max(500).optional(), + locationUrl: z.string().url().max(500).optional(), + eventStartTime: z.string().datetime(), + eventEndTime: z.string().datetime().optional(), + maxParticipants: z.number().int().min(1).optional(), + }), + summary: 'Create a new event', + tag: 'Events', + }, + 'get-events': { + method: 'GET', + authed: false, + rateLimited: false, + returns: {} as { + upcoming: any[] + past: any[] + }, + props: z.object({}), + summary: 'Get all public events split into upcoming and past', + tag: 'Events', + }, + 'update-event': { + method: 'POST', + authed: true, + rateLimited: false, + returns: {} as { success: boolean }, + props: z + .object({ + eventId: z.string(), + title: z.string().min(1).max(200), + description: z.string().max(2000).optional(), + locationType: z.enum(['in_person', 'online']), + locationAddress: z.string().max(500).optional(), + locationUrl: z.string().url().max(500).optional(), + eventStartTime: z.string(), + eventEndTime: z.string().optional(), + maxParticipants: z.number().min(1).max(1000).optional(), + }) + .strict(), + summary: 'Update an existing event', + tag: 'Events', + }, } as const) export type APIPath = keyof typeof API diff --git a/common/src/envs/constants.ts b/common/src/envs/constants.ts index acce16e..368556f 100644 --- a/common/src/envs/constants.ts +++ b/common/src/envs/constants.ts @@ -99,6 +99,7 @@ export const RESERVED_PATHS = [ 'dashboard', 'discord', 'embed', + 'events', 'facebook', 'faq', 'financials', diff --git a/common/src/supabase/schema.ts b/common/src/supabase/schema.ts index 4890028..e140c56 100644 --- a/common/src/supabase/schema.ts +++ b/common/src/supabase/schema.ts @@ -327,6 +327,101 @@ export type Database = { }, ] } + events: { + Row: { + created_time: string + creator_id: string | null + description: string | null + event_end_time: string | null + event_start_time: string + id: string + is_public: boolean + location_address: string | null + location_type: string + location_url: string | null + max_participants: number | null + status: string + title: string + } + Insert: { + created_time?: string + creator_id?: string | null + description?: string | null + event_end_time?: string | null + event_start_time: string + id?: string + is_public?: boolean + location_address?: string | null + location_type: string + location_url?: string | null + max_participants?: number | null + status?: string + title: string + } + Update: { + created_time?: string + creator_id?: string | null + description?: string | null + event_end_time?: string | null + event_start_time?: string + id?: string + is_public?: boolean + location_address?: string | null + location_type?: string + location_url?: string | null + max_participants?: number | null + status?: string + title?: string + } + Relationships: [ + { + foreignKeyName: 'events_creator_id_fkey' + columns: ['creator_id'] + isOneToOne: false + referencedRelation: 'users' + referencedColumns: ['id'] + }, + ] + } + events_participants: { + Row: { + event_id: string + id: string + rsvp_time: string + status: string + user_id: string + } + Insert: { + event_id: string + id?: string + rsvp_time?: string + status?: string + user_id: string + } + Update: { + event_id?: string + id?: string + rsvp_time?: string + status?: string + user_id?: string + } + Relationships: [ + { + foreignKeyName: 'events_participants_event_id_fkey' + columns: ['event_id'] + isOneToOne: false + referencedRelation: 'events' + referencedColumns: ['id'] + }, + { + foreignKeyName: 'events_participants_user_id_fkey' + columns: ['user_id'] + isOneToOne: false + referencedRelation: 'users' + referencedColumns: ['id'] + }, + ] + } hidden_profiles: { Row: { created_time: string diff --git a/tests/e2e/utils/seedDatabase.ts b/tests/e2e/utils/seedDatabase.ts index 801bc15..9d462e7 100644 --- a/tests/e2e/utils/seedDatabase.ts +++ b/tests/e2e/utils/seedDatabase.ts @@ -2,16 +2,17 @@ import {insert} from "../../../backend/shared/lib/supabase/utils"; import {PrivateUser} from "../../../common/lib/user"; import {getDefaultNotificationPreferences} from "../../../common/lib/user-notification-preferences"; import {randomString} from "../../../common/lib/util/random"; -import UserAccountInformation from "../backend/utils/userInformation"; +import UserAccountInformation from "../backend/utils/userInformation"; +import {cleanUsername} from "common/lib/util/clean-username"; /** * Function used to populate the database with profiles. - * + * * @param pg - Supabase client used to access the database. * @param userInfo - Class object containing information to create a user account generated by `fakerjs`. * @param profileType - Optional param used to signify how much information is used in the account generation. */ -export async function seedDatabase (pg: any, userInfo: UserAccountInformation, profileType?: string) { +export async function seedDatabase(pg: any, userInfo: UserAccountInformation, profileType?: string) { const userId = userInfo.user_id const deviceToken = randomString() @@ -61,7 +62,7 @@ export async function seedDatabase (pg: any, userInfo: UserAccountInformation, p const profileData = profileType === 'basic' ? basicProfile : profileType === 'medium' ? mediumProfile - : fullProfile + : fullProfile const user = { // avatarUrl, @@ -79,12 +80,12 @@ export async function seedDatabase (pg: any, userInfo: UserAccountInformation, p blockedByUserIds: [], } - await pg.tx(async (tx:any) => { + await pg.tx(async (tx: any) => { await insert(tx, 'users', { id: userId, name: userInfo.name, - username: userInfo.name, + username: cleanUsername(userInfo.name), data: user, }) @@ -93,7 +94,7 @@ export async function seedDatabase (pg: any, userInfo: UserAccountInformation, p data: privateUser, }) - await insert(tx, 'profiles', profileData ) + await insert(tx, 'profiles', profileData) }) }; \ No newline at end of file diff --git a/web/components/events/create-event-modal.tsx b/web/components/events/create-event-modal.tsx new file mode 100644 index 0000000..4845346 --- /dev/null +++ b/web/components/events/create-event-modal.tsx @@ -0,0 +1,324 @@ +'use client' + +import {useEffect, useState} from 'react' +import DatePicker from 'react-datepicker' +import 'react-datepicker/dist/react-datepicker.css' +import {Modal, MODAL_CLASS, SCROLLABLE_MODAL_CLASS} from 'web/components/layout/modal' +import {api} from 'web/lib/api' +import {APIError} from "common/api/utils"; +import clsx from "clsx"; +import {Col} from "web/components/layout/col"; +import {useT} from 'web/lib/locale'; + +import {Event} from 'web/hooks/use-events' + +export function CreateEventModal(props: { + open: boolean + setOpen: (open: boolean) => void + onClose: () => void + onSuccess: () => void + event?: Event | null | undefined +}) { + const {open, setOpen, onClose, onSuccess, event} = props + const isEditing = !!event + const t = useT() + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + const [formData, setFormData] = useState({ + title: '', + description: '', + locationType: 'in_person' as 'in_person' | 'online', + locationAddress: '', + locationUrl: '', + eventStartTime: null as Date | null, + eventEndTime: null as Date | null, + maxParticipants: '', + }) + + // Update form data when event prop changes (for editing) + useEffect(() => { + if (event) { + setFormData({ + title: event.title || '', + description: event.description || '', + locationType: event.location_type || 'in_person' as 'in_person' | 'online', + locationAddress: event.location_address || '', + locationUrl: event.location_url || '', + eventStartTime: event.event_start_time ? new Date(event.event_start_time) : null, + eventEndTime: event.event_end_time ? new Date(event.event_end_time) : null, + maxParticipants: event.max_participants?.toString() || '', + }) + } else { + // Reset form for create mode + setFormData({ + title: '', + description: '', + locationType: 'in_person' as 'in_person' | 'online', + locationAddress: '', + locationUrl: '', + eventStartTime: null, + eventEndTime: null, + maxParticipants: '', + }) + } + }, [event]) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setLoading(true) + setError(null) + + try { + if (isEditing && event) { + await api('update-event', { + eventId: event.id, + title: formData.title, + description: formData.description || undefined, + locationType: formData.locationType, + locationAddress: formData.locationAddress || undefined, + locationUrl: formData.locationUrl || undefined, + eventStartTime: formData.eventStartTime!.toISOString(), + eventEndTime: formData.eventEndTime + ? formData.eventEndTime.toISOString() + : undefined, + maxParticipants: formData.maxParticipants + ? parseInt(formData.maxParticipants) + : undefined, + }) + } else { + await api('create-event', { + title: formData.title, + description: formData.description || undefined, + locationType: formData.locationType, + locationAddress: formData.locationAddress || undefined, + locationUrl: formData.locationUrl || undefined, + eventStartTime: formData.eventStartTime!.toISOString(), + eventEndTime: formData.eventEndTime + ? formData.eventEndTime.toISOString() + : undefined, + maxParticipants: formData.maxParticipants + ? parseInt(formData.maxParticipants) + : undefined, + }) + } + + onSuccess() + onClose() + // Reset form only for create, not edit + if (!isEditing) { + setFormData({ + title: '', + description: '', + locationType: 'in_person', + locationAddress: '', + locationUrl: '', + eventStartTime: null, + eventEndTime: null, + maxParticipants: '', + }) + } + } catch (err) { + if (err instanceof APIError) { + setError(err.message) + } else { + setError(t('events.failed_create_event', 'Failed to save event. Please try again.')) + } + } finally { + setLoading(false) + } + } + + const handleChange = ( + e: React.ChangeEvent + ) => { + const {name, value} = e.target + setFormData((prev) => ({...prev, [name]: value})) + } + + return ( + + +
+ +

{isEditing ? t('events.edit_event', 'Edit Event') : t('events.create_new_event', 'Create New Event')}

+
+ + +
+ +
+ +