refactor: pazarlama feature'ı — landing + meraklısına taşındı (Faz 5)

- landing/{sosyal-kanit,sosyal-kanit-govde,reveal} + hero-baslik +
  hero-focus-button + turkey-pixel-map features/pazarlama/components/ altına
- app/page.tsx'teki inline ComparisonCell landing-parcalari.tsx'e çıkarıldı
- /meraklisina içeriği MeraklisinaIcerik'e (okumayı pazarlama-queries'in
  meraklisinaDemoVerisi'nden alıyor); sayfa senkron + Suspense; demo.tsx →
  meraklisina-demo.tsx, huni-semasi.tsx feature'a taşındı
- AGENTS.md'deki huni-semasi ve anlatım metni yol referansları aynı commit'te
  güncellendi; SEMA içeriği değişmedi
- Davranış değişikliği yok

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
bilalgursen
2026-08-08 01:27:53 +03:00
parent 8b5aa8c8a6
commit baebc1fc80
15 changed files with 228 additions and 185 deletions

View File

@@ -1,33 +0,0 @@
"use client";
import { useTercihProfili } from "@/components/manuel-liste/profil-kapisi-store";
// Hero başlığı: sıralama girilmemişse tanıtım metni, kayıtlı profil varsa
// aynı şablonla (vurgulu italik orta parça) aksiyon metnine dönüşür.
// SSR/ilk boyamada profil bilinmediği için varsayılan metin render edilir;
// store yüklenince client'ta aksiyon haline geçer.
export function HeroBaslik() {
const profil = useTercihProfili();
return (
<h1 className="max-w-3xl font-heading text-5xl font-bold tracking-[0.015em] sm:text-6xl">
{profil ? (
<>
Sıralaman kayıtlı, <br className="hidden sm:block" />
<span className="font-bricolage italic tracking-tighter font-bold text-primary">
tercih listen
</span>{" "}
seni bekliyor
</>
) : (
<>
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>
);
}

View File

@@ -1,70 +0,0 @@
"use client";
import Link from "next/link";
import { ArrowRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTercihProfili } from "@/components/manuel-liste/profil-kapisi-store";
import { sonucHref } from "@/lib/sihirbaz";
export function HeroFocusButton() {
function handleClick() {
const input = document.getElementById("siralama");
if (input instanceof HTMLInputElement) {
input.focus({ preventScroll: true });
}
window.scrollTo({ top: 0, behavior: "smooth" });
}
return (
<Button
type="button"
size="lg"
onClick={handleClick}
className="mt-8 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
>
Ücretsiz dene
</Button>
);
}
/**
* Ana sayfanın kapanış CTA'sı: kayıtlı tercih profili olan adaya
* "sıralamanı gir" demek yerine doğrudan sonuç sayfasına götürür.
* Profil yoksa mevcut davranış — hero'daki sıralama formuna odaklanır.
*/
export function KapanisCta() {
const profil = useTercihProfili();
if (profil) {
return (
<>
<p className="mt-4 max-w-xl text-lg leading-relaxed text-white/90">
Sıralaman kayıtlı — yapay zekâ danışmanın veriyle konuşsun. Tercih
dönemi bitmeden yerini al.
</p>
<Button
asChild
size="lg"
className="mt-8 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
>
<Link href={sonucHref(profil)}>
Üniversitelere göz at
<ArrowRight className="size-4" aria-hidden />
</Link>
</Button>
</>
);
}
return (
<>
<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>
<HeroFocusButton />
</>
);
}

View File

@@ -1,57 +0,0 @@
"use client";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { cn } from "@/lib/utils";
/**
* Landing bölümlerinin görünüme girerken yumuşakça belirmesi.
* Motion yerine CSS transition + IntersectionObserver: SSR HTML'i görünür
* gelir (JS'siz/yavaş istemcide sayfa boş kalmaz), gizleme yalnızca
* `@media (scripting: enabled)` altında yaşar (bkz. globals.css .kt-reveal).
*/
export function Reveal({
children,
delay = 0,
y = 28,
className,
}: {
children: ReactNode;
delay?: number;
y?: number;
className?: string;
}) {
const ref = useRef<HTMLDivElement>(null);
const [gorunur, setGorunur] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el || typeof IntersectionObserver === "undefined") {
setGorunur(true);
return;
}
const io = new IntersectionObserver(
(entries) => {
if (entries.some((e) => e.isIntersecting)) {
setGorunur(true);
io.disconnect();
}
},
{ rootMargin: "0px 0px -80px 0px" },
);
io.observe(el);
return () => io.disconnect();
}, []);
return (
<div
ref={ref}
className={cn("kt-reveal", gorunur && "kt-reveal-in", className)}
style={{
transitionDelay: delay ? `${delay}s` : undefined,
...(y !== 28 ? { "--reveal-y": `${y}px` } : null),
} as React.CSSProperties}
>
{children}
</div>
);
}

View File

