Add public profiles and cosmetic shop
All checks were successful
Deploy MineGeo to Dokploy / deploy (push) Successful in 7m10s

This commit is contained in:
2026-10-15 17:30:10 +05:00
parent 44e62cff2f
commit c541cc09b4
28 changed files with 1359 additions and 45 deletions

View 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>
);
}