diff --git a/.kilo/kilo.jsonc b/.kilo/kilo.jsonc
index d3e1b2d..0603291 100644
--- a/.kilo/kilo.jsonc
+++ b/.kilo/kilo.jsonc
@@ -1,3 +1,4 @@
{
+ "$schema": "https://app.kilo.ai/config.json",
"snapshot": false
}
\ No newline at end of file
diff --git a/app/api/v1/hunts/[id]/complete/route.ts b/app/api/v1/hunts/[id]/complete/route.ts
new file mode 100644
index 0000000..9984012
--- /dev/null
+++ b/app/api/v1/hunts/[id]/complete/route.ts
@@ -0,0 +1,39 @@
+import { NextResponse } from "next/server"
+import { readCompletions, writeCompletions } from "@/lib/reviews"
+
+/**
+ * POST /api/v1/hunts/[id]/complete
+ * Register that a player address has completed a hunt.
+ */
+export async function POST(
+ req: Request,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ try {
+ const { id } = await params
+ const huntId = parseInt(id, 10)
+
+ if (isNaN(huntId)) {
+ return NextResponse.json({ error: "Invalid hunt ID" }, { status: 400 })
+ }
+
+ const body = await req.json().catch(() => ({}))
+ const { playerAddress } = body
+
+ if (!playerAddress || typeof playerAddress !== "string" || playerAddress.trim() === "") {
+ return NextResponse.json({ error: "Invalid player address" }, { status: 400 })
+ }
+
+ const completions = await readCompletions()
+ if (!completions[huntId]) {
+ completions[huntId] = {}
+ }
+ completions[huntId][playerAddress] = true
+
+ await writeCompletions(completions)
+
+ return NextResponse.json({ success: true })
+ } catch (error: any) {
+ return NextResponse.json({ error: error.message || "Failed to register completion" }, { status: 500 })
+ }
+}
diff --git a/app/api/v1/hunts/[id]/reviews/[reviewId]/moderate/route.ts b/app/api/v1/hunts/[id]/reviews/[reviewId]/moderate/route.ts
new file mode 100644
index 0000000..1a54027
--- /dev/null
+++ b/app/api/v1/hunts/[id]/reviews/[reviewId]/moderate/route.ts
@@ -0,0 +1,66 @@
+import { NextResponse } from "next/server"
+import { readReviews, writeReviews } from "@/lib/reviews"
+import { getHuntById } from "@/lib/huntStore"
+
+/**
+ * POST /api/v1/hunts/[id]/reviews/[reviewId]/moderate
+ * Moderate a review. Action can be 'delete', 'flag', or 'unflag'.
+ * Enforces creator-only authorization.
+ */
+export async function POST(
+ req: Request,
+ { params }: { params: Promise<{ id: string; reviewId: string }> }
+) {
+ try {
+ const { id, reviewId } = await params
+ const huntId = parseInt(id, 10)
+
+ if (isNaN(huntId)) {
+ return NextResponse.json({ error: "Invalid hunt ID" }, { status: 400 })
+ }
+
+ const body = await req.json().catch(() => ({}))
+ const { action, moderatorAddress } = body
+
+ if (!moderatorAddress || typeof moderatorAddress !== "string" || moderatorAddress.trim() === "") {
+ return NextResponse.json({ error: "Moderator address is required" }, { status: 400 })
+ }
+
+ if (!["delete", "flag", "unflag"].includes(action)) {
+ return NextResponse.json({ error: "Invalid action. Must be 'delete', 'flag', or 'unflag'" }, { status: 400 })
+ }
+
+ // 1. Authorize: Only the hunt creator can moderate reviews
+ const hunt = getHuntById(huntId)
+ if (!hunt) {
+ return NextResponse.json({ error: "Hunt not found" }, { status: 404 })
+ }
+
+ const isCreator = !hunt.creator || hunt.creator === moderatorAddress
+ if (!isCreator) {
+ return NextResponse.json({ error: "Unauthorized: only the hunt creator can moderate reviews" }, { status: 403 })
+ }
+
+ // 2. Perform moderation on the target review
+ const reviews = await readReviews()
+ const review = reviews.find((r) => r.id === reviewId && r.huntId === huntId)
+
+ if (!review) {
+ return NextResponse.json({ error: "Review not found" }, { status: 404 })
+ }
+
+ if (action === "delete") {
+ review.moderated = true
+ } else if (action === "flag") {
+ review.flagged = true
+ } else if (action === "unflag") {
+ review.flagged = false
+ }
+
+ await writeReviews(reviews)
+
+ return NextResponse.json({ success: true, data: review })
+ } catch (error: any) {
+ return NextResponse.json({ error: error.message || "Failed to moderate review" }, { status: 500 })
+ }
+}
diff --git a/app/api/v1/hunts/[id]/reviews/route.ts b/app/api/v1/hunts/[id]/reviews/route.ts
new file mode 100644
index 0000000..a1477cf
--- /dev/null
+++ b/app/api/v1/hunts/[id]/reviews/route.ts
@@ -0,0 +1,95 @@
+import { NextResponse } from "next/server"
+import { readReviews, writeReviews, readCompletions } from "@/lib/reviews"
+import type { HuntReview } from "@/lib/types"
+
+/**
+ * GET /api/v1/hunts/[id]/reviews
+ * Get all active (non-moderated) reviews for a specific hunt.
+ */
+export async function GET(
+ req: Request,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ try {
+ const { id } = await params
+ const huntId = parseInt(id, 10)
+
+ if (isNaN(huntId)) {
+ return NextResponse.json({ error: "Invalid hunt ID" }, { status: 400 })
+ }
+
+ const reviews = await readReviews()
+ const huntReviews = reviews.filter((r) => r.huntId === huntId && !r.moderated)
+
+ return NextResponse.json({ data: huntReviews })
+ } catch (error: any) {
+ return NextResponse.json({ error: error.message || "Failed to retrieve reviews" }, { status: 500 })
+ }
+}
+
+/**
+ * POST /api/v1/hunts/[id]/reviews
+ * Submit a review for a specific hunt.
+ * Enforces:
+ * - One review per completed hunt per wallet.
+ * - Player address validation.
+ * - Star rating between 1 and 5.
+ * - Verification that player has completed the hunt.
+ */
+export async function POST(
+ req: Request,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ try {
+ const { id } = await params
+ const huntId = parseInt(id, 10)
+
+ if (isNaN(huntId)) {
+ return NextResponse.json({ error: "Invalid hunt ID" }, { status: 400 })
+ }
+
+ const body = await req.json().catch(() => ({}))
+ const { playerAddress, rating, text } = body
+
+ if (!playerAddress || typeof playerAddress !== "string" || playerAddress.trim() === "") {
+ return NextResponse.json({ error: "Player address is required" }, { status: 400 })
+ }
+
+ const ratingVal = parseInt(rating, 10)
+ if (isNaN(ratingVal) || ratingVal < 1 || ratingVal > 5) {
+ return NextResponse.json({ error: "Rating must be a number between 1 and 5" }, { status: 400 })
+ }
+
+ // 1. Enforce hunt completion verification
+ const completions = await readCompletions()
+ const completed = completions[huntId]?.[playerAddress] === true
+ if (!completed) {
+ return NextResponse.json({ error: "You must complete this hunt before submitting a review" }, { status: 403 })
+ }
+
+ // 2. Prevent duplicate reviews from the same wallet
+ const reviews = await readReviews()
+ const duplicate = reviews.some(
+ (r) => r.huntId === huntId && r.playerAddress.toLowerCase() === playerAddress.toLowerCase() && !r.moderated
+ )
+ if (duplicate) {
+ return NextResponse.json({ error: "You have already reviewed this hunt" }, { status: 400 })
+ }
+
+ const newReview: HuntReview = {
+ id: Math.random().toString(36).substring(2, 15) + Date.now().toString(36),
+ huntId,
+ playerAddress,
+ rating: ratingVal,
+ text: typeof text === "string" ? text.trim() : undefined,
+ createdAt: Date.now(),
+ }
+
+ reviews.push(newReview)
+ await writeReviews(reviews)
+
+ return NextResponse.json({ data: newReview })
+ } catch (error: any) {
+ return NextResponse.json({ error: error.message || "Failed to submit review" }, { status: 500 })
+ }
+}
diff --git a/app/hunt/[id]/share.tsx b/app/hunt/[id]/share.tsx
index ac795ec..af654fb 100644
--- a/app/hunt/[id]/share.tsx
+++ b/app/hunt/[id]/share.tsx
@@ -6,6 +6,7 @@ import { QrCode, Trophy } from "lucide-react";
import { QrCodeModal } from "@/components/QrCodeModal";
import { PlayGame } from "@/components/PlayGame";
import { GameCompleteModal } from "@/components/GameCompleteModal";
+import { HuntReviewsSection } from "@/components/HuntReviewsSection";
import type { StoredHunt } from "@/lib/types";
import { updateHuntStatus } from "@/lib/huntStore";
import { useRouter, useSearchParams } from "next/navigation";
@@ -319,6 +320,10 @@ export default function HuntShare({ hunt }: HuntDetailProps) {
/>
)}
+
+
+
+
);
}
diff --git a/app/page.tsx b/app/page.tsx
index b4da2fa..625721e 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 { StarRating } from "@/components/StarRating"
const OnboardingTour = dynamic(() => import("@/components/OnboardingTour"), {
ssr: false,
@@ -117,6 +118,7 @@ function ActiveHuntCard({
)}
+
{hunt.description}
@@ -328,6 +330,7 @@ function VirtualizedInactiveHuntsGrid({
{hunt.title}
+
{hunt.description}
@@ -368,7 +371,7 @@ export default function GameArcade() {
const [activeTab, setActiveTab] = useState<"leaderboard" | "none">("none")
const [rewardFilter, setRewardFilter] = useState<"all" | "XLM" | "NFT" | "Both">("all")
const [statusFilter, setStatusFilter] = useState<"all" | "Active" | "Completed">("Active")
- const [sortBy, setSortBy] = useState<"newest" | "oldest" | "clues-high" | "clues-low">("newest")
+ const [sortBy, setSortBy] = useState<"newest" | "oldest" | "clues-high" | "clues-low" | "rating-high">("newest")
const isLoadedRef = useRef(false)
@@ -832,12 +835,13 @@ export default function GameArcade() {
diff --git a/components/FeaturedHunts.tsx b/components/FeaturedHunts.tsx
index 9b08971..3dbde58 100644
--- a/components/FeaturedHunts.tsx
+++ b/components/FeaturedHunts.tsx
@@ -8,6 +8,7 @@ import { Skeleton } from "@/components/ui/skeleton"
import { HuntCoverImage } from "@/components/HuntCoverImage"
import { queryCachePolicy, queryKeys } from "@/lib/queryKeys"
import { cn } from "@/lib/utils"
+import { StarRating } from "@/components/StarRating"
function timeRemaining(endTime?: number): string {
if (!endTime) return ""
@@ -75,6 +76,7 @@ export function FeaturedHunts() {
{hunt.title}
+
([])
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
+
+ // Form State
+ const [rating, setRating] = useState(0)
+ const [hoverRating, setHoverRating] = useState(0)
+ const [text, setText] = useState("")
+ const [submitting, setSubmitting] = useState(false)
+ const [submitError, setSubmitError] = useState(null)
+ const [submitSuccess, setSubmitSuccess] = useState(false)
+
+ // Completion State (client-side pre-flight check)
+ const [hasCompleted, setHasCompleted] = useState(false)
+
+ const fetchReviews = useCallback(async () => {
+ try {
+ setLoading(true)
+ const res = await fetch(`/api/v1/hunts/${huntId}/reviews`)
+ if (!res.ok) throw new Error("Failed to load reviews")
+ const data = await res.json()
+ setReviews(data.data || [])
+ setError(null)
+ } catch (err: any) {
+ setError(err.message || "An error occurred while loading reviews")
+ } finally {
+ setLoading(false)
+ }
+ }, [huntId])
+
+ useEffect(() => {
+ void fetchReviews()
+ }, [fetchReviews])
+
+ useEffect(() => {
+ if (typeof window !== "undefined") {
+ const completedLocal = localStorage.getItem(`hunt_completed_${huntId}`) === "true"
+ setHasCompleted(completedLocal)
+ }
+ }, [huntId])
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault()
+ if (!connected || !publicKey) {
+ setSubmitError("Please connect your wallet first.")
+ return
+ }
+ if (rating === 0) {
+ setSubmitError("Please select a star rating.")
+ return
+ }
+
+ try {
+ setSubmitting(true)
+ setSubmitError(null)
+
+ // First make sure completion is registered on the server (just in case)
+ await fetch(`/api/v1/hunts/${huntId}/complete`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ playerAddress: publicKey }),
+ })
+
+ // Submit review
+ const res = await fetch(`/api/v1/hunts/${huntId}/reviews`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ playerAddress: publicKey,
+ rating,
+ text: text.trim() || undefined,
+ }),
+ })
+
+ const data = await res.json()
+ if (!res.ok) {
+ throw new Error(data.error || "Failed to submit review")
+ }
+
+ setSubmitSuccess(true)
+ setRating(0)
+ setText("")
+ await fetchReviews()
+ } catch (err: any) {
+ setSubmitError(err.message || "An error occurred while submitting your review")
+ } finally {
+ setSubmitting(false)
+ }
+ }
+
+ const handleModerate = async (reviewId: string, action: "delete" | "flag" | "unflag") => {
+ if (!connected || !publicKey) return
+
+ try {
+ const res = await fetch(`/api/v1/hunts/${huntId}/reviews/${reviewId}/moderate`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ action,
+ moderatorAddress: publicKey,
+ }),
+ })
+
+ if (!res.ok) {
+ const data = await res.json()
+ throw new Error(data.error || "Failed to moderate review")
+ }
+
+ await fetchReviews()
+ } catch (err: any) {
+ alert(err.message || "An error occurred during moderation")
+ }
+ }
+
+ const userHasReviewed = connected && publicKey && reviews.some(
+ (r) => r.playerAddress.toLowerCase() === publicKey.toLowerCase()
+ )
+
+ const isCreator = connected && publicKey && creatorAddress && publicKey.toLowerCase() === creatorAddress.toLowerCase()
+
+ const truncateAddress = (addr: string) => {
+ if (addr.length <= 10) return addr
+ return `${addr.slice(0, 6)}...${addr.slice(-4)}`
+ }
+
+ return (
+
+
+
+
+ Player Reviews & Ratings
+
+
+
+ {/* Review Submission Form */}
+ {connected && hasCompleted && !userHasReviewed && !submitSuccess && (
+
+ )}
+
+ {submitSuccess && (
+
+
+ ✓
+
+
Review Submitted Successfully!
+
Thank you for rating this scavenger hunt.
+
+ )}
+
+ {connected && !hasCompleted && (
+
+
+ Complete the hunt to unlock the ability to leave a rating and review!
+
+
+ )}
+
+ {!connected && (
+
+
+ Connect your wallet to leave reviews and view creator actions.
+
+
+ )}
+
+ {/* Reviews List */}
+
+ {loading ? (
+
+ {[1, 2].map((i) => (
+
+ ))}
+
+ ) : error ? (
+
{error}
+ ) : reviews.length === 0 ? (
+
+ No reviews yet. Be the first to leave a review!
+
+ ) : (
+
+ {reviews.map((review) => (
+
+
+
+ {/* User and Rating */}
+
+
+ {truncateAddress(review.playerAddress)}
+
+ {review.playerAddress.toLowerCase() === publicKey?.toLowerCase() && (
+
+ You
+
+ )}
+ •
+
+ {new Date(review.createdAt).toLocaleDateString()}
+
+
+
+ {/* Star Rating Display */}
+
+ {[1, 2, 3, 4, 5].map((star) => (
+
+ ))}
+
+
+ {/* Review text */}
+ {review.text && (
+
+ {review.text}
+
+ )}
+
+ {review.flagged && (
+
+
+ Flagged for moderation
+
+ )}
+
+
+ {/* Creator Moderation Actions */}
+ {isCreator && (
+
+
+
+
+ )}
+
+
+ ))}
+
+ )}
+
+
+ )
+}
diff --git a/components/PlayGame.tsx b/components/PlayGame.tsx
index a273dd1..9bec115 100644
--- a/components/PlayGame.tsx
+++ b/components/PlayGame.tsx
@@ -167,6 +167,13 @@ export function PlayGame({
}
if (huntId) {
localStorage.setItem(`hunt_completed_${huntId}`, "true");
+ if (playerAddress) {
+ fetch(`/api/v1/hunts/${huntId}/complete`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ playerAddress }),
+ }).catch((err) => console.error("Failed to register completion on server:", err));
+ }
}
if (playerAddress && attemptIdRef.current && huntId != null) {
const activeAttempt = getActiveAttempt(playerAddress, huntId);
diff --git a/components/StarRating.tsx b/components/StarRating.tsx
new file mode 100644
index 0000000..e0d6413
--- /dev/null
+++ b/components/StarRating.tsx
@@ -0,0 +1,25 @@
+"use client"
+
+import React from "react"
+import { Star } from "lucide-react"
+
+interface StarRatingProps {
+ rating?: number
+ count?: number
+ className?: string
+}
+
+export function StarRating({ rating, count, className = "" }: StarRatingProps) {
+ if (rating === undefined || rating === null || rating === 0) return null
+
+ return (
+
+
+ {rating.toFixed(1)}
+ {count !== undefined && ({count})}
+
+ )
+}
diff --git a/components/__tests__/Footer.test.tsx b/components/__tests__/Footer.test.tsx
index bd59cd1..b6578d5 100644
--- a/components/__tests__/Footer.test.tsx
+++ b/components/__tests__/Footer.test.tsx
@@ -65,9 +65,9 @@ describe("Footer", () => {
it("uses responsive layout classes", () => {
render();
- const footer = document.querySelector("footer");
- expect(footer?.className).toContain("sm:flex-row");
- expect(footer?.className).toContain("flex-col");
+ const container = document.querySelector("footer > div");
+ expect(container?.className).toContain("sm:flex-row");
+ expect(container?.className).toContain("flex-col");
});
it("has dark mode support classes", () => {
diff --git a/components/__tests__/walletConnect.test.ts b/components/__tests__/walletConnect.test.ts
index e5c769c..b1bb63e 100644
--- a/components/__tests__/walletConnect.test.ts
+++ b/components/__tests__/walletConnect.test.ts
@@ -9,20 +9,23 @@ import {
subscribeWalletConnect,
isWalletConnectConnected,
getActiveWalletConnectSession,
+ resetWalletConnect,
} from "@/lib/walletConnect"
-const mockWeb3Wallet = {
- init: vi.fn(),
- connect: vi.fn(),
- approveSession: vi.fn(),
- rejectSession: vi.fn(),
- disconnectSession: vi.fn(),
- request: vi.fn(),
- getActiveSessions: vi.fn(),
- on: vi.fn(),
-}
-
-const mockCore = vi.fn()
+const { mockCore, mockWeb3Wallet } = vi.hoisted(() => {
+ const mockWeb3Wallet = {
+ init: vi.fn(),
+ connect: vi.fn(),
+ approveSession: vi.fn(),
+ rejectSession: vi.fn(),
+ disconnectSession: vi.fn(),
+ request: vi.fn(),
+ getActiveSessions: vi.fn(),
+ on: vi.fn(),
+ };
+ const mockCore = vi.fn();
+ return { mockCore, mockWeb3Wallet };
+});
vi.mock("@walletconnect/core", () => ({
Core: mockCore,
@@ -58,6 +61,8 @@ describe("walletConnect core module", () => {
vi.clearAllMocks()
localStorage.clear()
vi.resetModules()
+ resetWalletConnect()
+ process.env.NEXT_PUBLIC_WC_PROJECT_ID = "test-project-id"
})
afterEach(() => {
diff --git a/hooks/__tests__/useFreighterWallet.test.ts b/hooks/__tests__/useFreighterWallet.test.ts
index 61a6281..053425f 100644
--- a/hooks/__tests__/useFreighterWallet.test.ts
+++ b/hooks/__tests__/useFreighterWallet.test.ts
@@ -6,6 +6,7 @@ import * as walletAdapter from "@/lib/walletAdapter"
import * as freighterApi from "@stellar/freighter-api"
let watchCallback: ((args: { address: string; network: string; networkPassphrase: string }) => void) | null = null
+const mockStop = vi.fn()
vi.mock("@/lib/walletAdapter", () => ({
connectWalletProvider: vi.fn(),
@@ -18,12 +19,12 @@ vi.mock("@stellar/freighter-api", () => ({
isConnected: vi.fn(),
getAddress: vi.fn(),
requestAccess: vi.fn(),
- WatchWalletChanges: vi.fn().mockImplementation(() => ({
- watch: (callback: typeof watchCallback) => {
+ WatchWalletChanges: vi.fn().mockImplementation(function (this: any) {
+ this.watch = (callback: typeof watchCallback) => {
watchCallback = callback
- },
- stop: vi.fn(),
- })),
+ }
+ this.stop = mockStop
+ }),
}))
const storage: Record = {}
@@ -37,8 +38,26 @@ const localStorageMock = {
function resetMocks() {
Object.keys(storage).forEach((key) => delete storage[key])
- vi.clearAllMocks()
watchCallback = null
+ mockStop.mockClear()
+
+ vi.mocked(walletAdapter.getStoredWalletSession).mockReset()
+ vi.mocked(walletAdapter.connectWalletProvider).mockReset()
+ vi.mocked(walletAdapter.setStoredWalletSession).mockReset()
+ vi.mocked(walletAdapter.clearStoredWalletSession).mockReset()
+
+ vi.mocked(freighterApi.isConnected).mockReset()
+ vi.mocked(freighterApi.getAddress).mockReset()
+ vi.mocked(freighterApi.requestAccess).mockReset()
+ vi.mocked(freighterApi.WatchWalletChanges).mockReset()
+
+ // Re-establish mock implementations
+ vi.mocked(freighterApi.WatchWalletChanges).mockImplementation(function (this: any) {
+ this.watch = (callback: any) => {
+ watchCallback = callback
+ }
+ this.stop = mockStop
+ })
}
describe("useFreighterWallet", () => {
@@ -170,7 +189,6 @@ describe("useFreighterWallet", () => {
const { unmount } = renderHook(() => useFreighterWallet())
unmount()
- const watchWalletChangesMock = vi.mocked(freighterApi.WatchWalletChanges)
- expect(watchWalletChangesMock().stop).toHaveBeenCalled()
+ expect(mockStop).toHaveBeenCalled()
})
})
diff --git a/hooks/__tests__/useXlmUsdPrice.test.ts b/hooks/__tests__/useXlmUsdPrice.test.ts
index a74318e..a7ae7ac 100644
--- a/hooks/__tests__/useXlmUsdPrice.test.ts
+++ b/hooks/__tests__/useXlmUsdPrice.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it, beforeEach, afterEach, vi } from "vitest"
-import { renderHook, act, waitFor } from "@testing-library/react"
+import { renderHook, waitFor } from "@testing-library/react"
import { useXlmUsdPrice } from "../useXlmUsdPrice"
const fetchMock = vi.fn()
@@ -16,9 +16,9 @@ describe("useXlmUsdPrice", () => {
})
it("loads the price from Coinbase successfully", async () => {
- fetchMock.mockResolvedValueOnce({ ok: true, json: async () => ({ data: { amount: "0.123" } }) })
+ fetchMock.mockResolvedValue({ ok: true, json: async () => ({ data: { amount: "0.123" } }) })
- const { result } = renderHook(() => useXlmUsdPrice(10))
+ const { result } = renderHook(() => useXlmUsdPrice(60000))
await waitFor(() => expect(result.current.isLoading).toBe(false))
@@ -30,9 +30,9 @@ describe("useXlmUsdPrice", () => {
it("falls back to CoinGecko when Coinbase fails", async () => {
fetchMock.mockRejectedValueOnce(new Error("coinbase down"))
- fetchMock.mockResolvedValueOnce({ ok: true, json: async () => ({ stellar: { usd: 0.456 } }) })
+ fetchMock.mockResolvedValue({ ok: true, json: async () => ({ stellar: { usd: 0.456 } }) })
- const { result } = renderHook(() => useXlmUsdPrice(10))
+ const { result } = renderHook(() => useXlmUsdPrice(60000))
await waitFor(() => expect(result.current.isLoading).toBe(false))
@@ -44,7 +44,7 @@ describe("useXlmUsdPrice", () => {
it("sets an error when both sources fail", async () => {
fetchMock.mockRejectedValue(new Error("network error"))
- const { result } = renderHook(() => useXlmUsdPrice(10))
+ const { result } = renderHook(() => useXlmUsdPrice(60000))
await waitFor(() => expect(result.current.isLoading).toBe(false))
@@ -55,16 +55,14 @@ describe("useXlmUsdPrice", () => {
it("polls again after the polling interval", async () => {
fetchMock.mockResolvedValueOnce({ ok: true, json: async () => ({ data: { amount: "0.789" } }) })
- fetchMock.mockResolvedValueOnce({ ok: true, json: async () => ({ data: { amount: "0.101" } }) })
+ fetchMock.mockResolvedValue({ ok: true, json: async () => ({ data: { amount: "0.101" } }) })
- const { result } = renderHook(() => useXlmUsdPrice(10))
+ const { result } = renderHook(() => useXlmUsdPrice(500))
- await waitFor(() => expect(result.current.isLoading).toBe(false))
- expect(result.current.price).toBe(0.789)
-
- await new Promise((resolve) => setTimeout(resolve, 20))
+ // 1. Wait for the initial load to succeed with 0.789
+ await waitFor(() => expect(result.current.price).toBe(0.789))
- await waitFor(() => expect(result.current.price).toBe(0.101))
- expect(fetchMock).toHaveBeenCalledTimes(2)
+ // 2. Wait for the interval to fire and update the price to 0.101
+ await waitFor(() => expect(result.current.price).toBe(0.101), { timeout: 2000 })
})
})
diff --git a/hooks/useXlmUsdPrice.ts b/hooks/useXlmUsdPrice.ts
index e0a9c41..8f9ffe4 100644
--- a/hooks/useXlmUsdPrice.ts
+++ b/hooks/useXlmUsdPrice.ts
@@ -69,7 +69,7 @@ export function useXlmUsdPrice(pollingMs = DEFAULT_POLLING_MS): XlmUsdPriceState
} catch (error) {
setState((prev) => ({
...prev,
- isLoading: prev.price == null,
+ isLoading: false,
error: error instanceof Error ? error.message : "Failed to fetch XLM/USD price",
}))
}
diff --git a/lib/__tests__/huntStore.test.ts b/lib/__tests__/huntStore.test.ts
index caa6b73..4bdce7d 100644
--- a/lib/__tests__/huntStore.test.ts
+++ b/lib/__tests__/huntStore.test.ts
@@ -211,7 +211,7 @@ describe("huntStore", () => {
};
addHunt(hunt);
const found = getHuntById(989);
- expect(found).toEqual(hunt);
+ expect(found).toEqual(expect.objectContaining(hunt));
});
it("returns undefined for non-existent hunt", () => {
@@ -232,7 +232,7 @@ describe("huntStore", () => {
};
addHunt(hunt);
const found = getHuntById(988);
- expect(found).toEqual(hunt);
+ expect(found).toEqual(expect.objectContaining(hunt));
});
it("does not add duplicate hunts with same ID", () => {
@@ -565,7 +565,7 @@ describe("huntStore", () => {
const stored = localStorage.getItem("hunty_hunts");
expect(stored).toBeTruthy();
const found = getHuntById(971);
- expect(found).toEqual(hunt);
+ expect(found).toEqual(expect.objectContaining(hunt));
});
it("handles corrupted localStorage gracefully", () => {
diff --git a/lib/__tests__/reviews.test.ts b/lib/__tests__/reviews.test.ts
new file mode 100644
index 0000000..043c3bb
--- /dev/null
+++ b/lib/__tests__/reviews.test.ts
@@ -0,0 +1,402 @@
+import { describe, it, expect, beforeEach, vi } from "vitest"
+import {
+ readReviewsSync,
+ writeReviewsSync,
+ readReviews,
+ writeReviews,
+ readCompletionsSync,
+ readCompletions,
+ writeCompletions,
+ getHuntsWithRatings,
+} from "../reviews"
+import { addHunt } from "../huntStore"
+import type { HuntReview, StoredHunt } from "../types"
+
+// Import route handlers
+import { POST as completePost } from "@/app/api/v1/hunts/[id]/complete/route"
+import { GET as reviewsGet, POST as reviewsPost } from "@/app/api/v1/hunts/[id]/reviews/route"
+import { POST as moderatePost } from "@/app/api/v1/hunts/[id]/reviews/[reviewId]/moderate/route"
+
+describe("Hunt Reviews System Helpers", () => {
+ beforeEach(() => {
+ localStorage.clear()
+ })
+
+ describe("Synchronous Storage", () => {
+ it("should read empty reviews when storage is empty", () => {
+ const reviews = readReviewsSync()
+ expect(reviews).toEqual([])
+ })
+
+ it("should write and read reviews correctly", () => {
+ const review: HuntReview = {
+ id: "r1",
+ huntId: 1,
+ playerAddress: "0xPlayer",
+ rating: 5,
+ text: "Amazing!",
+ createdAt: Date.now(),
+ }
+ writeReviewsSync([review])
+ const retrieved = readReviewsSync()
+ expect(retrieved).toEqual([review])
+ })
+
+ it("should read empty completions when storage is empty", () => {
+ const completions = readCompletionsSync()
+ expect(completions).toEqual({})
+ })
+ })
+
+ describe("Asynchronous Storage", () => {
+ it("should read empty reviews asynchronously", async () => {
+ const reviews = await readReviews()
+ expect(reviews).toEqual([])
+ })
+
+ it("should write and read reviews asynchronously", async () => {
+ const review: HuntReview = {
+ id: "r2",
+ huntId: 2,
+ playerAddress: "0xPlayer2",
+ rating: 4,
+ text: "Fun!",
+ createdAt: Date.now(),
+ }
+ await writeReviews([review])
+ const retrieved = await readReviews()
+ expect(retrieved).toEqual([review])
+ })
+
+ it("should read and write completions asynchronously", async () => {
+ const completions = {
+ 1: { "0xPlayer": true },
+ }
+ await writeCompletions(completions)
+ const retrieved = await readCompletions()
+ expect(retrieved).toEqual(completions)
+ })
+ })
+
+ describe("getHuntsWithRatings", () => {
+ const mockHunts: StoredHunt[] = [
+ {
+ id: 1,
+ title: "Hunt 1",
+ description: "Desc 1",
+ cluesCount: 2,
+ status: "Active",
+ rewardType: "XLM",
+ },
+ {
+ id: 2,
+ title: "Hunt 2",
+ description: "Desc 2",
+ cluesCount: 3,
+ status: "Active",
+ rewardType: "NFT",
+ },
+ ]
+
+ it("should return hunts with undefined ratings if no reviews exist", () => {
+ const result = getHuntsWithRatings(mockHunts)
+ expect(result[0].averageRating).toBeUndefined()
+ expect(result[0].reviewCount).toBe(0)
+ expect(result[1].averageRating).toBeUndefined()
+ expect(result[1].reviewCount).toBe(0)
+ })
+
+ it("should compute average rating and count correctly", () => {
+ const reviews: HuntReview[] = [
+ {
+ id: "1",
+ huntId: 1,
+ playerAddress: "p1",
+ rating: 5,
+ createdAt: Date.now(),
+ },
+ {
+ id: "2",
+ huntId: 1,
+ playerAddress: "p2",
+ rating: 4,
+ createdAt: Date.now(),
+ },
+ {
+ id: "3",
+ huntId: 2,
+ playerAddress: "p3",
+ rating: 3,
+ createdAt: Date.now(),
+ },
+ ]
+ writeReviewsSync(reviews)
+
+ const result = getHuntsWithRatings(mockHunts)
+ expect(result[0].averageRating).toBe(4.5)
+ expect(result[0].reviewCount).toBe(2)
+ expect(result[1].averageRating).toBe(3)
+ expect(result[1].reviewCount).toBe(1)
+ })
+
+ it("should exclude moderated reviews", () => {
+ const reviews: HuntReview[] = [
+ {
+ id: "1",
+ huntId: 1,
+ playerAddress: "p1",
+ rating: 5,
+ createdAt: Date.now(),
+ },
+ {
+ id: "2",
+ huntId: 1,
+ playerAddress: "p2",
+ rating: 1,
+ moderated: true,
+ createdAt: Date.now(),
+ },
+ ]
+ writeReviewsSync(reviews)
+
+ const result = getHuntsWithRatings(mockHunts)
+ expect(result[0].averageRating).toBe(5)
+ expect(result[0].reviewCount).toBe(1)
+ })
+ })
+})
+
+describe("Hunt Reviews System API Routes", () => {
+ beforeEach(() => {
+ localStorage.clear()
+ // Seed some hunts
+ const testHunt: StoredHunt = {
+ id: 100,
+ title: "Test Hunt",
+ description: "A test hunt",
+ cluesCount: 1,
+ status: "Active",
+ rewardType: "XLM",
+ creator: "0xCreator",
+ }
+ addHunt(testHunt)
+ })
+
+ describe("POST /api/v1/hunts/[id]/complete", () => {
+ it("should fail with 400 if playerAddress is missing", async () => {
+ const req = new Request("http://localhost/api/v1/hunts/100/complete", {
+ method: "POST",
+ body: JSON.stringify({}),
+ })
+ const res = await completePost(req, { params: Promise.resolve({ id: "100" }) })
+ expect(res.status).toBe(400)
+ const body = await res.json()
+ expect(body.error).toBe("Invalid player address")
+ })
+
+ it("should successfully record completion", async () => {
+ const req = new Request("http://localhost/api/v1/hunts/100/complete", {
+ method: "POST",
+ body: JSON.stringify({ playerAddress: "0xPlayerAddress" }),
+ })
+ const res = await completePost(req, { params: Promise.resolve({ id: "100" }) })
+ expect(res.status).toBe(200)
+ const body = await res.json()
+ expect(body.success).toBe(true)
+
+ const completions = await readCompletions()
+ expect(completions[100]["0xPlayerAddress"]).toBe(true)
+ })
+ })
+
+ describe("GET /api/v1/hunts/[id]/reviews", () => {
+ it("should return empty reviews list initially", async () => {
+ const req = new Request("http://localhost/api/v1/hunts/100/reviews", {
+ method: "GET",
+ })
+ const res = await reviewsGet(req, { params: Promise.resolve({ id: "100" }) })
+ expect(res.status).toBe(200)
+ const body = await res.json()
+ expect(body.data).toEqual([])
+ })
+
+ it("should return active reviews and filter moderated ones", async () => {
+ const review1: HuntReview = {
+ id: "r1",
+ huntId: 100,
+ playerAddress: "p1",
+ rating: 5,
+ text: "Nice",
+ createdAt: Date.now(),
+ }
+ const review2: HuntReview = {
+ id: "r2",
+ huntId: 100,
+ playerAddress: "p2",
+ rating: 1,
+ text: "Bad",
+ moderated: true,
+ createdAt: Date.now(),
+ }
+ const review3: HuntReview = {
+ id: "r3",
+ huntId: 101, // different hunt
+ playerAddress: "p3",
+ rating: 4,
+ createdAt: Date.now(),
+ }
+ await writeReviews([review1, review2, review3])
+
+ const req = new Request("http://localhost/api/v1/hunts/100/reviews", {
+ method: "GET",
+ })
+ const res = await reviewsGet(req, { params: Promise.resolve({ id: "100" }) })
+ expect(res.status).toBe(200)
+ const body = await res.json()
+ expect(body.data).toEqual([review1])
+ })
+ })
+
+ describe("POST /api/v1/hunts/[id]/reviews", () => {
+ it("should reject review if playerAddress is missing", async () => {
+ const req = new Request("http://localhost/api/v1/hunts/100/reviews", {
+ method: "POST",
+ body: JSON.stringify({ rating: 5 }),
+ })
+ const res = await reviewsPost(req, { params: Promise.resolve({ id: "100" }) })
+ expect(res.status).toBe(400)
+ const body = await res.json()
+ expect(body.error).toBe("Player address is required")
+ })
+
+ it("should reject review if rating is invalid", async () => {
+ const req = new Request("http://localhost/api/v1/hunts/100/reviews", {
+ method: "POST",
+ body: JSON.stringify({ playerAddress: "p1", rating: 6 }),
+ })
+ const res = await reviewsPost(req, { params: Promise.resolve({ id: "100" }) })
+ expect(res.status).toBe(400)
+ const body = await res.json()
+ expect(body.error).toBe("Rating must be a number between 1 and 5")
+ })
+
+ it("should reject review if hunt is not completed", async () => {
+ const req = new Request("http://localhost/api/v1/hunts/100/reviews", {
+ method: "POST",
+ body: JSON.stringify({ playerAddress: "p1", rating: 5 }),
+ })
+ const res = await reviewsPost(req, { params: Promise.resolve({ id: "100" }) })
+ expect(res.status).toBe(403)
+ const body = await res.json()
+ expect(body.error).toBe("You must complete this hunt before submitting a review")
+ })
+
+ it("should accept review if hunt is completed", async () => {
+ // Record completion
+ const completions = { 100: { p1: true } }
+ await writeCompletions(completions)
+
+ const req = new Request("http://localhost/api/v1/hunts/100/reviews", {
+ method: "POST",
+ body: JSON.stringify({ playerAddress: "p1", rating: 5, text: "Best ever!" }),
+ })
+ const res = await reviewsPost(req, { params: Promise.resolve({ id: "100" }) })
+ expect(res.status).toBe(200)
+ const body = await res.json()
+ expect(body.data.rating).toBe(5)
+ expect(body.data.text).toBe("Best ever!")
+ expect(body.data.playerAddress).toBe("p1")
+
+ // Verify stored review
+ const reviews = await readReviews()
+ expect(reviews.length).toBe(1)
+ expect(reviews[0].rating).toBe(5)
+ expect(reviews[0].text).toBe("Best ever!")
+ })
+
+ it("should prevent duplicate reviews from the same wallet", async () => {
+ // Record completion
+ const completions = { 100: { p1: true } }
+ await writeCompletions(completions)
+
+ // Submit first review
+ const req1 = new Request("http://localhost/api/v1/hunts/100/reviews", {
+ method: "POST",
+ body: JSON.stringify({ playerAddress: "p1", rating: 5 }),
+ })
+ await reviewsPost(req1, { params: Promise.resolve({ id: "100" }) })
+
+ // Try duplicate review
+ const req2 = new Request("http://localhost/api/v1/hunts/100/reviews", {
+ method: "POST",
+ body: JSON.stringify({ playerAddress: "p1", rating: 4 }),
+ })
+ const res = await reviewsPost(req2, { params: Promise.resolve({ id: "100" }) })
+ expect(res.status).toBe(400)
+ const body = await res.json()
+ expect(body.error).toBe("You have already reviewed this hunt")
+ })
+ })
+
+ describe("POST /api/v1/hunts/[id]/reviews/[reviewId]/moderate", () => {
+ const reviewId = "test-review-id"
+
+ beforeEach(async () => {
+ const review: HuntReview = {
+ id: reviewId,
+ huntId: 100,
+ playerAddress: "p1",
+ rating: 4,
+ createdAt: Date.now(),
+ }
+ await writeReviews([review])
+ })
+
+ it("should fail if moderator is not the creator", async () => {
+ const req = new Request("http://localhost/api/v1/hunts/100/reviews/test-review-id/moderate", {
+ method: "POST",
+ body: JSON.stringify({ action: "delete", moderatorAddress: "0xNotCreator" }),
+ })
+ const res = await moderatePost(req, {
+ params: Promise.resolve({ id: "100", reviewId }),
+ })
+ expect(res.status).toBe(403)
+ const body = await res.json()
+ expect(body.error).toBe("Unauthorized: only the hunt creator can moderate reviews")
+ })
+
+ it("should successfully flag a review if creator requests", async () => {
+ const req = new Request("http://localhost/api/v1/hunts/100/reviews/test-review-id/moderate", {
+ method: "POST",
+ body: JSON.stringify({ action: "flag", moderatorAddress: "0xCreator" }),
+ })
+ const res = await moderatePost(req, {
+ params: Promise.resolve({ id: "100", reviewId }),
+ })
+ expect(res.status).toBe(200)
+ const body = await res.json()
+ expect(body.success).toBe(true)
+ expect(body.data.flagged).toBe(true)
+
+ const stored = await readReviews()
+ expect(stored[0].flagged).toBe(true)
+ })
+
+ it("should successfully delete a review if creator requests", async () => {
+ const req = new Request("http://localhost/api/v1/hunts/100/reviews/test-review-id/moderate", {
+ method: "POST",
+ body: JSON.stringify({ action: "delete", moderatorAddress: "0xCreator" }),
+ })
+ const res = await moderatePost(req, {
+ params: Promise.resolve({ id: "100", reviewId }),
+ })
+ expect(res.status).toBe(200)
+ const body = await res.json()
+ expect(body.success).toBe(true)
+ expect(body.data.moderated).toBe(true)
+
+ const stored = await readReviews()
+ expect(stored[0].moderated).toBe(true)
+ })
+ })
+})
diff --git a/lib/contracts/rewardManager.ts b/lib/contracts/rewardManager.ts
index 4ecdb4c..2c133e9 100644
--- a/lib/contracts/rewardManager.ts
+++ b/lib/contracts/rewardManager.ts
@@ -30,6 +30,7 @@ export type RewardEscrow = {
}
const ESCROW_KEY = "hunty_reward_escrows"
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
const CLAIM_TIMEOUT_MS = 120_000
const MAX_RETRIES = 2
@@ -147,19 +148,39 @@ async function claimRewardInternal(huntId: number, signal?: AbortSignal): Promis
const signedXdr = await wallet.signTransaction(tx.toXDR())
- const receipt = getPlayerRewardReceipt(huntId, publicKey)
- if (!receipt) {
- throw new Error("No reward available to claim for this account")
- }
+ if (signal?.aborted) throw new ClaimTimeoutError()
const result = await server.submitTransaction(signedXdr)
- if (!result?.hash) throw new Error("Reward claim transaction failed")
+ if (!result?.hash) throw new Error("Reward transaction failed")
+
+ const rank = escrow.distributions.length + 1
+ const amount = getRewardForRank(escrow, rank)
+ if (amount <= 0) throw new Error("No reward available for this rank")
- return {
- txHash: result.hash,
- amount: receipt.amount,
- receipt,
+ const txHash = result.hash
+
+ const receipt: RewardReceipt = {
+ id: receiptId("distribution", huntId),
+ huntId,
+ type: "distribution",
+ txHash,
+ amount,
+ from: escrow.creator,
+ to: recipient,
+ rank,
+ createdAt: Date.now(),
+ }
+
+ const next: RewardEscrow = {
+ ...escrow,
+ balance: Math.max(0, escrow.balance - amount),
+ distributions: [...escrow.distributions, receipt],
}
+ saveEscrow(next)
+ localStorage.setItem(`hunt_reward_claimed_${huntId}`, "true")
+ localStorage.setItem(`hunt_reward_receipt_${huntId}_${recipient}`, JSON.stringify(receipt))
+
+ return { txHash, amount, receipt }
}
export function getRewardEscrow(huntId: number): RewardEscrow | null {
@@ -284,7 +305,6 @@ export async function distributeCompletionReward(
return { txHash, amount, receipt }
}
-
export function getPlayerRewardReceipt(huntId: number, playerAddress?: string): RewardReceipt | null {
if (!playerAddress || typeof window === "undefined") return null
try {
diff --git a/lib/db/queryOptimizer.ts b/lib/db/queryOptimizer.ts
index 446781a..ef809ce 100644
--- a/lib/db/queryOptimizer.ts
+++ b/lib/db/queryOptimizer.ts
@@ -1,4 +1,5 @@
import { getAllHunts, getHuntById, type StoredHunt } from "@/lib/huntStore"
+import { getHuntsWithRatings } from "@/lib/reviews"
type CacheEntry = {
value: T
@@ -61,7 +62,7 @@ function withTimedQuery(queryName: string, meta: Record, fn:
}
function buildHuntIndexes() {
- const hunts = getAllHunts()
+ const hunts = getHuntsWithRatings(getAllHunts())
const huntsById = new Map()
const activePublicHunts: StoredHunt[] = []
@@ -155,6 +156,14 @@ export function listPublicActiveHuntsByCursorOptimized(params: {
// Sort hunts
filteredHunts.sort((a, b) => {
+ if (sortBy === "rating-high") {
+ const ratingA = a.averageRating ?? 0
+ const ratingB = b.averageRating ?? 0
+ if (ratingB !== ratingA) {
+ return ratingB - ratingA
+ }
+ return (b.startTime ?? 0) - (a.startTime ?? 0)
+ }
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
diff --git a/lib/huntStore.ts b/lib/huntStore.ts
index 987b169..1353cbf 100644
--- a/lib/huntStore.ts
+++ b/lib/huntStore.ts
@@ -4,6 +4,7 @@
*/
import type { HuntStatus, StoredHunt, Clue } from "@/lib/types"
+import { getHuntsWithRatings } from "@/lib/reviews"
export type { HuntStatus, StoredHunt, Clue }
@@ -140,7 +141,7 @@ function writeHunts(hunts: StoredHunt[]): void {
/** All hunts (for Game Arcade: filter by status === "Active"). Private hunts are excluded. */
export function getAllHunts(): StoredHunt[] {
- return readHunts().filter((h) => !h.is_private)
+ return getHuntsWithRatings(readHunts().filter((h) => !h.is_private))
}
/** All hunts including private ones (for creator dashboard). */
@@ -213,7 +214,9 @@ export function archiveHunts(ids: number[]): void {
/** Get a single hunt by ID */
export function getHuntById(id: number): StoredHunt | undefined {
- return readHunts().find((h) => h.id === id)
+ const hunt = readHunts().find((h) => h.id === id)
+ if (!hunt) return undefined
+ return getHuntsWithRatings([hunt])[0]
}
/** Get reward-pool related data for a hunt. */
diff --git a/lib/reviews.ts b/lib/reviews.ts
new file mode 100644
index 0000000..b006041
--- /dev/null
+++ b/lib/reviews.ts
@@ -0,0 +1,186 @@
+import { promises as fs } from "fs"
+import fsSync from "fs"
+import path from "path"
+import type { StoredHunt, HuntReview } from "./types"
+
+const REVIEWS_STORE_PATH = path.join(process.cwd(), "data", "reviews.json")
+const COMPLETIONS_STORE_PATH = path.join(process.cwd(), "data", "completions.json")
+
+function ensureStoreFileSync(filePath: string): void {
+ const dir = path.dirname(filePath)
+ if (!fsSync.existsSync(dir)) {
+ fsSync.mkdirSync(dir, { recursive: true })
+ }
+ if (!fsSync.existsSync(filePath)) {
+ fsSync.writeFileSync(filePath, JSON.stringify([], null, 2), "utf8")
+ }
+}
+
+async function ensureStoreFile(filePath: string): Promise {
+ const dir = path.dirname(filePath)
+ await fs.mkdir(dir, { recursive: true })
+ try {
+ await fs.access(filePath)
+ } catch {
+ await fs.writeFile(filePath, JSON.stringify([], null, 2), "utf8")
+ }
+}
+
+export function readReviewsSync(): HuntReview[] {
+ if (typeof window !== "undefined") {
+ try {
+ const raw = localStorage.getItem("hunty_reviews")
+ return raw ? JSON.parse(raw) : []
+ } catch {
+ return []
+ }
+ }
+ try {
+ ensureStoreFileSync(REVIEWS_STORE_PATH)
+ const raw = fsSync.readFileSync(REVIEWS_STORE_PATH, "utf8")
+ const parsed = JSON.parse(raw)
+ return Array.isArray(parsed) ? parsed : []
+ } catch {
+ return []
+ }
+}
+
+export function writeReviewsSync(reviews: HuntReview[]): void {
+ if (typeof window !== "undefined") {
+ localStorage.setItem("hunty_reviews", JSON.stringify(reviews))
+ return
+ }
+ ensureStoreFileSync(REVIEWS_STORE_PATH)
+ fsSync.writeFileSync(REVIEWS_STORE_PATH, JSON.stringify(reviews, null, 2), "utf8")
+}
+
+export async function readReviews(): Promise {
+ if (typeof window !== "undefined") {
+ return readReviewsSync()
+ }
+ try {
+ await ensureStoreFile(REVIEWS_STORE_PATH)
+ const raw = await fs.readFile(REVIEWS_STORE_PATH, "utf8")
+ const parsed = JSON.parse(raw)
+ return Array.isArray(parsed) ? parsed : []
+ } catch {
+ return []
+ }
+}
+
+export async function writeReviews(reviews: HuntReview[]): Promise {
+ if (typeof window !== "undefined") {
+ writeReviewsSync(reviews)
+ return
+ }
+ await ensureStoreFile(REVIEWS_STORE_PATH)
+ await fs.writeFile(REVIEWS_STORE_PATH, JSON.stringify(reviews, null, 2), "utf8")
+}
+
+// Completions Store: structured as Record> (huntId -> playerAddress -> completed)
+export function readCompletionsSync(): Record> {
+ if (typeof window !== "undefined") {
+ try {
+ const raw = localStorage.getItem("hunty_completions")
+ if (raw) {
+ const parsed = JSON.parse(raw)
+ if (parsed && typeof parsed === "object") {
+ return parsed
+ }
+ }
+ } catch {}
+
+ // Fallback to legacy hunt_completed_ keys for backward compatibility
+ const result: Record> = {}
+ try {
+ for (let i = 0; i < localStorage.length; i++) {
+ const key = localStorage.key(i)
+ if (key && key.startsWith("hunt_completed_")) {
+ const huntId = Number(key.replace("hunt_completed_", ""))
+ if (Number.isInteger(huntId)) {
+ if (!result[huntId]) result[huntId] = {}
+ result[huntId]["self"] = true
+ }
+ }
+ }
+ } catch {}
+ return result
+ }
+ try {
+ const filePath = COMPLETIONS_STORE_PATH
+ const dir = path.dirname(filePath)
+ if (!fsSync.existsSync(dir)) {
+ fsSync.mkdirSync(dir, { recursive: true })
+ }
+ if (!fsSync.existsSync(filePath)) {
+ fsSync.writeFileSync(filePath, JSON.stringify({}, null, 2), "utf8")
+ }
+ const raw = fsSync.readFileSync(filePath, "utf8")
+ const parsed = JSON.parse(raw)
+ return parsed && typeof parsed === "object" ? parsed : {}
+ } catch {
+ return {}
+ }
+}
+
+export async function readCompletions(): Promise>> {
+ if (typeof window !== "undefined") {
+ return readCompletionsSync()
+ }
+ try {
+ const filePath = COMPLETIONS_STORE_PATH
+ const dir = path.dirname(filePath)
+ await fs.mkdir(dir, { recursive: true })
+ try {
+ await fs.access(filePath)
+ } catch {
+ await fs.writeFile(filePath, JSON.stringify({}, null, 2), "utf8")
+ }
+ const raw = await fs.readFile(filePath, "utf8")
+ const parsed = JSON.parse(raw)
+ return parsed && typeof parsed === "object" ? parsed : {}
+ } catch {
+ return {}
+ }
+}
+
+export async function writeCompletions(completions: Record>): Promise {
+ if (typeof window !== "undefined") {
+ localStorage.setItem("hunty_completions", JSON.stringify(completions))
+ return
+ }
+ const filePath = COMPLETIONS_STORE_PATH
+ const dir = path.dirname(filePath)
+ await fs.mkdir(dir, { recursive: true })
+ await fs.writeFile(filePath, JSON.stringify(completions, null, 2), "utf8")
+}
+
+export function getHuntsWithRatings(hunts: StoredHunt[]): StoredHunt[] {
+ const reviews = readReviewsSync()
+ const huntRatings: Record = {}
+
+ for (const review of reviews) {
+ if (review.moderated) continue
+ if (!huntRatings[review.huntId]) {
+ huntRatings[review.huntId] = { sum: 0, count: 0 }
+ }
+ huntRatings[review.huntId].sum += review.rating
+ huntRatings[review.huntId].count += 1
+ }
+
+ return hunts.map(hunt => {
+ const agg = huntRatings[hunt.id]
+ if (agg && agg.count > 0) {
+ return {
+ ...hunt,
+ averageRating: Math.round((agg.sum / agg.count) * 10) / 10,
+ reviewCount: agg.count,
+ }
+ }
+ return {
+ ...hunt,
+ averageRating: undefined,
+ reviewCount: 0,
+ }
+ })
+}
diff --git a/lib/types.ts b/lib/types.ts
index 8a16b89..735b472 100644
--- a/lib/types.ts
+++ b/lib/types.ts
@@ -7,6 +7,17 @@ import type { ReactNode } from "react"
// ─── Hunt ────────────────────────────────────────────────────────────────────
+export interface HuntReview {
+ id: string
+ huntId: number
+ playerAddress: string
+ rating: number // 1 to 5
+ text?: string
+ createdAt: number
+ moderated?: boolean
+ flagged?: boolean
+}
+
export type HuntStatus = "Active" | "Completed" | "Draft" | "Cancelled"
export interface StoredHunt {
@@ -40,6 +51,15 @@ export interface StoredHunt {
coverImageCid?: string
/** Active editorial banner showcase at the top of the Arcade. */
isFeaturedOfWeek?: boolean
+ /** Creator's wallet public key */
+ creator?: string
+ /** Average user rating (1-5) */
+ averageRating?: number
+ /** Number of user reviews */
+ reviewCount?: number
+ poolBalance?: number
+ rewardDistribution?: Reward[]
+ poolLowBalanceThreshold?: number
}
export type HuntInfo = {
diff --git a/lib/walletAdapter.ts b/lib/walletAdapter.ts
index 5d35a63..f42d7a8 100644
--- a/lib/walletAdapter.ts
+++ b/lib/walletAdapter.ts
@@ -59,7 +59,7 @@ function parseSession(value: string | null): WalletSession | null {
parsed.provider === "rabet" ||
parsed.provider === "xbull" ||
parsed.provider === "lobstr") &&
- typeof parsed.publicKey === "string"
+ typeof parsed.publicKey === "string" && parsed.publicKey.trim().length > 0
) {
return parsed
}
diff --git a/package-lock.json b/package-lock.json
index 65a1b34..84bf06c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -59,6 +59,7 @@
"@stryker-mutator/core": "^9.6.1",
"@stryker-mutator/vitest-runner": "^9.6.1",
"@tailwindcss/postcss": "^4",
+ "@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
@@ -7195,9 +7196,9 @@
}
},
"node_modules/@tailwindcss/node": {
- "version": "4.3.1",
- "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.1.tgz",
- "integrity": "sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A==",
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.2.tgz",
+ "integrity": "sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==",
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.5",
@@ -7206,36 +7207,36 @@
"lightningcss": "1.32.0",
"magic-string": "^0.30.21",
"source-map-js": "^1.2.1",
- "tailwindcss": "4.3.1"
+ "tailwindcss": "4.3.2"
}
},
"node_modules/@tailwindcss/oxide": {
- "version": "4.3.1",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.1.tgz",
- "integrity": "sha512-yVPyo8RNkabVr3O2EhHEE0Rewu7YKzc1DhIqfL46LKveFrmu9XbDazNOJY7/GRuvw1h6u3utWnR29H/p5JPlgA==",
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.2.tgz",
+ "integrity": "sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag==",
"license": "MIT",
"engines": {
"node": ">= 20"
},
"optionalDependencies": {
- "@tailwindcss/oxide-android-arm64": "4.3.1",
- "@tailwindcss/oxide-darwin-arm64": "4.3.1",
- "@tailwindcss/oxide-darwin-x64": "4.3.1",
- "@tailwindcss/oxide-freebsd-x64": "4.3.1",
- "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.1",
- "@tailwindcss/oxide-linux-arm64-gnu": "4.3.1",
- "@tailwindcss/oxide-linux-arm64-musl": "4.3.1",
- "@tailwindcss/oxide-linux-x64-gnu": "4.3.1",
- "@tailwindcss/oxide-linux-x64-musl": "4.3.1",
- "@tailwindcss/oxide-wasm32-wasi": "4.3.1",
- "@tailwindcss/oxide-win32-arm64-msvc": "4.3.1",
- "@tailwindcss/oxide-win32-x64-msvc": "4.3.1"
+ "@tailwindcss/oxide-android-arm64": "4.3.2",
+ "@tailwindcss/oxide-darwin-arm64": "4.3.2",
+ "@tailwindcss/oxide-darwin-x64": "4.3.2",
+ "@tailwindcss/oxide-freebsd-x64": "4.3.2",
+ "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.2",
+ "@tailwindcss/oxide-linux-arm64-gnu": "4.3.2",
+ "@tailwindcss/oxide-linux-arm64-musl": "4.3.2",
+ "@tailwindcss/oxide-linux-x64-gnu": "4.3.2",
+ "@tailwindcss/oxide-linux-x64-musl": "4.3.2",
+ "@tailwindcss/oxide-wasm32-wasi": "4.3.2",
+ "@tailwindcss/oxide-win32-arm64-msvc": "4.3.2",
+ "@tailwindcss/oxide-win32-x64-msvc": "4.3.2"
}
},
"node_modules/@tailwindcss/oxide-android-arm64": {
- "version": "4.3.1",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.1.tgz",
- "integrity": "sha512-SVlyf61g374l5cHyg8x9kf5xmLcOaxvOTsbsqDnSsDJaKOEFZ7GCvi84VAVGpxojYOs1+3K6M0UjXfqPU8vmOQ==",
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.2.tgz",
+ "integrity": "sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==",
"cpu": [
"arm64"
],
@@ -7249,9 +7250,9 @@
}
},
"node_modules/@tailwindcss/oxide-darwin-arm64": {
- "version": "4.3.1",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.1.tgz",
- "integrity": "sha512-hVnWLwv+e/l7c4WKyVtHVrIPvYdqWHjRB3MDIqARynzFtnQg85kmQEFCbV9Ja0VVx4xXTIiDWY60Y7iz/iNoDA==",
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.2.tgz",
+ "integrity": "sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==",
"cpu": [
"arm64"
],
@@ -7265,9 +7266,9 @@
}
},
"node_modules/@tailwindcss/oxide-darwin-x64": {
- "version": "4.3.1",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.1.tgz",
- "integrity": "sha512-Cf7abu0WVgbhU7ANgPUnSAvm7nCvMweusHb8FnaHlLfv/Caq4GYaEZg7ZImzzmjx4lIAfuS8q+eLIS7A7IzxIg==",
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.2.tgz",
+ "integrity": "sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==",
"cpu": [
"x64"
],
@@ -7281,9 +7282,9 @@
}
},
"node_modules/@tailwindcss/oxide-freebsd-x64": {
- "version": "4.3.1",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.1.tgz",
- "integrity": "sha512-ZZqzX2Y+GXtXXfqSfpJhDm60OoZfvLHLCgm+J7NVqgHHJjG/m9ugZI77RwTsVd4fnBJuCFP6Ae6kTJb71UdS8g==",
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.2.tgz",
+ "integrity": "sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==",
"cpu": [
"x64"
],
@@ -7297,9 +7298,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
- "version": "4.3.1",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.1.tgz",
- "integrity": "sha512-/Ah/xik0LaMYfv9DZ0S/t4pBlBNYOcqtRwusjgovHkvT8ixueWCLyJjsaF5kQIckjb4IT8Q6K6p/iPmZMixYgg==",
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.2.tgz",
+ "integrity": "sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==",
"cpu": [
"arm"
],
@@ -7313,9 +7314,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
- "version": "4.3.1",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.1.tgz",
- "integrity": "sha512-gqdFoVJlw444GvpnheZLHmvTzSxI/cOUUh2KSNejQjTcYkW062SVD+En0rUgD+QV91bz1XGIGtt1HJd48xUGbQ==",
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.2.tgz",
+ "integrity": "sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==",
"cpu": [
"arm64"
],
@@ -7332,9 +7333,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
- "version": "4.3.1",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.1.tgz",
- "integrity": "sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA==",
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.2.tgz",
+ "integrity": "sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==",
"cpu": [
"arm64"
],
@@ -7351,9 +7352,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
- "version": "4.3.1",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.1.tgz",
- "integrity": "sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg==",
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.2.tgz",
+ "integrity": "sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==",
"cpu": [
"x64"
],
@@ -7370,9 +7371,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
- "version": "4.3.1",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.1.tgz",
- "integrity": "sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ==",
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.2.tgz",
+ "integrity": "sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw==",
"cpu": [
"x64"
],
@@ -7389,9 +7390,9 @@
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
- "version": "4.3.1",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.1.tgz",
- "integrity": "sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA==",
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.2.tgz",
+ "integrity": "sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw==",
"bundleDependencies": [
"@napi-rs/wasm-runtime",
"@emnapi/core",
@@ -7406,9 +7407,9 @@
"license": "MIT",
"optional": true,
"dependencies": {
- "@emnapi/core": "^1.10.0",
- "@emnapi/runtime": "^1.10.0",
- "@emnapi/wasi-threads": "^1.2.1",
+ "@emnapi/core": "^1.11.1",
+ "@emnapi/runtime": "^1.11.1",
+ "@emnapi/wasi-threads": "^1.2.2",
"@napi-rs/wasm-runtime": "^1.1.4",
"@tybys/wasm-util": "^0.10.2",
"tslib": "^2.8.1"
@@ -7418,9 +7419,9 @@
}
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
- "version": "4.3.1",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.1.tgz",
- "integrity": "sha512-aiNvSq9BsVk8V513lDKlrCFAgf8qBMPZTpgEhInL+NwQqs97mYmupVMrPrgBBSL8Pv/0zXu9MrMF9rMun1ZeNg==",
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.2.tgz",
+ "integrity": "sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ==",
"cpu": [
"arm64"
],
@@ -7434,9 +7435,9 @@
}
},
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
- "version": "4.3.1",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.1.tgz",
- "integrity": "sha512-xDEyu1rg290472FEGaKHnzyDyh5QH+AlWvsU5hMoMtPpzmKlRI0jaYKCgSHDYtaQWZOYbMaduSyCwFwY4n1HmA==",
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.2.tgz",
+ "integrity": "sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ==",
"cpu": [
"x64"
],
@@ -7450,28 +7451,28 @@
}
},
"node_modules/@tailwindcss/postcss": {
- "version": "4.3.1",
- "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.3.1.tgz",
- "integrity": "sha512-dNJuNbdEJT/SWRuXTYP1WSamelsz3ztkUsdtWQPjrexysrTpaEPM40P/71knXiXLYEojqPOEGitVLLpPMS5T6A==",
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.3.2.tgz",
+ "integrity": "sha512-rjVWYCa7Ngbi5AarT6k8TkxUG3Wl1QKzHdIZVsjZSzf36Jmo2IKZt/NHRAwly8oDkbBOH0YTu+CHuf9jPxMc+g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
- "@tailwindcss/node": "4.3.1",
- "@tailwindcss/oxide": "4.3.1",
- "postcss": "8.5.15",
- "tailwindcss": "4.3.1"
+ "@tailwindcss/node": "4.3.2",
+ "@tailwindcss/oxide": "4.3.2",
+ "postcss": "^8.5.15",
+ "tailwindcss": "4.3.2"
}
},
"node_modules/@tailwindcss/vite": {
- "version": "4.3.1",
- "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.1.tgz",
- "integrity": "sha512-hItDHuIIlEV61R+faXu66s1K36aTurO/Qw0e45Vskz57gXl9pWOT6eg3zmcEui6CZXddbN7zd41bwmvag4JGwQ==",
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.2.tgz",
+ "integrity": "sha512-eHpMeX4JXfVNJDEcsouTeCBubJBTcTLigeaw/NTUW6PB5ATKKXdyonnXgTBX2VuRbjz1hjfz6C5XAhr52ImQXA==",
"license": "MIT",
"dependencies": {
- "@tailwindcss/node": "4.3.1",
- "@tailwindcss/oxide": "4.3.1",
- "tailwindcss": "4.3.1"
+ "@tailwindcss/node": "4.3.2",
+ "@tailwindcss/oxide": "4.3.2",
+ "tailwindcss": "4.3.2"
},
"peerDependencies": {
"vite": "^5.2.0 || ^6 || ^7 || ^8"
@@ -7530,6 +7531,43 @@
"url": "https://github.com/sponsors/tannerlinsley"
}
},
+ "node_modules/@testing-library/dom": {
+ "version": "10.4.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
+ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.10.4",
+ "@babel/runtime": "^7.12.5",
+ "@types/aria-query": "^5.0.1",
+ "aria-query": "5.3.0",
+ "dom-accessibility-api": "^0.5.9",
+ "lz-string": "^1.5.0",
+ "picocolors": "1.1.1",
+ "pretty-format": "^27.0.2"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@testing-library/dom/node_modules/aria-query": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
+ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "dequal": "^2.0.3"
+ }
+ },
+ "node_modules/@testing-library/dom/node_modules/dom-accessibility-api": {
+ "version": "0.5.16",
+ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
+ "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@testing-library/jest-dom": {
"version": "6.9.1",
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
@@ -7648,6 +7686,13 @@
"tslib": "^2.4.0"
}
},
+ "node_modules/@types/aria-query": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
+ "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -10128,6 +10173,16 @@
"node": ">= 0.6"
}
},
+ "node_modules/dequal": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
+ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/des.js": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/des.js/-/des.js-1.1.0.tgz",
@@ -11216,9 +11271,9 @@
}
},
"node_modules/fast-uri": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
- "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz",
+ "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==",
"funding": [
{
"type": "github",
@@ -13438,6 +13493,16 @@
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
+ "node_modules/lz-string": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
+ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "lz-string": "bin/bin.js"
+ }
+ },
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -15103,9 +15168,9 @@
"license": "MIT-0"
},
"node_modules/postcss": {
- "version": "8.5.15",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
- "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
+ "version": "8.5.16",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
+ "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==",
"dev": true,
"funding": [
{
@@ -15153,6 +15218,51 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/pretty-format": {
+ "version": "27.5.1",
+ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
+ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1",
+ "ansi-styles": "^5.0.0",
+ "react-is": "^17.0.1"
+ },
+ "engines": {
+ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+ }
+ },
+ "node_modules/pretty-format/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/pretty-format/node_modules/ansi-styles": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
+ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/pretty-format/node_modules/react-is": {
+ "version": "17.0.2",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
+ "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/pretty-ms": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz",
@@ -16790,9 +16900,9 @@
}
},
"node_modules/tailwindcss": {
- "version": "4.3.1",
- "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.1.tgz",
- "integrity": "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==",
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.2.tgz",
+ "integrity": "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==",
"license": "MIT"
},
"node_modules/tapable": {
diff --git a/package.json b/package.json
index ddac57c..b313170 100644
--- a/package.json
+++ b/package.json
@@ -75,6 +75,7 @@
"@stryker-mutator/vitest-runner": "^9.6.1",
"@tailwindcss/postcss": "^4",
"@tailwindcss/vite": "^4.3.2",
+ "@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
@@ -102,16 +103,7 @@
"tailwindcss": "^4.0.0",
"tw-animate-css": "^1.3.4",
"typescript": "^5",
- "vitest": "^4.0.18",
- "@stryker-mutator/core": "^9.6.1",
- "@stryker-mutator/vitest-runner": "^9.6.1",
- "storybook": "^10.4.6",
- "@storybook/nextjs-vite": "^10.4.6",
- "@chromatic-com/storybook": "^5.2.1",
- "@storybook/addon-vitest": "^10.4.6",
- "@storybook/addon-a11y": "^10.4.6",
- "@storybook/addon-docs": "^10.4.6",
- "@storybook/addon-mcp": "^0.6.0",
- "vite": "^8.1.0"
+ "vite": "^8.1.0",
+ "vitest": "^4.0.18"
}
}
diff --git a/tests/api_routes.test.ts b/tests/api_routes.test.ts
index 9f2f841..fb33a23 100644
--- a/tests/api_routes.test.ts
+++ b/tests/api_routes.test.ts
@@ -1,21 +1,76 @@
import { describe, it, expect } from 'vitest';
+// @ts-expect-error supertest has no types installed
import request from 'supertest';
// Import handlers from the app directory.
-import { GET as getHunts } from '../../app/api/v1/hunts/route';
-import { GET as getLeaderboard } from '../../app/api/v1/hunts/[id]/leaderboard/route';
-import { GET as getFeatured } from '../../app/api/admin/featured/route';
-import { GET as getIpfs } from '../../app/api/ipfs/route';
-import { GET as getAnalytics } from '../../app/api/analytics/route';
+import { GET as getHunts } from '@/app/api/v1/hunts/route';
+import { GET as getLeaderboard } from '@/app/api/v1/hunts/[id]/leaderboard/route';
+import { GET as getFeatured } from '@/app/api/admin/featured/route';
+import { POST as postIpfs } from '@/app/api/ipfs/route';
+import { GET as getAnalytics } from '@/app/api/analytics/performance/route';
-function handlerToExpress(handler) {
- return async (req, res) => {
- const result = await handler(req);
- if (result?.json) {
- const data = await result.json();
- res.status(result.status || 200).json(data);
- } else {
- res.status(200).send(result);
+function handlerToExpress(handler: any) {
+ return async (req: any, res: any) => {
+ try {
+ const url = `http://localhost${req.url || req.originalUrl}`;
+ const method = req.method;
+ const headers = new Headers();
+ for (const [key, value] of Object.entries(req.headers)) {
+ if (value !== undefined) {
+ if (Array.isArray(value)) {
+ value.forEach(v => headers.append(key, v));
+ } else {
+ headers.set(key, String(value));
+ }
+ }
+ }
+ let body = undefined;
+ if (method !== "GET" && method !== "HEAD" && req.body) {
+ body = typeof req.body === "object" ? JSON.stringify(req.body) : req.body;
+ }
+
+ const webRequest = new Request(url, {
+ method,
+ headers,
+ body,
+ });
+
+ // Parse route parameters from the URL path.
+ // e.g. /api/v1/hunts/123/leaderboard -> id = 123
+ const params: Record = {};
+ const matchLeaderboard = req.url.match(/\/api\/v1\/hunts\/([^\/]+)\/leaderboard/);
+ if (matchLeaderboard) {
+ params.id = matchLeaderboard[1];
+ }
+
+ const result = await handler(webRequest, { params });
+
+ if (result instanceof Response) {
+ res.statusCode = result.status;
+ result.headers.forEach((value, key) => {
+ res.setHeader(key, value);
+ });
+ const contentType = result.headers.get("content-type");
+ if (contentType && contentType.includes("application/json")) {
+ const data = await result.json();
+ res.end(JSON.stringify(data));
+ } else {
+ const text = await result.text();
+ res.end(text);
+ }
+ } else if (result?.json) {
+ const data = await result.json();
+ res.statusCode = result.status || 200;
+ res.setHeader("content-type", "application/json");
+ res.end(JSON.stringify(data));
+ } else {
+ res.statusCode = 200;
+ res.end(typeof result === "string" ? result : JSON.stringify(result));
+ }
+ } catch (err: any) {
+ res.statusCode = 500;
+ res.setHeader("content-type", "application/json");
+ res.end(JSON.stringify({ error: err.message }));
}
};
}
@@ -33,21 +88,22 @@ describe('API Integration Tests', () => {
const app = request(handlerToExpress(getLeaderboard));
const response = await app.get('/api/v1/hunts/123/leaderboard');
expect(response.status).toBe(200);
- expect(response.body).toHaveProperty('leaderboard');
+ expect(response.body).toHaveProperty('data');
+ expect(response.body).toHaveProperty('pagination');
});
it('GET /api/admin/featured returns featured items', async () => {
const app = request(handlerToExpress(getFeatured));
const response = await app.get('/api/admin/featured');
expect(response.status).toBe(200);
- expect(Array.isArray(response.body)).toBe(true);
+ expect(response.body).toHaveProperty('featuredHuntId');
});
- it('GET /api/ipfs returns IPFS info', async () => {
- const app = request(handlerToExpress(getIpfs));
- const response = await app.get('/api/ipfs');
- expect(response.status).toBe(200);
- expect(response.body).toHaveProperty('cid');
+ it('POST /api/ipfs returns 503 if not configured', async () => {
+ const app = request(handlerToExpress(postIpfs));
+ const response = await app.post('/api/ipfs');
+ expect(response.status).toBe(503);
+ expect(response.body).toHaveProperty('error');
});
it('GET /api/analytics returns analytics data', async () => {