- List IGDB platform names verbatim (Windows, MacOS) instead of coarse buckets; fall back to Steam OS flags and hardware distributors only when IGDB has none - Show a Free badge instead of prices when best price is zero
643 lines
30 KiB
TypeScript
643 lines
30 KiB
TypeScript
"use client";
|
||
|
||
import Image from "next/image";
|
||
import { useEffect, useMemo, useRef, useState } from "react";
|
||
import type { GamePricePoint, GameProfile } from "@/lib/game-profile";
|
||
import { steamdbRating } from "@/lib/game-profile";
|
||
import type { Dictionary } from "@/lib/i18n/get-dictionary";
|
||
|
||
type ModalDict = Dictionary["discord"]["gameModal"];
|
||
|
||
const STEAM_SCORE_KEYS: Record<string, keyof ModalDict["reviewScores"]> = {
|
||
"Overwhelmingly Positive": "overwhelminglyPositive",
|
||
"Very Positive": "veryPositive",
|
||
Positive: "positive",
|
||
"Mostly Positive": "mostlyPositive",
|
||
Mixed: "mixed",
|
||
"Mostly Negative": "mostlyNegative",
|
||
Negative: "negative",
|
||
"Very Negative": "veryNegative",
|
||
"Overwhelmingly Negative": "overwhelminglyNegative",
|
||
};
|
||
|
||
const LOCALE_TAGS: Record<string, string> = {
|
||
tr: "tr-TR",
|
||
en: "en-US",
|
||
de: "de-DE",
|
||
};
|
||
|
||
const COUNTRIES: Record<string, string> = {
|
||
tr: "TR",
|
||
en: "US",
|
||
de: "DE",
|
||
};
|
||
|
||
function localizeScore(label: string, dict: ModalDict): string {
|
||
const key = STEAM_SCORE_KEYS[label];
|
||
return key ? dict.reviewScores[key] : label;
|
||
}
|
||
|
||
function formatRatingPercent(value: number, tag: string): string {
|
||
try {
|
||
return new Intl.NumberFormat(tag, {
|
||
style: "percent",
|
||
maximumFractionDigits: 2,
|
||
}).format(value);
|
||
} catch {
|
||
return `%${(value * 100).toFixed(2)}`;
|
||
}
|
||
}
|
||
|
||
function formatPrice(point: GamePricePoint, tag: string): string {
|
||
try {
|
||
return new Intl.NumberFormat(tag, {
|
||
style: "currency",
|
||
currency: point.currency,
|
||
}).format(point.price);
|
||
} catch {
|
||
return `${point.price} ${point.currency}`;
|
||
}
|
||
}
|
||
|
||
function formatReleaseDate(value: string, tag: string): string {
|
||
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
||
const parsed = new Date(`${value}T00:00:00`);
|
||
if (!Number.isNaN(parsed.getTime())) {
|
||
return parsed.toLocaleDateString(tag, {
|
||
day: "numeric",
|
||
month: "long",
|
||
year: "numeric",
|
||
});
|
||
}
|
||
}
|
||
return value;
|
||
}
|
||
|
||
function formatCount(value: number | null | undefined, tag: string, unit: string): string {
|
||
if (value == null) {
|
||
return "—";
|
||
}
|
||
return `${value.toLocaleString(tag)} ${unit}`;
|
||
}
|
||
|
||
function discountPercent(point: GamePricePoint): number | null {
|
||
if (point.regular > point.price && point.regular > 0) {
|
||
return Math.round((1 - point.price / point.regular) * 100);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
export function GameProfileModal({
|
||
applicationId,
|
||
heroSeed,
|
||
lang,
|
||
iconUrl,
|
||
fallbackName,
|
||
fallbackGameUrl,
|
||
dict,
|
||
onClose,
|
||
}: {
|
||
applicationId: string;
|
||
heroSeed: number;
|
||
lang: string;
|
||
iconUrl: string | null;
|
||
fallbackName: string;
|
||
fallbackGameUrl: string;
|
||
dict: ModalDict;
|
||
onClose: () => void;
|
||
}) {
|
||
const [profile, setProfile] = useState<GameProfile | null>(null);
|
||
const [failed, setFailed] = useState(false);
|
||
const [attempt, setAttempt] = useState(0);
|
||
const [bannerPick, setBannerPick] = useState<string | null>(null);
|
||
const tag = LOCALE_TAGS[lang] ?? "en-US";
|
||
const country = COUNTRIES[lang] ?? "US";
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
async function load(): Promise<void> {
|
||
try {
|
||
const response = await fetch(
|
||
`/api/game/${applicationId}?lang=${lang}&country=${country}`,
|
||
);
|
||
if (!response.ok) {
|
||
throw new Error(`game api: ${response.status}`);
|
||
}
|
||
const json = (await response.json()) as GameProfile;
|
||
if (!cancelled) {
|
||
setProfile(json);
|
||
setFailed(false);
|
||
}
|
||
} catch {
|
||
if (!cancelled) {
|
||
setFailed(true);
|
||
}
|
||
}
|
||
}
|
||
void load();
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [applicationId, lang, country, attempt]);
|
||
const partialFail =
|
||
!!profile && !profile.sources.igdb && !profile.sources.steam && !profile.sources.itad;
|
||
|
||
useEffect(() => {
|
||
function onKey(event: KeyboardEvent): void {
|
||
if (event.key === "Escape") {
|
||
onClose();
|
||
}
|
||
}
|
||
document.addEventListener("keydown", onKey);
|
||
const previous = document.body.style.overflow;
|
||
document.body.style.overflow = "hidden";
|
||
return () => {
|
||
document.removeEventListener("keydown", onKey);
|
||
document.body.style.overflow = previous;
|
||
};
|
||
}, [onClose]);
|
||
|
||
const gameUrl = profile?.gameUrl ?? fallbackGameUrl;
|
||
const name = profile?.name ?? fallbackName;
|
||
const best = profile?.itad?.prices ?? null;
|
||
const low = profile?.itad?.historyLow ?? null;
|
||
const isFree = !!best && best.price === 0;
|
||
const hero = useMemo(() => {
|
||
if (bannerPick) {
|
||
return bannerPick;
|
||
}
|
||
const shots = profile?.screenshots ?? [];
|
||
if (shots.length > 0) {
|
||
return shots[heroSeed % shots.length] ?? null;
|
||
}
|
||
return profile?.artworkUrl ?? null;
|
||
}, [profile, heroSeed, bannerPick]);
|
||
|
||
const dbRating =
|
||
profile?.steamReviews?.positive != null && profile?.steamReviews?.total != null
|
||
? steamdbRating(profile.steamReviews.positive, profile.steamReviews.total)
|
||
: null;
|
||
const itadReviews = profile?.itad?.reviews ?? [];
|
||
const hasDetails = !!(
|
||
profile &&
|
||
(profile.developers.length > 0 ||
|
||
profile.publishers.length > 0 ||
|
||
profile.releaseDate ||
|
||
profile.platforms.length > 0 ||
|
||
profile.links.length > 0)
|
||
);
|
||
const metascore = itadReviews.find((r) => r.source === "Metascore") ?? null;
|
||
const opencritic = itadReviews.find((r) => r.source === "OpenCritic") ?? null;
|
||
const hasReviews = !!(
|
||
profile?.steamReviews ||
|
||
(dbRating != null && profile?.steamId) ||
|
||
metascore ||
|
||
opencritic
|
||
);
|
||
|
||
const stripRef = useRef<HTMLDivElement | null>(null);
|
||
|
||
useEffect(() => {
|
||
const strip = stripRef.current;
|
||
if (!strip) {
|
||
return;
|
||
}
|
||
function onWheel(event: WheelEvent): void {
|
||
const el = stripRef.current;
|
||
if (!el) {
|
||
return;
|
||
}
|
||
if (Math.abs(event.deltaX) >= Math.abs(event.deltaY)) {
|
||
return;
|
||
}
|
||
if (el.scrollWidth <= el.clientWidth + 1) {
|
||
return;
|
||
}
|
||
event.preventDefault();
|
||
el.scrollLeft += event.deltaY;
|
||
}
|
||
strip.addEventListener("wheel", onWheel, { passive: false });
|
||
return () => strip.removeEventListener("wheel", onWheel);
|
||
}, [profile]);
|
||
|
||
function scrollStrip(by: number): void {
|
||
stripRef.current?.scrollBy({ left: by, behavior: "smooth" });
|
||
}
|
||
|
||
return (
|
||
<div
|
||
className="fixed inset-0 z-50 flex items-end justify-center bg-black/70 p-4 backdrop-blur-sm sm:items-center"
|
||
onClick={onClose}
|
||
role="presentation"
|
||
>
|
||
<div
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-label={name}
|
||
className="modal-scroll max-h-[85vh] w-full max-w-4xl overflow-y-auto rounded-3xl border border-white/10 bg-panel shadow-2xl"
|
||
onClick={(event) => event.stopPropagation()}
|
||
>
|
||
{hero ? (
|
||
<div className="relative h-48 w-full shrink-0 sm:h-64">
|
||
<Image
|
||
src={hero}
|
||
alt=""
|
||
fill
|
||
sizes="(max-width: 896px) 100vw, 896px"
|
||
className="rounded-t-3xl object-cover"
|
||
priority
|
||
/>
|
||
<div className="absolute inset-0 rounded-t-3xl bg-gradient-to-t from-panel via-panel/30 to-transparent" />
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="p-5 sm:p-6">
|
||
<div className="flex items-center gap-4">
|
||
{iconUrl ? (
|
||
<Image
|
||
src={iconUrl}
|
||
alt=""
|
||
width={64}
|
||
height={64}
|
||
className="shrink-0 rounded-2xl border border-white/10"
|
||
/>
|
||
) : (
|
||
<div
|
||
aria-hidden
|
||
className="flex h-16 w-16 shrink-0 items-center justify-center rounded-2xl bg-blurple/20 text-2xl font-bold text-blurple-soft"
|
||
>
|
||
{name.charAt(0).toUpperCase()}
|
||
</div>
|
||
)}
|
||
<div className="min-w-0 flex-1">
|
||
<h2 className="truncate text-xl font-bold">{name}</h2>
|
||
{profile && profile.genres.length > 0 ? (
|
||
<div className="mt-1.5 flex flex-wrap gap-1.5">
|
||
{profile.genres.map((genre) => (
|
||
<span
|
||
key={genre}
|
||
className="rounded-full border border-white/10 bg-white/5 px-2 py-0.5 text-xs text-zinc-300"
|
||
>
|
||
{genre}
|
||
</span>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={onClose}
|
||
className="shrink-0 rounded-full border border-white/10 px-3 py-1 text-xs text-zinc-400 transition-colors hover:border-white/25 hover:text-white"
|
||
>
|
||
{dict.close}
|
||
</button>
|
||
</div>
|
||
|
||
{!profile && !failed ? (
|
||
<p className="mt-4 animate-pulse text-sm text-zinc-500">{dict.loading}</p>
|
||
) : null}
|
||
{failed && !profile ? (
|
||
<div className="mt-4 flex items-center gap-3 text-sm text-zinc-500">
|
||
<p>{dict.loadError}</p>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setFailed(false);
|
||
setAttempt((n) => n + 1);
|
||
}}
|
||
className="shrink-0 cursor-pointer rounded-full border border-white/10 px-3 py-1 text-xs text-zinc-300 transition-colors hover:border-white/25 hover:text-white"
|
||
>
|
||
{dict.retry}
|
||
</button>
|
||
</div>
|
||
) : null}
|
||
{partialFail ? (
|
||
<div className="mt-4 flex items-center gap-3 rounded-xl border border-white/5 bg-white/[0.03] p-3 text-sm text-zinc-400">
|
||
<p className="flex-1">{dict.partialFail}</p>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setProfile(null);
|
||
setAttempt((n) => n + 1);
|
||
}}
|
||
className="shrink-0 cursor-pointer rounded-full border border-white/10 px-3 py-1 text-xs text-zinc-300 transition-colors hover:border-white/25 hover:text-white"
|
||
>
|
||
{dict.retry}
|
||
</button>
|
||
</div>
|
||
) : null}
|
||
|
||
{profile?.description ? (
|
||
<p className="mt-4 text-sm leading-relaxed text-zinc-300">
|
||
{profile.description}
|
||
</p>
|
||
) : null}
|
||
|
||
<div className="mt-4 flex flex-wrap gap-2">
|
||
{profile?.steamUrl ? (
|
||
<a
|
||
href={profile.steamUrl}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="rounded-full bg-blurple px-4 py-1.5 text-xs font-semibold text-white transition-colors hover:bg-blurple-soft hover:text-void"
|
||
>
|
||
{dict.openOnSteam} ↗
|
||
</a>
|
||
) : null}
|
||
{profile?.steamId ? (
|
||
<a
|
||
href={`https://steamdb.info/app/${profile.steamId}/`}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="rounded-full border border-white/15 px-4 py-1.5 text-xs text-zinc-300 transition-colors hover:border-blurple hover:text-white"
|
||
>
|
||
{dict.openOnSteamDb} ↗
|
||
</a>
|
||
) : null}
|
||
{profile?.igdbUrl ? (
|
||
<a
|
||
href={profile.igdbUrl}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="rounded-full border border-white/15 px-4 py-1.5 text-xs text-zinc-300 transition-colors hover:border-blurple hover:text-white"
|
||
>
|
||
{dict.openOnIgdb} ↗
|
||
</a>
|
||
) : null}
|
||
<a
|
||
href={gameUrl}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="rounded-full border border-white/15 px-4 py-1.5 text-xs text-zinc-300 transition-colors hover:border-blurple hover:text-white"
|
||
>
|
||
{dict.openOnDiscord} ↗
|
||
</a>
|
||
{profile?.itad?.pageUrl ? (
|
||
<a
|
||
href={profile.itad.pageUrl}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="rounded-full border border-white/15 px-4 py-1.5 text-xs text-zinc-300 transition-colors hover:border-blurple hover:text-white"
|
||
>
|
||
{dict.allPrices} ↗
|
||
</a>
|
||
) : null}
|
||
</div>
|
||
|
||
{hasDetails ? (
|
||
<dl className="mt-5 grid gap-x-6 gap-y-2 rounded-2xl border border-white/5 bg-white/[0.03] p-4 text-sm md:grid-cols-2">
|
||
{profile.developers.length > 0 ? (
|
||
<div className="flex flex-col gap-0.5 sm:flex-row sm:gap-3">
|
||
<dt className="shrink-0 text-zinc-500 sm:w-36">
|
||
{dict.developers}
|
||
</dt>
|
||
<dd className="text-zinc-200">
|
||
{profile.developers.join(", ")}
|
||
</dd>
|
||
</div>
|
||
) : null}
|
||
{profile.publishers.length > 0 ? (
|
||
<div className="flex flex-col gap-0.5 sm:flex-row sm:gap-3">
|
||
<dt className="shrink-0 text-zinc-500 sm:w-36">
|
||
{dict.publishers}
|
||
</dt>
|
||
<dd className="text-zinc-200">
|
||
{profile.publishers.join(", ")}
|
||
</dd>
|
||
</div>
|
||
) : null}
|
||
{profile.releaseDate ? (
|
||
<div className="flex flex-col gap-0.5 sm:flex-row sm:gap-3">
|
||
<dt className="shrink-0 text-zinc-500 sm:w-36">
|
||
{dict.releaseDate}
|
||
</dt>
|
||
<dd className="text-zinc-200">
|
||
{formatReleaseDate(profile.releaseDate, tag)}
|
||
</dd>
|
||
</div>
|
||
) : null}
|
||
{profile.platforms.length > 0 ? (
|
||
<div className="flex flex-col gap-0.5 sm:flex-row sm:gap-3">
|
||
<dt className="shrink-0 text-zinc-500 sm:w-36">
|
||
{dict.platforms}
|
||
</dt>
|
||
<dd className="text-zinc-200">
|
||
{profile.platforms.join(" · ")}
|
||
</dd>
|
||
</div>
|
||
) : null}
|
||
{profile.links.length > 0 ? (
|
||
<div className="flex flex-col gap-0.5 sm:flex-row sm:gap-3 md:col-span-2">
|
||
<dt className="shrink-0 text-zinc-500 sm:w-36">{dict.links}</dt>
|
||
<dd className="flex flex-wrap gap-x-3 gap-y-1">
|
||
{profile.links.slice(0, 8).map((link) => (
|
||
<a
|
||
key={link.url}
|
||
href={link.url}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="text-blurple-soft transition-colors hover:text-white"
|
||
>
|
||
{link.label} ↗
|
||
</a>
|
||
))}
|
||
</dd>
|
||
</div>
|
||
) : null}
|
||
</dl>
|
||
) : null}
|
||
|
||
{profile && hasReviews ? (
|
||
<div className="mt-4">
|
||
<p className="font-mono text-xs uppercase tracking-wider text-zinc-500">
|
||
{dict.reviews}
|
||
</p>
|
||
<div className="mt-1.5 grid gap-2 sm:grid-cols-2">
|
||
{profile.steamReviews ? (
|
||
<a
|
||
href={profile.steamReviews.url}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="rounded-xl border border-white/5 bg-white/[0.03] p-3 transition-colors hover:border-white/15"
|
||
>
|
||
<p className="font-mono text-xs text-zinc-500">
|
||
{dict.totalReviews}
|
||
</p>
|
||
<p className="mt-1 text-sm font-semibold">
|
||
{localizeScore(profile.steamReviews.label, dict)}
|
||
</p>
|
||
<p className="font-mono text-xs text-zinc-500">
|
||
{formatCount(
|
||
profile.steamReviews.total,
|
||
tag,
|
||
dict.reviewsUnit,
|
||
)}
|
||
</p>
|
||
</a>
|
||
) : null}
|
||
{dbRating != null && profile.steamId ? (
|
||
<a
|
||
href={`https://steamdb.info/app/${profile.steamId}/`}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="rounded-xl border border-white/5 bg-white/[0.03] p-3 transition-colors hover:border-white/15"
|
||
>
|
||
<p className="font-mono text-xs text-zinc-500">
|
||
{dict.steamdbRating}
|
||
</p>
|
||
<p className="mt-1 text-sm font-semibold">
|
||
{formatRatingPercent(dbRating, tag)}
|
||
</p>
|
||
<p className="font-mono text-xs text-zinc-500">
|
||
steamdb.info ↗
|
||
</p>
|
||
</a>
|
||
) : null}
|
||
{metascore ? (
|
||
<a
|
||
href={metascore.url}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="rounded-xl border border-white/5 bg-white/[0.03] p-3 transition-colors hover:border-white/15"
|
||
>
|
||
<p className="font-mono text-xs text-zinc-500">
|
||
Metacritic
|
||
</p>
|
||
<p className="mt-1 text-sm font-semibold">
|
||
{metascore.score ?? "—"}
|
||
<span className="font-normal text-zinc-500">/100</span>
|
||
</p>
|
||
<p className="font-mono text-xs text-zinc-500">
|
||
{formatCount(metascore.count, tag, dict.reviewsUnit)}
|
||
</p>
|
||
</a>
|
||
) : null}
|
||
{opencritic ? (
|
||
<a
|
||
href={opencritic.url}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="rounded-xl border border-white/5 bg-white/[0.03] p-3 transition-colors hover:border-white/15"
|
||
>
|
||
<p className="font-mono text-xs text-zinc-500">
|
||
OpenCritic
|
||
</p>
|
||
<p className="mt-1 text-sm font-semibold">
|
||
{opencritic.score ?? "—"}
|
||
<span className="font-normal text-zinc-500">/100</span>
|
||
</p>
|
||
<p className="font-mono text-xs text-zinc-500">
|
||
{formatCount(opencritic.count, tag, dict.reviewsUnit)}
|
||
</p>
|
||
</a>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{profile?.itad && (best || low) ? (
|
||
<div className="mt-4">
|
||
<p className="font-mono text-xs uppercase tracking-wider text-zinc-500">
|
||
{dict.prices}
|
||
</p>
|
||
{isFree ? (
|
||
<p className="mt-1.5 inline-block rounded-full border border-mint/20 bg-mint/5 px-4 py-1.5 text-xs font-semibold text-mint">
|
||
{dict.free}
|
||
</p>
|
||
) : (
|
||
<div className="mt-1.5 grid gap-2 sm:grid-cols-2">
|
||
{best ? (
|
||
<a
|
||
href={best.url || undefined}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="rounded-xl border border-mint/20 bg-mint/5 p-3 transition-colors hover:border-mint/40"
|
||
>
|
||
<p className="font-mono text-xs text-mint">
|
||
{dict.bestPrice} · {best.shop}
|
||
</p>
|
||
<p className="mt-1 text-lg font-bold">
|
||
{formatPrice(best, tag)}{" "}
|
||
{discountPercent(best) ? (
|
||
<span className="ml-1 rounded bg-mint/15 px-1.5 py-0.5 align-middle font-mono text-xs text-mint">
|
||
-%{discountPercent(best)}
|
||
</span>
|
||
) : null}
|
||
</p>
|
||
</a>
|
||
) : null}
|
||
{low ? (
|
||
<div className="rounded-xl border border-white/5 bg-white/[0.03] p-3">
|
||
<p className="font-mono text-xs text-zinc-500">
|
||
{dict.historyLow} · {low.shop}
|
||
</p>
|
||
<p className="mt-1 text-lg font-bold text-zinc-200">
|
||
{formatPrice(low, tag)}
|
||
</p>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : null}
|
||
|
||
{profile && profile.screenshots.length > 0 ? (
|
||
<div className="mt-4">
|
||
<div className="flex items-center justify-between">
|
||
<p className="font-mono text-xs uppercase tracking-wider text-zinc-500">
|
||
{dict.screenshots}
|
||
</p>
|
||
<div className="flex gap-1.5">
|
||
<button
|
||
type="button"
|
||
onClick={() => scrollStrip(-360)}
|
||
aria-label={dict.scrollBack}
|
||
className="rounded-full border border-white/10 px-2.5 py-0.5 text-xs text-zinc-400 transition-colors hover:border-white/25 hover:text-white"
|
||
>
|
||
‹
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => scrollStrip(360)}
|
||
aria-label={dict.scrollForward}
|
||
className="rounded-full border border-white/10 px-2.5 py-0.5 text-xs text-zinc-400 transition-colors hover:border-white/25 hover:text-white"
|
||
>
|
||
›
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div
|
||
ref={stripRef}
|
||
className="no-scrollbar mt-1.5 flex gap-2 overflow-x-auto p-1"
|
||
>
|
||
{profile.screenshots.map((shot) => (
|
||
<button
|
||
key={shot}
|
||
type="button"
|
||
onClick={() => setBannerPick(shot)}
|
||
aria-label={dict.screenshots}
|
||
className={`shrink-0 overflow-hidden rounded-xl transition-all ${
|
||
hero === shot
|
||
? "ring-2 ring-blurple"
|
||
: "opacity-70 hover:opacity-100"
|
||
}`}
|
||
>
|
||
<Image
|
||
src={shot}
|
||
alt=""
|
||
width={320}
|
||
height={180}
|
||
loading="lazy"
|
||
className="h-auto w-56"
|
||
/>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|