feat(ui): Arc bileşenleri, landing bölümleri ve bildirim yığını
Sonner/vaul yerine kendi bildirim yığını ve alt sayfa; çip grubu, kart destesi, kaydırma eylemi, yazı dönüşümü, bölme tutamacı; landing'e program duvarı, süzülen haplar ve veri akışı; yaylar tek dosyada (lib/hareket.ts). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -4,13 +4,14 @@ import { useLayoutEffect, useRef, type ReactNode } from "react";
|
||||
import { animate, useReducedMotion } from "motion/react";
|
||||
import { ChevronRight, GraduationCap, X } from "lucide-react";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "@/components/ui/drawer";
|
||||
AltSayfa,
|
||||
AltSayfaKapat,
|
||||
AltSayfaGovde,
|
||||
AltSayfaIcerik,
|
||||
AltSayfaAciklama,
|
||||
AltSayfaBaslik,
|
||||
AltSayfaTetik,
|
||||
} from "@/components/ui/alt-sayfa";
|
||||
|
||||
/**
|
||||
* Üniversite ve bölüm sayfasında özet kartlarının altındaki ayrıntı satırı
|
||||
@@ -38,8 +39,8 @@ export function AkademikAyrinti({
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Drawer handleOnly>
|
||||
<DrawerTrigger asChild>
|
||||
<AltSayfa>
|
||||
<AltSayfaTetik asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-4 flex w-full cursor-pointer items-center gap-4 group rounded-2xl border border-slate-200 bg-card p-4 text-left transition-[background-color,border-color,transform] duration-150 hover:border-orange-500 hover:bg-orange-50/60 active:scale-[0.99] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring sm:p-5"
|
||||
@@ -62,23 +63,23 @@ export function AkademikAyrinti({
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent className="max-h-[92dvh] bg-card sm:mx-auto sm:max-w-2xl sm:border-x sm:border-slate-200 sm:shadow-2xl">
|
||||
<DrawerClose className="absolute top-3 right-3 z-10 flex size-9 cursor-pointer items-center justify-center rounded-full border border-slate-200 bg-popover text-slate-500 transition-[background-color,transform] duration-150 hover:bg-slate-100 hover:text-slate-800 active:scale-[0.96]">
|
||||
</AltSayfaTetik>
|
||||
<AltSayfaIcerik>
|
||||
<AltSayfaKapat className="absolute top-3 right-3 z-10 flex size-9 cursor-pointer items-center justify-center rounded-full border border-slate-200 bg-popover text-slate-500 transition-[background-color,transform] duration-150 hover:bg-slate-100 hover:text-slate-800 active:scale-[0.96]">
|
||||
<X className="size-4" aria-hidden />
|
||||
<span className="sr-only">Kapat</span>
|
||||
</DrawerClose>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 pt-5 pb-[max(1.25rem,env(safe-area-inset-bottom))] sm:px-6">
|
||||
<DrawerTitle className="px-10 text-center font-heading text-2xl font-bold text-slate-900">
|
||||
</AltSayfaKapat>
|
||||
<AltSayfaGovde className="px-4 pt-3 pb-[max(1.25rem,env(safe-area-inset-bottom))] sm:px-6">
|
||||
<AltSayfaBaslik className="px-10 text-center font-heading text-2xl font-bold text-slate-900">
|
||||
{baslik}
|
||||
</DrawerTitle>
|
||||
<DrawerDescription className="mt-1 px-10 text-center text-sm text-slate-500">
|
||||
</AltSayfaBaslik>
|
||||
<AltSayfaAciklama className="mt-1 px-10 text-center text-sm text-slate-500">
|
||||
{baglam}
|
||||
</DrawerDescription>
|
||||
</AltSayfaAciklama>
|
||||
<AcilisSahnesi>{children}</AcilisSahnesi>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</AltSayfaGovde>
|
||||
</AltSayfaIcerik>
|
||||
</AltSayfa>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
Armchair,
|
||||
Briefcase,
|
||||
Calculator,
|
||||
ChartLine,
|
||||
ChevronRight,
|
||||
GraduationCap,
|
||||
Landmark,
|
||||
@@ -35,11 +34,7 @@ import { VeriKaynagi } from "./veri-kaynagi";
|
||||
import { NavBaslik } from "./nav-baslik";
|
||||
import { AkademikAyrinti } from "./akademik-ayrinti";
|
||||
import { OzetKart, kartSayisi } from "@/components/ozet-kart";
|
||||
import {
|
||||
KartBaglantisi,
|
||||
kisaUniAdi,
|
||||
ortaTabanAciklamasi,
|
||||
} from "./taban-karti";
|
||||
import { KartBaglantisi, kisaUniAdi } from "./taban-karti";
|
||||
import { trBaslikDuzeni } from "@/lib/slug";
|
||||
import {
|
||||
BOLUM_ISTIHDAM_KAYNAK_URL,
|
||||
@@ -106,7 +101,7 @@ function MezunIstihdami({
|
||||
const istihdamKaresi =
|
||||
veri.istihdam != null ? (
|
||||
<div data-kare="sol" className={`${kare} min-h-40 bg-orange-500 py-4`}>
|
||||
<dt className={etiket}>Kayıtlı çalışan</dt>
|
||||
<dt className={etiket}>İşe yerleşen</dt>
|
||||
<dd className={`mt-3 ${devSayi}`}>%{ondalik(veri.istihdam)}</dd>
|
||||
{ort.istihdam != null ? (
|
||||
<dd className="mt-2 text-base font-bold text-white/90">
|
||||
@@ -165,7 +160,7 @@ function MezunIstihdami({
|
||||
>
|
||||
Türkiye geneli, tüm üniversitelerin{" "}
|
||||
{duzey === "lisans" ? "lisans" : "ön lisans"} mezunları birlikte;
|
||||
üniversiteye göre değişir. Kayıtlı çalışan, sigortalı bir işte
|
||||
üniversiteye göre değişir. İşe yerleşen, sigortalı bir işte
|
||||
çalışanların oranıdır. Kazanç grubu bölümler arası kıyastır, maaş
|
||||
değildir.
|
||||
</p>
|
||||
@@ -409,10 +404,11 @@ export function BolumIcerik({ slug, sayfa }: { slug: string; sayfa: number }) {
|
||||
...(bolum.onlisansProgramlari.length > 0 ? ["Önlisans"] : []),
|
||||
];
|
||||
// Özet kartları (Bilal, 6 Eki 2026: "kaç program var" sayıları ilgi
|
||||
// çekmiyor): öğrencinin sorusuna cevap veren dört sayı — en seçici
|
||||
// program, devlette en geniş kapı, orta tabanın yıllar içindeki seyri ve
|
||||
// boş kalan koltuk. Hesap lib/taban-ozeti.ts; program/devlet/vakıf sayıları
|
||||
// özet paragrafta kalır. Üniversite sayfasıyla aynı düzen (DESIGN.md §4.8).
|
||||
// çekmiyor): öğrencinin sorusuna cevap veren üç sayı — en yüksek taban,
|
||||
// devlette en düşük taban ve boş kalan kontenjan. Orta taban kartı
|
||||
// kaldırıldı (Bilal, 6 Eki 2026). Hesap lib/taban-ozeti.ts; program/
|
||||
// devlet/vakıf sayıları özet paragrafta kalır. Üniversite sayfasıyla aynı
|
||||
// kartlar (DESIGN.md §4.8).
|
||||
const { taban } = bolum;
|
||||
const uniBaglantisi = (universite: string) => (
|
||||
<KartBaglantisi
|
||||
@@ -554,13 +550,14 @@ export function BolumIcerik({ slug, sayfa }: { slug: string; sayfa: number }) {
|
||||
<CtaSiraForm varyant="kompakt" konum="ust" baslik={ctaBaslik} />
|
||||
</div>
|
||||
|
||||
{/* Sayfada en çok dört özet sayı (DESIGN.md §1.1, §4.8). Derin
|
||||
sayfalarda tekrar edilmez — üniversite sayfasıyla aynı. */}
|
||||
{/* Sayfada en çok dört özet sayı (DESIGN.md §1.1, §4.8); burada üç.
|
||||
Telefonda iki sütunda tek kalan üçüncü kart satırı boylu boyunca
|
||||
alır. Derin sayfalarda tekrar edilmez — üniversite sayfasıyla aynı. */}
|
||||
{ilkSayfa ? (
|
||||
<dl className="mt-8 grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||
<dl className="mt-8 grid grid-cols-2 gap-4 lg:grid-cols-3 [&>*:last-child]:col-span-2 lg:[&>*:last-child]:col-span-1">
|
||||
<OzetKart
|
||||
ikon={Target}
|
||||
etiket="En seçici"
|
||||
etiket="En yüksek taban"
|
||||
{...(taban.enSecici
|
||||
? kartSayisi(taban.enSecici.sira)
|
||||
: { deger: "—" })}
|
||||
@@ -575,11 +572,11 @@ export function BolumIcerik({ slug, sayfa }: { slug: string; sayfa: number }) {
|
||||
)
|
||||
}
|
||||
/>
|
||||
{/* Etiket kısa kalır ("Devlette en geniş" 131 px'te iki satıra
|
||||
{/* Etiket kısa kalır ("Devlette en düşük" 131 px'te iki satıra
|
||||
kırılıyor, 6 Eki 2026 ölçümü); küme açıklamanın başında. */}
|
||||
<OzetKart
|
||||
ikon={Landmark}
|
||||
etiket="En geniş kapı"
|
||||
etiket="En düşük taban"
|
||||
{...(taban.enGenis
|
||||
? kartSayisi(taban.enGenis.sira)
|
||||
: { deger: "—" })}
|
||||
@@ -595,18 +592,9 @@ export function BolumIcerik({ slug, sayfa }: { slug: string; sayfa: number }) {
|
||||
)
|
||||
}
|
||||
/>
|
||||
<OzetKart
|
||||
ikon={ChartLine}
|
||||
etiket="Orta taban"
|
||||
{...(() => {
|
||||
const son = [...taban.ortaTaban].reverse().find((v) => v != null);
|
||||
return son != null ? kartSayisi(son) : { deger: "—" };
|
||||
})()}
|
||||
alt={ortaTabanAciklamasi(taban)}
|
||||
/>
|
||||
<OzetKart
|
||||
ikon={Armchair}
|
||||
etiket="Boş koltuk"
|
||||
etiket="Boş kontenjan"
|
||||
deger={taban.doluluk ? sayi(taban.doluluk.bosKoltuk) : "—"}
|
||||
alt={
|
||||
taban.doluluk
|
||||
|
||||
@@ -37,13 +37,14 @@ import { UNITUR_ETIKET } from "@/lib/unitur";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SON_YIL } from "@/lib/veri-yillari";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "@/components/ui/drawer";
|
||||
AltSayfa,
|
||||
AltSayfaKapat,
|
||||
AltSayfaGovde,
|
||||
AltSayfaIcerik,
|
||||
AltSayfaAciklama,
|
||||
AltSayfaBaslik,
|
||||
AltSayfaTetik,
|
||||
} from "@/components/ui/alt-sayfa";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -137,8 +138,8 @@ export function ProgramAyrintiButonu({
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer onOpenChange={acildi} handleOnly>
|
||||
<DrawerTrigger asChild>
|
||||
<AltSayfa onOpenChange={acildi}>
|
||||
<AltSayfaTetik asChild>
|
||||
{varyant === "metin" ? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -160,21 +161,24 @@ export function ProgramAyrintiButonu({
|
||||
<span className="sr-only">{isim} — program ayrıntısı</span>
|
||||
</button>
|
||||
)}
|
||||
</DrawerTrigger>
|
||||
{/* Çekmece kabı ve çarpı cta-sira-form.tsx'teki çekmeceyle birebir (§4.10, §4.3) */}
|
||||
<DrawerContent
|
||||
className="max-h-[92dvh] bg-card sm:mx-auto sm:max-w-2xl sm:border-x sm:border-slate-200 sm:shadow-2xl"
|
||||
</AltSayfaTetik>
|
||||
{/* Alt sayfa kabı tek kaynak (alt-sayfa.tsx), çarpı §4.3. İçerik uzun:
|
||||
sayfa ekranın %60'ında açılır (başlık + künye), yukarı çekince ya da
|
||||
kaydırınca tam boya çıkar (§4.19). */}
|
||||
<AltSayfaIcerik
|
||||
duraklar={[0.6, 0.92]}
|
||||
ilkDurak={0}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DrawerClose className="absolute top-3 right-3 z-10 flex size-9 cursor-pointer items-center justify-center rounded-full border border-slate-200 bg-popover text-slate-500 transition-[background-color,transform] duration-150 hover:bg-slate-100 hover:text-slate-800 active:scale-[0.96]">
|
||||
<AltSayfaKapat className="absolute top-3 right-3 z-10 flex size-9 cursor-pointer items-center justify-center rounded-full border border-slate-200 bg-popover text-slate-500 transition-[background-color,transform] duration-150 hover:bg-slate-100 hover:text-slate-800 active:scale-[0.96]">
|
||||
<X className="size-4" aria-hidden />
|
||||
<span className="sr-only">Kapat</span>
|
||||
</DrawerClose>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 pt-5 pb-[max(1.25rem,env(safe-area-inset-bottom))] sm:px-6">
|
||||
<DrawerTitle className="px-10 text-center font-heading text-2xl font-bold text-slate-900">
|
||||
</AltSayfaKapat>
|
||||
<AltSayfaGovde className="px-4 pt-3 pb-[max(1.25rem,env(safe-area-inset-bottom))] sm:px-6">
|
||||
<AltSayfaBaslik className="px-10 text-center font-heading text-2xl font-bold text-slate-900">
|
||||
{isim}
|
||||
</DrawerTitle>
|
||||
<DrawerDescription className="mx-auto mt-1 max-w-md px-4 text-center text-sm text-slate-500">
|
||||
</AltSayfaBaslik>
|
||||
<AltSayfaAciklama className="mx-auto mt-1 max-w-md px-4 text-center text-sm text-slate-500">
|
||||
{veri
|
||||
? [
|
||||
trBaslikDuzeni(veri.program.universite),
|
||||
@@ -184,13 +188,13 @@ export function ProgramAyrintiButonu({
|
||||
.filter(Boolean)
|
||||
.join(" · ")
|
||||
: (universite ?? "Programın tüm açık verisi")}
|
||||
</DrawerDescription>
|
||||
</AltSayfaAciklama>
|
||||
<div className="mt-5">
|
||||
<AyrintiIcerik durum={durum} veri={veri} />
|
||||
</div>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</AltSayfaGovde>
|
||||
</AltSayfaIcerik>
|
||||
</AltSayfa>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,47 +1,14 @@
|
||||
import Link from "next/link";
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
ortaTabanYonu,
|
||||
YON_ETIKET,
|
||||
type TabanKumesi,
|
||||
type TabanDuzeyi,
|
||||
type TabanOzeti,
|
||||
} from "@/lib/taban-ozeti";
|
||||
import { kisaSayi } from "@/components/ozet-kart";
|
||||
|
||||
/**
|
||||
* Üniversite ve bölüm sayfasının özet kartlarının ortak metin ve link
|
||||
* yardımcıları (DESIGN.md §4.8). Yıl yıl ayrıntı çekmecesi ("Yıllara göre
|
||||
* taban") 6 Eki 2026'da Bilal'in isteğiyle şimdilik kaldırıldı; geri gelirse
|
||||
* git geçmişinde `taban-yillari.tsx` + `siranla-kapilar.tsx` olarak duruyor.
|
||||
* taban") ve orta taban kartı 6 Eki 2026'da Bilal'in isteğiyle kaldırıldı;
|
||||
* geri gelirse git geçmişinde `taban-yillari.tsx`, `siranla-kapilar.tsx` ve
|
||||
* `ortaTabanAciklamasi` olarak duruyor.
|
||||
*/
|
||||
|
||||
/** "2026'da" — yıl eki ünlü uyumuna göre (2023'te, 2026'da, 2027'de). */
|
||||
export function yilEki(yil: number): string {
|
||||
const son = yil % 10;
|
||||
if (son === 3 || son === 4 || son === 5) return `${yil}'te`;
|
||||
if (son === 0 || son === 6 || son === 9) return `${yil}'da`;
|
||||
return `${yil}'de`;
|
||||
}
|
||||
|
||||
/** Orta taban kümesinin adı: "Devlet" / "Lisans" / "Önlisans". */
|
||||
export function kumeAdi(kume: TabanKumesi, duzey: TabanDuzeyi): string {
|
||||
if (kume === "devlet") return "Devlet";
|
||||
return duzey === "lisans" ? "Lisans" : "Önlisans";
|
||||
}
|
||||
|
||||
/** Özet kartının açıklaması: "Devlet · 2021'de 105 bin · gevşedi". */
|
||||
export function ortaTabanAciklamasi(taban: TabanOzeti): string {
|
||||
const yon = ortaTabanYonu(taban.ortaTaban);
|
||||
const kume = kumeAdi(taban.kume, taban.duzey);
|
||||
if (!yon) {
|
||||
return taban.ortaTabanAdet > 0
|
||||
? `${kume} · ${taban.ortaTabanAdet.toLocaleString("tr-TR")} programın ortası`
|
||||
: "henüz taban oluşmadı";
|
||||
}
|
||||
return `${kume} · ${yilEki(yon.ilkYil)} ${kisaSayi(yon.ilk)} · ${YON_ETIKET[yon.yon]}`;
|
||||
}
|
||||
|
||||
/** Özet kartı açıklamasındaki ad linki (üniversite ↔ bölüm çapraz bağ). */
|
||||
export function KartBaglantisi({
|
||||
href,
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
Armchair,
|
||||
ArrowRight,
|
||||
Building2,
|
||||
ChartLine,
|
||||
ChevronRight,
|
||||
Landmark,
|
||||
LayoutList,
|
||||
@@ -57,7 +56,7 @@ import { UrapLogo } from "./urap-logo";
|
||||
import { AkademikAyrinti } from "./akademik-ayrinti";
|
||||
import { NavBaslik } from "./nav-baslik";
|
||||
import { OzetKart, kartSayisi } from "@/components/ozet-kart";
|
||||
import { KartBaglantisi, ortaTabanAciklamasi } from "./taban-karti";
|
||||
import { KartBaglantisi } from "./taban-karti";
|
||||
import { bolumBazAdi } from "@/lib/slug";
|
||||
import { uniLogoYolu } from "@/lib/uni-logolar";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
@@ -678,14 +677,15 @@ export function UniversiteIcerik({
|
||||
{/* Şablon tam 4 hücre bırakıyor: masaüstünde harita 3. sütunu
|
||||
iki satır boyunca kaplıyor, 5. kart ızgarayı bozar. Dört
|
||||
kart öğrencinin sorusuna cevap verir (Bilal, 6 Eki 2026:
|
||||
program/kontenjan adedi ilgi çekmiyor): en seçici bölüm
|
||||
adıyla, orta tabanın yıllar içindeki seyri, doluluk ve
|
||||
Türkiye sırası. Program, birim ve kontenjan sayıları özet
|
||||
paragrafta. Hesap lib/taban-ozeti.ts. */}
|
||||
program/kontenjan adedi ilgi çekmiyor): en yüksek ve en
|
||||
düşük taban bölüm adıyla, doluluk ve Türkiye sırası. Orta
|
||||
taban kartı kaldırıldı (Bilal, 6 Eki 2026). Program, birim
|
||||
ve kontenjan sayıları özet paragrafta. Hesap
|
||||
lib/taban-ozeti.ts. */}
|
||||
<dl className="contents">
|
||||
<OzetKart
|
||||
ikon={Target}
|
||||
etiket="En seçici bölüm"
|
||||
etiket="En yüksek taban"
|
||||
{...(taban.enSecici
|
||||
? kartSayisi(taban.enSecici.sira)
|
||||
: { deger: "—" })}
|
||||
@@ -708,15 +708,28 @@ export function UniversiteIcerik({
|
||||
}
|
||||
/>
|
||||
<OzetKart
|
||||
ikon={ChartLine}
|
||||
etiket="Orta taban"
|
||||
{...(() => {
|
||||
const son = [...taban.ortaTaban]
|
||||
.reverse()
|
||||
.find((v) => v != null);
|
||||
return son != null ? kartSayisi(son) : { deger: "—" };
|
||||
})()}
|
||||
alt={ortaTabanAciklamasi(taban)}
|
||||
ikon={Landmark}
|
||||
etiket="En düşük taban"
|
||||
{...(taban.enGenis
|
||||
? kartSayisi(taban.enGenis.sira)
|
||||
: { deger: "—" })}
|
||||
alt={
|
||||
taban.enGenis ? (
|
||||
<>
|
||||
<KartBaglantisi
|
||||
href={(() => {
|
||||
const s = bolumSlugFromIsim(taban.enGenis.isim);
|
||||
return s ? `/bolum/${s}` : null;
|
||||
})()}
|
||||
>
|
||||
{bolumBazAdi(taban.enGenis.isim)}
|
||||
</KartBaglantisi>{" "}
|
||||
· {SON_YIL} taban sırası
|
||||
</>
|
||||
) : (
|
||||
"henüz taban oluşmadı"
|
||||
)
|
||||
}
|
||||
/>
|
||||
{taban.doluluk ? (
|
||||
<OzetKart
|
||||
@@ -725,7 +738,7 @@ export function UniversiteIcerik({
|
||||
deger={`%${taban.doluluk.yuzde}`}
|
||||
alt={
|
||||
taban.doluluk.bosKoltuk > 0
|
||||
? `${sayi(taban.doluluk.kontenjan)} kontenjandan ${sayi(taban.doluluk.bosKoltuk)} koltuk boş kaldı · ${SON_YIL}`
|
||||
? `${sayi(taban.doluluk.kontenjan)} kontenjanın ${sayi(taban.doluluk.bosKoltuk)} tanesi boş kaldı · ${SON_YIL}`
|
||||
: `${sayi(taban.doluluk.kontenjan)} kontenjanın tamamı doldu · ${SON_YIL}`
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { SayiRulo } from "@/components/ui/sayi-rulo";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ArrowRight, MailCheck } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { toast } from "@/components/ui/bildirim";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { olay } from "@/lib/analitik";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
"use client";
|
||||
|
||||
// Manuel 24'lük listenin alt çekmecesi (bottom drawer). Layout'ta bir kez
|
||||
// Manuel 24'lük listenin alt sayfası (DESIGN.md §4.10). Layout'ta bir kez
|
||||
// monte edilir; navbar'daki Seçtiklerim butonu açar/kapatır.
|
||||
//
|
||||
// Vaul drawer: tutamaktan aşağı swipe ile kapanır (handleOnly — satır
|
||||
// sürükleme/silme jestleriyle çakışmasın diye). Escape ve overlay tıklaması
|
||||
// da kapatır.
|
||||
// Alt sayfa (alt-sayfa.tsx): tutamaçtan ya da başlıktan aşağı çekince, liste
|
||||
// en üstteyken aşağı çekince kapanır; Escape ve perde tıklaması da kapatır.
|
||||
//
|
||||
// Satır sıralama yalnız grip tutamağından başlar (dragControls); gövdeyi
|
||||
// sağa kaydırmak silme jesti. prefers-reduced-motion: satır animasyonları
|
||||
// sadeleşir; X butonu her durumda kalır.
|
||||
// Satır sıralama yalnız grip tutamağından başlar (dragControls). Satırı sola
|
||||
// kaydırmak "Çıkar" eylemini açar, sonuna kadar çekmek uygular
|
||||
// (kaydirma-eylemi.tsx, §4.23); aynı iş satırdaki "−" düğmesiyle de yapılır.
|
||||
// prefers-reduced-motion: satır animasyonları sadeleşir.
|
||||
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { toast } from "@/components/ui/bildirim";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { SayiRulo } from "@/components/ui/sayi-rulo";
|
||||
@@ -21,13 +21,14 @@ import { UniLogo } from "@/components/uni-logo";
|
||||
import { RiskEtiketi } from "@/components/risk-etiketi";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
} from "@/components/ui/drawer";
|
||||
AltSayfa,
|
||||
AltSayfaBaslik,
|
||||
AltSayfaGovde,
|
||||
AltSayfaIcerik,
|
||||
AltSayfaKapat,
|
||||
AltSayfaSurukle,
|
||||
} from "@/components/ui/alt-sayfa";
|
||||
import { KaydirmaGrubu, KaydirmaSatiri } from "@/components/ui/kaydirma-eylemi";
|
||||
import {
|
||||
ArrowRight,
|
||||
Check,
|
||||
@@ -43,9 +44,7 @@ import {
|
||||
motion,
|
||||
Reorder,
|
||||
useDragControls,
|
||||
useMotionValue,
|
||||
useTransform,
|
||||
type PanInfo,
|
||||
useReducedMotion,
|
||||
} from "motion/react";
|
||||
import { bolumBazAdi, trBaslikDuzeni, turkishSlugify } from "@/lib/slug";
|
||||
import { SATIR_AD, SATIR_IKINCIL_LINK } from "@/components/liste-satiri";
|
||||
@@ -120,7 +119,7 @@ export function ListeCekmecesi() {
|
||||
const girisli = useOturumDurumu();
|
||||
const girisUrl = useGirisUrl();
|
||||
// Giriş animasyonu bitmeden satır layout projeksiyonu kapalı — transform'lu
|
||||
// drawer içinde erken ölçüm satırları kaydırıyordu
|
||||
// sayfa 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);
|
||||
@@ -144,7 +143,7 @@ export function ListeCekmecesi() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
<AltSayfa
|
||||
open={acik}
|
||||
onOpenChange={(v) => {
|
||||
if (v) cekmeceAc();
|
||||
@@ -153,39 +152,35 @@ export function ListeCekmecesi() {
|
||||
cekmeceKapat();
|
||||
}
|
||||
}}
|
||||
handleOnly
|
||||
onAnimationEnd={(open) => setOturdu(open)}
|
||||
>
|
||||
<DrawerContent
|
||||
className="min-h-0 max-h-[92dvh] bg-card sm:mx-auto sm:max-w-2xl sm:border-x sm:border-slate-200 sm:shadow-2xl"
|
||||
>
|
||||
<AltSayfaIcerik onOturdu={setOturdu} aria-describedby={undefined}>
|
||||
{/* Alt sayfa başlığı (DESIGN.md §4.10): ortalı başlık + bağlam;
|
||||
çarpı §4.3 kanonik sınıf setinde, temizle düğmesi aynı ölçüde
|
||||
solda. */}
|
||||
<DrawerHeader className="relative border-b border-slate-100 px-4 pt-5 pb-4 text-center md:text-center">
|
||||
{liste.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
geriAlinabilirCikar(
|
||||
liste.map((t) => t.id),
|
||||
`Listen temizlendi · ${liste.length} program çıkarıldı`,
|
||||
)
|
||||
}
|
||||
title="Listeyi temizle"
|
||||
className="absolute top-3 left-3 z-10 flex size-9 cursor-pointer items-center justify-center rounded-full border border-slate-200 bg-popover text-slate-500 transition-[background-color,transform] duration-150 hover:bg-slate-100 hover:text-slate-800 active:scale-[0.96]"
|
||||
>
|
||||
<Trash2 className="size-4" aria-hidden />
|
||||
<span className="sr-only">Listeyi temizle</span>
|
||||
</button>
|
||||
) : null}
|
||||
<DrawerClose className="absolute top-3 right-3 z-10 flex size-9 cursor-pointer items-center justify-center rounded-full border border-slate-200 bg-popover text-slate-500 transition-[background-color,transform] duration-150 hover:bg-slate-100 hover:text-slate-800 active:scale-[0.96]">
|
||||
<X className="size-4" aria-hidden />
|
||||
<span className="sr-only">Kapat</span>
|
||||
</DrawerClose>
|
||||
<DrawerTitle className="px-10 text-center font-heading text-2xl font-bold text-slate-900">
|
||||
solda. Başlık alanından da tutulup aşağı çekilir. */}
|
||||
{liste.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
geriAlinabilirCikar(
|
||||
liste.map((t) => t.id),
|
||||
`Listen temizlendi · ${liste.length} program çıkarıldı`,
|
||||
)
|
||||
}
|
||||
title="Listeyi temizle"
|
||||
className="absolute top-3 left-3 z-10 flex size-9 cursor-pointer items-center justify-center rounded-full border border-slate-200 bg-popover text-slate-500 transition-[background-color,transform] duration-150 hover:bg-slate-100 hover:text-slate-800 active:scale-[0.96]"
|
||||
>
|
||||
<Trash2 className="size-4" aria-hidden />
|
||||
<span className="sr-only">Listeyi temizle</span>
|
||||
</button>
|
||||
) : null}
|
||||
<AltSayfaKapat className="absolute top-3 right-3 z-10 flex size-9 cursor-pointer items-center justify-center rounded-full border border-slate-200 bg-popover text-slate-500 transition-[background-color,transform] duration-150 hover:bg-slate-100 hover:text-slate-800 active:scale-[0.96]">
|
||||
<X className="size-4" aria-hidden />
|
||||
<span className="sr-only">Kapat</span>
|
||||
</AltSayfaKapat>
|
||||
<AltSayfaSurukle className="border-b border-slate-100 px-4 pt-3 pb-4 text-center">
|
||||
<AltSayfaBaslik className="px-10 text-center">
|
||||
Kendi Listem
|
||||
</DrawerTitle>
|
||||
</AltSayfaBaslik>
|
||||
{/* Durum ekran okuyucuya da duyurulur (WCAG 4.1.3). Bilal'in
|
||||
kararı (24 Eyl 2026): buradaki piksel liste başlığı çok dolu
|
||||
gösteriyordu; ilerleme çubuğuna geri dönüldü. */}
|
||||
@@ -238,11 +233,9 @@ export function ListeCekmecesi() {
|
||||
Güvenli yok; alta birkaç tane eklemek riski azaltır.
|
||||
</p>
|
||||
) : null}
|
||||
</DrawerHeader>
|
||||
</AltSayfaSurukle>
|
||||
|
||||
<div
|
||||
className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-contain px-4 py-2"
|
||||
>
|
||||
<AltSayfaGovde className="overflow-x-hidden px-4 py-2">
|
||||
{liste.length === 0 ? (
|
||||
<div className="relative flex flex-col items-center justify-center gap-2 overflow-hidden px-4 py-16 text-center">
|
||||
<ListChecks
|
||||
@@ -258,24 +251,26 @@ export function ListeCekmecesi() {
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<Reorder.Group
|
||||
axis="y"
|
||||
values={liste}
|
||||
onReorder={yenidenSirala}
|
||||
layoutScroll
|
||||
as="ol"
|
||||
>
|
||||
<AnimatePresence initial={false}>
|
||||
{liste.map((t, i) => (
|
||||
<CekmeceSatiri
|
||||
key={t.id}
|
||||
tercih={t}
|
||||
sira={i + 1}
|
||||
projeksiyonAcik={oturdu}
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</Reorder.Group>
|
||||
<KaydirmaGrubu>
|
||||
<Reorder.Group
|
||||
axis="y"
|
||||
values={liste}
|
||||
onReorder={yenidenSirala}
|
||||
layoutScroll
|
||||
as="ol"
|
||||
>
|
||||
<AnimatePresence initial={false}>
|
||||
{liste.map((t, i) => (
|
||||
<CekmeceSatiri
|
||||
key={t.id}
|
||||
tercih={t}
|
||||
sira={i + 1}
|
||||
projeksiyonAcik={oturdu}
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</Reorder.Group>
|
||||
</KaydirmaGrubu>
|
||||
)}
|
||||
|
||||
{liste.length < MANUEL_LISTE_MAX ? (
|
||||
@@ -290,9 +285,9 @@ export function ListeCekmecesi() {
|
||||
Program ekle
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</AltSayfaGovde>
|
||||
|
||||
<DrawerFooter className="border-t border-slate-100 pt-3 pb-[max(1rem,env(safe-area-inset-bottom))]">
|
||||
<div className="mt-auto flex flex-col gap-2 border-t border-slate-100 px-4 pt-3 pb-[max(1rem,env(safe-area-inset-bottom))]">
|
||||
{/* C2 kalıcı satır: girişsiz kullanıcıya listenin uçucu olduğu
|
||||
hatırlatılır; giriş doğal "kaydet" gerekçesi olarak sunulur. */}
|
||||
{girisli === false ? (
|
||||
@@ -373,15 +368,12 @@ export function ListeCekmecesi() {
|
||||
</Button>
|
||||
)}
|
||||
</CtaBandi>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</div>
|
||||
</AltSayfaIcerik>
|
||||
</AltSayfa>
|
||||
);
|
||||
}
|
||||
|
||||
/** Sağa kaydırıp silme eşiği (px) — bunu aşınca satır listeden çıkar */
|
||||
const SIL_ESIK = 90;
|
||||
|
||||
function CekmeceSatiri({
|
||||
tercih,
|
||||
sira,
|
||||
@@ -392,19 +384,9 @@ function CekmeceSatiri({
|
||||
projeksiyonAcik: boolean;
|
||||
}) {
|
||||
const siraKontrol = useDragControls();
|
||||
|
||||
// Kartın yatay konumu: sil bandının görünürlüğü buna bağlı
|
||||
const x = useMotionValue(0);
|
||||
const silGorunur = useTransform(x, [0, SIL_ESIK / 2], [0, 1]);
|
||||
|
||||
const kaydirinca = (_: unknown, info: PanInfo) => {
|
||||
if (
|
||||
info.offset.x > SIL_ESIK ||
|
||||
(info.offset.x > 32 && info.velocity.x > 500)
|
||||
) {
|
||||
geriAlinabilirCikar([tercih.id], `${tercih.isim} çıkarıldı`);
|
||||
}
|
||||
};
|
||||
const azalt = useReducedMotion();
|
||||
const cikarIste = () =>
|
||||
geriAlinabilirCikar([tercih.id], `${tercih.isim} çıkarıldı`);
|
||||
|
||||
return (
|
||||
<Reorder.Item
|
||||
@@ -414,36 +396,34 @@ function CekmeceSatiri({
|
||||
layoutId={projeksiyonAcik ? `manuel-tercih-${tercih.id}` : undefined}
|
||||
dragListener={false}
|
||||
dragControls={siraKontrol}
|
||||
initial={{ opacity: 0, y: 8, scale: 0.97 }}
|
||||
initial={azalt ? { opacity: 0 } : { opacity: 0, y: 8, scale: 0.97 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, x: 96, transition: { duration: 0.18 } }}
|
||||
// Satır "Çıkar" bandının altında yana kayarak gider (KaydirmaSatiri);
|
||||
// burada yalnız söner, alttakiler layout animasyonuyla yukarı gelir.
|
||||
exit={{ opacity: 0, transition: { duration: 0.16, delay: azalt ? 0 : 0.14 } }}
|
||||
transition={{ type: "spring", duration: 0.4, bounce: 0 }}
|
||||
whileDrag={{ scale: 1.03, zIndex: 10 }}
|
||||
className="relative select-none border-b border-slate-100 last:border-b-0"
|
||||
className="relative border-b border-slate-100 last:border-b-0"
|
||||
>
|
||||
{/* Sil bandı: kart sağa kaydıkça altından görünür */}
|
||||
<motion.div
|
||||
style={{ opacity: silGorunur }}
|
||||
className="absolute inset-0 flex items-center bg-red-500 pl-4"
|
||||
aria-hidden
|
||||
>
|
||||
<Trash2 className="size-4 text-white" />
|
||||
</motion.div>
|
||||
|
||||
{/* Kart gövdesi: sağa kaydırılabilir (sola kilitli) */}
|
||||
<motion.div
|
||||
drag="x"
|
||||
style={{ x }}
|
||||
dragConstraints={{ left: 0, right: 0 }}
|
||||
dragElastic={{ left: 0, right: 0.7 }}
|
||||
dragSnapToOrigin
|
||||
onDragEnd={kaydirinca}
|
||||
className="relative flex items-center gap-3 bg-card py-3"
|
||||
{/* Sola kaydır: "Çıkar" açılır; sonuna kadar çekince uygulanır (§4.23) */}
|
||||
<KaydirmaSatiri
|
||||
sag={[
|
||||
{
|
||||
etiket: "Çıkar",
|
||||
ikon: <CircleMinus aria-hidden />,
|
||||
ton: "tehlike",
|
||||
onSec: cikarIste,
|
||||
},
|
||||
]}
|
||||
icerikSinifi="flex items-center gap-3 bg-card py-3"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
// Dikey sürükleme sıralamaya aittir: ne yatay kaydırmayı ne de alt
|
||||
// sayfanın aşağı çekilişini başlatır.
|
||||
data-alt-sayfa-yoksay=""
|
||||
onPointerDown={(e) => {
|
||||
e.stopPropagation(); // yatay kaydırmayı tetiklemesin
|
||||
e.stopPropagation();
|
||||
siraKontrol.start(e);
|
||||
}}
|
||||
className="-ml-1 flex h-11 w-7 shrink-0 cursor-grab touch-none items-center justify-center rounded-lg text-slate-300 transition-colors hover:text-slate-500 active:cursor-grabbing"
|
||||
@@ -458,7 +438,6 @@ function CekmeceSatiri({
|
||||
<Link
|
||||
href={bolumHref(tercih.isim)}
|
||||
onClick={cekmeceKapat}
|
||||
onPointerDown={(e) => e.stopPropagation()} // kaydırmayı tetiklemesin
|
||||
className={SATIR_AD}
|
||||
>
|
||||
{tercih.isim}
|
||||
@@ -468,7 +447,6 @@ function CekmeceSatiri({
|
||||
<Link
|
||||
href={uniHref(tercih.universite)}
|
||||
onClick={cekmeceKapat}
|
||||
onPointerDown={(e) => e.stopPropagation()} // kaydırmayı tetiklemesin
|
||||
title={`${tercih.universite} taban puanları sayfası`}
|
||||
className={`min-w-0 ${SATIR_IKINCIL_LINK}`}
|
||||
>
|
||||
@@ -491,18 +469,16 @@ function CekmeceSatiri({
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
geriAlinabilirCikar([tercih.id], `${tercih.isim} çıkarıldı`)
|
||||
}
|
||||
onPointerDown={(e) => e.stopPropagation()} // kaydırmayı tetiklemesin
|
||||
onClick={cikarIste}
|
||||
// Satırdan çıkarma "−" ile, çerçevesiz: kapatma çarpısıyla (§4.3)
|
||||
// karışmasın diye çarpı yalnız çekmecenin kendisini kapatır.
|
||||
// karışmasın diye çarpı yalnız sayfanın kendisini kapatır. Kaydırma
|
||||
// eyleminin klavye ve ekran okuyucu yolu da bu düğmedir.
|
||||
className="inline-flex size-11 shrink-0 cursor-pointer items-center justify-center rounded-full text-slate-400 transition-[background-color,color,transform] duration-150 hover:bg-orange-50/60 hover:text-orange-500 active:scale-[0.92]"
|
||||
>
|
||||
<CircleMinus className="size-5" aria-hidden />
|
||||
<span className="sr-only">{tercih.isim} listeden çıkar</span>
|
||||
</button>
|
||||
</motion.div>
|
||||
</KaydirmaSatiri>
|
||||
</Reorder.Item>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { SATIR_DUGME_BOYU } from "@/components/liste-satiri";
|
||||
import { ArrowRight, Check, Plus } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { toast } from "@/components/ui/bildirim";
|
||||
import type { Program } from "@/types/yokatlas";
|
||||
import { olay } from "@/lib/analitik";
|
||||
import {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
|
||||
import { ArrowRight, Trophy } from "lucide-react";
|
||||
import { useReducedMotion } from "motion/react";
|
||||
import useMeasure from "react-use-measure";
|
||||
import { toast } from "sonner";
|
||||
import { toast } from "@/components/ui/bildirim";
|
||||
import { TransitionPanel } from "@/components/core/transition-panel";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useLayoutEffect, useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ArrowRight, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { toast } from "@/components/ui/bildirim";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { olay } from "@/lib/analitik";
|
||||
import { baslatOdeme } from "../odeme-actions";
|
||||
|
||||
109
src/features/pazarlama/components/deste-demosu.tsx
Normal file
109
src/features/pazarlama/components/deste-demosu.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
// Ana sayfadaki "Dene" bölümü (DESIGN.md §4.27): örnek bir sıralamaya göre
|
||||
// gerçek programlar kart destesinde tek tek sorulur — "listene girer mi?".
|
||||
// Ürünün yaptığı işin (24 tercihi riskine göre seçmek) elle bir tadımlığı.
|
||||
// Veri sunucudan gelir (landing-verisi.ts); burada uydurma sayı yoktur.
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { ArrowRight, Landmark, MapPin } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { KartDestesi } from "@/components/ui/kart-destesi";
|
||||
import { RiskEtiketi } from "@/components/risk-etiketi";
|
||||
import type { OrnekProgram } from "@/features/pazarlama/landing-verisi";
|
||||
|
||||
function ProgramKarti({ program, ornekSira }: { program: OrnekProgram; ornekSira: string }) {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Künye şeridi: sürüklerken karar damgaları bu şeridin üstünde belirir */}
|
||||
<div className="flex h-16 shrink-0 items-center justify-end gap-3 border-b border-slate-100 bg-slate-50 px-5 text-sm font-semibold text-slate-500">
|
||||
{program.il ? (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<MapPin className="size-4 text-primary" aria-hidden />
|
||||
{program.il}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Landmark className="size-4 text-primary" aria-hidden />
|
||||
Devlet
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 flex-col p-5 text-left">
|
||||
<p className="line-clamp-2 font-heading text-xl font-bold leading-snug text-slate-900">
|
||||
{program.isim}
|
||||
</p>
|
||||
<p className="mt-1 line-clamp-1 text-sm text-slate-500">{program.universite}</p>
|
||||
<div className="mt-auto">
|
||||
<p className="text-sm font-semibold text-slate-500">Son taban sıralaması</p>
|
||||
<p className="mt-1 font-heading text-4xl font-bold leading-none tracking-tight text-slate-900">
|
||||
{program.tabanSira.toLocaleString("tr-TR")}
|
||||
</p>
|
||||
<p className="mt-3 flex items-center gap-2 text-sm text-slate-500">
|
||||
<RiskEtiketi risk={program.risk} />
|
||||
<span aria-hidden>·</span>
|
||||
senin sıran {ornekSira}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DesteDemosu({
|
||||
programlar,
|
||||
ornekSira,
|
||||
}: {
|
||||
programlar: OrnekProgram[];
|
||||
/** Biçimlenmiş örnek sıralama ("85.000"). */
|
||||
ornekSira: string;
|
||||
}) {
|
||||
const [alinanlar, setAlinanlar] = useState<string[]>([]);
|
||||
|
||||
return (
|
||||
<KartDestesi
|
||||
ogeler={programlar}
|
||||
anahtar={(p) => p.id}
|
||||
ad={(p) => `${p.isim}, ${p.universite}`}
|
||||
etiket="Örnek programlar"
|
||||
etiketler={{ sol: "Geç", sag: "Listeme al" }}
|
||||
onKarar={(p, karar) => {
|
||||
if (karar === "sag") setAlinanlar((a) => [...a, p.id]);
|
||||
}}
|
||||
onGeriAl={(p, karar) => {
|
||||
if (karar === "sag") setAlinanlar((a) => a.filter((id) => id !== p.id));
|
||||
}}
|
||||
onSifirla={() => setAlinanlar([])}
|
||||
kart={(p) => <ProgramKarti program={p} ornekSira={ornekSira} />}
|
||||
bos={(sifirla) => (
|
||||
<>
|
||||
<p className="font-heading text-xl font-bold text-balance text-slate-900">
|
||||
{alinanlar.length > 0
|
||||
? `${alinanlar.length} programı listene aldın`
|
||||
: "Hiçbirini almadın, olur"}
|
||||
</p>
|
||||
<p className="max-w-56 text-sm leading-relaxed text-slate-600">
|
||||
Gerçek listede bunu 24 tercih için biz yaparız; her birinin gerekçesini de yazarız.
|
||||
</p>
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="mt-1 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600 active:scale-[0.97]"
|
||||
>
|
||||
<Link href="#hero-form">
|
||||
Sıralamamı gir
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Link>
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={sifirla}
|
||||
className="min-h-11 cursor-pointer text-sm font-semibold text-slate-500 transition-colors duration-150 hover:text-orange-500"
|
||||
>
|
||||
Kartları baştan gör
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
245
src/features/pazarlama/components/program-duvari.tsx
Normal file
245
src/features/pazarlama/components/program-duvari.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
"use client";
|
||||
|
||||
// Program duvarı (DESIGN.md §4.29): "24 tercih, binlerce ihtimal" cümlesinin
|
||||
// resmi. Küçük bir pencerede piksel kareler satır satır akar — her kare bir
|
||||
// program; aralarında tam 24 turuncu kare yerinde durur: senin listen.
|
||||
// İmleç yaklaştığı satırları hızlandırır. Veri taşıyan pikseldir (§1.2);
|
||||
// anlamı altındaki cümle söyler, renk tek başına değil (§2.2).
|
||||
//
|
||||
// Esin: React Bits "Squares Terminal" (yanıp sönen kare ızgarası, imlece
|
||||
// tepki veren satırlar). Kod bize ait: koyu CRT ekranı, parlama ve tüp eğimi
|
||||
// alınmadı; açık zemin, hero'nun ızgara ölçüleri (hücre 10, nokta 7.4, köşe 2)
|
||||
// ve doku opaklıkları kullanılır. Tek <canvas>, yalnız ekrandayken ~30 fps;
|
||||
// hareketi azalt'ta durağan tek kare.
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const HUCRE = 10;
|
||||
const NOKTA = 7.4;
|
||||
const BOSLUK = (HUCRE - NOKTA) / 2;
|
||||
// Hero ile aynı doku paleti
|
||||
const OPAKLIKLAR = [0.3, 0.16, 0.24, 0.12, 0.27, 0.19];
|
||||
const TERCIH = 24;
|
||||
/** İmlecin satırları etkilediği dikey uzaklık (px) ve en çok ek hız (hücre/sn). */
|
||||
const ETKI = 46;
|
||||
const EK_HIZ = 9;
|
||||
|
||||
// Deterministik sözde-rastgele: sunucuda yok ama her ziyarette aynı desen
|
||||
const karma = (x: number, y: number) => {
|
||||
const n = Math.sin(x * 127.1 + y * 311.7) * 43758.5453;
|
||||
return n - Math.floor(n);
|
||||
};
|
||||
|
||||
export function ProgramDuvari({
|
||||
etiket,
|
||||
className,
|
||||
}: {
|
||||
/** Pencere çubuğundaki kısa yazı, ör. "21.000+ program". */
|
||||
etiket: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const kapRef = useRef<HTMLDivElement>(null);
|
||||
const tuvalRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const kap = kapRef.current;
|
||||
const tuval = tuvalRef.current;
|
||||
const ctx = tuval?.getContext("2d");
|
||||
if (!kap || !tuval || !ctx) return;
|
||||
|
||||
const azalt = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
let en = 0;
|
||||
let boy = 0;
|
||||
let sutun = 0;
|
||||
let satir = 0;
|
||||
let mavi = "";
|
||||
let turuncu = "";
|
||||
/** Satır başına kayma (hücre) ve imleç kaynaklı ek hız. */
|
||||
let kayma: number[] = [];
|
||||
let ekHiz: number[] = [];
|
||||
let tercihler: { c: number; r: number }[] = [];
|
||||
let imlecY: number | null = null;
|
||||
let gorunur = false;
|
||||
let kare = 0;
|
||||
let onceki = 0;
|
||||
let basladi = 0;
|
||||
|
||||
const renkleriOku = () => {
|
||||
// Tema değişince palet token'ları değişir (§2.3); renk CSS'ten okunur
|
||||
mavi = getComputedStyle(tuval).color;
|
||||
turuncu =
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--color-orange-500").trim() ||
|
||||
"#f97316";
|
||||
};
|
||||
|
||||
const olc = () => {
|
||||
const kutu = kap.getBoundingClientRect();
|
||||
const oran = Math.min(window.devicePixelRatio || 1, 2);
|
||||
en = Math.round(kutu.width);
|
||||
boy = Math.round(kutu.height);
|
||||
tuval.width = en * oran;
|
||||
tuval.height = boy * oran;
|
||||
ctx.setTransform(oran, 0, 0, oran, 0, 0);
|
||||
sutun = Math.ceil(en / HUCRE) + 1;
|
||||
satir = Math.floor(boy / HUCRE);
|
||||
kayma = Array.from({ length: satir }, (_, r) => kayma[r] ?? karma(r, 3) * 40);
|
||||
ekHiz = Array.from({ length: satir }, () => 0);
|
||||
// 24 tercih: kenarlardan bir hücre içeride, birbirine değmeyen sabit kareler
|
||||
tercihler = [];
|
||||
const dolu = new Set<string>();
|
||||
for (let i = 0; tercihler.length < TERCIH && i < 4000; i++) {
|
||||
const c = 1 + Math.floor(karma(i, 17) * Math.max(1, sutun - 3));
|
||||
const r = 1 + Math.floor(karma(i, 29) * Math.max(1, satir - 2));
|
||||
const yakin = [-1, 0, 1].some((dx) =>
|
||||
[-1, 0, 1].some((dy) => dolu.has(`${c + dx}:${r + dy}`)),
|
||||
);
|
||||
if (yakin) continue;
|
||||
dolu.add(`${c}:${r}`);
|
||||
tercihler.push({ c, r });
|
||||
}
|
||||
};
|
||||
|
||||
const kareCiz = (x: number, y: number) => {
|
||||
ctx.beginPath();
|
||||
if (ctx.roundRect) ctx.roundRect(x, y, NOKTA, NOKTA, 2);
|
||||
else ctx.rect(x, y, NOKTA, NOKTA);
|
||||
ctx.fill();
|
||||
};
|
||||
|
||||
const ciz = (t: number) => {
|
||||
ctx.clearRect(0, 0, en, boy);
|
||||
const ustBosluk = (boy - satir * HUCRE) / 2;
|
||||
ctx.fillStyle = mavi;
|
||||
for (let r = 0; r < satir; r++) {
|
||||
const tam = Math.floor(kayma[r]);
|
||||
const kesir = kayma[r] - tam;
|
||||
// Tek satırlar sola, çift satırlar sağa akar
|
||||
const yon = r % 2 === 0 ? 1 : -1;
|
||||
for (let c = -1; c < sutun; c++) {
|
||||
const kimlik = c - yon * tam;
|
||||
const h = karma(kimlik, r);
|
||||
// Karelerin üçte biri boş: duvar değil doku
|
||||
if (h < 0.34) continue;
|
||||
const taban = OPAKLIKLAR[Math.abs(kimlik * 7 + r * 11) % OPAKLIKLAR.length];
|
||||
// Yavaş nefes: her kare kendi fazında hafifçe parlar ve söner
|
||||
const nefes = 0.6 + 0.4 * Math.sin(t * 0.9 + h * 6.283);
|
||||
ctx.globalAlpha = taban * nefes;
|
||||
kareCiz((c + yon * kesir) * HUCRE + BOSLUK, ustBosluk + r * HUCRE + BOSLUK);
|
||||
}
|
||||
}
|
||||
// Tercihler: sırayla belirir (110 ms arayla), sonra yerinde kalır
|
||||
ctx.fillStyle = turuncu;
|
||||
const gecen = azalt ? Infinity : t - basladi;
|
||||
tercihler.forEach(({ c, r }, i) => {
|
||||
const yas = gecen - 0.4 - i * 0.11;
|
||||
if (yas <= 0) return;
|
||||
const giris = Math.min(1, yas / 0.3);
|
||||
const x = c * HUCRE + BOSLUK;
|
||||
const y = ustBosluk + r * HUCRE + BOSLUK;
|
||||
// Altındaki akan kareyi sil: turuncu kare tek başına okunur
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.clearRect(c * HUCRE, ustBosluk + r * HUCRE, HUCRE, HUCRE);
|
||||
ctx.globalAlpha = 0.9 * giris;
|
||||
kareCiz(x, y);
|
||||
});
|
||||
ctx.globalAlpha = 1;
|
||||
};
|
||||
|
||||
const adim = (simdi: number) => {
|
||||
kare = requestAnimationFrame(adim);
|
||||
// ~30 fps yeter: kareler yavaş akar
|
||||
if (simdi - onceki < 32) return;
|
||||
const dt = Math.min(0.1, (simdi - onceki) / 1000);
|
||||
onceki = simdi;
|
||||
const ustBosluk = (boy - satir * HUCRE) / 2;
|
||||
for (let r = 0; r < satir; r++) {
|
||||
const merkez = ustBosluk + r * HUCRE + HUCRE / 2;
|
||||
const yakinlik =
|
||||
imlecY == null ? 0 : Math.max(0, 1 - Math.abs(merkez - imlecY) / ETKI);
|
||||
// Ek hız hedefe yumuşak yaklaşır; imleç çekilince yavaşça söner
|
||||
ekHiz[r] += (yakinlik * yakinlik * EK_HIZ - ekHiz[r]) * Math.min(1, dt * 5);
|
||||
kayma[r] += (0.5 + karma(r, 7) * 1.3 + ekHiz[r]) * dt;
|
||||
}
|
||||
ciz(simdi / 1000);
|
||||
};
|
||||
|
||||
const baslat = () => {
|
||||
if (kare || azalt || !gorunur) return;
|
||||
onceki = performance.now();
|
||||
if (!basladi) basladi = onceki / 1000;
|
||||
kare = requestAnimationFrame(adim);
|
||||
};
|
||||
const durdur = () => {
|
||||
cancelAnimationFrame(kare);
|
||||
kare = 0;
|
||||
};
|
||||
|
||||
renkleriOku();
|
||||
olc();
|
||||
ciz(0);
|
||||
|
||||
const boyut = new ResizeObserver(() => {
|
||||
olc();
|
||||
ciz(performance.now() / 1000);
|
||||
});
|
||||
boyut.observe(kap);
|
||||
const gorunum = new IntersectionObserver(([kayit]) => {
|
||||
gorunur = kayit.isIntersecting;
|
||||
if (gorunur) baslat();
|
||||
else durdur();
|
||||
});
|
||||
gorunum.observe(kap);
|
||||
const tema = new MutationObserver(() => {
|
||||
renkleriOku();
|
||||
ciz(performance.now() / 1000);
|
||||
});
|
||||
tema.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
|
||||
|
||||
const imlec = (e: PointerEvent) => {
|
||||
if (e.pointerType !== "mouse") return;
|
||||
const kutu = kap.getBoundingClientRect();
|
||||
const icinde =
|
||||
e.clientX >= kutu.left &&
|
||||
e.clientX <= kutu.right &&
|
||||
e.clientY >= kutu.top - ETKI &&
|
||||
e.clientY <= kutu.bottom + ETKI;
|
||||
imlecY = icinde ? e.clientY - kutu.top : null;
|
||||
};
|
||||
window.addEventListener("pointermove", imlec, { passive: true });
|
||||
|
||||
return () => {
|
||||
durdur();
|
||||
boyut.disconnect();
|
||||
gorunum.disconnect();
|
||||
tema.disconnect();
|
||||
window.removeEventListener("pointermove", imlec);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<figure className={cn("mx-auto w-full max-w-3xl", className)}>
|
||||
{/* Mini pencere: aramadaki tarayıcı penceresiyle aynı dil (§4.20) */}
|
||||
<div className="overflow-hidden rounded-2xl border border-slate-200 bg-card shadow-sm">
|
||||
<div className="flex h-9 items-center gap-1.5 border-b border-slate-100 bg-slate-50 px-3.5">
|
||||
<span className="size-2 rounded-full bg-slate-300" aria-hidden />
|
||||
<span className="size-2 rounded-full bg-slate-300 opacity-65" aria-hidden />
|
||||
<span className="size-2 rounded-full bg-slate-300 opacity-35" aria-hidden />
|
||||
<span className="ml-2 text-xs font-medium text-slate-500">{etiket}</span>
|
||||
</div>
|
||||
<div ref={kapRef} className="h-36 sm:h-44">
|
||||
<canvas
|
||||
ref={tuvalRef}
|
||||
role="img"
|
||||
aria-label="Akan piksel kareler: her kare bir program. Aralarında 24 turuncu kare senin tercih listen."
|
||||
className="block size-full text-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<figcaption className="mt-3 flex items-center justify-center gap-2 text-center text-sm text-slate-500">
|
||||
<span className="size-2.5 shrink-0 rounded-[3px] bg-orange-500" aria-hidden />
|
||||
Her kare bir program; turuncular senin 24 tercihin.
|
||||
</figcaption>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
208
src/features/pazarlama/components/suzulen-haplar.tsx
Normal file
208
src/features/pazarlama/components/suzulen-haplar.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
"use client";
|
||||
|
||||
// Süzülen haplar (DESIGN.md §4.30): kataloğun en çok aranan bölüm ve şehir
|
||||
// sayfalarına giden haplar. Bölüm ekrana girince haplar yukarıdan hafif
|
||||
// eğik düşüp yerine oturur; imleç gezdikçe her hap kendi derinliğinde azıcık
|
||||
// süzülür, arkadaki piksel kareler ters yöne kayar. Her hap gerçek bir
|
||||
// linktir (§4.12 kimlik hapı, hover tek turuncu).
|
||||
//
|
||||
// Esin: React Bits "Parallax Pills" (imleci izleyen, sekerek giren etiketli
|
||||
// haplar). Kod bize ait: haplar mutlak konumla değil akışla dizilir (375 px'te
|
||||
// üst üste binmez), giriş tek ve hafif sekmeli (düşen nesne, §6), arka plan
|
||||
// süs hapı yerine sitenin piksel kareleri. Dokunmatikte süzülme yoktur;
|
||||
// hareketi azalt'ta haplar yalnız belirir.
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
motion,
|
||||
useMotionValue,
|
||||
useReducedMotion,
|
||||
useSpring,
|
||||
useTransform,
|
||||
type MotionValue,
|
||||
} from "motion/react";
|
||||
import { GraduationCap, MapPin } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { KatalogHapi } from "@/features/pazarlama/landing-verisi";
|
||||
|
||||
/** İmlecin kenardan kenara taşıdığı en büyük kayma (px). */
|
||||
const GUC = 18;
|
||||
const SUZULME_YAYI = { stiffness: 120, damping: 20, mass: 0.6 };
|
||||
/** Düşüş: yukarıdan gelir, tek kez hafif seker. */
|
||||
const DUSUS = { type: "spring", duration: 0.7, bounce: 0.22 } as const;
|
||||
|
||||
const karma = (i: number, tohum: number) => {
|
||||
const n = Math.sin(i * 127.1 + tohum * 311.7) * 43758.5453;
|
||||
return n - Math.floor(n);
|
||||
};
|
||||
|
||||
// Arkadaki piksel kareler: kenarlarda, orta boş (§10.8 doku kuralı). Konum %.
|
||||
const KARELER = [
|
||||
{ x: 4, y: 12, boy: 27, sicak: false },
|
||||
{ x: 13, y: 78, boy: 17, sicak: true },
|
||||
{ x: 24, y: 2, boy: 17, sicak: false },
|
||||
{ x: 77, y: 0, boy: 27, sicak: false },
|
||||
{ x: 90, y: 22, boy: 17, sicak: true },
|
||||
{ x: 94, y: 70, boy: 37, sicak: false },
|
||||
{ x: 70, y: 90, boy: 17, sicak: false },
|
||||
{ x: 0, y: 52, boy: 17, sicak: false },
|
||||
];
|
||||
|
||||
type Durum = "ssr" | "bekliyor" | "geldi";
|
||||
|
||||
function Hap({
|
||||
hap,
|
||||
sira,
|
||||
durum,
|
||||
azalt,
|
||||
mx,
|
||||
my,
|
||||
}: {
|
||||
hap: KatalogHapi;
|
||||
sira: number;
|
||||
durum: Durum;
|
||||
azalt: boolean;
|
||||
mx: MotionValue<number>;
|
||||
my: MotionValue<number>;
|
||||
}) {
|
||||
// Her hap kendi derinliğinde: öndekiler çok, arkadakiler az kayar
|
||||
const derinlik = 0.45 + karma(sira, 5) * 0.75;
|
||||
const x = useTransform(mx, (v) => v * GUC * derinlik);
|
||||
const y = useTransform(my, (v) => v * GUC * 0.7 * derinlik);
|
||||
const egim = (karma(sira, 9) - 0.5) * 16;
|
||||
const iri = sira % 3 === 0;
|
||||
const Ikon = hap.tur === "sehir" ? MapPin : GraduationCap;
|
||||
const gizli = azalt ? { opacity: 0 } : { opacity: 0, y: -36, rotate: egim };
|
||||
|
||||
return (
|
||||
<motion.div style={{ x, y }}>
|
||||
<motion.div
|
||||
initial={false}
|
||||
animate={durum === "bekliyor" ? gizli : { opacity: 1, y: 0, rotate: 0 }}
|
||||
transition={
|
||||
durum !== "geldi"
|
||||
? { duration: 0 }
|
||||
: azalt
|
||||
? { duration: 0.2, delay: sira * 0.02 }
|
||||
: { ...DUSUS, delay: sira * 0.045, opacity: { duration: 0.2, delay: sira * 0.045 } }
|
||||
}
|
||||
>
|
||||
<Link
|
||||
href={hap.href}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-2 rounded-full border border-slate-200 bg-card font-semibold text-slate-800 transition-[background-color,border-color,color,transform] duration-150 hover:border-orange-500 hover:bg-orange-50/60 hover:text-orange-500 active:scale-[0.97]",
|
||||
iri ? "h-12 px-5 text-base" : "h-11 px-4 text-sm",
|
||||
)}
|
||||
>
|
||||
<Ikon className="size-4 shrink-0 text-primary" aria-hidden />
|
||||
{hap.etiket}
|
||||
<span className="sr-only">
|
||||
{hap.tur === "sehir" ? " üniversiteleri" : " taban puanları"}
|
||||
</span>
|
||||
</Link>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function ArkaKare({
|
||||
kare,
|
||||
mx,
|
||||
my,
|
||||
}: {
|
||||
kare: (typeof KARELER)[number];
|
||||
mx: MotionValue<number>;
|
||||
my: MotionValue<number>;
|
||||
}) {
|
||||
// Arka katman ters yöne ve daha çok kayar: derinlik hissi
|
||||
const x = useTransform(mx, (v) => -v * GUC * 1.4);
|
||||
const y = useTransform(my, (v) => -v * GUC);
|
||||
return (
|
||||
<motion.span
|
||||
className={cn(
|
||||
"absolute rounded",
|
||||
kare.sicak ? "bg-orange-500/70" : "bg-primary/15",
|
||||
)}
|
||||
style={{ left: `${kare.x}%`, top: `${kare.y}%`, width: kare.boy, height: kare.boy, x, y }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function SuzulenHaplar({
|
||||
haplar,
|
||||
className,
|
||||
}: {
|
||||
haplar: KatalogHapi[];
|
||||
className?: string;
|
||||
}) {
|
||||
const kapRef = useRef<HTMLDivElement>(null);
|
||||
const azalt = useReducedMotion() ?? false;
|
||||
// SSR'da haplar görünür çizilir (JS'siz ve bot için). Montajdan sonra bölüm
|
||||
// henüz ekranda değilse gizlenir, girince düşer; zaten ekrandaysa dokunulmaz.
|
||||
const [durum, setDurum] = useState<Durum>("ssr");
|
||||
const hamX = useMotionValue(0);
|
||||
const hamY = useMotionValue(0);
|
||||
const mx = useSpring(hamX, SUZULME_YAYI);
|
||||
const my = useSpring(hamY, SUZULME_YAYI);
|
||||
|
||||
useEffect(() => {
|
||||
const kap = kapRef.current;
|
||||
if (!kap || typeof IntersectionObserver === "undefined") return;
|
||||
let ilk = true;
|
||||
const io = new IntersectionObserver(
|
||||
([kayit]) => {
|
||||
if (kayit.isIntersecting) {
|
||||
setDurum((d) => (d === "bekliyor" ? "geldi" : d));
|
||||
io.disconnect();
|
||||
} else if (ilk) {
|
||||
setDurum("bekliyor");
|
||||
}
|
||||
ilk = false;
|
||||
},
|
||||
{ rootMargin: "0px 0px -80px 0px" },
|
||||
);
|
||||
io.observe(kap);
|
||||
return () => io.disconnect();
|
||||
}, []);
|
||||
|
||||
// İmleç: yalnız fare; kabın ortasına göre −0,5…0,5
|
||||
useEffect(() => {
|
||||
const kap = kapRef.current;
|
||||
if (!kap || azalt || !window.matchMedia("(hover: hover) and (pointer: fine)").matches) {
|
||||
return;
|
||||
}
|
||||
const oynadi = (e: PointerEvent) => {
|
||||
const kutu = kap.getBoundingClientRect();
|
||||
hamX.set(Math.max(-0.5, Math.min(0.5, (e.clientX - kutu.left) / kutu.width - 0.5)));
|
||||
hamY.set(Math.max(-0.5, Math.min(0.5, (e.clientY - kutu.top) / kutu.height - 0.5)));
|
||||
};
|
||||
const cikti = () => {
|
||||
hamX.set(0);
|
||||
hamY.set(0);
|
||||
};
|
||||
kap.addEventListener("pointermove", oynadi, { passive: true });
|
||||
kap.addEventListener("pointerleave", cikti);
|
||||
return () => {
|
||||
kap.removeEventListener("pointermove", oynadi);
|
||||
kap.removeEventListener("pointerleave", cikti);
|
||||
};
|
||||
}, [azalt, hamX, hamY]);
|
||||
|
||||
return (
|
||||
<div ref={kapRef} className={cn("relative isolate px-2 py-10 sm:px-10", className)}>
|
||||
<div aria-hidden className="pointer-events-none absolute inset-0 -z-10">
|
||||
{KARELER.map((kare) => (
|
||||
<ArkaKare key={`${kare.x}-${kare.y}`} kare={kare} mx={mx} my={my} />
|
||||
))}
|
||||
</div>
|
||||
<ul className="mx-auto flex max-w-2xl flex-wrap items-center justify-center gap-x-2.5 gap-y-3">
|
||||
{haplar.map((hap, i) => (
|
||||
<li key={hap.href}>
|
||||
<Hap hap={hap} sira={i} durum={durum} azalt={azalt} mx={mx} my={my} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
157
src/features/pazarlama/components/veri-akisi.tsx
Normal file
157
src/features/pazarlama/components/veri-akisi.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
"use client";
|
||||
|
||||
// Veri akışı (DESIGN.md §4.28): listenin neyle kurulduğunu tek bakışta
|
||||
// gösterir. Ortada KolayTercih işareti, çevresinde altı girdi; her girdiden
|
||||
// merkeze düz bir çizgi iner, üzerinde piksel kare akar. Soldaki üçü
|
||||
// öğrencinin seçimi (turuncu), sağdaki üçü YÖK Atlas verisi (mavi) — ayrımı
|
||||
// renk değil, etiket ve yan taşır; renk yalnız eşlik eder (§2.2).
|
||||
//
|
||||
// Kartsız ve zeminsizdir (Bilal, 7 Eki 2026): sayfa zemininde açıkta durur,
|
||||
// eskiden altında duran sayılar ve dürüstlük notu geniş ekranda iki yanına
|
||||
// dizilir (yerleşim app/page.tsx'te). Ortadaki işaret site başlığındaki
|
||||
// logonun animasyonunu oynar: kareler sırayla birbirinin yerine süzülür
|
||||
// (`AnimasyonluMark` "rota").
|
||||
//
|
||||
// Esin: React Bits "Center Flow" (merkezden çevreye akan çizgiler). Kod bize
|
||||
// ait: parlama/bulanıklık yok (§2.2), akış yönü içeri doğrudur ve yalnız
|
||||
// ekrandayken oynar.
|
||||
|
||||
import { useEffect, useRef, useState, type CSSProperties } from "react";
|
||||
import { Compass, MapPin, PieChart, TrendingUp, Trophy, Users } from "lucide-react";
|
||||
import { AnimasyonluMark } from "@/components/animasyonlu-mark";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Sağ yarı (−60°, 0°, 60°) veri; sol yarı (120°, 180°, 240°) öğrencinin seçimi.
|
||||
const DUGUMLER = [
|
||||
{ etiket: "Taban sıralamaları", ikon: TrendingUp, aci: -60, kaynak: "veri" },
|
||||
{ etiket: "Kontenjanlar", ikon: Users, aci: 0, kaynak: "veri" },
|
||||
{ etiket: "Doluluk oranları", ikon: PieChart, aci: 60, kaynak: "veri" },
|
||||
{ etiket: "Şehir tercihin", ikon: MapPin, aci: 120, kaynak: "sen" },
|
||||
{ etiket: "Senin sıralaman", ikon: Trophy, aci: 180, kaynak: "sen" },
|
||||
{ etiket: "İlgi alanların", ikon: Compass, aci: 240, kaynak: "sen" },
|
||||
] as const;
|
||||
|
||||
/** viewBox 100×100: düğümlerin merkeze uzaklığı. Çizgi merkezden merkeze
|
||||
* çekilir ve karoların altında kalır; akan kare ise karoların dışında
|
||||
* kalan aralıkta yürür (iki uçtaki boşluk). */
|
||||
const YARICAP = 36;
|
||||
const DIS_BOSLUK = 9.5;
|
||||
const IC_BOSLUK = 15.5;
|
||||
/** Akan karenin kenarı (hero noktasının viewBox karşılığı). */
|
||||
const KARE = 2.4;
|
||||
|
||||
const nokta = (aci: number, r: number) => {
|
||||
const rad = (aci * Math.PI) / 180;
|
||||
return { x: 50 + Math.cos(rad) * r, y: 50 + Math.sin(rad) * r };
|
||||
};
|
||||
|
||||
export function VeriAkisi({ className }: { className?: string }) {
|
||||
const kapRef = useRef<HTMLDivElement>(null);
|
||||
const [oynuyor, setOynuyor] = useState(false);
|
||||
|
||||
// Ekran dışındayken akış da işaret de durur (kareler CSS'te `data-oynuyor`
|
||||
// ile, işaret `oynat` ile).
|
||||
useEffect(() => {
|
||||
const kap = kapRef.current;
|
||||
if (!kap || typeof IntersectionObserver === "undefined") return;
|
||||
const io = new IntersectionObserver(([kayit]) => {
|
||||
setOynuyor(kayit.isIntersecting);
|
||||
});
|
||||
io.observe(kap);
|
||||
return () => io.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<figure className={cn("mx-auto w-full max-w-sm", className)}>
|
||||
<div
|
||||
ref={kapRef}
|
||||
data-oynuyor={oynuyor ? "" : undefined}
|
||||
role="img"
|
||||
aria-label="Listen altı şeyden kurulur: senin sıralaman, ilgi alanların ve şehir tercihin; YÖK Atlas'ın taban sıralamaları, kontenjanları ve doluluk oranları."
|
||||
className="group/akis relative aspect-square w-full"
|
||||
>
|
||||
<svg viewBox="0 0 100 100" className="absolute inset-0 size-full" aria-hidden>
|
||||
{DUGUMLER.map((d, i) => {
|
||||
const dugum = nokta(d.aci, YARICAP);
|
||||
const dis = nokta(d.aci, YARICAP - DIS_BOSLUK);
|
||||
const ic = nokta(d.aci, IC_BOSLUK);
|
||||
return (
|
||||
<g key={d.etiket}>
|
||||
{/* Düz çizgi: karodan merkeze; uçları karoların altında */}
|
||||
<line
|
||||
x1={dugum.x}
|
||||
y1={dugum.y}
|
||||
x2={50}
|
||||
y2={50}
|
||||
stroke="var(--color-slate-300)"
|
||||
strokeWidth={1.5}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
<rect
|
||||
x={-KARE / 2}
|
||||
y={-KARE / 2}
|
||||
width={KARE}
|
||||
height={KARE}
|
||||
rx={0.6}
|
||||
className={cn(
|
||||
"kt-akis-kare",
|
||||
d.kaynak === "sen" ? "fill-orange-500" : "fill-primary",
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--x1": `${dis.x}px`,
|
||||
"--y1": `${dis.y}px`,
|
||||
"--x2": `${ic.x}px`,
|
||||
"--y2": `${ic.y}px`,
|
||||
// Sırayla değil, dağınık kalkış: 0 · 1,1 · 0,5 · 1,6 · 0,3 · 1,3 sn
|
||||
animationDelay: `${[0, 1.1, 0.5, 1.6, 0.3, 1.3][i]}s`,
|
||||
} as CSSProperties
|
||||
}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{DUGUMLER.map((d) => {
|
||||
const p = nokta(d.aci, YARICAP);
|
||||
const ustte = d.aci < 0 || d.aci > 180;
|
||||
return (
|
||||
<div
|
||||
key={d.etiket}
|
||||
className="absolute flex -translate-x-1/2 -translate-y-1/2 flex-col items-center"
|
||||
style={{ left: `${p.x}%`, top: `${p.y}%` }}
|
||||
>
|
||||
{/* Beyaz, dolu karo (çizgi altından geçer); yanı ikonun rengi söyler */}
|
||||
<span className="flex size-14 items-center justify-center rounded-2xl border border-slate-200 bg-card">
|
||||
<d.ikon
|
||||
className={cn("size-6", d.kaynak === "sen" ? "text-orange-500" : "text-primary")}
|
||||
aria-hidden
|
||||
/>
|
||||
</span>
|
||||
{/* Üstteki iki düğümde etiket karonun üstünde: altında çizgi merkeze iniyor */}
|
||||
<span
|
||||
className={cn(
|
||||
"absolute w-20 text-center text-xs font-semibold leading-tight text-slate-700",
|
||||
ustte ? "bottom-full mb-1.5" : "top-full mt-1.5",
|
||||
)}
|
||||
>
|
||||
{d.etiket}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="absolute top-1/2 left-1/2 flex size-24 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-3xl border border-slate-200 bg-card">
|
||||
{/* Site başlığındaki logonun animasyonu (rota): orada üzerine
|
||||
gelince, burada ekrandayken sürekli oynar. */}
|
||||
<AnimasyonluMark oynat={oynuyor} className="size-14" />
|
||||
</div>
|
||||
</div>
|
||||
<figcaption className="mt-8 text-center text-sm leading-relaxed text-slate-500">
|
||||
Solda senin seçimlerin, sağda YÖK Atlas verisi. Yapay zekâ yalnız bu
|
||||
ikisinin kesiştiği programlardan seçer.
|
||||
</figcaption>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
129
src/features/pazarlama/landing-verisi.ts
Normal file
129
src/features/pazarlama/landing-verisi.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import "server-only";
|
||||
|
||||
// Ana sayfadaki iki etkileşimli parçanın verisi (sunucuda, build/istek anında):
|
||||
// - Kart destesi demosu (DESIGN.md §4.27): örnek bir sıralamaya göre GERÇEK
|
||||
// programlar. Uydurma taban sıralaması gösterilmez; veri okunamazsa bölüm
|
||||
// hiç çizilmez.
|
||||
// - Süzülen haplar (§4.30): yalnız katalogda sayfası olan bölüm ve şehirler.
|
||||
|
||||
import { dilimAra } from "@/lib/db";
|
||||
import { bolumSlugFromIsim, getIlBySlug } from "@/lib/katalog";
|
||||
import { ozelKontenjanSatiriMi } from "@/lib/rapor-havuzu";
|
||||
import { riskHesapla, type RiskSeviyesi } from "@/lib/risk";
|
||||
import { bolumBazAdi, ilSayfaHref, trBaslikDuzeni, turkishSlugify } from "@/lib/slug";
|
||||
import type { DilimKey } from "@/types/yokatlas";
|
||||
|
||||
/** Demonun örnek öğrencisi: Sayısal, 85.000. sıra (§10.1'deki örnekle aynı). */
|
||||
export const ORNEK_SIRA = 85_000;
|
||||
const ORNEK_TUR = "say" as const;
|
||||
|
||||
export interface OrnekProgram {
|
||||
id: string;
|
||||
isim: string;
|
||||
universite: string;
|
||||
il: string | null;
|
||||
tabanSira: number;
|
||||
risk: RiskSeviyesi;
|
||||
}
|
||||
|
||||
/**
|
||||
* Her dilimden birkaç program: sınıra yapışık olanlar yerine dilimin içine
|
||||
* yayılmış (riskli / az riskli / güvenli farkı kartta görünsün), her üniversite
|
||||
* ve her bölüm adı en çok bir kez.
|
||||
*/
|
||||
export function ornekProgramlar(): OrnekProgram[] {
|
||||
try {
|
||||
const gorulenUni = new Set<string>();
|
||||
const gorulenBolum = new Set<string>();
|
||||
const sec = (dilim: DilimKey, adet: number, adim: number): OrnekProgram[] => {
|
||||
const { programlar } = dilimAra(ORNEK_SIRA, ORNEK_TUR, dilim, {
|
||||
uniturGrubu: "devlet",
|
||||
barajDisiHaric: true,
|
||||
limit: 600,
|
||||
});
|
||||
const secilen: OrnekProgram[] = [];
|
||||
for (let i = adim; i < programlar.length && secilen.length < adet; i += adim) {
|
||||
const p = programlar[i];
|
||||
const bolum = bolumBazAdi(p.isim);
|
||||
const risk = riskHesapla(p.efektifSira, ORNEK_SIRA);
|
||||
if (
|
||||
p.efektifSira == null ||
|
||||
!risk ||
|
||||
ozelKontenjanSatiriMi(p.isim) ||
|
||||
/\(/.test(p.isim) ||
|
||||
gorulenUni.has(p.universite) ||
|
||||
gorulenBolum.has(bolum)
|
||||
) {
|
||||
// Elenen satırın bir sonrakine bak; adım bozulmasın diye geri al
|
||||
i -= adim - 1;
|
||||
continue;
|
||||
}
|
||||
gorulenUni.add(p.universite);
|
||||
gorulenBolum.add(bolum);
|
||||
secilen.push({
|
||||
id: p.id,
|
||||
isim: p.isim,
|
||||
universite: trBaslikDuzeni(p.universite),
|
||||
il: p.il ? trBaslikDuzeni(p.il) : null,
|
||||
tabanSira: p.efektifSira,
|
||||
risk,
|
||||
});
|
||||
}
|
||||
return secilen;
|
||||
};
|
||||
const hayal = sec("hayal", 2, 60);
|
||||
const dengeli = sec("dengeli", 3, 45);
|
||||
const guvenli = sec("garanti", 2, 90);
|
||||
// Sıra karışık: aynı risk art arda gelmesin
|
||||
return [dengeli[0], hayal[0], guvenli[0], dengeli[1], hayal[1], dengeli[2], guvenli[1]].filter(
|
||||
(p): p is OrnekProgram => Boolean(p),
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export interface KatalogHapi {
|
||||
etiket: string;
|
||||
href: string;
|
||||
tur: "bolum" | "sehir";
|
||||
}
|
||||
|
||||
const HAP_BOLUMLERI = [
|
||||
"Tıp",
|
||||
"Hukuk",
|
||||
"Bilgisayar Mühendisliği",
|
||||
"Psikoloji",
|
||||
"Diş Hekimliği",
|
||||
"Hemşirelik",
|
||||
"Mimarlık",
|
||||
"Yazılım Mühendisliği",
|
||||
"Eczacılık",
|
||||
"İşletme",
|
||||
];
|
||||
const HAP_SEHIRLERI = ["İstanbul", "Ankara", "İzmir", "Eskişehir", "Bursa", "Antalya"];
|
||||
|
||||
/** Bölüm ve şehir hapları; katalogda karşılığı olmayan ad sessizce düşer. */
|
||||
export function katalogHaplari(): KatalogHapi[] {
|
||||
try {
|
||||
const bolumler: KatalogHapi[] = HAP_BOLUMLERI.flatMap((ad) => {
|
||||
const slug = bolumSlugFromIsim(ad);
|
||||
return slug ? [{ etiket: ad, href: `/bolum/${slug}`, tur: "bolum" as const }] : [];
|
||||
});
|
||||
const sehirler: KatalogHapi[] = HAP_SEHIRLERI.flatMap((ad) =>
|
||||
getIlBySlug(turkishSlugify(ad))
|
||||
? [{ etiket: ad, href: ilSayfaHref(ad), tur: "sehir" as const }]
|
||||
: [],
|
||||
);
|
||||
// Bölüm ve şehir karışık dizilsin: her üç bölümden sonra bir şehir
|
||||
const sonuc: KatalogHapi[] = [];
|
||||
let s = 0;
|
||||
bolumler.forEach((b, i) => {
|
||||
sonuc.push(b);
|
||||
if (i % 2 === 1 && s < sehirler.length) sonuc.push(sehirler[s++]);
|
||||
});
|
||||
return [...sonuc, ...sehirler.slice(s)];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { toast } from "@/components/ui/bildirim";
|
||||
import { ArrowRight, Check, RefreshCcw, SlidersHorizontal } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
|
||||
@@ -8,12 +8,13 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { SecimHaplari } from "@/components/ui/secim-haplari";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerTitle,
|
||||
} from "@/components/ui/drawer";
|
||||
AltSayfa,
|
||||
AltSayfaKapat,
|
||||
AltSayfaGovde,
|
||||
AltSayfaIcerik,
|
||||
AltSayfaAciklama,
|
||||
AltSayfaBaslik,
|
||||
} from "@/components/ui/alt-sayfa";
|
||||
import { PikselZemin } from "@/components/piksel-zemin";
|
||||
import { useGorunumOlayi } from "@/components/use-gorunum-olayi";
|
||||
import {
|
||||
@@ -305,19 +306,15 @@ export function CtaSiraForm({
|
||||
// başlığı yalnız ekran okuyucuya.
|
||||
const siraAdimi = !kompakt && adim === "sira";
|
||||
const cekmece = (
|
||||
<Drawer open={acik} onOpenChange={acikDegisti} handleOnly>
|
||||
<DrawerContent
|
||||
className="max-h-[92dvh] bg-card sm:mx-auto sm:max-w-2xl sm:border-x sm:border-slate-200 sm:shadow-2xl"
|
||||
>
|
||||
<DrawerClose className="absolute top-3 right-3 z-10 flex size-9 cursor-pointer items-center justify-center rounded-full border border-slate-200 bg-popover text-slate-500 transition-[background-color,transform] duration-150 hover:bg-slate-100 hover:text-slate-800 active:scale-[0.96]">
|
||||
<AltSayfa open={acik} onOpenChange={acikDegisti}>
|
||||
<AltSayfaIcerik>
|
||||
<AltSayfaKapat className="absolute top-3 right-3 z-10 flex size-9 cursor-pointer items-center justify-center rounded-full border border-slate-200 bg-popover text-slate-500 transition-[background-color,transform] duration-150 hover:bg-slate-100 hover:text-slate-800 active:scale-[0.96]">
|
||||
<X className="size-4" aria-hidden />
|
||||
<span className="sr-only">Kapat</span>
|
||||
</DrawerClose>
|
||||
</AltSayfaKapat>
|
||||
|
||||
<div
|
||||
className="min-h-0 flex-1 overflow-y-auto px-4 pt-5 pb-[max(1.25rem,env(safe-area-inset-bottom))] sm:px-6"
|
||||
>
|
||||
<DrawerTitle
|
||||
<AltSayfaGovde className="px-4 pt-3 pb-[max(1.25rem,env(safe-area-inset-bottom))] sm:px-6">
|
||||
<AltSayfaBaslik
|
||||
className={
|
||||
siraAdimi
|
||||
? "px-10 text-center font-heading text-2xl font-bold text-slate-900"
|
||||
@@ -325,8 +322,8 @@ export function CtaSiraForm({
|
||||
}
|
||||
>
|
||||
Sıralamana yakın programlar hangileri?
|
||||
</DrawerTitle>
|
||||
<DrawerDescription
|
||||
</AltSayfaBaslik>
|
||||
<AltSayfaAciklama
|
||||
className={
|
||||
siraAdimi
|
||||
? "mx-auto mt-1 max-w-md px-4 text-center text-sm text-slate-500"
|
||||
@@ -336,7 +333,7 @@ export function CtaSiraForm({
|
||||
Puan türünü seç, sıralamanı yaz; hayal, dengeli ve güvenli
|
||||
dilimlerindeki programları gerçek YÖK Atlas verisiyle bu sayfadan
|
||||
ayrılmadan gör. Ücretsiz, üyelik gerekmez.
|
||||
</DrawerDescription>
|
||||
</AltSayfaAciklama>
|
||||
{siraAdimi ? (
|
||||
<div className="mt-5">{siraFormu}</div>
|
||||
) : facetler && sira != null ? (
|
||||
@@ -351,9 +348,9 @@ export function CtaSiraForm({
|
||||
onTamamla={secimleriTamamla}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</AltSayfaGovde>
|
||||
</AltSayfaIcerik>
|
||||
</AltSayfa>
|
||||
);
|
||||
|
||||
if (kompakt) {
|
||||
|
||||
@@ -20,12 +20,9 @@ import {
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { olay, siraKovasi } from "@/lib/analitik";
|
||||
import { onbellekliJsonGetir } from "@/lib/istek-cache";
|
||||
import {
|
||||
profilDuzenlemeyiAc,
|
||||
useTercihProfili,
|
||||
} from "@/features/liste/hooks/use-tercih-profili";
|
||||
import { useTercihProfili } from "@/features/liste/hooks/use-tercih-profili";
|
||||
import type { SihirbazFacetleri } from "@/types/yokatlas";
|
||||
import { PUAN_TURU_ETIKET, sonucHref, type SihirbazSecimleri } from "@/features/sihirbaz/sihirbaz-sabitler";
|
||||
import { sonucHref, type SihirbazSecimleri } from "@/features/sihirbaz/sihirbaz-sabitler";
|
||||
import { tercihProfiliYaz } from "@/features/sihirbaz/sihirbaz-profil";
|
||||
|
||||
// lib/db sunucu tarafı; etiketler client bundle'a sızmasın diye burada.
|
||||
@@ -107,11 +104,10 @@ export function HeroForm() {
|
||||
}
|
||||
|
||||
// Kayıtlı profil varsa sihirbazı atla, doğrudan sonuç sayfasına götür.
|
||||
// "Farklı sıralama gir" navbar'daki Değiştir ile aynı düzenleme modalını
|
||||
// (TercihProfiliKapisi, layout'ta monte) açar.
|
||||
// Sıralamayı değiştirmek navbar'daki sıralama hapından yapılır.
|
||||
if (kayitliProfil) {
|
||||
return (
|
||||
<div className="flex w-full max-w-md flex-col items-center gap-3">
|
||||
<div className="flex w-full max-w-md flex-col items-center">
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
@@ -122,18 +118,6 @@ export function HeroForm() {
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Link>
|
||||
</Button>
|
||||
<p className="text-sm text-slate-600">
|
||||
{kayitliProfil.sira.toLocaleString("tr-TR")} ·{" "}
|
||||
{PUAN_TURU_ETIKET[kayitliProfil.tur] ?? kayitliProfil.tur} sıralamanla
|
||||
devam edersin.{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={profilDuzenlemeyiAc}
|
||||
className="cursor-pointer font-medium text-slate-900 underline underline-offset-2 hover:text-slate-700"
|
||||
>
|
||||
Farklı sıralama gir
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import { ArrowLeft, ArrowRight, Search, X } from "lucide-react";
|
||||
import { useReducedMotion } from "motion/react";
|
||||
import useMeasure from "react-use-measure";
|
||||
import { toast } from "sonner";
|
||||
import { toast } from "@/components/ui/bildirim";
|
||||
import { cities } from "turkey-map-react/lib/data";
|
||||
import { TransitionPanel } from "@/components/core/transition-panel";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -23,47 +23,8 @@ import { trBaslikDuzeni } from "@/lib/slug";
|
||||
import type { SihirbazFacetleri } from "@/types/yokatlas";
|
||||
import { ONCELIKLER, UNIVERSITE_TIPI_ETIKET, type SihirbazSecimleri, type UniversiteTipi } from "@/features/sihirbaz/sihirbaz-sabitler";
|
||||
import { OZEL_DURUMLAR, OZEL_DURUM_ETIKET, type OzelDurum } from "@/lib/ozel-durum";
|
||||
|
||||
export function Cip({
|
||||
secili,
|
||||
onClick,
|
||||
children,
|
||||
kucuk = false,
|
||||
ton = "notr",
|
||||
className = "",
|
||||
}: {
|
||||
secili: boolean;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
kucuk?: boolean;
|
||||
/**
|
||||
* bilgi: özel kontenjan durumu çipi (DESIGN.md §4.21) — pasif hâli bilgi
|
||||
* mavisi tonunda (§2 `primary` rolü), seçili hâli nötr çiple aynı.
|
||||
*/
|
||||
ton?: "notr" | "bilgi";
|
||||
className?: string;
|
||||
}) {
|
||||
// Şişko ve sade (DESIGN.md §1.1): çip 44 px dokunma hedefi; il
|
||||
// çipleri (kucuk) çok sayıda olduğu için 40 px. Hover §2.1 tek turuncu.
|
||||
const boyut = kucuk ? "h-10 px-3.5 text-sm" : "h-11 px-4 text-sm";
|
||||
const temel = `inline-flex cursor-pointer items-center rounded-full border font-semibold transition-[background-color,border-color,color,transform] duration-150 active:scale-[0.97] ${boyut} ${className}`;
|
||||
const pasif =
|
||||
ton === "bilgi"
|
||||
? "border-primary/30 bg-primary/10 text-primary hover:border-orange-500 hover:bg-orange-50/60 hover:text-orange-500"
|
||||
: "border-slate-200 bg-card text-slate-700 hover:border-orange-500 hover:bg-orange-50/60 hover:text-orange-500";
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-pressed={secili}
|
||||
className={
|
||||
secili ? `${temel} border-primary bg-primary text-white` : `${temel} ${pasif}`
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
// Seçim çipi tek kaynaktan (DESIGN.md §4.24): seçilince tik büyür, yazı kayar.
|
||||
import { CipGrubu, SecimCipi as Cip } from "@/components/ui/cip-grubu";
|
||||
|
||||
const ADIM_ADLARI = ["Alan", "Şehir", "Öncelik"] as const;
|
||||
|
||||
@@ -337,7 +298,7 @@ export function SihirbazAdimlar({
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
Sıralamanla ulaşabileceğin alanlar. En az bir tane seç.
|
||||
</p>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<CipGrubu etiket="İlgi alanları" className="mt-4">
|
||||
{facetler.kategoriler.map((k) => (
|
||||
<Cip
|
||||
key={k.ad}
|
||||
@@ -348,7 +309,7 @@ export function SihirbazAdimlar({
|
||||
<span className="ml-1.5 text-xs font-medium opacity-70">{k.adet}</span>
|
||||
</Cip>
|
||||
))}
|
||||
</div>
|
||||
</CipGrubu>
|
||||
</div>
|
||||
<div ref={panelRef}>
|
||||
<h3 className="font-heading text-xl font-bold text-slate-900">
|
||||
@@ -387,8 +348,9 @@ export function SihirbazAdimlar({
|
||||
görünüm ilk 10 il; kalanı "+N il daha" rozetiyle açılır. Arama
|
||||
yalnızca görünümü daraltır, seçimleri etkilemez ve kırpmayı
|
||||
devre dışı bırakır. */}
|
||||
<div
|
||||
className={`mt-3 flex max-h-64 flex-wrap gap-2 overflow-y-auto sm:max-h-none sm:overflow-visible ${
|
||||
<CipGrubu
|
||||
etiket="Şehirler"
|
||||
className={`mt-3 max-h-64 overflow-y-auto sm:max-h-none sm:overflow-visible ${
|
||||
facetYukleniyor ? "animate-pulse opacity-60" : ""
|
||||
}`}
|
||||
>
|
||||
@@ -424,7 +386,7 @@ export function SihirbazAdimlar({
|
||||
Daha az göster
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</CipGrubu>
|
||||
|
||||
{ilAramaNorm &&
|
||||
gorunenTurkiyeIlleri.length === 0 &&
|
||||
@@ -439,8 +401,9 @@ export function SihirbazAdimlar({
|
||||
<p className="mb-2 text-sm font-semibold text-slate-700">
|
||||
KKTC & Yurt Dışı
|
||||
</p>
|
||||
<div
|
||||
className={`flex flex-wrap gap-2 ${
|
||||
<CipGrubu
|
||||
etiket="KKTC ve yurt dışı"
|
||||
className={`${
|
||||
facetYukleniyor ? "animate-pulse opacity-60" : ""
|
||||
}`}
|
||||
>
|
||||
@@ -455,7 +418,7 @@ export function SihirbazAdimlar({
|
||||
<span className="ml-1.5 text-xs font-medium opacity-70">{i.adet}</span>
|
||||
</Cip>
|
||||
))}
|
||||
</div>
|
||||
</CipGrubu>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -500,8 +463,9 @@ export function SihirbazAdimlar({
|
||||
<p className="mb-2 text-sm font-semibold text-slate-700">
|
||||
Üniversite tipi
|
||||
</p>
|
||||
<div
|
||||
className={`flex flex-wrap gap-2 ${
|
||||
<CipGrubu
|
||||
etiket="Üniversite tipi"
|
||||
className={`${
|
||||
facetYukleniyor ? "animate-pulse opacity-60" : ""
|
||||
}`}
|
||||
>
|
||||
@@ -525,7 +489,7 @@ export function SihirbazAdimlar({
|
||||
>
|
||||
Fark etmez
|
||||
</Cip>
|
||||
</div>
|
||||
</CipGrubu>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-semibold text-slate-700">
|
||||
@@ -533,7 +497,7 @@ export function SihirbazAdimlar({
|
||||
</p>
|
||||
{/* Telefonda iki sütun, uzun ad iki satıra sarar: tek sütunda
|
||||
yedi çip 44 px'le pencereyi 812 px'in dışına itiyordu. */}
|
||||
<div className="grid grid-cols-2 gap-2 sm:flex sm:flex-wrap">
|
||||
<CipGrubu etiket="Senin için önemli olan" className="grid grid-cols-2 sm:flex sm:flex-wrap">
|
||||
{ONCELIKLER.map((o) => (
|
||||
<Cip
|
||||
key={o}
|
||||
@@ -544,7 +508,7 @@ export function SihirbazAdimlar({
|
||||
{o}
|
||||
</Cip>
|
||||
))}
|
||||
</div>
|
||||
</CipGrubu>
|
||||
<p className="mt-2 text-sm text-slate-500">
|
||||
Listeyi daraltmaz; programların sırasını ve gerekçelerini
|
||||
yönlendirir (istihdam ve kazanç TÜİK 2025, araştırma sırası URAP,
|
||||
@@ -559,7 +523,7 @@ export function SihirbazAdimlar({
|
||||
Özel kontenjan durumun{" "}
|
||||
<span className="font-normal text-slate-500">(isteğe bağlı)</span>
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2 sm:flex sm:flex-wrap">
|
||||
<CipGrubu etiket="Özel kontenjan durumun" className="grid grid-cols-2 sm:flex sm:flex-wrap">
|
||||
{OZEL_DURUMLAR.map((d) => (
|
||||
<Cip
|
||||
key={d}
|
||||
@@ -577,7 +541,7 @@ export function SihirbazAdimlar({
|
||||
{OZEL_DURUM_ETIKET[d]}
|
||||
</Cip>
|
||||
))}
|
||||
</div>
|
||||
</CipGrubu>
|
||||
<p className="mt-2 text-sm text-slate-500">
|
||||
ÖSYM bu durumlar için ayrı kontenjan ayırır. İşaretlersen o
|
||||
kontenjanı olan programlar listende öne çıkar ve danışman bunu
|
||||
|
||||
Reference in New Issue
Block a user