Add "detay" script to package.json and enhance ListemSekmeleri component functionality
All checks were successful
Deploy / deploy (push) Successful in 16m29s

- Introduced a new "detay" script in package.json for additional processing.
- Refactored ListemSekmeleri component to improve tab functionality and state management.
- Updated UI elements for better user interaction and clarity, including renaming "Seçtiklerim" to "Kendi Listem" for consistency across the application.
- Made adjustments to related components for improved integration and user experience.
This commit is contained in:
bilalgursen
2026-08-01 21:58:28 +03:00
parent f27f3ffaac
commit 716f7bb3fb
17 changed files with 506 additions and 105 deletions

View File

@@ -1,36 +1,85 @@
"use client";
import { useState } from "react";
import { ListChecks, Sparkles } from "lucide-react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Tabs as TabsPrimitive } from "radix-ui";
import { SecimlerimPaneli } from "@/components/manuel-liste/secimlerim-paneli";
import { useManuelListe } from "@/components/manuel-liste/store";
export type ListemSekme = "secimlerim" | "ai";
export function ListemSekmeleri({
varsayilan,
aiIcerik,
aiHazir,
}: {
varsayilan: ListemSekme;
aiIcerik: React.ReactNode;
aiHazir?: boolean;
}) {
const [sekme, setSekme] = useState<ListemSekme>(varsayilan);
const liste = useManuelListe();
function degistir(deger: string) {
const yeni = deger as ListemSekme;
setSekme(yeni);
const url = new URL(window.location.href);
url.searchParams.set("sekme", yeni);
window.history.replaceState(null, "", url);
}
return (
<Tabs defaultValue={varsayilan} className="mt-2 gap-6">
<TabsList variant="line" className="h-auto w-full justify-start gap-1">
<TabsTrigger value="secimlerim" className="cursor-pointer px-3 py-2.5">
<TabsPrimitive.Root value={sekme} onValueChange={degistir} className="mt-6">
<TabsPrimitive.List
aria-label="Liste görünümü"
className="relative mx-auto grid w-full max-w-md grid-cols-2 rounded-full border border-slate-200 bg-slate-100 p-1"
>
<span
aria-hidden
className="absolute inset-y-1 left-1 w-[calc(50%-0.25rem)] rounded-full bg-white shadow-sm transition-transform duration-200 [transition-timing-function:cubic-bezier(0.23,1,0.32,1)]"
style={{
transform: sekme === "ai" ? "translateX(100%)" : "translateX(0)",
}}
/>
<TabsPrimitive.Trigger
value="secimlerim"
className="relative z-10 flex h-10 cursor-pointer items-center justify-center gap-2 rounded-full text-sm font-semibold text-slate-500 transition-colors duration-200 hover:text-slate-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring data-[state=active]:text-slate-900"
>
<ListChecks className="size-4" aria-hidden />
Seçtiklerim
</TabsTrigger>
<TabsTrigger value="ai" className="cursor-pointer px-3 py-2.5">
Kendi Listem
{liste.length > 0 ? (
<span className="min-w-5 rounded-full bg-primary/10 px-1.5 py-0.5 text-center text-[11px] font-bold tabular-nums text-primary">
{liste.length}
</span>
) : null}
</TabsPrimitive.Trigger>
<TabsPrimitive.Trigger
value="ai"
className="relative z-10 flex h-10 cursor-pointer items-center justify-center gap-2 rounded-full text-sm font-semibold text-slate-500 transition-colors duration-200 hover:text-slate-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring data-[state=active]:text-slate-900"
>
<Sparkles className="size-4" aria-hidden />
AI Listem
</TabsTrigger>
</TabsList>
<TabsContent value="secimlerim" className="mt-0">
{aiHazir ? (
<span
className="size-1.5 rounded-full bg-emerald-500"
aria-hidden
title="AI listen hazır"
/>
) : null}
</TabsPrimitive.Trigger>
</TabsPrimitive.List>
<TabsPrimitive.Content
value="secimlerim"
className="mt-6 outline-none data-[state=active]:animate-in data-[state=active]:fade-in-0 data-[state=active]:slide-in-from-bottom-1 data-[state=active]:duration-200"
>
<SecimlerimPaneli />
</TabsContent>
<TabsContent value="ai" className="mt-0">
</TabsPrimitive.Content>
<TabsPrimitive.Content
value="ai"
className="mt-6 outline-none data-[state=active]:animate-in data-[state=active]:fade-in-0 data-[state=active]:slide-in-from-bottom-1 data-[state=active]:duration-200"
>
{aiIcerik}
</TabsContent>
</Tabs>
</TabsPrimitive.Content>
</TabsPrimitive.Root>
);
}

