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", "runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev", "--", "-p", "3100"], "runtimeArgs": ["run", "dev", "--", "-p", "3100"],
"port": 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": { "dependencies": {
"@anthropic-ai/sdk": "^0.112.4", "@anthropic-ai/sdk": "^0.112.4",
"@cds/city": "^1.1.0",
"@libsql/client": "^0.17.4", "@libsql/client": "^0.17.4",
"@react-pdf/renderer": "^4.5.1", "@react-pdf/renderer": "^4.5.1",
"better-auth": "^1.6.23", "better-auth": "^1.6.23",
@@ -20,6 +21,7 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.45.2",
"iyzipay": "^2.0.69", "iyzipay": "^2.0.69",
"lenis": "^1.3.25",
"lucide-react": "^1.25.0", "lucide-react": "^1.25.0",
"marked": "^18.0.7", "marked": "^18.0.7",
"motion": "^12.42.2", "motion": "^12.42.2",

29
pnpm-lock.yaml generated
View File

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

View File

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

View File

@@ -1,5 +1,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import Link from "next/link"; import Link from "next/link";
import { Parallax } from "@/components/parallax";
import { ScrollFlow } from "@/components/scroll-flow";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Gizlilik ve KVKK — KolayTercih", title: "Gizlilik ve KVKK — KolayTercih",
@@ -8,13 +10,22 @@ export const metadata: Metadata = {
// NOT: MVP taslağıdır; yayına almadan önce hukuki gözden geçirme gerekir. // NOT: MVP taslağıdır; yayına almadan önce hukuki gözden geçirme gerekir.
export default function GizlilikPage() { export default function GizlilikPage() {
return ( return (
<ScrollFlow>
<main className="mx-auto w-full max-w-2xl px-4 py-16"> <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"> <h1 className="font-heading text-3xl font-bold">
Gizlilik ve Kişisel Verilerin Korunması Gizlilik ve Kişisel Verilerin Korunması
</h1> </h1>
<p className="mt-2 text-sm text-slate-500">Son güncelleme: 26 Temmuz 2026</p> <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"> <Parallax
fromStart
strength={4}
className="mt-8 space-y-6 text-sm leading-7 text-slate-700"
>
<section> <section>
<h2 className="font-heading text-lg font-bold text-slate-900"> <h2 className="font-heading text-lg font-bold text-slate-900">
Hangi verileri işliyoruz? Hangi verileri işliyoruz?
@@ -34,9 +45,9 @@ export default function GizlilikPage() {
</h2> </h2>
<p className="mt-2"> <p className="mt-2">
Verilerin yalnızca tercih önerileri üretmek, hesabını ve kredi Verilerin yalnızca tercih önerileri üretmek, hesabını ve kredi
bakiyeni yönetmek ve sana hizmetle ilgili e-posta (giriş bağlantısı, bakiyeni yönetmek ve sana hizmetle ilgili e-posta (giriş
ödeme bilgilendirmesi) göndermek için kullanılır. Verilerini bağlantısı, ödeme bilgilendirmesi) göndermek için kullanılır.
üçüncü taraflara satmayız, reklam amacıyla paylaşmayız. Verilerini üçüncü taraflara satmayız, reklam amacıyla paylaşmayız.
</p> </p>
</section> </section>
<section> <section>
@@ -48,7 +59,8 @@ export default function GizlilikPage() {
<Link href="/iletisim" className="text-primary underline"> <Link href="/iletisim" className="text-primary underline">
iletişim sayfasındaki iletişim sayfasındaki
</Link>{" "} </Link>{" "}
adresten bize yazabilirsin; talepler 30 gün içinde sonuçlandırılır. adresten bize yazabilirsin; talepler 30 gün içinde
sonuçlandırılır.
</p> </p>
</section> </section>
<section> <section>
@@ -60,7 +72,8 @@ export default function GizlilikPage() {
reklam veya izleme çerezi kullanılmaz. reklam veya izleme çerezi kullanılmaz.
</p> </p>
</section> </section>
</div> </Parallax>
</main> </main>
</ScrollFlow>
); );
} }

View File

@@ -7,9 +7,10 @@
@theme inline { @theme inline {
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--font-sans: var(--font-work-sans); --font-sans: var(--font-clarity-city);
--font-mono: var(--font-geist-mono); --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-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border); --color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
@@ -168,7 +169,22 @@
body { body {
@apply bg-background text-foreground; @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 { html {
@apply font-sans; @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 type { Metadata } from "next";
import { Mail } from "lucide-react"; import { Mail } from "lucide-react";
import { Parallax } from "@/components/parallax";
import { ScrollFlow } from "@/components/scroll-flow";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "İletişim — KolayTercih", title: "İletişim — KolayTercih",
@@ -7,8 +9,11 @@ export const metadata: Metadata = {
export default function IletisimPage() { export default function IletisimPage() {
return ( return (
<ScrollFlow>
<main className="mx-auto w-full max-w-2xl px-4 py-16"> <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> <h1 className="font-heading text-3xl font-bold">İletişim</h1>
</Parallax>
<p className="mt-4 text-sm leading-7 text-slate-700"> <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 Ö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. var? Tercih döneminde mesajlara aynı gün dönmeye çalışıyoruz.
@@ -26,5 +31,6 @@ export default function IletisimPage() {
asla e-postayla paylaşma. asla e-postayla paylaşma.
</p> </p>
</main> </main>
</ScrollFlow>
); );
} }

View File

@@ -1,5 +1,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import Link from "next/link"; import Link from "next/link";
import { Parallax } from "@/components/parallax";
import { ScrollFlow } from "@/components/scroll-flow";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Kullanım ve Satış Koşulları — KolayTercih", title: "Kullanım ve Satış Koşulları — KolayTercih",
@@ -8,13 +10,22 @@ export const metadata: Metadata = {
// NOT: MVP taslağıdır; yayına almadan önce hukuki gözden geçirme gerekir. // NOT: MVP taslağıdır; yayına almadan önce hukuki gözden geçirme gerekir.
export default function KosullarPage() { export default function KosullarPage() {
return ( return (
<ScrollFlow>
<main className="mx-auto w-full max-w-2xl px-4 py-16"> <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"> <h1 className="font-heading text-3xl font-bold">
Kullanım ve Satış Koşulları Kullanım ve Satış Koşulları
</h1> </h1>
<p className="mt-2 text-sm text-slate-500">Son güncelleme: 26 Temmuz 2026</p> <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"> <Parallax
fromStart
strength={4}
className="mt-8 space-y-6 text-sm leading-7 text-slate-700"
>
<section> <section>
<h2 className="font-heading text-lg font-bold text-slate-900"> <h2 className="font-heading text-lg font-bold text-slate-900">
Hizmetin kapsamı Hizmetin kapsamı
@@ -49,8 +60,8 @@ export default function KosullarPage() {
Buna rağmen kişisel listen henüz üretilmemişse (paket hiç 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 kullanılmamışsa) satın almadan itibaren 14 gün içinde koşulsuz
iade talep edebilirsin. Teknik bir sorun nedeniyle hizmeti iade talep edebilirsin. Teknik bir sorun nedeniyle hizmeti
alamadıysan, kullanımdan bağımsız olarak ücretin iade edilir. alamadıysan, kullanımdan bağımsız olarak ücretin iade edilir. İade
İade talepleri için{" "} talepleri için{" "}
<Link href="/iletisim" className="text-primary underline"> <Link href="/iletisim" className="text-primary underline">
bize yaz bize yaz
</Link> </Link>
@@ -68,7 +79,8 @@ export default function KosullarPage() {
kazıma vb.) hesaplar kapatılabilir. kazıma vb.) hesaplar kapatılabilir.
</p> </p>
</section> </section>
</div> </Parallax>
</main> </main>
</ScrollFlow>
); );
} }

View File

@@ -1,5 +1,6 @@
import type { Metadata } from "next"; 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 { Toaster } from "@/components/ui/sonner";
import { SiteHeader } from "@/components/site-header"; import { SiteHeader } from "@/components/site-header";
import { ListeCekmecesi } from "@/components/manuel-liste/liste-cekmecesi"; 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 { DevPanel } from "@/components/dev/dev-panel";
import "./globals.css"; import "./globals.css";
const outfit = Outfit({ const calSans = Cal_Sans({
variable: "--font-outfit", variable: "--font-cal-sans",
weight: "400",
subsets: ["latin", "latin-ext"], subsets: ["latin", "latin-ext"],
}); });
const workSans = Work_Sans({ const clarityCity = localFont({
variable: "--font-work-sans", variable: "--font-clarity-city",
subsets: ["latin", "latin-ext"], 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({ const geistMono = Geist_Mono({
@@ -23,6 +31,12 @@ const geistMono = Geist_Mono({
subsets: ["latin"], subsets: ["latin"],
}); });
const bricolage = Bricolage_Grotesque({
variable: "--font-bricolage-grotesque",
subsets: ["latin", "latin-ext"],
axes: ["opsz"],
});
export const metadata: Metadata = { export const metadata: Metadata = {
title: "KolayTercih — Tercih Danışmanı", title: "KolayTercih — Tercih Danışmanı",
description: description:
@@ -37,7 +51,7 @@ export default function RootLayout({
return ( return (
<html <html
lang="tr" 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"> <body className="min-h-full flex flex-col bg-slate-50">
{/* Site geneli progressive blur — üst ve alt */} {/* Site geneli progressive blur — üst ve alt */}

View File

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

View File

@@ -21,6 +21,7 @@ import {
import { PromptSuggestion } from "@/components/ui/prompt-suggestion"; import { PromptSuggestion } from "@/components/ui/prompt-suggestion";
import { ScrollButton } from "@/components/ui/scroll-button"; import { ScrollButton } from "@/components/ui/scroll-button";
import { DotsLoader } from "@/components/ui/loader"; import { DotsLoader } from "@/components/ui/loader";
import { Skeleton } from "@/components/ui/skeleton";
interface Mesaj { interface Mesaj {
id: string; id: string;
@@ -174,7 +175,15 @@ export function SohbetClient({
))} ))}
</div> </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> </div>
) : ( ) : (
@@ -195,8 +204,10 @@ export function SohbetClient({
{m.content} {m.content}
</MessageContent> </MessageContent>
) : ( ) : (
<div className="rounded-lg bg-slate-100 px-4 py-3"> <div className="w-full max-w-[85%] space-y-2 rounded-lg bg-slate-100 px-4 py-3">
<DotsLoader size="sm" /> <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> </div>
)} )}
</Message> </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() { export default function Loading() {
return ( return (
<main <main aria-live="polite" className="mx-auto w-full max-w-6xl px-4 py-12">
aria-live="polite" {/* Rozet + başlık + alt metin */}
className="flex min-h-[60vh] flex-col items-center justify-center gap-3" <Skeleton className="h-6 w-44 rounded-full" />
> <Skeleton className="mt-5 h-10 w-3/4 max-w-2xl" />
<Loader2 className="size-8 animate-spin text-primary" aria-hidden /> <Skeleton className="mt-3 h-10 w-1/2 max-w-md" />
<p className="text-sm text-slate-500">Yükleniyor</p> <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> </main>
); );
} }

View File

@@ -12,6 +12,16 @@ import {
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { HeroForm } from "@/components/hero-form"; 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 { URUNLER } from "@/lib/credits";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -156,34 +166,62 @@ function ComparisonCell({ value }: { value: boolean | string }) {
export default function Home() { export default function Home() {
return ( return (
<div className="flex min-h-screen flex-col bg-slate-50 text-slate-900"> <div className="bg-slate-50 text-slate-900">
<ScrollFlow className="flex min-h-screen flex-col">
<main className="flex-1"> <main className="flex-1">
{/* Hero */} {/* Hero */}
<section className="mx-auto flex max-w-6xl flex-col items-center px-4 pb-20 pt-16 text-center sm:pt-24"> <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 <Badge
variant="secondary" variant="secondary"
className="mb-6 bg-primary/10 text-primary" className="mb-6 bg-primary/10 text-primary"
> >
2026 YKS tercih dönemi ık 2026 YKS tercih dönemi ık
</Badge> </Badge>
<h1 className="max-w-3xl font-heading text-4xl font-bold tracking-tight sm:text-6xl"> <h1 className="max-w-3xl font-heading text-4xl font-bold tracking-[0.015em] sm:text-6xl">
Dört yıllık geleceğini{" "} Dört yıllık geleceğini <br className="hidden sm:block" />
<span className="text-primary">48 saatlik panikle</span> seçme <span className="font-bricolage italic tracking-tighter font-bold text-primary">
48 saatlik panikle
</span>{" "}
seçme
</h1> </h1>
<p className="mt-6 max-w-2xl text-lg leading-relaxed text-slate-600"> {/* <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 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 danışmanın. İnsan danışmanın binlerce lira aldığı işi — dengeli
liste, risk analizi, soru-cevap — onda bir fiyatına yapar. liste, risk analizi, soru-cevap — onda bir fiyatına yapar.
</p> </p> */}
<div <div
id="hero-form" id="hero-form"
className="mt-10 flex w-full scroll-mt-28 justify-center" className="mt-6 flex w-full scroll-mt-28 justify-center"
> >
<HeroForm /> <HeroForm />
</div> </div>
<p className="mt-4 text-sm text-slate-500"> <p className="mt-4 text-sm text-slate-500">
Temel arama her zaman ücretsiz · Kredi kartı gerekmez · Tam analiz Temel arama her zaman ücretsiz · Kredi kartı gerekmez · Tam
paketi tek seferlik {PAKET_FIYATI} TL analiz paketi tek seferlik {PAKET_FIYATI} TL
</p> </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"> <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 <Image
@@ -201,17 +239,39 @@ export default function Home() {
güvenilir sonuçlar güvenilir sonuçlar
</span> </span>
</div> </div>
</Parallax>
</section> </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 */} {/* Problem */}
<section className="border-y border-slate-200 bg-white py-20"> <section className="py-20 sm:py-24">
<div className="mx-auto max-w-6xl px-4"> <div className="mx-auto max-w-6xl px-4">
<h2 className="text-center font-heading text-3xl font-bold sm:text-4xl"> {/* 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? Tercih dönemi neden bu kadar stresli?
</h2> </h2>
<div className="mt-12 grid gap-6 md:grid-cols-3"> <p className="max-w-xl text-lg leading-relaxed text-slate-600">
{problems.map((p) => ( Her yıl iki milyondan fazla aday aynı üç duvara çarpıyor.
<Card key={p.title} className="border-slate-200"> </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> <CardHeader>
<div className="mb-2 flex size-11 items-center justify-center rounded-xl bg-primary/10"> <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 /> <p.icon className="size-5 text-primary" aria-hidden />
@@ -224,26 +284,45 @@ export default function Home() {
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
</Card> </Card>
</Reveal>
))} ))}
</div> </Parallax>
</div> </div>
</section> </section>
{/* 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 */} {/* How it works */}
<section id="nasil-calisir" className="scroll-mt-24 py-20"> <section id="nasil-calisir" className="scroll-mt-24 py-20 sm:py-24">
<div className="mx-auto max-w-6xl px-4"> <div className="mx-auto max-w-6xl px-4">
<h2 className="text-center font-heading text-3xl font-bold sm:text-4xl"> <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 Üç adımda listen hazır
</h2> </h2>
<div className="mt-12 grid gap-6 md:grid-cols-3"> <p className="max-w-xl text-lg leading-relaxed text-slate-600">
{steps.map((s) => ( Sıralamandan dengeli 24&apos;lük listeye giden yol üç adım
<div sürer.
key={s.step} </p>
className="relative rounded-2xl border border-slate-200 bg-white p-8" </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"> <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} {s.step}
</span> </span>
<PixelCorner className="absolute right-4 top-4" />
<s.icon className="size-6 text-primary" aria-hidden /> <s.icon className="size-6 text-primary" aria-hidden />
<h3 className="mt-4 font-heading text-xl font-semibold"> <h3 className="mt-4 font-heading text-xl font-semibold">
{s.title} {s.title}
@@ -252,15 +331,26 @@ export default function Home() {
{s.description} {s.description}
</p> </p>
</div> </div>
</Reveal>
))} ))}
</div> </Parallax>
{/* Veriye dayalı güven + garanti vaadine karşı dürüst konumlandırma */} {/* 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"> <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"> <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: "23.000+", label: "Taranan lisans programı" },
{ value: "4 yıl", label: "Taban sıralaması trend verisi" }, {
value: "4 yıl",
label: "Taban sıralaması trend verisi",
},
{ value: "%100", label: "Resmî YÖK Atlas kaynağı" }, { value: "%100", label: "Resmî YÖK Atlas kaynağı" },
].map((stat) => ( ].map((stat) => (
<div key={stat.label} className="p-6 text-center"> <div key={stat.label} className="p-6 text-center">
@@ -284,26 +374,43 @@ export default function Home() {
Yerleşme garantisi vermiyoruz çünkü kimse veremez. Yerleşme garantisi vermiyoruz çünkü kimse veremez.
</p> </p>
<p className="mt-1.5 text-sm leading-6 text-slate-600"> <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 Taban sıralamaları her yıl değişir. Biz sonuç vaadi
yerine riski satır satır gösterir, daha güvenli bir liste satmak yerine riski satır satır gösterir, daha güvenli
kurmana yardımcı oluruz. bir liste kurmana yardımcı oluruz.
</p> </p>
</div> </div>
</div> </div>
</div> </Reveal>
</Parallax>
</div> </div>
</section> </section>
{/* 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>
{/* Comparison */} {/* Comparison */}
<section <section id="karsilastirma" className="scroll-mt-24 py-20 sm:py-24">
id="karsilastirma"
className="scroll-mt-24 border-y border-slate-200 bg-white py-20"
>
<div className="mx-auto max-w-4xl px-4"> <div className="mx-auto max-w-4xl px-4">
<h2 className="text-center font-heading text-3xl font-bold sm:text-4xl"> <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? Robot mu, danışman mı, KolayTercih mi?
</h2> </h2>
<div className="mt-12 overflow-x-auto rounded-2xl border border-slate-200 bg-white"> <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> <Table>
<TableHeader> <TableHeader>
<TableRow className="bg-slate-50"> <TableRow className="bg-slate-50">
@@ -338,19 +445,42 @@ export default function Home() {
))} ))}
</TableBody> </TableBody>
</Table> </Table>
</div> </Reveal>
</Parallax>
</div> </div>
</section> </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 */} {/* FAQ */}
<section id="sss" className="scroll-mt-24 py-20"> <section id="sss" className="scroll-mt-24 py-20 sm:py-24">
<div className="mx-auto max-w-3xl px-4"> <div className="mx-auto max-w-3xl px-4">
<h2 className="text-center font-heading text-3xl font-bold sm:text-4xl"> <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 Sık sorulan sorular
</h2> </h2>
<Accordion type="single" collapsible className="mt-10"> </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) => ( {faqs.map((faq) => (
<AccordionItem key={faq.question} value={faq.question}> <AccordionItem
key={faq.question}
value={faq.question}
className="last:border-b-0"
>
<AccordionTrigger className="cursor-pointer text-left font-heading text-base font-semibold"> <AccordionTrigger className="cursor-pointer text-left font-heading text-base font-semibold">
{faq.question} {faq.question}
</AccordionTrigger> </AccordionTrigger>
@@ -360,19 +490,37 @@ export default function Home() {
</AccordionItem> </AccordionItem>
))} ))}
</Accordion> </Accordion>
</Reveal>
</Parallax>
</div> </div>
</section> </section>
{/* Final CTA */} {/* Final CTA — tam genişlik bant yerine yüzen, köşeleri yumuşak panel */}
<section className="bg-primary py-20 text-white"> <section className="px-4 pb-24 pt-6 sm:px-6">
<div className="mx-auto flex max-w-3xl flex-col items-center px-4 text-center"> <Parallax strength={12} className="mx-auto max-w-6xl">
<MessageCircleQuestion className="size-10 opacity-80" aria-hidden /> <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"> <h2 className="mt-6 font-heading text-3xl font-bold sm:text-4xl">
Tercih listen, pişmanlık listesi olmasın Tercih listen, pişmanlık listesi olmasın
</h2> </h2>
<p className="mt-4 max-w-xl text-lg leading-relaxed text-white/90"> <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 Sıralamanı gir, yapay zekâ danışmanın veriyle konuşsun.
dönemi bitmeden yerini al. Tercih dönemi bitmeden yerini al.
</p> </p>
<Button <Button
asChild asChild
@@ -382,10 +530,13 @@ export default function Home() {
<a href="#hero-form">Ücretsiz dene</a> <a href="#hero-form">Ücretsiz dene</a>
</Button> </Button>
</div> </div>
</div>
</Reveal>
</Parallax>
</section> </section>
</main> </main>
<footer className="border-t border-slate-200 bg-white pb-32 pt-10"> <footer className="pb-32 pt-4">
<div className="mx-auto max-w-6xl px-4 text-center text-sm leading-relaxed text-slate-500"> <div className="mx-auto max-w-6xl px-4 text-center text-sm leading-relaxed text-slate-500">
<p> <p>
© 2026 KolayTercih. Veriler resmî YÖK Atlas kaynağından derlenir; © 2026 KolayTercih. Veriler resmî YÖK Atlas kaynağından derlenir;
@@ -399,18 +550,28 @@ export default function Home() {
aria-label="Yasal sayfalar" aria-label="Yasal sayfalar"
className="mt-4 flex items-center justify-center gap-4" className="mt-4 flex items-center justify-center gap-4"
> >
<Link href="/gizlilik" className="hover:text-slate-700 hover:underline"> <Link
href="/gizlilik"
className="hover:text-slate-700 hover:underline"
>
Gizlilik & KVKK Gizlilik & KVKK
</Link> </Link>
<Link href="/kosullar" className="hover:text-slate-700 hover:underline"> <Link
href="/kosullar"
className="hover:text-slate-700 hover:underline"
>
Kullanım & iade koşulları Kullanım & iade koşulları
</Link> </Link>
<Link href="/iletisim" className="hover:text-slate-700 hover:underline"> <Link
href="/iletisim"
className="hover:text-slate-700 hover:underline"
>
İletişim İletişim
</Link> </Link>
</nav> </nav>
</div> </div>
</footer> </footer>
</ScrollFlow>
</div> </div>
); );
} }

View File

@@ -13,6 +13,8 @@ import { getCurrentUser } from "@/lib/session";
import { URUNLER, DENEME_KREDISI } from "@/lib/credits"; import { URUNLER, DENEME_KREDISI } from "@/lib/credits";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Parallax } from "@/components/parallax";
import { ScrollFlow } from "@/components/scroll-flow";
import { SatinAlForm } from "./satin-al-form"; import { SatinAlForm } from "./satin-al-form";
export const metadata: Metadata = { export const metadata: Metadata = {
@@ -51,7 +53,8 @@ export default async function PaketPage({
const odemeHatasi = hata === "token" || hata === "siparis"; const odemeHatasi = hata === "token" || hata === "siparis";
return ( return (
<div className="flex min-h-screen flex-col bg-slate-50 text-slate-900"> <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"> <main className="mx-auto w-full max-w-3xl flex-1 px-4 py-16">
{odemeHatasi ? ( {odemeHatasi ? (
<div <div
@@ -63,7 +66,7 @@ export default async function PaketPage({
deneyebilirsin çekim yapılmadıysa hiçbir ücret ödemedin. deneyebilirsin çekim yapılmadıysa hiçbir ücret ödemedin.
</div> </div>
) : null} ) : null}
<div className="text-center"> <Parallax fromStart strength={12} className="text-center">
<Badge variant="secondary" className="bg-primary/10 text-primary"> <Badge variant="secondary" className="bg-primary/10 text-primary">
Tek seferlik ödeme · Abonelik yok Tek seferlik ödeme · Abonelik yok
</Badge> </Badge>
@@ -75,9 +78,12 @@ export default async function PaketPage({
Tercih danışmanları bu dönemde 2.00010.000 TL alıyor. KolayTercih 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. aynı işi gerçek YÖK Atlas verisiyle, dakikalar içinde yapar.
</p> </p>
</div> </Parallax>
<div className="mt-10 grid gap-6 sm:grid-cols-[1fr_auto]"> <Parallax
strength={6}
className="mt-10 grid gap-6 sm:grid-cols-[1fr_auto]"
>
{/* Ana paket */} {/* Ana paket */}
<section className="rounded-2xl border-2 border-orange-500 bg-white p-8 shadow-sm"> <section className="rounded-2xl border-2 border-orange-500 bg-white p-8 shadow-sm">
<div className="flex items-baseline justify-between"> <div className="flex items-baseline justify-between">
@@ -121,7 +127,9 @@ export default async function PaketPage({
asChild asChild
className="h-12 w-full cursor-pointer bg-orange-500 px-8 text-white transition-colors duration-200 hover:bg-orange-600" 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")}`}> <Link
href={`/giris?callback=${encodeURIComponent("/paket")}`}
>
Giriş yap ve {DENEME_KREDISI} deneme kredisi kazan Giriş yap ve {DENEME_KREDISI} deneme kredisi kazan
</Link> </Link>
</Button> </Button>
@@ -160,25 +168,28 @@ export default async function PaketPage({
variant="outline" variant="outline"
className="h-11 w-full cursor-pointer" className="h-11 w-full cursor-pointer"
> >
<Link href={`/giris?callback=${encodeURIComponent("/paket")}`}> <Link
href={`/giris?callback=${encodeURIComponent("/paket")}`}
>
Önce giriş yap Önce giriş yap
</Link> </Link>
</Button> </Button>
)} )}
</div> </div>
</section> </section>
</div> </Parallax>
<p className="mt-10 text-center text-xs leading-relaxed text-slate-500"> <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 KolayTercih bir karar destek aracıdır; öneriler resmî YÖK Atlas
dayanır ancak yerleşme garantisi verilmez. Tercih listesinin nihai verisine dayanır ancak yerleşme garantisi verilmez. Tercih
sorumluluğu adaya aittir. Sorun mu var?{" "} listesinin nihai sorumluluğu adaya aittir. Sorun mu var?{" "}
<Link href="/iletisim" className="underline hover:text-slate-700"> <Link href="/iletisim" className="underline hover:text-slate-700">
Bize yaz Bize yaz
</Link> </Link>
. .
</p> </p>
</main> </main>
</ScrollFlow>
</div> </div>
); );
} }

View File

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

View File

@@ -2,8 +2,9 @@
import { useMemo, useEffect, useRef, useState } from "react"; import { useMemo, useEffect, useRef, useState } from "react";
import Link from "next/link"; 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 { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { SohbetPopup } from "./sohbet-popup"; import { SohbetPopup } from "./sohbet-popup";
import { RaporListesi } from "@/components/rapor-listesi"; import { RaporListesi } from "@/components/rapor-listesi";
import { TercihHaritasi, type HaritaPin } from "@/components/tercih-haritasi"; import { TercihHaritasi, type HaritaPin } from "@/components/tercih-haritasi";
@@ -305,9 +306,9 @@ function UretimGovdesi() {
ref={govdeRef} ref={govdeRef}
tabIndex={-1} tabIndex={-1}
aria-live="polite" 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 /> <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="font-heading text-lg font-bold">Listen hazırlanıyor</p>
<p className="max-w-sm text-sm text-slate-500"> <p className="max-w-sm text-sm text-slate-500">
{URETIM_MESAJLARI[mesajIdx]} {URETIM_MESAJLARI[mesajIdx]}
@@ -315,6 +316,18 @@ function UretimGovdesi() {
<p className="text-xs text-slate-400"> <p className="text-xs text-slate-400">
Bu işlem yarım dakika kadar sürebilir, sayfayı kapatma. Bu işlem yarım dakika kadar sürebilir, sayfayı kapatma.
</p> </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> </section>
); );
} }
@@ -370,11 +383,7 @@ function RevizyonKutusu({
}} }}
className="cursor-pointer" 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)"} {pending ? "Yeniden kuruluyor…" : "Revize et (3 kredi)"}
</Button> </Button>
<span className="text-xs text-slate-500"> <span className="text-xs text-slate-500">

View File

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

View File

@@ -2,7 +2,7 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { ArrowRight, Loader2 } from "lucide-react"; import { ArrowRight } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { import {
@@ -123,12 +123,16 @@ export function HeroForm() {
<Input <Input
id="siralama" id="siralama"
inputMode="numeric" inputMode="numeric"
autoComplete="off"
placeholder="YKS başarı sıralaman (ör. 85.000)" placeholder="YKS başarı sıralaman (ör. 85.000)"
value={siralama} value={siralama}
onChange={(e) => siralamaDegisti(e.target.value)} onChange={(e) => siralamaDegisti(e.target.value)}
aria-invalid={hata ? true : undefined} aria-invalid={hata ? true : undefined}
aria-describedby={hata ? "siralama-hata" : 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 <Button
type="submit" type="submit"
@@ -136,11 +140,8 @@ export function HeroForm() {
disabled={yukleniyor} disabled={yukleniyor}
className="h-12 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600" className="h-12 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
> >
{yukleniyor ? ( {yukleniyor ? "Hazırlanıyor…" : "Listemi oluştur"}
<Loader2 className="size-4 animate-spin" aria-hidden /> <ArrowRight className="size-4" aria-hidden />
) : null}
Listemi oluştur
{yukleniyor ? null : <ArrowRight className="size-4" aria-hidden />}
</Button> </Button>
</form> </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"> <div className="mx-auto flex h-16 max-w-6xl items-center justify-between gap-3 px-4">
<Link <Link
href="/" 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"> <span>
<GraduationCap className="size-6" aria-hidden /> <span className="font-light text-3xl tracking-tighter">Kolay</span>
<span className="font-semibold text-3xl tracking-tighter">
Tercih
</span>
</span> </span>
KolayTercih
</Link> </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"> <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) => ( {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 { export interface LoaderProps {
variant?: variant?:
| "circular"
| "classic"
| "pulse" | "pulse"
| "pulse-dot" | "pulse-dot"
| "dots" | "dots"
@@ -22,78 +20,6 @@ export interface LoaderProps {
className?: string 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({ export function PulseLoader({
className, className,
size = "md", size = "md",
@@ -461,16 +387,12 @@ export function TextDotsLoader({
} }
function Loader({ function Loader({
variant = "circular", variant = "dots",
size = "md", size = "md",
text, text,
className, className,
}: LoaderProps) { }: LoaderProps) {
switch (variant) { switch (variant) {
case "circular":
return <CircularLoader size={size} className={className} />
case "classic":
return <ClassicLoader size={size} className={className} />
case "pulse": case "pulse":
return <PulseLoader size={size} className={className} /> return <PulseLoader size={size} className={className} />
case "pulse-dot": case "pulse-dot":
@@ -492,7 +414,7 @@ function Loader({
case "loading-dots": case "loading-dots":
return <TextDotsLoader text={text} size={size} className={className} /> return <TextDotsLoader text={text} size={size} className={className} />
default: 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 { useTheme } from "next-themes"
import { Toaster as Sonner, type ToasterProps } from "sonner" 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 Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme() const { theme = "system" } = useTheme()
@@ -25,7 +25,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
<OctagonXIcon className="size-4" /> <OctagonXIcon className="size-4" />
), ),
loading: ( loading: (
<Loader2Icon className="size-4 animate-spin" /> <span className="block size-2.5 animate-pulse rounded-full bg-current" />
), ),
}} }}
style={ 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.