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", "lint": "eslint",
"ingest": "tsx scripts/ingest.ts", "ingest": "tsx scripts/ingest.ts",
"refresh": "tsx scripts/refresh.ts", "refresh": "tsx scripts/refresh.ts",
"detay": "tsx scripts/detay.ts",
"logolar": "tsx scripts/logo-indir.ts" "logolar": "tsx scripts/logo-indir.ts"
}, },
"dependencies": { "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"; "use client";
import { useState } from "react";
import { ListChecks, Sparkles } from "lucide-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 { SecimlerimPaneli } from "@/components/manuel-liste/secimlerim-paneli";
import { useManuelListe } from "@/components/manuel-liste/store";
export type ListemSekme = "secimlerim" | "ai"; export type ListemSekme = "secimlerim" | "ai";
export function ListemSekmeleri({ export function ListemSekmeleri({
varsayilan, varsayilan,
aiIcerik, aiIcerik,
aiHazir,
}: { }: {
varsayilan: ListemSekme; varsayilan: ListemSekme;
aiIcerik: React.ReactNode; 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 ( return (
<Tabs defaultValue={varsayilan} className="mt-2 gap-6"> <TabsPrimitive.Root value={sekme} onValueChange={degistir} className="mt-6">
<TabsList variant="line" className="h-auto w-full justify-start gap-1"> <TabsPrimitive.List
<TabsTrigger value="secimlerim" className="cursor-pointer px-3 py-2.5"> 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 /> <ListChecks className="size-4" aria-hidden />
Seçtiklerim Kendi Listem
</TabsTrigger> {liste.length > 0 ? (
<TabsTrigger value="ai" className="cursor-pointer px-3 py-2.5"> <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 /> <Sparkles className="size-4" aria-hidden />
AI Listem AI Listem
</TabsTrigger> {aiHazir ? (
</TabsList> <span
<TabsContent value="secimlerim" className="mt-0"> 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 /> <SecimlerimPaneli />
</TabsContent> </TabsPrimitive.Content>
<TabsContent value="ai" className="mt-0"> <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} {aiIcerik}
</TabsContent> </TabsPrimitive.Content>
</Tabs> </TabsPrimitive.Root>
); );
} }

View File

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

View File

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

View File

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

View File

@@ -6,12 +6,19 @@ import { RehberKapak } from "@/components/rehber-kapak";
import { UniLogo } from "@/components/uni-logo"; import { UniLogo } from "@/components/uni-logo";
import { aramaNormalize, eslesmePuani } from "@/lib/arama"; import { aramaNormalize, eslesmePuani } from "@/lib/arama";
import type { RehberOzet } from "@/lib/rehber"; import type { RehberOzet } from "@/lib/rehber";
import { PUAN_TURU_ETIKET } from "@/lib/sihirbaz";
import {
profilDuzenlemeyiAc,
useTercihProfili,
} from "@/components/manuel-liste/profil-kapisi-store";
import { import {
ArrowRight, ArrowRight,
BookOpenText, BookOpenText,
Building2, Building2,
GraduationCap, GraduationCap,
Search, Search,
SquarePen,
Trophy,
X, X,
} from "lucide-react"; } from "lucide-react";
import { import {
@@ -50,6 +57,7 @@ const REHBER_SONUC_SINIRI = 4;
const sayi = (deger: number) => deger.toLocaleString("tr-TR"); const sayi = (deger: number) => deger.toLocaleString("tr-TR");
export function KatalogArama({ rehberler }: { rehberler: RehberOzet[] }) { export function KatalogArama({ rehberler }: { rehberler: RehberOzet[] }) {
const profil = useTercihProfili();
const [acik, setAcik] = useState(false); const [acik, setAcik] = useState(false);
const [sorgu, setSorgu] = useState(""); const [sorgu, setSorgu] = useState("");
const [sonuclar, setSonuclar] = useState<AramaSonuclari>(BOS_SONUCLAR); 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 */} {/* 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"> <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 <Link
href="/#nasil-calisir" href="/#nasil-calisir"
className="rounded-full px-5 py-2 transition-colors duration-200 hover:bg-slate-100 hover:text-slate-900" 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 Karşılaştır
</Link> </Link>
</>
)}
<button <button
type="button" type="button"
onClick={() => setAcik(true)} onClick={() => setAcik(true)}

View File

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

View File

@@ -27,13 +27,13 @@ export function ListemButonu() {
type="button" type="button"
onClick={cekmeceDegistir} onClick={cekmeceDegistir}
aria-expanded={acik} 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" 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" 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 /> <ListChecks className="size-3.5 text-primary sm:size-4" aria-hidden />
{/* lgxl: ortadaki nav ile yer dar — metin gizli, ikon+rozet kalır */} {/* 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 ? ( {liste.length > 0 ? (
// key=length: her eklemede rozet yeniden doğar ve spring ile oturur // key=length: her eklemede rozet yeniden doğar ve spring ile oturur
<motion.span <motion.span

View File

@@ -6,6 +6,7 @@
import { useSyncExternalStore } from "react"; import { useSyncExternalStore } from "react";
import type { Program } from "@/lib/db"; import type { Program } from "@/lib/db";
import { import {
PROFIL_DEGISTI_EVENT,
tercihProfiliOku, tercihProfiliOku,
tercihProfiliTamamlandiMi, tercihProfiliTamamlandiMi,
type TercihProfili, type TercihProfili,
@@ -75,6 +76,12 @@ function yukle() {
profiliTazele(); profiliTazele();
yayinla(); 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) { function abone(cb: () => void) {

View File

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

View File

@@ -32,6 +32,7 @@ import {
} from "@/components/ui/table"; } from "@/components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ProgramEkleButonu } from "@/components/manuel-liste/program-ekle-butonu"; import { ProgramEkleButonu } from "@/components/manuel-liste/program-ekle-butonu";
import { UniLogo } from "@/components/uni-logo";
const DILIMLER: { const DILIMLER: {
key: DilimKey; key: DilimKey;
@@ -337,7 +338,12 @@ function ProgramSatiri({
) : null} ) : null}
</TableCell> </TableCell>
<TableCell className="max-w-0 w-full"> <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="mt-0.5 flex items-center gap-1.5 text-xs text-slate-500">
<span <span
className={`shrink-0 rounded px-1 py-px text-[10px] font-medium leading-4 ${ 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> <span className="shrink-0 text-slate-400">· {p.sure} yıl</span>
) : null} ) : null}
</span> </span>
</span>
</span>
</TableCell> </TableCell>
<TableCell className="hidden text-right text-xs md:table-cell"> <TableCell className="hidden text-right text-xs md:table-cell">
<ProgramSiraGecmisi program={p} /> <ProgramSiraGecmisi program={p} />

View File

@@ -23,10 +23,26 @@ export const UNIVERSITE_TIPI_ETIKET: Record<UniturGrubu | "farketmez", string> =
export type UniversiteTipi = UniturGrubu | "farketmez"; 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 // Girişsiz kullanıcının modalda yaptığı seçimler, giriş sonrası /sonuc'a
// dönene kadar burada bekler. // dönene kadar burada bekler.
export const SIHIRBAZ_STORAGE_KEY = "kolaytercih.sihirbaz"; 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 { export interface SihirbazSecimleri {
kategoriler: string[]; kategoriler: string[];
iller: string[]; iller: string[];
@@ -128,6 +144,16 @@ export function tercihProfiliYaz(profil: TercihProfili): void {
try { try {
localStorage.setItem(SIHIRBAZ_STORAGE_KEY, JSON.stringify(dogru)); localStorage.setItem(SIHIRBAZ_STORAGE_KEY, JSON.stringify(dogru));
} catch {} } 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 { export function tercihProfiliTamamlandiMi(): boolean {