diff --git a/app/(creator)/actions.ts b/app/(creator)/actions.ts
index 7cb7da3..47ff9b1 100644
--- a/app/(creator)/actions.ts
+++ b/app/(creator)/actions.ts
@@ -1,5 +1,6 @@
"use server";
+import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { getServerAuthSession } from "@/lib/auth";
@@ -12,6 +13,7 @@ import {
renameOrganization,
revokeOrganizationInviteLink,
} from "@/lib/organizations";
+import { updateProfileSettings } from "@/lib/users";
import { parseWorkspaceValue, setActiveWorkspaceCookie, getActiveWorkspaceForUser } from "@/lib/workspaces";
async function requireSessionUser() {
@@ -24,15 +26,31 @@ async function requireSessionUser() {
return session.user.id;
}
-function settingsRedirectTarget(formData: FormData, fallbackOrganizationId?: string | null) {
- const rawValue = String(formData.get("returnOrganizationId") ?? fallbackOrganizationId ?? "").trim();
- return rawValue ? `/settings?organization=${encodeURIComponent(rawValue)}` : "/settings";
+function settingsRedirectTarget(
+ formData: FormData,
+ fallback?: { organizationId?: string | null; section?: string | null },
+) {
+ const organizationId = String(formData.get("returnOrganizationId") ?? fallback?.organizationId ?? "").trim();
+ const section = String(formData.get("returnSection") ?? fallback?.section ?? "").trim();
+ const params = new URLSearchParams();
+
+ if (section) {
+ params.set("section", section);
+ }
+
+ if (organizationId) {
+ params.set("organization", organizationId);
+ }
+
+ const query = params.toString();
+ return query ? `/settings?${query}` : "/settings";
}
export async function createFormAction() {
const userId = await requireSessionUser();
const { activeWorkspace } = await getActiveWorkspaceForUser(userId);
- const form = await createDraftForm(userId, activeWorkspace);
+ const session = await getServerAuthSession();
+ const form = await createDraftForm(userId, activeWorkspace, session?.user.locale);
redirect(`/forms/${form.id}/edit`);
}
@@ -51,28 +69,28 @@ export async function createOrganizationAction(formData: FormData) {
const organization = await createOrganization(userId, { name: formData.get("name") });
await setActiveWorkspaceCookie({ kind: "organization", organizationId: organization.id });
- redirect(settingsRedirectTarget(formData, organization.id));
+ redirect(settingsRedirectTarget(formData, { organizationId: organization.id, section: "organizations" }));
}
export async function renameOrganizationAction(formData: FormData) {
const userId = await requireSessionUser();
const organizationId = String(formData.get("organizationId") ?? "");
await renameOrganization(userId, organizationId, { name: formData.get("name") });
- redirect(settingsRedirectTarget(formData, organizationId));
+ redirect(settingsRedirectTarget(formData, { organizationId, section: "organizations" }));
}
export async function deleteOrganizationAction(formData: FormData) {
const userId = await requireSessionUser();
await deleteOrganization(userId, String(formData.get("organizationId") ?? ""));
await setActiveWorkspaceCookie({ kind: "personal" });
- redirect("/settings");
+ redirect(settingsRedirectTarget(formData, { section: "organizations" }));
}
export async function createOrganizationInviteLinkAction(formData: FormData) {
const userId = await requireSessionUser();
const organizationId = String(formData.get("organizationId") ?? "");
await createOrganizationInviteLink(userId, organizationId);
- redirect(settingsRedirectTarget(formData, organizationId));
+ redirect(settingsRedirectTarget(formData, { organizationId, section: "organizations" }));
}
export async function revokeOrganizationInviteLinkAction(formData: FormData) {
@@ -83,7 +101,7 @@ export async function revokeOrganizationInviteLinkAction(formData: FormData) {
organizationId,
String(formData.get("inviteLinkId") ?? ""),
);
- redirect(settingsRedirectTarget(formData, organizationId));
+ redirect(settingsRedirectTarget(formData, { organizationId, section: "organizations" }));
}
export async function removeOrganizationMemberAction(formData: FormData) {
@@ -94,5 +112,18 @@ export async function removeOrganizationMemberAction(formData: FormData) {
organizationId,
String(formData.get("memberUserId") ?? ""),
);
- redirect(settingsRedirectTarget(formData, organizationId));
+ redirect(settingsRedirectTarget(formData, { organizationId, section: "organizations" }));
+}
+
+export async function updateProfileAction(formData: FormData) {
+ const userId = await requireSessionUser();
+ await updateProfileSettings(userId, {
+ firstName: formData.get("firstName"),
+ secondName: formData.get("secondName"),
+ locale: formData.get("locale"),
+ image: formData.get("image"),
+ });
+ revalidatePath("/settings");
+ revalidatePath("/", "layout");
+ return { ok: true };
}
diff --git a/app/(creator)/dashboard/loading.tsx b/app/(creator)/dashboard/loading.tsx
index 42ac9cd..74f2717 100644
--- a/app/(creator)/dashboard/loading.tsx
+++ b/app/(creator)/dashboard/loading.tsx
@@ -1,5 +1,8 @@
import { LoadingShell } from "@/components/loading-shell";
+import { getRequestI18n } from "@/lib/i18n-server";
-export default function DashboardLoading() {
- return ;
+export default async function DashboardLoading() {
+ const { t } = await getRequestI18n();
+
+ return ;
}
diff --git a/app/(creator)/dashboard/page.tsx b/app/(creator)/dashboard/page.tsx
index 648fade..61cae91 100644
--- a/app/(creator)/dashboard/page.tsx
+++ b/app/(creator)/dashboard/page.tsx
@@ -6,41 +6,42 @@ import { EmptyState } from "@/components/empty-state";
import { Button } from "@/components/ui/button";
import { getServerAuthSession } from "@/lib/auth";
import { listFormsForWorkspace } from "@/lib/forms";
+import { getRequestI18n } from "@/lib/i18n-server";
import { getActiveWorkspaceForUser } from "@/lib/workspaces";
export default async function DashboardPage() {
const session = await getServerAuthSession();
+ const { t } = await getRequestI18n();
const workspaceState = await getActiveWorkspaceForUser(session!.user.id);
const forms = await listFormsForWorkspace(session!.user.id, workspaceState.activeWorkspace);
- const workspaceLabel = workspaceState.activeOption.label;
+ const workspaceLabel = workspaceState.activeOption.kind === "personal" ? t("workspace.personal") : workspaceState.activeOption.label;
return forms.length === 0 ? (
-
Dashboard
{workspaceLabel}
- Create a form, open a draft, or review responses in the active workspace.
+ {t("dashboard.description")}
- New form
+ {t("dashboard.newForm")}
}
diff --git a/app/(creator)/forms/[id]/edit/loading.tsx b/app/(creator)/forms/[id]/edit/loading.tsx
index be6ab8e..3e85827 100644
--- a/app/(creator)/forms/[id]/edit/loading.tsx
+++ b/app/(creator)/forms/[id]/edit/loading.tsx
@@ -1,5 +1,8 @@
import { LoadingShell } from "@/components/loading-shell";
+import { getRequestI18n } from "@/lib/i18n-server";
-export default function EditFormLoading() {
- return ;
+export default async function EditFormLoading() {
+ const { t } = await getRequestI18n();
+
+ return ;
}
diff --git a/app/(creator)/forms/[id]/responses/[responseId]/page.tsx b/app/(creator)/forms/[id]/responses/[responseId]/page.tsx
index b711967..c508a15 100644
--- a/app/(creator)/forms/[id]/responses/[responseId]/page.tsx
+++ b/app/(creator)/forms/[id]/responses/[responseId]/page.tsx
@@ -7,6 +7,7 @@ import { Card } from "@/components/ui/card";
import { getServerAuthSession } from "@/lib/auth";
import { AppError } from "@/lib/errors";
import { getOwnedResponseDetail } from "@/lib/forms";
+import { getRequestI18n } from "@/lib/i18n-server";
import { formatDate } from "@/lib/utils";
export default async function ResponseDetailPage({
@@ -15,6 +16,7 @@ export default async function ResponseDetailPage({
params: Promise<{ id: string; responseId: string }>;
}) {
const session = await getServerAuthSession();
+ const { locale, t } = await getRequestI18n();
const { id, responseId } = await params;
let response;
@@ -29,17 +31,19 @@ export default async function ResponseDetailPage({
throw error;
}
+ const workspaceLabel = response.workspace.kind === "personal" ? t("workspace.personal") : response.workspace.label;
+ const blockTypeLabel = (type: string) => t(`blocks.types.${type}`);
+
return (
-
Submission replay
-
Submission #{response.submissionNumber} · {formatDate(response.submittedAt)}
-
{response.workspace.label}
-
Review submitted answers in form order.
+
{t("responseDetail.title", { number: response.submissionNumber, date: formatDate(response.submittedAt, locale) })}
+
{workspaceLabel}
+
{t("responseDetail.description")}
- Back to responses
+ {t("responseDetail.backToResponses")}
@@ -50,8 +54,8 @@ export default async function ResponseDetailPage({
if (block.type === "TEXT") {
return (
- Context block
- {block.title || "Context"}
+ {t("responseDetail.contextBlock")}
+ {block.title || t("responseDetail.contextTitle")}
{block.description ? {block.description}
: null}
{"body" in block.config ? {block.config.body}
: null}
@@ -61,8 +65,8 @@ export default async function ResponseDetailPage({
return (
- {block.type.replaceAll("_", " ")}
- {block.required ? Required : null}
+ {blockTypeLabel(block.type)}
+ {block.required ? {t("responseDetail.required")} : null}
{block.title}
{block.description ? {block.description}
: null}
@@ -78,7 +82,7 @@ export default async function ResponseDetailPage({
) : answer ? (
{answer}
) : (
- No answer provided.
+ {t("responseDetail.noAnswer")}
)}
diff --git a/app/(creator)/forms/[id]/responses/loading.tsx b/app/(creator)/forms/[id]/responses/loading.tsx
index 6f68713..20733f3 100644
--- a/app/(creator)/forms/[id]/responses/loading.tsx
+++ b/app/(creator)/forms/[id]/responses/loading.tsx
@@ -1,5 +1,8 @@
import { LoadingShell } from "@/components/loading-shell";
+import { getRequestI18n } from "@/lib/i18n-server";
-export default function ResponsesLoading() {
- return ;
+export default async function ResponsesLoading() {
+ const { t } = await getRequestI18n();
+
+ return ;
}
diff --git a/app/(creator)/forms/[id]/responses/page.tsx b/app/(creator)/forms/[id]/responses/page.tsx
index d1c7b49..6b6a506 100644
--- a/app/(creator)/forms/[id]/responses/page.tsx
+++ b/app/(creator)/forms/[id]/responses/page.tsx
@@ -10,10 +10,12 @@ import { Card } from "@/components/ui/card";
import { getServerAuthSession } from "@/lib/auth";
import { AppError } from "@/lib/errors";
import { listResponsesForOwnedForm } from "@/lib/forms";
+import { getRequestI18n } from "@/lib/i18n-server";
import { formatDate } from "@/lib/utils";
export default async function ResponsesPage({ params }: { params: Promise<{ id: string }> }) {
const session = await getServerAuthSession();
+ const { locale, t } = await getRequestI18n();
const { id } = await params;
let form;
@@ -31,32 +33,33 @@ export default async function ResponsesPage({ params }: { params: Promise<{ id:
throw error;
}
+ const workspaceLabel = form.workspace.kind === "personal" ? t("workspace.personal") : form.workspace.label;
+
return (
-
Responses
-
{form.title}
-
{form.workspace.label}
-
Review anonymous submissions in form order.
+
{form.title}
+
{workspaceLabel}
+
{t("responses.description")}
- {responses.length} submissions
+ {t("responses.submissionsCount", { count: responses.length })}
- Back to builder
+ {t("responses.backToBuilder")}
{responses.length === 0 ? (
- Open builder
+ {t("responses.openBuilder")}
}
/>
@@ -67,14 +70,14 @@ export default async function ResponsesPage({ params }: { params: Promise<{ id:
-
Submission #{response.submissionNumber} · {formatDate(response.submittedAt)}
-
{response.answerCount} answers
+
{t("responses.submissionTitle", { number: response.submissionNumber, date: formatDate(response.submittedAt, locale) })}
+
{t("responses.answersCount", { count: response.answerCount })}
- Open
+ {t("responses.open")}
diff --git a/app/(creator)/layout.tsx b/app/(creator)/layout.tsx
index 09ef617..0264398 100644
--- a/app/(creator)/layout.tsx
+++ b/app/(creator)/layout.tsx
@@ -2,13 +2,16 @@ import Image from "next/image";
import Link from "next/link";
import { redirect } from "next/navigation";
-import { SignOutButton } from "@/components/auth/sign-out-button";
+import { AccountMenu } from "@/components/account-menu";
import { WorkspaceSwitcher } from "@/components/workspace-switcher";
import { getServerAuthSession } from "@/lib/auth";
+import { getRequestI18n } from "@/lib/i18n-server";
import { getActiveWorkspaceForUser, serializeWorkspaceValue } from "@/lib/workspaces";
export default async function CreatorLayout({ children }: { children: React.ReactNode }) {
const session = await getServerAuthSession();
+ const { t } = await getRequestI18n();
+ const appName = t("app.name");
if (!session?.user?.id) {
redirect("/");
@@ -22,27 +25,21 @@ export default async function CreatorLayout({ children }: { children: React.Reac
-
+
- Lively Forms
+ {appName}
-
- Dashboard
-
-
- Settings
-
-
+
{children}
diff --git a/app/(creator)/settings/page.tsx b/app/(creator)/settings/page.tsx
index 08c8dea..23e6b48 100644
--- a/app/(creator)/settings/page.tsx
+++ b/app/(creator)/settings/page.tsx
@@ -1,18 +1,32 @@
import { SettingsShell } from "@/components/settings-shell";
import { getServerAuthSession } from "@/lib/auth";
import { listOrganizationsForUser } from "@/lib/organizations";
+import { getProfileSettingsUser } from "@/lib/users";
export default async function SettingsPage({
searchParams,
}: {
- searchParams: Promise<{ organization?: string | string[] }>;
+ searchParams: Promise<{ organization?: string | string[]; section?: string | string[] }>;
}) {
const session = await getServerAuthSession();
- const organizations = await listOrganizationsForUser(session!.user.id);
+ const [organizations, profileUser] = await Promise.all([
+ listOrganizationsForUser(session!.user.id),
+ getProfileSettingsUser(session!.user.id),
+ ]);
const resolvedSearchParams = await searchParams;
const initialOrganizationId = Array.isArray(resolvedSearchParams.organization)
? resolvedSearchParams.organization[0] ?? null
: resolvedSearchParams.organization ?? null;
+ const initialSection = Array.isArray(resolvedSearchParams.section)
+ ? resolvedSearchParams.section[0] ?? "profile"
+ : resolvedSearchParams.section ?? "profile";
- return
;
+ return (
+
+ );
}
diff --git a/app/api/forms/[formId]/blocks/route.ts b/app/api/forms/[formId]/blocks/route.ts
index 7dfebef..16d5273 100644
--- a/app/api/forms/[formId]/blocks/route.ts
+++ b/app/api/forms/[formId]/blocks/route.ts
@@ -14,7 +14,7 @@ export async function POST(request: Request, context: { params: Promise<{ formId
try {
const payload = await request.json();
const { formId } = await context.params;
- const block = await addOwnedBlock(session.user.id, formId, payload);
+ const block = await addOwnedBlock(session.user.id, formId, payload, session.user.locale);
return NextResponse.json({ block });
} catch (error) {
diff --git a/app/api/forms/[formId]/export/route.ts b/app/api/forms/[formId]/export/route.ts
index 66625be..be21897 100644
--- a/app/api/forms/[formId]/export/route.ts
+++ b/app/api/forms/[formId]/export/route.ts
@@ -12,7 +12,7 @@ export async function GET(request: Request, context: { params: Promise<{ formId:
try {
const { formId } = await context.params;
const format = parseResponseExportFormat(new URL(request.url).searchParams.get("format"));
- const exportFile = await createOwnedFormResponseExport(session.user.id, formId, format);
+ const exportFile = await createOwnedFormResponseExport(session.user.id, formId, format, session.user.locale);
return new Response(exportFile.body, {
status: 200,
diff --git a/app/error.tsx b/app/error.tsx
index b1753dd..1ffd889 100644
--- a/app/error.tsx
+++ b/app/error.tsx
@@ -1,28 +1,45 @@
"use client";
-import { useEffect } from "react";
+import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
+import { DEFAULT_LOCALE, normalizeLocale, type AppLocale } from "@/lib/i18n";
+
+const errorMessages: Record
= {
+ en: {
+ eyebrow: "Something broke",
+ title: "The forms hit an unexpected error.",
+ description: "Try the action again. If it keeps happening, check your environment variables, Google OAuth setup, and local Postgres container.",
+ tryAgain: "Try again",
+ },
+ ru: {
+ eyebrow: "Что-то сломалось",
+ title: "В формах произошла непредвиденная ошибка.",
+ description: "Попробуйте выполнить действие ещё раз. Если это повторяется, проверьте переменные окружения, настройку Google OAuth и локальный контейнер Postgres.",
+ tryAgain: "Попробовать снова",
+ },
+};
export default function GlobalError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
+ const [locale, setLocale] = useState(DEFAULT_LOCALE);
+
useEffect(() => {
console.error(error);
+ setLocale(normalizeLocale(window.navigator.language));
}, [error]);
+ const copy = errorMessages[locale];
+
return (
-
+
- Something broke
- The forms hit an unexpected error.
-
- Try the action again. If it keeps happening, check your environment variables, Google OAuth setup, and
- local Postgres container.
-
+ {copy.title}
+ {copy.description}
- reset()}>Try again
+ reset()}>{copy.tryAgain}
diff --git a/app/f/[slug]/loading.tsx b/app/f/[slug]/loading.tsx
index 0d07795..1f0e173 100644
--- a/app/f/[slug]/loading.tsx
+++ b/app/f/[slug]/loading.tsx
@@ -1,5 +1,8 @@
import { LoadingShell } from "@/components/loading-shell";
+import { getRequestI18n } from "@/lib/i18n-server";
-export default function PublicFormLoading() {
- return ;
+export default async function PublicFormLoading() {
+ const { t } = await getRequestI18n();
+
+ return ;
}
diff --git a/app/globals.css b/app/globals.css
index a4ade4d..3619c75 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -64,7 +64,7 @@
@theme inline {
--color-background: var(--bg);
--color-foreground: var(--ink);
- --font-sans: var(--font-manrope);
+ --font-sans: var(--font-inter);
--font-display: var(--font-kurale);
}
@@ -82,7 +82,7 @@ body {
background-color: var(--bg-end);
background-image: linear-gradient(180deg, var(--bg-start) 0%, var(--bg-end) 100%);
color: var(--ink);
- font-family: var(--font-manrope), sans-serif;
+ font-family: var(--font-inter), sans-serif;
}
main {
diff --git a/app/join/[token]/page.tsx b/app/join/[token]/page.tsx
index 3ab59f7..dc166d1 100644
--- a/app/join/[token]/page.tsx
+++ b/app/join/[token]/page.tsx
@@ -5,10 +5,12 @@ import { acceptOrganizationInviteAction } from "@/app/join/actions";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { getServerAuthSession } from "@/lib/auth";
+import { getRequestI18n } from "@/lib/i18n-server";
import { getOrganizationInviteState } from "@/lib/organizations";
export default async function JoinOrganizationPage({ params }: { params: Promise<{ token: string }> }) {
const session = await getServerAuthSession();
+ const { t } = await getRequestI18n();
if (!session?.user?.id) {
redirect("/");
@@ -22,40 +24,36 @@ export default async function JoinOrganizationPage({ params }: { params: Promise
{invite ? (
<>
- Organization invite
- Join {invite.organizationName}
+ {t("join.joinTitle", { organization: invite.organizationName })}
- {invite.alreadyMember
- ? "You already belong to this organization. You can switch to it from the workspace selector."
- : "Accept this invite to collaborate in the organization workspace."}
+ {invite.alreadyMember ? t("join.alreadyMember") : t("join.inviteDescription")}
{invite.alreadyMember ? (
- Open dashboard
+ {t("join.openDashboard")}
) : (
)}
- Cancel
+ {t("join.cancel")}
>
) : (
<>
- Organization invite
- Invite not available
+ {t("join.unavailableTitle")}
- This invite link is invalid or has been revoked.
+ {t("join.unavailableDescription")}
- Back to dashboard
+ {t("join.backToDashboard")}
>
diff --git a/app/layout.tsx b/app/layout.tsx
index c3932df..25853f7 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -1,7 +1,9 @@
import type { Metadata } from "next";
-import { Kurale, Manrope } from "next/font/google";
+import { Inter, Kurale } from "next/font/google";
+import { I18nProvider } from "@/components/i18n-provider";
import { ThemeProvider } from "@/components/theme-provider";
+import { getRequestI18n } from "@/lib/i18n-server";
import { themeInitScript } from "@/lib/theme";
import "./globals.css";
@@ -11,28 +13,36 @@ const kurale = Kurale({
subsets: ["latin"],
});
-const manrope = Manrope({
- variable: "--font-manrope",
+const inter = Inter({
+ variable: "--font-inter",
subsets: ["latin"],
});
-export const metadata: Metadata = {
- title: "Lively Forms",
- description: "Lively Forms is a conversational form builder for creating, publishing, and reviewing forms.",
-};
+export async function generateMetadata(): Promise {
+ const { t } = await getRequestI18n();
-export default function RootLayout({
+ return {
+ title: t("app.name"),
+ description: t("app.description"),
+ };
+}
+
+export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
+ const { locale, messages } = await getRequestI18n();
+
return (
-
+
-
- {children}
+
+
+ {children}
+
);
diff --git a/app/not-found.tsx b/app/not-found.tsx
index 0e3fe34..7aa707e 100644
--- a/app/not-found.tsx
+++ b/app/not-found.tsx
@@ -2,20 +2,20 @@ import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
+import { getRequestI18n } from "@/lib/i18n-server";
-export default function NotFound() {
+export default async function NotFound() {
+ const { t } = await getRequestI18n();
return (
- Not found
- That page or form doesn’t exist.
+ {t("notFound.title")}
- If you were expecting a public form, it may still be in draft. If you were editing a creator form, you may
- not have access to it.
+ {t("notFound.description")}
- Back home
+ {t("notFound.backHome")}
diff --git a/app/page.tsx b/app/page.tsx
index f047a07..97f8a24 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -6,9 +6,12 @@ import { GoogleSignInButton } from "@/components/auth/google-sign-in-button";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { getServerAuthSession } from "@/lib/auth";
+import { getRequestI18n } from "@/lib/i18n-server";
export default async function HomePage() {
const session = await getServerAuthSession();
+ const { t } = await getRequestI18n();
+ const appName = t("app.name");
return (
@@ -16,19 +19,18 @@ export default async function HomePage() {
-
-
Lively Forms
+
+ {appName}
- Like a seed in good soil, every thoughtful question has the power to make something grow. Build forms
- that feel alive—gentle invitations to reflect, respond, and uncover what matters most.
+ {t("home.description")}
{session?.user ? (
- Open dashboard
+ {t("home.openDashboard")}
diff --git a/bun.lock b/bun.lock
index 8c2458f..27d43b0 100644
--- a/bun.lock
+++ b/bun.lock
@@ -21,6 +21,7 @@
"react-dom": "^19.2.4",
"tailwind-merge": "^3.5.0",
"xlsx": "^0.18.5",
+ "yaml": "^2.8.3",
"zod": "^4.3.6",
},
"devDependencies": {
@@ -1065,6 +1066,8 @@
"yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
+ "yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="],
+
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
diff --git a/components/account-menu.tsx b/components/account-menu.tsx
new file mode 100644
index 0000000..a2ec979
--- /dev/null
+++ b/components/account-menu.tsx
@@ -0,0 +1,83 @@
+"use client";
+
+import Link from "next/link";
+import { LoaderCircle, LogOut, Settings } from "lucide-react";
+import { signOut } from "next-auth/react";
+import { useEffect, useRef, useState, useTransition } from "react";
+
+import { useI18n } from "@/components/i18n-provider";
+import { UserAvatar } from "@/components/user-avatar";
+import { getUserDisplayName, type UserIdentityLike } from "@/lib/user-identity";
+
+export function AccountMenu({ user }: { user: UserIdentityLike }) {
+ const [isOpen, setIsOpen] = useState(false);
+ const [isPending, startTransition] = useTransition();
+ const menuRef = useRef
(null);
+ const displayName = getUserDisplayName(user);
+ const { t } = useI18n();
+
+ useEffect(() => {
+ function handlePointerDown(event: PointerEvent) {
+ if (!menuRef.current?.contains(event.target as Node)) {
+ setIsOpen(false);
+ }
+ }
+
+ if (!isOpen) {
+ return;
+ }
+
+ window.addEventListener("pointerdown", handlePointerDown);
+ return () => window.removeEventListener("pointerdown", handlePointerDown);
+ }, [isOpen]);
+
+ return (
+
+
setIsOpen((current) => !current)}
+ className="inline-flex items-center justify-center rounded-full transition hover:opacity-95"
+ aria-haspopup="menu"
+ aria-expanded={isOpen}
+ aria-label={t("account.openMenuAria")}
+ >
+
+
+
+ {isOpen ? (
+
+
+
+
+
{displayName}
+ {user.email ?
{user.email}
: null}
+
+
+
+ setIsOpen(false)}
+ >
+
+ {t("account.settings")}
+
+
+ startTransition(() => {
+ setIsOpen(false);
+ void signOut({ callbackUrl: "/" });
+ })
+ }
+ >
+ {isPending ? : }
+ {t("account.logout")}
+
+
+
+ ) : null}
+
+ );
+}
diff --git a/components/auth/google-sign-in-button.tsx b/components/auth/google-sign-in-button.tsx
index 8c7ebce..f88dd19 100644
--- a/components/auth/google-sign-in-button.tsx
+++ b/components/auth/google-sign-in-button.tsx
@@ -4,10 +4,12 @@ import { useTransition } from "react";
import { LoaderCircle, LogIn } from "lucide-react";
import { signIn } from "next-auth/react";
+import { useI18n } from "@/components/i18n-provider";
import { Button } from "@/components/ui/button";
export function GoogleSignInButton() {
const [isPending, startTransition] = useTransition();
+ const { t } = useI18n();
return (
{isPending ? : }
- Continue with Google
+ {t("auth.continueWithGoogle")}
);
}
diff --git a/components/dashboard-form-browser.tsx b/components/dashboard-form-browser.tsx
index de8c351..238a7f4 100644
--- a/components/dashboard-form-browser.tsx
+++ b/components/dashboard-form-browser.tsx
@@ -6,6 +6,7 @@ import { ArrowDown, ArrowUp, LayoutGrid, Plus, TableProperties } from "lucide-re
import { useEffect, useMemo, useState } from "react";
import { createFormAction } from "@/app/(creator)/actions";
+import { useI18n } from "@/components/i18n-provider";
import { FormStatusBadge } from "@/components/form-status-badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
@@ -27,6 +28,7 @@ function compareText(left: string, right: string) {
export function DashboardFormBrowser({ forms, workspaceLabel }: { forms: FormListItem[]; workspaceLabel: string }) {
const router = useRouter();
+ const { locale, t } = useI18n();
const pathname = usePathname();
const searchParams = useSearchParams();
const initialView = searchParams.get("view") === "table" ? "table" : "grid";
@@ -107,9 +109,8 @@ export function DashboardFormBrowser({ forms, workspaceLabel }: { forms: FormLis
-
Dashboard
{workspaceLabel}
-
Create a form, open a draft, or review responses in the active workspace.
+
{t("dashboard.description")}
@@ -123,7 +124,7 @@ export function DashboardFormBrowser({ forms, workspaceLabel }: { forms: FormLis
aria-pressed={view === "grid"}
>
- Grid
+ {t("dashboard.viewGrid")}
- Table
+ {t("dashboard.viewTable")}
@@ -157,25 +158,25 @@ export function DashboardFormBrowser({ forms, workspaceLabel }: { forms: FormLis
{form.title}
- {form.description || "No description yet."}
+ {form.description || t("dashboard.noDescription")}
-
Open
+
{t("dashboard.open")}
-
Share URL
+
{t("dashboard.shareUrl")}
/{form.slug}
-
Responses
+
{t("dashboard.responses")}
{form.responseCount}
-
Updated
-
{formatDate(form.updatedAt)}
+
{t("dashboard.updated")}
+
{formatDate(form.updatedAt, locale)}
@@ -187,28 +188,28 @@ export function DashboardFormBrowser({ forms, workspaceLabel }: { forms: FormLis
-
+
handleSort("title")} className="inline-flex items-center gap-2 font-semibold transition hover:text-[var(--ink)]">
- Title
+ {t("dashboard.titleColumn")}
{renderSortIcon("title")}
handleSort("status")} className="inline-flex items-center gap-2 font-semibold transition hover:text-[var(--ink)]">
- Status
+ {t("dashboard.statusColumn")}
{renderSortIcon("status")}
handleSort("responseCount")} className="inline-flex items-center gap-2 font-semibold transition hover:text-[var(--ink)]">
- Responses
+ {t("dashboard.responses")}
{renderSortIcon("responseCount")}
handleSort("updatedAt")} className="inline-flex items-center gap-2 font-semibold transition hover:text-[var(--ink)]">
- Updated
+ {t("dashboard.updated")}
{renderSortIcon("updatedAt")}
@@ -222,14 +223,14 @@ export function DashboardFormBrowser({ forms, workspaceLabel }: { forms: FormLis
{form.title}
- {form.description || "No description yet."}
+ {form.description || t("dashboard.noDescription")}
{form.responseCount}
- {formatDate(form.updatedAt)}
+ {formatDate(form.updatedAt, locale)}
))}
diff --git a/components/delete-organization-button.tsx b/components/delete-organization-button.tsx
new file mode 100644
index 0000000..36eee81
--- /dev/null
+++ b/components/delete-organization-button.tsx
@@ -0,0 +1,48 @@
+"use client";
+
+import { useState } from "react";
+
+import { deleteOrganizationAction } from "@/app/(creator)/actions";
+import { useI18n } from "@/components/i18n-provider";
+import { Button } from "@/components/ui/button";
+import { ConfirmDialog } from "@/components/ui/confirm-dialog";
+
+export function DeleteOrganizationButton({
+ organizationId,
+ returnOrganizationId,
+}: {
+ organizationId: string;
+ returnOrganizationId: string;
+}) {
+ const { t } = useI18n();
+ const [open, setOpen] = useState(false);
+ const formId = `delete-organization-${organizationId}`;
+
+ return (
+
+ );
+}
diff --git a/components/empty-state.tsx b/components/empty-state.tsx
index 90d02d7..a99ad5a 100644
--- a/components/empty-state.tsx
+++ b/components/empty-state.tsx
@@ -5,7 +5,7 @@ import { Card } from "@/components/ui/card";
export function EmptyState({ eyebrow, title, description, action }: { eyebrow: string; title: string; description: string; action?: ReactNode }) {
return (
- {eyebrow}
+ {eyebrow}
{title}
{description}
{action ? {action}
: null}
diff --git a/components/form-builder-panels.tsx b/components/form-builder-panels.tsx
index 5489d1d..b5b14a5 100644
--- a/components/form-builder-panels.tsx
+++ b/components/form-builder-panels.tsx
@@ -14,12 +14,13 @@ import { Copy, GripVertical, Link as LinkIcon, LoaderCircle, MessagesSquare, Plu
import Link from "next/link";
import type { Dispatch, SetStateAction } from "react";
+import { useI18n } from "@/components/i18n-provider";
import { FormStatusBadge } from "@/components/form-status-badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
- blockTypeLabels,
+ blockTypeTranslationKeys,
isQuestionBlock,
type BlockConfig,
type LongTextBlockConfig,
@@ -56,31 +57,33 @@ export function BuilderHeader({
onOpenSettings: () => void;
onTogglePublished: () => void;
}) {
+ const { t } = useI18n();
+ const workspaceLabel = form.workspace.kind === "personal" ? t("workspace.personal") : form.workspace.label;
+
return (
-
Builder
-
+
{form.title}
-
{form.workspace.label}
-
Edit blocks, update settings, and review responses.
+
{workspaceLabel}
+
{t("builder.description")}
- Settings
+ {t("builder.settings")}
- Responses ({form.responseCount})
+ {t("builder.responses", { count: form.responseCount })}
{busy === "publish" ? : }
- {form.status === "PUBLISHED" ? "Unpublish" : "Publish form"}
+ {form.status === "PUBLISHED" ? t("builder.unpublish") : t("builder.publish")}
@@ -104,52 +107,54 @@ export function FormSettingsPanel({
saveMetadata: () => void;
busy: string | null;
}) {
+ const { t } = useI18n();
+
return (
-
Settings
+ {t("builder.settingsTitle")}
- Title
+ {t("builder.titleField")}
setMetadataDraft((current) => ({ ...current, title: event.target.value }))} />
- Description
+ {t("builder.descriptionField")}
- Share URL slug
+ {t("builder.shareSlug")}
setMetadataDraft((current) => ({ ...current, slug: event.target.value }))} />
-
After submission
+
{t("builder.afterSubmission")}
- Customize what respondents see after they finish the form, including an optional next-step link.
+ {t("builder.afterSubmissionDescription")}
@@ -157,20 +162,20 @@ export function FormSettingsPanel({
-
Public route
+
{t("builder.publicRoute")}
{shareHref}
- Published forms accept anonymous submissions. Changes to a published form go live immediately.
+ {t("builder.publicRouteHelp")}
- Copy link
+ {t("builder.copyLink")}
- Open runner
+ {t("builder.openRunner")}
@@ -178,11 +183,11 @@ export function FormSettingsPanel({
{busy === "delete-form" ? : }
- Delete form
+ {t("builder.deleteForm")}
{busy === "metadata" ? : }
- Save form settings
+ {t("builder.saveFormSettings")}
@@ -202,6 +207,7 @@ function SortableChoiceOptionRow({
onChange: (optionId: string, value: string) => void;
onRemove: (optionId: string) => void;
}) {
+ const { t } = useI18n();
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: option.id });
return (
@@ -216,17 +222,17 @@ function SortableChoiceOptionRow({
-
onChange(option.id, event.target.value)} />
+
onChange(option.id, event.target.value)} />
onRemove(option.id)}
>
@@ -255,6 +261,7 @@ export function BlockEditorPanel({
saveBlock: () => void;
busy: string | null;
}) {
+ const { t } = useI18n();
const optionSensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } }));
function syncChoiceOptions(nextOptions: ChoiceOptionDraft[]) {
@@ -278,7 +285,7 @@ export function BlockEditorPanel({
return;
}
- syncChoiceOptions([...choiceOptionsDraft, { id: crypto.randomUUID(), value: `Option ${choiceOptionsDraft.length + 1}` }]);
+ syncChoiceOptions([...choiceOptionsDraft, { id: crypto.randomUUID(), value: t("builder.option", { number: choiceOptionsDraft.length + 1 }) }]);
}
function removeChoiceOption(optionId: string) {
@@ -311,23 +318,23 @@ export function BlockEditorPanel({
{SelectedBlockIcon ? : null}
-
{blockTypeLabels[blockDraft.type]}
+ {t(blockTypeTranslationKeys[blockDraft.type])}
)}
@@ -386,7 +393,7 @@ export function BlockEditorPanel({
type="checkbox"
onChange={(event) => setBlockDraft((current) => (current ? { ...current, required: event.target.checked } : current))}
/>
- Mark this question as required
+ {t("builder.requiredToggle")}
) : null}
@@ -394,11 +401,11 @@ export function BlockEditorPanel({
deleteBlock(blockDraft.id)}>
{busy === `delete-${blockDraft.id}` ? : }
- Delete block
+ {t("builder.deleteBlock")}
{busy === `block-${blockDraft.id}` ? : }
- Save block
+ {t("builder.saveBlock")}
@@ -406,16 +413,18 @@ export function BlockEditorPanel({
}
export function EmptyEditorState({ onAddShortText }: { onAddShortText: () => void }) {
+ const { t } = useI18n();
+
return (
-
Nothing selected
-
Select a block or open form settings.
-
Use the left panel to choose what you want to edit.
+
{t("builder.nothingSelected")}
+
{t("builder.nothingSelectedTitle")}
+
{t("builder.nothingSelectedDescription")}
- Add a short text question
+ {t("builder.addShortTextQuestion")}
diff --git a/components/form-builder.tsx b/components/form-builder.tsx
index 4c4423e..d4c13a7 100644
--- a/components/form-builder.tsx
+++ b/components/form-builder.tsx
@@ -37,10 +37,12 @@ import {
type ChoiceOptionDraft,
type FormMetadataDraft,
} from "@/components/form-builder-panels";
+import { useI18n } from "@/components/i18n-provider";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
+import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { ToastViewport, type ToastData } from "@/components/ui/toast";
-import { blockTypeLabels, getBlockPreview, type ChoiceBlockConfig } from "@/lib/blocks";
+import { blockTypeTranslationKeys, getBlockPreview, type ChoiceBlockConfig } from "@/lib/blocks";
import type { BuilderBlock, BuilderForm } from "@/lib/form-types";
import { cn } from "@/lib/utils";
@@ -89,12 +91,15 @@ function BlockRowInner({
onSelect: (blockId: string) => void;
dragHandle: React.ReactNode;
}) {
+ const { t } = useI18n();
+ const preview = getBlockPreview(block, t(blockTypeTranslationKeys[block.type]));
+
return (
<>
{dragHandle}
onSelect(block.id)}>
- {getBlockPreview(block)}
+ {preview}
>
@@ -110,8 +115,10 @@ function SortableBlockRow({
selected: boolean;
onSelect: (blockId: string) => void;
}) {
+ const { t } = useI18n();
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: block.id });
const Icon = blockIcons[block.type];
+ const preview = getBlockPreview(block, t(blockTypeTranslationKeys[block.type]));
return (
@@ -182,6 +189,7 @@ function StaticBlockRow({
}
export function FormBuilder({ initialForm }: { initialForm: BuilderForm }) {
+ const { t } = useI18n();
const router = useRouter();
const [form, setForm] = useState(initialForm);
const [selection, setSelection] = useState
(() =>
@@ -202,6 +210,7 @@ export function FormBuilder({ initialForm }: { initialForm: BuilderForm }) {
});
const [isBlockMenuOpen, setIsBlockMenuOpen] = useState(false);
+ const [isDeleteFormDialogOpen, setIsDeleteFormDialogOpen] = useState(false);
const blockMenuRef = useRef(null);
const selectedBlock = useMemo(
@@ -274,7 +283,7 @@ export function FormBuilder({ initialForm }: { initialForm: BuilderForm }) {
try {
await runner();
} catch (caughtError) {
- const nextError = caughtError instanceof Error ? caughtError.message : "Something went wrong.";
+ const nextError = caughtError instanceof Error ? caughtError.message : t("builder.toasts.genericError");
showToast(nextError, "error");
} finally {
setBusy(null);
@@ -289,7 +298,7 @@ export function FormBuilder({ initialForm }: { initialForm: BuilderForm }) {
});
setForm(payload.form);
- showToast("Saved form settings");
+ showToast(t("builder.toasts.savedFormSettings"));
});
}
@@ -306,7 +315,7 @@ export function FormBuilder({ initialForm }: { initialForm: BuilderForm }) {
}));
setSelection({ kind: "block", blockId: payload.block.id });
setIsBlockMenuOpen(false);
- showToast(`Added ${blockTypeLabels[type].toLowerCase()}`);
+ showToast(t("builder.toasts.addedBlock", { type: t(blockTypeTranslationKeys[type]) }));
});
}
@@ -333,7 +342,7 @@ export function FormBuilder({ initialForm }: { initialForm: BuilderForm }) {
...current,
blocks: current.blocks.map((block) => (block.id === payload.block.id ? payload.block : block)),
}));
- showToast("Saved block");
+ showToast(t("builder.toasts.savedBlock"));
});
}
@@ -353,7 +362,7 @@ export function FormBuilder({ initialForm }: { initialForm: BuilderForm }) {
})),
}));
setSelection({ kind: "form" });
- showToast("Removed block");
+ showToast(t("builder.toasts.removedBlock"));
});
}
@@ -367,7 +376,7 @@ export function FormBuilder({ initialForm }: { initialForm: BuilderForm }) {
});
setForm(payload.form);
- showToast(payload.form.status === "PUBLISHED" ? "Form published" : "Form moved back to draft");
+ showToast(payload.form.status === "PUBLISHED" ? t("builder.toasts.formPublished") : t("builder.toasts.formDrafted"));
router.refresh();
});
}
@@ -375,23 +384,20 @@ export function FormBuilder({ initialForm }: { initialForm: BuilderForm }) {
async function copyShareLink() {
const url = `${window.location.origin}/f/${form.slug}`;
await navigator.clipboard.writeText(url);
- showToast("Copied share link");
+ showToast(t("builder.toasts.copiedShareLink"));
}
- async function deleteForm() {
- const confirmed = window.confirm(
- "Delete this form permanently? This will also delete all blocks and responses. This action cannot be undone.",
- );
-
- if (!confirmed) {
- return;
- }
+ function deleteForm() {
+ setIsDeleteFormDialogOpen(true);
+ }
+ async function confirmDeleteForm() {
await withTask("delete-form", async () => {
await fetchJson(`/api/forms/${form.id}`, {
method: "DELETE",
});
+ setIsDeleteFormDialogOpen(false);
router.push("/dashboard");
router.refresh();
});
@@ -422,10 +428,10 @@ export function FormBuilder({ initialForm }: { initialForm: BuilderForm }) {
});
setForm(payload.form);
- showToast("Updated block order");
+ showToast(t("builder.toasts.updatedBlockOrder"));
} catch (caughtError) {
setForm((current) => ({ ...current, blocks: previous }));
- showToast(caughtError instanceof Error ? caughtError.message : "Could not reorder blocks.", "error");
+ showToast(caughtError instanceof Error ? caughtError.message : t("builder.toasts.reorderError"), "error");
}
}
@@ -434,6 +440,16 @@ export function FormBuilder({ initialForm }: { initialForm: BuilderForm }) {
return (
<>
+ setIsDeleteFormDialogOpen(false)}
+ title={t("builder.deleteFormTitle")}
+ description={t("builder.deleteFormDescription")}
+ confirmLabel={t("ui.delete")}
+ cancelLabel={t("ui.cancel")}
+ pending={busy === "delete-form"}
+ onConfirm={confirmDeleteForm}
+ />
-
Blocks
+
{t("builder.blocks")}
- New block
+ {t("builder.newBlock")}
{isBlockMenuOpen ? (
@@ -471,7 +487,7 @@ export function FormBuilder({ initialForm }: { initialForm: BuilderForm }) {
onClick={() => addBlock(type)}
>
{busy === `add-${type}` ?
:
}
- {blockTypeLabels[type]}
+ {t(blockTypeTranslationKeys[type])}
);
})}
diff --git a/components/form-status-badge.tsx b/components/form-status-badge.tsx
index e5d4f50..14e1ac1 100644
--- a/components/form-status-badge.tsx
+++ b/components/form-status-badge.tsx
@@ -1,9 +1,12 @@
import type { FormStatus } from "@prisma/client";
+import { useI18n } from "@/components/i18n-provider";
import { Badge } from "@/components/ui/badge";
-import { cn, sentenceCase } from "@/lib/utils";
+import { cn } from "@/lib/utils";
export function FormStatusBadge({ status, className }: { status: FormStatus; className?: string }) {
+ const { t } = useI18n();
+
return (
- {sentenceCase(status)}
+ {t(`statuses.${status.toLowerCase()}`)}
);
}
diff --git a/components/i18n-provider.tsx b/components/i18n-provider.tsx
new file mode 100644
index 0000000..18b3a68
--- /dev/null
+++ b/components/i18n-provider.tsx
@@ -0,0 +1,40 @@
+"use client";
+
+import { createContext, useContext, useMemo } from "react";
+
+import { translate, type AppLocale, type TranslationTree } from "@/lib/i18n";
+
+type I18nContextValue = {
+ locale: AppLocale;
+ messages: TranslationTree;
+ t: (key: string, values?: Record
) => string;
+};
+
+const I18nContext = createContext(null);
+
+export function I18nProvider({
+ locale,
+ messages,
+ children,
+}: {
+ locale: AppLocale;
+ messages: TranslationTree;
+ children: React.ReactNode;
+}) {
+ const value = useMemo(
+ () => ({ locale, messages, t: (key: string, values?: Record) => translate(messages, key, values) }),
+ [locale, messages],
+ );
+
+ return {children} ;
+}
+
+export function useI18n() {
+ const value = useContext(I18nContext);
+
+ if (!value) {
+ throw new Error("useI18n must be used inside I18nProvider.");
+ }
+
+ return value;
+}
diff --git a/components/loading-shell.tsx b/components/loading-shell.tsx
index 2d898c0..b31b731 100644
--- a/components/loading-shell.tsx
+++ b/components/loading-shell.tsx
@@ -1,4 +1,4 @@
-export function LoadingShell({ title }: { title: string }) {
+export function LoadingShell({ label }: { label: string }) {
return (
@@ -7,7 +7,7 @@ export function LoadingShell({ title }: { title: string }) {
- Loading {title}…
+ {label}
);
}
diff --git a/components/organization-settings-panel.tsx b/components/organization-settings-panel.tsx
index 63a4099..6dd6be0 100644
--- a/components/organization-settings-panel.tsx
+++ b/components/organization-settings-panel.tsx
@@ -1,23 +1,26 @@
"use client";
-import { Link2, Trash2 } from "lucide-react";
+import { Link2 } from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useMemo, useState } from "react";
import {
createOrganizationAction,
createOrganizationInviteLinkAction,
- deleteOrganizationAction,
removeOrganizationMemberAction,
renameOrganizationAction,
revokeOrganizationInviteLinkAction,
} from "@/app/(creator)/actions";
+import { DeleteOrganizationButton } from "@/components/delete-organization-button";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
-import { ToastViewport, type ToastData } from "@/components/ui/toast";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { ToastViewport, type ToastData } from "@/components/ui/toast";
+import { useI18n } from "@/components/i18n-provider";
+import { UserAvatar } from "@/components/user-avatar";
import type { OrganizationSettingsSummary } from "@/lib/form-types";
+import { getUserDisplayName } from "@/lib/user-identity";
import { formatDate } from "@/lib/utils";
export function OrganizationSettingsPanel({
@@ -30,6 +33,7 @@ export function OrganizationSettingsPanel({
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
+ const { locale, t } = useI18n();
const [selectedOrganizationId, setSelectedOrganizationId] = useState
(initialOrganizationId ?? organizations[0]?.id ?? null);
const [toasts, setToasts] = useState([]);
const selectedOrganization = useMemo(
@@ -50,9 +54,9 @@ export function OrganizationSettingsPanel({
try {
const url = `${window.location.origin}/join/${token}`;
await navigator.clipboard.writeText(url);
- showToast("Copied invite link.");
+ showToast(t("settings.organizations.copyInviteSuccess"));
} catch {
- showToast("Could not copy invite link.", "error");
+ showToast(t("settings.organizations.copyInviteError"), "error");
}
}
@@ -61,36 +65,35 @@ export function OrganizationSettingsPanel({
-
Organizations
-
Workspace settings
+
{t("settings.organizations.title")}
- Create shared workspaces, manage members, and issue invite links. Inside an organization, every member can work with forms. Only the owner can manage the organization itself.
+ {t("settings.organizations.description")}
{organizations.length === 0 ? (
- You are not part of any organizations yet.
+ {t("settings.organizations.noneYet")}
) : null}
{selectedOrganization ? (
<>
-
Organization
-
The settings below apply to the selected organization.
+
{t("settings.organizations.organizationLabel")}
+
{t("settings.organizations.selectedOrgHelp")}
(
{organization.name}
- {organization.isOwner ? " · Owner" : ""}
+ {organization.isOwner ? ` · ${t("settings.organizations.owner")}` : ""}
))}
@@ -121,7 +124,7 @@ export function OrganizationSettingsPanel({
- Rename organization
+ {t("settings.organizations.renameOrganization")}
) : null}
@@ -140,14 +143,17 @@ export function OrganizationSettingsPanel({
-
Members
+
{t("settings.organizations.members")}
{selectedOrganization.members.map((member) => (
-
-
{member.name || member.email || "Unnamed member"}
- {member.email ?
{member.email}
: null}
-
{member.role === "OWNER" ? "Owner" : "Member"}
+
+
+
+
{getUserDisplayName(member)}
+ {member.email ?
{member.email}
: null}
+
{member.role === "OWNER" ? t("settings.organizations.owner") : t("settings.organizations.member")}
+
{selectedOrganization.isOwner && member.role !== "OWNER" ? (
) : null}
@@ -166,14 +172,14 @@ export function OrganizationSettingsPanel({
-
Invite links
+
{t("settings.organizations.inviteLinks")}
{selectedOrganization.isOwner ? (
) : null}
@@ -181,7 +187,7 @@ export function OrganizationSettingsPanel({
{selectedOrganization.inviteLinks.length === 0 ? (
- No invite links yet.
+ {t("settings.organizations.noInviteLinks")}
) : null}
{selectedOrganization.inviteLinks.map((inviteLink) => {
@@ -195,7 +201,7 @@ export function OrganizationSettingsPanel({
>
{`/join/${inviteLink.token}`}
-
Created {formatDate(inviteLink.createdAt)}
+
{t("settings.organizations.inviteCreated", { date: formatDate(inviteLink.createdAt, locale) })}
{selectedOrganization.isOwner ? (
) : null}
@@ -215,16 +221,12 @@ export function OrganizationSettingsPanel({
{selectedOrganization.isOwner ? (
-
Danger zone
-
Deleting an organization also deletes its forms, responses, memberships, and invite links.
-
+
{t("settings.organizations.dangerZone")}
+
{t("settings.organizations.deleteOrgHelp")}
+
) : null}
diff --git a/components/profile-settings-panel.tsx b/components/profile-settings-panel.tsx
new file mode 100644
index 0000000..7999421
--- /dev/null
+++ b/components/profile-settings-panel.tsx
@@ -0,0 +1,106 @@
+"use client";
+
+import { LoaderCircle, Save } from "lucide-react";
+import { useState } from "react";
+
+import { updateProfileAction } from "@/app/(creator)/actions";
+import { useI18n } from "@/components/i18n-provider";
+import { UserAvatar } from "@/components/user-avatar";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { supportedLocales } from "@/lib/i18n";
+import type { ProfileSettingsUserSummary } from "@/lib/form-types";
+import { useRouter } from "next/navigation";
+
+export function ProfileSettingsPanel({
+ user,
+ initialOrganizationId,
+}: {
+ user: ProfileSettingsUserSummary;
+ initialOrganizationId: string | null;
+}) {
+ const { t } = useI18n();
+ const router = useRouter();
+ const [locale, setLocale] = useState(user.locale);
+ const [isPending, setIsPending] = useState(false);
+
+ async function submitProfile(formData: FormData) {
+ setIsPending(true);
+
+ try {
+ await updateProfileAction(formData);
+ router.refresh();
+ } finally {
+ setIsPending(false);
+ }
+ }
+
+ return (
+
+
+
{t("settings.profile.title")}
+
+ {t("settings.profile.description")}
+
+
+
+
+
+ );
+}
diff --git a/components/public-form-runner.tsx b/components/public-form-runner.tsx
index 717776e..10918e9 100644
--- a/components/public-form-runner.tsx
+++ b/components/public-form-runner.tsx
@@ -6,6 +6,7 @@ import Image from "next/image";
import Link from "next/link";
import { useMemo, useState } from "react";
+import { useI18n } from "@/components/i18n-provider";
import { Button, buttonVariants } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
@@ -35,6 +36,8 @@ async function submitResponse(slug: string, answers: Record
>({});
const [toasts, setToasts] = useState([]);
const [isSubmitting, setIsSubmitting] = useState(false);
@@ -42,10 +45,10 @@ export function PublicFormRunner({ form }: { form: PublicForm }) {
const currentBlock = form.blocks[step];
const progress = useMemo(() => ((step + 1) / form.blocks.length) * 100, [form.blocks.length, step]);
- const completionTitle = form.completionTitle.trim() || "Thanks for taking the time.";
+ const completionTitle = form.completionTitle.trim() || t("publicRunner.defaultCompletionTitle");
const completionMessage =
form.completionMessage.trim() ||
- "Your response was submitted anonymously. The creator can review your answers, but they are not linked to a login or account.";
+ t("publicRunner.defaultCompletionMessage");
const completionLinkLabel = form.completionLinkLabel?.trim() || null;
const completionLinkUrl = form.completionLinkUrl?.trim() || null;
@@ -81,7 +84,7 @@ export function PublicFormRunner({ form }: { form: PublicForm }) {
return true;
}
- showToast("Please answer before continuing.", "error");
+ showToast(t("publicRunner.answerRequired"), "error");
return false;
}
@@ -90,7 +93,7 @@ export function PublicFormRunner({ form }: { form: PublicForm }) {
return true;
}
- showToast("Choose one option before continuing.", "error");
+ showToast(t("publicRunner.singleChoiceRequired"), "error");
return false;
}
@@ -99,7 +102,7 @@ export function PublicFormRunner({ form }: { form: PublicForm }) {
return true;
}
- showToast("Choose at least one option before continuing.", "error");
+ showToast(t("publicRunner.multiChoiceRequired"), "error");
return false;
}
@@ -117,7 +120,7 @@ export function PublicFormRunner({ form }: { form: PublicForm }) {
await submitResponse(form.slug, answers);
setIsComplete(true);
} catch (caughtError) {
- showToast(caughtError instanceof Error ? caughtError.message : "Could not submit response.", "error");
+ showToast(caughtError instanceof Error ? caughtError.message : t("publicRunner.submitError"), "error");
} finally {
setIsSubmitting(false);
}
@@ -137,10 +140,10 @@ export function PublicFormRunner({ form }: { form: PublicForm }) {
-
+
-
Response received
-
Lively Forms
+
{t("publicRunner.responseReceived")}
+
{appName}
@@ -172,9 +175,9 @@ export function PublicFormRunner({ form }: { form: PublicForm }) {
-
+
-
Lively Forms
+
{appName}
{form.title}
@@ -184,7 +187,7 @@ export function PublicFormRunner({ form }: { form: PublicForm }) {
- Step {step + 1} of {form.blocks.length}
+ {t("publicRunner.step", { current: step + 1, total: form.blocks.length })}
{Math.round(progress)}%
@@ -211,9 +214,9 @@ export function PublicFormRunner({ form }: { form: PublicForm }) {
>
{currentBlock.type === "TEXT" ? (
-
Context
+
{t("publicRunner.context")}
- {currentBlock.title || "Take a breath before the next step."}
+ {currentBlock.title || t("publicRunner.defaultTextTitle")}
{currentBlock.description ?
{currentBlock.description}
: null}
{(currentBlock.config as TextBlockConfig).body}
@@ -221,10 +224,10 @@ export function PublicFormRunner({ form }: { form: PublicForm }) {
) : (
-
Question
+
{t("publicRunner.question")}
{currentBlock.required ? (
- Required
+ {t("publicRunner.required")}
) : null}
@@ -236,11 +239,11 @@ export function PublicFormRunner({ form }: { form: PublicForm }) {
{currentBlock.type === "SINGLE_CHOICE" ? (
-
Choose one option.
+
{t("publicRunner.chooseOne")}
) : null}
{currentBlock.type === "MULTIPLE_CHOICE" ? (
-
Select all that apply.
+
{t("publicRunner.selectAll")}
) : null}
{currentBlock.type === "SHORT_TEXT" ? (
- Back
+ {t("publicRunner.back")}
{isSubmitting ? : null}
- {step === form.blocks.length - 1 ? "Submit response" : "Continue"}
+ {step === form.blocks.length - 1 ? t("publicRunner.submit") : t("publicRunner.continue")}
{!isSubmitting ? : null}
diff --git a/components/response-export-actions.tsx b/components/response-export-actions.tsx
index b8514a2..543a688 100644
--- a/components/response-export-actions.tsx
+++ b/components/response-export-actions.tsx
@@ -1,8 +1,9 @@
"use client";
-import { useState } from "react";
-import { Download, FileSpreadsheet, LoaderCircle } from "lucide-react";
+import { useEffect, useRef, useState } from "react";
+import { ChevronDown, Download, FileSpreadsheet, LoaderCircle } from "lucide-react";
+import { useI18n } from "@/components/i18n-provider";
import { Button } from "@/components/ui/button";
import { ToastViewport, type ToastData } from "@/components/ui/toast";
@@ -24,8 +25,11 @@ function getFilenameFromDisposition(disposition: string | null, fallback: string
}
export function ResponseExportActions({ formId }: { formId: string }) {
+ const { t } = useI18n();
const [busyFormat, setBusyFormat] = useState
(null);
+ const [isMenuOpen, setIsMenuOpen] = useState(false);
const [toasts, setToasts] = useState([]);
+ const menuRef = useRef(null);
function showToast(message: string, variant: ToastData["variant"] = "success") {
const id = crypto.randomUUID();
@@ -36,6 +40,21 @@ export function ResponseExportActions({ formId }: { formId: string }) {
setToasts((current) => current.filter((toast) => toast.id !== id));
}
+ useEffect(() => {
+ function handlePointerDown(event: PointerEvent) {
+ if (!menuRef.current?.contains(event.target as Node)) {
+ setIsMenuOpen(false);
+ }
+ }
+
+ if (!isMenuOpen) {
+ return;
+ }
+
+ window.addEventListener("pointerdown", handlePointerDown);
+ return () => window.removeEventListener("pointerdown", handlePointerDown);
+ }, [isMenuOpen]);
+
async function exportResponses(format: ResponseExportFormat) {
try {
setBusyFormat(format);
@@ -44,7 +63,7 @@ export function ResponseExportActions({ formId }: { formId: string }) {
if (!response.ok) {
const payload = (await response.json().catch(() => ({}))) as { error?: string };
- throw new Error(payload.error ?? "Could not export responses.");
+ throw new Error(payload.error ?? t("responseExport.error"));
}
const blob = await response.blob();
@@ -62,9 +81,10 @@ export function ResponseExportActions({ formId }: { formId: string }) {
link.remove();
URL.revokeObjectURL(url);
- showToast(`Downloaded ${format.toUpperCase()} export.`);
+ setIsMenuOpen(false);
+ showToast(t("responseExport.downloaded", { format: format.toUpperCase() }));
} catch (error) {
- showToast(error instanceof Error ? error.message : "Could not export responses.", "error");
+ showToast(error instanceof Error ? error.message : t("responseExport.error"), "error");
} finally {
setBusyFormat(null);
}
@@ -72,15 +92,42 @@ export function ResponseExportActions({ formId }: { formId: string }) {
return (
<>
-
-
exportResponses("csv")}>
- {busyFormat === "csv" ? : }
- Export CSV
-
-
exportResponses("xlsx")}>
- {busyFormat === "xlsx" ? : }
- Export XLSX
+
+
setIsMenuOpen((current) => !current)}
+ aria-expanded={isMenuOpen}
+ aria-haspopup="menu"
+ >
+ {busyFormat ? : }
+ {t("responseExport.export")}
+
+ {isMenuOpen ? (
+
+
+ exportResponses("csv")}
+ disabled={busyFormat !== null}
+ >
+ {busyFormat === "csv" ? : }
+ CSV
+
+ exportResponses("xlsx")}
+ disabled={busyFormat !== null}
+ >
+ {busyFormat === "xlsx" ? : }
+ XLSX
+
+
+
+ ) : null}
>
diff --git a/components/settings-shell.tsx b/components/settings-shell.tsx
index 7c3c2ea..98c436e 100644
--- a/components/settings-shell.tsx
+++ b/components/settings-shell.tsx
@@ -1,45 +1,59 @@
"use client";
-import { Building2, Palette } from "lucide-react";
+import { Building2, Palette, UserRound } from "lucide-react";
+import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useState } from "react";
+import { useI18n } from "@/components/i18n-provider";
import { OrganizationSettingsPanel } from "@/components/organization-settings-panel";
+import { ProfileSettingsPanel } from "@/components/profile-settings-panel";
import { ThemeSettingsPanel } from "@/components/theme-settings-panel";
import { cn } from "@/lib/utils";
-import type { OrganizationSettingsSummary } from "@/lib/form-types";
+import type { OrganizationSettingsSummary, ProfileSettingsUserSummary } from "@/lib/form-types";
-type SettingsSection = "organizations" | "appearance";
+type SettingsSection = "profile" | "organizations" | "appearance";
-const sectionMeta: Record = {
+const sectionMeta: Record = {
+ profile: {
+ key: "profile",
+ icon: UserRound,
+ },
organizations: {
- title: "Organizations",
+ key: "organizations",
icon: Building2,
- description: "Members, invites, and owner controls.",
},
appearance: {
- title: "Appearance",
+ key: "appearance",
icon: Palette,
- description: "Theme and visual preferences.",
},
};
export function SettingsShell({
organizations,
+ profileUser,
initialOrganizationId,
+ initialSection,
}: {
organizations: OrganizationSettingsSummary[];
+ profileUser: ProfileSettingsUserSummary;
initialOrganizationId: string | null;
+ initialSection: string;
}) {
- const [selection, setSelection] = useState("organizations");
+ const router = useRouter();
+ const pathname = usePathname();
+ const searchParams = useSearchParams();
+ const { t } = useI18n();
+ const [selection, setSelection] = useState(
+ initialSection === "organizations" || initialSection === "appearance" ? initialSection : "profile",
+ );
return (
-
Settings
-
Workspace and site preferences
+
{t("settings.shell.title")}
- Manage shared workspaces and choose how Lively Forms looks across creator pages and public forms.
+ {t("settings.shell.description")}
@@ -55,7 +69,12 @@ export function SettingsShell({
setSelection(section)}
+ onClick={() => {
+ setSelection(section);
+ const nextParams = new URLSearchParams(searchParams.toString());
+ nextParams.set("section", section);
+ router.replace(`${pathname}?${nextParams.toString()}`, { scroll: false });
+ }}
className={cn(
"group flex w-full items-center gap-3 rounded-[16px] px-3 py-3 text-left transition",
selected
@@ -70,8 +89,8 @@ export function SettingsShell({
-
{meta.title}
-
{meta.description}
+
{t(`settings.sections.${meta.key}.title`)}
+
{t(`settings.sections.${meta.key}.description`)}
);
@@ -79,7 +98,9 @@ export function SettingsShell({
- {selection === "organizations" ? (
+ {selection === "profile" ? (
+
+ ) : selection === "organizations" ? (
) : (
diff --git a/components/theme-settings-panel.tsx b/components/theme-settings-panel.tsx
index 251c97c..c7e7e9f 100644
--- a/components/theme-settings-panel.tsx
+++ b/components/theme-settings-panel.tsx
@@ -2,46 +2,39 @@
import { LaptopMinimal, MoonStar, SunMedium } from "lucide-react";
+import { useI18n } from "@/components/i18n-provider";
import { useThemePreference } from "@/components/theme-provider";
import { cn } from "@/lib/utils";
import type { ThemePreference } from "@/lib/theme";
const options: Array<{
value: ThemePreference;
- title: string;
- description: string;
icon: typeof SunMedium;
}> = [
{
value: "light",
- title: "Light",
- description: "Always use the light theme.",
icon: SunMedium,
},
{
value: "dark",
- title: "Dark",
- description: "Always use the dark theme.",
icon: MoonStar,
},
{
value: "system",
- title: "System",
- description: "Follow your device theme automatically.",
icon: LaptopMinimal,
},
];
export function ThemeSettingsPanel() {
const { preference, setPreference } = useThemePreference();
+ const { t } = useI18n();
return (
-
Appearance
-
Theme settings
+
{t("settings.appearance.title")}
- Choose how Lively Forms looks across creator pages, public forms, and the home page.
+ {t("settings.appearance.description")}
@@ -64,12 +57,12 @@ export function ThemeSettingsPanel() {
aria-pressed={selected}
>
-
+
-
{option.title}
-
{option.description}
+
{t(`settings.appearance.options.${option.value}.title`)}
+
{t(`settings.appearance.options.${option.value}.description`)}
diff --git a/components/ui/badge.tsx b/components/ui/badge.tsx
index 3a9b26e..ee9defb 100644
--- a/components/ui/badge.tsx
+++ b/components/ui/badge.tsx
@@ -6,7 +6,7 @@ export function Badge({ className, ...props }: HTMLAttributes
)
return (
void;
+ onConfirm?: () => void;
+ actions?: React.ReactNode;
+};
+
+export function ConfirmDialog({
+ open,
+ title,
+ description,
+ confirmLabel,
+ cancelLabel,
+ confirmVariant = "danger",
+ pending = false,
+ onClose,
+ onConfirm,
+ actions,
+}: ConfirmDialogProps) {
+ useEffect(() => {
+ if (!open) {
+ return;
+ }
+
+ function handleKeyDown(event: KeyboardEvent) {
+ if (event.key === "Escape" && !pending) {
+ onClose();
+ }
+ }
+
+ window.addEventListener("keydown", handleKeyDown);
+ return () => window.removeEventListener("keydown", handleKeyDown);
+ }, [onClose, open, pending]);
+
+ if (!open || typeof document === "undefined") {
+ return null;
+ }
+
+ return createPortal(
+ {
+ if (!pending) {
+ onClose();
+ }
+ }}
+ >
+
event.stopPropagation()}
+ >
+
{title}
+
{description}
+ {actions ?? (
+
+
+ {cancelLabel}
+
+
+ {confirmLabel}
+
+
+ )}
+
+
,
+ document.body,
+ );
+}
diff --git a/components/ui/toast.tsx b/components/ui/toast.tsx
index 7921911..1483a72 100644
--- a/components/ui/toast.tsx
+++ b/components/ui/toast.tsx
@@ -4,6 +4,7 @@ import { useEffect } from "react";
import { AlertCircle, CheckCircle2, X } from "lucide-react";
import { AnimatePresence, motion } from "framer-motion";
+import { useI18n } from "@/components/i18n-provider";
import { cn } from "@/lib/utils";
export type ToastData = {
@@ -28,6 +29,7 @@ function ToastItem({
return () => window.clearTimeout(timeout);
}, [onDismiss, toast.duration, toast.id, toast.variant]);
+ const { t } = useI18n();
const Icon = toast.variant === "error" ? AlertCircle : CheckCircle2;
return (
@@ -55,7 +57,7 @@ function ToastItem({
"rounded-full p-1 opacity-60 transition hover:opacity-100",
toast.variant === "error" ? "hover:bg-[var(--danger-soft)]" : "hover:bg-[var(--accent-soft)]",
)}
- aria-label="Dismiss notification"
+ aria-label={t("ui.dismissNotification")}
>
diff --git a/components/user-avatar.tsx b/components/user-avatar.tsx
new file mode 100644
index 0000000..11e358f
--- /dev/null
+++ b/components/user-avatar.tsx
@@ -0,0 +1,49 @@
+"use client";
+
+import { useEffect, useState } from "react";
+
+import { getUserDisplayName, getUserImage, getUserInitials, type UserIdentityLike } from "@/lib/user-identity";
+import { cn } from "@/lib/utils";
+
+export function UserAvatar({
+ user,
+ className,
+ initialsClassName,
+}: {
+ user: UserIdentityLike;
+ className?: string;
+ initialsClassName?: string;
+}) {
+ const image = getUserImage(user);
+ const [imageFailed, setImageFailed] = useState(false);
+
+ useEffect(() => {
+ setImageFailed(false);
+ }, [image]);
+
+ const displayName = getUserDisplayName(user);
+ const initials = getUserInitials(user);
+
+ return (
+
+ {image && !imageFailed ? (
+ setImageFailed(true)}
+ />
+ ) : (
+ {initials}
+ )}
+
+ );
+}
diff --git a/components/workspace-switcher.tsx b/components/workspace-switcher.tsx
index 35fbb61..5499a83 100644
--- a/components/workspace-switcher.tsx
+++ b/components/workspace-switcher.tsx
@@ -11,6 +11,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
+import { useI18n } from "@/components/i18n-provider";
import { cn } from "@/lib/utils";
import type { CreatorWorkspaceOption } from "@/lib/form-types";
@@ -23,6 +24,7 @@ export function WorkspaceSwitcher({
}) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
+ const { t } = useI18n();
const selectKey = options
.map((option) => `${option.value}:${option.label}:${option.kind === "organization" && option.isOwner ? "owner" : "member"}`)
.join("|");
@@ -43,7 +45,7 @@ export function WorkspaceSwitcher({
}}
>
@@ -51,8 +53,8 @@ export function WorkspaceSwitcher({
{options.map((option) => (
- {option.label}
- {option.kind === "organization" && option.isOwner ? " · Owner" : ""}
+ {option.kind === "personal" ? t("workspace.personal") : option.label}
+ {option.kind === "organization" && option.isOwner ? ` · ${t("workspace.ownerSuffix")}` : ""}
))}
diff --git a/lib/auth.ts b/lib/auth.ts
index 6011f73..0aaf4bc 100644
--- a/lib/auth.ts
+++ b/lib/auth.ts
@@ -4,6 +4,8 @@ import { getServerSession, type NextAuthOptions } from "next-auth";
import GoogleProvider from "next-auth/providers/google";
import { db } from "@/lib/db";
+import { normalizeLocale } from "@/lib/i18n";
+import { syncGoogleProfileFields } from "@/lib/users";
export const authOptions: NextAuthOptions = {
adapter: PrismaAdapter(db) as Adapter,
@@ -29,11 +31,44 @@ export const authOptions: NextAuthOptions = {
async session({ session, user }) {
if (session.user) {
session.user.id = user.id;
+ session.user.firstName = user.firstName ?? null;
+ session.user.secondName = user.secondName ?? null;
+ session.user.locale = normalizeLocale(user.locale ?? "en");
+ session.user.name = user.name ?? null;
+ session.user.email = user.email ?? null;
+ session.user.image = user.image ?? null;
}
return session;
},
},
+ events: {
+ async signIn({ user, account, profile }) {
+ if (account?.provider !== "google" || !user.id) {
+ return;
+ }
+
+ await syncGoogleProfileFields(user.id, {
+ name: typeof profile?.name === "string" ? profile.name : null,
+ given_name:
+ profile && typeof (profile as { given_name?: unknown }).given_name === "string"
+ ? ((profile as { given_name?: string }).given_name ?? null)
+ : null,
+ family_name:
+ profile && typeof (profile as { family_name?: unknown }).family_name === "string"
+ ? ((profile as { family_name?: string }).family_name ?? null)
+ : null,
+ picture:
+ profile && typeof (profile as { picture?: unknown }).picture === "string"
+ ? ((profile as { picture?: string }).picture ?? null)
+ : null,
+ locale:
+ profile && typeof (profile as { locale?: unknown }).locale === "string"
+ ? normalizeLocale((profile as { locale?: string }).locale ?? null)
+ : null,
+ });
+ },
+ },
};
export function getServerAuthSession() {
diff --git a/lib/blocks.ts b/lib/blocks.ts
index c2bfc87..bf20a37 100644
--- a/lib/blocks.ts
+++ b/lib/blocks.ts
@@ -1,6 +1,8 @@
import { FormBlockType, type FormBlock } from "@prisma/client";
import { z } from "zod";
+import { DEFAULT_LOCALE, type AppLocale } from "@/lib/i18n";
+
export const blockTypeLabels: Record = {
TEXT: "Text block",
SHORT_TEXT: "Short text",
@@ -9,20 +11,47 @@ export const blockTypeLabels: Record = {
MULTIPLE_CHOICE: "Multiple choice",
};
+export const blockTypeTranslationKeys: Record = {
+ TEXT: "blocks.types.TEXT",
+ SHORT_TEXT: "blocks.types.SHORT_TEXT",
+ LONG_TEXT: "blocks.types.LONG_TEXT",
+ SINGLE_CHOICE: "blocks.types.SINGLE_CHOICE",
+ MULTIPLE_CHOICE: "blocks.types.MULTIPLE_CHOICE",
+};
+
+const defaultBlockCopyByLocale: Record = {
+ en: {
+ textBody: "Introduce the next part of your form.",
+ shortPlaceholder: "Type your answer here…",
+ longPlaceholder: "Tell us a little more…",
+ options: ["Option 1", "Option 2"],
+ },
+ ru: {
+ textBody: "Подготовьте человека к следующей части формы.",
+ shortPlaceholder: "Введите ответ…",
+ longPlaceholder: "Расскажите немного подробнее…",
+ options: ["Вариант 1", "Вариант 2"],
+ },
+};
+
+function getDefaultBlockCopy(locale: AppLocale = DEFAULT_LOCALE) {
+ return defaultBlockCopyByLocale[locale] ?? defaultBlockCopyByLocale[DEFAULT_LOCALE];
+}
+
const textConfigSchema = z.object({
- body: z.string().max(2400).default("Introduce the next part of your form."),
+ body: z.string().max(2400).default(defaultBlockCopyByLocale.en.textBody),
});
const shortTextConfigSchema = z.object({
- placeholder: z.string().max(120).default("Type your answer here…"),
+ placeholder: z.string().max(120).default(defaultBlockCopyByLocale.en.shortPlaceholder),
});
const longTextConfigSchema = z.object({
- placeholder: z.string().max(240).default("Tell us a little more…"),
+ placeholder: z.string().max(240).default(defaultBlockCopyByLocale.en.longPlaceholder),
});
const optionListSchema = z.object({
- options: z.array(z.string().min(1).max(120)).min(2).max(10).default(["Option 1", "Option 2"]),
+ options: z.array(z.string().min(1).max(120)).min(2).max(10).default(defaultBlockCopyByLocale.en.options),
});
export type TextBlockConfig = z.infer;
@@ -43,19 +72,21 @@ export function isQuestionBlock(type: FormBlockType) {
return type !== FormBlockType.TEXT;
}
-export function getDefaultBlockConfig(type: FormBlockType): BlockConfig {
+export function getDefaultBlockConfig(type: FormBlockType, locale: AppLocale = DEFAULT_LOCALE): BlockConfig {
+ const copy = getDefaultBlockCopy(locale);
+
switch (type) {
case FormBlockType.TEXT:
- return textConfigSchema.parse({});
+ return textConfigSchema.parse({ body: copy.textBody });
case FormBlockType.SHORT_TEXT:
- return shortTextConfigSchema.parse({});
+ return shortTextConfigSchema.parse({ placeholder: copy.shortPlaceholder });
case FormBlockType.LONG_TEXT:
- return longTextConfigSchema.parse({});
+ return longTextConfigSchema.parse({ placeholder: copy.longPlaceholder });
case FormBlockType.SINGLE_CHOICE:
case FormBlockType.MULTIPLE_CHOICE:
- return optionListSchema.parse({});
+ return optionListSchema.parse({ options: copy.options });
default:
- return textConfigSchema.parse({});
+ return textConfigSchema.parse({ body: copy.textBody });
}
}
@@ -82,7 +113,7 @@ export function serializeBlock(block: FormBlock): SerializedBlock {
};
}
-export function getBlockPreview(block: Pick) {
+export function getBlockPreview(block: Pick, fallbackLabel?: string) {
const config = parseBlockConfig(block.type, block.config);
if (block.title.trim()) {
@@ -101,5 +132,5 @@ export function getBlockPreview(block: Pick;
+}> = {
+ en: {
+ untitledForm: "Untitled form",
+ initialBlocks: [
+ {
+ type: FormBlockType.TEXT,
+ title: "Welcome",
+ description: "Set the scene before people start answering.",
+ position: 0,
+ required: false,
+ config: getDefaultBlockConfig(FormBlockType.TEXT, "en"),
+ },
+ {
+ type: FormBlockType.SHORT_TEXT,
+ title: "What should we call you?",
+ description: "A simple first question keeps the flow moving.",
+ position: 1,
+ required: true,
+ config: getDefaultBlockConfig(FormBlockType.SHORT_TEXT, "en"),
+ },
+ ],
+ newBlockTitles: {
+ TEXT: "Section note",
+ SHORT_TEXT: "Short text question",
+ LONG_TEXT: "Long text question",
+ SINGLE_CHOICE: "Single choice question",
+ MULTIPLE_CHOICE: "Multiple choice question",
},
- {
- type: FormBlockType.SHORT_TEXT,
- title: "What should we call you?",
- description: "A simple first question keeps the flow moving.",
- position: 1,
- required: true,
- config: getDefaultBlockConfig(FormBlockType.SHORT_TEXT),
+ },
+ ru: {
+ untitledForm: "Форма без названия",
+ initialBlocks: [
+ {
+ type: FormBlockType.TEXT,
+ title: "Добро пожаловать",
+ description: "Задайте тон перед тем, как люди начнут отвечать.",
+ position: 0,
+ required: false,
+ config: getDefaultBlockConfig(FormBlockType.TEXT, "ru"),
+ },
+ {
+ type: FormBlockType.SHORT_TEXT,
+ title: "Как к вам обращаться?",
+ description: "Простой первый вопрос помогает сохранить ритм.",
+ position: 1,
+ required: true,
+ config: getDefaultBlockConfig(FormBlockType.SHORT_TEXT, "ru"),
+ },
+ ],
+ newBlockTitles: {
+ TEXT: "Текстовый блок",
+ SHORT_TEXT: "Вопрос с коротким ответом",
+ LONG_TEXT: "Вопрос с длинным ответом",
+ SINGLE_CHOICE: "Вопрос с одним выбором",
+ MULTIPLE_CHOICE: "Вопрос с несколькими вариантами",
},
- ];
+ },
+};
+
+function getDefaultFormCopy(locale: AppLocale = DEFAULT_LOCALE) {
+ return defaultFormCopyByLocale[locale] ?? defaultFormCopyByLocale[DEFAULT_LOCALE];
+}
+
+function initialBlocks(locale: AppLocale = DEFAULT_LOCALE): Prisma.FormBlockCreateWithoutFormInput[] {
+ return getDefaultFormCopy(locale).initialBlocks;
}
async function ensureUniqueSlug(slug: string, exceptFormId?: string) {
@@ -363,22 +414,24 @@ export async function listFormsForWorkspace(userId: string, workspace: ActiveWor
}));
}
-export async function createDraftForm(userId: string, workspace: ActiveWorkspace) {
+export async function createDraftForm(userId: string, workspace: ActiveWorkspace, locale: AppLocale = DEFAULT_LOCALE) {
if (workspace.kind === "organization") {
await assertOrganizationMember(userId, workspace.organizationId);
}
const slug = await createUniqueSlug(`untitled-form-${Math.random().toString(36).slice(2, 6)}`);
+ const copy = getDefaultFormCopy(locale);
+
const form = await db.form.create({
data: {
userId,
organizationId: workspace.kind === "organization" ? workspace.organizationId : null,
- title: "Untitled form",
+ title: copy.untitledForm,
description: "",
slug,
blocks: {
- create: initialBlocks(),
+ create: initialBlocks(locale),
},
},
include: builderFormInclude,
@@ -433,18 +486,12 @@ export async function setOwnedFormPublished(userId: string, formId: string, payl
return toBuilderForm(updated);
}
-export async function addOwnedBlock(userId: string, formId: string, payload: unknown) {
+export async function addOwnedBlock(userId: string, formId: string, payload: unknown, locale: AppLocale = DEFAULT_LOCALE) {
const parsed = createBlockSchema.parse(payload);
const form = await assertAccessibleForm(userId, formId);
const position = form.blocks.length;
- const titleByType: Record = {
- TEXT: "Section note",
- SHORT_TEXT: "Short text question",
- LONG_TEXT: "Long text question",
- SINGLE_CHOICE: "Single choice question",
- MULTIPLE_CHOICE: "Multiple choice question",
- };
+ const titleByType = getDefaultFormCopy(locale).newBlockTitles;
const created = await db.formBlock.create({
data: {
@@ -454,7 +501,7 @@ export async function addOwnedBlock(userId: string, formId: string, payload: unk
description: "",
required: false,
position,
- config: getDefaultBlockConfig(parsed.type),
+ config: getDefaultBlockConfig(parsed.type, locale),
},
});
diff --git a/lib/i18n-server.ts b/lib/i18n-server.ts
new file mode 100644
index 0000000..301def0
--- /dev/null
+++ b/lib/i18n-server.ts
@@ -0,0 +1,43 @@
+import "server-only";
+
+import { headers } from "next/headers";
+import { cache } from "react";
+import { readFile } from "node:fs/promises";
+import path from "node:path";
+import YAML from "yaml";
+
+import { getServerAuthSession } from "@/lib/auth";
+import { DEFAULT_LOCALE, resolveBrowserLocale, translate, type AppLocale, type TranslationTree } from "@/lib/i18n";
+
+const localeFileByKey: Record = {
+ en: "en.yml",
+ ru: "ru.yml",
+};
+
+export const getMessages = cache(async (locale: AppLocale): Promise => {
+ const filePath = path.join(process.cwd(), "locales", localeFileByKey[locale]);
+ const file = await readFile(filePath, "utf8");
+ return YAML.parse(file) as TranslationTree;
+});
+
+export async function getRequestLocale(): Promise {
+ const session = await getServerAuthSession();
+
+ if (session?.user?.locale) {
+ return session.user.locale;
+ }
+
+ const headerStore = await headers();
+ return resolveBrowserLocale(headerStore.get("accept-language") ?? DEFAULT_LOCALE);
+}
+
+export async function getRequestI18n() {
+ const locale = await getRequestLocale();
+ const messages = await getMessages(locale);
+
+ return {
+ locale,
+ messages,
+ t: (key: string, values?: Record) => translate(messages, key, values),
+ };
+}
diff --git a/lib/i18n.ts b/lib/i18n.ts
new file mode 100644
index 0000000..7c7e22f
--- /dev/null
+++ b/lib/i18n.ts
@@ -0,0 +1,80 @@
+import type { ReactNode } from "react";
+
+export const supportedLocales = ["en", "ru"] as const;
+export type AppLocale = (typeof supportedLocales)[number];
+export const DEFAULT_LOCALE: AppLocale = "en";
+
+export type TranslationTree = {
+ [key: string]: string | TranslationTree;
+};
+
+export function isSupportedLocale(value: string | null | undefined): value is AppLocale {
+ return Boolean(value && supportedLocales.includes(value as AppLocale));
+}
+
+export function normalizeLocale(value: string | null | undefined): AppLocale {
+ const normalized = value?.trim().toLowerCase().replace("_", "-") ?? "";
+ const base = normalized.split("-")[0] ?? "";
+ return isSupportedLocale(base) ? base : DEFAULT_LOCALE;
+}
+
+export function resolveBrowserLocale(headerValue: string | null | undefined): AppLocale {
+ if (!headerValue) {
+ return DEFAULT_LOCALE;
+ }
+
+ for (const rawPart of headerValue.split(",")) {
+ const locale = rawPart.split(";")[0]?.trim() ?? "";
+ const normalized = normalizeLocale(locale);
+
+ if (isSupportedLocale(locale) || isSupportedLocale(locale.split("-")[0] ?? "")) {
+ return normalized;
+ }
+ }
+
+ return DEFAULT_LOCALE;
+}
+
+function getValue(messages: TranslationTree, key: string): string | null {
+ let current: string | TranslationTree | undefined = messages;
+
+ for (const part of key.split(".")) {
+ if (!current || typeof current === "string" || !(part in current)) {
+ return null;
+ }
+
+ current = current[part];
+ }
+
+ return typeof current === "string" ? current : null;
+}
+
+export function interpolateMessage(template: string, values?: Record) {
+ if (!values) {
+ return template;
+ }
+
+ return template.replace(/\{(.*?)\}/g, (_, key) => String(values[key.trim()] ?? `{${key}}`));
+}
+
+export function translate(messages: TranslationTree, key: string, values?: Record) {
+ const template = getValue(messages, key) ?? key;
+ return interpolateMessage(template, values);
+}
+
+export function flattenTranslationTree(tree: TranslationTree, prefix = ""): Record {
+ const entries: Record = {};
+
+ for (const [key, value] of Object.entries(tree)) {
+ const nextKey = prefix ? `${prefix}.${key}` : key;
+
+ if (typeof value === "string") {
+ entries[nextKey] = value;
+ continue;
+ }
+
+ Object.assign(entries, flattenTranslationTree(value, nextKey));
+ }
+
+ return entries;
+}
diff --git a/lib/organizations.ts b/lib/organizations.ts
index 5309a62..0748306 100644
--- a/lib/organizations.ts
+++ b/lib/organizations.ts
@@ -26,12 +26,21 @@ function normalizeOrganizationName(value: unknown) {
function toMemberSummary(member: {
userId: string;
role: OrganizationMemberRole;
- user: { name: string | null; email: string | null };
+ user: {
+ name: string | null;
+ firstName: string | null;
+ secondName: string | null;
+ email: string | null;
+ image: string | null;
+ };
}): OrganizationMemberSummary {
return {
userId: member.userId,
name: member.user.name,
+ firstName: member.user.firstName,
+ secondName: member.user.secondName,
email: member.user.email,
+ image: member.user.image,
role: member.role,
};
}
@@ -61,7 +70,10 @@ export async function listOrganizationsForUser(userId: string): Promise = {
+ en: {
+ metadataColumns: [
+ { key: "submissionNumber", label: "Submission #" },
+ { key: "submittedAt", label: "Submitted at" },
+ { key: "responseId", label: "Response ID" },
+ ],
+ questionFallback: "Question {number}",
+ sheetName: "Responses",
+ },
+ ru: {
+ metadataColumns: [
+ { key: "submissionNumber", label: "Ответ №" },
+ { key: "submittedAt", label: "Дата ответа" },
+ { key: "responseId", label: "ID ответа" },
+ ],
+ questionFallback: "Вопрос {number}",
+ sheetName: "Ответы",
+ },
+};
+
+function getExportCopy(locale: AppLocale = DEFAULT_LOCALE) {
+ return exportCopyByLocale[locale] ?? exportCopyByLocale[DEFAULT_LOCALE];
+}
export async function loadOwnedFormResponsesForExport(userId: string, formId: string) {
const form = await db.form.findFirst({
@@ -64,8 +88,9 @@ export async function loadOwnedFormResponsesForExport(userId: string, formId: st
return form;
}
-function createQuestionColumnLabel(block: SerializedBlock, questionIndex: number, seenLabels: Map) {
- const baseLabel = block.title.trim() || `Question ${questionIndex}`;
+function createQuestionColumnLabel(block: SerializedBlock, questionIndex: number, seenLabels: Map, locale: AppLocale) {
+ const copy = getExportCopy(locale);
+ const baseLabel = block.title.trim() || copy.questionFallback.replace("{number}", String(questionIndex));
const occurrence = (seenLabels.get(baseLabel) ?? 0) + 1;
seenLabels.set(baseLabel, occurrence);
@@ -80,15 +105,16 @@ export function serializeExportAnswer(value: ExportAnswerValue) {
return typeof value === "string" ? value : "";
}
-export function buildResponseExportDataset(form: OwnedFormExportRecord): ResponseExportDataset {
+export function buildResponseExportDataset(form: OwnedFormExportRecord, locale: AppLocale = DEFAULT_LOCALE): ResponseExportDataset {
const questionBlocks = form.blocks.map(serializeBlock).filter((block) => isQuestionBlock(block.type));
const seenLabels = new Map();
+ const copy = getExportCopy(locale);
const columns = [
- ...METADATA_COLUMNS,
+ ...copy.metadataColumns,
...questionBlocks.map((block, index) => ({
key: block.id,
- label: createQuestionColumnLabel(block, index + 1, seenLabels),
+ label: createQuestionColumnLabel(block, index + 1, seenLabels, locale),
blockId: block.id,
})),
];
@@ -144,14 +170,14 @@ export function createCsvExportBuffer(dataset: ResponseExportDataset) {
return Buffer.from(`\uFEFF${content}`, "utf8");
}
-export function createXlsxExportBuffer(dataset: ResponseExportDataset) {
+export function createXlsxExportBuffer(dataset: ResponseExportDataset, locale: AppLocale = DEFAULT_LOCALE) {
const worksheet = utils.aoa_to_sheet([
dataset.columns.map((column) => column.label),
...dataset.rows.map((row) => dataset.columns.map((column) => row.values[column.key] ?? "")),
]);
const workbook = utils.book_new();
- utils.book_append_sheet(workbook, worksheet, "Responses");
+ utils.book_append_sheet(workbook, worksheet, getExportCopy(locale).sheetName);
return write(workbook, {
type: "buffer",
@@ -178,12 +204,12 @@ export function parseResponseExportFormat(value: string | null): ResponseExportF
throw new AppError("Unsupported export format.", 422);
}
-export async function createOwnedFormResponseExport(userId: string, formId: string, format: ResponseExportFormat) {
+export async function createOwnedFormResponseExport(userId: string, formId: string, format: ResponseExportFormat, locale: AppLocale = DEFAULT_LOCALE) {
const form = await loadOwnedFormResponsesForExport(userId, formId);
- const dataset = buildResponseExportDataset(form);
+ const dataset = buildResponseExportDataset(form, locale);
const filename = getResponseExportFilename(form.slug, format);
const contentType = getResponseExportContentType(format);
- const body = format === "csv" ? createCsvExportBuffer(dataset) : createXlsxExportBuffer(dataset);
+ const body = format === "csv" ? createCsvExportBuffer(dataset) : createXlsxExportBuffer(dataset, locale);
return {
filename,
diff --git a/lib/user-identity.ts b/lib/user-identity.ts
new file mode 100644
index 0000000..ab3bf21
--- /dev/null
+++ b/lib/user-identity.ts
@@ -0,0 +1,67 @@
+export type UserIdentityLike = {
+ name?: string | null;
+ firstName?: string | null;
+ secondName?: string | null;
+ email?: string | null;
+ image?: string | null;
+};
+
+function clean(value: string | null | undefined) {
+ const nextValue = value?.trim() ?? "";
+ return nextValue || null;
+}
+
+export function getComposedName(firstName?: string | null, secondName?: string | null) {
+ const composed = [clean(firstName), clean(secondName)].filter(Boolean).join(" ").trim();
+ return composed || null;
+}
+
+export function splitNameParts(value?: string | null) {
+ const source = clean(value);
+
+ if (!source) {
+ return { firstName: null, secondName: null };
+ }
+
+ const [firstName, ...rest] = source.split(/\s+/);
+ return {
+ firstName: clean(firstName),
+ secondName: clean(rest.join(" ")),
+ };
+}
+
+export function getUserDisplayName(user: UserIdentityLike) {
+ return getComposedName(user.firstName, user.secondName) ?? clean(user.name) ?? clean(user.email)?.split("@")[0] ?? "User";
+}
+
+export function getUserImage(user: UserIdentityLike) {
+ return clean(user.image);
+}
+
+export function getUserInitials(user: UserIdentityLike) {
+ const firstName = clean(user.firstName);
+ const secondName = clean(user.secondName);
+
+ if (firstName && secondName) {
+ return `${firstName[0]}${secondName[0]}`.toUpperCase();
+ }
+
+ if (firstName) {
+ return firstName.slice(0, 2).toUpperCase();
+ }
+
+ const name = clean(user.name);
+
+ if (name) {
+ const parts = name.split(/\s+/).filter(Boolean);
+
+ if (parts.length >= 2) {
+ return `${parts[0]?.[0] ?? ""}${parts[1]?.[0] ?? ""}`.toUpperCase();
+ }
+
+ return parts[0]?.slice(0, 2).toUpperCase() ?? "U";
+ }
+
+ const emailLocalPart = clean(user.email)?.split("@")[0] ?? "U";
+ return emailLocalPart.slice(0, 2).toUpperCase();
+}
diff --git a/lib/users.ts b/lib/users.ts
new file mode 100644
index 0000000..4366913
--- /dev/null
+++ b/lib/users.ts
@@ -0,0 +1,174 @@
+import { db } from "@/lib/db";
+import { AppLocale, DEFAULT_LOCALE, normalizeLocale } from "@/lib/i18n";
+import { AppError } from "@/lib/errors";
+import type { ProfileSettingsUserSummary } from "@/lib/form-types";
+import { getComposedName, splitNameParts } from "@/lib/user-identity";
+import { profileSettingsSchema } from "@/lib/validators";
+
+function toProfileSettingsUserSummary(user: {
+ id: string;
+ name: string | null;
+ firstName: string | null;
+ secondName: string | null;
+ locale: string | null;
+ email: string | null;
+ image: string | null;
+}): ProfileSettingsUserSummary {
+ return {
+ id: user.id,
+ name: user.name,
+ firstName: user.firstName,
+ secondName: user.secondName,
+ locale: normalizeLocale(user.locale),
+ email: user.email,
+ image: user.image,
+ };
+}
+
+export async function getProfileSettingsUser(userId: string): Promise {
+ const user = await db.user.findUnique({
+ where: { id: userId },
+ select: {
+ id: true,
+ name: true,
+ firstName: true,
+ secondName: true,
+ locale: true,
+ email: true,
+ image: true,
+ },
+ });
+
+ if (!user) {
+ throw new AppError("User not found.", 404);
+ }
+
+ return toProfileSettingsUserSummary(user);
+}
+
+export async function updateProfileSettings(userId: string, payload: { firstName: unknown; secondName: unknown; locale: unknown; image: unknown }) {
+ const parsed = profileSettingsSchema.parse(payload);
+ const name = getComposedName(parsed.firstName, parsed.secondName);
+
+ return db.user.update({
+ where: { id: userId },
+ data: {
+ name,
+ firstName: parsed.firstName,
+ secondName: parsed.secondName,
+ locale: parsed.locale,
+ image: parsed.image,
+ },
+ select: {
+ id: true,
+ name: true,
+ firstName: true,
+ secondName: true,
+ locale: true,
+ email: true,
+ image: true,
+ },
+ });
+}
+
+type GoogleProfilePayload = {
+ name?: string | null;
+ given_name?: string | null;
+ family_name?: string | null;
+ picture?: string | null;
+ locale?: string | null;
+};
+
+function clean(value: string | null | undefined, maxLength = 120) {
+ const nextValue = value?.trim() ?? "";
+ return nextValue ? nextValue.slice(0, maxLength) : null;
+}
+
+function normalizeImageUrl(value: string | null | undefined) {
+ const nextValue = clean(value, 2048);
+
+ if (!nextValue) {
+ return null;
+ }
+
+ try {
+ const url = new URL(nextValue);
+ if (url.protocol === "http:" || url.protocol === "https:") {
+ return nextValue;
+ }
+ } catch {
+ return null;
+ }
+
+ return null;
+}
+
+export async function syncGoogleProfileFields(
+ userId: string,
+ profile: GoogleProfilePayload | null | undefined,
+) {
+ if (!profile) {
+ return;
+ }
+
+ const currentUser = await db.user.findUnique({
+ where: { id: userId },
+ select: {
+ id: true,
+ name: true,
+ firstName: true,
+ secondName: true,
+ locale: true,
+ image: true,
+ },
+ });
+
+ if (!currentUser) {
+ return;
+ }
+
+ const parsedName = splitNameParts(clean(profile.name));
+ const nextFirstName = clean(profile.given_name, 60) ?? parsedName.firstName;
+ const nextSecondName = clean(profile.family_name, 60) ?? parsedName.secondName;
+ const nextName = clean(profile.name) ?? getComposedName(nextFirstName, nextSecondName);
+ const nextImage = normalizeImageUrl(profile.picture);
+ const hasProvidedLocale = typeof profile.locale === "string" && profile.locale.trim().length > 0;
+ const nextLocale = hasProvidedLocale ? normalizeLocale(profile.locale) : null;
+
+ const data: {
+ firstName?: string;
+ secondName?: string;
+ name?: string;
+ locale?: AppLocale;
+ image?: string;
+ } = {};
+
+ if (!currentUser.firstName?.trim() && nextFirstName) {
+ data.firstName = nextFirstName;
+ }
+
+ if (!currentUser.secondName?.trim() && nextSecondName) {
+ data.secondName = nextSecondName;
+ }
+
+ if (!currentUser.name?.trim() && nextName) {
+ data.name = nextName;
+ }
+
+ if (!currentUser.locale && hasProvidedLocale) {
+ data.locale = nextLocale ?? DEFAULT_LOCALE;
+ }
+
+ if (!currentUser.image?.trim() && nextImage) {
+ data.image = nextImage;
+ }
+
+ if (Object.keys(data).length === 0) {
+ return;
+ }
+
+ await db.user.update({
+ where: { id: userId },
+ data,
+ });
+}
diff --git a/lib/utils.ts b/lib/utils.ts
index b3b83e5..554dd4f 100644
--- a/lib/utils.ts
+++ b/lib/utils.ts
@@ -14,10 +14,10 @@ export function slugify(value: string) {
.slice(0, 48);
}
-export function formatDate(value: Date | string) {
+export function formatDate(value: Date | string, locale = "en") {
const date = typeof value === "string" ? new Date(value) : value;
- return new Intl.DateTimeFormat("en", {
+ return new Intl.DateTimeFormat(locale, {
dateStyle: "medium",
timeStyle: "short",
}).format(date);
diff --git a/lib/validators.ts b/lib/validators.ts
index a9b3ac5..cf1b1a1 100644
--- a/lib/validators.ts
+++ b/lib/validators.ts
@@ -1,6 +1,8 @@
import { FormBlockType } from "@prisma/client";
import { z } from "zod";
+import { supportedLocales } from "@/lib/i18n";
+
export const formMetadataSchema = z
.object({
title: z.string().trim().min(1).max(120),
@@ -74,3 +76,40 @@ export const reorderBlocksSchema = z.object({
export const publishFormSchema = z.object({
published: z.boolean(),
});
+
+export const profileSettingsSchema = z
+ .object({
+ firstName: z.string().trim().max(60).default(""),
+ secondName: z.string().trim().max(60).default(""),
+ locale: z.enum(supportedLocales).default("en"),
+ image: z.string().trim().max(2048).default(""),
+ })
+ .superRefine((value, context) => {
+ if (!value.image) {
+ return;
+ }
+
+ try {
+ const parsedUrl = new URL(value.image);
+
+ if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
+ context.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ["image"],
+ message: "Use a full http:// or https:// URL for the profile image.",
+ });
+ }
+ } catch {
+ context.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ["image"],
+ message: "Use a valid profile image URL.",
+ });
+ }
+ })
+ .transform((value) => ({
+ firstName: value.firstName || null,
+ secondName: value.secondName || null,
+ locale: value.locale,
+ image: value.image || null,
+ }));
diff --git a/locales/en.yml b/locales/en.yml
new file mode 100644
index 0000000..28a3285
--- /dev/null
+++ b/locales/en.yml
@@ -0,0 +1,236 @@
+app:
+ name: Lively Forms
+ description: Lively Forms is a conversational form builder for creating, publishing, and reviewing forms.
+ goToDashboardAria: Go to dashboard
+home:
+ description: "Like a seed in good soil, every thoughtful question has the power to make something grow. Build forms that feel alive—gentle invitations to reflect, respond, and uncover what matters most."
+ openDashboard: Open dashboard
+auth:
+ continueWithGoogle: Continue with Google
+workspace:
+ selectAria: Select workspace
+ personal: Personal workspace
+ ownerSuffix: Owner
+account:
+ openMenuAria: Open account menu
+ settings: Settings
+ logout: Log out
+statuses:
+ draft: Draft
+ published: Published
+loading:
+ page: "Loading {title}…"
+ titles:
+ dashboard: dashboard
+ publicForm: public form
+ formBuilder: form builder
+ responses: responses
+settings:
+ shell:
+ title: Settings
+ description: Manage your identity, shared workspaces, and how Lively Forms looks across creator pages and public forms.
+ sections:
+ profile:
+ title: Profile
+ description: Name, avatar, and account identity.
+ organizations:
+ title: Organizations
+ description: Members, invites, and owner controls.
+ appearance:
+ title: Appearance
+ description: Theme and visual preferences.
+ profile:
+ title: Your profile
+ description: Choose how your identity appears across creator surfaces, organization member lists, and the navbar menu.
+ profilePicture: Profile picture
+ profilePictureDescription: Paste a public image URL to set or replace your current avatar.
+ firstName: First name
+ secondName: Second name
+ imageUrl: Profile image URL
+ imageUrlHelp: Google can populate this initially. You can replace it with any public image URL.
+ locale: Language
+ localeHelp: Choose the language used across the app for your account.
+ save: Save
+ localeOptions:
+ en: English
+ ru: Russian
+ appearance:
+ title: Theme settings
+ description: Choose how Lively Forms looks across creator pages, public forms, and the home page.
+ options:
+ light:
+ title: Light
+ description: Always use the light theme.
+ dark:
+ title: Dark
+ description: Always use the dark theme.
+ system:
+ title: System
+ description: Follow your device theme automatically.
+ organizations:
+ title: Workspace settings
+ description: Create shared workspaces, manage members, and issue invite links. Inside an organization, every member can work with forms. Only the owner can manage the organization itself.
+ newOrganizationName: New organization name
+ newOrganizationPlaceholder: Alive Org
+ createOrganization: Create organization
+ noneYet: You are not part of any organizations yet.
+ organizationLabel: Organization
+ selectedOrgHelp: The settings below apply to the selected organization.
+ owner: Owner
+ member: Member
+ renameOrganization: Rename organization
+ members: Members
+ removeMember: Remove
+ inviteLinks: Invite links
+ newInviteLink: New invite link
+ noInviteLinks: No invite links yet.
+ inviteCreated: "Created {date}"
+ revokeInvite: Revoke
+ dangerZone: Danger zone
+ deleteOrgHelp: Deleting an organization also deletes its forms, responses, and invite links.
+ deleteOrganization: Delete organization
+ deleteOrganizationTitle: Delete organization?
+ deleteOrganizationConfirmDescription: This will also delete its forms, responses, and invite links. This action cannot be undone.
+ copyInviteSuccess: Copied invite link.
+ copyInviteError: Could not copy invite link.
+dashboard:
+ description: Create a form, open a draft, or review responses in the active workspace.
+ newForm: New form
+ noFormsEyebrow: No forms yet
+ noFormsTitle: "Create the first form in {workspace}"
+ noFormsDescription: New forms start as drafts so you can edit before publishing.
+ viewGrid: Grid
+ viewTable: Table
+ noDescription: No description yet.
+ shareUrl: Share URL
+ titleColumn: Title
+ statusColumn: Status
+ responses: Responses
+ updated: Updated
+ open: Open
+builder:
+ description: Edit blocks, update settings, and review responses.
+ settings: Settings
+ responses: "Responses ({count})"
+ publish: Publish form
+ unpublish: Unpublish
+ blocks: Blocks
+ newBlock: New block
+ settingsTitle: Settings
+ titleField: Title
+ descriptionField: Description
+ shareSlug: Public URL address
+ afterSubmission: After submission
+ afterSubmissionDescription: Customize what respondents see after they finish the form, including an optional next-step link.
+ completionTitle: Completion title
+ completionMessage: Completion message
+ followUpLabel: Follow-up link label
+ followUpUrl: Follow-up link URL
+ followUpLabelPlaceholder: Read next steps
+ followUpUrlPlaceholder: https://example.com/next
+ publicRoute: Public route
+ publicRouteHelp: Published forms accept anonymous submissions. Changes to a published form go live immediately.
+ copyLink: Copy link
+ openRunner: Open runner
+ deleteForm: Delete form
+ deleteFormTitle: Delete form?
+ deleteFormDescription: This will also delete all blocks and responses. This action cannot be undone.
+ saveFormSettings: Save form settings
+ dragBlockAria: "Drag {label}"
+ prompt: Prompt
+ supportText: Support text
+ bodyCopy: Body copy
+ placeholder: Placeholder
+ choiceOptions: Choice options
+ addOption: Add option
+ option: "Option {number}"
+ dragOptionAria: "Drag option {number}"
+ removeOptionAria: "Remove option {number}"
+ optionsHelp: Drag to reorder, rename in place, and keep between 2 and 10 options.
+ requiredToggle: Mark this question as required
+ deleteBlock: Delete block
+ saveBlock: Save block
+ nothingSelected: Nothing selected
+ nothingSelectedTitle: Select a block or open form settings.
+ nothingSelectedDescription: Use the left panel to choose what you want to edit.
+ addShortTextQuestion: Add a short text question
+ toasts:
+ genericError: Something went wrong.
+ savedFormSettings: Saved form settings
+ addedBlock: "Added {type}"
+ savedBlock: Saved block
+ removedBlock: Removed block
+ formPublished: Form published
+ formDrafted: Form moved back to draft
+ copiedShareLink: Copied share link
+ deleteFormConfirm: Delete this form permanently? This will also delete all blocks and responses. This action cannot be undone.
+ updatedBlockOrder: Updated block order
+ reorderError: Could not reorder blocks.
+blocks:
+ types:
+ TEXT: Text block
+ SHORT_TEXT: Short text
+ LONG_TEXT: Long text
+ SINGLE_CHOICE: Single choice
+ MULTIPLE_CHOICE: Multiple choice
+publicRunner:
+ responseReceived: Response received
+ defaultCompletionTitle: Thanks for taking the time.
+ defaultCompletionMessage: Your response was submitted anonymously. The creator can review your answers, but they are not linked to a login or account.
+ step: "Step {current} of {total}"
+ context: Context
+ defaultTextTitle: Take a breath before the next step.
+ question: Question
+ required: Required
+ chooseOne: Choose one option.
+ selectAll: Select all that apply.
+ answerRequired: Please answer before continuing.
+ singleChoiceRequired: Choose one option before continuing.
+ multiChoiceRequired: Choose at least one option before continuing.
+ submitError: Could not submit response.
+ back: Back
+ continue: Continue
+ submit: Submit
+responses:
+ description: Review anonymous submissions in form order.
+ submissionsCount: "{count} submissions"
+ backToBuilder: Back to builder
+ noSubmissionsEyebrow: No submissions yet
+ noSubmissionsTitle: Publish the form to start collecting responses.
+ noSubmissionsDescription: Only published forms are available on the public route, and every respondent stays anonymous on submission.
+ openBuilder: Open builder
+ submissionTitle: "Submission #{number} · {date}"
+ answersCount: "{count} answers"
+ open: Open
+responseDetail:
+ title: "Submission #{number} · {date}"
+ description: Review submitted answers in form order.
+ backToResponses: Back to responses
+ contextBlock: Context block
+ contextTitle: Context
+ required: Required
+ noAnswer: No answer provided.
+responseExport:
+ export: Export
+ exportCsv: Export CSV
+ exportXlsx: Export XLSX
+ downloaded: "Downloaded {format} export."
+ error: Could not export responses.
+join:
+ joinTitle: "Join {organization}"
+ alreadyMember: You already belong to this organization. You can switch to it from the workspace selector.
+ inviteDescription: Accept this invite to collaborate in the organization workspace.
+ openDashboard: Open dashboard
+ joinOrganization: Join organization
+ cancel: Cancel
+ unavailableTitle: Invite not available
+ unavailableDescription: This invite link is invalid or has been revoked.
+ backToDashboard: Back to dashboard
+notFound:
+ title: That page or form doesn’t exist.
+ description: If you were expecting a public form, it may still be in draft. If you were editing a creator form, you may not have access to it.
+ backHome: Back home
+ui:
+ cancel: Cancel
+ delete: Delete
+ dismissNotification: Dismiss notification
diff --git a/locales/ru.yml b/locales/ru.yml
new file mode 100644
index 0000000..a241f02
--- /dev/null
+++ b/locales/ru.yml
@@ -0,0 +1,236 @@
+app:
+ name: Живые формы
+ description: Живые формы — это конструктор диалоговых форм для создания, публикации и просмотра форм.
+ goToDashboardAria: Перейти в дашборд
+home:
+ description: "Как семя в хорошей почве, каждый продуманный вопрос может помочь чему-то вырасти. Создавайте формы, которые ощущаются живыми — мягкими приглашениями подумать, ответить и понять, что действительно важно."
+ openDashboard: Открыть дашборд
+auth:
+ continueWithGoogle: Продолжить с Google
+workspace:
+ selectAria: Выбрать рабочее пространство
+ personal: Личное пространство
+ ownerSuffix: Владелец
+account:
+ openMenuAria: Открыть меню аккаунта
+ settings: Настройки
+ logout: Выйти
+statuses:
+ draft: Черновик
+ published: Опубликовано
+loading:
+ page: "Загрузка: {title}…"
+ titles:
+ dashboard: дашборд
+ publicForm: публичная форма
+ formBuilder: редактор формы
+ responses: ответы
+settings:
+ shell:
+ title: Настройки
+ description: Управляйте своей идентичностью, общими рабочими пространствами и тем, как Живые формы выглядят на страницах для создателя и в публичных формах.
+ sections:
+ profile:
+ title: Профиль
+ description: Имя, аватар и идентичность аккаунта.
+ organizations:
+ title: Организации
+ description: Участники, приглашения и управление владельца.
+ appearance:
+ title: Внешний вид
+ description: Тема и визуальные настройки.
+ profile:
+ title: Ваш профиль
+ description: Выберите, как ваша идентичность отображается в интерфейсе создателя, списках участников организации и меню в навбаре.
+ profilePicture: Аватар
+ profilePictureDescription: Вставьте публичный URL изображения, чтобы установить или заменить текущий аватар.
+ firstName: Имя
+ secondName: Фамилия
+ imageUrl: URL изображения профиля
+ imageUrlHelp: Google может заполнить это поле изначально. Позже вы можете заменить его любым публичным URL изображения.
+ locale: Язык
+ localeHelp: Выберите язык интерфейса для вашего аккаунта.
+ save: Сохранить
+ localeOptions:
+ en: Английский
+ ru: Русский
+ appearance:
+ title: Настройки темы
+ description: Выберите, как Живые формы выглядят на страницах создателя, в публичных формах и на главной странице.
+ options:
+ light:
+ title: Светлая
+ description: Всегда использовать светлую тему.
+ dark:
+ title: Тёмная
+ description: Всегда использовать тёмную тему.
+ system:
+ title: Системная
+ description: Следовать теме вашего устройства автоматически.
+ organizations:
+ title: Настройки рабочего пространства
+ description: Создавайте общие рабочие пространства, управляйте участниками и создавайте ссылки-приглашения. Внутри организации каждый участник может работать с формами. Только владелец может управлять самой организацией.
+ newOrganizationName: Название новой организации
+ newOrganizationPlaceholder: Живая организация
+ createOrganization: Создать организацию
+ noneYet: Вы пока не состоите ни в одной организации.
+ organizationLabel: Организация
+ selectedOrgHelp: Настройки ниже применяются к выбранной организации.
+ owner: Владелец
+ member: Участник
+ renameOrganization: Переименовать организацию
+ members: Участники
+ removeMember: Удалить
+ inviteLinks: Приглашения
+ newInviteLink: Новое приглашение
+ noInviteLinks: Пока нет ссылок-приглашений.
+ inviteCreated: "Создано {date}"
+ revokeInvite: Отозвать
+ dangerZone: Опасная зона
+ deleteOrgHelp: Удаление организации также удалит её формы, ответы и ссылки-приглашения.
+ deleteOrganization: Удалить организацию
+ deleteOrganizationTitle: Удалить организацию?
+ deleteOrganizationConfirmDescription: Вместе с ней будут удалены её формы, ответы и ссылки-приглашения. Это действие нельзя отменить.
+ copyInviteSuccess: Ссылка-приглашение скопирована.
+ copyInviteError: Не удалось скопировать ссылку-приглашение.
+dashboard:
+ description: Создавайте форму, открывайте черновик или просматривайте ответы в активном рабочем пространстве.
+ newForm: Новая форма
+ noFormsEyebrow: Форм пока нет
+ noFormsTitle: "Создайте первую форму в пространстве {workspace}"
+ noFormsDescription: Новые формы создаются как черновики, чтобы вы могли отредактировать их перед публикацией.
+ viewGrid: Сетка
+ viewTable: Таблица
+ noDescription: Описание пока не добавлено.
+ shareUrl: Публичный URL
+ titleColumn: Название
+ statusColumn: Статус
+ responses: Ответы
+ updated: Обновлено
+ open: Открыть
+builder:
+ description: Редактируйте блоки, меняйте настройки и просматривайте ответы.
+ settings: Настройки
+ responses: "Ответы ({count})"
+ publish: Опубликовать форму
+ unpublish: Снять с публикации
+ blocks: Блоки
+ newBlock: Новый блок
+ settingsTitle: Настройки
+ titleField: Название
+ descriptionField: Описание
+ shareSlug: Адрес публичного URL
+ afterSubmission: После отправки
+ afterSubmissionDescription: Настройте то, что увидят респонденты после завершения формы, включая необязательную ссылку на следующий шаг.
+ completionTitle: Заголовок завершения
+ completionMessage: Сообщение после завершения
+ followUpLabel: Текст ссылки продолжения
+ followUpUrl: URL ссылки продолжения
+ followUpLabelPlaceholder: Читать следующие шаги
+ followUpUrlPlaceholder: https://example.com/next
+ publicRoute: Публичный доступ
+ publicRouteHelp: Опубликованные формы принимают анонимные ответы. Изменения в опубликованной форме становятся доступны сразу.
+ copyLink: Скопировать ссылку
+ openRunner: Открыть раннер
+ deleteForm: Удалить форму
+ deleteFormTitle: Удалить форму?
+ deleteFormDescription: Вместе с ней будут удалены все блоки и ответы. Это действие нельзя отменить.
+ saveFormSettings: Сохранить настройки формы
+ dragBlockAria: "Перетащить: {label}"
+ prompt: Вопрос
+ supportText: Дополнительный текст
+ bodyCopy: Основной текст
+ placeholder: Плейсхолдер
+ choiceOptions: Варианты ответа
+ addOption: Добавить вариант
+ option: "Вариант {number}"
+ dragOptionAria: "Перетащить вариант {number}"
+ removeOptionAria: "Удалить вариант {number}"
+ optionsHelp: Перетаскивайте варианты для изменения порядка, переименовывайте их на месте и держите количество в диапазоне от 2 до 10.
+ requiredToggle: Сделать этот вопрос обязательным
+ deleteBlock: Удалить блок
+ saveBlock: Сохранить блок
+ nothingSelected: Ничего не выбрано
+ nothingSelectedTitle: Выберите блок или откройте настройки формы.
+ nothingSelectedDescription: Используйте левую панель, чтобы выбрать, что вы хотите редактировать.
+ addShortTextQuestion: Добавить вопрос с коротким ответом
+ toasts:
+ genericError: Что-то пошло не так.
+ savedFormSettings: Настройки формы сохранены
+ addedBlock: "Добавлен блок: {type}"
+ savedBlock: Блок сохранён
+ removedBlock: Блок удалён
+ formPublished: Форма опубликована
+ formDrafted: Форма возвращена в черновик
+ copiedShareLink: Ссылка скопирована
+ deleteFormConfirm: Удалить эту форму навсегда? Вместе с ней будут удалены все блоки и ответы. Это действие нельзя отменить.
+ updatedBlockOrder: Порядок блоков обновлён
+ reorderError: Не удалось изменить порядок блоков.
+blocks:
+ types:
+ TEXT: Текстовый блок
+ SHORT_TEXT: Короткий текст
+ LONG_TEXT: Длинный текст
+ SINGLE_CHOICE: Один вариант
+ MULTIPLE_CHOICE: Несколько вариантов
+publicRunner:
+ responseReceived: Ответ получен
+ defaultCompletionTitle: Спасибо, что уделили время.
+ defaultCompletionMessage: Ваш ответ был отправлен анонимно. Создатель формы может просмотреть ответы, но они не связаны с вашим аккаунтом или входом в систему.
+ step: "Шаг {current} из {total}"
+ context: Контекст
+ defaultTextTitle: Сделайте паузу перед следующим шагом.
+ question: Вопрос
+ required: Обязательно
+ chooseOne: Выберите один вариант.
+ selectAll: Выберите все подходящие варианты.
+ answerRequired: Пожалуйста, ответьте перед продолжением.
+ singleChoiceRequired: Выберите один вариант перед продолжением.
+ multiChoiceRequired: Выберите хотя бы один вариант перед продолжением.
+ submitError: Не удалось отправить ответ.
+ back: Назад
+ continue: Далее
+ submit: Отправить
+responses:
+ description: Просматривайте анонимные ответы в порядке формы.
+ submissionsCount: "{count} ответов"
+ backToBuilder: Назад к редактору
+ noSubmissionsEyebrow: Пока нет ответов
+ noSubmissionsTitle: Опубликуйте форму, чтобы начать собирать ответы.
+ noSubmissionsDescription: Только опубликованные формы доступны по публичному URL, и каждый респондент остаётся анонимным при ответе.
+ openBuilder: Открыть редактор
+ submissionTitle: "Ответ №{number} · {date}"
+ answersCount: "{count} ответов"
+ open: Открыть
+responseDetail:
+ title: "Ответ №{number} · {date}"
+ description: Просматривайте ответы в порядке формы.
+ backToResponses: Назад к ответам
+ contextBlock: Контекстный блок
+ contextTitle: Контекст
+ required: Обязательно
+ noAnswer: Ответ не был предоставлен.
+responseExport:
+ export: Экспорт
+ exportCsv: Экспорт CSV
+ exportXlsx: Экспорт XLSX
+ downloaded: "Экспорт {format} загружен."
+ error: Не удалось экспортировать ответы.
+join:
+ joinTitle: "Вступить в {organization}"
+ alreadyMember: Вы уже состоите в этой организации. Вы можете переключиться на неё через переключатель рабочих пространств.
+ inviteDescription: Примите это приглашение, чтобы работать вместе в пространстве организации.
+ openDashboard: Открыть дашборд
+ joinOrganization: Вступить в организацию
+ cancel: Отмена
+ unavailableTitle: Приглашение недоступно
+ unavailableDescription: Эта ссылка-приглашение недействительна или была отозвана.
+ backToDashboard: Назад в дашборд
+notFound:
+ title: Такая страница или форма не существует.
+ description: Если вы ожидали увидеть публичную форму, возможно, она всё ещё в черновике. Если вы редактировали форму создателя, возможно, у вас нет к ней доступа.
+ backHome: На главную
+ui:
+ cancel: Отмена
+ delete: Удалить
+ dismissNotification: Закрыть уведомление
diff --git a/openspec/changes/archive/2026-04-09-add-user-profile-settings/.openspec.yaml b/openspec/changes/archive/2026-04-09-add-user-profile-settings/.openspec.yaml
new file mode 100644
index 0000000..98d7681
--- /dev/null
+++ b/openspec/changes/archive/2026-04-09-add-user-profile-settings/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-04-09
diff --git a/openspec/changes/archive/2026-04-09-add-user-profile-settings/design.md b/openspec/changes/archive/2026-04-09-add-user-profile-settings/design.md
new file mode 100644
index 0000000..659a3b4
--- /dev/null
+++ b/openspec/changes/archive/2026-04-09-add-user-profile-settings/design.md
@@ -0,0 +1,72 @@
+## Context
+
+The product already has authenticated creator settings, organization management, workspace switching, and a creator navbar, but it does not have a dedicated user-profile surface. The `User` model already contains `firstName`, `secondName`, and `image` fields, while the current session payload only exposes `id` and the current creator shell still uses a separate sign-out button instead of an avatar-triggered account menu.
+
+This change crosses several parts of the app: settings information architecture, auth/session shaping, navbar UI, and organization member rendering. It also needs a consistent rule for how Google OAuth data interacts with user-managed profile data.
+
+## Goals / Non-Goals
+
+**Goals:**
+- Add a Profile section to settings where a signed-in user can edit first name, second name, and profile image.
+- Reuse Google profile data to seed identity fields, especially the profile image, when a user first signs in or when stored values are still missing.
+- Show the current user avatar in the creator navbar and use it as the trigger for an account menu with Settings and Log out actions.
+- Show organization member avatars in member lists using the same shared avatar behavior.
+- Keep the implementation aligned with the existing flatter settings and creator-shell UI.
+
+**Non-Goals:**
+- Public profile pages or profile discovery outside authenticated creator surfaces.
+- Rich profile editing beyond first name, second name, and profile image.
+- A full social/account-management system.
+- A large standalone media-management subsystem.
+
+## Decisions
+
+### 1. Reuse the existing `User.firstName`, `User.secondName`, and `User.image` fields
+The schema already contains the profile fields needed for this feature, so the implementation should build on them instead of introducing a parallel profile table.
+
+**Why:** This keeps the change small, avoids unnecessary migrations, and matches how NextAuth already relates identity data to the `User` record.
+
+**Alternative considered:** Add a separate `UserProfile` model. Rejected because the required data is already present and the additional relation would add complexity without clear product benefit.
+
+### 2. Treat Google profile data as an initializer, not a permanent source of truth
+Google OAuth should populate missing user-facing identity fields when available, especially the avatar image, but it should not overwrite fields that the user has already edited in settings.
+
+**Why:** The user explicitly wants Google to fetch the profile image initially, but profile settings should remain user-controlled after that. Preserving manual edits avoids a frustrating loop where every login resets the profile.
+
+**Alternative considered:** Always overwrite names and image from Google on sign-in. Rejected because it would conflict with editable profile settings.
+
+### 3. Store the canonical profile picture as a URL in `User.image`
+The avatar shown in the navbar, settings preview, and organization member lists should come from `User.image`, regardless of whether it originated from Google or a later user update.
+
+**Why:** The app already has a standard place for identity images, and all avatar surfaces can render from the same field.
+
+**Alternative considered:** Separate `googleImage` and `customImage` fields. Rejected for v1 because it adds branching behavior without a strong product need.
+
+### 4. Keep profile image editing simple in v1
+The first implementation should update the profile image through the existing application data flow and keep the stored result as a URL in `User.image`. If direct binary upload infrastructure is not already available, the settings UI can still be implemented around setting or replacing the stored image URL rather than introducing a new storage system during this change.
+
+**Why:** The requested user value is profile presence across the product, not a full file-storage architecture. This keeps the change implementable inside the current stack.
+
+**Alternative considered:** Introduce a new upload/storage provider as part of this change. Deferred because it expands scope and deployment complexity.
+
+### 5. Introduce a shared avatar/display-name primitive for creator surfaces
+The navbar and organization member list should use the same avatar component and display-name resolution logic. The fallback order should be: profile image → initials from first/second name → initials from `name` → email-derived fallback.
+
+**Why:** This keeps avatar behavior consistent and avoids duplicate formatting logic.
+
+**Alternative considered:** Handle avatar rendering independently in each surface. Rejected because it would drift quickly and complicate maintenance.
+
+### 6. Replace the standalone navbar sign-out button with an avatar-triggered account menu
+The current creator shell should use the user avatar at the right side of the navbar as the account entry point. The menu should contain Settings and Log out, keeping workspace switching separate.
+
+**Why:** This matches the requested interaction model and reduces header clutter while preserving a fast path to settings and sign-out.
+
+**Alternative considered:** Keep the separate sign-out button and add another profile button. Rejected because it adds more chrome instead of simplifying the shell.
+
+## Risks / Trade-offs
+
+- **[Profile image management stays URL-based in v1]** → Mitigation: keep the profile-image logic isolated so a later upload backend can replace the input mechanism without changing avatar consumers.
+- **[Google provider payloads can vary in available name/image fields]** → Mitigation: populate only when values are present and keep robust avatar/name fallbacks.
+- **[Session payload expansion can create stale display state if not refreshed]** → Mitigation: refresh the creator shell after profile updates and keep critical data sourced from the server-rendered session path.
+- **[Navbar menu changes can affect creator-shell spacing and discoverability]** → Mitigation: keep the avatar trigger compact and preserve clear Settings access in the menu.
+- **[Organization member rows can become visually crowded]** → Mitigation: use a small shared avatar size and preserve the current compact row layout.
diff --git a/openspec/changes/archive/2026-04-09-add-user-profile-settings/proposal.md b/openspec/changes/archive/2026-04-09-add-user-profile-settings/proposal.md
new file mode 100644
index 0000000..47761a4
--- /dev/null
+++ b/openspec/changes/archive/2026-04-09-add-user-profile-settings/proposal.md
@@ -0,0 +1,26 @@
+## Why
+
+Creators need a basic profile surface inside settings so they can control how they appear across the product. Right now names are not editable from the UI, profile photos are not consistently sourced or shown, and the navbar account affordance does not provide a clear profile entry point.
+
+## What Changes
+
+- Add a Profile section in settings where a signed-in user can edit their first name and second name.
+- Add profile image support so a user can keep the Google profile photo by default and later set or replace their own image.
+- Show the user profile image in the creator navbar and use it as the trigger for an account menu with links to Settings and Log out.
+- Show member profile images in organization member lists so workspace membership is easier to scan.
+- Extend the authenticated user model and flows so Google OAuth initializes profile image data for new and returning users when available.
+
+## Capabilities
+
+### New Capabilities
+- `creator-profile-settings`: manage creator profile fields and profile image from the authenticated settings experience
+
+### Modified Capabilities
+- `creator-auth`: authenticated identity data should include Google-sourced profile image data and avatar-based account access in creator surfaces
+- `organizations`: organization member management should display member profile images alongside names and email information
+
+## Impact
+
+- Affected code: settings page and settings panels, creator layout/navbar, auth/session handling, organization settings UI, user persistence
+- Affected systems: NextAuth Google provider mapping, user profile storage, creator navigation UI
+- Potential dependencies: image upload/storage approach if custom profile images are stored outside the database directly
diff --git a/openspec/changes/archive/2026-04-09-add-user-profile-settings/specs/creator-auth/spec.md b/openspec/changes/archive/2026-04-09-add-user-profile-settings/specs/creator-auth/spec.md
new file mode 100644
index 0000000..098491c
--- /dev/null
+++ b/openspec/changes/archive/2026-04-09-add-user-profile-settings/specs/creator-auth/spec.md
@@ -0,0 +1,23 @@
+## ADDED Requirements
+
+### Requirement: Authenticated identity initializes profile data from Google when available
+The system SHALL use Google OAuth profile data to initialize missing creator identity fields, including profile image and available name data, without overwriting profile values the user has already edited.
+
+#### Scenario: New creator signs in with Google
+- **WHEN** a user signs in with Google and their stored profile fields are missing
+- **THEN** the system stores available Google name and profile image data on the user record for creator-facing identity use
+
+#### Scenario: Returning creator signs in after editing profile settings
+- **WHEN** a returning creator signs in with Google after manually updating their profile fields
+- **THEN** the system preserves the creator-managed profile values instead of replacing them with provider values
+
+### Requirement: Creator shell exposes avatar-based account access
+The system SHALL show the current creator identity in the navbar as an avatar trigger that opens account actions including Settings and Log out.
+
+#### Scenario: Creator views the creator navbar
+- **WHEN** an authenticated creator is in the creator experience
+- **THEN** the system shows the creator avatar in the navbar using the current profile image or a fallback identity treatment
+
+#### Scenario: Creator opens the account menu
+- **WHEN** an authenticated creator activates the avatar trigger in the navbar
+- **THEN** the system shows account actions for navigating to Settings and logging out
diff --git a/openspec/changes/archive/2026-04-09-add-user-profile-settings/specs/creator-profile-settings/spec.md b/openspec/changes/archive/2026-04-09-add-user-profile-settings/specs/creator-profile-settings/spec.md
new file mode 100644
index 0000000..a2c2d2d
--- /dev/null
+++ b/openspec/changes/archive/2026-04-09-add-user-profile-settings/specs/creator-profile-settings/spec.md
@@ -0,0 +1,23 @@
+## ADDED Requirements
+
+### Requirement: Authenticated creator can manage profile details in settings
+The system SHALL provide a Profile section within authenticated creator settings where a signed-in user can review and update their first name and second name.
+
+#### Scenario: Creator updates profile names
+- **WHEN** an authenticated creator saves new first-name or second-name values in the Profile section
+- **THEN** the system persists the updated values for that user and uses them in creator-facing identity displays
+
+#### Scenario: Creator opens profile settings
+- **WHEN** an authenticated creator opens settings
+- **THEN** the system shows a Profile section with editable profile fields for that user's account
+
+### Requirement: Authenticated creator can manage their profile image
+The system SHALL allow a signed-in creator to set or replace the profile image used for creator-facing identity surfaces.
+
+#### Scenario: Creator sets a profile image
+- **WHEN** an authenticated creator provides a valid profile image value in profile settings and saves it
+- **THEN** the system stores that image for the user account and uses it in creator-facing avatar displays
+
+#### Scenario: Creator replaces an existing profile image
+- **WHEN** an authenticated creator updates a previously stored profile image
+- **THEN** the system uses the new image in place of the previous one across creator-facing identity surfaces
diff --git a/openspec/changes/archive/2026-04-09-add-user-profile-settings/specs/organizations/spec.md b/openspec/changes/archive/2026-04-09-add-user-profile-settings/specs/organizations/spec.md
new file mode 100644
index 0000000..250220a
--- /dev/null
+++ b/openspec/changes/archive/2026-04-09-add-user-profile-settings/specs/organizations/spec.md
@@ -0,0 +1,12 @@
+## ADDED Requirements
+
+### Requirement: Organization member lists display member identity avatars
+The system SHALL show each organization member with an avatar alongside their name and email in organization member lists.
+
+#### Scenario: Member has a profile image
+- **WHEN** an authenticated creator views an organization member list and a listed member has a stored profile image
+- **THEN** the system shows that member's profile image in the row
+
+#### Scenario: Member does not have a profile image
+- **WHEN** an authenticated creator views an organization member list and a listed member does not have a stored profile image
+- **THEN** the system shows a fallback avatar treatment while still displaying the member's name or email information
diff --git a/openspec/changes/archive/2026-04-09-add-user-profile-settings/tasks.md b/openspec/changes/archive/2026-04-09-add-user-profile-settings/tasks.md
new file mode 100644
index 0000000..2e84859
--- /dev/null
+++ b/openspec/changes/archive/2026-04-09-add-user-profile-settings/tasks.md
@@ -0,0 +1,32 @@
+## 1. Profile data and auth shaping
+
+- [x] 1.1 Update authenticated session and user-loading paths so creator surfaces can access first name, second name, and profile image data.
+- [x] 1.2 Add or refine Google sign-in synchronization so missing profile fields are initialized from Google without overwriting creator-managed values.
+- [x] 1.3 Add shared helpers for resolving display name, avatar image, and fallback initials from user identity data.
+
+## 2. Profile settings experience
+
+- [x] 2.1 Add a Profile section to the settings shell alongside the existing settings areas.
+- [x] 2.2 Implement profile settings UI for editing first name and second name.
+- [x] 2.3 Implement profile image management UI and persistence using the canonical user image field.
+- [x] 2.4 Ensure profile updates refresh the current creator experience cleanly after save.
+
+## 3. Creator shell account surfaces
+
+- [x] 3.1 Replace the standalone navbar sign-out button with an avatar trigger on the right side of the creator navbar.
+- [x] 3.2 Implement the avatar account menu with Settings and Log out actions.
+- [x] 3.3 Ensure the navbar avatar uses the current profile image or a consistent fallback treatment.
+
+## 4. Organization member identity display
+
+- [x] 4.1 Extend organization member data loading to include profile image and profile-name fields needed for display.
+- [x] 4.2 Update the organization members list UI to show member avatars alongside name and email information.
+- [x] 4.3 Reuse the shared avatar and display-name behavior in organization member rows.
+
+## 5. Verification
+
+- [x] 5.1 Verify a new or incomplete Google-backed user gets an initial profile image and available name data.
+- [x] 5.2 Verify manual profile edits persist and are not overwritten by later Google sign-ins.
+- [x] 5.3 Verify the navbar avatar menu, settings navigation, and log out flow work correctly.
+- [x] 5.4 Verify organization member lists show avatars and correct fallbacks.
+- [x] 5.5 Run the production build and fix any type or layout issues introduced by the change.
diff --git a/openspec/changes/archive/2026-04-10-introduce-i18n-locales/.openspec.yaml b/openspec/changes/archive/2026-04-10-introduce-i18n-locales/.openspec.yaml
new file mode 100644
index 0000000..98d7681
--- /dev/null
+++ b/openspec/changes/archive/2026-04-10-introduce-i18n-locales/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-04-09
diff --git a/openspec/changes/archive/2026-04-10-introduce-i18n-locales/design.md b/openspec/changes/archive/2026-04-10-introduce-i18n-locales/design.md
new file mode 100644
index 0000000..8e8ef82
--- /dev/null
+++ b/openspec/changes/archive/2026-04-10-introduce-i18n-locales/design.md
@@ -0,0 +1,96 @@
+## Context
+
+The app currently renders UI copy from hardcoded strings spread across creator pages, shared components, and public flows. That makes it difficult to support multiple languages, keep text consistent, or let users control their preferred language over time.
+
+This change is cross-cutting because it affects how strings are authored, loaded, and consumed across the app. It also touches authentication and profile settings because the initial locale should come from OAuth when possible, and users must be able to override that choice later.
+
+## Goals / Non-Goals
+
+**Goals:**
+- Introduce a real i18n layer where application copy is stored in per-language YAML files and referenced from code by labels/keys.
+- Support English and Russian initially.
+- Resolve locale with a predictable fallback to English when the detected locale is unsupported.
+- Use browser locale for unauthenticated/public usage when it is supported.
+- Warn during build when translation labels are missing from a locale file.
+- Initialize locale from OAuth profile data when available for a signed-in user.
+- Allow the signed-in user to change locale later in Profile settings.
+- Provide a reusable translation-loading API that works for creator, shared, and public surfaces.
+
+**Non-Goals:**
+- Automatic runtime machine translation.
+- Full pluralization/ICU formatting support in v1 beyond what is needed for current UI copy.
+- Shipping every existing string in the product in one large uncontrolled rewrite without structure.
+- Locale-specific routing such as `/ru/...` paths in v1.
+
+## Decisions
+
+### 1. Use YAML resource files keyed by stable translation labels
+Translations will live in language-specific YAML files, with the same key structure across locales. Application code will reference labels/keys and resolve strings through a shared translation layer rather than embedding copy inline.
+
+**Why:** This matches the requested authoring model, keeps text centralized, and makes future languages additive instead of requiring code rewrites.
+
+**Alternative considered:** JSON dictionaries. Rejected because the user explicitly requested YAML-backed language files.
+
+### 2. Start with a single shared locale model and English fallback
+The supported locale set for v1 will be `en` and `ru`. Locale resolution will always normalize unknown or unsupported values to `en`.
+
+**Why:** This gives predictable behavior and avoids partial unsupported-state bugs. English is also the safest default for currently existing copy.
+
+**Alternative considered:** Preserve raw browser/provider locale even when unsupported. Rejected because it would produce ambiguous fallback behavior and make persistence harder to reason about.
+
+### 3. Persist creator locale on the `User` record
+Authenticated user locale should be stored as a user preference so the app can respect it consistently across sessions and devices. OAuth-derived locale should populate this field only when the stored preference is missing.
+
+**Why:** The user explicitly wants locale to be changeable later in Profile settings, which makes it a persisted account preference rather than a transient client-only state.
+
+**Alternative considered:** Store locale only in localStorage or cookies. Rejected because it would not follow the user across sessions/devices and would conflict with account-based preferences.
+
+### 4. Resolve locale with a clear precedence order
+For authenticated users, locale precedence should be: stored user locale → supported OAuth locale at initialization time only → `en`. For unauthenticated or public cases, the app should use browser locale detection when it resolves to a supported locale, otherwise `en`.
+
+**Why:** This keeps user choice authoritative while still seeding a sensible initial locale from OAuth.
+
+**Alternative considered:** Always prefer OAuth locale over stored preference. Rejected because it would override explicit user changes.
+
+### 5. Add a small translation access layer instead of passing raw dictionaries everywhere
+The app should expose shared helpers that load a locale dictionary and return a translator function or structured message accessor. Components should consume translated strings from those helpers rather than reading YAML directly.
+
+**Why:** This localizes file-parsing and fallback behavior in one place and avoids duplicating lookup logic.
+
+**Alternative considered:** Import YAML dictionaries directly in every component. Rejected because it couples components to storage format and makes fallback behavior inconsistent.
+
+### 6. Add build-time translation validation with warnings for missing labels
+The localization system should compare locale files against the default label set during build and emit warnings listing any missing translation labels instead of failing the build immediately.
+
+**Why:** Missing translations should be visible early, but the initial rollout may be iterative. Warnings preserve momentum while still making gaps actionable.
+
+**Alternative considered:** Hard-fail the build for any missing translation. Rejected for v1 because it is stricter than requested and could slow migration.
+
+### 7. Roll out i18n by converting targeted surfaces first, but keep the architecture global
+The initial implementation should localize the major creator/public/shared strings touched by the current app experience and establish the pattern for remaining strings. The important part of the change is that strings are sourced through labels and locale files, even if some cleanup continues later.
+
+**Why:** Converting every string in one pass can be large, but the new architecture must be complete enough to carry the product going forward.
+
+**Alternative considered:** Keep a mixed long-term model with many inline strings. Rejected because it undermines the goal of a real maintainable i18n system.
+
+## Risks / Trade-offs
+
+- **[Large copy migration surface]** → Mitigation: introduce a shared translation layer and convert strings systematically by area rather than ad hoc.
+- **[YAML key drift between languages]** → Mitigation: keep a mirrored key structure and emit build-time warnings listing missing labels by locale.
+- **[Auth/provider locale availability is inconsistent]** → Mitigation: normalize all provider input through a supported-locale helper and fall back to English.
+- **[Server/client translation access can diverge]** → Mitigation: centralize dictionary loading and locale normalization helpers in shared utilities.
+- **[Adding persisted locale may require a database migration]** → Mitigation: keep the field small and default-safe, and backfill existing users with `en` semantics through runtime normalization.
+
+## Migration Plan
+
+1. Add locale persistence to the user model and session shaping.
+2. Introduce the translation resource structure and shared locale helpers.
+3. Add English and Russian YAML files with mirrored keys.
+4. Convert targeted UI surfaces from inline strings to translation keys.
+5. Add locale selection to Profile settings and OAuth locale initialization.
+6. Add build-time missing-translation warnings against the default locale labels.
+7. Verify fallback behavior for unsupported locales, browser locale resolution for unauthenticated users, and existing users without a stored locale.
+
+## Open Questions
+
+- Whether the build-time warning mechanism should live in a dedicated validation script, a build hook, or an existing app check path.
diff --git a/openspec/changes/archive/2026-04-10-introduce-i18n-locales/proposal.md b/openspec/changes/archive/2026-04-10-introduce-i18n-locales/proposal.md
new file mode 100644
index 0000000..20d13cb
--- /dev/null
+++ b/openspec/changes/archive/2026-04-10-introduce-i18n-locales/proposal.md
@@ -0,0 +1,27 @@
+## Why
+
+The product currently hardcodes interface strings directly in code, which makes it difficult to support multiple languages and keep text consistent across creator and public experiences. Adding a real i18n system now will let the app support English and Russian immediately while creating a maintainable path for future languages.
+
+## What Changes
+
+- Introduce an application-wide i18n system that resolves UI copy from per-language YAML files instead of inline strings in code.
+- Add locale support for English and Russian, with English as the fallback when a detected locale is not supported.
+- Use the browser locale for unauthenticated respondents and other non-logged-in app usage, still falling back to English when unsupported.
+- Initialize a user’s locale from Google OAuth data when possible during sign-in.
+- Allow authenticated users to change their locale later from Profile settings.
+- Apply localized copy consistently across creator surfaces, shared UI, and public-facing flows covered by the new localization system.
+
+## Capabilities
+
+### New Capabilities
+- `site-localization`: load labeled strings from language-specific YAML resources, resolve supported locales, and provide fallback behavior across app surfaces
+
+### Modified Capabilities
+- `creator-auth`: authenticated identity initialization should derive a supported locale from OAuth when available and fall back to English otherwise
+- `creator-profile-settings`: profile settings should allow a signed-in user to view and update their language preference
+
+## Impact
+
+- Affected code: shared UI text rendering, settings/profile UI, auth/session shaping, locale resolution helpers, and app/page-level text access
+- Affected systems: Google OAuth profile ingestion, user preference persistence, server/client translation loading
+- Potential dependencies: YAML parsing support, a shared translation-loading layer, and build-time translation validation/warning support
diff --git a/openspec/changes/archive/2026-04-10-introduce-i18n-locales/specs/creator-auth/spec.md b/openspec/changes/archive/2026-04-10-introduce-i18n-locales/specs/creator-auth/spec.md
new file mode 100644
index 0000000..778003d
--- /dev/null
+++ b/openspec/changes/archive/2026-04-10-introduce-i18n-locales/specs/creator-auth/spec.md
@@ -0,0 +1,31 @@
+## MODIFIED Requirements
+
+### Requirement: Authenticated identity initializes profile data from Google when available
+The system SHALL use Google OAuth profile data to initialize missing creator identity fields, including profile image, available name data, and locale when available, without overwriting profile values the user has already edited.
+
+#### Scenario: New creator signs in with Google
+- **WHEN** a user signs in with Google and their stored profile fields are missing
+- **THEN** the system stores available Google name and profile image data on the user record for creator-facing identity use
+
+#### Scenario: New creator signs in with a supported Google locale
+- **WHEN** a user signs in with Google, has no stored locale preference, and Google provides a supported locale
+- **THEN** the system stores that supported locale on the user record for future app localization
+
+#### Scenario: New creator signs in with an unsupported Google locale
+- **WHEN** a user signs in with Google, has no stored locale preference, and Google provides an unsupported locale
+- **THEN** the system stores or resolves English as the default locale for that user
+
+#### Scenario: Returning creator signs in after editing profile settings
+- **WHEN** a returning creator signs in with Google after manually updating their profile fields or locale preference
+- **THEN** the system preserves the creator-managed profile values instead of replacing them with provider values
+
+### Requirement: Creator shell exposes avatar-based account access
+The system SHALL show the current creator identity in the navbar as an avatar trigger that opens account actions including Settings and Log out.
+
+#### Scenario: Creator views the creator navbar
+- **WHEN** an authenticated creator is in the creator experience
+- **THEN** the system shows the creator avatar in the navbar using the current profile image or a fallback identity treatment
+
+#### Scenario: Creator opens the account menu
+- **WHEN** an authenticated creator activates the avatar trigger in the navbar
+- **THEN** the system shows account actions for navigating to Settings and logging out
diff --git a/openspec/changes/archive/2026-04-10-introduce-i18n-locales/specs/creator-profile-settings/spec.md b/openspec/changes/archive/2026-04-10-introduce-i18n-locales/specs/creator-profile-settings/spec.md
new file mode 100644
index 0000000..d75e970
--- /dev/null
+++ b/openspec/changes/archive/2026-04-10-introduce-i18n-locales/specs/creator-profile-settings/spec.md
@@ -0,0 +1,16 @@
+## ADDED Requirements
+
+### Requirement: Authenticated creator can manage locale preference in profile settings
+The system SHALL allow a signed-in creator to view and update their language preference from the Profile section in settings.
+
+#### Scenario: Creator opens profile settings
+- **WHEN** an authenticated creator opens the Profile section in settings
+- **THEN** the system shows the current language preference and the supported locale choices
+
+#### Scenario: Creator switches to Russian
+- **WHEN** an authenticated creator selects Russian in profile settings and saves the change
+- **THEN** the system persists the `ru` locale preference and uses it for subsequent localized app rendering
+
+#### Scenario: Creator switches to English
+- **WHEN** an authenticated creator selects English in profile settings and saves the change
+- **THEN** the system persists the `en` locale preference and uses it for subsequent localized app rendering
diff --git a/openspec/changes/archive/2026-04-10-introduce-i18n-locales/specs/site-localization/spec.md b/openspec/changes/archive/2026-04-10-introduce-i18n-locales/specs/site-localization/spec.md
new file mode 100644
index 0000000..965956a
--- /dev/null
+++ b/openspec/changes/archive/2026-04-10-introduce-i18n-locales/specs/site-localization/spec.md
@@ -0,0 +1,38 @@
+## ADDED Requirements
+
+### Requirement: Application strings resolve from locale resource files
+The system SHALL resolve user-facing application strings from locale-specific YAML resource files using stable translation labels instead of embedding final copy directly in code.
+
+#### Scenario: App renders a labeled string
+- **WHEN** a creator or respondent opens a surface that uses localized copy
+- **THEN** the system resolves the requested label from the active locale resource file before rendering the text
+
+#### Scenario: App adds a second supported language
+- **WHEN** a supported locale file exists with the same translation labels as the default locale
+- **THEN** the system can render that surface in the additional language without requiring the component to hardcode translated copy
+
+### Requirement: Supported locale resolution falls back to English
+The system SHALL support `en` and `ru` locales initially and SHALL normalize unsupported locale values to English.
+
+#### Scenario: Supported locale is requested
+- **WHEN** the active locale is `en` or `ru`
+- **THEN** the system renders strings from that supported locale resource set
+
+#### Scenario: Unauthenticated user has a supported browser locale
+- **WHEN** an unauthenticated creator or respondent opens the app and the browser locale resolves to a supported locale
+- **THEN** the system uses that supported browser locale for rendering without requiring sign-in
+
+#### Scenario: Unsupported locale is requested
+- **WHEN** the system receives an unsupported locale value from user state, OAuth data, or environment detection
+- **THEN** the system falls back to English as the active locale
+
+### Requirement: Missing translations are surfaced during build
+The system SHALL compare locale resource files against the default label set during build and SHALL emit warnings listing missing translation labels.
+
+#### Scenario: Locale file is missing labels
+- **WHEN** a non-default locale file does not contain one or more labels present in the default locale file
+- **THEN** the build process warns with the missing labels for that locale
+
+#### Scenario: Locale files are complete
+- **WHEN** each supported locale file contains the required translation labels
+- **THEN** the build process completes without missing-translation warnings
diff --git a/openspec/changes/archive/2026-04-10-introduce-i18n-locales/tasks.md b/openspec/changes/archive/2026-04-10-introduce-i18n-locales/tasks.md
new file mode 100644
index 0000000..45e46e0
--- /dev/null
+++ b/openspec/changes/archive/2026-04-10-introduce-i18n-locales/tasks.md
@@ -0,0 +1,35 @@
+## 1. Locale model and translation infrastructure
+
+- [x] 1.1 Add persisted locale support for users and shared locale normalization helpers with `en`/`ru` support and English fallback.
+- [x] 1.2 Add YAML-backed translation resources for English and Russian with a mirrored key structure.
+- [x] 1.3 Implement shared translation-loading and label-resolution utilities for server/client app surfaces.
+- [x] 1.4 Add build-time translation validation that warns with missing labels for incomplete locale files.
+
+## 2. Auth and profile locale behavior
+
+- [x] 2.1 Update Google sign-in synchronization to initialize a missing user locale from OAuth when supported and default to English otherwise.
+- [x] 2.2 Expose the resolved locale through authenticated session and user-loading paths needed by the app.
+- [x] 2.3 Extend Profile settings so a signed-in user can view and update their locale preference.
+
+## 3. UI text migration
+
+- [x] 3.1 Replace inline strings on core creator shell and settings surfaces with translation labels.
+- [x] 3.2 Replace inline strings on shared/public surfaces needed for the current product experience with translation labels.
+- [x] 3.3 Ensure translation lookup is used consistently instead of introducing new hardcoded UI copy.
+
+## 4. Locale application flow
+
+- [x] 4.1 Apply the active locale during app rendering so supported users see English or Russian copy consistently.
+- [x] 4.2 Use browser locale for unauthenticated/public usage when it is supported.
+- [x] 4.3 Ensure unsupported detected locales resolve to English without breaking rendering.
+- [x] 4.4 Ensure profile locale changes take effect cleanly in subsequent creator usage.
+
+## 5. Verification
+
+- [x] 5.1 Verify a Google-backed user with a supported OAuth locale is initialized into that locale.
+- [x] 5.2 Verify unsupported OAuth locales fall back to English.
+- [x] 5.3 Verify unauthenticated/public usage follows supported browser locale and falls back to English otherwise.
+- [x] 5.4 Verify Profile settings can switch between English and Russian and persist the preference.
+- [x] 5.5 Verify labeled strings render from YAML resources across creator and public surfaces covered by the change.
+- [x] 5.6 Verify the build warns with missing translation labels when a locale file is incomplete.
+- [x] 5.7 Run the production build and fix any type or integration issues introduced by the localization system.
diff --git a/openspec/specs/creator-auth/spec.md b/openspec/specs/creator-auth/spec.md
index cc72b76..6f15145 100644
--- a/openspec/specs/creator-auth/spec.md
+++ b/openspec/specs/creator-auth/spec.md
@@ -25,3 +25,33 @@ The system SHALL allow creators to manage personal forms they own and organizati
#### Scenario: Creator opens another creator's inaccessible form
- **WHEN** an authenticated creator requests a form they do not own personally and that does not belong to an organization they are part of
- **THEN** the system denies access to form management, response review, and response export for that form
+
+### Requirement: Authenticated identity initializes profile data from Google when available
+The system SHALL use Google OAuth profile data to initialize missing creator identity fields, including profile image, available name data, and locale when available, without overwriting profile values the user has already edited.
+
+#### Scenario: New creator signs in with Google
+- **WHEN** a user signs in with Google and their stored profile fields are missing
+- **THEN** the system stores available Google name and profile image data on the user record for creator-facing identity use
+
+#### Scenario: New creator signs in with a supported Google locale
+- **WHEN** a user signs in with Google, has no stored locale preference, and Google provides a supported locale
+- **THEN** the system stores that supported locale on the user record for future app localization
+
+#### Scenario: New creator signs in with an unsupported Google locale
+- **WHEN** a user signs in with Google, has no stored locale preference, and Google provides an unsupported locale
+- **THEN** the system stores or resolves English as the default locale for that user
+
+#### Scenario: Returning creator signs in after editing profile settings
+- **WHEN** a returning creator signs in with Google after manually updating their profile fields or locale preference
+- **THEN** the system preserves the creator-managed profile values instead of replacing them with provider values
+
+### Requirement: Creator shell exposes avatar-based account access
+The system SHALL show the current creator identity in the navbar as an avatar trigger that opens account actions including Settings and Log out.
+
+#### Scenario: Creator views the creator navbar
+- **WHEN** an authenticated creator is in the creator experience
+- **THEN** the system shows the creator avatar in the navbar using the current profile image or a fallback identity treatment
+
+#### Scenario: Creator opens the account menu
+- **WHEN** an authenticated creator activates the avatar trigger in the navbar
+- **THEN** the system shows account actions for navigating to Settings and logging out
diff --git a/openspec/specs/creator-profile-settings/spec.md b/openspec/specs/creator-profile-settings/spec.md
new file mode 100644
index 0000000..a46a382
--- /dev/null
+++ b/openspec/specs/creator-profile-settings/spec.md
@@ -0,0 +1,38 @@
+## ADDED Requirements
+
+### Requirement: Authenticated creator can manage profile details in settings
+The system SHALL provide a Profile section within authenticated creator settings where a signed-in user can review and update their first name and second name.
+
+#### Scenario: Creator updates profile names
+- **WHEN** an authenticated creator saves new first-name or second-name values in the Profile section
+- **THEN** the system persists the updated values for that user and uses them in creator-facing identity displays
+
+#### Scenario: Creator opens profile settings
+- **WHEN** an authenticated creator opens settings
+- **THEN** the system shows a Profile section with editable profile fields for that user's account
+
+### Requirement: Authenticated creator can manage their profile image
+The system SHALL allow a signed-in creator to set or replace the profile image used for creator-facing identity surfaces.
+
+#### Scenario: Creator sets a profile image
+- **WHEN** an authenticated creator provides a valid profile image value in profile settings and saves it
+- **THEN** the system stores that image for the user account and uses it in creator-facing avatar displays
+
+#### Scenario: Creator replaces an existing profile image
+- **WHEN** an authenticated creator updates a previously stored profile image
+- **THEN** the system uses the new image in place of the previous one across creator-facing identity surfaces
+
+### Requirement: Authenticated creator can manage locale preference in profile settings
+The system SHALL allow a signed-in creator to view and update their language preference from the Profile section in settings.
+
+#### Scenario: Creator opens profile settings
+- **WHEN** an authenticated creator opens the Profile section in settings
+- **THEN** the system shows the current language preference and the supported locale choices
+
+#### Scenario: Creator switches to Russian
+- **WHEN** an authenticated creator selects Russian in profile settings and saves the change
+- **THEN** the system persists the `ru` locale preference and uses it for subsequent localized app rendering
+
+#### Scenario: Creator switches to English
+- **WHEN** an authenticated creator selects English in profile settings and saves the change
+- **THEN** the system persists the `en` locale preference and uses it for subsequent localized app rendering
diff --git a/openspec/specs/organizations/spec.md b/openspec/specs/organizations/spec.md
index bfb5de0..d2d4670 100644
--- a/openspec/specs/organizations/spec.md
+++ b/openspec/specs/organizations/spec.md
@@ -35,3 +35,14 @@ The system SHALL present a creator with their personal workspace and any organiz
#### Scenario: User belongs to one or more organizations
- **WHEN** an authenticated creator opens the creator experience
- **THEN** the system shows the personal workspace plus each accessible organization as available workspace contexts
+
+### Requirement: Organization member lists display member identity avatars
+The system SHALL show each organization member with an avatar alongside their name and email in organization member lists.
+
+#### Scenario: Member has a profile image
+- **WHEN** an authenticated creator views an organization member list and a listed member has a stored profile image
+- **THEN** the system shows that member's profile image in the row
+
+#### Scenario: Member does not have a profile image
+- **WHEN** an authenticated creator views an organization member list and a listed member does not have a stored profile image
+- **THEN** the system shows a fallback avatar treatment while still displaying the member's name or email information
diff --git a/openspec/specs/site-localization/spec.md b/openspec/specs/site-localization/spec.md
new file mode 100644
index 0000000..965956a
--- /dev/null
+++ b/openspec/specs/site-localization/spec.md
@@ -0,0 +1,38 @@
+## ADDED Requirements
+
+### Requirement: Application strings resolve from locale resource files
+The system SHALL resolve user-facing application strings from locale-specific YAML resource files using stable translation labels instead of embedding final copy directly in code.
+
+#### Scenario: App renders a labeled string
+- **WHEN** a creator or respondent opens a surface that uses localized copy
+- **THEN** the system resolves the requested label from the active locale resource file before rendering the text
+
+#### Scenario: App adds a second supported language
+- **WHEN** a supported locale file exists with the same translation labels as the default locale
+- **THEN** the system can render that surface in the additional language without requiring the component to hardcode translated copy
+
+### Requirement: Supported locale resolution falls back to English
+The system SHALL support `en` and `ru` locales initially and SHALL normalize unsupported locale values to English.
+
+#### Scenario: Supported locale is requested
+- **WHEN** the active locale is `en` or `ru`
+- **THEN** the system renders strings from that supported locale resource set
+
+#### Scenario: Unauthenticated user has a supported browser locale
+- **WHEN** an unauthenticated creator or respondent opens the app and the browser locale resolves to a supported locale
+- **THEN** the system uses that supported browser locale for rendering without requiring sign-in
+
+#### Scenario: Unsupported locale is requested
+- **WHEN** the system receives an unsupported locale value from user state, OAuth data, or environment detection
+- **THEN** the system falls back to English as the active locale
+
+### Requirement: Missing translations are surfaced during build
+The system SHALL compare locale resource files against the default label set during build and SHALL emit warnings listing missing translation labels.
+
+#### Scenario: Locale file is missing labels
+- **WHEN** a non-default locale file does not contain one or more labels present in the default locale file
+- **THEN** the build process warns with the missing labels for that locale
+
+#### Scenario: Locale files are complete
+- **WHEN** each supported locale file contains the required translation labels
+- **THEN** the build process completes without missing-translation warnings
diff --git a/package.json b/package.json
index 31d5c6a..da8753f 100644
--- a/package.json
+++ b/package.json
@@ -4,7 +4,7 @@
"private": true,
"scripts": {
"dev": "next dev",
- "build": "next build",
+ "build": "node scripts/validate-translations.mjs && next build",
"start": "next start",
"lint": "eslint",
"db:up": "podman-compose up -d",
@@ -44,6 +44,7 @@
"react-dom": "^19.2.4",
"tailwind-merge": "^3.5.0",
"xlsx": "^0.18.5",
+ "yaml": "^2.8.3",
"zod": "^4.3.6"
}
}
diff --git a/prisma/migrations/20260409151000_add_user_locale/migration.sql b/prisma/migrations/20260409151000_add_user_locale/migration.sql
new file mode 100644
index 0000000..ef73ef0
--- /dev/null
+++ b/prisma/migrations/20260409151000_add_user_locale/migration.sql
@@ -0,0 +1,2 @@
+ALTER TABLE "User"
+ADD COLUMN "locale" TEXT;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 46ee138..5970ae8 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -30,6 +30,7 @@ model User {
name String?
firstName String?
secondName String?
+ locale String?
email String? @unique
emailVerified DateTime?
image String?
diff --git a/scripts/validate-translations.mjs b/scripts/validate-translations.mjs
new file mode 100644
index 0000000..6acf91a
--- /dev/null
+++ b/scripts/validate-translations.mjs
@@ -0,0 +1,54 @@
+import { readFile } from "node:fs/promises";
+import path from "node:path";
+import process from "node:process";
+import YAML from "yaml";
+
+const localeFiles = {
+ en: "en.yml",
+ ru: "ru.yml",
+};
+
+function flatten(tree, prefix = "") {
+ const entries = {};
+
+ for (const [key, value] of Object.entries(tree ?? {})) {
+ const nextKey = prefix ? `${prefix}.${key}` : key;
+
+ if (typeof value === "string") {
+ entries[nextKey] = value;
+ continue;
+ }
+
+ Object.assign(entries, flatten(value, nextKey));
+ }
+
+ return entries;
+}
+
+async function loadLocale(locale) {
+ const filePath = path.join(process.cwd(), "locales", localeFiles[locale]);
+ const file = await readFile(filePath, "utf8");
+ return YAML.parse(file);
+}
+
+const defaultLabels = flatten(await loadLocale("en"));
+let warningCount = 0;
+
+for (const locale of Object.keys(localeFiles)) {
+ if (locale === "en") continue;
+
+ const labels = flatten(await loadLocale(locale));
+ const missing = Object.keys(defaultLabels).filter((key) => !(key in labels));
+
+ if (missing.length === 0) continue;
+
+ warningCount += missing.length;
+ console.warn(`\n[i18n] Missing translations for locale '${locale}':`);
+ for (const key of missing) {
+ console.warn(`- ${key}`);
+ }
+}
+
+if (warningCount === 0) {
+ console.log("[i18n] Translation validation passed.");
+}
diff --git a/types/next-auth.d.ts b/types/next-auth.d.ts
index 1509601..5e3347d 100644
--- a/types/next-auth.d.ts
+++ b/types/next-auth.d.ts
@@ -4,10 +4,16 @@ declare module "next-auth" {
interface Session {
user: DefaultSession["user"] & {
id: string;
+ firstName: string | null;
+ secondName: string | null;
+ locale: "en" | "ru";
};
}
interface User {
id: string;
+ firstName: string | null;
+ secondName: string | null;
+ locale: "en" | "ru";
}
}