Revert profile cosmetics and GeoCoin currency
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 6m16s
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 6m16s
This commit is contained in:
@@ -3,7 +3,6 @@ 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";
|
||||
@@ -20,7 +19,6 @@ export function AccountSettings({
|
||||
accountId,
|
||||
email,
|
||||
displayName,
|
||||
bio,
|
||||
avatarPath,
|
||||
appearance,
|
||||
passwordUpdated = false,
|
||||
@@ -28,14 +26,12 @@ 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);
|
||||
@@ -128,16 +124,13 @@ export function AccountSettings({
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const data = await api<{
|
||||
profile: { display_name: string; bio: string };
|
||||
}>(
|
||||
const data = await api<{ profile: { display_name: string } }>(
|
||||
"profile",
|
||||
{ displayName: valid.data, bio: about },
|
||||
{ displayName: valid.data },
|
||||
"PATCH",
|
||||
);
|
||||
setName(data.profile.display_name);
|
||||
setAbout(data.profile.bio);
|
||||
setMessage("Public profile saved.");
|
||||
setMessage("Display name saved.");
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
@@ -168,21 +161,15 @@ export function AccountSettings({
|
||||
<div>
|
||||
<h2>{t("Your public profile.")}</h2>
|
||||
<p className="muted">
|
||||
{t("A name and description for other explorers to see.")}
|
||||
{t("A name for the maps and places you share.")}
|
||||
</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">
|
||||
@@ -234,19 +221,6 @@ 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>
|
||||
@@ -268,7 +242,7 @@ export function AccountSettings({
|
||||
</p>
|
||||
)}
|
||||
<button className="button primary" disabled={busy}>
|
||||
{busy ? t("Saving…") : t("Save public profile")}
|
||||
{busy ? t("Saving…") : t("Save display name")}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -107,15 +107,9 @@ export function LeaderboardTable({
|
||||
<PlayerAvatar
|
||||
id={entry.player_id}
|
||||
avatarPath={entry.avatar_path}
|
||||
effect={entry.profile_effect}
|
||||
/>
|
||||
<span className="player-name">
|
||||
<Link
|
||||
className="profile-name-link"
|
||||
href={"/players/" + entry.player_id}
|
||||
>
|
||||
<Username name={entry.display_name} appearance={entry} />
|
||||
</Link>
|
||||
<Username name={entry.display_name} appearance={entry} />
|
||||
{entry.player_id === playerId && (
|
||||
<span className="you-label">{t("You")}</span>
|
||||
)}
|
||||
@@ -164,15 +158,9 @@ export function WinnersPodium({ leaders }: { leaders: LeaderboardEntry[] }) {
|
||||
<PlayerAvatar
|
||||
id={entry.player_id}
|
||||
avatarPath={entry.avatar_path}
|
||||
effect={entry.profile_effect}
|
||||
/>
|
||||
<h2>
|
||||
<Link
|
||||
className="profile-name-link"
|
||||
href={"/players/" + entry.player_id}
|
||||
>
|
||||
<Username name={entry.display_name} appearance={entry} />
|
||||
</Link>
|
||||
<Username name={entry.display_name} appearance={entry} />
|
||||
</h2>
|
||||
<p>
|
||||
{formatNumber(entry.best_score)} <span>/ 25,000</span>
|
||||
|
||||
@@ -142,7 +142,6 @@ export function MultiplayerGame({
|
||||
<PlayerAvatar
|
||||
id={player.id}
|
||||
avatarPath={player.appearance?.avatar_path}
|
||||
effect={player.appearance?.profile_effect}
|
||||
className="multiplayer-avatar"
|
||||
/>
|
||||
<span>
|
||||
|
||||
@@ -334,7 +334,6 @@ 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 && (
|
||||
@@ -442,7 +441,6 @@ 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
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { profileAvatarUrl } from "@/shared/profile-appearance";
|
||||
import { profileCosmeticIds } from "@/shared/profile-cosmetic-ids";
|
||||
|
||||
export function PixelTrophy({ className = "" }: { className?: string }) {
|
||||
return (
|
||||
@@ -28,28 +27,23 @@ 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 (
|
||||
<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>
|
||||
<img
|
||||
className={`player-avatar ${className}`.trim()}
|
||||
src={avatarUrl}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
);
|
||||
const seed = [...id].reduce(
|
||||
(value, char) => (value * 31 + char.charCodeAt(0)) >>> 0,
|
||||
@@ -59,28 +53,26 @@ export function PlayerAvatar({
|
||||
const skin = ["#c99a77", "#a67b60", "#dab28d", "#875f4d"][(seed >>> 3) % 4];
|
||||
const shirt = ["#68876a", "#697e91", "#a68a50", "#8b6e7f"][(seed >>> 5) % 4];
|
||||
return (
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user