diff --git a/docs/tasarim/DESIGN.md b/docs/tasarim/DESIGN.md index dc0a159..e4d1585 100644 --- a/docs/tasarim/DESIGN.md +++ b/docs/tasarim/DESIGN.md @@ -589,3 +589,21 @@ Araştırma ve kaynaklar: `docs/tasarim/2026-09-25-samimi-arayuz-arastirmasi.md` Seri (streak), kayıp korkusu, puan/XP, rozet koleksiyonu, liderlik tablosu, maskot, sahte aciliyet/kıtlık, utandırarak onay. Tercih günlük alışkanlık değil, tek seferlik kaygılı bir karar. + +### 10.8 Funnel CTA yüzeyi ve 24'lük piksel liste +Bilal'in isteği (25 Eyl 2026): "CTA alanlarını arama içindeki alandan ilham, piksel detaylı yap; +pikseller bir kümede kümeleşmesin, interaktif kreatif olsun." +- **Yüzey — `PikselCtaYuzeyi`, `src/components/piksel-cta-yuzeyi.tsx`:** arama penceresindeki + kartın dili: `rounded-3xl border border-blue-400/20 bg-slate-950 text-white`, altta yazılıp + silinen satırlar (`FUNNEL_METINLERI`, `src/lib/funnel-metinleri.ts`), tek turuncu CTA. + Kullanıldığı yerler: ana sayfa kapanış CTA'sı, `/sonuc`'taki iki sihirbaz paneli. Eski + `bg-primary` + köşeye kümelenmiş `PixelField` bu yüzeylerde kullanılmaz. +- **Kreatif — `PikselListe`, `src/components/piksel-liste.tsx`:** 24 kare = 24 tercih, hero'nun + nokta ölçüsüyle (hücre 10, nokta 7.4, rx 2). Örnek modda liste kendini kurar (5 hayal · 13 + dengeli · 6 güvenli, 110 ms arayla), yalnız ekrandayken. İmleç yakın kareleri büyütür; kart + içindeki düğmeye imleç/odak gelince 24 kare birden dolar; dokunmada basılan kare ve komşuları + nabız atar (`kt-piksel-nabiz`). Hareketi azalt: dolu, durağan. Renk her zaman + `PikselListeAciklama` metniyle. +- **Gerçek liste:** çekmecede ilerleme çubuğunun yerinde öğrencinin kendi listesi (`gercek`, + 12×2): her kare o tercihin riskiyle, riski bilinmeyen beyaz; metin karşılığı yanındaki risk + dağılımında. diff --git a/src/app/globals.css b/src/app/globals.css index 6cee40b..2ba1976 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -303,3 +303,21 @@ mask-image: none; } } + +/* 24'lük piksel listede dokunma nabzı (bkz. components/piksel-liste.tsx): + basılan kare ve komşuları kısa bir büyüyüp söner. Tek seferlik, 420 ms. */ +@keyframes kt-piksel-nabiz { + 0% { + transform: scale(1); + } + 35% { + transform: scale(1.3); + } + 100% { + transform: scale(1); + } +} + +.kt-piksel-nabiz { + animation: kt-piksel-nabiz 0.42s cubic-bezier(0.23, 1, 0.32, 1); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 882eb68..0439e78 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -25,9 +25,9 @@ import { TurkeyPixelMap } from "@/features/pazarlama/components/turkey-pixel-map import { PixelCorner, PixelDivider, - PixelField, SectionEyebrow, } from "@/components/pixel-decor"; +import { PikselCtaYuzeyi } from "@/components/piksel-cta-yuzeyi"; import { MAX_REVIZYON, URUNLER } from "@/lib/credits"; import { VERI_YIL_SAYISI } from "@/lib/veri-kapsami"; import { @@ -681,28 +681,14 @@ export default function Home() {
-
- {/* 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 */} -
- - - -
-
- -

- Tercih listen, pişmanlık listesi olmasın -

- -
-
+ {/* Funnel CTA yüzeyi (DESIGN.md §10.8): köşede kümelenen + piksel zemini yerine 24'lük listenin interaktif kreatifi */} + +

