diff --git a/app/api/v1/hunts/route.ts b/app/api/v1/hunts/route.ts
index b394052ee..b90f56ab1 100644
--- a/app/api/v1/hunts/route.ts
+++ b/app/api/v1/hunts/route.ts
@@ -5,6 +5,7 @@ import { listPublicActiveHuntsByCursorOptimized } from "@/lib/db/queryOptimizer"
/**
* GET /api/v1/hunts
* List all public active hunts with cursor pagination.
+ * Supports category filtering: trending, new, nearby, featured
*/
export async function GET(req: Request) {
const ip = getIP(req);
@@ -22,6 +23,7 @@ export async function GET(req: Request) {
const reward = searchParams.get("reward") || "all";
const search = searchParams.get("search") || "";
const sortBy = searchParams.get("sortBy") || "newest";
+ const category = searchParams.get("category") || "new";
const requestId = req.headers.get("x-request-id") ?? undefined;
if (cursorParam && cursorParam !== "null" && cursorParam !== "" && (cursor == null || Number.isNaN(cursor))) {
@@ -35,6 +37,7 @@ export async function GET(req: Request) {
reward,
search,
sortBy,
+ category,
requestId,
});
@@ -46,5 +49,6 @@ export async function GET(req: Request) {
cursor,
nextCursor,
},
+ category,
});
}
diff --git a/app/feed/page.tsx b/app/feed/page.tsx
new file mode 100644
index 000000000..9eb06f53a
--- /dev/null
+++ b/app/feed/page.tsx
@@ -0,0 +1,45 @@
+import type { Metadata } from "next"
+import { HuntFeed } from "@/components/HuntFeed"
+
+export const metadata: Metadata = {
+ title: "Discover Hunts — Hunty",
+ description:
+ "Browse trending, new, nearby, and featured scavenger hunts on Hunty. Find your next adventure!",
+ openGraph: {
+ title: "Discover Hunts — Hunty",
+ description:
+ "Browse trending, new, nearby, and featured scavenger hunts on Hunty. Find your next adventure!",
+ },
+}
+
+export default function FeedPage() {
+ return (
+
+ {/* Header bar */}
+
+
+ {/* Feed content */}
+
+
+
+
+ )
+}
diff --git a/app/page.tsx b/app/page.tsx
index b4da2fa13..70499f4b9 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -25,6 +25,7 @@ import { usePlayerCounts } from "@/hooks/usePlayerCounts"
import { useRecentlyCompleted } from "@/hooks/useRecentlyCompleted"
import type { PlayerCountResult } from "@/lib/types"
import { queryCachePolicy, queryKeys } from "@/lib/queryKeys"
+import { Compass } from "lucide-react"
const OnboardingTour = dynamic(() => import("@/components/OnboardingTour"), {
ssr: false,
@@ -649,6 +650,12 @@ export default function GameArcade() {
>
Leaderboard
+
diff --git a/components/HuntFeed.tsx b/components/HuntFeed.tsx
new file mode 100644
index 000000000..6ea6d5c04
--- /dev/null
+++ b/components/HuntFeed.tsx
@@ -0,0 +1,519 @@
+"use client"
+
+import { useCallback, useEffect, useMemo, useRef, useState } from "react"
+import { useInfiniteQuery } from "@tanstack/react-query"
+import {
+ Flame,
+ Sparkles,
+ MapPin,
+ Star,
+ Compass,
+ RefreshCw,
+ Search,
+} from "lucide-react"
+import { cn } from "@/lib/utils"
+import { queryCachePolicy, queryKeys } from "@/lib/queryKeys"
+import { useRefreshByUser } from "@/useRefreshByUser"
+import { HuntFeedCard, HuntFeedCardGridSkeleton } from "@/components/HuntFeedCard"
+import { EmptyState } from "@/components/EmptyState"
+import type { StoredHunt, HuntFeedCategory } from "@/lib/types"
+
+// ─── Constants ───────────────────────────────────────────────────────────
+
+const CATEGORIES: {
+ key: HuntFeedCategory
+ label: string
+ icon: React.ReactNode
+ description: string
+}[] = [
+ {
+ key: "trending",
+ label: "Trending",
+ icon: ,
+ description: "Most popular hunts right now",
+ },
+ {
+ key: "new",
+ label: "New",
+ icon: ,
+ description: "Latest hunts added",
+ },
+ {
+ key: "nearby",
+ label: "Nearby",
+ icon: ,
+ description: "Hunts near your location",
+ },
+ {
+ key: "featured",
+ label: "Featured",
+ icon: ,
+ description: "Editor's picks this week",
+ },
+]
+
+const FEED_PAGE_SIZE = 12
+const SCROLL_THRESHOLD_PX = 300
+const PULL_REFRESH_THRESHOLD_PX = 80
+
+// Grid column mappings (static strings for Tailwind compatibility)
+const GRID_COLUMNS_SM: Record = {
+ "1": "sm:grid-cols-1",
+ "2": "sm:grid-cols-2",
+ "3": "sm:grid-cols-3",
+ "4": "sm:grid-cols-4",
+}
+
+const GRID_COLUMNS_MD: Record = {
+ "1": "md:grid-cols-1",
+ "2": "md:grid-cols-2",
+ "3": "md:grid-cols-3",
+ "4": "md:grid-cols-4",
+}
+
+const GRID_COLUMNS_LG: Record = {
+ "1": "lg:grid-cols-1",
+ "2": "lg:grid-cols-2",
+ "3": "lg:grid-cols-3",
+ "4": "lg:grid-cols-4",
+}
+
+const GRID_COLUMNS_XL: Record = {
+ "1": "xl:grid-cols-1",
+ "2": "xl:grid-cols-2",
+ "3": "xl:grid-cols-3",
+ "4": "xl:grid-cols-4",
+}
+
+// ─── Category Descriptions & Empty States ──────────────────────────────────
+
+const CATEGORY_EMPTY: Record = {
+ trending: {
+ title: "No trending hunts yet",
+ description: "Be the first to play and make a hunt trend! Create or join a hunt to get things started.",
+ icon: ,
+ },
+ new: {
+ title: "No new hunts available",
+ description: "There are no recently created hunts yet. Check back soon or create your own!",
+ icon: ,
+ },
+ nearby: {
+ title: "No nearby hunts found",
+ description: "There are no hunts near your location. Enable location access or check other categories.",
+ icon: ,
+ },
+ featured: {
+ title: "No featured hunts right now",
+ description: "Check back later for featured hunts picked by our editors.",
+ icon: ,
+ },
+}
+
+// ─── Geolocation helpers ──────────────────────────────────────────────────
+
+function requestLocationPermission(): Promise {
+ if (typeof window === "undefined" || !navigator.geolocation) return Promise.resolve(null)
+ return new Promise((resolve) => {
+ navigator.geolocation.getCurrentPosition(
+ (position) => resolve(position),
+ () => resolve(null),
+ { timeout: 5000, enableHighAccuracy: false }
+ )
+ })
+}
+
+// ─── Pull-to-refresh indicator ──────────────────────────────────────────────
+
+function PullToRefreshIndicator({
+ refreshing,
+ pullDistance,
+}: {
+ refreshing: boolean
+ pullDistance: number
+}) {
+ if (!refreshing && pullDistance <= 0) return null
+
+ return (
+
+
+
+ {refreshing
+ ? "Refreshing..."
+ : pullDistance >= PULL_REFRESH_THRESHOLD_PX
+ ? "Release to refresh"
+ : "Pull to refresh"}
+
+
+ )
+}
+
+// ─── Grid class builder ────────────────────────────────────────────────────
+
+function buildGridClasses(gridColumns?: {
+ sm?: number
+ md?: number
+ lg?: number
+ xl?: number
+}): string {
+ if (!gridColumns) return "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3"
+
+ // Always start with a base single-column layout as a safe fallback
+ const classes: string[] = ["grid-cols-1"]
+ if (gridColumns.sm) classes.push(GRID_COLUMNS_SM[String(gridColumns.sm)] ?? "sm:grid-cols-2")
+ if (gridColumns.md) classes.push(GRID_COLUMNS_MD[String(gridColumns.md)] ?? "md:grid-cols-2")
+ if (gridColumns.lg) classes.push(GRID_COLUMNS_LG[String(gridColumns.lg)] ?? "lg:grid-cols-3")
+ if (gridColumns.xl) classes.push(GRID_COLUMNS_XL[String(gridColumns.xl)] ?? "xl:grid-cols-4")
+
+ return classes.join(" ")
+}
+
+// ─── Main Component ────────────────────────────────────────────────────────
+
+interface HuntFeedProps {
+ /** Initial active category */
+ defaultCategory?: HuntFeedCategory
+ /** Optional class name */
+ className?: string
+ /** Whether to show the category navigation header */
+ showHeader?: boolean
+ /** Optional callback when category changes */
+ onCategoryChange?: (category: HuntFeedCategory) => void
+ /** Grid columns configuration */
+ gridColumns?: {
+ sm?: number
+ md?: number
+ lg?: number
+ xl?: number
+ }
+}
+
+export function HuntFeed({
+ defaultCategory = "trending",
+ className,
+ showHeader = true,
+ onCategoryChange,
+ gridColumns,
+}: HuntFeedProps) {
+ const [activeCategory, setActiveCategory] = useState(defaultCategory)
+ const [geoLoading, setGeoLoading] = useState(false)
+ const [geoError, setGeoError] = useState(null)
+
+ // Pull-to-refresh state
+ const [pullDistance, setPullDistance] = useState(0)
+ const [isPulling, setIsPulling] = useState(false)
+ const touchStartRef = useRef(0)
+ const feedContainerRef = useRef(null)
+
+ // ─── Infinite Query ────────────────────────────────────────────────────
+
+ const {
+ data: infiniteData,
+ fetchNextPage,
+ hasNextPage,
+ isFetchingNextPage,
+ isLoading: isLoadingHunts,
+ refetch,
+ } = useInfiniteQuery({
+ queryKey: queryKeys.hunts.feed(activeCategory),
+ queryFn: async ({ pageParam }) => {
+ const cursorVal = pageParam !== null ? String(pageParam) : ""
+
+ // For the "nearby" category, pass coordinates if available
+ let url = `/api/v1/hunts?limit=${FEED_PAGE_SIZE}&cursor=${cursorVal}&status=Active&category=${activeCategory}&sortBy=newest`
+
+ // Try to include geolocation for nearby
+ if (activeCategory === "nearby") {
+ try {
+ const pos = await requestLocationPermission()
+ if (pos) {
+ url += `&lat=${pos.coords.latitude}&lng=${pos.coords.longitude}`
+ }
+ } catch {
+ // Silently fall back to non-geo results
+ }
+ }
+
+ const res = await fetch(url)
+ if (!res.ok) {
+ throw new Error("Failed to fetch hunt feed")
+ }
+ return res.json() as Promise<{
+ data: StoredHunt[]
+ pagination: {
+ total: number
+ limit: number
+ cursor: number | null
+ nextCursor: number | null
+ }
+ category: string
+ }>
+ },
+ initialPageParam: null as number | null,
+ getNextPageParam: (lastPage) => lastPage.pagination.nextCursor,
+ staleTime: queryCachePolicy.hunts.staleTime,
+ gcTime: queryCachePolicy.hunts.gcTime,
+ })
+
+ // ─── Pull-to-refresh ───────────────────────────────────────────────────
+
+ const { isRefreshing, onRefresh } = useRefreshByUser(async () => {
+ await refetch()
+ })
+
+ // ─── Touch handlers for pull-to-refresh ─────────────────────────────────
+
+ const handleTouchStart = useCallback((e: React.TouchEvent) => {
+ if (feedContainerRef.current && feedContainerRef.current.scrollTop <= 0) {
+ touchStartRef.current = e.touches[0]?.clientY ?? 0
+ setIsPulling(true)
+ }
+ }, [])
+
+ const handleTouchMove = useCallback((e: React.TouchEvent) => {
+ if (!isPulling || isRefreshing) return
+ const currentY = e.touches[0]?.clientY ?? 0
+ const diff = currentY - touchStartRef.current
+ if (diff > 0) {
+ // Apply damping to make it feel more natural
+ setPullDistance(Math.min(diff * 0.5, PULL_REFRESH_THRESHOLD_PX * 1.5))
+ }
+ }, [isPulling, isRefreshing])
+
+ const handleTouchEnd = useCallback(() => {
+ if (pullDistance >= PULL_REFRESH_THRESHOLD_PX && !isRefreshing) {
+ onRefresh()
+ }
+ setPullDistance(0)
+ setIsPulling(false)
+ }, [pullDistance, isRefreshing, onRefresh])
+
+ // ─── Infinite scroll observer ──────────────────────────────────────────
+
+ const loadMoreRef = useRef(null)
+
+ useEffect(() => {
+ const target = loadMoreRef.current
+ if (!target || !hasNextPage || isFetchingNextPage || isRefreshing) return
+
+ const observer = new IntersectionObserver(
+ (entries) => {
+ if (entries[0]?.isIntersecting) {
+ fetchNextPage()
+ }
+ },
+ { rootMargin: `${SCROLL_THRESHOLD_PX}px` }
+ )
+
+ observer.observe(target)
+ return () => observer.disconnect()
+ }, [hasNextPage, isFetchingNextPage, fetchNextPage, isRefreshing])
+
+ // ─── Flatten pages ─────────────────────────────────────────────────────
+
+ const hunts = useMemo(() => {
+ if (!infiniteData) return []
+ return infiniteData.pages.flatMap((page) => page.data)
+ }, [infiniteData])
+
+ const totalResults = useMemo(() => {
+ return infiniteData?.pages[0]?.pagination.total ?? 0
+ }, [infiniteData])
+
+ // ─── Category change handler ───────────────────────────────────────────
+
+ const handleCategoryChange = useCallback(
+ (category: HuntFeedCategory) => {
+ setActiveCategory(category)
+ onCategoryChange?.(category)
+ // Reset scroll position
+ if (feedContainerRef.current) {
+ feedContainerRef.current.scrollTop = 0
+ }
+ window.scrollTo({ top: 0, behavior: "instant" })
+
+ // Trigger geolocation for nearby tab
+ if (category === "nearby") {
+ setGeoLoading(true)
+ setGeoError(null)
+ requestLocationPermission()
+ .then((pos) => {
+ if (!pos) {
+ setGeoError("Location access denied. Showing recent hunts instead.")
+ }
+ setGeoLoading(false)
+ })
+ .catch(() => {
+ setGeoError("Unable to get location. Showing recent hunts instead.")
+ setGeoLoading(false)
+ })
+ }
+ },
+ [onCategoryChange]
+ )
+
+ // ─── Active category info ──────────────────────────────────────────────
+
+ const activeCategoryInfo = CATEGORIES.find(
+ (cat) => cat.key === activeCategory
+ )
+ const emptyState = CATEGORY_EMPTY[activeCategory]
+
+ // ─── Grid classes ──────────────────────────────────────────────────────
+
+ const gridClasses = useMemo(() => buildGridClasses(gridColumns), [gridColumns])
+
+ // ─── Render ────────────────────────────────────────────────────────────
+
+ return (
+
+ {/* Header with category tabs */}
+ {showHeader && (
+
+
+
+
+
+ Discover Hunts
+
+
+ {totalResults > 0 && (
+
+ {totalResults} hunt{totalResults === 1 ? "" : "s"}
+
+ )}
+
+
+ {/* Category Tabs */}
+
+ {CATEGORIES.map((category) => {
+ const isActive = activeCategory === category.key
+ return (
+
+ )
+ })}
+
+
+ {/* Active category description */}
+ {activeCategoryInfo && (
+
+ {activeCategoryInfo.description}
+
+ )}
+
+ {/* Geo error message */}
+ {geoError && (
+
+ {geoError}
+
+ )}
+
+ )}
+
+ {/* Feed content */}
+
+ {/* Pull-to-refresh indicator */}
+
+
+ {/* Loading state */}
+ {isLoadingHunts && !isRefreshing ? (
+
+ ) : hunts.length === 0 ? (
+ /* Empty state */
+
+ ) : (
+ <>
+ {/* Hunt grid */}
+
+ {hunts.map((hunt) => (
+
+ ))}
+
+
+ {/* Loading more indicator */}
+
+ {isFetchingNextPage && (
+
+
+
+ )}
+
+
+ {/* End of results marker */}
+ {!hasNextPage && hunts.length > 0 && (
+
+
+
+ You've reached the end
+
+
+ )}
+ >
+ )}
+
+
+ )
+}
diff --git a/components/HuntFeedCard.tsx b/components/HuntFeedCard.tsx
new file mode 100644
index 000000000..3a780f693
--- /dev/null
+++ b/components/HuntFeedCard.tsx
@@ -0,0 +1,203 @@
+"use client"
+
+import { Trophy, MapPin, Clock, Users } from "lucide-react"
+import Link from "next/link"
+import { Button } from "@/components/ui/button"
+import { Card, CardDescription, CardTitle } from "@/components/ui/card"
+import { HuntCoverImage } from "@/components/HuntCoverImage"
+import type { StoredHunt } from "@/lib/types"
+import { cn } from "@/lib/utils"
+
+interface HuntFeedCardProps {
+ hunt: StoredHunt
+ /** Optional player count badge */
+ playerCount?: number
+ /** Whether this hunt is trending */
+ isTrending?: boolean
+ /** Whether the card is compact (list style) or full (grid style) */
+ compact?: boolean
+ /** Optional class name */
+ className?: string
+}
+
+function relativeTime(timestampSeconds: number): string {
+ const diffSeconds = Math.floor(Date.now() / 1000) - timestampSeconds
+ if (diffSeconds < 60) return "just now"
+ const diffMinutes = Math.floor(diffSeconds / 60)
+ if (diffMinutes < 60) return `${diffMinutes}m ago`
+ const diffHours = Math.floor(diffMinutes / 60)
+ if (diffHours < 24) return `${diffHours}h ago`
+ const diffDays = Math.floor(diffHours / 24)
+ if (diffDays < 7) return `${diffDays}d ago`
+ return new Date(timestampSeconds * 1000).toLocaleDateString()
+}
+
+export function HuntFeedCard({
+ hunt,
+ playerCount,
+ isTrending,
+ compact = false,
+ className,
+}: HuntFeedCardProps) {
+ const huntStatus = hunt.status === "Active" ? "Live" : hunt.status
+
+ return (
+
+
+ {/* Cover Image - hidden in compact mode */}
+ {!compact && (
+
+
+ {/* Status badge */}
+
+ {huntStatus}
+
+ {/* Trending badge */}
+ {isTrending && (
+
+ 🔥 Trending
+
+ )}
+
+ )}
+
+ {/* Content */}
+
+ {/* Title & Description */}
+
+
+
+ {hunt.title}
+
+
+
+ {hunt.description}
+
+
+
+ {/* Meta info */}
+
+ {/* Clues count */}
+
+
+ {hunt.cluesCount} {hunt.cluesCount === 1 ? "Clue" : "Clues"}
+
+
+ {/* Reward type */}
+
+
+ {hunt.rewardType}
+
+
+ {/* Player count */}
+ {playerCount !== undefined && (
+
+
+ {playerCount}
+
+ )}
+
+
+ {/* Time info and CTA */}
+
+ {hunt.startTime && (
+
+
+ {relativeTime(hunt.startTime)}
+
+ )}
+
+
+
+
+
+ )
+}
+
+export function HuntFeedCardSkeleton({ compact = false }: { compact?: boolean }) {
+ return (
+
+ {!compact && (
+
+ )}
+
+
+ )
+}
+
+export function HuntFeedCardGridSkeleton({ count = 6 }: { count?: number }) {
+ return (
+
+ {Array.from({ length: count }).map((_, index) => (
+
+ ))}
+
+ )
+}
diff --git a/lib/db/queryOptimizer.ts b/lib/db/queryOptimizer.ts
index 446781a88..30cf4951b 100644
--- a/lib/db/queryOptimizer.ts
+++ b/lib/db/queryOptimizer.ts
@@ -101,6 +101,7 @@ export function listPublicActiveHuntsByCursorOptimized(params: {
reward?: string | null
search?: string | null
sortBy?: string | null
+ category?: string | null
requestId?: string
}) {
const {
@@ -110,20 +111,22 @@ export function listPublicActiveHuntsByCursorOptimized(params: {
reward = "all",
search = "",
sortBy = "newest",
+ category = "new",
requestId,
} = params
trackPotentialNPlusOne("listPublicActiveHuntsByCursorOptimized", requestId)
return withTimedQuery(
"listPublicActiveHuntsByCursorOptimized",
- { cursor, limit, status, reward, search, sortBy },
+ { cursor, limit, status, reward, search, sortBy, category },
() => {
- const cacheKey = `active:${cursor ?? "start"}:${limit}:${status ?? "all"}:${reward ?? "all"}:${search ?? ""}:${sortBy ?? "newest"}`
+ const cacheKey = `active:${cursor ?? "start"}:${limit}:${status ?? "all"}:${reward ?? "all"}:${search ?? ""}:${sortBy ?? "newest"}:${category ?? "new"}`
const cached = readCache<{ data: StoredHunt[]; nextCursor: number | null; total: number }>(cacheKey)
if (cached) return cached
// Get all hunts (which already filters out private hunts)
const allHunts = getAllHunts()
+ const now = Math.floor(Date.now() / 1000)
// Filter hunts based on parameters
const filteredHunts = allHunts.filter((hunt) => {
@@ -150,16 +153,43 @@ export function listPublicActiveHuntsByCursorOptimized(params: {
hunt.title.toLowerCase().includes(search.toLowerCase()) ||
hunt.description.toLowerCase().includes(search.toLowerCase())
- return matchesStatus && matchesReward && matchesSearch
+ // Category filter:
+ let matchesCategory = true
+ if (category === "featured") {
+ matchesCategory = hunt.isFeaturedOfWeek === true && hunt.status === "Active"
+ } else if (category === "active") {
+ matchesCategory = hunt.status === "Active"
+ }
+
+ return matchesStatus && matchesReward && matchesSearch && matchesCategory
})
- // Sort hunts
+ // Sort hunts based on category
filteredHunts.sort((a, b) => {
+ if (category === "trending") {
+ // Trending: sort by player count descending, then clues
+ const aCount = a.playerCount ?? 0
+ const bCount = b.playerCount ?? 0
+ if (bCount !== aCount) return bCount - aCount
+ return b.cluesCount - a.cluesCount
+ }
+ if (category === "nearby") {
+ // Nearby: sort by recently active (startTime descending)
+ return (b.startTime ?? 0) - (a.startTime ?? 0)
+ }
+ if (category === "featured") {
+ // Featured: featured hunts first, then by startTime
+ const aFeatured = a.isFeaturedOfWeek ? 1 : 0
+ const bFeatured = b.isFeaturedOfWeek ? 1 : 0
+ if (bFeatured !== aFeatured) return bFeatured - aFeatured
+ return (b.startTime ?? 0) - (a.startTime ?? 0)
+ }
+ // New (default): sort by startTime descending
if (sortBy === "newest") return (b.startTime ?? 0) - (a.startTime ?? 0)
if (sortBy === "oldest") return (a.startTime ?? 0) - (b.startTime ?? 0)
if (sortBy === "clues-high") return b.cluesCount - a.cluesCount
if (sortBy === "clues-low") return a.cluesCount - b.cluesCount
- return 0
+ return (b.startTime ?? 0) - (a.startTime ?? 0)
})
// Apply cursor pagination
diff --git a/lib/queryKeys.ts b/lib/queryKeys.ts
index f463d5a0c..ebbc3c587 100644
--- a/lib/queryKeys.ts
+++ b/lib/queryKeys.ts
@@ -4,6 +4,7 @@ export const queryKeys = {
featured: () => ["hunts", "featured"] as const,
detail: (huntId: number | string) => ["hunts", "detail", String(huntId)] as const,
clues: (huntId: number | null | undefined) => ["hunts", "clues", huntId ?? "unknown"] as const,
+ feed: (category: string) => ["hunts", "feed", category] as const,
},
registration: {
status: (huntId: number | undefined, playerAddress: string | undefined) =>
diff --git a/lib/types.ts b/lib/types.ts
index 8a16b89af..c10dd6dbb 100644
--- a/lib/types.ts
+++ b/lib/types.ts
@@ -447,6 +447,10 @@ export interface SeasonBadge {
earnedAt: number
}
+// ─── Hunt Feed ───────────────────────────────────────────────────────────────
+
+export type HuntFeedCategory = "trending" | "new" | "nearby" | "featured"
+
// ─── Core Web Vitals ────────────────────────────────────────────────────────────
export type WebVitalMetric = "LCP" | "FID" | "CLS" | "TTFB" | "INP" | "FCP"