@@ -1,155 +0,0 @@
"use client";
import { useEffect, useRef, useState } from "react";
import {
CANLI_ESIGI,
LISTE_ESIGI,
esikGecti,
type SosyalKanitVerisi,
} from "@/lib/sosyal-kanit-sabitler";
/** Sayfa açık kaldıkça canlı sayıyı tazeleyen yoklama aralığı. */
const YOKLAMA_MS = 60_000;
/** Sayacın son değere ulaşma süresi. */
const SAYAC_MS = 900;
function sayi(n: number): string {
return n.toLocaleString("tr-TR");
}
/**
* Liste sayısı görünür olunca son değere kadar sayar. SSR HTML'i son değeri
* taşır (JS'siz istemcide sayı doğru görünür); animasyon yalnızca hareket
* isteyen istemcide, üstelik yalnızca bir kez çalışır.
*/
function useSayac(hedef: number, ref: React.RefObject<HTMLElement | null>) {
// null = animasyon çalışmıyor → güncel hedef doğrudan gösterilir. Böylece
// yoklama sayıyı tazelediğinde sayaç baştan oynamaz, sadece değer değişir.
const [animasyon, setAnimasyon] = useState<number | null>(null);
const hedefRef = useRef(hedef);
useEffect(() => {
hedefRef.current = hedef;
}, [hedef]);
useEffect(() => {
const el = ref.current;
if (!el) return;
if (
typeof IntersectionObserver === "undefined" ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches
) {
return;
}
let cerceve = 0;
const io = new IntersectionObserver(
(girisler) => {
if (!girisler.some((g) => g.isIntersecting)) return;
io.disconnect();
const son = hedefRef.current;
const basla = performance.now();
const adim = (simdi: number) => {
const t = Math.min(1, (simdi - basla) / SAYAC_MS);
if (t < 1) {
// easeOutCubic: hızlı başlar, son sayıya yumuşak oturur
setAnimasyon(Math.round(son * (1 - Math.pow(1 - t, 3))));
cerceve = requestAnimationFrame(adim);
} else {
setAnimasyon(null);
}
};
setAnimasyon(0);
cerceve = requestAnimationFrame(adim);
},
{ rootMargin: "0px 0px -60px 0px" },
);
io.observe(el);
return () => {
io.disconnect();
cancelAnimationFrame(cerceve);
};
}, [ref]);
return animasyon ?? hedef;
}
export function SosyalKanitGovde({ ilk }: { ilk: SosyalKanitVerisi }) {
const [veri, setVeri] = useState(ilk);
const ref = useRef<HTMLDivElement>(null);
const gosterilenListe = useSayac(veri.liste, ref);
useEffect(() => {
// Sekme arkadayken yoklama yok: kimse bakmıyorken sunucuyu meşgul etme.
let iptal = false;
let zamanlayici: ReturnType<typeof setTimeout> | undefined;
async function tazele() {
try {
const cevap = await fetch("/api/sosyal-kanit", { cache: "no-store" });
if (!cevap.ok) return;
const yeni = (await cevap.json()) as SosyalKanitVerisi;
if (!iptal) setVeri(yeni);
} catch {
// Sessiz: ekrandaki son sayı kalır.
}
}
function planla() {
clearTimeout(zamanlayici);
if (document.visibilityState !== "visible") return;
zamanlayici = setTimeout(() => {
void tazele().then(planla);
}, YOKLAMA_MS);
}
function gorunurlukDegisti() {
if (document.visibilityState === "visible") void tazele();
planla();
}
planla();
document.addEventListener("visibilitychange", gorunurlukDegisti);
return () => {
iptal = true;
clearTimeout(zamanlayici);
document.removeEventListener("visibilitychange", gorunurlukDegisti);
};
}, []);
if (!esikGecti(veri.liste, LISTE_ESIGI)) return null;
const canliVar = veri.canli !== null && esikGecti(veri.canli, CANLI_ESIGI);
return (
<div
ref={ref}
className="rounded-xl border border-slate-200/70 bg-slate-50/80 px-4 py-3.5"
>
<p className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
<span className="font-heading text-3xl font-bold tabular-nums leading-none text-slate-900">
{sayi(gosterilenListe)}
</span>
<span className="text-sm leading-snug text-slate-600">
öğrenci listesini KolayTercih ile oluşturdu
</span>
</p>
{canliVar ? (
<p className="mt-3 flex items-center gap-2 border-t border-slate-200/70 pt-3 text-sm text-slate-600">
<span className="relative flex size-2.5 shrink-0">
<span className="absolute inline-flex size-full rounded-full bg-emerald-400 opacity-70 motion-safe:animate-ping" />
<span className="relative inline-flex size-2.5 rounded-full bg-emerald-500" />
</span>
<span>
şu anda{" "}
<strong className="font-semibold tabular-nums text-slate-900">
{sayi(veri.canli as number)}
</strong>{" "}
kişi KolayTercih ile tercihini planlıyor
</span>
</p>
) : null}
</div>
);
}

View File

@@ -1,17 +0,0 @@
import { SosyalKanitGovde } from "@/components/landing/sosyal-kanit-govde";
import { LISTE_ESIGI, esikGecti } from "@/lib/sosyal-kanit-sabitler";
import { sosyalKanitVerisi } from "@/lib/sosyal-kanit";
/**
* Fiyat bölümünün üstündeki sosyal kanıt şeridi: kaç öğrenci listesini
* oluşturdu + şu anda kaç kişi sitede.
*
* Dinamik (istek anında okunur) olduğu için çağıran tarafta bir `Suspense`
* sınırının içinde durmalı. Fallback bilerek boş: eşik altındayken şerit hiç
* çizilmiyor, yer ayıran bir iskelet o durumda boşluk bırakırdı.
*/
export async function SosyalKanitBandi() {
const veri = await sosyalKanitVerisi();
if (!esikGecti(veri.liste, LISTE_ESIGI)) return null;
return <SosyalKanitGovde ilk={veri} />;
}

View File

@@ -1,166 +0,0 @@
"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 }}
/>
);
}