diff --git a/app/game/[id]/page.tsx b/app/game/[id]/page.tsx
index 09b178b..2cd1395 100644
--- a/app/game/[id]/page.tsx
+++ b/app/game/[id]/page.tsx
@@ -39,23 +39,27 @@ async function createGameStub(steamAppId: number) {
const data = (await res.json()) as Record<
string,
{ success: boolean; data: {
+ type?: string
name: string
developers?: string[]
publishers?: string[]
genres?: { description: string }[]
header_image?: string
- capsule_imagev5?: string
short_description?: string
} }
>
const entry = data[String(steamAppId)]
if (entry?.success && entry.data) {
+ // Reject non-games (DLCs, soundtracks, demos, etc.)
+ if (entry.data.type && entry.data.type !== "game") {
+ notFound()
+ }
title = entry.data.name
developer = entry.data.developers?.[0] ?? null
publisher = entry.data.publishers?.[0] ?? null
genres = entry.data.genres?.map((g) => g.description) ?? []
headerImage = entry.data.header_image ?? null
- capsuleImage = entry.data.capsule_imagev5 ?? entry.data.header_image ?? null
+ capsuleImage = `https://cdn.akamai.steamstatic.com/steam/apps/${steamAppId}/library_600x900.jpg`
description = entry.data.short_description ?? null
}
}
diff --git a/app/search/page.tsx b/app/search/page.tsx
index 92176dd..e23d01e 100644
--- a/app/search/page.tsx
+++ b/app/search/page.tsx
@@ -1,239 +1,578 @@
"use client"
-import { Suspense, useEffect, useState } from "react"
+import { Suspense, useState, useEffect } from "react"
import { useRouter, useSearchParams } from "next/navigation"
-import { ExternalLinkIcon, Gamepad2Icon, MessageSquareIcon, SettingsIcon, TrendingUpIcon } from "lucide-react"
+import { motion, AnimatePresence } from "motion/react"
+import {
+ ExternalLinkIcon,
+ Gamepad2Icon,
+ MessageSquareIcon,
+ MonitorIcon,
+ SettingsIcon,
+ TrendingUpIcon,
+ DatabaseIcon,
+ SparklesIcon,
+} from "lucide-react"
+import { FaSteam } from "react-icons/fa"
import Image from "next/image"
+import { WindowsIcon, MacIcon, LinuxIcon } from "@/app/components/PlatformIcons"
interface UnifiedResult {
- kind: "local" | "steam"
- id?: string
- appId: number | null
- title: string
- image: string | null
- developer: string | null
- publisher: string | null
- source: string
- counts: { benchmarks: number; presets: number; comments: number } | null
+ kind: "local" | "steam"
+ id?: string
+ appId: number | null
+ title: string
+ image: string | null
+ developer: string | null
+ publisher: string | null
+ description: string | null
+ genres: string[] | null
+ source: string
+ counts: { benchmarks: number; presets: number; comments: number } | null
+ platformSupport: {
+ isSupported: boolean
+ protonStatus: string
+ antiCheatRelevant: boolean
+ antiCheatName: string | null
+ antiCheatStatus: string
+ } | null
+ metascore?: string | null
+ price?: { currency: string; initial: number; final: number } | null
+ platforms?: { windows: boolean; mac: boolean; linux: boolean } | null
+ controllerSupport?: string | null
}
function SearchContent() {
- const searchParams = useSearchParams()
- const router = useRouter()
- const query = searchParams.get("q") || ""
+ const searchParams = useSearchParams()
+ const router = useRouter()
+ const query = searchParams.get("q") || ""
- const [results, setResults] = useState
([])
- const [loading, setLoading] = useState(false)
- const [error, setError] = useState(null)
-
- useEffect(() => {
- if (!query) {
- setResults([])
- return
- }
-
- let cancelled = false
-
- async function fetchResults() {
- setLoading(true)
- setError(null)
-
- try {
- const res = await fetch(
- `/api/search/unified?q=${encodeURIComponent(query)}`,
- )
- if (!res.ok) throw new Error(await res.text())
- const data = await res.json()
- if (!cancelled) setResults(data.results || [])
- } catch (err) {
- if (!cancelled) {
- setError("Failed to fetch search results")
- console.error(err)
- }
- } finally {
- if (!cancelled) setLoading(false)
- }
- }
-
- fetchResults()
- return () => { cancelled = true }
- }, [query])
-
- function handleClick(result: UnifiedResult) {
- const path = result.appId
- ? `/game/${result.appId}`
- : `/game/${result.id}`
- router.push(path)
+ const [results, setResults] = useState([])
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState(null)
+ // Handle direct navigation / browser back-forward
+ useEffect(() => {
+ if (!query || query.length < 2) {
+ setResults([])
+ setError(null)
+ return
}
- return (
-
-
-
- {query ? `results for "${query}"` : "search using game name or appid"}
-
-
- {query
- ? `${results.length} result${results.length !== 1 ? "s" : ""} found`
- : "Enter a game name or AppID to find benchmarks, settings, and reviews."}
-
+ let cancelled = false
- {!query && (
-
-
-
- Start typing to search for games
-
-
- )}
+ async function fetchResults() {
+ setLoading(true)
+ setError(null)
- {query && loading && (
-
-
-
- Searching for "{query}"...
-
-
- )}
+ try {
+ const res = await fetch(
+ `/api/search/unified?q=${encodeURIComponent(query)}`,
+ )
+ if (!res.ok) throw new Error(await res.text())
+ const data = await res.json()
+ if (!cancelled) setResults(data.results || [])
+ } catch (err) {
+ if (!cancelled) {
+ setError("Failed to fetch search results")
+ console.error(err)
+ }
+ } finally {
+ if (!cancelled) setLoading(false)
+ }
+ }
- {query && !loading && error && (
-
- )}
+ fetchResults()
+ return () => {
+ cancelled = true
+ }
+ }, [query])
- {query && !loading && !error && results.length === 0 && (
-
-
-
- No results found for "{query}"
-
-
- )}
+ function handleClick(result: UnifiedResult) {
+ const path = result.appId
+ ? `/game/${result.appId}`
+ : `/game/${result.id}`
+ router.push(path)
+ }
- {query && !loading && !error && results.length > 0 && (
-
- {results.map((result, idx) => (
-
- ))}
-
- )}
-
-
- )
+ return (
+
+
+
+ {query ? `Results for "${query}"` : "Search using game name or AppID"}
+
+
+ {query
+ ? `${results.length} result${results.length !== 1 ? "s" : ""} found`
+ : "Enter a game name or AppID to find benchmarks, settings, and reviews."}
+
+
+ {!query && (
+
+
+
+ Start typing to search for games
+
+
+ )}
+
+ {query && loading && (
+
+
+
+ Searching for "{query}"...
+
+
+ )}
+
+ {query && !loading && error && (
+
+ {error}
+
+ )}
+
+ {query && !loading && !error && results.length === 0 && (
+
+
+
+ No results found for "{query}"
+
+
+ )}
+
+ {query && !loading && !error && results.length > 0 && (
+
+
+ {results.map((result, idx) => (
+
+ ))}
+
+
+ )}
+
+
+ )
}
function SearchResultCard({
- result,
- onClick,
+ result,
+ onClick,
+ index,
}: {
- result: UnifiedResult
- onClick: (r: UnifiedResult) => void
+ result: UnifiedResult
+ onClick: (r: UnifiedResult) => void
+ index: number
}) {
- const isLocal = result.kind === "local"
- const hasData = isLocal && result.counts && (
- result.counts.benchmarks > 0 ||
- result.counts.presets > 0 ||
- result.counts.comments > 0
- )
- const hasDeveloperInfo = result.developer || result.publisher
+ const isLocal = result.kind === "local"
+ const counts = result.counts
+ const hasData =
+ isLocal &&
+ counts &&
+ (counts.benchmarks > 0 || counts.presets > 0 || counts.comments > 0)
- return (
- onClick(result)}
+ return (
+
+ {/* Full-card click target */}
+ onClick(result)}
+ />
+
+
+ {/* Cover */}
+
- {/* Image */}
-
- {result.image ? (
-
- ) : (
-
-
-
- )}
- {/* Source badge */}
-
-
+
+
- {/* Info */}
-
-
- {result.title}
+ {/* Main Content */}
+
+ {/* Row 1: Title + metascore/price row */}
+
+
+
+ {result.title}
+
+ {(result.developer || result.publisher) && (
+
+ {result.developer}
+ {result.developer && result.publisher ? " ยท " : ""}
+ {result.publisher}
- {hasDeveloperInfo ? (
-
- {result.developer || result.publisher}
-
- ) : !isLocal && result.appId ? (
-
- AppID: {result.appId}
-
- ) : null}
+ )}
- {/* Stats (local only) */}
- {isLocal && result.counts && (
-
- {result.counts.benchmarks > 0 && (
-
-
- {result.counts.benchmarks}
-
- )}
- {result.counts.presets > 0 && (
-
-
- {result.counts.presets}
-
- )}
- {result.counts.comments > 0 && (
-
-
- {result.counts.comments}
-
- )}
- {!hasData && (
- No data yet
- )}
-
+ {/* Metascore + Price row */}
+
+ {result.metascore ? (
+
+
+ {result.metascore}
+
+ ) : null}
+
+
+
+
+ {/* Row 2: Description */}
+ {result.description && (
+
+ {result.description}
+
+ )}
+
+ {/* Row 3: Genre tags + Platform icons + Controller */}
+
+ {result.genres && result.genres.length > 0 && (
+
+ {result.genres.slice(0, 3).map((genre) => (
+
+ {genre}
+
+ ))}
+ {result.genres.length > 3 && (
+
+ +{result.genres.length - 3}
+
+ )}
+
)}
+
+ {/* Platform icons โ always show all 3, color if present */}
+
+
+
+
+
+
+
+
+
+
+
+
+ {result.controllerSupport && (
+
+
+ Controller
+
+ )}
+
+
+ {/* Row 4: Stats row */}
+
+ {hasData ? (
+ <>
+ {counts!.benchmarks > 0 && (
+
+ )}
+ {counts!.presets > 0 && (
+
+ )}
+ {counts!.comments > 0 && (
+
+ )}
+ >
+ ) : isLocal ? (
+
+ No data yet โ be the first to contribute
+
+ ) : (
+
+ )}
+
+
+ {/* Row 5: Anti-cheat info */}
+ {result.platformSupport?.antiCheatRelevant && (
+
+
+ Anti-cheat: {result.platformSupport.antiCheatName || "Unknown"}
+
+ {" "}โ {result.platformSupport.antiCheatStatus}
+
+
+
+ )}
+
+ {/* Right Panel โ Desktop Only */}
+
+ {/* Deck Status */}
+
+
+ {/* Avg FPS */}
+
+
+ {/* Version */}
+
+
+
+
+ )
+}
+
+function DataField({
+ label,
+ value,
+ bar,
+ color,
+}: {
+ label: string
+ value: string
+ bar?: boolean
+ color?: string
+}) {
+ return (
+
+
+ {label}
+
+
+ {bar && (
+
+ )}
+
+ {value}
+
+
+
+ )
+}
+
+function protonLabel(status: string): string {
+ const map: Record
= {
+ native: "Native",
+ proton: "Proton",
+ unsupported: "Unsupported",
+ unknown: "Unknown",
+ }
+ return map[status] || status
+}
+
+function protonColor(status: string): string {
+ const map: Record = {
+ native: "text-green-400",
+ proton: "text-blue-400",
+ unsupported: "text-red-400",
+ unknown: "text-text/25",
+ }
+ return map[status] || "text-text/25"
+}
+
+function GameCover({ image, title }: { image: string | null; title: string }) {
+ const [error, setError] = useState(false)
+
+ if (image && !error) {
+ return (
+ setError(true)}
+ />
)
+ }
+
+ return (
+
+
+
+ )
+}
+
+function PriceTag({
+ price,
+}: {
+ price?: { currency: string; initial: number; final: number } | null
+}) {
+ if (!price || price.final === 0) {
+ return (
+
+ Free
+
+ )
+ }
+
+ const isDiscounted = price.final < price.initial
+ const fmt = new Intl.NumberFormat("en-US", {
+ style: "currency",
+ currency: price.currency,
+ })
+
+ return (
+
+ {isDiscounted && (
+
+ {fmt.format(price.initial / 100)}
+
+ )}
+
+ {fmt.format(price.final / 100)}
+
+
+ )
+}
+
+function StatBadge({
+ icon: Icon,
+ count,
+ label,
+}: {
+ icon: React.ElementType
+ count: number
+ label: string
+}) {
+ return (
+
+
+ {count}
+ {label}
+
+ )
}
export default function SearchPage() {
- return (
-
-
-
- )
+ return (
+
+
+
+ )
}
diff --git a/bun.lock b/bun.lock
index 36c634d..ac46656 100644
--- a/bun.lock
+++ b/bun.lock
@@ -23,6 +23,7 @@
"pg": "^8.20.0",
"react": "19.2.4",
"react-dom": "19.2.4",
+ "react-icons": "^5.6.0",
"resend": "^6.12.2",
"web-haptics": "^0.0.6",
},
@@ -1304,6 +1305,8 @@
"react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
+ "react-icons": ["react-icons@5.6.0", "", { "peerDependencies": { "react": "*" } }, "sha512-RH93p5ki6LfOiIt0UtDyNg/cee+HLVR6cHHtW3wALfo+eOHTp8RnU2kRkI6E+H19zMIs03DyxUG/GfZMOGvmiA=="],
+
"react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"reflect-metadata": ["reflect-metadata@0.2.2", "", {}, "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="],
diff --git a/components/navbar.tsx b/components/navbar.tsx
index fc475d0..7e2dbbb 100644
--- a/components/navbar.tsx
+++ b/components/navbar.tsx
@@ -17,7 +17,7 @@ export default function Navbar() {
const isLanding = pathname === "/"
- const [searchQuery, setSearchQuery] = useState("")
+ const [searchQuery, setSearchQuery] = useState(() => searchParams.get("q") || "")
const debouncedQuery = useDebounce(searchQuery, 300)
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
const [isFocused, setIsFocused] = useState(false)
@@ -35,6 +35,8 @@ export default function Navbar() {
if (isLanding) return
const currentQ = searchParams.get("q") || ""
if (debouncedQuery === currentQ) return
+ // Don't overwrite URL if the typed query hasn't debounced yet
+ if (searchQuery !== debouncedQuery) return
const params = new URLSearchParams(searchParams.toString())
if (debouncedQuery) {
@@ -43,7 +45,7 @@ export default function Navbar() {
params.delete("q")
}
router.replace(`/search?${params.toString()}`, { scroll: false })
- }, [debouncedQuery, isLanding, router, searchParams])
+ }, [debouncedQuery, isLanding, router, searchParams, searchQuery])
// Maintain focus & styles when flying from landing page search
useEffect(() => {
diff --git a/lib/api/game-stub.ts b/lib/api/game-stub.ts
index d97a569..dc8520d 100644
--- a/lib/api/game-stub.ts
+++ b/lib/api/game-stub.ts
@@ -4,12 +4,12 @@ import { games } from "@/lib/db/schema"
import { eq } from "drizzle-orm"
interface SteamAppDetails {
+ type?: string
steam_appid: number
name: string
developers?: string[]
publishers?: string[]
header_image?: string
- capsule_imagev5?: string
genres?: { id: string; description: string }[]
website?: string
}
@@ -50,6 +50,13 @@ export const gameStubRoutes = new Elysia({ prefix: "/games" }).post(
details = entry.data
}
}
+
+ // Reject non-games (DLCs, soundtracks, demos, etc.)
+ if (details?.type && details.type !== "game") {
+ set.status = 400
+ return { error: `Not a game (type: ${details.type})` }
+
+ }
} catch (err) {
console.error("Failed to fetch Steam appdetails:", err)
}
@@ -60,7 +67,7 @@ export const gameStubRoutes = new Elysia({ prefix: "/games" }).post(
const genres = details?.genres?.map((g) => g.description) || []
const headerImage = details?.header_image || null
const capsuleImage =
- details?.capsule_imagev5 || details?.header_image || null
+ `https://cdn.akamai.steamstatic.com/steam/apps/${body.steamAppId}/library_600x900.jpg`
const [game] = await db
.insert(games)
diff --git a/lib/api/search-unified.ts b/lib/api/search-unified.ts
index c2c0564..8b12f0b 100644
--- a/lib/api/search-unified.ts
+++ b/lib/api/search-unified.ts
@@ -1,6 +1,12 @@
import { Elysia, t } from "elysia"
import { db } from "@/lib/db/index"
-import { games, gameVersions, performanceEntries, communityPresets, gameComments } from "@/lib/db/schema"
+import {
+ games,
+ gameVersions,
+ performanceEntries,
+ communityPresets,
+ gameComments,
+} from "@/lib/db/schema"
import { ilike, or, sql, eq, inArray } from "drizzle-orm"
interface SteamSearchItem {
@@ -8,7 +14,9 @@ interface SteamSearchItem {
name: string
tiny_image: string
metascore: string
+ price?: { currency: string; initial: number; final: number }
platforms: { windows: boolean; mac: boolean; linux: boolean }
+ controller_support?: string
}
interface SteamSearchResponse {
@@ -44,6 +52,42 @@ export const searchUnifiedRoutes = new Elysia({ prefix: "/search" }).get(
localGames.map((g) => g.steamAppId).filter(Boolean),
)
+ // Fetch platform support + anti-cheat for local games
+ let platformSupportMap = new Map<
+ string,
+ {
+ isSupported: boolean
+ protonStatus: string
+ antiCheatRelevant: boolean
+ antiCheatName: string | null
+ antiCheatStatus: string
+ }
+ >()
+ if (localGameIds.length > 0) {
+ const { gamePlatformSupport } = await import("@/lib/db/schema")
+ const supportRows = await db
+ .select({
+ gameId: gamePlatformSupport.gameId,
+ isSupported: gamePlatformSupport.isSupported,
+ protonStatus: gamePlatformSupport.protonStatus,
+ antiCheatRelevant: gamePlatformSupport.antiCheatRelevant,
+ antiCheatName: gamePlatformSupport.antiCheatName,
+ antiCheatStatus: gamePlatformSupport.antiCheatStatus,
+ })
+ .from(gamePlatformSupport)
+ .where(inArray(gamePlatformSupport.gameId, localGameIds))
+
+ for (const row of supportRows) {
+ platformSupportMap.set(row.gameId, {
+ isSupported: row.isSupported,
+ protonStatus: row.protonStatus,
+ antiCheatRelevant: row.antiCheatRelevant,
+ antiCheatName: row.antiCheatName,
+ antiCheatStatus: row.antiCheatStatus,
+ })
+ }
+ }
+
// โโ 2. Count related data for local games โโโโโโโโโโโโโโโโโโโโโโโ
let benchmarkCounts: { gameId: string; count: number }[] = []
let presetCounts: { gameId: string; count: number }[] = []
@@ -115,19 +159,40 @@ export const searchUnifiedRoutes = new Elysia({ prefix: "/search" }).get(
})
if (res.ok) {
const data = (await res.json()) as SteamSearchResponse
- steamItems = data.items || []
+ steamItems = (data.items || []).filter((item) => {
+ const name = item.name.toLowerCase()
+ const exclude = [
+ "soundtrack",
+ " original soundtrack",
+ " ost",
+ " - ost",
+ "dlc",
+ "expansion",
+ "season pass",
+ " deluxe edition",
+ " ultimate edition",
+ " premium edition",
+ " demo",
+ " trial",
+ " playtest",
+ " beta",
+ " artbook",
+ " soundtrack bundle",
+ ]
+ return !exclude.some((kw) => name.includes(kw))
+ })
}
} catch {
// Steam search failure is non-fatal
}
// โโ 4. Build unified results โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
- // Local games first (they have data), then Steam-only results
const results = []
// Add local games
for (const g of localGames) {
const counts = countMap.get(g.id)!
+ const platform = platformSupportMap.get(g.id)
results.push({
kind: "local" as const,
id: g.id,
@@ -136,23 +201,47 @@ export const searchUnifiedRoutes = new Elysia({ prefix: "/search" }).get(
image: g.capsuleImage || g.headerImage,
developer: g.developer,
publisher: g.publisher,
+ description: g.description,
+ genres: g.genres,
source: g.source,
counts,
+ platformSupport: platform
+ ? {
+ isSupported: platform.isSupported,
+ protonStatus: platform.protonStatus,
+ antiCheatRelevant: platform.antiCheatRelevant,
+ antiCheatName: platform.antiCheatName,
+ antiCheatStatus: platform.antiCheatStatus,
+ }
+ : null,
})
}
- // Add Steam-only games (deduplicated against local steamAppIds)
+ // Add Steam-only games
for (const item of steamItems) {
if (localSteamAppIds.has(item.id)) continue
results.push({
kind: "steam" as const,
appId: item.id,
title: item.name,
- image: item.tiny_image,
+ image: `https://cdn.akamai.steamstatic.com/steam/apps/${item.id}/library_600x900.jpg`,
developer: null,
publisher: null,
+ description: null,
+ genres: null,
source: "steam" as const,
counts: null,
+ platformSupport: null,
+ metascore: item.metascore || null,
+ price: item.price
+ ? {
+ currency: item.price.currency,
+ initial: item.price.initial,
+ final: item.price.final,
+ }
+ : null,
+ platforms: item.platforms,
+ controllerSupport: item.controller_support || null,
})
}
diff --git a/lib/api/steam-search.ts b/lib/api/steam-search.ts
index 35339f7..aa5edb0 100644
--- a/lib/api/steam-search.ts
+++ b/lib/api/steam-search.ts
@@ -38,11 +38,36 @@ export const steamSearchRoutes = new Elysia({ prefix: "/search" })
const data = await res.json()
+ const items: SteamSearchItem[] = (data.items || []).filter(
+ (item: SteamSearchItem) => {
+ const name = item.name.toLowerCase()
+ const exclude = [
+ "soundtrack",
+ " original soundtrack",
+ " ost",
+ " - ost",
+ "dlc",
+ "expansion",
+ "season pass",
+ " deluxe edition",
+ " ultimate edition",
+ " premium edition",
+ " demo",
+ " trial",
+ " playtest",
+ " beta",
+ " artbook",
+ " soundtrack bundle",
+ ]
+ return !exclude.some((kw) => name.includes(kw))
+ },
+ )
+
return {
- items: (data.items || []).map((item: SteamSearchItem) => ({
+ items: items.map((item) => ({
appId: item.id,
title: item.name,
- image: item.tiny_image,
+ image: `https://cdn.akamai.steamstatic.com/steam/apps/${item.id}/library_600x900.jpg`,
platforms: item.platforms,
metascore: item.metascore,
})),
diff --git a/lib/steam/sync.ts b/lib/steam/sync.ts
index ee16bc1..ffb2914 100644
--- a/lib/steam/sync.ts
+++ b/lib/steam/sync.ts
@@ -8,7 +8,6 @@ interface SteamAppDetails {
developers?: string[]
publishers?: string[]
header_image?: string
- capsule_imagev5?: string
genres?: { id: string; description: string }[]
website?: string
short_description?: string
@@ -59,7 +58,7 @@ export async function syncSteamGame(steamAppId: number): Promise {
description: d.short_description || null,
genres: d.genres?.map((g) => g.description) || [],
headerImage: d.header_image || null,
- capsuleImage: d.capsule_imagev5 || d.header_image || null,
+ capsuleImage: `https://cdn.akamai.steamstatic.com/steam/apps/${steamAppId}/library_600x900.jpg`,
storeUrl: `https://store.steampowered.com/app/${steamAppId}`,
lastSync: new Date(),
syncStatus: "synced",
diff --git a/package.json b/package.json
index 0078fc5..ccb4d65 100644
--- a/package.json
+++ b/package.json
@@ -34,6 +34,7 @@
"pg": "^8.20.0",
"react": "19.2.4",
"react-dom": "19.2.4",
+ "react-icons": "^5.6.0",
"resend": "^6.12.2",
"web-haptics": "^0.0.6"
},