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

@@ -3,6 +3,7 @@ import { useI18n } from "@/components/I18nProvider";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { UserRound, LockKeyhole, Mail } from "lucide-react";
import { api } from "./StartGame";
import { PasswordForm } from "./PasswordForm";
@@ -19,6 +20,7 @@ export function AccountSettings({
accountId,
email,
displayName,
bio,
avatarPath,
appearance,
passwordUpdated = false,
@@ -26,12 +28,14 @@ export function AccountSettings({
accountId: string;
email: string;
displayName: string;
bio: string;
avatarPath: string | null;
appearance: ProfileAppearance;
passwordUpdated?: boolean;
}) {
const { t } = useI18n();
const [name, setName] = useState(displayName),
[about, setAbout] = useState(bio),
[message, setMessage] = useState(""),
[error, setError] = useState(""),
[busy, setBusy] = useState(false);
@@ -124,13 +128,16 @@ export function AccountSettings({
}
setBusy(true);
try {
const data = await api<{ profile: { display_name: string } }>(
const data = await api<{
profile: { display_name: string; bio: string };
}>(
"profile",
{ displayName: valid.data },
{ displayName: valid.data, bio: about },
"PATCH",
);
setName(data.profile.display_name);
setMessage("Display name saved.");
setAbout(data.profile.bio);
setMessage("Public profile saved.");
router.refresh();
} catch (e) {
setError((e as Error).message);
@@ -161,15 +168,21 @@ export function AccountSettings({
<div>
<h2>{t("Your public profile.")}</h2>
<p className="muted">
{t("A name for the maps and places you share.")}
{t("A name and description for other explorers to see.")}
</p>
</div>
</div>
<p>
<Link className="text-link" href={"/players/" + accountId}>
{t("View public profile")} →
</Link>
</p>
<form onSubmit={save}>
<div className="account-avatar-control">
<PlayerAvatar
id={accountId}
avatarPath={photoPath}
effect={appearance.profile_effect}
className="account-avatar-preview"
/>
<div className="account-avatar-input">
@@ -221,6 +234,19 @@ export function AccountSettings({
{t("2–32 characters. Display names do not have to be unique.")}
</small>
</label>
<label className="field">
{t("About you")}
<textarea
name="bio"
value={about}
onChange={(event) => setAbout(event.target.value)}
maxLength={280}
rows={4}
placeholder={t("Tell other explorers a little about yourself.")}
disabled={busy}
/>
<small>{t("Shown on your public profile · Maximum 280 characters.")}</small>
</label>
<div className="account-email">
<Mail size={17} />
<div>
@@ -242,7 +268,7 @@ export function AccountSettings({
</p>
)}
<button className="button primary" disabled={busy}>
{busy ? t("Saving…") : t("Save display name")}
{busy ? t("Saving…") : t("Save public profile")}
</button>
</form>
</section>

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

View File

@@ -107,9 +107,15 @@ export function LeaderboardTable({
<PlayerAvatar
id={entry.player_id}
avatarPath={entry.avatar_path}
effect={entry.profile_effect}
/>
<span className="player-name">
<Username name={entry.display_name} appearance={entry} />
<Link
className="profile-name-link"
href={"/players/" + entry.player_id}
>
<Username name={entry.display_name} appearance={entry} />
</Link>
{entry.player_id === playerId && (
<span className="you-label">{t("You")}</span>
)}
@@ -158,9 +164,15 @@ export function WinnersPodium({ leaders }: { leaders: LeaderboardEntry[] }) {
<PlayerAvatar
id={entry.player_id}
avatarPath={entry.avatar_path}
effect={entry.profile_effect}
/>
<h2>
<Username name={entry.display_name} appearance={entry} />
<Link
className="profile-name-link"
href={"/players/" + entry.player_id}
>
<Username name={entry.display_name} appearance={entry} />
</Link>
</h2>
<p>
{formatNumber(entry.best_score)} <span>/ 25,000</span>

View File

@@ -142,6 +142,7 @@ export function MultiplayerGame({
<PlayerAvatar
id={player.id}
avatarPath={player.appearance?.avatar_path}
effect={player.appearance?.profile_effect}
className="multiplayer-avatar"
/>
<span>

View File

@@ -334,6 +334,7 @@ export function MultiplayerRoom({ id }: { id: string }) {
<PlayerAvatar
id={player.id}
avatarPath={player.appearance?.avatar_path}
effect={player.appearance?.profile_effect}
className="multiplayer-avatar"
/>
{player.id === room.hostId && (
@@ -441,6 +442,7 @@ export function MultiplayerRoom({ id }: { id: string }) {
<PlayerAvatar
id={player.id}
avatarPath={player.appearance?.avatar_path}
effect={player.appearance?.profile_effect}
className="multiplayer-final-avatar"
/>
<Username

View File

@@ -1,4 +1,5 @@
import { profileAvatarUrl } from "@/shared/profile-appearance";
import { profileCosmeticIds } from "@/shared/profile-cosmetic-ids";
export function PixelTrophy({ className = "" }: { className?: string }) {
return (
@@ -27,23 +28,28 @@ export function PixelTrophy({ className = "" }: { className?: string }) {
export function PlayerAvatar({
id,
avatarPath,
effect,
className = "",
}: {
id: string;
avatarPath?: string | null;
effect?: string | null;
className?: string;
}) {
const avatarUrl = profileAvatarUrl(avatarPath);
const activeEffect = profileCosmeticIds.find((value) => value === effect);
if (avatarUrl)
return (
<img
className={`player-avatar ${className}`.trim()}
src={avatarUrl}
alt=""
aria-hidden="true"
loading="lazy"
decoding="async"
/>
<span className="profile-avatar-shell" data-effect={activeEffect}>
<img
className={`player-avatar ${className}`.trim()}
src={avatarUrl}
alt=""
aria-hidden="true"
loading="lazy"
decoding="async"
/>
</span>
);
const seed = [...id].reduce(
(value, char) => (value * 31 + char.charCodeAt(0)) >>> 0,
@@ -53,26 +59,28 @@ export function PlayerAvatar({
const skin = ["#c99a77", "#a67b60", "#dab28d", "#875f4d"][(seed >>> 3) % 4];
const shirt = ["#68876a", "#697e91", "#a68a50", "#8b6e7f"][(seed >>> 5) % 4];
return (
<svg
className={`player-avatar ${className}`.trim()}
viewBox="0 0 8 8"
aria-hidden="true"
shapeRendering="crispEdges"
>
<path fill={shirt} d="M0 0h8v8H0z" />
<path fill={skin} d="M1 1h6v6H1z" />
<path
fill={hair}
d={
seed % 2
? "M1 0h6v2H3v1H1zM6 2h1v2H6z"
: "M1 0h6v2H1zM1 2h1v2H1zM6 2h1v2H6z"
}
/>
<path fill="#e6ded0" d="M2 3h1v1H2zM5 3h1v1H5z" />
<path fill="#3b4140" d="M3 3h1v1H3zM4 3h1v1H4z" />
<path fill="#825b49" d="M3 5h2v1H3z" />
<path fill={shirt} d="M0 7h8v1H0z" />
</svg>
<span className="profile-avatar-shell" data-effect={activeEffect}>
<svg
className={`player-avatar ${className}`.trim()}
viewBox="0 0 8 8"
aria-hidden="true"
shapeRendering="crispEdges"
>
<path fill={shirt} d="M0 0h8v8H0z" />
<path fill={skin} d="M1 1h6v6H1z" />
<path
fill={hair}
d={
seed % 2
? "M1 0h6v2H3v1H1zM6 2h1v2H6z"
: "M1 0h6v2H1zM1 2h1v2H1zM6 2h1v2H6z"
}
/>
<path fill="#e6ded0" d="M2 3h1v1H2zM5 3h1v1H5z" />
<path fill="#3b4140" d="M3 3h1v1H3zM4 3h1v1H4z" />
<path fill="#825b49" d="M3 5h2v1H3z" />
<path fill={shirt} d="M0 7h8v1H0z" />
</svg>
</span>
);
}