diff --git a/.prettierrc b/.prettierrc deleted file mode 100644 index a91da40c..00000000 --- a/.prettierrc +++ /dev/null @@ -1,9 +0,0 @@ -{ - "semi": true, - "singleQuote": false, - "tabWidth": 2, - "trailingComma": "all", - "printWidth": 110, - "arrowParens": "always", - "endOfLine": "lf" -} diff --git a/biome.json b/biome.json index 8333afa0..c24a9b21 100644 --- a/biome.json +++ b/biome.json @@ -7,7 +7,7 @@ }, "files": { "ignoreUnknown": false, - "includes": ["**", "!setup", "!public"] + "includes": ["**", "!setup", "!public", "!*.cjs"] }, "formatter": { "enabled": true, diff --git a/e2e/example.spec.ts b/e2e/example.spec.ts index 5aef9540..ca4dfcc6 100644 --- a/e2e/example.spec.ts +++ b/e2e/example.spec.ts @@ -1,16 +1,16 @@ -import { test, expect } from "@playwright/test"; +import { expect, test } from "@playwright/test"; test("homepage has title", async ({ page }) => { - await page.goto("/"); - await expect(page).toHaveTitle(/Magic Hotel|Atom/i); + await page.goto("/"); + await expect(page).toHaveTitle(/Magic Hotel|Atom/i); }); test("register page loads", async ({ page }) => { - await page.goto("/register"); - await expect(page).toHaveTitle(/registreer|register|konto/i); + await page.goto("/register"); + await expect(page).toHaveTitle(/registreer|register|konto/i); }); test("login page loads", async ({ page }) => { - await page.goto("/login"); - await expect(page).toHaveTitle(/inloggen|login/i); + await page.goto("/login"); + await expect(page).toHaveTitle(/inloggen|login/i); }); diff --git a/eslint.config.mjs b/eslint.config.mjs index 0d31e24f..edcb5fb9 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -3,19 +3,19 @@ import unusedImports from "eslint-plugin-unused-imports"; import tseslint from "typescript-eslint"; export default tseslint.config( - { - ignores: ["node_modules", ".next", "dist", "build"], - }, - js.configs.recommended, - tseslint.configs.recommended, - { - files: ["src/**/*.{ts,tsx}", "pages/**/*.{ts,tsx}", "app/**/*.{ts,tsx}"], - plugins: { - "unused-imports": unusedImports, - }, - rules: { - "unused-imports/no-unused-vars": "error", - "no-console": "warn", - }, - } + { + ignores: ["node_modules", ".next", "dist", "build"], + }, + js.configs.recommended, + tseslint.configs.recommended, + { + files: ["src/**/*.{ts,tsx}", "pages/**/*.{ts,tsx}", "app/**/*.{ts,tsx}"], + plugins: { + "unused-imports": unusedImports, + }, + rules: { + "unused-imports/no-unused-vars": "error", + "no-console": "warn", + }, + }, ); diff --git a/lighthouserc.json b/lighthouserc.cjs similarity index 100% rename from lighthouserc.json rename to lighthouserc.cjs diff --git a/next.config.ts b/next.config.ts index 28b38627..f8a8c5e1 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,7 @@ -import type { NextConfig } from "next"; -import { withSentryConfig } from "@sentry/nextjs"; -import createNextIntlPlugin from "next-intl/plugin"; import withBundleAnalyzer from "@next/bundle-analyzer"; +import { withSentryConfig } from "@sentry/nextjs"; +import type { NextConfig } from "next"; +import createNextIntlPlugin from "next-intl/plugin"; const securityHeaders = [ { key: "X-DNS-Prefetch-Control", value: "on" }, @@ -79,17 +79,19 @@ const withBA = withBundleAnalyzer({ enabled: process.env.ANALYZE === "true", }); -export default withBA(withSentryConfig(config, { - org: process.env.SENTRY_ORG, - project: process.env.SENTRY_PROJECT, - authToken: sentryAuthToken, - silent: !process.env.CI, - widenClientFileUpload: true, - sourcemaps: { - disable: !sentryAuthToken, - }, - release: { - create: Boolean(sentryAuthToken), - }, - telemetry: false, -})); +export default withBA( + withSentryConfig(config, { + org: process.env.SENTRY_ORG, + project: process.env.SENTRY_PROJECT, + authToken: sentryAuthToken, + silent: !process.env.CI, + widenClientFileUpload: true, + sourcemaps: { + disable: !sentryAuthToken, + }, + release: { + create: Boolean(sentryAuthToken), + }, + telemetry: false, + }), +); diff --git a/playwright.config.ts b/playwright.config.ts index 203d3b9b..d6aa5f04 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,33 +1,33 @@ import { defineConfig, devices } from "@playwright/test"; export default defineConfig({ - testDir: "./e2e", - fullyParallel: true, - forbidOnly: !!process.env.CI, - retries: process.env.CI ? 2 : 0, - workers: process.env.CI ? 1 : undefined, - reporter: "html", - use: { - baseURL: process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000", - trace: "on-first-retry", - }, - projects: [ - { - name: "chromium", - use: { ...devices["Desktop Chrome"] }, - }, - { - name: "firefox", - use: { ...devices["Desktop Firefox"] }, - }, - { - name: "webkit", - use: { ...devices["Desktop Safari"] }, - }, - ], - webServer: { - command: "pnpm dev", - url: "http://localhost:3000", - reuseExistingServer: !process.env.CI, - }, + testDir: "./e2e", + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: "html", + use: { + baseURL: process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000", + trace: "on-first-retry", + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + { + name: "firefox", + use: { ...devices["Desktop Firefox"] }, + }, + { + name: "webkit", + use: { ...devices["Desktop Safari"] }, + }, + ], + webServer: { + command: "pnpm dev", + url: "http://localhost:3000", + reuseExistingServer: !process.env.CI, + }, }); diff --git a/scripts/apply-migrations.ts b/scripts/apply-migrations.ts index b3637885..8f385b63 100644 --- a/scripts/apply-migrations.ts +++ b/scripts/apply-migrations.ts @@ -29,6 +29,7 @@ async function openConnection() { const mysql = await import("mysql2/promise"); return mysql.createConnection(url); } +void openConnection; async function createTrackingTable( conn: import("mysql2/promise").Connection, diff --git a/src/actions/admin-ads.ts b/src/actions/admin-ads.ts index d5ea4f4e..2ed55928 100644 --- a/src/actions/admin-ads.ts +++ b/src/actions/admin-ads.ts @@ -5,11 +5,11 @@ import { redirect } from "next/navigation"; import { z } from "zod"; import { requirePermission } from "@/lib/admin/guard"; import { formPositiveBigInt } from "@/lib/form-data"; +import { logger } from "@/lib/logger"; 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 { logger } from "@/lib/logger"; import { logStaffActivity } from "@/lib/services/staff-activity"; // CRUD for website advertisements (website_ads). Emulator does not own this diff --git a/src/actions/admin-applications.ts b/src/actions/admin-applications.ts index 1088fe35..2192f4b0 100644 --- a/src/actions/admin-applications.ts +++ b/src/actions/admin-applications.ts @@ -2,8 +2,8 @@ import { revalidatePath } from "next/cache"; 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"; export async function dismissApplication(formData: FormData): Promise { diff --git a/src/actions/admin-articles.ts b/src/actions/admin-articles.ts index 939408ee..82f518a4 100644 --- a/src/actions/admin-articles.ts +++ b/src/actions/admin-articles.ts @@ -3,8 +3,8 @@ import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; import { requirePermission } from "@/lib/admin/guard"; -import { PERMS } from "@/lib/permissions"; import { slugify } from "@/lib/format"; +import { PERMS } from "@/lib/permissions"; import { prisma } from "@/lib/prisma"; async function uniqueSlug(title: string): Promise { @@ -74,9 +74,7 @@ export async function updateArticle(formData: FormData): Promise { .normalize("NFC") .trim() .slice(0, 255), - ...(rawSlug - ? { slug: await uniqueSlug(rawSlug) } - : {}), + ...(rawSlug ? { slug: await uniqueSlug(rawSlug) } : {}), shortStory: String(formData.get("shortStory") ?? "") .normalize("NFC") .trim() diff --git a/src/actions/admin-email-templates.ts b/src/actions/admin-email-templates.ts index f1e98d46..70990457 100644 --- a/src/actions/admin-email-templates.ts +++ b/src/actions/admin-email-templates.ts @@ -2,8 +2,8 @@ import { revalidatePath } from "next/cache"; 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"; export async function createEmailTemplate(formData: FormData): Promise { diff --git a/src/actions/admin-help.ts b/src/actions/admin-help.ts index 8459fe71..9e4f3300 100644 --- a/src/actions/admin-help.ts +++ b/src/actions/admin-help.ts @@ -3,9 +3,9 @@ import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; import { requirePermission } from "@/lib/admin/guard"; -import { PERMS } from "@/lib/permissions"; import { formPositiveBigInt } from "@/lib/form-data"; import { canonicalize, sanitizeField } from "@/lib/foundation/security"; +import { PERMS } from "@/lib/permissions"; import { prisma } from "@/lib/prisma"; import { logStaffActivity } from "@/lib/services/staff-activity"; @@ -26,8 +26,10 @@ export async function createHelpQuestion(formData: FormData): Promise { const imageUrl = sanitizeField(formData.get("imageUrl")); const buttonText = sanitizeField(formData.get("buttonText")); const buttonUrl = sanitizeField(formData.get("buttonUrl")); - const buttonColor = sanitizeField(formData.get("buttonColor"), 16) || "#eeb425"; - const buttonBorderColor = sanitizeField(formData.get("buttonBorderColor"), 16) || "#facc15"; + const buttonColor = + sanitizeField(formData.get("buttonColor"), 16) || "#eeb425"; + const buttonBorderColor = + sanitizeField(formData.get("buttonBorderColor"), 16) || "#facc15"; try { const entry = await prisma.websiteHelpCenterCategories.create({ @@ -73,8 +75,10 @@ export async function updateHelpQuestion(formData: FormData): Promise { const imageUrl = sanitizeField(formData.get("imageUrl")); const buttonText = sanitizeField(formData.get("buttonText")); const buttonUrl = sanitizeField(formData.get("buttonUrl")); - const buttonColor = sanitizeField(formData.get("buttonColor"), 16) || "#eeb425"; - const buttonBorderColor = sanitizeField(formData.get("buttonBorderColor"), 16) || "#facc15"; + const buttonColor = + sanitizeField(formData.get("buttonColor"), 16) || "#eeb425"; + const buttonBorderColor = + sanitizeField(formData.get("buttonBorderColor"), 16) || "#facc15"; try { await prisma.websiteHelpCenterCategories.update({ diff --git a/src/actions/admin-media.ts b/src/actions/admin-media.ts index f8260daa..02962c26 100644 --- a/src/actions/admin-media.ts +++ b/src/actions/admin-media.ts @@ -4,8 +4,8 @@ import { mkdir, writeFile } from "node:fs/promises"; import path from "node:path"; import { revalidatePath } from "next/cache"; import { requirePermission } from "@/lib/admin/guard"; -import { PERMS } from "@/lib/permissions"; import { MEDIA_ROOT, resolveMediaPath } from "@/lib/media-storage"; +import { PERMS } from "@/lib/permissions"; const MAX_SIZE = 5 * 1024 * 1024; // 5MB const ALLOWED = ["image/png", "image/jpeg", "image/gif", "image/webp"]; @@ -19,7 +19,10 @@ export async function uploadMedia( if (file.size > MAX_SIZE) return { ok: false, error: "File too large (max 5MB)" }; if (!ALLOWED.includes(file.type)) - return { ok: false, error: "Invalid file type. Allowed: PNG, JPEG, GIF, WebP" }; + return { + ok: false, + error: "Invalid file type. Allowed: PNG, JPEG, GIF, WebP", + }; const baseDir = MEDIA_ROOT; // eslint-disable-next-line security/detect-non-literal-fs-filename diff --git a/src/actions/admin-rare-values.ts b/src/actions/admin-rare-values.ts index 6c9e35b4..9933a4b5 100644 --- a/src/actions/admin-rare-values.ts +++ b/src/actions/admin-rare-values.ts @@ -2,8 +2,8 @@ import { revalidatePath } from "next/cache"; 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"; export async function createCategory(formData: FormData): Promise { diff --git a/src/actions/admin-settings.ts b/src/actions/admin-settings.ts index c8a78245..d738bc91 100644 --- a/src/actions/admin-settings.ts +++ b/src/actions/admin-settings.ts @@ -11,8 +11,8 @@ import { } from "@/lib/habbo-gamedata-hotel"; import { PERMS } from "@/lib/permissions"; import { prisma } from "@/lib/prisma"; -import { clearBadgeCache } from "@/lib/services/habboassets"; import { clearHabboItCache } from "@/lib/services/habbo-furnidata-cache"; +import { clearBadgeCache } from "@/lib/services/habboassets"; import { siteSettings } from "@/lib/services/site-settings"; const managedKeySet = new Set(MANAGED_SETTING_KEYS); diff --git a/src/actions/admin-shop.ts b/src/actions/admin-shop.ts index 5eaaefc2..a59b084a 100644 --- a/src/actions/admin-shop.ts +++ b/src/actions/admin-shop.ts @@ -3,8 +3,8 @@ import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; 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 { logServerError } from "@/lib/server-log"; import { logStaffActivity } from "@/lib/services/staff-activity"; diff --git a/src/actions/admin-theme.ts b/src/actions/admin-theme.ts index 20e7f047..8e12e04f 100644 --- a/src/actions/admin-theme.ts +++ b/src/actions/admin-theme.ts @@ -7,6 +7,7 @@ import { PERMS } from "@/lib/permissions"; import { prisma } from "@/lib/prisma"; import { siteSettings } from "@/lib/services/site-settings"; import { logStaffActivity } from "@/lib/services/staff-activity"; +import { ensureReadableThemeColors } from "@/lib/theme-contrast"; import { deleteCustomThemeStore, getCustomTheme, @@ -15,7 +16,6 @@ import { } from "@/lib/theme-custom-store"; import { FONTS, PRESETS, THEME_COLOR_KEYS } from "@/lib/theme-presets"; import { presetSettings, settingKey } from "@/lib/theme-settings"; -import { ensureReadableThemeColors } from "@/lib/theme-contrast"; // Only hex/keyword colour values are accepted (matches ThemeVars' sanitiser). const COLOR_RE = /^[#a-zA-Z0-9(),.\s%-]+$/; @@ -46,7 +46,10 @@ export async function saveTheme(formData: FormData): Promise { } const fixed = ensureReadableThemeColors(bag); for (const [key, value] of Object.entries(fixed)) { - await writeSetting(settingKey(key as (typeof THEME_COLOR_KEYS)[number], mode), value); + await writeSetting( + settingKey(key as (typeof THEME_COLOR_KEYS)[number], mode), + value, + ); } } const ADMIN_KEYS = [ diff --git a/src/actions/admin-vouchers.ts b/src/actions/admin-vouchers.ts index 70085ac3..fe9458d2 100644 --- a/src/actions/admin-vouchers.ts +++ b/src/actions/admin-vouchers.ts @@ -1,14 +1,14 @@ "use server"; import { revalidatePath } from "next/cache"; -import { positiveBigInt } from "@/lib/api"; import { requirePermission } from "@/lib/admin/guard"; +import { positiveBigInt } from "@/lib/api"; import { PERMS } from "@/lib/permissions"; import { prisma } from "@/lib/prisma"; import { + type ActionResult, actionError, actionOk, - type ActionResult, } from "@/lib/safe-action-shared"; import { logServerError } from "@/lib/server-log"; diff --git a/src/actions/admin-wordfilter.ts b/src/actions/admin-wordfilter.ts index 743d7ebf..586b0a6d 100644 --- a/src/actions/admin-wordfilter.ts +++ b/src/actions/admin-wordfilter.ts @@ -5,9 +5,9 @@ import { requirePermission } from "@/lib/admin/guard"; import { PERMS } from "@/lib/permissions"; import { prisma } from "@/lib/prisma"; import { + type ActionResult, actionError, actionOk, - type ActionResult, } from "@/lib/safe-action-shared"; import { reloadWordFilter } from "@/lib/services/moderation"; import { rcon } from "@/lib/services/rcon"; @@ -33,9 +33,7 @@ export async function addWord(input: { } } -export async function deleteWord(input: { - id: string; -}): Promise { +export async function deleteWord(input: { id: string }): Promise { await requirePermission(PERMS.WORDFILTER_EDIT); const raw = String(input.id ?? "").normalize("NFC"); if (!raw) return actionError("Missing word id"); diff --git a/src/actions/draw-badge.ts b/src/actions/draw-badge.ts index d8e93073..c6a8ca99 100644 --- a/src/actions/draw-badge.ts +++ b/src/actions/draw-badge.ts @@ -64,63 +64,65 @@ export async function buyBadge(formData: FormData): Promise { 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 }, - }); + const badge = await prisma.websiteDrawbadges.findUnique({ + where: { id: BigInt(rawId) }, + select: { id: true, badgePath: true, published: true }, + }); - if (!badge?.published) { - outcome = "invalid"; - } else { - const code = badgeCodeFromPath(badge.badgePath); - if (code.length === 0) { + if (!badge?.published) { outcome = "invalid"; } else { - const price = await resolvePrice(); - - // Re-read the buyer's live credit balance and verify it covers the cost. - const buyer = await prisma.user.findUnique({ - where: { id: userId }, - select: { credits: true }, - }); - if (!buyer || buyer.credits < price) { - outcome = "credits"; + const code = badgeCodeFromPath(badge.badgePath); + if (code.length === 0) { + outcome = "invalid"; } else { - // Atomically deduct credits and persist the badge so a failure - // between the two operations cannot orphan the user. - if (price > 0) { - await prisma.$transaction(async (tx: Prisma.TransactionClient) => { - await tx.user.update({ - where: { id: userId }, - data: { credits: { decrement: price } }, - }); + const price = await resolvePrice(); - const existing = await tx.usersBadges.findFirst({ - where: { userId, badgeCode: code }, - select: { id: true }, - }); - if (!existing) { - const max = await tx.usersBadges.aggregate({ - where: { userId }, - _max: { slotId: true }, - }); - const slotId = (max._max.slotId ?? 0) + 1; - await tx.usersBadges.create({ - data: { userId, slotId, badgeCode: code }, - }); - } - }); + // Re-read the buyer's live credit balance and verify it covers the cost. + const buyer = await prisma.user.findUnique({ + where: { id: userId }, + select: { credits: true }, + }); + if (!buyer || buyer.credits < price) { + outcome = "credits"; + } else { + // Atomically deduct credits and persist the badge so a failure + // between the two operations cannot orphan the user. + if (price > 0) { + await prisma.$transaction( + async (tx: Prisma.TransactionClient) => { + await tx.user.update({ + where: { id: userId }, + data: { credits: { decrement: price } }, + }); + + const existing = await tx.usersBadges.findFirst({ + where: { userId, badgeCode: code }, + select: { id: true }, + }); + if (!existing) { + const max = await tx.usersBadges.aggregate({ + where: { userId }, + _max: { slotId: true }, + }); + const slotId = (max._max.slotId ?? 0) + 1; + await tx.usersBadges.create({ + data: { userId, slotId, badgeCode: code }, + }); + } + }, + ); + } + + // Grant the badge live so it appears immediately for online users. + await rcon.giveBadge(userId, code).catch(() => {}); + + outcome = "bought"; + boughtCode = code; } - - // Grant the badge live so it appears immediately for online users. - await rcon.giveBadge(userId, code).catch(() => {}); - - outcome = "bought"; - boughtCode = code; } } } - } } catch { outcome = "fail"; } diff --git a/src/actions/email-verify.test.ts b/src/actions/email-verify.test.ts index 1ad3ea87..fb200193 100644 --- a/src/actions/email-verify.test.ts +++ b/src/actions/email-verify.test.ts @@ -53,9 +53,9 @@ describe("email verification tokens", () => { it("rejects legacy forever-valid digests", async () => { const legacy = "a".repeat(64); - expect( - await isValidVerificationToken("user@example.com", legacy), - ).toBe(false); + expect(await isValidVerificationToken("user@example.com", legacy)).toBe( + false, + ); }); it("rejects expired tokens", async () => { @@ -69,18 +69,19 @@ describe("email verification tokens", () => { }); it("sends mail with a verify link", async () => { - mockGetTranslations.mockResolvedValue( - ((key: string, values?: { hotel?: string }) => { - const map: Record = { - subject: `Verify your email · ${values?.hotel}`, - heading: "Verify your email", - body: `Welcome to ${values?.hotel}!`, - button: "Verify email", - fallback: "Paste this link:", - }; - return map[key] ?? key; - }) as never, - ); + mockGetTranslations.mockResolvedValue((( + key: string, + values?: { hotel?: string }, + ) => { + const map: Record = { + subject: `Verify your email · ${values?.hotel}`, + heading: "Verify your email", + body: `Welcome to ${values?.hotel}!`, + button: "Verify email", + fallback: "Paste this link:", + }; + return map[key] ?? key; + }) as never); await sendVerification("user@example.com"); expect(mockSendMail).toHaveBeenCalledWith( diff --git a/src/actions/help-tickets.ts b/src/actions/help-tickets.ts index a30b5729..0a09444d 100644 --- a/src/actions/help-tickets.ts +++ b/src/actions/help-tickets.ts @@ -3,10 +3,10 @@ import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; import { z } from "zod"; +import { positiveBigInt } from "@/lib/api"; import { auth } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; import { clientIp, rateLimit } from "@/lib/rate-limit"; -import { positiveBigInt } from "@/lib/api"; import { moderateOrThrow } from "@/lib/services/moderation"; import { createOwnedTicketReply } from "@/lib/services/ticket-replies"; diff --git a/src/actions/messenger.ts b/src/actions/messenger.ts index ef0ab840..78d87a05 100644 --- a/src/actions/messenger.ts +++ b/src/actions/messenger.ts @@ -3,8 +3,8 @@ import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; import { auth } from "@/lib/auth"; -import { clientIp, rateLimit } from "@/lib/rate-limit"; import { prisma } from "@/lib/prisma"; +import { clientIp, rateLimit } from "@/lib/rate-limit"; type FriendOutcome = | "accepted" @@ -71,68 +71,72 @@ export async function acceptFriend(formData: FormData): Promise { if (!(await rateLimit(`friend-accept:${meId}`, 10, 60_000)).ok) { outcome = "ratelimit"; } else { - const requestId = Number(formData.get("requestId")); - if (!Number.isInteger(requestId) || requestId <= 0) { - outcome = "invalid"; - } else { - // The request must exist AND be addressed to the session user. - const request = await prisma.messengerFriendrequests.findUnique({ - where: { id: requestId }, - select: { id: true, userFromId: true, userToId: true }, - }); - if (!request) { - outcome = "not_found"; - } else if (request.userToId !== meId) { - outcome = "unauthorized"; + const requestId = Number(formData.get("requestId")); + if (!Number.isInteger(requestId) || requestId <= 0) { + outcome = "invalid"; } else { - const friendId = request.userFromId; - if (!Number.isInteger(friendId) || friendId <= 0 || friendId === meId) { - // Malformed/self request — clear it and treat as not found. - await prisma.messengerFriendrequests.delete({ - where: { id: requestId }, - }); + // The request must exist AND be addressed to the session user. + const request = await prisma.messengerFriendrequests.findUnique({ + where: { id: requestId }, + select: { id: true, userFromId: true, userToId: true }, + }); + if (!request) { outcome = "not_found"; + } else if (request.userToId !== meId) { + outcome = "unauthorized"; } else { - const friendsSince = Math.floor(Date.now() / 1000); - - await prisma.$transaction(async (tx) => { - // Don't double-insert if a friendship already exists in either direction. - const existing = await tx.messengerFriendships.findFirst({ - where: { - OR: [ - { userOneId: meId, userTwoId: friendId }, - { userOneId: friendId, userTwoId: meId }, - ], - }, - select: { id: true }, + const friendId = request.userFromId; + if ( + !Number.isInteger(friendId) || + friendId <= 0 || + friendId === meId + ) { + // Malformed/self request — clear it and treat as not found. + await prisma.messengerFriendrequests.delete({ + where: { id: requestId }, }); + outcome = "not_found"; + } else { + const friendsSince = Math.floor(Date.now() / 1000); - if (!existing) { - await tx.messengerFriendships.createMany({ - data: [ - { userOneId: meId, userTwoId: friendId, friendsSince }, - { userOneId: friendId, userTwoId: meId, friendsSince }, - ], + await prisma.$transaction(async (tx) => { + // Don't double-insert if a friendship already exists in either direction. + const existing = await tx.messengerFriendships.findFirst({ + where: { + OR: [ + { userOneId: meId, userTwoId: friendId }, + { userOneId: friendId, userTwoId: meId }, + ], + }, + select: { id: true }, }); - } - // Clear this request and any reverse pending request between the pair. - await tx.messengerFriendrequests.deleteMany({ - where: { - OR: [ - { id: requestId }, - { userFromId: meId, userToId: friendId }, - { userFromId: friendId, userToId: meId }, - ], - }, + if (!existing) { + await tx.messengerFriendships.createMany({ + data: [ + { userOneId: meId, userTwoId: friendId, friendsSince }, + { userOneId: friendId, userTwoId: meId, friendsSince }, + ], + }); + } + + // Clear this request and any reverse pending request between the pair. + await tx.messengerFriendrequests.deleteMany({ + where: { + OR: [ + { id: requestId }, + { userFromId: meId, userToId: friendId }, + { userFromId: friendId, userToId: meId }, + ], + }, + }); }); - }); - outcome = "accepted"; + outcome = "accepted"; + } } } } - } } catch (e) { // redirect() throws a NEXT_REDIRECT control-flow signal — re-throw it. if ( @@ -172,26 +176,26 @@ export async function declineFriendRequest(formData: FormData): Promise { if (!(await rateLimit(`friend-decline:${meId}`, 10, 60_000)).ok) { outcome = "ratelimit"; } else { - const requestId = Number(formData.get("requestId")); - if (!Number.isInteger(requestId) || requestId <= 0) { - outcome = "invalid"; - } else { - const request = await prisma.messengerFriendrequests.findUnique({ - where: { id: requestId }, - select: { id: true, userToId: true }, - }); - if (!request) { - outcome = "not_found"; - } else if (request.userToId !== meId) { - outcome = "unauthorized"; + const requestId = Number(formData.get("requestId")); + if (!Number.isInteger(requestId) || requestId <= 0) { + outcome = "invalid"; } else { - await prisma.messengerFriendrequests.delete({ + const request = await prisma.messengerFriendrequests.findUnique({ where: { id: requestId }, + select: { id: true, userToId: true }, }); - outcome = "declined"; + if (!request) { + outcome = "not_found"; + } else if (request.userToId !== meId) { + outcome = "unauthorized"; + } else { + await prisma.messengerFriendrequests.delete({ + where: { id: requestId }, + }); + outcome = "declined"; + } } } - } } catch (e) { if ( e && @@ -231,34 +235,34 @@ export async function removeFriendship(formData: FormData): Promise { if (!(await rateLimit(`friend-remove:${meId}`, 10, 60_000)).ok) { outcome = "ratelimit"; } else { - const friendId = Number(formData.get("friendId")); - if (!Number.isInteger(friendId) || friendId <= 0 || friendId === meId) { - outcome = "invalid"; - } else { - const deleted = await prisma.$transaction(async (tx) => { - const result = await tx.messengerFriendships.deleteMany({ - where: { - OR: [ - { userOneId: meId, userTwoId: friendId }, - { userOneId: friendId, userTwoId: meId }, - ], - }, + const friendId = Number(formData.get("friendId")); + if (!Number.isInteger(friendId) || friendId <= 0 || friendId === meId) { + outcome = "invalid"; + } else { + const deleted = await prisma.$transaction(async (tx) => { + const result = await tx.messengerFriendships.deleteMany({ + where: { + OR: [ + { userOneId: meId, userTwoId: friendId }, + { userOneId: friendId, userTwoId: meId }, + ], + }, + }); + + await tx.messengerFriendrequests.deleteMany({ + where: { + OR: [ + { userFromId: meId, userToId: friendId }, + { userFromId: friendId, userToId: meId }, + ], + }, + }); + + return result.count; }); - await tx.messengerFriendrequests.deleteMany({ - where: { - OR: [ - { userFromId: meId, userToId: friendId }, - { userFromId: friendId, userToId: meId }, - ], - }, - }); - - return result.count; - }); - - outcome = deleted > 0 ? "removed" : "not_found"; - } + outcome = deleted > 0 ? "removed" : "not_found"; + } } } catch (e) { if ( diff --git a/src/actions/radio-apply.ts b/src/actions/radio-apply.ts index d132332f..068db3ad 100644 --- a/src/actions/radio-apply.ts +++ b/src/actions/radio-apply.ts @@ -12,11 +12,7 @@ const NAME_MAX = 255; const TEXT_MAX = 5000; const STYLE_MAX = 5000; -type ApplyOutcome = - | "submitted" - | "invalid" - | "ratelimit" - | "error"; +type ApplyOutcome = "submitted" | "invalid" | "ratelimit" | "error"; function applyRedirect(outcome: ApplyOutcome): never { if (outcome === "submitted") redirect("/radio/apply?submitted=1"); diff --git a/src/actions/radio-requests.ts b/src/actions/radio-requests.ts index 5407c880..2d22a69d 100644 --- a/src/actions/radio-requests.ts +++ b/src/actions/radio-requests.ts @@ -9,12 +9,7 @@ import { clientIp, rateLimit } from "@/lib/rate-limit"; const SONG_MAX = 255; const ARTIST_MAX = 255; -type RequestOutcome = - | "posted" - | "empty" - | "invalid" - | "ratelimit" - | "error"; +type RequestOutcome = "posted" | "empty" | "invalid" | "ratelimit" | "error"; function requestsRedirect(outcome: RequestOutcome): never { if (outcome === "posted") redirect("/radio/requests?posted=1"); diff --git a/src/actions/radio-shouts.ts b/src/actions/radio-shouts.ts index 77da5cc4..986401c8 100644 --- a/src/actions/radio-shouts.ts +++ b/src/actions/radio-shouts.ts @@ -12,12 +12,7 @@ const shoutSchema = z.object({ message: z.string().min(1, "Message is required").max(255), }); -type ShoutOutcome = - | "posted" - | "invalid" - | "moderated" - | "ratelimit" - | "error"; +type ShoutOutcome = "posted" | "invalid" | "moderated" | "ratelimit" | "error"; function shoutsRedirect(outcome: ShoutOutcome): never { if (outcome === "posted") redirect("/radio/shouts?posted=1"); diff --git a/src/actions/register.ts b/src/actions/register.ts index f4fbaa78..aad29919 100644 --- a/src/actions/register.ts +++ b/src/actions/register.ts @@ -4,6 +4,7 @@ import { redirect } from "next/navigation"; import { z } from "zod"; import { sendVerification } from "@/actions/email-verify"; import { hashPassword } from "@/lib/auth/password"; +import { logger } from "@/lib/logger"; import { prisma } from "@/lib/prisma"; import { clientIp, rateLimit } from "@/lib/rate-limit"; import { captchaConfig, verifyCaptcha } from "@/lib/services/captcha"; @@ -100,6 +101,7 @@ export async function register( }); if (existing) return "That username is already taken"; } catch { + logger.warn("Username uniqueness check failed during registration"); return "Registration is temporarily unavailable"; } @@ -122,10 +124,11 @@ export async function register( try { await sendVerification(mail); } catch { - // No-op: account is created; user can request a new link later. + logger.warn("Failed to send verification email after registration"); } } } catch { + logger.warn("Account creation failed"); return "Could not create the account (is the username unique?)"; } diff --git a/src/actions/save-favicon.ts b/src/actions/save-favicon.ts index 57c1780b..6dfc005f 100644 --- a/src/actions/save-favicon.ts +++ b/src/actions/save-favicon.ts @@ -3,9 +3,9 @@ import { mkdir, unlink, writeFile } from "node:fs/promises"; import path from "node:path"; import { revalidatePath } from "next/cache"; +import { resolveMediaPath } from "@/lib/media-storage"; import { prisma } from "@/lib/prisma"; import { siteSettings } from "@/lib/services/site-settings"; -import { resolveMediaPath } from "@/lib/media-storage"; const FAVICON_DIR = resolveMediaPath("favicon"); const MAX_SIZE = 2 * 1024 * 1024; // 2MB diff --git a/src/actions/save-logo.ts b/src/actions/save-logo.ts index af398622..8b92d9e0 100644 --- a/src/actions/save-logo.ts +++ b/src/actions/save-logo.ts @@ -3,9 +3,9 @@ import { mkdir, writeFile } from "node:fs/promises"; import path from "node:path"; import { revalidatePath } from "next/cache"; +import { resolveMediaPath } from "@/lib/media-storage"; import { prisma } from "@/lib/prisma"; import { siteSettings } from "@/lib/services/site-settings"; -import { resolveMediaPath } from "@/lib/media-storage"; const MEDIA_DIR = resolveMediaPath("logo"); diff --git a/src/actions/sessions.ts b/src/actions/sessions.ts index 9c17bd35..91efa347 100644 --- a/src/actions/sessions.ts +++ b/src/actions/sessions.ts @@ -37,10 +37,13 @@ export async function signOutEverywhere(): Promise { where: personalTokenScope(userId), }); } catch (err) { - logger.warn("Failed to revoke personal access tokens during sign-out-everywhere", { - userId, - error: err instanceof Error ? err.message : "Unknown", - }); + logger.warn( + "Failed to revoke personal access tokens during sign-out-everywhere", + { + userId, + error: err instanceof Error ? err.message : "Unknown", + }, + ); } await signOut({ redirectTo: "/login?signedOutAll=1" }); diff --git a/src/actions/shop.ts b/src/actions/shop.ts index ec7eddb3..1ad44fb3 100644 --- a/src/actions/shop.ts +++ b/src/actions/shop.ts @@ -28,12 +28,7 @@ function parseBadgeCodes(raw: string | null | undefined): string[] { .filter((s) => s.length > 0 && s.length <= 32); } -type BuyOutcome = - | "bought" - | "invalid" - | "credits" - | "ratelimit" - | "error"; +type BuyOutcome = "bought" | "invalid" | "credits" | "ratelimit" | "error"; function shopRedirect( categoryId: string, diff --git a/src/actions/social.ts b/src/actions/social.ts index 4e02f32f..127dfbfa 100644 --- a/src/actions/social.ts +++ b/src/actions/social.ts @@ -22,12 +22,7 @@ type FriendRequestOutcome = | "ratelimit" | "error"; -type ThreadOutcome = - | "posted" - | "invalid" - | "not_found" - | "ratelimit" - | "error"; +type ThreadOutcome = "posted" | "invalid" | "not_found" | "ratelimit" | "error"; type ReplyOutcome = | "replied" diff --git a/src/actions/users.ts b/src/actions/users.ts index 9ae2bff3..17c5ec03 100644 --- a/src/actions/users.ts +++ b/src/actions/users.ts @@ -388,6 +388,7 @@ export const muteUser = adminAction( { permission: PERMS.USERS_EDIT, schema: muteSchema }, async (ctx) => { const _target = await guardRank(ctx.data.userId, ctx.session.user.rank); + void _target; const success = await rcon.muteUser(ctx.data.userId, ctx.data.duration); if (!success) throw new ActionError("Failed to mute. Is the emulator running?"); @@ -440,6 +441,7 @@ export const sendCredits = adminAction( { permission: PERMS.USERS_EDIT, schema: sendCreditsSchema }, async (ctx) => { const _target = await guardRank(ctx.data.userId, ctx.session.user.rank); + void _target; const success = await rcon.giveCredits(ctx.data.userId, ctx.data.amount); if (!success) throw new ActionError("Failed to send credits. Is the emulator running?"); diff --git a/src/app/(site)/apply/staff/page.tsx b/src/app/(site)/apply/staff/page.tsx index fbf68295..5c47a327 100644 --- a/src/app/(site)/apply/staff/page.tsx +++ b/src/app/(site)/apply/staff/page.tsx @@ -4,9 +4,9 @@ import type { CSSProperties } from "react"; import { applyStaff } from "@/actions/applications"; import { ContentCard, EmptyState } from "@/components/public/ui"; import { auth } from "@/lib/auth"; -import { prisma } from "@/lib/prisma"; import { formatDate } from "@/lib/format-date"; import { resolveHotelName } from "@/lib/hotel-name"; +import { prisma } from "@/lib/prisma"; export const dynamic = "force-dynamic"; @@ -145,7 +145,13 @@ export default async function ApplyStaffPage({

{position.description}

- {t("deadline", { date: formatDate(position.applyTo, "datetime", t("noDeadline")) })} + {t("deadline", { + date: formatDate( + position.applyTo, + "datetime", + t("noDeadline"), + ), + })}

{alreadyApplied ? ( diff --git a/src/app/(site)/apply/team/page.tsx b/src/app/(site)/apply/team/page.tsx index 907b9b36..dc6aafd6 100644 --- a/src/app/(site)/apply/team/page.tsx +++ b/src/app/(site)/apply/team/page.tsx @@ -4,8 +4,8 @@ import type { CSSProperties } from "react"; import { applyTeam } from "@/actions/applications"; import { ContentCard, EmptyState } from "@/components/public/ui"; import { auth } from "@/lib/auth"; -import { prisma } from "@/lib/prisma"; import { resolveHotelName } from "@/lib/hotel-name"; +import { prisma } from "@/lib/prisma"; export const dynamic = "force-dynamic"; diff --git a/src/app/(site)/events/[slugOrId]/page.tsx b/src/app/(site)/events/[slugOrId]/page.tsx index 3087ea09..aa5af8d5 100644 --- a/src/app/(site)/events/[slugOrId]/page.tsx +++ b/src/app/(site)/events/[slugOrId]/page.tsx @@ -42,7 +42,10 @@ export default async function EventDetailPage({ }) .catch(() => null); - if (!event || (event.status !== "published" && event.status !== "completed")) { + if ( + !event || + (event.status !== "published" && event.status !== "completed") + ) { notFound(); } @@ -70,8 +73,7 @@ export default async function EventDetailPage({ const winnerName = new Map(winners.map((u) => [u.id, u.username])); const isFull = - event.maxPlayers != null && - event._count.registrations >= event.maxPlayers; + event.maxPlayers != null && event._count.registrations >= event.maxPlayers; const hasEnded = event.status === "completed" || (event.endsAt != null && event.endsAt.getTime() < Date.now()); @@ -157,7 +159,11 @@ export default async function EventDetailPage({
- + {session?.user && canRegister ? ( ) : ( diff --git a/src/app/(site)/events/page.tsx b/src/app/(site)/events/page.tsx index 9fe37ec8..e5ed5b06 100644 --- a/src/app/(site)/events/page.tsx +++ b/src/app/(site)/events/page.tsx @@ -112,7 +112,13 @@ export default async function EventsPage() { > {formatDate(event.startsAt, "datetime", "")}

-

+

{excerpt(event.description, 120)}

- {formatDate(post.createdAt ? new Date(post.createdAt * 1000) : null)} + {formatDate( + post.createdAt ? new Date(post.createdAt * 1000) : null, + )}

{canReply ? ( - +

@@ -262,8 +268,7 @@ export default async function GuildForumThreadPage({

) : (

- {t("loginToReply")}{" "} - {t("loginLink")} + {t("loginToReply")} {t("loginLink")}

)} diff --git a/src/app/(site)/guilds/[id]/forum/page.tsx b/src/app/(site)/guilds/[id]/forum/page.tsx index 2c89991e..118617c3 100644 --- a/src/app/(site)/guilds/[id]/forum/page.tsx +++ b/src/app/(site)/guilds/[id]/forum/page.tsx @@ -2,8 +2,8 @@ import Link from "next/link"; import { notFound } from "next/navigation"; import { getTranslations } from "next-intl/server"; import type { CSSProperties } from "react"; -import { formatDate } from "@/lib/format-date"; import { ContentCard, EmptyState } from "@/components/public/ui"; +import { formatDate } from "@/lib/format-date"; import { prisma } from "@/lib/prisma"; export const revalidate = 300; @@ -190,7 +190,10 @@ export default async function GuildForumPage({ {t2.postsCount ?? 0} - {formatDate(t2.createdAt ? new Date(t2.createdAt * 1000) : null, "date")} + {formatDate( + t2.createdAt ? new Date(t2.createdAt * 1000) : null, + "date", + )} ); diff --git a/src/app/(site)/guilds/[id]/page.tsx b/src/app/(site)/guilds/[id]/page.tsx index a604dea8..6cade49b 100644 --- a/src/app/(site)/guilds/[id]/page.tsx +++ b/src/app/(site)/guilds/[id]/page.tsx @@ -3,8 +3,8 @@ import { notFound } from "next/navigation"; import { getTranslations } from "next-intl/server"; import { ContentCard, EmptyState, StatBlock } from "@/components/public/ui"; import { avatarImageUrl } from "@/lib/format"; -import { prisma } from "@/lib/prisma"; import { formatDate } from "@/lib/format-date"; +import { prisma } from "@/lib/prisma"; import { siteSettings } from "@/lib/services/site-settings"; export const revalidate = 300; diff --git a/src/app/(site)/help/[category]/page.tsx b/src/app/(site)/help/[category]/page.tsx index 6e7c1a7a..503d795c 100644 --- a/src/app/(site)/help/[category]/page.tsx +++ b/src/app/(site)/help/[category]/page.tsx @@ -2,10 +2,10 @@ import Link from "next/link"; import { notFound } from "next/navigation"; import { getTranslations } from "next-intl/server"; import { ContentCard } from "@/components/public/ui"; -import { prisma } from "@/lib/prisma"; -import { sanitize } from "@/lib/sanitize"; import { SanitizedHtml } from "@/components/shared/sanitized-html"; import { resolveHotelName } from "@/lib/hotel-name"; +import { prisma } from "@/lib/prisma"; +import { sanitize } from "@/lib/sanitize"; export const revalidate = 300; @@ -118,8 +118,8 @@ export default async function HelpCategoryPage({ /> ) : null} - {/* content is author-supplied HTML — sanitised server-side. */} - + {/* content is author-supplied HTML — sanitised server-side. */} + {hasButton ? (
diff --git a/src/app/(site)/help/tickets/[id]/page.tsx b/src/app/(site)/help/tickets/[id]/page.tsx index 6a3aa835..48be01e0 100644 --- a/src/app/(site)/help/tickets/[id]/page.tsx +++ b/src/app/(site)/help/tickets/[id]/page.tsx @@ -99,9 +99,7 @@ export default async function HelpTicketDetailPage({ replies = []; } - const authorIds = [ - ...new Set([userId, ...replies.map((r) => r.userId)]), - ]; + const authorIds = [...new Set([userId, ...replies.map((r) => r.userId)])]; let users: { id: number; username: string }[] = []; try { users = await prisma.user.findMany({ @@ -174,7 +172,11 @@ export default async function HelpTicketDetailPage({ {t("backToList")}

- + {ticket.open ? t("statusOpen") : t("statusClosed")} @@ -220,7 +222,9 @@ export default async function HelpTicketDetailPage({ }} > - {index === 0 ? t("you") : username ? ( + {index === 0 ? ( + t("you") + ) : username ? ( {username} ) : ( diff --git a/src/app/(site)/layout.tsx b/src/app/(site)/layout.tsx index bb7d38bb..c151250f 100644 --- a/src/app/(site)/layout.tsx +++ b/src/app/(site)/layout.tsx @@ -11,7 +11,11 @@ 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 async function SiteLayout({ children }: { children: ReactNode }) { +export default async function SiteLayout({ + children, +}: { + children: ReactNode; +}) { const session = await auth(); return ( diff --git a/src/app/(site)/login/page.tsx b/src/app/(site)/login/page.tsx index 97db0168..0ba7bee0 100644 --- a/src/app/(site)/login/page.tsx +++ b/src/app/(site)/login/page.tsx @@ -3,21 +3,21 @@ import Image from "next/image"; import Link from "next/link"; import { getTranslations } from "next-intl/server"; import { LoginForm } from "@/components/auth/login-form"; -import { cached } from "@/lib/cache"; -import { avatarImageUrl } from "@/lib/format"; -import { prisma } from "@/lib/prisma"; -import { captchaConfig } from "@/lib/services/captcha"; -import { resolveHotelName } from "@/lib/hotel-name"; -import { siteSettings } from "@/lib/services/site-settings"; -import { ThemeSwitcher } from "@/components/theme-switcher"; +import { Clock } from "@/components/clock"; import { LanguageSwitcher } from "@/components/language-switcher"; import { Reveal } from "@/components/motion-reveal"; -import { Clock } from "@/components/clock"; +import { ThemeSwitcher } from "@/components/theme-switcher"; +import { cached } from "@/lib/cache"; +import { avatarImageUrl } from "@/lib/format"; +import { resolveHotelName } from "@/lib/hotel-name"; +import { prisma } from "@/lib/prisma"; +import { captchaConfig } from "@/lib/services/captcha"; +import { siteSettings } from "@/lib/services/site-settings"; export default async function LoginPage() { const t = await getTranslations("pages.login"); const th = await getTranslations("header"); - const [hotelName, imagerRaw, cfg, discordUrl, logo] = await Promise.all([ + const [hotelName, imagerRaw, cfg, logo] = await Promise.all([ resolveHotelName(), siteSettings.get( "habbo_imaging_url", @@ -54,229 +54,347 @@ export default async function LoginPage() {
{/* ── Top Bar ── */} -
+
{logo ? ( - {hotelName} + {hotelName} ) : ( - {hotelName} + {hotelName} )} - + {hotelName}
-
- +
-
+
-
+
-
- {/* Left panel */} -
-
-
-
- Frank +
+ {/* Left panel */} +
- - - - - {th("online", { count: online, hotel: "" }).trim()} +
+
+ Frank +
+ + + + + {th("online", { count: online, hotel: "" }).trim()} +
+

+ {t("welcomeBack")} +

+

+ {t("welcomeBackSub", { hotelName })} +

+
-

- {t("welcomeBack")} -

+ + {recentUsers.length > 0 && ( +
+
+ +

+ {th("online", { count: online, hotel: "" }).trim()} +

+
+
+
+ {recentUsers.map((u) => ( +
+ + + {u.username} + +
+ ))} +
+
+
+ )} + + {latestUsers.length > 0 && ( +
+
+ +

+ Newest citizens +

+
+
+
+ {latestUsers.map((u) => ( +
+ + + {u.username} + +
+ ))} +
+
+
+ )} +

- {t("welcomeBackSub", { hotelName })} + {t("noAccount") ?? "No account?"}{" "} + + {t("createOne") ?? "Create one"} +

+ + {/* Right: form */} +
+
+
+ +

+ {t("title")} +

+
+
+

+ {t("subtitle")} +

+ +
+
+
- - {recentUsers.length > 0 && ( -
-
- -

- {th("online", { count: online, hotel: "" }).trim()} -

-
-
-
- {recentUsers.map((u) => ( -
- - - {u.username} - -
- ))} -
-
-
- )} - - {latestUsers.length > 0 && ( -
-
- -

- Newest citizens -

-
-
-
- {latestUsers.map((u) => ( -
- - - {u.username} - -
- ))} -
-
-
- )} - -

- {t("noAccount") ?? "No account?"}{" "} - - {t("createOne") ?? "Create one"} - -

-
- - {/* Right: form */} -
-
-
- -

- {t("title")} -

-
-
-

- {t("subtitle")} -

- -
-
-
-
); diff --git a/src/app/(site)/marketplace/page.tsx b/src/app/(site)/marketplace/page.tsx index 2ddf4154..16a5408b 100644 --- a/src/app/(site)/marketplace/page.tsx +++ b/src/app/(site)/marketplace/page.tsx @@ -1,6 +1,6 @@ import { getTranslations } from "next-intl/server"; -import { formatDate } from "@/lib/format-date"; import { ContentCard, EmptyState, StatBlock } from "@/components/public/ui"; +import { formatDate } from "@/lib/format-date"; import { prisma } from "@/lib/prisma"; export const revalidate = 300; @@ -124,7 +124,10 @@ export default async function MarketplacePage() { {o.price.toLocaleString()} cr - + {listedAgo(o.timestamp, t)} diff --git a/src/app/(site)/me/page.tsx b/src/app/(site)/me/page.tsx index 52bf7767..c5eeda1e 100644 --- a/src/app/(site)/me/page.tsx +++ b/src/app/(site)/me/page.tsx @@ -3,7 +3,7 @@ import Link from "next/link"; import { redirect } from "next/navigation"; import type { CSSProperties, ReactNode } from "react"; import { claimReferral } from "@/actions/referral"; -import { ContentCard, EmptyState, OnlineBadge } from "@/components/public/ui"; + import { Reveal } from "@/components/motion-reveal"; import { auth } from "@/lib/auth"; import { avatarImageUrl } from "@/lib/format"; @@ -71,89 +71,105 @@ export default async function MePage({ try { const BADGE_IMG_BASE = "https://images.habbo.com/c_images/album1584"; - const [user, imagerBase, hotelName, neededRaw, amountRaw, currencyRaw, alertRaw, recentRooms, badges, lastWebLogin, userSettings, friendCount, unreadCount] = - await Promise.all([ - prisma.user.findUnique({ - where: { id: userId }, + const [ + user, + imagerBase, + hotelName, + neededRaw, + amountRaw, + currencyRaw, + alertRaw, + recentRooms, + badges, + lastWebLogin, + userSettings, + friendCount, + unreadCount, + ] = await Promise.all([ + prisma.user.findUnique({ + where: { id: userId }, + select: { + id: true, + username: true, + look: true, + motto: true, + rank: true, + credits: true, + accountCreated: true, + lastLogin: true, + lastOnline: true, + }, + }), + siteSettings.get( + "habbo_imaging_url", + "https://www.habbo.com/habbo-imaging/avatarimage", + ), + resolveHotelName(), + prisma.websiteSetting + .findUnique({ + where: { key: "referrals_needed" }, + select: { value: true }, + }) + .catch(() => null), + prisma.websiteSetting + .findUnique({ + where: { key: "referral_reward_amount" }, + select: { value: true }, + }) + .catch(() => null), + prisma.websiteSetting + .findFirst({ + where: { + key: { + in: ["referral_reward_currency_type", "referral_reward_currency"], + }, + }, + select: { value: true }, + }) + .catch(() => null), + siteSettings.get("hotel_alert_banner", ""), + prisma.rooms + .findMany({ + orderBy: { id: "desc" }, select: { id: true, - username: true, - look: true, - motto: true, - rank: true, - credits: true, - accountCreated: true, - lastLogin: true, - lastOnline: true, + name: true, + ownerName: true, + users: true, + usersMax: true, }, - }), - siteSettings.get( - "habbo_imaging_url", - "https://www.habbo.com/habbo-imaging/avatarimage", - ), - resolveHotelName(), - prisma.websiteSetting - .findUnique({ - where: { key: "referrals_needed" }, - select: { value: true }, - }) - .catch(() => null), - prisma.websiteSetting - .findUnique({ - where: { key: "referral_reward_amount" }, - select: { value: true }, - }) - .catch(() => null), - prisma.websiteSetting - .findFirst({ - where: { - key: { - in: [ - "referral_reward_currency_type", - "referral_reward_currency", - ], - }, - }, - select: { value: true }, - }) - .catch(() => null), - siteSettings.get("hotel_alert_banner", ""), - prisma.rooms - .findMany({ - orderBy: { id: "desc" }, - select: { id: true, name: true, ownerName: true, users: true, usersMax: true }, - take: 6, - }) - .catch(() => []), - prisma.usersBadges - .findMany({ - where: { userId, slotId: { gt: 0 } }, - orderBy: { slotId: "asc" }, - select: { badgeCode: true, slotId: true }, - }) - .catch(() => []), - prisma.websiteLoginLogs - .findFirst({ - where: { userId }, - orderBy: { id: "desc" }, - select: { createdAt: true }, - }) - .catch(() => null), - prisma.usersSettings - .findUnique({ - where: { userId }, - select: { achievementScore: true, respectsReceived: true }, - }) - .catch(() => null), - prisma.messengerFriendships - .count({ - where: { OR: [{ userOneId: userId }, { userTwoId: userId }] }, - }) - .catch(() => 0), - prisma.messengerOffline - .count({ where: { userId } }) - .catch(() => 0), - ]); + take: 6, + }) + .catch(() => []), + prisma.usersBadges + .findMany({ + where: { userId, slotId: { gt: 0 } }, + orderBy: { slotId: "asc" }, + select: { badgeCode: true, slotId: true }, + }) + .catch(() => []), + prisma.websiteLoginLogs + .findFirst({ + where: { userId }, + orderBy: { id: "desc" }, + select: { createdAt: true }, + }) + .catch(() => null), + prisma.usersSettings + .findUnique({ + where: { userId }, + select: { achievementScore: true, respectsReceived: true }, + }) + .catch(() => null), + prisma.messengerFriendships + .count({ + where: { OR: [{ userOneId: userId }, { userTwoId: userId }] }, + }) + .catch(() => 0), + prisma.messengerOffline.count({ where: { userId } }).catch(() => 0), + ]); + + void lastWebLogin; if (!user) throw new Error("user-not-found"); @@ -212,23 +228,23 @@ export default async function MePage({ const lastLoginDate = user.lastLogin ? new Date(user.lastLogin * 1000).toISOString().slice(0, 10) : "Never"; - const lastWebLoginDate = lastWebLogin?.createdAt - ? new Date(lastWebLogin.createdAt).toISOString().slice(0, 10) - : null; const lastOnlineDate = user.lastOnline ? new Date(user.lastOnline * 1000).toISOString().slice(0, 10) : "Never"; const referralLink = `/register?ref=${encodeURIComponent(user.username)}`; - const imager = imagerBase ?? "https://www.habbo.com/habbo-imaging/avatarimage"; - const avatarFull = avatarImageUrl(imager, user.look, { size: "l", direction: 2 }); - const avatarHead = avatarImageUrl(imager, user.look, { headOnly: true, direction: 2, size: "s" }); - + const imager = + imagerBase ?? "https://www.habbo.com/habbo-imaging/avatarimage"; + const avatarFull = avatarImageUrl(imager, user.look, { + size: "l", + direction: 2, + }); content = (
{claimed ? (
- Woah! You have successfully claimed your reward — keep up the good work! + Woah! You have successfully claimed your reward — keep up the good + work!
) : null} {error ? ( @@ -242,24 +258,47 @@ export default async function MePage({ className="relative overflow-hidden rounded-2xl border p-6 flex items-start gap-6" style={{ background: `linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 12%, var(--color-surface)), var(--color-surface))`, - borderColor: "color-mix(in srgb, var(--color-primary) 20%, transparent)", + borderColor: + "color-mix(in srgb, var(--color-primary) 20%, transparent)", }} > -
+
-
- +
+ - + Enter {hotelName}
@@ -267,25 +306,39 @@ export default async function MePage({
Rank {user.rank}
-

+

{user.username}

-

+

{user.motto || "No motto"}

- Registered: {registered} + Registered:{" "} + + {registered} + - Last online: {lastOnlineDate} + Last online:{" "} + + {lastOnlineDate} +
@@ -294,8 +347,21 @@ export default async function MePage({ {alertRaw ? ( -
-

{alertRaw}

+
+

+ {alertRaw} +

) : null} @@ -305,8 +371,14 @@ export default async function MePage({ {[ { value: user.credits.toLocaleString(), label: "Credits" }, { value: `Rank ${user.rank}`, label: "Hotel rank" }, - { value: (userSettings?.achievementScore ?? 0).toLocaleString(), label: "Achievement score" }, - { value: (userSettings?.respectsReceived ?? 0).toLocaleString(), label: "Respects" }, + { + value: (userSettings?.achievementScore ?? 0).toLocaleString(), + label: "Achievement score", + }, + { + value: (userSettings?.respectsReceived ?? 0).toLocaleString(), + label: "Respects", + }, { value: friendCount.toLocaleString(), label: "Friends" }, { value: lastLoginDate, label: "Last login" }, ].map((s) => ( @@ -315,13 +387,20 @@ export default async function MePage({ className="relative rounded-2xl border p-5 text-center transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg" style={{ background: "var(--color-surface)", - borderColor: "color-mix(in srgb, var(--color-primary) 12%, transparent)", + borderColor: + "color-mix(in srgb, var(--color-primary) 12%, transparent)", }} > -
+
{s.value}
-
+
{s.label}
@@ -330,19 +409,47 @@ export default async function MePage({ -
+
-

Online friends

-

- {onlineFriends.length === 0 ? "None of your friends are online right now" : `${onlineFriends.length} of your ${friends.length} friends online`} +

+ Online friends +

+

+ {onlineFriends.length === 0 + ? "None of your friends are online right now" + : `${onlineFriends.length} of your ${friends.length} friends online`}

- View all + + View all +
{onlineFriends.length === 0 ? (
-

No friends online right now. Check back later!

+

+ No friends online right now. Check back later! +

) : (
@@ -351,14 +458,45 @@ export default async function MePage({ key={friend.id} href={`/u/${friend.username}`} className="flex items-center gap-3 p-3 rounded-xl transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md" - style={{ background: "color-mix(in srgb, var(--color-primary) 4%, transparent)", textDecoration: "none" }} + style={{ + background: + "color-mix(in srgb, var(--color-primary) 4%, transparent)", + textDecoration: "none", + }} > - +
-

{friend.username}

-

{friend.motto || "No motto"}

- - Online +

+ {friend.username} +

+

+ {friend.motto || "No motto"} +

+ + {" "} + Online
@@ -369,41 +507,92 @@ export default async function MePage({ -
+
-

Refer a friend ({referralTotal}/{needed})

-

Refer new users and earn in-game rewards

+

+ Refer a friend ({referralTotal}/{needed}) +

+

+ Refer new users and earn in-game rewards +

-

+

{rewardAmount > 0 ? `For every ${needed} users who register through your referral link you can claim a reward of ${rewardAmount.toLocaleString()} ${rewardCurrency}!` : `Invite ${needed} users through your referral link to claim a reward!`}

- Boosting referrals by making your own accounts will lead to removal of all progress, currency, inventory and a potential ban. + Boosting referrals by making your own accounts will lead to + removal of all progress, currency, inventory and a potential + ban.
- +
-
{canClaim ? ( - ) : ( - @@ -414,22 +603,62 @@ export default async function MePage({ {recentRooms.length > 0 && ( -
+
-

Recent rooms

-

Newest rooms in the hotel

+

+ Recent rooms +

+

+ Newest rooms in the hotel +

- View all + + View all +
{recentRooms.map((room) => ( - -

{room.name}

-

{room.ownerName} · {room.users}/{room.usersMax}

+

+ {room.name} +

+

+ {room.ownerName} · {room.users}/{room.usersMax} +

))}
@@ -439,14 +668,39 @@ export default async function MePage({ {badges.length > 0 && ( -
+
-

Badges

-

{badges.length} badge{badges.length !== 1 ? "s" : ""} equipped

+

+ Badges +

+

+ {badges.length} badge{badges.length !== 1 ? "s" : ""} equipped +

{badges.map((badge) => ( - {badge.badgeCode} + {badge.badgeCode} ))}
@@ -454,8 +708,20 @@ export default async function MePage({ )} -
-

Quick links

+
+

+ Quick links +

{[ { href: "/messages", label: "Messages", badge: unreadCount }, @@ -463,13 +729,27 @@ export default async function MePage({ { href: "/settings", label: "Settings", badge: 0 }, { href: "/shop", label: "Shop", badge: 0 }, ].map((link) => ( - {link.label} {link.badge > 0 && ( - + {link.badge > 99 ? "99+" : link.badge} )} @@ -486,11 +766,16 @@ export default async function MePage({ className="rounded-2xl border p-6 text-center" style={{ background: "var(--color-surface)", - borderColor: "color-mix(in srgb, var(--color-text-muted) 10%, transparent)", + borderColor: + "color-mix(in srgb, var(--color-text-muted) 10%, transparent)", }} > -

- We couldn't load your dashboard right now. Please try again shortly. +

+ We couldn't load your dashboard right now. Please try again + shortly.

); diff --git a/src/app/(site)/messages/page.tsx b/src/app/(site)/messages/page.tsx index 8a30fa89..d743908f 100644 --- a/src/app/(site)/messages/page.tsx +++ b/src/app/(site)/messages/page.tsx @@ -10,8 +10,8 @@ import { import { ContentCard, EmptyState } from "@/components/public/ui"; import { auth } from "@/lib/auth"; import { avatarImageUrl } from "@/lib/format"; -import { prisma } from "@/lib/prisma"; import { formatDate } from "@/lib/format-date"; +import { prisma } from "@/lib/prisma"; import { siteSettings } from "@/lib/services/site-settings"; export const dynamic = "force-dynamic"; diff --git a/src/app/(site)/news/[slug]/page.tsx b/src/app/(site)/news/[slug]/page.tsx index 5d935f0a..c0669c14 100644 --- a/src/app/(site)/news/[slug]/page.tsx +++ b/src/app/(site)/news/[slug]/page.tsx @@ -16,7 +16,11 @@ import { sanitize } from "@/lib/sanitize"; export const revalidate = 60; -type SearchParams = Promise<{ comment?: string; reaction?: string; error?: string }>; +type SearchParams = Promise<{ + comment?: string; + reaction?: string; + error?: string; +}>; function feedbackStyle(tone: "success" | "error"): CSSProperties { const accent = @@ -78,7 +82,6 @@ const REACTIONS: { key: string; emoji: string }[] = [ { key: "wow", emoji: "😮" }, ]; - export default async function ArticlePage({ params, searchParams, @@ -204,9 +207,7 @@ export default async function ArticlePage({ icon="📰" title={article.title} subtitle={ - article.createdAt - ? formatDate(article.createdAt, "date") - : undefined + article.createdAt ? formatDate(article.createdAt, "date") : undefined } action={ @@ -222,8 +223,11 @@ export default async function ArticlePage({ style={{ width: "100%", borderRadius: 10, margin: "0 0 1rem" }} /> ) : null} - {/* Article body is rich HTML (atom uses TinyMCE) — sanitised server-side. */} - + {/* Article body is rich HTML (atom uses TinyMCE) — sanitised server-side. */} + {/* ── Reactions ─────────────────────────────────────────── */} diff --git a/src/app/(site)/news/page.tsx b/src/app/(site)/news/page.tsx index 96c2297f..68dd07be 100644 --- a/src/app/(site)/news/page.tsx +++ b/src/app/(site)/news/page.tsx @@ -1,16 +1,15 @@ import Image from "next/image"; -import { formatDate } from "@/lib/format-date"; import Link from "next/link"; import { getTranslations } from "next-intl/server"; import { ContentCard, EmptyState } from "@/components/public/ui"; import { excerpt } from "@/lib/format"; +import { formatDate } from "@/lib/format-date"; import { prisma } from "@/lib/prisma"; export const revalidate = 300; export const metadata = { title: "News" }; - export default async function NewsPage() { const t = await getTranslations("pages.news"); diff --git a/src/app/(site)/page.tsx b/src/app/(site)/page.tsx index 8cbc7070..ffa578cc 100644 --- a/src/app/(site)/page.tsx +++ b/src/app/(site)/page.tsx @@ -1,24 +1,24 @@ +import { headers } from "next/headers"; import Image from "next/image"; import Link from "next/link"; import { redirect } from "next/navigation"; -import { headers } from "next/headers"; import { getTranslations } from "next-intl/server"; +import { AmbientOrbs } from "@/components/ambient-orbs"; +import { AnimatedCounter } from "@/components/animated-counter"; +import { HomeLoginForm } from "@/components/auth/home-login-form"; +import { Clock } from "@/components/clock"; +import { LanguageSwitcher } from "@/components/language-switcher"; +import { Reveal } from "@/components/motion-reveal"; +import { ThemeSwitcher } from "@/components/theme-switcher"; +import { TypewriterText } from "@/components/typewriter-text"; +import { auth } from "@/lib/auth"; import { cached } from "@/lib/cache"; import { avatarImageUrl } from "@/lib/format"; import { formatDate } from "@/lib/format-date"; -import { prisma } from "@/lib/prisma"; import { resolveHotelName } from "@/lib/hotel-name"; -import { siteSettings } from "@/lib/services/site-settings"; +import { prisma } from "@/lib/prisma"; import { captchaConfig } from "@/lib/services/captcha"; -import { auth } from "@/lib/auth"; -import { ThemeSwitcher } from "@/components/theme-switcher"; -import { LanguageSwitcher } from "@/components/language-switcher"; -import { HomeLoginForm } from "@/components/auth/home-login-form"; -import { Reveal } from "@/components/motion-reveal"; -import { Clock } from "@/components/clock"; -import { TypewriterText } from "@/components/typewriter-text"; -import { AnimatedCounter } from "@/components/animated-counter"; -import { AmbientOrbs } from "@/components/ambient-orbs"; +import { siteSettings } from "@/lib/services/site-settings"; export const revalidate = 120; @@ -33,38 +33,54 @@ async function getHotelData() { ]); const imager = imagerRaw ?? "https://www.habbo.com/habbo-imaging/avatarimage"; - const [online, users, rooms, articles, recentUsers, recentPhotos, latestUsers] = - await Promise.all([ - cached("online_count", 10_000, () => - prisma.user.count({ where: { online: "1" } }), - ).catch(() => 0), - cached("total_users", 30_000, () => prisma.user.count()).catch(() => 0), - cached("total_rooms", 30_000, () => prisma.rooms.count()).catch(() => 0), - prisma.websiteArticles - .findMany({ orderBy: { createdAt: "desc" }, take: 5 }) - .catch(() => []), - prisma.user - .findMany({ - where: { online: "1" }, - select: { username: true, look: true }, - take: 30, - }) - .catch(() => []), - prisma.cameraWeb - .findMany({ orderBy: { timestamp: "desc" }, take: 4 }) - .catch(() => []), - prisma.user - .findMany({ - orderBy: { accountCreated: "desc" }, - select: { username: true, look: true }, - take: 8, - }) - .catch(() => []), - ]); + const [ + online, + users, + rooms, + articles, + recentUsers, + recentPhotos, + latestUsers, + ] = await Promise.all([ + cached("online_count", 10_000, () => + prisma.user.count({ where: { online: "1" } }), + ).catch(() => 0), + cached("total_users", 30_000, () => prisma.user.count()).catch(() => 0), + cached("total_rooms", 30_000, () => prisma.rooms.count()).catch(() => 0), + prisma.websiteArticles + .findMany({ orderBy: { createdAt: "desc" }, take: 5 }) + .catch(() => []), + prisma.user + .findMany({ + where: { online: "1" }, + select: { username: true, look: true }, + take: 30, + }) + .catch(() => []), + prisma.cameraWeb + .findMany({ orderBy: { timestamp: "desc" }, take: 4 }) + .catch(() => []), + prisma.user + .findMany({ + orderBy: { accountCreated: "desc" }, + select: { username: true, look: true }, + take: 8, + }) + .catch(() => []), + ]); - const discordUrl = await siteSettings.get("radio_social_discord", ""); - - return { hotelName, imager, online, users, rooms, articles, recentUsers, recentPhotos, latestUsers, discordUrl, logo }; + return { + hotelName, + imager, + online, + users, + rooms, + articles, + recentUsers, + recentPhotos, + latestUsers, + logo, + }; } export default async function Home() { @@ -73,40 +89,88 @@ export default async function Home() { const tp = await getTranslations("pages.home"); const th = await getTranslations("header"); - const { hotelName, imager, online, users, rooms, articles, recentUsers, recentPhotos, latestUsers, discordUrl, logo } = await getHotelData(); + const { + hotelName, + imager, + online, + users, + rooms, + articles, + recentUsers, + recentPhotos, + latestUsers, + logo, + } = await getHotelData(); const captcha = await captchaConfig(); const nonce = (await headers()).get("x-nonce") ?? undefined; return (
- {/* ── Top Bar ── */} -
+
{logo ? ( - {hotelName} + {hotelName} ) : ( - {hotelName} + {hotelName} )} - + {hotelName}
-
- +
-
+
-
+
@@ -119,23 +183,30 @@ export default async function Home() { style={{ background: `url(/assets/images/next_header.jpg) center/cover no-repeat`, backgroundPosition: "center 20%", - borderColor: "color-mix(in srgb, var(--color-text-muted) 8%, transparent)", + borderColor: + "color-mix(in srgb, var(--color-text-muted) 8%, transparent)", }} >
-
+
@@ -147,7 +218,10 @@ export default async function Home() {

@@ -164,19 +238,28 @@ export default async function Home() { style={{ background: "var(--color-primary)", color: "var(--color-primary-foreground)", - boxShadow: "0 4px 24px color-mix(in srgb, var(--color-primary) 35%, transparent)", + boxShadow: + "0 4px 24px color-mix(in srgb, var(--color-primary) 35%, transparent)", }} > - + {tp("register")} {th("login")} @@ -206,18 +289,32 @@ export default async function Home() {
{[ - { value: online, label: tp("statsOnline"), icon: "/assets/images/icons/online-friends.png" }, - { value: users, label: tp("statsCitizens"), icon: "/assets/images/icons/friends.png" }, - { value: rooms, label: tp("statsRooms"), icon: "/assets/images/icons/catalog.png" }, + { + value: online, + label: tp("statsOnline"), + icon: "/assets/images/icons/online-friends.png", + }, + { + value: users, + label: tp("statsCitizens"), + icon: "/assets/images/icons/friends.png", + }, + { + value: rooms, + label: tp("statsRooms"), + icon: "/assets/images/icons/catalog.png", + }, ].map((s) => (
- +
-
+
-
+
{s.label}
@@ -245,59 +357,122 @@ export default async function Home() { {/* ── Login + News ── */}
- {/* Left: Login & Register */}
- -

+ +

Login

-
- +
+
-
- Register +
+ Register
- + {tp("register")} {latestUsers.length > 0 && ( -
+
- -

+ +

{tp("latestUsers", { count: users.toLocaleString() })}

{latestUsers.map((u) => ( -
+
{u.username}
- + {u.username}
@@ -317,20 +495,40 @@ export default async function Home() { {/* Right: News */} -
+
- -

+ +

{tp("latestNews")}

- + {tp("allNews")}
@@ -345,7 +543,8 @@ export default async function Home() { style={{ height: "200px", backgroundColor: "var(--color-background)", - border: "1px solid color-mix(in srgb, var(--color-text-muted) 10%, transparent)", + border: + "1px solid color-mix(in srgb, var(--color-text-muted) 10%, transparent)", }} >
@@ -356,22 +555,26 @@ export default async function Home() { className="rounded-lg object-cover transition-all duration-300 group-hover:scale-105" unoptimized /> -
- → +
+ + → +

{a.title}

-

+

{formatDate(a.createdAt, "date", "")}

@@ -380,7 +583,10 @@ export default async function Home() { ))}
) : ( -

+

{tp("noArticles")}

)} @@ -392,20 +598,40 @@ export default async function Home() { {/* ── Online users ── */} {recentUsers.length > 0 && ( -
+
- -

+ +

{tp("recentUsers")}

- + {tp("allUsers")}
@@ -415,17 +641,27 @@ export default async function Home() {
{u.username} - + {u.username}
@@ -442,13 +678,23 @@ export default async function Home() { className="relative overflow-hidden rounded-xl border text-center px-6 py-10" style={{ background: `linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 8%, var(--color-surface)), color-mix(in srgb, var(--color-surface) 70%, transparent))`, - borderColor: "color-mix(in srgb, var(--color-primary) 15%, transparent)", + borderColor: + "color-mix(in srgb, var(--color-primary) 15%, transparent)", }} > -

+

{tp("joinNow", { hotelName })}

-

+

{tp("welcomeBody")}

- + {tp("register")}
@@ -469,22 +722,47 @@ export default async function Home() { {/* ── Recent photos ── */} {recentPhotos.length > 0 && ( -
+
- -

+ +

{tp("recentPhotos")}

{recentPhotos.slice(0, 4).map((p) => ( - + now && poll.status === "active") { + if ( + poll.startsAt && + poll.startsAt.getTime() > now && + poll.status === "active" + ) { // Not started yet — treat as unavailable for guests notFound(); } @@ -101,7 +105,11 @@ export default async function PollDetailPage({ {isOpen && !hasVoted ? ( session?.user ? ( - + ({ @@ -156,7 +164,10 @@ export default async function PollDetailPage({ const counts = new Map(); for (const opt of options) counts.set(opt, 0); for (const vote of votes) { - for (const part of vote.split("\n").map((a) => a.trim()).filter(Boolean)) { + for (const part of vote + .split("\n") + .map((a) => a.trim()) + .filter(Boolean)) { counts.set(part, (counts.get(part) ?? 0) + 1); } } diff --git a/src/app/(site)/polls/[id]/poll-vote-form.tsx b/src/app/(site)/polls/[id]/poll-vote-form.tsx index dfaea23e..d3681d2c 100644 --- a/src/app/(site)/polls/[id]/poll-vote-form.tsx +++ b/src/app/(site)/polls/[id]/poll-vote-form.tsx @@ -99,10 +99,16 @@ export function PollVoteForm({ value={answers[q.id] ?? ""} onChange={(e) => setSingle(q.id, e.target.value)} placeholder={t("textPlaceholder")} - style={{ width: "100%", marginTop: "0.35rem", resize: "vertical" }} + style={{ + width: "100%", + marginTop: "0.35rem", + resize: "vertical", + }} /> ) : q.type === "multiple" ? ( -
+
{options.map((opt) => (