Add events page

This commit is contained in:
MartinBraquet
2026-02-18 18:34:18 +01:00
parent cad1fd72e3
commit 46082c5f64
30 changed files with 1798 additions and 25 deletions

View File

@@ -2,8 +2,8 @@
android {
compileOptions {
sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_21
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
}

View File

@@ -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",

View File

@@ -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<k> } = {
// '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,

View File

@@ -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}
}

View File

@@ -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}
}

View File

@@ -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}
}

View File

@@ -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}
}

View File

@@ -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}
}

View File

@@ -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}
}

View File

@@ -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;

View File

@@ -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)
);

View File

@@ -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

View File

@@ -99,6 +99,7 @@ export const RESERVED_PATHS = [
'dashboard',
'discord',
'embed',
'events',
'facebook',
'faq',
'financials',

View File

@@ -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

View File

@@ -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)
})
};

View File

@@ -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<string | null>(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<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>
) => {
const {name, value} = e.target
setFormData((prev) => ({...prev, [name]: value}))
}
return (
<Modal open={open} setOpen={setOpen} onClose={onClose} size="lg">
<Col className={clsx("", MODAL_CLASS)}>
<form onSubmit={handleSubmit} className={clsx("space-y-4 pr-4", SCROLLABLE_MODAL_CLASS)}>
<h3>{isEditing ? t('events.edit_event', 'Edit Event') : t('events.create_new_event', 'Create New Event')}</h3>
<div>
<label htmlFor="title" className="mb-1 block text-sm font-medium min-w-[300px]">
{t('events.event_title', 'Event Title')} *
</label>
<input
type="text"
id="title"
name="title"
required
maxLength={200}
value={formData.title}
onChange={handleChange}
className="bg-canvas-50 border-canvas-300 focus:border-primary-500 focus:ring-primary-500 w-full rounded-md border px-3 py-2"
placeholder={t('events.event_title_placeholder', 'Enter event title')}
/>
</div>
<div>
<label htmlFor="description" className="mb-1 block text-sm font-medium">
{t('events.description', 'Description')}
</label>
<textarea
id="description"
name="description"
rows={3}
maxLength={2000}
value={formData.description}
onChange={handleChange}
className="bg-canvas-50 border-canvas-300 focus:border-primary-500 focus:ring-primary-500 w-full rounded-md border px-3 py-2"
placeholder={t('events.description_placeholder', 'Describe your event...')}
/>
</div>
<div>
<label htmlFor="locationType" className="mb-1 block text-sm font-medium">
{t('events.location_type', 'Location Type')} *
</label>
<select
id="locationType"
name="locationType"
required
value={formData.locationType}
onChange={handleChange}
className="bg-canvas-50 border-canvas-300 focus:border-primary-500 focus:ring-primary-500 w-full rounded-md border px-3 py-2"
>
<option value="in_person">{t('events.in_person', 'In Person')}</option>
<option value="online">{t('events.online', 'Online')}</option>
</select>
</div>
{formData.locationType === 'in_person' ? (
<div>
<label htmlFor="locationAddress" className="mb-1 block text-sm font-medium">
{t('events.location_address', 'Address')} *
</label>
<input
type="text"
id="locationAddress"
name="locationAddress"
required={formData.locationType === 'in_person'}
maxLength={500}
value={formData.locationAddress}
onChange={handleChange}
className="bg-canvas-50 border-canvas-300 focus:border-primary-500 focus:ring-primary-500 w-full rounded-md border px-3 py-2"
placeholder={t('events.location_address_placeholder', 'Enter event address')}
/>
</div>
) : (
<div>
<label htmlFor="locationUrl" className="mb-1 block text-sm font-medium">
{t('events.location_url', 'Online Link (URL')} *
</label>
<input
type="url"
id="locationUrl"
name="locationUrl"
required={formData.locationType === 'online'}
maxLength={500}
value={formData.locationUrl}
onChange={handleChange}
className="bg-canvas-50 border-canvas-300 focus:border-primary-500 focus:ring-primary-500 w-full rounded-md border px-3 py-2"
placeholder={t('events.location_url_placeholder', 'Enter event URL')}
/>
</div>
)}
<div className="grid grid-cols-1 xs:grid-cols-2 gap-4">
<div>
<label className="mb-1 block text-sm font-medium">
{t('events.start_time', 'Start Time')} *
</label>
<DatePicker
selected={formData.eventStartTime}
onChange={(date: Date | null) => {
if (!date) return
setFormData((prev) => {
const newEndTime = (!prev.eventEndTime || prev.eventEndTime < date)
? new Date(date.getTime() + 60 * 60 * 1000)
: prev.eventEndTime
return {...prev, eventStartTime: date, eventEndTime: newEndTime}
})
}}
showTimeSelect
timeFormat="HH:mm"
timeIntervals={15}
dateFormat="MMM d, yyyy h:mm aa"
minDate={new Date()}
required
placeholderText="Select date and time"
className="bg-canvas-50 border-canvas-300 focus:border-primary-500 focus:ring-primary-500 w-full rounded-md border px-3 py-2"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium">
{t('events.end_time', 'End Time')}
</label>
<DatePicker
selected={formData.eventEndTime}
onChange={(date: Date | null) => {
setFormData((prev) => {
const startTime = prev.eventStartTime
if (startTime && date && date <= startTime) {
// If end time is before or equal to start, set start to 1 hour before end
return {...prev, eventStartTime: new Date(date.getTime() - 60 * 60 * 1000), eventEndTime: date}
}
return {...prev, eventEndTime: date}
})
}}
showTimeSelect
timeFormat="HH:mm"
timeIntervals={15}
dateFormat="MMM d, yyyy h:mm aa"
minDate={formData.eventStartTime || new Date()}
placeholderText="Select end time (optional)"
className="bg-canvas-50 border-canvas-300 focus:border-primary-500 focus:ring-primary-500 w-full rounded-md border px-3 py-2"
/>
</div>
</div>
<div>
<label htmlFor="maxParticipants" className="mb-1 block text-sm font-medium">
{t('events.max_participants', 'Max Participants (optional)')}
</label>
<input
type="number"
id="maxParticipants"
name="maxParticipants"
min={1}
value={formData.maxParticipants}
onChange={handleChange}
className="bg-canvas-50 border-canvas-300 focus:border-primary-500 focus:ring-primary-500 w-full rounded-md border px-3 py-2"
placeholder={t('events.leave_empty', 'Leave empty for unlimited')}
/>
</div>
{error && (
<div className="text-red-800 rounded-md p-3 text-sm">{error}</div>
)}
<div className="flex justify-end gap-3 pt-4 pb-4">
<button
type="button"
onClick={onClose}
className="text-ink-700 bg-canvas-100 hover:bg-canvas-200 rounded-md px-4 py-2 text-sm font-medium"
>
{t('events.cancel', 'Cancel')}
</button>
<button
type="submit"
disabled={loading}
className="bg-primary-500 hover:bg-primary-600 text-white rounded-md px-4 py-2 text-sm font-medium disabled:opacity-50"
>
{loading ? (isEditing ? t('events.updating', 'Updating...') : t('events.creating', 'Creating...')) : (isEditing ? t('events.update_event', 'Update Event') : t('events.create_event', 'Create Event'))}
</button>
</div>
</form>
</Col>
</Modal>
)
}

