feat: support localized domain content

This commit is contained in:
poyrazavsever
2026-07-19 03:07:20 +03:00
parent c7a4f3de51
commit c1c473469a
15 changed files with 659 additions and 208 deletions
@@ -2,7 +2,7 @@
import { useState } from "react";
import { format } from "date-fns";
import { tr } from "date-fns/locale";
import { getDocumentDateFnsLocale } from "@/lib/i18n/date-fns";
import { Card, CardContent, Badge, Button, Input, Textarea, Label } from "poyraz-ui/atoms";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogFooter, Select, SelectTrigger, SelectValue, SelectContent, SelectItem, DialogDescription } from "poyraz-ui/molecules";
import { Phone, Mail, ExternalLink, Calendar, Plus, MessageSquare, UserPlus, Loader2, Copy } from "lucide-react";
@@ -20,6 +20,7 @@ export type ClientDetailData = {
status: string;
notes: string | null;
client_auth_id: string | null;
portal_locale: string;
};
export type ClientActivity = {
@@ -31,9 +32,18 @@ export type ClientActivity = {
created_at: string;
};
export function ClientDetailClient({ client, activities }: { client: ClientDetailData; activities: ClientActivity[] }) {
export function ClientDetailClient({
client,
activities,
locales,
}: {
client: ClientDetailData;
activities: ClientActivity[];
locales: Array<{ code: string; nativeName: string; name: string }>;
}) {
const [isAddingActivity, setIsAddingActivity] = useState(false);
const [openDialog, setOpenDialog] = useState(false);
const [portalLocale, setPortalLocale] = useState(client.portal_locale);
const getActivityIcon = (type: string) => {
switch (type) {
@@ -71,19 +81,21 @@ export function ClientDetailClient({ client, activities }: { client: ClientDetai
e.preventDefault();
const formData = new FormData(e.currentTarget);
const email = formData.get("email") as string;
const locale = formData.get("locale") as string;
setIsCreatingUser(true);
try {
const res = await fetch("/api/create-client-user", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, client_id: client.id })
body: JSON.stringify({ email, client_id: client.id, locale })
});
const data = await res.json();
if (!res.ok || data.error) {
throw new Error(data.error || "Kullanıcı oluşturulamadı.");
}
setInvitationUrl(data.invitation.invitationUrl);
setPortalLocale(data.invitation.locale ?? locale);
toast.success("Güvenli portal daveti oluşturuldu.");
} catch (error: unknown) {
toast.error(error instanceof Error ? error.message : "Davet oluşturulamadı.");
@@ -92,6 +104,23 @@ export function ClientDetailClient({ client, activities }: { client: ClientDetai
}
}
async function handlePortalLocaleChange(nextLocale: string) {
setPortalLocale(nextLocale);
try {
const response = await fetch(`/api/portal-clients/${client.id}/locale`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ locale: nextLocale }),
});
const data = await response.json();
if (!response.ok || data.error) throw new Error(data.error || "Portal dili güncellenemedi.");
toast.success("Portal dili güncellendi.");
} catch (error) {
setPortalLocale(client.portal_locale);
toast.error(error instanceof Error ? error.message : "Portal dili güncellenemedi.");
}
}
return (
<div className="mx-auto flex max-w-7xl flex-col gap-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
{/* Header Info */}
@@ -129,6 +158,21 @@ export function ClientDetailClient({ client, activities }: { client: ClientDetai
<Label htmlFor="email">E-posta Adresi</Label>
<Input id="email" name="email" type="email" required defaultValue={client.email || ""} />
</div>
<div className="space-y-2">
<Label>Portal dili</Label>
<Select name="locale" defaultValue={portalLocale}>
<SelectTrigger>
<SelectValue placeholder="Dil seç" />
</SelectTrigger>
<SelectContent>
{locales.map((locale) => (
<SelectItem key={locale.code} value={locale.code}>
{locale.nativeName || locale.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{invitationUrl ? (
<div className="space-y-2">
<Label htmlFor="invitation-url">Davet bağlantısı</Label>
@@ -163,9 +207,23 @@ export function ClientDetailClient({ client, activities }: { client: ClientDetai
</Dialog>
)}
{client.client_auth_id && (
<Badge className="bg-emerald-500/10 text-emerald-600 border-emerald-500/20 px-3 py-1 text-sm flex items-center gap-1.5 ml-2">
<UserPlus className="h-3.5 w-3.5" /> Portal Aktif
</Badge>
<>
<Badge className="bg-emerald-500/10 text-emerald-600 border-emerald-500/20 px-3 py-1 text-sm flex items-center gap-1.5 ml-2">
<UserPlus className="h-3.5 w-3.5" /> Portal Aktif
</Badge>
<Select value={portalLocale} onValueChange={handlePortalLocaleChange}>
<SelectTrigger className="h-9 w-36">
<SelectValue placeholder="Portal dili" />
</SelectTrigger>
<SelectContent>
{locales.map((locale) => (
<SelectItem key={locale.code} value={locale.code}>
{locale.nativeName || locale.name}
</SelectItem>
))}
</SelectContent>
</Select>
</>
)}
</div>
</div>
@@ -290,7 +348,7 @@ export function ClientDetailClient({ client, activities }: { client: ClientDetai
{getActivityBadge(activity.type)}
</div>
<time className="text-xs text-muted-foreground block mb-2 font-medium">
{format(new Date(activity.activity_date), "d MMM yyyy, HH:mm", { locale: tr })}
{format(new Date(activity.activity_date), "d MMM yyyy, HH:mm", { locale: getDocumentDateFnsLocale() })}
</time>
{activity.content && (
<p className="text-sm text-muted-foreground whitespace-pre-wrap">{activity.content}</p>
+7 -1
View File
@@ -1,11 +1,16 @@
import { notFound } from "next/navigation";
import { ClientDetailClient, type ClientDetailData, type ClientActivity } from "./client-detail-client";
import { getSqliteConnection } from "@/server/db/client";
import { DomainError } from "@/server/domain/errors";
import { I18nService } from "@/server/i18n/service";
import { requireFreelancerBackend } from "@/server/web/freelancer";
export default async function ClientDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const { actor, service } = await requireFreelancerBackend();
const i18n = new I18nService(getSqliteConnection().db);
const locales = i18n.listLocales(actor).filter((locale) => locale.status === "active");
const defaultLocale = i18n.getSettings(actor).defaultLocale;
let data: { client: ClientDetailData; activities: ClientActivity[] };
try {
@@ -21,6 +26,7 @@ export default async function ClientDetailPage({ params }: { params: Promise<{ i
status: row.status,
notes: row.notes,
client_auth_id: row.authUserId,
portal_locale: row.portalLocale ?? defaultLocale,
};
const activities: ClientActivity[] = service.listClientActivities(actor, id).map((activity) => ({
id: activity.id,
@@ -37,5 +43,5 @@ export default async function ClientDetailPage({ params }: { params: Promise<{ i
throw error;
}
return <ClientDetailClient client={data.client} activities={data.activities} />;
return <ClientDetailClient client={data.client} activities={data.activities} locales={locales} />;
}
+8 -5
View File
@@ -1,5 +1,7 @@
"use client";
import { getDocumentIntlLocale } from "@/lib/i18n/browser";
import { useTranslations } from "@/components/i18n/i18n-provider";
import {
createClientRecord,
updateClientRecord,
@@ -40,7 +42,7 @@ import {
import Link from "next/link";
import { useState } from "react";
import { format, isPast, isToday } from "date-fns";
import { tr } from "date-fns/locale";
import { getDocumentDateFnsLocale } from "@/lib/i18n/date-fns";
import { cn } from "@/lib/utils";
import { StatCard } from "@/components/system/stat-card";
@@ -88,6 +90,7 @@ export function ClientsClient({
totalRevenue,
activeCount,
}: ClientsClientProps) {
const t = useTranslations();
const [query, setQuery] = useState("");
const normalizedQuery = query.trim().toLowerCase();
@@ -154,7 +157,7 @@ export function ClientsClient({
<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">
CRM & Müşteriler
{t("clients.title")}
</h1>
</div>
@@ -326,7 +329,7 @@ function DraggableClientCard({
<div className="mt-3 flex items-center gap-1.5 text-xs pointer-events-none">
<Clock className={`h-3 w-3 ${isPast(new Date(client.next_follow_up_date)) ? 'text-rose-500' : 'text-muted-foreground'}`} />
<span className={isPast(new Date(client.next_follow_up_date)) ? 'text-rose-500 font-medium' : 'text-muted-foreground'}>
{format(new Date(client.next_follow_up_date), 'd MMM yyyy', { locale: tr })}
{format(new Date(client.next_follow_up_date), 'd MMM yyyy', { locale: getDocumentDateFnsLocale() })}
</span>
</div>
)}
@@ -384,7 +387,7 @@ function ClientRow({ client }: { client: ClientListItem }) {
{client.next_follow_up_date ? (
<div className={`flex items-center gap-1.5 ${isFollowUpOverdue ? 'text-rose-600 font-medium' : 'text-muted-foreground'}`}>
<Clock className="h-3.5 w-3.5" />
{format(new Date(client.next_follow_up_date), 'd MMM yyyy', { locale: tr })}
{format(new Date(client.next_follow_up_date), 'd MMM yyyy', { locale: getDocumentDateFnsLocale() })}
</div>
) : (
<span className="text-muted-foreground opacity-50">-</span>
@@ -635,5 +638,5 @@ function formatPhone(input: string) {
}
function formatCurrency(value: number) {
return new Intl.NumberFormat("tr-TR", { style: "currency", currency: "USD", maximumFractionDigits: 0 }).format(value);
return new Intl.NumberFormat(getDocumentIntlLocale(), { style: "currency", currency: "USD", maximumFractionDigits: 0 }).format(value);
}