Add public profiles and cosmetic shop
All checks were successful
Deploy MineGeo to Dokploy / deploy (push) Successful in 7m10s
All checks were successful
Deploy MineGeo to Dokploy / deploy (push) Successful in 7m10s
This commit is contained in:
152
components/CosmeticsShop.tsx
Normal file
152
components/CosmeticsShop.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user