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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -9,6 +9,7 @@
"lint": "eslint",
"ingest": "tsx scripts/ingest.ts",
"refresh": "tsx scripts/refresh.ts",
"detay": "tsx scripts/detay.ts",
"logolar": "tsx scripts/logo-indir.ts"
},
"dependencies": {

267
scripts/detay.ts Normal file
View File

@@ -0,0 +1,267 @@
/**
* Canlı YÖK Atlas API'sinden program detay verilerini çekip DB'ye işler:
*
* 1. Kontenjan türü kırılımı (son yerleştirme yılı, ör. 2025):
* Genel / Okul Birincisi / Şehit-Gazi / Deprem / 34 Yaş Üstü Kadın
* için kontenjan + yerleşen. Kaynak: POST /api/tercih-kilavuz/search
* (gk1/gkY1, obk1/obkY1, sgy1/sgyY1, dprm1/dprmY1, y34_1/y34Y1 —
* "1" son yerleştirme yılı demek; kayıttaki `yil` kılavuz yılıdır,
* veri yılı = yil - 1).
*
* 2. Yerleşen son kişinin netleri (yıl bazında, tüm puan türleri):
* Kaynak: POST /api/netler/search → `netler` tablosu.
*
* Not: YÖK Atlas 2026'da SPA'ya geçti; eski content/lisans-dynamic/*.php
* panelleri (ve onlarla birlikte kadın-erkek dağılımı) yayından kalktı.
* Cinsiyet verisi yeni API'lerin hiçbirinde yok.
*
* Kullanım: npx tsx scripts/detay.ts
*/
import path from "node:path";
import Database from "better-sqlite3";
const KILAVUZ_API = "https://yokatlas.yok.gov.tr/api/tercih-kilavuz/search";
const NETLER_API = "https://yokatlas.yok.gov.tr/api/netler/search";
const PAGE_SIZE = 500;
const DELAY_MS = 300;
type KilavuzRecord = {
kilavuzKodu: number;
eskiKilavuzKodu?: number | null;
yil: number;
gk1?: number | null;
gkY1?: number | null;
obk1?: number | null;
obkY1?: number | null;
sgy1?: number | null;
sgyY1?: number | null;
dprm1?: number | null;
dprmY1?: number | null;
y34_1?: number | null;
y34Y1?: number | null;
};
type NetlerRecord = {
kilavuzKodu: number;
yil: number;
puanTuru: string | null;
tabanPuan?: number | null;
obp?: number | null;
katsayi?: number | null;
tytTrkNet?: number | null;
tytSosNet?: number | null;
tytMatNet?: number | null;
tytFenNet?: number | null;
aytMatNet?: number | null;
aytFizNet?: number | null;
aytKimNet?: number | null;
aytBioNet?: number | null;
aytTdeNet?: number | null;
aytTrh1Net?: number | null;
aytCog1Net?: number | null;
aytTrh2Net?: number | null;
aytCog2Net?: number | null;
aytDinNet?: number | null;
aytFelNet?: number | null;
ydtYdilNet?: number | null;
};
type ApiPage<T> = { content: T[]; totalPages: number; totalElements: number };
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
async function fetchPage<T>(api: string, page: number): Promise<ApiPage<T>> {
const res = await fetch(api, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
filters: {},
page,
size: PAGE_SIZE,
sortBy: "kilavuzKodu",
direction: "ASC",
}),
});
if (!res.ok) throw new Error(`${api} ${res.status} (sayfa ${page})`);
return (await res.json()) as ApiPage<T>;
}
function num(v: unknown): number | null {
return typeof v === "number" && Number.isFinite(v) ? v : null;
}
const NET_COLS = [
"puanTuru",
"tabanPuan",
"obp",
"katsayi",
"tytTrkNet",
"tytSosNet",
"tytMatNet",
"tytFenNet",
"aytMatNet",
"aytFizNet",
"aytKimNet",
"aytBioNet",
"aytTdeNet",
"aytTrh1Net",
"aytCog1Net",
"aytTrh2Net",
"aytCog2Net",
"aytDinNet",
"aytFelNet",
"ydtYdilNet",
] as const;
async function main() {
const db = new Database(path.join(process.cwd(), "data", "yokatlas.db"));
db.pragma("journal_mode = WAL");
// ── 1. Kontenjan türü kırılımı ─────────────────────────────────────────
const first = await fetchPage<KilavuzRecord>(KILAVUZ_API, 0);
// API kaydındaki yil kılavuz yılı; "1" ekli alanlar bir önceki
// yerleştirme dönemine ait (2026 kılavuzu → 2025 verisi)
const veriYili = first.content[0].yil - 1;
const kontCols = [
`gk${veriYili}`,
`gkY${veriYili}`,
`obk${veriYili}`,
`obkY${veriYili}`,
`sgy${veriYili}`,
`sgyY${veriYili}`,
`dprm${veriYili}`,
`dprmY${veriYili}`,
`y34k${veriYili}`,
`y34kY${veriYili}`,
];
const mevcut = new Set(
(db.prepare("PRAGMA table_info(programs)").all() as { name: string }[]).map(
(c) => c.name
)
);
for (const col of kontCols) {
if (!mevcut.has(col)) {
db.exec(`ALTER TABLE programs ADD COLUMN ${col} INTEGER`);
}
}
const updateKont = db.prepare(`
UPDATE programs SET ${kontCols.map((c) => `${c} = ?`).join(", ")}
WHERE id = ?
`);
const exists = db.prepare("SELECT 1 FROM programs WHERE id = ?");
// Kılavuz kodu yıllar arasında değişebiliyor (eskiKilavuzKodu); netler
// API'si yeni kodla döndüğü için eşleşen DB id'sini haritada tutuyoruz.
const dbId = new Map<number, string>();
let kontUpdated = 0;
let kontMissing = 0;
const processKilavuz = db.transaction((records: KilavuzRecord[]) => {
for (const r of records) {
if (!r.kilavuzKodu) continue;
let id = String(r.kilavuzKodu);
if (!exists.get(id)) {
const eski = r.eskiKilavuzKodu ? String(r.eskiKilavuzKodu) : null;
if (eski && exists.get(eski)) {
id = eski;
} else {
kontMissing++;
continue;
}
}
dbId.set(r.kilavuzKodu, id);
updateKont.run(
num(r.gk1),
num(r.gkY1),
num(r.obk1),
num(r.obkY1),
num(r.sgy1),
num(r.sgyY1),
num(r.dprm1),
num(r.dprmY1),
num(r.y34_1),
num(r.y34Y1),
id
);
kontUpdated++;
}
});
console.log(
`Kontenjan kırılımı (${veriYili}): ${first.totalElements} kayıt, ${first.totalPages} sayfa…`
);
processKilavuz(first.content);
for (let p = 1; p < first.totalPages; p++) {
await sleep(DELAY_MS);
processKilavuz((await fetchPage<KilavuzRecord>(KILAVUZ_API, p)).content);
process.stdout.write(`\rkontenjan sayfa ${p + 1}/${first.totalPages}`);
}
console.log(
`\nKontenjan kırılımı: ${kontUpdated} güncellendi, ${kontMissing} DB'de yok`
);
// ── 2. Yerleşen son kişinin netleri ────────────────────────────────────
db.exec(`
CREATE TABLE IF NOT EXISTS netler (
program_id TEXT NOT NULL,
yil INTEGER NOT NULL,
${NET_COLS.map((c) => `${c} ${c === "puanTuru" ? "TEXT" : "REAL"}`).join(
",\n "
)},
PRIMARY KEY (program_id, yil)
);
`);
const insertNet = db.prepare(`
INSERT OR REPLACE INTO netler (program_id, yil, ${NET_COLS.join(", ")})
VALUES (${["?", "?", ...NET_COLS.map(() => "?")].join(", ")})
`);
let netRows = 0;
const processNetler = db.transaction((records: NetlerRecord[]) => {
for (const r of records) {
if (!r.kilavuzKodu || !r.yil) continue;
const id = dbId.get(r.kilavuzKodu) ?? String(r.kilavuzKodu);
insertNet.run(
id,
r.yil,
r.puanTuru?.trim() ?? null,
...NET_COLS.slice(1).map((c) => num(r[c as keyof NetlerRecord]))
);
netRows++;
}
});
const netFirst = await fetchPage<NetlerRecord>(NETLER_API, 0);
console.log(
`Netler: ${netFirst.totalElements} kayıt, ${netFirst.totalPages} sayfa…`
);
processNetler(netFirst.content);
for (let p = 1; p < netFirst.totalPages; p++) {
await sleep(DELAY_MS);
processNetler((await fetchPage<NetlerRecord>(NETLER_API, p)).content);
process.stdout.write(`\rnetler sayfa ${p + 1}/${netFirst.totalPages}`);
}
console.log(`\nNetler: ${netRows} satır yazıldı`);
const ozet = db
.prepare(
`SELECT
(SELECT COUNT(*) FROM programs WHERE gkY${veriYili} IS NOT NULL) AS kontlu,
(SELECT COUNT(*) FROM netler) AS net,
(SELECT COUNT(DISTINCT program_id) FROM netler) AS netli_program`
)
.get() as { kontlu: number; net: number; netli_program: number };
console.log(
`Özet → kontenjan kırılımı olan program: ${ozet.kontlu}, ` +
`net satırı: ${ozet.net} (${ozet.netli_program} program)`
);
db.close();
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

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">
<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 max-w-2xl text-sm leading-6 text-slate-600">
Seçtiklerin ve AI listen burada yan yana ikisi birbirinden bağımsız.
<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="mt-6" />
<ListemSekmeleri
varsayilan={varsayilanSekme}
aiIcerik={aiBos}
/>
<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">
<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 max-w-2xl text-sm leading-6 text-slate-600">
Seçtiklerin ve AI listen burada yan yana ikisi birbirinden bağımsız.
<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="mt-6" />
<ListemSekmeleri varsayilan={varsayilanSekme} aiIcerik={aiIcerik} />
<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,6 +178,29 @@ 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">
{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"
@@ -182,6 +213,8 @@ export function KatalogArama({ rehberler }: { rehberler: RehberOzet[] }) {
>
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,7 +338,12 @@ function ProgramSatiri({
) : null}
</TableCell>
<TableCell className="max-w-0 w-full">
<span className="block truncate text-sm font-semibold">{p.isim}</span>
<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 ${
@@ -363,6 +369,8 @@ function ProgramSatiri({
<span className="shrink-0 text-slate-400">· {p.sure} yıl</span>
) : null}
</span>
</span>
</span>
</TableCell>
<TableCell className="hidden text-right text-xs md:table-cell">
<ProgramSiraGecmisi program={p} />

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 {