+ Tercih listen, pişmanlık listesi olmasın +

+ +
diff --git a/src/components/arama-funnel-karti.tsx b/src/components/arama-funnel-karti.tsx index 7fac0fe..2ca1dae 100644 --- a/src/components/arama-funnel-karti.tsx +++ b/src/components/arama-funnel-karti.tsx @@ -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(dizi: readonly T[]): T[] { const kopya = [...dizi]; diff --git a/src/components/piksel-cta-yuzeyi.tsx b/src/components/piksel-cta-yuzeyi.tsx new file mode 100644 index 0000000..c9d75ad --- /dev/null +++ b/src/components/piksel-cta-yuzeyi.tsx @@ -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(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 ( +
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, + )} + > +
+
+ {children} + {kayanMetin ? ( + + ) : null} +
+
+ + +
+
+
+ ); +} diff --git a/src/components/piksel-liste.tsx b/src/components/piksel-liste.tsx new file mode 100644 index 0000000..33ab0a9 --- /dev/null +++ b/src/components/piksel-liste.tsx @@ -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 = { + 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(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) => { + 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 ( + + ); +} + +/** 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 => ( + + + {sayi} {etiket.toLocaleLowerCase("tr-TR")} + + ); + return ( +

+ {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)} +

+ ); +} diff --git a/src/features/liste/components/liste-cekmecesi.tsx b/src/features/liste/components/liste-cekmecesi.tsx index dd066b1..c2d6ea5 100644 --- a/src/features/liste/components/liste-cekmecesi.tsx +++ b/src/features/liste/components/liste-cekmecesi.tsx @@ -15,7 +15,7 @@ import { useState } from "react"; import { toast } from "sonner"; import Link from "next/link"; import { useRouter } from "next/navigation"; -import { PixelField } from "@/components/pixel-decor"; +import { PikselListe } from "@/components/piksel-liste"; import { UniLogo } from "@/components/uni-logo"; import { RiskEtiketi } from "@/components/risk-etiketi"; import { Button } from "@/components/ui/button"; @@ -122,7 +122,6 @@ export function ListeCekmecesi() { // drawer içinde erken ölçüm satırları kaydırıyordu const [oturdu, setOturdu] = useState(false); - const doluluk = Math.round((liste.length / MANUEL_LISTE_MAX) * 100); // Risk dağılımı: riskliden güvenliye, sıfır olanlar yazılmaz const riskSayilari = (["riskli", "az-riskli", "guvenli"] as const) .map((r) => [r, liste.filter((t) => t.risk === r).length] as const) @@ -185,55 +184,66 @@ export function ListeCekmecesi() { Kendi Listem - {/* Durum ekran okuyucuya da duyurulur (WCAG 4.1.3) */} -