View File

@@ -0,0 +1,228 @@
import clsx from 'clsx'
import {Event} from 'web/hooks/use-events'
import {useUser} from 'web/hooks/use-user'
import {useLocale, useT} from 'web/lib/locale'
import Link from 'next/link'
import dayjs from 'dayjs'
import {UserLink} from './user-link'
import {useUsersInStore} from "web/hooks/use-user-supabase";
import {formatTimeShort, fromNow} from "web/lib/util/time";
import {capitalize} from "lodash";
import {HOUR_MS} from "common/util/time";
export function EventCard(props: {
event: Event
onRsvp?: (eventId: string, status: 'going' | 'maybe' | 'not_going') => void
onCancelRsvp?: (eventId: string) => void
onCancelEvent?: (eventId: string) => void
onEdit?: (event: Event) => void
className?: string
}) {
const {event, onRsvp, onCancelRsvp, onCancelEvent, onEdit, className} = props
const user = useUser()
const t = useT()
const {locale} = useLocale()
const users = useUsersInStore([
...(event.participants ?? []),
...(event.maybe ?? []),
],
`event-card-${event.id}`
)
const isRsvped = user && event.participants.includes(user.id)
const isMaybe = user && event.maybe.includes(user.id)
const isCreator = user && event.creator_id === user.id
const isPast = new Date(event.event_start_time) < new Date()
const formattedDate = formatTimeShort(event.event_start_time, locale)
const formattedEnd = event.event_end_time && formatTimeShort(
event.event_end_time,
locale,
dayjs(event.event_end_time).isSame(event.event_start_time, 'day')
)
let timeAgo = fromNow(event.event_start_time, false, t, locale)
const assumedEnd = new Date(event.event_end_time ?? new Date(event.event_start_time).getTime() + 24 * HOUR_MS)
if (isPast && assumedEnd > new Date()) timeAgo = t('events.started', 'Started {time}', {time: timeAgo})
return (
<div
className={clsx(
'bg-canvas-0 border-canvas-200 rounded-lg border p-4 shadow-sm',
className
)}
>
{/* Header */}
<div className="mb-3">
<h3 className="text-lg font-semibold mt-0">{event.title}</h3>
{event.creator && (
<div className="text-ink-500 mt-1 flex items-center gap-2 text-sm">
<span>{t('events.organized_by', 'Organized by')}</span>
<UserLink user={event.creator}/>
</div>
)}
</div>
{/* Date & Time */}
<div className="mb-3">
<p className="text-ink-700 font-medium">{formattedDate} - {formattedEnd}</p>
<p className="text-ink-500 text-sm">{capitalize(timeAgo)}</p>
</div>
{/* Description */}
{event.description && (
<div className="text-ink-600 mb-3">
{event.description.split('\n').map((line, index) => (
<p key={index} className={index > 0 ? 'mt-2' : ''}>{line}</p>
))}
</div>
)}
{/* Location */}
<div className="mb-3">
{event.location_type === 'in_person' && event.location_address ? (
<div className="text-ink-600 flex items-start gap-2">
<svg className="mt-0.5 h-5 w-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"/>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
<span>{event.location_address}</span>
</div>
) : event.location_type === 'online' && event.location_url ? (
<a
href={event.location_url}
target="_blank"
rel="noopener noreferrer"
className="text-primary-600 hover:text-primary-700 flex items-center gap-2"
>
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
<span>{t('events.online_event_join_link', 'Online Event - Join Link')}</span>
</a>
) : null}
</div>
{/* Participants */}
<div className="mb-3">
<p className="text-ink-500 text-sm">
{t('events.participants_count', '{count} going', {count: event.participants.length})}
{event.max_participants && t('events.participants_max', ' / {max} max', {max: event.max_participants})}
{event.maybe.length > 0 && t('events.maybe_count', ' ({count} maybe)', {count: event.maybe.length})}
</p>
{users && event.participants.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{event.participants.map((participantId: any) => (
<span key={participantId} className="bg-canvas-100 text-ink-700 px-2 py-1 rounded text-xs">
<UserLink user={participantId === user?.id ? user : users[participantId]}/>
</span>
))}
</div>
)}
</div>
{/* Status badge */}
{event.status !== 'active' && (
<div className="mb-3">
<span
className={clsx(
'inline-flex items-center rounded-full px-2 py-1 text-xs font-medium',
event.status === 'cancelled'
? 'bg-red-100 text-red-800'
: 'bg-gray-100 text-gray-800'
)}
>
{event.status === 'cancelled' ? t('events.cancelled', 'Cancelled') : t('events.completed', 'Completed')}
</span>
</div>
)}
{/* Actions */}
{user && !isPast && event.status === 'active' && (
<div className="flex items-center gap-2">
{isRsvped && (
<>
<span className="text-green-600 flex items-center gap-1 text-sm font-medium">
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"/>
</svg>
{t('events.going', 'Going')}
</span>
<button
onClick={() => onCancelRsvp?.(event.id)}
className="text-ink-500 hover:text-ink-700 text-sm"
>
{t('events.cancel_rsvp', 'Cancel RSVP')}
</button>
</>
)}
{isMaybe && (
<>
<span className="text-yellow-600 flex items-center gap-1 text-sm font-medium">
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clipRule="evenodd"/>
</svg>
{t('events.maybe', 'Maybe')}
</span>
<button
onClick={() => onCancelRsvp?.(event.id)}
className="text-ink-500 hover:text-ink-700 text-sm"
>
{t('events.not_going', 'Not going')}
</button>
</>
)}
{(!isRsvped || !isMaybe) && (
<div className="flex items-center gap-2">
{!isRsvped && <button
onClick={() => onRsvp?.(event.id, 'going')}
className="bg-primary-500 hover:bg-primary-600 text-white rounded-md px-3 py-1.5 text-sm font-medium"
>
{t('events.going', 'Going')}
</button>}
{!isMaybe && <button
onClick={() => onRsvp?.(event.id, 'maybe')}
className="bg-canvas-100 hover:bg-canvas-200 text-ink-700 rounded-md px-3 py-1.5 text-sm font-medium"
>
{t('events.maybe', 'Maybe')}
</button>}
</div>
)}
</div>
)}
{/* Cancel Event Button for Creators */}
{user && isCreator && event.status === 'active' && (
<div className="mt-3 pt-3 border-t border-canvas-200 flex gap-2">
<button
onClick={() => onCancelEvent?.(event.id)}
className="text-red-600 hover:text-red-700 text-sm font-medium"
>
{t('events.cancel_event', 'Cancel Event')}
</button>
<button
onClick={() => onEdit?.(event)}
className="text-ink-500 hover:text-ink-700 text-sm"
>
{t('events.edit_event', 'Edit Event')}
</button>
</div>
)}
{/* Login prompt for non-users */}
{!user && !isPast && event.status === 'active' && (
<Link
href="/signin"
className="text-primary-600 hover:text-primary-700 text-sm font-medium"
>
{t('events.login_to_rsvp', 'Log in to RSVP')}
</Link>
)}
</div>
)
}

