1772 lines
58 KiB
Markdown
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'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`. |