mirror of
https://github.com/CompassConnections/Compass.git
synced 2026-02-18 23:06:40 -05:00
Add events page
This commit is contained in:
@@ -2,8 +2,8 @@
|
||||
|
||||
android {
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_21
|
||||
targetCompatibility JavaVersion.VERSION_21
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
46
backend/api/src/cancel-event.ts
Normal file
46
backend/api/src/cancel-event.ts
Normal 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}
|
||||
}
|
||||
38
backend/api/src/cancel-rsvp.ts
Normal file
38
backend/api/src/cancel-rsvp.ts
Normal 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}
|
||||
}
|
||||
49
backend/api/src/create-event.ts
Normal file
49
backend/api/src/create-event.ts
Normal 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}
|
||||
}
|
||||
87
backend/api/src/get-events.ts
Normal file
87
backend/api/src/get-events.ts
Normal 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}
|
||||
}
|
||||
83
backend/api/src/rsvp-event.ts
Normal file
83
backend/api/src/rsvp-event.ts
Normal 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}
|
||||
}
|
||||
53
backend/api/src/update-event.ts
Normal file
53
backend/api/src/update-event.ts
Normal 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}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
58
backend/supabase/migrations/20260218_add_events.sql
Normal file
58
backend/supabase/migrations/20260218_add_events.sql
Normal 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)
|
||||
);
|
||||
@@ -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
|
||||
|
||||
@@ -99,6 +99,7 @@ export const RESERVED_PATHS = [
|
||||
'dashboard',
|
||||
'discord',
|
||||
'embed',
|
||||
'events',
|
||||
'facebook',
|
||||
'faq',
|
||||
'financials',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
})
|
||||
};
|
||||
324
web/components/events/create-event-modal.tsx
Normal file
324
web/components/events/create-event-modal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
228
web/components/events/event-card.tsx
Normal file
228
web/components/events/event-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
37
web/components/events/events-list.tsx
Normal file
37
web/components/events/events-list.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
31
web/components/events/user-link.tsx
Normal file
31
web/components/events/user-link.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
66
web/hooks/use-events.ts
Normal 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}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
205
web/pages/events.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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?
|
||||
|
||||
|
||||
@@ -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 people’s 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/)
|
||||
|
||||
@@ -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 d’abord. 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 à l’amitié ?
|
||||
|
||||
|
||||
50
yarn.lock
50
yarn.lock
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user