From c2eeb0ff92772de43428e030cf97b7aa4dc8d91d Mon Sep 17 00:00:00 2001 From: poyrazavsever Date: Sun, 19 Jul 2026 03:07:42 +0300 Subject: [PATCH] feat: localize auth and portal pages --- app/error.tsx | 46 ++++ app/forgot-password/page.tsx | 58 +++++ app/login/actions.ts | 22 +- app/login/page.tsx | 71 +++++- app/not-found.tsx | 34 +++ app/portal/page.tsx | 39 ++- app/portal/projects/[id]/page.tsx | 67 +++-- .../projects/[id]/portal-project-client.tsx | 241 +++++++++++------- app/portal/projects/page.tsx | 31 ++- app/portal/revisions/page.tsx | 33 ++- app/portal/tasks/page.tsx | 38 ++- app/register/page.tsx | 75 ++++-- app/reset-password/page.tsx | 58 +++++ components/auth/auth-page-shell.tsx | 47 ++-- 14 files changed, 648 insertions(+), 212 deletions(-) create mode 100644 app/error.tsx create mode 100644 app/forgot-password/page.tsx create mode 100644 app/not-found.tsx create mode 100644 app/reset-password/page.tsx diff --git a/app/error.tsx b/app/error.tsx new file mode 100644 index 0000000..e4eaf8c --- /dev/null +++ b/app/error.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { Button, Typography } from "poyraz-ui/atoms"; + +const copy = { + tr: { + title: "Bir şeyler ters gitti", + description: "Beklenmeyen bir hata oluştu. Lütfen tekrar dene.", + retry: "Tekrar dene", + }, + en: { + title: "Something went wrong", + description: "An unexpected error occurred. Please try again.", + retry: "Try again", + }, +}; + +function getCopy() { + const language = typeof document === "undefined" ? "tr" : document.documentElement.lang; + return language?.startsWith("en") ? copy.en : copy.tr; +} + +export default function ErrorPage({ reset }: { reset: () => void }) { + const t = getCopy(); + + return ( +
+
+
+ ! +
+
+ + {t.title} + + + {t.description} + +
+ +
+
+ ); +} diff --git a/app/forgot-password/page.tsx b/app/forgot-password/page.tsx new file mode 100644 index 0000000..8fe081d --- /dev/null +++ b/app/forgot-password/page.tsx @@ -0,0 +1,58 @@ +import { AuthPageShell } from "@/components/auth/auth-page-shell"; +import { LocaleSelectForm } from "@/components/i18n/locale-select-form"; +import { getPublicBranding } from "@/server/branding/runtime"; +import { getSqliteConnection } from "@/server/db/client"; +import { ContentTranslationService } from "@/server/i18n/content"; +import { resolveRequestLocale } from "@/server/i18n/resolver"; +import { createTranslator } from "@/server/i18n/translator"; +import Link from "next/link"; +import { Alert, AlertDescription } from "poyraz-ui/molecules"; + +export const dynamic = "force-dynamic"; + +export default async function ForgotPasswordPage() { + const branding = getPublicBranding(); + const locale = await resolveRequestLocale(); + const t = createTranslator(locale.locale, ["auth"]).t; + const localization = new ContentTranslationService(getSqliteConnection().db).getPublicLocalizationContext(); + + return ( + + + + {t("auth.forgot.helper")} + + + } + secondaryAction={null} + footer={ + + {t("auth.forgot.back")} + + } + /> + ); +} diff --git a/app/login/actions.ts b/app/login/actions.ts index e9b8cc0..a42c758 100644 --- a/app/login/actions.ts +++ b/app/login/actions.ts @@ -13,7 +13,10 @@ import { } from '@/server/auth/setup' import { getDefaultDisplayName, parseAuthCredentials } from '@/server/auth/validation' -const genericLoginError = 'E-posta veya \u015fifre hatal\u0131.' +const LOGIN_ERROR_CODE = 'auth.messages.invalidCredentials' +const SETUP_UNAVAILABLE_CODE = 'auth.messages.setupUnavailable' +const SETUP_STATE_ERROR_CODE = 'auth.messages.setupStateError' +const SIGNUP_FAILED_CODE = 'auth.messages.signupFailed' type SignInEmailResult = Awaited> type SignUpEmailResult = Awaited> @@ -34,7 +37,7 @@ export async function login(formData: FormData) { email: credentials.email, metadata: { reason: 'invalid_credentials' }, }) - redirect(`/login?error=true&message=${encodeURIComponent(genericLoginError)}`) + redirect(`/login?error=true&code=${LOGIN_ERROR_CODE}`) } let profile = getProfileByAuthUserId(result.user.id) @@ -52,7 +55,7 @@ export async function login(formData: FormData) { email: credentials.email, metadata: { reason: 'missing_or_disabled_profile' }, }) - redirect(`/login?error=true&message=${encodeURIComponent(genericLoginError)}`) + redirect(`/login?error=true&code=${LOGIN_ERROR_CODE}`) } redirectTarget = profile.role === 'client' ? '/portal' : '/' @@ -65,15 +68,11 @@ export async function signup(formData: FormData) { const setupState = await getFirstFreelancerSetupState() if (setupState.errorMessage) { - redirect(`/register?error=true&message=${encodeURIComponent(setupState.errorMessage)}`) + redirect(`/register?error=true&code=${SETUP_STATE_ERROR_CODE}`) } if (!setupState.available) { - redirect( - `/login?error=true&message=${encodeURIComponent( - 'Kay\u0131t kapal\u0131. Bu Neta kurulumunda ilk freelancer hesab\u0131 zaten olu\u015fturulmu\u015f.', - )}`, - ) + redirect(`/login?error=true&code=${SETUP_UNAVAILABLE_CODE}`) } const credentials = parseAuthCredentials(formData) @@ -85,10 +84,9 @@ export async function signup(formData: FormData) { password: credentials.password, rememberMe: true, }) - } catch (error) { + } catch { failFirstFreelancerSetup(credentials.email, 'better_auth_signup_failed') - const message = error instanceof Error ? error.message : 'Kullan\u0131c\u0131 olu\u015fturulamad\u0131.' - redirect(`/register?error=true&message=${encodeURIComponent(message)}`) + redirect(`/register?error=true&code=${SIGNUP_FAILED_CODE}`) } revalidatePath('/', 'layout') diff --git a/app/login/page.tsx b/app/login/page.tsx index 2457dc4..86f6bfb 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -1,12 +1,34 @@ import { login } from "@/app/login/actions"; import { AuthPageShell } from "@/components/auth/auth-page-shell"; import { ErrorToaster } from "@/components/error-toaster"; +import { LocaleSelectForm } from "@/components/i18n/locale-select-form"; import { LockKeyhole, LogIn, Mail } from "lucide-react"; import Link from "next/link"; import { Input, Label } from "poyraz-ui/atoms"; import { Alert, AlertDescription } from "poyraz-ui/molecules"; import { SubmitButton } from "@/components/auth/submit-button"; import { getPublicBranding } from "@/server/branding/runtime"; +import { getSqliteConnection } from "@/server/db/client"; +import { ContentTranslationService } from "@/server/i18n/content"; +import { resolveRequestLocale } from "@/server/i18n/resolver"; +import { createTranslator } from "@/server/i18n/translator"; +import type { TranslationValues } from "@/lib/i18n"; + +function firstParam(value: string | string[] | undefined): string | null { + if (Array.isArray(value)) return value[0] ?? null; + return value ?? null; +} + +function resolveAuthMessage( + code: string | null, + fallback: string | null, + t: (key: string, values?: TranslationValues) => string, +): string | null { + if (!code) return fallback; + const key = code.startsWith("auth.") ? code : `auth.${code}`; + const message = t(key); + return message === key ? fallback : message; +} export default async function LoginPage({ searchParams, @@ -14,39 +36,60 @@ export default async function LoginPage({ searchParams: Promise<{ [key: string]: string | string[] | undefined }>; }) { const resolvedParams = await searchParams; - const error = resolvedParams?.error; - const message = resolvedParams?.message; + const error = firstParam(resolvedParams?.error); + const code = firstParam(resolvedParams?.code); + const rawMessage = firstParam(resolvedParams?.message); const branding = getPublicBranding(); + const locale = await resolveRequestLocale(); + const t = createTranslator(locale.locale, ["auth"]).t; + const localization = new ContentTranslationService(getSqliteConnection().db).getPublicLocalizationContext(); + const message = resolveAuthMessage(code, rawMessage, t); + const marketing = { + headline: t("auth.marketing.headline"), + description: t("auth.marketing.description", { app: branding.organizationName ?? branding.applicationName }), + openSource: t("auth.marketing.openSource"), + github: t("auth.marketing.github"), + via: t("auth.marketing.via"), + builtBy: t("auth.marketing.builtBy"), + highlights: [ + t("auth.highlights.clients"), + t("auth.highlights.calendar"), + t("auth.highlights.finance"), + t("auth.highlights.reports"), + ] as [string, string, string, string], + }; return ( <> - {error && message && } + {error && message ? : null} {!error && message ? ( - {String(message)} + {message} ) : null} +
@@ -56,13 +99,13 @@ export default async function LoginPage({
- Şifremi unuttum + {t("auth.login.forgotPassword")}
- + - Giriş yap + {t("auth.login.submit")} } secondaryAction={null} footer={
- İlk kurulumu yapmadın mı?{" "} + {t("auth.login.setupPrompt")}{" "} - Admin hesabını oluştur + {t("auth.login.createAdmin")}
} diff --git a/app/not-found.tsx b/app/not-found.tsx new file mode 100644 index 0000000..5931ea3 --- /dev/null +++ b/app/not-found.tsx @@ -0,0 +1,34 @@ +import { getPublicBranding } from "@/server/branding/runtime"; +import { resolveRequestLocale } from "@/server/i18n/resolver"; +import { createTranslator } from "@/server/i18n/translator"; +import Link from "next/link"; +import { Button, Typography } from "poyraz-ui/atoms"; + +export default async function NotFoundPage() { + const locale = await resolveRequestLocale(); + const t = createTranslator(locale.locale, ["common"]).t; + const branding = getPublicBranding(); + + return ( +
+
+
+ 404 +
+
+ + {t("common.notFound.title")} + + + {t("common.notFound.description")} + +
+ +
+
+ ); +} diff --git a/app/portal/page.tsx b/app/portal/page.tsx index 2914d3d..f3ec879 100644 --- a/app/portal/page.tsx +++ b/app/portal/page.tsx @@ -1,14 +1,29 @@ import { Card, CardContent, Badge } from "poyraz-ui/atoms"; import { FolderKanban, CheckCircle2, Clock, BarChart } from "lucide-react"; import Link from "next/link"; -import { format } from "date-fns"; -import { tr } from "date-fns/locale"; import { StatCard } from "@/components/system/stat-card"; +import { getSqliteConnection } from "@/server/db/client"; +import { ContentTranslationService, getContentFallbackLocale } from "@/server/i18n/content"; +import { formatDate } from "@/lib/i18n/format"; +import { resolveRequestLocale } from "@/server/i18n/resolver"; +import { createTranslator } from "@/server/i18n/translator"; import { requirePortalBackend } from "@/server/web/portal"; export default async function PortalDashboardPage() { + const locale = await resolveRequestLocale(); + const t = createTranslator(locale.locale, ["portal"]).t; const { actor, service } = await requirePortalBackend(); - const projects = service.listProjects(actor); + const content = new ContentTranslationService(getSqliteConnection().db); + const localization = content.getPublicLocalizationContext(); + const fallbackLocale = getContentFallbackLocale(locale.locale, localization); + const projectRows = service.listProjects(actor); + const projectTranslations = content.listBatch("project", projectRows.map((project) => project.id)); + const projects = projectRows.map((project) => content.resolveEntity("project", project, { + locale: locale.locale, + fallbackLocale, + defaultLocale: locale.defaultLocale, + translations: projectTranslations.get(project.id) ?? [], + })); const activeProjects = projects.filter((project) => project.status !== "completed" && project.status !== "cancelled"); const completedProjects = projects.filter((project) => project.status === "completed"); const avgProgress = projects.length @@ -19,22 +34,22 @@ export default async function PortalDashboardPage() {
-

Müşteri Paneli

+

{t("portal.dashboard.title")}

- - - + + +
-

Tüm Projeleriniz

+

{t("portal.dashboard.allProjects")}

{projects.length === 0 ? (
- Henüz size atanmış bir proje bulunmuyor. + {t("portal.projects.empty")}
) : projects.map((project) => ( @@ -49,19 +64,19 @@ export default async function PortalDashboardPage() {
- {project.status === "completed" ? "Tamamlandı" : project.status === "active" ? "Aktif" : "Beklemede"} + {project.status === "completed" ? t("portal.status.project.completed") : project.status === "active" ? t("portal.status.project.active") : t("portal.status.project.waiting")} {project.dueDate && (
- Teslim: {format(new Date(project.dueDate), "d MMM yyyy", { locale: tr })} + {t("portal.labels.delivery")}: {formatDate(project.dueDate, locale.locale)}
)}
- İlerleme + {t("portal.labels.progress")} %{project.progress}
diff --git a/app/portal/projects/[id]/page.tsx b/app/portal/projects/[id]/page.tsx index 560a5eb..a28efc6 100644 --- a/app/portal/projects/[id]/page.tsx +++ b/app/portal/projects/[id]/page.tsx @@ -1,5 +1,8 @@ import { notFound } from "next/navigation"; +import { getSqliteConnection } from "@/server/db/client"; import { DomainError } from "@/server/domain/errors"; +import { ContentTranslationService, getContentFallbackLocale } from "@/server/i18n/content"; +import { resolveRequestLocale } from "@/server/i18n/resolver"; import { requirePortalBackend } from "@/server/web/portal"; import { PortalProjectClient, @@ -11,7 +14,11 @@ import { export default async function PortalProjectPage({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; + const locale = await resolveRequestLocale(); const { actor, service } = await requirePortalBackend(); + const content = new ContentTranslationService(getSqliteConnection().db); + const localization = content.getPublicLocalizationContext(); + const fallbackLocale = getContentFallbackLocale(locale.locale, localization); let data: { project: PortalProjectDetail; sections: PortalPlanningSection[]; @@ -21,32 +28,57 @@ export default async function PortalProjectPage({ params }: { params: Promise<{ try { const row = service.getProject(actor, id); + const projectTranslations = content.listEntityTranslations("project", row.id); + const projectRow = content.resolveEntity("project", row, { + locale: locale.locale, + fallbackLocale, + defaultLocale: locale.defaultLocale, + translations: projectTranslations, + }); const allowance = service.getRevisionAllowance(actor, id); + const sectionRows = service.listPlanningSections(actor, id); + const sectionTranslations = content.listBatch("planning_section", sectionRows.map((section) => section.id)); + const taskRows = service.listTasks(actor, id).filter((task) => task.status !== "cancelled"); + const taskTranslations = content.listBatch("task", taskRows.map((task) => task.id)); data = { project: { - id: row.id, - name: row.name, - description: row.description, - status: row.status, - progress: row.progress, - due_date: row.dueDate, + id: projectRow.id, + name: projectRow.name, + description: projectRow.description, + status: projectRow.status, + progress: projectRow.progress, + due_date: projectRow.dueDate, revision_quota: allowance.remaining, can_request_revision: allowance.canRequest, }, - sections: service.listPlanningSections(actor, id).map((section) => ({ - id: section.id, - title: section.title, - content: section.content, - type: section.category, - })), - tasks: service.listTasks(actor, id) - .filter((task) => task.status !== "cancelled") - .map((task) => ({ + sections: sectionRows.map((section) => { + const sectionRow = content.resolveEntity("planning_section", section, { + locale: locale.locale, + fallbackLocale, + defaultLocale: locale.defaultLocale, + translations: sectionTranslations.get(section.id) ?? [], + }); + return { + id: sectionRow.id, + title: sectionRow.title, + content: sectionRow.content, + type: sectionRow.category, + }; + }), + tasks: taskRows.map((task) => { + const taskRow = content.resolveEntity("task", task, { + locale: locale.locale, + fallbackLocale, + defaultLocale: locale.defaultLocale, + translations: taskTranslations.get(task.id) ?? [], + }); + return { id: task.id, - title: task.title, + title: taskRow.title, status: task.status as PortalTask["status"], date: task.dueAt?.toISOString() ?? task.scheduledDate, - })), + }; + }), revisions: service.listRevisions(actor, id).map((revision) => ({ id: revision.id, description: revision.description, @@ -65,6 +97,7 @@ export default async function PortalProjectPage({ params }: { params: Promise<{ sections={data.sections} tasks={data.tasks} revisions={data.revisions} + locale={locale.locale} /> ); } diff --git a/app/portal/projects/[id]/portal-project-client.tsx b/app/portal/projects/[id]/portal-project-client.tsx index fcb12c4..0b76a72 100644 --- a/app/portal/projects/[id]/portal-project-client.tsx +++ b/app/portal/projects/[id]/portal-project-client.tsx @@ -1,15 +1,24 @@ "use client"; import { useState } from "react"; -import { format } from "date-fns"; -import { tr } from "date-fns/locale"; import { Card, CardContent, Badge, Button, Textarea, Label } from "poyraz-ui/atoms"; -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogFooter } from "poyraz-ui/molecules"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, + DialogFooter, + Tabs, + TabsContent, + TabsList, + TabsTrigger, + toast, +} from "poyraz-ui/molecules"; import { CheckCircle2, Clock, MessageSquare, Loader2, RefreshCw } from "lucide-react"; -import { toast } from "poyraz-ui/molecules"; import { createRevisionRequest } from "./actions"; - -import { Tabs, TabsContent, TabsList, TabsTrigger } from "poyraz-ui/molecules"; +import { useTranslations } from "@/components/i18n/i18n-provider"; +import { formatDate, formatDateTime } from "@/lib/i18n/format"; export type PortalProjectDetail = { id: string; @@ -48,150 +57,174 @@ type PortalProjectClientProps = { sections: PortalPlanningSection[]; tasks: PortalTask[]; revisions: PortalRevision[]; + locale: string; }; -export function PortalProjectClient({ project, sections, tasks, revisions }: PortalProjectClientProps) { +export function PortalProjectClient({ + project, + sections, + tasks, + revisions, + locale, +}: PortalProjectClientProps) { + const t = useTranslations(); const [isSubmitting, setIsSubmitting] = useState(false); const [openRevision, setOpenRevision] = useState(false); - const handleRevision = async (e: React.FormEvent) => { - e.preventDefault(); + async function handleRevision(event: React.FormEvent) { + event.preventDefault(); setIsSubmitting(true); - const formData = new FormData(e.currentTarget); + const formData = new FormData(event.currentTarget); + try { - const res = await createRevisionRequest(project.id, formData); - if (res.error) throw new Error(res.error); - toast.success("Revizyon talebiniz başarıyla iletildi."); + const response = await createRevisionRequest(project.id, formData); + if (response.error) throw new Error(response.error); + toast.success(t("portal.revision.success")); setOpenRevision(false); } catch (error: unknown) { - toast.error(error instanceof Error ? error.message : "Revizyon talebi oluşturulamadı."); + toast.error(error instanceof Error ? error.message : t("portal.revision.error")); } finally { setIsSubmitting(false); } - }; + } - const pendingRevisions = revisions.filter((revision) => revision.status === 'pending' || revision.status === 'in_progress').length; + const pendingRevisions = revisions.filter( + (revision) => revision.status === "pending" || revision.status === "in_progress", + ).length; const hasRevisionQuota = project.can_request_revision; return (
- {/* Header Info */} -
+

{project.name}

- {project.status} + + {projectStatusLabel(project.status, t)} + {hasRevisionQuota ? ( -
- Yeni Revizyon Talebi + {t("portal.revision.title")} -
- {pendingRevisions > 0 && ( -
- Şu anda sonuçlanmamış {pendingRevisions} adet revizyon talebiniz var. Yeni bir tane eklemek istediğinize emin misiniz? +
+ {pendingRevisions > 0 ? ( +
+ {t("portal.revision.pendingWarning", { count: pendingRevisions })}
- )} + ) : null}
- -