feat(me): organize personal dashboard and localize user actions
CI / check (push) Successful in 1m41s
CI / deploy (push) Successful in 1m29s
CI / e2e (push) Successful in 23s

This commit is contained in:
Simo committed 2026-09-06 11:30:24 +02:00
1 parent 2840ef5d9d
commit 85a6df162b
9 files changed
+717 -525

No files matched your search

+15 -4
View File
@@ -1,11 +1,16 @@
"use client"; "use client";
import { useTranslations } from "next-intl";
import { useState } from "react"; import { useState } from "react";
export default function CopyReferralButton({ value }: { value: string }) { export default function CopyReferralButton({ value }: { value: string }) {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [failed, setFailed] = useState(false);
const t = useTranslations("pages.myDashboard");
async function copy() { async function copy() {
setFailed(false);
setCopied(false);
const absolute = const absolute =
typeof window !== "undefined" && value.startsWith("/") typeof window !== "undefined" && value.startsWith("/")
? `${window.location.origin}${value}` ? `${window.location.origin}${value}`
@@ -20,9 +25,11 @@ export default function CopyReferralButton({ value }: { value: string }) {
document.body.appendChild(ta); document.body.appendChild(ta);
ta.select(); ta.select();
try { try {
document.execCommand("copy"); if (!document.execCommand("copy")) throw new Error("copy-failed");
} catch { } catch {
/* give up silently */ setFailed(true);
document.body.removeChild(ta);
return;
} }
document.body.removeChild(ta); document.body.removeChild(ta);
} }
@@ -31,17 +38,21 @@ export default function CopyReferralButton({ value }: { value: string }) {
} }
return ( return (
<>
<button <button
type="button" type="button"
onClick={copy} onClick={copy}
className="rounded-xl font-bold text-sm px-5 py-2.5 transition-all duration-200 hover:scale-[1.02] active:scale-95 shrink-0" className="rounded-xl font-bold text-sm px-5 py-2.5 transition-all duration-200 hover:scale-[1.02] active:scale-95 shrink-0"
style={{ style={{
background: "color-mix(in srgb, var(--color-primary) 12%, transparent)", background:
"color-mix(in srgb, var(--color-primary) 12%, transparent)",
color: "var(--color-primary-readable, var(--color-primary))", color: "var(--color-primary-readable, var(--color-primary))",
border: `2px solid color-mix(in srgb, var(--color-primary) 20%, transparent)`, border: `2px solid color-mix(in srgb, var(--color-primary) 20%, transparent)`,
}} }}
> >
{copied ? "Copied!" : "Copy link"} {copied ? t("copied") : t("copy")}
</button> </button>
{failed && <p role="alert">{t("copyFailed")}</p>}
</>
); );
} }
File diff suppressed because it is too large. Load diff
+265 -518
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -8,6 +8,7 @@ import { sendFriendRequest } from "@/actions/social";
import Link from "@/components/link"; import Link from "@/components/link";
import { ContentCard, EmptyState, OnlineBadge } from "@/components/public/ui"; import { ContentCard, EmptyState, OnlineBadge } from "@/components/public/ui";
import { CurrencyIcon } from "@/components/shared/currency-icon"; import { CurrencyIcon } from "@/components/shared/currency-icon";
import { ProfileImage } from "@/components/shared/profile-image";
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail"; import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { import {
@@ -25,7 +26,6 @@ import {
import { avatarImageUrl } from "@/lib/format"; import { avatarImageUrl } from "@/lib/format";
import { formatDate } from "@/lib/format-date"; import { formatDate } from "@/lib/format-date";
import styles from "./profile.module.css"; import styles from "./profile.module.css";
import { ProfileImage } from "./profile-image";
type SearchParams = Promise<{ type SearchParams = Promise<{
friend?: string; friend?: string;
+53
View File
@@ -0,0 +1,53 @@
import { readFileSync } from "node:fs";
import { createTranslator } from "next-intl";
import { expect, it } from "vitest";
import en from "@/messages/en.json";
import italian from "@/messages/it.json";
it.each([
["en", en],
["it", italian],
] as const)("renders all dashboard messages in %s", (locale, messages) => {
const errors: unknown[] = [];
const t = createTranslator({
locale,
messages: messages as typeof en,
namespace: "pages.myDashboard",
onError: (error) => errors.push(error),
});
const keys = new Set(
["credits", "duckets", "diamonds", "points"].map(
(key) => `currencies.${key}`,
),
);
for (const error of [
"not_enough",
"no_referrals",
"bad_config",
"ratelimit",
"error",
])
keys.add(`errors.${error}`);
for (const file of ["page.tsx", "CopyReferralButton.tsx"]) {
for (const match of readFileSync(
`src/app/(site)/me/${file}`,
"utf8",
).matchAll(/\bt\("([^"]+)"/g))
keys.add(match[1]);
}
for (const key of keys) {
const message = key as Parameters<typeof t>[0];
expect(t.has(message), key).toBe(true);
expect(
t(message, {
count: 3,
amount: "30",
currency: "Diamanti",
hotel: "Epicnabbo",
date: "06/09/2026",
}),
key,
).not.toBe("");
}
expect(errors).toEqual([]);
});
+14 -2
View File
@@ -1,5 +1,5 @@
import "server-only"; import "server-only";
import { and, asc, count, desc, eq, gt, or } from "drizzle-orm"; import { and, asc, count, desc, eq, gt, inArray, or } from "drizzle-orm";
import { import {
db, db,
MessengerFriendships, MessengerFriendships,
@@ -8,6 +8,7 @@ import {
User, User,
UserReferrals, UserReferrals,
UsersBadges, UsersBadges,
UsersCurrency,
UsersSettings, UsersSettings,
} from "@/lib/db"; } from "@/lib/db";
import { resolveHotelName } from "@/lib/hotel-name"; import { resolveHotelName } from "@/lib/hotel-name";
@@ -27,6 +28,7 @@ export async function loadUserDashboard(userId: number) {
unreadCountRows, unreadCountRows,
referralsRows, referralsRows,
friends, friends,
currencyRows,
] = await Promise.all([ ] = await Promise.all([
db db
.select({ .select({
@@ -34,7 +36,6 @@ export async function loadUserDashboard(userId: number) {
username: User.username, username: User.username,
look: User.look, look: User.look,
motto: User.motto, motto: User.motto,
rank: User.rank,
credits: User.credits, credits: User.credits,
accountCreated: User.accountCreated, accountCreated: User.accountCreated,
lastLogin: User.lastLogin, lastLogin: User.lastLogin,
@@ -132,6 +133,16 @@ export async function loadUserDashboard(userId: number) {
), ),
) )
.catch(() => []), .catch(() => []),
db
.select({ type: UsersCurrency.type, amount: UsersCurrency.amount })
.from(UsersCurrency)
.where(
and(
eq(UsersCurrency.userId, userId),
inArray(UsersCurrency.type, [0, 5]),
),
)
.catch(() => null),
]); ]);
return { return {
@@ -148,5 +159,6 @@ export async function loadUserDashboard(userId: number) {
unreadCountRows, unreadCountRows,
referralsRows, referralsRows,
friends, friends,
currencyRows,
}; };
} }
+60
View File
@@ -3475,6 +3475,66 @@
"description": "Try again later or contact an administrator." "description": "Try again later or contact an administrator."
} }
} }
},
"myDashboard": {
"title": "My dashboard",
"loadError": "We could not load your dashboard. Please try again.",
"retry": "Try again",
"login": "Sign in",
"never": "Never",
"claimed": "Reward claimed successfully!",
"welcome": "Your space on {hotel}",
"noMotto": "No motto yet",
"registered": "Registered: {date}",
"lastOnline": "Last online: {date}",
"enter": "Enter {hotel}",
"quickLinks": "Quick links",
"messages": "Messages",
"profile": "My profile",
"settings": "Settings",
"shop": "Shop",
"unread": "{count} unread messages",
"wallet": "Your wallet",
"currencies": {
"credits": "Credits",
"duckets": "Duckets",
"diamonds": "Diamonds",
"points": "Points"
},
"onlineFriends": "Online friends",
"allFriends": "All friends",
"noOnlineFriends": "None of your friends are online right now.",
"recentRooms": "New rooms",
"community": "Community",
"roomsHint": "The newest rooms created in the hotel.",
"noRooms": "No rooms available yet.",
"activity": "Your activity",
"achievements": "Achievement score",
"respects": "Respects received",
"friends": "Friends",
"lastLogin": "Last login",
"referrals": "Invite your friends",
"referralHint": "Share your link and earn rewards when new players register.",
"referralProgress": "Progress towards your reward",
"reward": "{amount} {currency} for every {count} new players.",
"rewardHint": "Invite {count} players to unlock your reward.",
"referralLink": "Your invitation link",
"claim": "Claim reward",
"remaining": "{count, plural, one {Invite # more player to unlock your reward.} other {Invite # more players to unlock your reward.}}",
"rules": "Invitation rules",
"rulesText": "Creating extra accounts to increase invitations can lead to removal of progress, currency and inventory, and a ban.",
"badges": "Equipped badges",
"noBadges": "You are not wearing any badges yet.",
"copy": "Copy link",
"copied": "Copied!",
"copyFailed": "Copy failed. Select the link and copy it manually.",
"errors": {
"not_enough": "You need more invitations to claim the reward.",
"no_referrals": "Share your link to invite your first friend.",
"bad_config": "The reward is not configured correctly. Contact the staff.",
"ratelimit": "Too many attempts. Wait a moment and try again.",
"error": "The reward could not be claimed. Please try again."
}
} }
}, },
"toolbar": { "toolbar": {
+60
View File
@@ -3442,6 +3442,66 @@
"description": "Riprova più tardi o contatta un amministratore." "description": "Riprova più tardi o contatta un amministratore."
} }
} }
},
"myDashboard": {
"title": "La mia pagina",
"loadError": "Impossibile caricare la tua pagina. Riprova.",
"retry": "Riprova",
"login": "Accedi",
"never": "Mai",
"claimed": "Premio riscattato!",
"welcome": "Il tuo spazio su {hotel}",
"noMotto": "Nessun motto",
"registered": "Iscritto dal {date}",
"lastOnline": "Ultima presenza: {date}",
"enter": "Entra in {hotel}",
"quickLinks": "Collegamenti rapidi",
"messages": "Messaggi",
"profile": "Il mio profilo",
"settings": "Impostazioni",
"shop": "Negozio",
"unread": "{count} messaggi non letti",
"wallet": "Il tuo portafoglio",
"currencies": {
"credits": "Crediti",
"duckets": "Duckets",
"diamonds": "Diamanti",
"points": "Punti"
},
"onlineFriends": "Amici online",
"allFriends": "Tutti gli amici",
"noOnlineFriends": "Nessuno dei tuoi amici è online al momento.",
"recentRooms": "Nuove stanze",
"community": "Community",
"roomsHint": "Le ultime stanze create nell’hotel.",
"noRooms": "Nessuna stanza disponibile.",
"activity": "La tua attività",
"achievements": "Punti traguardo",
"respects": "Rispetti ricevuti",
"friends": "Amici",
"lastLogin": "Ultimo accesso",
"referrals": "Invita i tuoi amici",
"referralHint": "Condividi il tuo link e ricevi premi quando si registrano nuovi giocatori.",
"referralProgress": "Progressi verso il premio",
"reward": "{amount} {currency} ogni {count} nuovi giocatori.",
"rewardHint": "Invita {count} giocatori per sbloccare il premio.",
"referralLink": "Il tuo link di invito",
"claim": "Riscatta premio",
"remaining": "{count, plural, one {Invita ancora # giocatore per sbloccare il premio.} other {Invita ancora # giocatori per sbloccare il premio.}}",
"rules": "Regole degli inviti",
"rulesText": "Creare altri account per aumentare gli inviti può comportare la rimozione di progressi, valute e inventario e il ban.",
"badges": "Distintivi indossati",
"noBadges": "Non stai ancora indossando distintivi.",
"copy": "Copia link",
"copied": "Copiato!",
"copyFailed": "Copia non riuscita. Seleziona il link e copialo manualmente.",
"errors": {
"not_enough": "Servono altri inviti per riscattare il premio.",
"no_referrals": "Condividi il link per invitare il tuo primo amico.",
"bad_config": "Il premio non è configurato correttamente. Contatta lo staff.",
"ratelimit": "Troppi tentativi. Attendi un momento e riprova.",
"error": "Non è stato possibile riscattare il premio. Riprova."
}
} }
}, },
"toolbar": { "toolbar": {