feat: add Steam sale section to landing page

This commit is contained in:
2026-05-16 17:24:43 +08:00
parent 719e3f1c4a
commit aef9802263
+150 -1
View File
@@ -35,6 +35,15 @@ interface SectionData {
recentBenchmarks: GameCard[] recentBenchmarks: GameCard[]
trending: GameCard[] trending: GameCard[]
mostTested: GameCard[] mostTested: GameCard[]
onSale: SaleGameCard[]
}
interface SaleGameCard extends GameCard {
price_current?: number
price_initial?: number
price_currency?: string
steam_review_score?: number
best_fps?: number
} }
function SkeletonSections() { function SkeletonSections() {
@@ -196,6 +205,138 @@ function GameSection({
) )
} }
function SaleSection({
games,
}: {
games: SaleGameCard[]
}) {
const router = useRouter()
return (
<motion.section
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-50px" }}
transition={{ duration: 0.4 }}
>
<div className='flex items-center gap-2 mb-4'>
<div className='border-l-2 border-green-500 pl-3'>
<div className='flex items-center gap-2'>
<span className="text-green-400 text-sm">💰</span>
<h2 className='text-sm font-semibold text-text/80'>
On Sale & Performing Well
</h2>
</div>
</div>
</div>
<div className='flex gap-3 overflow-x-auto pb-2 -mx-1 px-1 scrollbar-thin'>
{games.map((game, idx) => {
const discountPct = game.price_initial && game.price_current
? Math.round((1 - game.price_current / game.price_initial) * 100)
: 0
return (
<motion.div
key={game.id}
initial={{ opacity: 0, y: 10 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.3, delay: idx * 0.05 }}
whileHover={{ scale: 1.03 }}
whileTap={{ scale: 0.98 }}
className='group shrink-0 w-36 sm:w-44 rounded-xl bg-text/3 border border-border hover:border-text/30 hover:bg-text/6 transition-colors cursor-pointer overflow-hidden'
onClick={() => router.push(`/game/${game.id}?sync=1`)}
>
<div className='relative aspect-2/3 bg-text/10 overflow-hidden'>
{game.capsule_image ? (
<Image
src={game.capsule_image}
alt={game.title}
fill
className='object-cover group-hover:scale-105 transition-transform duration-300'
sizes='(max-width: 640px) 144px, 176px'
/>
) : (
<div className='w-full h-full flex items-center justify-center'>
<Gamepad2Icon className='h-8 w-8 text-text/15' />
</div>
)}
{/* Discount badge */}
{discountPct > 0 && (
<div className='absolute top-2 right-2 px-1.5 py-0.5 rounded bg-green-500 text-white text-[10px] font-bold'>
-{discountPct}%
</div>
)}
{game.playability_status &&
game.playability_status !== "unknown" && (
<div className='absolute bottom-1.5 left-1.5 right-1.5'>
<PlayabilityBadge
status={game.playability_status as "great" | "playable" | "needs_tweaks" | "unplayable" | "unknown" | null}
compact
showLabel
className='text-[10px] px-1.5 py-0.5 w-full justify-center'
/>
</div>
)}
</div>
<div className='p-2.5 space-y-1.5'>
<h3 className='text-xs font-semibold text-text line-clamp-2 leading-tight group-hover:text-primary transition-colors'>
{game.title}
</h3>
{/* Pricing */}
<div className='flex items-center gap-1.5'>
{game.price_current !== undefined && (
<span className='text-xs font-bold text-green-400'>
{game.price_currency === "USD" ? "$" : ""}{(game.price_current / 100).toFixed(2)}
</span>
)}
{game.price_initial !== undefined && game.price_initial > (game.price_current ?? 0) && (
<span className='text-[10px] text-text/30 line-through'>
{(game.price_initial / 100).toFixed(2)}
</span>
)}
</div>
{/* Performance badges */}
{game.best_fps !== undefined && game.best_fps !== null && (
<div className='flex flex-wrap gap-1'>
{game.best_fps >= 60 ? (
<span className='inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full bg-green-500/10 border border-green-500/20 text-green-400 text-[9px] font-semibold'>
RAW PERFORMER
</span>
) : game.best_fps < 30 ? (
<span className='inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full bg-red-500/10 border border-red-500/20 text-red-400 text-[9px] font-semibold'>
POOR PERFORMANCE
</span>
) : null}
<span className='inline-flex items-center gap-0.5 text-[9px] text-text/50 font-medium'>
{Math.round(game.best_fps)}fps
</span>
</div>
)}
{game.steam_review_score !== undefined && game.steam_review_score !== null && (
<p className='text-[10px] text-blue-400 font-medium'>
{game.steam_review_score}% positive
</p>
)}
</div>
</motion.div>
)
})}
</div>
{/* Disclaimer */}
<p className='text-[10px] text-text/20 mt-1 text-right'>
Prices may vary. Data refreshes weekly.
</p>
</motion.section>
)
}
export default function Landing() { export default function Landing() {
const router = useRouter() const router = useRouter()
const words = ["benchmarks", "settings", "reviews"] const words = ["benchmarks", "settings", "reviews"]
@@ -208,6 +349,7 @@ export default function Landing() {
recentBenchmarks: [], recentBenchmarks: [],
trending: [], trending: [],
mostTested: [], mostTested: [],
onSale: [],
}) })
const [sectionsLoading, setSectionsLoading] = useState(true) const [sectionsLoading, setSectionsLoading] = useState(true)
@@ -224,7 +366,7 @@ export default function Landing() {
let cancelled = false let cancelled = false
async function fetchSections() { async function fetchSections() {
try { try {
const [recentBenchmarks, trending, mostTested] = const [recentBenchmarks, trending, mostTested, onSale] =
await Promise.all([ await Promise.all([
fetch("/api/dashboard/recent-benchmarks").then((r) => fetch("/api/dashboard/recent-benchmarks").then((r) =>
r.ok ? r.json() : [], r.ok ? r.json() : [],
@@ -235,12 +377,16 @@ export default function Landing() {
fetch("/api/dashboard/most-tested").then((r) => fetch("/api/dashboard/most-tested").then((r) =>
r.ok ? r.json() : [], r.ok ? r.json() : [],
), ),
fetch("/api/dashboard/on-sale").then((r) =>
r.ok ? r.json() : [],
),
]) ])
if (!cancelled) { if (!cancelled) {
setSections({ setSections({
recentBenchmarks: Array.isArray(recentBenchmarks) ? recentBenchmarks : [], recentBenchmarks: Array.isArray(recentBenchmarks) ? recentBenchmarks : [],
trending: Array.isArray(trending) ? trending : [], trending: Array.isArray(trending) ? trending : [],
mostTested: Array.isArray(mostTested) ? mostTested : [], mostTested: Array.isArray(mostTested) ? mostTested : [],
onSale: Array.isArray(onSale) ? onSale : [],
}) })
} }
} catch { } catch {
@@ -411,6 +557,9 @@ export default function Landing() {
accentColor='text-blue-400' accentColor='text-blue-400'
/> />
)} )}
{sections.onSale.length > 0 && (
<SaleSection games={sections.onSale} />
)}
</> </>
)} )}
</div> </div>