Files
deckyvault/docs/superpowers/plans/2026-04-28-games-list-contact-seo.md

1772 lines
58 KiB
Markdown

# Games List, Contact Page & SEO — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a browsable games list page with search/filter/infinite scroll, a contact/report page with Discord webhook, and update SEO/sitemap coverage.
**Architecture:** Server components for initial data + SEO, client components for interactivity. New Elysia route for contact submission. Existing CRUD endpoint enhanced with a dedicated listing route for paginated games with stats. Discord webhook for contact form. Sitemap and robots.txt updated for new pages.
**Tech Stack:** Next.js 16 (App Router, Server Components), Elysia API, Drizzle ORM, Tailwind v4, Lucide React icons, motion/react (framer-motion)
---
## Phase 1: Games Listing API
### Task 1: Create games listing API endpoint
**Files:**
- Create: `lib/api/games-listing.ts`
- Modify: `lib/api/index.ts`
- Modify: `app/api/[[...slugs]]/route.ts`
- [ ] **Step 1: Create `lib/api/games-listing.ts`**
This endpoint returns paginated games enriched with benchmark count and platform support status, supporting search, genre filter, device filter, and sort.
```typescript
import { Elysia, t } from "elysia"
import { db } from "@/lib/db/index"
import {
games,
gameVersions,
performanceEntries,
gamePlatformSupport,
hardware,
} from "@/lib/db/schema"
import { ilike, or, sql, eq, and, desc, asc, inArray } from "drizzle-orm"
const MAX_OFFSET = 10000
const PAGE_SIZE = 24
export const gamesListingRoutes = new Elysia({ prefix: "/games/listing" }).get(
"/",
async ({ query, set }) => {
const offset = Math.min(Number(query.offset) || 0, MAX_OFFSET)
const limit = Math.min(Number(query.limit) || PAGE_SIZE, 100)
const search = query.search || ""
const genre = query.genre || ""
const device = query.device || ""
const sort = query.sort || "recent"
// Build where conditions
const conditions = []
if (search) {
const term = `%${search}%`
conditions.push(
or(
ilike(games.title, term),
ilike(games.developer, term),
ilike(games.publisher, term),
)!,
)
}
if (genre) {
conditions.push(sql`${games.genres} @> ${JSON.stringify([genre])}::jsonb`)
}
if (device) {
// Sub-select game IDs that have platform support for this device
const supportedIds = await db
.select({ gameId: gamePlatformSupport.gameId })
.from(gamePlatformSupport)
.where(
and(
eq(gamePlatformSupport.hardwareSlug, device),
eq(gamePlatformSupport.isSupported, true),
),
)
if (supportedIds.length > 0) {
conditions.push(inArray(games.id, supportedIds.map((s) => s.gameId)))
} else {
// No games support this device
return { data: [], total: 0, limit, offset, genres: [], devices: [] }
}
}
const where = conditions.length > 0 ? and(...conditions) : undefined
// Determine sort order
let orderBy
switch (sort) {
case "name":
orderBy = asc(games.title)
break
case "benchmarks":
orderBy = desc(sql`benchmark_count`)
break
case "recent":
default:
orderBy = desc(games.createdAt)
break
}
// Fetch games with benchmark counts
const gamesQuery = db
.select({
id: games.id,
steamAppId: games.steamAppId,
title: games.title,
developer: games.developer,
capsuleImage: games.capsuleImage,
headerImage: games.headerImage,
genres: games.genres,
source: games.source,
createdAt: games.createdAt,
benchmarkCount: sql<number>`(
SELECT count(*)::int FROM ${performanceEntries}
INNER JOIN ${gameVersions} ON ${performanceEntries.versionId} = ${gameVersions.id}
WHERE ${gameVersions.gameId} = ${games.id}
AND ${performanceEntries.isRemoved} = false
)`,
})
.from(games)
.where(where)
.orderBy(sort === "benchmarks" ? desc(sql`benchmark_count`) : orderBy)
.limit(limit)
.offset(offset)
// Count total
const countQuery = db
.select({ count: sql<number>`count(*)::int` })
.from(games)
.where(where)
// Fetch all genres (for filter options)
const genreRows = await db
.select({ genres: games.genres })
.from(games)
.where(sql`${games.genres} IS NOT NULL`)
const genreSet = new Set<string>()
for (const row of genreRows) {
if (Array.isArray(row.genres)) {
for (const g of row.genres) {
if (typeof g === "string") genreSet.add(g)
}
}
}
// Fetch all hardware devices (for filter options)
const deviceRows = await db
.select({ slug: hardware.slug, name: hardware.name })
.from(hardware)
.orderBy(hardware.sortOrder)
const [data, countResult] = await Promise.all([gamesQuery, countQuery])
// Fetch platform support for the returned games
const gameIds = data.map((g) => g.id)
let platformMap = new Map<string, string>()
if (gameIds.length > 0) {
const platformRows = await db
.select({
gameId: gamePlatformSupport.gameId,
protonStatus: gamePlatformSupport.protonStatus,
})
.from(gamePlatformSupport)
.where(inArray(gamePlatformSupport.gameId, gameIds))
for (const row of platformRows) {
// Use the first platform support entry found
if (!platformMap.has(row.gameId)) {
platformMap.set(row.gameId, row.protonStatus)
}
}
}
const enrichedData = data.map((g) => ({
id: g.id,
steamAppId: g.steamAppId,
title: g.title,
developer: g.developer,
capsuleImage: g.capsuleImage,
headerImage: g.headerImage,
genres: g.genres,
source: g.source,
benchmarkCount: g.benchmarkCount,
deckStatus: platformMap.get(g.id) ?? null,
}))
return {
data: enrichedData,
total: countResult[0]?.count ?? 0,
limit,
offset,
genres: Array.from(genreSet).sort(),
devices: deviceRows,
}
},
{
query: t.Object({
offset: t.Optional(t.String()),
limit: t.Optional(t.String()),
search: t.Optional(t.String()),
genre: t.Optional(t.String()),
device: t.Optional(t.String()),
sort: t.Optional(t.String()),
}),
},
)
```
- [ ] **Step 2: Export from `lib/api/index.ts`**
Add this line to the existing barrel export:
```typescript
export { gamesListingRoutes } from "./games-listing"
```
- [ ] **Step 3: Register route in `app/api/[[...slugs]]/route.ts`**
Add import:
```typescript
import { gamesListingRoutes } from "@/lib/api/games-listing"
```
Add `.use(gamesListingRoutes)` after `.use(gamesRoutes)`:
```typescript
// Games + Versions + Listing
.use(gamesRoutes)
.use(gameVersionsRoutes)
.use(gamesListingRoutes)
```
- [ ] **Step 4: Verify the API starts**
Run: `bun run dev` and confirm no compilation or startup errors. Hit `http://localhost:3000/api/games/listing` and verify it returns a JSON response with `data`, `total`, `genres`, `devices` fields.
- [ ] **Step 5: Commit**
```bash
git add lib/api/games-listing.ts lib/api/index.ts app/api/\[\[...slugs\]\]/route.ts
git commit -m "feat: add games listing API endpoint with search, genre/device filter, and sort"
```
---
## Phase 2: Games List Page
### Task 2: Create games list server component (`page.tsx`)
**Files:**
- Create: `app/games/page.tsx`
- [ ] **Step 1: Create `app/games/page.tsx`**
Server component that fetches initial games data, all genres, all devices, and renders metadata + JSON-LD. Passes serialized data to client component.
```typescript
import type { Metadata } from "next"
import { db } from "@/lib/db/index"
import {
games,
gameVersions,
performanceEntries,
gamePlatformSupport,
hardware,
} from "@/lib/db/schema"
import { sql, eq, and, desc, inArray } from "drizzle-orm"
import { GamesPageClient } from "./games-page-client"
export const metadata: Metadata = {
title: "Games — DeckyVault",
description:
"Browse the full catalog of Steam Deck games with benchmarks, community settings, and performance data. Filter by genre, device, and more.",
keywords: [
"Steam Deck games",
"game benchmarks",
"Steam Deck settings",
"game catalog",
"performance data",
],
alternates: { canonical: "https://deckyvault.xyz/games" },
openGraph: {
title: "Games — DeckyVault",
description:
"Browse the full catalog of Steam Deck games with benchmarks and performance data.",
url: "https://deckyvault.xyz/games",
siteName: "DeckyVault",
type: "website",
},
}
export default async function GamesPage() {
// Fetch initial 24 games with benchmark counts
const gamesData = await db
.select({
id: games.id,
steamAppId: games.steamAppId,
title: games.title,
developer: games.developer,
capsuleImage: games.capsuleImage,
headerImage: games.headerImage,
genres: games.genres,
source: games.source,
createdAt: games.createdAt,
benchmarkCount: sql<number>`(
SELECT count(*)::int FROM ${performanceEntries}
INNER JOIN ${gameVersions} ON ${performanceEntries.versionId} = ${gameVersions.id}
WHERE ${gameVersions.gameId} = ${games.id}
AND ${performanceEntries.isRemoved} = false
)`,
})
.from(games)
.orderBy(desc(games.createdAt))
.limit(24)
// Get total count
const [{ count: totalCount }] = await db
.select({ count: sql<number>`count(*)::int` })
.from(games)
// Fetch platform support for initial games
const gameIds = gamesData.map((g) => g.id)
const platformRows = gameIds.length > 0
? await db
.select({
gameId: gamePlatformSupport.gameId,
protonStatus: gamePlatformSupport.protonStatus,
})
.from(gamePlatformSupport)
.where(inArray(gamePlatformSupport.gameId, gameIds))
: []
const platformMap = new Map<string, string>()
for (const row of platformRows) {
if (!platformMap.has(row.gameId)) {
platformMap.set(row.gameId, row.protonStatus)
}
}
// Fetch all genres
const genreRows = await db
.select({ genres: games.genres })
.from(games)
.where(sql`${games.genres} IS NOT NULL`)
const genreSet = new Set<string>()
for (const row of genreRows) {
if (Array.isArray(row.genres)) {
for (const g of row.genres) {
if (typeof g === "string") genreSet.add(g)
}
}
}
// Fetch all hardware devices
const deviceRows = await db
.select({ slug: hardware.slug, name: hardware.name })
.from(hardware)
.orderBy(hardware.sortOrder)
// Serialize for client
const initialGames = gamesData.map((g) => ({
id: g.id,
steamAppId: g.steamAppId,
title: g.title,
developer: g.developer,
capsuleImage: g.capsuleImage,
headerImage: g.headerImage,
genres: g.genres,
source: g.source,
benchmarkCount: g.benchmarkCount,
deckStatus: platformMap.get(g.id) ?? null,
}))
const allGenres = Array.from(genreSet).sort()
const allDevices = deviceRows
// JSON-LD ItemList
const jsonLd = {
"@context": "https://schema.org",
"@type": "ItemList",
itemListElement: initialGames.map((game, i) => ({
"@type": "ListItem",
position: i + 1,
name: game.title,
url: `https://deckyvault.xyz/game/${game.id}`,
})),
}
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<GamesPageClient
initialGames={initialGames}
totalCount={totalCount}
allGenres={allGenres}
allDevices={allDevices}
/>
</>
)
}
```
- [ ] **Step 2: Verify server component compilation**
Run: `bun run dev` and confirm `/games` route loads without errors. It will error on missing `GamesPageClient` — that's expected.
- [ ] **Step 3: Commit**
```bash
git add app/games/page.tsx
git commit -m "feat: add games list page server component with SEO and initial data"
```
### Task 3: Create games list client component (`games-page-client.tsx`)
**Files:**
- Create: `app/games/games-page-client.tsx`
- [ ] **Step 1: Create `app/games/games-page-client.tsx`**
This is the main interactive component with search, genre filter pills, device filter pills, sort dropdown, infinite scroll, and compact game cards.
```typescript
"use client"
import { useState, useEffect, useRef, useCallback } from "react"
import Image from "next/image"
import Link from "next/link"
import { motion } from "motion/react"
import {
Gamepad2Icon,
SearchIcon,
TrendingUpIcon,
ChevronDownIcon,
XIcon,
Loader2Icon,
} from "lucide-react"
interface GamesListItem {
id: string
steamAppId: number | null
title: string
developer: string | null
capsuleImage: string | null
headerImage: string | null
genres: string[] | null
source: string
benchmarkCount: number
deckStatus: string | null
}
interface DeviceOption {
slug: string
name: string
}
type SortOption = "recent" | "name" | "benchmarks"
const DECK_STATUS_CONFIG: Record<string, { label: string; className: string }> = {
native: { label: "Native", className: "bg-green-500/10 border-green-500/20 text-green-400" },
proton: { label: "Proton", className: "bg-blue-500/10 border-blue-500/20 text-blue-400" },
unsupported: { label: "Unsupported", className: "bg-red-500/10 border-red-500/20 text-red-400" },
unknown: { label: "Unknown", className: "bg-text/5 border-border text-text/40" },
}
const SORT_OPTIONS: { value: SortOption; label: string }[] = [
{ value: "recent", label: "Recently Added" },
{ value: "name", label: "Name A\u2013Z" },
{ value: "benchmarks", label: "Most Benchmarks" },
]
export function GamesPageClient({
initialGames,
totalCount,
allGenres,
allDevices,
}: {
initialGames: GamesListItem[]
totalCount: number
allGenres: string[]
allDevices: DeviceOption[]
}) {
const [games, setGames] = useState<GamesListItem[]>(initialGames)
const [total, setTotal] = useState(totalCount)
const [search, setSearch] = useState("")
const [selectedGenres, setSelectedGenres] = useState<string[]>([])
const [selectedDevice, setSelectedDevice] = useState("")
const [sort, setSort] = useState<SortOption>("recent")
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [showFilters, setShowFilters] = useState(false)
const observerRef = useRef<IntersectionObserver | null>(null)
const sentinelRef = useRef<HTMLDivElement>(null)
const hasMore = games.length < total
const buildUrl = useCallback(
(offset: number) => {
const params = new URLSearchParams()
params.set("offset", String(offset))
params.set("limit", "24")
params.set("sort", sort)
if (search) params.set("search", search)
if (selectedDevice) params.set("device", selectedDevice)
if (selectedGenres.length > 0) {
// API takes a single genre; combine with client-side filtering if needed
// For simplicity, we support single genre filter on API
// But the UI has multi-select, so we post-filter client-side
// Actually, let's send the first selected genre for now
// and filter the rest client-side in a future enhancement
// For now, we only allow single genre to keep API simple
}
// Only one genre supported in API for now
if (selectedGenres.length === 1) {
params.set("genre", selectedGenres[0])
}
return `/api/games/listing?${params.toString()}`
},
[sort, search, selectedDevice, selectedGenres],
)
// Load more function for infinite scroll
const loadMore = useCallback(async () => {
if (loading || !hasMore) return
setLoading(true)
setError(null)
try {
const url = buildUrl(games.length)
const res = await fetch(url)
if (!res.ok) throw new Error("Failed to load games")
const data = await res.json()
setGames((prev) => [...prev, ...data.data])
setTotal(data.total)
} catch (err) {
setError("Failed to load more games. Please try again.")
console.error(err)
} finally {
setLoading(false)
}
}, [loading, hasMore, games.length, buildUrl])
// Full reload when filters/sort change
useEffect(() => {
let cancelled = false
setLoading(true)
setError(null)
async function fetchGames() {
try {
const url = buildUrl(0)
const res = await fetch(url)
if (!res.ok) throw new Error("Failed to load games")
const data = await res.json()
if (!cancelled) {
setGames(data.data)
setTotal(data.total)
}
} catch (err) {
if (!cancelled) setError("Failed to load games. Please try again.")
console.error(err)
} finally {
if (!cancelled) setLoading(false)
}
}
fetchGames()
return () => {
cancelled = true
}
}, [buildUrl])
// IntersectionObserver for infinite scroll
useEffect(() => {
if (observerRef.current) observerRef.current.disconnect()
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore && !loading) {
loadMore()
}
},
{ rootMargin: "200px" },
)
if (sentinelRef.current) {
observer.observe(sentinelRef.current)
}
observerRef.current = observer
return () => observer.disconnect()
}, [hasMore, loading, loadMore])
const toggleGenre = (genre: string) => {
setSelectedGenres((prev) =>
prev.includes(genre) ? prev.filter((g) => g !== genre) : [...prev, genre],
)
}
return (
<section className="w-full flex flex-col gap-8 py-8">
{/* Header */}
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
className="px-4 md:px-[10svw]"
>
<div className="max-w-7xl mx-auto">
<h1 className="text-2xl sm:text-3xl font-bold">Games</h1>
<p className="text-sm text-text/60 mt-1">
Browse {total.toLocaleString()} games with benchmarks, settings, and performance data
</p>
</div>
</motion.div>
{/* Search & Filter Bar */}
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.05 }}
className="px-4 md:px-[10svw]"
>
<div className="max-w-7xl mx-auto flex flex-col gap-3">
{/* Search + Sort Row */}
<div className="flex flex-row items-center gap-3">
<label className="flex-1 flex flex-row items-center gap-2 bg-text/5 px-3 py-2 rounded-md border border-border hover:border-border-active focus-within:border-primary/80 focus-within:ring-2 focus-within:ring-primary/50 focus-within:ring-offset-2 focus-within:ring-offset-background transition-colors cursor-text">
<SearchIcon className="h-4 w-4 text-text/40 shrink-0" />
<input
type="text"
placeholder="Search games..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="flex-1 outline-none bg-transparent text-sm min-w-0"
/>
{search && (
<button
onClick={() => setSearch("")}
className="text-text/40 hover:text-text/70 transition-colors cursor-pointer"
>
<XIcon className="h-3.5 w-3.5" />
</button>
)}
</label>
<select
value={sort}
onChange={(e) => setSort(e.target.value as SortOption)}
className="bg-text/5 border border-border rounded-md px-3 py-2 text-sm outline-none focus:border-primary/80 focus:ring-2 focus:ring-primary/50 cursor-pointer"
>
{SORT_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<button
onClick={() => setShowFilters(!showFilters)}
className={`px-3 py-2 rounded-md text-sm transition-colors cursor-pointer border ${
showFilters || selectedDevice || selectedGenres.length > 0
? "bg-primary/10 text-primary border-primary/30"
: "bg-text/5 text-text/60 hover:text-text/80 border-border hover:border-border-active"
}`}
>
Filters
{(selectedDevice || selectedGenres.length > 0) && (
<span className="ml-1.5 inline-flex items-center justify-center w-4 h-4 rounded-full bg-primary text-background text-[10px] font-bold">
{selectedGenres.length + (selectedDevice ? 1 : 0)}
</span>
)}
</button>
</div>
{/* Filter Panel (collapsible) */}
{showFilters && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
className="flex flex-col gap-3 pt-1"
>
{/* Device filter */}
<div>
<span className="text-xs text-text/50 uppercase tracking-wider mb-1.5 block">
Device
</span>
<div className="flex flex-wrap gap-1.5">
<button
onClick={() => setSelectedDevice("")}
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors cursor-pointer ${
selectedDevice === ""
? "bg-primary/10 text-primary border border-primary/30"
: "text-text/50 hover:text-text/70 hover:bg-text/5 border border-transparent"
}`}
>
All Devices
</button>
{allDevices.map((device) => (
<button
key={device.slug}
onClick={() =>
setSelectedDevice(selectedDevice === device.slug ? "" : device.slug)
}
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors cursor-pointer ${
selectedDevice === device.slug
? "bg-primary/10 text-primary border border-primary/30"
: "text-text/50 hover:text-text/70 hover:bg-text/5 border border-transparent"
}`}
>
{device.name}
</button>
))}
</div>
</div>
{/* Genre filter */}
<div>
<span className="text-xs text-text/50 uppercase tracking-wider mb-1.5 block">
Genre
</span>
<div className="flex flex-wrap gap-1.5 max-h-32 overflow-y-auto scrollbar-hide">
{allGenres.map((genre) => (
<button
key={genre}
onClick={() => toggleGenre(genre)}
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors cursor-pointer ${
selectedGenres.includes(genre)
? "bg-primary/10 text-primary border border-primary/30"
: "text-text/50 hover:text-text/70 hover:bg-text/5 border border-transparent"
}`}
>
{genre}
</button>
))}
</div>
</div>
{/* Clear filters */}
{(selectedDevice || selectedGenres.length > 0) && (
<button
onClick={() => {
setSelectedDevice("")
setSelectedGenres([])
}}
className="text-xs text-text/50 hover:text-primary transition-colors cursor-pointer self-start"
>
Clear all filters
</button>
)}
</motion.div>
)}
</div>
</motion.div>
{/* Error State */}
{error && !loading && (
<div className="max-w-7xl mx-auto px-4 md:px-0 text-center py-12">
<p className="text-red-400 text-sm">{error}</p>
<button
onClick={() => window.location.reload()}
className="mt-2 text-xs text-text/50 hover:text-primary transition-colors cursor-pointer"
>
Try again
</button>
</div>
)}
{/* Games Grid */}
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.1 }}
className="px-4 md:px-[10svw]"
>
<div className="max-w-7xl mx-auto grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3 sm:gap-4">
{games.map((game) => (
<GameCard key={game.id} game={game} />
))}
</div>
{/* Empty state */}
{!loading && games.length === 0 && !error && (
<div className="flex flex-col items-center justify-center py-20 gap-4">
<Gamepad2Icon className="h-12 w-12 text-text/20" />
<p className="text-text/40 text-sm">
{search || selectedDevice || selectedGenres.length > 0
? "No games match your filters"
: "No games found"}
</p>
{(search || selectedDevice || selectedGenres.length > 0) && (
<button
onClick={() => {
setSearch("")
setSelectedDevice("")
setSelectedGenres([])
}}
className="text-xs text-primary hover:text-primary/80 transition-colors cursor-pointer"
>
Clear filters
</button>
)}
</div>
)}
</motion.div>
{/* Loading indicator for infinite scroll */}
{loading && (
<div className="flex items-center justify-center py-8">
<Loader2Icon className="h-6 w-6 text-primary animate-spin" />
</div>
)}
{/* End of list */}
{!loading && !hasMore && games.length > 0 && (
<div className="text-center py-6">
<p className="text-text/30 text-xs">
Showing all {games.length} of {total.toLocaleString()} games
</p>
</div>
)}
{/* Infinite scroll sentinel */}
{hasMore && !loading && <div ref={sentinelRef} className="h-1" />}
</section>
)
}
function GameCard({ game }: { game: GamesListItem }) {
const [imgError, setImgError] = useState(false)
const imageUrl = game.capsuleImage || game.headerImage
const deckConfig = game.deckStatus
? DECK_STATUS_CONFIG[game.deckStatus] ?? DECK_STATUS_CONFIG.unknown
: null
return (
<Link
href={`/game/${game.id}`}
className="group rounded-xl border border-border bg-text/3 hover:border-primary/30 transition-all duration-200 overflow-hidden"
>
<div className="relative w-full aspect-[2/3] bg-text/10 overflow-hidden">
{imageUrl && !imgError ? (
<Image
src={imageUrl}
alt={game.title}
fill
className="object-cover group-hover:scale-105 transition-transform duration-300"
sizes="(max-width: 640px) 50vw, (max-width: 768px) 33vw, (max-width: 1024px) 25vw, 20vw"
onError={() => setImgError(true)}
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<Gamepad2Icon className="h-8 w-8 text-text/20" />
</div>
)}
</div>
<div className="p-2.5 sm:p-3">
<h3 className="text-xs sm:text-sm font-semibold text-text group-hover:text-primary transition-colors line-clamp-2 leading-tight">
{game.title}
</h3>
<div className="mt-1.5 flex items-center gap-2 flex-wrap">
{game.benchmarkCount > 0 ? (
<span className="inline-flex items-center gap-1 text-[10px] text-text/50">
<TrendingUpIcon className="h-3 w-3 text-primary/60" />
<span className="tabular-nums">{game.benchmarkCount}</span>
</span>
) : (
<span className="text-[10px] text-text/25 italic">No data yet</span>
)}
{deckConfig && (
<span
className={`text-[9px] px-1.5 py-0.5 rounded-full border ${deckConfig.className}`}
>
{deckConfig.label}
</span>
)}
</div>
</div>
</Link>
)
}
```
- [ ] **Step 2: Verify the games page renders**
Run: `bun run dev` and visit `/games`. Confirm games render in a grid with images, search, filter, and infinite scroll behavior.
- [ ] **Step 3: Commit**
```bash
git add app/games/games-page-client.tsx
git commit -m "feat: add games list client component with search, filters, and infinite scroll"
```
---
## Phase 3: Contact API (Discord Webhook)
### Task 4: Create Discord webhook API route
**Files:**
- Create: `lib/api/contact.ts`
- Modify: `lib/api/index.ts`
- Modify: `app/api/[[...slugs]]/route.ts`
- Modify: `.env.example`
- [ ] **Step 1: Create `lib/api/contact.ts`**
Elysia route handling form submission, validation, honeypot, timing check, IP rate limiting, and Discord webhook dispatch.
```typescript
import { Elysia, t } from "elysia"
// ── In-memory rate limiter for contact form ──────────────────────
const contactLimiter = new Map<string, { count: number; resetAt: number }>()
const RATE_LIMIT_MAX = 3
const RATE_LIMIT_WINDOW = 60 * 60 * 1000 // 1 hour in ms
// Clean up expired entries every 5 minutes
setInterval(() => {
const now = Date.now()
for (const [key, entry] of contactLimiter) {
if (now > entry.resetAt) contactLimiter.delete(key)
}
}, 5 * 60 * 1000)
function getClientIP(request: Request): string {
const forwarded = request.headers.get("x-forwarded-for")
if (forwarded) return forwarded.split(",")[0].trim()
return "unknown"
}
function checkContactRateLimit(ip: string): { allowed: boolean; retryAfter: number } {
const now = Date.now()
const entry = contactLimiter.get(ip)
if (!entry || now > entry.resetAt) {
contactLimiter.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW })
return { allowed: true, retryAfter: 0 }
}
if (entry.count >= RATE_LIMIT_MAX) {
const retryAfter = Math.ceil((entry.resetAt - now) / 1000)
return { allowed: false, retryAfter }
}
entry.count++
return { allowed: true, retryAfter: 0 }
}
// ── Discord embed colors by category ─────────────────────────────
const CATEGORY_COLORS: Record<string, number> = {
bug: 0xe74c3c, // red
game_data: 0xf1c40f, // yellow
user_report: 0x3498db, // blue
feature: 0x2ecc71, // green
feedback: 0x95a5a6, // grey
database: 0xe74c3c, // red
}
const CATEGORY_LABELS: Record<string, string> = {
bug: "Bug Report",
game_data: "Game Data Issue",
user_report: "User Report",
feature: "Feature Request",
feedback: "General Feedback",
database: "Database Error",
}
interface ContactPayload {
category: string
name?: string
email?: string
subject: string
message: string
gameUrl?: string
honeypot?: string
_timestamp?: string
}
export const contactRoutes = new Elysia({ prefix: "/contact" }).post(
"/",
async ({ body, request, set }) => {
const payload = body as ContactPayload
// ── Honeypot check ──────────────────────────────────────────
if (payload.honeypot) {
set.status = 200
return { success: true } // Silently accept but discard
}
// ── Timing check (must take > 3 seconds) ───────────────────
if (payload._timestamp) {
const start = Number(payload._timestamp)
if (!isNaN(start) && Date.now() - start < 3000) {
set.status = 200
return { success: true } // Silently discard
}
}
// ── Rate limit ──────────────────────────────────────────────
const ip = getClientIP(request)
const rateCheck = checkContactRateLimit(ip)
if (!rateCheck.allowed) {
set.status = 429
return { error: "Too many submissions. Please try again later.", retryAfter: rateCheck.retryAfter }
}
// ── Validate category ──────────────────────────────────────
const validCategories = ["bug", "game_data", "user_report", "feature", "feedback", "database"]
if (!validCategories.includes(payload.category)) {
set.status = 400
return { error: "Invalid category" }
}
// ── Validate required fields ───────────────────────────────
if (!payload.subject || payload.subject.trim().length === 0) {
set.status = 400
return { error: "Subject is required" }
}
if (payload.subject.length > 200) {
set.status = 400
return { error: "Subject must be 200 characters or less" }
}
if (!payload.message || payload.message.trim().length === 0) {
set.status = 400
return { error: "Message is required" }
}
if (payload.message.length > 2000) {
set.status = 400
return { error: "Message must be 2000 characters or less" }
}
// ── Validate game URL for game_data category ────────────────
if (payload.category === "game_data" && payload.gameUrl) {
if (!payload.gameUrl.includes("/game/")) {
set.status = 400
return { error: "Game URL must be a valid DeckyVault game link" }
}
}
// ── Validate email format if provided ───────────────────────
if (payload.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(payload.email)) {
set.status = 400
return { error: "Invalid email format" }
}
// ── Send to Discord webhook ─────────────────────────────────
const webhookUrl = process.env.DISCORD_WEBHOOK_URL
if (!webhookUrl) {
console.error("[Contact] DISCORD_WEBHOOK_URL not configured")
set.status = 500
return { error: "Service not configured. Please try again later." }
}
const embed = {
title: `[${CATEGORY_LABELS[payload.category]}] ${payload.subject}`,
description: payload.message.slice(0, 4096),
color: CATEGORY_COLORS[payload.category] ?? 0x95a5a6,
fields: [
...(payload.name ? [{ name: "Name", value: payload.name, inline: true }] : []),
...(payload.email ? [{ name: "Email", value: payload.email, inline: true }] : []),
...(payload.gameUrl ? [{ name: "Game URL", value: payload.gameUrl, inline: false }] : []),
{ name: "IP Hash", value: `\`${ip.slice(0, 8)}...\``, inline: true },
],
timestamp: new Date().toISOString(),
}
try {
const res = await fetch(webhookUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ embeds: [embed] }),
})
if (!res.ok) {
console.error("[Contact] Discord webhook failed:", res.status, await res.text())
set.status = 500
return { error: "Failed to submit. Please try again later." }
}
} catch (err) {
console.error("[Contact] Discord webhook error:", err)
set.status = 500
return { error: "Failed to submit. Please try again later." }
}
return { success: true }
},
{
body: t.Object({
category: t.String(),
name: t.Optional(t.String()),
email: t.Optional(t.String()),
subject: t.String(),
message: t.String(),
gameUrl: t.Optional(t.String()),
honeypot: t.Optional(t.String()),
_timestamp: t.Optional(t.String()),
}),
},
)
```
- [ ] **Step 2: Export from `lib/api/index.ts`**
Add this line:
```typescript
export { contactRoutes } from "./contact"
```
- [ ] **Step 3: Register route in `app/api/[[...slugs]]/route.ts`**
Add import:
```typescript
import { contactRoutes } from "@/lib/api/contact"
```
Add `.use(contactRoutes)` — place it near the end before the root route:
```typescript
// Contact form
.use(contactRoutes)
// Root
.get("/", () => ({
name: "DeckyVault API",
version: "2026.0.1",
}))
```
- [ ] **Step 4: Update `.env.example`**
Add to the bottom of `.env.example`:
```
# -----------------------------------------------------------------------------
# CONTACT FORM
# -----------------------------------------------------------------------------
# Discord webhook URL for contact/report submissions
DISCORD_WEBHOOK_URL=""
```
- [ ] **Step 5: Test the contact endpoint**
Run: `bun run dev` and test with curl:
```bash
curl -X POST http://localhost:3000/api/contact \
-H "Content-Type: application/json" \
-d '{"category":"feedback","subject":"Test","message":"Hello from test"}'
```
Expected: `{ "success": true }` (if `DISCORD_WEBHOOK_URL` is set) or a 500 error (if not set yet).
- [ ] **Step 6: Commit**
```bash
git add lib/api/contact.ts lib/api/index.ts app/api/\[\[...slugs\]\]/route.ts .env.example
git commit -m "feat: add contact form API with Discord webhook, rate limiting, and anti-bot checks"
```
---
## Phase 4: Contact Page
### Task 5: Create contact page
**Files:**
- Create: `app/contact/page.tsx`
- [ ] **Step 1: Create `app/contact/page.tsx`**
Client component with the contact/report form. Includes category selection, validation, honeypot, timestamp, and submit logic.
```typescript
"use client"
import { useState, useRef } from "react"
import { motion } from "motion/react"
import {
SendIcon,
BugIcon,
DatabaseIcon,
FlagIcon,
LightbulbIcon,
MessageSquareIcon,
AlertTriangleIcon,
CheckCircleIcon,
Loader2Icon,
} from "lucide-react"
type Category = "bug" | "game_data" | "user_report" | "feature" | "feedback" | "database"
interface CategoryOption {
id: Category
label: string
icon: React.ElementType
color: string
}
const CATEGORIES: CategoryOption[] = [
{ id: "bug", label: "Bug Report", icon: BugIcon, color: "text-red-400 border-red-500/20 bg-red-500/5 hover:bg-red-500/10" },
{ id: "game_data", label: "Game Data Issue", icon: AlertTriangleIcon, color: "text-yellow-400 border-yellow-500/20 bg-yellow-500/5 hover:bg-yellow-500/10" },
{ id: "user_report", label: "User Report", icon: FlagIcon, color: "text-blue-400 border-blue-500/20 bg-blue-500/5 hover:bg-blue-500/10" },
{ id: "feature", label: "Feature Request", icon: LightbulbIcon, color: "text-green-400 border-green-500/20 bg-green-500/5 hover:bg-green-500/10" },
{ id: "feedback", label: "General Feedback", icon: MessageSquareIcon, color: "text-text/60 border-border bg-text/3 hover:bg-text/6" },
{ id: "database", label: "Database Error", icon: DatabaseIcon, color: "text-red-400 border-red-500/20 bg-red-500/5 hover:bg-red-500/10" },
]
export default function ContactPage() {
const [category, setCategory] = useState<Category | "">("")
const [name, setName] = useState("")
const [email, setEmail] = useState("")
const [subject, setSubject] = useState("")
const [message, setMessage] = useState("")
const [gameUrl, setGameUrl] = useState("")
const [honeypot, setHoneypot] = useState("")
const formRef = useRef<HTMLFormElement>(null)
const timestampRef = useRef<string>("")
const [submitting, setSubmitting] = useState(false)
const [submitted, setSubmitted] = useState(false)
const [error, setError] = useState<string | null>(null)
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({})
// Set timestamp when user starts interacting
const startTimestamp = () => {
if (!timestampRef.current) {
timestampRef.current = Date.now().toString()
}
}
const validate = (): boolean => {
const errors: Record<string, string> = {}
if (!category) errors.category = "Please select a category"
if (!subject.trim()) errors.subject = "Subject is required"
else if (subject.length > 200) errors.subject = "Subject must be 200 characters or less"
if (!message.trim()) errors.message = "Message is required"
else if (message.length > 2000) errors.message = "Message must be 2000 characters or less"
if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) errors.email = "Invalid email format"
if (category === "game_data" && gameUrl && !gameUrl.includes("/game/")) errors.gameUrl = "Please provide a valid DeckyVault game link"
setFieldErrors(errors)
return Object.keys(errors).length === 0
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
startTimestamp()
if (!validate()) return
setSubmitting(true)
setError(null)
try {
const res = await fetch("/api/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
category,
name: name || undefined,
email: email || undefined,
subject: subject.trim(),
message: message.trim(),
gameUrl: category === "game_data" ? gameUrl || undefined : undefined,
honeypot,
_timestamp: timestampRef.current || undefined,
}),
})
const data = await res.json()
if (!res.ok) {
if (res.status === 429) {
setError("You've sent too many messages. Please try again later.")
} else {
setError(data.error || "Something went wrong. Please try again.")
}
return
}
setSubmitted(true)
} catch {
setError("Network error. Please check your connection and try again.")
} finally {
setSubmitting(false)
}
}
if (submitted) {
return (
<section className="w-full flex flex-col items-center justify-center py-20 p-4">
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="max-w-md w-full text-center space-y-4"
>
<CheckCircleIcon className="h-12 w-12 text-green-400 mx-auto" />
<h1 className="text-2xl font-bold">Message Sent</h1>
<p className="text-text/60 text-sm">
Thank you for reaching out. We&apos;ll review your message as soon as possible.
</p>
<button
onClick={() => {
setSubmitted(false)
setCategory("")
setSubject("")
setMessage("")
setGameUrl("")
setName("")
setEmail("")
setHoneypot("")
timestampRef.current = ""
setFieldErrors({})
}}
className="px-4 py-2 rounded-md bg-text/5 border border-border text-sm hover:bg-text/10 transition-colors cursor-pointer"
>
Send another message
</button>
</motion.div>
</section>
)
}
return (
<section className="w-full flex flex-col items-center py-16 p-4">
<div className="max-w-lg w-full space-y-8">
{/* Header */}
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
>
<h1 className="text-2xl sm:text-3xl font-bold">Contact & Report</h1>
<p className="text-sm text-text/60 mt-1">
Report issues, suggest features, or send us feedback.
</p>
</motion.div>
<motion.form
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.05 }}
onSubmit={handleSubmit}
className="space-y-5"
>
{/* Honeypot */}
<input
type="text"
name="honeypot"
value={honeypot}
onChange={(e) => setHoneypot(e.target.value)}
tabIndex={-1}
autoComplete="off"
style={{ position: "absolute", opacity: 0, pointerEvents: "none" }}
aria-hidden="true"
/>
{/* Category */}
<fieldset>
<legend className="text-xs text-text/50 uppercase tracking-wider mb-2">
Category <span className="text-primary">*</span>
</legend>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{CATEGORIES.map((cat) => (
<button
key={cat.id}
type="button"
onClick={() => {
setCategory(cat.id)
startTimestamp()
setFieldErrors((prev) => {
const next = { ...prev }
delete next.category
return next
})
}}
className={`flex items-center gap-1.5 px-3 py-2 rounded-lg border text-xs font-medium transition-colors cursor-pointer ${
category === cat.id
? cat.color + " ring-1 ring-current"
: "text-text/50 border-border bg-text/3 hover:bg-text/6"
}`}
>
<cat.icon className="h-3.5 w-3.5" />
{cat.label}
</button>
))}
</div>
{fieldErrors.category && (
<p className="text-red-400 text-xs mt-1">{fieldErrors.category}</p>
)}
</fieldset>
{/* Name & Email (optional) */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label htmlFor="contact-name" className="text-xs text-text/50 uppercase tracking-wider mb-1.5 block">
Name <span className="text-text/30">(optional)</span>
</label>
<input
id="contact-name"
type="text"
value={name}
onChange={(e) => { setName(e.target.value); startTimestamp() }}
placeholder="Your name"
className="w-full bg-text/5 border border-border rounded-md px-3 py-2 text-sm outline-none focus:border-primary/80 focus:ring-2 focus:ring-primary/50 focus:ring-offset-2 focus:ring-offset-background transition-colors"
/>
</div>
<div>
<label htmlFor="contact-email" className="text-xs text-text/50 uppercase tracking-wider mb-1.5 block">
Email <span className="text-text/30">(optional)</span>
</label>
<input
id="contact-email"
type="email"
value={email}
onChange={(e) => { setEmail(e.target.value); startTimestamp() }}
placeholder="you@example.com"
className="w-full bg-text/5 border border-border rounded-md px-3 py-2 text-sm outline-none focus:border-primary/80 focus:ring-2 focus:ring-primary/50 focus:ring-offset-2 focus:ring-offset-background transition-colors"
/>
{fieldErrors.email && (
<p className="text-red-400 text-xs mt-1">{fieldErrors.email}</p>
)}
</div>
</div>
{/* Subject */}
<div>
<label htmlFor="contact-subject" className="text-xs text-text/50 uppercase tracking-wider mb-1.5 block">
Subject <span className="text-primary">*</span>
</label>
<input
id="contact-subject"
type="text"
value={subject}
onChange={(e) => {
setSubject(e.target.value)
startTimestamp()
if (fieldErrors.subject) setFieldErrors((prev) => { const next = { ...prev }; delete next.subject; return next })
}}
placeholder="Brief description of your issue"
maxLength={200}
className="w-full bg-text/5 border border-border rounded-md px-3 py-2 text-sm outline-none focus:border-primary/80 focus:ring-2 focus:ring-primary/50 focus:ring-offset-2 focus:ring-offset-background transition-colors"
/>
{fieldErrors.subject && (
<p className="text-red-400 text-xs mt-1">{fieldErrors.subject}</p>
)}
</div>
{/* Game URL (conditional) */}
{category === "game_data" && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
>
<label htmlFor="contact-game-url" className="text-xs text-text/50 uppercase tracking-wider mb-1.5 block">
Game URL <span className="text-text/30">(link to the game page)</span>
</label>
<input
id="contact-game-url"
type="url"
value={gameUrl}
onChange={(e) => setGameUrl(e.target.value)}
placeholder="https://deckyvault.xyz/game/..."
className="w-full bg-text/5 border border-border rounded-md px-3 py-2 text-sm outline-none focus:border-primary/80 focus:ring-2 focus:ring-primary/50 focus:ring-offset-2 focus:ring-offset-background transition-colors"
/>
{fieldErrors.gameUrl && (
<p className="text-red-400 text-xs mt-1">{fieldErrors.gameUrl}</p>
)}
</motion.div>
)}
{/* Message */}
<div>
<label htmlFor="contact-message" className="text-xs text-text/50 uppercase tracking-wider mb-1.5 block">
Message <span className="text-primary">*</span>
</label>
<textarea
id="contact-message"
value={message}
onChange={(e) => {
setMessage(e.target.value)
startTimestamp()
if (fieldErrors.message) setFieldErrors((prev) => { const next = { ...prev }; delete next.message; return next })
}}
placeholder="Describe your issue, suggestion, or feedback in detail..."
rows={5}
maxLength={2000}
className="w-full bg-text/5 border border-border rounded-md px-3 py-2 text-sm outline-none focus:border-primary/80 focus:ring-2 focus:ring-primary/50 focus:ring-offset-2 focus:ring-offset-background transition-colors resize-y"
/>
<div className="flex justify-between items-center mt-1">
{fieldErrors.message ? (
<p className="text-red-400 text-xs">{fieldErrors.message}</p>
) : (
<span />
)}
<span className="text-[10px] text-text/30 tabular-nums">{message.length}/2000</span>
</div>
</div>
{/* Error */}
{error && (
<div className="text-red-400 text-sm bg-red-500/10 border border-red-500/20 rounded-md px-3 py-2">
{error}
</div>
)}
{/* Submit */}
<button
type="submit"
disabled={submitting || !category}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-md bg-primary text-background font-semibold text-sm hover:bg-primary/80 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
>
{submitting ? (
<>
<Loader2Icon className="h-4 w-4 animate-spin" />
Sending...
</>
) : (
<>
<SendIcon className="h-4 w-4" />
Send Message
</>
)}
</button>
</motion.form>
</div>
</section>
)
}
```
- [ ] **Step 2: Add metadata export for SEO**
Add `noindex` metadata at the top of `app/contact/page.tsx`. The file is a `"use client"` component, so we need a separate layout or move the `metadata` export. Since `metadata` must be exported from a server component, create a small `layout.tsx`:
Create `app/contact/layout.tsx`:
```typescript
import type { Metadata } from "next"
export const metadata: Metadata = {
title: "Contact & Report",
description: "Report issues, suggest features, or send feedback to the DeckyVault team.",
robots: { index: false, follow: true },
}
export default function ContactLayout({ children }: { children: React.ReactNode }) {
return children
}
```
- [ ] **Step 3: Verify the contact page renders and submits**
Run: `bun run dev` and visit `/contact`. Test form validation, category selection, conditional game URL field, and submission.
- [ ] **Step 4: Commit**
```bash
git add app/contact/page.tsx app/contact/layout.tsx
git commit -m "feat: add contact/report page with Discord webhook integration"
```
---
## Phase 5: Navigation & Sitemap
### Task 6: Update navigation routes
**Files:**
- Modify: `lib/routes.ts`
The routes file already has a `/games` entry. We need to add `/contact`.
- [ ] **Step 1: Update `lib/routes.ts`**
Add the contact route to the `routes` array:
```typescript
export const routes = [
{
title: "Home",
href: "/",
},
{
title: "Search",
href: "/search",
},
{
title: "Games",
href: "/games",
},
{
title: "Devices",
href: "/devices",
},
{
title: "Contact",
href: "/contact",
},
]
```
- [ ] **Step 2: Verify navigation includes Contact link**
Run: `bun run dev` and confirm the Contact link appears in the navbar (both desktop and mobile).
- [ ] **Step 3: Commit**
```bash
git add lib/routes.ts
git commit -m "feat: add Contact route to navigation"
```
### Task 7: Update sitemap and robots.ts
**Files:**
- Modify: `app/sitemap.ts`
- Modify: `app/robots.ts`
- [ ] **Step 1: Update `app/sitemap.ts`**
Add `/games`, `/contact`, `/login`, `/signup` entries and enhance game entries with images.
```typescript
import type { MetadataRoute } from "next"
import { db } from "@/lib/db/index"
import { games, hardware } from "@/lib/db/schema"
const BASE_URL = "https://deckyvault.xyz"
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const [allGames, allDevices] = await Promise.all([
db.select({ id: games.id, updatedAt: games.updatedAt, capsuleImage: games.capsuleImage }).from(games),
db.select({ slug: hardware.slug, createdAt: hardware.createdAt }).from(hardware),
])
const gameEntries: MetadataRoute.Sitemap = allGames.map((game) => ({
url: `${BASE_URL}/game/${game.id}`,
lastModified: game.updatedAt,
changeFrequency: "weekly",
priority: 0.8,
images: game.capsuleImage ? [{ url: game.capsuleImage }] : undefined,
}))
const deviceEntries: MetadataRoute.Sitemap = allDevices.map((device) => ({
url: `${BASE_URL}/devices/${device.slug}`,
lastModified: device.createdAt,
changeFrequency: "monthly",
priority: 0.6,
}))
return [
{ url: BASE_URL, lastModified: new Date(), changeFrequency: "weekly", priority: 1 },
{ url: `${BASE_URL}/games`, lastModified: new Date(), changeFrequency: "weekly", priority: 0.7 },
{ url: `${BASE_URL}/devices`, lastModified: new Date(), changeFrequency: "monthly", priority: 0.6 },
{ url: `${BASE_URL}/contact`, lastModified: new Date(), changeFrequency: "monthly", priority: 0.3 },
{ url: `${BASE_URL}/login`, lastModified: new Date(), changeFrequency: "monthly", priority: 0.3 },
{ url: `${BASE_URL}/signup`, lastModified: new Date(), changeFrequency: "monthly", priority: 0.3 },
...gameEntries,
...deviceEntries,
]
}
```
- [ ] **Step 2: Update `app/robots.ts`**
Add disallow rules for `/admin` and `/api`:
```typescript
import type { MetadataRoute } from "next"
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: "*",
allow: "/",
disallow: ["/admin", "/api"],
},
],
sitemap: "https://deckyvault.xyz/sitemap.xml",
host: "https://deckyvault.xyz",
}
}
```
- [ ] **Step 3: Verify sitemap and robots**
Run: `bun run dev` and check:
- Visit `http://localhost:3000/sitemap.xml` — should include `/games`, `/contact`, `/login`, `/signup`
- Visit `http://localhost:3000/robots.txt` — should include `Disallow: /admin` and `Disallow: /api`
- [ ] **Step 4: Commit**
```bash
git add app/sitemap.ts app/robots.ts
git commit -m "feat: update sitemap with new pages and add robots.txt disallow rules"
```
---
## Phase 6: Verification
### Task 8: Build and lint check
- [ ] **Step 1: Run TypeScript type check**
```bash
npx tsc --noEmit
```
Expected: 0 errors. If there are errors, fix them before proceeding.
- [ ] **Step 2: Run ESLint**
```bash
bun run lint
```
Expected: 0 errors. If there are warnings or errors, fix them.
- [ ] **Step 3: Run Next.js build**
```bash
bun run build
```
Expected: Build succeeds without errors.
- [ ] **Step 4: Run tests**
```bash
bun run test
```
Expected: All existing tests pass (no new tests added in this plan since the features are primarily UI/API without test infrastructure).
- [ ] **Step 5: Final commit if any fixes were needed**
```bash
git add -A
git commit -m "fix: resolve build, lint, and type errors"
```
---
## Self-Review Checklist
**Spec coverage:**
- ✅ Games list page with search, genre/device/sort filters, infinite scroll, compact cards
- ✅ Contact page with Discord webhook, category selector, honeypot, timing check, IP rate limiting
- ✅ SEO: generateMetadata for games, noindex for contact, JSON-LD ItemList for games
- ✅ Sitemap: added `/games`, `/contact`, `/login`, `/signup`; enhanced game entries with images
- ✅ Robots.txt: added `/admin` and `/api` disallow rules
- ✅ Navigation: Contact route added
**Placeholder scan:** No TBDs, TODOs, or vague steps. All code is complete.
**Type consistency:** All interfaces match across server → client serialization. API request/response types align between `lib/api/contact.ts` and `app/contact/page.tsx`. Games listing types align between `lib/api/games-listing.ts` and `app/games/games-page-client.tsx`.