feat(v2): 9–10 Ekim çalışma ağacı — deneyim dili 1. dalga, PWA manifest, revizyon niyet modeli, listem/tadımlık bileşen denetimi

9–10 Ekim oturumlarının commit'lenmemiş işi tek parça alındı
(raporlar: docs/ekip-raporlari/2026-10-09, 2026-10-10):
- Landing deneyim dili 1. dalga: sayfa geçişi (ViewTransition), piksel morf/şerit,
  sorun sahneleri, adım akışı, sayan sayı, mobil menü, adım göstergesi, logoya tepki;
  DESIGN.md §11 ve AGENTS.md kuralları, tasarım denetimine yeni kurallar
- Tema: tema ikonu, D kısayolu, tema geçişi
- PWA: manifest.ts ve ikonlar
- Danışman: revizyon niyet modeli (OPENROUTER_NIYET_MODEL), sohbet geçmişi, soru API'si
- /listem ve /sonuc tadımlığı bileşen denetimi düzeltmeleri
- eslint: .venv-tts/ yok sayılır

tsc, eslint ve tasarım denetimi sıfır hata.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
bilalgursen
2026-10-11 00:34:29 +03:00
parent da0e9dff7a
commit aec8a2ad76
80 changed files with 4134 additions and 654 deletions

View File

