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:
@@ -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
|
(`next-themes`, `<html class="dark">`). Renklerin tek kaynağı `src/app/globals.css` içindeki
|
||||||
`.dark` bloğu.
|
`.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.
|
**Nasıl çalışır:** bileşenlere `dark:` yazılmaz; palet token'ları çevrilir.
|
||||||
|
|
||||||
| Açık temadaki sınıf | Koyu temada ne olur |
|
| 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).
|
- Kaydırmaya bağlı tetik `IntersectionObserver` ile (kaydırma dinleyicisi değil).
|
||||||
- "Hareketi azalt": kayma/yay yok, yalnız opaklık (`useReducedMotion`,
|
- "Hareketi azalt": kayma/yay yok, yalnız opaklık (`useReducedMotion`,
|
||||||
`motion-reduce:transition-opacity`).
|
`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
|
## 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 |
|
| 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 |
|
| 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` |
|
| 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ı |
|
| 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 |
|
| 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 |
|
| Üç kart yalnızca üç gerçek öğe varsa | Hero + üç kart iskeleti refleksi |
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ const staticEntries: MetadataRoute.Sitemap = [
|
|||||||
{ url: `${SITE_URL}/paket`, priority: 0.7, changeFrequency: "monthly" },
|
{ url: `${SITE_URL}/paket`, priority: 0.7, changeFrequency: "monthly" },
|
||||||
{ url: `${SITE_URL}/meraklisina`, priority: 0.6, changeFrequency: "monthly" },
|
{ url: `${SITE_URL}/meraklisina`, priority: 0.6, changeFrequency: "monthly" },
|
||||||
{ url: `${SITE_URL}/iletisim`, priority: 0.3, changeFrequency: "yearly" },
|
{ 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}/gizlilik`, priority: 0.2, changeFrequency: "yearly" },
|
||||||
{
|
{
|
||||||
url: `${SITE_URL}/cerez-politikasi`,
|
url: `${SITE_URL}/cerez-politikasi`,
|
||||||
|
|||||||
114
src/app/surum-notlari/page.tsx
Normal file
114
src/app/surum-notlari/page.tsx
Normal 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 />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
57
src/components/arayuz-bilgisi.tsx
Normal file
57
src/components/arayuz-bilgisi.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -201,6 +201,12 @@ export function SiteFooter() {
|
|||||||
>
|
>
|
||||||
İletişim
|
İletişim
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/surum-notlari"
|
||||||
|
className="hover:text-slate-700 hover:underline"
|
||||||
|
>
|
||||||
|
Sürüm notları
|
||||||
|
</Link>
|
||||||
</nav>
|
</nav>
|
||||||
<TemaSecici className="mt-6" />
|
<TemaSecici className="mt-6" />
|
||||||
{/* iyzico işyeri sözleşmesi gereği: "iyzico ile Öde" + kart logoları
|
{/* iyzico işyeri sözleşmesi gereği: "iyzico ile Öde" + kart logoları
|
||||||
|
|||||||
@@ -33,10 +33,12 @@ export function SiteHeader() {
|
|||||||
Tercih
|
Tercih
|
||||||
</span>
|
</span>
|
||||||
</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>
|
</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
|
<div
|
||||||
id="nav-baslik-yuvasi"
|
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"
|
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"
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import Link from "next/link";
|
||||||
import { SURUM } from "@/lib/surum";
|
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
|
* (`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
|
* 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ö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 }) {
|
export function SurumRozeti({ className = "" }: { className?: string }) {
|
||||||
return (
|
return (
|
||||||
<span className={`inline-flex shrink-0 items-center gap-1.5 ${className}`}>
|
<Link
|
||||||
<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">
|
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
|
Beta
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
|
aria-hidden
|
||||||
className="hidden text-xs leading-none font-medium tracking-[-0.01em] text-slate-500 tabular-nums sm:inline"
|
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}
|
v{SURUM}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
82
src/features/geri-bildirim/geri-bildirim-actions.ts
Normal file
82
src/features/geri-bildirim/geri-bildirim-actions.ts
Normal 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.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -211,3 +211,39 @@ export function krediHatirlatmaEpostasi(): EpostaIcerik {
|
|||||||
].join("\n"),
|
].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> · 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"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user