feat(surum-notlari): geri bildirim kutusu ve arayüz iyileştirmeleri balonu

Dev CTA bandı yerine başlık + metin alanı + buton; mesaj sunucu aksiyonuyla
destek kutusuna e-posta olarak gider (IP başına 10 dk'da 3). Başlığın başı
sabit, devamı bir kez daktilo gibi yazılır.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bilalgursen
2026-10-09 01:39:59 +03:00
parent e0d0553293
commit e8e2c73527
5 changed files with 330 additions and 94 deletions

View File

@@ -1,147 +1,112 @@
import type { Metadata } from "next";
import { ArrowRight, Mail } from "lucide-react";
import { ArayuzBilgisi } from "@/components/arayuz-bilgisi";
import { Parallax } from "@/components/parallax";
import { PagePixelDivider, SectionEyebrow } from "@/components/pixel-decor";
import { GeriBildirimFormu } from "@/features/geri-bildirim/components/geri-bildirim-formu";
import { SiteFooter } from "@/components/site-footer";
import { Kbd } from "@/components/ui/kbd";
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, neler geliyor ve geri bildirimini nasıl iletirsin.",
"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; "Geliyor" listesi bitenlerden arındırılır.
* 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;
baslik: string;
maddeler: string[];
arayuz?: string[];
}[] = [
{
surum: "1.1.0",
tarih: "7 Ekim 2026",
baslik: "Tema kısayolu ve sürüm notları",
maddeler: [
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 adresi. Header'daki Beta rozeti buraya getirir.",
"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",
baslik: "Beta açılışı",
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ı.",
"Açık, koyu ve sistem teması.",
],
arayuz: ["Açık, koyu ve sistem teması."],
},
];
const GELIYOR = [
"Listeni PDF olarak indirme ve paylaşma.",
"Yerleşme sonuçları açıklandığında listenin gerçekleşme karnesi.",
"Veli görünümü: listeyi birlikte inceleyip not bırakma.",
];
export default function SurumNotlariPage() {
return (
<>
<main className="mx-auto w-full max-w-2xl px-4 py-16">
<SectionEyebrow>Beta · v{SURUM}</SectionEyebrow>
<Parallax fromStart strength={10}>
<h1 className="mt-4 font-heading text-3xl font-bold">Sürüm notları</h1>
<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>
<PagePixelDivider seed={61} className="mt-6" />
<section
aria-labelledby="beta-baslik"
className="mt-6 rounded-2xl border border-slate-200 bg-card p-5 shadow-xs sm:p-6"
>
<h2 id="beta-baslik" className="font-heading text-xl font-bold">
KolayTercih şu an beta sürümünde
</h2>
<p className="mt-3 text-sm leading-7 text-slate-700">
Ü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. Bir hata, yanlış bir sayı, anlaşılmayan bir ekran
ya da “keşke şöyle olsa” dediğin bir şey görürsen bize yaz: her
geri bildirim okunur ve çoğu bir sonraki sürüme girer.
</p>
<a
href="mailto:destek@kolaytercih.com?subject=KolayTercih%20beta%20geri%20bildirimi"
className="mt-5 inline-flex items-center gap-2 rounded-full bg-slate-900 px-5 py-3 text-sm font-semibold text-slate-50 transition-[background-color,transform] duration-150 hover:bg-slate-700 active:scale-[0.97]"
>
<Mail className="size-4" aria-hidden />
Geri bildirim gönder
<ArrowRight className="size-4" aria-hidden />
</a>
<p className="mt-3 text-xs text-slate-500">
destek@kolaytercih.com · Hangi sayfada, hangi sıralamayla ne
gördüğünü yazarsan daha hızlı çözeriz. Kart bilgini asla e-postayla
paylaşma.
</p>
</section>
<section aria-labelledby="gunluk-baslik" className="mt-12">
<h2 id="gunluk-baslik" className="font-heading text-xl font-bold">
Neler değişti
</h2>
<ol className="mt-4 space-y-8">
{SURUMLER.map((s) => (
<li key={s.surum} className="relative pl-6">
{/* 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-2 left-0 size-2.5 rounded-[2px] bg-primary"
className="absolute top-4 -bottom-4 left-[4.5px] w-px bg-slate-200 sm:top-[18px] sm:-bottom-[18px]"
/>
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<h3 className="font-heading text-lg font-bold tabular-nums">
v{s.surum}
</h3>
<span className="text-sm font-semibold text-slate-700">
{s.baslik}
)}
<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>
<time className="text-xs text-slate-500">{s.tarih}</time>
</div>
<ul className="mt-3 list-disc space-y-1.5 pl-5 text-sm leading-7 text-slate-700">
{s.maddeler.map((m) => (
<li key={m}>{m}</li>
))}
</ul>
</li>
))}
</ol>
</section>
)}
</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>
<section aria-labelledby="geliyor-baslik" className="mt-12">
<h2 id="geliyor-baslik" className="font-heading text-xl font-bold">
Sırada ne var
</h2>
<ul className="mt-4 list-disc space-y-1.5 pl-5 text-sm leading-7 text-slate-700">
{GELIYOR.map((m) => (
<li key={m}>{m}</li>
))}
</ul>
<p className="mt-4 text-xs text-slate-500">
Sıra geri bildirimle değişir; en çok istenen öne alınır.
</p>
</section>
<p className="mt-12 flex flex-wrap items-center gap-1.5 text-xs text-slate-500">
İpucu: <Kbd>D</Kbd> açık / koyu temayı değiştirir,
<Kbd>⌘</Kbd>
<Kbd>K</Kbd> aramayı açar.
</p>
<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

@@ -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 "sonner";
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"
disabled={pending || mesaj.trim().length < 10}
className="cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600 active:scale-[0.97]"
>
{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"),
};
}