diff --git a/docs/tasarim/DESIGN.md b/docs/tasarim/DESIGN.md index 25187ed..adfed78 100644 --- a/docs/tasarim/DESIGN.md +++ b/docs/tasarim/DESIGN.md @@ -222,7 +222,7 @@ shadcn `Input`, `Select`, `Textarea`, `Slider`. Etiket her alanda; hata mesajı ### 4.7 İkonlar Yalnızca `lucide-react`. Boyut: `size-4` buton/satır içi (varsayılan), `size-5` ikon kutusu, `size-3.5` küçük çip, `size-6` üstü yalnızca boş durum. Dekoratif ikon `aria-hidden`; tek başına ikon buton `aria-label` ya da `sr-only` metin taşır. Emoji ikon olarak kullanılmaz. -### 4.8 Özet kartı — `OzetKart`, `universite-icerik.tsx` +### 4.8 Özet kartı — `OzetKart`, `src/components/ozet-kart.tsx` Apple Fitness özet kartının sitenin ölçüleriyle hâli. Sayfada **en çok dört** tane (harita yanındaki 2×2). @@ -239,7 +239,11 @@ yanındaki 2×2). ikon kutusu `flex size-8 shrink-0 items-center justify-center rounded-xl bg-primary/10 sm:size-10`. - `dl` anlamı için `dt` (başlık) DOM'da önce gelir, görsel sıra `order-*` ile kurulur. - Telefonda kart iç genişliği ~131 px: en uzun sayı ("13.889", 36 px'te ~90 px) + 32 px ikon - sığar. Daha uzun sayı gelecekse önce ölç (§9.2), sonra yaz. + sığar. Daha uzun sayı gelecekse önce ölç (§9.2), sonra yaz. Ölçülen sınır: 36 px'te + "1.618.988" 123 px — ikonun altına girer. 6–7 haneli sayı birimle kısalır: 245.300 → + `245` + birim `bin`, 1.618.988 → `1,62` + birim `milyon` (bölüm sayfasındaki `kartSayisi`). +- Kullanıldığı yerler: üniversite ve bölüm sayfası (bölümde dört kart: Program, Devlet, + Vakıf, En iyi sıralama; `grid grid-cols-2 gap-4 lg:grid-cols-4`, haritası olmadığı için). - Tıklanabilir kart (ör. Türkiye sırası → sıralama sayfası): kartın tamamı link olur (`after:absolute after:inset-0` gerilmiş link), iç kaynak linki `relative z-10` ile üstte kalır; hover §2.1. @@ -341,7 +345,14 @@ opaklık. adın altındaki satıra iner: `mt-1.5 flex items-center gap-1.5 text-sm text-slate-500 sm:hidden` içinde değer `font-semibold text-slate-900`. - Tür çipi: `rounded-full bg-slate-100 px-2.5 py-0.5 text-xs font-semibold text-slate-700`. -- Satır butonları varsayılan `size-11` (44 px); `size-8` gibi küçültme verilmez. +- Satır butonları varsayılan `size-11` (44 px); `size-8` gibi küçültme verilmez. Telefonda + iki buton alt alta durur (`inline-flex flex-col items-center gap-2 sm:flex-row`, sütun + `w-14 sm:w-28`): yan yana 96 px yiyip adı 157 px'e sıkıştırıyordu, alt alta 273 px kalıyor + (24 Eyl 2026 ölçümü). +- Satırda logo varsa (bölüm sayfası) `UniLogo boy="sm"` adın solunda, `items-start`. +- Program varyantı ("… (İngilizce) (Burslu)") adın altında ayrı satır + `mt-0.5 block text-sm leading-snug text-slate-600`; tür çipine yapıştırılıp kesilmez. +- Tür çipi devlet/vakıf için de aynı slate çip; vakıf violet değildir (§2.2). - Tablo başlığı `h-12 text-sm font-semibold text-slate-500`; hücre dolgusu `py-4 pl-4 sm:pl-5`. ## 5. Yerleşim @@ -378,6 +389,11 @@ kartlarla aynı hizada: yorar), form alanları, uzun rehber metni. - Alt sayfa (§4.10) içeriği her ekranda ortalıdır. +### 5.2.1 Sayfalama — `Sayfalama`, `sayfalama.tsx` +Haplar `h-11` (44 px), hover §2.1. Telefonda (sm altı) tek satır kalsın diye yalnız Önceki · +aktif sayfa · Sonraki görünür; diğer numaralar `hidden sm:inline-flex` ile HTML'de durur +(tarayıcı botları tüm sayfaları görür). 375 px'te 7 sayfalık şerit iki satıra kırılıyordu. + ### 5.3 Verimli ve havadar - İlişkili öğeler yan yana dizilir (iki sütun) — tek sütunda alt alta uzayan panel yapılmaz. - Yan yana dizmek "hepsini tek şeride sıkıştırmak" değildir: 23 Eyl'de denenen yatay "bilgi @@ -470,7 +486,11 @@ Bu dilde sorunların çoğu koddan görünmez (taşma, sığma, hizalanma). Tasa ### 9.4 Site taraması için denetim uyarıları `pnpm tasarim:denetim` bu dilin eski izlerini **uyarı** olarak listeler (lint'i kırmaz, dönüşüm listesidir): `minik-yazi` (10–11 px), `buyuk-harf-etiket` (uppercase + tracking-wide), -`mavi-hover` (§2.1; liste satırı adı istisna), `iri-sayi-tabular` (Cal Sans + tabular-nums). +`mavi-hover` (§2.1), `iri-sayi-tabular` (Cal Sans + tabular-nums). +`mavi-hover` §4.15 kanonik liste satırı adını (`text-base font-semibold leading-snug` + +`hover:text-primary hover:underline` aynı satırda) kendiliğinden atlar — istisna uyarı +vermez (Bilal'in kararı, 24 Eyl 2026). Eski `truncate text-sm` adlar yine uyarır; önce §4.15'e +çevrilir. Bir ekranı dönüştüren ajan o ekrandaki uyarıları sıfırlar ya da neden kaldığını yazar. ### 9.5 Bitirmeden diff --git a/scripts/tasarim-denetim.ts b/scripts/tasarim-denetim.ts index 810ead0..96dbabb 100644 --- a/scripts/tasarim-denetim.ts +++ b/scripts/tasarim-denetim.ts @@ -191,7 +191,20 @@ const KURALLAR: Kural[] = [ ad: "mavi-hover", seviye: "uyari", mesaj: "Hover rengi tek turuncu (orange-500); liste satırındaki ad linkiyse istisna, mavi kalır (DESIGN.md §2.1)", - bul: regexKurali(/\b(?:group-)?hover:(?:text|border|shadow)-primary\b/), + // §2.1 istisnası: §4.15 kanonik liste satırı adı (sarar, text-base + // font-semibold leading-snug + hover:text-primary hover:underline) mavi + // kalır. Eski truncate/text-sm adlar istisnaya girmez — önce §4.15'e + // çevrilir. (Bilal'in kararı, 24 Eyl 2026: istisna uyarı vermesin.) + bul: (icerik) => + regexKurali(/\b(?:group-)?hover:(?:text|border|shadow)-primary\b/)(icerik).filter((e) => { + const bas = icerik.lastIndexOf("\n", e.index) + 1; + const son = icerik.indexOf("\n", e.index); + const satir = icerik.slice(bas, son === -1 ? undefined : son); + return !( + /\btext-base font-semibold leading-snug\b/.test(satir) && + /\bhover:text-primary hover:underline\b/.test(satir) + ); + }), }, { ad: "iri-sayi-tabular", diff --git a/src/components/ozet-kart.tsx b/src/components/ozet-kart.tsx new file mode 100644 index 0000000..4a3ec4d --- /dev/null +++ b/src/components/ozet-kart.tsx @@ -0,0 +1,58 @@ +import type { ReactNode } from "react"; +import type { LucideIcon } from "lucide-react"; + +/** + * Özet kartı (DESIGN.md §4.8 — Apple Fitness özet kartlarının düzeni, + * sitenin ölçüleriyle): kartın en baskın öğesi iri sayı; ikonu sağında, + * küçük ikon kutusunda (§4.4 ikon kutusunun küçük hali). Altında başlık ve + * tek satır açıklama. Telefonda kart iç genişliği ~131 px: en uzun sayı + * ("13.889", text-4xl ≈ 90 px) + size-8 ikon kutusu sığar; sm'den itibaren + * text-5xl. Üniversite ve bölüm sayfalarının tek kaynağı. `ek` verilirse + * açıklamanın altında durur (ör. Türkiye sırasındaki piksel şeridi). dl + * anlamı için dt DOM'da önce gelir, görsel sıra `order` ile kurulur; çağıran + * kartları bir `
` içine koyar. + */ +export function OzetKart({ + ikon: Ikon, + etiket, + deger, + birim, + alt, + ek, + className, +}: { + ikon: LucideIcon; + etiket: string; + deger: ReactNode; + birim?: string; + alt: ReactNode; + ek?: ReactNode; + className?: string; +}) { + return ( +
+
+ {etiket} +
+
+ + + {deger} + + {birim ? ( + + {birim} + + ) : null} + + + + +
+
{alt}
+ {ek ?
{ek}
: null} +
+ ); +} diff --git a/src/features/katalog/components/bolum-icerik.tsx b/src/features/katalog/components/bolum-icerik.tsx index 1459913..4da8337 100644 --- a/src/features/katalog/components/bolum-icerik.tsx +++ b/src/features/katalog/components/bolum-icerik.tsx @@ -1,7 +1,15 @@ import type { Metadata } from "next"; import Link from "next/link"; import { notFound } from "next/navigation"; -import { ChevronRight } from "lucide-react"; +import { + Building2, + Calculator, + ChevronRight, + GraduationCap, + Landmark, + LayoutList, + Target, +} from "lucide-react"; import { getAllBolumler, getAllBolumSlugs, @@ -23,6 +31,8 @@ import { import { BolumProgramListesi } from "./bolum-program-listesi"; import { VeriKaynagi } from "./veri-kaynagi"; import { NavBaslik } from "./nav-baslik"; +import { OzetKart } from "@/components/ozet-kart"; +import { PagePixelDivider } from "@/components/pixel-decor"; import { CtaSiraForm } from "@/features/sihirbaz/components/cta-sira-form"; import { SiteFooter } from "@/components/site-footer"; import { Skeleton } from "@/components/ui/skeleton"; @@ -35,6 +45,32 @@ import { const sayi = (n: number) => n.toLocaleString("tr-TR"); +/** + * Özet kartında iri sayı telefonda ~94 px'e sığmalı (DESIGN.md §4.8): + * 36 px Cal Sans'ta "1.618.988" 123 px ölçüldü, ikonun altına giriyor. 6 ve + * 7 haneli sıralar birimle kısalır: 245.300 → "245" + "bin", + * 1.618.988 → "1,62" + "milyon". + */ +function kartSayisi(n: number): { deger: string; birim?: string } { + // 999.500+ "1.000 bin" olmasın diye milyona geçer + if (n >= 999_500) { + return { + deger: (n / 1e6).toLocaleString("tr-TR", { maximumFractionDigits: 2 }), + birim: "milyon", + }; + } + if (n >= 100_000) return { deger: sayi(Math.round(n / 1000)), birim: "bin" }; + return { deger: sayi(n) }; +} + +/** Lisans puan türünün kimlik hapındaki adı (DB: SAYISAL, EŞİT AĞIRLIK…). */ +const PUAN_TURU_HAP: Record = { + SAYISAL: "Sayısal puan", + "EŞİT AĞIRLIK": "Eşit ağırlık puanı", + SÖZEL: "Sözel puan", + DİL: "Dil puanı", +}; + export function bolumToplamSayfa(programSayisi: number): number { return Math.max(1, Math.ceil(programSayisi / SAYFA_BOYU)); } @@ -179,76 +215,126 @@ export function BolumIcerik({ slug, sayfa }: { slug: string; sayfa: number }) { // adı geçer (ziyaretçi Google'dan doğrudan /sayfa/N'ye iniyor). const ctaBaslik = `Sıralaman ${bolum.ad} tabanlarının neresinde?`; + // Kimlik hapları (DESIGN.md §4.12): düzey + lisans puan türleri. TYT + // puanı önlisansın kendisi olduğundan ayrı hap açmaz. + const puanTurleri = [ + ...new Set(bolum.lisansProgramlari.map((p) => p.tur)), + ].flatMap((t) => (PUAN_TURU_HAP[t] ? [PUAN_TURU_HAP[t]] : [])); + const duzeyler = [ + ...(bolum.lisansProgramlari.length > 0 ? ["Lisans"] : []), + ...(bolum.onlisansProgramlari.length > 0 ? ["Önlisans"] : []), + ]; + // Özet kartları: devlet ve vakıf ayrı sayılır; KKTC ve yurt dışı + // programları toplamda var ama iki karta da girmez (vakıf sayılmaz). + const devletUni = new Set(); + const vakifUni = new Set(); + let vakifProgram = 0; + for (const p of tumProgramlar) { + if (p.unitur === "DEVLET") devletUni.add(p.universite); + else if (p.unitur === "VAKIF" || p.unitur === "VAKIF MYO") { + vakifProgram += 1; + vakifUni.add(p.universite); + } + } + const { stats } = bolum; + const hapSinifi = + "inline-flex h-10 items-center gap-2 rounded-full border border-slate-200 bg-white px-4 text-sm font-semibold text-slate-800"; + return ( <> -
- +
+ - + + Ana Sayfa + + + + Bölümler + + + {ilkSayfa ? ( + {bolum.ad} + ) : ( + <> + + {bolum.ad} + + + Sayfa {sayfa} + + )} + - {/* Başlık üst çubuğun altına girince üst çubukta bölüm adı belirir */} - -
+ {/* Başlık üst çubuğun altına girince üst çubukta bölüm adı belirir + (logosu yok — DESIGN.md §4.14). Geniş ekran (lg) altında başlık + bloğu ortalı (§5.2). */} +

+ className="mt-6 text-center font-heading text-3xl font-bold leading-tight text-slate-900 sm:text-5xl lg:text-left" + > {bolum.ad} {ilkSayfa ? "" : ` — Sayfa ${sayfa}`} {!ilkSayfa && aralik ? ( <> {" "} - + {aralik.charAt(0).toLocaleUpperCase("tr-TR") + aralik.slice(1)} ) : null}

+ + {duzeyler.length > 0 || puanTurleri.length > 0 ? ( +

+ {duzeyler.map((d) => ( + + + {d} + + ))} + {puanTurleri.map((t) => ( + + + {t} + + ))} +

+ ) : null} + {ilkSayfa ? ( -

- {sayi(tumProgramlar.length)} üniversite programının {SON_YIL} taban - puanlarını, başarı sıralamalarını ve kontenjanlarını incele. +

+ {bolum.ad} bölümünü açan {sayi(tumProgramlar.length)} üniversite + programının {SON_YIL} taban puanlarını, başarı sıralamalarını ve + kontenjanlarını incele.{" "} + + {TERCIH_YILI} tabanları, yerleştirme sonuçları açıklandığında + netleşir. +

) : ( -

+

{bolum.ad} taban puanları tablosunun {sayfa}. sayfası ( {sayi(tumProgramlar.length)} programın{" "} {sayi((sayfa - 1) * SAYFA_BOYU + 1)}– @@ -257,54 +343,99 @@ export function BolumIcerik({ slug, sayfa }: { slug: string; sayfa: number }) { {aralik ? `: ${aralik}` : ""}. Listenin başı{" "} ilk sayfada .

)} -
- {/* Tek sayfada çıkan ziyaretçi alttaki karta inmiyor: kompakt sıra girişi - başlık bloğunun hemen altında, listenin üstünde. Alttaki kart kalır. */} -
- -
- -
-
-

- Üniversite programları -

- - {sayi(tumProgramlar.length)} program - + {/* Tek sayfada çıkan ziyaretçi alttaki karta inmiyor: kompakt sıra + girişi özet paragrafın hemen altında, listenin üstünde. Alttaki + kart kalır. */} +
+
- ({ - ...program, - universiteSlug: uniSlugFromAd(program.universite), - }))} - bazAd={bolum.ad} - tabanYol={`/bolum/${slug}`} - sayfa={sayfa} - /> - -
+ {/* Sayfada en çok dört özet sayı (DESIGN.md §1.1, §4.8). Derin + sayfalarda tekrar edilmez — üniversite sayfasıyla aynı. */} + {ilkSayfa ? ( +
+ + 0 + ? `${sayi(devletUni.size)} üniversitede` + : "bu bölümde yok" + } + /> + 0 + ? `${sayi(vakifUni.size)} üniversitede` + : "bu bölümde yok" + } + /> + +
+ ) : null} - {/* Üniversite sayfasıyla aynı konum ve aynı kart: liste + sayfalama altı */} -
- -
+ + +
+
+

+ Üniversite programları +

+

+ {sayi(tumProgramlar.length)} program, taban sırasına göre +

+
+ +
+ ({ + ...program, + universiteSlug: uniSlugFromAd(program.universite), + }))} + bazAd={bolum.ad} + tabanYol={`/bolum/${slug}`} + sayfa={sayfa} + /> +
+ +
+ + {/* Üniversite sayfasıyla aynı konum ve aynı kart: liste + sayfalama altı */} +
+ +
@@ -313,16 +444,20 @@ export function BolumIcerik({ slug, sayfa }: { slug: string; sayfa: number }) { export function BolumIcerikSkeleton() { return ( -
- -
- - +
+ + + + +
+ {Array.from({ length: 4 }, (_, i) => ( + + ))}
-
+
{Array.from({ length: 5 }, (_, i) => ( - + ))}
diff --git a/src/features/katalog/components/bolum-listesi.tsx b/src/features/katalog/components/bolum-listesi.tsx index bb6af6f..3096042 100644 --- a/src/features/katalog/components/bolum-listesi.tsx +++ b/src/features/katalog/components/bolum-listesi.tsx @@ -15,7 +15,7 @@ function BolumLinkleri({ bolumler }: { bolumler: BolumOzet[] }) {
  • {b.ad} diff --git a/src/features/katalog/components/bolum-program-listesi.tsx b/src/features/katalog/components/bolum-program-listesi.tsx index 76a0d0c..b4e2310 100644 --- a/src/features/katalog/components/bolum-program-listesi.tsx +++ b/src/features/katalog/components/bolum-program-listesi.tsx @@ -58,7 +58,7 @@ function SiralamaDugmesi({ aktif && yon === "artan" ? "azalan" : "artan" } sırala`} aria-pressed={aktif} - className="inline-flex min-h-11 cursor-pointer items-center gap-1 rounded-full px-1.5 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" + className="inline-flex min-h-11 cursor-pointer items-center gap-1 rounded-full px-1.5 text-sm font-semibold text-slate-500 transition-colors hover:text-orange-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" > {etiket} @@ -66,7 +66,7 @@ function SiralamaDugmesi({ className={`size-3 ${ aktif && yon === "artan" ? "text-primary" - : "text-muted-foreground/40" + : "text-slate-300" }`} strokeWidth={aktif && yon === "artan" ? 3 : 2} /> @@ -74,7 +74,7 @@ function SiralamaDugmesi({ className={`size-3 ${ aktif && yon === "azalan" ? "text-primary" - : "text-muted-foreground/40" + : "text-slate-300" }`} strokeWidth={aktif && yon === "azalan" ? 3 : 2} /> @@ -154,14 +154,21 @@ export function BolumProgramListesi({ ref={listeRef} className="scroll-mt-24 overflow-hidden rounded-2xl border border-slate-200 bg-white" > + {/* Şişko ve sade (DESIGN.md §4.15): üniversite adı kesilmez, sarar; + satır butonları 44 px. 375 px'te yalnız ad + butonlar sütun + olarak kalır; taban sıra adın altındaki satıra iner. Sıralama + düğmeleri sütun başlıklarıyla birlikte sm'den itibaren görünür — + telefonda liste zaten taban sırasına göre dizili. */} - - Üniversite - + + + Üniversite + + Son {VERI_YIL_SAYISI} yıl - + - + - + - + Netler ve listeye ekle @@ -194,12 +201,13 @@ export function BolumProgramListesi({ {gosterilenProgramlar.map((p) => { const uniAd = trBaslikDuzeni(p.universite); const varyant = p.isim.trim() !== bazAd ? p.isim.trim() : null; - const devlet = p.unitur === "DEVLET"; + const tabanMetni = + p.siraSon != null ? `~${sayi(p.siraSon)}.` : "—"; return ( - - + + {p.universiteSlug ? ( ) : null} @@ -207,71 +215,71 @@ export function BolumProgramListesi({ {p.universiteSlug ? ( {uniAd} ) : ( - + {uniAd} )} - + {varyant ? ( + + {varyant} + + ) : null} + {p.unitur ? ( - + {UNITUR_ETIKET[p.unitur]?.cip ?? trBaslikDuzeni(p.unitur)} ) : null} - - {p.il ? trBaslikDuzeni(p.il) : null} - {varyant ? `${p.il ? " · " : ""}${varyant}` : ""} - - {p.sure ? ( - - · {p.sure} yıl + {p.il ? ( + + {trBaslikDuzeni(p.il)} ) : null} + {p.sure ? ( + {p.sure} yıl + ) : null} + + + Taban sıra + + {tabanMetni} + + - + - + {p.puanSon != null ? p.puanSon.toFixed(2).replace(".", ",") : "—"} - + {p.kontenjanSon != null ? `${sayi(p.kontenjanSon)} kişi` : "—"} - - + + - {p.siraSon != null ? `~${sayi(p.siraSon)}.` : "—"} + {tabanMetni} - - + + - + diff --git a/src/features/katalog/components/program-netleri.tsx b/src/features/katalog/components/program-netleri.tsx index 02570ad..e2e08fd 100644 --- a/src/features/katalog/components/program-netleri.tsx +++ b/src/features/katalog/components/program-netleri.tsx @@ -96,7 +96,7 @@ export function ProgramNetleriButonu({ type="button" title="Son yerleşenin netleri" onClick={(e) => e.stopPropagation()} - className={`inline-flex size-11 min-h-11 min-w-11 cursor-pointer items-center justify-center rounded-full border border-slate-200 bg-white text-slate-500 transition-[background-color,border-color,color,transform] duration-150 hover:border-primary hover:bg-primary/5 hover:text-primary active:scale-[0.92] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${className}`} + className={`inline-flex size-11 min-h-11 min-w-11 cursor-pointer items-center justify-center rounded-full border border-slate-200 bg-white text-slate-500 transition-[background-color,border-color,color,transform] duration-150 hover:border-orange-500 hover:bg-orange-50/60 hover:text-orange-500 active:scale-[0.92] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${className}`} > {isim} — son yerleşenin netleri diff --git a/src/features/katalog/components/sayfalama.tsx b/src/features/katalog/components/sayfalama.tsx index bf2c546..8b2a033 100644 --- a/src/features/katalog/components/sayfalama.tsx +++ b/src/features/katalog/components/sayfalama.tsx @@ -95,7 +95,9 @@ export function Sayfalama({ ); - // 7'den çok sayfada pencere: 1 … (c-1) c (c+1) … son + // 7'den çok sayfada pencere: 1 … (c-1) c (c+1) … son. Telefonda + // (sm altı) tek satır kalsın diye yalnız Önceki · aktif · Sonraki görünür; + // diğer numaralar HTML'de durur (tarayıcı botları tüm sayfaları görür). const numaralar: (number | "...")[] = []; if (toplamSayfa <= 7) { for (let n = 1; n <= toplamSayfa; n++) numaralar.push(n); @@ -116,12 +118,12 @@ export function Sayfalama({ return (