View File

@@ -85,7 +85,7 @@ export default async function ListemPage({
<p className="mt-3 text-slate-600">
Sıralamanı girip birkaç adımda 24 tercihlik risk analizli listeni kur;
sonra burada görüntüleyip AI danışmanla üzerinde konuşabilirsin.
Seçtiklerim sekmesinde ise tablolardan eklediğin programlar durur.
Kendi Listem sekmesinde ise tablolardan eklediğin programlar durur.
</p>
<PagePixelDivider seed={67} className="mx-auto mt-6" />
<Button
@@ -101,17 +101,17 @@ export default async function ListemPage({
if (!raporVar) {
return (
<main className="mx-auto w-full max-w-7xl px-4 py-8 sm:py-10">
<h1 className="font-heading text-2xl font-bold tracking-tight sm:text-3xl">
Tercih karar merkezin
</h1>
<p className="mt-2 max-w-2xl text-sm leading-6 text-slate-600">
Seçtiklerin ve AI listen burada yan yana ikisi birbirinden bağımsız.
</p>
<PagePixelDivider seed={71} className="mt-6" />
<ListemSekmeleri
varsayilan={varsayilanSekme}
aiIcerik={aiBos}
/>
<div className="mx-auto max-w-2xl text-center">
<h1 className="font-heading text-2xl font-bold tracking-tight sm:text-3xl">
Tercih karar merkezin
</h1>
<p className="mt-2 text-sm leading-6 text-slate-600">
Kendi listen ve AI listen burada yan yana ikisi birbirinden
bağımsız.
</p>
<PagePixelDivider seed={71} className="mx-auto mt-6" />
</div>
<ListemSekmeleri varsayilan={varsayilanSekme} aiIcerik={aiBos} />
</main>
);
}
@@ -375,14 +375,21 @@ export default async function ListemPage({
return (
<main className="mx-auto w-full max-w-7xl px-4 py-8 sm:py-10">
<h1 className="font-heading text-2xl font-bold tracking-tight sm:text-3xl">
Tercih karar merkezin
</h1>
<p className="mt-2 max-w-2xl text-sm leading-6 text-slate-600">
Seçtiklerin ve AI listen burada yan yana ikisi birbirinden bağımsız.
</p>
<PagePixelDivider seed={71} className="mt-6" />
<ListemSekmeleri varsayilan={varsayilanSekme} aiIcerik={aiIcerik} />
<div className="mx-auto max-w-2xl text-center">
<h1 className="font-heading text-2xl font-bold tracking-tight sm:text-3xl">
Tercih karar merkezin
</h1>
<p className="mt-2 text-sm leading-6 text-slate-600">
Kendi listen ve AI listen burada yan yana ikisi birbirinden
bağımsız.
</p>
<PagePixelDivider seed={71} className="mx-auto mt-6" />
</div>
<ListemSekmeleri
varsayilan={varsayilanSekme}
aiIcerik={aiIcerik}
aiHazir
/>
</main>
);
}

View File

@@ -13,6 +13,8 @@ import type { SihirbazFacetleri } from "@/lib/db";
import {
SIHIRBAZ_STORAGE_KEY,
sihirbazDogrula,
tercihProfiliSil,
tercihProfiliYaz,
type SihirbazSecimleri,
} from "@/lib/sihirbaz";
import { SihirbazModal } from "./sihirbaz-modal";
@@ -210,9 +212,7 @@ export function SihirbazBolumu({
});
setKredi(sonuc.kredi);
setPaketli(sonuc.hasPaket);
try {
localStorage.removeItem(SIHIRBAZ_STORAGE_KEY);
} catch {}
tercihProfiliSil();
// AI çıktısı bu sayfada gösterilmez: üretim animasyonu açık kalır,
// liste (paketsizse paywall'lı hâliyle) /listem'de karşılar.
router.push("/listem");
@@ -240,12 +240,7 @@ export function SihirbazBolumu({
}
function secimleriKaydetVeGirise(secimler: SihirbazSecimleri) {
try {
localStorage.setItem(
SIHIRBAZ_STORAGE_KEY,
JSON.stringify({ sira, tur, secimler }),
);
} catch {}
tercihProfiliYaz({ sira, tur, secimler });
const geri = `/sonuc?sira=${sira}&tur=${tur}&sihirbaz=1`;
router.push(`/giris?callback=${encodeURIComponent(geri)}`);
}

View File

@@ -17,7 +17,7 @@ import {
} from "@/components/sihirbaz-adimlar-lazy";
import { authClient } from "@/lib/auth-client";
import type { SihirbazFacetleri } from "@/lib/db";
import { SIHIRBAZ_STORAGE_KEY, type SihirbazSecimleri } from "@/lib/sihirbaz";
import { tercihProfiliYaz, type SihirbazSecimleri } from "@/lib/sihirbaz";
// lib/db sunucu tarafı; etiketler client bundle'a sızmasın diye burada.
const PUAN_TURLERI_SECENEK = [
@@ -82,12 +82,7 @@ export function HeroForm() {
// oradan gelir (değer önce — giriş duvarı sihirbazın hemen arkasında değil).
function secimleriTamamla(secimler: SihirbazSecimleri) {
if (!sira) return;
try {
localStorage.setItem(
SIHIRBAZ_STORAGE_KEY,
JSON.stringify({ sira, tur, secimler }),
);
} catch {}
tercihProfiliYaz({ sira, tur, secimler });
setModalAcik(false);
router.push(
girisli

View File

@@ -6,12 +6,19 @@ import { RehberKapak } from "@/components/rehber-kapak";
import { UniLogo } from "@/components/uni-logo";
import { aramaNormalize, eslesmePuani } from "@/lib/arama";
import type { RehberOzet } from "@/lib/rehber";
import { PUAN_TURU_ETIKET } from "@/lib/sihirbaz";
import {
profilDuzenlemeyiAc,
useTercihProfili,
} from "@/components/manuel-liste/profil-kapisi-store";
import {
ArrowRight,
BookOpenText,
Building2,
GraduationCap,
Search,
SquarePen,
Trophy,
X,
} from "lucide-react";
import {
@@ -50,6 +57,7 @@ const REHBER_SONUC_SINIRI = 4;
const sayi = (deger: number) => deger.toLocaleString("tr-TR");
export function KatalogArama({ rehberler }: { rehberler: RehberOzet[] }) {
const profil = useTercihProfili();
const [acik, setAcik] = useState(false);
const [sorgu, setSorgu] = useState("");
const [sonuclar, setSonuclar] = useState<AramaSonuclari>(BOS_SONUCLAR);
@@ -170,18 +178,43 @@ export function KatalogArama({ rehberler }: { rehberler: RehberOzet[] }) {
<>
{/* lg altında logo + sağ küme ile çakışır (absolute); o aralıkta gizli */}
<nav className="absolute left-1/2 hidden -translate-x-1/2 items-center gap-1 rounded-full border border-slate-200 bg-white p-1.5 text-base font-medium text-slate-600 lg:flex">
<Link
href="/#nasil-calisir"
className="rounded-full px-5 py-2 transition-colors duration-200 hover:bg-slate-100 hover:text-slate-900"
>
Nasıl çalışır?
</Link>
<Link
href="/#karsilastirma"
className="rounded-full px-5 py-2 transition-colors duration-200 hover:bg-slate-100 hover:text-slate-900"
>
Karşılaştır
</Link>
{profil ? (
<button
type="button"
onClick={profilDuzenlemeyiAc}
aria-label={`Sıralaman ${profil.sira.toLocaleString("tr-TR")} (${PUAN_TURU_ETIKET[profil.tur] ?? profil.tur}) — tercihlerini düzenle`}
className="group/profil flex cursor-pointer items-center gap-2.5 rounded-full py-1.5 pl-4 pr-1.5 transition-colors duration-200 hover:bg-slate-100"
>
<Trophy className="size-4 text-orange-500" aria-hidden />
<span className="flex items-baseline gap-1.5">
<span className="font-semibold tabular-nums tracking-tight text-slate-900">
{profil.sira.toLocaleString("tr-TR")}
</span>
<span className="text-xs font-medium text-slate-400">
{PUAN_TURU_ETIKET[profil.tur] ?? profil.tur}
</span>
</span>
<span className="flex items-center gap-1 rounded-full bg-slate-100 px-2.5 py-1.5 text-xs font-medium text-slate-500 transition-colors duration-200 group-hover/profil:bg-white group-hover/profil:text-slate-900">
<SquarePen className="size-3" aria-hidden />
Düzenle
</span>
</button>
) : (
<>
<Link
href="/#nasil-calisir"
className="rounded-full px-5 py-2 transition-colors duration-200 hover:bg-slate-100 hover:text-slate-900"
>
Nasıl çalışır?
</Link>
<Link
href="/#karsilastirma"
className="rounded-full px-5 py-2 transition-colors duration-200 hover:bg-slate-100 hover:text-slate-900"
>
Karşılaştır
</Link>
</>
)}
<button
type="button"
onClick={() => setAcik(true)}

View File

@@ -49,10 +49,10 @@ import {
} from "./store";
// Risk renk dili ürünün geri kalanıyla birebir aynı (bkz. rapor-listesi)
const RISK_STIL: Record<RiskSeviyesi, { nokta: string; kenar: string }> = {
guvenli: { nokta: "bg-emerald-500", kenar: "border-l-emerald-500" },
"az-riskli": { nokta: "bg-amber-500", kenar: "border-l-amber-500" },
riskli: { nokta: "bg-red-500", kenar: "border-l-red-500" },
const RISK_NOKTA: Record<RiskSeviyesi, string> = {
guvenli: "bg-emerald-500",
"az-riskli": "bg-amber-500",
riskli: "bg-red-500",
};
export function ListeCekmecesi() {
@@ -137,7 +137,7 @@ function CekmeceSheet() {
<div className="flex items-center justify-between gap-3">
<h2 className="flex items-center gap-2 font-heading text-lg font-bold">
<ListChecks className="size-5 text-primary" aria-hidden />
Seçtiklerim
Kendi Listem
<span className="font-heading text-sm font-bold tabular-nums text-slate-400">
{liste.length}/{MANUEL_LISTE_MAX}
</span>
@@ -182,6 +182,26 @@ function CekmeceSheet() {
Bölüm, üniversite veya sonuç tablolarından eklediğin programlar
tutamaktan sürükleyip sırala, sağa kaydırıp sil.
</p>
<ul
className="mt-2.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-slate-500"
aria-label="Yerleşme riski renkleri"
>
{(
[
["guvenli", "yeşil güvenli"],
["az-riskli", "turuncu az riskli"],
["riskli", "kırmızı riskli"],
] as const
).map(([seviye, aciklama]) => (
<li key={seviye} className="inline-flex items-center gap-1.5">
<span
className={`size-2 shrink-0 rounded-full ${RISK_NOKTA[seviye]}`}
aria-hidden
/>
<span>{aciklama}</span>
</li>
))}
</ul>
</motion.div>
{/* Gövde: sürüklenebilir satırlar */}
@@ -235,7 +255,7 @@ function CekmeceSheet() {
className="inline-flex items-center gap-1.5 text-sm font-medium text-primary transition-colors duration-200 hover:text-primary/80"
>
<ListChecks className="size-4" aria-hidden />
Seçtiklerimi ve uyum notlarını
Kendi listemi ve uyum notlarını
</Link>
<Link
href="/listem?sekme=ai"
@@ -263,7 +283,6 @@ function CekmeceSatiri({
sira: number;
projeksiyonAcik: boolean;
}) {
const stil = tercih.risk ? RISK_STIL[tercih.risk] : null;
const siraKontrol = useDragControls();
// Kartın yatay konumu: sil bandının görünürlüğü buna bağlı
@@ -291,11 +310,7 @@ function CekmeceSatiri({
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, x: 96, transition: { duration: 0.18 } }}
transition={{ type: "spring", duration: 0.4, bounce: 0 }}
whileDrag={{
scale: 1.03,
boxShadow: "0 12px 32px rgba(15, 23, 42, 0.16)",
zIndex: 10,
}}
whileDrag={{ scale: 1.03, zIndex: 10 }}
className="relative select-none"
>
{/* Sil bandı: kart sağa kaydıkça altından görünür */}
@@ -315,9 +330,7 @@ function CekmeceSatiri({
dragElastic={{ left: 0, right: 0.7 }}
dragSnapToOrigin
onDragEnd={kaydirinca}
className={`relative flex items-center gap-2.5 rounded-xl border border-l-4 border-slate-200 bg-white px-3 py-2.5 ${
stil?.kenar ?? "border-l-slate-300"
}`}
className="relative flex items-center gap-2.5 rounded-xl border border-slate-200 bg-white px-3 py-2.5"
>
<button
type="button"
@@ -350,7 +363,7 @@ function CekmeceSatiri({
) : null}
{tercih.risk ? (
<span
className={`size-2.5 shrink-0 rounded-full ${RISK_STIL[tercih.risk].nokta}`}
className={`size-2.5 shrink-0 rounded-full ${RISK_NOKTA[tercih.risk]}`}
aria-label={RISK_ETIKET[tercih.risk]}
/>
) : null}

View File

@@ -27,13 +27,13 @@ export function ListemButonu() {
type="button"
onClick={cekmeceDegistir}
aria-expanded={acik}
aria-label={`Seçtiklerim — ${liste.length}/${MANUEL_LISTE_MAX} program`}
aria-label={`Kendi listem — ${liste.length}/${MANUEL_LISTE_MAX} program`}
title="Tablodan seçtiğin programlar"
className="relative inline-flex h-9 cursor-pointer items-center gap-1.5 whitespace-nowrap rounded-full border border-slate-200 bg-white px-3 text-xs font-semibold text-slate-700 transition-[color,background-color,border-color,transform] duration-200 hover:border-slate-300 hover:bg-slate-50 active:scale-[0.97] sm:h-12 sm:gap-2 sm:px-5 sm:text-sm"
>
<ListChecks className="size-3.5 text-primary sm:size-4" aria-hidden />
{/* lgxl: ortadaki nav ile yer dar — metin gizli, ikon+rozet kalır */}
<span className="hidden sm:inline lg:hidden xl:inline">Seçtiklerim</span>
<span className="hidden sm:inline lg:hidden xl:inline">Kendi Listem</span>
{liste.length > 0 ? (
// key=length: her eklemede rozet yeniden doğar ve spring ile oturur
<motion.span

View File

@@ -6,6 +6,7 @@
import { useSyncExternalStore } from "react";
import type { Program } from "@/lib/db";
import {
PROFIL_DEGISTI_EVENT,
tercihProfiliOku,
tercihProfiliTamamlandiMi,
type TercihProfili,
@@ -75,6 +76,12 @@ function yukle() {
profiliTazele();
yayinla();
});
// Aynı sekmedeki yazmalar (hero formu, AI sihirbazı) storage olayı
// tetiklemez; tercihProfiliYaz/Sil bu olayı yayınlar.
window.addEventListener(PROFIL_DEGISTI_EVENT, () => {
profiliTazele();
yayinla();
});
}
function abone(cb: () => void) {

View File

@@ -80,7 +80,7 @@ export function SecimlerimPaneli() {
<div>
<h2 className="flex items-center gap-2 font-heading text-xl font-bold">
<ListChecks className="size-5 text-primary" aria-hidden />
Seçtiklerim
Kendi Listem
<span className="text-sm font-bold tabular-nums text-slate-400">
{liste.length}/{MANUEL_LISTE_MAX}
</span>

View File

@@ -32,6 +32,7 @@ import {
} from "@/components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ProgramEkleButonu } from "@/components/manuel-liste/program-ekle-butonu";
import { UniLogo } from "@/components/uni-logo";
const DILIMLER: {
key: DilimKey;
@@ -337,31 +338,38 @@ function ProgramSatiri({
) : null}
</TableCell>
<TableCell className="max-w-0 w-full">
<span className="block truncate text-sm font-semibold">{p.isim}</span>
<span className="mt-0.5 flex items-center gap-1.5 text-xs text-slate-500">
<span
className={`shrink-0 rounded px-1 py-px text-[10px] font-medium leading-4 ${
devlet
? "bg-slate-100 text-slate-600"
: "bg-violet-50 text-violet-600"
}`}
>
{devlet ? "Devlet" : "Vakıf"}
<span className="flex min-w-0 items-center gap-2.5">
<UniLogo ad={p.universite} boy="sm" />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-semibold">
{p.isim}
</span>
<span className="mt-0.5 flex items-center gap-1.5 text-xs text-slate-500">
<span
className={`shrink-0 rounded px-1 py-px text-[10px] font-medium leading-4 ${
devlet
? "bg-slate-100 text-slate-600"
: "bg-violet-50 text-violet-600"
}`}
>
{devlet ? "Devlet" : "Vakıf"}
</span>
<span className="truncate">
<Link
href={`/universite/${uniSayfaSlug(p.universite)}`}
className="hover:text-primary hover:underline"
title={`${p.universite} taban puanları sayfası`}
onClick={(e) => e.stopPropagation()}
>
{p.universite}
</Link>
{p.il ? ` · ${p.il.toLocaleLowerCase("tr-TR")}` : ""}
</span>
{p.sure ? (
<span className="shrink-0 text-slate-400">· {p.sure} yıl</span>
) : null}
</span>
</span>
<span className="truncate">
<Link
href={`/universite/${uniSayfaSlug(p.universite)}`}
className="hover:text-primary hover:underline"
title={`${p.universite} taban puanları sayfası`}
onClick={(e) => e.stopPropagation()}
>
{p.universite}
</Link>
{p.il ? ` · ${p.il.toLocaleLowerCase("tr-TR")}` : ""}
</span>
{p.sure ? (
<span className="shrink-0 text-slate-400">· {p.sure} yıl</span>
) : null}
</span>
</TableCell>
<TableCell className="hidden text-right text-xs md:table-cell">

View File

@@ -23,10 +23,26 @@ export const UNIVERSITE_TIPI_ETIKET: Record<UniturGrubu | "farketmez", string> =
export type UniversiteTipi = UniturGrubu | "farketmez";
export const PUAN_TURU_ETIKET: Record<string, string> = {
say: "Sayısal",
ea: "Eşit Ağırlık",
soz: "Sözel",
dil: "Dil",
tyt: "TYT",
};
// Girişsiz kullanıcının modalda yaptığı seçimler, giriş sonrası /sonuc'a
// dönene kadar burada bekler.
export const SIHIRBAZ_STORAGE_KEY = "kolaytercih.sihirbaz";
// storage olayı yalnızca diğer sekmelerde tetiklenir; aynı sekmedeki
// aboneler (ör. navbar sıralama rozeti) bu olayla haberdar edilir.
export const PROFIL_DEGISTI_EVENT = "kolaytercih:profil-degisti";
function profilDegistiginiYayinla(): void {
window.dispatchEvent(new Event(PROFIL_DEGISTI_EVENT));
}
export interface SihirbazSecimleri {
kategoriler: string[];
iller: string[];
@@ -128,6 +144,16 @@ export function tercihProfiliYaz(profil: TercihProfili): void {
try {
localStorage.setItem(SIHIRBAZ_STORAGE_KEY, JSON.stringify(dogru));
} catch {}
profilDegistiginiYayinla();
}
/** Profili siler (ör. AI listesi üretilip seçimler tüketildiğinde). */
export function tercihProfiliSil(): void {
if (typeof window === "undefined") return;
try {
localStorage.removeItem(SIHIRBAZ_STORAGE_KEY);
} catch {}
profilDegistiginiYayinla();
}
export function tercihProfiliTamamlandiMi(): boolean {