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:
+195
-3
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
@@ -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("@");
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>;
|
||||||
@@ -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",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user