latest changes

This commit is contained in:
remco committed 2026-07-01 15:06:52 +02:00
1 parent 56cd6fd058
commit 1de72863db
40 files changed
+3835 -566

No files matched your search

-65
View File
@@ -1,65 +0,0 @@
# Connection to the LIVE/COPY emulator MySQL/MariaDB database.
# The schema is owned by the Arcturus emulator — this app reads/writes data,
# it does NOT own or migrate the emulator tables. Format:
DATABASE_URL=mysql://user:[email protected]:3306/atomcms
# Optional pool tuning (defaults shown)
DATABASE_POOL_SIZE=40
DATABASE_IDLE_TIMEOUT_MS=300000
DATABASE_CONNECT_TIMEOUT_MS=10000
# Used by SSO ticket generation ({HOTEL_NAME}-{uuid})
HOTEL_NAME=Atom
APP_URL=http://localhost:3000
# NextAuth (>=32 chars) + Laravel APP_KEY (base64:...) for existing 2FA secrets
AUTH_SECRET=
APP_KEY=
CONVERT_PASSWORDS=false
# Password hashing for NEW/upgraded passwords: "bcrypt" (default; 60-char $2y$,
# fits a varchar(64) users.password) or "argon2id" (~97 chars, needs a wider
# column). Existing accounts in either format still verify on login.
PASSWORD_HASH=bcrypt
# Filesystem directory the badge uploader (/admin/badges) writes <code>.gif into
# — the emulator's badge image folder (e.g. .../assets/c_images/album1584).
# Leave unset to disable badge uploads.
BADGE_UPLOAD_DIR=
# Emulator JAR backup job (jobs-worker, runs host-side). When both are set, the
# worker copies the JAR daily into the backup dir, keeping the newest N.
EMULATOR_JAR_PATH=
EMULATOR_BACKUP_DIR=
EMULATOR_BACKUP_KEEP=7
# RCON link to the Arcturus emulator
RCON_HOST=127.0.0.1
RCON_PORT=3001
# Optional OAuth (enabled when both id+secret are set)
DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# Optional SMTP (password reset / alert emails)
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASSWORD=
SMTP_FROM=
# Optional alerting (jobs worker / alert service)
DISCORD_WEBHOOK_URL=
ALERT_EMAIL=
# Optional AI content moderation (user comments / guestbook).
# When set, posts are checked against the OpenAI Moderations endpoint in
# addition to the website_wordfilter blocklist. Fail-open if unset/erroring.
OPENAI_API_KEY=
# Optional PayPal top-up (sandbox by default)
PAYPAL_CLIENT_ID=
PAYPAL_SECRET=
PAYPAL_API=https://api-m.sandbox.paypal.com
+1 -1
View File
@@ -1564,7 +1564,7 @@ model WebsiteArticles {
title String @db.VarChar(255)
shortStory String @map("short_story") @db.VarChar(255)
fullStory String @map("full_story") @db.Text
userId String @map("user_id") @db.VarChar(255)
userId Int? @map("user_id")
image String @db.VarChar(255)
createdAt DateTime? @map("created_at") @db.Timestamp(0)
updatedAt DateTime? @map("updated_at") @db.Timestamp(0)
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 279 B

After

Width:  |  Height:  |  Size: 123 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 491 B

After

Width:  |  Height:  |  Size: 150 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 485 B

After

Width:  |  Height:  |  Size: 116 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 558 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 824 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

+6 -3
View File
@@ -10,7 +10,7 @@ async function uniqueSlug(title: string): Promise<string> {
const base = slugify(title);
let slug = base;
let n = 2;
while (await prisma.websiteArticles.findUnique({ where: { slug } })) {
while (await prisma.websiteArticles.findUnique({ where: { slug }, select: { id: true } })) {
slug = `${base}-${n++}`;
}
return slug;
@@ -32,7 +32,7 @@ export async function createArticle(formData: FormData): Promise<void> {
shortStory: shortStory.slice(0, 255),
fullStory,
image: image.slice(0, 255),
userId: String(staff.id),
userId: staff.id,
createdAt: now,
updatedAt: now,
},
@@ -59,6 +59,9 @@ export async function updateArticle(formData: FormData): Promise<void> {
export async function deleteArticle(formData: FormData): Promise<void> {
await requireStaff();
await prisma.websiteArticles.delete({ where: { id: BigInt(String(formData.get("id"))) } });
const id = BigInt(String(formData.get("id")));
await prisma.websiteArticleReactions.deleteMany({ where: { articleId: id } });
await prisma.websiteArticleComments.deleteMany({ where: { articleId: id } });
await prisma.websiteArticles.delete({ where: { id } });
redirect("/admin/articles");
}
+5 -7
View File
@@ -12,12 +12,10 @@ export default async function EditArticle({
params: Promise<{ id: string }>;
}) {
const { id } = await params;
let article: Awaited<ReturnType<typeof prisma.websiteArticles.findUnique>> = null;
try {
article = await prisma.websiteArticles.findUnique({ where: { id: BigInt(id) } });
} catch {
notFound();
}
const article = await prisma.websiteArticles.findUnique({
where: { id: BigInt(id) },
select: { id: true, slug: true, title: true, shortStory: true, fullStory: true, image: true, createdAt: true, updatedAt: true },
}).catch(() => null);
if (!article) notFound();
return (
@@ -36,7 +34,7 @@ export default async function EditArticle({
</form>
<form action={deleteArticle} style={{ marginTop: "1rem" }}>
<input type="hidden" name="id" value={String(article.id)} />
<button type="submit">Delete article</button>
<button type="submit" className="btn btn-primary">Delete article</button>
</form>
</main>
);
+22 -3
View File
@@ -1,12 +1,11 @@
import Link from "next/link";
import { StatusCard } from "@/components/admin/dashboard";
import { deleteArticle } from "@/actions/admin-articles";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
export default async function AdminArticles() {
// .catch(() => []) keeps the precise selected-row type while falling back to
// an empty list when the DB is unavailable (instead of a 500).
const articles = await prisma.websiteArticles
.findMany({
orderBy: { createdAt: "desc" },
@@ -47,13 +46,16 @@ export default async function AdminArticles() {
<th>Title</th>
<th>Slug</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{articles.map((a) => (
<tr key={String(a.id)}>
<td>
<Link href={`/admin/articles/${a.id}`}>{a.title}</Link>
<Link href={`/admin/articles/${a.id}`} style={{ color: "var(--link-color)" }}>
{a.title}
</Link>
</td>
<td className="muted">
<code>{a.slug}</code>
@@ -61,6 +63,23 @@ export default async function AdminArticles() {
<td className="muted" style={{ whiteSpace: "nowrap" }}>
{a.createdAt ? a.createdAt.toISOString().slice(0, 10) : "—"}
</td>
<td>
<div style={{ display: "flex", gap: "0.5rem" }}>
<Link href={`/admin/articles/${a.id}`} className="btn btn-primary" style={{ fontSize: "0.8rem", padding: "0.3rem 0.6rem" }}>
Edit
</Link>
<form action={deleteArticle} style={{ margin: 0 }}>
<input type="hidden" name="id" value={String(a.id)} />
<button
type="submit"
className="btn btn-danger"
style={{ fontSize: "0.8rem", padding: "0.3rem 0.6rem" }}
>
Delete
</button>
</form>
</div>
</td>
</tr>
))}
</tbody>
+4 -4
View File
@@ -58,20 +58,20 @@ export default async function AdminDashboard() {
value={online}
hint={dbOk ? `${users} accounts` : "DB unavailable"}
state={dbOk ? "neutral" : "danger"}
icon="\u{1F464}"
icon="👤"
/>
<StatusCard label="Articles" value={dbOk ? articles : "—"} icon="\u{1F4F0}" />
<StatusCard label="Articles" value={dbOk ? articles : "—"} icon="📰" />
<StatusCard
label="Active bans"
value={dbOk ? bans : "—"}
state={bans > 0 ? "warn" : "neutral"}
icon="\u{1F528}"
icon="🔧"
/>
<StatusCard
label="Database"
value={dbOk ? "Connected" : "Down"}
state={dbOk ? "ok" : "danger"}
icon="\u{1F5C4}️"
icon="🗄️"
/>
</div>
+28
View File
@@ -0,0 +1,28 @@
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
import { issueSsoTicket } from "@/lib/auth/sso-ticket";
export const dynamic = "force-dynamic";
export async function GET() {
const session = await auth();
if (!session?.user?.id) {
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 });
}
const userId = Number(session.user.id);
const [hotelName, clientUrl] = await Promise.all([
siteSettings.get("hotel_name", "Atom"),
siteSettings.get("nitro_client_url", ""),
]);
const hdrs = await headers();
const ip =
hdrs.get("x-forwarded-for")?.split(",")[0]?.trim() ?? hdrs.get("x-real-ip") ?? "0.0.0.0";
const ticket = await issueSsoTicket(prisma, userId, hotelName ?? "Atom", ip);
return Response.json({ ticket, hotelName: hotelName ?? "Atom", clientUrl });
}
-100
View File
@@ -1,100 +0,0 @@
import Link from "next/link";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { ContentCard, EmptyState } from "@/components/public/ui";
import { issueSsoTicket } from "@/lib/auth/sso-ticket";
import { buildNitroClientUrl } from "@/lib/services/clients";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
export const dynamic = "force-dynamic";
export default async function FlashClientPage() {
const session = await auth();
if (!session?.user?.id) redirect("/login");
const userId = Number(session.user.id);
// The Flash client base URL may be stored under either settings key.
const [hotelNameRaw, flashUrl, flashUrlAlt] = await Promise.all([
siteSettings.get("hotel_name", "Atom").catch(() => "Atom"),
siteSettings.get("flash_client_url", "").catch(() => ""),
siteSettings.get("client_flash_url", "").catch(() => ""),
]);
const hotelName = hotelNameRaw ?? "Atom";
const hdrs = await headers();
const ip =
hdrs.get("x-forwarded-for")?.split(",")[0]?.trim() ?? hdrs.get("x-real-ip") ?? "0.0.0.0";
// Faithful to AtomCMS's client controllers: issue (and persist) the SSO ticket
// on the launcher render, then hand it to the Flash client.
const ticket = await issueSsoTicket(prisma, userId, hotelName, ip);
// Use a configured Flash base URL (with the SSO ticket appended); otherwise
// fall back to the Nitro client so the launcher still works.
const flashBase = flashUrl || flashUrlAlt || "";
const flashSrc = flashBase
? `${flashBase}${flashBase.includes("?") ? "&" : "?"}sso=${encodeURIComponent(ticket)}`
: "";
const src = flashSrc || (await buildNitroClientUrl(ticket));
return (
<main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard
icon="🎮"
title={`Launching ${hotelName ?? "Atom"} (Flash)…`}
subtitle="Your single sign-on ticket is ready"
action={
<Link href="/client" className="btn btn-outline">
Use Nitro client
</Link>
}
/>
{src ? (
<>
<ContentCard
icon="🚀"
title="Open the Flash client"
subtitle="Flash works best in its own tab"
action={
<a
href={src}
target="_blank"
rel="noreferrer"
className="btn btn-primary"
>
Launch in a new tab
</a>
}
/>
<ContentCard padded={false}>
<iframe
title="Flash hotel client"
src={src}
style={{
width: "100%",
height: "80vh",
border: 0,
borderRadius: 10,
display: "block",
}}
/>
</ContentCard>
</>
) : (
<ContentCard
icon="🔌"
title="No Flash client configured"
subtitle="Set flash_client_url in settings"
>
<EmptyState icon="🔌">
No Flash client URL configured (set <code>flash_client_url</code> in settings). SSO
ticket issued for this session: <code>{ticket}</code>
</EmptyState>
</ContentCard>
)}
</main>
);
}
+99 -49
View File
File diff suppressed because it is too large. Load diff
+3
View File
@@ -197,6 +197,9 @@ body {
.text-shadow {
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.8);
}
.text-shadow-sm {
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);
}
.transition-base {
transition: all 0.2s ease-in-out;
}
+4 -6
View File
@@ -45,12 +45,10 @@ export default async function ArticlePage({
const { slug } = await params;
const t = await getTranslations("pages.article");
let article: Awaited<ReturnType<typeof prisma.websiteArticles.findUnique>> = null;
try {
article = await prisma.websiteArticles.findUnique({ where: { slug } });
} catch {
article = null;
}
const article = await prisma.websiteArticles.findUnique({
where: { slug },
select: { id: true, slug: true, title: true, shortStory: true, fullStory: true, image: true, createdAt: true, updatedAt: true },
}).catch(() => null);
if (!article) notFound();
const articleId = article.id;
+310 -70
View File
File diff suppressed because it is too large. Load diff
+26 -47
View File
@@ -1,8 +1,6 @@
import Script from "next/script";
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { register } from "@/actions/register";
import { ContentCard } from "@/components/public/ui";
import { siteSettings } from "@/lib/services/site-settings";
import { RegisterForm } from "@/components/auth/register-form";
import { captchaConfig } from "@/lib/services/captcha";
export const dynamic = "force-dynamic";
@@ -14,51 +12,32 @@ export default async function RegisterPage({
}) {
const t = await getTranslations("pages.register");
const { error } = await searchParams;
const captcha = await captchaConfig();
const showCaptcha = captcha.provider !== "none" && !!captcha.siteKey;
const [hotelName, imager, captcha, femaleFigures, maleFigures] = await Promise.all([
siteSettings.get("hotel_name", "Atom"),
siteSettings.get("habbo_imaging_url", "https://www.habbo.com/habbo-imaging/avatarimage"),
captchaConfig(),
siteSettings.get("register_female_figures", "hr-100-45.hd-180-1.ch-215-62.lg-270-62.sh-290-62,hr-130-45.hd-180-2.ch-220-62.lg-280-62.sh-305-62,hr-140-45.hd-180-3.ch-225-62.lg-270-62.sh-310-62,hr-165-45.hd-180-1.ch-210-62.lg-270-62.sh-290-62,hr-190-45.hd-180-2.ch-215-62.lg-270-62.sh-305-62"),
siteSettings.get("register_male_figures", "hr-100-61.hd-180-1.ch-210-66.lg-270-110.sh-305-62,hr-105-61.hd-190-2.ch-215-66.lg-280-110.sh-310-62,hr-110-61.hd-200-3.ch-220-66.lg-290-110.sh-320-62,hr-115-61.hd-185-1.ch-210-66.lg-275-110.sh-315-62,hr-120-61.hd-180-2.ch-205-66.lg-285-110.sh-305-62"),
]);
const allFigures = [
...(femaleFigures?.split(",").filter(Boolean) || []),
...(maleFigures?.split(",").filter(Boolean) || []),
];
return (
<main style={{ maxWidth: 420, margin: "2rem auto" }}>
<ContentCard
icon="🎉"
title={t("title")}
subtitle={t("subtitle")}
>
<form action={register} style={{ display: "grid", gap: "0.7rem" }}>
<input name="username" placeholder={t("usernamePlaceholder")} autoComplete="username" required />
<input name="mail" type="email" placeholder={t("emailPlaceholder")} autoComplete="email" required />
<input
name="password"
type="password"
placeholder={t("passwordPlaceholder")}
autoComplete="new-password"
required
/>
{showCaptcha && captcha.provider === "turnstile" ? (
<div className="cf-turnstile" data-sitekey={captcha.siteKey} />
) : null}
{showCaptcha && captcha.provider === "recaptcha" ? (
<div className="g-recaptcha" data-sitekey={captcha.siteKey} />
) : null}
<button type="submit" className="btn btn-primary">
{t("register")}
</button>
</form>
{showCaptcha && captcha.provider === "turnstile" ? (
<Script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer />
) : null}
{showCaptcha && captcha.provider === "recaptcha" ? (
<Script src="https://www.google.com/recaptcha/api.js" async defer />
) : null}
{error ? (
<p style={{ color: "var(--color-danger)", textAlign: "center", marginBottom: 0 }}>
{error}
</p>
) : null}
<p className="muted" style={{ textAlign: "center", marginBottom: 0, marginTop: "1rem" }}>
{t("alreadyHaveAccount")} <Link href="/login">{t("signIn")}</Link>
</p>
</ContentCard>
<main className="flex min-h-[calc(100vh-200px)] items-center justify-center px-4 py-8">
<div className="w-full">
<h1 className="sr-only">{t("title")}</h1>
<RegisterForm
initialFigures={allFigures}
imager={imager ?? ""}
hotelName={hotelName ?? "Atom"}
captcha={captcha}
error={error}
/>
</div>
</main>
);
}
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
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -4,7 +4,7 @@ import { getRequestConfig } from "next-intl/server";
// i18n WITHOUT routing: the locale is chosen by a `NEXT_LOCALE` cookie (set by
// the language switcher) rather than a URL segment, so every existing route and
// the access-guard middleware keep working unchanged. English is the fallback.
export const SUPPORTED_LOCALES = ["en", "it"] as const;
export const SUPPORTED_LOCALES = ["en", "it", "nl", "de", "fr", "es"] as const;
export type AppLocale = (typeof SUPPORTED_LOCALES)[number];
export const DEFAULT_LOCALE: AppLocale = "en";
-39
View File
@@ -1,39 +0,0 @@
import { siteSettings } from "./site-settings";
/**
* Builds the Nitro client launch URL, faithful to AtomCMS's NitroController +
* nitro.blade.php:
*
* {nitro_path}/index.html?sso={ticket}&toolbar_primary=…&toolbar_hover=…
* &toolbar_border=…&toolbar_text=…
*
* The client files are served by the web root at `nitro_path` (e.g.
* `/Nitro-V3/dist`). Returns "" when no `nitro_path` is configured.
*/
export async function buildNitroClientUrl(ticket: string): Promise<string> {
const [nitroPathRaw, tPrimary, tHover, tBorder, tText, bPrimary, bHover, bBorder, bText] =
await Promise.all([
siteSettings.get("nitro_path", ""),
siteSettings.get("toolbar_primary_color", ""),
siteSettings.get("toolbar_hover_color", ""),
siteSettings.get("toolbar_border_color", ""),
siteSettings.get("toolbar_text_color", ""),
siteSettings.get("button_primary_color", ""),
siteSettings.get("button_hover_color", ""),
siteSettings.get("button_border_color", ""),
siteSettings.get("button_text_color", ""),
]);
const nitroPath = (nitroPathRaw ?? "").trim().replace(/\/+$/, "");
if (!nitroPath) return "";
// Same fallbacks as the blade: toolbar_* color, else button_* color, else the
// atom defaults. URLSearchParams encodes "#" → "%23" so the query is valid.
const params = new URLSearchParams({ sso: ticket });
params.set("toolbar_primary", tPrimary || bPrimary || "#eeb425");
params.set("toolbar_hover", tHover || bHover || "#cf9d15");
params.set("toolbar_border", tBorder || bBorder || "#cf9d15");
params.set("toolbar_text", tText || bText || "#1a1a2e");
return `${nitroPath}/index.html?${params.toString()}`;
}
+2 -2
View File
@@ -4,9 +4,9 @@ import { prisma } from "@/lib/prisma";
// so the app still renders (e.g. local dev without a DATABASE_URL).
const DEFAULTS: Record<string, string> = {
hotel_name: "Atom",
habbo_imaging_url: "https://www.habbo.com/habbo-imaging/avatarimage",
habbo_imaging_url: "https://epicnabbo.nl/imaging/",
logo_url: "",
nitro_path: "",
nitro_client_url: "",
};
/**
File diff suppressed because it is too large. Load diff
+27 -3
View File
@@ -121,11 +121,17 @@
"welcomeSubtitle": "Your retro hotel, now powered by Next.js",
"welcomeBody": "Hang out, collect furni, and meet friends.",
"enterHotel": "Enter hotel",
"register": "REGISTER NOW",
"joinNow": "Join {hotelName} today!",
"recentUsers": "Recent Users",
"recentPhotos": "Recent Photos",
"latestNews": "Latest news",
"newsTitle": "Latest news",
"newsSubtitle": "Fresh from the hotel",
"allNews": "All news →",
"noArticles": "No articles yet."
"noArticles": "No articles yet.",
"noPhotos": "No photos yet.",
"noUsers": "No users online"
},
"news": {
"title": "News",
@@ -565,12 +571,30 @@
"register": {
"title": "Create account",
"subtitle": "Join the hotel — it only takes a moment.",
"username": "Username",
"email": "Email",
"password": "Password",
"confirmPassword": "Repeat password",
"usernamePlaceholder": "Username",
"emailPlaceholder": "Email",
"passwordPlaceholder": "Password (min 6 chars)",
"confirmPasswordPlaceholder": "Repeat password",
"selectOutfit": "Select your outfit",
"termsAccept": "I am 18+ and accept the {hotel} terms & rules.",
"createAccount": "Create account",
"creatingAccount": "Creating account...",
"alreadyHaveAccount": "Already have an account? Login!",
"register": "Register",
"alreadyHaveAccount": "Already have an account?",
"signIn": "Sign in"
"termsError": "You must accept the terms & rules to register.",
"usernameError": "Username is required.",
"emailError": "Valid email is required.",
"passwordError": "Password must be at least 6 characters.",
"confirmPasswordError": "Passwords do not match.",
"termsRequired": "You must accept the terms & rules.",
"usernameRequired": "Username is required.",
"emailRequired": "Email is required.",
"passwordRequired": "Password is required.",
"passwordsDontMatch": "Passwords do not match."
},
"forgot": {
"title": "Reset password",
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+27 -3
View File
@@ -121,11 +121,17 @@
"welcomeSubtitle": "Il tuo hotel retrò, ora con tecnologia Next.js",
"welcomeBody": "Rilassati, colleziona furni e fai nuove amicizie.",
"enterHotel": "Entra nell'hotel",
"register": "REGISTRATI ORA",
"joinNow": "Unisciti a {hotelName} oggi!",
"recentUsers": "Utenti recenti",
"recentPhotos": "Foto recenti",
"latestNews": "Ultime notizie",
"newsTitle": "Ultime notizie",
"newsSubtitle": "Appena arrivate dall'hotel",
"allNews": "Tutte le notizie →",
"noArticles": "Ancora nessun articolo."
"noArticles": "Ancora nessun articolo.",
"noPhotos": "Ancora nessuna foto.",
"noUsers": "Nessun utente online"
},
"news": {
"title": "Notizie",
@@ -565,12 +571,30 @@
"register": {
"title": "Crea un account",
"subtitle": "Entra nell'hotel — bastano pochi istanti.",
"username": "Nome utente",
"email": "Email",
"password": "Password",
"confirmPassword": "Ripeti la password",
"usernamePlaceholder": "Nome utente",
"emailPlaceholder": "Email",
"passwordPlaceholder": "Password (min 6 caratteri)",
"confirmPasswordPlaceholder": "Ripeti la password",
"selectOutfit": "Scegli il tuo outfit",
"termsAccept": "Ho 18+ anni e accetto i termini e regole di {hotel}.",
"createAccount": "Crea account",
"creatingAccount": "Creazione account...",
"alreadyHaveAccount": "Hai già un account? Accedi!",
"register": "Registrati",
"alreadyHaveAccount": "Hai già un account?",
"signIn": "Accedi"
"termsError": "Devi accettare i termini e regole per registrarti.",
"usernameError": "Il nome utente è obbligatorio.",
"emailError": "Email valida obbligatoria.",
"passwordError": "La password deve essere di almeno 6 caratteri.",
"confirmPasswordError": "Le password non coincidono.",
"termsRequired": "Devi accettare i termini e le regole.",
"usernameRequired": "Il nome utente è obbligatorio.",
"emailRequired": "Email è obbligatoria.",
"passwordRequired": "La password è obbligatoria.",
"passwordsDontMatch": "Le password non coincidono."
},
"forgot": {
"title": "Reimposta password",
File diff suppressed because it is too large. Load diff