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) => (
-
- ))}
-
- ) : (
-
- 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 ? (
-

- ) : (
-
- )}
-
-
-
- );
- })}
-
- ) : (
-
- 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 ? (
+

+ ) : (
+
+ Image unavailable
+
+ )}
+
+
+
+ ))}
+
+ {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) {