Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified public/images/hibento-vibes-01.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified public/images/hibento-vibes-03.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/images/hibento-vibes-04.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/images/hibento-vibes-05.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/images/hibento-vibes-06.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/images/hibento-vibes-07.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/images/hibento-vibes-08.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/images/hibento-vibes-09.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/images/hibento-vibes-10.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion src/app/api/ai/events/[eventId]/recommendations/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down
196 changes: 196 additions & 0 deletions src/app/api/ai/recommendations/global/route.ts
Original file line number Diff line number Diff line change
@@ -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<string> }
>();

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 },
);
}
}
4 changes: 2 additions & 2 deletions src/app/api/ai/search/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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);

Expand Down
10 changes: 9 additions & 1 deletion src/app/events/[eventId]/page.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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),
Expand All @@ -43,6 +45,12 @@ export default function EventDetailPage() {
const [statusFilter, setStatusFilter] = useState<StatusFilter>("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)[]>([]);
Expand Down
2 changes: 1 addition & 1 deletion src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,8 @@ export default function RootLayout({
<ProgressBar />
</Suspense>
<DotGrid />
<NavSelector />
<Providers>
<NavSelector />
<MainWrapper>{children}</MainWrapper>
</Providers>
</body>
Expand Down
5 changes: 4 additions & 1 deletion src/app/providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -21,7 +22,9 @@ export function Providers({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<ToastProvider>
<FavoritesProvider>{children}</FavoritesProvider>
<FavoritesProvider>
<NotificationProvider>{children}</NotificationProvider>
</FavoritesProvider>
</ToastProvider>
</QueryClientProvider>
);
Expand Down
10 changes: 9 additions & 1 deletion src/components/layouts/Nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -61,7 +63,8 @@ export function Nav() {
const isFavoritesActive = pathname.startsWith("/favorites");

return (
<nav className="sticky top-0 z-40 backdrop-blur border-b border-white/10">
<>
<nav className="sticky top-0 z-40 backdrop-blur border-b border-white/10">
<div className="max-w-7xl mx-auto px-6 py-4">
<div className="flex items-center justify-between">
<Link href="/" className="flex items-center h-7">
Expand Down Expand Up @@ -110,6 +113,8 @@ export function Nav() {
FAVORITES
</Link>

<NotificationBell />

<motion.div
className="absolute bottom-0 h-0.75 bg-chartreuse rounded-full"
animate={{
Expand All @@ -127,5 +132,8 @@ export function Nav() {
</div>
</div>
</nav>

<RecommendationPanel />
</>
);
}
21 changes: 21 additions & 0 deletions src/components/layouts/NotificationBell.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
"use client";

import { useNotifications } from "@/lib/hooks/NotificationContext";
import { Bell } from "lucide-react";

export function NotificationBell() {
const { hasNew, toggle } = useNotifications();

return (
<button
onClick={toggle}
className="relative p-2 -m-2 text-white/70 hover:text-white transition-colors"
aria-label={hasNew ? "New recommendations available" : "Recommendations"}
>
<Bell size={18} />
{hasNew && (
<span className="absolute top-0.5 right-0.5 w-2 h-2 rounded-full bg-red-500 ring-2 ring-[#0a0a0a]" />
)}
</button>
);
}
Loading