feat: localize dashboard shell
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { getDocumentIntlLocale } from "@/lib/i18n/browser";
|
||||
import { useTranslations } from "@/components/i18n/i18n-provider";
|
||||
import {
|
||||
createCalendarEventRecord,
|
||||
deleteCalendarEventRecord,
|
||||
@@ -73,6 +75,7 @@ type CalendarClientProps = {
|
||||
};
|
||||
|
||||
export function CalendarClient({ events, clients, projects, tasks }: CalendarClientProps) {
|
||||
const t = useTranslations();
|
||||
const [monthDate, setMonthDate] = useState(() => new Date());
|
||||
const [selectedDate, setSelectedDate] = useState(() => toDateKey(new Date()));
|
||||
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="flex flex-col gap-4 border-b border-border pb-5 lg:flex-row lg:items-end lg:justify-between">
|
||||
<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>
|
||||
|
||||
<CalendarEventDialog
|
||||
@@ -442,16 +445,16 @@ function startOfToday() {
|
||||
}
|
||||
|
||||
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) {
|
||||
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) {
|
||||
const start = new Intl.DateTimeFormat("tr-TR", { 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 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(getDocumentIntlLocale(), { hour: "2-digit", minute: "2-digit" }).format(new Date(event.ends_at)) : null;
|
||||
return end ? `${start} - ${end}` : start;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import { useTranslations } from "@/components/i18n/i18n-provider";
|
||||
import { DefaultChatTransport, type UIMessage } from "ai";
|
||||
import { Brain, Loader2, MessageSquare, Plus, Send, Trash2 } from "lucide-react";
|
||||
import { Button } from "poyraz-ui/atoms";
|
||||
@@ -43,6 +44,7 @@ type ChatSession = {
|
||||
};
|
||||
|
||||
export default function AIChatPage() {
|
||||
const t = useTranslations();
|
||||
const [sessions, setSessions] = useState<ChatSession[]>([]);
|
||||
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
|
||||
const [input, setInput] = useState("");
|
||||
@@ -235,7 +237,7 @@ export default function AIChatPage() {
|
||||
<Brain className="h-4 w-4" />
|
||||
</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>
|
||||
<Button effect="shine" variant="secondary" size="sm" className="md:hidden text-xs px-3" onClick={() => setIsMobileSessionsOpen(true)}>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { getDocumentIntlLocale } from "@/lib/i18n/browser";
|
||||
import { useTranslations } from "@/components/i18n/i18n-provider";
|
||||
import { useRouter, usePathname, useSearchParams } from "next/navigation";
|
||||
import { PendingLink } from "@/components/ui/pending-link";
|
||||
import { StatCard } from "@/components/system/stat-card";
|
||||
@@ -27,6 +29,7 @@ type DashboardClientProps = {
|
||||
};
|
||||
|
||||
export function DashboardClient({ data }: DashboardClientProps) {
|
||||
const t = useTranslations();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
@@ -41,20 +44,20 @@ export function DashboardClient({ data }: DashboardClientProps) {
|
||||
|
||||
// Format dates for Recharts using local timezone
|
||||
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,
|
||||
expense: f.expense
|
||||
}));
|
||||
|
||||
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,
|
||||
energy: l.energy,
|
||||
}));
|
||||
|
||||
// Format currency
|
||||
const formatCurrency = (val: number) => {
|
||||
return new Intl.NumberFormat("tr-TR", {
|
||||
return new Intl.NumberFormat(getDocumentIntlLocale(), {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
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>
|
||||
<h1 className="text-3xl font-semibold tracking-normal text-foreground">
|
||||
Dashboard
|
||||
{t("dashboard.title")}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Select value={data.range} onValueChange={handleRangeChange}>
|
||||
<SelectTrigger className="w-[160px]">
|
||||
<SelectValue placeholder="Tarih aralığı" />
|
||||
<SelectValue placeholder={t("dashboard.filters.range")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="today">Bugün</SelectItem>
|
||||
<SelectItem value="this_week">Bu Hafta</SelectItem>
|
||||
<SelectItem value="this_month">Bu Ay</SelectItem>
|
||||
<SelectItem value="today">{t("dashboard.filters.today")}</SelectItem>
|
||||
<SelectItem value="this_week">{t("dashboard.filters.thisWeek")}</SelectItem>
|
||||
<SelectItem value="this_month">{t("dashboard.filters.thisMonth")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
@@ -88,17 +91,17 @@ export function DashboardClient({ data }: DashboardClientProps) {
|
||||
|
||||
{/* KPI Cards */}
|
||||
<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="Aktif Projeler" value={activeProjectsCount.toString()} icon={FolderKanban} tone="blue" />
|
||||
<StatCard label="Tamamlanan Görev" value={completedTasksCount.toString()} icon={CheckCircle2} tone="amber" />
|
||||
<StatCard label="Ortalama Mood" value={avgMood} icon={Activity} tone="red" />
|
||||
<StatCard label={t("dashboard.stats.netEarnings")} value={formatCurrency(netProfit)} icon={Wallet} tone="green" />
|
||||
<StatCard label={t("dashboard.stats.activeProjects")} value={activeProjectsCount.toString()} icon={FolderKanban} tone="blue" />
|
||||
<StatCard label={t("dashboard.stats.completedTasks")} value={completedTasksCount.toString()} icon={CheckCircle2} tone="amber" />
|
||||
<StatCard label={t("dashboard.stats.averageMood")} value={avgMood} icon={Activity} tone="red" />
|
||||
</div>
|
||||
|
||||
{/* Charts */}
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<Card>
|
||||
<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">
|
||||
{incomeTrendData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
@@ -160,7 +163,7 @@ export function DashboardClient({ data }: DashboardClientProps) {
|
||||
</BarChart>
|
||||
</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>
|
||||
</CardContent>
|
||||
@@ -168,7 +171,7 @@ export function DashboardClient({ data }: DashboardClientProps) {
|
||||
|
||||
<Card>
|
||||
<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">
|
||||
{moodTrendData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
@@ -216,7 +219,7 @@ export function DashboardClient({ data }: DashboardClientProps) {
|
||||
</LineChart>
|
||||
</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>
|
||||
</CardContent>
|
||||
@@ -229,7 +232,7 @@ export function DashboardClient({ data }: DashboardClientProps) {
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<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" />
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
@@ -240,7 +243,7 @@ export function DashboardClient({ data }: DashboardClientProps) {
|
||||
<div>
|
||||
<p className="text-sm font-medium">{project.name}</p>
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
))}
|
||||
{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>
|
||||
</CardContent>
|
||||
@@ -260,7 +263,7 @@ export function DashboardClient({ data }: DashboardClientProps) {
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<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" />
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
</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>
|
||||
</CardContent>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { getDocumentIntlLocale } from "@/lib/i18n/browser";
|
||||
import { useTranslations } from "@/components/i18n/i18n-provider";
|
||||
import {
|
||||
createFinanceTransactionRecord,
|
||||
deleteFinanceTransactionRecord,
|
||||
@@ -103,6 +105,7 @@ type FinanceClientProps = {
|
||||
};
|
||||
|
||||
export function FinanceClient({ transactions, clients, projects }: FinanceClientProps) {
|
||||
const t = useTranslations();
|
||||
const [query, setQuery] = useState("");
|
||||
const [monthFilter, setMonthFilter] = useState(() => new Date().toISOString().slice(0, 7));
|
||||
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>
|
||||
<h1 className="text-3xl font-semibold tracking-normal text-foreground">
|
||||
Finans işlemleri
|
||||
{t("finance.title")}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
@@ -730,7 +733,7 @@ function calculateExpenseCategories(transactions: FinanceTransactionItem[]) {
|
||||
}
|
||||
|
||||
function formatCurrency(value: number, currency = "USD") {
|
||||
return new Intl.NumberFormat("tr-TR", {
|
||||
return new Intl.NumberFormat(getDocumentIntlLocale(), {
|
||||
style: "currency",
|
||||
currency,
|
||||
maximumFractionDigits: 0,
|
||||
@@ -738,7 +741,7 @@ function formatCurrency(value: number, currency = "USD") {
|
||||
}
|
||||
|
||||
function formatDate(value: string) {
|
||||
return new Intl.DateTimeFormat("tr-TR", {
|
||||
return new Intl.DateTimeFormat(getDocumentIntlLocale(), {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { getDocumentIntlLocale } from "@/lib/i18n/browser";
|
||||
import { useTranslations } from "@/components/i18n/i18n-provider";
|
||||
import {
|
||||
createDailyLogRecord,
|
||||
deleteDailyLogRecord,
|
||||
@@ -60,6 +62,7 @@ const scoreLabels: Record<number, string> = {
|
||||
};
|
||||
|
||||
export function JournalClient({ logs }: JournalClientProps) {
|
||||
const t = useTranslations();
|
||||
const summary = useMemo(() => calculateSummary(logs), [logs]);
|
||||
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>
|
||||
<h1 className="text-3xl font-semibold tracking-normal text-foreground">
|
||||
Mood ve enerji
|
||||
{t("journal.title")}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
@@ -436,7 +439,7 @@ function average(values: number[]) {
|
||||
}
|
||||
|
||||
function formatDate(value: string) {
|
||||
return new Intl.DateTimeFormat("tr-TR", {
|
||||
return new Intl.DateTimeFormat(getDocumentIntlLocale(), {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
@@ -444,14 +447,14 @@ function formatDate(value: string) {
|
||||
}
|
||||
|
||||
function formatShortDate(value: string) {
|
||||
return new Intl.DateTimeFormat("tr-TR", {
|
||||
return new Intl.DateTimeFormat(getDocumentIntlLocale(), {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
}).format(new Date(`${value}T00:00:00`));
|
||||
}
|
||||
|
||||
function formatWeekday(value: string) {
|
||||
return new Intl.DateTimeFormat("tr-TR", {
|
||||
return new Intl.DateTimeFormat(getDocumentIntlLocale(), {
|
||||
weekday: "long",
|
||||
}).format(new Date(`${value}T00:00:00`));
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { DashboardShell } from "@/components/layout/dashboard-shell";
|
||||
import { domainActorFromSession } from "@/server/auth/domain-actor";
|
||||
import { requireFreelancer } from "@/server/auth/session";
|
||||
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";
|
||||
|
||||
export default async function DashboardLayout({
|
||||
@@ -13,6 +15,23 @@ export default async function DashboardLayout({
|
||||
const { user, profile } = context;
|
||||
const branding = getPublicBranding();
|
||||
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 shortName =
|
||||
@@ -33,6 +52,36 @@ export default async function DashboardLayout({
|
||||
darkLogoUrl: branding.darkLogoUrl,
|
||||
}}
|
||||
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={{
|
||||
email: user.email,
|
||||
displayName,
|
||||
|
||||
+4
-1
@@ -9,6 +9,7 @@ import {
|
||||
} from "@/lib/color-mode";
|
||||
import { Toaster } from "poyraz-ui/molecules";
|
||||
import { getPublicBranding } from "@/server/branding/runtime";
|
||||
import { resolveRequestLocale } from "@/server/i18n/resolver";
|
||||
|
||||
const colorModeScript = `(() => {
|
||||
const root = document.documentElement;
|
||||
@@ -52,10 +53,12 @@ export default async function RootLayout({
|
||||
const colorMode = isColorMode(cookieColorMode)
|
||||
? cookieColorMode
|
||||
: branding.defaultColorMode;
|
||||
const locale = await resolveRequestLocale();
|
||||
|
||||
return (
|
||||
<html
|
||||
lang="tr"
|
||||
lang={locale.locale}
|
||||
dir={locale.direction}
|
||||
className={cn("font-sans", colorMode === "dark" && "dark")}
|
||||
data-color-mode={colorMode}
|
||||
style={branding.cssVariables as CSSProperties}
|
||||
|
||||
@@ -72,6 +72,7 @@ type AppShellProps = {
|
||||
branding: AppShellBranding;
|
||||
children: React.ReactNode;
|
||||
homeHref: string;
|
||||
labels?: AppShellLabels;
|
||||
navGroups: AppShellNavGroup[];
|
||||
settingsHref: string;
|
||||
user: ShellUser;
|
||||
@@ -79,10 +80,27 @@ type AppShellProps = {
|
||||
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({
|
||||
branding,
|
||||
children,
|
||||
homeHref,
|
||||
labels = defaultAppShellLabels,
|
||||
navGroups,
|
||||
settingsHref,
|
||||
user,
|
||||
@@ -90,7 +108,7 @@ export function AppShell({
|
||||
colorMode,
|
||||
}: AppShellProps) {
|
||||
const pathname = usePathname();
|
||||
const sidebarProps = { branding, homeHref, navGroups, pathname, progress, settingsHref, user };
|
||||
const sidebarProps = { branding, homeHref, labels, navGroups, pathname, progress, settingsHref, user };
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
@@ -100,7 +118,7 @@ export function AppShell({
|
||||
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"
|
||||
>
|
||||
Ana içeriğe geç
|
||||
{labels.skipToContent}
|
||||
</a>
|
||||
|
||||
<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 = {
|
||||
branding: AppShellBranding;
|
||||
homeHref: string;
|
||||
labels: AppShellLabels;
|
||||
navGroups: AppShellNavGroup[];
|
||||
pathname: string;
|
||||
progress?: number;
|
||||
@@ -145,15 +180,15 @@ function MobileSidebar(props: SidebarCompositionProps) {
|
||||
<Link
|
||||
href={props.homeHref}
|
||||
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 />
|
||||
</Link>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<SidebarTrigger action="mobile" aria-label="Ana menüyü aç veya kapat" />
|
||||
<SidebarTrigger action="mobile" aria-label={props.labels.mobileMenuAriaLabel} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Menü</TooltipContent>
|
||||
<TooltipContent>{props.labels.mobileMenuTooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
</header>
|
||||
|
||||
@@ -167,6 +202,7 @@ function MobileSidebar(props: SidebarCompositionProps) {
|
||||
function SidebarComposition({
|
||||
branding,
|
||||
homeHref,
|
||||
labels,
|
||||
navGroups,
|
||||
pathname,
|
||||
progress,
|
||||
@@ -179,7 +215,7 @@ function SidebarComposition({
|
||||
<Link
|
||||
href={homeHref}
|
||||
className="flex min-h-12 w-full items-center justify-center"
|
||||
aria-label={`${branding.applicationName} ana sayfa`}
|
||||
aria-label={labels.homeAriaLabel}
|
||||
>
|
||||
<WorkspaceLogo branding={branding} />
|
||||
</Link>
|
||||
@@ -194,8 +230,8 @@ function SidebarComposition({
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarFooter className="flex flex-col gap-3">
|
||||
{typeof progress === "number" ? <ProgressSummary progress={progress} /> : null}
|
||||
<AccountMenu user={user} settingsHref={settingsHref} />
|
||||
{typeof progress === "number" ? <ProgressSummary labels={labels} progress={progress} /> : null}
|
||||
<AccountMenu labels={labels} user={user} settingsHref={settingsHref} />
|
||||
</SidebarFooter>
|
||||
</>
|
||||
);
|
||||
@@ -257,7 +293,7 @@ function WorkspaceLogo({
|
||||
<span className="flex h-full w-full items-center justify-center overflow-hidden">
|
||||
<Image
|
||||
src={lightLogoUrl}
|
||||
alt={`${branding.applicationName} logosu`}
|
||||
alt={branding.applicationName}
|
||||
width={180}
|
||||
height={56}
|
||||
unoptimized
|
||||
@@ -265,7 +301,7 @@ function WorkspaceLogo({
|
||||
/>
|
||||
<Image
|
||||
src={darkLogoUrl}
|
||||
alt={`${branding.applicationName} logosu`}
|
||||
alt={branding.applicationName}
|
||||
width={180}
|
||||
height={56}
|
||||
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 progressValue = labels.progressValue.replace("{progress}", String(normalizedProgress));
|
||||
|
||||
return (
|
||||
<Card variant="soft" className="w-full overflow-hidden border-primary/10 shadow-none">
|
||||
<CardContent className="space-y-3 p-3">
|
||||
<Typography variant="small" className="font-semibold">
|
||||
Proje ilerlemesi
|
||||
{labels.progressTitle}
|
||||
</Typography>
|
||||
<div className="space-y-1.5">
|
||||
<Typography variant="caption" className="font-medium text-primary">
|
||||
%{normalizedProgress} tamamlandı
|
||||
{progressValue}
|
||||
</Typography>
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-label="Proje ilerlemesi"
|
||||
aria-label={labels.progressAriaLabel}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
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 [isSigningOut, startSignOutTransition] = useTransition();
|
||||
|
||||
@@ -318,7 +355,7 @@ function AccountMenu({ user, settingsHref }: { user: ShellUser; settingsHref: st
|
||||
router.replace(result.redirectTo);
|
||||
router.refresh();
|
||||
} 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"
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
<SidebarUserProfile
|
||||
@@ -365,7 +402,7 @@ function AccountMenu({ user, settingsHref }: { user: ShellUser; settingsHref: st
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={settingsHref} className="gap-2">
|
||||
<Settings className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
<span>Ayarlar</span>
|
||||
<span>{labels.settings}</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
@@ -385,7 +422,7 @@ function AccountMenu({ user, settingsHref }: { user: ShellUser; settingsHref: st
|
||||
className="w-full justify-start gap-2 text-left text-destructive"
|
||||
>
|
||||
<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>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { AppShell, type AppShellBranding } from "@/components/layout/app-shell";
|
||||
import { sidebarData } from "@/config/sidebar";
|
||||
import { I18nProvider } from "@/components/i18n/i18n-provider";
|
||||
import { AppShell, type AppShellBranding, type AppShellLabels } from "@/components/layout/app-shell";
|
||||
import { localizeSidebarData } from "@/config/sidebar";
|
||||
import type { ColorMode } from "@/lib/color-mode";
|
||||
import { createTranslatorFromMessages } from "@/lib/i18n";
|
||||
|
||||
type DashboardShellProps = {
|
||||
branding: AppShellBranding;
|
||||
children: React.ReactNode;
|
||||
colorMode: ColorMode;
|
||||
i18n: {
|
||||
locale: string;
|
||||
messages: Record<string, string>;
|
||||
};
|
||||
labels: AppShellLabels;
|
||||
user: {
|
||||
email: 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 (
|
||||
<I18nProvider locale={i18n.locale} messages={i18n.messages}>
|
||||
<AppShell
|
||||
branding={branding}
|
||||
colorMode={colorMode}
|
||||
homeHref="/"
|
||||
navGroups={sidebarData}
|
||||
labels={labels}
|
||||
navGroups={navGroups}
|
||||
settingsHref="/settings"
|
||||
user={user}
|
||||
>
|
||||
{children}
|
||||
</AppShell>
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
||||
|
||||
+26
-9
@@ -13,6 +13,7 @@ import {
|
||||
|
||||
export type SidebarNavItem = {
|
||||
title: string;
|
||||
titleKey: string;
|
||||
href?: string;
|
||||
icon?: LucideIcon;
|
||||
items?: SidebarNavItem[];
|
||||
@@ -20,33 +21,49 @@ export type SidebarNavItem = {
|
||||
|
||||
export type SidebarNavGroup = {
|
||||
title: string;
|
||||
titleKey: string;
|
||||
items: SidebarNavItem[];
|
||||
};
|
||||
|
||||
export const sidebarData: SidebarNavGroup[] = [
|
||||
{
|
||||
title: "GENEL BAKIŞ",
|
||||
titleKey: "navigation.groups.overview",
|
||||
items: [
|
||||
{ title: "Dashboard", href: "/", icon: Sparkles },
|
||||
{ title: "Takvim", href: "/calendar", icon: Calendar },
|
||||
{ title: "Analizler", href: "/analytics", icon: BarChart3 },
|
||||
{ title: "Dashboard", titleKey: "navigation.items.dashboard", href: "/", icon: Sparkles },
|
||||
{ title: "Takvim", titleKey: "navigation.items.calendar", href: "/calendar", icon: Calendar },
|
||||
{ title: "Analizler", titleKey: "navigation.items.analytics", href: "/analytics", icon: BarChart3 },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "OPERASYON",
|
||||
titleKey: "navigation.groups.operations",
|
||||
items: [
|
||||
{ title: "Müşteriler", href: "/clients", icon: Building2 },
|
||||
{ title: "Projeler", href: "/projects", icon: FolderKanban },
|
||||
{ title: "Görevler", href: "/tasks", icon: CheckSquare2 },
|
||||
{ title: "Finans", href: "/finance", icon: Wallet },
|
||||
{ title: "Müşteriler", titleKey: "navigation.items.clients", href: "/clients", icon: Building2 },
|
||||
{ title: "Projeler", titleKey: "navigation.items.projects", href: "/projects", icon: FolderKanban },
|
||||
{ title: "Görevler", titleKey: "navigation.items.tasks", href: "/tasks", icon: CheckSquare2 },
|
||||
{ title: "Finans", titleKey: "navigation.items.finance", href: "/finance", icon: Wallet },
|
||||
],
|
||||
},
|
||||
{
|
||||
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",
|
||||
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),
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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")}`;
|
||||
}
|
||||
Reference in New Issue
Block a user