diff --git a/apps/web/app/api/v1/achievements/showcase/route.ts b/apps/web/app/api/v1/achievements/showcase/route.ts new file mode 100644 index 000000000..dddcf62e4 --- /dev/null +++ b/apps/web/app/api/v1/achievements/showcase/route.ts @@ -0,0 +1,39 @@ +import { achievementShowcaseBodySchema, stellarAddressSchema } from "@hunty/types/api-schemas"; +import { NextResponse } from "next/server"; + +import { + getPublicPinnedAchievements, + savePinnedAchievements, +} from "@/lib/achievements/showcaseStore"; +import { ForbiddenError, ValidationError } from "@/lib/api/errors"; +import { withErrorHandling } from "@/lib/api/withErrorHandling"; +import { withValidation } from "@/lib/api/withValidation"; + +/** Public profile readers only receive achievement IDs, never the owner secret. */ +export const GET = withErrorHandling(async (request: Request) => { + const address = new URL(request.url).searchParams.get("address"); + const parsed = stellarAddressSchema.safeParse(address); + if (!parsed.success) { + throw new ValidationError("A valid wallet address is required", { field: "address" }); + } + + return NextResponse.json({ pinned: await getPublicPinnedAchievements(parsed.data) }); +}); + +/** + * Saves a profile showcase. The first save mints an owner secret; later saves + * require it, matching the app's established wallet-ownership fallback. + */ +export const PUT = withValidation( + { body: achievementShowcaseBodySchema }, + async (_request, _context, { body }) => { + const saved = await savePinnedAchievements(body.address, body.pinned, body.ownerSecret); + if (!saved) { + throw new ForbiddenError( + "A valid ownerSecret is required to update this achievement showcase" + ); + } + + return NextResponse.json(saved); + } +); diff --git a/apps/web/app/profile/page.tsx b/apps/web/app/profile/page.tsx index 81a828312..511019ed0 100644 --- a/apps/web/app/profile/page.tsx +++ b/apps/web/app/profile/page.tsx @@ -1,36 +1,37 @@ -"use client" - -import { useContext, useEffect, useMemo, useState } from "react" -import Link from "next/link" -import { formatISOString } from "@/lib/dateUtils" -import { logger } from "@/lib/logger" - -import { Header } from "@/components/Header" -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" -import { Button } from "@/components/ui/button" -import { WalletContext, shortenAddress } from "@/lib/context/WalletContext" -import { NftGallery } from "@/components/NftGallery" -import { BadgeWall } from "@/components/BadgeWall" -import { LevelBadge, LevelProgress } from "@/components/LevelBadge" -import { ProfilePageSkeleton } from "@/components/LoadingSkeletons" -import type { NftRewardDetail } from "@/components/NftDetailModal" -import { RewardHistorySection } from "@/components/RewardHistorySection" -import { fetchPlayerRewardHistory } from "@/lib/rewardHistory" -import { getPlayerAttempts } from "@/lib/huntAttemptHistory" -import type { HuntAttemptRecord, ReferralStats } from "@/lib/types" -import { getReferralStats } from "@/lib/referrals" +"use client"; + +import Link from "next/link"; +import { useContext, useEffect, useMemo, useState } from "react"; + +import { AchievementShowcase } from "@/components/AchievementShowcase"; +import { Header } from "@/components/Header"; +import { LevelBadge, LevelProgress } from "@/components/LevelBadge"; +import { ProfilePageSkeleton } from "@/components/LoadingSkeletons"; +import type { NftRewardDetail } from "@/components/NftDetailModal"; +import { NftGallery } from "@/components/NftGallery"; +import { RewardHistorySection } from "@/components/RewardHistorySection"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { usePlayerProfileStats } from "@/hooks/usePlayerProfileStats"; +import { shortenAddress, WalletContext } from "@/lib/context/WalletContext"; +import { formatISOString } from "@/lib/dateUtils"; +import { getPlayerAttempts } from "@/lib/huntAttemptHistory"; +import { logger } from "@/lib/logger"; +import { getReferralStats } from "@/lib/referrals"; +import { fetchPlayerRewardHistory } from "@/lib/rewardHistory"; +import type { HuntAttemptRecord, ReferralStats } from "@/lib/types"; // --------------------------------------------------------------------------- // #355 — Registered Hunts types and fetcher // --------------------------------------------------------------------------- -type RegistrationStatus = "Registered" | "In Progress" | "Completed" +type RegistrationStatus = "Registered" | "In Progress" | "Completed"; interface RegisteredHunt { - huntId: number - title: string - startTime: number // unix epoch seconds - status: RegistrationStatus + huntId: number; + title: string; + startTime: number; // unix epoch seconds + status: RegistrationStatus; } /** @@ -41,7 +42,7 @@ interface RegisteredHunt { * the indexer endpoint is available. */ async function fetchPlayerRegistrations(address: string): Promise { - if (!address) return [] + if (!address) return []; // Stub data — replace with real contract / indexer call return [ @@ -63,27 +64,27 @@ async function fetchPlayerRegistrations(address: string): Promise { // In a real implementation this would: @@ -92,7 +93,7 @@ async function fetchPlayerHunts(address: string): Promise // 3. Filter down to hunts where the player has any progress. // // For now we simulate a few hunts with mixed completion states. - if (!address) return [] + if (!address) return []; return [ { @@ -124,17 +125,18 @@ async function fetchPlayerHunts(address: string): Promise startedAt: "2026-02-20T11:00:00Z", completedAt: "2026-02-20T11:25:00Z", }, - ] + ]; } async function fetchPlayerRewards(address: string): Promise { - if (!address) return [] + if (!address) return []; return [ { id: 1, name: "Golden Compass", - description: "A legendary artifact awarded to those who uncover all secret murals in the City Secrets hunt.", + description: + "A legendary artifact awarded to those who uncover all secret murals in the City Secrets hunt.", imageUri: "/static-images/nft1.png", earnedAt: "2026-02-10T15:16:00Z", claimed: true, @@ -142,12 +144,13 @@ async function fetchPlayerRewards(address: string): Promise { attributes: [ { trait_type: "Rarity", value: "Legendary" }, { trait_type: "Type", value: "Utility" }, - ] + ], }, { id: 2, name: "Explorer Trophy", - description: "Granted for successfully completing the Office Onboarding challenge within the time limit.", + description: + "Granted for successfully completing the Office Onboarding challenge within the time limit.", imageUri: "/static-images/nft2.png", earnedAt: "2026-02-20T11:26:00Z", claimed: false, @@ -155,12 +158,13 @@ async function fetchPlayerRewards(address: string): Promise { attributes: [ { trait_type: "Rarity", value: "Rare" }, { trait_type: "Level", value: 5 }, - ] + ], }, { id: 3, name: "Soroban Sage", - description: "Awarded to players who demonstrate exceptional knowledge of smart contract riddles.", + description: + "Awarded to players who demonstrate exceptional knowledge of smart contract riddles.", imageUri: "ipfs://QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG", // Example IPFS earnedAt: "2026-03-05T09:45:00Z", claimed: true, @@ -168,96 +172,108 @@ async function fetchPlayerRewards(address: string): Promise { attributes: [ { trait_type: "Rarity", value: "Epic" }, { trait_type: "Skill", value: "Contracting" }, - ] - } - ] + ], + }, + ]; } - export default function UserProfilePage() { - const wallet = useContext(WalletContext) - const connected = wallet?.connected ?? false - const publicKey = wallet?.publicKey ?? "" - const [hunts, setHunts] = useState([]) - const [nftRewards, setNftRewards] = useState([]) - const [rewardHistory, setRewardHistory] = useState extends Promise ? U : never>([]) - const [registrations, setRegistrations] = useState([]) - const [attemptHistory, setAttemptHistory] = useState([]) - const [referralStats, setReferralStats] = useState(null) - const [isLoading, setIsLoading] = useState(false) - const [error, setError] = useState(null) - + const wallet = useContext(WalletContext); + const connected = wallet?.connected ?? false; + const publicKey = wallet?.publicKey ?? ""; + const [hunts, setHunts] = useState([]); + const [nftRewards, setNftRewards] = useState([]); + const [rewardHistory, setRewardHistory] = useState< + ReturnType extends Promise ? U : never + >([]); + const [registrations, setRegistrations] = useState([]); + const [attemptHistory, setAttemptHistory] = useState([]); + const [referralStats, setReferralStats] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const { stats: profileStats } = usePlayerProfileStats(publicKey); useEffect(() => { + const reset = () => { + setHunts([]); + setNftRewards([]); + setRegistrations([]); + setAttemptHistory([]); + setReferralStats(null); + }; if (!connected || !publicKey) { - setHunts([]) - setNftRewards([]) - setRegistrations([]) - setAttemptHistory([]) - setReferralStats(null) - return + reset(); } + }, [connected, publicKey]); - let cancelled = false - setIsLoading(true) - setError(null) + useEffect(() => { + if (!connected || !publicKey) return; + + let cancelled = false; const load = async () => { try { - const data = await fetchPlayerHunts(publicKey) + const data = await fetchPlayerHunts(publicKey); if (!cancelled) { - setHunts(data) + setHunts(data); } } catch (err) { if (!cancelled) { - setError(err instanceof Error ? err.message : "Failed to load profile data.") + setError(err instanceof Error ? err.message : "Failed to load profile data."); } } finally { if (!cancelled) { - setIsLoading(false) + setIsLoading(false); } } - } + }; const loadRewards = async () => { try { - const rewardsData = await fetchPlayerRewards(publicKey!) + const rewardsData = await fetchPlayerRewards(publicKey!); if (!cancelled) { - setNftRewards(rewardsData) + setNftRewards(rewardsData); } } catch (err) { - logger.error("Failed to load NFT rewards:", err) + logger.error("Failed to load NFT rewards:", err); } - } + }; const loadRegistrations = async () => { try { - const data = await fetchPlayerRegistrations(publicKey!) - if (!cancelled) setRegistrations(data) + const data = await fetchPlayerRegistrations(publicKey!); + if (!cancelled) setRegistrations(data); } catch (err) { - logger.error("Failed to load registrations:", err) + logger.error("Failed to load registrations:", err); } - } + }; const loadRewardHistory = async () => { try { - const data = await fetchPlayerRewardHistory(publicKey!) - if (!cancelled) setRewardHistory(data) + const data = await fetchPlayerRewardHistory(publicKey!); + if (!cancelled) setRewardHistory(data); } catch (err) { - logger.error("Failed to load reward history:", err) + logger.error("Failed to load reward history:", err); } - } - - load() - loadRewards() - loadRegistrations() - loadRewardHistory() - setAttemptHistory(getPlayerAttempts(publicKey)) - setReferralStats(getReferralStats(publicKey, typeof window !== "undefined" ? window.location.origin : undefined)) + }; + + const run = async () => { + setIsLoading(true); + setError(null); + setAttemptHistory(getPlayerAttempts(publicKey)); + setReferralStats( + getReferralStats( + publicKey, + typeof window !== "undefined" ? window.location.origin : undefined + ) + ); + await Promise.all([load(), loadRewards(), loadRegistrations(), loadRewardHistory()]); + }; + run(); return () => { - cancelled = true - } - }, [connected, publicKey]) + cancelled = true; + }; + }, [connected, publicKey]); const summary = useMemo(() => { if (!hunts.length) { @@ -267,13 +283,13 @@ export default function UserProfilePage() { inProgressHunts: 0, totalPoints: 0, completionRate: 0, - } + }; } - const completedHunts = hunts.filter((h) => h.status === "Completed").length - const inProgressHunts = hunts.filter((h) => h.status === "In-Progress").length - const totalPoints = hunts.reduce((sum, h) => sum + h.pointsEarned, 0) - const completionRate = Math.round((completedHunts / hunts.length) * 100) + const completedHunts = hunts.filter((h) => h.status === "Completed").length; + const inProgressHunts = hunts.filter((h) => h.status === "In-Progress").length; + const totalPoints = hunts.reduce((sum, h) => sum + h.pointsEarned, 0); + const completionRate = Math.round((completedHunts / hunts.length) * 100); return { totalHunts: hunts.length, @@ -284,13 +300,13 @@ export default function UserProfilePage() { totalNftRewards: nftRewards.length, claimedNftRewards: nftRewards.filter((nft) => nft.claimed).length, unclaimedNftRewards: nftRewards.filter((nft) => !nft.claimed).length, - } - }, [hunts, nftRewards]) + }; + }, [hunts, nftRewards]); - const completedHunts = hunts.filter((h) => h.status === "Completed") - const inProgressHunts = hunts.filter((h) => h.status === "In-Progress") + const completedHunts = hunts.filter((h) => h.status === "Completed"); + const inProgressHunts = hunts.filter((h) => h.status === "In-Progress"); - const displayAddress = publicKey ? shortenAddress(publicKey) : "Not connected" + const displayAddress = publicKey ? shortenAddress(publicKey) : "Not connected"; return (
@@ -319,11 +335,12 @@ export default function UserProfilePage() { Connect your wallet to see your history

- Your profile uses the connected Stellar address to load hunts you've played and aggregate your - points across games. + Your profile uses the connected Stellar address to load hunts you've played and + aggregate your points across games.

- Use the Connect Wallet button in the header to get started. + Use the Connect Wallet button in the header to + get started.

) : isLoading ? ( @@ -337,9 +354,7 @@ export default function UserProfilePage() { Player Level - - Earn XP from completing hunts and level up! - + Earn XP from completing hunts and level up! @@ -357,7 +372,8 @@ export default function UserProfilePage() { Summary statistics - Aggregated from all hunts where you have progress via get_player_progress. + Aggregated from all hunts where you have progress via{" "} + get_player_progress. @@ -392,7 +408,8 @@ export default function UserProfilePage() { Referral Program - Invite new players with your wallet-bound link and earn bonus points after their first completed hunt. + Invite new players with your wallet-bound link and earn bonus points after their + first completed hunt. @@ -400,23 +417,42 @@ export default function UserProfilePage() { - + {referralStats ? ( <>
-
Referral Link
-
{referralStats.referralLink}
+
+ Referral Link +
+
+ {referralStats.referralLink} +
{referralStats.referrals.length === 0 ? (

No referrals yet.

) : ( referralStats.referrals.slice(0, 5).map((referral) => ( -
- {shortenAddress(referral.referredAddress)} - - {referral.bonusAwarded ? `+${referral.bonusPoints} pts` : "Waiting for first completion"} +
+ + {shortenAddress(referral.referredAddress)} + + + {referral.bonusAwarded + ? `+${referral.bonusPoints} pts` + : "Waiting for first completion"}
)) @@ -434,13 +470,15 @@ export default function UserProfilePage() {

Digital Trophies

-

Collectible rewards earned through your achievements

+

+ Collectible rewards earned through your achievements +

{nftRewards.length} Unlocked
- + @@ -451,7 +489,15 @@ export default function UserProfilePage() { />
- +
{/* #355 — Registered Hunts */} @@ -504,7 +550,9 @@ export default function UserProfilePage() { {isLoading && ( - Refreshing your latest games… + + Refreshing your latest games… + )} @@ -517,7 +565,8 @@ export default function UserProfilePage() { {!isLoading && !hunts.length && !error && (
- You haven't played any hunts yet. Join a game from the arcade to see your history here. + You haven't played any hunts yet. Join a game from the arcade to see your + history here.
)} @@ -563,7 +612,7 @@ export default function UserProfilePage() { )} - ) + ); } function StatPill({ @@ -571,44 +620,47 @@ function StatPill({ value, valueClassName, }: { - label: string - value: number - valueClassName?: string + label: string; + value: number; + valueClassName?: string; }) { return (
{label} - {value} + + {value} +
- ) + ); } // --------------------------------------------------------------------------- // #355 — RegistrationCard // --------------------------------------------------------------------------- -const REGISTRATION_STATUS_STYLES: Record< - RegisteredHunt["status"], - { badge: string; dot: string } -> = { - Registered: { badge: "bg-blue-50 text-blue-700 border border-blue-200", dot: "bg-blue-400" }, - "In Progress":{ badge: "bg-amber-50 text-amber-700 border border-amber-200", dot: "bg-amber-400" }, - Completed: { badge: "bg-emerald-50 text-emerald-700 border border-emerald-200", dot: "bg-emerald-400" }, -} +const REGISTRATION_STATUS_STYLES: Record = + { + Registered: { badge: "bg-blue-50 text-blue-700 border border-blue-200", dot: "bg-blue-400" }, + "In Progress": { + badge: "bg-amber-50 text-amber-700 border border-amber-200", + dot: "bg-amber-400", + }, + Completed: { + badge: "bg-emerald-50 text-emerald-700 border border-emerald-200", + dot: "bg-emerald-400", + }, + }; function RegistrationCard({ registration }: { registration: RegisteredHunt }) { - const { badge, dot } = REGISTRATION_STATUS_STYLES[registration.status] - const isCompleted = registration.status === "Completed" - const isActive = registration.status === "In Progress" + const { badge, dot } = REGISTRATION_STATUS_STYLES[registration.status]; + const isCompleted = registration.status === "Completed"; + const isActive = registration.status === "In Progress"; return (
-
- ) + ); } diff --git a/apps/web/components/AchievementShowcase.tsx b/apps/web/components/AchievementShowcase.tsx new file mode 100644 index 000000000..e5d9e8efb --- /dev/null +++ b/apps/web/components/AchievementShowcase.tsx @@ -0,0 +1,206 @@ +"use client"; + +import type { AchievementId } from "@hunty/types"; +import { Pin, PinOff } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { ACHIEVEMENTS, RARITY_BORDER_COLORS, RARITY_COLORS } from "@/lib/achievements/config"; +import { + type AchievementProgressStats, + getAchievementProgress, + getAllAchievementsWithStatus, +} from "@/lib/achievements/service"; +import { logger } from "@/lib/logger"; +import { cn } from "@/lib/utils"; + +interface AchievementShowcaseProps { + playerAddress: string; + stats: AchievementProgressStats; + isOwnProfile?: boolean; +} + +const ownerSecretKey = (address: string) => + `hunty_achievement_showcase_owner_secret_${address.toLowerCase()}`; + +export function AchievementShowcase({ + playerAddress, + stats, + isOwnProfile = false, +}: AchievementShowcaseProps) { + const [achievements, setAchievements] = useState>( + [] + ); + const [pinned, setPinned] = useState([]); + const [isSaving, setIsSaving] = useState(false); + + const progress = useMemo( + () => + new Map( + getAchievementProgress(playerAddress, stats).map((item) => [item.achievementId, item]) + ), + [playerAddress, stats] + ); + + useEffect(() => { + if (!playerAddress) return; + + const loadAchievements = () => { + setAchievements(getAllAchievementsWithStatus(playerAddress)); + }; + loadAchievements(); + fetch(`/api/v1/achievements/showcase?address=${encodeURIComponent(playerAddress)}`) + .then(async (response) => + response.ok ? ((await response.json()) as { pinned: AchievementId[] }) : null + ) + .then((data) => { + if (data) setPinned(data.pinned); + }) + .catch((error) => logger.error("Failed to load pinned achievements:", error)); + }, [playerAddress]); + + async function persistPinned(nextPinned: AchievementId[]): Promise { + setIsSaving(true); + try { + const ownerSecret = localStorage.getItem(ownerSecretKey(playerAddress)) ?? undefined; + const response = await fetch("/api/v1/achievements/showcase", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ address: playerAddress, pinned: nextPinned, ownerSecret }), + }); + if (!response.ok) return false; + + const data = (await response.json()) as { pinned: AchievementId[]; ownerSecret?: string }; + if (data.ownerSecret) localStorage.setItem(ownerSecretKey(playerAddress), data.ownerSecret); + setPinned(data.pinned); + return true; + } catch (error) { + logger.error("Failed to save pinned achievements:", error); + return false; + } finally { + setIsSaving(false); + } + } + + async function togglePin(id: AchievementId) { + const isPinned = pinned.includes(id); + if (isPinned) { + await persistPinned(pinned.filter((pinnedId) => pinnedId !== id)); + return; + } + const achievement = achievements.find((item) => item.id === id); + if (!achievement?.earned) return; + if (pinned.length >= 3) return; + await persistPinned([...pinned, id]); + } + + const pinnedAchievements = pinned.map((id) => ACHIEVEMENTS[id]).filter(Boolean); + + return ( +
+ + + Achievement Showcase + + {isOwnProfile + ? "Pin up to three earned achievements to your public profile." + : "Highlighted achievements"} + + + + {pinnedAchievements.length > 0 ? ( +
+ {pinnedAchievements.map((achievement) => ( +
+ +

{achievement.title}

+

{achievement.condition}

+
+ ))} +
+ ) : ( +

No achievements pinned yet.

+ )} + +
+

All achievements

+
+ + {achievements.map((achievement) => { + const itemProgress = progress.get(achievement.id); + const isPinned = pinned.includes(achievement.id); + return ( + + +
+ {isOwnProfile && achievement.earned && ( + + )} + + {achievement.title} + {achievement.earned ? ( + Earned + ) : itemProgress ? ( + + {itemProgress.current}/{itemProgress.target} + + ) : null} +
+
+ +

{achievement.title}

+

{achievement.description}

+

Criteria: {achievement.condition}

+ {itemProgress && ( +

+ Progress: {itemProgress.current}/{itemProgress.target} +

+ )} +
+
+ ); + })} +
+
+
+
+
+
+ ); +} diff --git a/apps/web/components/PlayerProfileView.tsx b/apps/web/components/PlayerProfileView.tsx index 452700205..6d062bcc0 100644 --- a/apps/web/components/PlayerProfileView.tsx +++ b/apps/web/components/PlayerProfileView.tsx @@ -3,6 +3,7 @@ import { Wallet } from "lucide-react"; import Link from "next/link"; +import { AchievementShowcase } from "@/components/AchievementShowcase"; import { HuntCompletionTimeline } from "@/components/HuntCompletionTimeline"; import { ProfileHighlightBadge, ProfileStatsDashboard } from "@/components/ProfileStatsDashboard"; import { Button } from "@/components/ui/button"; @@ -93,6 +94,18 @@ export function PlayerProfileView({ address, isOwnProfile = false }: PlayerProfi )} + {hasAddress && ( + + )} + {/* ── Aggregated statistics ───────────────────────────────────────── */}
@@ -163,4 +176,3 @@ export function ProfilePageHeading({
); } - \ No newline at end of file diff --git a/apps/web/lib/achievements/config.ts b/apps/web/lib/achievements/config.ts index d2644f499..b1fb91713 100644 --- a/apps/web/lib/achievements/config.ts +++ b/apps/web/lib/achievements/config.ts @@ -6,9 +6,9 @@ // Achievement and AchievementId are defined in the shared @hunty/types package. // Imported for local use and re-exported so existing // "@/lib/achievements/config" imports keep working. -import type { Achievement, AchievementId } from "@hunty/types" +import type { Achievement, AchievementId } from "@hunty/types"; -export type { Achievement, AchievementId } +export type { Achievement, AchievementId }; export const ACHIEVEMENTS: Record = { first_hunt_completed: { @@ -91,7 +91,7 @@ export const ACHIEVEMENTS: Record = { rarity: "legendary", condition: "Win 100 hunts", }, -} +}; export const RARITY_COLORS: Record = { common: "from-slate-400 to-slate-600", @@ -99,7 +99,7 @@ export const RARITY_COLORS: Record = { rare: "from-blue-400 to-blue-600", epic: "from-purple-400 to-purple-600", legendary: "from-yellow-400 to-yellow-600", -} +}; export const RARITY_BORDER_COLORS: Record = { common: "border-slate-400", @@ -107,4 +107,20 @@ export const RARITY_BORDER_COLORS: Record = { rare: "border-blue-400", epic: "border-purple-400", legendary: "border-yellow-400", -} +}; + +export const PROGRESS_THRESHOLDS: Partial< + Record< + AchievementId, + { stat: "totalHuntsCompleted" | "totalHuntsWon" | "totalNftsEarned"; target: number } + > +> = { + first_hunt_completed: { stat: "totalHuntsCompleted", target: 1 }, + first_win: { stat: "totalHuntsWon", target: 1 }, + five_wins: { stat: "totalHuntsWon", target: 5 }, + ten_wins: { stat: "totalHuntsWon", target: 10 }, + twenty_five_wins: { stat: "totalHuntsWon", target: 25 }, + first_nft: { stat: "totalNftsEarned", target: 1 }, + veteran: { stat: "totalHuntsCompleted", target: 50 }, + legend: { stat: "totalHuntsWon", target: 100 }, +}; diff --git a/apps/web/lib/achievements/service.ts b/apps/web/lib/achievements/service.ts index cb55129a3..c5162bee6 100644 --- a/apps/web/lib/achievements/service.ts +++ b/apps/web/lib/achievements/service.ts @@ -3,41 +3,54 @@ * Handles achievement logic and storage. */ -import { logger } from "@/lib/logger" +import { logger } from "@/lib/logger"; -import type { AchievementId } from "./config" -import { ACHIEVEMENTS } from "./config" +import type { AchievementId } from "./config"; +import { ACHIEVEMENTS, PROGRESS_THRESHOLDS } from "./config"; export interface EarnedAchievement { - id: AchievementId - earnedAt: number // Unix timestamp + id: AchievementId; + earnedAt: number; // Unix timestamp } export interface PlayerAchievements { - address: string - earned: EarnedAchievement[] - lastUpdated: number + address: string; + earned: EarnedAchievement[]; + pinned: AchievementId[]; + lastUpdated: number; +} + +export type AchievementProgressStats = { + totalHuntsCompleted: number; + totalHuntsWon: number; + totalNftsEarned: number; +}; + +export interface AchievementProgress { + achievementId: AchievementId; + current: number; + target: number; } /** * Storage key for player achievements in localStorage */ -const getStorageKey = (address: string): string => `hunty_achievements_${address}` +const getStorageKey = (address: string): string => `hunty_achievements_${address}`; /** * Get all earned achievements for a player */ export function getEarnedAchievements(address: string): EarnedAchievement[] { - if (typeof window === "undefined") return [] + if (typeof window === "undefined") return []; try { - const stored = localStorage.getItem(getStorageKey(address)) - if (!stored) return [] - const data = JSON.parse(stored) as PlayerAchievements - return data.earned || [] + const stored = localStorage.getItem(getStorageKey(address)); + if (!stored) return []; + const data = JSON.parse(stored) as PlayerAchievements; + return data.earned || []; } catch (error) { - logger.error("Failed to load achievements:", error) - return [] + logger.error("Failed to load achievements:", error); + return []; } } @@ -45,47 +58,48 @@ export function getEarnedAchievements(address: string): EarnedAchievement[] { * Check if a player has earned a specific achievement */ export function hasAchievement(address: string, achievementId: AchievementId): boolean { - const earned = getEarnedAchievements(address) - return earned.some((a) => a.id === achievementId) + const earned = getEarnedAchievements(address); + return earned.some((a) => a.id === achievementId); } /** * Award an achievement to a player */ export function awardAchievement(address: string, achievementId: AchievementId): boolean { - if (typeof window === "undefined") return false + if (typeof window === "undefined") return false; // Check if already earned if (hasAchievement(address, achievementId)) { - return false + return false; } try { - const key = getStorageKey(address) - const stored = localStorage.getItem(key) - let data: PlayerAchievements + const key = getStorageKey(address); + const stored = localStorage.getItem(key); + let data: PlayerAchievements; if (stored) { - data = JSON.parse(stored) as PlayerAchievements + data = JSON.parse(stored) as PlayerAchievements; } else { data = { address, earned: [], + pinned: [], lastUpdated: Date.now(), - } + }; } data.earned.push({ id: achievementId, earnedAt: Date.now(), - }) - data.lastUpdated = Date.now() + }); + data.lastUpdated = Date.now(); - localStorage.setItem(key, JSON.stringify(data)) - return true + localStorage.setItem(key, JSON.stringify(data)); + return true; } catch (error) { - logger.error("Failed to award achievement:", error) - return false + logger.error("Failed to award achievement:", error); + return false; } } @@ -96,54 +110,54 @@ export function awardAchievement(address: string, achievementId: AchievementId): export function checkAndAwardAchievements( address: string, stats: { - totalHuntsCompleted: number - totalHuntsWon: number - totalNftsEarned: number - fastestCompletionSeconds?: number - monthlyHighScore?: number + totalHuntsCompleted: number; + totalHuntsWon: number; + totalNftsEarned: number; + fastestCompletionSeconds?: number; + monthlyHighScore?: number; } ): AchievementId[] { - const newAchievements: AchievementId[] = [] + const newAchievements: AchievementId[] = []; // First hunt completed if (stats.totalHuntsCompleted >= 1 && !hasAchievement(address, "first_hunt_completed")) { if (awardAchievement(address, "first_hunt_completed")) { - newAchievements.push("first_hunt_completed") + newAchievements.push("first_hunt_completed"); } } // First win if (stats.totalHuntsWon >= 1 && !hasAchievement(address, "first_win")) { if (awardAchievement(address, "first_win")) { - newAchievements.push("first_win") + newAchievements.push("first_win"); } } // Five wins if (stats.totalHuntsWon >= 5 && !hasAchievement(address, "five_wins")) { if (awardAchievement(address, "five_wins")) { - newAchievements.push("five_wins") + newAchievements.push("five_wins"); } } // Ten wins if (stats.totalHuntsWon >= 10 && !hasAchievement(address, "ten_wins")) { if (awardAchievement(address, "ten_wins")) { - newAchievements.push("ten_wins") + newAchievements.push("ten_wins"); } } // Twenty-five wins if (stats.totalHuntsWon >= 25 && !hasAchievement(address, "twenty_five_wins")) { if (awardAchievement(address, "twenty_five_wins")) { - newAchievements.push("twenty_five_wins") + newAchievements.push("twenty_five_wins"); } } // First NFT if (stats.totalNftsEarned >= 1 && !hasAchievement(address, "first_nft")) { if (awardAchievement(address, "first_nft")) { - newAchievements.push("first_nft") + newAchievements.push("first_nft"); } } @@ -154,21 +168,21 @@ export function checkAndAwardAchievements( !hasAchievement(address, "speed_hunter") ) { if (awardAchievement(address, "speed_hunter")) { - newAchievements.push("speed_hunter") + newAchievements.push("speed_hunter"); } } // Veteran (50 hunts completed) if (stats.totalHuntsCompleted >= 50 && !hasAchievement(address, "veteran")) { if (awardAchievement(address, "veteran")) { - newAchievements.push("veteran") + newAchievements.push("veteran"); } } // Legend (100 wins) if (stats.totalHuntsWon >= 100 && !hasAchievement(address, "legend")) { if (awardAchievement(address, "legend")) { - newAchievements.push("legend") + newAchievements.push("legend"); } } @@ -180,32 +194,56 @@ export function checkAndAwardAchievements( // } // } - return newAchievements + return newAchievements; } /** * Get all achievements with earned status for a player */ export function getAllAchievementsWithStatus(address: string) { - const earned = getEarnedAchievements(address) - const earnedIds = new Set(earned.map((a) => a.id)) + const earned = getEarnedAchievements(address); + const earnedIds = new Set(earned.map((a) => a.id)); return Object.values(ACHIEVEMENTS).map((achievement) => ({ ...achievement, earned: earnedIds.has(achievement.id), earnedAt: earned.find((a) => a.id === achievement.id)?.earnedAt, - })) + })); +} + +/** + * Returns progress for incomplete, measurable achievements. The caller + * supplies the existing on-chain profile stats, avoiding duplicate fetches. + */ +export function getAchievementProgress( + address: string, + stats: AchievementProgressStats +): AchievementProgress[] { + const earnedIds = new Set(getEarnedAchievements(address).map((achievement) => achievement.id)); + + return Object.entries(PROGRESS_THRESHOLDS).flatMap(([id, threshold]) => { + const achievementId = id as AchievementId; + if (!threshold || earnedIds.has(achievementId)) return []; + + return [ + { + achievementId, + current: Math.min(stats[threshold.stat], threshold.target), + target: threshold.target, + }, + ]; + }); } /** * Clear all achievements for a player (for testing) */ export function clearAchievements(address: string): void { - if (typeof window === "undefined") return + if (typeof window === "undefined") return; try { - localStorage.removeItem(getStorageKey(address)) + localStorage.removeItem(getStorageKey(address)); } catch (error) { - logger.error("Failed to clear achievements:", error) + logger.error("Failed to clear achievements:", error); } } diff --git a/apps/web/lib/achievements/showcaseStore.ts b/apps/web/lib/achievements/showcaseStore.ts new file mode 100644 index 000000000..c3401fee9 --- /dev/null +++ b/apps/web/lib/achievements/showcaseStore.ts @@ -0,0 +1,66 @@ +import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; + +import { getDb } from "@/lib/db"; + +import type { AchievementId } from "./config"; + +const SETTINGS_PREFIX = "achievement_showcase:"; +const MAX_PINNED_ACHIEVEMENTS = 3; + +interface StoredShowcase { + ownerSecret: string; + pinned: AchievementId[]; +} + +function keyFor(address: string): string { + return `${SETTINGS_PREFIX}${address.toLowerCase()}`; +} + +function digest(value: string): Buffer { + return createHash("sha256").update(value).digest(); +} + +function secretsMatch(expected: string, received: string | undefined): boolean { + return Boolean(received) && timingSafeEqual(digest(expected), digest(received as string)); +} + +async function getStoredShowcase(address: string): Promise { + const sql = getDb(); + const rows = await sql<{ value: string }[]>` + SELECT value FROM app_settings WHERE key = ${keyFor(address)} LIMIT 1 + `; + if (!rows.length) return null; + + try { + return JSON.parse(rows[0].value) as StoredShowcase; + } catch { + return null; + } +} + +export async function getPublicPinnedAchievements(address: string): Promise { + return (await getStoredShowcase(address))?.pinned ?? []; +} + +export async function savePinnedAchievements( + address: string, + pinned: AchievementId[], + ownerSecret?: string +): Promise<{ pinned: AchievementId[]; ownerSecret?: string } | null> { + if (pinned.length > MAX_PINNED_ACHIEVEMENTS || new Set(pinned).size !== pinned.length) + return null; + + const existing = await getStoredShowcase(address); + if (existing && !secretsMatch(existing.ownerSecret, ownerSecret)) return null; + + const secret = existing?.ownerSecret ?? randomBytes(32).toString("hex"); + const value = JSON.stringify({ ownerSecret: secret, pinned }); + const sql = getDb(); + await sql` + INSERT INTO app_settings (key, value, updated_at) + VALUES (${keyFor(address)}, ${value}, NOW()) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW() + `; + + return { pinned, ...(existing ? {} : { ownerSecret: secret }) }; +} diff --git a/eslint.config.mjs b/eslint.config.mjs index a542a9c31..f0a2c1300 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -3,7 +3,6 @@ import { fileURLToPath } from "url"; import { FlatCompat } from "@eslint/eslintrc"; -import baseConfig from "@hunty/config/eslint/base"; import baseConfig from "@hunty/config/eslint/base.mjs"; import jsxA11y from "eslint-plugin-jsx-a11y"; import reactHooks from "eslint-plugin-react-hooks"; diff --git a/packages/types/src/api-schemas.ts b/packages/types/src/api-schemas.ts index 546dfd71c..4b4b84029 100644 --- a/packages/types/src/api-schemas.ts +++ b/packages/types/src/api-schemas.ts @@ -7,20 +7,22 @@ * never duplicated between server and client. */ -import { z } from "zod" +import { z } from "zod"; + +import { achievementIdSchema } from "./schemas"; // ─── Primitives ────────────────────────────────────────────────────────────── /** Positive integer path/body param (hunt IDs, season IDs, …) */ -export const positiveIntSchema = z.number().int().positive() +export const positiveIntSchema = z.number().int().positive(); /** Non-empty trimmed string */ -export const nonEmptyStringSchema = z.string().min(1).trim() +export const nonEmptyStringSchema = z.string().min(1).trim(); /** Stellar G-address: starts with "G", exactly 56 characters */ export const stellarAddressSchema = z .string() - .regex(/^G[A-Z2-7]{55}$/, "Must be a valid Stellar G-address (56 chars)") + .regex(/^G[A-Z2-7]{55}$/, "Must be a valid Stellar G-address (56 chars)"); // ─── Admin / Moderation ────────────────────────────────────────────────────── @@ -32,7 +34,7 @@ export const contentPolicyViolationSchema = z.enum([ "violence", "misinformation", "other", -]) +]); export const adminModerationBodySchema = z.discriminatedUnion("action", [ z.object({ @@ -53,18 +55,21 @@ export const adminModerationBodySchema = z.discriminatedUnion("action", [ policyViolations: z.array(contentPolicyViolationSchema).min(1), reviewedBy: z.string().optional(), }), -]) +]); export const adminModerationQuerySchema = z.object({ view: z.enum(["pending", "all"]).optional().default("pending"), -}) +}); // ─── Admin / Anti-cheat ────────────────────────────────────────────────────── export const antiCheatQuerySchema = z.object({ - type: z.enum(["flagged", "anomalies", "submissions", "bans", "config"]).optional().default("flagged"), + type: z + .enum(["flagged", "anomalies", "submissions", "bans", "config"]) + .optional() + .default("flagged"), wallet: z.string().optional(), -}) +}); export const antiCheatBodySchema = z.discriminatedUnion("action", [ z.object({ @@ -82,13 +87,13 @@ export const antiCheatBodySchema = z.discriminatedUnion("action", [ action: z.literal("updateConfig"), config: z.record(z.string(), z.unknown()), }), -]) +]); // ─── Admin / Featured ──────────────────────────────────────────────────────── export const adminFeaturedBodySchema = z.object({ huntId: z.number().int().nullable(), -}) +}); // ─── Analytics / Hint usage ────────────────────────────────────────────────── @@ -97,7 +102,7 @@ export const hintUsageBodySchema = z.object({ clueId: positiveIntSchema, hintIndex: z.number().int().min(0).max(2), wallet: nonEmptyStringSchema, -}) +}); export const hintUsageQuerySchema = z.object({ huntId: z @@ -106,7 +111,7 @@ export const hintUsageQuerySchema = z.object({ message: "huntId must be a positive integer", }) .transform(Number), -}) +}); // ─── Analytics / Hunt view ─────────────────────────────────────────────────── @@ -114,7 +119,7 @@ export const huntViewBodySchema = z.object({ huntId: z .union([z.number(), z.string().transform(Number)]) .refine((v) => Number.isFinite(v) && v > 0, { message: "huntId must be a positive number" }), -}) +}); // ─── Analytics / Performance ───────────────────────────────────────────────── @@ -124,7 +129,7 @@ export const performanceMetricBodySchema = z.object({ rating: z.string().optional(), timestamp: z.number().optional(), url: z.string().optional(), -}) +}); // ─── Push / Send ───────────────────────────────────────────────────────────── @@ -134,49 +139,64 @@ export const pushEventTypeSchema = z.enum([ "leaderboard_overtake", "player_registered", "first_completion", -]) +]); export const pushSendBodySchema = z.object({ type: pushEventTypeSchema, walletAddresses: z .array(nonEmptyStringSchema) .min(1, { message: "walletAddresses must be a non-empty array" }), - context: z.record(z.string(), z.union([z.string(), z.number()])).optional().default({}), -}) + context: z + .record(z.string(), z.union([z.string(), z.number()])) + .optional() + .default({}), +}); // ─── Push tokens ───────────────────────────────────────────────────────────── export const pushTokenRegisterBodySchema = z.object({ token: nonEmptyStringSchema, walletAddress: nonEmptyStringSchema, -}) +}); + +// ─── Achievement showcase ────────────────────────────────────────────────── -export const pushTokenDeleteBodySchema = z.object({ - token: nonEmptyStringSchema.optional(), - walletAddress: nonEmptyStringSchema.optional(), -}).refine((b) => b.token !== undefined || b.walletAddress !== undefined, { - message: "token or walletAddress is required", -}) +export const achievementShowcaseBodySchema = z.object({ + address: stellarAddressSchema, + pinned: z.array(achievementIdSchema).max(3), + ownerSecret: z.string().min(1).optional(), +}); + +export const pushTokenDeleteBodySchema = z + .object({ + token: nonEmptyStringSchema.optional(), + walletAddress: nonEmptyStringSchema.optional(), + }) + .refine((b) => b.token !== undefined || b.walletAddress !== undefined, { + message: "token or walletAddress is required", + }); // ─── Moderation / Submit ───────────────────────────────────────────────────── export const moderationSubmitBodySchema = z.object({ - hunt: z.object({ - id: positiveIntSchema, - title: nonEmptyStringSchema, - }).passthrough(), -}) + hunt: z + .object({ + id: positiveIntSchema, + title: nonEmptyStringSchema, + }) + .passthrough(), +}); // ─── Moderation / Sync ─────────────────────────────────────────────────────── export const moderationSyncBodySchema = z.object({ notificationId: nonEmptyStringSchema, -}) +}); export const moderationSyncQuerySchema = z.object({ email: z.string().email().optional(), huntIds: z.string().optional(), -}) +}); // ─── Notifications / Complete ──────────────────────────────────────────────── @@ -184,12 +204,12 @@ export const notificationsCompleteBodySchema = z.object({ huntName: nonEmptyStringSchema, creatorEmail: z.string().email("creatorEmail must be a valid email address"), completionTime: nonEmptyStringSchema, -}) +}); // ─── Hunts / Schedule ──────────────────────────────────────────────────────── // POST /api/hunts/schedule has no body — it's a cron-style trigger. // We provide an empty schema for completeness. -export const huntScheduleBodySchema = z.object({}).optional() +export const huntScheduleBodySchema = z.object({}).optional(); // ─── v1 / Tags ─────────────────────────────────────────────────────────────── @@ -197,12 +217,12 @@ export const tagsQuerySchema = z.object({ q: z.string().optional().default(""), title: z.string().optional(), description: z.string().optional(), -}) +}); export const tagsBodySchema = z.object({ category: z.string().optional(), tags: z.array(z.string()).optional(), -}) +}); // ─── v1 / Hunts / Bulk ─────────────────────────────────────────────────────── @@ -212,24 +232,24 @@ export const huntsBulkBodySchema = z.object({ .array(z.union([z.string(), z.number()])) .min(1, { message: "huntIds must be a non-empty array" }), confirmed: z.boolean().optional(), -}) +}); // ─── v1 / Hunts / [id] / Archive ───────────────────────────────────────────── export const huntArchiveBodySchema = z.object({ action: z.enum(["archive", "unarchive"]), -}) +}); // ─── v1 / Hunts / [id] / Delete ────────────────────────────────────────────── export const huntDeleteBodySchema = z.object({ action: z.enum(["soft-delete", "restore", "permanent-delete"]), confirmed: z.boolean().optional(), -}) +}); // ─── v1 / Hunts / [id] / Collaborators ─────────────────────────────────────── -export const collaboratorRoleSchema = z.enum(["editor", "viewer"]) +export const collaboratorRoleSchema = z.enum(["editor", "viewer"]); export const collaboratorsBodySchema = z.discriminatedUnion("action", [ z.object({ action: z.literal("ensure_owner"), actorAddress: nonEmptyStringSchema }), @@ -256,7 +276,7 @@ export const collaboratorsBodySchema = z.discriminatedUnion("action", [ actorAddress: nonEmptyStringSchema, newOwnerAddress: nonEmptyStringSchema, }), -]) +]); // ─── v1 / Hunts / [id] / Progress ──────────────────────────────────────────── @@ -267,17 +287,17 @@ export const huntProgressBodySchema = z.object({ totalPoints: z.number().int().min(0).optional().default(0), completedClueIds: z.array(z.number().int()).optional().default([]), completed: z.boolean().optional().default(false), -}) +}); export const huntProgressQuerySchema = z.object({ wallet: nonEmptyStringSchema, -}) +}); // ─── v1 / Hunts / [id] / Complete ──────────────────────────────────────────── export const huntCompleteBodySchema = z.object({ playerAddress: nonEmptyStringSchema, -}) +}); // ─── v1 / Hunts / [id] / Reviews ───────────────────────────────────────────── @@ -290,14 +310,14 @@ export const huntReviewBodySchema = z.object({ }), text: z.string().optional(), difficultyRating: z.string().optional(), -}) +}); // ─── v1 / Hunts / [id] / Reviews / [reviewId] / Moderate ──────────────────── export const reviewModerateBodySchema = z.object({ action: z.enum(["delete", "flag", "unflag"]), moderatorAddress: nonEmptyStringSchema, -}) +}); // ─── v1 / Seasons ──────────────────────────────────────────────────────────── @@ -306,19 +326,19 @@ export const seasonCreateBodySchema = z.object({ startTime: z.string().datetime({ message: "startTime must be a valid ISO 8601 datetime" }), endTime: z.string().datetime({ message: "endTime must be a valid ISO 8601 datetime" }), rewards: z.array(z.unknown()).optional(), -}) +}); export const seasonArchiveBodySchema = z.object({ finalLeaderboard: z.array(z.unknown()).min(0), -}) +}); // ─── v1 / Seasons / [id] ───────────────────────────────────────────────────── -export const seasonStatusSchema = z.enum(["Upcoming", "Active", "Ended"]) +export const seasonStatusSchema = z.enum(["Upcoming", "Active", "Ended"]); export const seasonPatchBodySchema = z.object({ status: seasonStatusSchema.optional(), -}) +}); // ─── v1 / Seasons / Badges ─────────────────────────────────────────────────── @@ -327,7 +347,7 @@ export const seasonBadgeBodySchema = z.object({ address: nonEmptyStringSchema, name: z.string().optional(), rank: z.number().int().min(1).optional(), -}) +}); // ─── v1 / Drafts ───────────────────────────────────────────────────────────── @@ -340,22 +360,22 @@ export const draftUpsertBodySchema = z.object({ rewards: z.array(z.unknown()).optional(), meta: z.record(z.string(), z.unknown()).optional(), recovered: z.boolean().optional(), -}) +}); export const draftListQuerySchema = z.object({ ownerKey: nonEmptyStringSchema, -}) +}); export const draftPatchBodySchema = z.object({ recovered: z.boolean().optional(), -}) +}); // ─── Paymaster / Sponsor ───────────────────────────────────────────────────── export const paymasterSponsorBodySchema = z.object({ txXdr: nonEmptyStringSchema, walletAddress: stellarAddressSchema, -}) +}); // ─── Paymaster / Admin config ──────────────────────────────────────────────── @@ -363,7 +383,7 @@ export const paymasterAdminConfigBodySchema = z.object({ maxSponsoredTx: z.number().int().min(0).nullable().optional(), maxBudgetPerUserStroops: z.number().int().min(0).nullable().optional(), maxFeePerTxStroops: z.number().int().min(0).nullable().optional(), -}) +}); // ─── Re-export convenience map ─────────────────────────────────────────────── @@ -379,6 +399,7 @@ export const apiSchemas = { performanceMetricBody: performanceMetricBodySchema, pushSendBody: pushSendBodySchema, pushTokenRegister: pushTokenRegisterBodySchema, + achievementShowcase: achievementShowcaseBodySchema, pushTokenDelete: pushTokenDeleteBodySchema, moderationSubmitBody: moderationSubmitBodySchema, moderationSyncBody: moderationSyncBodySchema, @@ -404,4 +425,4 @@ export const apiSchemas = { draftPatchBody: draftPatchBodySchema, paymasterSponsorBody: paymasterSponsorBodySchema, paymasterAdminConfigBody: paymasterAdminConfigBodySchema, -} as const +} as const;