diff --git a/public/images/hibento-vibes-01.jpg b/public/images/hibento-vibes-01.jpg index 846743c..16473d2 100644 Binary files a/public/images/hibento-vibes-01.jpg and b/public/images/hibento-vibes-01.jpg differ diff --git a/public/images/hibento-vibes-03.jpg b/public/images/hibento-vibes-03.jpg index fa45a8c..60d12c1 100644 Binary files a/public/images/hibento-vibes-03.jpg and b/public/images/hibento-vibes-03.jpg differ diff --git a/public/images/hibento-vibes-04.jpg b/public/images/hibento-vibes-04.jpg new file mode 100644 index 0000000..617fd35 Binary files /dev/null and b/public/images/hibento-vibes-04.jpg differ diff --git a/public/images/hibento-vibes-05.jpg b/public/images/hibento-vibes-05.jpg new file mode 100644 index 0000000..0dcc483 Binary files /dev/null and b/public/images/hibento-vibes-05.jpg differ diff --git a/public/images/hibento-vibes-06.jpg b/public/images/hibento-vibes-06.jpg new file mode 100644 index 0000000..c041500 Binary files /dev/null and b/public/images/hibento-vibes-06.jpg differ diff --git a/public/images/hibento-vibes-07.jpg b/public/images/hibento-vibes-07.jpg new file mode 100644 index 0000000..a37447f Binary files /dev/null and b/public/images/hibento-vibes-07.jpg differ diff --git a/public/images/hibento-vibes-08.jpg b/public/images/hibento-vibes-08.jpg new file mode 100644 index 0000000..9b8eed0 Binary files /dev/null and b/public/images/hibento-vibes-08.jpg differ diff --git a/public/images/hibento-vibes-09.jpg b/public/images/hibento-vibes-09.jpg new file mode 100644 index 0000000..109ac3b Binary files /dev/null and b/public/images/hibento-vibes-09.jpg differ diff --git a/public/images/hibento-vibes-10.jpg b/public/images/hibento-vibes-10.jpg new file mode 100644 index 0000000..ea30983 Binary files /dev/null and b/public/images/hibento-vibes-10.jpg differ diff --git a/src/app/api/ai/events/[eventId]/recommendations/route.ts b/src/app/api/ai/events/[eventId]/recommendations/route.ts index 20c492a..5b43138 100644 --- a/src/app/api/ai/events/[eventId]/recommendations/route.ts +++ b/src/app/api/ai/events/[eventId]/recommendations/route.ts @@ -50,7 +50,7 @@ export async function GET( const otherVecs = await generateBatchEmbeddings(otherTexts); - const scored = findMostSimilar(sourceVec, otherVecs, 0.4).slice(0, 5); + const scored = findMostSimilar(sourceVec, otherVecs, 0.3).slice(0, 6); const results: SearchResultDto[] = scored.map((s) => { const e = otherEvents[s.index]; diff --git a/src/app/api/ai/recommendations/global/route.ts b/src/app/api/ai/recommendations/global/route.ts new file mode 100644 index 0000000..b745069 --- /dev/null +++ b/src/app/api/ai/recommendations/global/route.ts @@ -0,0 +1,196 @@ +import { NextRequest, NextResponse } from "next/server"; +import prisma from "@/lib/db/prisma"; +import { + generateEmbedding, + generateBatchEmbeddings, + findMostSimilar, +} from "@/ai"; +import type { RecommendedEventDto } from "@/types/dto"; + +const MIN_SCORE = 0.2; +const MAX_RESULTS = 6; + +export async function GET( + request: NextRequest, +): Promise< + NextResponse<{ data: RecommendedEventDto[] } | { error: string }> +> { + try { + const { searchParams } = new URL(request.url); + const favoriteIds = searchParams + .get("favorites") + ?.split(",") + .filter(Boolean); + const currentEventId = searchParams.get("currentEventId"); + + const now = new Date(); + + // 1. Resolve favorite session IDs → parent event IDs + let favoriteEventIds: string[] = []; + if (favoriteIds && favoriteIds.length > 0) { + const sessions = await prisma.eventSession.findMany({ + where: { id: { in: favoriteIds } }, + select: { eventId: true }, + }); + favoriteEventIds = [...new Set(sessions.map((s) => s.eventId))]; + } + + // 2. Fetch source events (favorites + current page) + const sourceIds = [ + ...new Set([ + ...favoriteEventIds, + ...(currentEventId ? [currentEventId] : []), + ]), + ]; + + const sourceEvents = + sourceIds.length > 0 + ? await prisma.event.findMany({ where: { id: { in: sourceIds } } }) + : []; + + // 3. Candidate pool: upcoming/live events, excluding sources + const excludeIds = [...new Set(sourceIds)]; + const candidateEvents = await prisma.event.findMany({ + where: { + id: { notIn: excludeIds }, + endDate: { gte: now }, + }, + include: { venue: true }, + orderBy: { startDate: "asc" }, + }); + + if (candidateEvents.length === 0) { + return NextResponse.json({ data: [] }); + } + + // 4. Batch-embed candidates + const candidateTexts = candidateEvents.map((e) => + [e.title, e.description, e.venue?.name ?? ""] + .filter(Boolean) + .join(" "), + ); + const candidateVecs = await generateBatchEmbeddings(candidateTexts); + + // 5. Score each source against candidates + const scored = new Map< + string, + { score: number; reasons: Set } + >(); + + const addIfBetter = ( + eventId: string, + score: number, + reason: string, + ) => { + const existing = scored.get(eventId); + if (!existing || score > existing.score) { + scored.set(eventId, { score, reasons: new Set([reason]) }); + } else if (existing && score > 0) { + existing.reasons.add(reason); + } + }; + + for (const source of sourceEvents) { + const sourceText = [source.title, source.description] + .filter(Boolean) + .join(" "); + const sourceVec = await generateEmbedding(sourceText); + const similar = findMostSimilar(sourceVec, candidateVecs, MIN_SCORE); + + let reason = ""; + if (currentEventId === source.id) { + reason = `Similar to ${source.title} you're exploring`; + } else if (favoriteEventIds.includes(source.id)) { + reason = `Because you favorited a session in ${source.title}`; + } + + for (const s of similar) { + addIfBetter(candidateEvents[s.index].id, s.score, reason); + } + } + + // 6. Trending — events with most upvoted questions + const trending = await prisma.event.findMany({ + where: { + id: { notIn: excludeIds }, + endDate: { gte: now }, + }, + include: { + eventSessions: { + include: { questions: { select: { upvotes: true } } }, + }, + }, + }); + + const trendingUpvotes = trending.map((e) => { + const total = e.eventSessions.reduce( + (sum, s) => + sum + s.questions.reduce((qsum, q) => qsum + q.upvotes, 0), + 0, + ); + return { id: e.id, upvotes: total }; + }); + + const maxUpvotes = Math.max(...trendingUpvotes.map((t) => t.upvotes), 1); + for (const t of trendingUpvotes) { + if (t.upvotes === 0) continue; + const normalized = t.upvotes / maxUpvotes; + addIfBetter(t.id, normalized * 0.5, "Trending among attendees"); + } + + // 7. Also check same venue as a source + for (const source of sourceEvents) { + if (!source.venueId) continue; + for (const c of candidateEvents) { + if (c.venueId === source.venueId) { + addIfBetter( + c.id, + 0.25, + `Also happening at ${c.venue?.name ?? "the same venue"}`, + ); + } + } + } + + // 8. Sort and cap + const sorted = [...scored.entries()] + .sort((a, b) => b[1].score - a[1].score) + .slice(0, MAX_RESULTS); + + const data: RecommendedEventDto[] = sorted.map(([id, { score, reasons }]) => { + const event = candidateEvents.find((e) => e.id === id)!; + const v = event.venue; + return { + id: event.id, + title: event.title, + description: event.description, + startDate: event.startDate.toISOString(), + endDate: event.endDate.toISOString(), + isOnline: event.isOnline, + type: "event", + score: Math.round(score * 1000) / 1000, + match: { + venue: v + ? { + id: v.id, + name: v.name, + city: v.city, + neighborhood: v.neighborhood, + totalRooms: v.totalRooms, + } + : null, + speakers: [], + }, + reasons: [...new Set(reasons)], + }; + }); + + return NextResponse.json({ data }, { status: 200 }); + } catch (err) { + console.error("GET /api/ai/recommendations/global error:", err); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/ai/search/route.ts b/src/app/api/ai/search/route.ts index 6a347a4..2c06fef 100644 --- a/src/app/api/ai/search/route.ts +++ b/src/app/api/ai/search/route.ts @@ -34,7 +34,7 @@ export async function GET( try { const { searchParams } = new URL(request.url); const query = searchParams.get("q")?.trim(); - const limit = Math.min(50, parseInt(searchParams.get("limit") || "10")); + const limit = Math.min(50, parseInt(searchParams.get("limit") || "20")); const city = searchParams.get("city"); const dateFrom = searchParams.get("dateFrom"); const dateTo = searchParams.get("dateTo"); @@ -114,7 +114,7 @@ export async function GET( item, score: cosineSimilarity(queryVec, item.embedding), })) - .filter((s) => s.score > 0.3) + .filter((s) => s.score > 0.2) .sort((a, b) => b.score - a.score) .slice(0, limit); diff --git a/src/app/events/[eventId]/page.tsx b/src/app/events/[eventId]/page.tsx index 772cb78..9919355 100644 --- a/src/app/events/[eventId]/page.tsx +++ b/src/app/events/[eventId]/page.tsx @@ -1,10 +1,11 @@ "use client"; import Link from "next/link"; -import { useState, useRef, useCallback, useMemo } from "react"; +import { useState, useRef, useCallback, useMemo, useEffect } from "react"; import { useParams } from "next/navigation"; import { useQuery } from "@tanstack/react-query"; import { useGetEvent } from "@/lib/hooks/useEvents"; +import { useNotifications } from "@/lib/hooks/NotificationContext"; import { api } from "@/lib/api"; import { EventHero } from "@/components/events/EventHero"; import { EventInfoGrid } from "@/components/events/EventInfoGrid"; @@ -32,6 +33,7 @@ function NotFound() { export default function EventDetailPage() { const { eventId } = useParams<{ eventId: string }>(); const { data: event, isLoading } = useGetEvent(eventId); + const { setCurrentEventId } = useNotifications(); const { data: recsData } = useQuery({ queryKey: ["recommendations", eventId], queryFn: () => api.getRecommendations(eventId), @@ -43,6 +45,12 @@ export default function EventDetailPage() { const [statusFilter, setStatusFilter] = useState("all"); const [tablePage, setTablePage] = useState(1); + // Register this event as the current context for global recommendations + useEffect(() => { + setCurrentEventId(eventId); + return () => setCurrentEventId(null); + }, [eventId, setCurrentEventId]); + const viewPillRefs = useRef<(HTMLButtonElement | null)[]>([]); const statusPillRefs = useRef<(HTMLButtonElement | null)[]>([]); const dayPillRefs = useRef<(HTMLButtonElement | null)[]>([]); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 238081a..77f6179 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -53,8 +53,8 @@ export default function RootLayout({ - + {children} diff --git a/src/app/providers.tsx b/src/app/providers.tsx index 7597420..59c38c8 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -4,6 +4,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { useState } from "react"; import { ToastProvider } from "@/components/ui/Toast"; import { FavoritesProvider } from "@/lib/hooks/FavoritesContext"; +import { NotificationProvider } from "@/lib/hooks/NotificationContext"; export function Providers({ children }: { children: React.ReactNode }) { const [queryClient] = useState( @@ -21,7 +22,9 @@ export function Providers({ children }: { children: React.ReactNode }) { return ( - {children} + + {children} + ); diff --git a/src/components/layouts/Nav.tsx b/src/components/layouts/Nav.tsx index 53c392e..0ea123c 100644 --- a/src/components/layouts/Nav.tsx +++ b/src/components/layouts/Nav.tsx @@ -5,6 +5,8 @@ import { usePathname } from "next/navigation"; import Link from "next/link"; import Image from "next/image"; import { motion } from "motion/react"; +import { NotificationBell } from "./NotificationBell"; +import { RecommendationPanel } from "./RecommendationPanel"; export function Nav() { const pathname = usePathname(); @@ -61,7 +63,8 @@ export function Nav() { const isFavoritesActive = pathname.startsWith("/favorites"); return ( -