feat(shared): zod şemaları, Türkçe arama ve TL/tarih biçimleri

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-05 01:35:10 +03:00
parent c875847b65
commit 4dbd2eb441
11 changed files with 504 additions and 3 deletions
+195 -3
View File
@@ -1,15 +1,207 @@
/** /**
* Arayüz metinleri (tr-TR). Tüm kullanıcıya görünen metinler burada toplanır. * Arayüz metinleri (tr-TR). Kullanıcıya görünen tüm metinler burada toplanır.
* Şimdilik tek dil; ileride çok dil gerekirse bu nesnenin şekli sözleşme olur. * Şimdilik tek dil; ileride çok dil gerekirse bu nesnenin şekli sözleşme olur.
*/ */
export const tr = { export const tr = {
app: { app: {
name: "BÜTÇEM", name: "BÜTÇEM",
tagline: "Aile bütçesi, birikim, ev stoğu ve akıllı alışveriş", tagline: "Aile bütçesi, birikim, ev stoğu ve akıllı alışveriş",
setupInProgress: "Kurulum sürüyor",
setupDescription: "Altyapı hazır. Giriş ekranı ve arayüz bir sonraki fazda eklenecek.",
version: "Sürüm", version: "Sürüm",
}, },
common: {
save: "Kaydet",
cancel: "Vazgeç",
close: "Kapat",
delete: "Sil",
edit: "Düzenle",
create: "Oluştur",
search: "Ara",
actions: "İşlemler",
loading: "Yükleniyor…",
comingSoon: "Yakında",
required: "Bu alan zorunlu",
yes: "Evet",
no: "Hayır",
previous: "Önceki",
next: "Sonraki",
noResults: "Sonuç bulunamadı.",
page: (current: number, total: number) => `Sayfa ${current} / ${total}`,
rowsPerPage: "Sayfa başına",
totalRows: (n: number) => `Toplam ${n} kayıt`,
showPassword: "Şifreyi göster",
hidePassword: "Şifreyi gizle",
genericError: "Beklenmeyen bir hata oluştu. Lütfen tekrar deneyin.",
},
theme: {
label: "Tema",
light: "Açık",
dark: "Koyu",
system: "Sistem",
},
nav: {
main: "Menü",
dashboard: "Ana sayfa",
transactions: "İşlemler",
accounts: "Hesaplar",
budget: "Bütçe",
planned: "Planlı işlemler",
contacts: "Cari hesaplar",
savings: "Birikim",
stock: "Ev stoğu",
shopping: "Alışveriş",
reports: "Raporlar",
settings: "Ayarlar",
more: "Daha fazla",
admin: "Yönetim",
adminUsers: "Kullanıcılar",
adminHouseholds: "Haneler",
toggleSidebar: "Menüyü aç/kapat",
},
household: {
label: "Aktif hane",
select: "Hane seçin",
roles: { owner: "Sahip", member: "Üye", viewer: "İzleyici" },
},
userMenu: {
open: "Kullanıcı menüsü",
profile: "Profil",
logout: "Çıkış yap",
loggedOut: "Çıkış yapıldı.",
},
roles: { admin: "Yönetici", user: "Kullanıcı" },
quickAdd: {
button: "Hızlı ekle",
title: "Hızlı ekle",
description: "Ne eklemek istiyorsunuz?",
expense: "Gider",
income: "Gelir",
transfer: "Transfer",
expenseHint: "Harcama, fatura, alışveriş",
incomeHint: "Maaş, ek gelir, iade",
transferHint: "Hesaplar ve cariler arası",
notYet: "İşlem formu Faz 6'da eklenecek.",
},
auth: {
loginTitle: "Giriş yap",
loginDescription: "Hesabınıza kullanıcı adı veya e-posta ile giriş yapın.",
identifier: "Kullanıcı adı veya e-posta",
identifierPlaceholder: "ornek veya ornek@eposta.com",
password: "Şifre",
submit: "Giriş yap",
submitting: "Giriş yapılıyor…",
invalidCredentials: "Kullanıcı adı/e-posta veya şifre hatalı.",
noSignUp: "Hesaplar yalnızca yönetici tarafından açılır.",
identifierRequired: "Kullanıcı adı veya e-posta girin",
passwordRequired: "Şifre girin",
},
dashboard: {
title: "Ana sayfa",
welcome: (name: string) => `Hoş geldin, ${name}`,
welcomeDescription: "Bu ayın özetini burada göreceksin. Veriler sonraki fazlarda bağlanacak.",
cards: {
monthIncome: "Bu ay gelir",
monthExpense: "Bu ay gider",
monthRemaining: "Kalan",
totalBalance: "Toplam hesap bakiyesi",
cardDebt: "Kart borçları",
netWorth: "Net varlık",
},
noData: "Henüz veri yok",
},
adminUsers: {
title: "Kullanıcılar",
description: "Kullanıcı oluşturma, rol, şifre ve erişim yönetimi.",
newUser: "Yeni kullanıcı",
searchPlaceholder: "Ad, kullanıcı adı veya e-posta ara…",
columns: {
name: "Ad soyad",
username: "Kullanıcı adı",
email: "E-posta",
role: "Rol",
status: "Durum",
createdAt: "Oluşturulma",
},
status: { active: "Aktif", banned: "Askıda" },
actions: {
edit: "Düzenle",
changeRole: "Rolü değiştir",
makeAdmin: "Yönetici yap",
makeUser: "Kullanıcı yap",
resetPassword: "Şifre sıfırla",
ban: "Askıya al",
unban: "Askıyı kaldır",
delete: "Sil",
},
form: {
createTitle: "Yeni kullanıcı",
createDescription: "Kullanıcı kendi hesabını açamaz; hesapları buradan oluşturun.",
editTitle: "Kullanıcıyı düzenle",
editDescription: "Ad, kullanıcı adı, e-posta ve rol bilgilerini güncelleyin.",
name: "Ad soyad",
username: "Kullanıcı adı",
usernameHint: "Küçük harf, rakam, nokta, alt çizgi; 3–30 karakter.",
email: "E-posta",
emailHint: "İsteğe bağlı. Boş bırakılırsa giriş kullanıcı adıyla yapılır.",
role: "Sistem rolü",
password: "Geçici şifre",
passwordHint: "En az 8 karakter. Kullanıcıya güvenli bir kanaldan iletin.",
},
password: {
title: "Şifre sıfırla",
description: (name: string) => `${name} için yeni bir şifre belirleyin.`,
newPassword: "Yeni şifre",
confirm: "Şifreyi belirle",
},
ban: {
title: "Kullanıcıyı askıya al",
description: (name: string) => `${name} giriş yapamayacak ve açık oturumları kapatılacak.`,
reason: "Neden (isteğe bağlı)",
confirm: "Askıya al",
},
remove: {
title: "Kullanıcı silinsin mi?",
description: (name: string) => `${name} kalıcı olarak silinecek. Bu işlem geri alınamaz.`,
confirm: "Kalıcı olarak sil",
},
toast: {
created: (name: string) => `${name} oluşturuldu.`,
updated: (name: string) => `${name} güncellendi.`,
roleChanged: (name: string) => `${name} için rol değiştirildi.`,
passwordSet: (name: string) => `${name} için şifre güncellendi.`,
banned: (name: string) => `${name} askıya alındı.`,
unbanned: (name: string) => `${name} için askı kaldırıldı.`,
removed: (name: string) => `${name} silindi.`,
},
guards: {
self: "Kendi hesabınız için bu işlem yapılamaz.",
lastAdmin: "Sistemdeki son yönetici silinemez veya rolü düşürülemez.",
},
errors: {
usernameTaken: "Bu kullanıcı adı kullanılıyor.",
emailTaken: "Bu e-posta adresi kullanılıyor.",
checkFields: "Lütfen işaretli alanları düzeltin.",
},
you: "Siz",
noEmail: "—",
},
validation: {
nameMin: "Ad en az 2 karakter olmalı",
nameMax: "Ad en fazla 80 karakter olabilir",
usernameFormat: "Yalnızca küçük harf, rakam, nokta ve alt çizgi; 3–30 karakter",
email: "Geçerli bir e-posta adresi girin",
passwordMin: "Şifre en az 8 karakter olmalı",
passwordMax: "Şifre en fazla 128 karakter olabilir",
banReasonMax: "Neden en fazla 200 karakter olabilir",
},
errors: {
notFoundTitle: "Sayfa bulunamadı",
notFoundDescription: "Aradığınız sayfa taşınmış veya hiç var olmamış olabilir.",
backHome: "Ana sayfaya dön",
errorTitle: "Bir şeyler ters gitti",
errorDescription: "Sayfa yüklenirken bir hata oluştu. Tekrar deneyebilirsiniz.",
retry: "Tekrar dene",
reference: "Hata kodu",
},
} as const; } as const;
export type Messages = typeof tr; export type Messages = typeof tr;
+17
View File
@@ -0,0 +1,17 @@
/**
* Server Action ve servis dönüş tipi (Faz 3+). Hata durumunda kullanıcıya gösterilecek
* genel mesaj ve isteğe bağlı alan bazlı hatalar döner; iç detay asla sızdırılmaz.
*/
export type ActionResult<T = void> =
| { ok: true; data: T }
| { ok: false; error: string; fieldErrors?: Partial<Record<string, string>> };
export const ok = <T>(data: T): ActionResult<T> => ({ ok: true, data });
export const fail = (
error: string,
fieldErrors?: Partial<Record<string, string>>,
): ActionResult<never> => ({
ok: false,
error,
fieldErrors,
});
View File
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { formatDate, formatDateTime, formatMoney } from "./format";
// Intl çıktısındaki boşluklar (ör. U+00A0) ortama göre değişebilir; karşılaştırmada normalleştir.
const norm = (s: string) => s.replace(/\s/g, " ");
describe("formatMoney", () => {
it("kuruşu TL olarak Türkçe biçimde gösterir", () => {
expect(norm(formatMoney(125050))).toBe("₺1.250,50");
expect(norm(formatMoney(0))).toBe("₺0,00");
expect(norm(formatMoney(-9999))).toBe("-₺99,99");
});
it("tamsayı olmayan tutarı reddeder", () => {
expect(() => formatMoney(12.5)).toThrow(TypeError);
});
});
describe("formatDate", () => {
it("İstanbul saatine göre gün sınırını uygular", () => {
// 31.12.2026 21:30 UTC = 01.01.2027 00:30 İstanbul (UTC+3)
expect(formatDate("2026-12-31T21:30:00Z")).toBe("01.01.2027");
expect(formatDate("2026-12-31T20:59:00Z")).toBe("31.12.2026");
});
it("tarih-saat biçimi", () => {
expect(norm(formatDateTime("2026-11-05T07:05:00Z"))).toBe("05.11.2026 10:05");
});
});
+42
View File
@@ -0,0 +1,42 @@
/**
* Görüntüleme biçimleri (tr-TR, Europe/Istanbul). Sunucu ve istemci aynı sonucu üretir
* çünkü yerel ayar ve saat dilimi her zaman açıkça verilir (hydration uyumsuzluğu olmaz).
*/
export const LOCALE = "tr-TR";
export const TIME_ZONE = "Europe/Istanbul";
const moneyFmt = new Intl.NumberFormat(LOCALE, { style: "currency", currency: "TRY" });
/** Kuruş cinsinden tamsayı tutarı TL olarak biçimlendirir: 125050 → "₺1.250,50". */
export function formatMoney(amountMinor: number): string {
if (!Number.isSafeInteger(amountMinor)) {
throw new TypeError(`amountMinor tamsayı olmalı: ${amountMinor}`);
}
return moneyFmt.format(amountMinor / 100);
}
const dateFmt = new Intl.DateTimeFormat(LOCALE, {
timeZone: TIME_ZONE,
day: "2-digit",
month: "2-digit",
year: "numeric",
});
const dateTimeFmt = new Intl.DateTimeFormat(LOCALE, {
timeZone: TIME_ZONE,
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
/** UTC tarihi İstanbul saatine göre "dd.MM.yyyy" biçiminde gösterir. */
export function formatDate(date: Date | string): string {
return dateFmt.format(new Date(date));
}
/** UTC tarihi İstanbul saatine göre "dd.MM.yyyy HH:mm" biçiminde gösterir. */
export function formatDateTime(date: Date | string): string {
return dateTimeFmt.format(new Date(date));
}
+10
View File
@@ -0,0 +1,10 @@
import { z } from "zod";
import { tr } from "../../i18n/tr";
/** Giriş formu: kullanıcı adı veya e-posta + şifre. Biçim kontrolü sunucuda yapılır. */
export const loginSchema = z.object({
identifier: z.string().trim().min(1, tr.auth.identifierRequired),
password: z.string().min(1, tr.auth.passwordRequired),
});
export type LoginInput = z.infer<typeof loginSchema>;
+36
View File
@@ -0,0 +1,36 @@
import { z } from "zod";
import { tr } from "../../i18n/tr";
const v = tr.validation;
/** Sistem rolleri (Better Auth admin plugin). */
export const SYSTEM_ROLES = ["admin", "user"] as const;
export const systemRoleSchema = z.enum(SYSTEM_ROLES);
export type SystemRole = z.infer<typeof systemRoleSchema>;
export const USERNAME_PATTERN = /^[a-z0-9._]{3,30}$/;
export const nameSchema = z.string().trim().min(2, v.nameMin).max(80, v.nameMax);
/** Kullanıcı adı küçük harfe çevrilir; Türkçe büyük İ/I sorununu önlemek için tr-TR kullanılır. */
export const usernameSchema = z
.string()
.trim()
.transform((s) => s.toLocaleLowerCase("tr-TR"))
.pipe(z.string().regex(USERNAME_PATTERN, v.usernameFormat));
export const emailSchema = z.string().trim().toLowerCase().pipe(z.email(v.email));
/** Boş bırakılabilen e-posta: "" → undefined. */
export const optionalEmailSchema = z
.string()
.trim()
.transform((s) => (s === "" ? undefined : s.toLowerCase()))
.pipe(z.email(v.email).optional());
export const passwordSchema = z.string().min(8, v.passwordMin).max(128, v.passwordMax);
/** Girilen değer e-posta mı (giriş sırasında e-posta/kullanıcı adı yöntemini seçmek için)? */
export function looksLikeEmail(value: string): boolean {
return value.includes("@");
}
+73
View File
@@ -0,0 +1,73 @@
import { describe, expect, it } from "vitest";
import { loginSchema } from "./auth";
import { looksLikeEmail, usernameSchema } from "./common";
import { banUserSchema, userCreateSchema, userUpdateSchema } from "./user";
const base = {
name: "Ayşe Yılmaz",
username: "ayse",
email: "",
role: "user",
password: "gizli-1234",
};
describe("usernameSchema", () => {
it("küçük harfe çevirir ve boşlukları kırpar (Türkçe İ dahil)", () => {
expect(usernameSchema.parse(" AYSE.K ")).toBe("ayse.k");
expect(usernameSchema.parse("Ali_2")).toBe("ali_2");
});
it("Türkçe karakter, boşluk ve kısa değerleri reddeder", () => {
for (const bad of ["ayşe", "ali veli", "ab", "a".repeat(31), "ali-veli"]) {
expect(usernameSchema.safeParse(bad).success, bad).toBe(false);
}
});
});
describe("userCreateSchema", () => {
it("boş e-postayı undefined yapar", () => {
const out = userCreateSchema.parse(base);
expect(out.email).toBeUndefined();
expect(out.name).toBe("Ayşe Yılmaz");
});
it("e-postayı küçük harfe çevirir ve doğrular", () => {
expect(userCreateSchema.parse({ ...base, email: " Ayse@Ornek.COM " }).email).toBe(
"ayse@ornek.com",
);
expect(userCreateSchema.safeParse({ ...base, email: "gecersiz" }).success).toBe(false);
});
it("kısa şifreyi ve geçersiz rolü reddeder", () => {
expect(userCreateSchema.safeParse({ ...base, password: "1234567" }).success).toBe(false);
expect(userCreateSchema.safeParse({ ...base, role: "owner" }).success).toBe(false);
});
it("güncelleme şemasında şifre yoktur", () => {
const { password: _password, ...rest } = base;
expect(userUpdateSchema.parse(rest)).not.toHaveProperty("password");
});
});
describe("banUserSchema", () => {
it("boş nedeni undefined yapar, uzun nedeni reddeder", () => {
expect(banUserSchema.parse({ reason: " " }).reason).toBeUndefined();
expect(banUserSchema.safeParse({ reason: "x".repeat(201) }).success).toBe(false);
});
});
describe("loginSchema", () => {
it("boş alanları Türkçe mesajla reddeder", () => {
const r = loginSchema.safeParse({ identifier: " ", password: "" });
expect(r.success).toBe(false);
expect(r.error?.issues.map((i) => i.message)).toEqual([
"Kullanıcı adı veya e-posta girin",
"Şifre girin",
]);
});
it("e-posta / kullanıcı adı ayrımı", () => {
expect(looksLikeEmail("ayse@ornek.com")).toBe(true);
expect(looksLikeEmail("ayse")).toBe(false);
});
});
+39
View File
@@ -0,0 +1,39 @@
import { z } from "zod";
import { tr } from "../../i18n/tr";
import {
nameSchema,
optionalEmailSchema,
passwordSchema,
systemRoleSchema,
usernameSchema,
} from "./common";
/** Admin: yeni kullanıcı. E-posta isteğe bağlı (çocuklar vb. için kullanıcı adıyla giriş). */
export const userCreateSchema = z.object({
name: nameSchema,
username: usernameSchema,
email: optionalEmailSchema,
role: systemRoleSchema,
password: passwordSchema,
});
/** Admin: kullanıcı bilgisi güncelleme (şifre ayrı akışta). */
export const userUpdateSchema = userCreateSchema.omit({ password: true });
export const setPasswordSchema = z.object({ password: passwordSchema });
export const banUserSchema = z.object({
reason: z
.string()
.trim()
.max(200, tr.validation.banReasonMax)
.transform((s) => (s === "" ? undefined : s))
.optional(),
});
export type UserCreateInput = z.input<typeof userCreateSchema>;
export type UserCreate = z.output<typeof userCreateSchema>;
export type UserUpdateInput = z.input<typeof userUpdateSchema>;
export type UserUpdate = z.output<typeof userUpdateSchema>;
export type SetPasswordInput = z.input<typeof setPasswordSchema>;
export type BanUserInput = z.input<typeof banUserSchema>;
+38
View File
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import { compareTr, foldTr, includesTr } from "./search";
describe("foldTr", () => {
it("Türkçe harfleri ve büyük/küçük farkını normalize eder", () => {
expect(foldTr("İrem")).toBe("irem");
expect(foldTr("IŞIK")).toBe("isik");
expect(foldTr("Ayşe Çağrı Öztürk Ünal")).toBe("ayse cagri ozturk unal");
});
});
describe("includesTr", () => {
it("aksansız ve büyük harfli aramayla eşleşir", () => {
expect(includesTr("İrem Güneş", "irem")).toBe(true);
expect(includesTr("İrem Güneş", "GUNES")).toBe(true);
expect(includesTr("Ayşe", "ayse")).toBe(true);
expect(includesTr("Ayşe", "mehmet")).toBe(false);
});
it("boş arama her şeyle eşleşir, boş değer hiçbir aramayla eşleşmez", () => {
expect(includesTr("x", " ")).toBe(true);
expect(includesTr(undefined, "a")).toBe(false);
});
});
describe("compareTr", () => {
it("Türk alfabesine göre sıralar", () => {
expect(["Zeynep", "Çiğdem", "Can", "Şule", "Selin", "İrem", "Ilgın"].sort(compareTr)).toEqual([
"Can",
"Çiğdem",
"Ilgın",
"İrem",
"Selin",
"Şule",
"Zeynep",
]);
});
});
+25
View File
@@ -0,0 +1,25 @@
/**
* Türkçe arama normalizasyonu: büyük/küçük harf ve aksan farkını yok sayar.
* "İrem" ↔ "irem", "IŞIK" ↔ "isik", "Ayşe" ↔ "ayse", "Çağrı" ↔ "cagri".
*/
export function foldTr(value: string): string {
return value
.toLocaleLowerCase("tr-TR")
.replace(/ı/g, "i")
.normalize("NFD")
.replace(/\p{M}/gu, "")
.trim();
}
/** `haystack` içinde `needle` (normalize edilmiş) geçiyor mu? */
export function includesTr(haystack: string | undefined | null, needle: string): boolean {
if (!needle.trim()) return true;
return haystack ? foldTr(haystack).includes(foldTr(needle)) : false;
}
const collator = new Intl.Collator("tr-TR", { sensitivity: "base" });
/** Türkçe alfabetik sıralama karşılaştırıcısı (Ç, Ğ, İ, Ö, Ş, Ü doğru yerde). */
export function compareTr(a: string, b: string): number {
return collator.compare(a, b);
}