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

@@ -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

@@ -210,4 +210,40 @@ export function krediHatirlatmaEpostasi(): EpostaIcerik {
...METIN_ALT_BILGI,
].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"),
};
}