Merge pull request 'v1-beta-tema' (#9) from v1-beta-tema into main
All checks were successful
Deploy / deploy (push) Successful in 12m32s

Reviewed-on: #9
This commit was merged in pull request #9.
This commit is contained in:
2026-10-09 02:30:43 +03:00
22 changed files with 940 additions and 96 deletions

View File

@@ -9,6 +9,11 @@ This version has breaking changes — APIs, conventions, and file structure may
**İstisna: `tasarimci` ve UX işi.** Tasarımcı, gerekli gördüğünde önizlemeyi/dev sunucusunu **açabilir**. Bazı tasarım sorunları koddan teşhis edilemez — taşma, üst üste binme, kırılma noktaları, gerçek metin uzunluğuyla oluşan yerleşim, karanlık mod, odak halkası, dokunma hedefi. Bunlarda "koddan çıkarım" yetmez; bakmak gerekir.
**Zorunlu: yeni UI parçası tasarlayan herkes bakar (9 Eki 2026).** Yukarıdaki "açma" varsayılanı küçük düzeltmeler içindir (metin, renk, tek sınıf değişikliği). İş **yeni bir bileşen/bölüm tasarlamak ya da mevcut bir bölümü yeniden düzenlemek** ise — rol ne olursa olsun, ana oturum dahil — sonuç ekranda görülmeden teslim edilmez. 9 Ekim'de sürüm notlarındaki geri bildirim formu bakılmadan teslim edildi; DESIGN.md sınıfları birebir doğruydu ama sonuç kötüydü (kutu içinde kutu, sayfa başlıklarının yanında cılız başlık, soluk küçük buton) ve Bilal uyarmak zorunda kaldı. Sınıfların doğru olması kompozisyonun iyi olduğunu göstermez; oran ve hiyerarşi koddan görünmez. Yapılacaklar:
- Önce Bilal'in açık dev sunucusu var mı bak (`lsof -iTCP -sTCP:LISTEN -P -n | grep node`); varsa onu kullan, yeni sunucu açma ve onunkini kapatma. Yoksa aç, iş bitince kapat.
- **375 px ve masaüstünde** ekran görüntüsü al; ilk hâli beğenmediysen düzelt, tekrar bak. Raporda hangi genişlikte ne gördüğünü ve neye bakmadığını (ör. açık tema) yaz.
- Yazmadan önce parçayı **sayfanın geri kalanıyla** ölç: başlık o sayfadaki kardeş başlıklarla aynı ölçekte olur; kart içine kenarlıklı alan konmaz (kutu içinde kutu) — form için tek yüzey: kenarlıksız `Textarea` + buton aynı yüzeyin içinde (sohbet kutusu kalıbı, örnek `src/features/geri-bildirim/components/geri-bildirim-formu.tsx`); aksiyon butonu kanonik turuncu CTA'dır (`size="lg"`), soluk `h-9` değil.
Açtığında geçerli olanlar:
- **Gerekçesini yaz:** neyi koddan göremediğin için açtın.
- **Ne gördüğünü yaz**, hangi genişlikte (375 / 768 / 1024 / 1280 px) ne olduğunu tek tek. Ekran görüntüsü alabilirsin.

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` `temaSekli`, 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 |
@@ -698,6 +714,43 @@ genişletilebilir olmalı; UI için prompt-kit kullan."
zaman yazılıdır (§2.2). Sohbet ilerlese de kalır. Yeni bir liste uyarısı gerekince
`listeUyarilari`'na eklenir, sayfaya yeni kutu açılmaz.
### 4.34 Logo biçim dönüşümü (morph) — `AnimasyonluMark` `temaSekli`, `tema-ikonu.tsx`
(Numara 4.34: `v2` dalındaki belge 4.21–4.33'ü kullanıyor; iki dal birleşince çakışmasın.)
Bilal'in kararı (9 Eki 2026): logonun üç karesi yalnız süs değildir; **bir durumu anlatmak
için kısa bir an başka bir biçime dönüşüp geri toplanabilir.** İlk ve şimdilik tek kullanım
tema değişimidir (§2.3). Bu, markanın yumuşak ve oyunsu tarafının imza hareketidir; piksel
imzası (§1) durağan dokuysa bu onun canlı karşılığıdır.
**Biçimler** (180'lik logo kutusunda; renkler logonun kendi renkleri, iki temada aynı):
| Durum | Mavi kare | Şeftali kare | Turuncu kare |
|---|---|---|---|
| Açık tema: **güneş** | merkezde söner | hale: büyük, yarı saydam daire | güneş: küçük dolu daire |
| Koyu tema: **ay** | yıldız: hilalin iki sivri ucunun tam ortasında | hilal: sırtı solda | yıldızın yerinde söner |
| Sistem (yalnız ikon) | logo karesi | logo karesi | logo karesi |
Simetri kasıtlıdır: güneşte mavi, ayda turuncu sahneden çekilir. Hilal, kareyi kesen ikinci
bir şekille değil karenin kendi iç gölgesiyle çizilir (zemin rengine bağlı kalmaz).
**Koreografi:** toplam 1,7 sn — %30'da biçim tamam, %70'e kadar durur, sonra logoya döner.
Kareler yuvarlanırken yer değiştirir ve boy değiştirir; sekme yok (§6). Sayfa geçişiyle
**üst üste binmez**: önce sayfa geçişi biter (600 ms), kısa bir nefes, sonra logo dönüşür.
Hover döngüsünden önceliklidir; biçim bitince imleç hâlâ üzerindeyse hover yerinden sürer.
"Hareketi azalt" açıksa dönüşüm oynamaz.
**Durağan hâli:** tema seçicinin ikonları aynı biçimlerdir (`tema-ikonu.tsx`). Logo ile ikon
aynı geometriyi kullanır; biri değişirse öbürü de aynı commit'te değişir.
**Kurallar:**
- Dönüşüm yalnız header'daki logoda oynar; aynı ekranda iki logo birden dönüşmez.
- Bir **durum değişimini** anlatır (tema gibi), dekor olarak döngüye alınmaz, sayfa
açılışında oynatılmaz.
- Yeni bir biçim eklenecekse üç kareden ve logonun üç renginden çıkar; dördüncü şekil,
yeni renk, çizgi ikon eklenmez. Her biçimin durağan ikon karşılığı da yazılır.
- Biçim logonun kendi alanına sığar (x 27–151): sağındaki "KolayTercih" yazısına değmez.
## 5. Yerleşim
### 5.1 Genel
@@ -767,6 +820,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
@@ -784,6 +839,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

@@ -1,6 +1,6 @@
{
"name": "kolaytercih",
"version": "1.0.0",
"version": "1.1.0",
"private": true,
"scripts": {
"dev": "next dev",

View File

@@ -476,3 +476,47 @@
.toaster [data-sonner-toast][data-styled="true"] [data-button]:hover {
background: var(--color-slate-700);
}
/* 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);
}
}

View File

@@ -117,7 +117,7 @@ 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}
</div>

View File

@@ -58,11 +58,9 @@ import {
import type { Metadata } from "next";
import {
JsonLd,
KATALOG_VERI_TARIHI,
faqPageJsonLd,
softwareApplicationJsonLd,
} from "@/lib/seo";
import { KATALOG_TARIH_METNI } from "@/features/katalog/components/veri-kaynagi";
import { SiteFooter } from "@/components/site-footer";
export const metadata: Metadata = {
@@ -257,27 +255,6 @@ export default function Home() {
>
<HeroForm />
</div>
<div className="mt-3 inline-flex items-center gap-2.5 rounded-lg border border-slate-200 bg-card px-3 py-2 text-left text-xs text-slate-700 shadow-xs">
<span>
<strong className="font-semibold text-slate-900">
Veri: YÖK Atlas
</strong>{" "}
· son güncelleme{" "}
<time dateTime={KATALOG_VERI_TARIHI}>
{KATALOG_TARIH_METNI}
</time>
</span>
</div>
{/* Markayı tek cümlede tanımlayan görünür metin: sitedeki SSS ve
/kosullar ifadelerinin toplamı, yeni vaat yok. Meta/OG'de zaten
var; yapay zekâ cevap motorları gövde metnini önceliklendirdiği
için burada da durur (docs/arastirma/2026-09-24-geo-seo). */}
<p className="mt-5 max-w-2xl text-sm leading-6 text-slate-600">
KolayTercih, YÖK Atlas&apos;ın resmî yerleştirme verisiyle
sıralamana uygun 24 tercihlik dengeli liste kuran, yapay zekâ
destekli bir karar destek aracıdır. ÖSYM ya da YÖK ile bağlantılı
değildir; yerleşme garantisi vermez.
</p>
</Parallax>
</section>

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

@@ -1,6 +1,7 @@
"use client";
import { useEffect, useRef } from "react";
import { TEMA_GECIS_OLAYI } from "@/lib/tema-gecisi";
import { cn } from "@/lib/utils";
// public/kolay-tercih-mark.svg'nin inline, animasyonlu karşılığı.
@@ -97,6 +98,51 @@ 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 };
// 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. */
function sekilHedefi(i: number, koyu: boolean): SekilHedefi {
const b = KARELER[i].boyut;
if (koyu) {
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 }, // yıldız
{ translate: merkezde(ay(76), ay(92, 0), b), scale: String((148 * AY_OLCEK) / b), opacity: 1 }, // hilal
{ translate: merkezde(ay(108), ay(79, 0), b), scale: "0.2", opacity: 0 }, // söner
][i];
}
return [
{ translate: merkezde(90, 90, b), scale: "0.3", opacity: 0 }, // söner
{ translate: merkezde(90, 90, b), scale: "2.15", opacity: 0.45 }, // hale
{ translate: merkezde(90, 90, b), scale: "1.1", opacity: 1 }, // güneş
][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ı). */
@@ -184,6 +230,7 @@ export function AnimasyonluMark({
const kokRef = useRef<HTMLSpanElement>(null);
const kontrolRef = useRef<{
ayarla: (hedef: Varyant | null, cevik?: boolean) => void;
temaSekli: (koyu: boolean) => void;
} | null>(null);
// Animasyonların sahibi: bileşen yaşadıkça bir kez kurulur. Prop değişimi
@@ -197,6 +244,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 +282,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 +295,94 @@ 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.
*/
const temaSekli = (koyu: boolean) => {
if (azHareket.matches) return;
// 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, koyu), borderRadius: "50%" };
const secenek = { duration: SEKIL_SURE };
const animasyonlar = [
el.animate(
[
{ ...anlik[i], easing: DONUS },
{ ...bicim, easing: "linear" },
{ ...bicim, easing: YERLES },
ev,
].map((kare, d) => ({ ...kare, offset: SEKIL_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, temaSekli };
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());
};
}, []);
@@ -276,9 +404,18 @@ 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?.temaSekli(
document.documentElement.classList.contains("dark"),
);
window.addEventListener(TEMA_GECIS_OLAYI, temaGecti);
return () => {
hoverHedefi.removeEventListener("mouseenter", gir);
hoverHedefi.removeEventListener("mouseleave", cik);
window.removeEventListener(TEMA_GECIS_OLAYI, temaGecti);
};
}, [oynat, sadeceHover, varyant]);

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

@@ -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">
@@ -199,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ı
@@ -212,6 +220,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

@@ -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,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,15 @@
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 { temayiGecisle } from "@/lib/tema-gecisi";
import { cn } from "@/lib/utils";
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" },
{ deger: "dark", etiket: "Koyu" },
{ deger: "system", etiket: "Sistem" },
] as const;
const aboneOlma = () => () => {};
@@ -16,7 +18,9 @@ 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).
* hydration sonrası işaretlenir (yerleşim oynamaz). İkonlar geçişte logonun
* dönüştüğü biçimlerdir: güneş, ay ve logonun kendisi (tema-ikonu.tsx). Seçim logodan yumuşak bir halka
* gibi yayılarak uygulanır (lib/tema-gecisi.ts); altında `D` kısayolu ipucu durur.
*/
export function TemaSecici({ className }: { className?: string }) {
const { theme, setTheme } = useTheme();
@@ -27,35 +31,38 @@ export function TemaSecici({ className }: { className?: string }) {
);
return (
<div
role="radiogroup"
aria-label="Tema"
className={cn(
"inline-flex items-center gap-1 rounded-full border border-slate-200 bg-card p-1",
className,
)}
>
{SECENEKLER.map(({ deger, etiket, ikon: Ikon }) => {
const secili = tarayicida && theme === deger;
return (
<button
key={deger}
type="button"
role="radio"
aria-checked={secili}
onClick={() => setTheme(deger)}
className={cn(
"inline-flex h-9 cursor-pointer items-center gap-1.5 rounded-full px-3 text-sm font-semibold transition-[background-color,color,transform] duration-150 outline-none focus-visible:ring-3 focus-visible:ring-ring/50 active:scale-[0.97]",
secili
? "bg-slate-900 text-slate-50"
: "text-slate-600 hover:text-orange-500",
)}
>
<Ikon className="size-4" aria-hidden />
{etiket}
</button>
);
})}
<div className={cn("flex flex-col items-center gap-2", className)}>
<div
role="radiogroup"
aria-label="Tema"
className="inline-flex items-center gap-1 rounded-full border border-slate-200 bg-card p-1"
>
{SECENEKLER.map(({ deger, etiket }) => {
const secili = tarayicida && theme === deger;
return (
<button
key={deger}
type="button"
role="radio"
aria-checked={secili}
onClick={() => temayiGecisle(() => setTheme(deger))}
className={cn(
"inline-flex h-9 cursor-pointer items-center gap-1.5 rounded-full px-3 text-sm font-semibold transition-[background-color,color,transform] duration-150 outline-none focus-visible:ring-3 focus-visible:ring-ring/50 active:scale-[0.97]",
secili
? "bg-slate-900 text-slate-50"
: "text-slate-600 hover:text-orange-500",
)}
>
<TemaIkonu tur={deger} className="size-[18px]" />
{etiket}
</button>
);
})}
</div>
<p className="flex items-center gap-1.5 text-xs text-slate-500">
<Kbd>D</Kbd>
<span>ile açık / koyu arasında geç</span>
</p>
</div>
);
}

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

@@ -20,11 +20,10 @@ import { authClient } from "@/lib/auth-client";
import { olay, siraKovasi } from "@/lib/analitik";
import { onbellekliJsonGetir } from "@/lib/istek-cache";
import {
profilDuzenlemeyiAc,
useTercihProfili,
} from "@/features/liste/hooks/use-tercih-profili";
import type { SihirbazFacetleri } from "@/types/yokatlas";
import { PUAN_TURU_ETIKET, sonucHref, type SihirbazSecimleri } from "@/features/sihirbaz/sihirbaz-sabitler";
import { sonucHref, type SihirbazSecimleri } from "@/features/sihirbaz/sihirbaz-sabitler";
import { tercihProfiliYaz } from "@/features/sihirbaz/sihirbaz-profil";
// lib/db sunucu tarafı; etiketler client bundle'a sızmasın diye burada.
@@ -105,8 +104,7 @@ export function HeroForm() {
}
// Kayıtlı profil varsa sihirbazı atla, doğrudan sonuç sayfasına götür.
// "Farklı sıralama gir" navbar'daki Değiştir ile aynı düzenleme modalını
// (TercihProfiliKapisi, layout'ta monte) açar.
// Sıralamayı değiştirmek navbar'daki Değiştir'den yapılır.
if (kayitliProfil) {
return (
<div className="flex w-full max-w-md flex-col items-center gap-3">
@@ -120,18 +118,6 @@ export function HeroForm() {
<ArrowRight className="size-4" aria-hidden />
</Link>
</Button>
<p className="text-sm text-slate-600">
{kayitliProfil.sira.toLocaleString("tr-TR")} ·{" "}
{PUAN_TURU_ETIKET[kayitliProfil.tur] ?? kayitliProfil.tur} sıralamanla
devam edersin.{" "}
<button
type="button"
onClick={profilDuzenlemeyiAc}
className="cursor-pointer font-medium text-slate-900 underline underline-offset-2 hover:text-slate-700"
>
Farklı sıralama gir
</button>
</p>
</div>
);
}

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"),
};
}

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,
);
});
}