feat: shop buy, forum replies, tickets, messages, sessions, and UX hardening
Deploy / release (push) Skipped
Deploy / deploy (push) Failing after 4m23s

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:
SimoandCursor committed 2026-07-21 21:21:02 +02:00
1 parent ed7db6e048
commit 803e8f36c1
47 files changed
+2793 -358

No files matched your search

@@ -0,0 +1,4 @@
-- CMS JWT invalidation counter on users (stateless NextAuth sessions).
-- Idempotent (MariaDB).
ALTER TABLE users
ADD COLUMN IF NOT EXISTS website_jwt_version INT NOT NULL DEFAULT 0;
+2
View File
@@ -64,6 +64,8 @@ model User {
twoFactorSecret String? @map("two_factor_secret") @db.Text
twoFactorRecoveryCodes String? @map("two_factor_recovery_codes") @db.Text
twoFactorConfirmedAt DateTime? @map("two_factor_confirmed_at") @db.Timestamp(0)
// Bumped to invalidate all CMS JWT sessions ("sign out everywhere").
websiteJwtVersion Int @default(0) @map("website_jwt_version")
createdTickets WebsiteTicket[] @relation("ticketCreator")
assignedTickets WebsiteTicket[] @relation("ticketAssignee")
+33 -2
View File
@@ -2,10 +2,13 @@
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { requirePermission } from "@/lib/admin/guard";
import { PERMS } from "@/lib/permissions";
import { formPositiveBigInt } from "@/lib/form-data";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared";
import { logStaffActivity } from "@/lib/services/staff-activity";
// CRUD for website advertisements (website_ads). Emulator does not own this
@@ -70,7 +73,35 @@ export async function updateAd(formData: FormData): Promise<void> {
redirect("/admin/ads");
}
export async function deleteAd(formData: FormData): Promise<void> {
const deleteAdInput = z.object({
id: z
.union([z.string(), z.number(), z.bigint()])
.transform((v) => BigInt(String(v))),
});
export const deleteAd = adminAction(
{ permission: PERMS.PAGES_EDIT, schema: deleteAdInput },
async (ctx) => {
const id = ctx.data.id;
try {
await prisma.websiteAds.delete({ where: { id } });
} catch {
throw new ActionError("Advertisement not found");
}
await logStaffActivity({
staffId: Number(ctx.session.user.id),
action: "ad_delete",
description: `Deleted advertisement #${id}`,
targetType: "website_ad",
targetId: Number(id),
});
revalidatePath("/admin/ads");
return actionOk();
},
);
/** Legacy form POST delete — kept for compatibility; prefer client deleteAd action. */
export async function deleteAdForm(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.PAGES_EDIT);
const id = formPositiveBigInt(formData, "id");
if (!id) return;
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+7 -1
View File
@@ -5,6 +5,7 @@ import { redirect } from "next/navigation";
import type { Prisma } from "@/generated/prisma/client";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { clientIp, rateLimit } from "@/lib/rate-limit";
import { rcon } from "@/lib/services/rcon";
import { siteSettings } from "@/lib/services/site-settings";
@@ -55,10 +56,14 @@ export async function buyBadge(formData: FormData): Promise<void> {
.trim();
if (!/^\d+$/.test(rawId)) redirect("/draw-badge?error=invalid");
let outcome: "bought" | "invalid" | "credits" | "fail";
let outcome: "bought" | "invalid" | "credits" | "ratelimit" | "fail";
let boughtCode = "";
try {
await clientIp();
if (!(await rateLimit(`draw-badge-buy:${userId}`, 5, 60_000)).ok) {
outcome = "ratelimit";
} else {
const badge = await prisma.websiteDrawbadges.findUnique({
where: { id: BigInt(rawId) },
select: { id: true, badgePath: true, published: true },
@@ -115,6 +120,7 @@ export async function buyBadge(formData: FormData): Promise<void> {
}
}
}
}
} catch {
outcome = "fail";
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+28
View File
@@ -0,0 +1,28 @@
"use server";
import { auth, signOut } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
/**
* Invalidate every CMS JWT for the signed-in user by bumping website_jwt_version,
* then end the current browser session too.
*/
export async function signOutEverywhere(): Promise<void> {
const session = await auth();
const userId = Number(session?.user?.id);
if (!Number.isInteger(userId) || userId <= 0) {
await signOut({ redirectTo: "/login" });
return;
}
try {
await prisma.user.update({
where: { id: userId },
data: { websiteJwtVersion: { increment: 1 } },
});
} catch {
/* still sign out locally */
}
await signOut({ redirectTo: "/login?signedOutAll=1" });
}
+196
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+18 -3
View File
@@ -17,7 +17,12 @@ import {
// ── User actions (authenticated, no admin perms needed) ──────────────
export const createTicket = authAction(
{ schema: createTicketSchema },
{
schema: createTicketSchema,
rateLimitKey: "ticket-create",
rateLimitMax: 5,
rateLimitWindowMs: 60_000,
},
async (ctx) => {
const ticket = await prisma.websiteTicket.create({
data: {
@@ -49,7 +54,12 @@ export const createTicket = authAction(
);
export const userReplyTicket = authAction(
{ schema: replyTicketSchema },
{
schema: replyTicketSchema,
rateLimitKey: "ticket-reply",
rateLimitMax: 10,
rateLimitWindowMs: 60_000,
},
async (ctx) => {
const ticket = await prisma.websiteTicket.findUnique({
where: { id: ctx.data.ticketId },
@@ -82,7 +92,12 @@ export const userReplyTicket = authAction(
);
export const closeTicketByUser = authAction(
{ schema: replyTicketSchema.pick({ ticketId: true }) },
{
schema: replyTicketSchema.pick({ ticketId: true }),
rateLimitKey: "ticket-close",
rateLimitMax: 10,
rateLimitWindowMs: 60_000,
},
async (ctx) => {
const ticket = await prisma.websiteTicket.findUnique({
where: { id: ctx.data.ticketId },
+9
View File
@@ -3,6 +3,7 @@
import { revalidatePath } from "next/cache";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { clientIp, rateLimit } from "@/lib/rate-limit";
import { rcon } from "@/lib/services/rcon";
import { sendCurrency } from "@/lib/services/send-currency";
@@ -40,6 +41,14 @@ export async function redeem(
};
}
await clientIp();
if (!(await rateLimit(`voucher-redeem:${userId}`, 5, 60_000)).ok) {
return {
ok: false,
message: "You're redeeming too fast. Please wait a moment and try again.",
};
}
const code = String(formData.get("code") ?? "")
.normalize("NFC")
.trim();
+47 -1
View File
@@ -1,5 +1,6 @@
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import type { CSSProperties } from "react";
import { applyStaff } from "@/actions/applications";
import { ContentCard, EmptyState } from "@/components/public/ui";
import { auth } from "@/lib/auth";
@@ -11,11 +12,45 @@ export const dynamic = "force-dynamic";
// Canonical Habbo badge image CDN (same base used by the profile page).
const BADGE_IMG_BASE = "https://images.habbo.com/c_images/album1584";
export default async function ApplyStaffPage() {
function feedbackStyle(tone: "success" | "error"): CSSProperties {
const accent =
tone === "error" ? "var(--color-danger)" : "var(--color-primary)";
return {
margin: 0,
padding: "0.85rem 1rem",
borderRadius: "var(--radius-md)",
border: `1px solid ${accent}`,
color: "var(--color-text-readable, var(--color-text))",
fontSize: "0.9rem",
fontWeight: 600,
background: "var(--color-surface)",
borderLeft: `4px solid ${accent}`,
};
}
export default async function ApplyStaffPage({
searchParams,
}: {
searchParams: Promise<{ submitted?: string; error?: string }>;
}) {
const session = await auth();
if (!session?.user?.id) redirect("/login");
const t = await getTranslations("pages.applyStaff");
const { submitted, error } = await searchParams;
const errorMessage =
error === "empty"
? t("errors.empty")
: error === "duplicate"
? t("errors.duplicate")
: error === "ratelimit"
? t("errors.ratelimit")
: error === "invalid"
? t("errors.invalid")
: error
? t("errors.error")
: null;
const userId = Number(session.user.id);
const hotelName = (await siteSettings.get("hotel_name", "Atom")) ?? "Atom";
@@ -53,6 +88,17 @@ export default async function ApplyStaffPage() {
return (
<main style={{ display: "grid", gap: "1.5rem" }}>
{submitted === "1" ? (
<div role="status" style={feedbackStyle("success")}>
{t("success.submitted")}
</div>
) : null}
{errorMessage ? (
<div role="alert" style={feedbackStyle("error")}>
{errorMessage}
</div>
) : null}
<ContentCard
icon="🛡️"
title={t("title", { hotel: hotelName })}
+47 -1
View File
@@ -1,5 +1,6 @@
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import type { CSSProperties } from "react";
import { applyTeam } from "@/actions/applications";
import { ContentCard, EmptyState } from "@/components/public/ui";
import { auth } from "@/lib/auth";
@@ -11,11 +12,45 @@ export const dynamic = "force-dynamic";
// Canonical Habbo badge image CDN (same base used by the profile page).
const BADGE_IMG_BASE = "https://images.habbo.com/c_images/album1584";
export default async function ApplyTeamPage() {
function feedbackStyle(tone: "success" | "error"): CSSProperties {
const accent =
tone === "error" ? "var(--color-danger)" : "var(--color-primary)";
return {
margin: 0,
padding: "0.85rem 1rem",
borderRadius: "var(--radius-md)",
border: `1px solid ${accent}`,
color: "var(--color-text-readable, var(--color-text))",
fontSize: "0.9rem",
fontWeight: 600,
background: "var(--color-surface)",
borderLeft: `4px solid ${accent}`,
};
}
export default async function ApplyTeamPage({
searchParams,
}: {
searchParams: Promise<{ submitted?: string; error?: string }>;
}) {
const session = await auth();
if (!session?.user?.id) redirect("/login");
const t = await getTranslations("pages.applyTeam");
const { submitted, error } = await searchParams;
const errorMessage =
error === "empty"
? t("errors.empty")
: error === "duplicate"
? t("errors.duplicate")
: error === "ratelimit"
? t("errors.ratelimit")
: error === "invalid"
? t("errors.invalid")
: error
? t("errors.error")
: null;
const userId = Number(session.user.id);
const hotelName = (await siteSettings.get("hotel_name", "Atom")) ?? "Atom";
@@ -40,6 +75,17 @@ export default async function ApplyTeamPage() {
return (
<main style={{ display: "grid", gap: "1.5rem" }}>
{submitted === "1" ? (
<div role="status" style={feedbackStyle("success")}>
{t("success.submitted")}
</div>
) : null}
{errorMessage ? (
<div role="alert" style={feedbackStyle("error")}>
{errorMessage}
</div>
) : null}
<ContentCard
icon="🤝"
title={t("title", { hotel: hotelName })}
+2
View File
@@ -26,12 +26,14 @@ const BOUGHT_NOTE = (code: string) =>
const ERROR_NOTE: Record<string, string> = {
invalid: "That badge is no longer available.",
credits: "You don't have enough credits to buy this badge.",
ratelimit: "You're purchasing too fast. Please wait a moment and try again.",
fail: "Something went wrong. Please try again.",
};
function getErrorNote(error: string): string {
if (error === "invalid") return ERROR_NOTE.invalid;
if (error === "credits") return ERROR_NOTE.credits;
if (error === "ratelimit") return ERROR_NOTE.ratelimit;
return ERROR_NOTE.fail;
}
+1
View File
@@ -0,0 +1 @@
export { default } from "../friends/loading";
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,15 @@
export default function GuildForumLoading() {
return (
<div
style={{ display: "flex", justifyContent: "center", padding: "4rem 0" }}
>
<div
className="animate-spin w-8 h-8 border-4 rounded-full"
style={{
borderColor: "var(--color-primary)",
borderTopColor: "transparent",
}}
/>
</div>
);
}
+5 -2
View File
@@ -156,9 +156,12 @@ export default async function GuildForumPage({
return (
<tr key={t2.id}>
<td style={{ padding: "0.6rem 0.75rem" }}>
<span style={{ fontWeight: 700 }}>
<Link
href={`/guilds/${guild.id}/forum/${t2.id}`}
style={{ fontWeight: 700 }}
>
{t2.subject?.trim() || t("noSubject")}
</span>
</Link>
{t2.pinned ? (
<span
className="online-badge online"
+1
View File
@@ -0,0 +1 @@
export { default } from "../friends/loading";
File diff suppressed because it is too large. Load diff
+1
View File
@@ -0,0 +1 @@
export { default } from "../../friends/loading";
+7 -2
View File
@@ -1,3 +1,4 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import type { CSSProperties } from "react";
@@ -136,8 +137,12 @@ export default async function HelpTicketsPage({
<tbody>
{tickets.map((t2) => (
<tr key={String(t2.id)}>
<td>{String(t2.id)}</td>
<td>{t2.title}</td>
<td>
<Link href={`/help/tickets/${t2.id}`}>{String(t2.id)}</Link>
</td>
<td>
<Link href={`/help/tickets/${t2.id}`}>{t2.title}</Link>
</td>
<td>
<span className={`online-badge${t2.open ? " online" : ""}`}>
<span className="online-dot" aria-hidden />
+6 -3
View File
@@ -5,17 +5,20 @@ import RadioPlayerGate from "@/components/public/radio-player-gate";
import { SiteFooter } from "@/components/site-footer";
import { SiteHeader } from "@/components/site-header";
import { TopHeader } from "@/components/top-header";
import { auth } from "@/lib/auth";
/**
* Public site chrome. Route group `(site)` keeps this off `/admin` and `/client`,
* so housekeeping is never constrained by the public max-w-7xl grid.
*/
export default function SiteLayout({ children }: { children: ReactNode }) {
export default async function SiteLayout({ children }: { children: ReactNode }) {
const session = await auth();
return (
<>
<TopHeader />
<TopHeader session={session} />
<SiteHeader />
<Navigation />
<Navigation session={session} />
<main>
<div className="mx-auto mt-6 grid max-w-7xl grid-cols-12 gap-x-3 gap-y-6 px-4 py-4 md:mt-0 md:p-6">
<div className="col-span-12 min-w-0">
File diff suppressed because it is too large. Load diff
+8 -2
View File
@@ -16,7 +16,7 @@ import { sanitize } from "@/lib/sanitize";
export const revalidate = 60;
type SearchParams = Promise<{ comment?: string; error?: string }>;
type SearchParams = Promise<{ comment?: string; reaction?: string; error?: string }>;
function feedbackStyle(tone: "success" | "error"): CSSProperties {
const accent =
@@ -87,7 +87,7 @@ export default async function ArticlePage({
searchParams: SearchParams;
}) {
const { slug } = await params;
const { comment, error } = await searchParams;
const { comment, reaction, error } = await searchParams;
const t = await getTranslations("pages.article");
const article = await cacheQuery(
@@ -189,6 +189,11 @@ export default async function ArticlePage({
{t("success.posted")}
</div>
) : null}
{reaction === "1" ? (
<div role="status" style={feedbackStyle("success")}>
{t("success.reaction")}
</div>
) : null}
{errorMessage ? (
<div role="alert" style={feedbackStyle("error")}>
{errorMessage}
@@ -240,6 +245,7 @@ export default async function ArticlePage({
name="articleId"
value={String(articleId)}
/>
<input type="hidden" name="slug" value={article.slug} />
<input type="hidden" name="reaction" value={r.key} />
<button
type="submit"
+1
View File
@@ -0,0 +1 @@
export { default } from "../friends/loading";
+43 -1
View File
@@ -1,5 +1,6 @@
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import type { CSSProperties } from "react";
import { applyDj } from "@/actions/radio-apply";
import { ContentCard } from "@/components/public/ui";
import { auth } from "@/lib/auth";
@@ -7,11 +8,41 @@ import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
export default async function RadioApplyPage() {
function feedbackStyle(tone: "success" | "error"): CSSProperties {
const accent =
tone === "error" ? "var(--color-danger)" : "var(--color-primary)";
return {
margin: 0,
padding: "0.85rem 1rem",
borderRadius: "var(--radius-md)",
border: `1px solid ${accent}`,
color: "var(--color-text-readable, var(--color-text))",
fontSize: "0.9rem",
fontWeight: 600,
background: "var(--color-surface)",
borderLeft: `4px solid ${accent}`,
};
}
export default async function RadioApplyPage({
searchParams,
}: {
searchParams: Promise<{ submitted?: string; error?: string }>;
}) {
const session = await auth();
if (!session?.user?.id) redirect("/login");
const t = await getTranslations("pages.radioApply");
const { submitted, error } = await searchParams;
const errorMessage =
error === "invalid"
? t("errors.invalid")
: error === "ratelimit"
? t("errors.ratelimit")
: error
? t("errors.error")
: null;
const userId = Number(session.user.id);
@@ -31,6 +62,17 @@ export default async function RadioApplyPage() {
return (
<main style={{ display: "grid", gap: "1.5rem" }}>
{submitted === "1" ? (
<div role="status" style={feedbackStyle("success")}>
{t("success.submitted")}
</div>
) : null}
{errorMessage ? (
<div role="alert" style={feedbackStyle("error")}>
{errorMessage}
</div>
) : null}
<ContentCard icon="🎤" title={t("title")} subtitle={t("subtitle")} />
{existing ? (
+1
View File
@@ -0,0 +1 @@
export { default } from "../friends/loading";
+46 -1
View File
@@ -1,4 +1,5 @@
import { getTranslations } from "next-intl/server";
import type { CSSProperties } from "react";
import { formatDate } from "@/lib/format-date";
import { submitRequest } from "@/actions/radio-requests";
import { ContentCard, EmptyState } from "@/components/public/ui";
@@ -7,10 +8,43 @@ import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
type SearchParams = Promise<{ posted?: string; error?: string }>;
export default async function RadioRequestsPage() {
function feedbackStyle(tone: "success" | "error"): CSSProperties {
const accent =
tone === "error" ? "var(--color-danger)" : "var(--color-primary)";
return {
margin: 0,
padding: "0.85rem 1rem",
borderRadius: "var(--radius-md)",
border: `1px solid ${accent}`,
color: "var(--color-text-readable, var(--color-text))",
fontSize: "0.9rem",
fontWeight: 600,
background: "var(--color-surface)",
borderLeft: `4px solid ${accent}`,
};
}
export default async function RadioRequestsPage({
searchParams,
}: {
searchParams: SearchParams;
}) {
const t = await getTranslations("pages.radioRequests");
const genericT = await getTranslations("pages.radio");
const { posted, error } = await searchParams;
const errorMessage =
error === "empty"
? t("errors.empty")
: error === "ratelimit"
? t("errors.ratelimit")
: error === "invalid"
? t("errors.invalid")
: error
? t("errors.error")
: null;
const [requests, session] = await Promise.all([
prisma.radioSongRequests
@@ -37,6 +71,17 @@ export default async function RadioRequestsPage() {
return (
<>
{posted === "1" ? (
<div role="status" style={feedbackStyle("success")}>
{t("success.posted")}
</div>
) : null}
{errorMessage ? (
<div role="alert" style={feedbackStyle("error")}>
{errorMessage}
</div>
) : null}
<ContentCard
icon="🎵"
title={genericT("linkRequests")}
File diff suppressed because it is too large. Load diff
+15
View File
@@ -0,0 +1,15 @@
export default function ShopLoading() {
return (
<div
style={{ display: "flex", justifyContent: "center", padding: "4rem 0" }}
>
<div
className="animate-spin w-8 h-8 border-4 rounded-full"
style={{
borderColor: "var(--color-primary)",
borderTopColor: "transparent",
}}
/>
</div>
);
}
File diff suppressed because it is too large. Load diff
+12 -11
View File
@@ -1,9 +1,11 @@
import { Megaphone } from "lucide-react";
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { deleteAd, updateAd } from "@/actions/admin-ads";
import { getTranslations } from "next-intl/server";
import { updateAd } from "@/actions/admin-ads";
import { prisma } from "@/lib/prisma";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { EditAdDeleteButton } from "../ads-table";
export const dynamic = "force-dynamic";
@@ -26,6 +28,8 @@ export default async function EditAd({
}
if (!ad) notFound();
const t = await getTranslations("pages.admin.ads");
return (
<main>
<div className="flex items-center gap-3 mb-6">
@@ -34,10 +38,10 @@ export default async function EditAd({
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
Edit advertisement
{t("edit")} #{String(ad.id)}
</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
<Link href="/admin/ads">← Advertisements</Link>
<Link href="/admin/ads">← {t("title")}</Link>
</p>
</div>
</div>
@@ -46,20 +50,17 @@ export default async function EditAd({
<input
name="image"
defaultValue={ad.image}
placeholder="Image URL"
placeholder={t("form.imagePlaceholder")}
required
maxLength={255}
/>
<button type="submit" className="btn btn-primary">
Save
</button>
</form>
<form action={deleteAd} style={{ marginTop: "1rem" }}>
<input type="hidden" name="id" value={String(ad.id)} />
<button type="submit" className="btn btn-danger">
Delete advertisement
{t("form.save")}
</button>
</form>
<div style={{ marginTop: "1rem" }}>
<EditAdDeleteButton id={String(ad.id)} />
</div>
</main>
);
}
File diff suppressed because it is too large. Load diff
+10 -35
View File
@@ -4,6 +4,7 @@ import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma";
import { redirect } from "next/navigation";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { AdsTable } from "./ads-table";
export const dynamic = "force-dynamic";
@@ -51,41 +52,15 @@ export default async function AdminAds() {
{ads.length === 0 ? (
<div className="admin-empty">{t("noAds")}</div>
) : (
<table>
<thead>
<tr>
<th>{t("colPreview")}</th>
<th>{t("colImage")}</th>
<th>{t("colCreated")}</th>
<th />
</tr>
</thead>
<tbody>
{ads.map((ad) => (
<tr key={String(ad.id)}>
<td>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={ad.image}
alt=""
style={{ maxHeight: 40, maxWidth: 120 }}
/>
</td>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
<code>{ad.image}</code>
</td>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
{ad.createdAt
? ad.createdAt.toISOString().slice(0, 10)
: "—"}
</td>
<td>
<Link href={`/admin/ads/${ad.id}`}>{t("edit")}</Link>
</td>
</tr>
))}
</tbody>
</table>
<AdsTable
ads={ads.map((ad) => ({
id: String(ad.id),
image: ad.image,
createdAt: ad.createdAt
? ad.createdAt.toISOString().slice(0, 10)
: null,
}))}
/>
)}
</section>
</main>
+2 -3
View File
@@ -1,15 +1,14 @@
import Image from "next/image";
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import type { Session } from "next-auth";
import { LanguageSwitcher } from "@/components/language-switcher";
import { MobileNav } from "@/components/mobile-nav";
import { NavDropdown } from "@/components/nav-dropdown";
import { NavbarColorPicker } from "@/components/navbar-color-picker";
import { ThemeSwitcher } from "@/components/theme-switcher";
import { auth } from "@/lib/auth";
export async function Navigation() {
const session = await auth();
export async function Navigation({ session }: { session: Session | null }) {
const t = await getTranslations("nav");
const isStaff = (session?.user?.rank ?? 0) >= 7;
+1 -3
View File
@@ -1,15 +1,13 @@
import Image from "next/image";
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { auth } from "@/lib/auth";
import { cached } from "@/lib/cache";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
export async function SiteHeader() {
const t = await getTranslations("header");
const [_session, hotelName, header, logo] = await Promise.all([
auth(),
const [hotelName, header, logo] = await Promise.all([
siteSettings.get("hotel_name", "Atom"),
siteSettings.get("cms_header", "/assets/images/background.png"),
siteSettings.get("cms_logo", ""),
+3 -3
View File
@@ -1,6 +1,7 @@
import Image from "next/image";
import Link from "next/link";
import { auth, signOut } from "@/lib/auth";
import type { Session } from "next-auth";
import { signOut } from "@/lib/auth";
import { avatarImageUrl } from "@/lib/format";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
@@ -37,8 +38,7 @@ function Currency({
);
}
export async function TopHeader() {
const session = await auth();
export async function TopHeader({ session }: { session: Session | null }) {
if (!session?.user?.id) return null;
const id = Number(session.user.id);
+56 -4
View File
File diff suppressed because it is too large. Load diff
+156 -10
View File
File diff suppressed because it is too large. Load diff
+174 -27
View File
File diff suppressed because it is too large. Load diff
+156 -9
View File
File diff suppressed because it is too large. Load diff
+2
View File
@@ -9,5 +9,7 @@ declare module "next-auth" {
declare module "next-auth/jwt" {
interface JWT {
rank?: number;
jwtVersion?: number;
invalid?: boolean;
}
}