feat: shop buy, forum replies, tickets, messages, sessions, and UX hardening
Complete remaining product gaps: credit-based shop purchases, guild thread replies, help ticket detail/reply/close, offline message compose, sign-out-everywhere via JWT version, ads delete confirm, soft-fail feedback, rate limits, loading states, and single auth() in site layout. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
1 parent
ed7db6e048
commit
803e8f36c1
47 files changed
+2793
-358
No files matched your search
@@ -2,7 +2,11 @@ import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import type { CSSProperties } from "react";
|
||||
import { acceptFriend, declineFriendRequest } from "@/actions/messenger";
|
||||
import {
|
||||
acceptFriend,
|
||||
declineFriendRequest,
|
||||
sendOfflineMessage,
|
||||
} from "@/actions/messenger";
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { avatarImageUrl } from "@/lib/format";
|
||||
@@ -14,7 +18,9 @@ export const dynamic = "force-dynamic";
|
||||
type SearchParams = Promise<{
|
||||
accepted?: string;
|
||||
declined?: string;
|
||||
sent?: string;
|
||||
error?: string;
|
||||
send_error?: string;
|
||||
}>;
|
||||
|
||||
function feedbackStyle(tone: "success" | "error"): CSSProperties {
|
||||
@@ -49,10 +55,8 @@ export default async function MessagesPage({
|
||||
if (!session?.user?.id) redirect("/login");
|
||||
|
||||
const userId = Number(session.user.id);
|
||||
const { accepted, declined, error } = await searchParams;
|
||||
const { accepted, declined, sent, error, send_error } = await searchParams;
|
||||
|
||||
// Pending friend requests addressed to the session user, the most recent
|
||||
// offline messages they've received, and the avatar-imager base URL.
|
||||
let requests: { id: number; userFromId: number }[] = [];
|
||||
let messages: {
|
||||
id: number;
|
||||
@@ -60,10 +64,11 @@ export default async function MessagesPage({
|
||||
message: string;
|
||||
sendedOn: number;
|
||||
}[] = [];
|
||||
let friendships: { userOneId: number; userTwoId: number }[] = [];
|
||||
let imagerBase = "https://www.habbo.com/habbo-imaging/avatarimage";
|
||||
|
||||
try {
|
||||
const [reqRows, msgRows, base] = await Promise.all([
|
||||
const [reqRows, msgRows, friendRows, base] = await Promise.all([
|
||||
prisma.messengerFriendrequests.findMany({
|
||||
where: { userToId: userId },
|
||||
select: { id: true, userFromId: true },
|
||||
@@ -76,6 +81,12 @@ export default async function MessagesPage({
|
||||
orderBy: { sendedOn: "desc" },
|
||||
take: 50,
|
||||
}),
|
||||
prisma.messengerFriendships.findMany({
|
||||
where: {
|
||||
OR: [{ userOneId: userId }, { userTwoId: userId }],
|
||||
},
|
||||
select: { userOneId: true, userTwoId: true },
|
||||
}),
|
||||
siteSettings.get(
|
||||
"habbo_imaging_url",
|
||||
"https://www.habbo.com/habbo-imaging/avatarimage",
|
||||
@@ -83,17 +94,27 @@ export default async function MessagesPage({
|
||||
]);
|
||||
requests = reqRows;
|
||||
messages = msgRows;
|
||||
friendships = friendRows;
|
||||
if (base) imagerBase = base;
|
||||
} catch {
|
||||
requests = [];
|
||||
messages = [];
|
||||
friendships = [];
|
||||
}
|
||||
|
||||
// Resolve usernames/looks for every counterparty referenced above.
|
||||
const friendIds = Array.from(
|
||||
new Set(
|
||||
friendships.map((f) =>
|
||||
f.userOneId === userId ? f.userTwoId : f.userOneId,
|
||||
),
|
||||
),
|
||||
).filter((id) => id > 0 && id !== userId);
|
||||
|
||||
const otherIds = Array.from(
|
||||
new Set([
|
||||
...requests.map((r) => r.userFromId),
|
||||
...messages.map((m) => m.userFromId),
|
||||
...friendIds,
|
||||
]),
|
||||
).filter((id) => id && id > 0);
|
||||
|
||||
@@ -113,6 +134,13 @@ export default async function MessagesPage({
|
||||
}
|
||||
}
|
||||
|
||||
const friendsForSelect = friendIds
|
||||
.map((id) => userById.get(id))
|
||||
.filter((u): u is { id: number; username: string; look: string } =>
|
||||
Boolean(u),
|
||||
)
|
||||
.sort((a, b) => a.username.localeCompare(b.username));
|
||||
|
||||
const errorMessage =
|
||||
error === "not_found"
|
||||
? t("errors.notFound")
|
||||
@@ -124,6 +152,19 @@ export default async function MessagesPage({
|
||||
? t("errors.error")
|
||||
: null;
|
||||
|
||||
const sendErrorMessage =
|
||||
send_error === "not_friend"
|
||||
? t("errors.notFriend")
|
||||
: send_error === "empty"
|
||||
? t("errors.emptyMessage")
|
||||
: send_error === "rate_limited"
|
||||
? t("errors.rateLimited")
|
||||
: send_error === "invalid"
|
||||
? t("errors.invalid")
|
||||
: send_error
|
||||
? t("errors.error")
|
||||
: null;
|
||||
|
||||
return (
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
{accepted === "1" ? (
|
||||
@@ -136,14 +177,71 @@ export default async function MessagesPage({
|
||||
{t("success.declined")}
|
||||
</div>
|
||||
) : null}
|
||||
{sent === "1" ? (
|
||||
<div role="status" style={feedbackStyle("success")}>
|
||||
{t("success.sent")}
|
||||
</div>
|
||||
) : null}
|
||||
{errorMessage ? (
|
||||
<div role="alert" style={feedbackStyle("error")}>
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
{sendErrorMessage ? (
|
||||
<div role="alert" style={feedbackStyle("error")}>
|
||||
{sendErrorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<ContentCard icon="✉️" title={t("title")} subtitle={t("subtitle")} />
|
||||
|
||||
<ContentCard
|
||||
icon="📤"
|
||||
title={t("composeTitle")}
|
||||
subtitle={t("composeSubtitle")}
|
||||
padded={friendsForSelect.length === 0}
|
||||
>
|
||||
{friendsForSelect.length === 0 ? (
|
||||
<EmptyState icon="👥">{t("composeEmptyFriends")}</EmptyState>
|
||||
) : (
|
||||
<form
|
||||
action={sendOfflineMessage}
|
||||
className="grid"
|
||||
style={{ padding: "1rem", gap: "0.75rem", maxWidth: "36rem" }}
|
||||
>
|
||||
<label className="grid" style={{ gap: "0.35rem" }}>
|
||||
<span className="muted">{t("composeFriendLabel")}</span>
|
||||
<select name="friendId" required defaultValue="">
|
||||
<option value="" disabled>
|
||||
{t("composeFriendPlaceholder")}
|
||||
</option>
|
||||
{friendsForSelect.map((friend) => (
|
||||
<option key={friend.id} value={String(friend.id)}>
|
||||
{friend.username}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid" style={{ gap: "0.35rem" }}>
|
||||
<span className="muted">{t("composeMessageLabel")}</span>
|
||||
<textarea
|
||||
name="message"
|
||||
required
|
||||
maxLength={500}
|
||||
rows={4}
|
||||
placeholder={t("composeMessagePlaceholder")}
|
||||
style={{ resize: "vertical" }}
|
||||
/>
|
||||
</label>
|
||||
<div>
|
||||
<button type="submit" className="btn btn-secondary">
|
||||
{t("composeSend")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</ContentCard>
|
||||
|
||||
{/* ── Pending friend requests ───────────────────────────── */}
|
||||
<ContentCard
|
||||
icon="🙋"
|
||||
|
||||
Reference in new issue
Block a user