From d21e25e7be7e1743ad3d1fa0c5f4a8f7715dbd68 Mon Sep 17 00:00:00 2001 From: mrmoney10010-design Date: Tue, 30 Jun 2026 07:07:31 -0500 Subject: [PATCH 1/3] feat: implement hunt reviews and rating system with comprehensive unit tests --- app/api/v1/hunts/[id]/complete/route.ts | 39 + .../[id]/reviews/[reviewId]/moderate/route.ts | 66 + app/api/v1/hunts/[id]/reviews/route.ts | 95 + app/hunt/[id]/share.tsx | 5 + app/page.tsx | 8 +- components/FeaturedHunts.tsx | 2 + components/HuntReviewsSection.tsx | 362 + components/PlayGame.tsx | 8 + components/StarRating.tsx | 25 + lib/__tests__/huntStore.test.ts | 6 +- lib/__tests__/reviews.test.ts | 402 + lib/contracts/rewardManager.ts | 99 +- lib/db/queryOptimizer.ts | 11 +- lib/huntStore.ts | 7 +- lib/reviews.ts | 186 + lib/types.ts | 20 + package-lock.json | 10911 +++++++--------- package.json | 19 +- tests/api_routes.test.ts | 10 +- 19 files changed, 6135 insertions(+), 6146 deletions(-) create mode 100644 app/api/v1/hunts/[id]/complete/route.ts create mode 100644 app/api/v1/hunts/[id]/reviews/[reviewId]/moderate/route.ts create mode 100644 app/api/v1/hunts/[id]/reviews/route.ts create mode 100644 components/HuntReviewsSection.tsx create mode 100644 components/StarRating.tsx create mode 100644 lib/__tests__/reviews.test.ts create mode 100644 lib/reviews.ts 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 6bb63ae..990f540 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 } from "next/navigation"; @@ -312,6 +313,10 @@ export default function HuntShare({ hunt }: HuntDetailProps) { /> )} + +
+ +
); } diff --git a/app/page.tsx b/app/page.tsx index 756922b..fbf7f53 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) @@ -813,12 +816,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 && ( +
+

+ Share your experience! Rate this hunt: +

+ +
+ {[1, 2, 3, 4, 5].map((star) => ( + + ))} +
+ +
+