diff --git a/app/events/[eventId]/EventDetailClient.tsx b/app/events/[eventId]/EventDetailClient.tsx index 1bd7752..051bd17 100644 --- a/app/events/[eventId]/EventDetailClient.tsx +++ b/app/events/[eventId]/EventDetailClient.tsx @@ -8,6 +8,8 @@ import { PotionBackground } from "@/app/components/PotionBackground" import { ErrorBoundary } from "@/app/components/ErrorBoundary" import { Button } from "@/app/components/Button" import { TextInput } from "@/app/components/TextInput" +import { supabaseClient } from "@/lib/supabaseClient" +import { isInterestedInEvent, removeEventInterest, toggleEventInterest } from "@/lib/eventInterests" // Components // @@ -21,14 +23,35 @@ export default function EventDetailClient() { const [userInfo, setUserInfo] = useState<{ name: string; email: string }>({ name: "", email: "" }) const [registering, setRegistering] = useState(false) const [hasStoredInfo, setHasStoredInfo] = useState(false) + const [isLoggedIn, setIsLoggedIn] = useState(false) + const [isInterested, setIsInterested] = useState(false) + const [interestLoading, setInterestLoading] = useState(true) + const [interestSaving, setInterestSaving] = useState(false) const nameInputRef = useRef(null) useEffect(() => { loadEvent() loadSavedInfo() + loadInterestState() // eslint-disable-next-line react-hooks/exhaustive-deps }, [eventId]) + useEffect(() => { + if (!event || !isLoggedIn) { + return + } + + const isPast = new Date(event.start_at) < new Date() + if (!isPast || !isInterested) { + return + } + + // Past events are no longer actionable — drop the DB row + removeEventInterest(eventId) + .then(() => setIsInterested(false)) + .catch((error) => console.error("Failed to prune past event interest:", error)) + }, [event, isLoggedIn, isInterested, eventId]) + const loadEvent = async () => { try { const eventData = await lumaService.getEvent(eventId) @@ -44,6 +67,53 @@ export default function EventDetailClient() { } } + const loadInterestState = async () => { + setInterestLoading(true) + try { + const { + data: { user } + } = await supabaseClient.auth.getUser() + + if (!user) { + setIsLoggedIn(false) + setIsInterested(false) + return + } + + setIsLoggedIn(true) + const interested = await isInterestedInEvent(eventId) + setIsInterested(interested) + } catch (error) { + console.error("Failed to load event interest:", error) + setIsInterested(false) + } finally { + setInterestLoading(false) + } + } + + const handleToggleInterest = async () => { + if (!event || new Date(event.start_at) < new Date()) { + return + } + + if (!isLoggedIn) { + const redirectUrl = encodeURIComponent(`/events/${eventId}`) + router.push(`/login?redirect=${redirectUrl}`) + return + } + + setInterestSaving(true) + try { + const next = await toggleEventInterest(eventId) + setIsInterested(next) + } catch (error) { + console.error("Failed to update event interest:", error) + alert("Failed to update interest. Please try again.") + } finally { + setInterestSaving(false) + } + } + const loadSavedInfo = () => { const savedUserInfo = localStorage.getItem("devx_user_info") if (savedUserInfo) { @@ -174,17 +244,19 @@ export default function EventDetailClient() {
{event.name} {formatEventDateTime(event.start_at, event.end_at)} - + + +
{event.location && event.location.type === "online" && ( @@ -206,6 +278,32 @@ export default function EventDetailClient() { + {!isPastEvent && ( + + Save Event + + {isInterested + ? "Saved to your account. Come back anytime while you decide." + : "On the fence? Mark this event so you can find it later."} + + + + )} + {!isPastEvent && ( Registration @@ -441,6 +539,12 @@ const Header = styled.header` margin-bottom: 2rem; ` +const MobileAttendButton = styled.div` + @media (min-width: 768px) { + display: none; + } +` + const Title = styled.h1` font-size: 2.25rem; font-weight: bold; @@ -600,6 +704,28 @@ const AttendeeLink = styled.a` } ` +const InterestSection = styled.section` + background-color: rgba(255, 255, 255, 0.01); + backdrop-filter: blur(32px); + box-shadow: 4px 8px 8px 0 rgba(0, 0, 0, 0.05); + padding: 1.5rem; + border-radius: 0.5rem; + margin: 2rem 0 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.75rem; + text-align: center; +` + +const InterestCopy = styled.p` + font-size: 0.875rem; + color: #d1d5db; + line-height: 1.5; + margin: 0; +` + const RegistrationSection = styled.section` background-color: rgba(255, 255, 255, 0.01); backdrop-filter: blur(32px); diff --git a/app/events/page.tsx b/app/events/page.tsx index 1c3c796..fdcfac2 100644 --- a/app/events/page.tsx +++ b/app/events/page.tsx @@ -1,5 +1,6 @@ "use client" import { useEffect, useState } from "react" +import { useRouter } from "next/navigation" import styled from "styled-components" import type { LumaEvent } from "@/app/services/luma" import { lumaService } from "@/app/services/luma" @@ -7,15 +8,20 @@ import { PotionBackground } from "../components/PotionBackground" import { ErrorBoundary } from "../components/ErrorBoundary" import { Card, CardContent, CardTitle, CardText } from "../components/Card" import { Button } from "../components/Button" +import { supabaseClient } from "@/lib/supabaseClient" +import { pruneStaleEventInterests } from "@/lib/eventInterests" // Types // -type EventFilter = "upcoming" | "past" +type EventFilter = "upcoming" | "past" | "saved" // Components // export default function Events() { + const router = useRouter() const [events, setEvents] = useState([]) + const [interestedIds, setInterestedIds] = useState>(new Set()) + const [isLoggedIn, setIsLoggedIn] = useState(false) const [filter, setFilter] = useState("upcoming") const [loading, setLoading] = useState(true) @@ -27,6 +33,20 @@ export default function Events() { try { const allEvents = await lumaService.listEvents() setEvents(allEvents) + + const { + data: { user } + } = await supabaseClient.auth.getUser() + + if (!user) { + setIsLoggedIn(false) + setInterestedIds(new Set()) + return + } + + setIsLoggedIn(true) + const interests = await pruneStaleEventInterests(allEvents) + setInterestedIds(new Set(interests)) } catch (error) { console.error("Failed to load events:", error) } finally { @@ -34,19 +54,58 @@ export default function Events() { } } + const handleFilterChange = async (nextFilter: EventFilter) => { + if (nextFilter === "saved" && !isLoggedIn) { + const { + data: { user } + } = await supabaseClient.auth.getUser() + + if (!user) { + router.push(`/login?redirect=${encodeURIComponent("/events")}`) + return + } + + setIsLoggedIn(true) + } + + setFilter(nextFilter) + + if (nextFilter === "saved") { + try { + const interests = await listInterestedEventIds() + setInterestedIds(new Set(interests)) + } catch (error) { + console.error("Failed to load saved events:", error) + } + } + } + + const now = new Date() + const isUpcoming = (event: LumaEvent) => new Date(event.start_at) >= now + const filteredEvents = events .filter((event) => { - const eventDate = new Date(event.start_at) - const now = new Date() - return filter === "upcoming" ? eventDate >= now : eventDate < now + if (filter === "saved") { + // Saved is for deciding/signing up later — only upcoming events belong here + return interestedIds.has(event.api_id) && isUpcoming(event) + } + + return filter === "upcoming" ? isUpcoming(event) : !isUpcoming(event) }) .sort((a, b) => { const dateA = new Date(a.start_at).getTime() const dateB = new Date(b.start_at).getTime() - // Ascending for upcoming (oldest first), descending for past (newest first) - return filter === "upcoming" ? dateA - dateB : dateB - dateA + + if (filter === "past") { + return dateB - dateA + } + + // Upcoming and saved: soonest first + return dateA - dateB }) + const emptyMessage = getEmptyMessage(filter, isLoggedIn) + return ( <> @@ -67,22 +126,28 @@ export default function Events() { + {loading ? ( Loading events... ) : filteredEvents.length === 0 ? ( - No {filter} events at this time. Check back soon! + {emptyMessage} ) : ( {filteredEvents.map((event) => ( @@ -103,6 +168,13 @@ export default function Events() { : "Online Event"} )} + {filter !== "saved" && + interestedIds.has(event.api_id) && + isUpcoming(event) && ( + + Saved + + )} {event.guest_count !== undefined && event.guest_count !== -1 && ( {event.guest_count} attendees @@ -127,6 +199,17 @@ export default function Events() { // Functions // +function getEmptyMessage(filter: EventFilter, isLoggedIn: boolean): string { + if (filter === "saved") { + if (!isLoggedIn) { + return "Log in to view events you've saved." + } + return "No upcoming saved events. Open an event and tap I'm Interested." + } + + return `No ${filter} events at this time. Check back soon!` +} + function formatEventDate(dateString: string): string { const date = new Date(dateString) return date.toLocaleDateString("en-US", { @@ -193,6 +276,7 @@ const EventDescription = styled.p` const FilterToggle = styled.div` display: flex; justify-content: center; + flex-wrap: wrap; gap: 1rem; margin-bottom: 2rem; ` diff --git a/lib/eventInterests.ts b/lib/eventInterests.ts new file mode 100644 index 0000000..88262e8 --- /dev/null +++ b/lib/eventInterests.ts @@ -0,0 +1,210 @@ +import { supabaseClient } from "./supabaseClient" + +// +// Types +// + +export type EventInterest = { + id: number + user_id: string + profile_id: number + event_id: string + created_at: string +} + +type AuthContext = { + userId: string + profileId: number +} + +// +// Functions +// + +export async function listInterestedEventIds(): Promise { + const { + data: { user } + } = await supabaseClient.auth.getUser() + + if (!user) { + return [] + } + + const { data, error } = await supabaseClient + .from("event_interests") + .select("event_id") + .eq("user_id", user.id) + .order("created_at", { ascending: false }) + + if (error) { + throw error + } + + return (data ?? []).map((row) => row.event_id as string) +} + +/** + * Deletes the current user's interests for past (or unknown) events so the + * table only keeps actionable upcoming saves. + * Returns the remaining interested event ids. + */ +export async function pruneStaleEventInterests( + knownEvents: Array<{ api_id: string; start_at: string }> +): Promise { + const { + data: { user } + } = await supabaseClient.auth.getUser() + + if (!user) { + return [] + } + + const interestedIds = await listInterestedEventIds() + if (interestedIds.length === 0) { + return [] + } + + const now = Date.now() + const eventById = new Map(knownEvents.map((event) => [event.api_id, event])) + const staleIds = interestedIds.filter((eventId) => { + const event = eventById.get(eventId) + if (!event) { + return true + } + return new Date(event.start_at).getTime() < now + }) + + if (staleIds.length === 0) { + return interestedIds + } + + const { error } = await supabaseClient + .from("event_interests") + .delete() + .eq("user_id", user.id) + .in("event_id", staleIds) + + if (error) { + throw error + } + + return interestedIds.filter((eventId) => !staleIds.includes(eventId)) +} + +export async function isInterestedInEvent(eventId: string): Promise { + const { + data: { user } + } = await supabaseClient.auth.getUser() + + if (!user) { + return false + } + + const { data, error } = await supabaseClient + .from("event_interests") + .select("id") + .eq("user_id", user.id) + .eq("event_id", eventId) + .maybeSingle() + + if (error) { + throw error + } + + return data !== null +} + +export async function addEventInterest(eventId: string): Promise { + const auth = await requireAuthContext() + + const { data, error } = await supabaseClient + .from("event_interests") + .upsert( + { + user_id: auth.userId, + profile_id: auth.profileId, + event_id: eventId + }, + { onConflict: "user_id,event_id", ignoreDuplicates: true } + ) + .select("id, user_id, profile_id, event_id, created_at") + .maybeSingle() + + if (error) { + throw error + } + + if (data) { + return data as EventInterest + } + + const { data: existing, error: fetchError } = await supabaseClient + .from("event_interests") + .select("id, user_id, profile_id, event_id, created_at") + .eq("user_id", auth.userId) + .eq("event_id", eventId) + .single() + + if (fetchError) { + throw fetchError + } + + return existing as EventInterest +} + +export async function removeEventInterest(eventId: string): Promise { + const { + data: { user } + } = await supabaseClient.auth.getUser() + + if (!user) { + throw new Error("You must be logged in to update event interests") + } + + const { error } = await supabaseClient + .from("event_interests") + .delete() + .eq("user_id", user.id) + .eq("event_id", eventId) + + if (error) { + throw error + } +} + +export async function toggleEventInterest(eventId: string): Promise { + const currentlyInterested = await isInterestedInEvent(eventId) + + if (currentlyInterested) { + await removeEventInterest(eventId) + return false + } + + await addEventInterest(eventId) + return true +} + +async function requireAuthContext(): Promise { + const { + data: { user } + } = await supabaseClient.auth.getUser() + + if (!user) { + throw new Error("You must be logged in to save event interests") + } + + const { data: profile, error } = await supabaseClient + .from("profiles") + .select("id") + .eq("user_id", user.id) + .single() + + if (error || !profile) { + throw new Error("Profile not found. Please finish account setup first.") + } + + return { + userId: user.id, + profileId: profile.id as number + } +} diff --git a/supabase/migrations/20260812000000_create_event_interests.sql b/supabase/migrations/20260812000000_create_event_interests.sql new file mode 100644 index 0000000..a5118e1 --- /dev/null +++ b/supabase/migrations/20260812000000_create_event_interests.sql @@ -0,0 +1,34 @@ +-- Store per-account "interested" flags for Luma events (events live in JSON/Luma, not Postgres). +CREATE TABLE event_interests ( + id BIGSERIAL PRIMARY KEY, + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + profile_id BIGINT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + event_id TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT event_interests_user_event_unique UNIQUE (user_id, event_id) +); + +CREATE INDEX idx_event_interests_user_id ON event_interests(user_id); +CREATE INDEX idx_event_interests_event_id ON event_interests(event_id); +CREATE INDEX idx_event_interests_profile_id ON event_interests(profile_id); + +ALTER TABLE event_interests ENABLE ROW LEVEL SECURITY; + +-- Interests are private to the owning account +CREATE POLICY "Users can view their own event interests" + ON event_interests + FOR SELECT + TO authenticated + USING (auth.uid() = user_id); + +CREATE POLICY "Users can insert their own event interests" + ON event_interests + FOR INSERT + TO authenticated + WITH CHECK (auth.uid() = user_id); + +CREATE POLICY "Users can delete their own event interests" + ON event_interests + FOR DELETE + TO authenticated + USING (auth.uid() = user_id);