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")}

} 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")}

- +
@@ -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 })} - +
{responses.length === 0 ? ( - + } /> @@ -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 })}

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} - 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}

- +
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 ? ( - + ) : (
- +
)} - +
) : ( <> -

Organization invite

-

Invite not available

+

{t("join.unavailableTitle")}

- This invite link is invalid or has been revoked. + {t("join.unavailableDescription")}

- +
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 ( - +