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 && ( +
+

+ Share your experience! Rate this hunt: +

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