forked from jmug/cactoide
feat(tmp): invite link feature
This commit is contained in:
135
Makefile
135
Makefile
@@ -1,67 +1,94 @@
|
|||||||
.PHONY: help build up db-only logs db-clean prune i18n lint format
|
# Cactoide Makefile
|
||||||
|
# Database and application management commands
|
||||||
|
|
||||||
|
.PHONY: help migrate-up migrate-down db-reset dev build test
|
||||||
|
|
||||||
# Default target
|
# Default target
|
||||||
help:
|
help:
|
||||||
@echo "Cactoide Commands"
|
@echo "Available commands:"
|
||||||
@echo ""
|
@echo " migrate-up - Apply invite-only events migration"
|
||||||
@echo "Main commands:"
|
@echo " migrate-down - Rollback invite-only events migration"
|
||||||
@echo " make build - Build the Docker images"
|
@echo " db-reset - Reset database to initial state"
|
||||||
@echo " make up - Start all services (database + app)"
|
@echo " dev - Start development server"
|
||||||
@echo ""
|
@echo " build - Build the application"
|
||||||
@echo "Individual services:"
|
@echo " test - Run tests"
|
||||||
@echo " make db-only - Start only the database"
|
|
||||||
@echo ""
|
|
||||||
@echo "Utility commands:"
|
|
||||||
@echo " make logs - Show logs from all services"
|
|
||||||
@echo " make db-clean - Stop & remove database container"
|
|
||||||
@echo " make prune - Remove all containers, images, and volumes"
|
|
||||||
@echo " make i18n - Validate translation files against messages.json"
|
|
||||||
@echo " make help - Show this help message"
|
|
||||||
|
|
||||||
# Build the Docker images
|
# Database connection variables
|
||||||
build:
|
DB_HOST ?= localhost
|
||||||
@echo "Building Docker images..."
|
DB_PORT ?= 5432
|
||||||
docker compose build
|
DB_NAME ?= cactoide_database
|
||||||
|
DB_USER ?= cactoide
|
||||||
|
DB_PASSWORD ?= cactoide_password
|
||||||
|
|
||||||
# Start all services
|
# Migration variables
|
||||||
up:
|
MIGRATIONS_DIR = database/migrations
|
||||||
@echo "Starting all services..."
|
|
||||||
docker compose up -d
|
|
||||||
|
|
||||||
# Start only the database
|
# Database connection string
|
||||||
db-only:
|
DB_URL = postgresql://$(DB_USER):$(DB_PASSWORD)@$(DB_HOST):$(DB_PORT)/$(DB_NAME)
|
||||||
@echo "Starting only the database..."
|
|
||||||
docker compose up -d postgres
|
|
||||||
|
|
||||||
# Show logs from all services
|
# Apply invite-only events migration
|
||||||
logs:
|
migrate-up:
|
||||||
@echo "Showing logs from all services..."
|
@echo "Applying invite-only events migration..."
|
||||||
docker compose logs -f
|
@if [ -f "$(MIGRATIONS_DIR)/20241220_001_add_invite_only_events.sql" ]; then \
|
||||||
|
psql "$(DB_URL)" -f "$(MIGRATIONS_DIR)/20241220_001_add_invite_only_events.sql" && \
|
||||||
db-clean:
|
echo "Migration applied successfully!"; \
|
||||||
@echo "Cleaning up all Docker resources..."
|
|
||||||
docker stop cactoide-db && docker rm cactoide-db && docker volume prune -f && docker network prune -f
|
|
||||||
|
|
||||||
# Clean up everything (containers, images, volumes)
|
|
||||||
prune:
|
|
||||||
@echo "Cleaning up all Docker resources..."
|
|
||||||
docker compose down -v --rmi all
|
|
||||||
|
|
||||||
# Validate translation files
|
|
||||||
i18n:
|
|
||||||
@echo "Validating translation files..."
|
|
||||||
@if [ -n "$(FILE)" ]; then \
|
|
||||||
./scripts/i18n-check.sh $(FILE); \
|
|
||||||
else \
|
else \
|
||||||
./scripts/i18n-check.sh; \
|
echo "Migration file not found: $(MIGRATIONS_DIR)/20241220_001_add_invite_only_events.sql"; \
|
||||||
|
exit 1; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
lint:
|
# Rollback invite-only events migration
|
||||||
@echo "Linting the project..."
|
migrate-down:
|
||||||
npm run lint
|
@echo "Rolling back invite-only events migration..."
|
||||||
|
@if [ -f "$(MIGRATIONS_DIR)/20241220_001_add_invite_only_events_rollback.sql" ]; then \
|
||||||
|
psql "$(DB_URL)" -f "$(MIGRATIONS_DIR)/20241220_001_add_invite_only_events_rollback.sql" && \
|
||||||
|
echo "Migration rolled back successfully!"; \
|
||||||
|
else \
|
||||||
|
echo "Rollback file not found: $(MIGRATIONS_DIR)/20241220_001_add_invite_only_events_rollback.sql"; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
|
||||||
format:
|
# Reset database to initial state
|
||||||
@echo "Formatting the project..."
|
db-reset:
|
||||||
npm run format
|
@echo "Resetting database..."
|
||||||
|
@psql "$(DB_URL)" -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public; GRANT ALL ON SCHEMA public TO postgres; GRANT ALL ON SCHEMA public TO public;"
|
||||||
|
@psql "$(DB_URL)" -f database/init.sql
|
||||||
|
@echo "Database reset complete!"
|
||||||
|
|
||||||
|
# Development server
|
||||||
|
dev:
|
||||||
|
@echo "Starting development server..."
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Build application
|
||||||
|
build:
|
||||||
|
@echo "Building application..."
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
test:
|
||||||
|
@echo "Running tests..."
|
||||||
|
npm run test
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
install:
|
||||||
|
@echo "Installing dependencies..."
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Docker commands
|
||||||
|
docker-build:
|
||||||
|
@echo "Building Docker image..."
|
||||||
|
docker build -t cactoide .
|
||||||
|
|
||||||
|
docker-run:
|
||||||
|
@echo "Running Docker container..."
|
||||||
|
docker run -p 3000:3000 cactoide
|
||||||
|
|
||||||
|
# Database setup for development
|
||||||
|
db-setup: install db-reset migrate-up
|
||||||
|
@echo "Database setup complete!"
|
||||||
|
|
||||||
|
# Full development setup
|
||||||
|
setup: install db-setup
|
||||||
|
@echo "Development environment ready!"
|
||||||
|
@echo "Run 'make dev' to start the development server"
|
||||||
@@ -42,6 +42,9 @@ CREATE INDEX IF NOT EXISTS idx_events_date ON events(date);
|
|||||||
CREATE INDEX IF NOT EXISTS idx_events_location_type ON events(location_type);
|
CREATE INDEX IF NOT EXISTS idx_events_location_type ON events(location_type);
|
||||||
CREATE INDEX IF NOT EXISTS idx_rsvps_event_id ON rsvps(event_id);
|
CREATE INDEX IF NOT EXISTS idx_rsvps_event_id ON rsvps(event_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_rsvps_user_id ON rsvps(user_id);
|
CREATE INDEX IF NOT EXISTS idx_rsvps_user_id ON rsvps(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_invite_tokens_event_id ON invite_tokens(event_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_invite_tokens_token ON invite_tokens(token);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_invite_tokens_expires_at ON invite_tokens(expires_at);
|
||||||
|
|
||||||
-- =======================================
|
-- =======================================
|
||||||
-- Triggers (updated_at maintenance)
|
-- Triggers (updated_at maintenance)
|
||||||
|
|||||||
25
database/migrations/20241220_001_add_invite_only_events.sql
Normal file
25
database/migrations/20241220_001_add_invite_only_events.sql
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
-- Migration: Add invite-only events feature
|
||||||
|
-- Created: 2024-12-20
|
||||||
|
-- Description: Adds invite-only visibility option and invite tokens table
|
||||||
|
|
||||||
|
-- Add 'invite-only' to the visibility enum
|
||||||
|
ALTER TABLE events
|
||||||
|
DROP CONSTRAINT IF EXISTS events_visibility_check;
|
||||||
|
|
||||||
|
ALTER TABLE events
|
||||||
|
ADD CONSTRAINT events_visibility_check
|
||||||
|
CHECK (visibility IN ('public', 'private', 'invite-only'));
|
||||||
|
|
||||||
|
-- Create invite_tokens table
|
||||||
|
CREATE TABLE IF NOT EXISTS invite_tokens (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
event_id VARCHAR(8) NOT NULL REFERENCES events(id) ON DELETE CASCADE,
|
||||||
|
token VARCHAR(32) NOT NULL UNIQUE,
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create indexes for invite_tokens table
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_invite_tokens_event_id ON invite_tokens(event_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_invite_tokens_token ON invite_tokens(token);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_invite_tokens_expires_at ON invite_tokens(expires_at);
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
-- Rollback Migration: Remove invite-only events feature
|
||||||
|
-- Created: 2024-12-20
|
||||||
|
-- Description: Removes invite-only visibility option and invite tokens table
|
||||||
|
|
||||||
|
-- Drop invite_tokens table and its indexes
|
||||||
|
DROP INDEX IF EXISTS idx_invite_tokens_expires_at;
|
||||||
|
DROP INDEX IF EXISTS idx_invite_tokens_token;
|
||||||
|
DROP INDEX IF EXISTS idx_invite_tokens_event_id;
|
||||||
|
DROP TABLE IF EXISTS invite_tokens;
|
||||||
|
|
||||||
|
-- Revert visibility enum to original values
|
||||||
|
ALTER TABLE events
|
||||||
|
DROP CONSTRAINT IF EXISTS events_visibility_check;
|
||||||
|
|
||||||
|
ALTER TABLE events
|
||||||
|
ADD CONSTRAINT events_visibility_check
|
||||||
|
CHECK (visibility IN ('public', 'private'));
|
||||||
@@ -16,7 +16,7 @@ import type { InferInsertModel, InferSelectModel } from 'drizzle-orm';
|
|||||||
|
|
||||||
// --- Enums (matching the SQL CHECK constraints)
|
// --- Enums (matching the SQL CHECK constraints)
|
||||||
export const eventTypeEnum = pgEnum('event_type', ['limited', 'unlimited']);
|
export const eventTypeEnum = pgEnum('event_type', ['limited', 'unlimited']);
|
||||||
export const visibilityEnum = pgEnum('visibility', ['public', 'private']);
|
export const visibilityEnum = pgEnum('visibility', ['public', 'private', 'invite-only']);
|
||||||
export const locationTypeEnum = pgEnum('location_type', ['none', 'text', 'maps']);
|
export const locationTypeEnum = pgEnum('location_type', ['none', 'text', 'maps']);
|
||||||
|
|
||||||
// --- Events table
|
// --- Events table
|
||||||
@@ -71,11 +71,31 @@ export const rsvps = pgTable(
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// --- Invite Tokens table
|
||||||
|
export const inviteTokens = pgTable(
|
||||||
|
'invite_tokens',
|
||||||
|
{
|
||||||
|
id: uuid('id').defaultRandom().primaryKey(),
|
||||||
|
eventId: varchar('event_id', { length: 8 })
|
||||||
|
.notNull()
|
||||||
|
.references(() => events.id, { onDelete: 'cascade' }),
|
||||||
|
token: varchar('token', { length: 32 }).notNull().unique(),
|
||||||
|
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow()
|
||||||
|
},
|
||||||
|
(t) => ({
|
||||||
|
idxInviteTokensEventId: index('idx_invite_tokens_event_id').on(t.eventId),
|
||||||
|
idxInviteTokensToken: index('idx_invite_tokens_token').on(t.token),
|
||||||
|
idxInviteTokensExpiresAt: index('idx_invite_tokens_expires_at').on(t.expiresAt)
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
// --- Relations (optional but handy for type safety)
|
// --- Relations (optional but handy for type safety)
|
||||||
import { relations } from 'drizzle-orm';
|
import { relations } from 'drizzle-orm';
|
||||||
|
|
||||||
export const eventsRelations = relations(events, ({ many }) => ({
|
export const eventsRelations = relations(events, ({ many }) => ({
|
||||||
rsvps: many(rsvps)
|
rsvps: many(rsvps),
|
||||||
|
inviteTokens: many(inviteTokens)
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const rsvpsRelations = relations(rsvps, ({ one }) => ({
|
export const rsvpsRelations = relations(rsvps, ({ one }) => ({
|
||||||
@@ -85,16 +105,30 @@ export const rsvpsRelations = relations(rsvps, ({ one }) => ({
|
|||||||
})
|
})
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
export const inviteTokensRelations = relations(inviteTokens, ({ one }) => ({
|
||||||
|
event: one(events, {
|
||||||
|
fields: [inviteTokens.eventId],
|
||||||
|
references: [events.id]
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
|
||||||
// --- Inferred types for use in the application
|
// --- Inferred types for use in the application
|
||||||
export type Event = InferSelectModel<typeof events>;
|
export type Event = InferSelectModel<typeof events>;
|
||||||
export type NewEvent = InferInsertModel<typeof events>;
|
export type NewEvent = InferInsertModel<typeof events>;
|
||||||
export type Rsvp = InferSelectModel<typeof rsvps>;
|
export type Rsvp = InferSelectModel<typeof rsvps>;
|
||||||
export type NewRsvp = InferInsertModel<typeof rsvps>;
|
export type NewRsvp = InferInsertModel<typeof rsvps>;
|
||||||
|
export type InviteToken = InferSelectModel<typeof inviteTokens>;
|
||||||
|
export type NewInviteToken = InferInsertModel<typeof inviteTokens>;
|
||||||
|
|
||||||
// --- Additional utility types
|
// --- Additional utility types
|
||||||
export type EventWithRsvps = Event & {
|
export type EventWithRsvps = Event & {
|
||||||
rsvps: Rsvp[];
|
rsvps: Rsvp[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type EventWithInviteTokens = Event & {
|
||||||
|
inviteTokens: InviteToken[];
|
||||||
|
};
|
||||||
|
|
||||||
export type CreateEventData = Omit<NewEvent, 'id' | 'createdAt' | 'updatedAt'>;
|
export type CreateEventData = Omit<NewEvent, 'id' | 'createdAt' | 'updatedAt'>;
|
||||||
export type CreateRsvpData = Omit<NewRsvp, 'id' | 'createdAt'>;
|
export type CreateRsvpData = Omit<NewRsvp, 'id' | 'createdAt'>;
|
||||||
|
export type CreateInviteTokenData = Omit<NewInviteToken, 'id' | 'createdAt'>;
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
"visibility": "Visibility",
|
"visibility": "Visibility",
|
||||||
"public": "Public",
|
"public": "Public",
|
||||||
"private": "Private",
|
"private": "Private",
|
||||||
|
"inviteOnly": "Invite Only",
|
||||||
"limited": "Limited",
|
"limited": "Limited",
|
||||||
"unlimited": "Unlimited",
|
"unlimited": "Unlimited",
|
||||||
"capacity": "Capacity",
|
"capacity": "Capacity",
|
||||||
@@ -41,7 +42,7 @@
|
|||||||
"numberOfGuests": "Number of Guests",
|
"numberOfGuests": "Number of Guests",
|
||||||
"addGuests": "Add guest users",
|
"addGuests": "Add guest users",
|
||||||
"joinEvent": "Join Event",
|
"joinEvent": "Join Event",
|
||||||
"copyLink": "Copy Link",
|
"copyLink": "Event link copied to clipboard.",
|
||||||
"addToCalendar": "Add to Calendar",
|
"addToCalendar": "Add to Calendar",
|
||||||
"close": "Close",
|
"close": "Close",
|
||||||
"closeModal": "Close modal",
|
"closeModal": "Close modal",
|
||||||
@@ -64,9 +65,11 @@
|
|||||||
"eventNotFound": "Event Not Found",
|
"eventNotFound": "Event Not Found",
|
||||||
"eventIsFull": "Event is Full!",
|
"eventIsFull": "Event is Full!",
|
||||||
"maximumCapacityReached": "Maximum capacity reached",
|
"maximumCapacityReached": "Maximum capacity reached",
|
||||||
"eventLinkCopied": "Event link copied to clipboard!",
|
|
||||||
"rsvpAddedSuccessfully": "RSVP added successfully!",
|
"rsvpAddedSuccessfully": "RSVP added successfully!",
|
||||||
"removedRsvpSuccessfully": "Removed RSVP successfully.",
|
"removedRsvpSuccessfully": "Removed RSVP successfully.",
|
||||||
|
"inviteRequiredToDetails": "This event requires an invite link to see the details.",
|
||||||
|
"invalidInviteToken": "Invalid invite token",
|
||||||
|
"inviteTokenExpired": "Invite token has expired",
|
||||||
"anUnexpectedErrorOccurred": "An unexpected error occurred.",
|
"anUnexpectedErrorOccurred": "An unexpected error occurred.",
|
||||||
"somethingWentWrong": "Something went wrong. Please try again.",
|
"somethingWentWrong": "Something went wrong. Please try again.",
|
||||||
"failedToAddRsvp": "Failed to add RSVP",
|
"failedToAddRsvp": "Failed to add RSVP",
|
||||||
@@ -160,8 +163,10 @@
|
|||||||
"visibilityLabel": "Visibility",
|
"visibilityLabel": "Visibility",
|
||||||
"publicOption": "🌍 Public",
|
"publicOption": "🌍 Public",
|
||||||
"privateOption": "🔒 Private",
|
"privateOption": "🔒 Private",
|
||||||
|
"inviteOnlyOption": "🚧 Invite Only",
|
||||||
"publicDescription": "Public events are visible to everyone and can be discovered by others.",
|
"publicDescription": "Public events are visible to everyone and can be discovered by others.",
|
||||||
"privateDescription": "Private events are only visible to you and people you share the link with.",
|
"privateDescription": "Private events are only visible to you and people you share the link with.",
|
||||||
|
"inviteOnlyDescription": "Event is public but requires a special invite link to attend.",
|
||||||
"creatingEvent": "Creating Event...",
|
"creatingEvent": "Creating Event...",
|
||||||
"createEventButton": "Create Event"
|
"createEventButton": "Create Event"
|
||||||
},
|
},
|
||||||
@@ -188,9 +193,14 @@
|
|||||||
"noAttendeesYet": "No attendees yet",
|
"noAttendeesYet": "No attendees yet",
|
||||||
"beFirstToJoin": "Be the first to join!",
|
"beFirstToJoin": "Be the first to join!",
|
||||||
"copyLinkButton": "Copy Link",
|
"copyLinkButton": "Copy Link",
|
||||||
|
"copyInviteLinkButton": "Copy Invite Link",
|
||||||
|
"inviteOnlyBadge": "Invite Only",
|
||||||
|
"inviteOnlyBannerTitle": "Invite Only Event",
|
||||||
|
"inviteOnlyBannerSubtitle": "You're viewing this event through a special invite link",
|
||||||
"addToCalendarButton": "Add to Calendar",
|
"addToCalendarButton": "Add to Calendar",
|
||||||
"eventLinkCopied": "Event link copied to clipboard!",
|
"eventLinkCopied": "Event link copied to clipboard.",
|
||||||
"rsvpAddedSuccessfully": "RSVP added successfully!",
|
"inviteLinkCopied": "Invite link copied to clipboard.",
|
||||||
|
"rsvpAddedSuccessfully": "RSVP added successfully.",
|
||||||
"removedRsvpSuccessfully": "Removed RSVP successfully.",
|
"removedRsvpSuccessfully": "Removed RSVP successfully.",
|
||||||
"failedToAddRsvp": "Failed to add RSVP",
|
"failedToAddRsvp": "Failed to add RSVP",
|
||||||
"failedToRemoveRsvp": "Failed to remove RSVP",
|
"failedToRemoveRsvp": "Failed to remove RSVP",
|
||||||
@@ -208,7 +218,8 @@
|
|||||||
"viewEventAriaLabel": "View event",
|
"viewEventAriaLabel": "View event",
|
||||||
"editEventAriaLabel": "Edit event",
|
"editEventAriaLabel": "Edit event",
|
||||||
"deleteEventAriaLabel": "Delete event",
|
"deleteEventAriaLabel": "Delete event",
|
||||||
"removeRsvpAriaLabel": "Remove RSVP"
|
"removeRsvpAriaLabel": "Remove RSVP",
|
||||||
|
"inviteLinkExpiresAt": "This link expires when the event starts: {time}"
|
||||||
},
|
},
|
||||||
"discover": {
|
"discover": {
|
||||||
"title": "Discover Events - Cactoide",
|
"title": "Discover Events - Cactoide",
|
||||||
|
|||||||
33
src/lib/inviteTokenHelpers.ts
Normal file
33
src/lib/inviteTokenHelpers.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { randomBytes } from 'crypto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a secure random token for invite links
|
||||||
|
* @param length - Length of the token (default: 32)
|
||||||
|
* @returns A random hex string
|
||||||
|
*/
|
||||||
|
export function generateInviteToken(length: number = 32): string {
|
||||||
|
return randomBytes(length / 2).toString('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the expiration time for an invite token
|
||||||
|
* The token expires when the event starts
|
||||||
|
* @param eventDate - The event date in YYYY-MM-DD format
|
||||||
|
* @param eventTime - The event time in HH:MM:SS format
|
||||||
|
* @returns ISO string of the expiration time
|
||||||
|
*/
|
||||||
|
export function calculateTokenExpiration(eventDate: string, eventTime: string): string {
|
||||||
|
const eventDateTime = new Date(`${eventDate}T${eventTime}`);
|
||||||
|
return eventDateTime.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if an invite token is still valid
|
||||||
|
* @param expiresAt - The expiration time as ISO string
|
||||||
|
* @returns true if token is still valid, false otherwise
|
||||||
|
*/
|
||||||
|
export function isTokenValid(expiresAt: string): boolean {
|
||||||
|
const now = new Date();
|
||||||
|
const expiration = new Date(expiresAt);
|
||||||
|
return now < expiration;
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
export type EventType = 'limited' | 'unlimited';
|
export type EventType = 'limited' | 'unlimited';
|
||||||
export type EventVisibility = 'public' | 'private';
|
export type EventVisibility = 'public' | 'private' | 'invite-only';
|
||||||
export type ActionType = 'add' | 'remove';
|
export type ActionType = 'add' | 'remove';
|
||||||
export type LocationType = 'none' | 'text' | 'maps';
|
export type LocationType = 'none' | 'text' | 'maps';
|
||||||
|
|
||||||
@@ -62,3 +62,11 @@ export interface DatabaseRSVP {
|
|||||||
user_id: string;
|
user_id: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface InviteToken {
|
||||||
|
id: string;
|
||||||
|
event_id: string;
|
||||||
|
token: string;
|
||||||
|
expires_at: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { database } from '$lib/database/db';
|
import { database } from '$lib/database/db';
|
||||||
import { events } from '$lib/database/schema';
|
import { events, inviteTokens } from '$lib/database/schema';
|
||||||
import { fail, redirect } from '@sveltejs/kit';
|
import { fail, redirect } from '@sveltejs/kit';
|
||||||
import type { Actions } from './$types';
|
import type { Actions } from './$types';
|
||||||
|
import { generateInviteToken, calculateTokenExpiration } from '$lib/inviteTokenHelpers.js';
|
||||||
|
|
||||||
// Generate a random URL-friendly ID
|
// Generate a random URL-friendly ID
|
||||||
function generateEventId(): string {
|
function generateEventId(): string {
|
||||||
@@ -25,7 +26,7 @@ export const actions: Actions = {
|
|||||||
const locationUrl = formData.get('location_url') as string;
|
const locationUrl = formData.get('location_url') as string;
|
||||||
const type = formData.get('type') as 'limited' | 'unlimited';
|
const type = formData.get('type') as 'limited' | 'unlimited';
|
||||||
const attendeeLimit = formData.get('attendee_limit') as string;
|
const attendeeLimit = formData.get('attendee_limit') as string;
|
||||||
const visibility = formData.get('visibility') as 'public' | 'private';
|
const visibility = formData.get('visibility') as 'public' | 'private' | 'invite-only';
|
||||||
const userId = cookies.get('cactoideUserId');
|
const userId = cookies.get('cactoideUserId');
|
||||||
|
|
||||||
// Validation
|
// Validation
|
||||||
@@ -98,6 +99,7 @@ export const actions: Actions = {
|
|||||||
|
|
||||||
const eventId = generateEventId();
|
const eventId = generateEventId();
|
||||||
|
|
||||||
|
// Create the event
|
||||||
await database
|
await database
|
||||||
.insert(events)
|
.insert(events)
|
||||||
.values({
|
.values({
|
||||||
@@ -118,6 +120,24 @@ export const actions: Actions = {
|
|||||||
throw error;
|
throw error;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Generate invite token for invite-only events
|
||||||
|
if (visibility === 'invite-only') {
|
||||||
|
const token = generateInviteToken();
|
||||||
|
const expiresAt = calculateTokenExpiration(date, time);
|
||||||
|
|
||||||
|
await database
|
||||||
|
.insert(inviteTokens)
|
||||||
|
.values({
|
||||||
|
eventId: eventId,
|
||||||
|
token: token,
|
||||||
|
expiresAt: new Date(expiresAt)
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Error creating invite token', error);
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
throw redirect(303, `/event/${eventId}`);
|
throw redirect(303, `/event/${eventId}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
location_url: '',
|
location_url: '',
|
||||||
type: 'unlimited',
|
type: 'unlimited',
|
||||||
attendee_limit: undefined,
|
attendee_limit: undefined,
|
||||||
visibility: 'public'
|
visibility: 'public' as 'public' | 'private' | 'invite-only'
|
||||||
};
|
};
|
||||||
|
|
||||||
let errors: Record<string, string> = {};
|
let errors: Record<string, string> = {};
|
||||||
@@ -317,13 +317,13 @@
|
|||||||
{t('create.visibilityLabel')}
|
{t('create.visibilityLabel')}
|
||||||
<span class="text-red-400">{t('common.required')}</span>
|
<span class="text-red-400">{t('common.required')}</span>
|
||||||
</legend>
|
</legend>
|
||||||
<div class="grid grid-cols-2 gap-3">
|
<div class="grid grid-cols-3 gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-sm border-2 px-4 py-3 font-medium transition-all duration-200 {eventData.visibility ===
|
class="rounded-sm border-2 px-4 py-3 font-medium transition-all duration-200 {eventData.visibility ===
|
||||||
'public'
|
'public'
|
||||||
? ' border-violet-500 bg-violet-400/20 font-semibold hover:bg-violet-400/70'
|
? ' border-violet-500 bg-violet-400/20 font-semibold hover:bg-violet-400/70'
|
||||||
: 'border-dark-300 text-dark-700'}"
|
: 'border-dark-300 text-dark-700 bg-gray-600/20 hover:bg-gray-600/70'}"
|
||||||
on:click={() => (eventData.visibility = 'public')}
|
on:click={() => (eventData.visibility = 'public')}
|
||||||
>
|
>
|
||||||
{t('create.publicOption')}
|
{t('create.publicOption')}
|
||||||
@@ -338,11 +338,23 @@
|
|||||||
>
|
>
|
||||||
{t('create.privateOption')}
|
{t('create.privateOption')}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-sm border-2 px-4 py-3 font-medium transition-all duration-200 {eventData.visibility ===
|
||||||
|
'invite-only'
|
||||||
|
? ' border-violet-500 bg-violet-400/20 font-semibold hover:bg-violet-400/70'
|
||||||
|
: 'border-dark-300 text-dark-700 bg-gray-600/20 hover:bg-gray-600/70'}"
|
||||||
|
on:click={() => (eventData.visibility = 'invite-only')}
|
||||||
|
>
|
||||||
|
{t('create.inviteOnlyOption')}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-2 text-xs text-slate-400 italic">
|
<p class="mt-2 text-xs text-slate-400 italic">
|
||||||
{eventData.visibility === 'public'
|
{eventData.visibility === 'public'
|
||||||
? t('create.publicDescription')
|
? t('create.publicDescription')
|
||||||
: t('create.privateDescription')}
|
: eventData.visibility === 'private'
|
||||||
|
? t('create.privateDescription')
|
||||||
|
: 'Event is public but requires a special invite link to attend'}
|
||||||
</p>
|
</p>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { database } from '$lib/database/db';
|
import { database } from '$lib/database/db';
|
||||||
import { eq, desc } from 'drizzle-orm';
|
import { desc, inArray } from 'drizzle-orm';
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
import { events } from '$lib/database/schema';
|
import { events } from '$lib/database/schema';
|
||||||
|
|
||||||
export const load: PageServerLoad = async () => {
|
export const load: PageServerLoad = async () => {
|
||||||
try {
|
try {
|
||||||
// Fetch all public events ordered by creation date (newest first)
|
// Fetch all non-private events (public and invite-only) ordered by creation date (newest first)
|
||||||
const publicEvents = await database
|
const publicEvents = await database
|
||||||
.select()
|
.select()
|
||||||
.from(events)
|
.from(events)
|
||||||
.where(eq(events.visibility, 'public'))
|
.where(inArray(events.visibility, ['public', 'invite-only']))
|
||||||
.orderBy(desc(events.createdAt));
|
.orderBy(desc(events.createdAt));
|
||||||
|
|
||||||
// Transform the database events to match the expected Event interface
|
// Transform the database events to match the expected Event interface
|
||||||
|
|||||||
@@ -324,6 +324,16 @@
|
|||||||
{event.type === 'limited' ? t('common.limited') : t('common.unlimited')}
|
{event.type === 'limited' ? t('common.limited') : t('common.unlimited')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<span
|
||||||
|
class="rounded-sm border px-2 py-1 text-xs font-medium {event.visibility ===
|
||||||
|
'public'
|
||||||
|
? 'border-teal-500 text-teal-500'
|
||||||
|
: 'border-amber-600 text-amber-600'}"
|
||||||
|
>
|
||||||
|
{event.visibility === 'public' ? t('common.public') : t('common.inviteOnly')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { database } from '$lib/database/db';
|
import { database } from '$lib/database/db';
|
||||||
import { events, rsvps } from '$lib/database/schema';
|
import { events, rsvps, inviteTokens } from '$lib/database/schema';
|
||||||
import { eq, asc } from 'drizzle-orm';
|
import { eq, asc, and } from 'drizzle-orm';
|
||||||
import { error, fail } from '@sveltejs/kit';
|
import { error, fail } from '@sveltejs/kit';
|
||||||
import type { PageServerLoad, Actions } from './$types';
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
|
import { isTokenValid } from '$lib/inviteTokenHelpers.js';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ params, cookies }) => {
|
export const load: PageServerLoad = async ({ params, cookies }) => {
|
||||||
const eventId = params.id;
|
const eventId = params.id;
|
||||||
@@ -25,6 +26,16 @@ export const load: PageServerLoad = async ({ params, cookies }) => {
|
|||||||
const event = eventData[0];
|
const event = eventData[0];
|
||||||
const eventRsvps = rsvpData;
|
const eventRsvps = rsvpData;
|
||||||
|
|
||||||
|
// Check if this is an invite-only event
|
||||||
|
if (event.visibility === 'invite-only') {
|
||||||
|
// For invite-only events, check if user is the event creator
|
||||||
|
const userId = cookies.get('cactoideUserId');
|
||||||
|
if (event.userId !== userId) {
|
||||||
|
// User is not the creator, redirect to a message about needing invite
|
||||||
|
throw error(403, 'This event requires an invite link to view');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Transform the data to match the expected interface
|
// Transform the data to match the expected interface
|
||||||
const transformedEvent = {
|
const transformedEvent = {
|
||||||
id: event.id,
|
id: event.id,
|
||||||
@@ -85,6 +96,11 @@ export const actions: Actions = {
|
|||||||
return fail(404, { error: 'Event not found' });
|
return fail(404, { error: 'Event not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if this is an invite-only event
|
||||||
|
if (eventData.visibility === 'invite-only') {
|
||||||
|
return fail(403, { error: 'This event requires an invite link to RSVP' });
|
||||||
|
}
|
||||||
|
|
||||||
// Get current RSVPs
|
// Get current RSVPs
|
||||||
const currentRSVPs = await database.select().from(rsvps).where(eq(rsvps.eventId, eventId));
|
const currentRSVPs = await database.select().from(rsvps).where(eq(rsvps.eventId, eventId));
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,8 @@
|
|||||||
import { t } from '$lib/i18n/i18n.js';
|
import { t } from '$lib/i18n/i18n.js';
|
||||||
|
|
||||||
export let data: { event: Event; rsvps: RSVP[]; userId: string };
|
export let data: { event: Event; rsvps: RSVP[]; userId: string };
|
||||||
export let form;
|
type FormDataLocal = { success?: boolean; error?: string; type?: 'add' | 'remove' | 'copy' };
|
||||||
|
export let form: FormDataLocal | undefined;
|
||||||
|
|
||||||
let event: Event;
|
let event: Event;
|
||||||
let rsvps: RSVP[] = [];
|
let rsvps: RSVP[] = [];
|
||||||
@@ -22,6 +23,9 @@
|
|||||||
let numberOfGuests = 1;
|
let numberOfGuests = 1;
|
||||||
let showCalendarModal = false;
|
let showCalendarModal = false;
|
||||||
let calendarEvent: CalendarEvent;
|
let calendarEvent: CalendarEvent;
|
||||||
|
let toastType: 'add' | 'remove' | 'copy' | null = null;
|
||||||
|
let typeToShow: 'add' | 'remove' | 'copy' | undefined;
|
||||||
|
let successHideTimer: number | null = null;
|
||||||
|
|
||||||
// Use server-side data
|
// Use server-side data
|
||||||
$: event = data.event;
|
$: event = data.event;
|
||||||
@@ -45,6 +49,9 @@
|
|||||||
success = '';
|
success = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: ERROR
|
||||||
|
// //WHEN DELETING RSVP: THE MODAL MESSAGE IS "RSVP removed successfully."
|
||||||
|
|
||||||
// Handle form success from server
|
// Handle form success from server
|
||||||
$: if (form?.success) {
|
$: if (form?.success) {
|
||||||
success = 'RSVP added successfully!';
|
success = 'RSVP added successfully!';
|
||||||
@@ -52,7 +59,20 @@
|
|||||||
newAttendeeName = '';
|
newAttendeeName = '';
|
||||||
addGuests = false;
|
addGuests = false;
|
||||||
numberOfGuests = 1;
|
numberOfGuests = 1;
|
||||||
|
|
||||||
|
// show and auto-hide success toast for add action
|
||||||
|
toastType = 'add';
|
||||||
|
if (browser) {
|
||||||
|
if (successHideTimer) clearTimeout(successHideTimer);
|
||||||
|
successHideTimer = window.setTimeout(() => {
|
||||||
|
success = '';
|
||||||
|
toastType = null;
|
||||||
|
}, 3000);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive toast type from local or server form
|
||||||
|
$: typeToShow = toastType ?? form?.type;
|
||||||
|
|
||||||
const eventId = $page.params.id || '';
|
const eventId = $page.params.id || '';
|
||||||
|
|
||||||
@@ -60,9 +80,12 @@
|
|||||||
if (browser) {
|
if (browser) {
|
||||||
const url = `${$page.url.origin}/event/${eventId}`;
|
const url = `${$page.url.origin}/event/${eventId}`;
|
||||||
navigator.clipboard.writeText(url).then(() => {
|
navigator.clipboard.writeText(url).then(() => {
|
||||||
success = 'Event link copied to clipboard!';
|
toastType = 'copy';
|
||||||
|
success = t('event.eventLinkCopied');
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
success = '';
|
success = '';
|
||||||
|
toastType = null;
|
||||||
}, 3000);
|
}, 3000);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -71,6 +94,7 @@
|
|||||||
const clearMessages = () => {
|
const clearMessages = () => {
|
||||||
error = '';
|
error = '';
|
||||||
success = '';
|
success = '';
|
||||||
|
toastType = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Calendar modal functions
|
// Calendar modal functions
|
||||||
@@ -208,7 +232,13 @@
|
|||||||
<div class=" rounded-sm border p-6 shadow-2xl backdrop-blur-sm">
|
<div class=" rounded-sm border p-6 shadow-2xl backdrop-blur-sm">
|
||||||
<h3 class=" mb-4 text-xl font-bold">{t('event.joinThisEvent')}</h3>
|
<h3 class=" mb-4 text-xl font-bold">{t('event.joinThisEvent')}</h3>
|
||||||
|
|
||||||
{#if event.type === 'limited' && event.attendee_limit && rsvps.length >= event.attendee_limit}
|
{#if event.visibility === 'invite-only'}
|
||||||
|
<div class="py-6 text-center">
|
||||||
|
<div class="mb-3 text-4xl">🎫</div>
|
||||||
|
<p class="font-semibold text-amber-400">{t('event.inviteOnlyBannerTitle')}</p>
|
||||||
|
<p class="mt-1 text-sm text-amber-300">{t('common.inviteRequiredToDetails')}</p>
|
||||||
|
</div>
|
||||||
|
{:else if event.type === 'limited' && event.attendee_limit && rsvps.length >= event.attendee_limit}
|
||||||
<div class="py-6 text-center">
|
<div class="py-6 text-center">
|
||||||
<div class="mb-3 text-4xl text-red-400">🚫</div>
|
<div class="mb-3 text-4xl text-red-400">🚫</div>
|
||||||
<p class="font-semibold text-red-400">{t('event.eventIsFull')}</p>
|
<p class="font-semibold text-red-400">{t('event.eventIsFull')}</p>
|
||||||
@@ -316,6 +346,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Attendees List -->
|
<!-- Attendees List -->
|
||||||
|
{#if event.visibility !== 'invite-only'}
|
||||||
<div class="rounded-sm border p-6 shadow-2xl backdrop-blur-sm">
|
<div class="rounded-sm border p-6 shadow-2xl backdrop-blur-sm">
|
||||||
<div class="mb-4 flex items-center justify-between">
|
<div class="mb-4 flex items-center justify-between">
|
||||||
<h3 class=" text-xl font-bold">{t('event.attendeesTitle')}</h3>
|
<h3 class=" text-xl font-bold">{t('event.attendeesTitle')}</h3>
|
||||||
@@ -386,7 +417,12 @@
|
|||||||
class="text-dark-400 p-1 transition-colors duration-200 hover:text-red-400"
|
class="text-dark-400 p-1 transition-colors duration-200 hover:text-red-400"
|
||||||
aria-label={t('event.removeRsvpAriaLabel')}
|
aria-label={t('event.removeRsvpAriaLabel')}
|
||||||
>
|
>
|
||||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
|
class="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
<path
|
<path
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
@@ -402,6 +438,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Action Buttons -->
|
<!-- Action Buttons -->
|
||||||
<div class="max-w-2xl space-y-3">
|
<div class="max-w-2xl space-y-3">
|
||||||
@@ -436,18 +473,26 @@
|
|||||||
|
|
||||||
<!-- Success/Error Messages -->
|
<!-- Success/Error Messages -->
|
||||||
{#if success}
|
{#if success}
|
||||||
{#if form?.type === 'add'}
|
{#if typeToShow === 'add'}
|
||||||
<div
|
<div
|
||||||
class="fixed right-4 bottom-4 z-40 w-128 rounded-sm border border-green-500/30 bg-green-900/20 p-4 text-green-400"
|
class="fixed right-4 bottom-4 z-40 w-128 rounded-sm border border-green-500/30 bg-green-900/20 p-4 text-green-400"
|
||||||
>
|
>
|
||||||
{success}
|
{success}
|
||||||
</div>
|
</div>
|
||||||
{:else if form?.type === 'remove'}
|
{:else if typeToShow === 'remove'}
|
||||||
<div
|
<div
|
||||||
class="fixed right-4 bottom-4 z-40 w-128 rounded-sm border border-yellow-500/30 bg-yellow-900/20 p-4 text-yellow-400"
|
class="fixed right-4 bottom-4 z-40 w-128 rounded-sm border border-yellow-500/30 bg-yellow-900/20 p-4 text-yellow-400"
|
||||||
>
|
>
|
||||||
{t('event.removedRsvpSuccessfully')}
|
{t('event.removedRsvpSuccessfully')}
|
||||||
</div>
|
</div>
|
||||||
|
{:else if typeToShow === 'copy'}
|
||||||
|
<div
|
||||||
|
class="fixed right-4 bottom-4 z-40 w-128 rounded-sm border border-yellow-500/30 bg-yellow-900/20 p-4 text-yellow-400"
|
||||||
|
>
|
||||||
|
{t('event.eventLinkCopied')}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- fallback -->
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { database } from '$lib/database/db';
|
import { database } from '$lib/database/db';
|
||||||
import { events } from '$lib/database/schema';
|
import { events, inviteTokens } from '$lib/database/schema';
|
||||||
import { eq, and } from 'drizzle-orm';
|
import { eq, and } from 'drizzle-orm';
|
||||||
import { fail, redirect } from '@sveltejs/kit';
|
import { fail, redirect } from '@sveltejs/kit';
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
@@ -23,8 +23,29 @@ export const load: PageServerLoad = async ({ params, cookies }) => {
|
|||||||
throw redirect(303, '/event');
|
throw redirect(303, '/event');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch invite token if this is an invite-only event
|
||||||
|
let inviteToken = null;
|
||||||
|
if (event[0].visibility === 'invite-only') {
|
||||||
|
const tokenData = await database
|
||||||
|
.select()
|
||||||
|
.from(inviteTokens)
|
||||||
|
.where(eq(inviteTokens.eventId, eventId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (tokenData.length > 0) {
|
||||||
|
inviteToken = {
|
||||||
|
id: tokenData[0].id,
|
||||||
|
event_id: tokenData[0].eventId,
|
||||||
|
token: tokenData[0].token,
|
||||||
|
expires_at: tokenData[0].expiresAt.toISOString(),
|
||||||
|
created_at: tokenData[0].createdAt?.toISOString() || new Date().toISOString()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
event: event[0]
|
event: event[0],
|
||||||
|
inviteToken
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,9 @@
|
|||||||
|
|
||||||
let errors: Record<string, string> = {};
|
let errors: Record<string, string> = {};
|
||||||
let isSubmitting = false;
|
let isSubmitting = false;
|
||||||
|
let inviteToken = data.inviteToken;
|
||||||
|
|
||||||
|
let inviteLinkCopied = false;
|
||||||
|
|
||||||
// Get today's date in YYYY-MM-DD format for min attribute
|
// Get today's date in YYYY-MM-DD format for min attribute
|
||||||
const today = new Date().toISOString().split('T')[0];
|
const today = new Date().toISOString().split('T')[0];
|
||||||
@@ -67,6 +70,21 @@
|
|||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
goto(`/event/${data.event.id}`);
|
goto(`/event/${data.event.id}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const copyInviteLink = async () => {
|
||||||
|
if (inviteToken) {
|
||||||
|
const inviteUrl = `${window.location.origin}/event/${data.event.id}/invite/${inviteToken.token}`;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(inviteUrl);
|
||||||
|
inviteLinkCopied = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
inviteLinkCopied = false;
|
||||||
|
}, 3000);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to copy invite link:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -315,7 +333,7 @@
|
|||||||
<legend class="text-dark-800 mb-3 block text-sm font-semibold">
|
<legend class="text-dark-800 mb-3 block text-sm font-semibold">
|
||||||
{t('common.visibility')} <span class="text-red-400">{t('common.required')}</span>
|
{t('common.visibility')} <span class="text-red-400">{t('common.required')}</span>
|
||||||
</legend>
|
</legend>
|
||||||
<div class="grid grid-cols-2 gap-3">
|
<div class="grid grid-cols-3 gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-sm border-2 px-4 py-3 font-medium transition-all duration-200 {eventData.visibility ===
|
class="rounded-sm border-2 px-4 py-3 font-medium transition-all duration-200 {eventData.visibility ===
|
||||||
@@ -336,15 +354,59 @@
|
|||||||
>
|
>
|
||||||
{t('create.privateOption')}
|
{t('create.privateOption')}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-sm border-2 px-4 py-3 font-medium transition-all duration-200 {eventData.visibility ===
|
||||||
|
'invite-only'
|
||||||
|
? ' border-amber-500 bg-amber-400/20 font-semibold hover:bg-amber-400/70'
|
||||||
|
: 'border-dark-300 text-dark-700 bg-gray-600/20 hover:bg-gray-600/70'}"
|
||||||
|
on:click={() => (eventData.visibility = 'invite-only')}
|
||||||
|
>
|
||||||
|
{t('create.inviteOnlyOption')}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-2 text-xs text-slate-400">
|
<p class="mt-2 text-xs text-slate-400">
|
||||||
{eventData.visibility === 'public'
|
{eventData.visibility === 'public'
|
||||||
? t('create.publicDescription')
|
? t('create.publicDescription')
|
||||||
: t('create.privateDescription')}
|
: eventData.visibility === 'private'
|
||||||
|
? t('create.privateDescription')
|
||||||
|
: t('create.inviteOnlyDescription')}
|
||||||
</p>
|
</p>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Invite Link Section (only for invite-only events) -->
|
||||||
|
{#if eventData.visibility === 'invite-only' && inviteToken}
|
||||||
|
<div class="rounded-sm border border-amber-500/30 bg-amber-900/20 p-4">
|
||||||
|
<div class="mb-3 flex items-center justify-between">
|
||||||
|
<h3 class="text-lg font-semibold text-amber-400">Invite Link</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={`${window.location.origin}/event/${data.event.id}/invite/${inviteToken.token}`}
|
||||||
|
readonly
|
||||||
|
class="flex-1 rounded-sm border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-900"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
on:click={copyInviteLink}
|
||||||
|
class="rounded-sm border border-amber-300 bg-amber-200 px-3 py-2 text-sm font-medium text-amber-900 hover:bg-amber-300"
|
||||||
|
>
|
||||||
|
{inviteLinkCopied ? t('common.success') : t('common.copyLink')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-amber-300">
|
||||||
|
{t('event.inviteLinkExpiresAt', {
|
||||||
|
time: new Date(inviteToken.expires_at).toLocaleString()
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Action Buttons -->
|
<!-- Action Buttons -->
|
||||||
<div class="flex space-x-3">
|
<div class="flex space-x-3">
|
||||||
<button
|
<button
|
||||||
|
|||||||
223
src/routes/event/[id]/invite/[token]/+page.server.ts
Normal file
223
src/routes/event/[id]/invite/[token]/+page.server.ts
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
import { database } from '$lib/database/db';
|
||||||
|
import { events, rsvps, inviteTokens } from '$lib/database/schema';
|
||||||
|
import { eq, and, asc } from 'drizzle-orm';
|
||||||
|
import { error, fail } from '@sveltejs/kit';
|
||||||
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
|
import { isTokenValid } from '$lib/inviteTokenHelpers.js';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ params, cookies }) => {
|
||||||
|
const eventId = params.id;
|
||||||
|
const token = params.token;
|
||||||
|
|
||||||
|
if (!eventId || !token) {
|
||||||
|
throw error(404, 'Event or token not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch event, RSVPs, and invite token in parallel
|
||||||
|
const [eventData, rsvpData, tokenData] = await Promise.all([
|
||||||
|
database.select().from(events).where(eq(events.id, eventId)).limit(1),
|
||||||
|
database.select().from(rsvps).where(eq(rsvps.eventId, eventId)).orderBy(asc(rsvps.createdAt)),
|
||||||
|
database
|
||||||
|
.select()
|
||||||
|
.from(inviteTokens)
|
||||||
|
.where(and(eq(inviteTokens.eventId, eventId), eq(inviteTokens.token, token)))
|
||||||
|
.limit(1)
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!eventData[0]) {
|
||||||
|
throw error(404, 'Event not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tokenData[0]) {
|
||||||
|
throw error(404, 'Invalid invite token');
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = eventData[0];
|
||||||
|
const eventRsvps = rsvpData;
|
||||||
|
const inviteToken = tokenData[0];
|
||||||
|
|
||||||
|
// Check if token is still valid
|
||||||
|
if (!isTokenValid(inviteToken.expiresAt.toISOString())) {
|
||||||
|
throw error(410, 'Invite token has expired');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if event is invite-only
|
||||||
|
if (event.visibility !== 'invite-only') {
|
||||||
|
throw error(403, 'This event does not require an invite');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform the data to match the expected interface
|
||||||
|
const transformedEvent = {
|
||||||
|
id: event.id,
|
||||||
|
name: event.name,
|
||||||
|
date: event.date,
|
||||||
|
time: event.time,
|
||||||
|
location: event.location,
|
||||||
|
location_type: event.locationType,
|
||||||
|
location_url: event.locationUrl,
|
||||||
|
type: event.type,
|
||||||
|
attendee_limit: event.attendeeLimit,
|
||||||
|
visibility: event.visibility,
|
||||||
|
user_id: event.userId,
|
||||||
|
created_at: event.createdAt?.toISOString() || new Date().toISOString(),
|
||||||
|
updated_at: event.updatedAt?.toISOString() || new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
const transformedRsvps = eventRsvps.map((rsvp) => ({
|
||||||
|
id: rsvp.id,
|
||||||
|
event_id: rsvp.eventId,
|
||||||
|
name: rsvp.name,
|
||||||
|
user_id: rsvp.userId,
|
||||||
|
created_at: rsvp.createdAt?.toISOString() || new Date().toISOString()
|
||||||
|
}));
|
||||||
|
|
||||||
|
const userId = cookies.get('cactoideUserId');
|
||||||
|
|
||||||
|
return {
|
||||||
|
event: transformedEvent,
|
||||||
|
rsvps: transformedRsvps,
|
||||||
|
userId: userId,
|
||||||
|
inviteToken: {
|
||||||
|
id: inviteToken.id,
|
||||||
|
event_id: inviteToken.eventId,
|
||||||
|
token: inviteToken.token,
|
||||||
|
expires_at: inviteToken.expiresAt.toISOString(),
|
||||||
|
created_at: inviteToken.createdAt?.toISOString() || new Date().toISOString()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Response) throw err; // This is the 404/410/403 error
|
||||||
|
|
||||||
|
console.error('Error loading invite-only event:', err);
|
||||||
|
throw error(500, 'Failed to load event');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
addRSVP: async ({ request, params, cookies }) => {
|
||||||
|
const eventId = params.id;
|
||||||
|
const token = params.token;
|
||||||
|
const formData = await request.formData();
|
||||||
|
|
||||||
|
const name = formData.get('newAttendeeName') as string;
|
||||||
|
const numberOfGuests = parseInt(formData.get('numberOfGuests') as string) || 0;
|
||||||
|
const userId = cookies.get('cactoideUserId');
|
||||||
|
|
||||||
|
if (!name?.trim() || !userId) {
|
||||||
|
return fail(400, { error: 'Name and user ID are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Verify the invite token is still valid
|
||||||
|
const [tokenData] = await database
|
||||||
|
.select()
|
||||||
|
.from(inviteTokens)
|
||||||
|
.where(and(eq(inviteTokens.eventId, eventId), eq(inviteTokens.token, token)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!tokenData || !isTokenValid(tokenData.expiresAt.toISOString())) {
|
||||||
|
return fail(403, { error: 'Invalid or expired invite token' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if event exists and get its details
|
||||||
|
const [eventData] = await database.select().from(events).where(eq(events.id, eventId));
|
||||||
|
if (!eventData) {
|
||||||
|
return fail(404, { error: 'Event not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current RSVPs
|
||||||
|
const currentRSVPs = await database.select().from(rsvps).where(eq(rsvps.eventId, eventId));
|
||||||
|
|
||||||
|
// Calculate total attendees (including guests)
|
||||||
|
const totalAttendees = currentRSVPs.length + numberOfGuests;
|
||||||
|
|
||||||
|
// Check if event is full (for limited type events)
|
||||||
|
if (eventData.type === 'limited' && eventData.attendeeLimit) {
|
||||||
|
if (totalAttendees > eventData.attendeeLimit) {
|
||||||
|
return fail(400, {
|
||||||
|
error: `Event capacity exceeded. You're trying to add ${numberOfGuests + 1} attendees (including yourself), but only ${eventData.attendeeLimit - currentRSVPs.length} spots remain.`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if name is already in the list
|
||||||
|
if (currentRSVPs.some((rsvp) => rsvp.name.toLowerCase() === name.toLowerCase())) {
|
||||||
|
return fail(400, { error: 'Name already exists for this event' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare RSVPs to insert
|
||||||
|
const rsvpsToInsert = [
|
||||||
|
{
|
||||||
|
eventId: eventId,
|
||||||
|
name: name.trim(),
|
||||||
|
userId: userId,
|
||||||
|
createdAt: new Date()
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// Add guest entries
|
||||||
|
for (let i = 1; i <= numberOfGuests; i++) {
|
||||||
|
rsvpsToInsert.push({
|
||||||
|
eventId: eventId,
|
||||||
|
name: `${name.trim()}'s Guest #${i}`,
|
||||||
|
userId: userId,
|
||||||
|
createdAt: new Date()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert all RSVPs
|
||||||
|
await database.insert(rsvps).values(rsvpsToInsert);
|
||||||
|
|
||||||
|
return { success: true, type: 'add' };
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error adding RSVP:', err);
|
||||||
|
return fail(500, { error: 'Failed to add RSVP' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
removeRSVP: async ({ request, params, cookies }) => {
|
||||||
|
const eventId = params.id;
|
||||||
|
const token = params.token;
|
||||||
|
const formData = await request.formData();
|
||||||
|
|
||||||
|
const rsvpId = formData.get('rsvpId') as string;
|
||||||
|
const userId = cookies.get('cactoideUserId');
|
||||||
|
|
||||||
|
if (!rsvpId || !userId) {
|
||||||
|
return fail(400, { error: 'RSVP ID and user ID are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Verify the invite token is still valid
|
||||||
|
const [tokenData] = await database
|
||||||
|
.select()
|
||||||
|
.from(inviteTokens)
|
||||||
|
.where(and(eq(inviteTokens.eventId, eventId), eq(inviteTokens.token, token)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!tokenData || !isTokenValid(tokenData.expiresAt.toISOString())) {
|
||||||
|
return fail(403, { error: 'Invalid or expired invite token' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if RSVP exists and belongs to the user
|
||||||
|
const [rsvpData] = await database
|
||||||
|
.select()
|
||||||
|
.from(rsvps)
|
||||||
|
.where(and(eq(rsvps.id, rsvpId), eq(rsvps.eventId, eventId), eq(rsvps.userId, userId)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!rsvpData) {
|
||||||
|
return fail(404, { error: 'RSVP not found or you do not have permission to remove it' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the RSVP
|
||||||
|
await database.delete(rsvps).where(eq(rsvps.id, rsvpId));
|
||||||
|
|
||||||
|
return { success: true, type: 'remove' };
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error removing RSVP:', err);
|
||||||
|
return fail(500, { error: 'Failed to remove RSVP' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
468
src/routes/event/[id]/invite/[token]/+page.svelte
Normal file
468
src/routes/event/[id]/invite/[token]/+page.svelte
Normal file
@@ -0,0 +1,468 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
import type { Event, RSVP, InviteToken } from '$lib/types';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import { formatTime, formatDate } from '$lib/dateHelpers.js';
|
||||||
|
import CalendarModal from '$lib/components/CalendarModal.svelte';
|
||||||
|
import type { CalendarEvent } from '$lib/calendarHelpers.js';
|
||||||
|
import { t } from '$lib/i18n/i18n.js';
|
||||||
|
|
||||||
|
export let data: { event: Event; rsvps: RSVP[]; userId: string; inviteToken: InviteToken };
|
||||||
|
export let form;
|
||||||
|
|
||||||
|
let event: Event;
|
||||||
|
let rsvps: RSVP[] = [];
|
||||||
|
let newAttendeeName = '';
|
||||||
|
let isAddingRSVP = false;
|
||||||
|
let error = '';
|
||||||
|
let success = '';
|
||||||
|
let addGuests = false;
|
||||||
|
let numberOfGuests = 1;
|
||||||
|
let showCalendarModal = false;
|
||||||
|
let calendarEvent: CalendarEvent;
|
||||||
|
|
||||||
|
// Use server-side data
|
||||||
|
$: event = data.event;
|
||||||
|
$: rsvps = data.rsvps;
|
||||||
|
$: currentUserId = data.userId;
|
||||||
|
|
||||||
|
// Create calendar event object when event data changes
|
||||||
|
$: if (event && browser) {
|
||||||
|
calendarEvent = {
|
||||||
|
name: event.name,
|
||||||
|
date: event.date,
|
||||||
|
time: event.time,
|
||||||
|
location: event.location,
|
||||||
|
url: `${$page.url.origin}/event/${eventId}/invite/${token}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle form errors from server
|
||||||
|
$: if (form?.error) {
|
||||||
|
error = String(form.error);
|
||||||
|
success = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle form success from server
|
||||||
|
$: if (form?.success) {
|
||||||
|
success = 'RSVP added successfully!';
|
||||||
|
error = '';
|
||||||
|
newAttendeeName = '';
|
||||||
|
addGuests = false;
|
||||||
|
numberOfGuests = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventId = $page.params.id || '';
|
||||||
|
const token = $page.params.token || '';
|
||||||
|
|
||||||
|
const copyInviteLink = () => {
|
||||||
|
if (browser) {
|
||||||
|
const url = `${$page.url.origin}/event/${eventId}/invite/${token}`;
|
||||||
|
navigator.clipboard.writeText(url).then(() => {
|
||||||
|
success = 'Invite link copied to clipboard!';
|
||||||
|
setTimeout(() => {
|
||||||
|
success = '';
|
||||||
|
}, 3000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearMessages = () => {
|
||||||
|
error = '';
|
||||||
|
success = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calendar modal functions
|
||||||
|
const openCalendarModal = () => {
|
||||||
|
showCalendarModal = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeCalendarModal = () => {
|
||||||
|
showCalendarModal = false;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{event?.name || t('event.eventTitle')} - Invite Only</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="flex min-h-screen flex-col">
|
||||||
|
<!-- Main Content -->
|
||||||
|
<div class="container mx-auto flex-1 px-4 py-6">
|
||||||
|
{#if error && !event}
|
||||||
|
<!-- Error State -->
|
||||||
|
<div class="mx-auto max-w-md text-center">
|
||||||
|
<div class="rounded-sm border border-red-500/30 bg-red-900/20 p-8">
|
||||||
|
<div class="mb-4 text-6xl text-red-400">⚠️</div>
|
||||||
|
<h2 class="mb-4 text-2xl font-bold text-red-400">{t('event.eventNotFoundTitle')}</h2>
|
||||||
|
<p class="my-8">{t('event.eventNotFoundDescription')}</p>
|
||||||
|
<button
|
||||||
|
on:click={() => goto('/create')}
|
||||||
|
class="border-white-500 bg-white-400/20 mt-2 rounded-sm border px-6 py-3 font-semibold text-white duration-400 hover:scale-110 hover:bg-white/10"
|
||||||
|
>
|
||||||
|
{t('common.createNewEvent')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if event}
|
||||||
|
<div class="mx-auto max-w-2xl space-y-6">
|
||||||
|
<!-- Invite Only Banner -->
|
||||||
|
<div class="rounded-sm border border-amber-500/30 bg-amber-900/20 p-4">
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<div class="text-2xl">🎫</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="font-semibold text-amber-400">{t('event.inviteOnlyBannerTitle')}</h3>
|
||||||
|
<p class="text-sm text-amber-300">{t('event.inviteOnlyBannerSubtitle')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Event Details Card -->
|
||||||
|
<div class="rounded-sm border p-6 shadow-2xl">
|
||||||
|
<h2 class=" mb-4 text-center text-2xl font-bold">
|
||||||
|
{event.name}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- Date & Time -->
|
||||||
|
<div class="flex items-center space-x-3 text-violet-400">
|
||||||
|
<div class="flex h-8 w-8 items-center justify-center rounded-sm">
|
||||||
|
<svg class=" h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="font-semibold text-white">
|
||||||
|
{formatDate(event.date)}
|
||||||
|
<span class="font-medium text-violet-400">-</span>
|
||||||
|
{formatTime(event.time)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Location (only show when not 'none') -->
|
||||||
|
<div class="flex items-center space-x-3 text-violet-400">
|
||||||
|
<div class="flex h-8 w-8 items-center justify-center rounded-sm">
|
||||||
|
<svg class=" h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="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>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{#if event.location_type === 'none'}
|
||||||
|
<p class="font-semibold text-white">N/A</p>
|
||||||
|
{:else if event.location_type === 'maps' && event.location_url}
|
||||||
|
<a
|
||||||
|
href={event.location_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="font-semibold text-white transition-colors duration-200 hover:text-violet-300"
|
||||||
|
>
|
||||||
|
{t('create.locationMapsOption')}
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
<p class="font-semibold text-white">{event.location}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Event Type, Visibility & Capacity -->
|
||||||
|
<div class="flex items-center justify-between rounded-sm p-3">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<span
|
||||||
|
class="rounded-sm border px-2 py-1 text-xs font-medium {event.type === 'limited'
|
||||||
|
? 'border-amber-600 text-amber-600'
|
||||||
|
: 'border-teal-500 text-teal-500'}"
|
||||||
|
>
|
||||||
|
{event.type === 'limited' ? t('common.limited') : t('common.unlimited')}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="rounded-sm border border-amber-300 px-2 py-1 text-xs font-medium text-amber-400"
|
||||||
|
>
|
||||||
|
{t('event.inviteOnlyBadge')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if event.type === 'limited' && event.attendee_limit}
|
||||||
|
<div class="text-right">
|
||||||
|
<p class="text-sm">{t('common.capacity')}</p>
|
||||||
|
<p class=" text-lg font-bold">
|
||||||
|
{rsvps.length}/{event.attendee_limit}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- RSVP Form -->
|
||||||
|
<div class=" rounded-sm border p-6 shadow-2xl backdrop-blur-sm">
|
||||||
|
<h3 class=" mb-4 text-xl font-bold">{t('event.joinThisEvent')}</h3>
|
||||||
|
|
||||||
|
{#if event.type === 'limited' && event.attendee_limit && rsvps.length >= event.attendee_limit}
|
||||||
|
<div class="py-6 text-center">
|
||||||
|
<div class="mb-3 text-4xl text-red-400">🚫</div>
|
||||||
|
<p class="font-semibold text-red-400">{t('event.eventIsFull')}</p>
|
||||||
|
<p class="mt-1 text-sm">{t('event.maximumCapacityReached')}</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/addRSVP"
|
||||||
|
use:enhance={() => {
|
||||||
|
isAddingRSVP = true;
|
||||||
|
clearMessages();
|
||||||
|
return async ({ result, update }) => {
|
||||||
|
isAddingRSVP = false;
|
||||||
|
if (result.type === 'failure') {
|
||||||
|
error = String(result.data?.error || 'Failed to add RSVP');
|
||||||
|
}
|
||||||
|
update();
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
class="space-y-4"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="userId" value={currentUserId} />
|
||||||
|
<div>
|
||||||
|
<label for="attendeeName" class=" mb-2 block text-sm font-semibold">
|
||||||
|
{t('event.yourNameLabel')}
|
||||||
|
<span class="text-red-400">{t('common.required')}</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="attendeeName"
|
||||||
|
name="newAttendeeName"
|
||||||
|
type="text"
|
||||||
|
bind:value={newAttendeeName}
|
||||||
|
class="border-dark-300 w-full rounded-sm border-2 px-4 py-3 text-slate-900 shadow-sm"
|
||||||
|
placeholder={t('event.yourNamePlaceholder')}
|
||||||
|
maxlength="50"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add Guests Toggle -->
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<input
|
||||||
|
id="addGuests"
|
||||||
|
type="checkbox"
|
||||||
|
bind:checked={addGuests}
|
||||||
|
class="h-4 w-4 rounded border-gray-300 text-violet-600 focus:ring-violet-500"
|
||||||
|
/>
|
||||||
|
<label for="addGuests" class="text-sm font-medium text-white">
|
||||||
|
{t('event.addGuestsLabel')}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Number of Guests Input -->
|
||||||
|
{#if addGuests}
|
||||||
|
<div>
|
||||||
|
<label for="numberOfGuests" class="mb-2 block text-sm font-semibold">
|
||||||
|
{t('event.numberOfGuestsLabel')}
|
||||||
|
<span class="text-red-400">{t('common.required')}</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="numberOfGuests"
|
||||||
|
name="numberOfGuests"
|
||||||
|
type="number"
|
||||||
|
bind:value={numberOfGuests}
|
||||||
|
min="1"
|
||||||
|
max="10"
|
||||||
|
class="border-dark-300 w-full rounded-sm border-2 px-4 py-3 text-slate-900 shadow-sm"
|
||||||
|
placeholder={t('event.numberOfGuestsPlaceholder')}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-xs text-slate-400">
|
||||||
|
{t('event.guestsWillBeAddedAs', {
|
||||||
|
name: newAttendeeName || t('common.yourNamePlaceholder')
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isAddingRSVP ||
|
||||||
|
!newAttendeeName.trim() ||
|
||||||
|
(addGuests && numberOfGuests < 1)}
|
||||||
|
class=" hover:bg-violet-400/70' w-full rounded-sm border-2 border-violet-500 bg-violet-400/20 px-4 py-3 py-4 font-bold font-medium font-semibold text-white shadow-lg transition-all duration-200 hover:scale-105"
|
||||||
|
>
|
||||||
|
{#if isAddingRSVP}
|
||||||
|
<div class="flex items-center justify-center">
|
||||||
|
<div
|
||||||
|
class="mr-2 h-5 w-5 animate-spin rounded-full border-b-2 border-white"
|
||||||
|
></div>
|
||||||
|
{t('event.adding')}
|
||||||
|
</div>
|
||||||
|
{:else if addGuests && numberOfGuests > 0}
|
||||||
|
{t('event.joinEventWithGuests', {
|
||||||
|
count: numberOfGuests,
|
||||||
|
plural: numberOfGuests > 1 ? 's' : ''
|
||||||
|
})}
|
||||||
|
{:else}
|
||||||
|
{t('event.joinEventButton')}
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Attendees List -->
|
||||||
|
<div class="rounded-sm border p-6 shadow-2xl backdrop-blur-sm">
|
||||||
|
<div class="mb-4 flex items-center justify-between">
|
||||||
|
<h3 class=" text-xl font-bold">{t('event.attendeesTitle')}</h3>
|
||||||
|
<span class="text-2xl font-bold">{rsvps.length}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if rsvps.length === 0}
|
||||||
|
<div class="text-dark-400 py-8 text-center">
|
||||||
|
<p>{t('event.noAttendeesYet')}</p>
|
||||||
|
<p class="mt-1 text-sm">{t('event.beFirstToJoin')}</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="space-y-3">
|
||||||
|
{#each rsvps as attendee, i (i)}
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between rounded-sm border border-white/20 p-3"
|
||||||
|
>
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<div
|
||||||
|
class="flex h-8 w-8 items-center justify-center rounded-full text-sm font-bold {attendee.name.includes(
|
||||||
|
"'s Guest"
|
||||||
|
)
|
||||||
|
? 'text-white-400 bg-violet-500/40'
|
||||||
|
: 'bg-violet-500/20 text-violet-400'}"
|
||||||
|
>
|
||||||
|
{attendee.name.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p
|
||||||
|
class="font-medium text-white {attendee.name.includes("'s Guest")
|
||||||
|
? 'text-amber-300'
|
||||||
|
: ''}"
|
||||||
|
>
|
||||||
|
{attendee.name}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-violet-400">
|
||||||
|
{(() => {
|
||||||
|
const date = new Date(attendee.created_at);
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
const hours = String(date.getHours()).padStart(2, '0');
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||||
|
return `${year}/${month}/${day} ${hours}:${minutes}`;
|
||||||
|
})()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if attendee.user_id === currentUserId}
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/removeRSVP"
|
||||||
|
use:enhance={() => {
|
||||||
|
clearMessages();
|
||||||
|
return async ({ result, update }) => {
|
||||||
|
if (result.type === 'failure') {
|
||||||
|
error = String(result.data?.error || 'Failed to remove RSVP');
|
||||||
|
}
|
||||||
|
update();
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
style="display: inline;"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="rsvpId" value={attendee.id} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="text-dark-400 p-1 transition-colors duration-200 hover:text-red-400"
|
||||||
|
aria-label={t('event.removeRsvpAriaLabel')}
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<div class="max-w-2xl space-y-3">
|
||||||
|
<button
|
||||||
|
on:click={copyInviteLink}
|
||||||
|
class="hover:bg-violet-400/70' w-full rounded-sm border-2 border-violet-500 bg-violet-400/20 px-4 py-3 py-4 font-bold font-medium font-semibold text-white shadow-lg transition-all duration-200 hover:scale-105"
|
||||||
|
>
|
||||||
|
{t('event.copyInviteLinkButton')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
on:click={openCalendarModal}
|
||||||
|
class="hover:bg-violet-400/70' w-full rounded-sm border-2 border-violet-500 bg-violet-400/20 px-4 py-3 py-4 font-bold font-medium font-semibold text-white shadow-lg transition-all duration-200 hover:scale-105"
|
||||||
|
>
|
||||||
|
{t('event.addToCalendarButton')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Calendar Modal -->
|
||||||
|
{#if calendarEvent && browser}
|
||||||
|
<CalendarModal
|
||||||
|
bind:isOpen={showCalendarModal}
|
||||||
|
event={calendarEvent}
|
||||||
|
{eventId}
|
||||||
|
baseUrl={$page.url.origin}
|
||||||
|
on:close={closeCalendarModal}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Success/Error Messages -->
|
||||||
|
{#if success}
|
||||||
|
{#if form?.type === 'add'}
|
||||||
|
<div
|
||||||
|
class="fixed right-4 bottom-4 z-40 w-128 rounded-sm border border-green-500/30 bg-green-900/20 p-4 text-green-400"
|
||||||
|
>
|
||||||
|
{success}
|
||||||
|
</div>
|
||||||
|
{:else if form?.type === 'remove'}
|
||||||
|
<div
|
||||||
|
class="fixed right-4 bottom-4 z-40 w-128 rounded-sm border border-yellow-500/30 bg-yellow-900/20 p-4 text-yellow-400"
|
||||||
|
>
|
||||||
|
{t('event.removedRsvpSuccessfully')}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div
|
||||||
|
class="fixed right-4 bottom-4 z-40 w-128 rounded-sm border border-red-500/30 bg-red-900/20 p-4 text-red-400"
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
Reference in New Issue
Block a user