From e8ec3eb288717e36286fceb66178bb4a196e77ad Mon Sep 17 00:00:00 2001 From: VyomeshJ Date: Wed, 30 Sep 2026 12:34:44 +1300 Subject: [PATCH] fix: resolve member event details --- src/app/profile/[id]/page.tsx | 18 +---- src/app/profile/page.tsx | 117 ++------------------------- src/components/attended-events.tsx | 126 +++++++++++++++++++++++++++++ src/services/event-service.test.ts | 65 +++++++++++++++ src/services/event-service.ts | 87 +++++++++++++++++++- 5 files changed, 283 insertions(+), 130 deletions(-) create mode 100644 src/components/attended-events.tsx create mode 100644 src/services/event-service.test.ts diff --git a/src/app/profile/[id]/page.tsx b/src/app/profile/[id]/page.tsx index 85d508b..c8a478c 100644 --- a/src/app/profile/[id]/page.tsx +++ b/src/app/profile/[id]/page.tsx @@ -6,6 +6,7 @@ import { PublicProfilesService, type PublicProfile, } from "@/services/public-profiles-service"; +import { AttendedEvents } from "@/components/attended-events"; export default function PublicProfilePage() { const { id } = useParams<{ id: string }>(); @@ -138,22 +139,7 @@ export default function PublicProfilePage() {

Events Attended

- {profile.events_attended.length > 0 ? ( -
- {profile.events_attended.map((eventName, index) => ( -
-

{eventName}

-
- ))} -
- ) : ( -

- No events attended yet. -

- )} +
diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx index fd05db9..84a1136 100644 --- a/src/app/profile/page.tsx +++ b/src/app/profile/page.tsx @@ -1,7 +1,6 @@ "use client"; import React, { useEffect, useRef, useState } from "react"; -import Image from "next/image"; import { useWallet } from "@/hooks/use-wallet"; import { WalletButton } from "@/components/wallet-button"; import { CheckCheck, Copy, PenTool } from "lucide-react"; @@ -10,7 +9,7 @@ import { useSignMessage } from "wagmi"; import { ProfileVisibilityToggle } from "@/components/profile-visibility-toggle"; import { IoQrCode } from "react-icons/io5"; import QrCode from "qrcode"; -import { getSupabase } from "@/services/supabase"; +import { AttendedEvents } from "@/components/attended-events"; const getErrorMessage = (error: unknown) => { if (error instanceof Error) { @@ -24,25 +23,6 @@ const getErrorMessage = (error: unknown) => { return String(error); }; -const EVENT_THUMBNAIL_MAP: Record = { - "launch night": "/images/Launchnight1.jpg", - "industry night": "/images/Industrynight1.jpg", - govdojo: "/images/GovDojo1.jpg", -}; - -const normalizeEventName = (eventName: string) => - eventName.trim().toLowerCase(); - -const getEventImage = (eventName: string) => { - const normalized = normalizeEventName(eventName); - if (normalized.includes("launch")) return EVENT_THUMBNAIL_MAP["launch night"]; - if (normalized.includes("industry")) - return EVENT_THUMBNAIL_MAP["industry night"]; - if (normalized.includes("govdojo") || normalized.includes("gov")) - return EVENT_THUMBNAIL_MAP.govdojo; - return "/images/Launchnight1.jpg"; -}; - export default function ProfilePage() { const { address, isConnected, disconnect, mounted } = useWallet(); const { signMessageAsync } = useSignMessage(); @@ -52,9 +32,6 @@ export default function ProfilePage() { const [profileImage, setProfileImage] = useState(null); const [badges, setBadges] = useState([]); const [eventsAttended, setEventsAttended] = useState([]); - const [eventDetails, setEventDetails] = useState< - Record - >({}); const [profileLoading, setProfileLoading] = useState(true); const [walletRegistered, setWalletRegistered] = useState(false); const [walletChecking, setWalletChecking] = useState(true); @@ -195,46 +172,6 @@ export default function ProfilePage() { useEffect(() => { let cancelled = false; - const fetchAttendedEventDetails = async (attended: string[]) => { - if (attended.length === 0) return; - - try { - const supabase = getSupabase(); - const { data, error } = await supabase - .from("events") - .select("id,title,event_url") - .in("id", attended); - - if (error) { - console.error("Failed to load attended event details", error); - return; - } - - if (!cancelled && data) { - const details = data.reduce( - ( - acc: Record, - event, - ) => { - if (event.id) { - acc[event.id] = { - title: event.title || event.id, - eventUrl: event.event_url || null, - }; - } - return acc; - }, - {}, - ); - setEventDetails(details); - } - } catch (error) { - if (!cancelled) { - console.error("Failed to load attended event images", error); - } - } - }; - const loadProfile = async () => { if (!address) { setWalletRegistered(false); @@ -286,7 +223,6 @@ export default function ProfilePage() { setBadges(registration.badges || []); setEventsAttended(attended); setProfileVisible(registration.profile_visible ?? true); - await fetchAttendedEventDetails(attended); } else { setDisplayName("Name"); setProfileImage(null); @@ -615,53 +551,10 @@ export default function ProfilePage() {

Events Attended

- {profileLoading ? ( -

- Loading event history… -

- ) : eventsAttended.length > 0 ? ( -
- {eventsAttended.map((eventId, index) => { - const eventDetail = eventDetails[eventId]; - const eventName = eventDetail?.title || eventId; - const eventUrl = eventDetail?.eventUrl; - const eventImage = eventUrl ?? getEventImage(eventName); - return ( -
-
- {eventUrl ? ( - {eventName} - ) : ( - {eventName} - )} -
-

- {eventName} -

-
-
-
- ); - })} -
- ) : ( -

- No events attended yet. -

- )} +
diff --git a/src/components/attended-events.tsx b/src/components/attended-events.tsx new file mode 100644 index 0000000..ae24795 --- /dev/null +++ b/src/components/attended-events.tsx @@ -0,0 +1,126 @@ +"use client"; + +import { useEffect, useState } from "react"; + +import { + EventService, + type AttendedEventResolution, +} from "@/services/event-service"; + +type AttendedEventsProps = { + attendanceReferences: string[]; + profileLoading?: boolean; +}; + +const EMPTY_RESOLUTION: AttendedEventResolution = { + events: [], + unavailableCount: 0, +}; + +export function AttendedEvents({ + attendanceReferences, + profileLoading = false, +}: AttendedEventsProps) { + const [resolution, setResolution] = + useState(EMPTY_RESOLUTION); + const [detailsLoading, setDetailsLoading] = useState(false); + const [loadFailed, setLoadFailed] = useState(false); + + useEffect(() => { + let cancelled = false; + + if (attendanceReferences.length === 0) { + setResolution(EMPTY_RESOLUTION); + setDetailsLoading(false); + setLoadFailed(false); + return; + } + + setDetailsLoading(true); + setLoadFailed(false); + + EventService.getAttendedEvents(attendanceReferences) + .then((result) => { + if (!cancelled) { + setResolution(result); + } + }) + .catch((error) => { + console.error("Unable to load attended event details", error); + + if (!cancelled) { + setResolution(EMPTY_RESOLUTION); + setLoadFailed(true); + } + }) + .finally(() => { + if (!cancelled) { + setDetailsLoading(false); + } + }); + + return () => { + cancelled = true; + }; + }, [attendanceReferences]); + + if (profileLoading || detailsLoading) { + return ( +

+ Loading event history… +

+ ); + } + + if (loadFailed) { + return ( +

+ Event history is unavailable right now. +

+ ); + } + + if (attendanceReferences.length === 0) { + return ( +

+ No events attended yet. +

+ ); + } + + return ( +
+ {resolution.events.map((event) => ( +
+
+ {event.eventUrl ? ( + {`${event.title} + ) : ( +
+ Image unavailable +
+ )} +
+

{event.title}

+
+
+
+ ))} + + {resolution.unavailableCount > 0 && ( +

+ {resolution.unavailableCount === 1 + ? "Details for one past event are no longer available." + : `Details for ${resolution.unavailableCount} past events are no longer available.`} +

+ )} +
+ ); +} diff --git a/src/services/event-service.test.ts b/src/services/event-service.test.ts new file mode 100644 index 0000000..866d075 --- /dev/null +++ b/src/services/event-service.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; + +import { resolveAttendedEvents } from "./event-service"; + +const EVENTS = [ + { + id: "ed6707c8-232b-403e-ac17-6e64eef72790", + title: "Launch Night", + event_url: "https://example.com/launch.webp", + }, + { + id: "deba40f2-35af-47a3-8827-448146588e09", + title: "Crochet Workshop", + event_url: "https://example.com/crochet.jpg", + }, +]; + +describe("resolveAttendedEvents", () => { + it("resolves UUID attendance values to the event title and poster", () => { + const result = resolveAttendedEvents([EVENTS[0].id], EVENTS); + + expect(result).toEqual({ + events: [ + { + id: EVENTS[0].id, + title: "Launch Night", + eventUrl: "https://example.com/launch.webp", + }, + ], + unavailableCount: 0, + }); + }); + + it("supports legacy title values without case or whitespace sensitivity", () => { + const result = resolveAttendedEvents([" crochet workshop "], EVENTS); + + expect(result.events[0]).toMatchObject({ + id: EVENTS[1].id, + title: "Crochet Workshop", + eventUrl: "https://example.com/crochet.jpg", + }); + expect(result.unavailableCount).toBe(0); + }); + + it("does not expose dangling event IDs or assign them an unrelated poster", () => { + const result = resolveAttendedEvents( + ["62d08d91-6a21-4949-9041-e2bd2c15c86d"], + EVENTS, + ); + + expect(result).toEqual({ events: [], unavailableCount: 1 }); + }); + + it("deduplicates repeated attendance values while preserving order", () => { + const result = resolveAttendedEvents( + [EVENTS[1].id, EVENTS[0].id, EVENTS[1].id], + EVENTS, + ); + + expect(result.events.map((event) => event.id)).toEqual([ + EVENTS[1].id, + EVENTS[0].id, + ]); + }); +}); diff --git a/src/services/event-service.ts b/src/services/event-service.ts index 7f243fb..589894e 100644 --- a/src/services/event-service.ts +++ b/src/services/event-service.ts @@ -1,9 +1,65 @@ -import { getSupabase } from "@/services/supabase"; -import type { ClubEvent } from "@/lib/events"; +import { getSupabase } from "./supabase"; +import type { ClubEvent } from "../lib/events"; const EVENT_IMAGE_BUCKET = "events"; const EVENT_IMAGE_FOLDER = "events"; +export type AttendedEvent = { + id: string; + title: string; + eventUrl: string | null; +}; + +export type AttendedEventResolution = { + events: AttendedEvent[]; + unavailableCount: number; +}; + +type EventLookupRow = Pick; + +const normalizeAttendanceReference = (reference: string) => + reference.trim().toLowerCase(); + +export function resolveAttendedEvents( + attendanceReferences: string[], + eventRows: EventLookupRow[], +): AttendedEventResolution { + const uniqueReferences = Array.from( + new Set( + attendanceReferences.map((reference) => reference.trim()).filter(Boolean), + ), + ); + const eventsByReference = new Map(); + + for (const event of eventRows) { + eventsByReference.set(normalizeAttendanceReference(event.id), event); + + if (event.title) { + eventsByReference.set(normalizeAttendanceReference(event.title), event); + } + } + + const events: AttendedEvent[] = []; + let unavailableCount = 0; + + for (const reference of uniqueReferences) { + const event = eventsByReference.get(normalizeAttendanceReference(reference)); + + if (!event) { + unavailableCount += 1; + continue; + } + + events.push({ + id: event.id, + title: event.title?.trim() || "Untitled event", + eventUrl: event.event_url, + }); + } + + return { events, unavailableCount }; +} + export const EventService = { /** Public read used by the events page. Relies on anon SELECT being allowed. */ listEvents: async (): Promise => { @@ -35,6 +91,33 @@ export const EventService = { return (data as ClubEvent | null) ?? null; }, + /** + * Resolves attendance values written by both generations of the check-in + * flow: current UUIDs and legacy event titles. The small projection keeps + * member profiles consistent without exposing raw attendance identifiers. + */ + getAttendedEvents: async ( + attendanceReferences: string[], + ): Promise => { + if (attendanceReferences.length === 0) { + return { events: [], unavailableCount: 0 }; + } + + const supabase = getSupabase(); + const { data, error } = await supabase + .from("events") + .select("id,title,event_url"); + + if (error) { + throw error; + } + + return resolveAttendedEvents( + attendanceReferences, + (data ?? []) as EventLookupRow[], + ); + }, + getEventImagePath: (file: File) => { const fileExtension = file.name.split(".").pop()?.toLowerCase(); if (!fileExtension) {