View File

@@ -0,0 +1,37 @@
import {Event} from 'web/hooks/use-events'
import {EventCard} from './event-card'
export function EventsList(props: {
events: Event[]
title: string
emptyMessage: string
onRsvp?: (eventId: string, status: 'going' | 'maybe' | 'not_going') => void
onCancelRsvp?: (eventId: string) => void
onCancelEvent?: (eventId: string) => void
onEdit?: (event: Event) => void
className?: string
}) {
const {events, title, emptyMessage, onRsvp, onCancelRsvp, onCancelEvent, onEdit, className} = props
return (
<div className={className}>
<h2 className="text-xl font-semibold mb-4">{title}</h2>
{events.length === 0 ? (
<p className="text-ink-500 italic">{emptyMessage}</p>
) : (
<div className="grid gap-4 grid-cols-1">
{events.map((event) => (
<EventCard
key={event.id}
event={event}
onRsvp={onRsvp}
onCancelRsvp={onCancelRsvp}
onCancelEvent={onCancelEvent}
onEdit={onEdit}
/>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,31 @@
import Link from 'next/link'
type User = {
id: string
name: string
username: string
avatar_url?: string | null
}
export function UserLink({user, className = ""}: {
user: User | null | undefined
className?: string
}) {
if (!user) return null
return (
<Link
href={`/${user.username}`}
className={`hover:text-primary-600 flex items-center gap-1 ${className}`}
>
{user.avatar_url && (
<img
src={user.avatar_url}
alt={user.name}
className="h-5 w-5 rounded-full"
/>
)}
<span>{user.name}</span>
</Link>
)
}

View File

@@ -1,4 +1,4 @@
import {HomeIcon, NewspaperIcon, QuestionMarkCircleIcon} from '@heroicons/react/outline'
import {CalendarIcon, HomeIcon, NewspaperIcon, QuestionMarkCircleIcon} from '@heroicons/react/outline'
import {
CogIcon,
GlobeAltIcon,
@@ -116,17 +116,19 @@ const Signin = {key: 'nav.sign_in', name: 'Sign in', href: '/signin', icon: User
const Notifs = {key: 'nav.notifs', name: 'Notifs', href: `/notifications`, icon: NotificationsIcon}
const NotifsSolid = {key: 'nav.notifs', name: 'Notifs', href: `/notifications`, icon: SolidNotificationsIcon}
const Messages = {key: 'nav.messages', name: 'Messages', href: '/messages', icon: PrivateMessagesIcon}
const Social = {key: 'nav.social', name: 'Social', href: '/social', icon: LinkIcon}
const Social = {key: 'nav.social', name: 'Socials', href: '/social', icon: LinkIcon}
const Organization = {key: 'nav.organization', name: 'Organization', href: '/organization', icon: GlobeAltIcon}
const Vote = {key: 'nav.vote', name: 'Vote', href: '/vote', icon: MdThumbUp}
const Contact = {key: 'nav.contact', name: 'Contact', href: '/contact', icon: FaEnvelope}
const News = {key: 'nav.news', name: "What's new", href: '/news', icon: NewspaperIcon}
const Settings = {key: 'nav.settings', name: "Settings", href: '/settings', icon: CogIcon}
const Events = {key: 'nav.events', name: "Events", href: '/events', icon: CalendarIcon}
const base = [
About,
faq,
Vote,
Events,
News,
Social,
Organization,

66
web/hooks/use-events.ts Normal file
View File

@@ -0,0 +1,66 @@
'use client'
import {useEffect, useState} from 'react'
import {api} from 'web/lib/api'
import {useIsPageVisible} from './use-page-visible'
export type Event = {
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: string
event_end_time: string | null
is_public: boolean
max_participants: number | null
status: 'active' | 'cancelled' | 'completed'
participants: string[]
maybe: string[]
creator?: {
id: string
name: string
username: string
avatar_url: string | null
}
}
export type EventsData = {
upcoming: Event[]
past: Event[]
}
export const useEvents = () => {
const [events, setEvents] = useState<EventsData | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
const isPageVisible = useIsPageVisible()
const fetchEvents = async (showLoading = true) => {
try {
if (showLoading) setLoading(true)
const data = await api('get-events', {})
console.log('Fetched events', data)
setEvents(data)
setError(null)
} catch (err) {
setError(err as Error)
} finally {
if (showLoading) setLoading(false)
}
}
useEffect(() => {
// console.debug({isPageVisible})
if (isPageVisible) {
fetchEvents()
}
}, [isPageVisible])
const refetch = () => fetchEvents(false)
return {events, loading, error, refetch}
}

View File

@@ -19,8 +19,14 @@ const FORMATTER = new Intl.DateTimeFormat('default', {
export const formatTime = FORMATTER.format
export function formatTimeShort(time: number) {
return dayjs(time).format('MMM D, h:mma')
export function formatTimeShort(time: number | string | Date, locale: string | null = null, hourOnly: boolean | null = null) {
let date = dayjs(time)
let template = hourOnly ? 'h:mm A' : 'MMM D, h:mm A'
if (locale) {
date = date.locale(locale)
if (locale !== 'en') template = hourOnly ? 'HH:mm' : 'D MMM, HH:mm'
}
return date.format(template)
}
export function formatJustTime(time: number) {

View File

@@ -516,6 +516,70 @@
"profile.language.english": "Englisch",
"profile.language.french": "Französisch",
"profile.language.fula": "Fulfulde",
"events.title": "Events",
"events.subtitle": "Entdecken und nehmen Sie an Community-Events teil — oder erstellen Sie Ihre eigenen, um Menschen zusammenzubringen",
"events.create_event": "Event erstellen",
"events.why_organize": "Warum Events organisieren?",
"events.why_description": "Events sind das Herzbedeutungsvoller Verbindungen. Ob online oder in Person, sie schaffen Raum für tiefere Gespräche, gemeinsame Erlebnisse und dauerhafte Beziehungen.",
"events.upcoming_events": "Bevorstehende Events",
"events.past_events": "Vergangene Events",
"events.no_upcoming": "Keine bevorstehenden Events. Schauen Sie bald wieder oder erstellen Sie Ihre eigenen!",
"events.no_past": "Keine vergangenen Events",
"events.failed_to_load": "Events konnten nicht geladen werden. Bitte versuchen Sie es später erneut.",
"events.try_again": "Erneut versuchen",
"events.going": "1 partant",
"events.maybe": "maybe",
"events.not_going": "Nicht gehen",
"events.cancel_rsvp": "RSVP abbrechen",
"events.cancel_event": "Event abbrechen",
"events.edit_event": "Event bearbeiten",
"events.create_new_event": "Neues Event erstellen",
"events.update_event": "Event aktualisieren",
"events.event_created": "Event erfolgreich erstellt!",
"events.event_updated": "Event erfolgreich aktualisiert!",
"events.event_cancelled": "Event erfolgreich abgesagt!",
"events.failed_rsvp": "RSVP fehlgeschlagen. Bitte versuchen Sie es erneut.",
"events.failed_cancel_rsvp": "RSVP-Abbruch fehlgeschlagen. Bitte versuchen Sie es erneut.",
"events.failed_cancel_event": "Event-Abbruch fehlgeschlagen. Bitte versuchen Sie es erneut.",
"events.failed_update_event": "Event-Aktualisierung fehlgeschlagen. Bitte versuchen Sie es erneut.",
"events.failed_create_event": "Event-Erstellung fehlgeschlagen. Bitte versuchen Sie es erneut.",
"events.cancelled": "Abgesagt",
"events.completed": "Abgeschlossen",
"events.participants_count": "{count} going",
"events.participants_max": " / {max} max",
"events.maybe_count": " ({count} maybe)",
"events.event_title": "Event-Titel",
"events.description": "Beschreibung",
"events.location_type": "Ortstyp",
"events.location_address": "Ortsadresse",
"events.location_url": "Orts-URL",
"events.start_time": "Startzeit",
"events.end_time": "Endzeit",
"events.max_participants": "Max. Teilnehmer (optional)",
"events.leave_empty": "Leer lassen für unbegrenzt",
"events.in_person": "In Person",
"events.online": "Online",
"events.event_title_placeholder": "Event-Titel eingeben",
"events.description_placeholder": "Beschreiben Sie Ihr Event...",
"events.location_address_placeholder": "Ortsadresse eingeben",
"events.location_url_placeholder": "Orts-URL eingeben",
"events.cancel": "Abbrechen",
"events.loading": "Laden...",
"events.creating": "Erstellen...",
"events.updating": "Aktualisieren...",
"events.max_participants_hint": "Max. Teilnehmer",
"events.login_to_rsvp": "Anmelden für RSVP",
"events.book_clubs": "Buchclubs",
"events.game_nights": "Spieleabende",
"events.walking_groups": "Wandergruppen",
"events.coffee_chats": "Kaffeegespräche",
"events.creative_workshops": "Kreative Workshops",
"events.philosophy_discussions": "Philosophiediskussionen",
"events.sustainability_meetups": "Nachhaltigkeits-Treffen",
"events.hobby_exchanges": "Hobby-Austausche",
"events.started": "Gestartet {time}",
"events.organized_by": "Organisiert von",
"events.online_event_join_link": "Online-Event - Link beitreten",
"profile.language.gan": "Gan",
"profile.language.german": "Deutsch",
"profile.language.greek": "Griechisch",

View File

@@ -516,6 +516,70 @@
"profile.language.english": "Anglais",
"profile.language.french": "Français",
"profile.language.fula": "Peul",
"events.title": "Événements",
"events.subtitle": "Découvrez et participez aux événements communautaires — ou créez les vôtres pour rassembler les gens",
"events.create_event": "Créer un événement",
"events.why_organize": "Pourquoi organiser des événements ?",
"events.why_description": "Les événements sont au cœur de relations qui ont du sens. Qu'ils soient en ligne ou en personne, ils créent un espace pour des conversations plus profondes, des expériences partagées et des relations durables.",
"events.upcoming_events": "Événements à venir",
"events.past_events": "Événements passés",
"events.no_upcoming": "Aucun événement à venir. Revenez bientôt ou créez le vôtre !",
"events.no_past": "Aucun événement passé",
"events.failed_to_load": "Échec du chargement des événements. Veuillez réessayer plus tard.",
"events.try_again": "Réessayer",
"events.going": "Participer",
"events.maybe": "Peut-être",
"events.not_going": "Ne pas aller",
"events.cancel_rsvp": "Annuler RSVP",
"events.cancel_event": "Annuler l'événement",
"events.edit_event": "Modifier l'événement",
"events.create_new_event": "Créer un nouvel événement",
"events.update_event": "Mettre à jour l'événement",
"events.event_created": "Événement créé avec succès !",
"events.event_updated": "Événement mis à jour avec succès !",
"events.event_cancelled": "Événement annulé avec succès !",
"events.failed_rsvp": "Échec de l'RSVP. Veuillez réessayer.",
"events.failed_cancel_rsvp": "Échec de l'annulation de l'RSVP. Veuillez réessayer.",
"events.failed_cancel_event": "Échec de l'annulation de l'événement. Veuillez réessayer.",
"events.failed_update_event": "Échec de la mise à jour de l'événement. Veuillez réessayer.",
"events.failed_create_event": "Échec de la création de l'événement. Veuillez réessayer.",
"events.cancelled": "Annulé",
"events.completed": "Terminé",
"events.participants_count": "{count} going",
"events.participants_max": " / {max} max",
"events.maybe_count": " ({count} maybe)",
"events.event_title": "Titre de l'événement",
"events.description": "Description",
"events.location_type": "Type de lieu",
"events.location_address": "Adresse du lieu",
"events.location_url": "URL du lieu",
"events.start_time": "Heure de début",
"events.end_time": "Heure de fin",
"events.max_participants": "Participants max (optionnel)",
"events.leave_empty": "Laisser vide pour illimité",
"events.in_person": "En personne",
"events.online": "En ligne",
"events.event_title_placeholder": "Entrez le titre de l'événement",
"events.description_placeholder": "Décrivez votre événement...",
"events.location_address_placeholder": "Entrez l'adresse du lieu",
"events.location_url_placeholder": "Entrez l'URL du lieu",
"events.cancel": "Annuler",
"events.loading": "Chargement...",
"events.creating": "Création...",
"events.updating": "Mise à jour...",
"events.max_participants_hint": "Participants max",
"events.login_to_rsvp": "Se connecter pour RSVP",
"events.book_clubs": "Clubs de lecture",
"events.game_nights": "Soirées jeux",
"events.walking_groups": "Groupes de marche",
"events.coffee_chats": "Discussions café",
"events.creative_workshops": "Ateliers créatifs",
"events.philosophy_discussions": "Discussions philosophiques",
"events.sustainability_meetups": "Rencontres durabilité",
"events.hobby_exchanges": "Échanges de loisirs",
"events.started": "Commencé {time}",
"events.organized_by": "Organisé par",
"events.online_event_join_link": "Événement en ligne - Lien d'accès",
"profile.language.gan": "Gan",
"profile.language.german": "Allemand",
"profile.language.greek": "Grec",

View File

@@ -53,6 +53,7 @@
"next": "14.1.0",
"posthog-js": "1.234.1",
"punycode": "2.3.1",
"react-datepicker": "9.1.0",
"react-expanding-textarea": "2.3.6",
"react-hook-form": "7.65.0",
"react-hot-toast": "2.2.0",

205
web/pages/events.tsx Normal file
View File

@@ -0,0 +1,205 @@
'use client'
import {useState} from 'react'
import {Event, useEvents} from 'web/hooks/use-events'
import {useUser} from 'web/hooks/use-user'
import {EventsList} from 'web/components/events/events-list'
import {CreateEventModal} from 'web/components/events/create-event-modal'
import {api} from 'web/lib/api'
import {usePersistentInMemoryState} from 'web/hooks/use-persistent-in-memory-state'
import {APIError} from "common/api/utils";
import toast from "react-hot-toast";
import {PageBase} from "web/components/page-base";
import {useT} from "web/lib/locale";
import {Button} from "web/components/buttons/button";
import {Col} from "web/components/layout/col";
import {CompassLoadingIndicator} from "web/components/widgets/loading-indicator";
export default function EventsPage() {
const user = useUser()
const {events, loading, error, refetch} = useEvents()
const [showCreateModal, setShowCreateModal] = useState(false)
const [editingEvent, setEditingEvent] = useState<Event | null>(null)
const [_rsvpLoading, setRsvpLoading] = usePersistentInMemoryState<Record<string, boolean>>({}, 'rsvp-loading')
const t = useT()
const handleRsvp = async (eventId: string, status: 'going' | 'maybe' | 'not_going') => {
if (!user) return
setRsvpLoading((prev) => ({...prev, [eventId]: true}))
try {
await api('rsvp-event', {eventId, status})
refetch()
console.log('RSVPed to event', eventId)
} catch (err) {
if (err instanceof APIError) {
toast.error(err.message)
} else {
toast.error(t('events.failed_rsvp', 'Failed to RSVP. Please try again.'))
}
} finally {
setRsvpLoading((prev) => ({...prev, [eventId]: false}))
}
}
const handleCancelRsvp = async (eventId: string) => {
if (!user) return
setRsvpLoading((prev) => ({...prev, [eventId]: true}))
try {
await api('cancel-rsvp', {eventId})
refetch()
console.log('Cancelled RSVP to event', eventId)
} catch (err) {
if (err instanceof APIError) {
toast.error(err.message)
} else {
toast.error(t('events.failed_cancel_rsvp', 'Failed to cancel RSVP. Please try again.'))
}
} finally {
setRsvpLoading((prev) => ({...prev, [eventId]: false}))
}
}
const handleCancelEvent = async (eventId: string) => {
if (!user) return
setRsvpLoading((prev) => ({...prev, [eventId]: true}))
try {
await api('cancel-event', {eventId})
refetch()
toast.success(t('events.event_cancelled', 'Event cancelled successfully!'))
console.log('Cancelled event', eventId)
} catch (err) {
if (err instanceof APIError) {
toast.error(err.message)
} else {
toast.error(t('events.failed_cancel_event', 'Failed to cancel event. Please try again.'))
}
} finally {
setRsvpLoading((prev) => ({...prev, [eventId]: false}))
}
}
const handleEdit = (event: Event) => {
setEditingEvent(event)
setShowCreateModal(true)
}
return (
<PageBase trackPageView={'events'}>
<Col className=" mx-8 px-4 py-8 sm:px-6 lg:px-8">
{/* Header */}
<div className="mb-4">
<div>
<h1 className="text-3xl font-bold">{t('events.title', 'Events')}</h1>
<p className="text-ink-500 mt-1">
{t('events.subtitle', 'Discover and join community events — or create your own to bring people together')}
</p>
</div>
{/* Event Ideas Section */}
<div className="mt-6 bg-canvas-100 rounded-lg p-4">
<h2 className="text-lg font-semibold mb-2 mt-0">{t('events.why_organize', 'Why organize events?')}</h2>
<p className="text-ink-600 text-sm mb-3">
{t('events.why_description', 'Events are the heart of meaningful connection. Whether online or in-person, they create space for deeper conversations, shared experiences, and lasting relationships.')}
</p>
<div className="flex flex-wrap gap-2">
<span
className="bg-canvas-0 text-ink-700 px-3 py-1 rounded-full text-xs">📚 {t('events.book_clubs', 'Book clubs')}</span>
<span
className="bg-canvas-0 text-ink-700 px-3 py-1 rounded-full text-xs">🎮 {t('events.game_nights', 'Game nights')}</span>
<span
className="bg-canvas-0 text-ink-700 px-3 py-1 rounded-full text-xs">🚶 {t('events.walking_groups', 'Walking groups')}</span>
<span
className="bg-canvas-0 text-ink-700 px-3 py-1 rounded-full text-xs"> {t('events.coffee_chats', 'Coffee chats')}</span>
<span
className="bg-canvas-0 text-ink-700 px-3 py-1 rounded-full text-xs">🎨 {t('events.creative_workshops', 'Creative workshops')}</span>
<span
className="bg-canvas-0 text-ink-700 px-3 py-1 rounded-full text-xs">🤔 {t('events.philosophy_discussions', 'Philosophy discussions')}</span>
<span
className="bg-canvas-0 text-ink-700 px-3 py-1 rounded-full text-xs">🌱 {t('events.sustainability_meetups', 'Sustainability meetups')}</span>
<span
className="bg-canvas-0 text-ink-700 px-3 py-1 rounded-full text-xs">🎯 {t('events.hobby_exchanges', 'Hobby exchanges')}</span>
</div>
</div>
{user && (
<Button
onClick={() => setShowCreateModal(true)}
className="flex items-center gap-2 rounded-md px-4 py-2 mt-4"
>
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4"/>
</svg>
{t('events.create_event', 'Create Event')}
</Button>
)}
</div>
{/* Loading State */}
{loading && <CompassLoadingIndicator/>}
{/* Error State */}
{error && !loading && (
<div className="text-red-800 rounded-md p-4">
<p>{t('events.failed_to_load', 'Failed to load events. Please try again later.')}</p>
<button
onClick={refetch}
className="text-red-600 hover:text-red-800 mt-2 text-sm font-medium underline"
>
{t('events.try_again', 'Retry')}
</button>
</div>
)}
{/* Events Content */}
{!loading && !error && events && (
<div className="space-y-10">
{/* Upcoming Events */}
<EventsList
events={events.upcoming}
title={t('events.upcoming_events', 'Upcoming Events')}
emptyMessage={t('events.no_upcoming', 'No upcoming events. Check back soon or create your own!')}
onRsvp={handleRsvp}
onCancelRsvp={handleCancelRsvp}
onCancelEvent={handleCancelEvent}
onEdit={handleEdit}
/>
{/* Past Events */}
{events.past.length > 0 && (
<EventsList
events={events.past}
title={t('events.past_events', 'Past Events')}
emptyMessage=""
onRsvp={handleRsvp}
onCancelRsvp={handleCancelRsvp}
onCancelEvent={handleCancelEvent}
onEdit={handleEdit}
/>
)}
</div>
)}
{/* {t('events.create_event', 'Create Event')} Modal */}
<CreateEventModal
open={showCreateModal}
setOpen={setShowCreateModal}
event={editingEvent}
onClose={() => {
setShowCreateModal(false)
setEditingEvent(null)
}}
onSuccess={() => {
refetch()
toast.success(editingEvent ? t('events.event_updated', 'Event updated successfully!') : t('events.event_created', 'Event created successfully!'))
setShowCreateModal(false)
setEditingEvent(null)
}}
/>
</Col>
</PageBase>
)
}

View File

@@ -42,6 +42,39 @@ und du Beziehungen suchst, die auf **geteilten Werten, Vertrauen und Verständni
Scrollen nötig.
* **Persönlichkeitszentriert**: Werte und Ideen stehen im Vordergrund. Fotos sind zweitrangig.
* **Demokratisch & Open Source**: Von der Community für die Community — keine Werbung, keine versteckte Monetarisierung.
* **Events**: Erstelle und nimm an realen oder virtuellen Treffen teil, um tiefere Verbindungen zu knüpfen.
### Was sind Events und warum sind sie wichtig?
**Events** stehen im Mittelpunkt von Compass' Mission, tiefe und bedeutungsvolle Verbindungen zu fördern. Während
Profile und Kompatibilitätswerte dir helfen, Gleichgesinnte zu entdecken, schaffen Events den Raum, in dem echte
Beziehungen entstehen.
**Warum Events für unsere Mission wichtig sind:**
* **Tiefe durch gemeinsame Erlebnisse**: Treffen im echten Leben (oder virtuell) ermöglichen nuancierte Gespräche,
Lachen und spontane Momente, die echtes Vertrauen und Verständnis aufbauen.
* **Community-Aufbau**: Events verwandeln individuelle Verbindungen in ein florierendes Ökosystem von Menschen, die sich
gegenseitig bei ihrem Wachstum und ihren Zielen unterstützen.
* **Zielgerichtetes Zusammenkommen**: Im Gegensatz zu generischen sozialen Plattformen werden Compass-Events um
gemeinsame Werte, Interessen und Absichten herum organisiert — was von Anfang an natürliche Übereinstimmung schafft.
* **Zugänglichkeit**: Sowohl Online- als auch Offline-Events stellen sicher, dass jeder teilnehmen kann, unabhängig von
Geografie oder Mobilität.
**Beispiele für Events, die du finden oder erstellen kannst:**
* 📚 **Buchclubs** — Diskutiere Ideen, die unsere Weltanschauung prägen
* 🚶 **Wandertreffs** — Erkunde die Natur und tausche dich aus
***Kaffeeklatsch** — Eins-zu-eins oder kleine Gruppen für tiefe Gespräche
* 🎨 **Kreativ-Workshops** — Lerne und schaffe gemeinsam
* 🤔 **Philosophie-Diskussionen** — Erkunde die großen Fragen des Lebens
* 🌱 **Nachhaltigkeits-Treffs** — Kollaboriere für positive Wirkung
* 🎮 **Spieleabende** — Verbünde dich beim gemeinsamen Spielen
* 🎯 **Hobby-Tauschbörsen** — Lehre und lerne Fähigkeiten voneinander
Jeder kann [ein Event erstellen](/events) — ob du eine Wanderung organisieren, einen Schreibkreis starten oder eine
Diskussion über effektiven Altruismus hosten möchtest. Events sind es, wie wir gemeinsame Werte in gemeinsames Leben
verwandeln.
### Ist Compass für Dating oder Freundschaften?

View File

@@ -29,6 +29,7 @@ Anyone who wants more than small talk or casual networking. If you value **depth
* **Notification System**: Get alerts when new people match your searches — no endless scrolling required.
* **Personality-Centered**: Values and ideas first. Photos stay secondary.
* **Democratic & Open Source**: Built by the community, for the community — no ads, no hidden monetization.
* **Events**: Create and join real-world or virtual gatherings to form deeper connections.
### Is Compass for dating or friendship?
@@ -97,6 +98,36 @@ Matches are scored based on how well two peoples responses and accepted answe
The [full implementation](https://github.com/CompassConnections/Compass/blob/main/common/src/profiles/compatibility-score.ts) is **open source** and open to review, feedback, and improvement by the community.
### What are Events and why do they matter?
**Events** are at the core of Compass's mission to foster deep, meaningful connections. While profiles and compatibility
scores help you discover kindred spirits, events create the space where genuine relationships actually form.
**Why events matter for our mission:**
* **Depth through shared experience**: Meeting in person (or virtually) allows for the nuanced conversations, laughter,
and spontaneous moments that build real trust and understanding.
* **Community building**: Events transform individual connections into a thriving ecosystem of people supporting each
other's growth and goals.
* **Purposeful gathering**: Unlike generic social platforms, Compass events are organized around shared values,
interests, and intentions — creating natural alignment from the start.
* **Accessibility**: Both online and in-person events ensure everyone can participate, regardless of geography or
mobility.
Examples of events you might find or create:
* **Book clubs** — Discuss ideas that shape our worldview
* **Walking groups** — Explore nature while exploring ideas
* **Coffee chats** — One-on-one or small group deep conversations
* **Creative workshops** — Learn and create together
* **Philosophy discussions** — Explore life's big questions
* **Sustainability meetups** — Collaborate on positive impact
* **Game nights** — Bond over shared play
* **Hobby exchanges** — Teach and learn skills from each other
Anyone can [create an event](/events) — whether you're looking to find a hiking buddy, start a writers' circle, or host
a discussion about effective altruism. Events are how we turn shared values into shared lives.
### What platforms does Compass run on?
Compass is mostly both as [website](https://www.compassmeet.com/)

View File

@@ -30,6 +30,39 @@ Pour toute personne qui souhaite autre chose que des discussions superficielles
* **Système de notifications** : Recevez des alertes lorsque de nouvelles personnes correspondent à vos critères — pas de scroll infini.
* **Centré sur la personnalité** : Valeurs et idées dabord. Les photos restent secondaires.
* **Démocratique et open-source** : Construit par la communauté, pour la communauté — pas de publicité, pas de monétisation cachée.
* **Événements** : Créez et participez à des rencontres réelles ou virtuelles pour tisser des liens plus profonds.
### Qu'est-ce que les Événements et pourquoi sont-ils importants ?
**Les Événements** sont au cœur de la mission de Compass : favoriser des connexions profondes et significatives. Si les
profils et les scores de compatibilité vous aident à découvrir des âmes sœurs, les événements créent l'espace où les
relations authentiques se forment réellement.
**Pourquoi les événements comptent pour notre mission :**
* **Profondeur par l'expérience partagée** : Se rencontrer en personne (ou virtuellement) permet des conversations
nuancées, des rires et des moments spontanés qui construisent la confiance et la compréhension.
* **Construction de communauté** : Les événements transforment les connexions individuelles en un écosystème florissant
de personnes qui se soutiennent mutuellement dans leur croissance et leurs objectifs.
* **Rencontres intentionnelles** : Contrairement aux plateformes sociales génériques, les événements Compass sont
organisés autour de valeurs, d'intérêts et d'intentions partagés — créant un alignement naturel dès le départ.
* **Accessibilité** : Les événements en ligne et en personne garantissent que chacun peut participer, indépendamment de
la géographie ou de la mobilité.
**Exemples d'événements que vous pouvez trouver ou créer :**
* 📚 **Clubs de lecture** — Discutez des idées qui façonnent notre vision du monde
* 🚶 **Groupes de marche** — Explorez la nature tout en explorant des idées
***Conversations autour d'un café** — Entretiens en tête-à-tête ou petits groupes
* 🎨 **Ateliers créatifs** — Apprenez et créez ensemble
* 🤔 **Discussions philosophiques** — Explorez les grandes questions de l'existence
* 🌱 **Rencontres sur la durabilité** — Collaborez pour un impact positif
* 🎮 **Soirées jeux** — Créez des liens autour du jeu partagé
* 🎯 **Échanges de hobbies** — Enseignez et apprenez des compétences mutuellement
Chacun peut [créer un événement](/events) — que vous cherchiez un compagnon de randonnée, que vous souhaitiez lancer un
cercle d'écriture, ou organiser une discussion sur l'altruisme efficace. Les événements sont le moyen par lequel nous
transformons les valeurs partagées en vies partagées.
### Compass est-il destiné à la rencontre ou à lamitié ?

View File

@@ -1911,6 +1911,13 @@
dependencies:
"@floating-ui/utils" "^0.2.10"
"@floating-ui/core@^1.7.4":
version "1.7.4"
resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.7.4.tgz#4a006a6e01565c0f87ba222c317b056a2cffd2f4"
integrity sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==
dependencies:
"@floating-ui/utils" "^0.2.10"
"@floating-ui/dom@^1.2.1":
version "1.6.5"
resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.6.5.tgz#323f065c003f1d3ecf0ff16d2c2c4d38979f4cb9"
@@ -1927,6 +1934,14 @@
"@floating-ui/core" "^1.7.3"
"@floating-ui/utils" "^0.2.10"
"@floating-ui/dom@^1.7.5":
version "1.7.5"
resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.7.5.tgz#60bfc83a4d1275b2a90db76bf42ca2a5f2c231c2"
integrity sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==
dependencies:
"@floating-ui/core" "^1.7.4"
"@floating-ui/utils" "^0.2.10"
"@floating-ui/react-dom@2.1.6":
version "2.1.6"
resolved "https://registry.yarnpkg.com/@floating-ui/react-dom/-/react-dom-2.1.6.tgz#189f681043c1400561f62972f461b93f01bf2231"
@@ -1941,6 +1956,13 @@
dependencies:
"@floating-ui/dom" "^1.2.1"
"@floating-ui/react-dom@^2.1.7":
version "2.1.7"
resolved "https://registry.yarnpkg.com/@floating-ui/react-dom/-/react-dom-2.1.7.tgz#529475cc16ee4976ba3387968117e773d9aa703e"
integrity sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg==
dependencies:
"@floating-ui/dom" "^1.7.5"
"@floating-ui/react@0.19.0":
version "0.19.0"
resolved "https://registry.yarnpkg.com/@floating-ui/react/-/react-0.19.0.tgz#d8e19a3fcfaa0684d5ec3f335232b4e0ac0c87e1"
@@ -1950,6 +1972,15 @@
aria-hidden "^1.1.3"
tabbable "^6.0.1"
"@floating-ui/react@^0.27.15":
version "0.27.18"
resolved "https://registry.yarnpkg.com/@floating-ui/react/-/react-0.27.18.tgz#a664a45a867ed5e2999a858b5236a53cf60cf412"
integrity sha512-xJWJxvmy3a05j643gQt+pRbht5XnTlGpsEsAPnMi5F5YTOEEJymA90uZKBD8OvIv5XvZ1qi4GcccSlqT3Bq44Q==
dependencies:
"@floating-ui/react-dom" "^2.1.7"
"@floating-ui/utils" "^0.2.10"
tabbable "^6.0.0"
"@floating-ui/utils@^0.2.0":
version "0.2.2"
resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.2.tgz#d8bae93ac8b815b2bd7a98078cf91e2724ef11e5"
@@ -6848,6 +6879,11 @@ date-fns@^2.30.0:
dependencies:
"@babel/runtime" "^7.21.0"
date-fns@^4.1.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-4.1.0.tgz#64b3d83fff5aa80438f5b1a633c2e83b8a1c2d14"
integrity sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==
dateformat@^3.0.0:
version "3.0.3"
resolved "https://registry.yarnpkg.com/dateformat/-/dateformat-3.0.3.tgz#a6e37499a4d9a9cf85ef5872044d62901c9889ae"
@@ -13716,6 +13752,15 @@ re2@^1.17.7:
nan "^2.23.1"
node-gyp "^11.5.0"
react-datepicker@9.1.0:
version "9.1.0"
resolved "https://registry.yarnpkg.com/react-datepicker/-/react-datepicker-9.1.0.tgz#638c636780ae98f1930f87e1f76850de5fc37d5c"
integrity sha512-lOp+m5bc+ttgtB5MHEjwiVu4nlp4CvJLS/PG1OiOe5pmg9kV73pEqO8H0Geqvg2E8gjqTaL9eRhSe+ZpeKP3nA==
dependencies:
"@floating-ui/react" "^0.27.15"
clsx "^2.1.1"
date-fns "^4.1.0"
react-dom@18.2.0:
version "18.2.0"
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.2.0.tgz#22aaf38708db2674ed9ada224ca4aa708d821e3d"
@@ -15220,6 +15265,11 @@ swagger-ui-express@5.0.1:
dependencies:
swagger-ui-dist ">=5.0.0"
tabbable@^6.0.0:
version "6.4.0"
resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-6.4.0.tgz#36eb7a06d80b3924a22095daf45740dea3bf5581"
integrity sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==
tabbable@^6.0.1:
version "6.2.0"
resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-6.2.0.tgz#732fb62bc0175cfcec257330be187dcfba1f3b97"