feat(tasarim): funnel CTA'ları arama kartı dilinde, 24'lük piksel liste kreatifi

- PikselCtaYuzeyi: koyu slate-950 yüzey, yazılıp silinen satırlar, tek
  turuncu CTA (arama penceresindeki kartın dili); ana sayfa kapanışı ve
  /sonuc'taki iki panelde köşeye kümelenen PixelField'in yerine
- PikselListe: 24 kare = 24 tercih; kendini kurar (5 hayal · 13 dengeli ·
  6 güvenli), imlece tepki verir, CTA'ya gelince tamamı dolar, dokunmada
  nabız; hareketi azalt'ta durağan; renk hep metinle
- Liste çekmecesi: ilerleme çubuğunun yerinde öğrencinin kendi listesi
  pikselde (her kare o tercihin riski); başlık yan yana dizildi, liste
  alanı 150 → 234 px (375 px ölçümü)
- Funnel metinleri src/lib/funnel-metinleri.ts'e taşındı

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bilalgursen
2026-09-24 02:12:45 +03:00
parent 96b1516e35
commit 71e6389131
9 changed files with 451 additions and 164 deletions

View File

@@ -9,6 +9,7 @@ import {
ListChecks,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { FUNNEL_METINLERI } from "@/lib/funnel-metinleri";
import { TypingAnimation } from "@/components/ui/typing-animation";
import { SIHIRBAZ_AC_EVENT, sonucHref } from "@/features/sihirbaz/sihirbaz-sabitler";
import {
@@ -16,39 +17,6 @@ import {
useTercihProfili,
} from "@/features/liste/hooks/use-tercih-profili";
const FUNNEL_METINLERI = [
"Sıralamana en uygun listeyi birlikte kuralım.",
"Geleceğin 24 satırlık bir plana sığar.",
"Gerçek YÖK Atlas verisiyle güvenle seç.",
"Her tercihinin bir nedeni olsun.",
"Riskleri biz hesaplayalım, kararı sen ver.",
"Sana uygun bölümler bir adım uzağında.",
"Dengeli bir liste, rahat bir tercih dönemi.",
"Tercihlerini şansa bırakma.",
"Doğru strateji, doğru yerleşme.",
"Hayalindeki bölüm için doğru plan.",
"Verilerle konuşan bir tercih listesi.",
"Üç adımda sana özel 24 tercih.",
"Tercih dönemini stressiz geçir.",
"Geçen yılın verisi, senin avantajın.",
"Güvenli ve iddialı tercihleri dengele.",
"Sıralaman ne söylüyor? Birlikte bakalım.",
"Her satırı senin için hesaplıyoruz.",
"Akıllı tercih, içi rahat bir bekleyiş.",
"Yerleşme riskini görerek seç.",
"Sana özel liste birkaç dakikada hazır.",
"İlgi alanların ve şehrin, tek listede.",
"Boş kalan tercih, kaçan fırsattır.",
"Uzman işi bir liste, senin onayınla.",
"Kararını veriye dayandır.",
"Doğru tercih, doğru bilgiyle başlar.",
"Listeni bugün kur, için rahat olsun.",
"Yerleşme riskin renklerle önünde.",
"En önemli 24 kararını birlikte verelim.",
"Senin sıralaman, sana özel plan.",
"İyi bir liste, iyi bir başlangıçtır.",
];
/** Fisher-Yates ile kopya üzerinde karıştırır; her açılışta farklı sıra. */
function karistir<T>(dizi: readonly T[]): T[] {
const kopya = [...dizi];

View File

@@ -0,0 +1,77 @@
"use client";
import { useState, type ReactNode } from "react";
import { cn } from "@/lib/utils";
import { TypingAnimation } from "@/components/ui/typing-animation";
import { FUNNEL_METINLERI } from "@/lib/funnel-metinleri";
import { PikselListe, PikselListeAciklama } from "@/components/piksel-liste";
/** Listeyi `ofset` kadar döndürür: aynı sayfadaki yüzeyler farklı satırla
* başlar. Rastgele değil, deterministik — sunucu ve istemci aynı çizer. */
function dondur<T>(dizi: readonly T[], ofset: number): T[] {
const n = ofset % dizi.length;
return [...dizi.slice(n), ...dizi.slice(0, n)];
}
/**
* Funnel CTA yüzeyi (DESIGN.md §10.8) — arama penceresindeki kartın dili:
* koyu slate-950 zemin, ince mavi kenar, yazılıp silinen kısa satırlar,
* turuncu CTA. Pikseller bir köşede kümelenmez; yüzeyin kreatifi 24'lük
* listenin kendisidir (PikselListe). Kartın içindeki bir düğmeye ya da
* linke imleç/odak gelince 24 kare birden dolar — "listen böyle hazır olur".
*/
export function PikselCtaYuzeyi({
children,
className,
metinOfseti = 0,
kayanMetin = true,
}: {
children: ReactNode;
className?: string;
/** Aynı sayfadaki iki yüzey aynı satırla başlamasın */
metinOfseti?: number;
kayanMetin?: boolean;
}) {
const [tamamla, setTamamla] = useState(false);
const [metinler] = useState(() => dondur(FUNNEL_METINLERI, metinOfseti));
const hedefMi = (t: EventTarget | null) =>
t instanceof Element && t.closest("a, button") != null;
return (
<section
onPointerOver={(e) => setTamamla(hedefMi(e.target))}
onPointerLeave={() => setTamamla(false)}
onFocusCapture={(e) => setTamamla(hedefMi(e.target))}
onBlurCapture={() => setTamamla(false)}
className={cn(
"relative isolate overflow-hidden rounded-3xl border border-blue-400/20 bg-slate-950 text-white shadow-xl shadow-slate-900/10",
className,
)}
>
<div className="grid items-center gap-8 px-5 py-10 sm:px-10 sm:py-12 lg:grid-cols-[minmax(0,1fr)_16rem] lg:gap-12">
<div className="flex min-w-0 flex-col items-center text-center lg:items-start lg:text-left">
{children}
{kayanMetin ? (
<TypingAnimation
as="p"
words={metinler}
loop
typeSpeed={38}
deleteSpeed={18}
pauseDelay={2600}
className="mt-5 min-h-[2.75em] font-heading text-base font-bold leading-snug tracking-[-0.02em] text-slate-300 sm:min-h-[1.4em]"
/>
) : null}
</div>
<div className="order-first flex flex-col items-center lg:order-none">
<PikselListe
tamamla={tamamla}
className="w-full max-w-[9rem] sm:max-w-[11rem] lg:max-w-[14rem]"
/>
<PikselListeAciklama className="mt-4 justify-center" />
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,191 @@
"use client";
import {
useCallback,
useEffect,
useRef,
useState,
type PointerEvent as ReactPointerEvent,
type ReactNode,
} from "react";
import { useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
import { DILIM_ETIKET } from "@/lib/risk";
/**
* 24'lük tercih listesinin piksel hâli — CTA yüzeylerinin interaktif
* kreatifi (Bilal'in isteği, 25 Eyl 2026: "pikseller bir köşede
* kümelenmesin, interaktif kreatif olsun"). Her kare bir tercih; hero'daki
* dot-matrix ölçüsüyle (hücre 10, nokta 7.4, rx 2).
*
* Davranış (DESIGN.md §10.8):
* - Örnek modda liste kendini kurar: kareler sırayla dolar (5 hayal ·
* 13 dengeli · 6 güvenli), bir süre durur, sakin söner, yeniden başlar.
* Yalnız ekrandayken çalışır.
* - İmleç ızgaranın üstündeyken yakındaki kareler hafifçe büyür.
* - `tamamla` true ise (ör. CTA düğmesine gelince) 24 kare birden dolar.
* - Dokunmada kareye basınca o kare ve komşuları kısa bir nabız atar.
* - Hareketi azalt: dolu ve durağan ızgara.
* - `gercek` verilirse döngü yok; kareler öğrencinin kendi listesidir
* (her kare o tercihin riskiyle).
* Renk tek başına anlam taşımaz: çağıran `PikselListeAciklama` ile metni
* yanına koyar.
*/
export type PikselKare = "hayal" | "dengeli" | "guvenli" | "belirsiz" | "bos";
const SUTUN = 6;
const SATIR = 4;
const TOPLAM = SUTUN * SATIR;
const CELL = 10;
const DOT = 7.4;
const INSET = (CELL - DOT) / 2;
const ORNEK: PikselKare[] = Array.from({ length: TOPLAM }, (_, i) =>
i < 5 ? "hayal" : i < 18 ? "dengeli" : "guvenli",
);
const RENK: Record<PikselKare, string> = {
hayal: "fill-red-500/85",
dengeli: "fill-amber-400/85",
guvenli: "fill-emerald-500/85",
belirsiz: "fill-white/55",
bos: "fill-white/15",
};
const ADIM_MS = 110;
const BEKLE_MS = 2600;
export function PikselListe({
tamamla = false,
gercek,
sutun = SUTUN,
className,
}: {
tamamla?: boolean;
/** Öğrencinin gerçek listesi (sırasıyla); verilirse örnek döngü kapanır */
gercek?: PikselKare[];
sutun?: number;
className?: string;
}) {
const hareketAzalt = useReducedMotion();
const satir = Math.ceil(TOPLAM / sutun);
const svgRef = useRef<SVGSVGElement>(null);
const [dolu, setDolu] = useState(hareketAzalt || gercek ? TOPLAM : 0);
const [gorunur, setGorunur] = useState(false);
const [imlec, setImlec] = useState<{ x: number; y: number } | null>(null);
const [nabiz, setNabiz] = useState<{ i: number; t: number } | null>(null);
// Yalnız ekrandayken çalış (IntersectionObserver, DESIGN.md §6)
useEffect(() => {
const el = svgRef.current;
if (!el) return;
const io = new IntersectionObserver(([e]) => setGorunur(e.isIntersecting), {
rootMargin: "0px 0px -10% 0px",
});
io.observe(el);
return () => io.disconnect();
}, []);
// Örnek döngü: 0 → 24 sırayla, bekle, sıfırla
useEffect(() => {
if (gercek || hareketAzalt || !gorunur || tamamla) return;
const id = window.setTimeout(
() => setDolu((d) => (d >= TOPLAM ? 0 : d + 1)),
dolu >= TOPLAM ? BEKLE_MS : dolu === 0 ? 500 : ADIM_MS,
);
return () => window.clearTimeout(id);
}, [dolu, gorunur, gercek, hareketAzalt, tamamla]);
const kareler: PikselKare[] = gercek
? Array.from({ length: TOPLAM }, (_, i) => gercek[i] ?? "bos")
: ORNEK.map((k, i) => (tamamla || i < dolu ? k : "bos"));
const imlecGuncelle = useCallback((e: ReactPointerEvent<SVGSVGElement>) => {
if (e.pointerType !== "mouse") return;
const svg = svgRef.current;
if (!svg) return;
const r = svg.getBoundingClientRect();
setImlec({
x: ((e.clientX - r.left) / r.width) * sutun * CELL,
y: ((e.clientY - r.top) / r.height) * satir * CELL,
});
}, [sutun, satir]);
return (
<svg
ref={svgRef}
viewBox={`0 0 ${sutun * CELL} ${satir * CELL}`}
aria-hidden="true"
focusable="false"
onPointerMove={imlecGuncelle}
onPointerLeave={() => setImlec(null)}
className={cn("h-auto w-full touch-manipulation", className)}
>
{kareler.map((k, i) => {
const x = (i % sutun) * CELL;
const y = Math.floor(i / sutun) * CELL;
const cx = x + CELL / 2;
const cy = y + CELL / 2;
// İmlece yakın kare büyür (en çok %18), uzaklaştıkça etkisi söner
let olcek = 1;
if (imlec && !hareketAzalt) {
const d = Math.hypot(imlec.x - cx, imlec.y - cy);
olcek = 1 + Math.max(0, 1 - d / 16) * 0.18;
}
const nabizda =
nabiz &&
Math.abs((nabiz.i % sutun) - (i % sutun)) <= 1 &&
Math.abs(Math.floor(nabiz.i / sutun) - Math.floor(i / sutun)) <= 1;
return (
<rect
key={nabizda ? `${i}-${nabiz.t}` : i}
x={x + INSET}
y={y + INSET}
width={DOT}
height={DOT}
rx={2}
onPointerDown={(e) => {
if (e.pointerType === "mouse" || hareketAzalt) return;
setNabiz({ i, t: Date.now() });
}}
className={cn(
RENK[k],
"origin-center transition-[fill,transform] duration-300 ease-[cubic-bezier(0.23,1,0.32,1)] [transform-box:fill-box] motion-reduce:transition-none",
nabizda && "kt-piksel-nabiz",
)}
style={{ transform: olcek !== 1 ? `scale(${olcek})` : undefined }}
/>
);
})}
</svg>
);
}
/** Renklerin metin karşılığı (§2.2: renk tek başına anlam taşımaz). */
export function PikselListeAciklama({
className,
sayilar = { hayal: 5, dengeli: 13, guvenli: 6 },
}: {
className?: string;
sayilar?: { hayal: number; dengeli: number; guvenli: number };
}) {
const oge = (renk: string, sayi: number, etiket: string): ReactNode => (
<span className="inline-flex items-center gap-1.5">
<span className={cn("size-2 shrink-0 rounded-[2px]", renk)} aria-hidden />
{sayi} {etiket.toLocaleLowerCase("tr-TR")}
</span>
);
return (
<p
className={cn(
"flex flex-wrap items-center gap-x-3 gap-y-1 text-sm font-medium text-slate-300",
className,
)}
>
{oge("bg-red-500", sayilar.hayal, DILIM_ETIKET.hayal)}
{oge("bg-amber-400", sayilar.dengeli, DILIM_ETIKET.dengeli)}
{oge("bg-emerald-500", sayilar.guvenli, DILIM_ETIKET.garanti)}
</p>
);
}