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:
43
app/cosmetics/page.tsx
Normal file
43
app/cosmetics/page.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
import { CosmeticsShop } from "@/components/CosmeticsShop";
|
||||||
|
import { Footer, Header } from "@/components/Shell";
|
||||||
|
import { getI18n } from "@/lib/i18n";
|
||||||
|
import { accountProfile } from "@/lib/profile";
|
||||||
|
import { profileCosmeticsState } from "@/lib/profile-cosmetics";
|
||||||
|
import { user } from "@/lib/supabase";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function generateMetadata() {
|
||||||
|
const { t } = await getI18n();
|
||||||
|
return { title: t("Profile cosmetics") };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function CosmeticsPage() {
|
||||||
|
const account = await user();
|
||||||
|
if (!account) redirect("/log-in?next=%2Fcosmetics");
|
||||||
|
const [profile, shop] = await Promise.all([
|
||||||
|
accountProfile(account),
|
||||||
|
profileCosmeticsState(account.id),
|
||||||
|
]);
|
||||||
|
const { t } = await getI18n();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header />
|
||||||
|
<main className="section page-content">
|
||||||
|
<Link className="text-link" href="/dashboard">
|
||||||
|
<ArrowLeft size={15} aria-hidden="true" /> {t("My account")}
|
||||||
|
</Link>
|
||||||
|
<CosmeticsShop
|
||||||
|
accountId={account.id}
|
||||||
|
avatarPath={profile.avatar_path}
|
||||||
|
initialShop={shop}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -42,6 +42,14 @@ export default async function DashboardPage() {
|
|||||||
.
|
.
|
||||||
</p>
|
</p>
|
||||||
<Dashboard />
|
<Dashboard />
|
||||||
|
<div className="inline-actions dashboard-profile-actions">
|
||||||
|
<Link className="button primary" href="/cosmetics">
|
||||||
|
{t("Browse profile cosmetics")}
|
||||||
|
</Link>
|
||||||
|
<Link className="button" href={"/players/" + account.id}>
|
||||||
|
{t("View public profile")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
{history.length > 0 && (
|
{history.length > 0 && (
|
||||||
<section className="token-box">
|
<section className="token-box">
|
||||||
<h2>{t("Recent journeys")}</h2>
|
<h2>{t("Recent journeys")}</h2>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import "./mobile-game.css";
|
|||||||
import "./multiplayer.css";
|
import "./multiplayer.css";
|
||||||
import "./support.css";
|
import "./support.css";
|
||||||
import "./appearance.css";
|
import "./appearance.css";
|
||||||
|
import "./profile-cosmetics.css";
|
||||||
import "./community.css";
|
import "./community.css";
|
||||||
import { themeInitScript } from "@/shared/theme";
|
import { themeInitScript } from "@/shared/theme";
|
||||||
import { SiteSettingsProvider } from "@/components/SiteSettingsProvider";
|
import { SiteSettingsProvider } from "@/components/SiteSettingsProvider";
|
||||||
|
|||||||
76
app/players/[id]/page.tsx
Normal file
76
app/players/[id]/page.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { ArrowLeft, Compass, MapPinned } from "lucide-react";
|
||||||
|
import { Footer, Header } from "@/components/Shell";
|
||||||
|
import { PlayerAvatar } from "@/components/PixelArt";
|
||||||
|
import { Username } from "@/components/Username";
|
||||||
|
import { getI18n } from "@/lib/i18n";
|
||||||
|
import { publicProfile } from "@/lib/public-profile";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function PlayerProfilePage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}) {
|
||||||
|
const { id } = await params;
|
||||||
|
const profile = await publicProfile(id);
|
||||||
|
if (!profile) notFound();
|
||||||
|
const { t, formatDate, formatNumber } = await getI18n();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header />
|
||||||
|
<main className="section page-content public-profile-page">
|
||||||
|
<Link className="text-link" href="/leaderboard">
|
||||||
|
<ArrowLeft size={15} aria-hidden="true" /> {t("Back to leaderboard")}
|
||||||
|
</Link>
|
||||||
|
<section className="public-profile-hero">
|
||||||
|
<PlayerAvatar
|
||||||
|
id={profile.id}
|
||||||
|
avatarPath={profile.avatar_path}
|
||||||
|
effect={profile.profile_effect}
|
||||||
|
className="public-profile-avatar"
|
||||||
|
/>
|
||||||
|
<div className="public-profile-copy">
|
||||||
|
<span className="eyebrow">{t("MINEGEO EXPLORER")}</span>
|
||||||
|
<h1>
|
||||||
|
<Username name={profile.display_name} appearance={profile} />
|
||||||
|
</h1>
|
||||||
|
<p className="muted">
|
||||||
|
{t("Exploring since {date}", {
|
||||||
|
date: formatDate(profile.created_at),
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="public-profile-about" aria-labelledby="profile-about">
|
||||||
|
<h2 id="profile-about">{t("About this explorer")}</h2>
|
||||||
|
<p className="public-profile-bio">
|
||||||
|
{profile.bio || t("This explorer hasn’t added a description yet.")}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="public-profile-stats" aria-label={t("Profile stats")}>
|
||||||
|
<article className="public-profile-stat">
|
||||||
|
<Compass size={19} aria-hidden="true" />
|
||||||
|
<span>{t("Completed journeys")}</span>
|
||||||
|
<strong>{formatNumber(profile.completedGames)}</strong>
|
||||||
|
</article>
|
||||||
|
<article className="public-profile-stat">
|
||||||
|
<MapPinned size={19} aria-hidden="true" />
|
||||||
|
<span>{t("Published maps")}</span>
|
||||||
|
<strong>{formatNumber(profile.publishedMaps)}</strong>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p className="public-profile-footnote">
|
||||||
|
{t("Only public account details and published maps appear here.")}
|
||||||
|
</p>
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
409
app/profile-cosmetics.css
Normal file
409
app/profile-cosmetics.css
Normal file
@@ -0,0 +1,409 @@
|
|||||||
|
.profile-avatar-shell {
|
||||||
|
display: inline-grid;
|
||||||
|
position: relative;
|
||||||
|
isolation: isolate;
|
||||||
|
place-items: center;
|
||||||
|
flex: none;
|
||||||
|
line-height: 0;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-avatar-shell > .player-avatar {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-avatar-shell[data-effect]::before,
|
||||||
|
.profile-avatar-shell[data-effect]::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-avatar-shell[data-effect="redstone-pulse"]::before {
|
||||||
|
inset: -4px;
|
||||||
|
border: 2px solid #ef6556;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 0 8px #ef655688, inset 0 0 6px #ef655655;
|
||||||
|
animation: redstone-pulse 1.8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-avatar-shell[data-effect="ender-orbit"]::before {
|
||||||
|
inset: -5px;
|
||||||
|
border: 1px solid #ad83fa;
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: 0 0 9px #8d62e877;
|
||||||
|
transform: rotate(-24deg) scaleX(0.74);
|
||||||
|
animation: ender-orbit 3.4s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-avatar-shell[data-effect="ender-orbit"]::after {
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
top: -4px;
|
||||||
|
right: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #d3b7ff;
|
||||||
|
box-shadow: 0 0 8px #a780ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-avatar-shell[data-effect="nether-flame"]::before {
|
||||||
|
inset: -6px;
|
||||||
|
border: 2px solid #ff8c43;
|
||||||
|
border-radius: 7px;
|
||||||
|
box-shadow: 0 0 9px #f4632d99, inset 0 -5px 10px #f4632d55;
|
||||||
|
animation: nether-flicker 1.2s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-avatar-shell[data-effect="nether-flame"]::after {
|
||||||
|
inset: -8px -3px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border-top: 1px solid #ffd27c;
|
||||||
|
border-bottom: 1px solid #f4632d99;
|
||||||
|
filter: blur(1px);
|
||||||
|
opacity: 0.7;
|
||||||
|
animation: nether-flicker 1.7s ease-in-out infinite alternate-reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-avatar-shell[data-effect="aurora-crown"]::before {
|
||||||
|
inset: -6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: conic-gradient(
|
||||||
|
from 0deg,
|
||||||
|
#72f3d4,
|
||||||
|
#75a9ff,
|
||||||
|
#c89bff,
|
||||||
|
#72f3d4
|
||||||
|
);
|
||||||
|
-webkit-mask: radial-gradient(
|
||||||
|
farthest-side,
|
||||||
|
transparent calc(100% - 2px),
|
||||||
|
#000 calc(100% - 1px)
|
||||||
|
);
|
||||||
|
mask: radial-gradient(
|
||||||
|
farthest-side,
|
||||||
|
transparent calc(100% - 2px),
|
||||||
|
#000 calc(100% - 1px)
|
||||||
|
);
|
||||||
|
filter: drop-shadow(0 0 5px #78e7d5aa);
|
||||||
|
animation: aurora-turn 5s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-name-link {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-profile-actions {
|
||||||
|
margin: 20px 0 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-name-link:hover,
|
||||||
|
.profile-name-link:focus-visible {
|
||||||
|
color: var(--green);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-profile-page {
|
||||||
|
max-width: 900px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-profile-hero {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 28px;
|
||||||
|
margin: 34px 0 28px;
|
||||||
|
padding: 32px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse at 12% 50%, #84c8a31b, transparent 55%),
|
||||||
|
var(--panel);
|
||||||
|
box-shadow: 4px 4px #03070b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-profile-avatar {
|
||||||
|
width: 88px;
|
||||||
|
height: 88px;
|
||||||
|
box-shadow: 4px 4px #03070b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-profile-copy h1 {
|
||||||
|
margin: 8px 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-profile-copy p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-profile-about {
|
||||||
|
margin: 28px 0;
|
||||||
|
padding: 24px 28px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-profile-about h2 {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: 23px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-profile-bio {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.8;
|
||||||
|
white-space: pre-line;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-profile-stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-profile-stat {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px 12px;
|
||||||
|
padding: 18px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-profile-stat > svg {
|
||||||
|
color: var(--green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-profile-stat > span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-profile-stat > strong {
|
||||||
|
grid-column: 2;
|
||||||
|
font-size: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-profile-footnote {
|
||||||
|
margin: 16px 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cosmetics-shop {
|
||||||
|
margin-top: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cosmetics-shop-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 30px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cosmetics-shop-heading h1 {
|
||||||
|
margin: 10px 0;
|
||||||
|
font-size: clamp(38px, 6vw, 60px);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cosmetics-shop-heading .lead {
|
||||||
|
max-width: 610px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cosmetics-wallet {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 13px;
|
||||||
|
flex: none;
|
||||||
|
min-width: 190px;
|
||||||
|
padding: 16px 19px;
|
||||||
|
border: 1px solid #d8bd72;
|
||||||
|
background: #241f14;
|
||||||
|
box-shadow: 3px 3px #03070b;
|
||||||
|
color: #f0d891;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cosmetics-wallet > span {
|
||||||
|
display: grid;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cosmetics-wallet small {
|
||||||
|
color: #bcae84;
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cosmetics-wallet strong {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cosmetics-earning-note {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 18px 0 28px;
|
||||||
|
padding: 13px 16px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cosmetics-earning-note svg {
|
||||||
|
flex: none;
|
||||||
|
color: #d8bd72;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cosmetics-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cosmetic-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 350px;
|
||||||
|
padding: 18px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-top: 3px solid #7b8c83;
|
||||||
|
background: var(--panel);
|
||||||
|
box-shadow: 3px 3px #03070b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cosmetic-card[data-rarity="rare"] {
|
||||||
|
border-top-color: #8984d8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cosmetic-card[data-rarity="epic"] {
|
||||||
|
border-top-color: #e08b55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cosmetic-card[data-rarity="legendary"] {
|
||||||
|
border-top-color: #79d4bd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cosmetic-card-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cosmetic-rarity {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cosmetic-equipped {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
color: var(--green);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cosmetic-preview {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
min-height: 128px;
|
||||||
|
margin: 12px 0 14px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle, #9caa7122 0, transparent 58%),
|
||||||
|
#0b1117;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cosmetic-preview-avatar {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
box-shadow: 3px 3px #03070b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cosmetic-card h2 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 21px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cosmetic-card > p {
|
||||||
|
flex: 1;
|
||||||
|
margin: 0 0 18px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cosmetic-card-action .button {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 42px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cosmetics-real-money-note {
|
||||||
|
margin: 24px 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes redstone-pulse {
|
||||||
|
0%, 100% { opacity: 0.65; box-shadow: 0 0 5px #ef655655; }
|
||||||
|
50% { opacity: 1; box-shadow: 0 0 13px #ef6556bb, inset 0 0 6px #ef655655; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ender-orbit {
|
||||||
|
to { transform: rotate(336deg) scaleX(0.74); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes nether-flicker {
|
||||||
|
from { opacity: 0.58; filter: saturate(0.9); }
|
||||||
|
to { opacity: 1; filter: saturate(1.25); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes aurora-turn {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.profile-avatar-shell[data-effect]::before,
|
||||||
|
.profile-avatar-shell[data-effect]::after {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 680px) {
|
||||||
|
.public-profile-hero {
|
||||||
|
gap: 20px;
|
||||||
|
padding: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cosmetics-shop-heading {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cosmetics-wallet {
|
||||||
|
width: max-content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.public-profile-hero {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-profile-stats {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -85,6 +85,7 @@ export default async function Settings({
|
|||||||
accountId={account.id}
|
accountId={account.id}
|
||||||
email={account.email ?? ""}
|
email={account.email ?? ""}
|
||||||
displayName={profile.display_name}
|
displayName={profile.display_name}
|
||||||
|
bio={profile.bio}
|
||||||
avatarPath={profile.avatar_path}
|
avatarPath={profile.avatar_path}
|
||||||
appearance={profile}
|
appearance={profile}
|
||||||
passwordUpdated={query.password === "updated"}
|
passwordUpdated={query.password === "updated"}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useI18n } from "@/components/I18nProvider";
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
import { UserRound, LockKeyhole, Mail } from "lucide-react";
|
import { UserRound, LockKeyhole, Mail } from "lucide-react";
|
||||||
import { api } from "./StartGame";
|
import { api } from "./StartGame";
|
||||||
import { PasswordForm } from "./PasswordForm";
|
import { PasswordForm } from "./PasswordForm";
|
||||||
@@ -19,6 +20,7 @@ export function AccountSettings({
|
|||||||
accountId,
|
accountId,
|
||||||
email,
|
email,
|
||||||
displayName,
|
displayName,
|
||||||
|
bio,
|
||||||
avatarPath,
|
avatarPath,
|
||||||
appearance,
|
appearance,
|
||||||
passwordUpdated = false,
|
passwordUpdated = false,
|
||||||
@@ -26,12 +28,14 @@ export function AccountSettings({
|
|||||||
accountId: string;
|
accountId: string;
|
||||||
email: string;
|
email: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
|
bio: string;
|
||||||
avatarPath: string | null;
|
avatarPath: string | null;
|
||||||
appearance: ProfileAppearance;
|
appearance: ProfileAppearance;
|
||||||
passwordUpdated?: boolean;
|
passwordUpdated?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [name, setName] = useState(displayName),
|
const [name, setName] = useState(displayName),
|
||||||
|
[about, setAbout] = useState(bio),
|
||||||
[message, setMessage] = useState(""),
|
[message, setMessage] = useState(""),
|
||||||
[error, setError] = useState(""),
|
[error, setError] = useState(""),
|
||||||
[busy, setBusy] = useState(false);
|
[busy, setBusy] = useState(false);
|
||||||
@@ -124,13 +128,16 @@ export function AccountSettings({
|
|||||||
}
|
}
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
const data = await api<{ profile: { display_name: string } }>(
|
const data = await api<{
|
||||||
|
profile: { display_name: string; bio: string };
|
||||||
|
}>(
|
||||||
"profile",
|
"profile",
|
||||||
{ displayName: valid.data },
|
{ displayName: valid.data, bio: about },
|
||||||
"PATCH",
|
"PATCH",
|
||||||
);
|
);
|
||||||
setName(data.profile.display_name);
|
setName(data.profile.display_name);
|
||||||
setMessage("Display name saved.");
|
setAbout(data.profile.bio);
|
||||||
|
setMessage("Public profile saved.");
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError((e as Error).message);
|
setError((e as Error).message);
|
||||||
@@ -161,15 +168,21 @@ export function AccountSettings({
|
|||||||
<div>
|
<div>
|
||||||
<h2>{t("Your public profile.")}</h2>
|
<h2>{t("Your public profile.")}</h2>
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
{t("A name for the maps and places you share.")}
|
{t("A name and description for other explorers to see.")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<p>
|
||||||
|
<Link className="text-link" href={"/players/" + accountId}>
|
||||||
|
{t("View public profile")} →
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
<form onSubmit={save}>
|
<form onSubmit={save}>
|
||||||
<div className="account-avatar-control">
|
<div className="account-avatar-control">
|
||||||
<PlayerAvatar
|
<PlayerAvatar
|
||||||
id={accountId}
|
id={accountId}
|
||||||
avatarPath={photoPath}
|
avatarPath={photoPath}
|
||||||
|
effect={appearance.profile_effect}
|
||||||
className="account-avatar-preview"
|
className="account-avatar-preview"
|
||||||
/>
|
/>
|
||||||
<div className="account-avatar-input">
|
<div className="account-avatar-input">
|
||||||
@@ -221,6 +234,19 @@ export function AccountSettings({
|
|||||||
{t("2–32 characters. Display names do not have to be unique.")}
|
{t("2–32 characters. Display names do not have to be unique.")}
|
||||||
</small>
|
</small>
|
||||||
</label>
|
</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">
|
<div className="account-email">
|
||||||
<Mail size={17} />
|
<Mail size={17} />
|
||||||
<div>
|
<div>
|
||||||
@@ -242,7 +268,7 @@ export function AccountSettings({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<button className="button primary" disabled={busy}>
|
<button className="button primary" disabled={busy}>
|
||||||
{busy ? t("Saving…") : t("Save display name")}
|
{busy ? t("Saving…") : t("Save public profile")}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -107,9 +107,15 @@ export function LeaderboardTable({
|
|||||||
<PlayerAvatar
|
<PlayerAvatar
|
||||||
id={entry.player_id}
|
id={entry.player_id}
|
||||||
avatarPath={entry.avatar_path}
|
avatarPath={entry.avatar_path}
|
||||||
|
effect={entry.profile_effect}
|
||||||
/>
|
/>
|
||||||
<span className="player-name">
|
<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 && (
|
{entry.player_id === playerId && (
|
||||||
<span className="you-label">{t("You")}</span>
|
<span className="you-label">{t("You")}</span>
|
||||||
)}
|
)}
|
||||||
@@ -158,9 +164,15 @@ export function WinnersPodium({ leaders }: { leaders: LeaderboardEntry[] }) {
|
|||||||
<PlayerAvatar
|
<PlayerAvatar
|
||||||
id={entry.player_id}
|
id={entry.player_id}
|
||||||
avatarPath={entry.avatar_path}
|
avatarPath={entry.avatar_path}
|
||||||
|
effect={entry.profile_effect}
|
||||||
/>
|
/>
|
||||||
<h2>
|
<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>
|
</h2>
|
||||||
<p>
|
<p>
|
||||||
{formatNumber(entry.best_score)} <span>/ 25,000</span>
|
{formatNumber(entry.best_score)} <span>/ 25,000</span>
|
||||||
|
|||||||
@@ -142,6 +142,7 @@ export function MultiplayerGame({
|
|||||||
<PlayerAvatar
|
<PlayerAvatar
|
||||||
id={player.id}
|
id={player.id}
|
||||||
avatarPath={player.appearance?.avatar_path}
|
avatarPath={player.appearance?.avatar_path}
|
||||||
|
effect={player.appearance?.profile_effect}
|
||||||
className="multiplayer-avatar"
|
className="multiplayer-avatar"
|
||||||
/>
|
/>
|
||||||
<span>
|
<span>
|
||||||
|
|||||||
@@ -334,6 +334,7 @@ export function MultiplayerRoom({ id }: { id: string }) {
|
|||||||
<PlayerAvatar
|
<PlayerAvatar
|
||||||
id={player.id}
|
id={player.id}
|
||||||
avatarPath={player.appearance?.avatar_path}
|
avatarPath={player.appearance?.avatar_path}
|
||||||
|
effect={player.appearance?.profile_effect}
|
||||||
className="multiplayer-avatar"
|
className="multiplayer-avatar"
|
||||||
/>
|
/>
|
||||||
{player.id === room.hostId && (
|
{player.id === room.hostId && (
|
||||||
@@ -441,6 +442,7 @@ export function MultiplayerRoom({ id }: { id: string }) {
|
|||||||
<PlayerAvatar
|
<PlayerAvatar
|
||||||
id={player.id}
|
id={player.id}
|
||||||
avatarPath={player.appearance?.avatar_path}
|
avatarPath={player.appearance?.avatar_path}
|
||||||
|
effect={player.appearance?.profile_effect}
|
||||||
className="multiplayer-final-avatar"
|
className="multiplayer-final-avatar"
|
||||||
/>
|
/>
|
||||||
<Username
|
<Username
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { profileAvatarUrl } from "@/shared/profile-appearance";
|
import { profileAvatarUrl } from "@/shared/profile-appearance";
|
||||||
|
import { profileCosmeticIds } from "@/shared/profile-cosmetic-ids";
|
||||||
|
|
||||||
export function PixelTrophy({ className = "" }: { className?: string }) {
|
export function PixelTrophy({ className = "" }: { className?: string }) {
|
||||||
return (
|
return (
|
||||||
@@ -27,23 +28,28 @@ export function PixelTrophy({ className = "" }: { className?: string }) {
|
|||||||
export function PlayerAvatar({
|
export function PlayerAvatar({
|
||||||
id,
|
id,
|
||||||
avatarPath,
|
avatarPath,
|
||||||
|
effect,
|
||||||
className = "",
|
className = "",
|
||||||
}: {
|
}: {
|
||||||
id: string;
|
id: string;
|
||||||
avatarPath?: string | null;
|
avatarPath?: string | null;
|
||||||
|
effect?: string | null;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const avatarUrl = profileAvatarUrl(avatarPath);
|
const avatarUrl = profileAvatarUrl(avatarPath);
|
||||||
|
const activeEffect = profileCosmeticIds.find((value) => value === effect);
|
||||||
if (avatarUrl)
|
if (avatarUrl)
|
||||||
return (
|
return (
|
||||||
<img
|
<span className="profile-avatar-shell" data-effect={activeEffect}>
|
||||||
className={`player-avatar ${className}`.trim()}
|
<img
|
||||||
src={avatarUrl}
|
className={`player-avatar ${className}`.trim()}
|
||||||
alt=""
|
src={avatarUrl}
|
||||||
aria-hidden="true"
|
alt=""
|
||||||
loading="lazy"
|
aria-hidden="true"
|
||||||
decoding="async"
|
loading="lazy"
|
||||||
/>
|
decoding="async"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
);
|
);
|
||||||
const seed = [...id].reduce(
|
const seed = [...id].reduce(
|
||||||
(value, char) => (value * 31 + char.charCodeAt(0)) >>> 0,
|
(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 skin = ["#c99a77", "#a67b60", "#dab28d", "#875f4d"][(seed >>> 3) % 4];
|
||||||
const shirt = ["#68876a", "#697e91", "#a68a50", "#8b6e7f"][(seed >>> 5) % 4];
|
const shirt = ["#68876a", "#697e91", "#a68a50", "#8b6e7f"][(seed >>> 5) % 4];
|
||||||
return (
|
return (
|
||||||
<svg
|
<span className="profile-avatar-shell" data-effect={activeEffect}>
|
||||||
className={`player-avatar ${className}`.trim()}
|
<svg
|
||||||
viewBox="0 0 8 8"
|
className={`player-avatar ${className}`.trim()}
|
||||||
aria-hidden="true"
|
viewBox="0 0 8 8"
|
||||||
shapeRendering="crispEdges"
|
aria-hidden="true"
|
||||||
>
|
shapeRendering="crispEdges"
|
||||||
<path fill={shirt} d="M0 0h8v8H0z" />
|
>
|
||||||
<path fill={skin} d="M1 1h6v6H1z" />
|
<path fill={shirt} d="M0 0h8v8H0z" />
|
||||||
<path
|
<path fill={skin} d="M1 1h6v6H1z" />
|
||||||
fill={hair}
|
<path
|
||||||
d={
|
fill={hair}
|
||||||
seed % 2
|
d={
|
||||||
? "M1 0h6v2H3v1H1zM6 2h1v2H6z"
|
seed % 2
|
||||||
: "M1 0h6v2H1zM1 2h1v2H1zM6 2h1v2H6z"
|
? "M1 0h6v2H3v1H1zM6 2h1v2H6z"
|
||||||
}
|
: "M1 0h6v2H1zM1 2h1v2H1zM6 2h1v2H6z"
|
||||||
/>
|
}
|
||||||
<path fill="#e6ded0" d="M2 3h1v1H2zM5 3h1v1H5z" />
|
/>
|
||||||
<path fill="#3b4140" d="M3 3h1v1H3zM4 3h1v1H4z" />
|
<path fill="#e6ded0" d="M2 3h1v1H2zM5 3h1v1H5z" />
|
||||||
<path fill="#825b49" d="M3 5h2v1H3z" />
|
<path fill="#3b4140" d="M3 3h1v1H3zM4 3h1v1H4z" />
|
||||||
<path fill={shirt} d="M0 7h8v1H0z" />
|
<path fill="#825b49" d="M3 5h2v1H3z" />
|
||||||
</svg>
|
<path fill={shirt} d="M0 7h8v1H0z" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ export async function accountApi(
|
|||||||
.update({ avatar_path: avatarPath })
|
.update({ avatar_path: avatarPath })
|
||||||
.eq("id", account.id)
|
.eq("id", account.id)
|
||||||
.select(
|
.select(
|
||||||
"id,display_name,avatar_path,role,name_glow,glow_color,bug_finder,custom_prefix,custom_prefix_icon",
|
"id,display_name,bio,avatar_path,profile_effect,role,name_glow,glow_color,bug_finder,custom_prefix,custom_prefix_icon",
|
||||||
)
|
)
|
||||||
.single(),
|
.single(),
|
||||||
);
|
);
|
||||||
@@ -269,10 +269,11 @@ export async function accountApi(
|
|||||||
...(body.avatarPath !== undefined
|
...(body.avatarPath !== undefined
|
||||||
? { avatar_path: body.avatarPath }
|
? { avatar_path: body.avatarPath }
|
||||||
: {}),
|
: {}),
|
||||||
|
...(body.bio !== undefined ? { bio: body.bio } : {}),
|
||||||
})
|
})
|
||||||
.eq("id", account.id)
|
.eq("id", account.id)
|
||||||
.select(
|
.select(
|
||||||
"id,display_name,avatar_path,role,name_glow,glow_color,bug_finder,custom_prefix,custom_prefix_icon",
|
"id,display_name,bio,avatar_path,profile_effect,role,name_glow,glow_color,bug_finder,custom_prefix,custom_prefix_icon",
|
||||||
)
|
)
|
||||||
.single(),
|
.single(),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { requestOrigin, verifyOrigin } from "./request-origin";
|
|||||||
import { browserAccount } from "./request-security";
|
import { browserAccount } from "./request-security";
|
||||||
import { jsonBody } from "./json-body";
|
import { jsonBody } from "./json-body";
|
||||||
import { accountApi } from "./account-api";
|
import { accountApi } from "./account-api";
|
||||||
|
import { cosmeticsApi } from "./cosmetics-api";
|
||||||
import { multiplayerApi } from "./multiplayer-api";
|
import { multiplayerApi } from "./multiplayer-api";
|
||||||
import { bugReportApi } from "./bug-report-api";
|
import { bugReportApi } from "./bug-report-api";
|
||||||
import { moderatorUsersApi } from "./moderator-users-api";
|
import { moderatorUsersApi } from "./moderator-users-api";
|
||||||
@@ -131,6 +132,8 @@ export async function handle(request: NextRequest, path: string[]) {
|
|||||||
return await accountControlsApi(request, path, { jsonBody, limit });
|
return await accountControlsApi(request, path, { jsonBody, limit });
|
||||||
if (path[0] === "auth" || path[0] === "profile")
|
if (path[0] === "auth" || path[0] === "profile")
|
||||||
return await accountApi(request, path, { jsonBody, limit });
|
return await accountApi(request, path, { jsonBody, limit });
|
||||||
|
if (path[0] === "cosmetics")
|
||||||
|
return await cosmeticsApi(request, path, { jsonBody, limit });
|
||||||
// Check signed-in identities before any guest-cookie fallback (games/rooms).
|
// Check signed-in identities before any guest-cookie fallback (games/rooms).
|
||||||
if (path[0] !== "health") await user();
|
if (path[0] !== "health") await user();
|
||||||
if (path[0] === "rooms")
|
if (path[0] === "rooms")
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export async function catalog() {
|
|||||||
await client
|
await client
|
||||||
.from("profiles")
|
.from("profiles")
|
||||||
.select(
|
.select(
|
||||||
"id,display_name,avatar_path,name_glow,glow_color,role,bug_finder,custom_prefix,custom_prefix_icon",
|
"id,display_name,avatar_path,profile_effect,name_glow,glow_color,role,bug_finder,custom_prefix,custom_prefix_icon",
|
||||||
)
|
)
|
||||||
.in("id", [
|
.in("id", [
|
||||||
...new Set(
|
...new Set(
|
||||||
@@ -106,7 +106,7 @@ export async function getMap(id: string) {
|
|||||||
? admin()
|
? admin()
|
||||||
.from("profiles")
|
.from("profiles")
|
||||||
.select(
|
.select(
|
||||||
"display_name,avatar_path,name_glow,glow_color,role,bug_finder,custom_prefix,custom_prefix_icon",
|
"display_name,avatar_path,profile_effect,name_glow,glow_color,role,bug_finder,custom_prefix,custom_prefix_icon",
|
||||||
)
|
)
|
||||||
.eq("id", map.owner_id)
|
.eq("id", map.owner_id)
|
||||||
.maybeSingle()
|
.maybeSingle()
|
||||||
|
|||||||
52
lib/cosmetics-api.ts
Normal file
52
lib/cosmetics-api.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { profileCosmeticActionSchema } from "@/shared/profile-cosmetics";
|
||||||
|
import { admin, required, user } from "./supabase";
|
||||||
|
import { browserAccount } from "./request-security";
|
||||||
|
import { profileCosmeticsState } from "./profile-cosmetics";
|
||||||
|
|
||||||
|
type Context = {
|
||||||
|
jsonBody: (request: NextRequest) => Promise<unknown>;
|
||||||
|
limit: (key: string, count: number, seconds: number) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const json = (body: unknown, status = 200) =>
|
||||||
|
NextResponse.json(body, { status, headers: { "Cache-Control": "no-store" } });
|
||||||
|
|
||||||
|
export async function cosmeticsApi(
|
||||||
|
request: NextRequest,
|
||||||
|
path: string[],
|
||||||
|
context: Context,
|
||||||
|
) {
|
||||||
|
if (path.length !== 1) return json({ error: "Not found" }, 404);
|
||||||
|
const account = await browserAccount(request, user);
|
||||||
|
if (request.method === "GET")
|
||||||
|
return json({ shop: await profileCosmeticsState(account.id) });
|
||||||
|
if (request.method !== "POST")
|
||||||
|
return json({ error: "Method not allowed" }, 405);
|
||||||
|
|
||||||
|
const body = profileCosmeticActionSchema.parse(
|
||||||
|
await context.jsonBody(request),
|
||||||
|
);
|
||||||
|
if (body.action === "purchase") {
|
||||||
|
await context.limit("cosmetic-purchase:" + account.id, 8, 60);
|
||||||
|
return json({
|
||||||
|
shop: required(
|
||||||
|
await admin().rpc("buy_profile_cosmetic", {
|
||||||
|
p_user: account.id,
|
||||||
|
p_cosmetic_id: body.cosmeticId,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await context.limit("cosmetic-equip:" + account.id, 30, 60);
|
||||||
|
return json({
|
||||||
|
shop: required(
|
||||||
|
await admin().rpc("equip_profile_cosmetic", {
|
||||||
|
p_user: account.id,
|
||||||
|
p_cosmetic_id: body.cosmeticId,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -35,7 +35,7 @@ export async function leaderboard({
|
|||||||
await admin()
|
await admin()
|
||||||
.from("profiles")
|
.from("profiles")
|
||||||
.select(
|
.select(
|
||||||
"id,avatar_path,name_glow,glow_color,role,bug_finder,custom_prefix,custom_prefix_icon",
|
"id,avatar_path,profile_effect,name_glow,glow_color,role,bug_finder,custom_prefix,custom_prefix_icon",
|
||||||
)
|
)
|
||||||
.in("id", ids),
|
.in("id", ids),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export async function multiplayerAppearance(room: string, ids: string[]) {
|
|||||||
await db
|
await db
|
||||||
.from("profiles")
|
.from("profiles")
|
||||||
.select(
|
.select(
|
||||||
"id,display_name,avatar_path,name_glow,glow_color,role,bug_finder,custom_prefix,custom_prefix_icon",
|
"id,display_name,avatar_path,profile_effect,name_glow,glow_color,role,bug_finder,custom_prefix,custom_prefix_icon",
|
||||||
)
|
)
|
||||||
.in("id", accounts),
|
.in("id", accounts),
|
||||||
);
|
);
|
||||||
|
|||||||
15
lib/profile-cosmetics.ts
Normal file
15
lib/profile-cosmetics.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { admin, required } from "./supabase";
|
||||||
|
import {
|
||||||
|
cosmeticShopStateSchema,
|
||||||
|
type CosmeticShopState,
|
||||||
|
} from "@/shared/profile-cosmetics";
|
||||||
|
|
||||||
|
export async function profileCosmeticsState(
|
||||||
|
userId: string,
|
||||||
|
): Promise<CosmeticShopState> {
|
||||||
|
const result = required(
|
||||||
|
await admin().rpc("cosmetics_shop_state", { p_user: userId }),
|
||||||
|
);
|
||||||
|
return cosmeticShopStateSchema.parse(result);
|
||||||
|
}
|
||||||
@@ -21,7 +21,7 @@ export async function accountProfile(account: User) {
|
|||||||
await admin()
|
await admin()
|
||||||
.from("profiles")
|
.from("profiles")
|
||||||
.select(
|
.select(
|
||||||
"id,display_name,avatar_path,role,name_glow,glow_color,bug_finder,custom_prefix,custom_prefix_icon,account_status,account_reason,account_changed_at",
|
"id,display_name,bio,avatar_path,profile_effect,role,name_glow,glow_color,bug_finder,custom_prefix,custom_prefix_icon,account_status,account_reason,account_changed_at",
|
||||||
)
|
)
|
||||||
.eq("id", account.id)
|
.eq("id", account.id)
|
||||||
.single(),
|
.single(),
|
||||||
|
|||||||
41
lib/public-profile.ts
Normal file
41
lib/public-profile.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { admin } from "./supabase";
|
||||||
|
|
||||||
|
const uuidSchema = z.string().uuid();
|
||||||
|
|
||||||
|
export async function publicProfile(userId: string) {
|
||||||
|
const validId = uuidSchema.safeParse(userId);
|
||||||
|
if (!validId.success) return null;
|
||||||
|
|
||||||
|
const result = await admin()
|
||||||
|
.from("profiles")
|
||||||
|
.select(
|
||||||
|
"id,display_name,bio,avatar_path,profile_effect,name_glow,glow_color,role,bug_finder,custom_prefix,custom_prefix_icon,created_at",
|
||||||
|
)
|
||||||
|
.eq("id", validId.data)
|
||||||
|
.eq("account_status", "active")
|
||||||
|
.maybeSingle();
|
||||||
|
if (result.error) throw new Error(result.error.message);
|
||||||
|
if (!result.data) return null;
|
||||||
|
|
||||||
|
const [games, maps] = await Promise.all([
|
||||||
|
admin()
|
||||||
|
.from("game_sessions")
|
||||||
|
.select("id", { count: "exact", head: true })
|
||||||
|
.eq("owner_id", validId.data)
|
||||||
|
.eq("current_round", 5),
|
||||||
|
admin()
|
||||||
|
.from("datasets")
|
||||||
|
.select("id", { count: "exact", head: true })
|
||||||
|
.eq("owner_id", validId.data)
|
||||||
|
.eq("state", "published"),
|
||||||
|
]);
|
||||||
|
if (games.error) throw new Error(games.error.message);
|
||||||
|
if (maps.error) throw new Error(maps.error.message);
|
||||||
|
return {
|
||||||
|
...result.data,
|
||||||
|
completedGames: games.count ?? 0,
|
||||||
|
publishedMaps: maps.count ?? 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -43,13 +43,26 @@ export const profileUpdateSchema = z
|
|||||||
displayName: displayNameSchema.optional(),
|
displayName: displayNameSchema.optional(),
|
||||||
glowColor: glowColorSchema.optional(),
|
glowColor: glowColorSchema.optional(),
|
||||||
avatarPath: z.string().max(128).nullable().optional(),
|
avatarPath: z.string().max(128).nullable().optional(),
|
||||||
|
bio: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.max(280, "Use at most 280 characters")
|
||||||
|
.refine(
|
||||||
|
(value) =>
|
||||||
|
!/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069<>]/u.test(
|
||||||
|
value,
|
||||||
|
),
|
||||||
|
"Use plain text without hidden controls or markup",
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.refine(
|
.refine(
|
||||||
(value) =>
|
(value) =>
|
||||||
value.displayName !== undefined ||
|
value.displayName !== undefined ||
|
||||||
value.glowColor !== undefined ||
|
value.glowColor !== undefined ||
|
||||||
value.avatarPath !== undefined,
|
value.avatarPath !== undefined ||
|
||||||
|
value.bio !== undefined,
|
||||||
"Choose a profile change",
|
"Choose a profile change",
|
||||||
);
|
);
|
||||||
export const initialPasswordSchema = z
|
export const initialPasswordSchema = z
|
||||||
|
|||||||
@@ -568,6 +568,8 @@ export type Database = {
|
|||||||
profiles: {
|
profiles: {
|
||||||
Row: AccountStatusFields & {
|
Row: AccountStatusFields & {
|
||||||
avatar_path: string | null;
|
avatar_path: string | null;
|
||||||
|
bio: string;
|
||||||
|
profile_effect: string | null;
|
||||||
glow_color: string;
|
glow_color: string;
|
||||||
bug_finder: boolean;
|
bug_finder: boolean;
|
||||||
custom_prefix: string | null;
|
custom_prefix: string | null;
|
||||||
@@ -580,6 +582,8 @@ export type Database = {
|
|||||||
};
|
};
|
||||||
Insert: Partial<AccountStatusFields> & {
|
Insert: Partial<AccountStatusFields> & {
|
||||||
avatar_path?: string | null;
|
avatar_path?: string | null;
|
||||||
|
bio?: string;
|
||||||
|
profile_effect?: string | null;
|
||||||
glow_color?: string;
|
glow_color?: string;
|
||||||
bug_finder?: boolean;
|
bug_finder?: boolean;
|
||||||
custom_prefix?: string | null;
|
custom_prefix?: string | null;
|
||||||
@@ -592,6 +596,8 @@ export type Database = {
|
|||||||
};
|
};
|
||||||
Update: Partial<AccountStatusFields> & {
|
Update: Partial<AccountStatusFields> & {
|
||||||
avatar_path?: string | null;
|
avatar_path?: string | null;
|
||||||
|
bio?: string;
|
||||||
|
profile_effect?: string | null;
|
||||||
glow_color?: string;
|
glow_color?: string;
|
||||||
bug_finder?: boolean;
|
bug_finder?: boolean;
|
||||||
custom_prefix?: string | null;
|
custom_prefix?: string | null;
|
||||||
@@ -782,6 +788,18 @@ export type Database = {
|
|||||||
[_ in never]: never;
|
[_ in never]: never;
|
||||||
};
|
};
|
||||||
Functions: {
|
Functions: {
|
||||||
|
cosmetics_shop_state: {
|
||||||
|
Args: { p_user: string };
|
||||||
|
Returns: Json;
|
||||||
|
};
|
||||||
|
buy_profile_cosmetic: {
|
||||||
|
Args: { p_user: string; p_cosmetic_id: string };
|
||||||
|
Returns: Json;
|
||||||
|
};
|
||||||
|
equip_profile_cosmetic: {
|
||||||
|
Args: { p_user: string; p_cosmetic_id: string | null };
|
||||||
|
Returns: Json;
|
||||||
|
};
|
||||||
admin_account_action: {
|
admin_account_action: {
|
||||||
Args: {
|
Args: {
|
||||||
p_reviewer: string;
|
p_reviewer: string;
|
||||||
|
|||||||
@@ -10,6 +10,55 @@
|
|||||||
"Guide": "Как играть",
|
"Guide": "Как играть",
|
||||||
"Docs": "Документация",
|
"Docs": "Документация",
|
||||||
"My account": "Мой аккаунт",
|
"My account": "Мой аккаунт",
|
||||||
|
"Profile cosmetics": "Косметика профиля",
|
||||||
|
"Browse profile cosmetics": "Открыть магазин косметики",
|
||||||
|
"View public profile": "Открыть публичный профиль",
|
||||||
|
"About you": "О себе",
|
||||||
|
"Tell other explorers a little about yourself.": "Расскажите другим исследователям немного о себе.",
|
||||||
|
"Shown on your public profile · Maximum 280 characters.": "Отображается в публичном профиле · Не более 280 символов.",
|
||||||
|
"Save public profile": "Сохранить публичный профиль",
|
||||||
|
"Public profile saved.": "Публичный профиль сохранён.",
|
||||||
|
"A name and description for other explorers to see.": "Имя и описание, которые увидят другие исследователи.",
|
||||||
|
"Back to leaderboard": "Назад к рейтингу",
|
||||||
|
"MINEGEO EXPLORER": "ИССЛЕДОВАТЕЛЬ MINEGEO",
|
||||||
|
"Exploring since {date}": "Исследует миры с {date}",
|
||||||
|
"About this explorer": "Об исследователе",
|
||||||
|
"This explorer hasn’t added a description yet.": "Исследователь пока не добавил описание.",
|
||||||
|
"Profile stats": "Статистика профиля",
|
||||||
|
"Completed journeys": "Завершённые экспедиции",
|
||||||
|
"Published maps": "Опубликованные карты",
|
||||||
|
"Only public account details and published maps appear here.": "Здесь отображаются только публичные данные аккаунта и опубликованные карты.",
|
||||||
|
"THE EXPLORER’S WARDROBE": "ГАРДЕРОБ ИССЛЕДОВАТЕЛЯ",
|
||||||
|
"Leave a little sparkle.": "Добавь немного сияния.",
|
||||||
|
"Earn GeoCoins in solo expeditions and dress up your profile portrait.": "Зарабатывай GeoCoins в одиночных экспедициях и укрась портрет профиля.",
|
||||||
|
"GeoCoins balance": "Баланс GeoCoins",
|
||||||
|
"YOUR BALANCE": "ТВОЙ БАЛАНС",
|
||||||
|
"GeoCoins": "GeoCoins",
|
||||||
|
"Complete a public solo game to earn 10–20 GeoCoins. Rewards are capped at 100 every 24 hours.": "Заверши одиночную игру на публичной карте и получи 10–20 GeoCoins. Максимум — 100 за каждые 24 часа.",
|
||||||
|
"common": "обычный",
|
||||||
|
"rare": "редкий",
|
||||||
|
"epic": "эпический",
|
||||||
|
"legendary": "легендарный",
|
||||||
|
"Equipped": "Надето",
|
||||||
|
"Cosmetic purchased and equipped.": "Эффект куплен и надет.",
|
||||||
|
"Cosmetic equipped.": "Эффект надет.",
|
||||||
|
"Profile effect removed.": "Эффект профиля снят.",
|
||||||
|
"Not enough GeoCoins": "Недостаточно GeoCoins.",
|
||||||
|
"Cosmetic is not available": "Эта косметика сейчас недоступна.",
|
||||||
|
"Cosmetic not owned": "Сначала купи этот эффект.",
|
||||||
|
"Buy · {price} GeoCoins": "Купить · {price} GeoCoins",
|
||||||
|
"Unequip effect": "Снять эффект",
|
||||||
|
"Equip effect": "Надеть эффект",
|
||||||
|
"Buying…": "Покупка…",
|
||||||
|
"Cosmetics use GeoCoins for now. Payment options may be added later.": "Пока косметика покупается за GeoCoins. Позже могут появиться другие способы оплаты.",
|
||||||
|
"Redstone Pulse": "Импульс редстоуна",
|
||||||
|
"A redstone signal ripples around your portrait.": "Вокруг портрета пробегает импульс редстоуна.",
|
||||||
|
"Ender Orbit": "Орбита Края",
|
||||||
|
"Two ender sparks circle your portrait.": "Вокруг портрета вращаются две искры Края.",
|
||||||
|
"Nether Flame": "Пламя Незера",
|
||||||
|
"A warm nether flame flickers along the frame.": "По рамке мерцает жаркое пламя Незера.",
|
||||||
|
"Aurora Crown": "Корона сияния",
|
||||||
|
"A living aurora sweeps around your portrait.": "Вокруг портрета переливается северное сияние.",
|
||||||
"Log in": "Войти",
|
"Log in": "Войти",
|
||||||
"Tools & downloads": "Инструменты",
|
"Tools & downloads": "Инструменты",
|
||||||
"Moderation": "Модерация",
|
"Moderation": "Модерация",
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export const prefixUpdateSchema = z
|
|||||||
.strict();
|
.strict();
|
||||||
export type ProfileAppearance = {
|
export type ProfileAppearance = {
|
||||||
avatar_path?: string | null;
|
avatar_path?: string | null;
|
||||||
|
profile_effect?: string | null;
|
||||||
name_glow?: boolean;
|
name_glow?: boolean;
|
||||||
glow_color?: string;
|
glow_color?: string;
|
||||||
role?: string;
|
role?: string;
|
||||||
@@ -44,7 +45,7 @@ export type ProfileAppearance = {
|
|||||||
custom_prefix_icon?: string;
|
custom_prefix_icon?: string;
|
||||||
};
|
};
|
||||||
export const appearanceColumns =
|
export const appearanceColumns =
|
||||||
"avatar_path,name_glow,glow_color,role,bug_finder,custom_prefix,custom_prefix_icon" as const;
|
"avatar_path,profile_effect,name_glow,glow_color,role,bug_finder,custom_prefix,custom_prefix_icon" as const;
|
||||||
export type BadgeUser = ProfileAppearance & {
|
export type BadgeUser = ProfileAppearance & {
|
||||||
id: string;
|
id: string;
|
||||||
display_name: string;
|
display_name: string;
|
||||||
|
|||||||
8
shared/profile-cosmetic-ids.ts
Normal file
8
shared/profile-cosmetic-ids.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export const profileCosmeticIds = [
|
||||||
|
"redstone-pulse",
|
||||||
|
"ender-orbit",
|
||||||
|
"nether-flame",
|
||||||
|
"aurora-crown",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type ProfileCosmeticId = (typeof profileCosmeticIds)[number];
|
||||||
41
shared/profile-cosmetics.ts
Normal file
41
shared/profile-cosmetics.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { profileCosmeticIds } from "./profile-cosmetic-ids";
|
||||||
|
|
||||||
|
export const profileCosmeticIdSchema = z.enum(profileCosmeticIds);
|
||||||
|
export type { ProfileCosmeticId } from "./profile-cosmetic-ids";
|
||||||
|
|
||||||
|
export const profileCosmeticActionSchema = z.discriminatedUnion("action", [
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
action: z.literal("purchase"),
|
||||||
|
cosmeticId: profileCosmeticIdSchema,
|
||||||
|
})
|
||||||
|
.strict(),
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
action: z.literal("equip"),
|
||||||
|
cosmeticId: profileCosmeticIdSchema.nullable(),
|
||||||
|
})
|
||||||
|
.strict(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const cosmeticShopStateSchema = z
|
||||||
|
.object({
|
||||||
|
balance: z.number().int().nonnegative(),
|
||||||
|
owned: z.array(profileCosmeticIdSchema),
|
||||||
|
equipped: profileCosmeticIdSchema.nullable(),
|
||||||
|
catalog: z.array(
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
id: profileCosmeticIdSchema,
|
||||||
|
name: z.string().min(1).max(48),
|
||||||
|
description: z.string().min(1).max(160),
|
||||||
|
price: z.number().int().positive(),
|
||||||
|
rarity: z.enum(["common", "rare", "epic", "legendary"]),
|
||||||
|
})
|
||||||
|
.strict(),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export type CosmeticShopState = z.infer<typeof cosmeticShopStateSchema>;
|
||||||
@@ -0,0 +1,333 @@
|
|||||||
|
-- Public profile details and in-game cosmetic purchases.
|
||||||
|
alter table public.profiles
|
||||||
|
add column bio text not null default '' check (char_length(bio) <= 280);
|
||||||
|
|
||||||
|
create table public.profile_cosmetics (
|
||||||
|
id text primary key check (id in (
|
||||||
|
'redstone-pulse',
|
||||||
|
'ender-orbit',
|
||||||
|
'nether-flame',
|
||||||
|
'aurora-crown'
|
||||||
|
)),
|
||||||
|
name text not null,
|
||||||
|
description text not null,
|
||||||
|
price integer not null check (price > 0),
|
||||||
|
rarity text not null check (rarity in ('common', 'rare', 'epic', 'legendary')),
|
||||||
|
sort_order integer not null unique,
|
||||||
|
active boolean not null default true,
|
||||||
|
created_at timestamptz not null default clock_timestamp()
|
||||||
|
);
|
||||||
|
|
||||||
|
insert into public.profile_cosmetics (id, name, description, price, rarity, sort_order)
|
||||||
|
values
|
||||||
|
('redstone-pulse', 'Redstone Pulse', 'A redstone signal ripples around your portrait.', 50, 'common', 1),
|
||||||
|
('ender-orbit', 'Ender Orbit', 'Two ender sparks circle your portrait.', 90, 'rare', 2),
|
||||||
|
('nether-flame', 'Nether Flame', 'A warm nether flame flickers along the frame.', 130, 'epic', 3),
|
||||||
|
('aurora-crown', 'Aurora Crown', 'A living aurora sweeps around your portrait.', 180, 'legendary', 4);
|
||||||
|
|
||||||
|
alter table public.profiles
|
||||||
|
add column profile_effect text references public.profile_cosmetics(id) on delete set null;
|
||||||
|
|
||||||
|
create table public.game_wallets (
|
||||||
|
user_id uuid primary key references public.profiles(id) on delete cascade,
|
||||||
|
balance integer not null default 0 check (balance >= 0),
|
||||||
|
updated_at timestamptz not null default clock_timestamp()
|
||||||
|
);
|
||||||
|
|
||||||
|
create table public.profile_cosmetic_ownership (
|
||||||
|
user_id uuid not null references public.profiles(id) on delete cascade,
|
||||||
|
cosmetic_id text not null references public.profile_cosmetics(id) on delete restrict,
|
||||||
|
purchased_at timestamptz not null default clock_timestamp(),
|
||||||
|
primary key (user_id, cosmetic_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
create table public.game_wallet_transactions (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
user_id uuid not null references public.profiles(id) on delete cascade,
|
||||||
|
amount integer not null check (amount <> 0),
|
||||||
|
source text not null check (source in ('game_reward', 'cosmetic_purchase')),
|
||||||
|
source_key text not null check (char_length(source_key) between 1 and 64),
|
||||||
|
created_at timestamptz not null default clock_timestamp(),
|
||||||
|
unique (user_id, source, source_key),
|
||||||
|
check (
|
||||||
|
(source = 'game_reward' and amount > 0)
|
||||||
|
or (source = 'cosmetic_purchase' and amount < 0)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
create index profile_cosmetic_ownership_user
|
||||||
|
on public.profile_cosmetic_ownership(user_id, purchased_at desc);
|
||||||
|
create index game_wallet_transactions_user_created
|
||||||
|
on public.game_wallet_transactions(user_id, created_at desc);
|
||||||
|
|
||||||
|
alter table public.profile_cosmetics enable row level security;
|
||||||
|
alter table public.game_wallets enable row level security;
|
||||||
|
alter table public.profile_cosmetic_ownership enable row level security;
|
||||||
|
alter table public.game_wallet_transactions enable row level security;
|
||||||
|
|
||||||
|
revoke all on public.profile_cosmetics,
|
||||||
|
public.game_wallets,
|
||||||
|
public.profile_cosmetic_ownership,
|
||||||
|
public.game_wallet_transactions
|
||||||
|
from public, anon, authenticated;
|
||||||
|
grant select on public.profile_cosmetics to service_role;
|
||||||
|
grant all on public.game_wallets,
|
||||||
|
public.profile_cosmetic_ownership,
|
||||||
|
public.game_wallet_transactions
|
||||||
|
to service_role;
|
||||||
|
|
||||||
|
create function public.cosmetics_shop_state(p_user uuid)
|
||||||
|
returns jsonb
|
||||||
|
language plpgsql
|
||||||
|
security invoker
|
||||||
|
set search_path = ''
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_balance integer;
|
||||||
|
v_owned jsonb;
|
||||||
|
v_effect text;
|
||||||
|
v_catalog jsonb;
|
||||||
|
begin
|
||||||
|
if not exists (
|
||||||
|
select 1 from public.profiles
|
||||||
|
where id = p_user and account_status = 'active'
|
||||||
|
) then
|
||||||
|
raise exception using errcode = '42501', message = 'Active account required';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
insert into public.game_wallets(user_id)
|
||||||
|
values (p_user)
|
||||||
|
on conflict (user_id) do nothing;
|
||||||
|
|
||||||
|
select balance into v_balance
|
||||||
|
from public.game_wallets
|
||||||
|
where user_id = p_user;
|
||||||
|
|
||||||
|
select profile_effect into v_effect
|
||||||
|
from public.profiles
|
||||||
|
where id = p_user;
|
||||||
|
|
||||||
|
select coalesce(
|
||||||
|
jsonb_agg(cosmetic_id order by purchased_at, cosmetic_id),
|
||||||
|
'[]'::jsonb
|
||||||
|
)
|
||||||
|
into v_owned
|
||||||
|
from public.profile_cosmetic_ownership
|
||||||
|
where user_id = p_user;
|
||||||
|
|
||||||
|
select coalesce(
|
||||||
|
jsonb_agg(
|
||||||
|
jsonb_build_object(
|
||||||
|
'id', id,
|
||||||
|
'name', name,
|
||||||
|
'description', description,
|
||||||
|
'price', price,
|
||||||
|
'rarity', rarity
|
||||||
|
)
|
||||||
|
order by sort_order
|
||||||
|
),
|
||||||
|
'[]'::jsonb
|
||||||
|
)
|
||||||
|
into v_catalog
|
||||||
|
from public.profile_cosmetics
|
||||||
|
where active;
|
||||||
|
|
||||||
|
return jsonb_build_object(
|
||||||
|
'balance', v_balance,
|
||||||
|
'owned', v_owned,
|
||||||
|
'equipped', v_effect,
|
||||||
|
'catalog', v_catalog
|
||||||
|
);
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create function public.buy_profile_cosmetic(p_user uuid, p_cosmetic_id text)
|
||||||
|
returns jsonb
|
||||||
|
language plpgsql
|
||||||
|
security invoker
|
||||||
|
set search_path = ''
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_price integer;
|
||||||
|
v_balance integer;
|
||||||
|
v_status text;
|
||||||
|
begin
|
||||||
|
select account_status into v_status
|
||||||
|
from public.profiles
|
||||||
|
where id = p_user
|
||||||
|
for update;
|
||||||
|
if v_status is distinct from 'active' then
|
||||||
|
raise exception using errcode = '42501', message = 'Active account required';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
select price into v_price
|
||||||
|
from public.profile_cosmetics
|
||||||
|
where id = p_cosmetic_id and active;
|
||||||
|
if not found then
|
||||||
|
raise exception using errcode = '22023', message = 'Cosmetic is not available';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
insert into public.game_wallets(user_id)
|
||||||
|
values (p_user)
|
||||||
|
on conflict (user_id) do nothing;
|
||||||
|
|
||||||
|
select balance into v_balance
|
||||||
|
from public.game_wallets
|
||||||
|
where user_id = p_user
|
||||||
|
for update;
|
||||||
|
|
||||||
|
if exists (
|
||||||
|
select 1 from public.profile_cosmetic_ownership
|
||||||
|
where user_id = p_user and cosmetic_id = p_cosmetic_id
|
||||||
|
) then
|
||||||
|
return public.cosmetics_shop_state(p_user);
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if v_balance < v_price then
|
||||||
|
raise exception using errcode = 'P0001', message = 'Not enough GeoCoins';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
insert into public.profile_cosmetic_ownership(user_id, cosmetic_id)
|
||||||
|
values (p_user, p_cosmetic_id);
|
||||||
|
|
||||||
|
insert into public.game_wallet_transactions(user_id, amount, source, source_key)
|
||||||
|
values (p_user, -v_price, 'cosmetic_purchase', p_cosmetic_id);
|
||||||
|
|
||||||
|
update public.game_wallets
|
||||||
|
set balance = balance - v_price,
|
||||||
|
updated_at = clock_timestamp()
|
||||||
|
where user_id = p_user;
|
||||||
|
|
||||||
|
update public.profiles
|
||||||
|
set profile_effect = p_cosmetic_id
|
||||||
|
where id = p_user;
|
||||||
|
|
||||||
|
return public.cosmetics_shop_state(p_user);
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create function public.equip_profile_cosmetic(p_user uuid, p_cosmetic_id text)
|
||||||
|
returns jsonb
|
||||||
|
language plpgsql
|
||||||
|
security invoker
|
||||||
|
set search_path = ''
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_status text;
|
||||||
|
begin
|
||||||
|
select account_status into v_status
|
||||||
|
from public.profiles
|
||||||
|
where id = p_user
|
||||||
|
for update;
|
||||||
|
if v_status is distinct from 'active' then
|
||||||
|
raise exception using errcode = '42501', message = 'Active account required';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if p_cosmetic_id is not null and not exists (
|
||||||
|
select 1 from public.profile_cosmetic_ownership
|
||||||
|
where user_id = p_user and cosmetic_id = p_cosmetic_id
|
||||||
|
) then
|
||||||
|
raise exception using errcode = '42501', message = 'Cosmetic not owned';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
update public.profiles
|
||||||
|
set profile_effect = p_cosmetic_id
|
||||||
|
where id = p_user;
|
||||||
|
|
||||||
|
return public.cosmetics_shop_state(p_user);
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create function public.reward_completed_game_cosmetic_currency()
|
||||||
|
returns trigger
|
||||||
|
language plpgsql
|
||||||
|
security invoker
|
||||||
|
set search_path = ''
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_user uuid;
|
||||||
|
v_status text;
|
||||||
|
v_wallet_balance integer;
|
||||||
|
v_score integer;
|
||||||
|
v_earned_24h integer;
|
||||||
|
v_reward integer;
|
||||||
|
begin
|
||||||
|
if old.result is not null or new.result is null or new.round_number <> 4 then
|
||||||
|
return new;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
select s.owner_id into v_user
|
||||||
|
from public.game_sessions s
|
||||||
|
join public.revisions r on r.id = s.revision_id
|
||||||
|
join public.datasets d on d.id = r.dataset_id
|
||||||
|
where s.id = new.session_id
|
||||||
|
and d.state = 'published';
|
||||||
|
|
||||||
|
if v_user is null then
|
||||||
|
return new;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
select account_status into v_status
|
||||||
|
from public.profiles
|
||||||
|
where id = v_user
|
||||||
|
for update;
|
||||||
|
if v_status is distinct from 'active' then
|
||||||
|
return new;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
insert into public.game_wallets(user_id)
|
||||||
|
values (v_user)
|
||||||
|
on conflict (user_id) do nothing;
|
||||||
|
|
||||||
|
select balance into v_wallet_balance
|
||||||
|
from public.game_wallets
|
||||||
|
where user_id = v_user
|
||||||
|
for update;
|
||||||
|
|
||||||
|
select coalesce(sum(amount), 0)::integer into v_earned_24h
|
||||||
|
from public.game_wallet_transactions
|
||||||
|
where user_id = v_user
|
||||||
|
and source = 'game_reward'
|
||||||
|
and created_at >= clock_timestamp() - interval '24 hours';
|
||||||
|
|
||||||
|
select coalesce(sum((result->>'score')::integer), 0)::integer into v_score
|
||||||
|
from public.game_rounds
|
||||||
|
where session_id = new.session_id
|
||||||
|
and result is not null;
|
||||||
|
|
||||||
|
v_reward := least(20, 10 + floor(v_score::numeric / 2500)::integer);
|
||||||
|
v_reward := least(v_reward, greatest(0, 100 - v_earned_24h));
|
||||||
|
if v_reward <= 0 then
|
||||||
|
return new;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
insert into public.game_wallet_transactions(user_id, amount, source, source_key)
|
||||||
|
values (v_user, v_reward, 'game_reward', new.session_id::text)
|
||||||
|
on conflict (user_id, source, source_key) do nothing;
|
||||||
|
if not found then
|
||||||
|
return new;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
update public.game_wallets
|
||||||
|
set balance = balance + v_reward,
|
||||||
|
updated_at = clock_timestamp()
|
||||||
|
where user_id = v_user;
|
||||||
|
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create trigger game_rounds_reward_cosmetic_currency
|
||||||
|
after update of result on public.game_rounds
|
||||||
|
for each row
|
||||||
|
when (old.result is null and new.result is not null and new.round_number = 4)
|
||||||
|
execute function public.reward_completed_game_cosmetic_currency();
|
||||||
|
|
||||||
|
revoke all on function public.cosmetics_shop_state(uuid) from public, anon, authenticated;
|
||||||
|
revoke all on function public.buy_profile_cosmetic(uuid, text) from public, anon, authenticated;
|
||||||
|
revoke all on function public.equip_profile_cosmetic(uuid, text) from public, anon, authenticated;
|
||||||
|
revoke all on function public.reward_completed_game_cosmetic_currency() from public, anon, authenticated;
|
||||||
|
grant execute on function public.cosmetics_shop_state(uuid) to service_role;
|
||||||
|
grant execute on function public.buy_profile_cosmetic(uuid, text) to service_role;
|
||||||
|
grant execute on function public.equip_profile_cosmetic(uuid, text) to service_role;
|
||||||
|
grant execute on function public.reward_completed_game_cosmetic_currency() to service_role;
|
||||||
Reference in New Issue
Block a user