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:
33
src/features/pazarlama/components/hero-baslik.tsx
Normal file
33
src/features/pazarlama/components/hero-baslik.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
70
src/features/pazarlama/components/hero-focus-button.tsx
Normal file
70
src/features/pazarlama/components/hero-focus-button.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
"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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
58
src/features/pazarlama/components/huni-semasi.tsx
Normal file
58
src/features/pazarlama/components/huni-semasi.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
// Sihirbaz hunisinin statik anlatımı: interaktif demo yerine akışın tamamını
|
||||
// tek bakışta gösteren bir mermaid şeması. Çizim, ürün genelindeki ortak
|
||||
// "el çizimi" bileşeniyle yapılır (bkz. features/rehber/components/el-cizimi-sema.tsx).
|
||||
|
||||
import { ElCizimiSema } from "@/features/rehber/components/el-cizimi-sema";
|
||||
|
||||
// Renkler ürünün risk dilinden (kırmızı/sarı/yeşil) ve slate paletinden;
|
||||
// renk tek başına anlam taşımaz, her düğüm metinle etiketlidir.
|
||||
const SEMA = `---
|
||||
config:
|
||||
look: handDrawn
|
||||
---
|
||||
flowchart TD
|
||||
T("Sıralamanla açılan tüm programlar") --> A("1. adım · Alan seçimi<br/>yalnızca ulaşabildiğin alanlar listelenir")
|
||||
A --> B("2. adım · İl seçimi<br/>iller, seçtiğin alanlardaki program sayısına göre yeniden sayılır")
|
||||
B --> C("3. adım · Devlet / Vakıf<br/>sayılar hem alanı hem ili hesaba katar")
|
||||
C --> H("Aday havuzu<br/>elde kalan gerçek programlar")
|
||||
H --> S{"Havuzda 24'ten az program mı kaldı?"}
|
||||
S -- "evet" --> G("Filtreler adım adım gevşetilir<br/>önce il, sonra alan — listede açıkça belirtilir")
|
||||
G --> H
|
||||
H --> U("Havuz yine de dar kalırsa<br/>liste eldeki programlarla kurulur, dağılım havuza uyarlanır ve sana açıkça söylenir")
|
||||
S -- "hayır" --> Y("Yapay zekâ havuzdan en fazla 24 tercih seçer<br/>havuzda olmayan bölümü yazamaz, her satıra gerekçe yazar")
|
||||
U --> Y
|
||||
Y --> R("Risk notu ve trend özeti<br/>gerçek sıralama geçmişinden hesaplanır, yapay zekâ yorumu değildir")
|
||||
R --> L("En fazla 24 tercihlik liste")
|
||||
L --> L1("üstte hayal — hedef 5, havuz elverdiğince")
|
||||
L --> L2("ortada dengeli — hedef 13, havuz elverdiğince")
|
||||
L --> L3("sonda garanti — hedef 6, havuz elverdiğince")
|
||||
|
||||
classDef havuz fill:#eff6ff,stroke:#3b82f6,color:#1e3a8a
|
||||
classDef uyari fill:#fffbeb,stroke:#f59e0b,color:#92400e
|
||||
classDef hayal fill:#fee2e2,stroke:#ef4444,color:#991b1b
|
||||
classDef dengeli fill:#fef3c7,stroke:#f59e0b,color:#92400e
|
||||
classDef garanti fill:#d1fae5,stroke:#10b981,color:#065f46
|
||||
class H,Y havuz
|
||||
class G,U uyari
|
||||
class L1 hayal
|
||||
class L2 dengeli
|
||||
class L3 garanti`;
|
||||
|
||||
export function HuniSemasi() {
|
||||
return (
|
||||
<ElCizimiSema
|
||||
tanim={SEMA}
|
||||
ariaLabel="Sihirbaz akış şeması: sıralamanla açılan programlar alan, il ve devlet/vakıf adımlarıyla aday havuzuna daraltılır; havuz 24'ün altına düşerse filtreler gevşetilir, yine de dar kalırsa liste eldeki programlarla kurulur ve dağılım havuza uyarlanır; yapay zekâ havuzdan üstte hayal, ortada dengeli, sonda garanti olacak şekilde en fazla 24 tercih seçip her satıra gerekçe yazar; risk notu ve trend özeti gerçek sıralama geçmişinden hesaplanır."
|
||||
figcaption={
|
||||
<>
|
||||
Her adım havuzu biraz daha daraltır; yapay zekâ listeyi bu havuzun{" "}
|
||||
<span className="font-medium text-slate-700">
|
||||
dışına asla çıkmadan
|
||||
</span>{" "}
|
||||
kurar — havuz dar kalırsa liste eldeki programlarla kurulur ve bunu
|
||||
listende açıkça görürsün.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
14
src/features/pazarlama/components/landing-parcalari.tsx
Normal file
14
src/features/pazarlama/components/landing-parcalari.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { CheckCircle2, X } from "lucide-react";
|
||||
|
||||
// Ana sayfadaki karşılaştırma tablosunun hücresi: metin değeri düz yazılır,
|
||||
// boolean değer Var/Yok ikonuna çevrilir.
|
||||
export function ComparisonCell({ value }: { value: boolean | string }) {
|
||||
if (typeof value === "string") {
|
||||
return <span className="text-sm font-medium">{value}</span>;
|
||||
}
|
||||
return value ? (
|
||||
<CheckCircle2 className="mx-auto size-5 text-primary" aria-label="Var" />
|
||||
) : (
|
||||
<X className="mx-auto size-5 text-muted-foreground/50" aria-label="Yok" />
|
||||
);
|
||||
}
|
||||
251
src/features/pazarlama/components/meraklisina-demo.tsx
Normal file
251
src/features/pazarlama/components/meraklisina-demo.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
"use client";
|
||||
|
||||
// /meraklisina'nın canlı anlatım katmanı: kullanıcının kayıtlı tercih
|
||||
// profilindeki sıralama + puan türü okunur, dilim şeridi o sıralamanın GERÇEK
|
||||
// sayılarıyla güncellenir. Profil yoksa sayfa SSR'daki örnek sıralamayla kalır.
|
||||
// Provider sayfadaki metin bölümlerini (RSC children) sarar; görseller
|
||||
// context'ten okur. (Sihirbaz hunisi statik bir şemayla anlatılır — bkz.
|
||||
// huni-semasi.tsx.)
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import Link from "next/link";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { useTercihProfili } from "@/components/manuel-liste/profil-kapisi-store";
|
||||
import { PUAN_TURU_ETIKET, sonucHref } from "@/lib/sihirbaz";
|
||||
import type { DilimKey, SihirbazFacetleri } from "@/types/yokatlas";
|
||||
|
||||
export type DemoVeri = {
|
||||
facetler: SihirbazFacetleri;
|
||||
dilimToplam: Record<DilimKey, number>;
|
||||
};
|
||||
|
||||
type DemoDurum = {
|
||||
sira: number;
|
||||
tur: string;
|
||||
veri: DemoVeri;
|
||||
yukleniyor: boolean;
|
||||
profilVar: boolean;
|
||||
};
|
||||
|
||||
const DemoContext = createContext<DemoDurum | null>(null);
|
||||
|
||||
function useDemo(): DemoDurum {
|
||||
const ctx = useContext(DemoContext);
|
||||
if (!ctx) throw new Error("Demo bileşenleri SiralamaDemo içinde kullanılmalı");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
async function facetGetir(
|
||||
sira: number,
|
||||
tur: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<{ facetler: SihirbazFacetleri; dilimToplam?: Record<DilimKey, number> } | null> {
|
||||
const params = new URLSearchParams({ sira: String(sira), tur, dilimler: "1" });
|
||||
try {
|
||||
const res = await fetch(`/api/facetler?${params}`, { signal });
|
||||
if (!res.ok) return null;
|
||||
return (await res.json()) as {
|
||||
facetler: SihirbazFacetleri;
|
||||
dilimToplam?: Record<DilimKey, number>;
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function SiralamaDemo({
|
||||
baslangicSira,
|
||||
baslangicVeri,
|
||||
children,
|
||||
}: {
|
||||
baslangicSira: number;
|
||||
baslangicVeri: DemoVeri;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const profil = useTercihProfili();
|
||||
const sira = profil?.sira ?? baslangicSira;
|
||||
const tur = profil?.tur ?? "say";
|
||||
const [veri, setVeri] = useState(baslangicVeri);
|
||||
const [yukleniyor, setYukleniyor] = useState(false);
|
||||
|
||||
// Profil yüklendi ve SSR örneğinden farklı → ana veriyi tazele
|
||||
useEffect(() => {
|
||||
if (sira === baslangicSira && tur === "say") return;
|
||||
const ctrl = new AbortController();
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- fetch başlangıç işareti
|
||||
setYukleniyor(true);
|
||||
facetGetir(sira, tur, ctrl.signal)
|
||||
.then((cevap) => {
|
||||
if (!cevap?.dilimToplam) return;
|
||||
setVeri({ facetler: cevap.facetler, dilimToplam: cevap.dilimToplam });
|
||||
})
|
||||
.finally(() => setYukleniyor(false));
|
||||
return () => ctrl.abort();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sira, tur]);
|
||||
|
||||
const durum: DemoDurum = {
|
||||
sira,
|
||||
tur,
|
||||
veri,
|
||||
yukleniyor,
|
||||
profilVar: profil !== null,
|
||||
};
|
||||
|
||||
return <DemoContext.Provider value={durum}>{children}</DemoContext.Provider>;
|
||||
}
|
||||
|
||||
/** Görsellerin hangi sıralamayla hesaplandığını söyleyen bilgi kartı. */
|
||||
export function DemoKumanda() {
|
||||
const { sira, tur, yukleniyor, profilVar } = useDemo();
|
||||
const turEtiket = PUAN_TURU_ETIKET[tur] ?? tur;
|
||||
return (
|
||||
<div className="rounded-2xl border border-primary/20 bg-primary/5 p-5">
|
||||
<p className="font-heading text-sm font-bold text-slate-900">
|
||||
{profilVar
|
||||
? "Görseller senin sıralamana göre hesaplandı"
|
||||
: "Görseller örnek bir sıralamayla gösteriliyor"}
|
||||
</p>
|
||||
<p
|
||||
className={`mt-2 text-sm text-slate-600 ${yukleniyor ? "animate-pulse" : ""}`}
|
||||
aria-live="polite"
|
||||
>
|
||||
{yukleniyor ? (
|
||||
"Hesaplanıyor…"
|
||||
) : (
|
||||
<>
|
||||
<span className="font-semibold tabular-nums text-slate-900">
|
||||
{sira.toLocaleString("tr-TR")}.
|
||||
</span>{" "}
|
||||
sıra · {turEtiket} — aşağıdaki tüm sayılar bu aday için gerçek YÖK
|
||||
Atlas verisidir.
|
||||
{!profilVar &&
|
||||
" Ana sayfadan kendi sıralamanı girersen bu sayfa sana göre güncellenir."}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Sıralama ekseni: adayın sırasına göre dilim sınırları ve gerçek adetler. */
|
||||
export function DilimSeridiCanli() {
|
||||
const { sira, veri, yukleniyor } = useDemo();
|
||||
const t = veri.dilimToplam;
|
||||
const dengeliUst = Math.round(sira * 1.4);
|
||||
const fmt = (n: number) => n.toLocaleString("tr-TR");
|
||||
|
||||
return (
|
||||
<figure className={`mt-6 ${yukleniyor ? "animate-pulse opacity-60" : ""}`}>
|
||||
<svg
|
||||
viewBox="0 0 640 168"
|
||||
role="img"
|
||||
aria-label={`Sıralama ekseni: ${fmt(sira)}. sıradaki aday için Hayal diliminde ${fmt(t.hayal)}, Dengeli diliminde ${fmt(t.dengeli)}, Garanti diliminde ${fmt(t.garanti)} program var.`}
|
||||
className="w-full"
|
||||
>
|
||||
{/* eksen */}
|
||||
<line
|
||||
x1="20"
|
||||
y1="86"
|
||||
x2="620"
|
||||
y2="86"
|
||||
stroke="currentColor"
|
||||
className="text-slate-300"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<text x="20" y="150" className="fill-slate-400 text-[11px]">
|
||||
1. sıra (en iyi)
|
||||
</text>
|
||||
<text
|
||||
x="620"
|
||||
y="150"
|
||||
textAnchor="end"
|
||||
className="fill-slate-400 text-[11px]"
|
||||
>
|
||||
taban sıralaması büyüdükçe →
|
||||
</text>
|
||||
|
||||
{/* hayal */}
|
||||
<rect x="20" y="70" width="150" height="32" rx="4" className="fill-red-100" />
|
||||
<text x="95" y="40" textAnchor="middle" className="fill-red-600 text-[12px] font-semibold">
|
||||
Hayal
|
||||
</text>
|
||||
<text x="95" y="56" textAnchor="middle" className="fill-slate-600 text-[11px] font-medium tabular-nums">
|
||||
{fmt(t.hayal)} program
|
||||
</text>
|
||||
<text x="95" y="123" textAnchor="middle" className="fill-slate-500 text-[10px]">
|
||||
tabanı senden iyi
|
||||
</text>
|
||||
|
||||
{/* aday işareti */}
|
||||
<line x1="172" y1="24" x2="172" y2="112" stroke="currentColor" className="text-slate-900" strokeWidth="2" strokeDasharray="4 3" />
|
||||
<text x="172" y="16" textAnchor="middle" className="fill-slate-900 text-[12px] font-bold">
|
||||
SEN · {fmt(sira)}.
|
||||
</text>
|
||||
|
||||
{/* dengeli */}
|
||||
<rect x="174" y="70" width="120" height="32" rx="4" className="fill-amber-100" />
|
||||
<text x="234" y="40" textAnchor="middle" className="fill-amber-600 text-[12px] font-semibold">
|
||||
Dengeli
|
||||
</text>
|
||||
<text x="234" y="56" textAnchor="middle" className="fill-slate-600 text-[11px] font-medium tabular-nums">
|
||||
{fmt(t.dengeli)} program
|
||||
</text>
|
||||
<text x="234" y="123" textAnchor="middle" className="fill-slate-500 text-[10px]">
|
||||
{fmt(sira)}. – {fmt(dengeliUst)}. arası taban
|
||||
</text>
|
||||
|
||||
{/* garanti */}
|
||||
<rect x="296" y="70" width="324" height="32" rx="4" className="fill-emerald-100" />
|
||||
<text x="458" y="40" textAnchor="middle" className="fill-emerald-700 text-[12px] font-semibold">
|
||||
Garanti
|
||||
</text>
|
||||
<text x="458" y="56" textAnchor="middle" className="fill-slate-600 text-[11px] font-medium tabular-nums">
|
||||
{fmt(t.garanti)} program
|
||||
</text>
|
||||
<text x="458" y="123" textAnchor="middle" className="fill-slate-500 text-[10px]">
|
||||
{fmt(dengeliUst + 1)}. sonrası — sonu yok, sayfaladıkça uzar
|
||||
</text>
|
||||
</svg>
|
||||
<figcaption className="mt-2 text-xs text-slate-500">
|
||||
Liste her dilimde{" "}
|
||||
<span className="font-medium text-slate-700">
|
||||
sana en yakın tabandan uzağa doğru
|
||||
</span>{" "}
|
||||
sıralanır; “Daha fazla göster” dedikçe uzaktakiler gelir.
|
||||
</figcaption>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sayfanın kapanış kartı: profili olmayan ziyaretçiyi ana sayfadaki sıralama
|
||||
* formuna, sıralamasını zaten girmiş adayı ise doğrudan sonuç sayfasına
|
||||
* gönderir — "sıralamanı gir" CTA'sı profil varken gösterilmez.
|
||||
*/
|
||||
export function DemoKapanisCta() {
|
||||
const profil = useTercihProfili();
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-slate-200 bg-white p-6 text-center">
|
||||
<p className="font-heading text-base font-bold text-slate-900">
|
||||
{profil
|
||||
? "Kuralları öğrendin — sayılar zaten senin sıralamanla hesaplandı."
|
||||
: "Kuralları öğrendin — şimdi kendi sıralamanla dene."}
|
||||
</p>
|
||||
<Link
|
||||
href={profil ? sonucHref(profil) : "/"}
|
||||
className="mt-3 inline-flex items-center gap-1.5 rounded-full bg-orange-500 px-5 py-2.5 text-sm font-medium text-white transition-colors duration-200 hover:bg-orange-600"
|
||||
>
|
||||
{profil ? "Sıralamana uygun programları gör" : "Sıralamanı gir"}
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
158
src/features/pazarlama/components/meraklisina-icerik.tsx
Normal file
158
src/features/pazarlama/components/meraklisina-icerik.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import { Parallax } from "@/components/parallax";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { meraklisinaDemoVerisi } from "../pazarlama-queries";
|
||||
import {
|
||||
DemoKapanisCta,
|
||||
DemoKumanda,
|
||||
DilimSeridiCanli,
|
||||
SiralamaDemo,
|
||||
} from "./meraklisina-demo";
|
||||
import { HuniSemasi } from "./huni-semasi";
|
||||
|
||||
/**
|
||||
* Algoritmanın öğrenci diline çevrilmiş anlatımı. Teknik detay (SQL, çarpan
|
||||
* mutfağı) bilerek verilmez; amaç güven: veri gerçek, kurallar şeffaf.
|
||||
* Dilim şeridi canlıdır: kullanıcının kayıtlı tercih profilindeki sıralama
|
||||
* okunur, şerit o sıralamanın gerçek sayılarıyla güncellenir (bkz.
|
||||
* meraklisina-demo.tsx); profil yoksa örnek sıralama gösterilir. Sihirbaz
|
||||
* hunisi ise statik bir mermaid şemasıyla anlatılır (bkz. huni-semasi.tsx).
|
||||
* Diyagramlar ürünün risk renk dilini kullanır (yeşil/sarı/kırmızı) ve her
|
||||
* bölge metinle de etiketlenir — renk tek başına anlam taşımaz.
|
||||
*/
|
||||
|
||||
// Profili olmayan ziyaretçi bu örnek sıralamayı görür; SSR'da gerçek veriyle
|
||||
// render edilir ki profil olmadan da sayfa anlamlı olsun.
|
||||
const VARSAYILAN_SIRA = 100_000;
|
||||
|
||||
export function MeraklisinaIcerik() {
|
||||
const baslangicVeri = meraklisinaDemoVerisi(VARSAYILAN_SIRA);
|
||||
|
||||
return (
|
||||
<SiralamaDemo
|
||||
baslangicSira={VARSAYILAN_SIRA}
|
||||
baslangicVeri={baslangicVeri}
|
||||
>
|
||||
<Parallax
|
||||
fromStart
|
||||
strength={4}
|
||||
className="mt-8 space-y-10 text-sm leading-7 text-slate-700"
|
||||
>
|
||||
<section>
|
||||
<h2 className="font-heading text-lg font-bold text-slate-900">
|
||||
1. Her şey taban sıralamasıyla başlar
|
||||
</h2>
|
||||
<p className="mt-2">
|
||||
Bir bölümün <strong>taban sıralaması</strong>, geçen yıl o bölüme
|
||||
yerleşen <em>son</em>{" "}
|
||||
öğrencinin Türkiye sıralamasıdır. Senin sıralaman bu sayıdan
|
||||
küçükse (yani daha iyiysen), geçen yılın koşullarında o bölüme
|
||||
yerleşebilirdin demektir. Sistemde her programın en güncel taban
|
||||
sıralaması kayıtlıdır; bir bölümün 2025 verisi henüz oluşmamışsa
|
||||
bir önceki yılınki kullanılır.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<DemoKumanda />
|
||||
|
||||
<section>
|
||||
<h2 className="font-heading text-lg font-bold text-slate-900">
|
||||
2. Programlar üç dilime ayrılır
|
||||
</h2>
|
||||
<p className="mt-2">
|
||||
Sıralamanı girdiğinde tüm programlar senin konumuna göre üç kümeye
|
||||
ayrılır: tabanı senden <strong>daha iyi</strong> olanlar{" "}
|
||||
<span className="font-semibold text-red-600">Hayal</span> (şansını
|
||||
denediğin satırlar), tabanı sıralamana{" "}
|
||||
<strong>yakın</strong> olanlar{" "}
|
||||
<span className="font-semibold text-amber-600">Dengeli</span>{" "}
|
||||
(listenin bel kemiği — sıralamanın yaklaşık yüzde kırk fazlasına
|
||||
kadar pay bırakılır), tabanı{" "}
|
||||
<strong>belirgin şekilde altında</strong> kalan her şey ise{" "}
|
||||
<span className="font-semibold text-emerald-600">Garanti</span>{" "}
|
||||
dilimidir.
|
||||
</p>
|
||||
<DilimSeridiCanli />
|
||||
<p className="mt-4">
|
||||
Garanti diliminin bir sonu yok: 87. sıradaki bir adayın da 500
|
||||
bininci sıradaki bir adayın da ulaşabildiği <em>bütün</em>{" "}
|
||||
programlar listededir. Liste sana en yakın tabandan başlar; aşağı
|
||||
indikçe ve “Daha fazla göster”e bastıkça senden
|
||||
uzaklaşan seçenekler gelir. Böylece ilk gördüklerin her zaman en
|
||||
isabetli adaylardır ama hiçbir seçenek senden saklanmaz.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="font-heading text-lg font-bold text-slate-900">
|
||||
3. Risk renkleri her yerde aynı dili konuşur
|
||||
</h2>
|
||||
<p className="mt-2">
|
||||
Tabloda, haritada ve yapay zekâ listesinde gördüğün nokta ve
|
||||
renkler tek bir kurala bağlıdır:{" "}
|
||||
<span className="font-semibold text-emerald-600">yeşil</span>{" "}
|
||||
tabanı sıralamanın belirgin altında (güvenli),{" "}
|
||||
<span className="font-semibold text-amber-600">sarı</span>{" "}
|
||||
sıralamana yakın (az riskli),{" "}
|
||||
<span className="font-semibold text-red-600">kırmızı</span>{" "}
|
||||
tabanı senden iyi (riskli). Renk hangi sayfada olursa olsun aynı
|
||||
hesaptan çıkar; bir satır sekme değiştirdi diye renk değiştirmez.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="font-heading text-lg font-bold text-slate-900">
|
||||
4. Sihirbaz süzer, yapay zekâ kurar
|
||||
</h2>
|
||||
<p className="mt-2">
|
||||
“Yapay Zeka listemi kur” sihirbazındaki üç adım birbirinden
|
||||
bağımsız değildir. İlk adımda yalnızca{" "}
|
||||
<strong>sıralamanla ulaşabildiğin</strong>{" "}
|
||||
alanlar listelenir; ikinci adımdaki iller, seçtiğin alanlardaki
|
||||
program sayısına göre yeniden hesaplanır; üçüncü adımdaki
|
||||
devlet/vakıf sayıları da hem alan hem il seçimini hesaba katar.
|
||||
Adımlar bitince elde kalan gerçek programlar bir{" "}
|
||||
<strong>aday havuzu</strong>{" "}
|
||||
olur ve yapay zekâya öyle gider: ona boş bir sayfa değil, bu havuz
|
||||
verilir. Aşağıdaki şema akışın tamamını gösterir — havuz
|
||||
24'ün altına düşerse filtrelerin nasıl gevşetildiği, yine de
|
||||
yetmezse listenin eldeki programlarla, sana açıkça belirtilerek
|
||||
nasıl kurulduğu da dahil.
|
||||
</p>
|
||||
<HuniSemasi />
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="font-heading text-lg font-bold text-slate-900">
|
||||
5. Dürüst olalım: bu bir tahmin aracıdır
|
||||
</h2>
|
||||
<p className="mt-2">
|
||||
Tüm bu hesaplar <strong>geçen yılların</strong>{" "}
|
||||
yerleştirme sonuçlarına dayanır. Kontenjanlar, tercih davranışları
|
||||
ve puanlar her yıl değişir; geçen yıl “garanti”
|
||||
görünen bir bölüm bu yıl dolabilir. Bu yüzden satırlarda son beş
|
||||
yılın taban seyrini de gösteririz — çizgi yukarı tırmanıyorsa
|
||||
bölüm zorlaşıyor demektir. KolayTercih karar <em>destek</em>{" "}
|
||||
aracıdır; listenin son hâli ve sorumluluğu her zaman sana aittir.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<DemoKapanisCta />
|
||||
</Parallax>
|
||||
</SiralamaDemo>
|
||||
);
|
||||
}
|
||||
|
||||
export function MeraklisinaIcerikSkeleton() {
|
||||
return (
|
||||
<div className="mt-8 space-y-10">
|
||||
{Array.from({ length: 3 }, (_, i) => (
|
||||
<div key={i}>
|
||||
<Skeleton className="h-6 w-72" />
|
||||
<Skeleton className="mt-3 h-4 w-full" />
|
||||
<Skeleton className="mt-1.5 h-4 w-full" />
|
||||
<Skeleton className="mt-1.5 h-4 w-2/3" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
57
src/features/pazarlama/components/reveal.tsx
Normal file
57
src/features/pazarlama/components/reveal.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
155
src/features/pazarlama/components/sosyal-kanit-govde.tsx
Normal file
155
src/features/pazarlama/components/sosyal-kanit-govde.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
17
src/features/pazarlama/components/sosyal-kanit.tsx
Normal file
17
src/features/pazarlama/components/sosyal-kanit.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { SosyalKanitGovde } from "./sosyal-kanit-govde";
|
||||
import { LISTE_ESIGI, esikGecti } from "@/lib/sosyal-kanit-sabitler";
|
||||
import { sosyalKanitVerisi } from "../pazarlama-queries";
|
||||
|
||||
/**
|
||||
* 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} />;
|
||||
}
|
||||
166
src/features/pazarlama/components/turkey-pixel-map.tsx
Normal file
166
src/features/pazarlama/components/turkey-pixel-map.tsx
Normal 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 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
22
src/features/pazarlama/pazarlama-queries.ts
Normal file
22
src/features/pazarlama/pazarlama-queries.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import "server-only";
|
||||
import { dilimAra, rankWindowFacets } from "@/lib/db";
|
||||
import type { DilimKey } from "@/types/yokatlas";
|
||||
import type { DemoVeri } from "./components/meraklisina-demo";
|
||||
|
||||
// Fiyat bölümü üstündeki sosyal kanıt şeridinin okuma kapısı.
|
||||
export { sosyalKanitVerisi } from "@/lib/sosyal-kanit";
|
||||
|
||||
// /meraklisina'nın SSR başlangıç verisi: örnek sıralamanın gerçek dilim
|
||||
// sayıları ve facetleri (puan türü anlatı gereği hep "say").
|
||||
export function meraklisinaDemoVerisi(sira: number): DemoVeri {
|
||||
const dilimToplam = Object.fromEntries(
|
||||
(["hayal", "dengeli", "garanti"] as DilimKey[]).map((d) => [
|
||||
d,
|
||||
dilimAra(sira, "say", d, { limit: 0 }).toplam,
|
||||
]),
|
||||
) as Record<DilimKey, number>;
|
||||
return {
|
||||
facetler: rankWindowFacets(sira, "say"),
|
||||
dilimToplam,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user