fix: update callback handling and user profile management in list components
All checks were successful
Deploy / deploy (push) Successful in 7m23s

Enhanced the handling of user profiles and callback URLs in various components. Updated the `GirisKarti` to support multiple callback scenarios, ensuring seamless user experience. Improved the `ListeUretici` and `ListemIcerik` components to manage profile data more effectively, including handling cases where the profile is stored in cookies. Additionally, refined the logic for redirecting users based on their profile status, ensuring they are directed to the appropriate pages without losing context. Updated the database file to reflect these changes.
This commit is contained in:
bilalgursen
2026-08-09 04:15:53 +03:00
parent 696dc69763
commit 437e2bc96a
13 changed files with 603 additions and 70 deletions

Binary file not shown.

View File

@@ -22,8 +22,10 @@ export async function GirisKarti({
const session = await getSession();
if (session) redirect(callbackURL);
// Sihirbaz funnel'ı: seçimler yapıldı, listeye giriş kaldı
const funnel = callbackURL.startsWith("/sonuc");
// Sihirbaz funnel'ı: seçimler yapıldı, listeye giriş kaldı. /listem?uret=1
// callback'i de aynı vaadi taşır (girişten dönüşte üretim orada başlar).
const funnel =
callbackURL.startsWith("/sonuc") || callbackURL.startsWith("/listem?uret=1");
return (
<>

View File

@@ -273,8 +273,15 @@ export function profilTamamlandi(
// Düzenleme /sonuc'a götürür: tablo profil çerezinden SSR'lanır
// (sira/tur/il/tip) ve tercihProfiliYaz çerezi az önce güncelledi. URL
// değişmediği için sunucu tablosunun tazelenmesi kapı bileşenindeki
// router.refresh ile garanti edilir.
if (duzenle) return sonucHref();
// router.refresh ile garanti edilir. Tek istisna /listem: kullanıcı
// listesinin başındayken tercihlerini düzenliyorsa listesinden koparılmaz,
// aynı sayfaya döner ve "tercihlerin değişti" uyarısı orada karşılar.
if (duzenle) {
return typeof window !== "undefined" &&
window.location.pathname === "/listem"
? "/listem"
: sonucHref();
}
return null;
}

View File

@@ -2,8 +2,14 @@
// /listem üretim gövdesi: sihirbazın kaydettiği bekleyen seçimlerle Yapay
// Zeka listesi burada kurulur (sonuç sayfası yalnızca buraya yönlendirir).
// Başarıda profil tüketilir ve sayfa yenilenir; kredi-yetersiz ve hata
// durumları da bu bileşende karşılanır.
// Kredi-yetersiz ve hata durumları da bu bileşende karşılanır.
//
// Profil üretimden sonra SİLİNMEZ: kullanıcının tercih profili sitenin geri
// kalanının da kaynağı (/sonuc tablo filtresi, navbar sıralama rozeti, "+"
// kapısı) ve /listem'deki "tercihlerin değişti" karşılaştırmasının temeli
// (bkz. tercih-farki.ts). Tekrar üretim riski yok: rapor varken bu bileşen
// hiç render edilmez (bkz. listem-icerik.tsx), yani ?uret=1 ile dönmek ya da
// sayfayı yenilemek ikinci bir harcama başlatmaz.
import { useEffect, useRef, useState, type ReactNode } from "react";
import Link from "next/link";
@@ -14,14 +20,8 @@ import { Skeleton } from "@/components/ui/skeleton";
import { listeOlustur } from "@/features/rapor/rapor-actions";
import { olay, siraKovasi } from "@/lib/analitik";
import { sonucHref, type TercihProfili } from "@/features/sihirbaz/sihirbaz-sabitler";
import { tercihProfiliOku, tercihProfiliSil } from "@/features/sihirbaz/sihirbaz-profil";
const URETIM_MESAJLARI = [
"Sıralamana uygun programlar taranıyor…",
"Gerçek YÖK Atlas verisiyle dengeli liste kuruluyor…",
"Her tercih için risk ve trend analizi yazılıyor…",
"Son rötuşlar yapılıyor, neredeyse hazır…",
];
import { tercihProfiliOku } from "@/features/sihirbaz/sihirbaz-profil";
import { useUretimMesaji } from "./uretim-mesaji";
type Durum =
| { ad: "uretiliyor" }
@@ -31,9 +31,14 @@ type Durum =
export function ListeUretici({
hasPaket,
sunucuProfil = null,
bosDurum,
}: {
hasPaket: boolean;
/** Profil çerezinden SSR'da çözülen yedek profil: localStorage silinmiş ya
* da erişilemez olsa bile (gizli sekme, temizlenmiş depo) üretim başlasın.
* localStorage yine de birincil kaynak — çerez onun aynası. */
sunucuProfil?: TercihProfili | null;
/** Storage'da bekleyen seçim yoksa gösterilecek boş-liste durumu */
bosDurum: ReactNode;
}) {
@@ -63,10 +68,9 @@ export function ListeUretici({
tur: profil.tur,
sira_kovasi: siraKovasi(profil.sira),
});
tercihProfiliSil();
// ?uret=1 URL'de kalmasın: yenileme/geri dönüş üretimi tekrar
// tetiklemesin. refresh sunucudaki raporu (ve header'daki kredi
// pill'ini) ekrana getirir.
// ?uret=1 URL'de kalmasın: geri dönüşte anlamsız bayrak taşınmasın.
// refresh sunucudaki raporu (ve header'daki kredi pill'ini) ekrana
// getirir.
window.history.replaceState(null, "", "/listem");
router.refresh();
return;
@@ -91,8 +95,9 @@ export function ListeUretici({
useEffect(() => {
if (basladi.current) return;
basladi.current = true;
// Bekleyen seçimler yalnızca client storage'da; mount sonrası okunur.
const kayitli = tercihProfiliOku();
// Bekleyen seçimler client storage'da; mount sonrası okunur. Depo boşsa
// (temizlenmiş/erişilemez) sunucunun çerezden çözdüğü profile düşülür.
const kayitli = tercihProfiliOku() ?? sunucuProfil;
if (!kayitli) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setDurum({ ad: "profil-yok" });
@@ -165,14 +170,7 @@ export function ListeUretici({
}
function UretimGovdesi() {
const [mesajIdx, setMesajIdx] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setMesajIdx((i) => Math.min(i + 1, URETIM_MESAJLARI.length - 1));
}, 7000);
return () => clearInterval(interval);
}, []);
const mesaj = useUretimMesaji();
return (
<section
@@ -181,9 +179,7 @@ function UretimGovdesi() {
>
<div className="flex flex-col items-center gap-1.5 text-center">
<h1 className="font-heading text-lg font-bold">Listen hazırlanıyor</h1>
<p className="max-w-sm text-sm text-slate-500">
{URETIM_MESAJLARI[mesajIdx]}
</p>
<p className="max-w-sm text-sm text-slate-500">{mesaj}</p>
<p className="text-xs text-slate-400">
Bu işlem yarım dakika kadar sürebilir, sayfayı kapatma.
</p>

View File

@@ -5,12 +5,11 @@ import { ArrowRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { PagePixelDivider } from "@/components/pixel-decor";
import { useTercihProfili } from "@/features/liste/hooks/use-tercih-profili";
import { sonucHref } from "@/features/sihirbaz/sihirbaz-sabitler";
/**
* /listem boş hâlinin metni + CTA'sı: sıralamasını zaten girmiş adaya
* "sıralamanı gir" demek yerine sihirbazıık hâlde sonuç sayfasına
* götürür; profil yoksa ana sayfadaki sıralama formuna yönlendirir.
* /listem boş hâlinin metni + CTA'sı: seçimleri zaten hazır olan adayı
* /sonuc üzerinden dolaştırmadan doğrudan üretime (?uret=1) alır; profil
* yoksa ana sayfadaki sıralama formuna yönlendirir.
*/
export function ListemBosCta() {
const profil = useTercihProfili();
@@ -28,7 +27,7 @@ export function ListemBosCta() {
size="lg"
className="mt-6 cursor-pointer bg-orange-500 text-white hover:bg-orange-600"
>
<Link href={profil ? sonucHref({ sihirbaz: "1" }) : "/"}>
<Link href={profil ? "/listem?uret=1" : "/"}>
{profil ? "Yapay Zeka listeni kur" : "Sıralamanı gir, Yapay Zeka listeni kur"}
<ArrowRight className="size-4" aria-hidden />
</Link>

View File

@@ -14,6 +14,7 @@ import {
Lock,
PencilLine,
ShieldCheck,
SlidersHorizontal,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { RaporListesi } from "./rapor-listesi";
@@ -24,6 +25,7 @@ import { pinleriTuret } from "@/lib/harita-pinler";
import type { RaporSonuc } from "@/features/rapor/types/rapor";
import { SohbetClient, type DanismanOzeti, type Mesaj } from "./sohbet-client";
import { RevizyonKutusu } from "./revizyon-kutusu";
import { profilDuzenlemeyiAc } from "@/features/liste/hooks/use-tercih-profili";
export function ListemGovde({
rapor,
@@ -122,8 +124,22 @@ export function ListemGovde({
? "Yapay Zeka tercih listen"
: "Veriyi ücretsiz incele. Kararı Yapay Zeka ile netleştir."}
</h1>
<div className="flex shrink-0 flex-wrap gap-2">
{/* Tercih düzenleme her kullanıcıya açık: kapı (layout'ta monte
TercihProfiliKapisi) profili günceller, /listem'e döner ve
değişiklik varsa "tercihlerin değişti" uyarısı karşılar. */}
<Button
type="button"
variant="outline"
size="sm"
onClick={profilDuzenlemeyiAc}
className="cursor-pointer"
>
<SlidersHorizontal className="size-4" aria-hidden />
Tercihlerimi düzenle
</Button>
{hasPaket ? (
<div className="flex shrink-0 gap-2">
<>
<Button
asChild
variant="outline"
@@ -146,9 +162,10 @@ export function ListemGovde({
PDF
</Link>
</Button>
</div>
</>
) : null}
</div>
</div>
<RaporListesi
rapor={rapor}
adaySira={adaySira}

View File

@@ -1,8 +1,13 @@
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { MessageCircleQuestion, Sparkles } from "lucide-react";
import { verifySession, getCurrentUser } from "@/lib/session";
import {
PROFIL_CEREZ_ADI,
cerezdenTercihProfili,
} from "@/features/sihirbaz/sihirbaz-sabitler";
import { kullanicininRaporu, sohbetGecmisiGetir } from "../rapor-queries";
import { URUNLER, MAX_REVIZYON } from "@/lib/credits";
import { URUNLER, MAX_REVIZYON, RAPOR_KREDI } from "@/lib/credits";
import type {
MaskeliRapor,
RaporParams,
@@ -16,6 +21,7 @@ import type { Mesaj } from "./sohbet-client";
import { ListemGovde } from "./listem-govde";
import { ListemBosCta } from "./listem-bos-cta";
import { ListeUretici } from "./liste-uretici";
import { TercihDegistiUyarisi } from "./tercih-degisti-uyarisi";
const PAKET_FIYATI = (URUNLER.paket.amountKurus / 100).toLocaleString("tr-TR", {
maximumFractionDigits: 0,
@@ -28,9 +34,14 @@ export async function ListemIcerik({
uret: boolean;
acildi: boolean;
}) {
// Girişsiz gelene callback'i ?uret=1 ile ver: girişten dönüşte bekleyen
// seçimlerle üretim kaldığı yerden başlasın.
const geriYol = uret ? "/listem?uret=1" : "/listem";
// Tercihleri girili olan (profil çerezi duran) ziyaretçi girişten dönünce
// "Henüz listen yok" boşluğuyla karşılaşmasın: callback ?uret=1 taşır,
// üretim dönüşte kendiliğinden başlar. ?uret=1 ile gelen zaten aynı yola
// döner. Profil çerezi localStorage'ın aynası (bkz. tercihProfiliYaz).
const cerezProfili = cerezdenTercihProfili(
(await cookies()).get(PROFIL_CEREZ_ADI)?.value,
);
const geriYol = uret || cerezProfili ? "/listem?uret=1" : "/listem";
const session = await verifySession(geriYol);
const [user, satir, sohbetGecmisi] = await Promise.all([
getCurrentUser(),
@@ -60,6 +71,7 @@ export async function ListemIcerik({
{uret ? (
<ListeUretici
hasPaket={Boolean(user.hasPaket)}
sunucuProfil={cerezProfili}
bosDurum={bosDurum}
/>
) : (
@@ -100,6 +112,16 @@ export async function ListemIcerik({
PDF artık ık.
</div>
) : null}
{/* Tercihler liste kurulduktan sonra değiştiyse (sıra, alan, il, tip,
öncelik) uyarı burada karşılar ve listeyi yeni tercihlerle yeniden
kurmayı önerir — üretimle aynı kredi kapısından geçerek. Fark
hesabı client'ta yapılır: güncel profil localStorage'ta yaşar. */}
<TercihDegistiUyarisi
params={raporParams}
kredi={user.creditBalance}
raporKredi={RAPOR_KREDI}
hasPaket={Boolean(user.hasPaket)}
/>
{/* Başlık ve revizyon/PDF aksiyonları listenin kendi başlık satırında
(bkz. listem-govde.tsx) — sayfa üstünde ayrı bir header yok. */}
<ListemGovde

View File

@@ -0,0 +1,302 @@
"use client";
// /listem'deki "tercihlerin değişti" uyarısı: kayıtlı liste hangi tercihlerle
// kurulduysa (rapor params) kullanıcının güncel profili ondan ayrıldığında
// belirir ve listeyi yeni tercihlere göre yeniden kurmayı önerir.
//
// Yeniden kurulum normal üretimle AYNI kapıdan geçer (listeOlustur): 3 kredi,
// aynı requestId idempotency'si, aynı kredi-yetersiz/iade davranışı. Ayrı bir
// "güncelleme" ürünü ya da indirimli yol yok — kullanıcı yeni bir liste alıyor,
// bedeli de yeni liste bedeli.
//
// Uyarı kapatılabilir: kapatma kararı o anki profil imzasına yazılır, tercih
// yeniden değişirse uyarı geri gelir (bkz. profilImzasi).
import { useMemo, useRef, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { ArrowRight, Check, RefreshCcw, SlidersHorizontal, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { olay, siraKovasi } from "@/lib/analitik";
import { useTercihProfili } from "@/features/liste/hooks/use-tercih-profili";
import { listeOlustur } from "@/features/rapor/rapor-actions";
import type { RaporParams } from "@/features/rapor/types/rapor";
import {
farkCumlesi,
farkSatirlari,
profilImzasi,
tercihFarki,
} from "@/features/rapor/tercih-farki";
import { useUretimMesaji } from "./uretim-mesaji";
const GIZLE_ANAHTARI = "kolaytercih.listem-fark-gizle";
function gizlenenImzayiOku(): string | null {
if (typeof window === "undefined") return null;
try {
return localStorage.getItem(GIZLE_ANAHTARI);
} catch {
return null;
}
}
type Durum =
| { ad: "bekliyor" }
| { ad: "uretiliyor" }
| { ad: "tamamlandi" }
| { ad: "kredi-yetersiz" }
| { ad: "hata"; mesaj: string };
export function TercihDegistiUyarisi({
params,
kredi,
raporKredi,
hasPaket,
}: {
/** Kayıtlı listenin üretildiği tercihler */
params: RaporParams;
kredi: number;
raporKredi: number;
hasPaket: boolean;
}) {
const router = useRouter();
const profil = useTercihProfili();
const [durum, setDurum] = useState<Durum>({ ad: "bekliyor" });
// Profil client'ta çözüldüğü için bu bileşen SSR'da zaten hiçbir şey
// render etmiyor; gizleme kaydı ilk render'da güvenle okunabilir.
const [gizlenenImza, setGizlenenImza] = useState<string | null>(
gizlenenImzayiOku,
);
// Aynı deneme için sabit requestId: cevabı kaybolan istekte retry çifte
// harcamaz (bkz. liste-uretici.tsx ve spendCredits idempotency'si).
const requestIdRef = useRef<string | null>(null);
const farklar = useMemo(
() => (profil ? tercihFarki(params, profil) : []),
[params, profil],
);
const satirlar = useMemo(
() => (profil ? farkSatirlari(params, profil, farklar) : []),
[params, profil, farklar],
);
async function guncelle() {
if (!profil) return;
if (!requestIdRef.current) requestIdRef.current = crypto.randomUUID();
setDurum({ ad: "uretiliyor" });
try {
const sonuc = await listeOlustur({
sira: profil.sira,
tur: profil.tur,
secimler: profil.secimler,
requestId: requestIdRef.current,
});
requestIdRef.current = null;
if (sonuc.ok) {
olay("liste_guncellendi", {
tur: profil.tur,
sira_kovasi: siraKovasi(profil.sira),
degisen_alan: farklar.length,
});
toast.success("Listen yeni tercihlerine göre yeniden kuruldu.");
// Sunucu params'ı tazelenince fark kalmaz ve uyarı kendiliğinden
// kaybolur; refresh dönene kadar "güncellendi" hâli görünür.
setDurum({ ad: "tamamlandi" });
router.refresh();
return;
}
if (sonuc.code === "KREDI" || sonuc.code === "PAKET") {
olay("kredi_bitti");
setDurum({ ad: "kredi-yetersiz" });
} else if (sonuc.code === "AUTH") {
router.push(`/giris?callback=${encodeURIComponent("/listem")}`);
} else {
setDurum({ ad: "hata", mesaj: sonuc.error });
}
} catch {
setDurum({
ad: "hata",
mesaj: "Beklenmeyen bir hata oluştu, tekrar dener misin?",
});
}
}
function gizle() {
if (!profil) return;
const imza = profilImzasi(profil);
try {
localStorage.setItem(GIZLE_ANAHTARI, imza);
} catch {}
setGizlenenImza(imza);
}
if (!profil || farklar.length === 0) return null;
if (durum.ad === "tamamlandi") {
return (
<div
role="status"
className="mb-6 flex items-center gap-3 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-medium text-emerald-800"
>
<Check className="size-4 shrink-0" aria-hidden />
Listen yeni tercihlerine göre güncellendi.
</div>
);
}
// Kapatılmış değişiklik: kullanıcı aynı tercihlerle bir daha rahatsız
// edilmez. Üretim/hata hâlleri kapatmadan bağımsız görünür kalır.
if (durum.ad === "bekliyor" && gizlenenImza === profilImzasi(profil)) {
return null;
}
const krediYeter = kredi >= raporKredi;
return (
<section
aria-live="polite"
className="mb-6 rounded-2xl border border-amber-200 bg-amber-50 p-4 sm:p-5"
>
<div className="flex items-start gap-3">
<span className="flex size-9 shrink-0 items-center justify-center rounded-full bg-amber-100 text-amber-700">
<SlidersHorizontal className="size-4" aria-hidden />
</span>
<div className="min-w-0 flex-1">
<h2 className="font-heading font-bold text-amber-900">
Tercihlerin değişti
</h2>
<p className="mt-1 text-sm text-amber-900/80">
Aşağıdaki liste eski tercihlerinle kuruldu; {farkCumlesi(farklar)}{" "}
o zamandan beri değişti. Yeni tercihlerine göre yeniden kurabilirsin
yeni liste bunun yerine geçer ve {raporKredi} kredi düşer.
</p>
<dl className="mt-3 flex flex-col gap-1.5 text-sm">
{satirlar.map((satir) => (
<div
key={satir.alan}
className="flex flex-col gap-0.5 sm:flex-row sm:items-baseline sm:gap-2"
>
<dt className="shrink-0 text-xs font-medium uppercase tracking-wide text-amber-700">
{satir.etiket}
</dt>
<dd className="min-w-0 text-amber-900">
<span className="text-amber-900/60">{satir.eski}</span>
<span className="px-1.5 text-amber-700" aria-label="yerine">
</span>
<span className="font-semibold">{satir.yeni}</span>
</dd>
</div>
))}
</dl>
{durum.ad === "uretiliyor" ? (
<UretimSatiri />
) : durum.ad === "kredi-yetersiz" ? (
<KrediYetersiz hasPaket={hasPaket} raporKredi={raporKredi} />
) : (
<div className="mt-4 flex flex-wrap items-center gap-3">
{durum.ad === "hata" ? (
<>
<p className="w-full text-sm text-red-700">{durum.mesaj}</p>
<Button
onClick={() => void guncelle()}
className="cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
>
<RefreshCcw className="size-4" aria-hidden />
Tekrar dene
</Button>
</>
) : krediYeter ? (
<Button
onClick={() => void guncelle()}
className="cursor-pointer bg-orange-500 text-white transition-[background-color,transform] duration-200 active:scale-[0.97] hover:bg-orange-600"
>
Listemi güncelle {raporKredi} kredi
<ArrowRight className="size-4" aria-hidden />
</Button>
) : (
<Button
asChild
className="cursor-pointer bg-orange-500 text-white transition-[background-color,transform] duration-200 active:scale-[0.97] hover:bg-orange-600"
>
<Link href="/paket">
{hasPaket ? "Kredi yükle ve güncelle" : "Paketi aktive et"}
<ArrowRight className="size-4" aria-hidden />
</Link>
</Button>
)}
<button
type="button"
onClick={gizle}
className="cursor-pointer text-sm font-medium text-amber-800 underline underline-offset-2 transition-colors hover:text-amber-900"
>
Şimdilik kalsın
</button>
{!krediYeter && durum.ad !== "hata" ? (
<p className="w-full text-xs text-amber-900/70">
Güncelleme {raporKredi} kredi; bakiyen {kredi} kredi.
</p>
) : null}
</div>
)}
</div>
{durum.ad === "bekliyor" ? (
<button
type="button"
onClick={gizle}
className="flex size-9 shrink-0 cursor-pointer items-center justify-center rounded-full border border-amber-200 text-amber-800 transition-colors duration-200 active:scale-[0.96] hover:bg-amber-100"
>
<X className="size-4" aria-hidden />
<span className="sr-only">Uyarıyı kapat</span>
</button>
) : null}
</div>
</section>
);
}
function UretimSatiri() {
const mesaj = useUretimMesaji();
return (
<div className="mt-4 flex flex-col gap-1">
<p className="flex items-center gap-2 text-sm font-medium text-amber-900">
<RefreshCcw className="size-4 animate-spin" aria-hidden />
Listen yeni tercihlerine göre kuruluyor
</p>
<p className="text-sm text-amber-900/70">{mesaj}</p>
<p className="text-xs text-amber-900/60">
Bu işlem yarım dakika kadar sürebilir, sayfayı kapatma.
</p>
</div>
);
}
function KrediYetersiz({
hasPaket,
raporKredi,
}: {
hasPaket: boolean;
raporKredi: number;
}) {
return (
<div className="mt-4 flex flex-wrap items-center gap-3">
<p className="w-full text-sm text-amber-900">
{hasPaket
? `Güncelleme ${raporKredi} kredi ve bakiyen yetmedi. Kredi yüklediğinde listen ve sohbet geçmişin duruyor, kaldığın yerden devam edersin.`
: `Güncelleme ${raporKredi} kredi ve bakiyen yetmedi. Tercih Dönemi Paketi'yle kredilerin, tam liste, PDF ve revizyon hakları birlikte gelir.`}
</p>
<Button
asChild
className="cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
>
<Link href="/paket">
{hasPaket ? "Kredi yükle" : "Paketi aktive et"}
<ArrowRight className="size-4" aria-hidden />
</Link>
</Button>
</div>
);
}

View File

@@ -0,0 +1,31 @@
"use client";
// Liste üretiminin bekleme metinleri tek kaynakta: ilk kurulum (ListeUretici)
// ve tercih değişikliği sonrası yeniden kurulum (TercihDegistiUyarisi) aynı
// dili ve ritmi paylaşsın diye.
import { useEffect, useState } from "react";
export const URETIM_MESAJLARI = [
"Sıralamana uygun programlar taranıyor…",
"Gerçek YÖK Atlas verisiyle dengeli liste kuruluyor…",
"Her tercih için risk ve trend analizi yazılıyor…",
"Son rötuşlar yapılıyor, neredeyse hazır…",
] as const;
const ARALIK_MS = 7000;
/** Sırayla ilerleyen, sonuncuda duran bekleme metni. Yalnızca üretim
* sürerken monte edilir — sayaç mount ile başlar. */
export function useUretimMesaji(): string {
const [idx, setIdx] = useState(0);
useEffect(() => {
const zamanlayici = setInterval(() => {
setIdx((i) => Math.min(i + 1, URETIM_MESAJLARI.length - 1));
}, ARALIK_MS);
return () => clearInterval(zamanlayici);
}, []);
return URETIM_MESAJLARI[idx];
}

View File

@@ -0,0 +1,134 @@
// Kayıtlı listenin üretildiği tercihler (rapor params) ile kullanıcının şu
// anki tercih profilini karşılaştırır. /listem'deki "tercihlerin değişti"
// uyarısı ve güncelleme CTA'sı bu farktan doğar.
//
// İzomorfik: yalnızca tip ve etiket sabitlerine bağlı — hem client bileşeni
// hem sunucu tarafı tüketebilir.
//
// Eski akışla üretilmiş raporlarda sihirbaz alanları (kategoriler/iller/
// universiteTipi/oncelikler) hiç yok. Olmayan alan "değişmiş" sayılmaz:
// kıyaslanacak bir değer yokken kullanıcıyı 3 kredilik güncellemeye
// çağırmak dürüst olmaz. Sıralama ve puan türü her raporda dolu olduğundan
// onlar her zaman kıyaslanır.
import {
PUAN_TURU_ETIKET,
UNIVERSITE_TIPI_ETIKET,
type TercihProfili,
} from "@/features/sihirbaz/sihirbaz-sabitler";
import type { RaporParams } from "./types/rapor";
export type FarkAlani =
| "sira"
| "tur"
| "kategoriler"
| "iller"
| "universiteTipi"
| "oncelikler";
export const FARK_ETIKET: Record<FarkAlani, string> = {
sira: "başarı sıralaman",
tur: "puan türün",
kategoriler: "ilgi alanların",
iller: "şehir tercihlerin",
universiteTipi: "devlet/vakıf tercihin",
oncelikler: "önceliklerin",
};
/** Sıra/tür/seçim üçlüsünün kararlı imzası — uyarının "bu değişikliği
* gizle" kaydı bu imzaya bağlanır (tercih yine değişirse uyarı geri gelir). */
export function profilImzasi(profil: TercihProfili): string {
const s = profil.secimler;
return JSON.stringify([
Math.round(profil.sira),
profil.tur,
[...s.kategoriler].sort(),
[...s.iller].sort(),
s.universiteTipi,
[...s.oncelikler].sort(),
]);
}
// Sıra bağımsız küme kıyası: sihirbaz seçimleri tekrarsız olarak doğrulanır
// (bkz. sihirbazDogrula), aynı seçimlerin farklı sırayla kaydedilmesi
// "değişiklik" değildir.
function kumeFarkli(
eski: readonly string[] | undefined,
yeni: readonly string[],
): boolean {
if (!eski) return false;
if (eski.length !== yeni.length) return true;
const kume = new Set(eski);
return yeni.some((x) => !kume.has(x));
}
function liste(degerler: readonly string[] | undefined): string {
return degerler && degerler.length > 0 ? degerler.join(", ") : "—";
}
export function tercihFarki(
params: RaporParams,
profil: TercihProfili,
): FarkAlani[] {
const farklar: FarkAlani[] = [];
const s = profil.secimler;
if (Math.round(params.sira) !== Math.round(profil.sira)) farklar.push("sira");
if (params.tur !== profil.tur) farklar.push("tur");
if (kumeFarkli(params.kategoriler, s.kategoriler)) farklar.push("kategoriler");
if (kumeFarkli(params.iller, s.iller)) farklar.push("iller");
if (
params.universiteTipi !== undefined &&
params.universiteTipi !== s.universiteTipi
) {
farklar.push("universiteTipi");
}
if (kumeFarkli(params.oncelikler, s.oncelikler)) farklar.push("oncelikler");
return farklar;
}
/** "başarı sıralaman ve ilgi alanların" — uyarı cümlesinin ortası. */
export function farkCumlesi(farklar: FarkAlani[]): string {
const etiketler = farklar.map((f) => FARK_ETIKET[f]);
if (etiketler.length <= 1) return etiketler[0] ?? "";
return `${etiketler.slice(0, -1).join(", ")} ve ${etiketler[etiketler.length - 1]}`;
}
export type FarkSatiri = { alan: FarkAlani; etiket: string; eski: string; yeni: string };
/** Değişen her alanın eski → yeni değeri; uyarıda tek tek gösterilir. */
export function farkSatirlari(
params: RaporParams,
profil: TercihProfili,
farklar: FarkAlani[],
): FarkSatiri[] {
const s = profil.secimler;
const sayi = (n: number) => Math.round(n).toLocaleString("tr-TR");
return farklar.map((alan) => {
const ortak = { alan, etiket: FARK_ETIKET[alan] };
switch (alan) {
case "sira":
return { ...ortak, eski: `${sayi(params.sira)}.`, yeni: `${sayi(profil.sira)}.` };
case "tur":
return {
...ortak,
eski: PUAN_TURU_ETIKET[params.tur] ?? params.tur,
yeni: PUAN_TURU_ETIKET[profil.tur] ?? profil.tur,
};
case "kategoriler":
return { ...ortak, eski: liste(params.kategoriler), yeni: liste(s.kategoriler) };
case "iller":
return { ...ortak, eski: liste(params.iller), yeni: liste(s.iller) };
case "universiteTipi":
return {
...ortak,
eski: UNIVERSITE_TIPI_ETIKET[params.universiteTipi ?? "farketmez"],
yeni: UNIVERSITE_TIPI_ETIKET[s.universiteTipi],
};
case "oncelikler":
return { ...ortak, eski: liste(params.oncelikler), yeni: liste(s.oncelikler) };
}
});
}

View File

@@ -17,6 +17,7 @@ export type OlayAdi =
| "sihirbaz_tamamlandi"
| "giris_denendi"
| "liste_olusturuldu"
| "liste_guncellendi"
| "liste_revize"
| "program_eklendi"
| "netler_goruntulendi"

View File

@@ -32,8 +32,9 @@ export type YurtdisiPin = {
/**
* Yurtdışı tercihini şehir pinine ekler; şehir çözülemezse false (harita
* dışı). Kilitli satırlarda universite null verilir — pin şehir bazlı
* olduğundan üniversite kimliği zaten görünmez.
* dışı). Kilitli satırlarda da üniversite adı verilebilir: ad yalnızca şehri
* çözmek için okunur (KKTC satırlarının çoğunda şehir adın parantez ekinde),
* üretilen pin yalnızca ülke/şehir/sayı taşır — üniversite kimliği görünmez.
*/
export function yurtdisiPinKaydet(
gruplar: Map<string, YurtdisiPin>,
@@ -92,7 +93,8 @@ export function pinleriTuret(
if (kilitli) {
const konum = ilKonum(p.il);
if (!konum) {
if (!yurtdisiPinKaydet(yurtdisi, null, p.il, t.sira, risk))
// Şehir çözümü için ad okunur, pine yazılmaz (bkz. yurtdisiPinKaydet)
if (!yurtdisiPinKaydet(yurtdisi, p.universite, p.il, t.sira, risk))
haritaDisi += 1;
return;
}

View File

@@ -81,6 +81,23 @@ const UNI_DUZELTME: Record<string, SehirAnahtari | null> = {
/** il değeri Türkiye dışını mı gösteriyor? ("KIBRIS" genel değeri dahil) */
const YURTDISI_IL = new Set([...Object.keys(SEHIRLER), "KIBRIS"]);
/**
* Ad sonundaki "(KKTC-LEFKOŞA)" ekinden şehir anahtarı. YÖK Atlas'ta 100'den
* fazla KKTC satırının il alanı şehir değil genel "KIBRIS"tır (Doğu Akdeniz,
* Yakın Doğu, Lefke Avrupa, Uluslararası Final, Kıbrıs Sağlık ve Toplum
* Bilimleri); şehir yalnızca üniversite adının parantezinde durur. Bu ek
* okunmazsa o programlar hiçbir haritaya düşmez, "haritada gösterilemiyor"
* sayısına yazılır.
*/
function ektenSehir(universite: string): SehirAnahtari | null {
const ek = universite.match(/\(([^)]*)\)\s*$/)?.[1];
if (!ek) return null;
// "KKTC-GAZİMAĞUSA" → "GAZİMAĞUSA"; ülke öneki olmayan "(ANKARA)" gibi
// ekler SEHIRLER'de karşılık bulmadığından kendiliğinden elenir.
const sehir = ek.split("-").pop()!.trim().toLocaleUpperCase("tr-TR");
return sehir in SEHIRLER ? (sehir as SehirAnahtari) : null;
}
/** Üniversite adı + il'den şehir anahtarı; şehri bilinmeyen KKTC'de ülke. */
type Cozum = { anahtar: SehirAnahtari } | { ulke: UlkeKodu } | null;
@@ -96,7 +113,10 @@ function coz(universite: string, il: string | null): Cozum {
}
if (!il || !YURTDISI_IL.has(il)) return null;
if (il in SEHIRLER) return { anahtar: il as SehirAnahtari };
return { ulke: "kktc" }; // il "KIBRIS": ülke bilinir, şehir bilinmez
// il "KIBRIS": ülke bilinir, şehir adın parantez ekinden okunur;
// o da yoksa ülke seviyesinde kalır (şehir pini çizilemez)
const ekSehir = ektenSehir(universite);
return ekSehir ? { anahtar: ekSehir } : { ulke: "kktc" };
}
export type UlkeKonum = {