- {liste.length === MANUEL_LISTE_MAX ? ( - // Nadir, hak edilmiş an: küçük giriş (Emil: scale 0.95 + opaklık, - // güçlü ease-out, <300 ms). Hareketi azalt'ta yalnız opaklık. - - - Listen tamam · {liste.length} / {MANUEL_LISTE_MAX} - - ) : ( - <> - {liste.length} / {MANUEL_LISTE_MAX} tercih - - )} -

-
- -
- {/* Listenin dengesi metinle (renk tek başına değil, §2.2) */} - {riskSayilari.length > 0 ? ( -

- {riskSayilari.map(([seviye, adet]) => ( - - - - {adet} + {/* Durum satırı: solda listenin piksel hâli (ilerleme çubuğunun + yerine), sağda sayı ve risk dağılımı — dikine uzamasın diye + yan yana (DESIGN.md §5.3). Durum ekran okuyucuya duyurulur. */} +

+
+ + t.risk === "riskli" + ? "hayal" + : t.risk === "az-riskli" + ? "dengeli" + : t.risk === "guvenli" + ? "guvenli" + : "belirsiz", + )} + sutun={12} + /> +
+
+

+ {liste.length === MANUEL_LISTE_MAX ? ( + // Nadir, hak edilmiş an: küçük giriş (Emil: scale 0.95 + opaklık, + // güçlü ease-out, <300 ms). Hareketi azalt'ta yalnız opaklık. + + + Listen tamam · {liste.length} / {MANUEL_LISTE_MAX} - - ))} -

- ) : null} + ) : ( + <> + {liste.length} / {MANUEL_LISTE_MAX} tercih + + )} +

+ {riskSayilari.length > 0 ? ( +

+ {riskSayilari.map(([seviye, adet]) => ( + + + + {adet} + + + ))} +

+ ) : null} +
+
{/* Denge ipucu: 24'lük iskeletin mantığı (alt sıralarda güvenli programlar). Yalnız riskleri bilinen ve en az 6 tercihli listede; vaat değil, gerekçe. */} {guvenliYok ? (

- Güvenli program yok; alta birkaç tane eklemek açıkta kalma riskini - azaltır. + Güvenli yok; alta birkaç tane eklemek riski azaltır.

) : null} @@ -325,18 +335,14 @@ export function ListeCekmecesi() { {/* Telefonda alt alta (başlık tek satır, buton tam genişlik): yan yana dizince başlık üç satıra kırılıp listenin yerini yiyordu (24 Eyl 2026, 375 px ölçümü). */} -
-
- -
-
+ {/* Funnel yüzeyi (DESIGN.md §10.8): arama penceresindeki kartın + koyu zemini; listenin piksel hâli başlıkta. */} +
+

{riskEksik ? "Risk analizi sıralamanı bekliyor" : "Risk analizini biz yaptık"}

-

+

{riskEksik ? "Riskleri gösterelim; istersen 24'lük listeni de kur (3 kredi)." : "Her seçimin yerleşme riski hazır — son kararı sen ver."} @@ -345,7 +351,7 @@ export function ListeCekmecesi() { {riskEksik ? ( ) : girisBekliyor ? ( -

-
- -
-

- Seçimlerin hazır — Yapay Zeka listen bir adım uzakta -

-

- Yukarıdaki tablo her zaman ücretsiz. 5 deneme kredinle 24 - tercihlik Yapay Zeka listeni de kurabilirsin: ücretsiz giriş yap, - seçimlerin kayıtlı — listen otomatik kurulsun (üretim 3 - kredi, kalan 2 kredinle danışmana soru sorarsın). -

- -

- Kredi kartı gerekmez · Google ya da e-posta ile 30 saniyede -

-
+ + olay("giris_cta_tiklandi", { kaynak: "hazir_panel" }) + } + > + 5 deneme kredinle Yapay Zeka listeni kur — giriş yap + + + +

+ Kredi kartı gerekmez · Google ya da e-posta ile 30 saniyede +

+ +
) : ( -
-
- -
-

+ +

24 tercihi tek tek elemek yerine, yapay zekâ kursun

-

+

Tablodan elle eklemek her zaman serbest. Ama üç adımda ilgi - alanlarını ve şehirlerini söylersen, yapay zekâ hayal, dengeli - ve güvenli dilimlerine dağıtılmış 24 tercihi — her satırı - yerleşme riski ve gerekçesiyle — senin yerine dizer. + alanlarını ve şehirlerini söylersen, yapay zekâ hayal, dengeli ve + güvenli dilimlerine dağıtılmış 24 tercihi — her satırı yerleşme + riski ve gerekçesiyle — senin yerine dizer.

-

+ )} {monteEdildi ? ( diff --git a/src/lib/funnel-metinleri.ts b/src/lib/funnel-metinleri.ts new file mode 100644 index 0000000..d296c39 --- /dev/null +++ b/src/lib/funnel-metinleri.ts @@ -0,0 +1,36 @@ +/** + * CTA yüzeylerinde yazılıp silinen kısa satırlar (arama penceresindeki + * kart ve PikselCtaYuzeyi). "use client" yok: sunucu da okuyabilsin (§9.3). + */ +export const FUNNEL_METINLERI: readonly string[] = [ + "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.", +];