feat: localize dashboard shell

This commit is contained in:
poyrazavsever
2026-07-19 03:07:10 +03:00
parent 5d3e720280
commit c7a4f3de51
11 changed files with 303 additions and 77 deletions
+8 -5
View File
@@ -1,5 +1,7 @@
"use client"; "use client";
import { getDocumentIntlLocale } from "@/lib/i18n/browser";
import { useTranslations } from "@/components/i18n/i18n-provider";
import { import {
createCalendarEventRecord, createCalendarEventRecord,
deleteCalendarEventRecord, deleteCalendarEventRecord,
@@ -73,6 +75,7 @@ type CalendarClientProps = {
}; };
export function CalendarClient({ events, clients, projects, tasks }: CalendarClientProps) { export function CalendarClient({ events, clients, projects, tasks }: CalendarClientProps) {
const t = useTranslations();
const [monthDate, setMonthDate] = useState(() => new Date()); const [monthDate, setMonthDate] = useState(() => new Date());
const [selectedDate, setSelectedDate] = useState(() => toDateKey(new Date())); const [selectedDate, setSelectedDate] = useState(() => toDateKey(new Date()));
const days = useMemo(() => buildMonthDays(monthDate), [monthDate]); const days = useMemo(() => buildMonthDays(monthDate), [monthDate]);
@@ -90,7 +93,7 @@ export function CalendarClient({ events, clients, projects, tasks }: CalendarCli
<div className="mx-auto flex max-w-7xl flex-col gap-6"> <div className="mx-auto flex max-w-7xl flex-col gap-6">
<div className="flex flex-col gap-4 border-b border-border pb-5 lg:flex-row lg:items-end lg:justify-between"> <div className="flex flex-col gap-4 border-b border-border pb-5 lg:flex-row lg:items-end lg:justify-between">
<div> <div>
<h1 className="text-3xl font-semibold tracking-normal text-foreground">Takvim</h1> <h1 className="text-3xl font-semibold tracking-normal text-foreground">{t("calendar.title")}</h1>
</div> </div>
<CalendarEventDialog <CalendarEventDialog
@@ -442,16 +445,16 @@ function startOfToday() {
} }
function formatMonth(date: Date) { function formatMonth(date: Date) {
return new Intl.DateTimeFormat("tr-TR", { month: "long", year: "numeric" }).format(date); return new Intl.DateTimeFormat(getDocumentIntlLocale(), { month: "long", year: "numeric" }).format(date);
} }
function formatDateLabel(dateKey: string) { function formatDateLabel(dateKey: string) {
return new Intl.DateTimeFormat("tr-TR", { day: "2-digit", month: "long", year: "numeric" }).format(new Date(`${dateKey}T00:00:00`)); return new Intl.DateTimeFormat(getDocumentIntlLocale(), { day: "2-digit", month: "long", year: "numeric" }).format(new Date(`${dateKey}T00:00:00`));
} }
function formatTimeRange(event: CalendarEventItem) { function formatTimeRange(event: CalendarEventItem) {
const start = new Intl.DateTimeFormat("tr-TR", { hour: "2-digit", minute: "2-digit" }).format(new Date(event.starts_at)); const start = new Intl.DateTimeFormat(getDocumentIntlLocale(), { hour: "2-digit", minute: "2-digit" }).format(new Date(event.starts_at));
const end = event.ends_at ? new Intl.DateTimeFormat("tr-TR", { hour: "2-digit", minute: "2-digit" }).format(new Date(event.ends_at)) : null; const end = event.ends_at ? new Intl.DateTimeFormat(getDocumentIntlLocale(), { hour: "2-digit", minute: "2-digit" }).format(new Date(event.ends_at)) : null;
return end ? `${start} - ${end}` : start; return end ? `${start} - ${end}` : start;
} }
+3 -1
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useChat } from "@ai-sdk/react"; import { useChat } from "@ai-sdk/react";
import { useTranslations } from "@/components/i18n/i18n-provider";
import { DefaultChatTransport, type UIMessage } from "ai"; import { DefaultChatTransport, type UIMessage } from "ai";
import { Brain, Loader2, MessageSquare, Plus, Send, Trash2 } from "lucide-react"; import { Brain, Loader2, MessageSquare, Plus, Send, Trash2 } from "lucide-react";
import { Button } from "poyraz-ui/atoms"; import { Button } from "poyraz-ui/atoms";
@@ -43,6 +44,7 @@ type ChatSession = {
}; };
export default function AIChatPage() { export default function AIChatPage() {
const t = useTranslations();
const [sessions, setSessions] = useState<ChatSession[]>([]); const [sessions, setSessions] = useState<ChatSession[]>([]);
const [activeSessionId, setActiveSessionId] = useState<string | null>(null); const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
const [input, setInput] = useState(""); const [input, setInput] = useState("");
@@ -235,7 +237,7 @@ export default function AIChatPage() {
<Brain className="h-4 w-4" /> <Brain className="h-4 w-4" />
</div> </div>
<div> <div>
<h1 className="text-sm font-semibold text-foreground">AI Asistan</h1> <h1 className="text-sm font-semibold text-foreground">{t("chat.title")}</h1>
</div> </div>
</div> </div>
<Button effect="shine" variant="secondary" size="sm" className="md:hidden text-xs px-3" onClick={() => setIsMobileSessionsOpen(true)}> <Button effect="shine" variant="secondary" size="sm" className="md:hidden text-xs px-3" onClick={() => setIsMobileSessionsOpen(true)}>
+25 -22
View File
@@ -1,5 +1,7 @@
"use client"; "use client";
import { getDocumentIntlLocale } from "@/lib/i18n/browser";
import { useTranslations } from "@/components/i18n/i18n-provider";
import { useRouter, usePathname, useSearchParams } from "next/navigation"; import { useRouter, usePathname, useSearchParams } from "next/navigation";
import { PendingLink } from "@/components/ui/pending-link"; import { PendingLink } from "@/components/ui/pending-link";
import { StatCard } from "@/components/system/stat-card"; import { StatCard } from "@/components/system/stat-card";
@@ -27,6 +29,7 @@ type DashboardClientProps = {
}; };
export function DashboardClient({ data }: DashboardClientProps) { export function DashboardClient({ data }: DashboardClientProps) {
const t = useTranslations();
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@@ -41,20 +44,20 @@ export function DashboardClient({ data }: DashboardClientProps) {
// Format dates for Recharts using local timezone // Format dates for Recharts using local timezone
const incomeTrendData = (financeTrend || []).map(f => ({ const incomeTrendData = (financeTrend || []).map(f => ({
name: new Date(f.date).toLocaleDateString("tr-TR", { month: "short", day: "numeric" }), name: new Date(f.date).toLocaleDateString(getDocumentIntlLocale(), { month: "short", day: "numeric" }),
income: f.income, income: f.income,
expense: f.expense expense: f.expense
})); }));
const moodTrendData = (moodTrend || []).map(l => ({ const moodTrendData = (moodTrend || []).map(l => ({
date: new Date(l.date).toLocaleDateString("tr-TR", { month: "short", day: "numeric" }), date: new Date(l.date).toLocaleDateString(getDocumentIntlLocale(), { month: "short", day: "numeric" }),
mood: l.mood, mood: l.mood,
energy: l.energy, energy: l.energy,
})); }));
// Format currency // Format currency
const formatCurrency = (val: number) => { const formatCurrency = (val: number) => {
return new Intl.NumberFormat("tr-TR", { return new Intl.NumberFormat(getDocumentIntlLocale(), {
style: "currency", style: "currency",
currency: "USD", currency: "USD",
maximumFractionDigits: 0, maximumFractionDigits: 0,
@@ -67,19 +70,19 @@ export function DashboardClient({ data }: DashboardClientProps) {
<div className="flex flex-col gap-4 border-b border-border pb-5 lg:flex-row lg:items-end lg:justify-between"> <div className="flex flex-col gap-4 border-b border-border pb-5 lg:flex-row lg:items-end lg:justify-between">
<div> <div>
<h1 className="text-3xl font-semibold tracking-normal text-foreground"> <h1 className="text-3xl font-semibold tracking-normal text-foreground">
Dashboard {t("dashboard.title")}
</h1> </h1>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Select value={data.range} onValueChange={handleRangeChange}> <Select value={data.range} onValueChange={handleRangeChange}>
<SelectTrigger className="w-[160px]"> <SelectTrigger className="w-[160px]">
<SelectValue placeholder="Tarih aralığı" /> <SelectValue placeholder={t("dashboard.filters.range")} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="today">Bugün</SelectItem> <SelectItem value="today">{t("dashboard.filters.today")}</SelectItem>
<SelectItem value="this_week">Bu Hafta</SelectItem> <SelectItem value="this_week">{t("dashboard.filters.thisWeek")}</SelectItem>
<SelectItem value="this_month">Bu Ay</SelectItem> <SelectItem value="this_month">{t("dashboard.filters.thisMonth")}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
@@ -88,17 +91,17 @@ export function DashboardClient({ data }: DashboardClientProps) {
{/* KPI Cards */} {/* KPI Cards */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatCard label="Net Kazanç" value={formatCurrency(netProfit)} icon={Wallet} tone="green" /> <StatCard label={t("dashboard.stats.netEarnings")} value={formatCurrency(netProfit)} icon={Wallet} tone="green" />
<StatCard label="Aktif Projeler" value={activeProjectsCount.toString()} icon={FolderKanban} tone="blue" /> <StatCard label={t("dashboard.stats.activeProjects")} value={activeProjectsCount.toString()} icon={FolderKanban} tone="blue" />
<StatCard label="Tamamlanan Görev" value={completedTasksCount.toString()} icon={CheckCircle2} tone="amber" /> <StatCard label={t("dashboard.stats.completedTasks")} value={completedTasksCount.toString()} icon={CheckCircle2} tone="amber" />
<StatCard label="Ortalama Mood" value={avgMood} icon={Activity} tone="red" /> <StatCard label={t("dashboard.stats.averageMood")} value={avgMood} icon={Activity} tone="red" />
</div> </div>
{/* Charts */} {/* Charts */}
<div className="grid gap-6 lg:grid-cols-2"> <div className="grid gap-6 lg:grid-cols-2">
<Card> <Card>
<CardContent className="p-6"> <CardContent className="p-6">
<h3 className="mb-6 text-sm font-semibold text-foreground">Gelir / Gider Özeti</h3> <h3 className="mb-6 text-sm font-semibold text-foreground">{t("dashboard.sections.financeSummary")}</h3>
<div className="h-[300px] w-full"> <div className="h-[300px] w-full">
{incomeTrendData.length > 0 ? ( {incomeTrendData.length > 0 ? (
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
@@ -160,7 +163,7 @@ export function DashboardClient({ data }: DashboardClientProps) {
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
) : ( ) : (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">Bu tarih aralığında finansal veri yok.</div> <div className="flex h-full items-center justify-center text-sm text-muted-foreground">{t("dashboard.empty.finance")}</div>
)} )}
</div> </div>
</CardContent> </CardContent>
@@ -168,7 +171,7 @@ export function DashboardClient({ data }: DashboardClientProps) {
<Card> <Card>
<CardContent className="p-6"> <CardContent className="p-6">
<h3 className="mb-6 text-sm font-semibold text-foreground">Mood & Enerji Trendi</h3> <h3 className="mb-6 text-sm font-semibold text-foreground">{t("dashboard.sections.moodTrend")}</h3>
<div className="h-[300px] w-full"> <div className="h-[300px] w-full">
{moodTrendData.length > 0 ? ( {moodTrendData.length > 0 ? (
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
@@ -216,7 +219,7 @@ export function DashboardClient({ data }: DashboardClientProps) {
</LineChart> </LineChart>
</ResponsiveContainer> </ResponsiveContainer>
) : ( ) : (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">Bu tarih aralığında günlük verisi yok.</div> <div className="flex h-full items-center justify-center text-sm text-muted-foreground">{t("dashboard.empty.journal")}</div>
)} )}
</div> </div>
</CardContent> </CardContent>
@@ -229,7 +232,7 @@ export function DashboardClient({ data }: DashboardClientProps) {
<Card> <Card>
<CardContent className="p-6"> <CardContent className="p-6">
<div className="mb-4 flex items-center justify-between"> <div className="mb-4 flex items-center justify-between">
<h3 className="text-sm font-semibold text-foreground">Son Eklenen Projeler</h3> <h3 className="text-sm font-semibold text-foreground">{t("dashboard.sections.recentProjects")}</h3>
<FolderKanban className="h-4 w-4 text-muted-foreground" /> <FolderKanban className="h-4 w-4 text-muted-foreground" />
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
@@ -240,7 +243,7 @@ export function DashboardClient({ data }: DashboardClientProps) {
<div> <div>
<p className="text-sm font-medium">{project.name}</p> <p className="text-sm font-medium">{project.name}</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{new Date(project.created_at).toLocaleDateString("tr-TR", { month: "short", day: "numeric" })} {new Date(project.created_at).toLocaleDateString(getDocumentIntlLocale(), { month: "short", day: "numeric" })}
</p> </p>
</div> </div>
<Badge variant={project.status === 'completed' ? 'secondary' : 'default'} className="capitalize text-[10px] px-1.5 py-0"> <Badge variant={project.status === 'completed' ? 'secondary' : 'default'} className="capitalize text-[10px] px-1.5 py-0">
@@ -250,7 +253,7 @@ export function DashboardClient({ data }: DashboardClientProps) {
</PendingLink> </PendingLink>
))} ))}
{data.projects.length === 0 && ( {data.projects.length === 0 && (
<div className="text-sm text-muted-foreground py-4 text-center border border-dashed rounded-sm border-border bg-muted/20">Henüz proje yok.</div> <div className="text-sm text-muted-foreground py-4 text-center border border-dashed rounded-sm border-border bg-muted/20">{t("dashboard.empty.projects")}</div>
)} )}
</div> </div>
</CardContent> </CardContent>
@@ -260,7 +263,7 @@ export function DashboardClient({ data }: DashboardClientProps) {
<Card> <Card>
<CardContent className="p-6"> <CardContent className="p-6">
<div className="mb-4 flex items-center justify-between"> <div className="mb-4 flex items-center justify-between">
<h3 className="text-sm font-semibold text-foreground">Son Eklenen Müşteriler</h3> <h3 className="text-sm font-semibold text-foreground">{t("dashboard.sections.recentClients")}</h3>
<Users className="h-4 w-4 text-muted-foreground" /> <Users className="h-4 w-4 text-muted-foreground" />
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
@@ -274,11 +277,11 @@ export function DashboardClient({ data }: DashboardClientProps) {
<p className="text-xs text-muted-foreground">{client.company_name || "Bireysel"}</p> <p className="text-xs text-muted-foreground">{client.company_name || "Bireysel"}</p>
</div> </div>
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
{new Date(client.created_at).toLocaleDateString("tr-TR", { month: "short", day: "numeric" })} {new Date(client.created_at).toLocaleDateString(getDocumentIntlLocale(), { month: "short", day: "numeric" })}
</div> </div>
</PendingLink> </PendingLink>
)) : ( )) : (
<div className="text-sm text-muted-foreground py-4 text-center border border-dashed rounded-sm border-border bg-muted/20">Henüz müşteri yok.</div> <div className="text-sm text-muted-foreground py-4 text-center border border-dashed rounded-sm border-border bg-muted/20">{t("dashboard.empty.clients")}</div>
)} )}
</div> </div>
</CardContent> </CardContent>
+6 -3
View File
@@ -1,5 +1,7 @@
"use client"; "use client";
import { getDocumentIntlLocale } from "@/lib/i18n/browser";
import { useTranslations } from "@/components/i18n/i18n-provider";
import { import {
createFinanceTransactionRecord, createFinanceTransactionRecord,
deleteFinanceTransactionRecord, deleteFinanceTransactionRecord,
@@ -103,6 +105,7 @@ type FinanceClientProps = {
}; };
export function FinanceClient({ transactions, clients, projects }: FinanceClientProps) { export function FinanceClient({ transactions, clients, projects }: FinanceClientProps) {
const t = useTranslations();
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [monthFilter, setMonthFilter] = useState(() => new Date().toISOString().slice(0, 7)); const [monthFilter, setMonthFilter] = useState(() => new Date().toISOString().slice(0, 7));
const summaryTrackRef = useRef<HTMLDivElement>(null); const summaryTrackRef = useRef<HTMLDivElement>(null);
@@ -146,7 +149,7 @@ export function FinanceClient({ transactions, clients, projects }: FinanceClient
<div className="flex flex-col gap-4 border-b border-border pb-5 lg:flex-row lg:items-end lg:justify-between"> <div className="flex flex-col gap-4 border-b border-border pb-5 lg:flex-row lg:items-end lg:justify-between">
<div> <div>
<h1 className="text-3xl font-semibold tracking-normal text-foreground"> <h1 className="text-3xl font-semibold tracking-normal text-foreground">
Finans işlemleri {t("finance.title")}
</h1> </h1>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
@@ -730,7 +733,7 @@ function calculateExpenseCategories(transactions: FinanceTransactionItem[]) {
} }
function formatCurrency(value: number, currency = "USD") { function formatCurrency(value: number, currency = "USD") {
return new Intl.NumberFormat("tr-TR", { return new Intl.NumberFormat(getDocumentIntlLocale(), {
style: "currency", style: "currency",
currency, currency,
maximumFractionDigits: 0, maximumFractionDigits: 0,
@@ -738,7 +741,7 @@ function formatCurrency(value: number, currency = "USD") {
} }
function formatDate(value: string) { function formatDate(value: string) {
return new Intl.DateTimeFormat("tr-TR", { return new Intl.DateTimeFormat(getDocumentIntlLocale(), {
day: "2-digit", day: "2-digit",
month: "short", month: "short",
year: "numeric", year: "numeric",
+7 -4
View File
@@ -1,5 +1,7 @@
"use client"; "use client";
import { getDocumentIntlLocale } from "@/lib/i18n/browser";
import { useTranslations } from "@/components/i18n/i18n-provider";
import { import {
createDailyLogRecord, createDailyLogRecord,
deleteDailyLogRecord, deleteDailyLogRecord,
@@ -60,6 +62,7 @@ const scoreLabels: Record<number, string> = {
}; };
export function JournalClient({ logs }: JournalClientProps) { export function JournalClient({ logs }: JournalClientProps) {
const t = useTranslations();
const summary = useMemo(() => calculateSummary(logs), [logs]); const summary = useMemo(() => calculateSummary(logs), [logs]);
const chartData = useMemo( const chartData = useMemo(
() => () =>
@@ -79,7 +82,7 @@ export function JournalClient({ logs }: JournalClientProps) {
<div className="flex flex-col gap-4 border-b border-border pb-5 lg:flex-row lg:items-end lg:justify-between"> <div className="flex flex-col gap-4 border-b border-border pb-5 lg:flex-row lg:items-end lg:justify-between">
<div> <div>
<h1 className="text-3xl font-semibold tracking-normal text-foreground"> <h1 className="text-3xl font-semibold tracking-normal text-foreground">
Mood ve enerji {t("journal.title")}
</h1> </h1>
</div> </div>
@@ -436,7 +439,7 @@ function average(values: number[]) {
} }
function formatDate(value: string) { function formatDate(value: string) {
return new Intl.DateTimeFormat("tr-TR", { return new Intl.DateTimeFormat(getDocumentIntlLocale(), {
day: "2-digit", day: "2-digit",
month: "short", month: "short",
year: "numeric", year: "numeric",
@@ -444,14 +447,14 @@ function formatDate(value: string) {
} }
function formatShortDate(value: string) { function formatShortDate(value: string) {
return new Intl.DateTimeFormat("tr-TR", { return new Intl.DateTimeFormat(getDocumentIntlLocale(), {
day: "2-digit", day: "2-digit",
month: "short", month: "short",
}).format(new Date(`${value}T00:00:00`)); }).format(new Date(`${value}T00:00:00`));
} }
function formatWeekday(value: string) { function formatWeekday(value: string) {
return new Intl.DateTimeFormat("tr-TR", { return new Intl.DateTimeFormat(getDocumentIntlLocale(), {
weekday: "long", weekday: "long",
}).format(new Date(`${value}T00:00:00`)); }).format(new Date(`${value}T00:00:00`));
} }
+49
View File
@@ -2,6 +2,8 @@ import { DashboardShell } from "@/components/layout/dashboard-shell";
import { domainActorFromSession } from "@/server/auth/domain-actor"; import { domainActorFromSession } from "@/server/auth/domain-actor";
import { requireFreelancer } from "@/server/auth/session"; import { requireFreelancer } from "@/server/auth/session";
import { getPublicBranding } from "@/server/branding/runtime"; import { getPublicBranding } from "@/server/branding/runtime";
import { resolveRequestLocale } from "@/server/i18n/resolver";
import { createTranslator, getClientI18nPayload } from "@/server/i18n/translator";
import { getUserPreferences } from "@/server/settings/preferences"; import { getUserPreferences } from "@/server/settings/preferences";
export default async function DashboardLayout({ export default async function DashboardLayout({
@@ -13,6 +15,23 @@ export default async function DashboardLayout({
const { user, profile } = context; const { user, profile } = context;
const branding = getPublicBranding(); const branding = getPublicBranding();
const preferences = getUserPreferences(domainActorFromSession(context)); const preferences = getUserPreferences(domainActorFromSession(context));
const resolvedLocale = await resolveRequestLocale();
const translator = createTranslator(resolvedLocale.locale, [
"common",
"navigation",
"dashboard",
"clients",
"projects",
"tasks",
"calendar",
"finance",
"journal",
"chat",
"settings",
"status",
"validation",
]);
const t = translator.t;
const displayName = profile.displayName || user.name || user.email.split("@")[0] || "Neta Kullanıcısı"; const displayName = profile.displayName || user.name || user.email.split("@")[0] || "Neta Kullanıcısı";
const shortName = const shortName =
@@ -33,6 +52,36 @@ export default async function DashboardLayout({
darkLogoUrl: branding.darkLogoUrl, darkLogoUrl: branding.darkLogoUrl,
}} }}
colorMode={preferences.colorMode} colorMode={preferences.colorMode}
i18n={getClientI18nPayload(resolvedLocale.locale, [
"common",
"navigation",
"dashboard",
"clients",
"projects",
"tasks",
"calendar",
"finance",
"journal",
"chat",
"settings",
"status",
"validation",
])}
labels={{
skipToContent: t("navigation.shell.skipToContent"),
homeAriaLabel: t("navigation.shell.homeAriaLabel", { app: branding.applicationName }),
mobileMenuAriaLabel: t("navigation.shell.mobileMenuAriaLabel"),
mobileMenuTooltip: t("navigation.shell.mobileMenuTooltip"),
logoAlt: t("navigation.shell.logoAlt", { app: branding.applicationName }),
progressTitle: t("navigation.shell.progressTitle"),
progressValue: t("navigation.shell.progressValue"),
progressAriaLabel: t("navigation.shell.progressAriaLabel"),
accountMenuAriaLabel: t("navigation.shell.accountMenuAriaLabel"),
settings: t("navigation.items.settings"),
signOut: t("navigation.account.signOut"),
signingOut: t("navigation.account.signingOut"),
signOutError: t("navigation.account.signOutError"),
}}
user={{ user={{
email: user.email, email: user.email,
displayName, displayName,
+4 -1
View File
@@ -9,6 +9,7 @@ import {
} from "@/lib/color-mode"; } from "@/lib/color-mode";
import { Toaster } from "poyraz-ui/molecules"; import { Toaster } from "poyraz-ui/molecules";
import { getPublicBranding } from "@/server/branding/runtime"; import { getPublicBranding } from "@/server/branding/runtime";
import { resolveRequestLocale } from "@/server/i18n/resolver";
const colorModeScript = `(() => { const colorModeScript = `(() => {
const root = document.documentElement; const root = document.documentElement;
@@ -52,10 +53,12 @@ export default async function RootLayout({
const colorMode = isColorMode(cookieColorMode) const colorMode = isColorMode(cookieColorMode)
? cookieColorMode ? cookieColorMode
: branding.defaultColorMode; : branding.defaultColorMode;
const locale = await resolveRequestLocale();
return ( return (
<html <html
lang="tr" lang={locale.locale}
dir={locale.direction}
className={cn("font-sans", colorMode === "dark" && "dark")} className={cn("font-sans", colorMode === "dark" && "dark")}
data-color-mode={colorMode} data-color-mode={colorMode}
style={branding.cssVariables as CSSProperties} style={branding.cssVariables as CSSProperties}
+56 -19
View File
@@ -72,6 +72,7 @@ type AppShellProps = {
branding: AppShellBranding; branding: AppShellBranding;
children: React.ReactNode; children: React.ReactNode;
homeHref: string; homeHref: string;
labels?: AppShellLabels;
navGroups: AppShellNavGroup[]; navGroups: AppShellNavGroup[];
settingsHref: string; settingsHref: string;
user: ShellUser; user: ShellUser;
@@ -79,10 +80,27 @@ type AppShellProps = {
colorMode?: ColorMode; colorMode?: ColorMode;
}; };
export type AppShellLabels = {
skipToContent: string;
homeAriaLabel: string;
mobileMenuAriaLabel: string;
mobileMenuTooltip: string;
logoAlt: string;
progressTitle: string;
progressValue: string;
progressAriaLabel: string;
accountMenuAriaLabel: string;
settings: string;
signOut: string;
signingOut: string;
signOutError: string;
};
export function AppShell({ export function AppShell({
branding, branding,
children, children,
homeHref, homeHref,
labels = defaultAppShellLabels,
navGroups, navGroups,
settingsHref, settingsHref,
user, user,
@@ -90,7 +108,7 @@ export function AppShell({
colorMode, colorMode,
}: AppShellProps) { }: AppShellProps) {
const pathname = usePathname(); const pathname = usePathname();
const sidebarProps = { branding, homeHref, navGroups, pathname, progress, settingsHref, user }; const sidebarProps = { branding, homeHref, labels, navGroups, pathname, progress, settingsHref, user };
return ( return (
<TooltipProvider> <TooltipProvider>
@@ -100,7 +118,7 @@ export function AppShell({
href="#main-content" href="#main-content"
className="sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-[90] focus:rounded-md focus:bg-surface focus:px-4 focus:py-2 focus:text-sm focus:font-medium focus:shadow-lg focus:ring-2 focus:ring-focus-ring" className="sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-[90] focus:rounded-md focus:bg-surface focus:px-4 focus:py-2 focus:text-sm focus:font-medium focus:shadow-lg focus:ring-2 focus:ring-focus-ring"
> >
Ana içeriğe geç {labels.skipToContent}
</a> </a>
<div className="flex min-h-screen"> <div className="flex min-h-screen">
@@ -118,9 +136,26 @@ export function AppShell({
); );
} }
const defaultAppShellLabels: AppShellLabels = {
skipToContent: "Ana içeriğe geç",
homeAriaLabel: "Ana sayfa",
mobileMenuAriaLabel: "Ana menüyü aç veya kapat",
mobileMenuTooltip: "Menü",
logoAlt: "Logo",
progressTitle: "Proje ilerlemesi",
progressValue: "%{progress} tamamlandı",
progressAriaLabel: "Proje ilerlemesi",
accountMenuAriaLabel: "{name} için hesap menüsünü aç",
settings: "Ayarlar",
signOut: "Çıkış yap",
signingOut: "Çıkış yapılıyor",
signOutError: "Çıkış yapılamadı. Lütfen tekrar deneyin.",
};
type SidebarCompositionProps = { type SidebarCompositionProps = {
branding: AppShellBranding; branding: AppShellBranding;
homeHref: string; homeHref: string;
labels: AppShellLabels;
navGroups: AppShellNavGroup[]; navGroups: AppShellNavGroup[];
pathname: string; pathname: string;
progress?: number; progress?: number;
@@ -145,15 +180,15 @@ function MobileSidebar(props: SidebarCompositionProps) {
<Link <Link
href={props.homeHref} href={props.homeHref}
className="flex min-w-0 max-w-40 items-center" className="flex min-w-0 max-w-40 items-center"
aria-label={`${props.branding.applicationName} ana sayfa`} aria-label={props.labels.homeAriaLabel}
> >
<WorkspaceLogo branding={props.branding} compact /> <WorkspaceLogo branding={props.branding} compact />
</Link> </Link>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<SidebarTrigger action="mobile" aria-label="Ana menüyü aç veya kapat" /> <SidebarTrigger action="mobile" aria-label={props.labels.mobileMenuAriaLabel} />
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>Menü</TooltipContent> <TooltipContent>{props.labels.mobileMenuTooltip}</TooltipContent>
</Tooltip> </Tooltip>
</header> </header>
@@ -167,6 +202,7 @@ function MobileSidebar(props: SidebarCompositionProps) {
function SidebarComposition({ function SidebarComposition({
branding, branding,
homeHref, homeHref,
labels,
navGroups, navGroups,
pathname, pathname,
progress, progress,
@@ -179,7 +215,7 @@ function SidebarComposition({
<Link <Link
href={homeHref} href={homeHref}
className="flex min-h-12 w-full items-center justify-center" className="flex min-h-12 w-full items-center justify-center"
aria-label={`${branding.applicationName} ana sayfa`} aria-label={labels.homeAriaLabel}
> >
<WorkspaceLogo branding={branding} /> <WorkspaceLogo branding={branding} />
</Link> </Link>
@@ -194,8 +230,8 @@ function SidebarComposition({
</SidebarContent> </SidebarContent>
<SidebarFooter className="flex flex-col gap-3"> <SidebarFooter className="flex flex-col gap-3">
{typeof progress === "number" ? <ProgressSummary progress={progress} /> : null} {typeof progress === "number" ? <ProgressSummary labels={labels} progress={progress} /> : null}
<AccountMenu user={user} settingsHref={settingsHref} /> <AccountMenu labels={labels} user={user} settingsHref={settingsHref} />
</SidebarFooter> </SidebarFooter>
</> </>
); );
@@ -257,7 +293,7 @@ function WorkspaceLogo({
<span className="flex h-full w-full items-center justify-center overflow-hidden"> <span className="flex h-full w-full items-center justify-center overflow-hidden">
<Image <Image
src={lightLogoUrl} src={lightLogoUrl}
alt={`${branding.applicationName} logosu`} alt={branding.applicationName}
width={180} width={180}
height={56} height={56}
unoptimized unoptimized
@@ -265,7 +301,7 @@ function WorkspaceLogo({
/> />
<Image <Image
src={darkLogoUrl} src={darkLogoUrl}
alt={`${branding.applicationName} logosu`} alt={branding.applicationName}
width={180} width={180}
height={56} height={56}
unoptimized unoptimized
@@ -275,22 +311,23 @@ function WorkspaceLogo({
); );
} }
function ProgressSummary({ progress }: { progress: number }) { function ProgressSummary({ labels, progress }: { labels: AppShellLabels; progress: number }) {
const normalizedProgress = Math.max(0, Math.min(100, progress)); const normalizedProgress = Math.max(0, Math.min(100, progress));
const progressValue = labels.progressValue.replace("{progress}", String(normalizedProgress));
return ( return (
<Card variant="soft" className="w-full overflow-hidden border-primary/10 shadow-none"> <Card variant="soft" className="w-full overflow-hidden border-primary/10 shadow-none">
<CardContent className="space-y-3 p-3"> <CardContent className="space-y-3 p-3">
<Typography variant="small" className="font-semibold"> <Typography variant="small" className="font-semibold">
Proje ilerlemesi {labels.progressTitle}
</Typography> </Typography>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Typography variant="caption" className="font-medium text-primary"> <Typography variant="caption" className="font-medium text-primary">
%{normalizedProgress} tamamlandı {progressValue}
</Typography> </Typography>
<div <div
role="progressbar" role="progressbar"
aria-label="Proje ilerlemesi" aria-label={labels.progressAriaLabel}
aria-valuemin={0} aria-valuemin={0}
aria-valuemax={100} aria-valuemax={100}
aria-valuenow={normalizedProgress} aria-valuenow={normalizedProgress}
@@ -307,7 +344,7 @@ function ProgressSummary({ progress }: { progress: number }) {
); );
} }
function AccountMenu({ user, settingsHref }: { user: ShellUser; settingsHref: string }) { function AccountMenu({ labels, user, settingsHref }: { labels: AppShellLabels; user: ShellUser; settingsHref: string }) {
const router = useRouter(); const router = useRouter();
const [isSigningOut, startSignOutTransition] = useTransition(); const [isSigningOut, startSignOutTransition] = useTransition();
@@ -318,7 +355,7 @@ function AccountMenu({ user, settingsHref }: { user: ShellUser; settingsHref: st
router.replace(result.redirectTo); router.replace(result.redirectTo);
router.refresh(); router.refresh();
} catch { } catch {
toast.error("Çıkış yapılamadı. Lütfen tekrar deneyin."); toast.error(labels.signOutError);
} }
}); });
} }
@@ -329,7 +366,7 @@ function AccountMenu({ user, settingsHref }: { user: ShellUser; settingsHref: st
<Button effect="shine" <Button effect="shine"
type="button" type="button"
variant="secondary" variant="secondary"
aria-label={`${user.displayName} için hesap menüsünü aç`} aria-label={labels.accountMenuAriaLabel.replace("{name}", user.displayName)}
className="group h-auto min-h-10 w-full justify-start p-1.5 text-left" className="group h-auto min-h-10 w-full justify-start p-1.5 text-left"
> >
<SidebarUserProfile <SidebarUserProfile
@@ -365,7 +402,7 @@ function AccountMenu({ user, settingsHref }: { user: ShellUser; settingsHref: st
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
<Link href={settingsHref} className="gap-2"> <Link href={settingsHref} className="gap-2">
<Settings className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" /> <Settings className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
<span>Ayarlar</span> <span>{labels.settings}</span>
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
@@ -385,7 +422,7 @@ function AccountMenu({ user, settingsHref }: { user: ShellUser; settingsHref: st
className="w-full justify-start gap-2 text-left text-destructive" className="w-full justify-start gap-2 text-left text-destructive"
> >
<LogOut className="h-4 w-4 shrink-0" aria-hidden="true" /> <LogOut className="h-4 w-4 shrink-0" aria-hidden="true" />
<span>{isSigningOut ? "Çıkış yapılıyor" : "Çıkış yap"}</span> <span>{isSigningOut ? labels.signingOut : labels.signOut}</span>
</Button> </Button>
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
+17 -4
View File
@@ -1,13 +1,20 @@
"use client"; "use client";
import { AppShell, type AppShellBranding } from "@/components/layout/app-shell"; import { I18nProvider } from "@/components/i18n/i18n-provider";
import { sidebarData } from "@/config/sidebar"; import { AppShell, type AppShellBranding, type AppShellLabels } from "@/components/layout/app-shell";
import { localizeSidebarData } from "@/config/sidebar";
import type { ColorMode } from "@/lib/color-mode"; import type { ColorMode } from "@/lib/color-mode";
import { createTranslatorFromMessages } from "@/lib/i18n";
type DashboardShellProps = { type DashboardShellProps = {
branding: AppShellBranding; branding: AppShellBranding;
children: React.ReactNode; children: React.ReactNode;
colorMode: ColorMode; colorMode: ColorMode;
i18n: {
locale: string;
messages: Record<string, string>;
};
labels: AppShellLabels;
user: { user: {
email: string; email: string;
displayName: string; displayName: string;
@@ -16,17 +23,23 @@ type DashboardShellProps = {
}; };
}; };
export function DashboardShell({ branding, children, colorMode, user }: DashboardShellProps) { export function DashboardShell({ branding, children, colorMode, i18n, labels, user }: DashboardShellProps) {
const translator = createTranslatorFromMessages(i18n.locale, i18n.messages);
const navGroups = localizeSidebarData(translator.t);
return ( return (
<I18nProvider locale={i18n.locale} messages={i18n.messages}>
<AppShell <AppShell
branding={branding} branding={branding}
colorMode={colorMode} colorMode={colorMode}
homeHref="/" homeHref="/"
navGroups={sidebarData} labels={labels}
navGroups={navGroups}
settingsHref="/settings" settingsHref="/settings"
user={user} user={user}
> >
{children} {children}
</AppShell> </AppShell>
</I18nProvider>
); );
} }
+26 -9
View File
@@ -13,6 +13,7 @@ import {
export type SidebarNavItem = { export type SidebarNavItem = {
title: string; title: string;
titleKey: string;
href?: string; href?: string;
icon?: LucideIcon; icon?: LucideIcon;
items?: SidebarNavItem[]; items?: SidebarNavItem[];
@@ -20,33 +21,49 @@ export type SidebarNavItem = {
export type SidebarNavGroup = { export type SidebarNavGroup = {
title: string; title: string;
titleKey: string;
items: SidebarNavItem[]; items: SidebarNavItem[];
}; };
export const sidebarData: SidebarNavGroup[] = [ export const sidebarData: SidebarNavGroup[] = [
{ {
title: "GENEL BAKIŞ", title: "GENEL BAKIŞ",
titleKey: "navigation.groups.overview",
items: [ items: [
{ title: "Dashboard", href: "/", icon: Sparkles }, { title: "Dashboard", titleKey: "navigation.items.dashboard", href: "/", icon: Sparkles },
{ title: "Takvim", href: "/calendar", icon: Calendar }, { title: "Takvim", titleKey: "navigation.items.calendar", href: "/calendar", icon: Calendar },
{ title: "Analizler", href: "/analytics", icon: BarChart3 }, { title: "Analizler", titleKey: "navigation.items.analytics", href: "/analytics", icon: BarChart3 },
], ],
}, },
{ {
title: "OPERASYON", title: "OPERASYON",
titleKey: "navigation.groups.operations",
items: [ items: [
{ title: "Müşteriler", href: "/clients", icon: Building2 }, { title: "Müşteriler", titleKey: "navigation.items.clients", href: "/clients", icon: Building2 },
{ title: "Projeler", href: "/projects", icon: FolderKanban }, { title: "Projeler", titleKey: "navigation.items.projects", href: "/projects", icon: FolderKanban },
{ title: "Görevler", href: "/tasks", icon: CheckSquare2 }, { title: "Görevler", titleKey: "navigation.items.tasks", href: "/tasks", icon: CheckSquare2 },
{ title: "Finans", href: "/finance", icon: Wallet }, { title: "Finans", titleKey: "navigation.items.finance", href: "/finance", icon: Wallet },
], ],
}, },
{ {
title: "KİŞİSEL", title: "KİŞİSEL",
items: [{ title: "Günlük", href: "/journal", icon: BookOpenText }], titleKey: "navigation.groups.personal",
items: [{ title: "Günlük", titleKey: "navigation.items.journal", href: "/journal", icon: BookOpenText }],
}, },
{ {
title: "AI ASİSTAN", title: "AI ASİSTAN",
items: [{ title: "Sohbet", href: "/chat", icon: MessageCircleHeart }], titleKey: "navigation.groups.ai",
items: [{ title: "Sohbet", titleKey: "navigation.items.chat", href: "/chat", icon: MessageCircleHeart }],
}, },
]; ];
export function localizeSidebarData(t: (key: string) => string): SidebarNavGroup[] {
return sidebarData.map((group) => ({
...group,
title: t(group.titleKey),
items: group.items.map((item) => ({
...item,
title: t(item.titleKey),
})),
}));
}
+93
View File
@@ -0,0 +1,93 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
const root = process.cwd();
const checkedRoots = [
"app/(dashboard)",
"components/layout",
"components/system",
"config/sidebar.ts",
];
const files = checkedRoots.flatMap((entry) => {
const absolute = path.join(root, entry);
if (fs.statSync(absolute).isFile()) return [absolute];
return walk(absolute).filter((file) => /\.(ts|tsx)$/.test(file));
});
const forbiddenFormatterPattern = /"tr-TR"|'tr-TR'|from "date-fns\/locale"|from 'date-fns\/locale'|locale:\s*tr\b/;
const formatterViolations = files
.map((file) => ({
file,
lines: fs.readFileSync(file, "utf8")
.split("\n")
.map((line, index) => ({ line, number: index + 1 }))
.filter(({ line }) => forbiddenFormatterPattern.test(line)),
}))
.filter((entry) => entry.lines.length > 0);
assert.deepEqual(
formatterViolations,
[],
`Hardcoded Turkish formatter usage remains:\n${formatterViolations.map(formatViolation).join("\n")}`,
);
const dashboardLayout = fs.readFileSync(path.join(root, "app/(dashboard)/layout.tsx"), "utf8");
const dashboardShell = fs.readFileSync(path.join(root, "components/layout/dashboard-shell.tsx"), "utf8");
assert.match(dashboardLayout, /getClientI18nPayload/, "Dashboard layout must provide client i18n payload.");
assert.match(dashboardShell, /localizeSidebarData/, "Dashboard shell must localize sidebar with client-safe icons.");
const dashboardClient = fs.readFileSync(path.join(root, "app/(dashboard)/dashboard-client.tsx"), "utf8");
assert.match(dashboardClient, /useTranslations/, "Dashboard page must consume translations.");
assert.doesNotMatch(dashboardClient, />Dashboard</, "Dashboard title must not be hardcoded.");
const turkishLiteralPattern = /["'`][^"'`\n]*(?:ğ|ü|ş|ö|ç|ı|İ|Ğ|Ü|Ş|Ö|Ç)[^"'`\n]*["'`]/;
const remainingTurkishLiterals = files
.flatMap((file) =>
fs.readFileSync(file, "utf8")
.split("\n")
.map((line, index) => ({ file, line, number: index + 1 }))
.filter(({ line }) => turkishLiteralPattern.test(line)),
);
const reportPath = path.join(root, "docs", "self-hosted-redesign", "i18n-phase-4-hardcoded-text-report.md");
fs.writeFileSync(
reportPath,
[
"---",
"title: Faz 4 Kalan Hardcoded Metin Raporu",
"phase: 4",
"status: generated",
`last_updated: ${new Date().toISOString()}`,
"---",
"",
"# Faz 4 Kalan Hardcoded Metin Raporu",
"",
"Bu rapor Faz-4 boundary script'i tarafindan uretilir. Formatter sabitleri release blocker kabul edilir; kalan Turkce stringler Faz-4 kapsaminda raporlanir ve sonraki UI migration dalgalarinda eritilir.",
"",
`Toplam kalan Turkce literal satiri: ${remainingTurkishLiterals.length}`,
"",
...remainingTurkishLiterals.slice(0, 250).map(({ file, line, number }) => (
`- \`${path.relative(root, file)}:${number}\` ${line.trim()}`
)),
remainingTurkishLiterals.length > 250 ? "" : null,
remainingTurkishLiterals.length > 250 ? `Ilk 250 satir listelendi; kalan: ${remainingTurkishLiterals.length - 250}` : null,
"",
].filter((line) => line !== null).join("\n"),
);
console.log(`I18n phase 4 boundary passed. Hardcoded text report: ${path.relative(root, reportPath)}`);
function walk(dir) {
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
const entryPath = path.join(dir, entry.name);
if (entry.isDirectory()) return walk(entryPath);
if (entry.isFile()) return [entryPath];
return [];
});
}
function formatViolation(entry) {
return `${path.relative(root, entry.file)}\n${entry.lines.map(({ line, number }) => ` ${number}: ${line}`).join("\n")}`;
}