All checks were successful
Deploy MineGeo to Dokploy / deploy (push) Successful in 7m10s
153 lines
5.0 KiB
TypeScript
153 lines
5.0 KiB
TypeScript
"use client";
|
||
|
||
import { useState } from "react";
|
||
import { useRouter } from "next/navigation";
|
||
import { Check, Coins, Sparkles } from "lucide-react";
|
||
import { useI18n } from "@/components/I18nProvider";
|
||
import { PlayerAvatar } from "@/components/PixelArt";
|
||
import { api } from "@/lib/client-api";
|
||
import type {
|
||
CosmeticShopState,
|
||
ProfileCosmeticId,
|
||
} from "@/shared/profile-cosmetics";
|
||
|
||
export function CosmeticsShop({
|
||
accountId,
|
||
avatarPath,
|
||
initialShop,
|
||
}: {
|
||
accountId: string;
|
||
avatarPath?: string | null;
|
||
initialShop: CosmeticShopState;
|
||
}) {
|
||
const { t, formatNumber } = useI18n();
|
||
const router = useRouter();
|
||
const [shop, setShop] = useState(initialShop);
|
||
const [busy, setBusy] = useState<string | null>(null);
|
||
const [error, setError] = useState("");
|
||
const [message, setMessage] = useState("");
|
||
|
||
async function change(action: "purchase" | "equip", cosmeticId: ProfileCosmeticId | null) {
|
||
const requestId = cosmeticId ?? "none";
|
||
setBusy(requestId);
|
||
setError("");
|
||
setMessage("");
|
||
try {
|
||
const result = await api<{ shop: CosmeticShopState }>("cosmetics", {
|
||
action,
|
||
cosmeticId,
|
||
});
|
||
setShop(result.shop);
|
||
setMessage(
|
||
action === "purchase"
|
||
? "Cosmetic purchased and equipped."
|
||
: cosmeticId
|
||
? "Cosmetic equipped."
|
||
: "Profile effect removed.",
|
||
);
|
||
router.refresh();
|
||
} catch (reason) {
|
||
setError((reason as Error).message);
|
||
} finally {
|
||
setBusy(null);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="cosmetics-shop">
|
||
<div className="cosmetics-shop-heading">
|
||
<div>
|
||
<span className="eyebrow">{t("THE EXPLORER’S WARDROBE")}</span>
|
||
<h1>{t("Leave a little sparkle.")}</h1>
|
||
<p className="lead">
|
||
{t("Earn GeoCoins in solo expeditions and dress up your profile portrait.")}
|
||
</p>
|
||
</div>
|
||
<div className="cosmetics-wallet" aria-label={t("GeoCoins balance")}>
|
||
<Coins size={20} aria-hidden="true" />
|
||
<span>
|
||
<small>{t("YOUR BALANCE")}</small>
|
||
<strong>{formatNumber(shop.balance)} {t("GeoCoins")}</strong>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<p className="cosmetics-earning-note">
|
||
<Sparkles size={17} aria-hidden="true" />
|
||
{t("Complete a public solo game to earn 10–20 GeoCoins. Rewards are capped at 100 every 24 hours.")}
|
||
</p>
|
||
|
||
{error && <p className="error-message" role="alert">{t(error)}</p>}
|
||
{message && <p className="success-message" role="status">{t(message)}</p>}
|
||
|
||
<div className="cosmetics-grid">
|
||
{shop.catalog.map((cosmetic) => {
|
||
const owned = shop.owned.includes(cosmetic.id);
|
||
const equipped = shop.equipped === cosmetic.id;
|
||
const itemBusy = busy === cosmetic.id;
|
||
return (
|
||
<article
|
||
className="cosmetic-card"
|
||
data-rarity={cosmetic.rarity}
|
||
key={cosmetic.id}
|
||
>
|
||
<div className="cosmetic-card-top">
|
||
<span className="cosmetic-rarity">{t(cosmetic.rarity)}</span>
|
||
{equipped && (
|
||
<span className="cosmetic-equipped">
|
||
<Check size={13} aria-hidden="true" /> {t("Equipped")}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div className="cosmetic-preview">
|
||
<PlayerAvatar
|
||
id={accountId}
|
||
avatarPath={avatarPath}
|
||
effect={cosmetic.id}
|
||
className="cosmetic-preview-avatar"
|
||
/>
|
||
</div>
|
||
<h2>{t(cosmetic.name)}</h2>
|
||
<p>{t(cosmetic.description)}</p>
|
||
<div className="cosmetic-card-action">
|
||
{owned ? (
|
||
<button
|
||
className={equipped ? "button" : "button primary"}
|
||
type="button"
|
||
onClick={() =>
|
||
change("equip", equipped ? null : cosmetic.id)
|
||
}
|
||
disabled={busy !== null}
|
||
>
|
||
{itemBusy
|
||
? t("Saving…")
|
||
: equipped
|
||
? t("Unequip effect")
|
||
: t("Equip effect")}
|
||
</button>
|
||
) : (
|
||
<button
|
||
className="button primary"
|
||
type="button"
|
||
onClick={() => change("purchase", cosmetic.id)}
|
||
disabled={busy !== null || shop.balance < cosmetic.price}
|
||
>
|
||
{itemBusy
|
||
? t("Buying…")
|
||
: t("Buy · {price} GeoCoins", {
|
||
price: formatNumber(cosmetic.price),
|
||
})}
|
||
</button>
|
||
)}
|
||
</div>
|
||
</article>
|
||
);
|
||
})}
|
||
</div>
|
||
<p className="cosmetics-real-money-note">
|
||
{t("Cosmetics use GeoCoins for now. Payment options may be added later.")}
|
||
</p>
|
||
</div>
|
||
);
|
||
}
|