merge(main): 1.1.0 v2'ye alındı — tema geçişi, sürüm notları, geri bildirim formu, footer yazı logosu

main'deki PR #9 (v1-beta-tema) ile 1.0.0/1.1.0 commit'leri v2'ye birleştirildi.
Çakışma çözümleri:
- package.json sürümü 2.0.0 kaldı
- animasyonlu-mark, tema-secici, hero-form, DESIGN.md §4.34: v2'deki üst küme korundu
  (main'in temaSekli / ikonlu radyogrubu v2'de sekilOynat / SecimHaplari ile kapsanıyor)
- surum-rozeti: main'in /surum-notlari'na bağlantılı hâli alındı; header'da logo
  Link'inin içindeki eski kopya kaldırıldı (bağlantı içinde bağlantı olmasın)
- globals.css: tema geçişi bloğu iki kez gelmişti, yinelenen silindi
- geri-bildirim-formu: sonner yerine v2 bildirim yığını (ui/bildirim), buton variant="cta"
- DESIGN.md §2.3: temaSekli → sekilOynat

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:40:29 +03:00
10 changed files with 428 additions and 9 deletions

View File

@@ -133,6 +133,22 @@ Seçici `src/components/tema-secici.tsx` (footer'da), sağlayıcı `src/componen
(`next-themes`, `<html class="dark">`). Renklerin tek kaynağı `src/app/globals.css` içindeki
`.dark` bloğu.
**Geçiş ve kısayol (7 Eki 2026; son hâli 9 Eki):** tema değişimi anlık değildir; yeni
tema header'daki logodan **yumuşak bir halka** gibi açılır: kenarı keskin değil, geniş ve
tüylü (ışık yayılır gibi), kısa ve hafif: 600 ms. **Geçiş bitince** logonun kareleri
yeni temanın biçimine dönüşür: koyuya geçişte şeftali kare hilale, mavi kare hilalin iki ucunun
ortasındaki yıldıza (turuncu söner); açığa geçişte turuncu kare güneşe, şeftali kare halesine. Kısa bir an durur, yeniden
logoya toplanır (`animasyonlu-mark.tsx` `sekilOynat`, 1,7 sn; yalnız header'daki logo; hover döngüsünden
önceliklidir, biçim bitince imleç üzerindeyse döngü kaldığı yerden değil yerinden sürer). Tema
seçicinin ikonları aynı biçimlerin durağan hâlidir (ayrıntı ve kurallar §4.34) (`tema-ikonu.tsx`: güneş, ay, Sistem için
logonun üç karesi); burada lucide ikonu kullanılmaz. View Transitions + radyal degrade
maskesi: `src/lib/tema-gecisi.ts`, `globals.css` `.kt-tema-gecis`; hareket azaltılmışsa düz
değişir. Denenip bırakılanlar: logo biçimli maske ("logo içinde logo"), düz crossfade, piksel
ızgarası (sert bulundu). Kural: bu geçişte sert kenar ve ikinci bir şekil yok.
`D` tuşu açık ↔ koyu geçişi yapar (`src/components/tema-kisayolu.tsx`); ipucu seçicinin
altında `Kbd` ile durur, başka yere kopyalanmaz. Header'daki Beta rozeti `/surum-notlari`na
bağlantıdır.
**Nasıl çalışır:** bileşenlere `dark:` yazılmaz; palet token'ları çevrilir.
| Açık temadaki sınıf | Koyu temada ne olur |
@@ -1276,6 +1292,8 @@ Hareket kuralları (Apple, `motion/react`):
- Kaydırmaya bağlı tetik `IntersectionObserver` ile (kaydırma dinleyicisi değil).
- "Hareketi azalt": kayma/yay yok, yalnız opaklık (`useReducedMotion`,
`motion-reduce:transition-opacity`).
- Logo kareleri bir durumu anlatmak için kısa bir an başka biçime dönüşebilir (§4.34); sayfa
geçişiyle üst üste binmez, sırayla oynar.
## 7. Yap / Yapma
@@ -1293,6 +1311,7 @@ Hareket kuralları (Apple, `motion/react`):
| Gösterge kareleri masonry (kaymış sütun, farklı boy) | Hizalı eşit ızgara; 8 px'lik "neredeyse hizalı" kaymalar |
| Liste satırında ad sarar, sayı alt satıra iner | `truncate` ile yarım ad; 32 px satır butonu |
| Hareket: `Reveal`, piksel ısısı, `kt-unblur`, sekmesiz yay; `animate-ping` yalnızca "şu anda canlı" noktasında | Her elemanda `fade-up`, `whileHover scale 1.05`, `animate-bounce` |
| Tema ikonu ve logo dönüşümü: logonun üç karesinden güneş / ay (§4.34) | Tema seçicide lucide `Sun`/`Moon`; logoyu döngüde sürekli dönüştürmek |
| Başlık `font-heading`, vurgu `font-bricolage` | Inter/Roboto/serif, tek kelimelik BÜYÜK HARF etiket, 10–11 px yazı |
| Risk rengi + metin etiketi; tür ayrımı ikonla | Renk tek başına, "yerleşirsin" gibi vaat cümlesi |
| Üç kart yalnızca üç gerçek öğe varsa | Hero + üç kart iskeleti refleksi |

View File

@@ -43,6 +43,7 @@ const staticEntries: MetadataRoute.Sitemap = [
{ url: `${SITE_URL}/paket`, priority: 0.7, changeFrequency: "monthly" },
{ url: `${SITE_URL}/meraklisina`, priority: 0.6, changeFrequency: "monthly" },
{ url: `${SITE_URL}/iletisim`, priority: 0.3, changeFrequency: "yearly" },
{ url: `${SITE_URL}/surum-notlari`, priority: 0.3, changeFrequency: "monthly" },
{ url: `${SITE_URL}/gizlilik`, priority: 0.2, changeFrequency: "yearly" },
{
url: `${SITE_URL}/cerez-politikasi`,

View File

@@ -0,0 +1,114 @@
import type { Metadata } from "next";
import { ArayuzBilgisi } from "@/components/arayuz-bilgisi";
import { Parallax } from "@/components/parallax";
import { GeriBildirimFormu } from "@/features/geri-bildirim/components/geri-bildirim-formu";
import { SiteFooter } from "@/components/site-footer";
import { SURUM } from "@/lib/surum";
import { sayfaMetadata } from "@/lib/seo";
export const metadata: Metadata = sayfaMetadata({
title: "Sürüm notları",
description:
"KolayTercih beta sürüm notları: neler değişti ve geri bildirimini nasıl iletirsin.",
path: "/surum-notlari",
});
/**
* Sürüm günlüğü. En yeni en üstte; her sürüm package.json'daki numarayla
* aynı adı taşır (AGENTS.md "Sürümleme"). Yeni bir sürüm çıkarken buraya bir
* kayıt eklenir. Yalnız görünüşü etkileyen değişiklikler `arayuz` alanına
* yazılır: listede tek satır ("Arayüz iyileştirmeleri") olur, ayrıntısı
* yanındaki bilgi düğmesinin balonunda durur.
*/
const SURUMLER: {
surum: string;
tarih: string;
maddeler: string[];
arayuz?: string[];
}[] = [
{
surum: "1.1.0",
tarih: "7 Ekim 2026",
maddeler: [],
arayuz: [
"D tuşu açık / koyu temayı değiştirir; geçiş logodan yayılarak yumuşak oynar.",
"Bu sayfa: beta durumu, sürüm günlüğü ve geri bildirim kutusu. Header'daki Beta rozeti buraya getirir.",
],
},
{
surum: "1.0.0",
tarih: "7 Ekim 2026",
maddeler: [
"Sıralama, alan, il ve devlet/vakıf tercihine göre 24 tercihlik dengeli liste: hayal, dengeli ve güvenli dilimler.",
"Her program için YÖK Atlas verisinden taban sıralama, kontenjan ve yıllara göre eğilim.",
"Yapay zekâ destekli tercih raporu ve liste üzerinde soru sorabildiğin danışman sohbeti.",
"Üniversite ve bölüm kataloğu, üniversite sıralamaları, rehber yazıları.",
],
arayuz: ["Açık, koyu ve sistem teması."],
},
];
export default function SurumNotlariPage() {
return (
<>
<main className="mx-auto w-full max-w-2xl px-4 py-16">
<Parallax fromStart strength={10}>
<h1 className="font-heading text-3xl font-bold">Sürüm notları</h1>
<p className="mt-3 text-slate-600">
KolayTercih şu an beta sürümünde. Ürün çalışıyor ve gerçek YÖK
Atlas verisiyle liste üretiyor; ama hâlâ her gün bir şeyler
düzeltiyor, eksik gördüğümüz yerleri tamamlıyoruz. Her sürümde
neyin değiştiği aşağıda.
</p>
</Parallax>
{/* Zaman çizgisi: her sürümün piksel karesi solda durur, kareleri
ince bir çizgi birbirine bağlar (karenin ortasından bir sonraki
karenin ortasına). En eski sürümde çizgi biter. */}
<ol aria-label="Sürümler" className="mt-12">
{SURUMLER.map((s, i) => (
<li key={s.surum} className="relative pb-12 pl-7 last:pb-0">
{i < SURUMLER.length - 1 && (
<span
aria-hidden
className="absolute top-4 -bottom-4 left-[4.5px] w-px bg-slate-200 sm:top-[18px] sm:-bottom-[18px]"
/>
)}
<span
aria-hidden
className="absolute top-[11px] left-0 size-2.5 rounded-[2px] bg-primary sm:top-[13px]"
/>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<h2 className="font-heading text-2xl font-bold text-slate-900 sm:text-3xl">
v{s.surum}
</h2>
<time className="text-sm text-slate-500">{s.tarih}</time>
{s.surum === SURUM && (
<span className="text-xs font-semibold text-primary">
Şu anki sürüm
</span>
)}
</div>
<ul className="mt-3 list-disc space-y-2 pl-5 text-base leading-relaxed text-slate-600 marker:text-slate-400">
{s.maddeler.map((m) => (
<li key={m}>{m}</li>
))}
{s.arayuz && (
<li>
<span className="inline-flex items-center gap-2 align-middle">
Arayüz iyileştirmeleri
<ArayuzBilgisi maddeler={s.arayuz} />
</span>
</li>
)}
</ul>
</li>
))}
</ol>
<GeriBildirimFormu className="mt-16" />
</main>
<SiteFooter />
</>
);
}

View File

@@ -0,0 +1,57 @@
"use client";
import { useRef, useState } from "react";
import { Info } from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
/**
* Sürüm notlarında "Arayüz iyileştirmeleri" satırının yanındaki bilgi düğmesi.
* Düğme ve balon, danışman sohbetindeki "Danışmanın bildikleri" düğmesiyle
* birebir aynıdır (`sohbet-client.tsx`). Fark: dokunmatikte üzerine gelme
* olmadığı için dokununca da açılıp kapanır.
*/
export function ArayuzBilgisi({ maddeler }: { maddeler: string[] }) {
const [acik, setAcik] = useState(false);
// Basış anındaki durum: Radix basışta balonu kapatır, tık ona göre çevirir.
const basistaAcikti = useRef(false);
return (
<TooltipProvider delayDuration={120}>
<Tooltip open={acik} onOpenChange={setAcik}>
<TooltipTrigger asChild>
<button
type="button"
aria-label="Arayüz iyileştirmelerinin ayrıntısı"
onPointerDown={() => {
basistaAcikti.current = acik;
}}
onClick={(e) => {
e.preventDefault();
setAcik(!basistaAcikti.current);
}}
className="relative inline-flex size-7 cursor-help items-center justify-center rounded-full border border-slate-200 bg-card text-slate-600 transition-colors after:absolute after:-inset-2 hover:bg-slate-100 hover:text-slate-800"
>
<Info className="size-3.5" aria-hidden />
</button>
</TooltipTrigger>
<TooltipContent
side="bottom"
sideOffset={6}
className="max-w-xs flex-col items-start gap-1.5 p-3 text-left leading-relaxed"
>
<p className="font-semibold">Bu sürümdeki arayüz iyileştirmeleri:</p>
<ul className="flex list-disc flex-col gap-1 pl-4">
{maddeler.map((m) => (
<li key={m}>{m}</li>
))}
</ul>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}

View File

@@ -201,6 +201,12 @@ export function SiteFooter() {
>
İletişim
</Link>
<Link
href="/surum-notlari"
className="hover:text-slate-700 hover:underline"
>
Sürüm notları
</Link>
</nav>
<TemaSecici className="mt-6" />
{/* iyzico işyeri sözleşmesi gereği: "iyzico ile Öde" + kart logoları

View File

@@ -33,10 +33,12 @@ export function SiteHeader() {
Tercih
</span>
</span>
{/* Beta rozeti + sürüm (surum-rozeti.tsx); yazı logosuyla aynı
fade'i taşır ki NavBaslik yuvası açılınca birlikte sönsün. */}
<SurumRozeti className="ml-2 transition-[opacity,translate] duration-300 ease-out in-data-[nav-baslik]:pointer-events-none in-data-[nav-baslik]:-translate-y-2 in-data-[nav-baslik]:opacity-0 motion-reduce:transition-opacity sm:ml-3" />
</Link>
{/* Beta rozeti + sürüm (surum-rozeti.tsx): /surum-notlari'na giden
ayrı bir Link — logo Link'inin içinde bağlantı içinde bağlantı
olmasın. Yazı logosuyla aynı fade'i taşır ki NavBaslik yuvası
açılınca birlikte sönsün. */}
<SurumRozeti className="ml-2 transition-[opacity,translate] duration-300 ease-out in-data-[nav-baslik]:pointer-events-none in-data-[nav-baslik]:-translate-y-2 in-data-[nav-baslik]:opacity-0 motion-reduce:transition-opacity sm:ml-3" />
<div
id="nav-baslik-yuvasi"
className="pointer-events-none absolute inset-y-0 right-0 left-9 flex min-w-0 items-center sm:left-12 lg:right-auto lg:w-52 xl:w-72"

View File

@@ -1,3 +1,4 @@
import Link from "next/link";
import { SURUM } from "@/lib/surum";
/**
@@ -7,20 +8,25 @@ import { SURUM } from "@/lib/surum";
* (`bg-indigo-500`, §2.2 istisnası). Sürüm rozetin içinde değil, yanında
* küçük puntoyla; dar ekranda yalnız rozet kalır. Logo fade'iyle birlikte
* söner (NavBaslik yuvası açılınca), o yüzden site-header'daki fade
* sınıflarını aynen taşır.
* sınıflarını aynen taşır. Rozet /surum-notlari'na bağlantıdır (beta
* açıklaması, sürüm günlüğü, geri bildirim adresi).
*/
export function SurumRozeti({ className = "" }: { className?: string }) {
return (
<span className={`inline-flex shrink-0 items-center gap-1.5 ${className}`}>
<span className="inline-flex h-[22px] items-center rounded-full bg-indigo-500 px-2 text-xs leading-none font-semibold tracking-[-0.01em] whitespace-nowrap text-white">
<Link
href="/surum-notlari"
aria-label={`Beta sürüm notları, sürüm ${SURUM}`}
className={`group/rozet inline-flex shrink-0 items-center gap-1.5 rounded-full outline-none focus-visible:ring-3 focus-visible:ring-ring/50 ${className}`}
>
<span className="inline-flex h-[22px] items-center rounded-full bg-indigo-500 px-2 text-xs leading-none font-semibold tracking-[-0.01em] whitespace-nowrap text-white transition-opacity duration-150 group-hover/rozet:opacity-85">
Beta
</span>
<span
aria-hidden
className="hidden text-xs leading-none font-medium tracking-[-0.01em] text-slate-500 tabular-nums sm:inline"
aria-label={`Sürüm ${SURUM}`}
>
v{SURUM}
</span>
</span>
</Link>
);
}

View File

@@ -0,0 +1,96 @@
"use client";
// Sürüm notları sayfasının altındaki geri bildirim kutusu: başlık, metin
// alanı, buton. Mesaj geriBildirimGonder sunucu aksiyonuyla destek kutusuna
// e-posta olarak gider.
import { useState } from "react";
import { ArrowRight } from "lucide-react";
import { toast } from "@/components/ui/bildirim";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { TypingAnimation } from "@/components/ui/typing-animation";
import { geriBildirimGonder } from "@/features/geri-bildirim/geri-bildirim-actions";
const BASLIK = "Bir hata mı gördün?";
const DEVAM = "Yalın üretim metodunu benimsiyor ve fikirlerine önem veriyoruz.";
export function GeriBildirimFormu({ className = "" }: { className?: string }) {
const [mesaj, setMesaj] = useState("");
const [pending, setPending] = useState(false);
async function gonder(e: React.FormEvent) {
e.preventDefault();
setPending(true);
try {
const sonuc = await geriBildirimGonder(mesaj);
if (sonuc.ok) {
toast.success("Geri bildirimin ulaştı, teşekkürler.");
setMesaj("");
} else {
toast.error(sonuc.error);
}
} catch {
toast.error("Beklenmeyen bir hata oluştu, tekrar dener misin?");
} finally {
setPending(false);
}
}
// Dış kart yok: başlık sayfadaki sürüm başlıklarıyla aynı ölçekte durur,
// altında tek yüzey var — metin alanı kenarlıksız, buton aynı yüzeyin
// içinde (sohbet kutusuyla aynı kalıp, DESIGN.md §4.6).
return (
<section aria-labelledby="geri-bildirim-baslik" className={className}>
<form onSubmit={gonder}>
{/* Başlığın başı sabit; devamı bir kez daktilo gibi yazılır ve kalır
(döngü yok). Görünmez tam metin aynı hücrede yer tutar ki yazı
ilerlerken kutu aşağı kaymasın. Ekran okuyucu tam metni okur. */}
<h2
id="geri-bildirim-baslik"
className="grid font-heading text-2xl font-bold text-balance text-slate-900 sm:text-3xl"
>
<span className="sr-only">
{BASLIK} {DEVAM}
</span>
<span aria-hidden className="invisible col-start-1 row-start-1">
{BASLIK} {DEVAM}
</span>
<span aria-hidden className="col-start-1 row-start-1">
{BASLIK}{" "}
<TypingAnimation
typeSpeed={40}
delay={400}
className="inline leading-[inherit] tracking-normal text-slate-500"
>
{DEVAM}
</TypingAnimation>
</span>
</h2>
<div className="mt-5 rounded-2xl border border-slate-200 bg-card p-2 transition-colors duration-150 focus-within:border-primary focus-within:ring-3 focus-within:ring-ring/50">
<Textarea
id="geri-bildirim-mesaj"
aria-labelledby="geri-bildirim-baslik"
value={mesaj}
onChange={(e) => setMesaj(e.target.value)}
maxLength={2000}
placeholder="Çekinme, aklına geleni yaz…"
disabled={pending}
className="min-h-28 resize-none border-0 bg-transparent px-3 py-2 focus-visible:ring-0 disabled:bg-transparent"
/>
<div className="flex justify-end">
<Button
type="submit"
size="lg"
variant="cta"
disabled={pending || mesaj.trim().length < 10}
>
{pending ? "Gönderiliyor…" : "Gönder"}
<ArrowRight className="size-4" aria-hidden />
</Button>
</div>
</div>
</form>
</section>
);
}

View File

@@ -0,0 +1,82 @@
"use server";
import { headers } from "next/headers";
import { getSession } from "@/lib/session";
import { geriBildirimEpostasi } from "@/lib/eposta";
import { epostaGonder } from "@/lib/eposta-gonder";
import { SURUM } from "@/lib/surum";
const DESTEK_ADRESI = "destek@kolaytercih.com";
const MIN_UZUNLUK = 10;
const MAX_UZUNLUK = 2000;
// Uç oturumsuz da çalışır; kötüye kullanım yalnız kendi destek kutumuzu ve
// Resend kotasını doldurur. Tek süreçte bellek içi sayaç bunu sınırlamaya
// yeter: IP başına 10 dakikada 3 mesaj.
const PENCERE_MS = 10 * 60 * 1000;
const PENCERE_LIMIT = 3;
const sonGonderimler = new Map<string, number[]>();
function sinirAsildi(ip: string): boolean {
const simdi = Date.now();
const taze = (sonGonderimler.get(ip) ?? []).filter(
(t) => simdi - t < PENCERE_MS,
);
if (taze.length >= PENCERE_LIMIT) {
sonGonderimler.set(ip, taze);
return true;
}
taze.push(simdi);
sonGonderimler.set(ip, taze);
// Harita sınırsız büyümesin: eskimiş kayıtları ara sıra süpür.
if (sonGonderimler.size > 1000) {
for (const [anahtar, zamanlar] of sonGonderimler) {
if (zamanlar.every((t) => simdi - t >= PENCERE_MS)) {
sonGonderimler.delete(anahtar);
}
}
}
return false;
}
export async function geriBildirimGonder(
hamMesaj: string,
): Promise<{ ok: true } | { ok: false; error: string }> {
const mesaj = typeof hamMesaj === "string" ? hamMesaj.trim() : "";
if (mesaj.length < MIN_UZUNLUK) {
return { ok: false, error: "Biraz daha ayrıntı yazar mısın?" };
}
if (mesaj.length > MAX_UZUNLUK) {
return {
ok: false,
error: `Mesaj en fazla ${MAX_UZUNLUK} karakter olabilir.`,
};
}
const [session, h] = await Promise.all([getSession(), headers()]);
const ip = h.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "bilinmiyor";
if (sinirAsildi(ip)) {
return {
ok: false,
error: "Kısa sürede çok mesaj gönderdin. Birkaç dakika sonra tekrar dene.",
};
}
try {
await epostaGonder(
DESTEK_ADRESI,
geriBildirimEpostasi({
mesaj,
gonderen: session?.user.email ?? null,
surum: SURUM,
}),
);
return { ok: true };
} catch (hata) {
console.error("[geri-bildirim] Gönderilemedi:", hata);
return {
ok: false,
error: `Mesajın gönderilemedi. ${DESTEK_ADRESI} adresine yazabilirsin.`,
};
}
}

View File

@@ -211,3 +211,39 @@ export function krediHatirlatmaEpostasi(): EpostaIcerik {
].join("\n"),
};
}
/**
* Sürüm notları sayfasındaki geri bildirim kutusundan gelen mesaj — destek
* kutusuna düşer, kullanıcıya gitmez. `gonderen` oturum varsa hesabın
* e-postasıdır; yoksa mesaj anonimdir.
*/
export function geriBildirimEpostasi({
mesaj,
gonderen,
surum,
}: {
mesaj: string;
gonderen: string | null;
surum: string;
}): EpostaIcerik {
const konu = `Geri bildirim (v${surum})`;
const kimden = gonderen ?? "Anonim (oturum yok)";
const govde = `
<h1 style="margin:0 0 12px;font-family:${BASLIK_TIPI};font-size:22px;line-height:1.35;font-weight:700;color:${LACIVERT};">
Yeni geri bildirim
</h1>
<p style="margin:0 0 16px;font-size:13px;">
Gönderen: <strong style="color:${LACIVERT};">${kacir(kimden)}</strong> &middot; v${kacir(surum)}
</p>
<p style="margin:0;white-space:pre-wrap;color:${LACIVERT};">${kacir(mesaj)}</p>`;
return {
subject: konu,
html: kabuk({
onizleme: kacir(mesaj.slice(0, 90)),
govde,
baslikEtiketi: konu,
}),
text: [`Gönderen: ${kimden} · v${surum}`, "", mesaj].join("\n"),
};
}