@@ -20,6 +20,7 @@ import { kuralOzeti } from "@/lib/revizyon-kurali";
import {
anketCevabiDogrula,
anketCevabiMetni,
type AnketCevaplari,
type RevizyonOzeti,
type SohbetMeta,
} from "@/lib/ai/sohbet-meta";
@@ -138,6 +139,7 @@ export async function POST(request: NextRequest) {
// üretilir; istemcinin yazdığı metin kullanılmaz.
const sonAnket = sonMesaj?.role === "assistant" ? sonMesaj.meta?.anket : undefined;
let kullaniciMeta: SohbetMeta | undefined;
let anketCevaplari: AnketCevaplari | null = null;
if (anketCevabiHam) {
if (!sonAnket || sonAnket.id !== anketCevabiHam.anketId) {
return Response.json({ error: "Bu form artık geçerli değil; isteğini yazarak söyleyebilirsin." }, { status: 409 });
@@ -148,6 +150,7 @@ export async function POST(request: NextRequest) {
}
mesaj = anketCevabiMetni(sonAnket, cevaplar);
kullaniciMeta = { anketCevabi: { anketId: sonAnket.id, cevaplar } };
anketCevaplari = cevaplar;
}
const bedelsiz = Boolean(anketCevabiHam) || onayMesajiMi(mesaj, sonAsistan);
@@ -294,7 +297,7 @@ export async function POST(request: NextRequest) {
let islem: KoddanIslem | undefined;
if (paketli && revizyonNiyetiVar(mesaj)) {
const t0 = Date.now();
const niyet = await revizyonNiyetiCikar({ mesaj, params: raporParams, oncekiForm: anketCevabiHam ? sonAnket : null });
const niyet = await revizyonNiyetiCikar({ mesaj, params: raporParams, oncekiForm: anketCevabiHam ? sonAnket : null, anketCevabi: anketCevaplari });
console.info("[soru] revizyon niyeti:", niyet.niyet, "kural" in niyet ? "kural" : "form" in niyet ? `form (eksik: ${niyet.eksik.join(", ") || "—"})` : "neden" in niyet ? niyet.neden : "", `${Date.now() - t0} ms`);
if (niyet.niyet === "revizyon" && "kural" in niyet) {
const kural = niyet.kural;

View File

@@ -74,6 +74,11 @@
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
/* Hareket eğrileri (DESIGN.md §6); JS karşılığı src/lib/hareket.ts EGRI */
--egri-giris: cubic-bezier(0.16, 1, 0.3, 1);
--egri-standart: cubic-bezier(0.22, 1, 0.36, 1);
--egri-guclu-cikis: cubic-bezier(0.23, 1, 0.32, 1);
--egri-yerlesme: cubic-bezier(0.32, 0.72, 0, 1);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
@@ -484,3 +489,297 @@
animation: none;
}
}
/* Tema geçişi (DESIGN.md §2.3, lib/tema-gecisi.ts): yeni tema header'daki
logodan yumuşak bir halka gibi açılır. Eski görüntü yerinde durur; yeni
görüntü radyal degrade maskesiyle üstünde belirir. Maskenin kenarı keskin
değil: yarıçapın %55'ine kadar tam opak, oradan kenara doğru tüy gibi
söner (--kt-tema-cekirdek; JS'teki CEKIRDEK ile aynı olmalı) — ışık
yayılır gibi, sert çizgi yok. Yarıçap (--kt-tema-r) 0 → --kt-tema-son
yumuşak kalkıp yumuşak durur; kısa ve hafif (600 ms) — asıl gösteri,
geçiş bitince oynayan logo dönüşümüdür. Merkez ve son yarıçap JS'ten gelir.
Hareket azaltılmışsa JS geçişi hiç başlatmaz, tema düz değişir. */
@property --kt-tema-r {
syntax: "<length>";
inherits: true;
initial-value: 0px;
}
.kt-tema-gecis {
view-transition-name: root;
--kt-tema-cekirdek: 0.55;
}
.kt-tema-gecis::view-transition-old(root),
.kt-tema-gecis::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
}
.kt-tema-gecis::view-transition-old(root) {
z-index: 1;
}
.kt-tema-gecis::view-transition-new(root) {
z-index: 2;
mask-image: radial-gradient(
circle at var(--kt-tema-x) var(--kt-tema-y),
#000 calc(var(--kt-tema-r) * var(--kt-tema-cekirdek)),
transparent var(--kt-tema-r)
);
animation: kt-tema-halka 600ms cubic-bezier(0.4, 0, 0.2, 1) both;
}
@keyframes kt-tema-halka {
from {
--kt-tema-r: 0px;
}
to {
--kt-tema-r: var(--kt-tema-son);
}
}
/* Sayfa geçişi (DESIGN.md §6, components/sayfa-gecisi.tsx): rota değişince
eski içerik hızla söner, yenisi hafifçe yükselerek belirir — çıkış girişten
hızlı. Üst çubuk kendi grubunda ve animasyonsuz: içerik değişir, çubuk
yerinde kalır (uygulama hissi, §11.4). Tema geçişinde (`.kt-tema-gecis`)
çubuk adını bırakır ki halka onu da kaplasın. */
.kt-ust-cubuk {
view-transition-name: kt-ust-cubuk;
}
.kt-tema-gecis .kt-ust-cubuk {
view-transition-name: none;
}
::view-transition-group(kt-ust-cubuk) {
animation: none;
z-index: 100;
}
::view-transition-old(kt-ust-cubuk) {
display: none;
}
::view-transition-new(kt-ust-cubuk) {
animation: none;
}
::view-transition-old(.kt-sayfa-cik) {
animation: 120ms var(--egri-standart) both kt-sayfa-sol reverse;
}
::view-transition-new(.kt-sayfa-gir) {
animation:
200ms var(--egri-standart) 80ms both kt-sayfa-sol,
280ms var(--egri-giris) both kt-sayfa-yuksel;
}
@keyframes kt-sayfa-sol {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes kt-sayfa-yuksel {
from {
translate: 0 8px;
}
to {
translate: 0 0;
}
}
@media (prefers-reduced-motion: reduce) {
::view-transition-old(.kt-sayfa-cik),
::view-transition-new(.kt-sayfa-gir) {
animation: none;
}
}
/* Ana sayfa ilk ekranının girişi (DESIGN.md §6 "sayfa başına tek orkestre
giriş", §11.2): önce harita belirir, sonra başlık, sonra form netleşerek
gelir — sırayla, üst üste binmeden. `--sira` ile kademelenir. Başlık LCP
öğesi olduğundan hiçbir kare tam saydam başlamaz (opaklık 0,35'ten). */
.kt-hero-gir {
animation: kt-hero-gir 0.7s var(--egri-giris) both;
animation-delay: calc(var(--sira, 0) * 110ms + 80ms);
}
.kt-hero-harita {
animation: kt-hero-harita 1.1s var(--egri-standart) both;
}
@keyframes kt-hero-gir {
from {
opacity: 0.35;
filter: blur(10px);
translate: 0 12px;
}
to {
opacity: 1;
filter: blur(0);
translate: 0 0;
}
}
@keyframes kt-hero-harita {
from {
opacity: 0;
scale: 0.985;
}
to {
opacity: 1;
scale: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.kt-hero-gir,
.kt-hero-harita {
animation: kt-unblur-sade 0.25s ease-out both;
}
}
/* "Nasıl çalışır" adımları arasındaki çizgi (DESIGN.md §4.32): adımın rakamı
biçimine dönüşüp geri toplanınca (adim-akisi.tsx `data-dolu` yazar) soldan
maviyle dolar. JS'siz istemcide dolu durur. */
@media (scripting: enabled) {
.kt-cizgi-dol {
transform: scaleX(0);
transform-origin: left;
transition: transform 0.6s var(--egri-guclu-cikis);
}
.kt-cizgi-dol[data-dolu] {
transform: scaleX(1);
}
@media (prefers-reduced-motion: reduce) {
.kt-cizgi-dol {
transform: none;
transition: none;
}
}
}
/* Piksel morf (components/piksel-morf.tsx, DESIGN.md §4.38): her piksel bir
<g>; yeri `translate` ile verilir ve kare değişince yeni hücresine süzülür.
`gizli` küçülüp söner. Turuncu vurgu, doku karesinin üstündeki ikinci
karedir: çabuk ısınır, yavaş soğur (ısı fırçasıyla aynı asimetri).
Sekme yok (§6); kayma süresi sahneye göre `--morf-sure` ile ayarlanır. */
.kt-morf-px {
transform-box: fill-box;
transform-origin: center;
transition:
translate var(--morf-sure, 0.55s) var(--egri-standart) var(--g, 0ms),
opacity 0.3s var(--egri-standart) var(--g, 0ms),
scale 0.3s var(--egri-standart) var(--g, 0ms);
}
.kt-morf-px[data-ton="gizli"] {
opacity: 0;
scale: 0.6;
}
.kt-morf-vurgu {
opacity: 0;
fill-opacity: 0.7;
transition: opacity 1.2s var(--egri-standart);
}
.kt-morf-taban {
transition: opacity 0.6s var(--egri-standart);
}
.kt-morf-px[data-ton="vurgu"] > .kt-morf-taban {
opacity: 0;
transition: opacity 0.25s var(--egri-standart) var(--g, 0ms);
}
.kt-morf-px[data-ton="vurgu"] > .kt-morf-vurgu {
opacity: 1;
animation:
kt-morf-isin 0.25s var(--egri-standart) var(--g, 0ms) backwards,
kt-morf-nabiz 3.6s ease-in-out var(--n, 0s) infinite;
}
@keyframes kt-morf-isin {
from {
opacity: 0;
}
}
@keyframes kt-morf-nabiz {
50% {
fill-opacity: 0.35;
}
}
@media (prefers-reduced-motion: reduce) {
.kt-morf-px,
.kt-morf-taban,
.kt-morf-vurgu {
transition: none;
}
.kt-morf-px[data-ton="vurgu"] > .kt-morf-vurgu {
animation: none;
}
}
/* Kendini çizen tik (landing-parcalari.tsx, DESIGN.md §5.2.3): karşılaştırma
tablosu ekrana girince (Reveal → .kt-reveal-in) önce halka, sonra işaret
çizilir; satır gecikmesi `--tik-g` ile gelir. `pathLength="1"` bekler. */
@media (scripting: enabled) and (prefers-reduced-motion: no-preference) {
.kt-reveal .kt-tik-ciz {
stroke-dasharray: 1;
stroke-dashoffset: 1;
opacity: 0;
transition:
stroke-dashoffset 0.5s var(--egri-standart) calc(var(--tik-g, 0ms) + 250ms),
opacity 0.01s linear calc(var(--tik-g, 0ms) + 250ms);
}
.kt-reveal .kt-tik-isaret {
transition-duration: 0.3s, 0.01s;
transition-delay:
calc(var(--tik-g, 0ms) + 600ms),
calc(var(--tik-g, 0ms) + 600ms);
}
.kt-reveal.kt-reveal-in .kt-tik-ciz {
stroke-dashoffset: 0;
opacity: 1;
}
}
/* Hero haritasının serpintisi (turkey-pixel-map.tsx, DESIGN.md §11.1 V2):
piksel bir süre yerinde durur, kopup `--d` birim (10 = bir hücre) düşerken
söner, sonra yerinde yeniden belirir. Süre ve faz satır içi. */
@keyframes kt-serpinti {
0%,
42% {
translate: 0 0;
opacity: 1;
animation-timing-function: ease-in;
}
76% {
translate: 0 calc(var(--d, 20) * 1px);
opacity: 0;
animation-timing-function: step-end;
}
80% {
translate: 0 0;
opacity: 0;
animation-timing-function: ease-out;
}
100% {
translate: 0 0;
opacity: 1;
}
}
.kt-serpinti {
animation: kt-serpinti 6s infinite;
}
@media (prefers-reduced-motion: reduce) {
.kt-serpinti {
animation: none;
}
}
/* Piksel şeridi (components/piksel-serit.tsx, DESIGN.md §4.38): kaydırmaya
bağlı sütun. Okuma çizgisini geçen piksel `data-yandi` alır ve turuncuya
ısınır; geri kaydırınca yavaş soğur. Şeridin ucu (`data-uc`) tam turuncu. */
.kt-serit-px {
fill: currentColor;
fill-opacity: var(--op, 0.2);
transition:
fill 0.9s var(--egri-standart),
fill-opacity 0.9s var(--egri-standart);
}
.kt-serit-px[data-yandi] {
fill: var(--color-orange-500);
fill-opacity: 0.62;
transition-duration: 0.16s;
}
.kt-serit-px[data-uc] {
fill-opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
.kt-serit-px {
transition: none;
}
}

View File

@@ -3,9 +3,11 @@ import Script from "next/script";
import { Cal_Sans, Geist_Mono, Bricolage_Grotesque } from "next/font/google";
import localFont from "next/font/local";
import { BildirimYigini } from "@/components/ui/bildirim";
import { SayfaGecisi } from "@/components/sayfa-gecisi";
import { SiteHeader } from "@/components/site-header";
import { SiteTopBanner } from "@/components/site-top-banner";
import { ListeCekmecesiLazy } from "@/features/liste/components/liste-cekmecesi-lazy";
import { UcusKatmani } from "@/features/liste/components/ucus-katmani";
import { KaydetBannerLazy } from "@/features/liste/components/kaydet-banner-lazy";
import { TercihProfiliKapisiLazy } from "@/features/liste/components/tercih-profili-kapisi-lazy";
import { HeaderGate } from "@/components/header-gate";
@@ -117,12 +119,14 @@ export default function RootLayout({
<div
id="icerik"
tabIndex={-1}
className="flex min-h-0 flex-1 flex-col pb-28 outline-none print:pb-0"
className="flex min-h-0 flex-1 flex-col pb-28 outline-none has-[[data-site-footer]]:pb-0 print:pb-0"
>
{children}
<SayfaGecisi>{children}</SayfaGecisi>
</div>
{/* Manuel 24'lük liste çekmecesi + "+" uçuş katmanı (navbar'dan açılır) */}
{/* Manuel 24'lük liste çekmecesi (navbar'dan açılır) */}
<ListeCekmecesiLazy />
{/* "+" ile eklemede rozete uçan piksel kare (DESIGN.md §4.37) */}
<UcusKatmani />
{/* Girişsiz + 3 seçimli kullanıcıya "listen yalnızca bu tarayıcıda" banner'ı */}
<KaydetBannerLazy />
{/* İlk "+" için sıra + sihirbaz kapısı */}

26
src/app/manifest.ts Normal file
View File

@@ -0,0 +1,26 @@
import type { MetadataRoute } from "next";
// Ana ekrana eklenebilir kabuk (DESIGN.md §11.4): telefonda "Ana ekrana ekle"
// ile site tarayıcı çubuğu olmadan, uygulama gibi açılır. Yalnız kurulum
// kabuğudur; service worker ve çevrimdışı çalışma yok. Renkler gövde zemini
// (bg-slate-50); koyu temada adres çubuğu rengini tema-saglayici.tsx sürer.
export default function manifest(): MetadataRoute.Manifest {
return {
name: "KolayTercih — YKS Tercih Danışmanı",
short_name: "KolayTercih",
description:
"YKS sıralamana göre gerçek YÖK Atlas verisiyle dengeli 24 tercihlik liste.",
lang: "tr",
start_url: "/",
scope: "/",
display: "standalone",
orientation: "portrait",
background_color: "#f8fafc",
theme_color: "#f8fafc",
icons: [
{ src: "/pwa-ikon.svg", sizes: "any", type: "image/svg+xml", purpose: "any" },
{ src: "/pwa-ikon-maskable.svg", sizes: "any", type: "image/svg+xml", purpose: "maskable" },
{ src: "/apple-icon.png", sizes: "180x180", type: "image/png", purpose: "any" },
],
};
}

View File

@@ -10,23 +10,22 @@ import {
} from "lucide-react";
import Link from "next/link";
import { Suspense } from "react";
import { AdimAkisi } from "@/features/pazarlama/components/adim-akisi";
import { BolumGecisi } from "@/features/pazarlama/components/bolum-gecisi";
import { ComparisonCell } from "@/features/pazarlama/components/landing-parcalari";
import { HeroBaslik } from "@/features/pazarlama/components/hero-baslik";
import { KapanisCta } from "@/features/pazarlama/components/hero-focus-button";
import { HeroForm } from "@/features/sihirbaz/components/hero-form";
import { Parallax } from "@/components/parallax";
import { Reveal } from "@/features/pazarlama/components/reveal";
import { SayanSayi } from "@/features/pazarlama/components/sayan-sayi";
import { SorunSahnesi } from "@/features/pazarlama/components/sorun-sahneleri";
import { SosyalKanitBandi } from "@/features/pazarlama/components/sosyal-kanit";
import { TurkeyPixelMap } from "@/features/pazarlama/components/turkey-pixel-map";
import { SuzulenHaplar } from "@/features/pazarlama/components/suzulen-haplar";
import { VeriAkisi } from "@/features/pazarlama/components/veri-akisi";
import { katalogHaplari } from "@/features/pazarlama/landing-verisi";
import {
PixelDivider,
PixelRakam,
PixelSahne,
SectionEyebrow,
} from "@/components/pixel-decor";
import { SectionEyebrow } from "@/components/pixel-decor";
import { PikselCtaYuzeyi } from "@/components/piksel-cta-yuzeyi";
import { URUNLER } from "@/lib/credits";
import {
@@ -68,78 +67,57 @@ const TOPUP_FIYATI = (URUNLER.topup.amountKurus / 100).toLocaleString("tr-TR", {
maximumFractionDigits: 0,
});
// Sorun kartlarının piksel sahneleri (DESIGN.md §4.31): hero haritasıyla aynı
// ızgara dili — `#` doku, `-` silik, `o` turuncu vurgu. Her biri kartın
// söylediğini çizer: binlerce programın içinde tam 24 seçim; her yıl başka
// boyda çubuklar ve yalnız sonuncusu işaretli; basamak basamak tırmanan fiyat.
const SAHNE_IHTIMAL = [
"o#o#######o##o##o#######",
"####o###o#########o###o#",
"######o####o###o########",
"#o##o###################",
"######o##o####o####o##o#",
"#o##########o####o######",
".###..o#......####.#..o.",
"..#...###...##......#...",
];
const SAHNE_YILLAR = [
"........................",
".................##.....",
".........##......##.....",
".##......##......##.....",
".##......##..##..##..oo.",
".##..##..##..##..##..##.",
".##..##..##..##..##..##.",
".##..##..##..##..##..##.",
];
const SAHNE_FIYAT = [
".....................ooo",
"..................###---",
"...............###------",
"............###---------",
".........###------------",
"......###---------------",
"...###------------------",
"###---------------------",
];
// Bölüm ritmi (DESIGN.md §5.1; Bilal, 11 Eki 2026: "section'lar arası es yok,
// her şey çok sıkışık"). Bölümler arasında piksel şeridi durur (§4.38) ve
// çevresinde boşluk ister: şerit üst bölümün içeriğinden uzak, alt bölümün
// rozetine yakındır — bir bölümü bitirip sıradakine iner. Alt boşluk bu yüzden
// üsttekinden bir kademe büyük. Şerit geçiş çizgisinin iki yanına telefonda
// 56, sm'den itibaren 75 px taşar; kalan nefes telefonda 56/40, sm'de 85/53,
// lg'de 101/69 px (üst bölüme/alt bölüme).
const BOLUM_BOSLUGU = "pt-24 pb-28 sm:pt-32 sm:pb-40 lg:pt-36 lg:pb-44";
// Sorun kartlarının piksel sahneleri (DESIGN.md §4.31, §4.38): hero haritasıyla
// aynı ızgara dili. Her biri kartın söylediğini hareketle çizer: binlerce
// programın içinde ısınıp sönen tam 24 seçim; her yıl bir sola kayan, sağdan
// başka boyda gelen çubuklar; basamak basamak tırmanan fiyat. Izgaralar
// `sorun-sahneleri.tsx`te.
const problems = [
{
sahne: SAHNE_IHTIMAL,
sahne: "ihtimal",
title: "24 tercih, binlerce ihtimal",
description: `${PROGRAM_SAYISI_CUMLE} lisans ve önlisans programı arasından sana uyan 24 tanesini doğru sırayla dizmek, tablolarla boğuşarak günler alıyor.`,
},
{
sahne: SAHNE_YILLAR,
sahne: "yillar",
title: "Geçen yılın puanı yetmiyor",
description:
"Taban sıralamaları her yıl kayıyor; tek yıla bakmak riski gizler.",
},
{
sahne: SAHNE_FIYAT,
sahne: "fiyat",
title: "Danışman lüks haline geldi",
description:
"Armut'ta tercih danışmanlığı ilanları ortalama 1.000–3.000 TL (Eylül 2026). Ücretsiz robotların çoğu ise yalnızca liste filtreliyor; listeyi kurup gerekçesini yazmıyor.",
},
];
] as const;
const steps = [
{
step: 1,
title: "Sıralamanı ve hedeflerini anlat",
description:
no: 1,
baslik: "Sıralamanı ve hedeflerini anlat",
aciklama:
"YKS başarı sıralamanı gir; sıralamana uygun ilgi alanı, şehir ve üniversite tercihlerini birkaç adımda işaretle.",
},
{
step: 2,
title: `Son ${VERI_YIL_SAYISI} yılın resmî verisi taransın`,
description:
no: 2,
baslik: `Son ${VERI_YIL_SAYISI} yılın resmî verisi taransın`,
aciklama:
"YÖK Atlas'ın taban sıralaması trendleri, kontenjan değişimleri ve doluluk oranları sıralamana göre elensin; yapay zekâ yalnızca bu havuzdan seçer, dışına çıkamaz.",
},
{
step: 3,
title: "Dengeli 24'lük listeni al",
description:
no: 3,
baslik: "Dengeli 24'lük listeni al",
aciklama:
"Hayal, dengeli ve güvenli dilimlerine dağıtılmış, her satırı gerekçeli tercih listeni indir; aklına takılanı listen üzerinden yapay zekâya sor.",
},
] as const;
@@ -270,19 +248,23 @@ export default function Home() {
aria-hidden
// SVG altta 9 hücrelik serpinti payı taşıdığı için silüet merkezi
// yüksekliğin %37,1'inde; -1/2 yerine bu oranla hizalanır
className="pointer-events-none absolute left-1/2 top-[43%] -z-10 hidden -translate-x-1/2 -translate-y-[37.14%] sm:block"
// Telefonda harita gizlenmez (DESIGN.md §11.2-9): ekrandan taşacak
// kadar iri durur, başlığın üstündeki boşluğu doldurur ve serpinti
// başlığa doğru dökülür.
className="kt-hero-harita pointer-events-none absolute left-1/2 top-[27%] -z-10 -translate-x-1/2 -translate-y-[37.14%] sm:top-[43%]"
>
{/* Arka katman: harita scroll'un gerisinde kalır, derinlik verir */}
<Parallax fromStart strength={-52}>
{/* Harita orta ekranlarda nefes payı bırakır, ekranla birlikte
ölçeklenir ve çok geniş ekranda 1665px tavanına oturur;
mobilde tümüyle gizli. */}
<TurkeyPixelMap className="w-[min(1665px,82vw)] max-w-none text-primary" />
telefonda ekranın 1,7 katı (kenarlardan taşar, kırpılır). */}
<TurkeyPixelMap className="w-[170vw] max-w-none text-primary sm:w-[min(1665px,82vw)]" />
</Parallax>
</div>
<div
aria-hidden
className="pointer-events-none absolute inset-0 -z-10 bg-[radial-gradient(ellipse_58%_52%_at_50%_42%,color-mix(in_oklab,var(--color-slate-50)_90%,transparent)_25%,transparent_75%)]"
// Okunurluk perdesi: telefonda başlık daha aşağıda ve dar, perde ona göre
className="pointer-events-none absolute inset-0 -z-10 bg-[radial-gradient(ellipse_95%_38%_at_50%_62%,color-mix(in_oklab,var(--color-slate-50)_92%,transparent)_35%,transparent_80%)] sm:bg-[radial-gradient(ellipse_58%_52%_at_50%_42%,color-mix(in_oklab,var(--color-slate-50)_90%,transparent)_25%,transparent_75%)]"
/>
<Parallax
fromStart
@@ -292,22 +274,21 @@ export default function Home() {
<HeroBaslik />
<div
id="hero-form"
className="mt-6 flex w-full scroll-mt-28 justify-center"
className="kt-hero-gir mt-6 flex w-full scroll-mt-28 justify-center"
style={{ "--sira": 2 } as React.CSSProperties}
>
<HeroForm />
</div>
</Parallax>
</section>
{/* Hero → Problem geçişi: border yerine piksel ayraç */}
<div aria-hidden className="mx-auto w-56">
<Parallax strength={26}>
<PixelDivider seed={11} />
</Parallax>
</div>
{/* Hero → Problem geçişi: border ya da ayraç yerine piksel şeridi.
Haritanın serpintisinden başlar, kaydırdıkça turuncuya ısınarak
bölümden bölüme iner (DESIGN.md §4.38). */}
<BolumGecisi tohum={11} ust={6} ustTelefon={6} ustKisaTelefon={3} />
{/* Problem */}
<section className="py-20 sm:py-24">
<section className={BOLUM_BOSLUGU}>
<div className="mx-auto max-w-6xl px-4">
{/* Başlık gövdeden hafif hızlı akar — katman derinliği */}
<Parallax strength={16}>
@@ -322,13 +303,18 @@ export default function Home() {
</Reveal>
</Parallax>
{/* Sahne kartı (DESIGN.md §4.31): üstte hero'nun piksel diliyle
kartın söylediğini çizen sahne, altında başlık ve metin. */}
<Parallax strength={7} className="mt-12 grid gap-6 md:grid-cols-3">
kartın söylediğini çizen sahne, altında başlık ve metin.
Sahneler sırayla oynar; karta gelince o sahne hemen oynar
(`data-sahne-karti`, sorun-sahneleri.tsx). */}
<Parallax strength={7} className="mt-12 grid gap-6 sm:mt-16 md:grid-cols-3">
{problems.map((p, i) => (
<Reveal key={p.title} delay={i * 0.08} className="h-full">
<div className="flex h-full flex-col overflow-hidden rounded-2xl border border-slate-200/70 bg-card shadow-sm transition-[translate,border-color,box-shadow] duration-300 hover:-translate-y-1 hover:border-orange-500 hover:shadow-lg hover:shadow-orange-500/5">
<div
data-sahne-karti=""
className="flex h-full flex-col overflow-hidden rounded-2xl border border-slate-200/70 bg-card shadow-sm transition-[translate,border-color,box-shadow] duration-300 hover:-translate-y-1 motion-reduce:hover:translate-y-0 hover:border-orange-500 hover:shadow-lg hover:shadow-orange-500/5"
>
<div className="border-b border-slate-100 bg-slate-50 px-6 py-8">
<PixelSahne izgara={p.sahne} />
<SorunSahnesi tur={p.sahne} sira={i} />
</div>
<div className="p-6">
<h3 className="font-heading text-xl font-bold text-balance">
@@ -346,14 +332,10 @@ export default function Home() {
</section>
{/* Problem → Nasıl çalışır geçişi */}
<div aria-hidden className="mx-auto w-56">
<Parallax strength={26}>
<PixelDivider seed={17} />
</Parallax>
</div>
<BolumGecisi tohum={17} />
{/* How it works */}
<section id="nasil-calisir" className="scroll-mt-24 py-20 sm:py-24">
<section id="nasil-calisir" className={`scroll-mt-24 ${BOLUM_BOSLUGU}`}>
<div className="mx-auto max-w-6xl px-4">
<Parallax strength={16}>
<Reveal className="flex flex-col items-center gap-4 text-center">
@@ -362,7 +344,7 @@ export default function Home() {
{/* Kuralların şeffaf anlatımına köprü: /meraklisina */}
<Link
href="/meraklisina"
className="group inline-flex items-center gap-1.5 rounded-full bg-orange-500 px-3.5 py-1.5 font-bricolage text-xs font-semibold text-white transition-colors duration-200 hover:bg-orange-600"
className="group inline-flex min-h-8 items-center gap-1 rounded-full px-2 text-sm font-semibold text-slate-600 transition-colors duration-200 hover:text-orange-500"
>
Meraklısına
<ArrowUpRight
@@ -381,34 +363,10 @@ export default function Home() {
</Reveal>
</Parallax>
{/* Adımlar kartsız (DESIGN.md §4.32): numara hero'nun piksel
diliyle iri rakam, md'den itibaren rakamlar düz çizgiyle
birbirine bağlanır. */}
<Parallax strength={7} className="mt-12">
<ol className="grid gap-12 md:grid-cols-3 md:gap-8">
{steps.map((s, i) => (
<li key={s.step}>
<Reveal
delay={i * 0.08}
className="relative flex flex-col items-center text-center"
>
<PixelRakam rakam={s.step} className="h-24 w-auto" />
{i < steps.length - 1 ? (
<span
aria-hidden
className="absolute top-12 left-[calc(50%+4.5rem)] hidden h-0.5 w-[calc(100%-7rem)] -translate-y-1/2 rounded-full bg-slate-200 md:block"
/>
) : null}
<h3 className="mt-6 font-heading text-xl font-bold text-balance">
<span className="sr-only">{s.step}. adım: </span>
{s.title}
</h3>
<p className="mt-2 max-w-sm text-base leading-relaxed text-slate-600">
{s.description}
</p>
</Reveal>
</li>
))}
</ol>
diliyle iri rakam; her rakam sırayla adımının biçimine dönüşüp
geri toplanır, md'den itibaren aradaki düz çizgi dolar. */}
<Parallax strength={7} className="mt-12 sm:mt-16">
<AdimAkisi adimlar={steps} yilSayisi={VERI_YIL_SAYISI} />
</Parallax>
{/* Veri akışı + veriye dayalı güven + dürüstlük notu (DESIGN.md
@@ -416,10 +374,10 @@ export default function Home() {
ekranda solda dürüstlük notu (sola yaslı), ortada animasyon,
sağda (YÖK Atlas girdilerinin yanında) üç sayı (sağa yaslı).
lg altında hepsi ortalı; sıra: animasyon → sayılar → not. */}
<Parallax strength={10} className="mt-20">
<Parallax strength={10} className="mt-24 sm:mt-32">
<Reveal
delay={0.1}
className="grid items-center gap-12 lg:grid-cols-[minmax(0,1fr)_22rem_minmax(0,1fr)] lg:gap-8 xl:grid-cols-[minmax(0,1fr)_24rem_minmax(0,1fr)] xl:gap-12"
className="grid items-center gap-16 lg:grid-cols-[minmax(0,1fr)_22rem_minmax(0,1fr)] lg:gap-8 xl:grid-cols-[minmax(0,1fr)_24rem_minmax(0,1fr)] xl:gap-12"
>
<VeriAkisi className="lg:order-2" />
@@ -432,7 +390,7 @@ export default function Home() {
{stat.label}
</dt>
<dd className="order-1 font-heading text-4xl font-bold leading-none tracking-tight text-primary sm:text-5xl">
{stat.value}
<SayanSayi deger={stat.value} />
{stat.birim ? (
<span className="ml-1.5 font-sans text-base font-semibold tracking-normal text-slate-400">
{stat.birim}
@@ -463,14 +421,10 @@ export default function Home() {
</section>
{/* Nasıl çalışır → Karşılaştırma geçişi */}
<div aria-hidden className="mx-auto w-56">
<Parallax strength={26}>
<PixelDivider seed={23} />
</Parallax>
</div>
<BolumGecisi tohum={23} />
{/* Comparison */}
<section id="karsilastirma" className="scroll-mt-24 py-20 sm:py-24">
<section id="karsilastirma" className={`scroll-mt-24 ${BOLUM_BOSLUGU}`}>
<div className="mx-auto max-w-4xl px-4">
<Parallax strength={16}>
<Reveal className="flex flex-col items-center gap-4 text-center">
@@ -488,7 +442,7 @@ export default function Home() {
üstte yapışık durur (site başlığının hemen altında: telefonda 96 px,
sm'den itibaren 128 px — ölçüldü).
lg'de klasik dört sütunlu tablo. */}
<Parallax strength={7} className="mt-12">
<Parallax strength={7} className="mt-12 sm:mt-16">
<Reveal delay={0.08}>
<div className="rounded-2xl border border-slate-200/70 bg-card shadow-sm lg:hidden">
<div
@@ -502,8 +456,12 @@ export default function Home() {
</span>
</div>
<ul className="divide-y divide-slate-100">
{comparison.map((row) => (
<li key={row.feature} className="px-3 py-5">
{comparison.map((row, i) => (
<li
key={row.feature}
className="px-3 py-5"
style={{ "--tik-g": `${i * 70}ms` } as React.CSSProperties}
>
<p className="px-1 text-center text-base font-semibold leading-snug text-slate-900">
{row.feature}
</p>
@@ -551,10 +509,11 @@ export default function Home() {
</TableRow>
</TableHeader>
<TableBody>
{comparison.map((row) => (
{comparison.map((row, i) => (
<TableRow
key={row.feature}
className="hover:bg-transparent"
style={{ "--tik-g": `${i * 70}ms` } as React.CSSProperties}
>
<TableCell className="whitespace-normal py-4 pl-5 text-base font-semibold text-slate-900">
{row.feature}
@@ -582,14 +541,10 @@ export default function Home() {
</section>
{/* Karşılaştırma → Fiyat geçişi */}
<div aria-hidden className="mx-auto w-56">
<Parallax strength={26}>
<PixelDivider seed={29} />
</Parallax>
</div>
<BolumGecisi tohum={29} />
{/* Fiyat */}
<section id="fiyat" className="scroll-mt-24 py-20 sm:py-24">
<section id="fiyat" className={`scroll-mt-24 ${BOLUM_BOSLUGU}`}>
<div className="mx-auto max-w-4xl px-4">
<Parallax strength={16}>
<Reveal className="flex flex-col items-center gap-4 text-center">
@@ -610,7 +565,7 @@ export default function Home() {
<Parallax
strength={7}
className="mt-12 grid gap-6 sm:grid-cols-[1fr_auto]"
className="mt-12 grid gap-6 sm:mt-16 sm:grid-cols-[1fr_auto]"
>
<Reveal
delay={0.08}
@@ -648,10 +603,7 @@ export default function Home() {
<SosyalKanitBandi />
</Suspense>
<Button
asChild
className="h-12 w-full cursor-pointer bg-orange-500 px-8 text-white transition-colors duration-200 hover:bg-orange-600"
>
<Button asChild size="lg" variant="cta" className="w-full">
<Link href="/paket?kaynak=fiyat_paket">
{PAKET_FIYATI} TL — Paketi incele
<ArrowRight className="size-4" aria-hidden />
@@ -677,11 +629,7 @@ export default function Home() {
+{URUNLER.topup.credits} soru hakkı, {TOPUP_FIYATI} TL. Pakete
ek olarak, istediğin zaman.
</p>
<Button
asChild
variant="outline"
className="mt-4 h-11 w-full cursor-pointer"
>
<Button asChild variant="ikincil" className="mt-4 h-11 w-full">
<Link href="/paket?kaynak=fiyat_kredi">
+{URUNLER.topup.credits} kredi — {TOPUP_FIYATI} TL
<ArrowRight className="size-4" aria-hidden />
@@ -695,12 +643,8 @@ export default function Home() {
{/* Katalog: süzülen haplar (DESIGN.md §4.30) */}
{haplar.length >= 6 ? (
<>
<div aria-hidden className="mx-auto w-56">
<Parallax strength={26}>
<PixelDivider seed={41} />
</Parallax>
</div>
<section id="katalog" className="scroll-mt-24 py-20 sm:py-24">
<BolumGecisi tohum={41} />
<section id="katalog" className={`scroll-mt-24 ${BOLUM_BOSLUGU}`}>
<div className="mx-auto max-w-6xl px-4">
<Parallax strength={16}>
<Reveal className="flex flex-col items-center gap-4 text-center">
@@ -719,8 +663,7 @@ export default function Home() {
<Button
asChild
size="lg"
variant="outline"
className="cursor-pointer px-6 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.97]"
variant="ikincil"
>
<Link href="/bolumler">
Tüm bölümleri gör
@@ -734,14 +677,10 @@ export default function Home() {
) : null}
{/* Fiyat → SSS geçişi */}
<div aria-hidden className="mx-auto w-56">
<Parallax strength={26}>
<PixelDivider seed={31} />
</Parallax>
</div>
<BolumGecisi tohum={31} />
{/* FAQ */}
<section id="sss" className="scroll-mt-24 py-20 sm:py-24">
<section id="sss" className={`scroll-mt-24 ${BOLUM_BOSLUGU}`}>
<div className="mx-auto max-w-3xl px-4">
<Parallax strength={16}>
<Reveal className="flex flex-col items-center gap-4 text-center">
@@ -756,7 +695,7 @@ export default function Home() {
<Accordion
type="single"
collapsible
className="mt-10 rounded-2xl border border-slate-200/70 bg-card px-6 shadow-sm"
className="mt-12 rounded-2xl border border-slate-200/70 bg-card px-6 shadow-sm sm:mt-16"
>
{faqs.map((faq) => (
<AccordionItem
@@ -779,7 +718,7 @@ export default function Home() {
</section>
{/* Final CTA — tam genişlik bant yerine yüzen, köşeleri yumuşak panel */}
<section className="px-4 pb-24 pt-6 sm:px-6">
<section className="px-4 pb-24 pt-6 sm:px-6 sm:pb-32">
<Parallax strength={12} className="mx-auto max-w-6xl">
<Reveal>
{/* Kapanış yüzeyi (DESIGN.md §10.8): koyu kart + 24'lük

View File

@@ -1,6 +1,8 @@
"use client";
import { useEffect, useRef } from "react";
import { LOGO_TEPKI_OLAYI, type LogoTepkisi } from "@/lib/logo-tepkisi";
import { TEMA_GECIS_OLAYI } from "@/lib/tema-gecisi";
import { cn } from "@/lib/utils";
// public/kolay-tercih-mark.svg'nin inline, animasyonlu karşılığı.
@@ -18,6 +20,11 @@ import { cn } from "@/lib/utils";
// küçülüp büyür (danışman panel başlığının sakin, sürekli hâli; danışman
// avatarı da cevap beklenirken böyle atar).
//
// `bicimler` verilirse döngü bir sahneye döner (DESIGN.md §4.28, ana sayfa
// veri akışının ortası): varyant belirli süre oynar, sonra kareler sıradaki
// biçime (liste, trend, sohbet) dönüşür, kısa bir an durur ve logoya
// toplanır; döngü yerinden sürer, sıra bir sonraki biçime geçer.
//
// Performans notu: kareler SVG <rect> değil HTML <span>, animasyon da Web
// Animations API ile yalnızca `translate` / `scale` / `opacity` üzerinden
// yürür. Bu üçü compositor'da (GPU) oynar: ana iş parçacığı meşgulken
@@ -97,6 +104,141 @@ function yer(pozisyon: number, boyut: number): string {
return `${(x / boyut) * 100}% ${(y / boyut) * 100}%`;
}
/** Karenin merkezini 180'lik kutuda (cx, cy) noktasına taşıyan `translate`. */
function merkezde(cx: number, cy: number, boyut: number): string {
return `${((cx - boyut / 2) / boyut) * 100}% ${((cy - boyut / 2) / boyut) * 100}%`;
}
// Tema geçişi biçimi (DESIGN.md §2.3): sayfa geçişi bitince kareler
// neye geçildiğini gösterir, kısa bir an durur ve yeniden logoya toplanır.
// - Koyuya: şeftali kare hilale, mavi kare hilalin kucağındaki yıldıza;
// turuncu söner.
// - Açığa: turuncu kare güneşe, şeftali kare arkasındaki haleye; mavi söner.
// Toplam süre ve duraklar: %30'da biçim tamam, %70'e kadar durur, sonra döner.
const SEKIL_SURE = 1700;
const SEKIL_DURAKLAR = [0, 0.3, 0.7, 1];
type SekilHedefi = { translate: string; scale: string; opacity: number; borderRadius: string };
// Kullanıcı eylemi biçimleri (DESIGN.md §4.34): sıralama girildi → kürsü,
// listeye eklendi → deste, liste doldu → tek sıra. Tema biçiminden kısadır,
// çünkü eylem tekrarlanır; duraklar aynı. Kareler sırayla yola çıkar.
const EYLEM_SURE = 1100;
const EYLEM_GECIKME_ADIMI = 70;
export type Bicim = "gunes" | "ay" | "kursu" | "deste" | "sira" | "trend" | "sohbet";
const TEPKI_BICIMI: Record<LogoTepkisi, Bicim> = { sira: "kursu", ekle: "deste", tamam: "sira" };
// Sahne (`bicimler`): eylem biçiminden uzun durur, çünkü kimse bir şeye
// basmadı — bakan kişi biçimi okuyacak kadar görmeli. Dönüşüm ≈0,6 sn,
// durma ≈1,3 sn, toplanma ≈0,6 sn. Arada döngü (danışmanın nabzı) üç karenin
// de birer kez atmasına yetecek kadar oynar.
const SAHNE_SURE = 2500;
const SAHNE_DURAKLAR = [0, 0.24, 0.76, 1];
const SAHNE_DONGU_SURE = 3000;
/** Merkezi (cx, cy), eni `en`, boyu `boy` olan köşeli dikdörtgen hedefi. */
function dikdortgen(cx: number, cy: number, en: number, boy: number, b: number): SekilHedefi {
return {
translate: merkezde(cx, cy, b),
scale: `${en / b} ${boy / b}`,
opacity: 1,
// Ölçek yarıçapı da gerer; kısa kenara göre verilir ki köşe ezilmesin.
borderRadius: `${(7 / en) * 100}% / ${(7 / boy) * 100}%`,
};
}
/** Konuşma balonu: yuvarlak köşeli dikdörtgen, `kuyruk` yanındaki alt köşesi
* sivri. Yarıçaplar ölçek gerdiği için her eksende ayrı verilir. */
function balon(
cx: number,
cy: number,
en: number,
boy: number,
b: number,
kuyruk: "sol" | "sag",
): SekilHedefi {
// Köşe sırası: sol üst, sağ üst, sağ alt, sol alt.
const r = kuyruk === "sol" ? [20, 20, 20, 4] : [20, 20, 4, 20];
const oran = (kenar: number) => r.map((v) => `${(v / kenar) * 100}%`).join(" ");
return {
translate: merkezde(cx, cy, b),
scale: `${en / b} ${boy / b}`,
opacity: 1,
borderRadius: `${oran(en)} / ${oran(boy)}`,
};
}
// Ay kompozisyonu kutunun merkezine (90) göre küçültülür ve logonun alanına
// (x 27–151) oturacak kadar kaydırılır.
const AY_OLCEK = 0.82;
const AY_KAYMA_X = 8;
function ay(nokta: number, kayma = AY_KAYMA_X): number {
return 90 + (nokta - 90) * AY_OLCEK + kayma;
}
/** Kare `i` için hedef biçim. Kareler sırayla mavi, şeftali, turuncu.
* Her biçim logonun kendi alanına (x 27–151) sığar. */
function sekilHedefi(i: number, bicim: Bicim): SekilHedefi {
const b = KARELER[i].boyut;
const daire = "50%";
switch (bicim) {
case "ay":
return [
// Biçim tema-ikonu.tsx'teki ay ikonuyla aynı: gövde r74 @ (76,92),
// tek yıldız r13 @ (108,79) — hilalin iki sivri ucunun tam ortası (iki
// dairenin kesişim kirişinin orta noktası). Turuncu kare yıldızın
// yerinde söner: güneşte mavi, ayda turuncu sahneden çekilir. `ay`
// kompozisyonu logonun kendi alanına sığdırır.
{ translate: merkezde(ay(108), ay(79, 0), b), scale: String((26 * AY_OLCEK) / b), opacity: 1, borderRadius: daire }, // yıldız
{ translate: merkezde(ay(76), ay(92, 0), b), scale: String((148 * AY_OLCEK) / b), opacity: 1, borderRadius: daire }, // hilal
{ translate: merkezde(ay(108), ay(79, 0), b), scale: "0.2", opacity: 0, borderRadius: daire }, // söner
][i];
case "gunes":
return [
{ translate: merkezde(90, 90, b), scale: "0.3", opacity: 0, borderRadius: daire }, // söner
{ translate: merkezde(90, 90, b), scale: "2.15", opacity: 0.45, borderRadius: daire }, // hale
{ translate: merkezde(90, 90, b), scale: "1.1", opacity: 1, borderRadius: daire }, // güneş
][i];
case "kursu":
// Üç basamak, tabanı aynı çizgide (y 150): mavi ortada en yüksek.
return [
dikdortgen(89, 103, 36, 94, b),
dikdortgen(49, 119, 36, 62, b),
dikdortgen(129, 127, 36, 46, b),
][i];
case "deste":
// Üst üste üç satır: yeni gelen (turuncu) en üstte.
return [
dikdortgen(89, 130, 112, 30, b),
dikdortgen(89, 90, 112, 30, b),
dikdortgen(89, 50, 112, 30, b),
][i];
case "sira":
// Liste tamam: üç kare tek sırada, eşit.
return [
dikdortgen(47, 90, 36, 36, b),
dikdortgen(89, 90, 36, 36, b),
dikdortgen(131, 90, 36, 36, b),
][i];
case "trend":
// Yükselen üç çubuk, tabanı aynı çizgide (y 137; biçim kutunun
// ortasına oturur): en yeni yıl (turuncu) sağda ve en yüksek — sorun
// kartındaki çubuklarla aynı vurgu.
return [
dikdortgen(89, 102, 36, 70, b),
dikdortgen(49, 114, 36, 46, b),
dikdortgen(129, 90, 36, 94, b),
][i];
case "sohbet":
// İki balon: solda danışman (mavi), sağ altta sen (turuncu). Şeftali
// kare turuncunun yerinde söner — güneşte mavi, ayda turuncu gibi.
return [
balon(76, 65, 92, 56, b, "sol"),
{ translate: merkezde(106, 122, b), scale: "0.2", opacity: 0, borderRadius: "50%" },
balon(106, 122, 84, 44, b, "sag"),
][i];
}
}
/** Kare için durak koordinatları: kendi yeri → sıradaki → ondan sonraki →
* kendi yeri. Duraklarda bekleme için her hedef iki kez tekrarlanır
* (KONUM_ZAMANLARI ile hizalı). */
@@ -170,6 +312,7 @@ export function AnimasyonluMark({
sadeceHover = false,
oynat = true,
varyant = "rota",
bicimler,
renk = "marka",
}: {
className?: string;
@@ -177,6 +320,10 @@ export function AnimasyonluMark({
/** `sadeceHover` yokken döngü açık mı. `false` iken işaret durağandır. */
oynat?: boolean;
varyant?: Varyant;
/** Sahne: döngü oynarken kareler aralıklarla sıradaki biçime dönüşüp geri
* toplanır. Dizi sabit olmalı (her render'da yenisi verilirse sıra başa
* döner). `sadeceHover` ile birlikte kullanılmaz. */
bicimler?: readonly Bicim[];
/** "marka": logodaki üç renk; "turuncu": tüm kareler sitenin CTA turuncusu
* (orange-500) — ör. danışman panel başlığı. */
renk?: "marka" | "turuncu";
@@ -184,6 +331,7 @@ export function AnimasyonluMark({
const kokRef = useRef<HTMLSpanElement>(null);
const kontrolRef = useRef<{
ayarla: (hedef: Varyant | null, cevik?: boolean) => void;
sekilOynat: (bicim: Bicim, sahne?: boolean) => void;
} | null>(null);
// Animasyonların sahibi: bileşen yaşadıkça bir kez kurulur. Prop değişimi
@@ -197,6 +345,8 @@ export function AnimasyonluMark({
let dongu: Animation[] = [];
let donus: Animation[] = [];
/** Tema geçişi biçimi (güneş/ay) animasyonları; bkz. `temaSekli`. */
let sekil: Animation[] = [];
/** Şu an dönen döngü ve istenen döngü (`null`: durağan). */
let aktif: Varyant | null = null;
let hedef: Varyant | null = null;
@@ -233,14 +383,11 @@ export function AnimasyonluMark({
);
};
const ayarla = (yeni: Varyant | null, cevik = false) => {
if (yeni === hedef) return;
hedef = yeni;
if (aktif !== null) yerles(cevik);
if (yeni === null || azHareket.matches) return;
// Kareler hâlâ yerine dönüyorsa dönüşün bitmesi beklenir; döngü hep
// yerinden başlar, sıçrama olmaz.
Promise.all(donus.map((a) => a.finished))
/** `yeni` döngüsünü, süren dönüş ve tema biçimi bitince yerinden başlatır. */
const donguyuSiraya = (yeni: Varyant) => {
// Kareler hâlâ yerine dönüyorsa (ya da güneş/ay oynuyorsa) bitmesi
// beklenir; döngü hep yerinden başlar, sıçrama olmaz.
Promise.all([...donus, ...sekil].map((a) => a.finished))
.then(() => {
if (sokuldu || hedef !== yeni || aktif !== null) return;
dongu = kareler.flatMap((el, i) => donguyuBaslat(el, i, yeni));
@@ -249,12 +396,104 @@ export function AnimasyonluMark({
.catch(() => {});
};
const kontrol = { ayarla };
const ayarla = (yeni: Varyant | null, cevik = false) => {
if (yeni === hedef) return;
hedef = yeni;
if (aktif !== null) yerles(cevik);
if (yeni === null || azHareket.matches) return;
donguyuSiraya(yeni);
};
/**
* Tema geçişi biçimi: kareler güneşe ya da aya dönüşür, durur, logoya
* döner. Hover döngüsünden önceliklidir: döngü oynuyorsa kesilir, kareler
* o an neredeyse oradan biçime geçer; biçim bitince imleç hâlâ
* üzerindeyse döngü yerinden yeniden başlar. Hareket azaltılmışsa çalışmaz.
* Hilal, kareyi kesen ikinci bir şekille değil karenin kendi iç gölgesiyle
* çizilir (zemin şeffaf, renk iç gölgede): arkadaki zemin ne olursa olsun
* doğru görünür. `sahne` biçimi daha uzun tutar (SAHNE_SURE).
*/
const sekilOynat = (hedefBicim: Bicim, sahne = false) => {
if (azHareket.matches) return;
const koyu = hedefBicim === "ay";
const eylem = hedefBicim !== "ay" && hedefBicim !== "gunes";
const duraklar = sahne ? SAHNE_DURAKLAR : SEKIL_DURAKLAR;
// Karelerin o anki hâli: döngü ya da önceki biçim yarıdaysa oradan çıkılır.
const anlik = kareler.map((el) => {
const cs = getComputedStyle(el);
return {
translate: cs.translate,
scale: cs.scale,
opacity: cs.opacity,
borderRadius: cs.borderRadius,
};
});
[...dongu, ...donus, ...sekil].forEach((a) => a.cancel());
dongu = [];
donus = [];
aktif = null;
sekil = kareler.flatMap((el, i) => {
const k = KARELER[i];
const ev = {
translate: yer(k.baslangic, k.boyut),
scale: "1",
opacity: 1,
borderRadius: `${(13 / k.boyut) * 100}%`,
};
const bicim = sekilHedefi(i, hedefBicim);
// `fill: backwards` gecikme boyunca kareyi o anki yerinde tutar.
const secenek = eylem
? {
duration: sahne ? SAHNE_SURE : EYLEM_SURE,
delay: i * EYLEM_GECIKME_ADIMI,
fill: "backwards" as const,
}
: { duration: SEKIL_SURE };
const animasyonlar = [
el.animate(
[
{ ...anlik[i], easing: DONUS },
{ ...bicim, easing: "linear" },
{ ...bicim, easing: YERLES },
ev,
].map((kare, d) => ({ ...kare, offset: duraklar[d] })),
secenek,
),
];
// Hilal: yalnız şeftali kare, yalnız koyuya geçerken.
if (koyu && i === 1) {
const renk = getComputedStyle(el).backgroundColor;
const w = el.offsetWidth;
const dolu = `inset 0 0 0 ${w}px ${renk}`;
// tema-ikonu.tsx'teki hilalle aynı kesim: gövde r74'ten, (+40,-16)
// kaydırılmış r66 daire çıkar — sırtı sol altta.
// İç gölgede: kaydırma = kesim dairesinin kayması, yayılma = iki
// yarıçapın farkı (hepsi gövde çapı 148'e oranla).
const hilal = `inset ${(40 / 148) * w}px ${(-16 / 148) * w}px 0 ${(8 / 148) * w}px ${renk}`;
animasyonlar.push(
el.animate(
[
{ boxShadow: dolu, backgroundColor: "transparent", easing: DONUS },
{ boxShadow: hilal, backgroundColor: "transparent", easing: "linear" },
{ boxShadow: hilal, backgroundColor: "transparent", easing: YERLES },
{ boxShadow: dolu, backgroundColor: "transparent" },
].map((kare, d) => ({ ...kare, offset: SEKIL_DURAKLAR[d] })),
secenek,
),
);
}
return animasyonlar;
});
// İmleç hâlâ üzerindeyse hover döngüsü biçimden sonra sürer.
if (hedef !== null) donguyuSiraya(hedef);
};
const kontrol = { ayarla, sekilOynat };
kontrolRef.current = kontrol;
return () => {
sokuldu = true;
if (kontrolRef.current === kontrol) kontrolRef.current = null;
[...dongu, ...donus].forEach((a) => a.cancel());
[...dongu, ...donus, ...sekil].forEach((a) => a.cancel());
};
}, []);
@@ -265,7 +504,17 @@ export function AnimasyonluMark({
if (!kok) return;
if (!sadeceHover) {
kontrolRef.current?.ayarla(oynat ? varyant : null);
return;
if (!oynat || !bicimler?.length) return;
// Sahne: döngü SAHNE_DONGU_SURE oynar, sonra sıradaki biçim; biçim
// bitince döngü yerinden sürer (`sekilOynat` sıraya koyar). `oynat`
// kapanınca sayaç durur, yarıdaki biçim kendi kendine logoya toplanır.
let sira = 0;
let sayac = window.setTimeout(function siradaki() {
kontrolRef.current?.sekilOynat(bicimler[sira % bicimler.length], true);
sira += 1;
sayac = window.setTimeout(siradaki, SAHNE_SURE + SAHNE_DONGU_SURE);
}, SAHNE_DONGU_SURE);
return () => window.clearTimeout(sayac);
}
kontrolRef.current?.ayarla(null, true);
// Hover hedefi işaretin kendisi değil, en yakın `group` ebeveyni (site
@@ -276,11 +525,39 @@ export function AnimasyonluMark({
const cik = () => kontrolRef.current?.ayarla(null, true);
hoverHedefi.addEventListener("mouseenter", gir);
hoverHedefi.addEventListener("mouseleave", cik);
// Tema geçişi bitince (lib/tema-gecisi.ts) kareler yeni temanın
// biçimine dönüşür: koyuya geçişte ay ve yıldızlar, açığa geçişte güneş.
// Olay anında <html> yeni temanın sınıfını taşır.
const temaGecti = () =>
kontrolRef.current?.sekilOynat(
document.documentElement.classList.contains("dark") ? "ay" : "gunes",
);
window.addEventListener(TEMA_GECIS_OLAYI, temaGecti);
// Kullanıcı eylemi (lib/logo-tepkisi.ts): sıralama, ekleme, dolu liste.
const tepki = (e: Event) => {
const tur = (e as CustomEvent<LogoTepkisi>).detail;
if (tur in TEPKI_BICIMI) kontrolRef.current?.sekilOynat(TEPKI_BICIMI[tur]);
};
window.addEventListener(LOGO_TEPKI_OLAYI, tepki);
// Dokunmatikte hover yok: logoya basınca döngü bir tur oynar, sonra
// kareler yerine oturur (telefonda da logonun canlı olduğu görülsün).
let dokunmaSayaci: number | undefined;
const dokun = (e: Event) => {
if ((e as PointerEvent).pointerType === "mouse") return;
window.clearTimeout(dokunmaSayaci);
gir();
dokunmaSayaci = window.setTimeout(cik, ROTA_SURE);
};
hoverHedefi.addEventListener("pointerdown", dokun);
return () => {
hoverHedefi.removeEventListener("mouseenter", gir);
hoverHedefi.removeEventListener("mouseleave", cik);
window.removeEventListener(TEMA_GECIS_OLAYI, temaGecti);
window.removeEventListener(LOGO_TEPKI_OLAYI, tepki);
hoverHedefi.removeEventListener("pointerdown", dokun);
window.clearTimeout(dokunmaSayaci);
};
}, [oynat, sadeceHover, varyant]);
}, [oynat, sadeceHover, varyant, bicimler]);
return (
<span

View File

@@ -0,0 +1,304 @@
"use client";
// Telefon menüsü (DESIGN.md §4.36): üst çubuk tek satır kalsın diye sıralama
// dışındaki her şey bu baloncuğun içinde. Açılınca tam ekran perde gelir,
// iri haplar sırayla "pop" eder; kapanış hızlı ve sekmesiz.
//
// Esin: React Bits "Bubble Menu" (reactbits.dev, MIT + Commons Clause). Kod
// KolayTercih diline çevrildi: GSAP yerine `motion`, zamanlama
// `lib/hareket.ts`, renkler token; eğik/gökkuşağı haplar alınmadı. Kabuk
// Radix Dialog ilkeli (odak tuzağı, Esc, odak dönüşü); hareketi azalt'ta
// haplar yalnız belirir.
import { useRef, useState, type ReactNode } from "react";
import Link from "next/link";
import { Dialog as DialogPrimitive } from "radix-ui";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
BookOpen,
Building2,
CircleHelp,
ClipboardList,
Coins,
GraduationCap,
ListChecks,
Search,
type LucideIcon,
} from "lucide-react";
import { AnimasyonluMark } from "@/components/animasyonlu-mark";
import { TemaSecici } from "@/components/tema-secici";
import { SayiRulo } from "@/components/ui/sayi-rulo";
import {
MANUEL_LISTE_MAX,
cekmeceAc,
useManuelListe,
} from "@/features/liste/liste-store";
import { preloadListeCekmecesi } from "@/features/liste/components/liste-cekmecesi-lazy";
import { EGRI, SURE, YAY } from "@/lib/hareket";
import { cn } from "@/lib/utils";
/** Arama penceresini açtıran olay; `katalog-arama.tsx` dinler. */
export const ARAMA_AC_OLAYI = "kt-arama-ac";
const HAP_ADIMI = 0.035;
const GEZINME: { href: string; etiket: string; ikon: LucideIcon }[] = [
{ href: "/bolumler", etiket: "Bölümler", ikon: GraduationCap },
{ href: "/universiteler", etiket: "Üniversiteler", ikon: Building2 },
{ href: "/rehber", etiket: "Rehber", ikon: BookOpen },
{ href: "/#nasil-calisir", etiket: "Nasıl çalışır?", ikon: CircleHelp },
];
const IRI_HAP =
"flex min-h-14 w-full cursor-pointer items-center gap-3 rounded-full border border-slate-200 bg-card px-6 text-left font-heading text-xl font-bold text-slate-900 outline-none transition-[background-color,transform] duration-150 focus-visible:ring-3 focus-visible:ring-ring/50 active:scale-[0.97] motion-reduce:transition-none";
const KUCUK_HAP =
"flex min-h-12 cursor-pointer items-center gap-2 rounded-full border border-slate-200 bg-card px-4 text-base font-semibold text-slate-700 outline-none transition-[background-color,transform] duration-150 focus-visible:ring-3 focus-visible:ring-ring/50 active:scale-[0.97] motion-reduce:transition-none";
export function MobilMenu({
kredi,
girisSlotu,
}: {
/** Girişli kullanıcının kredisi; girişsizse `null`. */
kredi: number | null;
/** "Giriş yap" ya da "Çıkış yap" hapı (sunucu tarafı karar verir). */
girisSlotu: ReactNode;
}) {
const [acik, setAcik] = useState(false);
// Kapatma düğmesi ve logo, üst çubuktaki yerlerinde durur: çubuğun o anki
// konumu (üst bant görünürken daha aşağıda) açılırken ölçülür.
const [ust, setUst] = useState(16);
const tetikRef = useRef<HTMLButtonElement>(null);
const azalt = useReducedMotion();
const liste = useManuelListe();
function degistir(yeni: boolean) {
if (yeni) {
const r = tetikRef.current?.getBoundingClientRect();
if (r) setUst(Math.max(8, r.top));
}
setAcik(yeni);
}
/** Sırayla "pop": baloncuk fırlar (§4.36: sekmenin serbest olduğu yer). */
const hap = (i: number) =>
azalt
? {
initial: { opacity: 0 },
animate: { opacity: 1, transition: { duration: SURE.hizli } },
exit: { opacity: 0, transition: { duration: SURE.anlik } },
}
: {
initial: { opacity: 0, scale: 0.6 },
animate: {
opacity: 1,
scale: 1,
transition: { ...YAY.birakma, delay: 0.06 + i * HAP_ADIMI },
},
exit: {
opacity: 0,
scale: 0.92,
transition: { duration: SURE.cikis, ease: EGRI.standart },
},
};
let sira = 0;
return (
<DialogPrimitive.Root open={acik} onOpenChange={degistir}>
<DialogPrimitive.Trigger asChild>
<button
ref={tetikRef}
type="button"
aria-label={
liste.length > 0
? `Menü — listende ${liste.length}/${MANUEL_LISTE_MAX} program`
: "Menü"
}
data-ucus-hedefi="mobil"
className="relative flex size-9 shrink-0 cursor-pointer items-center justify-center rounded-full border border-slate-200 bg-card text-slate-900 outline-none transition-[background-color,transform] duration-150 hover:bg-slate-100 focus-visible:ring-3 focus-visible:ring-ring/50 active:scale-[0.96] motion-reduce:transition-none sm:size-12"
>
<MenuCizgileri acik={false} />
{liste.length > 0 ? (
<span
data-ucus-rozet
className="absolute -top-1 -right-1 flex h-5 min-w-5 items-center justify-center rounded-full bg-orange-500 px-1 font-heading text-xs font-bold text-white"
>
<SayiRulo deger={liste.length} />
</span>
) : null}
</button>
</DialogPrimitive.Trigger>
<AnimatePresence>
{acik ? (
<DialogPrimitive.Portal forceMount>
<DialogPrimitive.Overlay forceMount asChild>
<motion.div
className="fixed inset-0 z-50 bg-slate-50/90 supports-backdrop-filter:backdrop-blur-md"
initial={{ opacity: 0 }}
animate={{ opacity: 1, transition: { duration: SURE.standart, ease: EGRI.standart } }}
exit={{ opacity: 0, transition: { duration: SURE.cikis, ease: EGRI.standart } }}
/>
</DialogPrimitive.Overlay>
<DialogPrimitive.Content
forceMount
aria-describedby={undefined}
className="fixed inset-0 z-50 overflow-y-auto overscroll-contain outline-none"
>
<DialogPrimitive.Title className="sr-only">Menü</DialogPrimitive.Title>
{/* Üst sıra: çubuğun yerinde. Önce imza (logo bir tur döner),
içerik ardından gelir (§11.2). */}
<div
className="pointer-events-none fixed inset-x-0 z-10 flex items-center justify-between px-3 sm:px-4"
style={{ top: ust }}
>
<motion.span
className="flex items-center font-bricolage"
initial={{ opacity: 0 }}
animate={{ opacity: 1, transition: { duration: SURE.standart } }}
exit={{ opacity: 0, transition: { duration: SURE.anlik } }}
>
<AnimasyonluMark varyant="nabiz" className="size-8 sm:size-11" />
<span className="text-lg font-light tracking-tighter sm:text-3xl">Kolay</span>
<span className="text-lg font-semibold tracking-tighter sm:text-3xl">Tercih</span>
</motion.span>
<DialogPrimitive.Close asChild>
<button
type="button"
aria-label="Menüyü kapat"
className="pointer-events-auto flex size-9 cursor-pointer items-center justify-center rounded-full border border-slate-200 bg-card text-slate-900 outline-none transition-[background-color,transform] duration-150 hover:bg-slate-100 focus-visible:ring-3 focus-visible:ring-ring/50 active:scale-[0.96] motion-reduce:transition-none sm:size-12"
>
<MenuCizgileri acik />
</button>
</DialogPrimitive.Close>
</div>
<nav
aria-label="Menü"
className="mx-auto flex min-h-full max-w-md flex-col gap-3 px-4 pb-10"
style={{ paddingTop: ust + 72 }}
>
<motion.div {...hap(sira++)}>
<button
type="button"
className={IRI_HAP}
onClick={() => {
setAcik(false);
window.dispatchEvent(new Event(ARAMA_AC_OLAYI));
}}
>
<Search className="size-5 shrink-0 text-primary" aria-hidden />
Ara
<span className="ml-auto text-sm font-medium font-sans text-slate-500">
bölüm, üniversite, rehber
</span>
</button>
</motion.div>
<motion.div {...hap(sira++)}>
<button
type="button"
className={IRI_HAP}
onPointerEnter={preloadListeCekmecesi}
onFocus={preloadListeCekmecesi}
onClick={() => {
setAcik(false);
cekmeceAc();
}}
>
<ListChecks className="size-5 shrink-0 text-primary" aria-hidden />
Kendi Listem
<span
className={cn(
"ml-auto flex h-7 items-center rounded-full px-2.5 font-sans text-sm font-bold tabular-nums",
liste.length > 0
? "bg-orange-500 text-white"
: "bg-slate-100 text-slate-500",
)}
>
{liste.length}/{MANUEL_LISTE_MAX}
</span>
</button>
</motion.div>
{kredi !== null ? (
<>
<motion.div {...hap(sira++)}>
<Link href="/listem" className={IRI_HAP} onClick={() => setAcik(false)}>
<ClipboardList className="size-5 shrink-0 text-primary" aria-hidden />
Yapay Zeka Listem
</Link>
</motion.div>
<motion.div {...hap(sira++)}>
<Link
href="/paket?kaynak=nav"
className={IRI_HAP}
onClick={() => setAcik(false)}
>
<Coins className="size-5 shrink-0 text-amber-500" aria-hidden />
Kredilerim
<span className="ml-auto font-sans text-base font-bold tabular-nums text-slate-700">
{kredi.toLocaleString("tr-TR")}
</span>
</Link>
</motion.div>
</>
) : null}
<motion.ul {...hap(sira++)} className="mt-2 grid grid-cols-2 gap-2">
{GEZINME.map(({ href, etiket, ikon: Ikon }) => (
<li key={href}>
<Link
href={href}
className={cn(KUCUK_HAP, "w-full")}
onClick={() => setAcik(false)}
>
<Ikon className="size-4 shrink-0 text-slate-500" aria-hidden />
<span className="truncate">{etiket}</span>
</Link>
</li>
))}
</motion.ul>
<motion.div {...hap(sira++)} className="mt-4 flex justify-center">
<TemaSecici ipucu={false} />
</motion.div>
<motion.div
{...hap(sira++)}
className="mt-auto flex justify-center pt-6"
// Giriş/çıkış sonrası menü açık kalmasın.
onClick={() => setAcik(false)}
>
{girisSlotu}
</motion.div>
</nav>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
) : null}
</AnimatePresence>
</DialogPrimitive.Root>
);
}
/** İki çizgi ↔ çarpı. Açık hâl perdede ayrı düğmede çizildiği için burada
* geçiş yok; iki hâl aynı ölçüde (§4.3 çarpı ölçüsü). */
function MenuCizgileri({ acik }: { acik: boolean }) {
return (
<span aria-hidden className="relative block size-4">
<span
className={cn(
"absolute left-0 h-0.5 w-4 rounded-full bg-current",
acik ? "top-[7px] rotate-45" : "top-1",
)}
/>
<span
className={cn(
"absolute left-0 h-0.5 w-4 rounded-full bg-current",
acik ? "top-[7px] -rotate-45" : "top-2.5",
)}
/>
</span>
);
}

View File

@@ -0,0 +1,145 @@
"use client";
// Piksel morf (DESIGN.md §4.38): piksel sahnenin (§4.31) canlı hâli. Sahne
// sabit bir piksel kümesidir; her pikselin kimliği vardır ve kareden kareye
// yeni hücresine süzülür, turuncuya ısınır ya da sahneden çekilir. Logonun üç
// karesinin yaptığını (§4.34) hero'nun ızgarasında, çok pikselle yapar.
//
// Bilerek aptal bir ilkel: hangi pikselin nerede, hangi tonda duracağını
// çağıran söyler (`pikseller`), hareketin tamamı CSS geçişidir (globals.css
// `.kt-morf-px`). Zamanlamayı, tekrarı ve görünürlüğü de çağıran yönetir.
//
// Doku pikselleri imleç ısı fırçasına katılır (usePixelHeat `canli`): fırça
// konumu her taramada okur, o yüzden yer değiştiren piksel doğru ısınır.
// Turuncu vurgu, doku pikselinin üstüne binen ikinci bir karedir — fırça
// alttaki karenin rengini sürerken vurgu ona karışmaz; vurgu yanarken alttaki
// kare söner (turuncu, mavinin üstünde bulanmasın).
import { useEffect, useRef, useState, type CSSProperties } from "react";
import { PIKSEL } from "@/components/pixel-decor";
import { pikselIsiTazele, usePixelHeat } from "@/components/use-pixel-heat";
import { cn } from "@/lib/utils";
export type MorfTon =
/** Hero haritasının mavi dokusu */
| "doku"
/** Aynı doku yarı opaklıkta (dolgu, arka plan) */
| "silik"
/** Turuncu vurgu; hero'daki şehirler gibi nabız atar */
| "vurgu"
/** Sahnede yok: küçülüp söner, yerinde bekler */
| "gizli";
export type MorfPiksel = {
/** Kareler arası kimlik: aynı `id` aynı pikseldir, yeni yerine süzülür. */
id: string;
/** Hücre koordinatı (buçuklu olabilir: ortalanmış küme). */
x: number;
y: number;
ton: MorfTon;
/** Doku opaklığı (0–1). Kimliğe bağlıdır; piksel taşınınca değişmez. */
op: number;
/** Bu karedeki geçişin gecikmesi (ms): sırayla ısınma, sırayla kalkış. */
gecikme?: number;
};
const { hucre: CELL, nokta: DOT, bosluk: INSET } = PIKSEL;
export function PikselMorf({
pikseller,
sutun,
satir,
yaricap = 26,
firca = true,
className,
style,
}: {
/** Sıra kareler arasında sabit kalmalı (DOM yer değiştirirse geçiş kopar). */
pikseller: readonly MorfPiksel[];
sutun: number;
satir: number;
/** Isı fırçasının yarıçapı (viewBox birimi; hücre 10) */
yaricap?: number;
/** `false`: bu karede fırça kapalı — turuncunun anlam taşıdığı biçimde
* (ör. "sen buradasın" karesi) imleç ısısı ona karışmasın. */
firca?: boolean;
className?: string;
/** Süre ayarı için: `--morf-sure` (kayma), varsayılan 0,55 sn */
style?: CSSProperties;
}) {
const svgRef = useRef<SVGSVGElement>(null);
usePixelHeat(svgRef, { radius: yaricap, canli: true });
// Pikseller yer değiştirdi: imleç yerinde dursa da fırça yeniden baksın.
useEffect(() => {
pikselIsiTazele();
}, [pikseller, firca]);
// Turuncu üst kare yalnız bir kez vurgu olmuş piksele eklenir ve orada
// kalır: sönerken de DOM'da olmalı ki soğuma geçişi oynasın.
const [vurguGordu, setVurguGordu] = useState(
() => new Set(pikseller.filter((p) => p.ton === "vurgu").map((p) => p.id)),
);
if (pikseller.some((p) => p.ton === "vurgu" && !vurguGordu.has(p.id))) {
const genis = new Set(vurguGordu);
for (const p of pikseller) if (p.ton === "vurgu") genis.add(p.id);
setVurguGordu(genis);
}
return (
<svg
ref={svgRef}
viewBox={`0 0 ${sutun * CELL} ${satir * CELL}`}
aria-hidden="true"
focusable="false"
className={cn("h-auto w-full text-primary", className)}
style={style}
>
{pikseller.map((p, i) => {
const isinir = firca && (p.ton === "doku" || p.ton === "silik");
const op = Math.round((p.ton === "silik" ? p.op * 0.5 : p.op) * 100) / 100;
return (
<g
key={p.id}
className="kt-morf-px"
data-ton={p.ton}
style={
{
translate: `${p.x * CELL}px ${p.y * CELL}px`,
"--g": p.gecikme ? `${p.gecikme}ms` : undefined,
} as CSSProperties
}
>
<rect
x={INSET}
y={INSET}
width={DOT}
height={DOT}
rx={2}
data-px={isinir ? "" : undefined}
data-cx={p.x * CELL + CELL / 2}
data-cy={p.y * CELL + CELL / 2}
data-op={op}
fill="currentColor"
fillOpacity={op}
className="kt-morf-taban"
/>
{vurguGordu.has(p.id) ? (
<rect
x={INSET}
y={INSET}
width={DOT}
height={DOT}
rx={2}
className="kt-morf-vurgu fill-orange-500"
// Nabızlar aynı anda atmasın (PixelSahne ile aynı kademe);
// sıra pikselin dizideki yerinden gelir, kareden kareye sabit.
style={{ "--n": `${((i * 5) % 8) * 0.45}s` } as CSSProperties}
/>
) : null}
</g>
);
})}
</svg>
);
}

View File

@@ -0,0 +1,75 @@
"use client";
// Piksel şeridi (DESIGN.md §4.38): hero haritasından dökülen serpintinin tek
// sıraya inmiş hâli. Dikey bir piksel sütunudur ve kaydırmaya bağlıdır:
// ekranın okuma çizgisini (varsayılan: yüksekliğin %65'i) geçen her piksel
// turuncuya ısınır, geri kaydırınca yavaşça soğur. Animasyon yoktur; şeridi
// kullanıcının parmağı çizer (Parallax gibi kaydırmayla 1:1).
//
// Renk geçişi CSS'tir (globals.css `.kt-serit-px`): çabuk ısınma, yavaş soğuma
// — ısı fırçasıyla aynı asimetri. Hareketi azalt'ta geçiş anlıktır.
import { useRef, useState, type CSSProperties } from "react";
import { useMotionValueEvent, useScroll } from "motion/react";
import { PIKSEL, dokuOpakligi } from "@/components/pixel-decor";
import { cn } from "@/lib/utils";
const { hucre: CELL, nokta: DOT, bosluk: INSET } = PIKSEL;
export function PikselSerit({
uzunluk,
okuma = 0.65,
tohum = 0,
hucreSinifi,
className,
style,
}: {
/** Şeridin boyu (hücre) */
uzunluk: number;
/** Okuma çizgisi: ekran yüksekliğinin oranı (0 üst, 1 alt) */
okuma?: number;
/** Doku deseni; yan yana şeritler aynı görünmesin */
tohum?: number;
/** Sıra başına ek sınıf: dar boşlukta içeriğe değecek pikseli gizlemek
* için (ör. `max-sm:hidden`). */
hucreSinifi?: readonly (string | undefined)[];
className?: string;
style?: CSSProperties;
}) {
const kapRef = useRef<HTMLDivElement>(null);
const [yanan, setYanan] = useState(0);
// 0: şeridin tepesi okuma çizgisinde; 1: dibi okuma çizgisinde.
const { scrollYProgress } = useScroll({
target: kapRef,
offset: [`start ${okuma}`, `end ${okuma}`],
});
useMotionValueEvent(scrollYProgress, "change", (ilerleme) => {
setYanan(Math.round(ilerleme * uzunluk));
});
return (
<div ref={kapRef} aria-hidden="true" className={className} style={style}>
<svg
viewBox={`0 0 ${CELL} ${uzunluk * CELL}`}
focusable="false"
className="block h-auto w-full text-primary"
>
{Array.from({ length: uzunluk }, (_, i) => (
<rect
key={i}
x={INSET}
y={i * CELL + INSET}
width={DOT}
height={DOT}
rx={2}
data-yandi={i < yanan ? "" : undefined}
// Şeridin ucu: en son yanan piksel tam turuncu
data-uc={i === yanan - 1 ? "" : undefined}
className={cn("kt-serit-px", hucreSinifi?.[i])}
style={{ "--op": dokuOpakligi(tohum, i) } as CSSProperties}
/>
))}
</svg>
</div>
);
}

View File

@@ -39,6 +39,12 @@ const hash = (x: number, y: number, seed: number) => {
return n - Math.floor(n);
};
// Kendi sahnesini üreten bileşenler (PikselMorf ve sahneleri) aynı ızgarayı,
// dokuyu ve sözde-rastgeleyi buradan alır; ölçü ikinci bir yerde yazılmaz.
export const PIKSEL = { hucre: CELL, nokta: DOT, bosluk: INSET } as const;
export const dokuOpakligi = opacityFor;
export const pikselHash = hash;
function pixelRect(
x: number,
y: number,
@@ -551,7 +557,7 @@ export function PixelSahne({
}
// 5×7 dot-matrix rakamlar. Yeni bir rakam gerekirse ızgarası buraya eklenir.
const RAKAMLAR = {
export const RAKAMLAR = {
1: ["..#..", ".##..", "..#..", "..#..", "..#..", "..#..", ".###."],
2: [".###.", "#...#", "....#", "...#.", "..#..", ".#...", "#####"],
3: [".###.", "#...#", "....#", "..##.", "....#", "#...#", ".###."],

View File

@@ -0,0 +1,22 @@
"use client";
import { ViewTransition, type ReactNode } from "react";
import { usePathname } from "next/navigation";
/**
* Rotalar arası kısa çapraz solma (DESIGN.md §6, §11.4): sayfa "belge" gibi
* bir anda değişmez, eski içerik söner yenisi belirir. Yalnız yol değişince
* oynar (`key`): aynı sayfadaki güncellemeler (arama, sıralama, Suspense)
* `default="none"` ile sessiz kalır. Üst çubuk geçişe katılmaz, yerinde durur
* (globals.css `.kt-ust-cubuk`). Süre ve eğri globals.css'te
* (`.kt-sayfa-gir` / `.kt-sayfa-cik`); hareketi azalt'ta kapalı. Tarayıcı
* View Transitions bilmiyorsa sayfa eskisi gibi düz değişir.
*/
export function SayfaGecisi({ children }: { children: ReactNode }) {
const yol = usePathname();
return (
<ViewTransition key={yol} enter="kt-sayfa-gir" exit="kt-sayfa-cik" default="none">
{children}
</ViewTransition>
);
}

View File

@@ -25,8 +25,10 @@ export function SiteFooter() {
.sort((a, b) => b.programSayisi - a.programSayisi)
.slice(0, 8);
// data-site-footer: layout'taki #icerik alt boşluğunu (pb-28) kapatır —
// dev yazı logosu ekranın altına yapışsın.
return (
<footer className="border-t border-slate-200/70 pb-32 pt-12">
<footer data-site-footer className="border-t border-slate-200/70 pt-12">
<div className="mx-auto max-w-6xl px-4">
<div className="grid gap-10 sm:grid-cols-2 lg:grid-cols-3">
<nav aria-label="Popüler bölümler">
@@ -212,6 +214,41 @@ export function SiteFooter() {
/>
</div>
</div>
{/* Dev yazı logosu: header'daki "KolayTercih" (Bricolage, ince + yarı
kalın) kenardan kenara, alttan kırpılmış ve aşağı doğru sönerek.
SVG + textLength: yazı her genişlikte kabı tam doldurur, font
ölçüsüne göre taşmaz. Renkler slate token'ı — koyu temada kendiliğinden
döner. Süs: ekran okuyucuya kapalı. */}
<svg
viewBox="0 0 1000 135"
aria-hidden
className="pointer-events-none mt-10 block w-full px-4 font-bricolage select-none"
>
<defs>
<linearGradient
id="footer-yazi-logo"
gradientUnits="userSpaceOnUse"
x1="0"
y1="0"
x2="0"
y2="135"
>
<stop offset="0" style={{ stopColor: "var(--color-slate-900)" }} />
<stop offset="0.75" style={{ stopColor: "var(--color-slate-300)" }} />
<stop offset="1" style={{ stopColor: "var(--color-slate-200)" }} />
</linearGradient>
</defs>
<text
x="0"
y="156"
fontSize="222"
textLength="1000"
fill="url(#footer-yazi-logo)"
>
<tspan className="font-light">Kolay</tspan>
<tspan className="font-semibold">Tercih</tspan>
</text>
</svg>
</footer>
);
}

View File

@@ -8,7 +8,7 @@ import { getRehberAramaKayitlari } from "@/lib/rehber";
export function SiteHeader() {
return (
<header className="sticky top-4 z-50 mt-4 sm:top-6 sm:mt-6 print:hidden">
<header className="kt-ust-cubuk sticky top-4 z-50 mt-4 sm:top-6 sm:mt-6 print:hidden">
{/* lg altında 2 satır: logo+UserNav / sıralama+ara. lg+: tek satır, ortada nav. */}
<div className="relative mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-x-2 gap-y-2 px-3 sm:gap-x-3 sm:px-4 lg:h-16 lg:flex-nowrap">
{/* Sayfa başlığı yuvası (NavBaslik): bir sayfa <html>'e

View File

@@ -0,0 +1,54 @@
import { useId } from "react";
// Tema seçicinin ikonları (DESIGN.md §2.3): tema geçişinde header'daki logonun
// dönüştüğü biçimlerin durağan hâli (animasyonlu-mark.tsx `temaSekli`).
// - "light": güneş — turuncu daire, arkasında şeftali hale.
// - "dark": ay — şeftali hilal, iki ucunun ortasında mavi bir yıldız.
// - "system": logonun üç karesi — ikisine de dönüşebilen hâl.
// Renkler logonun renkleridir ve iki temada aynıdır (§2.3); 180'lik kutu
// public/kolay-tercih-mark.svg ile aynı. Dekoratiftir: anlamı yanındaki
// etiket taşır.
const MAVI = "#3b82f6";
const SEFTALI = "#fdc7a7";
const TURUNCU = "#ff5a00";
export type TemaIkonuTuru = "light" | "dark" | "system";
export function TemaIkonu({
tur,
className,
}: {
tur: TemaIkonuTuru;
className?: string;
}) {
const maskeId = useId();
return (
<svg viewBox="0 0 180 180" aria-hidden="true" className={className}>
{tur === "light" ? (
<>
<circle cx="90" cy="90" r="84" fill={SEFTALI} opacity="0.55" />
<circle cx="90" cy="90" r="43" fill={TURUNCU} />
</>
) : tur === "dark" ? (
<>
{/* Hilal: daireden, sağa-yukarı kaydırılmış ikinci daire çıkarılır
(sırtı solda). */}
<mask id={maskeId}>
<rect width="180" height="180" fill="#fff" />
<circle cx="116" cy="76" r="66" fill="#000" />
</mask>
<circle cx="76" cy="92" r="74" fill={SEFTALI} mask={`url(#${maskeId})`} />
{/* Yıldız: hilalin iki sivri ucunun tam ortası. */}
<circle cx="108" cy="79" r="13" fill={MAVI} />
</>
) : (
<>
<rect x="27" y="56" width="57" height="57" rx="13" fill={MAVI} />
<rect x="91" y="29" width="56" height="56" rx="13" fill={SEFTALI} />
<rect x="91" y="95" width="56" height="56" rx="13" fill={TURUNCU} />
</>
)}
</svg>
);
}

View File

@@ -0,0 +1,38 @@
"use client";
import { useEffect } from "react";
import { useTheme } from "next-themes";
import { temayiGecisle } from "@/lib/tema-gecisi";
/** Tuş yazı alanında basıldıysa kısayol değildir. */
function yaziAlanindaMi(hedef: EventTarget | null) {
if (!(hedef instanceof HTMLElement)) return false;
if (hedef.isContentEditable) return true;
const etiket = hedef.tagName;
return etiket === "INPUT" || etiket === "TEXTAREA" || etiket === "SELECT";
}
/**
* Klavye kısayolu: `D` açık ↔ koyu temayı değiştirir (ipucu footer'daki
* TemaSecici'nin altında, Kbd ile). Değiştirici tuşla (⌘/Ctrl/Alt) ya da yazı
* alanındayken çalışmaz; "sistem" seçiliyken o anki çözümlenmiş temanın
* tersine, açık bir seçime geçer. Geçiş logodan yumuşak bir halka gibi yayılır.
*/
export function TemaKisayolu() {
const { resolvedTheme, setTheme } = useTheme();
useEffect(() => {
const dinle = (e: KeyboardEvent) => {
if (e.key !== "d" && e.key !== "D") return;
if (e.metaKey || e.ctrlKey || e.altKey || e.repeat) return;
if (yaziAlanindaMi(e.target)) return;
e.preventDefault();
const hedef = resolvedTheme === "dark" ? "light" : "dark";
temayiGecisle(() => setTheme(hedef));
};
window.addEventListener("keydown", dinle);
return () => window.removeEventListener("keydown", dinle);
}, [resolvedTheme, setTheme]);
return null;
}

View File

@@ -2,6 +2,7 @@
import { useEffect } from "react";
import { ThemeProvider, useTheme } from "next-themes";
import { TemaKisayolu } from "@/components/tema-kisayolu";
// Gövde zemini (bg-slate-50) — açık ve koyu temadaki karşılığı
const ZEMIN = { light: "#f8fafc", dark: "#080e19" };
@@ -25,7 +26,8 @@ function AdresCubuguRengi() {
* Tema (DESIGN.md §2.3): <html>'e `dark` sınıfı yazılır, renkler
* globals.css'teki `.dark` token eşlemesinden gelir. Varsayılan açık tema;
* kullanıcı footer'daki TemaSecici ile koyu ya da "sistem"e geçer, seçim
* tarayıcıda saklanır.
* tarayıcıda saklanır; `D` tuşu açık ↔ koyu geçişi yapar (TemaKisayolu).
* Tema değişimi logodan yumuşak bir halka gibi yayılır (lib/tema-gecisi.ts).
*/
export function TemaSaglayici({ children }: { children: React.ReactNode }) {
return (
@@ -37,6 +39,7 @@ export function TemaSaglayici({ children }: { children: React.ReactNode }) {
disableTransitionOnChange
>
<AdresCubuguRengi />
<TemaKisayolu />
{children}
</ThemeProvider>
);

View File

@@ -2,13 +2,24 @@
import { useSyncExternalStore } from "react";
import { useTheme } from "next-themes";
import { Monitor, Moon, Sun } from "lucide-react";
import { TemaIkonu } from "@/components/tema-ikonu";
import { Kbd } from "@/components/ui/kbd";
import { SecimHaplari } from "@/components/ui/secim-haplari";
import { temayiGecisle } from "@/lib/tema-gecisi";
import { cn } from "@/lib/utils";
// İkonlar geçişte logonun dönüştüğü biçimlerin durağan hâlidir (DESIGN.md
// §4.34): güneş, ay ve logonun kendisi. SecimHaplari ikonu bileşen olarak
// ister; her tür için ince bir sarmalayıcı.
type IkonProps = { className?: string };
const Gunes = ({ className }: IkonProps) => <TemaIkonu tur="light" className={className} />;
const Ay = ({ className }: IkonProps) => <TemaIkonu tur="dark" className={className} />;
const Sistem = ({ className }: IkonProps) => <TemaIkonu tur="system" className={className} />;
const SECENEKLER = [
{ deger: "light", etiket: "Açık", ikon: Sun },
{ deger: "dark", etiket: "Koyu", ikon: Moon },
{ deger: "system", etiket: "Sistem", ikon: Monitor },
{ deger: "light", etiket: "Açık", ikon: Gunes },
{ deger: "dark", etiket: "Koyu", ikon: Ay },
{ deger: "system", etiket: "Sistem", ikon: Sistem },
] as const;
type Tema = (typeof SECENEKLER)[number]["deger"];
@@ -18,9 +29,18 @@ const aboneOlma = () => () => {};
* Tema seçici: açık / koyu / sistem (DESIGN.md §2.3). Seçili tema yalnızca
* tarayıcıda bilinir; sunucu HTML'inde hiçbir seçenek işaretli gelmez,
* hydration sonrası işaretlenir (yerleşim oynamaz). Koyu vurgu seçenekten
* seçeneğe kayar (secim-haplari.tsx, kapsül varyantı).
* seçeneğe kayar (secim-haplari.tsx, kapsül varyantı). Seçim logodan yumuşak
* bir halka gibi yayılarak uygulanır, ardından logo yeni temanın biçimine
* dönüşür (lib/tema-gecisi.ts, §4.34); `ipucu` ile altında `D` kısayolu durur.
*/
export function TemaSecici({ className }: { className?: string }) {
export function TemaSecici({
className,
ipucu = true,
}: {
className?: string;
/** Altındaki klavye kısayolu satırı; dokunmatik menüde kapatılır. */
ipucu?: boolean;
}) {
const { theme, setTheme } = useTheme();
const tarayicida = useSyncExternalStore(
aboneOlma,
@@ -32,14 +52,21 @@ export function TemaSecici({ className }: { className?: string }) {
: null;
return (
<SecimHaplari<Tema>
ariaLabel="Tema"
secenekler={SECENEKLER}
deger={secili}
onChange={setTheme}
varyant="kapsul"
boyut="md"
className={className}
/>
<div className={cn("flex flex-col items-center gap-2", className)}>
<SecimHaplari<Tema>
ariaLabel="Tema"
secenekler={SECENEKLER}
deger={secili}
onChange={(deger) => temayiGecisle(() => setTheme(deger))}
varyant="kapsul"
boyut="md"
/>
{ipucu ? (
<p className="hidden items-center gap-1.5 text-xs text-slate-500 lg:flex">
<Kbd>D</Kbd>
<span>ile açık / koyu arasında geç</span>
</p>
) : null}
</div>
);
}

View File

@@ -9,7 +9,9 @@
// - Kartsız (program-ayrinti.tsx, §4.19 "kart yok"): `[data-kare]` yok,
// `[data-logo]` kabındaki üç `[data-logo-kare]` belirir, kısa durur, sonra
// sönerek kaybolur (scale 0.6 + opacity 0; Bilal, 7 Eki 2026) ve
// `[data-sonra]` bloklar netleşerek gelir.
// `[data-sonra]` bloklar netleşerek gelir. sahnenin kardeşi bir `[data-logo-hedef]` varsa kareler
// sönmek yerine küçülerek o yazıya (çekmece başlığı) uçar, yazı onlardan
// netleşir (Bilal, 10 Eki 2026).
// "Hareketi azalt"ta logo ve büyüme yok, yalnız 0,25 s solma (§6). Veri
// bekleyen çekmecede (`hazir` verilmiş) sahne yalnız veri ilk geldiğinde
// (false → true) oynar; önbellekli yeniden açılışta aynı 0,25 s solma
@@ -18,6 +20,7 @@
import { useLayoutEffect, useRef, type ReactNode } from "react";
import { animate, useReducedMotion } from "motion/react";
import { EGRI } from "@/lib/hareket";
// Logodaki üç kare (public/kolay-tercih-mark.svg, 180'lik viewBox): solda
// mavi, sağ üstte şeftali, sağ altta turuncu. animasyonlu-mark.tsx ile aynı.
@@ -37,8 +40,8 @@ const SLOT_ADIMI = 0.08;
const KARE_SURE = 1.15;
const KARE_ZAMANLARI = [0, 0.24, 0.52, 1];
const ICERIK_BASI = 0.95;
const GUCLU_CIKIS = [0.23, 1, 0.32, 1] as const;
const YERLESME = [0.32, 0.72, 0, 1] as const;
const GUCLU_CIKIS = EGRI.gucluCikis;
const YERLESME = EGRI.yerlesme;
// Kartsız mod (saniye): logo karesi belir → dur → sön; kareler 0,05 s arayla
// gelir, içerik blokları sönmeyle çaprazlanarak 0,05 s arayla netleşir.
@@ -55,6 +58,22 @@ const LOGO_ZAMANLARI = [
(LOGO_BELIRME + LOGO_DURUS) / LOGO_SURE,
1,
];
// Hedefli kartsız mod: kareler sönmek yerine LOGO_UCUS süresince hedef yazıya
// uçar; uçuşun son yarısında solar, yazı o sırada netleşir.
const LOGO_UCUS = 0.45;
const UCUS_SURE = LOGO_BELIRME + LOGO_DURUS + LOGO_UCUS;
const UCUS_ZAMANLARI = [
0,
LOGO_BELIRME / UCUS_SURE,
(LOGO_BELIRME + LOGO_DURUS) / UCUS_SURE,
1,
];
const UCUS_SOLMA_ZAMANLARI = [
0,
LOGO_BELIRME / UCUS_SURE,
(LOGO_BELIRME + LOGO_DURUS + LOGO_UCUS * 0.5) / UCUS_SURE,
1,
];
const KARTSIZ_SLOT_ADIMI = 0.05;
const KARTSIZ_ICERIK_BASI = 0.6;
const KARTSIZ_BLOK_ADIMI = 0.05;
@@ -63,14 +82,21 @@ const KARTSIZ_NETLESME = 0.35;
/** Kartsız modun logo kareleri, belirme sırasıyla (`data-logo-kare` değerleri). */
export const LOGO_SLOTLARI = SLOT_SIRASI;
/**
* Kartsız modun logo kabının kenarı (px) = `size-44`. Kartlı moddaki logoyla
* (akademik göstergeler, kare alanının yarısı, en çok 200 px) aynı ölçekte;
* 375 px'lik telefonda da sığar.
*/
export const LOGO_KAP_BOYU = 176;
/**
* Kartsız modun logo karesi stili: logodaki yer ve renk, kabın boyuna ölçekli
* (`data-logo` kabı `relative mx-auto h-16 w-16` → 64/180). Kullanım:
* `<span data-logo-kare={slot} className="absolute" style={logoKareStili(slot)} />`.
* (`data-logo` kabı `relative mx-auto size-44` → 176/180). Kullanım:
* `<span data-logo-kare={slot} className="absolute" style={logoKareStili(slot, LOGO_KAP_BOYU)} />`.
* Kareler yalnız sahne sırasında görünür; sahne bitince kap sıfır boya iner,
* "hareketi azalt"ta hiç gösterilmez.
*/
export function logoKareStili(slot: Slot, kapBoyu = 64) {
export function logoKareStili(slot: Slot, kapBoyu = LOGO_KAP_BOYU) {
const olcek = kapBoyu / 180;
return {
left: LOGO[slot].x * olcek,
@@ -100,6 +126,10 @@ export function logoKareStili(slot: Slot, kapBoyu = 64) {
* açıldı): sahne atlanır, içerik 0,25 s solmayla gelir — kareler o sırada
* çekmece daha ekrana kaymadan oynuyor, gövde boş kalıyordu. Verilmezse
* (içerik sunucudan hazır gelen çekmece) sahne her montajda oynar.
*
* `data-logo-hedef`: kartsız modda logo karelerinin küçülerek dönüşeceği yazı
* (sahnenin kardeşi, ör. çekmece başlığı). Kareler yazının üç eşit dilimine
* uçar; yazı sahne başında görünmez olur, kareler varırken netleşir.
*/
export function AcilisSahnesi({
children,
@@ -194,12 +224,52 @@ export function AcilisSahnesi({
// iner ki altında boşluk kalmasın; içerik KARTSIZ_ICERIK_BASI'ndan
// itibaren, sönmeyle çaprazlanarak gelir.
let sonKare = 0;
// Hedef yazının kutusu (blok değil, yazının kendisi: Range).
const hedef =
kap.parentElement?.querySelector<HTMLElement>("[data-logo-hedef]") ?? null;
let hedefKutu: DOMRect | null = null;
if (hedef) {
const aralik = document.createRange();
aralik.selectNodeContents(hedef);
hedefKutu = aralik.getBoundingClientRect();
}
logoKareleri.forEach((el) => {
const slot = (el.dataset.logoKare ?? "sol") as Slot;
const gecikme = BASLANGIC + SLOT_SIRASI.indexOf(slot) * KARTSIZ_SLOT_ADIMI;
const sira = SLOT_SIRASI.indexOf(slot);
const gecikme = BASLANGIC + sira * KARTSIZ_SLOT_ADIMI;
sonKare = Math.max(sonKare, gecikme);
el.style.opacity = "0";
dokunulan.push(el);
if (hedefKutu) {
// Kare ile hedef aynı çekmecede: çekmece kayarken fark değişmez.
const k = el.getBoundingClientRect();
const dx =
hedefKutu.left + (hedefKutu.width * (sira + 0.5)) / 3 - (k.left + k.width / 2);
const dy = hedefKutu.top + hedefKutu.height / 2 - (k.top + k.height / 2);
kontroller.push(
animate(
el,
{ x: [0, 0, 0, dx], y: [0, 0, 0, dy], scale: [0.4, 1, 1, 0.2] },
{
duration: UCUS_SURE,
delay: gecikme,
times: UCUS_ZAMANLARI,
ease: [GUCLU_CIKIS, "linear", YERLESME],
},
),
animate(
el,
{ opacity: [0, 1, 1, 0] },
{
duration: UCUS_SURE,
delay: gecikme,
times: UCUS_SOLMA_ZAMANLARI,
ease: [GUCLU_CIKIS, "linear", "easeIn"],
},
),
);
return;
}
kontroller.push(
animate(
el,
@@ -218,10 +288,13 @@ export function AcilisSahnesi({
});
const sonmeBasi = sonKare + LOGO_SURE * LOGO_ZAMANLARI[2];
dokunulan.push(logo!);
if (hedef && hedefKutu) {
netlestir(hedef, sonmeBasi + LOGO_UCUS * 0.4, 0.4);
}
const kapat = animate(
logo!,
{ height: [logo!.offsetHeight, 0] },
{ duration: LOGO_SONME, delay: sonmeBasi, ease: YERLESME },
{ duration: hedefKutu ? LOGO_UCUS : LOGO_SONME, delay: sonmeBasi, ease: YERLESME },
);
kapat.then(() => {
logo!.style.display = "none";

View File

@@ -0,0 +1,177 @@
"use client";
// Çok adımlı akışların tek göstergesi (DESIGN.md §4.35, §10.2): numaralı
// daireler ince çizgiyle bağlı; aktif adım mavi dolu, tamamlanan adımda tik
// kendini çizer, aradaki çizgi soldan dolar. Geri gidince aynı yoldan boşalır.
//
// Esin: React Bits "Stepper" (reactbits.dev, MIT + Commons Clause). Kod
// KolayTercih diline çevrildi: sabit renkler token'a, zamanlama
// `lib/hareket.ts`'e bağlandı; kontrollü hâle getirildi (içerik geçişi
// `TransitionPanel` + `lib/adim-gecisi.ts`'te kalır), `aria-current`,
// durum duyurusu ve hareketi azalt dalı eklendi. Turuncu yok: gösterge
// bilgidir, aksiyon değil (§2).
import { Fragment } from "react";
import { motion, useReducedMotion } from "motion/react";
import { SURE, YAY } from "@/lib/hareket";
import { cn } from "@/lib/utils";
type Durum = "bekliyor" | "aktif" | "tamam";
export function AdimGostergesi({
adimlar,
aktif,
onAdimSec,
ariaLabel = "Adımlar",
className,
}: {
/** Adım adları, sırayla. Sayı gerçek adım sayısıdır (§10.2). */
adimlar: readonly string[];
/** 0 tabanlı aktif adım. */
aktif: number;
/** Verilirse yalnız TAMAMLANMIŞ adımlar tıklanabilir olur (ileri atlama yok). */
onAdimSec?: (adim: number) => void;
ariaLabel?: string;
className?: string;
}) {
const azalt = useReducedMotion();
const anlik = { duration: 0 };
return (
<div className={className}>
<ol
aria-label={ariaLabel}
className="grid items-start"
// Daire sütunları eşit, aralarındaki çizgiler kalan yeri paylaşır;
// böylece ilk ve son daire kenara, adları dairenin altına oturur.
style={{
gridTemplateColumns: adimlar
.map((_, i) => (i === 0 ? "auto" : "1fr auto"))
.join(" "),
}}
>
{adimlar.map((ad, i) => {
const durum: Durum = i < aktif ? "tamam" : i === aktif ? "aktif" : "bekliyor";
const tiklanabilir = !!onAdimSec && durum === "tamam";
const daire = (
<span
aria-hidden
className={cn(
"relative flex size-9 items-center justify-center rounded-full border font-heading text-sm font-bold transition-colors motion-reduce:transition-none",
durum === "bekliyor"
? "border-slate-200 bg-card text-slate-500"
: "border-primary bg-primary text-white",
)}
style={{ transitionDuration: `${SURE.standart * 1000}ms` }}
>
{/* Numara ile tik aynı yuvada; biri çıkarken öbürü çizilir. */}
<motion.span
initial={false}
animate={{ opacity: durum === "tamam" ? 0 : 1, scale: durum === "tamam" ? 0.6 : 1 }}
transition={azalt ? anlik : { duration: SURE.hizli }}
>
{i + 1}
</motion.span>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={3}
strokeLinecap="round"
strokeLinejoin="round"
className="absolute size-4"
>
<motion.path
d="M4 12.5 9.5 18 20 6.5"
initial={false}
animate={{
pathLength: durum === "tamam" ? 1 : 0,
opacity: durum === "tamam" ? 1 : 0,
}}
transition={azalt ? anlik : YAY.donusum}
/>
</svg>
</span>
);
const etiket = (
<span
className={cn(
"mt-1.5 block text-center text-xs sm:text-sm",
durum === "aktif"
? "font-semibold text-slate-900"
: durum === "tamam"
? "font-medium text-slate-600"
: "text-slate-400",
)}
>
{ad}
</span>
);
return (
<Fragment key={ad}>
{i > 0 ? (
// Çizgi dairenin ortasında (size-9 → 18 px); dolum transform ile.
<li aria-hidden className="mx-2 mt-[1.0625rem] h-0.5 overflow-hidden rounded-full bg-slate-200">
<motion.span
className="block h-full origin-left rounded-full bg-primary"
initial={false}
animate={{ scaleX: i <= aktif ? 1 : 0 }}
transition={azalt ? anlik : YAY.akici}
/>
</li>
) : null}
<li
aria-current={durum === "aktif" ? "step" : undefined}
className="flex min-w-0 flex-col items-center"
>
{tiklanabilir ? (
<button
type="button"
onClick={() => onAdimSec(i)}
aria-label={`${i + 1}. adıma dön: ${ad}`}
className="group flex min-h-11 cursor-pointer flex-col items-center rounded-2xl outline-none transition-transform focus-visible:ring-3 focus-visible:ring-ring/50 active:scale-[0.97] motion-reduce:transition-none"
>
{daire}
{etiket}
</button>
) : (
<>
{daire}
{etiket}
{durum === "tamam" ? <span className="sr-only">(tamamlandı)</span> : null}
</>
)}
</li>
</Fragment>
);
})}
</ol>
<p role="status" className="sr-only">
Adım {Math.min(aktif + 1, adimlar.length)} / {adimlar.length}: {adimlar[Math.min(aktif, adimlar.length - 1)]}
</p>
</div>
);
}
/** Lazy yüklenen akışın iskeleti: göstergenin kutusunu birebir tutar (yerleşim sıçramaz). */
export function AdimGostergesiIskeleti({ adet = 3 }: { adet?: number }) {
return (
<div
aria-hidden
className="grid items-start"
style={{
gridTemplateColumns: Array.from({ length: adet }, (_, i) => (i === 0 ? "auto" : "1fr auto")).join(" "),
}}
>
{Array.from({ length: adet }, (_, i) => (
<Fragment key={i}>
{i > 0 ? <span className="mx-2 mt-[1.0625rem] h-0.5 rounded-full bg-slate-200" /> : null}
<span className="flex flex-col items-center">
<span className="size-9 rounded-full bg-slate-100 motion-safe:animate-pulse" />
<span className="mt-1.5 h-4 w-12 rounded-full bg-slate-100 motion-safe:animate-pulse sm:h-5" />
</span>
</Fragment>
))}
</div>
);
}

View File

@@ -19,6 +19,11 @@ const buttonVariants = cva(
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20",
link: "text-primary underline-offset-4 hover:underline",
// DESIGN.md §4.1: funnel CTA'sının tek kaynağı. Basma tepkisi ölçekle
// (tabandaki 1 px kayma bu iki varyantta kapalı; ikisi birlikte titrer).
cta: "cursor-pointer bg-orange-500 text-white transition-[background-color,transform] duration-200 hover:bg-orange-600 active:not-aria-[haspopup]:translate-y-0 active:scale-[0.97] motion-reduce:active:scale-100",
ikincil:
"cursor-pointer border-border bg-background transition-[background-color,border-color,color,transform] duration-150 hover:border-orange-500 hover:bg-orange-50/60 hover:text-orange-500 active:not-aria-[haspopup]:translate-y-0 active:scale-[0.97] motion-reduce:active:scale-100",
},
size: {
default:

View File

@@ -61,7 +61,11 @@ function DialogContent({
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
// Modal yüzeyi = DESIGN.md §4.4 sade kutu (rounded-2xl, slate-200
// kenarlık, bg-card); shadcn'in ring'li rounded-xl/popover hâli
// sitede yoktu (10 Eki 2026, Bilal: "yeni dizayn anlayışımıza uygun
// değil"). Yazı tabanı gövde (16 px, §3); açıklama text-sm'i kendi taşır.
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-5 rounded-2xl border border-slate-200 bg-card p-6 text-slate-900 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 motion-reduce:data-open:zoom-in-100 motion-reduce:data-closed:zoom-out-100",
className
)}
{...props}
@@ -102,8 +106,10 @@ function DialogFooter({
return (
<div
data-slot="dialog-footer"
// Gri bant yok: aksiyonlar yüzeyin içinde, telefonda alt alta tam
// genişlik (ana aksiyon üstte), sm'den itibaren sağa yaslı yan yana.
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
@@ -111,7 +117,9 @@ function DialogFooter({
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
<Button variant="ikincil" size="lg">
Kapat
</Button>
</DialogPrimitive.Close>
)}
</div>
@@ -125,8 +133,9 @@ function DialogTitle({
return (
<DialogPrimitive.Title
data-slot="dialog-title"
// Panel/kart h3 ölçeği (DESIGN.md §3): başlık her zaman bold Cal Sans
className={cn(
"font-heading text-base leading-none font-medium",
"font-heading text-xl leading-tight font-bold text-slate-900",
className
)}
{...props}
@@ -142,7 +151,7 @@ function DialogDescription({
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
"text-sm leading-relaxed text-slate-600 *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-orange-500",
className
)}
{...props}

View File

@@ -14,9 +14,10 @@
import { useId, type ComponentType } from "react";
import { motion, useReducedMotion } from "motion/react";
import { YAY as HAREKET_YAYI } from "@/lib/hareket";
import { cn } from "@/lib/utils";
const YAY = { type: "spring", bounce: 0, duration: 0.35 } as const;
const YAY = HAREKET_YAYI.varsayilan;
export interface SecimHapiSecenek<T extends string> {
deger: T;

View File

@@ -13,6 +13,10 @@
* modül seviyesinde TEK pointermove dinleyicisi + rAF birleştirmesi kullanılır;
* kare başına en fazla bir tarama yapılır, imleçten uzak örnekler hiç
* piksel iterasyonuna girmez.
*
* `canli`: pikselleri yer ve rol değiştiren sahneler (PikselMorf) içindir.
* Konum ve taban opaklık mount'ta kopyalanmaz, her taramada DOM'dan okunur;
* `data-px`'i kalkan piksel (turuncu vurgu oldu ya da gizlendi) fırçadan çıkar.
*/
import { useEffect, type RefObject } from "react";
@@ -46,6 +50,8 @@ type HeatInstance = {
/** preserveAspectRatio "slice" mi (kabı doldurur) */
slice: boolean;
baseColor: string;
/** Pikseller yer/rol değiştirir: konum ve opaklık her taramada okunur */
canli: boolean;
pixels: HeatTarget[];
hot: Set<SVGRectElement>;
controls: Map<SVGRectElement, AnimationPlaybackControls>;
@@ -99,8 +105,17 @@ function tara() {
const x = vb.x + (e.clientX - rect.left - ox) / scale;
const y = vb.y + (e.clientY - rect.top - oy) / scale;
for (const p of inst.pixels) {
const d2 = (p.cx - x) ** 2 + (p.cy - y) ** 2;
const isHot = d2 <= inst.radius * inst.radius;
let { cx, cy, baseOpacity } = p;
let katilir = true;
if (inst.canli) {
const veri = p.el.dataset;
katilir = veri.px !== undefined;
cx = Number(veri.cx);
cy = Number(veri.cy);
baseOpacity = Number(veri.op);
}
const d2 = (cx - x) ** 2 + (cy - y) ** 2;
const isHot = katilir && d2 <= inst.radius * inst.radius;
if (isHot && !inst.hot.has(p.el)) {
inst.hot.add(p.el);
calistir(
@@ -114,7 +129,7 @@ function tara() {
calistir(
inst,
p.el,
{ fill: inst.baseColor, fillOpacity: p.baseOpacity },
{ fill: inst.baseColor, fillOpacity: baseOpacity },
COOL_DURATION,
);
}
@@ -122,6 +137,14 @@ function tara() {
}
}
/** Pikseller yer değiştirdikten sonra (PikselMorf kare değişimi) fırçayı son
* imleç konumuyla yeniden çalıştırır: imleç yerinde dursa da ısı güncel kalır. */
export function pikselIsiTazele() {
if (sonOlay && instances.size > 0 && !rafId) {
rafId = requestAnimationFrame(tara);
}
}
function onMove(e: PointerEvent) {
sonOlay = e;
if (!rafId) rafId = requestAnimationFrame(tara);
@@ -201,7 +224,9 @@ export function usePixelHeat(
radius = 34,
/** İmleç olmadan da rastgele pikseller seyrekçe ısınıp sönsün (hero) */
kirpisma = false,
}: { radius?: number; kirpisma?: boolean } = {},
/** Pikseller yer/rol değiştiriyor (PikselMorf): konum her taramada okunur */
canli = false,
}: { radius?: number; kirpisma?: boolean; canli?: boolean } = {},
) {
useEffect(() => {
const svg = svgRef.current;
@@ -216,8 +241,9 @@ export function usePixelHeat(
if (!svg.viewBox.baseVal.width) return;
// Canlı sahnede `data-px` kareden kareye açılıp kapanır; hepsi toplanır.
const pixels: HeatTarget[] = Array.from(
svg.querySelectorAll<SVGRectElement>("[data-px]"),
svg.querySelectorAll<SVGRectElement>(canli ? "[data-cx]" : "[data-px]"),
).map((el) => ({
el,
cx: Number(el.dataset.cx),
@@ -231,6 +257,7 @@ export function usePixelHeat(
radius,
slice: (svg.getAttribute("preserveAspectRatio") ?? "").includes("slice"),
baseColor: getComputedStyle(svg).color,
canli,
pixels,
hot: new Set(),
controls: new Map(),
@@ -241,5 +268,5 @@ export function usePixelHeat(
kirpismaDur?.();
ayril(inst);
};
}, [svgRef, radius, kirpisma]);
}, [svgRef, radius, kirpisma, canli]);
}

View File

@@ -43,6 +43,8 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Kbd, KbdGroup } from "@/components/ui/kbd";
import { SayiRulo } from "@/components/ui/sayi-rulo";
import { ARAMA_AC_OLAYI } from "@/components/mobil-menu";
import { ProgressiveBlur } from "@/components/ui/skiper-ui/skiper41";
type BolumSonucu = {
@@ -172,6 +174,13 @@ export function KatalogArama({ rehberler }: { rehberler: RehberAramaKaydi[] }) {
// modal her açılıp kapandığında remove/add döngüsü dönmesin
// (client-event-listeners)
const kisayolTetiklendi = useEffectEvent(() => modalDegisti(!acik));
// Telefon menüsündeki "Ara" hapı (mobil-menu.tsx) pencereyi olayla açtırır.
const aramaIstendi = useEffectEvent(() => modalDegisti(true));
useEffect(() => {
window.addEventListener(ARAMA_AC_OLAYI, aramaIstendi);
return () => window.removeEventListener(ARAMA_AC_OLAYI, aramaIstendi);
}, []);
useEffect(() => {
function kisayolDinle(event: KeyboardEvent) {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
@@ -279,9 +288,7 @@ export function KatalogArama({ rehberler }: { rehberler: RehberAramaKaydi[] }) {
>
<Trophy className="size-4 shrink-0 text-orange-500" aria-hidden />
<span className="font-semibold tracking-tight text-slate-900">
<span className="tabular-nums">
{profil.sira.toLocaleString("tr-TR")}
</span>
<SayiRulo deger={profil.sira} className="tabular-nums" />
<span className="font-medium text-slate-500">
{" "}
· {PUAN_TURU_ETIKET[profil.tur] ?? profil.tur}
@@ -331,58 +338,42 @@ export function KatalogArama({ rehberler }: { rehberler: RehberAramaKaydi[] }) {
</button>
</nav>
{/* lg altında 2. satır: sıralama + ara (header flex-wrap + order-3) */}
<div className="order-3 flex w-full basis-full items-center gap-1.5 lg:hidden">
{profil ? (
<button
type="button"
onClick={profilDuzenlemeyiAc}
onPointerEnter={preloadTercihProfiliKapisi}
onFocus={preloadTercihProfiliKapisi}
aria-label={`Sıralamanı ve tercihlerini değiştir (${profil.sira.toLocaleString("tr-TR")} · ${PUAN_TURU_ETIKET[profil.tur] ?? profil.tur})`}
className="flex h-9 min-w-0 flex-1 cursor-pointer items-center gap-1.5 rounded-full border border-slate-200 bg-card py-1 pl-2.5 pr-1.5 text-xs font-semibold text-slate-900 transition-[background-color,transform] duration-150 hover:bg-slate-100 active:scale-[0.96] sm:h-12 sm:gap-2 sm:pl-3.5 sm:pr-2 sm:text-sm"
>
<Trophy
className="size-3.5 shrink-0 text-orange-500 sm:size-4"
aria-hidden
/>
<span className="truncate tracking-tight">
<span className="tabular-nums">
{profil.sira.toLocaleString("tr-TR")}
</span>
<span className="font-medium text-slate-500">
{" "}
· {PUAN_TURU_ETIKET[profil.tur] ?? profil.tur}
</span>
</span>
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-500">
<SquarePen className="size-3" aria-hidden />
Değiştir
</span>
</button>
) : (
<button
type="button"
onClick={siralamaGirVeyaSonucaGit}
onPointerEnter={preloadTercihProfiliKapisi}
onFocus={preloadTercihProfiliKapisi}
aria-label="Başarı sıralamanı gir"
className="flex h-9 min-w-0 flex-1 cursor-pointer items-center gap-1.5 rounded-full border border-slate-200 bg-card py-1 pl-2.5 pr-2.5 text-xs font-semibold text-slate-900 transition-[background-color,transform] duration-150 hover:bg-slate-100 active:scale-[0.96] sm:h-12 sm:gap-2 sm:pl-3.5 sm:pr-3 sm:text-sm"
>
<Trophy
className="size-3.5 shrink-0 text-orange-500 sm:size-4"
aria-hidden
/>
<span className="truncate">Sıralama gir</span>
</button>
)}
{/* lg altı: tek satırın ortasındaki sıralama hapı (DESIGN.md §4.36).
Arama ve geri kalan her şey sağdaki baloncuk menüde. Sayfa başlığı
çubuğa çıkınca (NavBaslik) hap yalnız kupaya iner, başlığa yer açar. */}
<div className="order-2 flex shrink-0 items-center lg:hidden">
<button
type="button"
onClick={() => modalDegisti(true)}
aria-label="Bölüm, üniversite veya rehber ara"
className="flex size-9 shrink-0 cursor-pointer items-center justify-center rounded-full border border-slate-200 bg-card text-slate-700 transition-[background-color,transform] duration-150 hover:bg-slate-100 active:scale-[0.96] sm:size-12"
onClick={profil ? profilDuzenlemeyiAc : siralamaGirVeyaSonucaGit}
onPointerEnter={preloadTercihProfiliKapisi}
onFocus={preloadTercihProfiliKapisi}
aria-label={
profil
? `Sıralamanı ve tercihlerini değiştir (${profil.sira.toLocaleString("tr-TR")} · ${PUAN_TURU_ETIKET[profil.tur] ?? profil.tur})`
: "Başarı sıralamanı gir"
}
className="flex h-9 max-w-40 cursor-pointer items-center gap-1.5 rounded-full border border-slate-200 bg-card px-2.5 text-xs font-semibold text-slate-900 transition-[background-color,transform] duration-150 hover:bg-slate-100 active:scale-[0.96] motion-reduce:transition-none sm:h-12 sm:max-w-none sm:gap-2 sm:px-4 sm:text-sm"
>
<Search className="size-4" aria-hidden />
<Trophy className="size-3.5 shrink-0 text-orange-500 sm:size-4" aria-hidden />
<span className="truncate tracking-tight in-data-[nav-baslik]:hidden">
{profil ? (
<>
<SayiRulo deger={profil.sira} className="tabular-nums" />
<span className="hidden font-medium text-slate-500 sm:inline">
{" "}
· {PUAN_TURU_ETIKET[profil.tur] ?? profil.tur}
</span>
</>
) : (
"Sıralama gir"
)}
</span>
{profil ? (
<SquarePen
className="size-3 shrink-0 text-slate-400 in-data-[nav-baslik]:hidden"
aria-hidden
/>
) : null}
</button>
</div>

View File

@@ -55,6 +55,7 @@ import {
AcilisSahnesi,
LOGO_SLOTLARI,
logoKareStili,
LOGO_KAP_BOYU,
} from "@/components/ui/acilis-sahnesi";
import {
Table,
@@ -183,7 +184,15 @@ export function ProgramAyrintiButonu({
<span className="sr-only">Kapat</span>
</AltSayfaKapat>
<AltSayfaGovde className="px-4 pt-3 pb-[max(1.25rem,env(safe-area-inset-bottom))] sm:px-6">
<AltSayfaBaslik className="px-10 text-center font-heading text-2xl font-bold text-slate-900">
{/* Başlık veri gelene dek görünmez: logo kareleri küçülüp buraya
uçar, yazı onlardan netleşir (AcilisSahnesi `data-logo-hedef`). */}
<AltSayfaBaslik
data-logo-hedef
className={cn(
"px-10 text-center font-heading text-2xl font-bold text-slate-900",
(durum === "bekliyor" || durum === "yukleniyor") && "opacity-0",
)}
>
{isim}
</AltSayfaBaslik>
<AltSayfaAciklama className="mx-auto mt-1 max-w-md px-4 text-center text-sm text-slate-500">
@@ -226,14 +235,20 @@ function AyrintiIcerik({ durum, veri }: { durum: Durum; veri: ProgramAyrintisi |
</p>
);
}
// Sıra = AcilisSahnesi'nin `data-sonra` sırası: logo söner, bloklar
// yukarıdan aşağıya 0,05 s arayla netleşir.
// Sıra = AcilisSahnesi'nin `data-sonra` sırası: logo küçülerek başlığa
// dönüşür, bloklar yukarıdan aşağıya 0,05 s arayla netleşir.
return (
<>
{/* Logo kareleri: sahne sırasında görünür, sönünce kap sıfır boya iner. */}
<div data-logo className="relative mx-auto h-16 w-16" aria-hidden>
{/* Logo kareleri: sahne sırasında görünür, küçülerek başlığa uçar; kap
sıfır boya iner. */}
<div data-logo className="relative mx-auto size-44" aria-hidden>
{LOGO_SLOTLARI.map((slot) => (
<span key={slot} data-logo-kare={slot} className="absolute" style={logoKareStili(slot)} />
<span
key={slot}
data-logo-kare={slot}
className="absolute"
style={logoKareStili(slot, LOGO_KAP_BOYU)}
/>
))}
</div>
<Kunye veri={veri} />

View File

@@ -33,7 +33,7 @@ import { Button } from "@/components/ui/button";
*/
const AUTH_GUVENLI_YOL = /^\/(?!\/|\\)[\w\-./+@]*$/;
export function GirisLinki() {
export function GirisLinki({ menu = false }: { /** Telefon menüsündeki iri hap (DESIGN.md §4.36). */ menu?: boolean }) {
const pathname = usePathname();
// /giris kendine callback veremez (döngü); pathname yoksa sade hedef.
const href =
@@ -44,7 +44,11 @@ export function GirisLinki() {
return (
<Button
asChild
className="h-9 cursor-pointer rounded-full bg-slate-900 px-3.5 text-sm font-medium text-slate-50 transition-colors duration-200 hover:bg-slate-700 sm:h-12 sm:px-7 sm:text-base"
className={
menu
? "h-14 w-full max-w-xs cursor-pointer rounded-full bg-slate-900 px-8 font-heading text-xl font-bold text-slate-50 transition-[background-color,transform] duration-200 hover:bg-slate-700 active:scale-[0.97]"
: "h-9 cursor-pointer rounded-full bg-slate-900 px-3.5 text-sm font-medium text-slate-50 transition-colors duration-200 hover:bg-slate-700 sm:h-12 sm:px-7 sm:text-base"
}
>
<Link href={href}>Giriş yap</Link>
</Button>

View File

@@ -5,8 +5,23 @@ import { LogOut } from "lucide-react";
import { authClient } from "@/lib/auth-client";
import { Button } from "@/components/ui/button";
export function SignOutButton() {
export function SignOutButton({ menu = false }: { /** Telefon menüsündeki yazılı hap (DESIGN.md §4.36). */ menu?: boolean }) {
const router = useRouter();
if (menu) {
return (
<Button
variant="ghost"
className="h-12 cursor-pointer rounded-full border border-slate-200 bg-card px-6 text-base font-semibold text-slate-700 transition-[background-color,transform] duration-200 hover:bg-slate-50 active:scale-[0.97]"
onClick={async () => {
await authClient.signOut();
router.refresh();
}}
>
<LogOut className="size-4" aria-hidden />
Çıkış yap
</Button>
);
}
return (
<Button
size="sm"

View File

@@ -7,6 +7,7 @@ import { ListeSenkron } from "@/features/liste/components/liste-senkron";
import { RybbitIdentify } from "@/components/rybbit-identify";
import { GirisLinki } from "./giris-linki";
import { SignOutButton } from "./sign-out-button";
import { MobilMenu } from "@/components/mobil-menu";
/**
* Suspense fallback'i: girişsiz görünümle aynı yükseklik/genişlikte sessiz
@@ -15,10 +16,11 @@ import { SignOutButton } from "./sign-out-button";
export function UserNavFallback() {
return (
<div
className="order-2 flex shrink-0 items-center gap-1.5 sm:gap-2"
className="order-3 flex shrink-0 items-center gap-1.5 sm:gap-2"
aria-hidden
>
<div className="h-9 w-16 rounded-full bg-slate-900/10 sm:h-12 sm:w-28" />
{/* lg altı: menü baloncuğunun yeri; lg+: "Giriş yap" hapının yeri */}
<div className="size-9 rounded-full bg-slate-900/10 sm:size-12 lg:h-12 lg:w-28" />
</div>
);
}
@@ -29,19 +31,29 @@ export async function UserNav() {
// Manuel liste localStorage'ta yaşadığı için Listem girişsiz de çalışır
if (!u) {
return (
<div className="order-2 flex shrink-0 items-center gap-1.5 sm:gap-2">
<div className="order-3 flex shrink-0 items-center gap-1.5 sm:gap-2">
<RybbitIdentify kullaniciId={null} />
<ListeSenkron userId={null} />
<ListemButonu />
<GirisLinki />
{/* lg altında her şey baloncuk menüde (DESIGN.md §4.36) */}
<div className="hidden items-center gap-2 lg:flex">
<ListemButonu />
<GirisLinki />
</div>
<div className="lg:hidden">
<MobilMenu kredi={null} girisSlotu={<GirisLinki menu />} />
</div>
</div>
);
}
return (
<div className="order-2 flex shrink-0 items-center gap-1.5 sm:gap-2">
<div className="order-3 flex shrink-0 items-center gap-1.5 sm:gap-2">
<RybbitIdentify kullaniciId={u.id} />
<ListeSenkron userId={u.id} />
<div className="lg:hidden">
<MobilMenu kredi={u.creditBalance} girisSlotu={<SignOutButton menu />} />
</div>
<div className="hidden items-center gap-2 lg:flex">
<ListemButonu />
{/* Yapay Zeka raporunun kalıcı kapısı: girişli kullanıcı /listem'i
navbar'dan bulamıyordu (çekmece butonu manuel liste boşken hiç
@@ -79,6 +91,7 @@ export async function UserNav() {
<span className="hidden md:inline lg:hidden">kredi</span>
</Link>
<SignOutButton />
</div>
</div>
);
}

View File

@@ -21,8 +21,8 @@ export function preloadListeCekmecesi() {
export function ListeCekmecesiLazy() {
const acik = useCekmeceAcik();
const liste = useManuelListe();
// "+" uçuş animasyon katmanı da çekmece modülünde yaşadığı için liste
// doluysa (çekmece kapalı olsa da) mount kalır; yalnızca boş+kapalı durumda
// atlanır — ilk ziyaretin en yaygın hâli.
// Liste doluysa çekmece kapalıyken de mount kalır (açılış gecikmesin);
// yalnız boş+kapalı durumda atlanır — ilk ziyaretin en yaygın hâli.
// ("+" uçuşu ayrı katmanda: ucus-katmani.tsx.)
return acik || liste.length > 0 ? <ListeCekmecesiIc /> : null;
}

View File

@@ -16,10 +16,17 @@ import {
* Liste localStorage'da yaşadığı için hidrasyondan önce yer tutucu çizilir,
* satır zıplamasın.
*/
export function ListeDurumuSatiri() {
export function ListeDurumuSatiri({
varyant = "sayfa",
}: {
/** "hero": ana sayfada yalnız liste doluyken görünür, her genişlikte ortalı. */
varyant?: "sayfa" | "hero";
}) {
const hidre = useListeHidreMi();
const liste = useManuelListe();
const hero = varyant === "hero";
if (hero && (!hidre || liste.length === 0)) return null;
if (!hidre) return <div className="mt-5 h-11" aria-hidden />;
if (liste.length === 0) {
@@ -36,7 +43,7 @@ export function ListeDurumuSatiri() {
const kalan = MANUEL_LISTE_MAX - liste.length;
return (
<div className="mt-5 flex justify-center lg:justify-start">
<div className={hero ? "mt-4 flex justify-center" : "mt-5 flex justify-center lg:justify-start"}>
<button
type="button"
onClick={cekmeceAc}

View File

@@ -4,7 +4,10 @@
// taşır, tıklanınca çekmeceyi açar/kapatır. ("Listem" adı Yapay Zeka raporunun
// yaşadığı /listem sayfasına aittir — isim çakışmasın.)
import { motion, useReducedMotion } from "motion/react";
import { ListChecks } from "lucide-react";
import { YAY } from "@/lib/hareket";
import { cn } from "@/lib/utils";
import { SayiRulo } from "@/components/ui/sayi-rulo";
import {
MANUEL_LISTE_MAX,
@@ -18,9 +21,10 @@ export function ListemButonu() {
const liste = useManuelListe();
const acik = useCekmeceAcik();
// Liste boşken buton gürültü: tabloya (+) ulaşmamış ziyaretçiye "0/24"
// bir şey ifade etmez. İlk program eklenince belirir, liste boşalınca gider.
if (liste.length === 0) return null;
const azalt = useReducedMotion();
// Boşken de görünür (DESIGN.md §4.37): halka boş, sayı soluk — hedef (24)
// ilk bakışta okunur ve ilk eklemede uçan karenin konacağı yer hazırdır.
const dolu = liste.length > 0;
return (
<button
@@ -31,6 +35,7 @@ export function ListemButonu() {
aria-expanded={acik}
aria-label={`Kendi listem — ${liste.length}/${MANUEL_LISTE_MAX} program`}
title="Tablodan seçtiğin programlar"
data-ucus-hedefi="masaustu"
className="relative inline-flex h-9 cursor-pointer items-center gap-1.5 whitespace-nowrap rounded-full border border-slate-200 bg-card px-3 text-xs font-semibold text-slate-700 transition-[color,background-color,border-color,transform] duration-200 hover:border-slate-300 hover:bg-slate-50 active:scale-[0.97] sm:h-12 sm:gap-2 sm:px-5 sm:text-sm"
>
<ListChecks className="size-3.5 text-primary sm:size-4" aria-hidden />
@@ -38,13 +43,35 @@ export function ListemButonu() {
hiçbir kırılma noktasında etikete yer yok — ikon + rozet + yukarıdaki
aria-label anlamı taşıyor. UserNav'daki pill'le aynı desen. */}
<span className="hidden">Kendi Listem</span>
{liste.length > 0 ? (
// Rozet yerinde durur, rakam rulo gibi döner (eklemede yukarı,
// çıkarmada aşağı) — Arc "Number field" rulosu, bkz. sayi-rulo.tsx.
<span className="flex h-6 min-w-6 items-center justify-center rounded-full bg-orange-500 px-1.5 font-heading text-xs font-bold text-white">
{/* Rozet yerinde durur, rakam rulo gibi döner (sayi-rulo.tsx). Çevresinde
24'lük doluluk halkası: yay ile dolar; sayı her zaman yazılı, halka
tek başına anlam taşımaz. */}
<span data-ucus-rozet className="relative flex size-8 items-center justify-center">
<svg viewBox="0 0 32 32" aria-hidden className="absolute inset-0 size-full -rotate-90">
<circle cx="16" cy="16" r="14.5" fill="none" strokeWidth="2.5" className="stroke-slate-200" />
<motion.circle
cx="16"
cy="16"
r="14.5"
fill="none"
strokeWidth="2.5"
strokeLinecap="round"
className="stroke-primary"
pathLength={1}
initial={false}
animate={{ pathLength: liste.length / MANUEL_LISTE_MAX, opacity: dolu ? 1 : 0 }}
transition={azalt ? { duration: 0 } : YAY.akici}
/>
</svg>
<span
className={cn(
"flex size-[22px] items-center justify-center rounded-full font-heading text-xs font-bold transition-colors duration-200",
dolu ? "bg-orange-500 text-white" : "bg-slate-100 text-slate-500",
)}
>
<SayiRulo deger={liste.length} />
</span>
) : null}
</span>
</button>
);
}

View File

@@ -1,10 +1,14 @@
"use client";
import { SATIR_DUGME_BOYU } from "@/components/liste-satiri";
import { ArrowRight, Check, Plus } from "lucide-react";
import { useState } from "react";
import { motion, useReducedMotion } from "motion/react";
import { ArrowRight, Plus } from "lucide-react";
import { toast } from "@/components/ui/bildirim";
import type { Program } from "@/types/yokatlas";
import { olay } from "@/lib/analitik";
import { YAY } from "@/lib/hareket";
import { logoTepkisi } from "@/lib/logo-tepkisi";
import {
MANUEL_LISTE_MAX,
cekmeceAc,
@@ -18,6 +22,7 @@ import {
programEkleIste,
} from "../hooks/use-tercih-profili";
import { preloadTercihProfiliKapisi } from "./tercih-profili-kapisi-lazy";
import { ucusBaslat } from "./ucus-katmani";
type ProgramOzet = Pick<
Program,
@@ -51,10 +56,15 @@ export function ProgramEkleButonu({
// taramanın listedeMi ile ikinci kez yapılması vardı).
const listede = useListedeMi(program.id);
const doldu = useListeDoluMu();
const azalt = useReducedMotion();
// Tik yalnız bu düğmeyle eklenince kendini çizer; sayfa açılışında zaten
// listede olan satırlar çizim oynatmaz.
const [yeniEklendi, setYeniEklendi] = useState(false);
function handleClick(e: React.MouseEvent) {
e.preventDefault();
e.stopPropagation();
const dugme = e.currentTarget;
const sonuc = programEkleIste({ program, kaynak, adaySira, adayTur });
if (sonuc.doldu) {
// Ne olduğunu ve ne yapılacağını söyle (DESIGN.md §10)
@@ -75,6 +85,12 @@ export function ProgramEkleButonu({
// olur, navbar sayacı artar). Profilsiz eklemede aşağıdaki toast
// zaten açıklama taşıyor.
const adet = listeOku().length;
// Eylemin kendi cevabı (§4.37): kare rozete uçar, logo döner, parmak
// hafifçe titrer. Bildirim değil; her eklemede olur.
setYeniEklendi(true);
ucusBaslat(dugme);
logoTepkisi(adet === MANUEL_LISTE_MAX ? "tamam" : "ekle");
navigator.vibrate?.(8);
if (!sonuc.profilsiz && adet === 1) {
toast.success(`İlk programın listende · 1/${MANUEL_LISTE_MAX}`, {
description: "Beğendiklerini ekledikçe sırasını çekmeceden değiştirebilirsin.",
@@ -139,7 +155,23 @@ export function ProgramEkleButonu({
} ${className}`}
>
{listede ? (
<Check className="size-4" aria-hidden />
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
className="size-4"
aria-hidden
>
<motion.path
d="M4 12.5 9.5 18 20 6.5"
initial={yeniEklendi && !azalt ? { pathLength: 0 } : false}
animate={{ pathLength: 1 }}
transition={YAY.donusum}
/>
</svg>
) : (
<Plus className="size-4" aria-hidden />
)}

View File

@@ -94,12 +94,15 @@ const BOS_EKSTRA: Record<DilimKey, ProgramListeSatiri[]> = {
garanti: [],
};
// Dengeli ve hayal dilimleri tamamen boşsa (sıralama çok gerideyse) boş bir
// "dengeli" sekmesi yerine dolu olan "garanti" ile açılır.
// Açılış sekmesi hiçbir zaman boş bir dilim olmaz (Bilal, 10 Eki 2026:
// "dengeli 0 dahi olsa tab aktif oluyor"): önce dengeli, o boşsa güvenli,
// o da boşsa hayal. Üçü de boşsa dengeli kalır — tek boş mesaj orada okunur.
function varsayilanDilim(sonuclar: RankResultsListe): DilimKey {
return sonuclar.toplam.dengeli === 0 && sonuclar.toplam.hayal === 0
? "garanti"
: "dengeli";
const { toplam } = sonuclar;
if (toplam.dengeli > 0) return "dengeli";
if (toplam.garanti > 0) return "garanti";
if (toplam.hayal > 0) return "hayal";
return "dengeli";
}
export function ProgramTablosu({
@@ -133,9 +136,10 @@ export function ProgramTablosu({
if (veriAnahtari !== oncekiAnahtar) {
setOncekiAnahtar(veriAnahtari);
setEkstra(BOS_EKSTRA);
// Yeni sıra/türde dengeli+hayal boş kaldıysa boş sekmede bırakma
if (varsayilanDilim(sonuclar) === "garanti" && aktifDilim !== "garanti") {
setAktifDilim("garanti");
// Yeni sıra/tür/filtrede açık sekme boşaldıysa dolu olana geç; kullanıcı
// bilerek dolu bir sekmedeyse (0 değilse) seçimi korunur.
if (sonuclar.toplam[aktifDilim] === 0) {
setAktifDilim(varsayilanDilim(sonuclar));
}
}

View File

@@ -1,5 +1,6 @@
"use client";
import { logoTepkisi } from "@/lib/logo-tepkisi";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { ArrowRight, Trophy } from "lucide-react";
@@ -7,6 +8,7 @@ import { useReducedMotion } from "motion/react";
import useMeasure from "react-use-measure";
import { toast } from "@/components/ui/bildirim";
import { TransitionPanel } from "@/components/core/transition-panel";
import { adimGecisi, adimGecisiVaryantlari } from "@/lib/adim-gecisi";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
@@ -230,6 +232,8 @@ function KapıIcerik({
if (sira == null) return;
const yeniProfil: TercihProfili = { sira, tur, secimler };
tercihProfiliYaz(yeniProfil);
// Üst çubuktaki logo kürsüye döner (DESIGN.md §4.34).
logoTepkisi("sira");
const bekleyenVar = Boolean(bekleyen);
const yonlendir = profilTamamlandi(yeniProfil, girisli);
// "sihirbaz" hedefinde akış /sonuc'ta devam eder (üretim ya da giriş
@@ -255,42 +259,11 @@ function KapıIcerik({
<TransitionPanel
activeIndex={ADIM_SIRASI[adim]}
custom={yon}
variants={{
enter: (gecisYonu: number) => ({
x: hareketiAzalt ? 0 : gecisYonu > 0 ? "100%" : "-100%",
opacity: 0,
height: panelYuksekligi,
position: "relative",
}),
center: {
zIndex: 1,
x: 0,
opacity: 1,
height: panelYuksekligi,
},
exit: (gecisYonu: number) => ({
zIndex: 0,
x: hareketiAzalt ? 0 : gecisYonu < 0 ? "100%" : "-100%",
opacity: 0,
position: "absolute",
top: 0,
left: 0,
width: "100%",
}),
}}
transition={
hareketiAzalt
? {
opacity: { duration: 0.12 },
x: { duration: 0 },
height: { duration: 0 },
}
: {
x: { type: "spring", stiffness: 300, damping: 30 },
height: { type: "spring", stiffness: 300, damping: 30 },
opacity: { duration: 0.2 },
}
}
variants={adimGecisiVaryantlari({
hareketiAzalt: !!hareketiAzalt,
yukseklik: panelYuksekligi,
})}
transition={adimGecisi(!!hareketiAzalt)}
>
{/* p-1: geçiş paneli taşmayı kırpar; alanların 3 px odak halkası
(Input/Select/Button ring-3) sığsın (Bilal, 24 Eyl 2026). */}
@@ -330,7 +303,7 @@ function KapıIcerik({
type="button"
disabled={yukleniyor}
onClick={() => void giristenIlerle()}
className="cursor-pointer bg-orange-500 text-white hover:bg-orange-600"
variant="cta"
>
{yukleniyor ? "Hazırlanıyor…" : "Tercihlerimi belirle"}
<ArrowRight className="size-4" aria-hidden />
@@ -423,7 +396,7 @@ function KapıIcerik({
<Button
type="submit"
disabled={yukleniyor}
className="cursor-pointer bg-orange-500 text-white hover:bg-orange-600"
variant="cta"
>
{yukleniyor ? "Hazırlanıyor…" : "Tercihlere geç"}
<ArrowRight className="size-4" aria-hidden />

View File

@@ -0,0 +1,111 @@
"use client";
// Listeye ekleme uçuşu (DESIGN.md §4.37, §11.2 "her eylemin cevabı var"):
// "+" düğmesine basılınca düğmenin yerinden bir piksel kare, üst çubuktaki
// Listem rozetine (telefonda menü baloncuğuna) uçar; varışta rozet bir kez
// nabız atar. Katman tektir ve layout'ta durur; düğmeler `ucusBaslat` ile
// pencereye olay atar. Hareketi azalt'ta uçuş yok (rozet sayısı yine değişir).
import { useEffect, useRef } from "react";
import { animate } from "motion/react";
import { EGRI } from "@/lib/hareket";
const UCUS_OLAYI = "kt-ucus";
const KARE = 10;
const UCUS_SURE = 0.55;
/** "+" düğmesi çağırır: uçuş `kaynak` öğesinin ortasından başlar. */
export function ucusBaslat(kaynak: Element) {
if (typeof window === "undefined") return;
const r = kaynak.getBoundingClientRect();
window.dispatchEvent(
new CustomEvent(UCUS_OLAYI, {
detail: { x: r.left + r.width / 2, y: r.top + r.height / 2 },
}),
);
}
/** Ekranda görünen hedef: masaüstünde Listem düğmesi, telefonda menü baloncuğu. */
function hedefBul(): HTMLElement | null {
for (const el of document.querySelectorAll<HTMLElement>("[data-ucus-hedefi]")) {
const r = el.getBoundingClientRect();
if (r.width > 0 && r.height > 0) return el;
}
return null;
}
function rozetNabzi(hedef: HTMLElement) {
const rozet = hedef.querySelector<HTMLElement>("[data-ucus-rozet]") ?? hedef;
rozet.classList.remove("kt-piksel-nabiz");
// Yeniden tetiklemek için yerleşimi oku (animasyon sıfırlanır).
void rozet.offsetWidth;
rozet.classList.add("kt-piksel-nabiz");
rozet.addEventListener(
"animationend",
() => rozet.classList.remove("kt-piksel-nabiz"),
{ once: true },
);
}
export function UcusKatmani() {
const kareRef = useRef<HTMLSpanElement>(null);
useEffect(() => {
let surenUcus: { stop: () => void } | null = null;
const azalt = window.matchMedia("(prefers-reduced-motion: reduce)");
function uc(e: Event) {
const { x, y } = (e as CustomEvent<{ x: number; y: number }>).detail;
const kare = kareRef.current;
// Rozet sayısı store'dan bir kare sonra güncellenir; hedef o zaman aranır
// (ilk eklemede rozet yeni belirir).
requestAnimationFrame(() => {
const hedef = hedefBul();
if (!hedef) return;
if (azalt.matches || !kare) {
return;
}
const h = hedef.getBoundingClientRect();
const hx = h.left + h.width / 2;
const hy = h.top + h.height / 2;
// Aynı anda tek kare: süren uçuş kesilir, yenisi kendi yerinden çıkar.
surenUcus?.stop();
kare.style.left = `${x - KARE / 2}px`;
kare.style.top = `${y - KARE / 2}px`;
// Yatay düz, dikey gecikmeli: iki eğrinin farkı yayı çizer.
surenUcus = animate(
kare,
{
x: [0, hx - x],
y: [0, hy - y],
opacity: [0, 1, 1, 0],
scale: [0.6, 1.4, 1, 0.5],
},
{
duration: UCUS_SURE,
x: { duration: UCUS_SURE, ease: EGRI.standart },
y: { duration: UCUS_SURE, ease: [0.5, 0, 0.75, 0] },
opacity: { duration: UCUS_SURE, times: [0, 0.12, 0.85, 1], ease: "linear" },
scale: { duration: UCUS_SURE, times: [0, 0.15, 0.8, 1], ease: "easeOut" },
onComplete: () => rozetNabzi(hedef),
},
);
});
}
window.addEventListener(UCUS_OLAYI, uc);
return () => {
window.removeEventListener(UCUS_OLAYI, uc);
surenUcus?.stop();
};
}, []);
return (
<span
ref={kareRef}
aria-hidden
className="pointer-events-none fixed z-[70] block rounded-[2px] bg-orange-500 opacity-0 print:hidden"
style={{ width: KARE, height: KARE, left: 0, top: 0 }}
/>
);
}

View File

@@ -0,0 +1,277 @@
"use client";
// Ana sayfa "Nasıl çalışır"ın üç adımı (DESIGN.md §4.32). Adım numarası
// hero'nun piksel diliyle yazılmış iri rakamdır; bölüm ekrana girince her
// rakam sırayla, kısa bir an o adımın biçimine dönüşüp geri toplanır
// (logonun yaptığının aynısı, §4.34):
// 1 → sıra şeridi (herkes tek sırada, turuncu kare sensin)
// 2 → yıl yıl çubuklar (taranan veri; en yenisinin tepesi turuncu)
// 3 → 6×4 = 24 kare (listen)
// Rakam geri toplanınca sıradaki adıma giden çizgi dolar; hareketler sırayla
// konuşur (§11.2-3). Sonrasında imleçle adıma gelince biçim yeniden açılır,
// telefonda dokununca bir kez oynar. Hareketi azalt'ta rakamlar durağandır.
import { useEffect, useMemo, useRef, useState } from "react";
import { useReducedMotion } from "motion/react";
import { PikselMorf, type MorfPiksel } from "@/components/piksel-morf";
import { RAKAMLAR, dokuOpakligi } from "@/components/pixel-decor";
import { Reveal } from "@/features/pazarlama/components/reveal";
export type Adim = {
no: keyof typeof RAKAMLAR;
baslik: string;
aciklama: string;
};
// Sahne: rakam (5×7) ortada, biçim iki yanına taşabilir.
const SUTUN = 11;
const SATIR = 7;
const RAKAM_X = (SUTUN - 5) / 2;
/** Biçimde kalma (dönüşüm dahil) ve rakama dönüş süreleri (ms). */
const BICIM_TUT = 1300;
const DONUS = 650;
const ILK_BEKLEME = 350;
type Hucre = { x: number; y: number; vurgu?: boolean };
function rakamHucreleri(no: Adim["no"]): Hucre[] {
return RAKAMLAR[no].flatMap((satir, y) =>
[...satir].flatMap((c, x) => (c === "#" ? [{ x: x + RAKAM_X, y }] : [])),
);
}
function bicimHucreleri(no: Adim["no"], yilSayisi: number): Hucre[] {
if (no === 1) {
// Sıra şeridi (PixelSira dili): dokuz kare tek sırada, biri sen
return Array.from({ length: 9 }, (_, i) => ({
x: 1 + i,
y: 3,
vurgu: i === 2,
}));
}
if (no === 2) {
// Yıl başına bir çubuk; sahne 6 çubuk alır
const n = Math.min(Math.max(yilSayisi, 3), 6);
const boylar = [3, 2, 4, 3, 5, 4].slice(-n);
const sol = (SUTUN - (2 * n - 1)) / 2;
return boylar.flatMap((boy, b) =>
Array.from({ length: boy }, (_, r) => ({
x: sol + b * 2,
y: SATIR - 1 - r,
vurgu: b === n - 1 && r === boy - 1,
})),
);
}
// 24'lük liste: 6×4, kapanıştaki piksel listeyle aynı dizilim
return Array.from({ length: 24 }, (_, i) => ({
x: (SUTUN - 6) / 2 + (i % 6),
y: (SATIR - 4) / 2 + Math.floor(i / 6),
}));
}
type Eslesme = { rakam?: Hucre; bicim?: Hucre; op: number };
/** Rakam pikselleri en yakın biçim hücresine gider; artanlar yerinde belirir/söner. */
function esle(rakam: Hucre[], bicim: Hucre[]): Eslesme[] {
const ciftler = rakam
.flatMap((r, ri) =>
bicim.map((b, bi) => ({ ri, bi, d: (r.x - b.x) ** 2 + (r.y - b.y) ** 2 })),
)
.sort((a, b) => a.d - b.d);
const rakamdan = new Map<number, number>();
const bicimden = new Set<number>();
for (const c of ciftler) {
if (rakamdan.has(c.ri) || bicimden.has(c.bi)) continue;
rakamdan.set(c.ri, c.bi);
bicimden.add(c.bi);
}
// "Dolgun" ton (PixelSahne ile aynı): okunması gereken şekil
const dolgun = (h: Hucre) =>
0.4 + dokuOpakligi(Math.abs(Math.round(h.x - RAKAM_X)), Math.round(h.y)) * 2;
return [
...rakam.map((r, ri) => {
const bi = rakamdan.get(ri);
return { rakam: r, bicim: bi === undefined ? undefined : bicim[bi], op: dolgun(r) };
}),
...bicim
.filter((_, bi) => !bicimden.has(bi))
.map((b) => ({ bicim: b, op: dolgun(b) })),
];
}
function RakamMorfu({
no,
yilSayisi,
bicimde,
}: {
no: Adim["no"];
yilSayisi: number;
bicimde: boolean;
}) {
const eslesmeler = useMemo(
() => esle(rakamHucreleri(no), bicimHucreleri(no, yilSayisi)),
[no, yilSayisi],
);
const pikseller = useMemo<MorfPiksel[]>(
() =>
eslesmeler.map((e, i) => {
const hedef = bicimde ? e.bicim : e.rakam;
// Bu hâlde yeri olmayan piksel öbür hâldeki yerinde gizli bekler
const yer = hedef ?? e.bicim ?? e.rakam!;
return {
id: String(i),
x: yer.x,
y: yer.y,
op: e.op,
ton: !hedef ? "gizli" : bicimde && hedef.vurgu ? "vurgu" : "doku",
// Kareler sırayla yola çıkar (logo biçimlerindeki gibi)
gecikme: i * 14,
};
}),
[eslesmeler, bicimde],
);
return (
<PikselMorf
pikseller={pikseller}
sutun={SUTUN}
satir={SATIR}
yaricap={22}
// Biçimde turuncu kare anlam taşır ("sen", "bu yıl"): fırça karışmasın
firca={!bicimde}
className="h-24 w-auto"
/>
);
}
export function AdimAkisi({
adimlar,
yilSayisi,
}: {
adimlar: readonly Adim[];
/** "Son N yıl": ikinci adımın biçimindeki çubuk sayısı */
yilSayisi: number;
}) {
const azalt = useReducedMotion();
const ogeler = useRef<(HTMLLIElement | null)[]>([]);
/** Şu an biçimini gösteren adım */
const [bicimde, setBicimde] = useState<number | null>(null);
/** Dolan çizgi sayısı (adım i ile i+1 arası: i < dolan) */
const [dolan, setDolan] = useState(0);
// Açılış sırası: kaç adım oynadı, şu an oynuyor mu
const oynanan = useRef(0);
const mesgul = useRef(false);
const gorunur = useRef<boolean[]>([]);
const zamanlayicilar = useRef<number[]>([]);
useEffect(() => {
if (azalt || typeof IntersectionObserver === "undefined") return;
const bekle = (ms: number, is: () => void) => {
zamanlayicilar.current.push(window.setTimeout(is, ms));
};
// Adım, kendisi ekrandaysa ve öncekiler bittiyse oynar: geniş ekranda
// 1 → 2 → 3 peş peşe, telefonda her biri görününce.
const ilerlet = () => {
const i = oynanan.current;
if (mesgul.current || i >= adimlar.length || !gorunur.current[i]) return;
mesgul.current = true;
bekle(i === 0 ? ILK_BEKLEME : 0, () => {
setBicimde(i);
bekle(BICIM_TUT, () => {
setBicimde(null);
setDolan(i + 1);
bekle(DONUS, () => {
oynanan.current = i + 1;
mesgul.current = false;
ilerlet();
});
});
});
};
const io = new IntersectionObserver(
(kayitlar) => {
for (const k of kayitlar) {
const i = ogeler.current.indexOf(k.target as HTMLLIElement);
if (i >= 0) gorunur.current[i] = k.isIntersecting;
}
ilerlet();
},
// Rakam ekranın alt kenarından biraz içeri girince
{ rootMargin: "0px 0px -120px 0px" },
);
ogeler.current.forEach((el) => el && io.observe(el));
const bekleyenler = zamanlayicilar.current;
return () => {
io.disconnect();
bekleyenler.forEach((z) => window.clearTimeout(z));
bekleyenler.length = 0;
mesgul.current = false;
};
}, [azalt, adimlar.length]);
// Açılışı bitmiş adım bakana cevap verir: imleç üstündeyken biçim açık
// kalır, dokunmada bir kez oynar. Açılış sürerken karışmaz.
const serbest = (i: number) => !azalt && !mesgul.current && oynanan.current > i;
const dokun = (i: number) => {
if (!serbest(i)) return;
mesgul.current = true;
setBicimde(i);
zamanlayicilar.current.push(
window.setTimeout(() => {
setBicimde(null);
mesgul.current = false;
}, BICIM_TUT),
);
};
return (
<ol className="grid gap-12 md:grid-cols-3 md:gap-8">
{adimlar.map((s, i) => (
<li
key={s.no}
ref={(el) => {
ogeler.current[i] = el;
}}
onPointerEnter={(e) => {
if (e.pointerType === "mouse" && serbest(i)) setBicimde(i);
}}
onPointerLeave={(e) => {
if (e.pointerType === "mouse" && !mesgul.current) {
setBicimde((b) => (b === i ? null : b));
}
}}
onPointerDown={(e) => {
if (e.pointerType !== "mouse") dokun(i);
}}
>
<Reveal
delay={i * 0.08}
className="relative flex flex-col items-center text-center"
>
<RakamMorfu no={s.no} yilSayisi={yilSayisi} bicimde={bicimde === i} />
{i < adimlar.length - 1 ? (
<span
aria-hidden
className="absolute top-12 left-[calc(50%+5.25rem)] hidden h-0.5 w-[calc(100%-8.5rem)] -translate-y-1/2 overflow-hidden rounded-full bg-slate-200 md:block"
>
{/* Adım göstergesiyle aynı mavi dolum (§4.35): rakam biçiminden
dönünce sıradaki adıma doğru dolar. */}
<span
data-dolu={dolan > i ? "" : undefined}
className="kt-cizgi-dol block h-full rounded-full bg-primary"
/>
</span>
) : null}
<h3 className="mt-6 font-heading text-xl font-bold text-balance">
<span className="sr-only">{s.no}. adım: </span>
{s.baslik}
</h3>
<p className="mt-2 max-w-sm text-base leading-relaxed text-slate-600">
{s.aciklama}
</p>
</Reveal>
</li>
))}
</ol>
);
}

View File

@@ -0,0 +1,61 @@
// Ana sayfa bölüm geçişi (DESIGN.md §4.38): bölümler arasında piksel ayraç
// yerine tek bir piksel şeridi durur. Şerit hero haritasının serpintisinden
// başlar ve her bölüm arasında yeniden görünür; kaydırdıkça turuncuya ısınarak
// aşağı iner — sayfanın bölümlerini birbirine bağlayan iplik. Bölümlerin
// içinde çizilmez (yazının arkasına piksel serilmez, §4.31), yalnız aradaki
// boşlukta durur.
import { Parallax } from "@/components/parallax";
import { PikselSerit } from "@/components/piksel-serit";
/** Geçişin akışta kapladığı boy (hücre): eski ayracın yüksekliği, bölümler
* arasındaki mesafe değişmesin. Şerit bunun üstüne ve altına taşar. */
const ORTA = 2;
export function BolumGecisi({
tohum,
ust = 4,
alt = 4,
ustTelefon = 3,
altTelefon = 3,
ustKisaTelefon = ustTelefon,
}: {
/** Şeridin doku deseni; sayfadaki her geçişte farklı */
tohum: number;
/** Şeridin geçiş çizgisinin üstünde ve altında kalan boyu (hücre ≈ 18,7 px) */
ust?: number;
alt?: number;
/** Telefonda bölüm boşluğu dardır (üstte 112, altta 96 px; app/page.tsx
* `BOLUM_BOSLUGU`): şerit daha kısa çizilir. */
ustTelefon?: number;
altTelefon?: number;
/** Kısa telefonda (yükseklik < 760 px) üstte kalan boy. Hero'nun altındaki
* boşluk ekran boyuna bağlıdır; şerit forma değmesin. */
ustKisaTelefon?: number;
}) {
const uzunluk = ust + ORTA + alt;
const hucreSinifi = Array.from({ length: uzunluk }, (_, i) => {
const ustten = ust - i; // orta kısmın kaç hücre üstünde (1 = hemen üstü)
const alttan = i - (ust + ORTA) + 1;
if (ustten > ustTelefon || alttan > altTelefon) return "max-sm:hidden";
if (ustten > ustKisaTelefon) {
return "max-sm:[@media(max-height:759px)]:hidden";
}
return undefined;
});
return (
// Bir hücre = 14rem / 12 (hero'dan sonraki en iri piksel; eski ayraçla aynı)
<div aria-hidden className="mx-auto h-[calc(14rem/6)] w-[calc(14rem/12)]">
<Parallax strength={26} className="relative">
<PikselSerit
uzunluk={uzunluk}
tohum={tohum}
hucreSinifi={hucreSinifi}
className="pointer-events-none absolute inset-x-0"
style={{ top: `calc(${-ust} * 14rem / 12)` }}
/>
</Parallax>
</div>
);
}

View File

@@ -10,7 +10,9 @@ export function HeroBaslik() {
const profil = useTercihProfili();
return (
<h1 className="max-w-3xl font-heading text-5xl font-bold tracking-[0.015em] sm:text-6xl">
// Sayfanın tek orkestre girişi (DESIGN.md §6, §11.2): başlık, ardından
// form netleşerek gelir (globals.css `.kt-hero-gir`).
<h1 className="kt-hero-gir max-w-3xl font-heading text-5xl font-bold tracking-[0.015em] sm:text-6xl">
{profil ? (
<>
Sıralaman kayıtlı, <br className="hidden sm:block" />

View File

@@ -22,7 +22,8 @@ export function HeroFocusButton() {
type="button"
size="lg"
onClick={handleClick}
className="mt-8 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
variant="cta"
className="mt-8"
>
Ücretsiz dene
<ArrowRight className="size-4" aria-hidden />
@@ -47,7 +48,8 @@ export function KapanisCta() {
<Button
asChild
size="lg"
className="mt-8 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
variant="cta"
className="mt-8"
>
<Link href={sonucHref()}>
Üniversitelere göz at

View File

@@ -1,7 +1,9 @@
import { CheckCircle2, X } from "lucide-react";
import { X } from "lucide-react";
// Ana sayfadaki karşılaştırma tablosunun hücresi: metin değeri düz yazılır,
// boolean değer Var/Yok ikonuna çevrilir.
// boolean değer Var/Yok ikonuna çevrilir. "Var" tiki tablo ekrana girince
// kendini çizer (DESIGN.md §5.2.3, §11.2-6; globals.css `.kt-tik-ciz`):
// lucide `circle-check` ile aynı geometri, iki parça `pathLength="1"`.
export function ComparisonCell({
value,
vurgu = false,
@@ -20,7 +22,24 @@ export function ComparisonCell({
);
}
return value ? (
<CheckCircle2 className="mx-auto size-6 text-primary" aria-label="Var" />
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
role="img"
aria-label="Var"
className="mx-auto size-6 text-primary"
>
<circle cx={12} cy={12} r={10} pathLength={1} className="kt-tik-ciz" />
<path
d="m9 12 2 2 4-4"
pathLength={1}
className="kt-tik-ciz kt-tik-isaret"
/>
</svg>
) : (
<X className="mx-auto size-5 text-slate-300" aria-label="Yok" />
);

View File

@@ -0,0 +1,61 @@
"use client";
// Landing'deki iri sayılar ekrana girince rulo gibi dönerek yerine oturur
// (DESIGN.md §11.2 "değişimi göster"; rulo: ui/sayi-rulo.tsx). Sunucu HTML'i
// ve ilk boyama gerçek değeri taşır; sayı ekran dışındayken sessizce biraz
// geriye alınır, görünüme girince gerçek değere döner. Hareketi azalt'ta ve
// zaten ekrandayken hiç oynamaz.
import { useEffect, useRef, useState } from "react";
import { SayiRulo } from "@/components/ui/sayi-rulo";
/** "20.000+", "%100", "5" → önek, sayı, sonek. Sayı yoksa `null`. */
function ayir(metin: string) {
const m = /^(\D*)([\d.]+)(\D*)$/.exec(metin);
if (!m) return null;
const sayi = Number(m[2].replace(/\./g, ""));
return Number.isFinite(sayi) ? { onek: m[1], sayi, sonek: m[3] } : null;
}
export function SayanSayi({ deger }: { deger: string }) {
const ref = useRef<HTMLSpanElement>(null);
const parca = ayir(deger);
const hedef = parca?.sayi ?? 0;
const [gosterilen, setGosterilen] = useState(hedef);
useEffect(() => {
const el = ref.current;
if (!el || !parca || typeof IntersectionObserver === "undefined") return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
let ilk = true;
const io = new IntersectionObserver(
([giris]) => {
if (ilk) {
ilk = false;
// İlk ölçümde zaten ekrandaysa dokunma; değilse geriye al ve bekle.
if (giris.isIntersecting) return io.disconnect();
setGosterilen(Math.floor(hedef * 0.9));
return;
}
if (giris.isIntersecting) {
setGosterilen(hedef);
io.disconnect();
}
},
{ rootMargin: "0px 0px -80px 0px" },
);
io.observe(el);
return () => io.disconnect();
// `parca` her render'da yeni nesne; hedef sayı değişmedikçe yeniden kurma.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hedef]);
if (!parca) return <>{deger}</>;
return (
<span ref={ref}>
{parca.onek}
<SayiRulo deger={gosterilen} />
{parca.sonek}
</span>
);
}

View File

@@ -0,0 +1,328 @@
"use client";
// Ana sayfa "Sorun" kartlarının canlı sahneleri (DESIGN.md §4.31, §4.38).
// Üç sahne de eskiden durağan duran ızgaranın aynısıdır; artık kartın
// söylediğini hareketle söyler:
// ihtimal → binlerce karenin içinde 24'ü ısınır, söner, başka 24'ü ısınır
// (hangi 24? — her seferinde başka bir ihtimal)
// yillar → çubuklar her yıl bir sola kayar, sağdan başka boyda yeni yıl girer
// fiyat → merdiven basamak basamak kurulur, turuncu tepe yukarı tırmanır
//
// Sahneler sırayla konuşur (§11.2-3): modül seviyesindeki tek orkestra aynı
// anda yalnız bir sahneyi oynatır. İmleçle karta gelmek ya da karta dokunmak
// o sahneyi hemen oynatır (bakana cevap). Sunucu HTML'i ve hareketi azalt
// eski durağan resmi gösterir.
import { useEffect, useMemo, useRef, useState } from "react";
import { useReducedMotion } from "motion/react";
import {
PikselMorf,
type MorfPiksel,
} from "@/components/piksel-morf";
import { dokuOpakligi, pikselHash } from "@/components/pixel-decor";
export type SorunSahnesiTuru = "ihtimal" | "yillar" | "fiyat";
const SUTUN = 24;
const SATIR = 8;
/* ------------------------------------------------------------------ */
/* Orkestra: aynı anda tek sahne */
/* ------------------------------------------------------------------ */
/** İki sahnenin söze başlaması arasındaki en kısa süre. */
const SOZ_ARASI = 2400;
/** Bir sahne oynadıktan sonra en az bu kadar durur. */
const DINLENME = 6600;
/** Elle (imleç/dokunma) tetiklemede aynı sahnenin tekrar aralığı. */
const ELLE_ARASI = 2600;
const TIK = 600;
type Kayit = {
sira: number;
gorunur: boolean;
sonBaslangic: number;
oynat: () => void;
};
const kayitlar = new Set<Kayit>();
let sayac = 0;
let sonSoz = -Infinity;
let siradaki = 0;
function sozVer(k: Kayit, simdi: number) {
k.sonBaslangic = simdi;
sonSoz = simdi;
k.oynat();
}
function tik() {
if (document.hidden) return;
const simdi = performance.now();
if (simdi - sonSoz < SOZ_ARASI) return;
const adaylar = [...kayitlar]
.filter((k) => k.gorunur && simdi - k.sonBaslangic >= DINLENME)
.sort((a, b) => a.sira - b.sira);
if (adaylar.length === 0) return;
const secilen = adaylar.find((k) => k.sira >= siradaki) ?? adaylar[0];
siradaki = secilen.sira + 1;
sozVer(secilen, simdi);
}
function saatiAyarla() {
const gorunenVar = [...kayitlar].some((k) => k.gorunur);
if (gorunenVar && !sayac) sayac = window.setInterval(tik, TIK);
if (!gorunenVar && sayac) {
window.clearInterval(sayac);
sayac = 0;
}
}
/* ------------------------------------------------------------------ */
/* Sahne 1 — 24 tercih, binlerce ihtimal */
/* ------------------------------------------------------------------ */
// Doku alanı (`o` eski durağan resimdeki 24 turuncu kare: ilk seçim).
const IHTIMAL = [
"o#o#######o##o##o#######",
"####o###o#########o###o#",
"######o####o###o########",
"#o##o###################",
"######o##o####o####o##o#",
"#o##########o####o######",
".###..o#......####.#..o.",
"..#...###...##......#...",
];
const ihtimalHucreleri = IHTIMAL.flatMap((satir, y) =>
[...satir].flatMap((c, x) => (c === "." ? [] : [{ x, y, ilk: c === "o" }])),
);
/** Her biri tam 24 hücrelik seçimler; sıra, karelerin ısınma sırasıdır. */
const SECIMLER: string[][] = [
ihtimalHucreleri.filter((h) => h.ilk).map((h) => `${h.x}-${h.y}`),
...[3, 8].map((tohum) =>
ihtimalHucreleri
// Alt iki seyrek satır seçime girmez: 24 kare alanın içinde kalsın
.filter((h) => h.y < 6)
.map((h) => ({ h, r: pikselHash(h.x, h.y, tohum) }))
.sort((a, b) => a.r - b.r)
.slice(0, 24)
.map(({ h }) => `${h.x}-${h.y}`),
),
];
/** `adim` 0: hepsi soğur (binlerce ihtimal). `adim` 1: yeni 24 tek tek ısınır. */
function ihtimalSahnesi(dongu: number, adim: number): MorfPiksel[] {
const secim = adim === 0 ? [] : SECIMLER[dongu % SECIMLER.length];
const sira = new Map(secim.map((id, i) => [id, i]));
return ihtimalHucreleri.map((h) => {
const id = `${h.x}-${h.y}`;
const s = sira.get(id);
return {
id,
x: h.x,
y: h.y,
op: dokuOpakligi(h.x, h.y),
ton: s === undefined ? "doku" : "vurgu",
// İlk boyamada (dongu 0) gecikme yok: resim hazır gelir
gecikme: s !== undefined && dongu > 0 ? s * 42 : undefined,
};
});
}
/* ------------------------------------------------------------------ */
/* Sahne 2 — Geçen yılın puanı yetmiyor */
/* ------------------------------------------------------------------ */
// Yıl yıl çubuk boyları; ilk altısı eski durağan resim (5·3·6·4·7·4).
const BOYLAR = [5, 3, 6, 4, 7, 4, 6, 3, 5, 2, 7];
const GORUNEN = 6;
// Görünen altı yuva + solda çıkan + sağda sırasını bekleyen = 8 çubuk döner.
const CUBUK = GORUNEN + 2;
const EN_BOY = 7;
function yillarSahnesi(dongu: number): MorfPiksel[] {
const pikseller: MorfPiksel[] = [];
for (let b = 0; b < CUBUK; b++) {
// Bu çubuğun şu an taşıdığı yıl: dongu-1 … dongu+6 aralığında, b ile denk
const yil = dongu - 1 + ((((b - (dongu - 1)) % CUBUK) + CUBUK) % CUBUK);
const yuva = yil - dongu; // -1 (çıktı) … 6 (bekliyor)
const sahnede = yuva >= 0 && yuva < GORUNEN;
const boy = BOYLAR[((yil % BOYLAR.length) + BOYLAR.length) % BOYLAR.length];
for (let c = 0; c < 2; c++) {
for (let r = 0; r < EN_BOY; r++) {
pikseller.push({
id: `${b}-${c}-${r}`,
x: 1 + yuva * 4 + c,
y: SATIR - 1 - r,
op: dokuOpakligi(b * 2 + c, r),
ton:
!sahnede || r >= boy
? "gizli"
: // Yalnız en yeni yılın tepesi turuncu (bu yılın tabanı)
yuva === GORUNEN - 1 && r === boy - 1
? "vurgu"
: "doku",
});
}
}
}
return pikseller;
}
/* ------------------------------------------------------------------ */
/* Sahne 3 — Danışman lüks haline geldi */
/* ------------------------------------------------------------------ */
const BASAMAK = 8;
/** `adim`: turuncu tepenin durduğu basamak (0 en alt, 7 en üst = eski resim). */
function fiyatSahnesi(adim: number): MorfPiksel[] {
const pikseller: MorfPiksel[] = [];
for (let k = 0; k < BASAMAK; k++) {
for (let c = 0; c < 3; c++) {
const x = k * 3 + c;
const ustY = SATIR - 1 - k;
// Basamağın üstü: tepe buradan geçtikten sonra dolu doku
pikseller.push({
id: `u-${k}-${c}`,
x,
y: ustY,
op: dokuOpakligi(x, ustY),
ton: k < adim ? "doku" : "gizli",
});
// Basamağın altındaki silik dolgu
for (let r = 1; r <= k; r++) {
pikseller.push({
id: `d-${k}-${c}-${r}`,
x,
y: ustY + r,
op: dokuOpakligi(x, ustY + r),
ton: k <= adim ? "silik" : "gizli",
});
}
}
}
// Turuncu tepe: üç piksel, basamaktan basamağa süzülür
for (let c = 0; c < 3; c++) {
pikseller.push({
id: `tepe-${c}`,
x: adim * 3 + c,
y: SATIR - 1 - adim,
op: 0.3,
ton: "vurgu",
});
}
return pikseller;
}
/* ------------------------------------------------------------------ */
const SAHNE: Record<
SorunSahnesiTuru,
{
adimSayisi: number;
adimMs: number;
/** Kayma süresi (sn); verilmezse ilkelin varsayılanı */
sure?: number;
ciz: (dongu: number, adim: number) => MorfPiksel[];
}
> = {
// Önce hepsi soğur (vurgunun soğuması 1,2 sn), sonra yeni 24 ısınır
ihtimal: { adimSayisi: 2, adimMs: 1100, ciz: ihtimalSahnesi },
yillar: { adimSayisi: 1, adimMs: 0, ciz: (dongu) => yillarSahnesi(dongu) },
fiyat: {
adimSayisi: BASAMAK,
adimMs: 300,
sure: 0.3,
ciz: (_dongu, adim) => fiyatSahnesi(adim),
},
};
export function SorunSahnesi({
tur,
sira,
}: {
tur: SorunSahnesiTuru;
/** Kartın sırası (0, 1, 2): sahneler bu sırayla konuşur. */
sira: number;
}) {
const azalt = useReducedMotion();
const kapRef = useRef<HTMLDivElement>(null);
const sahne = SAHNE[tur];
// Başlangıç (sunucu HTML'i, hareketi azalt): eski durağan resim = son adım.
const [an, setAn] = useState({ dongu: 0, adim: sahne.adimSayisi - 1 });
useEffect(() => {
const kap = kapRef.current;
if (azalt || !kap || typeof IntersectionObserver === "undefined") return;
let zamanlayicilar: number[] = [];
const kayit: Kayit = {
sira,
gorunur: false,
sonBaslangic: -Infinity,
oynat: () => {
zamanlayicilar.forEach((z) => window.clearTimeout(z));
zamanlayicilar = [];
setAn((o) => ({ dongu: o.dongu + 1, adim: 0 }));
for (let a = 1; a < sahne.adimSayisi; a++) {
zamanlayicilar.push(
window.setTimeout(
() => setAn((o) => ({ ...o, adim: a })),
a * sahne.adimMs,
),
);
}
},
};
kayitlar.add(kayit);
const io = new IntersectionObserver(
([k]) => {
kayit.gorunur = k.isIntersecting;
saatiAyarla();
},
{ threshold: 0.6 },
);
io.observe(kap);
// Bakana cevap: karta imleç gelince ya da dokununca sahne hemen oynar.
const kart = kap.closest<HTMLElement>("[data-sahne-karti]") ?? kap;
const elle = () => {
const simdi = performance.now();
if (simdi - kayit.sonBaslangic < ELLE_ARASI) return;
sozVer(kayit, simdi);
};
kart.addEventListener("pointerenter", elle);
return () => {
kart.removeEventListener("pointerenter", elle);
io.disconnect();
zamanlayicilar.forEach((z) => window.clearTimeout(z));
kayitlar.delete(kayit);
saatiAyarla();
};
}, [azalt, sira, sahne]);
const pikseller = useMemo(
() => sahne.ciz(an.dongu, an.adim),
[sahne, an.dongu, an.adim],
);
return (
<div ref={kapRef}>
<PikselMorf
pikseller={pikseller}
sutun={SUTUN}
satir={SATIR}
style={
sahne.sure
? ({ "--morf-sure": `${sahne.sure}s` } as React.CSSProperties)
: undefined
}
/>
</div>
);
}

View File

@@ -118,10 +118,12 @@ function ArkaKare({
// Arka katman ters yöne ve daha çok kayar: derinlik hissi
const x = useTransform(mx, (v) => -v * GUC * 1.4);
const y = useTransform(my, (v) => -v * GUC);
// Odak dışı: kareler bulanık durur, net olan yalnız haplardır. Bulanıklık
// karenin kendisindedir (kapta değil) — kayarken yeniden çizilmez.
return (
<motion.span
className={cn(
"absolute rounded",
"absolute rounded blur-xs",
kare.sicak ? "bg-orange-500/70" : "bg-primary/15",
)}
style={{ left: `${kare.x}%`, top: `${kare.y}%`, width: kare.boy, height: kare.boy, x, y }}

View File

@@ -19,6 +19,10 @@
*
* İmleç gelmese de rastgele pikseller tek tük, seyrek aralıklarla turuncuya
* ısınıp söner (usePixelHeat `kirpisma`); "hareketi azalt"ta kapalıdır.
*
* Kıyının altındaki serpinti canlıdır: piksellerin bir kısmı yerinden kopup
* aşağı düşer ve yeniden belirir (CSS, `.kt-serpinti`). Telefonda imleç
* olmadığı için haritanın hareketi budur.
*/
import { useRef } from "react";
@@ -137,8 +141,16 @@ const MAP_MARKUP = (() => {
const op =
Math.round(opacityFor(x, y) * (1 - d / (SCATTER_DEPTH + 1)) * 100) /
100;
// Serpintinin üçte biri damlar (globals.css `.kt-serpinti`): piksel
// yerinden kopup 1–3 hücre düşerken söner, sonra yerinde yeniden
// belirir. Harita bitmez, dökülür (DESIGN.md §11.1 V2); süre ve faz
// pikselden piksele farklı ki yağmur gibi sıralı düşmesin.
const damla =
hash(x, y, 57) > 0.67
? ` class="kt-serpinti" style="--d:${10 + Math.round(hash(x, y, 61) * 20)};animation-duration:${(4.5 + hash(x, y, 67) * 4).toFixed(2)}s;animation-delay:-${(hash(x, y, 71) * 8).toFixed(2)}s"`
: "";
parts.push(
`<rect data-px data-cx="${x * CELL + CELL / 2}" data-cy="${y * CELL + CELL / 2}" data-op="${op}" ${rectAttrs(x, y)} fill="currentColor" fill-opacity="${op}"/>`,
`<rect data-px data-cx="${x * CELL + CELL / 2}" data-cy="${y * CELL + CELL / 2}" data-op="${op}" ${rectAttrs(x, y)} fill="currentColor" fill-opacity="${op}"${damla}/>`,
);
}
}

View File

@@ -8,9 +8,13 @@
//
// Kartsız ve zeminsizdir (Bilal, 7 Eki 2026): sayfa zemininde açıkta durur,
// eskiden altında duran sayılar ve dürüstlük notu geniş ekranda iki yanına
// dizilir (yerleşim app/page.tsx'te). Ortadaki işaret site başlığındaki
// logonun animasyonunu oynar: kareler sırayla birbirinin yerine süzülür
// (`AnimasyonluMark` "rota").
// dizilir (yerleşim app/page.tsx'te).
//
// Ortadaki işaret danışmanın kendisidir (Bilal, 11 Eki 2026): bir süre
// DanışmanlaKonuş başlığındaki gibi nabız atar (danışman düşünüyor), sonra
// üç kare girdilerden çıkan şeye dönüşür, durur ve logoya toplanır — sırayla
// liste, trend çubukları, sohbet balonları (`AnimasyonluMark` `bicimler`,
// §4.34). Eski "rota" (kareler birbirinin yerine süzülür) kaldırıldı.
//
// Esin: React Bits "Center Flow" (merkezden çevreye akan çizgiler). Kod bize
// ait: parlama/bulanıklık yok (§2.2), akış yönü içeri doğrudur ve yalnız
@@ -18,9 +22,14 @@
import { useEffect, useRef, useState, type CSSProperties } from "react";
import { Compass, MapPin, PieChart, TrendingUp, Trophy, Users } from "lucide-react";
import { AnimasyonluMark } from "@/components/animasyonlu-mark";
import { AnimasyonluMark, type Bicim } from "@/components/animasyonlu-mark";
import { cn } from "@/lib/utils";
// Girdilerden çıkanlar, paketin verdiği sırayla: 24'lük liste, her tercihin
// trend analizi, danışmana soru. Bileşen dışında sabit: dizi değişirse sıra
// başa döner (animasyonlu-mark.tsx).
const CIKTILAR: readonly Bicim[] = ["deste", "trend", "sohbet"];
// Sağ yarı (−60°, 0°, 60°) veri; sol yarı (120°, 180°, 240°) öğrencinin seçimi.
const DUGUMLER = [
{ etiket: "Taban sıralamaları", ikon: TrendingUp, aci: -60, kaynak: "veri" },
@@ -40,9 +49,15 @@ const IC_BOSLUK = 15.5;
/** Akan karenin kenarı (hero noktasının viewBox karşılığı). */
const KARE = 2.4;
// Üç ondalığa yuvarlanır: sunucu (Node) ile tarayıcının sin/cos'u son
// basamakta ayrışıyor, yuvarlanmazsa hidrasyon uyuşmazlığı çıkıyor.
const yuvarla = (v: number) => Math.round(v * 1000) / 1000;
const nokta = (aci: number, r: number) => {
const rad = (aci * Math.PI) / 180;
return { x: 50 + Math.cos(rad) * r, y: 50 + Math.sin(rad) * r };
return {
x: yuvarla(50 + Math.cos(rad) * r),
y: yuvarla(50 + Math.sin(rad) * r),
};
};
export function VeriAkisi({ className }: { className?: string }) {
@@ -143,9 +158,14 @@ export function VeriAkisi({ className }: { className?: string }) {
})}
<div className="absolute top-1/2 left-1/2 flex size-24 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-3xl border border-slate-200 bg-card">
{/* Site başlığındaki logonun animasyonu (rota): orada üzerine
gelince, burada ekrandayken sürekli oynar. */}
<AnimasyonluMark oynat={oynuyor} className="size-14" />
{/* Danışmanın nabzı, aralıklarla çıktıya dönüşür (liste → trend →
sohbet) ve logoya toplanır; yalnız ekrandayken oynar. */}
<AnimasyonluMark
oynat={oynuyor}
varyant="nabiz"
bicimler={CIKTILAR}
className="size-14"
/>
</div>
</div>
<figcaption className="mt-8 text-center text-sm leading-relaxed text-slate-500">

View File

@@ -274,11 +274,7 @@ export function ListeUretici({
? "Liste üretimi 3 kredi. +30 kredi yüklediğinde listen ve sohbet geçmişin duruyor, kaldığın yerden devam edersin."
: "Liste üretimi 3 kredi. Tercih Dönemi Paketi'yle 60 kredi, tam liste, PDF ve revizyon hakları birlikte gelir."}
</p>
<Button
asChild
size="lg"
className="mt-6 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
>
<Button asChild size="lg" variant="cta" className="mt-6">
<Link href="/paket?kaynak=kredi_bitti">
{hasPaket ? "+30 kredi yükle — 129 TL" : "Paketi aktive et — 299 TL"}
<ArrowRight className="size-4" aria-hidden />
@@ -301,8 +297,9 @@ export function ListeUretici({
</p>
<Button
size="lg"
variant="cta"
onClick={() => profil && void uret(profil)}
className="mt-6 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
className="mt-6"
>
Tekrar dene
<RefreshCcw className="size-4" aria-hidden />
@@ -354,9 +351,10 @@ function SunucuYokEkrani() {
</p>
<Button
size="lg"
variant="cta"
onClick={() => void dene()}
disabled={deniyor}
className="mt-6 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
className="mt-6"
>
{deniyor ? "Kontrol ediliyor…" : "Listemi aç"}
<ArrowRight className="size-4" aria-hidden />
@@ -381,11 +379,7 @@ function SuruyorEkrani() {
Bunu başka bir sekmede başlatmışsın. İki kez kurmayalım — kredin bu
denemede harcanmadı. Hazır olunca listende görünecek.
</p>
<Button
asChild
size="lg"
className="mt-6 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
>
<Button asChild size="lg" variant="cta" className="mt-6">
<Link href="/listem">
Listeme git
<ArrowRight className="size-4" aria-hidden />
@@ -417,9 +411,10 @@ function UretimGovdesi({ uzunSurdu }: { uzunSurdu: boolean }) {
tıkalı olduğundan router.refresh burada işe yaramaz. replace:
geri tuşu ?uret=1'e dönüp üretimi yeniden başlatmasın. */}
<Button
variant="outline"
variant="ikincil"
size="lg"
onClick={() => window.location.replace("/listem")}
className="mt-2 cursor-pointer"
className="mt-2"
>
Listemi kontrol et
<ArrowRight className="size-4" aria-hidden />

View File

@@ -9,8 +9,8 @@ import {
useTercihProfili,
} from "@/features/liste/hooks/use-tercih-profili";
// Her iki dalın butonu da aynı CTA: sınıf seti tek yerde dursun.
const CTA_SINIF = "mt-6 cursor-pointer bg-orange-500 text-white hover:bg-orange-600";
// Her iki dalın butonu da kanonik CTA (`variant="cta"`, §4.1); burada yalnız yerleşim.
const CTA_SINIF = "mt-6";
/**
* /listem boş hâlinin metni + CTA'sı: seçimleri zaten hazır olan adayı
@@ -31,7 +31,7 @@ export function ListemBosCta() {
</p>
<PagePixelDivider seed={67} className="mx-auto mt-6" />
{profil ? (
<Button asChild size="lg" className={CTA_SINIF}>
<Button asChild size="lg" variant="cta" className={CTA_SINIF}>
<Link href="/listem?uret=1&kaynak=bos_durum">
Yapay Zeka listeni kur
<ArrowRight className="size-4" aria-hidden />
@@ -41,6 +41,7 @@ export function ListemBosCta() {
<Button
type="button"
size="lg"
variant="cta"
className={CTA_SINIF}
onClick={() => profilToplamaAc("sihirbaz", true, "listem_bos")}
>

View File

@@ -58,9 +58,9 @@ const SUTUN_ARALIGI = 24;
const kolonTabani = () =>
window.matchMedia("(min-width: 1280px)").matches ? 440 : 380;
// Başlık satırındaki ikincil düğmeler: 44 px, hover tek turuncu (§4.1, §2.1)
const IKINCIL_BUTON =
"h-11 cursor-pointer px-4 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.97]";
// Başlık satırındaki ikincil düğmeler: `variant="ikincil"` (hover ve basma
// tepkisi varyanttan, §4.1); burada yalnız 44 px boy ve yatay boşluk kalır.
const IKINCIL_BUTON = "h-11 px-4";
export function ListemGovde({
rapor,
@@ -293,7 +293,10 @@ export function ListemGovde({
)}
</p>
</div>
<div className="flex shrink-0 flex-wrap gap-2">
{/* `shrink-0` yok: üç düğme 375 px'te 343 px'lik sütuna sığmıyor,
grup küçülemeyince PDF sağdan taşıyordu (10 Eki 2026, ölçüldü);
grup daralır, düğmeler alt satıra sarar. */}
<div className="flex max-w-full flex-wrap gap-2">
{/* Tercih düzenleme her kullanıcıya açık: kapı (layout'ta monte
TercihProfiliKapisi) profili günceller, /listem'e döner.
Profil kayıtlı listeden ayrıldığı anda bu buton "Tercihlerin
@@ -316,7 +319,7 @@ export function ListemGovde({
) : (
<Button
type="button"
variant="outline"
variant="ikincil"
onClick={profilDuzenlemeyiAc}
className={IKINCIL_BUTON}
>
@@ -332,7 +335,7 @@ export function ListemGovde({
yeniden kurar. Sayı sınırı yok (7 Eki 2026). */}
<Button
type="button"
variant="outline"
variant="ikincil"
onClick={() => sohbetiOdakla("Listemde şunu değiştir: ")}
className={IKINCIL_BUTON}
>
@@ -341,7 +344,7 @@ export function ListemGovde({
</Button>
<Button
asChild
variant="outline"
variant="ikincil"
className={IKINCIL_BUTON}
>
<Link href="/rapor/yazdir" target="_blank">
@@ -372,8 +375,10 @@ export function ListemGovde({
className="flex flex-col items-start gap-3 sm:flex-row sm:items-center sm:justify-between"
>
<div className="flex gap-3">
<span className="flex size-10 shrink-0 items-center justify-center rounded-full bg-orange-100 text-orange-700">
<Lock className="size-4" aria-hidden />
{/* §4.4 ikon kutusu (fark modalındaki kutuyla aynı dil);
orange-100/700 §2.1'in "tek turuncu" kuralı dışındaydı */}
<span className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-primary/10">
<Lock className="size-5 text-primary" aria-hidden />
</span>
<div>
<p className="font-heading font-bold text-slate-900">
@@ -389,11 +394,13 @@ export function ListemGovde({
</div>
<Button
asChild
className="h-11 w-full shrink-0 cursor-pointer bg-orange-500 text-white transition-[background-color,transform] duration-200 active:scale-[0.97] hover:bg-orange-600 sm:w-auto"
size="lg"
variant="cta"
className="w-full shrink-0 sm:w-auto"
>
<Link href="/paket?kaynak=kilit">
Analizleri aç — {paketFiyati} TL
<ArrowRight data-icon="inline-end" aria-hidden />
<ArrowRight className="size-4" aria-hidden />
</Link>
</Button>
</div>
@@ -489,7 +496,7 @@ export function ListemGovde({
{[
"24 tercihin tamamında kişisel risk analizi",
"Her program için kısa, gerekçeli Yapay Zeka yorumu",
"2 liste revizyonu ve paylaşılabilir PDF",
"Sınırsız liste revizyonu ve paylaşılabilir PDF",
`${paketKredi} Yapay Zeka danışman sorusu`,
].map((ozellik) => (
<li key={ozellik} className="flex items-start gap-2.5">
@@ -509,14 +516,10 @@ export function ListemGovde({
</p>
</div>
<Button
asChild
size="lg"
className="mt-5 w-full cursor-pointer bg-orange-500 text-white transition-[background-color,transform] duration-200 active:scale-[0.97] hover:bg-orange-600"
>
<Button asChild size="lg" variant="cta" className="mt-5 w-full">
<Link href="/paket?kaynak=kilit">
Kişisel analizi aç
<ArrowRight data-icon="inline-end" aria-hidden />
<ArrowRight className="size-4" aria-hidden />
</Link>
</Button>

View File

@@ -1,6 +1,7 @@
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { Sparkles } from "lucide-react";
import { Check } from "lucide-react";
import { AnimasyonluMark } from "@/components/animasyonlu-mark";
import { verifySession, getCurrentUser } from "@/lib/session";
import {
PROFIL_CEREZ_ADI,
@@ -69,7 +70,9 @@ export async function ListemIcerik({
if (!raporVar) {
const bosDurum = (
<section className="mx-auto flex max-w-2xl flex-col items-center px-4 py-16 text-center">
<Sparkles className="size-10 text-primary" aria-hidden />
{/* Boş durumun tepesinde yıldız değil marka işareti (§7 "AI izi";
§11.2 "önce imza, sonra içerik"); durağan. */}
<AnimasyonluMark oynat={false} className="size-10" />
<h1 className="mt-4 font-heading text-2xl font-bold">
Henüz Yapay Zeka listen yok
</h1>
@@ -148,7 +151,7 @@ export async function ListemIcerik({
role="status"
className="mb-6 flex items-center gap-3 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-medium text-emerald-800"
>
<Sparkles className="size-4 shrink-0" aria-hidden />
<Check className="size-4 shrink-0" aria-hidden />
Paketin aktif — listendeki tercihlerin tamamı, gerekçeler, riskler ve
PDF artık açık.
</div>

View File

@@ -82,6 +82,7 @@ export function RaporSatirDetayi({
riskNotu,
trendOzeti,
oneCikan,
sikistir,
}: {
dilim: string;
risk: RiskSeviyesi;
@@ -91,11 +92,18 @@ export function RaporSatirDetayi({
trendOzeti: string;
/** Koddan hesaplanan "öne çıkan" veriler (uyum puanı); yoksa satır çizilmez */
oneCikan?: string[];
/** Sıkı ölçü (tadımlık, dar sütun): metin bir kademe küçük, kutu dolgusu
* bir kademe az. Aynı iskelet, aynı etiketler — yalnız ölçü değişir. */
sikistir?: boolean;
}) {
const stil = RISK_STIL[risk];
// Risk etiketi yalnızca dilimin varsayılanından farklıysa yazılır; yoksa
// üçüncü dilimde başlık "Güvenli · Güvenli" olurdu (bkz. risk.ts).
const farkliRisk = dilimdenFarkliRiskEtiketi(dilim, risk);
const kutu = cn(
"rounded-xl border border-slate-200 bg-card",
sikistir ? "p-3" : "p-4",
);
return (
<>
<div className="mb-3 flex flex-wrap items-center gap-2 text-sm text-slate-500">
@@ -108,8 +116,13 @@ export function RaporSatirDetayi({
<span>· {UNITUR_ETIKET[unitur]?.cip ?? unitur}</span>
) : null}
</div>
<dl className="grid gap-3 text-base leading-relaxed sm:grid-cols-2">
<div className="rounded-xl border border-slate-200 bg-card p-4">
<dl
className={cn(
"grid leading-relaxed sm:grid-cols-2",
sikistir ? "gap-2 text-sm" : "gap-3 text-base",
)}
>
<div className={kutu}>
<dt className="text-sm font-semibold text-slate-900">
Neden listede?
</dt>
@@ -121,7 +134,7 @@ export function RaporSatirDetayi({
</dd>
) : null}
</div>
<div className="rounded-xl border border-slate-200 bg-card p-4">
<div className={kutu}>
<dt className="text-sm font-semibold text-slate-900">
Risk ve Yapay Zeka yorumu
</dt>

View File

@@ -5,20 +5,21 @@ import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport, type ChatTransport, type UIMessage } from "ai";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { animate, useReducedMotion } from "motion/react";
import { AnimatePresence, animate, motion, useReducedMotion } from "motion/react";
import {
ArrowRight,
ArrowUp,
Coins,
Info,
Lock,
Maximize2,
Minimize2,
Sparkles,
Trash2,
X,
} from "lucide-react";
import { toast } from "@/components/ui/bildirim";
import { cn } from "@/lib/utils";
import { YAY } from "@/lib/hareket";
import { SayiRulo } from "@/components/ui/sayi-rulo";
import { AnimasyonluMark } from "@/components/animasyonlu-mark";
import { AracPilleri, aracPilleri } from "./arac-pilleri";
@@ -168,8 +169,10 @@ function cevapAnahtari(mesajlar: UIMessage[], i: number): string {
// Araç adı → öğrenci dili eşlemesi ve "…bakıyorum" hapları arac-pilleri.tsx'te.
/** Panelin boyut geçişi: sekmesiz yay (DESIGN.md §6); listem-govde ızgarası aynı yayı kullanır. */
export const SOHBET_GENISLEME_GECISI = { type: "spring", bounce: 0, duration: 0.35 } as const;
/** Panelin boyut geçişi: sihirbaz adım paneliyle AYNI yay (`YAY.adim`,
* adim-gecisi.ts; Bilal, 10 Eki 2026: "danışmanla konuş'taki motion wizard
* ile aynı olsun"). listem-govde ızgarası ve sayfa kaydırması da bunu kullanır. */
export const SOHBET_GENISLEME_GECISI = YAY.adim;
/**
* Genişletilmiş panel üstten `top-28` (7rem; listem sütununun sticky ofseti)
@@ -198,6 +201,28 @@ const GENIS_EN_AZ_PX = 380;
/** /dev/sohbet gibi sticky'siz yerlerde de aynı hesap; sabit yükseklik istenirse `genisYukseklik`. */
const GENIS_YUKSEKLIK = "";
/**
* Panel başlığındaki ikon düğmeleri (genişlet / bilgi / temizle): görünür
* 32 px (paneldeki h-8 çip ve araç haplarıyla aynı boy), dokunma alanı
* görünmez taşmayla 44 px (§8) — eski `size-7` hem 44'ün altındaydı hem ikonu
* çip boyutunda bırakıyordu. 36 px (`size-9`) denendi (10 Eki 2026): 375 px'te
* başlık "DanışmanlaK…" diye kırpılıyordu; 32 px'te üç düğme + kredi hapı +
* başlık yan yana sığıyor.
*/
const BASLIK_DUGMESI =
"relative inline-flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-full border border-slate-200 bg-card text-slate-600 transition-[background-color,color,transform] duration-150 before:absolute before:-inset-1.5 before:content-[''] hover:bg-slate-100 hover:text-slate-800 active:scale-[0.96] motion-reduce:active:scale-100";
/**
* Kredi hapı: üst çubuktaki kredi hapının sınıf dizisi (user-nav.tsx; §11.1 V4
* "çubuk kullanıcının durumudur" — aynı durum burada da aynı biçimde durur),
* yalnız boy panelin ölçeğinde (h-8, başlık düğmeleri ve çiplerle aynı).
* Eski amber hap bu üründe risk dilinin "az riskli"
* tonuydu (tercih-degisti-modali.tsx notu), kredi için yanlış sinyaldi.
* "kredi" sözcüğü yalnız sm'den itibaren: 375 px'te başlık kırpılmasın.
*/
const KREDI_HAPI =
"inline-flex h-8 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-full border border-slate-200 bg-card px-2.5 text-xs font-semibold text-slate-700 transition-colors duration-200 hover:border-slate-300 hover:bg-slate-50 sm:px-3";
type Red = { status: number; error?: string };
/**
@@ -725,31 +750,35 @@ export function SohbetClient({
Temizle butonu ikon-only — metinli hâli başlıkla yarışıp taşıyordu. */}
<div className="flex items-center gap-2 border-b border-slate-100 px-4 py-3">
<DanismanBaslik />
<div className="flex shrink-0 items-center gap-1.5">
<div className="flex shrink-0 items-center gap-1">
<button
type="button"
onClick={genisligiDegistir}
aria-pressed={genis}
aria-label={genis ? "Sohbeti daralt" : "Sohbeti genişlet"}
title={genis ? "Daralt" : "Genişlet"}
className="inline-flex size-7 cursor-pointer items-center justify-center rounded-full border border-slate-200 bg-card text-slate-600 transition-[background-color,color,transform] duration-150 hover:bg-slate-100 hover:text-slate-800 active:scale-[0.96]"
className={BASLIK_DUGMESI}
>
{genis ? (
<Minimize2 className="size-3.5" aria-hidden />
<Minimize2 className="size-4" aria-hidden />
) : (
<Maximize2 className="size-3.5" aria-hidden />
<Maximize2 className="size-4" aria-hidden />
)}
</button>
{bildikleri?.length ? (
<TooltipProvider delayDuration={120}>
<Tooltip>
<TooltipTrigger asChild>
{/* Telefonda (sm altı) gizli: ipucu hover/odakla açılır,
dokunmatikte zaten açılmıyordu (ölü düğme) ve 375 px'te
başlığı kırpıyordu. Dokunmayla açılan karşılığı (popover)
ayrı iş — raporda. */}
<button
type="button"
aria-label="Danışmanın bildikleri"
className="inline-flex size-7 cursor-help items-center justify-center rounded-full border border-slate-200 bg-card text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-800"
className={cn(BASLIK_DUGMESI, "cursor-help max-sm:hidden")}
>
<Info className="size-3.5" aria-hidden />
<Info className="size-4" aria-hidden />
</button>
</TooltipTrigger>
<TooltipContent
@@ -778,19 +807,25 @@ export function SohbetClient({
onClick={() => setTemizleAcik(true)}
disabled={bekliyor || temizleniyor}
aria-label="Sohbeti temizle"
className="inline-flex size-7 cursor-pointer items-center justify-center rounded-full border border-slate-200 bg-card text-slate-600 transition-[background-color,transform] duration-150 hover:bg-slate-100 hover:text-slate-800 active:scale-[0.96] disabled:cursor-not-allowed disabled:opacity-50"
className={cn(
BASLIK_DUGMESI,
"disabled:cursor-not-allowed disabled:opacity-50",
)}
title="Sohbet geçmişini sil"
>
<Trash2 className="size-3.5" aria-hidden />
<Trash2 className="size-4" aria-hidden />
</button>
) : null}
<Link
href="/paket?kaynak=danisman_rozet"
className="inline-flex items-center gap-1.5 rounded-full border border-amber-200 bg-amber-50 px-2.5 py-1 text-xs font-semibold text-amber-700 transition-colors hover:bg-amber-100"
className={KREDI_HAPI}
title="Kredilerin — yüklemek için tıkla"
>
<Coins className="size-3.5" aria-hidden />
<SayiRulo deger={kredi} /> kredi
<Coins className="size-3.5 text-amber-500" aria-hidden />
<span className="tabular-nums">
<SayiRulo deger={kredi} />
</span>
<span className="hidden sm:inline">kredi</span>
</Link>
</div>
</div>
@@ -804,26 +839,39 @@ export function SohbetClient({
<span className="text-xs font-semibold text-slate-500">
Kaydettiklerim
</span>
{profil.map((s) => (
// Etkileşimli çip (§4.17 kalıbı, kompakt h-8): rozet değil.
<span
key={s.alan}
className="inline-flex h-8 items-center gap-1 rounded-full border border-slate-200 bg-card pr-1 pl-3 text-xs font-semibold text-slate-700"
>
{s.metin}
<button
type="button"
onClick={() => profilAlaniSil(s.alan)}
aria-label={`Sil: ${s.metin}`}
className="inline-flex size-6 cursor-pointer items-center justify-center rounded-full text-slate-500 transition-[background-color,color,transform] duration-150 hover:bg-slate-100 hover:text-orange-500 active:scale-[0.96]"
{/* Çip silinince yerinden küçülerek söner, komşular kayarak yerini
kapatır (§11.2 "değişimi göster"; anında kaybolan çip sessiz
eylemdi). Hareketi azalt'ta yalnız opaklık. */}
<AnimatePresence initial={false}>
{profil.map((s) => (
// Etkileşimli çip (§4.17 kalıbı, kompakt h-8): rozet değil.
<motion.span
key={s.alan}
layout={!hareketAz}
initial={{ opacity: 0, scale: hareketAz ? 1 : 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: hareketAz ? 1 : 0.9 }}
transition={YAY.varsayilan}
className="inline-flex h-8 items-center gap-1 rounded-full border border-slate-200 bg-card pr-1 pl-3 text-xs font-semibold text-slate-700"
>
<X className="size-3.5" aria-hidden />
</button>
</span>
))}
{s.metin}
{/* Görünür 24 px, dokunma alanı taşmayla 44 px (§8) */}
<button
type="button"
onClick={() => profilAlaniSil(s.alan)}
aria-label={`Sil: ${s.metin}`}
className="relative inline-flex size-6 cursor-pointer items-center justify-center rounded-full text-slate-500 transition-[background-color,color,transform] duration-150 before:absolute before:-inset-2.5 before:content-[''] hover:bg-slate-100 hover:text-orange-500 active:scale-[0.96] motion-reduce:active:scale-100"
>
<X className="size-3.5" aria-hidden />
</button>
</motion.span>
))}
</AnimatePresence>
</div>
) : null}
{/* Onay modalı: yüzey ve başlık DialogContent/DialogTitle varsayılanı
(§4.4 sade kutu, §3 h3); aksiyonlar tek kaynak varyantlar (§4.1). */}
<Dialog open={temizleAcik} onOpenChange={setTemizleAcik}>
<DialogContent>
<DialogHeader>
@@ -836,15 +884,16 @@ export function SohbetClient({
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
className="cursor-pointer"
variant="ikincil"
size="lg"
onClick={() => setTemizleAcik(false)}
disabled={temizleniyor}
>
Vazgeç
</Button>
<Button
className="cursor-pointer bg-orange-500 text-white hover:bg-orange-600"
variant="cta"
size="lg"
onClick={sohbetiTemizle}
disabled={temizleniyor}
>
@@ -920,9 +969,12 @@ export function SohbetClient({
>
{!ozet ? (
<>
<Sparkles
className="mx-auto size-7 text-primary"
aria-hidden
{/* Boş durumda yıldız değil marka işareti: önce imza,
sonra içerik (§11.2); durağan — hareket "düşünüyor" demek. */}
<AnimasyonluMark
oynat={false}
renk="turuncu"
className="mx-auto size-8"
/>
<div>
<p className="font-heading font-bold">
@@ -1058,16 +1110,17 @@ export function SohbetClient({
<div className="border-t border-slate-100 p-3">
{krediYok ? (
<div className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3">
<p className="text-sm font-medium text-amber-800">
// Uyarı kutusu (§2 tablo: amber bilgilendirme notu) + kanonik CTA;
// eski h-7 buton 44 px'in altındaydı (§8).
<div className="flex flex-col gap-3 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-4">
<p className="text-sm font-medium text-amber-900">
Kredin bitti — sohbete devam etmek için kredi yükle.
</p>
<Button
asChild
size="sm"
className="cursor-pointer bg-orange-500 text-white hover:bg-orange-600"
>
<Link href="/paket?kaynak=danisman_kredi_bitti">Kredi yükle</Link>
<Button asChild size="lg" variant="cta" className="w-full">
<Link href="/paket?kaynak=danisman_kredi_bitti">
Kredi yükle
<ArrowRight className="size-4" aria-hidden />
</Link>
</Button>
</div>
) : (
@@ -1102,9 +1155,9 @@ export function SohbetClient({
<PromptInputAction tooltip="Gönder (Enter)">
<Button
size="icon"
variant="cta"
onClick={() => gonder(girdi)}
disabled={bekliyor || !girdi.trim()}
className="size-9 cursor-pointer rounded-full bg-orange-500 text-white hover:bg-orange-600"
aria-label="Gönder"
>
{bekliyor ? <DotsLoader size="sm" /> : <ArrowUp className="size-4" aria-hidden />}

View File

@@ -7,10 +7,17 @@
// SSR "genel" satırı render eder (prop), mount sonrası sihirbaz kategorisi
// varsa /api/tadimlik'ten kategoriye özel satır çekilip yerinde değiştirilir
// (seçim sunucuda kalır). Havuzda içerik yoksa hiç render edilmez.
//
// Yerleşim (10 Eki 2026, Bilal: "tadımlık çok büyük yer kaplıyor, pazarlama
// tarafı eksik"): tek kart, iki sütun — solda öğrencinin kendi sırasına bağlı
// başlık + üç kanıt satırı + CTA (PikselCtaYuzeyi'nin "metin solda, kreatif
// sağda" kurgusu), sağda örnek satır. Telefonda sıra: başlık → örnek → CTA.
// Kutu içinde kutu yok: örnek alanı kartın kendi bandı (border + bg-slate-50),
// ayrı yuvarlatılmış iç kutu değil. Ölçüm 1280 px: 599 → ~370 px.
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import { ArrowRight } from "lucide-react";
import { ArrowRight, Check } from "lucide-react";
import { Button } from "@/components/ui/button";
import { UniLogo } from "@/components/uni-logo";
import { SectionEyebrow } from "@/components/pixel-decor";
@@ -23,9 +30,10 @@ import {
} from "@/components/program-liste-verileri";
import { useGorunumOlayi } from "@/components/use-gorunum-olayi";
import { olay, siraKovasi } from "@/lib/analitik";
import { dilimdenRisk, riskHesapla } from "@/lib/risk";
import { DILIM_ETIKET, dilimdenRisk, riskHesapla } from "@/lib/risk";
import { ilSayfaHref, trBaslikDuzeni, turkishSlugify } from "@/lib/slug";
import { cn } from "@/lib/utils";
import { VERI_ARALIGI, VERI_YIL_SAYISI } from "@/lib/veri-yillari";
import { TurCipi } from "@/components/tur-cipi";
import {
SATIR_ALT_AD,
@@ -47,6 +55,16 @@ function tipeUyar(program: TadimlikSatir["program"], tip: TadimlikTip): boolean
return program.unitur != null && program.unitur !== "DEVLET";
}
// Üç kanıt satırı: marka mesaj mimarisinin üç sütunu (docs/ekip-raporlari/
// 2026-10-07/founder-arastirma-06 §2: veri, yöntem açıklığı, dürüst vaat).
// Rakamlar tek kaynaktan: dilim adları risk.ts, veri yılları veri-yillari.ts.
// Vaat cümlesi yok (§10, §7). Her satır 18 rem sütunda tek satıra sığacak
// kadar kısa tutulur (sütun 21 rem, ölçüm 1280 px) — uzatınca kart yeniden uzar.
const KANITLAR: readonly string[] = [
`3 risk dilimi: ${Object.values(DILIM_ETIKET).join(" · ")}`,
`${VERI_ARALIGI} YÖK Atlas verisi, ${VERI_YIL_SAYISI} yıl`,
];
export function TadimlikSatiri({
sira,
tur,
@@ -58,6 +76,12 @@ export function TadimlikSatiri({
ilk: TadimlikSatir | null;
}) {
const [satir, setSatir] = useState<TadimlikSatir | null>(ilk);
// Gösterilen satır sihirbaz seçimine (alan / devlet-vakıf) göre mi seçildi?
// Etiket bunu söyler (DESIGN.md §11.2 md. 1: kullanıcının durumu yansır).
const [secimeOzel, setSecimeOzel] = useState(false);
// Satır mount sonrası değiştiyse yeni satır blur çözülerek belirir
// (§11.2 md. 6: değişimi göster); ilk boyada sahne yok (§11.2 md. 8).
const [degisti, setDegisti] = useState(false);
const yuklenenAnahtar = useRef(`${GENEL}|${GENEL}`);
// Sihirbaz kategorisi ve devlet/vakıf tercihi yalnızca localStorage'da
@@ -78,6 +102,8 @@ export function TadimlikSatiri({
yuklenenAnahtar.current = anahtar;
if (slug === GENEL && tip === GENEL) {
setSatir(ilk);
setSecimeOzel(false);
setDegisti(true);
return;
}
// SSR "genel" satırı seçilen tipe uymuyorsa fetch bitene kadar gizle
@@ -94,6 +120,8 @@ export function TadimlikSatiri({
if (yuklenenAnahtar.current !== anahtar) return;
if (veri?.tadimlik) {
setSatir(veri.tadimlik);
setSecimeOzel(true);
setDegisti(true);
} else if (tip !== GENEL) {
// Uygun tipte satır yok: yanlış tip göstermektense gizle
setSatir(null);
@@ -128,97 +156,139 @@ export function TadimlikSatiri({
const girisUrl = `/giris?callback=${encodeURIComponent(listemUretHref("tadimlik"))}`;
return (
// /sonuc'ta artık ilk öğe sonuç başlığı (h1); tadımlık onun altında
// (listesi olanda haritanın ve tablonun altında) mt-12 ile durur.
// Tadımlığın render edilmediği durumlarda (girişli kullanıcı, boş havuz)
// bu bileşen hiç dönmez.
<section ref={gorunumRef} className="mt-12 min-w-0">
<div className="flex flex-col items-center text-center">
<SectionEyebrow>Yapay zekâdan örnek satır</SectionEyebrow>
<h2 className="mt-4 font-heading text-2xl font-bold sm:text-3xl">
Yapay zekâ listendeki her satırı böyle anlatır
</h2>
{/* "ilk üçü" = rapor-maske.ts içindeki ACIK_SATIR (3). O dosya
`server-only`, bu dosya "use client" — sabit buraya import
edilemiyor, sayı elle yazıldı. ACIK_SATIR değişirse bu cümle de
aynı commit'te değişmeli. Cümle kasten "her satır bu formatta
kurulur"u sınırlıyor: maske girişsiz/paketsiz kullanıcıya 3 satır
açıyor, kalan 21'in gerekçe/risk/trend metni maskeli geliyor. */}
<p className="mx-auto mt-3 max-w-xl text-slate-600">
Sıralamana uygun bir programın gerekçesi, yerleşme riski ve taban
trendi — 24 tercihlik listenin her satırı bu formatta kurulur, ilk
üçü hemen açılır.
</p>
</div>
<div className="mt-6 rounded-2xl border border-slate-200 bg-card p-4 shadow-sm sm:p-6">
{/* Rapor listesinin satırıyla aynı düzen (§4.15): ad sarar,
üniversite adı altında, taban sıra sağda iri. */}
<div className="flex items-start gap-3">
<UniLogo ad={p.universite} boy="sm" />
<span className="min-w-0 flex-1">
<span className={SATIR_AD_DUZ}>{p.isim}</span>
<span className={SATIR_ALT_GRUP}>
<span className={SATIR_ALT_AD}>
{trBaslikDuzeni(p.universite)}
</span>
<span className={SATIR_META}>
<TurCipi unitur={p.unitur} />
{p.il ? (
<Link
href={ilSayfaHref(p.il)}
className={cn("shrink-0", SATIR_IKINCIL_LINK)}
title={`${trBaslikDuzeni(p.il)} üniversiteleri`}
>
{trBaslikDuzeni(p.il)}
</Link>
) : null}
</span>
</span>
</span>
<span className="hidden text-right md:inline-flex">
<ProgramSiraGecmisi program={seri} />
</span>
<span className="inline-flex shrink-0 items-center gap-1.5 text-base font-semibold tabular-nums text-slate-900">
<ProgramTabanTrendi program={seri} />
{taban != null ? `~${taban.toLocaleString("tr-TR")}.` : "—"}
</span>
</div>
<div className="mt-4 rounded-xl bg-slate-50 px-4 py-5">
<RaporSatirDetayi
dilim={satir.dilim}
risk={risk}
unitur={p.unitur}
gerekce={satir.gerekce}
riskNotu={satir.riskNotu}
trendOzeti={satir.trendOzeti}
/>
</div>
<div className="mt-5 flex flex-col items-start gap-3 border-t border-slate-100 pt-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-slate-600">
Bu, 24 satırlık Yapay Zeka listenden yalnızca bir örnek.
<span className="block text-xs text-slate-500">
Kredi kartı gerekmez · Google ya da e-posta ile 30 saniyede ·
Giriş sonrası liste hemen üretilir
</span>
</p>
<Button
asChild
className="h-12 w-full shrink-0 cursor-pointer bg-orange-500 text-white transition-[background-color,transform] duration-200 active:scale-[0.97] hover:bg-orange-600 sm:w-auto"
>
<Link
href={girisUrl}
onClick={() => olay("giris_cta_tiklandi", { kaynak: "tadimlik" })}
// /sonuc'ta ilk öğe sonuç başlığı (h1); tadımlık onun altında (listesi
// olanda haritanın ve tablonun altında) durur. Tadımlığın render
// edilmediği durumlarda (girişli kullanıcı, boş havuz) bileşen hiç dönmez.
// data-tadimlik: QA/ölçüm kancası (yükseklik ölçümü), stil taşımaz.
<section
ref={gorunumRef}
data-tadimlik
aria-labelledby="tadimlik-baslik"
className="mt-10 min-w-0"
>
<div className="overflow-hidden rounded-2xl border border-slate-200 bg-card shadow-sm">
<div className="grid lg:grid-cols-[minmax(0,21rem)_minmax(0,1fr)] lg:grid-rows-[1fr_auto]">
{/* Sol üst: öğrencinin sırasına bağlı başlık + üç kanıt.
Sayı kahraman (§1.1): sıra başlıkta mavi vurguyla. */}
<div className="p-5 lg:pb-0">
<SectionEyebrow>Yapay zekâ listesi</SectionEyebrow>
<h2
id="tadimlik-baslik"
className="mt-4 font-heading text-2xl font-bold leading-tight text-balance text-slate-900"
>
{/* Buton metni listenin tamamını vaat etmez: maske girişsiz
kullanıcıya 24 satırdan yalnız ilk birkaçının analizini
açıyor (rapor-maske.ts), gerisi teslim edilmiyor. */}
Kendi listemi oluştur
<ArrowRight className="size-4" aria-hidden />
</Link>
</Button>
<span className="text-primary">{sira.toLocaleString("tr-TR")}.</span>{" "}
sıra için 24 tercih, her satırı böyle gerekçeli
</h2>
<ul className="mt-4 space-y-2 text-sm leading-snug text-slate-600">
{KANITLAR.map((kanit) => (
<li key={kanit} className="flex items-start gap-2">
<Check className="mt-0.5 size-4 shrink-0 text-primary" aria-hidden />
<span>{kanit}</span>
</li>
))}
{/* Üçüncü kanıt: dürüst vaat + yöntem açıklığı tek satırda;
link ne açtığını söyler (§10.4). */}
<li className="flex items-start gap-2">
<Check className="mt-0.5 size-4 shrink-0 text-primary" aria-hidden />
<span>
Garanti, komisyon, reklam yok;{" "}
{/* nowrap: telefonda satır sonunda "açık" tek başına kalmasın */}
<Link
href="/meraklisina"
className="whitespace-nowrap text-primary underline"
>
yöntem açık
</Link>
</span>
</li>
</ul>
</div>
{/* Sağ: gerçek rapor satırı — kartın kendi bandı (§4.15 satır +
RaporSatirDetayi), yuvarlatılmış iç kutu değil. lg'de iki
satır boyu, sol sütunun yanında. */}
<div className="border-t border-slate-200 bg-slate-50 p-4 sm:p-5 lg:row-span-2 lg:border-t-0 lg:border-l">
<p className="text-xs font-semibold text-slate-500">
{secimeOzel
? "Sırana ve seçimlerine uygun örnek satır"
: "Sırana uygun örnek satır"}
</p>
<div
key={`${p.universite}|${p.isim}`}
className={cn("mt-3", degisti && "kt-unblur")}
>
<div className="flex items-start gap-3">
{/* Telefonda logo gizli — rapor listesiyle aynı karar (§4.15:
logo adı üç satıra sıkıştırıyordu). */}
<UniLogo ad={p.universite} boy="sm" className="hidden sm:flex" />
<span className="min-w-0 flex-1">
<span className={SATIR_AD_DUZ}>{p.isim}</span>
<span className={SATIR_ALT_GRUP}>
<span className={SATIR_ALT_AD}>
{trBaslikDuzeni(p.universite)}
</span>
<span className={SATIR_META}>
<TurCipi unitur={p.unitur} />
{p.il ? (
<Link
href={ilSayfaHref(p.il)}
className={cn("shrink-0", SATIR_IKINCIL_LINK)}
title={`${trBaslikDuzeni(p.il)} üniversiteleri`}
>
{trBaslikDuzeni(p.il)}
</Link>
) : null}
</span>
</span>
</span>
<span className="hidden text-right md:inline-flex">
<ProgramSiraGecmisi program={seri} />
</span>
<span className="inline-flex shrink-0 items-center gap-1.5 text-base font-semibold tabular-nums text-slate-900">
<ProgramTabanTrendi program={seri} />
{taban != null ? `~${taban.toLocaleString("tr-TR")}.` : "—"}
</span>
</div>
<div className="mt-4">
<RaporSatirDetayi
dilim={satir.dilim}
risk={risk}
unitur={p.unitur}
gerekce={satir.gerekce}
riskNotu={satir.riskNotu}
trendOzeti={satir.trendOzeti}
sikistir
/>
</div>
</div>
</div>
{/* Sol alt: CTA. Telefonda örneğin altına iner (önce kanıt, sonra
eylem); lg'de sol sütunun dibinde, örnekle aynı hizada biter. */}
<div className="border-t border-slate-200 p-5 lg:border-t-0 lg:pt-4">
<Button asChild size="lg" variant="cta" className="w-full">
<Link
href={girisUrl}
onClick={() => olay("giris_cta_tiklandi", { kaynak: "tadimlik" })}
>
{/* Buton metni listenin tamamını vaat etmez: maske girişsiz
kullanıcıya 24 satırdan yalnız ilk birkaçının analizini
açıyor (rapor-maske.ts), gerisi teslim edilmiyor. */}
Kendi listemi oluştur
<ArrowRight className="size-4" aria-hidden />
</Link>
</Button>
{/* "İlk 3 satır" = rapor-maske.ts içindeki ACIK_SATIR (3). O dosya
`server-only`, bu dosya "use client" — sabit buraya import
edilemiyor, sayı elle yazıldı. ACIK_SATIR değişirse bu cümle de
aynı commit'te değişmeli. Cümle kasten sınırlıyor: maske
girişsiz/paketsiz kullanıcıya 3 satır açıyor, kalan 21'in
gerekçe/risk/trend metni maskeli geliyor. */}
<p className="mt-3 text-center text-xs leading-relaxed text-slate-500">
Kredi kartı gerekmez · 30 saniyede giriş · İlk 3 satır hemen açık
</p>
</div>
</div>
</div>
</section>

View File

@@ -161,7 +161,7 @@ export function TercihDegistiModali({
<Check className="size-5" aria-hidden />
</span>
<div className="flex flex-col gap-1.5">
<DialogTitle className="text-lg">Listen güncellendi</DialogTitle>
<DialogTitle>Listen güncellendi</DialogTitle>
<DialogDescription>
Yeni tercihlerine göre yeniden kuruldu — arkadaki liste birkaç
saniye içinde tazelenir.
@@ -169,8 +169,10 @@ export function TercihDegistiModali({
</div>
<Button
type="button"
size="lg"
variant="cta"
onClick={() => onOpenChange(false)}
className="cursor-pointer self-start transition-[background-color,transform] duration-200 active:scale-[0.97]"
className="self-start"
>
Listeme dön
<ArrowRight className="size-4" aria-hidden />
@@ -183,9 +185,7 @@ export function TercihDegistiModali({
<SlidersHorizontal className="size-5" aria-hidden />
</span>
<div className="flex min-w-0 flex-col gap-1.5">
<DialogTitle className="text-lg">
Tercihlerin değişmiş
</DialogTitle>
<DialogTitle>Tercihlerin değişmiş</DialogTitle>
{/* Hangi alanların değiştiği hemen altındaki diff
tablosunda zaten duruyor; açıklama yalnızca sonucu ve
bedeli söylüyor. */}
@@ -265,27 +265,31 @@ export function TercihDegistiModali({
Güncelleme {raporKredi} kredi; bakiyen {kredi} kredi.
</p>
) : null}
<div className="flex flex-wrap items-center justify-end gap-2">
{/* Telefonda alt alta tam genişlik (ana aksiyon üstte), sm'den
itibaren sağa yaslı; 44 px dokunma hedefi (§8). */}
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:flex-wrap sm:justify-end">
<Button
type="button"
variant="outline"
size="lg"
variant="ikincil"
onClick={() => onOpenChange(false)}
className="cursor-pointer transition-[background-color,transform] duration-200 active:scale-[0.97]"
>
Şimdilik kalsın
</Button>
{durum.ad === "hata" ? (
<Button
size="lg"
variant="cta"
onClick={() => void guncelle()}
className="cursor-pointer bg-orange-500 text-white transition-[background-color,transform] duration-200 active:scale-[0.97] hover:bg-orange-600"
>
<RefreshCcw className="size-4" aria-hidden />
Tekrar dene
<RefreshCcw className="size-4" aria-hidden />
</Button>
) : krediYeter ? (
<Button
size="lg"
variant="cta"
onClick={() => void guncelle()}
className="cursor-pointer bg-orange-500 text-white transition-[background-color,transform] duration-200 active:scale-[0.97] hover:bg-orange-600"
>
{ucretsiz
? "Listemi güncelle"
@@ -293,10 +297,7 @@ export function TercihDegistiModali({
<ArrowRight className="size-4" aria-hidden />
</Button>
) : (
<Button
asChild
className="cursor-pointer bg-orange-500 text-white transition-[background-color,transform] duration-200 active:scale-[0.97] hover:bg-orange-600"
>
<Button asChild size="lg" variant="cta">
<Link href="/paket?kaynak=tercih_degisti">
{hasPaket
? "Kredi yükle ve güncelle"
@@ -345,10 +346,7 @@ function KrediYetersiz({
? `Güncelleme ${raporKredi} kredi ve bakiyen yetmedi. Kredi yüklediğinde listen ve sohbet geçmişin duruyor, kaldığın yerden devam edersin.`
: `Güncelleme ${raporKredi} kredi ve bakiyen yetmedi. Tercih Dönemi Paketi'yle kredilerin, tam liste, PDF ve revizyon hakları birlikte gelir.`}
</p>
<Button
asChild
className="cursor-pointer bg-orange-500 text-white transition-[background-color,transform] duration-200 active:scale-[0.97] hover:bg-orange-600"
>
<Button asChild size="lg" variant="cta" className="w-full sm:w-auto">
<Link href="/paket?kaynak=tercih_degisti_yetersiz">
{hasPaket ? "Kredi yükle" : "Paketi aktive et"}
<ArrowRight className="size-4" aria-hidden />

View File

@@ -1,5 +1,6 @@
"use client";
import { logoTepkisi } from "@/lib/logo-tepkisi";
import { useId, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
@@ -211,6 +212,8 @@ export function CtaSiraForm({
if (sira == null) return;
const yeniProfil = { sira, tur, secimler };
tercihProfiliYaz(yeniProfil);
// Üst çubuktaki logo kürsüye döner (DESIGN.md §4.34).
logoTepkisi("sira");
setAcik(false);
router.push(sonucHref(girisli ? { sihirbaz: "1", kaynak: "cta" } : { hazir: "1" }));
}

View File

@@ -1,11 +1,13 @@
"use client";
import { logoTepkisi } from "@/lib/logo-tepkisi";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { ArrowRight } from "lucide-react";
import { ArrowRight, Trophy } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { SayiRulo } from "@/components/ui/sayi-rulo";
import { SecimHaplari } from "@/components/ui/secim-haplari";
import {
Dialog,
@@ -22,7 +24,8 @@ import { olay, siraKovasi } from "@/lib/analitik";
import { onbellekliJsonGetir } from "@/lib/istek-cache";
import { useTercihProfili } from "@/features/liste/hooks/use-tercih-profili";
import type { SihirbazFacetleri } from "@/types/yokatlas";
import { sonucHref, type SihirbazSecimleri } from "@/features/sihirbaz/sihirbaz-sabitler";
import { PUAN_TURU_ETIKET, sonucHref, type SihirbazSecimleri } from "@/features/sihirbaz/sihirbaz-sabitler";
import { ListeDurumuSatiri } from "@/features/liste/components/liste-durumu-satiri";
import { tercihProfiliYaz } from "@/features/sihirbaz/sihirbaz-profil";
// lib/db sunucu tarafı; etiketler client bundle'a sızmasın diye burada.
@@ -99,6 +102,8 @@ export function HeroForm() {
if (!sira) return;
const profil = { sira, tur, secimler };
tercihProfiliYaz(profil);
// Üst çubuktaki logo kürsüye döner (DESIGN.md §4.34).
logoTepkisi("sira");
setModalAcik(false);
router.push(sonucHref(girisli ? { sihirbaz: "1", kaynak: "hero" } : { hazir: "1" }));
}
@@ -108,16 +113,27 @@ export function HeroForm() {
if (kayitliProfil) {
return (
<div className="flex w-full max-w-md flex-col items-center">
{/* Üst çubuktaki sıralama hapının iri hâli (DESIGN.md §1.1 "sayı
kahramandır", §11.2 "durumu yansıt"): site seni tanıyor. */}
<p className="mb-5 flex items-center gap-2.5 font-heading text-3xl leading-none font-bold tracking-tight text-slate-900 sm:text-4xl">
<Trophy className="size-6 shrink-0 text-orange-500 sm:size-7" aria-hidden />
<SayiRulo deger={kayitliProfil.sira} />
<span className="font-sans text-base font-semibold tracking-normal text-slate-500">
· {PUAN_TURU_ETIKET[kayitliProfil.tur] ?? kayitliProfil.tur}
</span>
</p>
<Button
asChild
size="lg"
className="w-full cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600 sm:w-auto"
variant="cta"
className="w-full sm:w-auto"
>
<Link href={sonucHref()}>
Üniversitelere göz at
<ArrowRight className="size-4" aria-hidden />
</Link>
</Button>
<ListeDurumuSatiri varyant="hero" />
</div>
);
}
@@ -161,9 +177,9 @@ export function HeroForm() {
type="submit"
size="lg"
disabled={yukleniyor}
className="cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
variant="cta"
>
Yapay Zeka listemi kur
{yukleniyor ? "Hazırlanıyor…" : "Yapay Zeka listemi kur"}
<ArrowRight className="size-4" aria-hidden />
</Button>
</form>

View File

@@ -5,6 +5,7 @@
// dynamic import: hem hero-form hem sonuc/sihirbaz-modal burayı kullanır.
import dynamic from "next/dynamic";
import { AdimGostergesiIskeleti } from "@/components/ui/adim-gostergesi";
import { Skeleton } from "@/components/ui/skeleton";
function SihirbazAdimlarSkeleton() {
@@ -14,11 +15,8 @@ function SihirbazAdimlarSkeleton() {
aria-live="polite"
className="flex min-h-64 flex-col gap-5"
>
<div className="flex items-center gap-1.5">
<Skeleton className="h-1.5 w-6 rounded-full" />
<Skeleton className="size-1.5 rounded-full" />
<Skeleton className="size-1.5 rounded-full" />
<Skeleton className="ml-2 h-3 w-14" />
<div className="pr-12">
<AdimGostergesiIskeleti adet={3} />
</div>
<div>
@@ -36,8 +34,8 @@ function SihirbazAdimlarSkeleton() {
</div>
<div className="flex items-center justify-between border-t border-slate-100 pt-4">
<Skeleton className="h-9 w-20 rounded-lg" />
<Skeleton className="h-9 w-24 rounded-lg" />
<Skeleton className="h-11 w-20 rounded-full" />
<Skeleton className="h-11 w-24 rounded-full" />
</div>
</div>
);

View File

@@ -15,6 +15,7 @@ import { toast } from "@/components/ui/bildirim";
import { cities } from "turkey-map-react/lib/data";
import { TransitionPanel } from "@/components/core/transition-panel";
import { adimGecisi, adimGecisiVaryantlari } from "@/lib/adim-gecisi";
import { AdimGostergesi } from "@/components/ui/adim-gostergesi";
import { Button } from "@/components/ui/button";
import { IlSecimHaritasi } from "./il-secim-haritasi";
import { normalizeIlAdi } from "@/lib/harita";
@@ -214,41 +215,16 @@ export function SihirbazAdimlar({
return (
<div className="flex flex-col gap-5">
{/* Adım göstergesi (DESIGN.md §10): üç parçalı çubuk + adların
kendisi; öğrenci nerede olduğunu ve ne kaldığını görür. Değişim
ekran okuyucuya duyurulur (WCAG 4.1.3). */}
<div>
<ol className="grid grid-cols-3 gap-2" aria-label="Sihirbaz adımları">
{ADIM_ADLARI.map((ad, i) => (
<li key={ad} className="flex flex-col gap-1.5">
{/* Dolum transform ile (yalnız transform/opaklık canlandırılır);
soldan dolar, güçlü ease-out 300 ms. */}
<span className="h-1.5 overflow-hidden rounded-full bg-slate-200" aria-hidden>
<span
className={`block h-full origin-left rounded-full bg-primary transition-transform duration-300 ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none ${
i <= adim ? "scale-x-100" : "scale-x-0"
}`}
/>
</span>
<span
className={`text-sm ${
i === adim
? "font-semibold text-slate-900"
: i < adim
? "font-medium text-slate-600"
: "text-slate-400"
}`}
aria-current={i === adim ? "step" : undefined}
>
{ad}
</span>
</li>
))}
</ol>
<p role="status" className="sr-only">
Adım {adim + 1} / 3: {ADIM_ADLARI[adim]}
</p>
</div>
{/* Adım göstergesi (DESIGN.md §4.35, §10.2): numaralı, adlı; tamamlanan
adıma dokunarak geri dönülür. Değişimi ekran okuyucuya kendisi duyurur. */}
<AdimGostergesi
adimlar={ADIM_ADLARI}
aktif={adim}
onAdimSec={adimaGit}
ariaLabel="Sihirbaz adımları"
// Sağ üstteki kapatma çarpısına (size-9) yer bırakır
className="pr-12"
/>
{/* Geçiş sabitleri soru formuyla ortak (lib/adim-gecisi.ts) */}
<TransitionPanel
@@ -536,7 +512,8 @@ export function SihirbazAdimlar({
type="button"
onClick={() => adimaGit(adim + 1)}
disabled={!adimGecerli}
className="h-11 cursor-pointer bg-orange-500 px-5 text-white transition-[background-color,transform] duration-200 hover:bg-orange-600 active:scale-[0.97]"
variant="cta"
className="h-11 px-5"
>
Devam
<ArrowRight className="size-4" aria-hidden />
@@ -555,7 +532,8 @@ export function SihirbazAdimlar({
onTamamla({ kategoriler, iller, universiteTipi, oncelikler, ozelDurum });
}}
disabled={kategoriler.length === 0}
className="h-11 cursor-pointer bg-orange-500 px-5 text-white transition-[background-color,transform] duration-200 hover:bg-orange-600 active:scale-[0.97]"
variant="cta"
className="h-11 px-5"
>
{sonButonEtiketi}
<ArrowRight className="size-4" aria-hidden />

View File

@@ -1,5 +1,6 @@
"use client";
import { logoTepkisi } from "@/lib/logo-tepkisi";
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
@@ -182,6 +183,8 @@ export function SihirbazBolumu({
*/
async function ilerle(secimler: SihirbazSecimleri) {
tercihProfiliYaz({ sira, tur, secimler });
// Üst çubuktaki logo kürsüye döner (DESIGN.md §4.34).
logoTepkisi("sira");
await modalCikisiniOynat();
setElleAcik(false);
if (!girisliMi) {

View File

@@ -8,6 +8,7 @@
// yalnız 0,12 s'lik çapraz solma.
import type { Transition, Variants } from "motion/react";
import { YAY } from "@/lib/hareket";
/**
* enter / center / exit varyantları; `custom` geçiş yönüdür (1 ileri, -1
@@ -58,8 +59,8 @@ export function adimGecisi(hareketiAzalt: boolean): Transition {
height: { duration: 0 },
}
: {
x: { type: "spring", stiffness: 300, damping: 30 },
height: { type: "spring", stiffness: 300, damping: 30 },
x: YAY.adim,
height: YAY.adim,
opacity: { duration: 0.2 },
};
}

View File

@@ -511,7 +511,11 @@ export const LISTE_REVIZE_PARAMETRELERI = {
adet: { type: "integer", minimum: 1, maximum: 24 },
iller: { type: "array", items: { type: "string" } },
kategoriler: { type: "array", items: { type: "string", enum: [...KATEGORILER] } },
universiteTipi: { type: "string", enum: ["devlet", "vakif"] },
universiteTipi: {
type: "string",
enum: ["devlet", "vakif"],
description: "Yalnız ilk N tercihe özgü tip kısıtı ('ilk 5 devlet olsun, gerisi fark etmez'). 'Vakıf olmasın / sadece devlet' gibi genel istek buraya değil üst düzey universiteTipi'ne yazılır.",
},
},
required: ["adet"],
additionalProperties: false,

View File

@@ -81,6 +81,20 @@ const URETIM_ZAMAN_ASIMI_MS = 60_000;
* Kalanlar arasında yönlendirme OpenRouter'da kalır.
*/
const DISLAMA_SURESI_MS = 6 * 60 * 60_000;
/**
* Düşünme (reasoning) ayarı. Varsayılan "none": görev küçük, hibrit model
* düşünmeye bütçe yemesin. Bazı modeller/uç noktalar "none"ı 400 ile
* reddediyor ("Reasoning is mandatory", ölçüm 10 Eki 2026: glm-5.3-flash,
* gpt-5-nano); model değişiminde AI_REASONING_EFFORT ile "minimal"/"low"
* verilir, "yok" alanı hiç göndermez.
*/
function dusunmeAyari(): { reasoning_effort?: "none" | "minimal" | "low" | "medium" | "high" } {
const v = process.env.AI_REASONING_EFFORT?.trim();
if (v === "yok") return {};
if (v === "minimal" || v === "low" || v === "medium" || v === "high") return { reasoning_effort: v };
return { reasoning_effort: "none" };
}
const globalForCagri = globalThis as unknown as {
__aiDislananSaglayicilar?: Map<string, number>;
};
@@ -162,7 +176,7 @@ export async function yapilandirilmisUret<T extends z.ZodType>(opts: {
// Görev küçük (havuzdan seçim + tek cümlelik gerekce) ve şemaya
// uymayan çıktıyı doğrulama döngüsü zaten yakalıyor; hibrit
// modellerde düşünme bütçe/süre yemesin.
reasoning_effort: "none",
...dusunmeAyari(),
messages: [
{ role: "system", content: opts.system },
{ role: "user", content: opts.kullanici },

View File

@@ -36,7 +36,7 @@ import type { RaporParams, RevizyonKurali } from "@/features/rapor/types/rapor";
import { LISTE_REVIZE_PARAMETRELERI, SORU_SOR_PARAMETRELERI, revizyonKuraliKur } from "./araclar";
import { yapilandirilmisUret, type CagriGozlemi } from "./cagri";
import { niyetModeli } from "./client";
import { anketDogrula, anketModelMetni, type Anket } from "./sohbet-meta";
import { anketDogrula, anketModelMetni, type Anket, type AnketCevaplari } from "./sohbet-meta";
export type NiyetSonucu =
/** İstek net: kural doğrulandı, liste koddan kurulacak. */
@@ -95,19 +95,20 @@ const NIYET_TEL_SEMASI: Record<string, unknown> = {
const SISTEM = `Sen KolayTercih'in sunucu tarafı NİYET ÇÖZÜMLEYİCİSİSİN. Adayın (YKS öğrencisi) sohbet mesajını okur, 24'lük tercih listesinde DEĞİŞİKLİK isteyip istemediğine karar verir ve istiyorsa isteği yapısal kurala çevirirsin. Yalnız şemaya uyan JSON döndür; açıklama yazma.
NİYET:
- "revizyon": aday listenin değişmesini istiyor (il, ilgi alanı, devlet/vakıf, dilim dağılımı, "ilk N tercih", bir üniversite/bölümü çıkarma, program ekleme). "Form cevaplarım:" ile başlayan mesaj HER ZAMAN revizyondur.
- "revizyon": aday listenin değişmesini istiyor (il, ilgi alanı, devlet/vakıf, dilim dağılımı, "ilk N tercih", bir üniversite/bölümü çıkarma, program ekleme). "Form cevaplarım:" ile başlayan mesaj HER ZAMAN revizyondur. NE değişeceğini söylemeyen genel istekler de revizyondur ("listemi değiştir", "listemi güncelle", "listemi yeniden oluştur", "yeni liste istiyorum", "listeyi baştan kur", "tercihlerimi güncellemek istiyorum") — bunlarda kural null, form dolu; "diger" DEĞİL.
- "soru": listeyle ya da programlarla ilgili bilgi/soru; değişiklik istemiyor ("1. programın kontenjanı kaç?").
- "diger": sohbet, teşekkür, listeyle ilgisiz konu, not alma isteği.
KURAL (niyet=revizyon ve istek NET ise; aksi hâlde null). Karşılıklar:
- "ilk N tercih X ilinden/ilinde olsun", "ilk N'i X'e göre güncelle" → ilkTercihler {adet: N, iller: [X]}; X sihirbaz illerinde yoksa ayrıca iller.ekle: [X].
- "sadece X" → iller.yalniz; "X de olsun / X ekle" → iller.ekle; "X olmasın" → iller.cikar.
- "sadece devlet / vakıf olmasın" → universiteTipi "devlet"; "sadece vakıf" → "vakif"; "fark etmez" → "farketmez".
- "sadece devlet / vakıf olmasın" → universiteTipi "devlet"; "sadece vakıf" → "vakif"; "fark etmez" → "farketmez". Tip kısıtı aksi açıkça söylenmedikçe TÜM LİSTEYE uygulanır: üst düzey universiteTipi'ne yaz, ilkTercihler.universiteTipi'ne DEĞİL ("ilk 10 İstanbul olsun, sadece devlet" → ilkTercihler {adet: 10, iller: [İstanbul]} + universiteTipi "devlet"). ilkTercihler.universiteTipi yalnız aday "ilk N'i devlet olsun, gerisi fark etmez" gibi açıkça ilk N ile sınırlarsa.
- "daha güvenli / garantili olsun" → iskelet "guvenli-agirlikli"; "daha iddialı / hayal ağırlıklı" → "hayal-agirlikli"; "standart kalsın" → "standart".
- "X üniversitesi / Y bölümü olmasın, çıkar" → cikar.universiteler / cikar.bolumler (ad parçası yeter).
- Program ekleme program kimliği ister; kimlik bilmiyorsan ekle verme, eksik'e "program kimliği" yaz.
- "not": adayın isteğinin bir cümlelik özeti (Türkçe).
- Kurala YALNIZ adayın bu mesajda istediği değişiklikleri yaz; istemediği alanı verme. "farketmez" ve "standart" yalnız aday açıkça "fark etmez / standart kalsın" derse yazılır; uygulanmış kurallar zaten geçerlidir, onları tekrar yazma ya da sıfırlama.
- Bir mesajda birden çok istek varsa HEPSİNİ kurala yaz ("ilk 10 İstanbul olsun, vakıf olmasın" → ilkTercihler + universiteTipi "devlet"). "ilk N tercih X'ten" yalnız X demek DEĞİLDİR: iller.yalniz yazma, gerekirse iller.ekle.
- Kurala YALNIZ adayın bu mesajda istediği değişiklikleri yaz; istemediği alanı verme. "farketmez" ve "standart" yalnız aday açıkça "fark etmez / standart kalsın" derse yazılır; uygulanmış kurallar zaten geçerlidir, onları tekrar yazma ya da sıfırlama. Aday ne değişeceğini söylemediyse kural UYDURMA (ör. "universiteTipi: farketmez" ile boş kural) — kural null, form dolu.
İl adlarını Türkçe ve tam yaz (İstanbul, İzmir, Kahramanmaraş). İlgi alanı adları TAM OLARAK şunlardan: ${KATEGORILER.join(" | ")}.
Form cevabı geldiğinde cevapları forma göre kurala çevir; "standart kalsın", "fark etmez", "yok" gibi cevaplar kural üretmez (o alanı verme); hiç kural çıkmıyorsa kural null, eksik ["istek"].
@@ -125,9 +126,19 @@ export async function revizyonNiyetiCikar(opts: {
params: RaporParams;
/** Mesaj "Form cevaplarım:" ise cevaplanan form (sohbet-meta.anketModelMetni ile dökülür). */
oncekiForm?: Anket | null;
/** Form cevabının yapısal hâli (route anketCevabiDogrula'dan); varsayılan formda model çağrılmadan kurala çevrilir. */
anketCevabi?: AnketCevaplari | null;
gozlem?: (g: CagriGozlemi) => void;
}): Promise<NiyetSonucu> {
const p = opts.params;
// Varsayılan formun cevabı modelsiz okunur (10 Eki 2026): seçenek değerleri
// bizim, karşılıkları belli. Serbest metin il adıysa o da kodla çözülür;
// çözülemeyen cevap (bölüm/üniversite adı gibi) modele düşer.
if (opts.oncekiForm && opts.anketCevabi && varsayilanFormMu(opts.oncekiForm)) {
const kural = varsayilanFormKurali(opts.anketCevabi);
if (kural) return { niyet: "revizyon", kural };
}
const durum = [
`Başarı sırası ${p.sira.toLocaleString("tr-TR")}, puan türü ${PUAN_TURLERI[p.tur]}.`,
`Sihirbaz illeri: ${p.iller?.length ? p.iller.join(", ") : p.il ? p.il : "(seçilmedi — tüm iller)"}.`,
@@ -161,15 +172,24 @@ export async function revizyonNiyetiCikar(opts: {
} catch (err) {
const neden = err instanceof Error ? err.message : String(err);
console.warn("[revizyon-niyeti] çıkarım başarısız:", neden.slice(0, 120));
return { niyet: "belirsiz", neden: "çağrı başarısız" };
return cikarimsiz(opts, "çağrı başarısız");
}
if (!ham) return { niyet: "belirsiz", neden: "şema dışı çıktı" };
if (!ham) return cikarimsiz(opts, "şema dışı çıktı");
if (ham.niyet !== "revizyon") return { niyet: ham.niyet };
if (ham.kural && typeof ham.kural === "object") {
const k = revizyonKuraliKur(ham.kural);
if (k.ok) return { niyet: "revizyon", kural: k.kural };
console.info("[revizyon-niyeti] kural geçersiz, forma düşülüyor:", k.hata.slice(0, 120));
if (k.ok) {
// Etkisiz kural (10 Eki 2026 eval r12: "listemi yeniden oluştur" →
// model `universiteTipi: farketmez` uydurdu, liste "değişmeden" yeniden
// kuruldu): zaten geçerli olanı tekrar eden alanlar atılır; geriye bir
// şey kalmıyorsa istek belirsizdir, forma düşülür.
const etkili = etkisizAlanlariAt(k.kural, p);
if (etkili) return { niyet: "revizyon", kural: tipKuraliniTamamla(opts.mesaj, etkili) };
console.info("[revizyon-niyeti] kural etkisiz (uygulanmışı tekrarlıyor), forma düşülüyor");
} else {
console.info("[revizyon-niyeti] kural geçersiz, forma düşülüyor:", k.hata.slice(0, 120));
}
}
const eksik = (ham.eksik ?? []).map((e) => e.replace(/\s+/g, " ").trim().slice(0, 60)).filter(Boolean).slice(0, 5);
if (ham.form && typeof ham.form === "object") {
@@ -180,6 +200,118 @@ export async function revizyonNiyetiCikar(opts: {
return { niyet: "revizyon", form: varsayilanForm(p), eksik };
}
/**
* Çıkarım yapılamadı (zaman aşımı / sağlayıcı hatası / şema dışı). Düz
* mesajda regex zaten "liste değişsin" dedi: aday "okuyamadım" yerine
* varsayılan formu görür, cevabı modelsiz kurala çevrilir (10 Eki 2026 eval
* r09#2: 20 sn zaman aşımı → "okuyamadım" + iade; QA B3 bimodal gecikme).
* Form cevabında ikinci bir form açılmaz; route "belirsiz"i iade ile karşılar.
*/
function cikarimsiz(opts: { oncekiForm?: Anket | null; params: RaporParams }, neden: string): NiyetSonucu {
if (opts.oncekiForm) return { niyet: "belirsiz", neden };
return { niyet: "revizyon", form: varsayilanForm(opts.params), eksik: ["istek"] };
}
/**
* Uygulanmış kuralları ya da sihirbaz seçimini tekrar eden alanları atar
* (`universiteTipi: farketmez` zaten fark etmezken, `iskelet: standart`
* zaten standartken). Geriye kural kalmazsa null.
*/
function etkisizAlanlariAt(kural: RevizyonKurali, p: RaporParams): RevizyonKurali | null {
const sonuc: RevizyonKurali = { ...kural };
const mevcutTip = p.revizyon?.universiteTipi ?? p.universiteTipi ?? "farketmez";
if (sonuc.universiteTipi === "farketmez" && mevcutTip === "farketmez") delete sonuc.universiteTipi;
const mevcutIskelet = p.revizyon?.iskelet ?? "standart";
if (sonuc.iskelet === "standart" && mevcutIskelet === "standart") delete sonuc.iskelet;
const { not: _not, ...kalan } = sonuc;
void _not;
return Object.keys(kalan).length ? sonuc : null;
}
/**
* Mesajda açıkça yazan üniversite tipi isteği kuralda yoksa kod ekler
* (10 Eki 2026 eval r14: "ilk 10 İstanbul olsun, vakıf olmasın" → model 4/4
* koşuda universiteTipi'ni düşürdü; "not"a yazdı, kurala yazmadı). Yalnız
* tek anlamlı kalıplar; mesajda kalıp yoksa ("ilk N'i devlet yap" gibi
* kısmi istek) modelin yazdığı ilkTercihler.universiteTipi olduğu gibi kalır.
*/
const DEVLET_KALIBI = /vak[ıi]f(?:lar)?(?:\p{L}*)?\s+(?:olmasın|çıksın|çıkar|istemiyorum|kalmasın)|sadece devlet|yaln[ıi]z(?:ca)? devlet|devlet olsun/u;
const VAKIF_KALIBI = /sadece vak[ıi]f|yaln[ıi]z(?:ca)? vak[ıi]f|devlet(?:ler)?\s+(?:olmasın|çıksın|çıkar)/u;
function tipKuraliniTamamla(mesaj: string, kural: RevizyonKurali): RevizyonKurali {
if (kural.universiteTipi) return kural;
// "vakıf olmasın" gibi ayrı bir cümle listenin TAMAMI içindir; model tipi
// yalnız ilkTercihler içine yazdıysa (gemini-3.1-flash-lite, 10 Eki 2026,
// r14 4/4) üst düzeye de taşınır. ilkTercihler'deki kopya zararsızdır
// (zaten aynı tipe kısıtlı havuzdan seçilir).
const m = mesaj.toLocaleLowerCase("tr-TR");
if (DEVLET_KALIBI.test(m)) return { ...kural, universiteTipi: "devlet" };
if (VAKIF_KALIBI.test(m)) return { ...kural, universiteTipi: "vakif" };
return kural;
}
const VARSAYILAN_FORM_BASLIK = "Listeni nasıl değiştirelim?";
const VARSAYILAN_DEGERLER = new Set(["il-ekle", "devlet", "vakif-olmasin", "guvenli", "iddiali"]);
/** Cevaplanan form bizim varsayılan formumuz mu (soru adları + başlık). */
export function varsayilanFormMu(anket: Pick<Anket, "baslik" | "sorular">): boolean {
return (
anket.baslik === VARSAYILAN_FORM_BASLIK &&
anket.sorular.length === 2 &&
anket.sorular[0]?.ad === "degisiklik" &&
anket.sorular[1]?.ad === "adet"
);
}
/**
* Varsayılan formun cevabını modelsiz kurala çevirir. Serbest metin yalnız
* bilinen bir il adıysa çözülür (revizyonKuraliKur il doğrulaması); başka
* serbest metin, çelişen seçim (güvenli + iddialı) ya da il'siz "başka il"
* cevabı null döner → model yolu. Adet 24'ten küçükse il/tip ölçütü yalnız
* "ilk N"e uygulanır; iskelet her zaman listenin tamamınadır.
*/
export function varsayilanFormKurali(cevaplar: AnketCevaplari): RevizyonKurali | null {
const ham = cevaplar.degisiklik;
const secimler = (Array.isArray(ham) ? ham : ham ? [ham] : []).map((s) => s.trim()).filter(Boolean);
if (!secimler.length) return null;
const serbest = secimler.filter((s) => !VARSAYILAN_DEGERLER.has(s));
if (serbest.length > 1) return null;
const secili = new Set(secimler.filter((s) => VARSAYILAN_DEGERLER.has(s)));
if (secili.has("guvenli") && secili.has("iddiali")) return null;
let il: string | undefined;
if (serbest.length === 1) {
const dene = revizyonKuraliKur({ iller: { ekle: [serbest[0]] } });
if (!dene.ok || !dene.kural.iller?.ekle?.[0]) return null;
il = dene.kural.iller.ekle[0];
}
if (secili.has("il-ekle") && !il) return null;
const adetHam = Array.isArray(cevaplar.adet) ? cevaplar.adet[0] : cevaplar.adet;
const adet = adetHam ? Math.round(Number(adetHam)) : Number.NaN;
const kismi = Number.isFinite(adet) && adet >= 1 && adet < 24;
const tip = secili.has("devlet") || secili.has("vakif-olmasin") ? ("devlet" as const) : undefined;
const arg: Record<string, unknown> = {};
if (secili.has("guvenli")) arg.iskelet = "guvenli-agirlikli";
if (secili.has("iddiali")) arg.iskelet = "hayal-agirlikli";
if (kismi && (il || tip)) {
arg.ilkTercihler = { adet, ...(il ? { iller: [il] } : {}), ...(tip ? { universiteTipi: tip } : {}) };
if (il) arg.iller = { ekle: [il] };
} else {
if (il) arg.iller = { ekle: [il] };
if (tip) arg.universiteTipi = tip;
}
const ozet = [
il ? `${il} de olsun` : null,
tip ? "vakıf olmasın" : null,
secili.has("guvenli") ? "daha güvenli" : secili.has("iddiali") ? "daha iddialı" : null,
kismi ? `ilk ${adet} tercih` : null,
].filter(Boolean);
arg.not = `Form cevabı: ${ozet.join(", ")}.`;
const k = revizyonKuraliKur(arg);
return k.ok ? k.kural : null;
}
/**
* Modelin formu gelmez ya da şemaya uymazsa açılan deterministik form:
* ne değişsin (çoklu + serbest) ve kaç tercih etkilensin.
@@ -187,7 +319,7 @@ export async function revizyonNiyetiCikar(opts: {
export function varsayilanForm(p: RaporParams): Omit<Anket, "id"> {
const iller = p.iller?.length ? p.iller : p.il ? [p.il] : [];
return {
baslik: "Listeni nasıl değiştirelim?",
baslik: VARSAYILAN_FORM_BASLIK,
sorular: [
{
ad: "degisiklik",

View File

@@ -64,6 +64,11 @@ const REVIZYON_NIYETI: RegExp[] = [
/(?<!\p{L})(?:olsun|olmasın|kalsın|kalmasın)(?!\p{L})/u,
// "listeye ekle", "X'i çıkar / kaldır"
/(?<!\p{L})(?:ekle|çıkar|kaldır)(?:yelim|elim|alım|ın|in|sene|sana)?(?!\p{L})/u,
// Listeyi BAŞTAN isteyen kalıplar (10 Eki 2026, Bilal: paketli hesapta
// "liste oluşturma" isteği koşmadı): "listemi yeniden oluştur", "yeni liste
// yap/hazırla", "listeyi baştan/sıfırdan kur", "listemi tekrar kur". Yalnız
// "liste" sözcüğüyle birlikte; "oluştur/kur/yap" tek başına çok genel.
/list\p{L}*.*(?<!\p{L})(?:oluştur|hazırla|baştan|sıfırdan|yeniden|tekrar|kur)\p{L}*|(?<!\p{L})(?:oluştur|hazırla|baştan|sıfırdan|yeniden|tekrar)\p{L}*.*list\p{L}*|yeni (?:bir )?liste/u,
];
/**

View File

@@ -12,15 +12,22 @@ export const YAY = {
donusum: { type: "spring", visualDuration: 0.42, bounce: 0 },
/** Parmak bıraktıktan sonraki yerine oturma. Arc "snappy": tek yerde hafif sekme. */
birakma: { type: "spring", visualDuration: 0.26, bounce: 0.12 },
/** Adım paneli kayması ve boyu (sihirbaz, soru formu, profil kapısı); `adim-gecisi.ts`. */
adim: { type: "spring", stiffness: 300, damping: 30 },
} as const;
type Egri = [number, number, number, number];
export const EGRI: { giris: Egri; standart: Egri } = {
// CSS karşılıkları globals.css'te `--egri-*` (aynı sayılar; biri değişirse öbürü de).
export const EGRI: { giris: Egri; standart: Egri; gucluCikis: Egri; yerlesme: Egri } = {
/** Ekrana giren öğe: hızlı başlar, yumuşak durur. */
giris: [0.16, 1, 0.3, 1],
/** Ekrandayken değişen renk/opaklık. */
standart: [0.22, 1, 0.36, 1],
/** Sahne açılışı ve piksel nabzı: belirgin fırlayıp uzun süzülür (açılış sahnesi, dolum). */
gucluCikis: [0.23, 1, 0.32, 1],
/** Yerine oturan/kapanan öğe: geç hızlanır, sert durmaz (logo kabının kapanışı). */
yerlesme: [0.32, 0.72, 0, 1],
};
export const SURE = { anlik: 0.12, hizli: 0.16, cikis: 0.18, standart: 0.24 } as const;

18
src/lib/logo-tepkisi.ts Normal file
View File

@@ -0,0 +1,18 @@
// Logoya tepki (DESIGN.md §4.34): kullanıcının kendi yaptığı bir eylem
// header'daki logoyu kısa bir an başka bir biçime dönüştürür. Bileşenler
// logoya ref ile uzanmaz; buradan pencereye olay atılır, logo dinler
// (animasyonlu-mark.tsx). Tema değişimi ayrı kapıdan gelir (lib/tema-gecisi.ts),
// çünkü önce sayfa geçişinin bitmesini bekler.
//
// - "sira": sıralama girildi/değişti → kürsü
// - "ekle": listeye program eklendi → deste
// - "tamam": liste 24'e doldu → üç kare tek sıra
export type LogoTepkisi = "sira" | "ekle" | "tamam";
export const LOGO_TEPKI_OLAYI = "kt-logo-tepki";
export function logoTepkisi(tur: LogoTepkisi) {
if (typeof window === "undefined") return;
window.dispatchEvent(new CustomEvent<LogoTepkisi>(LOGO_TEPKI_OLAYI, { detail: tur }));
}

100
src/lib/tema-gecisi.ts Normal file
View File

@@ -0,0 +1,100 @@
// Tema geçişi (DESIGN.md §2.3): açık ↔ koyu değişimi ekranı bir anda
// çevirmez; yeni tema header'daki KolayTercih işaretinden yumuşak bir halka
// gibi açılır — kenarı keskin değil, geniş ve tüylü; ışık yayılır gibi.
// Geçiş kısa ve hafiftir; BİTİNCE logonun kareleri yeni temanın biçimine
// dönüşür (koyuya: ay ve yıldız; açığa: güneş), kısa bir an durur ve
// yeniden logoya toplanır.
// Markanın yumuşak, oyunsu diline uysun diye sert kenar, piksel ya da ikinci
// bir şekil yok (9 Eki 2026).
//
// Nasıl: View Transitions API eski/yeni görüntü çiftini verir; yeni görüntü
// radyal degrade maskesiyle açılır. Yarıçapı CSS sürer (globals.css
// `.kt-tema-gecis`), buradan yalnız merkez ve son yarıçap verilir. Tarayıcı
// View Transitions bilmiyorsa ya da hareket azaltılmışsa tema düz değişir.
/** Geçiş sırasında <html>'de duran sınıf (globals.css). */
const GECIS_SINIFI = "kt-tema-gecis";
/** Geçiş bitince pencereye atılan olay; header'daki logo bunu dinleyip
* karelerini güneşe ya da aya dönüştürür (animasyonlu-mark.tsx). */
export const TEMA_GECIS_OLAYI = "kt-tema-gecis";
// Maskenin tam opak çekirdeği yarıçapın bu oranında biter, kalanı tüydür
// (globals.css'teki --kt-tema-cekirdek ile aynı olmalı): son yarıçap, çekirdek
// en uzak köşeyi de örtecek kadar büyük seçilir.
const CEKIRDEK = 0.55;
/** Sayfa geçişi ile logo dönüşümü arasındaki kısa nefes (ms). */
const LOGO_NEFESI = 80;
let suruyor = false;
/**
* `uygula` temayı değiştiren çağrıdır (ör. `setTheme("dark")`). Geçiş
* sürerken gelen istek yok sayılır: art arda D'ye basınca ekrandaki görüntü
* ile tema durumu birbirine girmesin.
*/
export function temayiGecisle(uygula: () => void) {
if (typeof document === "undefined") {
uygula();
return;
}
const azHareket = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (azHareket) {
uygula();
return;
}
if (!document.startViewTransition) {
// Sayfa geçişi yok ama logo yine dönüşür; temanın sınıfı <html>'e
// yazılsın diye kısa bir an beklenir.
uygula();
window.setTimeout(
() => window.dispatchEvent(new Event(TEMA_GECIS_OLAYI)),
LOGO_NEFESI,
);
return;
}
if (suruyor) return;
const kok = document.documentElement;
// Halkanın merkezi header'daki logo; krom gizli sayfalarda sol üst köşe.
const logo = document
.querySelector('header a[aria-label="KolayTercih"]')
?.getBoundingClientRect();
const gorunur = logo && logo.width > 0 && logo.bottom > 0;
const x = gorunur ? logo.left + logo.height / 2 : 24;
const y = gorunur ? logo.top + logo.height / 2 : 24;
const enUzak = Math.hypot(
Math.max(x, window.innerWidth - x),
Math.max(y, window.innerHeight - y),
);
kok.style.setProperty("--kt-tema-x", `${x}px`);
kok.style.setProperty("--kt-tema-y", `${y}px`);
kok.style.setProperty("--kt-tema-son", `${Math.ceil(enUzak / CEKIRDEK)}px`);
kok.classList.add(GECIS_SINIFI);
suruyor = true;
const gecis = document.startViewTransition(uygula);
// Sekme gizliyken / yeniden boyutlanırken tarayıcı geçişi iptal eder:
// `ready` reddedilir, tema yine uygulanmıştır; konsola hata düşmesin.
gecis.ready.catch(() => {});
gecis.finished
.catch(() => {})
.finally(() => {
kok.classList.remove(GECIS_SINIFI);
kok.style.removeProperty("--kt-tema-x");
kok.style.removeProperty("--kt-tema-y");
kok.style.removeProperty("--kt-tema-son");
suruyor = false;
// Sıra: önce sayfa geçişi biter, sonra logo dönüşür — ikisi üst üste
// binmez, göz önce temaya sonra logoya gider. Kısa nefes (LOGO_NEFESI):
// geçiş iptal olduysa (sekme gizli vb.) `finished` hemen döner ve
// temanın sınıfı <html>'e henüz yazılmamış olabilir; logo yanlış
// biçime dönüşmesin.
window.setTimeout(
() => window.dispatchEvent(new Event(TEMA_GECIS_OLAYI)),
LOGO_NEFESI,
);
});
}