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 */} +
+
+
+
+ + + + +
+

+ Hunt Feed +

+
+
+
+ + {/* 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 ( +