Refactor code structure for improved readability and maintainability
All checks were successful
Deploy / deploy (push) Successful in 10m26s

This commit is contained in:
bilalgursen
2026-07-30 03:17:12 +03:00
parent 40a1c8ef8e
commit dc6d894a37
33 changed files with 1574 additions and 651 deletions

View File

@@ -12,6 +12,12 @@
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev", "--", "-p", "3100"],
"port": 3100
},
{
"name": "prod-3100",
"runtimeExecutable": "pnpm",
"runtimeArgs": ["start", "-p", "3100"],
"port": 3100
}
]
}

Binary file not shown.

View File

@@ -12,6 +12,7 @@
},
"dependencies": {
"@anthropic-ai/sdk": "^0.112.4",
"@cds/city": "^1.1.0",
"@libsql/client": "^0.17.4",
"@react-pdf/renderer": "^4.5.1",
"better-auth": "^1.6.23",
@@ -20,6 +21,7 @@
"clsx": "^2.1.1",
"drizzle-orm": "^0.45.2",
"iyzipay": "^2.0.69",
"lenis": "^1.3.25",
"lucide-react": "^1.25.0",
"marked": "^18.0.7",
"motion": "^12.42.2",

29
pnpm-lock.yaml generated
View File

@@ -11,6 +11,9 @@ importers:
'@anthropic-ai/sdk':
specifier: ^0.112.4
version: 0.112.4(zod@4.4.3)
'@cds/city':
specifier: ^1.1.0
version: 1.1.0
'@libsql/client':
specifier: ^0.17.4
version: 0.17.4
@@ -35,6 +38,9 @@ importers:
iyzipay:
specifier: ^2.0.69
version: 2.0.69
lenis:
specifier: ^1.3.25
version: 1.3.25(react@19.2.4)
lucide-react:
specifier: ^1.25.0
version: 1.25.0(react@19.2.4)
@@ -363,6 +369,9 @@ packages:
'@better-fetch/fetch@1.3.1':
resolution: {integrity: sha512-ABkD1WhyfPZprKRQI3bhATjeiFuNWC9PXhfGWqL+sg/gKrM977oFrYkdb4msM3hgUGonr7KlOsOFT5TU2rht9g==}
'@cds/city@1.1.0':
resolution: {integrity: sha512-S9K+Q39BGOghyLHmR0Wdcmu1i1noSUk8HcvMj+3IaohZw02WFd99aPTQDHJeseXrXZP3CNovaSlePI0R11NcFg==}
'@dotenvx/dotenvx@1.75.1':
resolution: {integrity: sha512-/BITOC9dmS/edY2zQwZNicQ059O6RKabtQfyEafV0nGtfYRNHYy1DIPiYVcov40+tob9hfmBnbR963dS+EQ1DQ==}
hasBin: true
@@ -3966,6 +3975,20 @@ packages:
resolution: {integrity: sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==}
engines: {node: '>=0.10'}
lenis@1.3.25:
resolution: {integrity: sha512-mOKxayErlaONK8fm4LN3XNd99Qu4plTpn9h9qf8wxzjGrJDzuD84FYzZ81HCd6ZsWp++VWVwOzL286Pf2s2u4A==}
peerDependencies:
'@nuxt/kit': '>=3.0.0'
react: '>=17.0.0'
vue: '>=3.0.0'
peerDependenciesMeta:
'@nuxt/kit':
optional: true
react:
optional: true
vue:
optional: true
levn@0.4.1:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
@@ -5679,6 +5702,8 @@ snapshots:
'@better-fetch/fetch@1.3.1': {}
'@cds/city@1.1.0': {}
'@dotenvx/dotenvx@1.75.1':
dependencies:
'@dotenvx/primitives': 0.8.0
@@ -9213,6 +9238,10 @@ snapshots:
dependencies:
language-subtag-registry: 0.3.23
lenis@1.3.25(react@19.2.4):
optionalDependencies:
react: 19.2.4
levn@0.4.1:
dependencies:
prelude-ls: 1.2.1

View File

@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { Loader2, Mail } from "lucide-react";
import { Mail } from "lucide-react";
import { toast } from "sonner";
import { authClient } from "@/lib/auth-client";
import { Button } from "@/components/ui/button";
@@ -126,12 +126,8 @@ export function GirisForm({ callbackURL }: { callbackURL: string }) {
onClick={googleIleGiris}
disabled={busy !== null}
>
{busy === "google" ? (
<Loader2 className="size-4 animate-spin" aria-hidden />
) : (
<GoogleIcon />
)}
Google ile devam et
<GoogleIcon />
{busy === "google" ? "Yönlendiriliyorsun…" : "Google ile devam et"}
</Button>
<div className="flex items-center gap-3 text-xs text-slate-400">
@@ -156,12 +152,8 @@ export function GirisForm({ callbackURL }: { callbackURL: string }) {
className="h-11 w-full cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
disabled={busy !== null}
>
{busy === "email" ? (
<Loader2 className="size-4 animate-spin" aria-hidden />
) : (
<Mail className="size-4" aria-hidden />
)}
Giriş bağlantısı gönder
<Mail className="size-4" aria-hidden />
{busy === "email" ? "Gönderiliyor…" : "Giriş bağlantısı gönder"}
</Button>
</form>
</div>

View File

@@ -1,5 +1,7 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Parallax } from "@/components/parallax";
import { ScrollFlow } from "@/components/scroll-flow";
export const metadata: Metadata = {
title: "Gizlilik ve KVKK — KolayTercih",
@@ -8,59 +10,70 @@ export const metadata: Metadata = {
// NOT: MVP taslağıdır; yayına almadan önce hukuki gözden geçirme gerekir.
export default function GizlilikPage() {
return (
<main className="mx-auto w-full max-w-2xl px-4 py-16">
<h1 className="font-heading text-3xl font-bold">
Gizlilik ve Kişisel Verilerin Korunması
</h1>
<p className="mt-2 text-sm text-slate-500">Son güncelleme: 26 Temmuz 2026</p>
<ScrollFlow>
<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">
Gizlilik ve Kişisel Verilerin Korunması
</h1>
<p className="mt-2 text-sm text-slate-500">
Son güncelleme: 26 Temmuz 2026
</p>
</Parallax>
<div className="mt-8 space-y-6 text-sm leading-7 text-slate-700">
<section>
<h2 className="font-heading text-lg font-bold text-slate-900">
Hangi verileri işliyoruz?
</h2>
<p className="mt-2">
KolayTercih; e-posta adresini (hesap oluşturma ve giriş için), YKS
başarı sıralamanı ve sihirbazda yaptığın seçimleri (tercih listeni
üretmek için), danışman sohbetine yazdığın mesajları (sorularını
yanıtlamak için) ve ödeme yaptığında sipariş kayıtlarını işler.
Kart bilgilerin bize hiç ulaşmaz; ödeme iyzico altyapısında
gerçekleşir.
</p>
</section>
<section>
<h2 className="font-heading text-lg font-bold text-slate-900">
Ne için kullanıyoruz?
</h2>
<p className="mt-2">
Verilerin yalnızca tercih önerileri üretmek, hesabını ve kredi
bakiyeni yönetmek ve sana hizmetle ilgili e-posta (giriş bağlantısı,
ödeme bilgilendirmesi) göndermek için kullanılır. Verilerini
üçüncü taraflara satmayız, reklam amacıyla paylaşmayız.
</p>
</section>
<section>
<h2 className="font-heading text-lg font-bold text-slate-900">
Saklama ve silme
</h2>
<p className="mt-2">
Hesabını sildirmek veya verilerinin bir kopyasını istemek için{" "}
<Link href="/iletisim" className="text-primary underline">
iletişim sayfasındaki
</Link>{" "}
adresten bize yazabilirsin; talepler 30 gün içinde sonuçlandırılır.
</p>
</section>
<section>
<h2 className="font-heading text-lg font-bold text-slate-900">
Çerezler
</h2>
<p className="mt-2">
Yalnızca oturumunu ık tutmak için zorunlu çerezler kullanılır;
reklam veya izleme çerezi kullanılmaz.
</p>
</section>
</div>
</main>
<Parallax
fromStart
strength={4}
className="mt-8 space-y-6 text-sm leading-7 text-slate-700"
>
<section>
<h2 className="font-heading text-lg font-bold text-slate-900">
Hangi verileri işliyoruz?
</h2>
<p className="mt-2">
KolayTercih; e-posta adresini (hesap oluşturma ve giriş için), YKS
başarı sıralamanı ve sihirbazda yaptığın seçimleri (tercih listeni
üretmek için), danışman sohbetine yazdığın mesajları (sorularını
yanıtlamak için) ve ödeme yaptığında sipariş kayıtlarını işler.
Kart bilgilerin bize hiç ulaşmaz; ödeme iyzico altyapısında
gerçekleşir.
</p>
</section>
<section>
<h2 className="font-heading text-lg font-bold text-slate-900">
Ne için kullanıyoruz?
</h2>
<p className="mt-2">
Verilerin yalnızca tercih önerileri üretmek, hesabını ve kredi
bakiyeni yönetmek ve sana hizmetle ilgili e-posta (giriş
bağlantısı, ödeme bilgilendirmesi) göndermek için kullanılır.
Verilerini üçüncü taraflara satmayız, reklam amacıyla paylaşmayız.
</p>
</section>
<section>
<h2 className="font-heading text-lg font-bold text-slate-900">
Saklama ve silme
</h2>
<p className="mt-2">
Hesabını sildirmek veya verilerinin bir kopyasını istemek için{" "}
<Link href="/iletisim" className="text-primary underline">
iletişim sayfasındaki
</Link>{" "}
adresten bize yazabilirsin; talepler 30 gün içinde
sonuçlandırılır.
</p>
</section>
<section>
<h2 className="font-heading text-lg font-bold text-slate-900">
Çerezler
</h2>
<p className="mt-2">
Yalnızca oturumunu ık tutmak için zorunlu çerezler kullanılır;
reklam veya izleme çerezi kullanılmaz.
</p>
</section>
</Parallax>
</main>
</ScrollFlow>
);
}

View File

@@ -7,9 +7,10 @@
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-work-sans);
--font-sans: var(--font-clarity-city);
--font-mono: var(--font-geist-mono);
--font-heading: var(--font-outfit);
--font-heading: var(--font-cal-sans);
--font-bricolage: var(--font-bricolage-grotesque);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
@@ -168,7 +169,22 @@
body {
@apply bg-background text-foreground;
}
/* Chrome autofill'in soluk mavi dolgusu inputun kendi arka planını
ezmesin; dolgu metne kırpılır, renkler temadan gelir. */
input:-webkit-autofill,
input:-webkit-autofill:hover,
input:-webkit-autofill:focus {
-webkit-background-clip: text;
-webkit-text-fill-color: var(--foreground);
caret-color: var(--foreground);
}
html {
@apply font-sans;
scroll-behavior: smooth;
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
}
}

View File

@@ -1,5 +1,7 @@
import type { Metadata } from "next";
import { Mail } from "lucide-react";
import { Parallax } from "@/components/parallax";
import { ScrollFlow } from "@/components/scroll-flow";
export const metadata: Metadata = {
title: "İletişim — KolayTercih",
@@ -7,24 +9,28 @@ export const metadata: Metadata = {
export default function IletisimPage() {
return (
<main className="mx-auto w-full max-w-2xl px-4 py-16">
<h1 className="font-heading text-3xl font-bold">İletişim</h1>
<p className="mt-4 text-sm leading-7 text-slate-700">
Ödeme, iade, hesap silme ya da başka bir konuda yardıma mı ihtiyacın
var? Tercih döneminde mesajlara aynı gün dönmeye çalışıyoruz.
</p>
<a
href="mailto:destek@kolaytercih.com"
className="mt-6 inline-flex items-center gap-2 rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm font-semibold text-slate-900 shadow-xs transition-colors hover:border-slate-300 hover:bg-slate-50"
>
<Mail className="size-4 text-primary" aria-hidden />
destek@kolaytercih.com
</a>
<p className="mt-6 text-xs text-slate-500">
Ödemenle ilgili yazıyorsan e-postana sipariş tarihini ve kullandığın
hesap adresini eklersen daha hızlı yardımcı olabiliriz. Kart bilgini
asla e-postayla paylaşma.
</p>
</main>
<ScrollFlow>
<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">İletişim</h1>
</Parallax>
<p className="mt-4 text-sm leading-7 text-slate-700">
Ödeme, iade, hesap silme ya da başka bir konuda yardıma mı ihtiyacın
var? Tercih döneminde mesajlara aynı gün dönmeye çalışıyoruz.
</p>
<a
href="mailto:destek@kolaytercih.com"
className="mt-6 inline-flex items-center gap-2 rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm font-semibold text-slate-900 shadow-xs transition-colors hover:border-slate-300 hover:bg-slate-50"
>
<Mail className="size-4 text-primary" aria-hidden />
destek@kolaytercih.com
</a>
<p className="mt-6 text-xs text-slate-500">
Ödemenle ilgili yazıyorsan e-postana sipariş tarihini ve kullandığın
hesap adresini eklersen daha hızlı yardımcı olabiliriz. Kart bilgini
asla e-postayla paylaşma.
</p>
</main>
</ScrollFlow>
);
}

View File

@@ -1,5 +1,7 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Parallax } from "@/components/parallax";
import { ScrollFlow } from "@/components/scroll-flow";
export const metadata: Metadata = {
title: "Kullanım ve Satış Koşulları — KolayTercih",
@@ -8,67 +10,77 @@ export const metadata: Metadata = {
// NOT: MVP taslağıdır; yayına almadan önce hukuki gözden geçirme gerekir.
export default function KosullarPage() {
return (
<main className="mx-auto w-full max-w-2xl px-4 py-16">
<h1 className="font-heading text-3xl font-bold">
Kullanım ve Satış Koşulları
</h1>
<p className="mt-2 text-sm text-slate-500">Son güncelleme: 26 Temmuz 2026</p>
<ScrollFlow>
<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">
Kullanım ve Satış Koşulları
</h1>
<p className="mt-2 text-sm text-slate-500">
Son güncelleme: 26 Temmuz 2026
</p>
</Parallax>
<div className="mt-8 space-y-6 text-sm leading-7 text-slate-700">
<section>
<h2 className="font-heading text-lg font-bold text-slate-900">
Hizmetin kapsamı
</h2>
<p className="mt-2">
KolayTercih, resmî YÖK Atlas verilerine dayanan bir karar destek
aracıdır. Öneriler bilgilendirme amaçlıdır; KolayTercih yerleşme
garantisi vermez ve ÖSYM ya da YÖK ile bağlantılı değildir. Tercih
listenin son hali ve başvuru sorumluluğu sana aittir.
</p>
</section>
<section>
<h2 className="font-heading text-lg font-bold text-slate-900">
Ücretlendirme
</h2>
<p className="mt-2">
Temel program arama ücretsizdir. Tercih Dönemi Paketi tek seferlik
bir satın almadır; abonelik değildir, otomatik yenilenmez. Paket
içeriği ve güncel fiyat{" "}
<Link href="/paket" className="text-primary underline">
paket sayfasında
</Link>{" "}
yazar.
</p>
</section>
<section id="iade" className="scroll-mt-24">
<h2 className="font-heading text-lg font-bold text-slate-900">
İade ve cayma
</h2>
<p className="mt-2">
Paket, satın alır almaz ifasına başlanan dijital bir içeriktir.
Buna rağmen kişisel listen henüz üretilmemişse (paket hiç
kullanılmamışsa) satın almadan itibaren 14 gün içinde koşulsuz
iade talep edebilirsin. Teknik bir sorun nedeniyle hizmeti
alamadıysan, kullanımdan bağımsız olarak ücretin iade edilir.
İade talepleri için{" "}
<Link href="/iletisim" className="text-primary underline">
bize yaz
</Link>
; talepler 14 gün içinde sonuçlandırılır ve iade, ödeme yaptığın
karta yapılır.
</p>
</section>
<section>
<h2 className="font-heading text-lg font-bold text-slate-900">
Hesap ve adil kullanım
</h2>
<p className="mt-2">
Krediler hesabına tanımlıdır, kişiseldir ve üçüncü kişilere
devredilemez. Hizmeti kötüye kullanan (otomatik istek, içerik
kazıma vb.) hesaplar kapatılabilir.
</p>
</section>
</div>
</main>
<Parallax
fromStart
strength={4}
className="mt-8 space-y-6 text-sm leading-7 text-slate-700"
>
<section>
<h2 className="font-heading text-lg font-bold text-slate-900">
Hizmetin kapsamı
</h2>
<p className="mt-2">
KolayTercih, resmî YÖK Atlas verilerine dayanan bir karar destek
aracıdır. Öneriler bilgilendirme amaçlıdır; KolayTercih yerleşme
garantisi vermez ve ÖSYM ya da YÖK ile bağlantılı değildir. Tercih
listenin son hali ve başvuru sorumluluğu sana aittir.
</p>
</section>
<section>
<h2 className="font-heading text-lg font-bold text-slate-900">
Ücretlendirme
</h2>
<p className="mt-2">
Temel program arama ücretsizdir. Tercih Dönemi Paketi tek seferlik
bir satın almadır; abonelik değildir, otomatik yenilenmez. Paket
içeriği ve güncel fiyat{" "}
<Link href="/paket" className="text-primary underline">
paket sayfasında
</Link>{" "}
yazar.
</p>
</section>
<section id="iade" className="scroll-mt-24">
<h2 className="font-heading text-lg font-bold text-slate-900">
İade ve cayma
</h2>
<p className="mt-2">
Paket, satın alır almaz ifasına başlanan dijital bir içeriktir.
Buna rağmen kişisel listen henüz üretilmemişse (paket hiç
kullanılmamışsa) satın almadan itibaren 14 gün içinde koşulsuz
iade talep edebilirsin. Teknik bir sorun nedeniyle hizmeti
alamadıysan, kullanımdan bağımsız olarak ücretin iade edilir. İade
talepleri için{" "}
<Link href="/iletisim" className="text-primary underline">
bize yaz
</Link>
; talepler 14 gün içinde sonuçlandırılır ve iade, ödeme yaptığın
karta yapılır.
</p>
</section>
<section>
<h2 className="font-heading text-lg font-bold text-slate-900">
Hesap ve adil kullanım
</h2>
<p className="mt-2">
Krediler hesabına tanımlıdır, kişiseldir ve üçüncü kişilere
devredilemez. Hizmeti kötüye kullanan (otomatik istek, içerik
kazıma vb.) hesaplar kapatılabilir.
</p>
</section>
</Parallax>
</main>
</ScrollFlow>
);
}

View File

@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import { Outfit, Work_Sans, Geist_Mono } from "next/font/google";
import { Cal_Sans, Geist_Mono, Bricolage_Grotesque } from "next/font/google";
import localFont from "next/font/local";
import { Toaster } from "@/components/ui/sonner";
import { SiteHeader } from "@/components/site-header";
import { ListeCekmecesi } from "@/components/manuel-liste/liste-cekmecesi";
@@ -8,14 +9,21 @@ import { ProgressiveBlur } from "@/components/ui/skiper-ui/skiper41";
import { DevPanel } from "@/components/dev/dev-panel";
import "./globals.css";
const outfit = Outfit({
variable: "--font-outfit",
const calSans = Cal_Sans({
variable: "--font-cal-sans",
weight: "400",
subsets: ["latin", "latin-ext"],
});
const workSans = Work_Sans({
variable: "--font-work-sans",
subsets: ["latin", "latin-ext"],
const clarityCity = localFont({
variable: "--font-clarity-city",
src: [
{ path: "../fonts/ClarityCity-Regular.woff2", weight: "400" },
{ path: "../fonts/ClarityCity-Medium.woff2", weight: "500" },
{ path: "../fonts/ClarityCity-SemiBold.woff2", weight: "600" },
{ path: "../fonts/ClarityCity-Bold.woff2", weight: "700" },
{ path: "../fonts/ClarityCity-ExtraBold.woff2", weight: "800" },
],
});
const geistMono = Geist_Mono({
@@ -23,6 +31,12 @@ const geistMono = Geist_Mono({
subsets: ["latin"],
});
const bricolage = Bricolage_Grotesque({
variable: "--font-bricolage-grotesque",
subsets: ["latin", "latin-ext"],
axes: ["opsz"],
});
export const metadata: Metadata = {
title: "KolayTercih — Tercih Danışmanı",
description:
@@ -37,7 +51,7 @@ export default function RootLayout({
return (
<html
lang="tr"
className={`${outfit.variable} ${workSans.variable} ${geistMono.variable} h-full antialiased`}
className={`${calSans.variable} ${clarityCity.variable} ${geistMono.variable} ${bricolage.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col bg-slate-50">
{/* Site geneli progressive blur — üst ve alt */}

View File

@@ -6,7 +6,7 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Loader2, RefreshCcw } from "lucide-react";
import { RefreshCcw } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { listeRevize } from "@/app/sonuc/actions";
@@ -80,11 +80,7 @@ export function RevizyonKutusu({ kalanHak }: { kalanHak: number }) {
onClick={revizeEt}
className="cursor-pointer"
>
{pending ? (
<Loader2 className="size-4 animate-spin" aria-hidden />
) : (
<RefreshCcw className="size-4" aria-hidden />
)}
<RefreshCcw className="size-4" aria-hidden />
{pending ? "Yeniden kuruluyor…" : "Revize et (3 kredi)"}
</Button>
<span className="text-xs text-slate-500">

View File

@@ -21,6 +21,7 @@ import {
import { PromptSuggestion } from "@/components/ui/prompt-suggestion";
import { ScrollButton } from "@/components/ui/scroll-button";
import { DotsLoader } from "@/components/ui/loader";
import { Skeleton } from "@/components/ui/skeleton";
interface Mesaj {
id: string;
@@ -174,7 +175,15 @@ export function SohbetClient({
))}
</div>
) : (
<DotsLoader />
<div className="flex max-w-lg flex-wrap justify-center gap-2">
{[112, 148, 96, 132].map((w, i) => (
<Skeleton
key={i}
className="h-7 rounded-full"
style={{ width: w }}
/>
))}
</div>
)}
</div>
) : (
@@ -195,8 +204,10 @@ export function SohbetClient({
{m.content}
</MessageContent>
) : (
<div className="rounded-lg bg-slate-100 px-4 py-3">
<DotsLoader size="sm" />
<div className="w-full max-w-[85%] space-y-2 rounded-lg bg-slate-100 px-4 py-3">
<Skeleton className="h-3.5 w-full bg-slate-200" />
<Skeleton className="h-3.5 w-4/5 bg-slate-200" />
<Skeleton className="h-3.5 w-3/5 bg-slate-200" />
</div>
)}
</Message>

View File

@@ -1,13 +1,30 @@
import { Loader2 } from "lucide-react";
import { Skeleton } from "@/components/ui/skeleton";
// Kendi loading dosyası olmayan tüm rotalar için genel sayfa iskeleti:
// spinner yerine başlık, metin ve kart bloklarının yer tutucuları
// içerik gelince zıplama olmaz.
export default function Loading() {
return (
<main
aria-live="polite"
className="flex min-h-[60vh] flex-col items-center justify-center gap-3"
>
<Loader2 className="size-8 animate-spin text-primary" aria-hidden />
<p className="text-sm text-slate-500">Yükleniyor</p>
<main aria-live="polite" className="mx-auto w-full max-w-6xl px-4 py-12">
{/* Rozet + başlık + alt metin */}
<Skeleton className="h-6 w-44 rounded-full" />
<Skeleton className="mt-5 h-10 w-3/4 max-w-2xl" />
<Skeleton className="mt-3 h-10 w-1/2 max-w-md" />
<Skeleton className="mt-5 h-5 w-2/3 max-w-lg" />
<Skeleton className="mt-2 h-5 w-1/2 max-w-sm" />
{/* Form / aksiyon şeridi */}
<div className="mt-8 flex max-w-xl flex-col gap-3 sm:flex-row">
<Skeleton className="h-12 flex-1 rounded-lg" />
<Skeleton className="h-12 w-full rounded-lg sm:w-44" />
</div>
{/* Kart bloklar */}
<div className="mt-14 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-40 w-full rounded-2xl" />
))}
</div>
</main>
);
}

View File

@@ -12,6 +12,16 @@ import {
import Image from "next/image";
import Link from "next/link";
import { HeroForm } from "@/components/hero-form";
import { Parallax } from "@/components/parallax";
import { ScrollFlow } from "@/components/scroll-flow";
import { Reveal } from "@/components/landing/reveal";
import { TurkeyPixelMap } from "@/components/turkey-pixel-map";
import {
PixelCorner,
PixelDivider,
PixelField,
SectionEyebrow,
} from "@/components/pixel-decor";
import { URUNLER } from "@/lib/credits";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -156,261 +166,412 @@ function ComparisonCell({ value }: { value: boolean | string }) {
export default function Home() {
return (
<div className="flex min-h-screen flex-col bg-slate-50 text-slate-900">
<main className="flex-1">
{/* Hero */}
<section className="mx-auto flex max-w-6xl flex-col items-center px-4 pb-20 pt-16 text-center sm:pt-24">
<Badge
variant="secondary"
className="mb-6 bg-primary/10 text-primary"
>
2026 YKS tercih dönemi ık
</Badge>
<h1 className="max-w-3xl font-heading text-4xl font-bold tracking-tight sm:text-6xl">
Dört yıllık geleceğini{" "}
<span className="text-primary">48 saatlik panikle</span> seçme
</h1>
<p className="mt-6 max-w-2xl text-lg leading-relaxed text-slate-600">
<div className="bg-slate-50 text-slate-900">
<ScrollFlow className="flex min-h-screen flex-col">
<main className="flex-1">
{/* Hero */}
<section className="relative isolate overflow-hidden">
<div
aria-hidden
// SVG altta 9 hücrelik serpinti payı taşıdığı için silüet merkezi
// yüksekliğin %37,1'inde; -1/2 yerine bu oranla hizalanır
className="pointer-events-none absolute left-1/2 top-[43%] -z-10 hidden -translate-x-1/2 -translate-y-[37.14%] sm:block"
>
{/* Arka katman: harita scroll'un gerisinde kalır, derinlik verir */}
<Parallax fromStart strength={-52}>
{/* Harita hiçbir boyutta taşmaz: her zaman ekran genişliği
eksi kenar payı kadar; sayfa küçüldükçe onunla birlikte
küçülür. Çok geniş ekranda 1665px tavanına oturur;
mobilde tümüyle gizli. */}
<TurkeyPixelMap className="w-[min(1665px,100vw_-_2.5rem)] max-w-none text-primary" />
</Parallax>
</div>
<div
aria-hidden
className="pointer-events-none absolute inset-0 -z-10 bg-[radial-gradient(ellipse_58%_52%_at_50%_42%,rgba(248,250,252,0.9)_25%,rgba(248,250,252,0)_75%)]"
/>
<Parallax
fromStart
strength={14}
className="mx-auto flex min-h-[calc(100svh-5.5rem)] max-w-6xl flex-col items-center justify-center px-4 pb-44 pt-4 text-center"
>
<Badge
variant="secondary"
className="mb-6 bg-primary/10 text-primary"
>
2026 YKS tercih dönemi ık
</Badge>
<h1 className="max-w-3xl font-heading text-4xl font-bold tracking-[0.015em] sm:text-6xl">
Dört yıllık geleceğini <br className="hidden sm:block" />
<span className="font-bricolage italic tracking-tighter font-bold text-primary">
48 saatlik panikle
</span>{" "}
seçme
</h1>
{/* <p className="mt-6 max-w-2xl text-lg leading-relaxed text-slate-600">
KolayTercih, gerçek YÖK Atlas verisiyle çalışan yapay zekâ tercih
danışmanın. İnsan danışmanın binlerce lira aldığı işi — dengeli
liste, risk analizi, soru-cevap — onda bir fiyatına yapar.
</p>
<div
id="hero-form"
className="mt-10 flex w-full scroll-mt-28 justify-center"
>
<HeroForm />
</div>
<p className="mt-4 text-sm text-slate-500">
Temel arama her zaman ücretsiz · Kredi kartı gerekmez · Tam analiz
paketi tek seferlik {PAKET_FIYATI} TL
</p>
<div className="mt-3 inline-flex items-center gap-2.5 rounded-lg border border-slate-200 bg-white px-3 py-2 text-left text-xs text-slate-700 shadow-xs">
<Image
src="/yok-logo.svg"
alt="Yükseköğretim Kurulu"
width={68}
height={33}
className="h-7 w-auto shrink-0"
/>
<span className="h-5 w-px shrink-0 bg-slate-200" aria-hidden />
<span>
<strong className="font-semibold text-slate-900">
YÖK Atlas&apos;ın gerçek verileriyle
</strong>{" "}
güvenilir sonuçlar
</span>
</div>
</section>
{/* Problem */}
<section className="border-y border-slate-200 bg-white py-20">
<div className="mx-auto max-w-6xl px-4">
<h2 className="text-center font-heading text-3xl font-bold sm:text-4xl">
Tercih dönemi neden bu kadar stresli?
</h2>
<div className="mt-12 grid gap-6 md:grid-cols-3">
{problems.map((p) => (
<Card key={p.title} className="border-slate-200">
<CardHeader>
<div className="mb-2 flex size-11 items-center justify-center rounded-xl bg-primary/10">
<p.icon className="size-5 text-primary" aria-hidden />
</div>
<CardTitle className="font-heading text-xl">
{p.title}
</CardTitle>
<CardDescription className="text-base leading-relaxed">
{p.description}
</CardDescription>
</CardHeader>
</Card>
))}
</div>
</div>
</section>
{/* How it works */}
<section id="nasil-calisir" className="scroll-mt-24 py-20">
<div className="mx-auto max-w-6xl px-4">
<h2 className="text-center font-heading text-3xl font-bold sm:text-4xl">
Üç adımda listen hazır
</h2>
<div className="mt-12 grid gap-6 md:grid-cols-3">
{steps.map((s) => (
<div
key={s.step}
className="relative rounded-2xl border border-slate-200 bg-white p-8"
>
<span className="absolute -top-4 left-8 flex size-8 items-center justify-center rounded-full bg-orange-500 font-heading text-sm font-bold text-white">
{s.step}
</span>
<s.icon className="size-6 text-primary" aria-hidden />
<h3 className="mt-4 font-heading text-xl font-semibold">
{s.title}
</h3>
<p className="mt-2 leading-relaxed text-slate-600">
{s.description}
</p>
</div>
))}
</div>
{/* Veriye dayalı güven + garanti vaadine karşı dürüst konumlandırma */}
<div className="mx-auto mt-12 w-full max-w-3xl overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm">
<dl className="grid grid-cols-1 divide-y divide-slate-200 sm:grid-cols-3 sm:divide-x sm:divide-y-0">
{[
{ value: "23.000+", label: "Taranan lisans programı" },
{ value: "4 yıl", label: "Taban sıralaması trend verisi" },
{ value: "%100", label: "Resmî YÖK Atlas kaynağı" },
].map((stat) => (
<div key={stat.label} className="p-6 text-center">
<dt className="text-sm text-slate-500">{stat.label}</dt>
<dd className="mt-1 font-heading text-3xl font-bold text-primary">
{stat.value}
</dd>
</div>
))}
</dl>
<div className="flex gap-4 border-t border-slate-200 bg-primary/5 p-6 text-left sm:items-center sm:p-7">
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-primary text-white shadow-sm">
<ShieldCheck className="size-5" aria-hidden />
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-primary">
Dürüstlük notu
</p>
<p className="mt-1 font-heading text-lg font-bold text-slate-900 sm:text-xl">
Yerleşme garantisi vermiyoruz çünkü kimse veremez.
</p>
<p className="mt-1.5 text-sm leading-6 text-slate-600">
Taban sıralamaları her yıl değişir. Biz sonuç vaadi satmak
yerine riski satır satır gösterir, daha güvenli bir liste
kurmana yardımcı oluruz.
</p>
</div>
</p> */}
<div
id="hero-form"
className="mt-6 flex w-full scroll-mt-28 justify-center"
>
<HeroForm />
</div>
<p className="mt-4 text-sm text-slate-500">
Temel arama her zaman ücretsiz · Kredi kartı gerekmez · Tam
analiz paketi tek seferlik {PAKET_FIYATI} TL
</p>
<div className="mt-3 inline-flex items-center gap-2.5 rounded-lg border border-slate-200 bg-white px-3 py-2 text-left text-xs text-slate-700 shadow-xs">
<Image
src="/yok-logo.svg"
alt="Yükseköğretim Kurulu"
width={68}
height={33}
className="h-7 w-auto shrink-0"
/>
<span className="h-5 w-px shrink-0 bg-slate-200" aria-hidden />
<span>
<strong className="font-semibold text-slate-900">
YÖK Atlas&apos;ın gerçek verileriyle
</strong>{" "}
güvenilir sonuçlar
</span>
</div>
</Parallax>
</section>
{/* Hero → Problem geçişi: border yerine piksel ayraç */}
<div aria-hidden className="mx-auto w-56">
<Parallax strength={26}>
<PixelDivider seed={11} />
</Parallax>
</div>
{/* Problem */}
<section className="py-20 sm:py-24">
<div className="mx-auto max-w-6xl px-4">
{/* Başlık gövdeden hafif hızlı akar — katman derinliği */}
<Parallax strength={16}>
<Reveal className="flex flex-col items-center gap-4 text-center">
<SectionEyebrow>Sorun</SectionEyebrow>
<h2 className="font-heading text-3xl font-bold sm:text-4xl">
Tercih dönemi neden bu kadar stresli?
</h2>
<p className="max-w-xl text-lg leading-relaxed text-slate-600">
Her yıl iki milyondan fazla aday aynı üç duvara çarpıyor.
</p>
</Reveal>
</Parallax>
<Parallax
strength={7}
className="mt-12 grid gap-6 md:grid-cols-3"
>
{problems.map((p, i) => (
<Reveal key={p.title} delay={i * 0.08}>
<Card className="relative h-full overflow-hidden border-slate-200/70 shadow-sm transition-all duration-300 hover:-translate-y-1 hover:border-primary/30 hover:shadow-lg hover:shadow-primary/5">
<PixelCorner className="absolute right-4 top-4" />
<CardHeader>
<div className="mb-2 flex size-11 items-center justify-center rounded-xl bg-primary/10">
<p.icon className="size-5 text-primary" aria-hidden />
</div>
<CardTitle className="font-heading text-xl">
{p.title}
</CardTitle>
<CardDescription className="text-base leading-relaxed">
{p.description}
</CardDescription>
</CardHeader>
</Card>
</Reveal>
))}
</Parallax>
</div>
</div>
</section>
</section>
{/* Comparison */}
<section
id="karsilastirma"
className="scroll-mt-24 border-y border-slate-200 bg-white py-20"
>
<div className="mx-auto max-w-4xl px-4">
<h2 className="text-center font-heading text-3xl font-bold sm:text-4xl">
Robot mu, danışman mı, KolayTercih mi?
</h2>
<div className="mt-12 overflow-x-auto rounded-2xl border border-slate-200 bg-white">
<Table>
<TableHeader>
<TableRow className="bg-slate-50">
<TableHead className="w-[40%]">Özellik</TableHead>
<TableHead className="text-center">
Ücretsiz robotlar
</TableHead>
<TableHead className="text-center">
İnsan danışman
</TableHead>
<TableHead className="text-center font-heading font-bold text-primary">
KolayTercih
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{comparison.map((row) => (
<TableRow key={row.feature}>
<TableCell className="font-medium">
{row.feature}
</TableCell>
<TableCell className="text-center">
<ComparisonCell value={row.robot} />
</TableCell>
<TableCell className="text-center">
<ComparisonCell value={row.danisman} />
</TableCell>
<TableCell className="bg-primary/5 text-center">
<ComparisonCell value={row.tercihai} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{/* Problem → Nasıl çalışır geçişi */}
<div aria-hidden className="mx-auto w-56">
<Parallax strength={26}>
<PixelDivider seed={17} />
</Parallax>
</div>
{/* How it works */}
<section id="nasil-calisir" className="scroll-mt-24 py-20 sm:py-24">
<div className="mx-auto max-w-6xl px-4">
<Parallax strength={16}>
<Reveal className="flex flex-col items-center gap-4 text-center">
<SectionEyebrow>Nasıl çalışır?</SectionEyebrow>
<h2 className="font-heading text-3xl font-bold sm:text-4xl">
Üç adımda listen hazır
</h2>
<p className="max-w-xl text-lg leading-relaxed text-slate-600">
Sıralamandan dengeli 24&apos;lük listeye giden yol üç adım
sürer.
</p>
</Reveal>
</Parallax>
<Parallax
strength={7}
className="mt-12 grid gap-6 md:grid-cols-3"
>
{steps.map((s, i) => (
<Reveal key={s.step} delay={i * 0.08} className="h-full">
<div className="relative h-full rounded-2xl border border-slate-200/70 bg-white p-8 shadow-sm transition-all duration-300 hover:-translate-y-1 hover:border-primary/30 hover:shadow-lg hover:shadow-primary/5">
<span className="absolute -top-4 left-8 flex size-8 items-center justify-center rounded-full bg-orange-500 font-heading text-sm font-bold text-white">
{s.step}
</span>
<PixelCorner className="absolute right-4 top-4" />
<s.icon className="size-6 text-primary" aria-hidden />
<h3 className="mt-4 font-heading text-xl font-semibold">
{s.title}
</h3>
<p className="mt-2 leading-relaxed text-slate-600">
{s.description}
</p>
</div>
</Reveal>
))}
</Parallax>
{/* Veriye dayalı güven + garanti vaadine karşı dürüst konumlandırma */}
<Parallax
strength={10}
className="mx-auto mt-12 w-full max-w-3xl"
>
<Reveal
delay={0.1}
className="overflow-hidden rounded-2xl border border-slate-200/70 bg-white shadow-sm"
>
<dl className="grid grid-cols-1 divide-y divide-slate-200 sm:grid-cols-3 sm:divide-x sm:divide-y-0">
{[
{ value: "23.000+", label: "Taranan lisans programı" },
{
value: "4 yıl",
label: "Taban sıralaması trend verisi",
},
{ value: "%100", label: "Resmî YÖK Atlas kaynağı" },
].map((stat) => (
<div key={stat.label} className="p-6 text-center">
<dt className="text-sm text-slate-500">{stat.label}</dt>
<dd className="mt-1 font-heading text-3xl font-bold text-primary">
{stat.value}
</dd>
</div>
))}
</dl>
<div className="flex gap-4 border-t border-slate-200 bg-primary/5 p-6 text-left sm:items-center sm:p-7">
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-primary text-white shadow-sm">
<ShieldCheck className="size-5" aria-hidden />
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-primary">
Dürüstlük notu
</p>
<p className="mt-1 font-heading text-lg font-bold text-slate-900 sm:text-xl">
Yerleşme garantisi vermiyoruz çünkü kimse veremez.
</p>
<p className="mt-1.5 text-sm leading-6 text-slate-600">
Taban sıralamaları her yıl değişir. Biz sonuç vaadi
satmak yerine riski satır satır gösterir, daha güvenli
bir liste kurmana yardımcı oluruz.
</p>
</div>
</div>
</Reveal>
</Parallax>
</div>
</div>
</section>
</section>
{/* FAQ */}
<section id="sss" className="scroll-mt-24 py-20">
<div className="mx-auto max-w-3xl px-4">
<h2 className="text-center font-heading text-3xl font-bold sm:text-4xl">
Sık sorulan sorular
</h2>
<Accordion type="single" collapsible className="mt-10">
{faqs.map((faq) => (
<AccordionItem key={faq.question} value={faq.question}>
<AccordionTrigger className="cursor-pointer text-left font-heading text-base font-semibold">
{faq.question}
</AccordionTrigger>
<AccordionContent className="text-base leading-relaxed text-slate-600">
{faq.answer}
</AccordionContent>
</AccordionItem>
))}
</Accordion>
{/* Nasıl çalışır → Karşılaştırma geçişi */}
<div aria-hidden className="mx-auto w-56">
<Parallax strength={26}>
<PixelDivider seed={23} />
</Parallax>
</div>
</section>
{/* Final CTA */}
<section className="bg-primary py-20 text-white">
<div className="mx-auto flex max-w-3xl flex-col items-center px-4 text-center">
<MessageCircleQuestion className="size-10 opacity-80" aria-hidden />
<h2 className="mt-6 font-heading text-3xl font-bold sm:text-4xl">
Tercih listen, pişmanlık listesi olmasın
</h2>
<p className="mt-4 max-w-xl text-lg leading-relaxed text-white/90">
Sıralamanı gir, yapay zekâ danışmanın veriyle konuşsun. Tercih
dönemi bitmeden yerini al.
{/* Comparison */}
<section id="karsilastirma" className="scroll-mt-24 py-20 sm:py-24">
<div className="mx-auto max-w-4xl px-4">
<Parallax strength={16}>
<Reveal className="flex flex-col items-center gap-4 text-center">
<SectionEyebrow>Karşılaştır</SectionEyebrow>
<h2 className="font-heading text-3xl font-bold sm:text-4xl">
Robot mu, danışman mı, KolayTercih mi?
</h2>
<p className="max-w-xl text-lg leading-relaxed text-slate-600">
Aynı işi kim, neyle, kaça yapıyor yan yana bak.
</p>
</Reveal>
</Parallax>
<Parallax strength={7} className="mt-12">
<Reveal
delay={0.08}
className="overflow-x-auto rounded-2xl border border-slate-200/70 bg-white shadow-sm"
>
<Table>
<TableHeader>
<TableRow className="bg-slate-50">
<TableHead className="w-[40%]">Özellik</TableHead>
<TableHead className="text-center">
Ücretsiz robotlar
</TableHead>
<TableHead className="text-center">
İnsan danışman
</TableHead>
<TableHead className="text-center font-heading font-bold text-primary">
KolayTercih
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{comparison.map((row) => (
<TableRow key={row.feature}>
<TableCell className="font-medium">
{row.feature}
</TableCell>
<TableCell className="text-center">
<ComparisonCell value={row.robot} />
</TableCell>
<TableCell className="text-center">
<ComparisonCell value={row.danisman} />
</TableCell>
<TableCell className="bg-primary/5 text-center">
<ComparisonCell value={row.tercihai} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Reveal>
</Parallax>
</div>
</section>
{/* Karşılaştırma → SSS geçişi */}
<div aria-hidden className="mx-auto w-56">
<Parallax strength={26}>
<PixelDivider seed={31} />
</Parallax>
</div>
{/* FAQ */}
<section id="sss" className="scroll-mt-24 py-20 sm:py-24">
<div className="mx-auto max-w-3xl px-4">
<Parallax strength={16}>
<Reveal className="flex flex-col items-center gap-4 text-center">
<SectionEyebrow>SSS</SectionEyebrow>
<h2 className="font-heading text-3xl font-bold sm:text-4xl">
Sık sorulan sorular
</h2>
</Reveal>
</Parallax>
<Parallax strength={7}>
<Reveal delay={0.08}>
<Accordion
type="single"
collapsible
className="mt-10 rounded-2xl border border-slate-200/70 bg-white px-6 shadow-sm"
>
{faqs.map((faq) => (
<AccordionItem
key={faq.question}
value={faq.question}
className="last:border-b-0"
>
<AccordionTrigger className="cursor-pointer text-left font-heading text-base font-semibold">
{faq.question}
</AccordionTrigger>
<AccordionContent className="text-base leading-relaxed text-slate-600">
{faq.answer}
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</Reveal>
</Parallax>
</div>
</section>
{/* Final CTA — tam genişlik bant yerine yüzen, köşeleri yumuşak panel */}
<section className="px-4 pb-24 pt-6 sm:px-6">
<Parallax strength={12} className="mx-auto max-w-6xl">
<Reveal>
<div className="relative isolate overflow-hidden rounded-3xl bg-primary px-4 py-20 text-white shadow-xl shadow-primary/20 sm:py-24">
{/* Hero'nun piksel dokusu koyu zeminde: ortası boş, kenarlara doğru yoğun */}
{/* Piksel zemin panel içinde geride kalır; taşma payı için dikeyde büyütülür */}
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 -inset-y-10 -z-10 text-white"
>
<Parallax strength={-22} className="h-full">
<PixelField seed={8} />
</Parallax>
</div>
<div className="mx-auto flex max-w-3xl flex-col items-center text-center">
<MessageCircleQuestion
className="size-10 opacity-80"
aria-hidden
/>
<h2 className="mt-6 font-heading text-3xl font-bold sm:text-4xl">
Tercih listen, pişmanlık listesi olmasın
</h2>
<p className="mt-4 max-w-xl text-lg leading-relaxed text-white/90">
Sıralamanı gir, yapay zekâ danışmanın veriyle konuşsun.
Tercih dönemi bitmeden yerini al.
</p>
<Button
asChild
size="lg"
className="mt-8 h-12 cursor-pointer bg-orange-500 px-8 text-white transition-colors duration-200 hover:bg-orange-600"
>
<a href="#hero-form">Ücretsiz dene</a>
</Button>
</div>
</div>
</Reveal>
</Parallax>
</section>
</main>
<footer className="pb-32 pt-4">
<div className="mx-auto max-w-6xl px-4 text-center text-sm leading-relaxed text-slate-500">
<p>
© 2026 KolayTercih. Veriler resmî YÖK Atlas kaynağından derlenir;
KolayTercih, ÖSYM veya YÖK ile bağlantılı değildir.
</p>
<Button
asChild
size="lg"
className="mt-8 h-12 cursor-pointer bg-orange-500 px-8 text-white transition-colors duration-200 hover:bg-orange-600"
<p className="mt-2">
KolayTercih bir karar destek aracıdır, yerleşme garantisi vermez.
Tercih listenizin son hali ve başvuru sorumluluğu size aittir.
</p>
<nav
aria-label="Yasal sayfalar"
className="mt-4 flex items-center justify-center gap-4"
>
<a href="#hero-form">Ücretsiz dene</a>
</Button>
<Link
href="/gizlilik"
className="hover:text-slate-700 hover:underline"
>
Gizlilik & KVKK
</Link>
<Link
href="/kosullar"
className="hover:text-slate-700 hover:underline"
>
Kullanım & iade koşulları
</Link>
<Link
href="/iletisim"
className="hover:text-slate-700 hover:underline"
>
İletişim
</Link>
</nav>
</div>
</section>
</main>
<footer className="border-t border-slate-200 bg-white pb-32 pt-10">
<div className="mx-auto max-w-6xl px-4 text-center text-sm leading-relaxed text-slate-500">
<p>
© 2026 KolayTercih. Veriler resmî YÖK Atlas kaynağından derlenir;
KolayTercih, ÖSYM veya YÖK ile bağlantılı değildir.
</p>
<p className="mt-2">
KolayTercih bir karar destek aracıdır, yerleşme garantisi vermez.
Tercih listenizin son hali ve başvuru sorumluluğu size aittir.
</p>
<nav
aria-label="Yasal sayfalar"
className="mt-4 flex items-center justify-center gap-4"
>
<Link href="/gizlilik" className="hover:text-slate-700 hover:underline">
Gizlilik & KVKK
</Link>
<Link href="/kosullar" className="hover:text-slate-700 hover:underline">
Kullanım & iade koşulları
</Link>
<Link href="/iletisim" className="hover:text-slate-700 hover:underline">
İletişim
</Link>
</nav>
</div>
</footer>
</footer>
</ScrollFlow>
</div>
);
}

View File

@@ -13,6 +13,8 @@ import { getCurrentUser } from "@/lib/session";
import { URUNLER, DENEME_KREDISI } from "@/lib/credits";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Parallax } from "@/components/parallax";
import { ScrollFlow } from "@/components/scroll-flow";
import { SatinAlForm } from "./satin-al-form";
export const metadata: Metadata = {
@@ -51,134 +53,143 @@ export default async function PaketPage({
const odemeHatasi = hata === "token" || hata === "siparis";
return (
<div className="flex min-h-screen flex-col bg-slate-50 text-slate-900">
<main className="mx-auto w-full max-w-3xl flex-1 px-4 py-16">
{odemeHatasi ? (
<div
role="alert"
className="mb-8 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800"
<div className="bg-slate-50 text-slate-900">
<ScrollFlow className="flex min-h-screen flex-col">
<main className="mx-auto w-full max-w-3xl flex-1 px-4 py-16">
{odemeHatasi ? (
<div
role="alert"
className="mb-8 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800"
>
Ödemen doğrulanamadı. Kartından çekim yapıldıysa kredilerin birkaç
dakika içinde otomatik tanımlanır; tanımlanmazsa aşağıdan tekrar
deneyebilirsin çekim yapılmadıysa hiçbir ücret ödemedin.
</div>
) : null}
<Parallax fromStart strength={12} className="text-center">
<Badge variant="secondary" className="bg-primary/10 text-primary">
Tek seferlik ödeme · Abonelik yok
</Badge>
<h1 className="mt-4 font-heading text-3xl font-bold sm:text-4xl">
İnsan danışmanın işini,{" "}
<span className="text-primary">onda bir fiyatına</span>
</h1>
<p className="mx-auto mt-3 max-w-xl text-slate-600">
Tercih danışmanları bu dönemde 2.00010.000 TL alıyor. KolayTercih
aynı işi gerçek YÖK Atlas verisiyle, dakikalar içinde yapar.
</p>
</Parallax>
<Parallax
strength={6}
className="mt-10 grid gap-6 sm:grid-cols-[1fr_auto]"
>
Ödemen doğrulanamadı. Kartından çekim yapıldıysa kredilerin birkaç
dakika içinde otomatik tanımlanır; tanımlanmazsa aşağıdan tekrar
deneyebilirsin çekim yapılmadıysa hiçbir ücret ödemedin.
</div>
) : null}
<div className="text-center">
<Badge variant="secondary" className="bg-primary/10 text-primary">
Tek seferlik ödeme · Abonelik yok
</Badge>
<h1 className="mt-4 font-heading text-3xl font-bold sm:text-4xl">
İnsan danışmanın işini,{" "}
<span className="text-primary">onda bir fiyatına</span>
</h1>
<p className="mx-auto mt-3 max-w-xl text-slate-600">
Tercih danışmanları bu dönemde 2.00010.000 TL alıyor. KolayTercih
aynı işi gerçek YÖK Atlas verisiyle, dakikalar içinde yapar.
</p>
</div>
<div className="mt-10 grid gap-6 sm:grid-cols-[1fr_auto]">
{/* Ana paket */}
<section className="rounded-2xl border-2 border-orange-500 bg-white p-8 shadow-sm">
<div className="flex items-baseline justify-between">
<h2 className="font-heading text-xl font-bold">
Tercih Dönemi Paketi
</h2>
<p className="font-heading text-3xl font-bold">
{tl(URUNLER.paket.amountKurus)} TL
</p>
</div>
<ul className="mt-6 space-y-3">
{OZELLIKLER.map((o) => (
<li key={o.text} className="flex items-start gap-3 text-sm">
<o.icon
className="mt-0.5 size-4 shrink-0 text-emerald-600"
aria-hidden
/>
{o.text}
</li>
))}
</ul>
<div className="mt-8">
{u ? (
u.hasPaket ? (
<div className="flex items-center gap-2 rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-medium text-emerald-800">
<CheckCircle2 className="size-4" aria-hidden />
Paketin aktif {" "}
<Link href="/" className="underline">
listeni oluştur
</Link>
</div>
{/* Ana paket */}
<section className="rounded-2xl border-2 border-orange-500 bg-white p-8 shadow-sm">
<div className="flex items-baseline justify-between">
<h2 className="font-heading text-xl font-bold">
Tercih Dönemi Paketi
</h2>
<p className="font-heading text-3xl font-bold">
{tl(URUNLER.paket.amountKurus)} TL
</p>
</div>
<ul className="mt-6 space-y-3">
{OZELLIKLER.map((o) => (
<li key={o.text} className="flex items-start gap-3 text-sm">
<o.icon
className="mt-0.5 size-4 shrink-0 text-emerald-600"
aria-hidden
/>
{o.text}
</li>
))}
</ul>
<div className="mt-8">
{u ? (
u.hasPaket ? (
<div className="flex items-center gap-2 rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-medium text-emerald-800">
<CheckCircle2 className="size-4" aria-hidden />
Paketin aktif {" "}
<Link href="/" className="underline">
listeni oluştur
</Link>
</div>
) : (
<SatinAlForm
urun="paket"
label={`${tl(URUNLER.paket.amountKurus)} TL — Paketi al`}
vurgulu
/>
)
) : (
<Button
asChild
className="h-12 w-full cursor-pointer bg-orange-500 px-8 text-white transition-colors duration-200 hover:bg-orange-600"
>
<Link
href={`/giris?callback=${encodeURIComponent("/paket")}`}
>
Giriş yap ve {DENEME_KREDISI} deneme kredisi kazan
</Link>
</Button>
)}
</div>
<p className="mt-4 text-center text-xs text-slate-500">
iyzico güvenli ödeme · Kart bilgilerin bize hiç ulaşmaz ·{" "}
<Link
href="/kosullar#iade"
className="underline hover:text-slate-700"
>
İade koşulları
</Link>
</p>
</section>
{/* Top-up */}
<section className="flex h-fit flex-col rounded-2xl border border-slate-200 bg-white p-6 shadow-sm sm:w-56">
<div className="flex items-center gap-2">
<Coins className="size-4 text-amber-500" aria-hidden />
<h2 className="font-heading font-bold">Kredi bitti mi?</h2>
</div>
<p className="mt-2 flex-1 text-sm text-slate-600">
+{URUNLER.topup.credits} soru hakkı,{" "}
{tl(URUNLER.topup.amountKurus)} TL.
</p>
<div className="mt-4">
{u ? (
<SatinAlForm
urun="paket"
label={`${tl(URUNLER.paket.amountKurus)} TL — Paketi al`}
vurgulu
urun="topup"
label={`+${URUNLER.topup.credits} kredi al`}
/>
)
) : (
<Button
asChild
className="h-12 w-full cursor-pointer bg-orange-500 px-8 text-white transition-colors duration-200 hover:bg-orange-600"
>
<Link href={`/giris?callback=${encodeURIComponent("/paket")}`}>
Giriş yap ve {DENEME_KREDISI} deneme kredisi kazan
</Link>
</Button>
)}
</div>
<p className="mt-4 text-center text-xs text-slate-500">
iyzico güvenli ödeme · Kart bilgilerin bize hiç ulaşmaz ·{" "}
<Link
href="/kosullar#iade"
className="underline hover:text-slate-700"
>
İade koşulları
</Link>
</p>
</section>
) : (
<Button
asChild
variant="outline"
className="h-11 w-full cursor-pointer"
>
<Link
href={`/giris?callback=${encodeURIComponent("/paket")}`}
>
Önce giriş yap
</Link>
</Button>
)}
</div>
</section>
</Parallax>
{/* Top-up */}
<section className="flex h-fit flex-col rounded-2xl border border-slate-200 bg-white p-6 shadow-sm sm:w-56">
<div className="flex items-center gap-2">
<Coins className="size-4 text-amber-500" aria-hidden />
<h2 className="font-heading font-bold">Kredi bitti mi?</h2>
</div>
<p className="mt-2 flex-1 text-sm text-slate-600">
+{URUNLER.topup.credits} soru hakkı,{" "}
{tl(URUNLER.topup.amountKurus)} TL.
</p>
<div className="mt-4">
{u ? (
<SatinAlForm
urun="topup"
label={`+${URUNLER.topup.credits} kredi al`}
/>
) : (
<Button
asChild
variant="outline"
className="h-11 w-full cursor-pointer"
>
<Link href={`/giris?callback=${encodeURIComponent("/paket")}`}>
Önce giriş yap
</Link>
</Button>
)}
</div>
</section>
</div>
<p className="mt-10 text-center text-xs leading-relaxed text-slate-500">
KolayTercih bir karar destek aracıdır; öneriler resmî YÖK Atlas verisine
dayanır ancak yerleşme garantisi verilmez. Tercih listesinin nihai
sorumluluğu adaya aittir. Sorun mu var?{" "}
<Link href="/iletisim" className="underline hover:text-slate-700">
Bize yaz
</Link>
.
</p>
</main>
<p className="mt-10 text-center text-xs leading-relaxed text-slate-500">
KolayTercih bir karar destek aracıdır; öneriler resmî YÖK Atlas
verisine dayanır ancak yerleşme garantisi verilmez. Tercih
listesinin nihai sorumluluğu adaya aittir. Sorun mu var?{" "}
<Link href="/iletisim" className="underline hover:text-slate-700">
Bize yaz
</Link>
.
</p>
</main>
</ScrollFlow>
</div>
);
}

View File

@@ -1,7 +1,6 @@
"use client";
import { useActionState, useEffect } from "react";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { baslatOdeme, type OdemeBaslatDurum } from "./actions";
@@ -37,9 +36,6 @@ export function SatinAlForm({
}
variant={vurgulu ? "default" : "outline"}
>
{pending ? (
<Loader2 className="size-4 animate-spin" aria-hidden />
) : null}
{pending ? "Ödeme sayfasına yönlendiriliyorsun…" : label}
</Button>
</form>

View File

@@ -2,8 +2,9 @@
import { useMemo, useEffect, useRef, useState } from "react";
import Link from "next/link";
import { FileText, Loader2, Lock, RefreshCcw } from "lucide-react";
import { FileText, Lock, RefreshCcw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { SohbetPopup } from "./sohbet-popup";
import { RaporListesi } from "@/components/rapor-listesi";
import { TercihHaritasi, type HaritaPin } from "@/components/tercih-haritasi";
@@ -305,16 +306,28 @@ function UretimGovdesi() {
ref={govdeRef}
tabIndex={-1}
aria-live="polite"
className="mt-16 flex flex-col items-center justify-center gap-4 rounded-2xl border border-slate-200 bg-white py-16 text-center outline-none"
className="mt-16 rounded-2xl border border-slate-200 bg-white p-6 outline-none sm:p-8"
>
<Loader2 className="size-10 animate-spin text-primary" aria-hidden />
<p className="font-heading text-lg font-bold">Listen hazırlanıyor</p>
<p className="max-w-sm text-sm text-slate-500">
{URETIM_MESAJLARI[mesajIdx]}
</p>
<p className="text-xs text-slate-400">
Bu işlem yarım dakika kadar sürebilir, sayfayı kapatma.
</p>
<div className="flex flex-col items-center gap-1.5 text-center">
<p className="font-heading text-lg font-bold">Listen hazırlanıyor</p>
<p className="max-w-sm text-sm text-slate-500">
{URETIM_MESAJLARI[mesajIdx]}
</p>
<p className="text-xs text-slate-400">
Bu işlem yarım dakika kadar sürebilir, sayfayı kapatma.
</p>
</div>
{/* Gelmekte olan tercih listesinin iskeleti */}
<div className="mt-8 flex flex-col gap-2">
<div className="flex items-center justify-between">
<Skeleton className="h-6 w-44" />
<Skeleton className="h-6 w-24 rounded-full" />
</div>
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full rounded-xl" />
))}
</div>
</section>
);
}
@@ -370,11 +383,7 @@ function RevizyonKutusu({
}}
className="cursor-pointer"
>
{pending ? (
<Loader2 className="size-4 animate-spin" aria-hidden />
) : (
<RefreshCcw className="size-4" aria-hidden />
)}
<RefreshCcw className="size-4" aria-hidden />
{pending ? "Yeniden kuruluyor…" : "Revize et (3 kredi)"}
</Button>
<span className="text-xs text-slate-500">

View File

@@ -12,7 +12,7 @@
import { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Loader2, Wrench, X } from "lucide-react";
import { Wrench, X } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { toast } from "sonner";
import { authClient } from "@/lib/auth-client";
@@ -184,8 +184,8 @@ export function DevPanel() {
</span>
Dev süperadmin
{mesgul ? (
<Loader2
className="size-3.5 animate-spin text-slate-400"
<span
className="size-2 animate-pulse rounded-full bg-slate-400"
aria-hidden
/>
) : null}

View File

@@ -2,7 +2,7 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { ArrowRight, Loader2 } from "lucide-react";
import { ArrowRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
@@ -123,12 +123,16 @@ export function HeroForm() {
<Input
id="siralama"
inputMode="numeric"
autoComplete="off"
placeholder="YKS başarı sıralaman (ör. 85.000)"
value={siralama}
onChange={(e) => siralamaDegisti(e.target.value)}
aria-invalid={hata ? true : undefined}
aria-describedby={hata ? "siralama-hata" : undefined}
className="h-12 flex-1 bg-white text-base"
// flex-1 yalnızca yatay dizilimde (sm+): mobilde form flex-col olduğu
// için flex-1 inputun yüksekliğini eziyordu. Mobilde geniş padding
// + büyük yazı; sm ve üzeri eski görünüm.
className="h-12 bg-background px-5 text-lg sm:flex-1 sm:px-2.5 sm:text-base"
/>
<Button
type="submit"
@@ -136,11 +140,8 @@ export function HeroForm() {
disabled={yukleniyor}
className="h-12 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
>
{yukleniyor ? (
<Loader2 className="size-4 animate-spin" aria-hidden />
) : null}
Listemi oluştur
{yukleniyor ? null : <ArrowRight className="size-4" aria-hidden />}
{yukleniyor ? "Hazırlanıyor…" : "Listemi oluştur"}
<ArrowRight className="size-4" aria-hidden />
</Button>
</form>

View File

@@ -0,0 +1,38 @@
"use client";
import type { ReactNode } from "react";
import { motion, useReducedMotion } from "motion/react";
/**
* Landing bölümlerinin görünüme girerken yumuşakça belirmesi.
* Yalnızca transform/opacity animasyonu yapar; hareket azaltılmışsa
* kaydırma yerine sadece kısa bir belirme kalır.
*/
export function Reveal({
children,
delay = 0,
y = 28,
className,
}: {
children: ReactNode;
delay?: number;
y?: number;
className?: string;
}) {
const reduceMotion = useReducedMotion();
return (
<motion.div
className={className}
initial={{ opacity: 0, y: reduceMotion ? 0 : y }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "0px 0px -80px 0px" }}
transition={{
duration: reduceMotion ? 0.25 : 0.55,
delay,
ease: [0.21, 0.47, 0.32, 0.98],
}}
>
{children}
</motion.div>
);
}

View File

@@ -0,0 +1,64 @@
"use client";
import { useRef, type ReactNode } from "react";
import {
motion,
useReducedMotion,
useScroll,
useTransform,
} from "motion/react";
/**
* Site geneli hafif scroll parallax'ı. Element görünümden geçerken
* scroll'a bağlı küçük bir dikey sürüklenme uygular (yalnızca transform,
* scroll ile 1:1 — gecikme/easing yok).
*
* strength > 0: içerik scroll'dan biraz hızlı akar (ön katman).
* strength < 0: içerik scroll'un gerisinde kalır (arka katman, derinlik).
*
* `fromStart`, sayfanın tepesinde yüklenen (hero gibi) elemanlar içindir:
* sayfa en üstteyken kayma tam 0'dır, aşağı indikçe birikir — böylece
* ilk boyamada içerik kaymış görünmez.
*
* Hareket azaltılmışsa (prefers-reduced-motion) hiç kayma uygulanmaz.
*/
export function Parallax({
children,
strength = 20,
fromStart = false,
className,
}: {
children: ReactNode;
/** Piksel cinsinden sürüklenme genliği; işaret katman yönünü belirler. */
strength?: number;
fromStart?: boolean;
className?: string;
}) {
const ref = useRef<HTMLDivElement>(null);
const reduceMotion = useReducedMotion();
const { scrollYProgress } = useScroll({
target: ref,
offset: fromStart
? ["start start", "end start"]
: ["start end", "end start"],
});
const y = useTransform(
scrollYProgress,
[0, 1],
fromStart ? [0, -2 * strength] : [strength, -strength],
);
return (
<motion.div
ref={ref}
className={className}
// willChange: kayma 0'dan geçerken katmanın compositor'dan düşüp
// tekrar oluşturulmasını (raster churn) engeller
style={
reduceMotion ? { y: 0 } : { y, willChange: "transform" }
}
>
{children}
</motion.div>
);
}

View File

@@ -0,0 +1,256 @@
"use client";
import { useRef, type ReactNode } from "react";
import { cn } from "@/lib/utils";
import { usePixelHeat } from "@/components/use-pixel-heat";
/**
* Hero'daki dot-matrix dilini (TurkeyPixelMap) bölüm ayraçlarına ve
* dekorlara taşıyan piksel süsleri. Rastgelelik yerine deterministik
* hash kullanılır ki sunucu ve istemci aynı deseni üretsin.
*
* Hepsi hero'daki imleç ısı fırçasına katılır (usePixelHeat): imleç
* yaklaşınca pikseller turuncuya ısınır, uzaklaşınca yavaşça soğur.
* Turuncu vurgu (accent) pikselleri hero'daki şehirler gibi fırçaya
* katılmaz.
*/
// Hero ile aynı ızgara ölçüleri
const CELL = 10;
const DOT = 7.4;
const INSET = (CELL - DOT) / 2;
// Hero ile aynı doku paleti
const OPACITIES = [0.3, 0.16, 0.24, 0.12, 0.27, 0.19];
const opacityFor = (x: number, y: number) =>
OPACITIES[(x * 7 + y * 11) % OPACITIES.length];
// Deterministik sözde-rastgele: aynı girdi her ortamda aynı deseni verir
const hash = (x: number, y: number, seed: number) => {
const n = Math.sin(x * 127.1 + y * 311.7 + seed * 74.7) * 43758.5453;
return n - Math.floor(n);
};
function pixelRect(
x: number,
y: number,
extra: { key: string; accent?: boolean; accentDelay?: number },
) {
const shared = {
x: x * CELL + INSET,
y: y * CELL + INSET,
width: DOT,
height: DOT,
rx: 2,
};
if (extra.accent) {
return (
<rect
key={extra.key}
{...shared}
className="animate-pulse fill-orange-500/70 motion-reduce:animate-none"
style={{
animationDuration: "3.6s",
animationDelay: `${extra.accentDelay ?? 0}s`,
}}
/>
);
}
const op = opacityFor(x, y);
return (
<rect
key={extra.key}
{...shared}
data-px=""
data-cx={x * CELL + CELL / 2}
data-cy={y * CELL + CELL / 2}
data-op={op}
fill="currentColor"
fillOpacity={op}
/>
);
}
/**
* Geniş alanlar için seyrek piksel zemini (ör. koyu CTA bandı).
* İçeriğin oturduğu orta bölge tamamen boş bırakılır; pikseller
* kenarlara doğru yoğunlaşır.
*/
export function PixelField({
seed = 7,
className,
}: {
seed?: number;
className?: string;
}) {
const svgRef = useRef<SVGSVGElement>(null);
usePixelHeat(svgRef);
const COLS = 42;
const ROWS = 12;
const cx = (COLS - 1) / 2;
const cy = (ROWS - 1) / 2;
const maxD = Math.sqrt(cx * cx + cy * cy);
const pixels: ReactNode[] = [];
let accentIdx = 0;
// Hero serpintisiyle aynı kural: içeriğin oturduğu orta kolon
// (genişliğin %2278 bandı) tüm yükseklik boyunca tamamen boş kalır
const BAND_MIN = COLS * 0.22;
const BAND_MAX = COLS * 0.78;
for (let y = 0; y < ROWS; y++) {
for (let x = 0; x < COLS; x++) {
if (x > BAND_MIN && x < BAND_MAX) continue;
// Kenarlara doğru artan doluluk
const d = Math.sqrt((x - cx) ** 2 + (y - cy) ** 2) / maxD;
const density = 0.1 + 0.42 * d * d;
if (hash(x, y, seed) > density) continue;
const accent = hash(x, y, seed + 99) > 0.96;
pixels.push(
pixelRect(x, y, {
key: `${x}-${y}`,
accent,
accentDelay: accent ? (accentIdx++ % 8) * 0.45 : 0,
}),
);
}
}
return (
<svg
ref={svgRef}
viewBox={`0 0 ${COLS * CELL} ${ROWS * CELL}`}
preserveAspectRatio="xMidYMid slice"
aria-hidden="true"
focusable="false"
className={cn("h-full w-full", className)}
>
{pixels}
</svg>
);
}
/**
* Bölümler arasında sert border çizgileri yerine kullanılan, ortada
* yoğunlaşıp kenarlara doğru dağılan piksel ayraç. Hero'nun dot-matrix
* dilini bölüm geçişlerine taşır.
*/
export function PixelDivider({
seed = 5,
className,
}: {
seed?: number;
className?: string;
}) {
const svgRef = useRef<SVGSVGElement>(null);
usePixelHeat(svgRef);
const COLS = 12;
const ROWS = 2;
const cx = (COLS - 1) / 2;
const pixels: ReactNode[] = [];
let accentPlaced = false;
for (let y = 0; y < ROWS; y++) {
for (let x = 0; x < COLS; x++) {
// Az sayıda, iri piksel: ortada yoğun, kenara doğru seyrelen küçük küme
const dx = Math.abs(x - cx) / cx;
const rowFalloff = y === 0 ? 0.9 : 0.35;
const density = (1 - dx) ** 1.1 * rowFalloff;
if (hash(x, y, seed) > density) continue;
const accent = !accentPlaced && hash(x, y, seed + 99) > 0.8;
if (accent) accentPlaced = true;
pixels.push(
pixelRect(x, y, {
key: `${x}-${y}`,
accent,
}),
);
}
}
return (
<svg
ref={svgRef}
viewBox={`0 0 ${COLS * CELL} ${ROWS * CELL}`}
aria-hidden="true"
focusable="false"
className={cn("h-auto w-full text-primary", className)}
>
{pixels}
</svg>
);
}
/** Küçük sabit süslerde (mark/korner) data-px öznitelikli rect. */
function fixedPixel(x: number, y: number, opacity: number) {
return (
<rect
x={x + INSET}
y={y + INSET}
width={DOT}
height={DOT}
rx={2}
data-px=""
data-cx={x + CELL / 2}
data-cy={y + CELL / 2}
data-op={opacity}
fill="currentColor"
fillOpacity={opacity}
/>
);
}
/** Eyebrow rozetlerinde kullanılan 2×2'lik minik piksel imzası. */
export function PixelMark({ className }: { className?: string }) {
const svgRef = useRef<SVGSVGElement>(null);
usePixelHeat(svgRef, { radius: 24 });
return (
<svg
ref={svgRef}
viewBox="0 0 20 20"
aria-hidden="true"
focusable="false"
className={cn("size-3.5 shrink-0", className)}
>
{fixedPixel(0, 0, 0.35)}
<rect x={11.3} y={1.3} width={7.4} height={7.4} rx={2} className="fill-orange-500" fillOpacity={0.85} />
{fixedPixel(0, 10, 0.6)}
{fixedPixel(10, 10, 0.18)}
</svg>
);
}
/** Kart köşelerine iliştirilen sessiz piksel kümesi. */
export function PixelCorner({ className }: { className?: string }) {
const svgRef = useRef<SVGSVGElement>(null);
usePixelHeat(svgRef, { radius: 28 });
return (
<svg
ref={svgRef}
viewBox="0 0 30 30"
aria-hidden="true"
focusable="false"
className={cn("size-7 text-primary", className)}
>
{fixedPixel(20, 0, 0.5)}
{fixedPixel(10, 0, 0.14)}
{fixedPixel(20, 10, 0.26)}
</svg>
);
}
/** Bölüm başlıklarının üstünde duran piksel imzalı eyebrow rozeti. */
export function SectionEyebrow({ children }: { children: ReactNode }) {
return (
<span className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-3.5 py-1.5 text-xs font-semibold uppercase tracking-[0.12em] text-primary">
<PixelMark />
{children}
</span>
);
}

View File

@@ -0,0 +1,74 @@
"use client";
import { type ReactNode } from "react";
import { ReactLenis } from "lenis/react";
import {
motion,
useReducedMotion,
useScroll,
useSpring,
useTransform,
useVelocity,
} from "motion/react";
/**
* Vitrin sayfalarının akışkan scroll efekti: Lenis ile lerp'li (yavaşlatılmış,
* momentumlu) kaydırma + scroll hızına bağlı içerik bükülmesi (skew).
* Hızlı kaydırdıkça sayfa hafifçe yana yatar, durunca yayla yerine oturur.
*
* Sohbet ve liste gibi iç scroll alanı olan uygulama sayfalarında KULLANMA —
* Lenis tekerlek olayını sayfaya yönlendirdiği için iç scroll'u bozar,
* transform da `fixed` konumlu çocukları koparır.
*
* Hareket azaltılmışsa hem yumuşatma hem bükülme tamamen kapalıdır.
*/
export function ScrollFlow({
children,
className,
maxSkew = 3,
}: {
children: ReactNode;
className?: string;
/** Derece cinsinden en fazla bükülme açısı. */
maxSkew?: number;
}) {
const reduceMotion = useReducedMotion();
const { scrollY } = useScroll();
const velocity = useVelocity(scrollY);
// Ham hız titrek olur; yay ile yumuşatınca bükülme salınıp yerine oturur
const smoothVelocity = useSpring(velocity, {
damping: 50,
stiffness: 420,
mass: 0.6,
});
// Ölü bölge: yavaş/ufak kaydırmalarda bükülme tamamen 0 kalır — transform
// hiç değişmediği için compositor'a kare başı iş çıkmaz. Eşik üstünde
// kareyle (ease-in) yükselir, 0,01°'lik adımlara yuvarlanır ki yayın
// mikro-salınımları her karede katmanı kirletmesin.
const skewY = useTransform(smoothVelocity, (v) => {
const DEADZONE = 500; // px/sn — bunun altı "normal gezinme" sayılır
const FULL = 2600; // px/sn — bu hızda maxSkew'e ulaşılır
const mag = Math.abs(v);
if (mag < DEADZONE) return 0;
const t = Math.min(1, (mag - DEADZONE) / (FULL - DEADZONE));
const s = t * t * maxSkew * Math.sign(v);
return Math.round(s * 100) / 100;
});
if (reduceMotion) {
return <div className={className}>{children}</div>;
}
return (
<ReactLenis root options={{ lerp: 0.09, anchors: true }}>
{/* willChange: bükülme 0'a döndüğünde dev katman GPU'dan düşüp
bir sonraki tekerlekte yeniden rasterize edilmesin */}
<motion.div
className={className}
style={{ skewY, willChange: "transform" }}
>
{children}
</motion.div>
</ReactLenis>
);
}

View File

@@ -14,12 +14,14 @@ export function SiteHeader() {
<div className="mx-auto flex h-16 max-w-6xl items-center justify-between gap-3 px-4">
<Link
href="/"
className="group flex items-center gap-3 font-heading text-xl font-bold"
className="group flex items-center gap-3 font-bricolage text-xl"
>
<span className="flex size-11 items-center justify-center rounded-full bg-primary text-white transition-transform duration-200 group-hover:-rotate-6">
<GraduationCap className="size-6" aria-hidden />
<span>
<span className="font-light text-3xl tracking-tighter">Kolay</span>
<span className="font-semibold text-3xl tracking-tighter">
Tercih
</span>
</span>
KolayTercih
</Link>
<nav className="hidden items-center gap-1 rounded-full border border-slate-200 bg-white p-1.5 text-base font-medium text-slate-600 sm:flex">
{navLinks.map((link) => (

View File

@@ -0,0 +1,166 @@
"use client";
/**
* Dot-matrix Türkiye silüeti — hero arka planı için dekoratif SVG.
* Izgara, gerçek sınır poligonlarından 64×28 hücreye rasterize edildi;
* Marmara Denizi boşluğu bilinçli olarak açık.
*
* Performans kurgusu: ~1000 piksel, React elemanı olarak DEĞİL, modül
* yüklenirken tek sefer üretilen statik bir SVG string'i olarak basılır
* (dangerouslySetInnerHTML). Böylece her sayfa yenilemesinde React bu
* elemanları yeniden kurmaz/hydrate etmez; sunucudan gelen HTML olduğu
* gibi kullanılır. Deterministik üretim sayesinde server ve client aynı
* çıktıyı verir (hydration uyumsuzluğu olmaz).
*
* Cursor etkileşimi: imleç yaklaşınca pikseller anında turuncuya ısınır,
* imleç uzaklaşınca yavaşça kendi mavi tonuna soğur (asimetrik zamanlama).
* Fırça mantığı usePixelHeat hook'unda ortaklaştırıldı; DOM'a
* querySelectorAll ile bağlandığı için statik markup'la sorunsuz çalışır.
*/
import { useRef } from "react";
import { usePixelHeat } from "@/components/use-pixel-heat";
const GRID = [
"..######..............##########................................",
"..#######...........##############..............................",
"..#######.........#################................#####........",
".########...###########################...........########......",
".######.....###############################################.....",
".###........###############################################.....",
".##.......##################################################....",
".###########################################################....",
".#############################################################..",
"..#############################################################.",
"...############################################################.",
"...##########################################################...",
"...##########################################################...",
"...###########################################################..",
"..############################################################..",
"..############################################################..",
"...###########################################################..",
"....##########################################################..",
"....##########################################################..",
".....#########################################################..",
".....###################################################..##....",
".....############################################...............",
"......##########..######################...###..................",
"...........#####....#########....#####..........................",
"............###.......#####......####...........................",
".................................###............................",
];
// Vurgu pikselleri: öğrenci yoğunluğu yüksek şehirler (col, row)
const CITIES = [
{ name: "İstanbul", x: 8, y: 4 },
{ name: "Ankara", x: 23, y: 9 },
{ name: "İzmir", x: 4, y: 16 },
{ name: "Antalya", x: 16, y: 22 },
{ name: "Kayseri", x: 32, y: 14 },
{ name: "Trabzon", x: 46, y: 5 },
{ name: "Diyarbakır", x: 47, y: 18 },
{ name: "Van", x: 58, y: 15 },
];
const CELL = 10;
const DOT = 7.4;
const INSET = (CELL - DOT) / 2;
const COLS = GRID[0].length;
const ROWS = GRID.length;
// Güney kıyının altına doğru seyrelerek dağılan serpinti derinliği (hücre)
const SCATTER_DEPTH = 9;
const VIEW_W = COLS * CELL;
const VIEW_H = (ROWS + SCATTER_DEPTH) * CELL;
// Deterministik doku: hücreye göre sabit opaklık (hydration güvenli)
const OPACITIES = [0.3, 0.16, 0.24, 0.12, 0.27, 0.19];
const opacityFor = (x: number, y: number) =>
OPACITIES[(x * 7 + y * 11) % OPACITIES.length];
const cityCells = new Set(CITIES.map((c) => `${c.x},${c.y}`));
// Deterministik sözde-rastgele (pixel-decor ile aynı): server ve client
// aynı serpinti desenini üretir
const hash = (x: number, y: number, seed = 0) => {
const n = Math.sin(x * 127.1 + y * 311.7 + seed * 74.7) * 43758.5453;
return n - Math.floor(n);
};
/* ------------------------------------------------------------------ */
/* Statik markup: modül yüklenirken TEK SEFER üretilir */
/* ------------------------------------------------------------------ */
const rectAttrs = (x: number, y: number) =>
`x="${x * CELL + INSET}" y="${y * CELL + INSET}" width="${DOT}" height="${DOT}" rx="2"`;
const MAP_MARKUP = (() => {
const parts: string[] = [];
GRID.forEach((row, y) => {
for (let x = 0; x < row.length; x++) {
if (row[x] !== "#" || cityCells.has(`${x},${y}`)) continue;
const op = opacityFor(x, y);
parts.push(
`<rect data-px data-cx="${x * CELL + CELL / 2}" data-cy="${y * CELL + CELL / 2}" data-op="${op}" ${rectAttrs(x, y)} fill="currentColor" fill-opacity="${op}"/>`,
);
}
});
// Silüetin altına serpinti: her kolonun en alttaki kara hücresinden
// aşağı doğru, kıyı hattını izleyerek seyrelen pikseller. Hover
// fırçasına da katılırlar (data-px).
const lastLand: number[] = Array(COLS).fill(-1);
GRID.forEach((row, y) => {
for (let x = 0; x < row.length; x++) {
if (row[x] === "#") lastLand[x] = y;
}
});
let scatterAccent = 0;
for (let x = 0; x < COLS; x++) {
if (lastLand[x] < 0) continue;
for (let d = 1; d <= SCATTER_DEPTH; d++) {
const y = lastLand[x] + d;
if (y >= ROWS + SCATTER_DEPTH) break;
// Kıyıdan uzaklaştıkça seyrelen doluluk; küçük taban sayesinde
// en derin sıralarda da tek tük piksel kalır
const density = 0.05 + 0.4 * (1 - (d - 1) / SCATTER_DEPTH) ** 2;
if (hash(x, y) > density) continue;
if (hash(x, y, 99) > 0.96) {
// Nadir turuncu vurgu: şehir pikselleriyle aynı nabız dili
parts.push(
`<rect ${rectAttrs(x, y)} class="animate-pulse fill-orange-500/70 motion-reduce:animate-none" style="animation-duration:3.6s;animation-delay:${(scatterAccent++ % 8) * 0.45}s"/>`,
);
continue;
}
// Derinlikle sönen opaklık
const op =
Math.round(opacityFor(x, y) * (1 - d / (SCATTER_DEPTH + 1)) * 100) /
100;
parts.push(
`<rect data-px data-cx="${x * CELL + CELL / 2}" data-cy="${y * CELL + CELL / 2}" data-op="${op}" ${rectAttrs(x, y)} fill="currentColor" fill-opacity="${op}"/>`,
);
}
}
// Şehir pikselleri: turuncu, hafif nabız; hover fırçasına katılmazlar
CITIES.forEach((city, i) => {
parts.push(
`<rect ${rectAttrs(city.x, city.y)} class="animate-pulse fill-orange-500/70 motion-reduce:animate-none" style="animation-duration:3.6s;animation-delay:${i * 0.45}s"/>`,
);
});
return parts.join("");
})();
export function TurkeyPixelMap({ className }: { className?: string }) {
const svgRef = useRef<SVGSVGElement>(null);
usePixelHeat(svgRef);
return (
<svg
ref={svgRef}
viewBox={`0 0 ${VIEW_W} ${VIEW_H}`}
className={className}
aria-hidden="true"
focusable="false"
// Statik, deterministik markup — her render'da yeniden kurulmaz
dangerouslySetInnerHTML={{ __html: MAP_MARKUP }}
/>
);
}

View File

@@ -5,8 +5,6 @@ import React from "react"
export interface LoaderProps {
variant?:
| "circular"
| "classic"
| "pulse"
| "pulse-dot"
| "dots"
@@ -22,78 +20,6 @@ export interface LoaderProps {
className?: string
}
export function CircularLoader({
className,
size = "md",
}: {
className?: string
size?: "sm" | "md" | "lg"
}) {
const sizeClasses = {
sm: "size-4",
md: "size-5",
lg: "size-6",
}
return (
<div
className={cn(
"border-primary animate-spin rounded-full border-2 border-t-transparent",
sizeClasses[size],
className
)}
>
<span className="sr-only">Loading</span>
</div>
)
}
export function ClassicLoader({
className,
size = "md",
}: {
className?: string
size?: "sm" | "md" | "lg"
}) {
const sizeClasses = {
sm: "size-4",
md: "size-5",
lg: "size-6",
}
const barSizes = {
sm: { height: "6px", width: "1.5px" },
md: { height: "8px", width: "2px" },
lg: { height: "10px", width: "2.5px" },
}
return (
<div className={cn("relative", sizeClasses[size], className)}>
<div className="absolute h-full w-full">
{[...Array(12)].map((_, i) => (
<div
key={i}
className="bg-primary absolute animate-[spinner-fade_1.2s_linear_infinite] rounded-full"
style={{
top: "0",
left: "50%",
marginLeft:
size === "sm" ? "-0.75px" : size === "lg" ? "-1.25px" : "-1px",
transformOrigin: `${size === "sm" ? "0.75px" : size === "lg" ? "1.25px" : "1px"} ${size === "sm" ? "10px" : size === "lg" ? "14px" : "12px"}`,
transform: `rotate(${i * 30}deg)`,
opacity: 0,
animationDelay: `${i * 0.1}s`,
height: barSizes[size].height,
width: barSizes[size].width,
}}
/>
))}
</div>
<span className="sr-only">Loading</span>
</div>
)
}
export function PulseLoader({
className,
size = "md",
@@ -461,16 +387,12 @@ export function TextDotsLoader({
}
function Loader({
variant = "circular",
variant = "dots",
size = "md",
text,
className,
}: LoaderProps) {
switch (variant) {
case "circular":
return <CircularLoader size={size} className={className} />
case "classic":
return <ClassicLoader size={size} className={className} />
case "pulse":
return <PulseLoader size={size} className={className} />
case "pulse-dot":
@@ -492,7 +414,7 @@ function Loader({
case "loading-dots":
return <TextDotsLoader text={text} size={size} className={className} />
default:
return <CircularLoader size={size} className={className} />
return <DotsLoader size={size} className={className} />
}
}

View File

@@ -2,7 +2,7 @@
import { useTheme } from "next-themes"
import { Toaster as Sonner, type ToasterProps } from "sonner"
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon } from "lucide-react"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
@@ -25,7 +25,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
<OctagonXIcon className="size-4" />
),
loading: (
<Loader2Icon className="size-4 animate-spin" />
<span className="block size-2.5 animate-pulse rounded-full bg-current" />
),
}}
style={

View File

@@ -0,0 +1,109 @@
"use client";
/**
* Hero'daki (TurkeyPixelMap) imleç ısı fırçasının ortak hook hali.
* `data-px` işaretli rect'ler imleç yaklaşınca anında turuncuya ısınır,
* uzaklaşınca yavaşça kendi rengine soğur (asimetrik zamanlama).
*
* SVG katmanları çoğunlukla pointer-events-none olduğundan imleç window
* üzerinden izlenir ve viewBox koordinatına çevrilir. Beklenen veri
* öznitelikleri: data-px, data-cx, data-cy, data-op (taban opaklık).
*/
import { useEffect, type RefObject } from "react";
import { animate, type AnimationPlaybackControls } from "motion";
const FINE_POINTER_QUERY = "(hover: hover) and (pointer: fine)";
const HOT_COLOR = "#f97316"; // orange-500
const HOT_OPACITY = 0.85;
const HEAT_DURATION = 0.15; // ısınma: anlık geri bildirim
const COOL_DURATION = 1.4; // soğuma: iz gibi yavaşça söner
type HeatTarget = {
el: SVGRectElement;
cx: number;
cy: number;
baseOpacity: number;
};
export function usePixelHeat(
svgRef: RefObject<SVGSVGElement | null>,
{
/** Fırça yarıçapı, SVG (viewBox) biriminde */
radius = 34,
}: { radius?: number } = {},
) {
useEffect(() => {
const svg = svgRef.current;
if (!svg) return;
// Dokunmatik cihazlarda hover yanlış tetiklenir; efekti imleçli cihazlara sakla
if (!window.matchMedia(FINE_POINTER_QUERY).matches) return;
const viewW = svg.viewBox.baseVal.width;
if (!viewW) return;
const baseColor = getComputedStyle(svg).color;
const pixels: HeatTarget[] = Array.from(
svg.querySelectorAll<SVGRectElement>("[data-px]"),
).map((el) => ({
el,
cx: Number(el.dataset.cx),
cy: Number(el.dataset.cy),
baseOpacity: Number(el.dataset.op),
}));
if (pixels.length === 0) return;
const hot = new Set<SVGRectElement>();
const controls = new Map<SVGRectElement, AnimationPlaybackControls>();
const run = (
el: SVGRectElement,
to: { fill: string; fillOpacity: number },
duration: number,
) => {
controls.get(el)?.stop();
controls.set(el, animate(el, to, { duration, ease: "easeOut" }));
};
const onMove = (e: PointerEvent) => {
const rect = svg.getBoundingClientRect();
if (rect.width === 0) return;
const scale = rect.width / viewW;
const pad = radius * scale;
// İmleç fırça menzilinin tamamen dışındaysa ve sıcak piksel yoksa
// hiç hesap yapma (sayfada birden çok örnek dinlediği için önemli)
if (
hot.size === 0 &&
(e.clientX < rect.left - pad ||
e.clientX > rect.right + pad ||
e.clientY < rect.top - pad ||
e.clientY > rect.bottom + pad)
) {
return;
}
const x = (e.clientX - rect.left) / scale;
const y = (e.clientY - rect.top) / scale;
for (const p of pixels) {
const d2 = (p.cx - x) ** 2 + (p.cy - y) ** 2;
const isHot = d2 <= radius * radius;
if (isHot && !hot.has(p.el)) {
hot.add(p.el);
run(p.el, { fill: HOT_COLOR, fillOpacity: HOT_OPACITY }, HEAT_DURATION);
} else if (!isHot && hot.has(p.el)) {
hot.delete(p.el);
run(
p.el,
{ fill: baseColor, fillOpacity: p.baseOpacity },
COOL_DURATION,
);
}
}
};
window.addEventListener("pointermove", onMove, { passive: true });
return () => {
window.removeEventListener("pointermove", onMove);
controls.forEach((c) => c.stop());
};
}, [svgRef, radius]);
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.