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:
@@ -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];
|
||||
|
||||
77
src/components/piksel-cta-yuzeyi.tsx
Normal file
77
src/components/piksel-cta-yuzeyi.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
191
src/components/piksel-liste.tsx
Normal file
191
src/components/piksel-liste.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user