Update package.json scripts, enhance layout with new components, and improve API error handling
Some checks failed
Deploy / deploy (push) Failing after 1h28m6s

- Added new scripts for database operations and temporary production in package.json.
- Integrated KaydetBannerLazy component into the layout for improved user notifications.
- Enhanced API error handling in the soru route to mark credit exhaustion.
- Updated the IletisimPage for better button styling and user experience.
- Refactored ListemPage to streamline user flow and improve session handling.
- Removed unused ListePaneli component to clean up the codebase.
This commit is contained in:
bilalgursen
2026-08-06 02:27:42 +03:00
parent 488845ae1f
commit e62c6f07df
77 changed files with 4132 additions and 1659 deletions

View File

@@ -7,6 +7,7 @@ import { PUAN_TURLERI, type PuanTuruKey } from "@/lib/db";
import {
spendCredits,
grantCredits,
krediBittiIsaretle,
RAPOR_KREDI,
MAX_REVIZYON,
} from "@/lib/credits";
@@ -113,6 +114,7 @@ export async function listeOlustur(input: {
});
if (!harcama.ok) {
if (harcama.error === "INSUFFICIENT") {
await krediBittiIsaretle(user.id);
return {
ok: false,
code: "KREDI",
@@ -241,6 +243,7 @@ export async function listeRevize(input: {
});
if (!harcama.ok) {
if (harcama.error === "INSUFFICIENT") {
await krediBittiIsaretle(user.id);
return {
ok: false,
code: "KREDI",

View File

@@ -1,401 +0,0 @@
"use client";
import { useMemo, useEffect, useRef, useState } from "react";
import Link from "next/link";
import { ArrowRight, FileText, Lock, RefreshCcw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { SohbetPopup } from "./sohbet-popup";
import { RaporListesi } from "@/components/rapor-listesi";
import { TercihHaritasi, type HaritaPin } from "@/components/tercih-haritasi";
import type { RaporSonuc } from "@/lib/ai/rapor";
import type { RaporParams } from "@/lib/rapor-havuzu";
import { ACIK_SATIR } from "@/lib/rapor-maske";
import { ilKonum, uniAdiNormalize, uniKonum } from "@/lib/harita";
import { dilimdenRisk, riskHesapla, type RiskSeviyesi } from "@/lib/risk";
const MAX_REVIZYON = 2;
export type MevcutRapor = {
rapor: RaporSonuc;
params: RaporParams;
revisionCount: number;
};
/** Panelin liste dışındaki geçici durumları (liste, rapor varlığından türer). */
export type PanelDurum = "bos" | "uretiliyor" | "kredi-yetersiz";
const RISK_AGIRLIK: Record<RiskSeviyesi, number> = {
guvenli: 0,
"az-riskli": 1,
riskli: 2,
};
/**
* Rapordan harita pinleri türetir. Açık satırlar üniversite bazında gruplanır
* (kampüs konumunda, adıyla); kilitli satırlar il başına tek adsız "?" pinine
* toplanır — maskeli veride üniversite adı zaten client'a inmediği için
* harita paywall'ı delemez. Haritada karşılığı olmayan tercihler sayılır.
*/
function pinleriTuret(
rapor: RaporSonuc,
adaySira: number,
kilitliBaslangic: number,
): { pinler: HaritaPin[]; haritaDisi: number } {
const gruplar = new Map<string, HaritaPin>();
let haritaDisi = 0;
rapor.tercihler.forEach((t, i) => {
const p = rapor.programlar[t.programId];
if (!p) return;
const risk = riskHesapla(p.efektifSira, adaySira) ?? dilimdenRisk(t.dilim);
const kilitli = i >= kilitliBaslangic;
if (kilitli) {
const konum = ilKonum(p.il);
if (!konum) {
haritaDisi += 1;
return;
}
const key = `kilit|${p.il}`;
const mevcut = gruplar.get(key);
if (mevcut) {
mevcut.tercihSiralari.push(t.sira);
if (RISK_AGIRLIK[risk] > RISK_AGIRLIK[mevcut.risk]) mevcut.risk = risk;
} else {
// Açık pinlerle üst üste binmesin diye il merkezinin hafif altına
gruplar.set(key, {
id: key,
universite: "",
il: p.il,
x: konum.x,
y: konum.y + 9,
tercihSiralari: [t.sira],
risk,
kilitli: true,
});
}
return;
}
const konum = uniKonum(p.universite, p.il);
if (!konum) {
haritaDisi += 1;
return;
}
const key = `${uniAdiNormalize(p.universite)}|${p.il ?? ""}`;
const mevcut = gruplar.get(key);
if (mevcut) {
mevcut.tercihSiralari.push(t.sira);
if (RISK_AGIRLIK[risk] > RISK_AGIRLIK[mevcut.risk]) mevcut.risk = risk;
} else {
gruplar.set(key, {
id: key,
universite: p.universite,
il: p.il,
x: konum.x,
y: konum.y,
tercihSiralari: [t.sira],
risk,
});
}
});
return { pinler: [...gruplar.values()], haritaDisi };
}
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…",
];
/**
* Sonuç sayfası gövdesindeki liste paneli: üretim animasyonu, paketli tam
* liste veya paketsiz progressive-blur önizleme burada render edilir.
* (Sihirbaz modalı yalnızca adımları toplar; sonuç artık sayfanın kendisinde.)
*/
export function ListePaneli({
durum,
rapor,
hasPaket,
kredi,
sira,
acilis,
onRevize,
}: {
durum: PanelDurum;
rapor: MevcutRapor | null;
hasPaket: boolean;
kredi: number;
sira: number;
/** Ödeme dönüşü: blur çözülme animasyonuyla aç */
acilis?: boolean;
onRevize: (feedback: string) => Promise<boolean>;
}) {
const kilitli = !hasPaket;
// Harita ⇄ liste köprüsü: pinden satıra, satırdan haritaya
const [seciliIl, setSeciliIl] = useState<string | null>(null);
const [acikSira, setAcikSira] = useState<number | null>(null);
const haritaRef = useRef<HTMLDivElement>(null);
const pinVerisi = useMemo(
() =>
rapor
? pinleriTuret(
rapor.rapor,
rapor.params.sira,
kilitli ? ACIK_SATIR : rapor.rapor.tercihler.length,
)
: null,
[rapor, kilitli],
);
if (durum === "uretiliyor") return <UretimGovdesi />;
if (durum === "kredi-yetersiz") {
// Paketli kullanıcıya paket satma: onun ürünü top-up. Liste ve sohbet
// geçmişi kalıcı; kredi yükleyince kaldığı yerden devam eder.
return (
<section className="mt-16 rounded-2xl border border-slate-200 bg-white p-8 text-center sm:p-12">
<Lock className="mx-auto size-8 text-slate-400" aria-hidden />
<h2 className="mt-4 font-heading text-xl font-bold">
Kredin bu liste için yetmiyor
</h2>
<p className="mx-auto mt-2 max-w-md text-sm text-slate-600">
{hasPaket
? "Liste üretimi 3 kredi. +30 kredi yüklediğinde listen ve sohbet geçmişin duruyor, kaldığın yerden devam edersin."
: "Liste üretimi 3 kredi. Tercih Dönemi Paketi'yle 60 kredi, tam liste, PDF ve revizyon hakları birlikte gelir."}
</p>
<Button
asChild
size="lg"
className="mt-6 h-12 cursor-pointer bg-orange-500 px-8 text-white transition-colors duration-200 hover:bg-orange-600"
>
<Link href="/paket">
{hasPaket ? "+30 kredi yükle — 129 TL" : "Paketi aktive et — 299 TL"}
<ArrowRight className="size-4" aria-hidden />
</Link>
</Button>
</section>
);
}
if (!rapor) return null;
return (
<section
className={`mt-16 ${acilis && !kilitli ? "kt-unblur" : ""}`}
aria-label="Tercih listen"
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 className="font-heading text-2xl font-bold">
24 Tercihlik Listen
</h2>
<p className="mt-1 text-sm text-slate-500">
{sira.toLocaleString("tr-TR")}. sıra ·{" "}
{kilitli
? `tüm taban verileri açık · ${ACIK_SATIR} Yapay Zeka analiz örneği`
: `${kredi} kredin kaldı`}
</p>
</div>
{!kilitli ? (
<Button asChild variant="outline" className="cursor-pointer">
<Link href="/rapor/yazdir" target="_blank">
<FileText className="size-4" aria-hidden />
PDF indir
</Link>
</Button>
) : null}
</div>
{/* Harita: 24 tercihin coğrafyası — ile dokun, üniversiteleri gör */}
<div ref={haritaRef} className="mt-5 scroll-mt-24">
<TercihHaritasi
pinler={pinVerisi?.pinler ?? []}
seciliIl={seciliIl}
onSeciliIlDegisti={setSeciliIl}
onTercihSec={setAcikSira}
/>
{pinVerisi && pinVerisi.haritaDisi > 0 ? (
<p className="mt-1 px-1 text-xs text-slate-500">
{pinVerisi.haritaDisi} tercih harita dışında (KKTC vb.)
</p>
) : null}
</div>
<div className="mt-5">
<RaporListesi
rapor={rapor.rapor}
adaySira={rapor.params.sira}
acikSira={acikSira}
onHaritadaGor={(il) => {
if (!il) return;
setSeciliIl(il);
haritaRef.current?.scrollIntoView({
behavior: "smooth",
block: "start",
});
}}
kilitliBaslangic={kilitli ? ACIK_SATIR : undefined}
kilitOverlay={
<div className="flex flex-col items-start gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex gap-3">
<Lock
className="mt-1 size-5 shrink-0 text-orange-700"
aria-hidden
/>
<div>
<p className="font-heading font-bold text-slate-900">
Tüm program ve taban verileri ücretsiz
</p>
<p className="mt-1 max-w-xl text-sm text-slate-600">
Paket; kalan {24 - ACIK_SATIR} tercih için kişisel risk,
gerekçe, PDF, 2 revizyon ve 60 Yapay Zeka danışman sorusunu açar.
</p>
{kredi > 0 ? (
<p className="mt-1 text-xs text-slate-500">
Kalan {kredi} deneme kredin hesabında duruyor.
</p>
) : null}
</div>
</div>
<Button
asChild
size="lg"
className="h-12 w-full shrink-0 cursor-pointer bg-orange-500 px-8 text-white transition-colors duration-200 hover:bg-orange-600 sm:w-auto"
>
<Link href="/paket">299 TL Analizleri </Link>
</Button>
</div>
}
/>
</div>
{!kilitli ? (
<div className="mt-6">
<RevizyonKutusu
kalanHak={MAX_REVIZYON - rapor.revisionCount}
onRevize={onRevize}
/>
</div>
) : null}
{/* Yapay Zeka danışman: sağ altta yüzen buton, alttan popup olarak açılır */}
<SohbetPopup kredi={kredi} />
</section>
);
}
function UretimGovdesi() {
const [mesajIdx, setMesajIdx] = useState(0);
const govdeRef = useRef<HTMLElement>(null);
useEffect(() => {
const interval = setInterval(() => {
setMesajIdx((i) => Math.min(i + 1, URETIM_MESAJLARI.length - 1));
}, 7000);
return () => clearInterval(interval);
}, []);
// Modal geçiş anında unmount edildiğinden Radix odağı tetikleyiciye geri
// veremiyor; odağı panele alıp klavye ve ekran okuyucu akışını sürdürüyoruz.
useEffect(() => {
govdeRef.current?.focus({ preventScroll: true });
}, []);
return (
<section
ref={govdeRef}
tabIndex={-1}
aria-live="polite"
className="mt-16 rounded-2xl border border-slate-200 bg-white p-6 outline-none sm:p-8"
>
<div className="flex flex-col items-center gap-1.5 text-center">
<p className="font-heading text-lg font-bold">Listen hazırlanıyor</p>
<p className="max-w-sm text-sm text-slate-500">
{URETIM_MESAJLARI[mesajIdx]}
</p>
<p className="text-xs text-slate-400">
Bu işlem yarım dakika kadar sürebilir, sayfayı kapatma.
</p>
</div>
{/* Gelmekte olan tercih listesinin iskeleti */}
<div className="mt-8 flex flex-col gap-2">
<div className="flex items-center justify-between">
<Skeleton className="h-6 w-44" />
<Skeleton className="h-6 w-24 rounded-full" />
</div>
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full rounded-xl" />
))}
</div>
</section>
);
}
function RevizyonKutusu({
kalanHak,
onRevize,
}: {
kalanHak: number;
onRevize: (feedback: string) => Promise<boolean>;
}) {
const [feedback, setFeedback] = useState("");
const [pending, setPending] = useState(false);
if (kalanHak <= 0) {
return (
<div className="rounded-2xl border border-slate-200 bg-white p-5">
<p className="text-sm text-slate-500">
Revizyon hakların doldu. Aşağıdan listen hakkında soru sorabilirsin.
</p>
</div>
);
}
return (
<div className="rounded-2xl border border-slate-200 bg-white p-5">
<h3 className="font-heading text-base font-bold">
Listede değişiklik mi istiyorsun?
</h3>
<p className="mb-3 mt-1 text-sm text-slate-600">
Ne değişsin istediğini yaz; liste aynı gerçek veriyle yeniden kurulsun
(3 kredi).
</p>
<textarea
value={feedback}
onChange={(e) => setFeedback(e.target.value)}
rows={2}
maxLength={1000}
placeholder="ör. İlk 5 tercihte daha fazla İstanbul olsun, garanti dilimini genişlet…"
disabled={pending}
className="w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
/>
<div className="mt-3 flex items-center gap-3">
<Button
type="button"
variant="outline"
disabled={pending || feedback.trim().length < 5}
onClick={async () => {
setPending(true);
const ok = await onRevize(feedback.trim());
setPending(false);
if (ok) setFeedback("");
}}
className="cursor-pointer"
>
<RefreshCcw className="size-4" aria-hidden />
{pending ? "Yeniden kuruluyor…" : "Revize et (3 kredi)"}
</Button>
<span className="text-xs text-slate-500">
Kalan hak: <span className="font-semibold">{kalanHak}</span>
</span>
</div>
</div>
);
}

View File

@@ -6,23 +6,16 @@ import {
searchByRank,
type PuanTuruKey,
} from "@/lib/db";
import { getSession, getCurrentUser } from "@/lib/session";
import { getSession } from "@/lib/session";
import { appDb, schema } from "@/lib/appdb";
import type { RaporSonuc } from "@/lib/ai/rapor";
import type { RaporParams } from "@/lib/rapor-havuzu";
import { raporMaskele } from "@/lib/rapor-maske";
import { tadimlikSec } from "@/lib/tadimlik-havuzu";
import { SihirbazBolumu } from "./sihirbaz-cagri-karti";
import type { MevcutRapor } from "./liste-paneli";
import { TadimlikSatiri } from "./tadimlik-satiri";
import { ProgramTablosu } from "@/components/program-tablosu";
import { ManuelHarita } from "@/components/manuel-liste/manuel-harita";
import { SiteFooter } from "@/components/site-footer";
import { SiraGerekli } from "./sira-gerekli";
// listeOlustur aksiyonu 2 AI denemesi yapabiliyor (deneme başına 50 sn abort
// sınırı, bkz. lib/ai/cagri.ts); platform limiti AI zaman aşımından SONRA
// dolmalı ki kredi iadesi her zaman çalışabilsin.
export const maxDuration = 120;
export const metadata: Metadata = {
title: "Tercih Listeni Kur",
// Param uzayı sonsuz + içerik sıralamaya göre kişisel: indexlenmez ama
@@ -38,7 +31,6 @@ export default async function SonucPage({
sira?: string;
tur?: string;
sihirbaz?: string;
acildi?: string;
hazir?: string;
}>;
}) {
@@ -58,34 +50,25 @@ export default async function SonucPage({
);
}
// Sihirbaz için: kullanıcı durumu, kayıtlı rapor ve dinamik facet'ler.
// Rapor sorgusu yalnızca session.user.id'ye bağlı — getCurrentUser'ı
// beklemeden paralel çalışır (seri DB zinciri kurma).
// Sihirbaz için: kullanıcının kayıtlı raporu var mı? Listenin kendisi
// /listem'de yaşar; burada yalnızca CTA'nın hangi hâlde render edileceği
// için varlık bilgisi gerekir.
const session = await getSession();
const [user, raporSatiri] = session
? await Promise.all([
getCurrentUser(),
appDb.query.reports.findFirst({
where: eq(schema.reports.userId, session.user.id),
}),
])
: [null, null];
const raporSatiri = session
? await appDb.query.reports.findFirst({
where: eq(schema.reports.userId, session.user.id),
columns: { result: true },
})
: null;
const facetler = rankWindowFacets(sira, turKey);
// Yapay Zeka'sız ham liste: yalnızca sıralamayla ulaşılabilen programlar (dilimli)
const hamSonuclar = searchByRank(sira, turKey, { limitPerBucket: 20 });
// Paketsize giden rapor sunucuda maskelenir: blur yalnızca CSS olduğundan
// kilitli satırların gerçek içeriği client'a hiç inmez.
const hasPaket = Boolean(user?.hasPaket);
const mevcutRapor: MevcutRapor | null = raporSatiri?.result
? {
rapor: hasPaket
? (raporSatiri.result as RaporSonuc)
: raporMaskele(raporSatiri.result as RaporSonuc),
params: raporSatiri.params as RaporParams,
revisionCount: raporSatiri.revisionCount,
}
: null;
// Girişsiz tadımlık (İP-B): SSR'da kovaya uyan "genel" satır seçilir;
// sihirbaz kategorisi client'ta okunup /api/tadimlik ile kişiselleştirilir.
// Girişli kullanıcı gerçek ürünü zaten görebildiği için tadımlık almaz.
const tadimlik = session ? null : await tadimlikSec(sira, turKey);
return (
<div className="min-h-screen bg-slate-50 text-slate-900">
@@ -99,18 +82,21 @@ export default async function SonucPage({
gövdesi; puan türü navbar'daki sıralama rozetinden değiştirilir */}
<ProgramTablosu sonuclar={hamSonuclar} adaySira={sira} tur={turKey} />
{/* Sihirbaz akışı (CTA + modal + Yapay Zeka listesi) — blurlu Yapay Zeka paneli
sayfanın en altında yaşar */}
{/* Girişsiz tadımlık: gerçek rapor satırı formatında tek örnek.
Havuzda içerik yoksa bileşen hiç render edilmez. */}
{!session ? (
<TadimlikSatiri sira={sira} tur={turKey} ilk={tadimlik} />
) : null}
{/* Sihirbaz akışı (CTA + modal) — üretim ve liste /listem'de yaşar,
burada yalnızca funnel kartları render edilir */}
<SihirbazBolumu
sira={sira}
tur={turKey}
facetler={facetler}
girisliMi={Boolean(session)}
hasPaket={hasPaket}
kredi={user?.creditBalance ?? 0}
mevcutRapor={mevcutRapor}
raporVar={Boolean(raporSatiri?.result)}
otomatikAc={params.sihirbaz === "1"}
acilis={params.acildi === "1"}
secimlerHazir={params.hazir === "1"}
/>

View File

@@ -3,29 +3,26 @@
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { ArrowRight, X } from "lucide-react";
import { ArrowRight } from "lucide-react";
import { animate } from "motion";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { toast } from "sonner";
import { useReducedMotion } from "motion/react";
import { Button } from "@/components/ui/button";
import { ViewportPortal } from "@/components/viewport-portal";
import { PixelField } from "@/components/pixel-decor";
import { useGorunumOlayi } from "@/components/use-gorunum-olayi";
import { olay } from "@/lib/analitik";
import type { SihirbazFacetleri } from "@/lib/db";
import {
SIHIRBAZ_AC_EVENT,
SIHIRBAZ_STORAGE_KEY,
sihirbazDogrula,
sonucHref,
tercihProfiliSil,
tercihProfiliYaz,
type SihirbazSecimleri,
} from "@/lib/sihirbaz";
import { olay, siraKovasi } from "@/lib/analitik";
import { SihirbazModal } from "./sihirbaz-modal";
import { ListePaneli, type MevcutRapor, type PanelDurum } from "./liste-paneli";
import { listeOlustur, listeRevize } from "./actions";
const KAPATMA_KEY = "kolaytercih.sihirbaz-karti-kapali";
// Üretim /listem'de koşar (bkz. listem/liste-uretici.tsx); sihirbaz
// tamamlanınca seçimler storage'a yazılıp kullanıcı buraya yönlendirilir.
const LISTEM_URET_HREF = "/listem?uret=1";
// SSR'da false, client'ta true — storage'a ancak mount sonrası dokunuruz
const bosAbone = () => () => {};
@@ -43,42 +40,35 @@ type BekleyenSecim = {
/**
* Sonuç sayfasında sihirbaz akışını yöneten client orkestratör:
* - Sayfa ortasındaki büyük CTA + sabit alt çağrı kartı (rapor yokken)
* - Sayfa ortasındaki büyük CTA (rapor yokken)
* - Adımları toplayan modal
* - Üretim/önizleme/liste paneli (sayfa gövdesinde)
* Akış: adımlar → kredi ile üretim → paketsizse progressive-blur önizleme,
* paketliyse tam liste. Girişsiz kullanıcı hero'dan seçimleri tamamlanmış
* gelirse önce ücretsiz tabloyu görür; giriş CTA'sı "hazır seçimlerin
* kaydedilmesi" olarak panelde sunulur, girişten dönüşte üretim otomatik
* başlar. Modal içinden ilerleyen girişsiz kullanıcı ise butonun söylediği
* gibi doğrudan girişe gider.
* Liste bu sayfada üretilmez ve gösterilmez: sihirbaz tamamlanınca seçimler
* storage'a yazılır ve kullanıcı /listem'e yönlendirilir; üretim animasyonu,
* kredi/hata durumları ve listenin kendisi orada yaşar. Girişsiz kullanıcı
* hero'dan seçimleri tamamlanmış gelirse önce ücretsiz tabloyu görür; giriş
* CTA'sı "hazır seçimlerin kaydedilmesi" olarak panelde sunulur, girişten
* dönüş doğrudan /listem'e düşer ve üretim orada başlar.
*/
export function SihirbazBolumu({
sira,
tur,
facetler,
girisliMi,
hasPaket,
kredi: baslangicKredi,
mevcutRapor,
raporVar,
otomatikAc,
acilis,
secimlerHazir,
}: {
sira: number;
tur: string;
facetler: SihirbazFacetleri;
girisliMi: boolean;
hasPaket: boolean;
kredi: number;
mevcutRapor: MevcutRapor | null;
/** Kullanıcının kayıtlı bir Yapay Zeka listesi var mı (listenin kendisi /listem'de) */
raporVar: boolean;
/** ?sihirbaz=1 ile gelindiyse modalı otomatik aç */
otomatikAc?: boolean;
/** ?acildi=1 (ödeme dönüşü): blur çözülme animasyonuyla aç */
acilis?: boolean;
/** ?hazir=1 (hero sihirbazından girişsiz geliş): seçimler storage'da,
panel giriş CTA'sına döner. URL'den gelir ki SSR'da doğru panel
render edilsin — mount sonrası anahtar değişimi animasyonu donduruyor. */
render edilsin. */
secimlerHazir?: boolean;
}) {
const router = useRouter();
@@ -87,36 +77,19 @@ export function SihirbazBolumu({
const [elleAcik, setElleAcik] = useState(false);
const [elleKapandi, setElleKapandi] = useState(false);
const [kartGizle, setKartGizle] = useState(false);
const [bekleyen, setBekleyen] = useState<BekleyenSecim | null>(null);
// ?sihirbaz=1 fallback modalı storage okunmadan açılmasın: hero'dan gelen
// kayıtlı seçimler varken modal bir anlığına açılıp kapanıyordu.
const [depoOkundu, setDepoOkundu] = useState(false);
const [rapor, setRapor] = useState<MevcutRapor | null>(mevcutRapor);
const [kredi, setKredi] = useState(baslangicKredi);
const [paketli, setPaketli] = useState(hasPaket);
const [durum, setDurum] = useState<PanelDurum>("bos");
// Adımlarda yapılan son seçimler (hata sonrası modala dönüşte prefill)
const [sonSecimler, setSonSecimler] = useState<SihirbazSecimleri | null>(
null,
);
// Aynı üretim için sabit requestId (retry çifte harcama yapmaz)
const requestIdRef = useRef<string | null>(null);
// Revizyon için aynı desen (bkz. listem/revizyon-kutusu.tsx)
const revizyonIdRef = useRef<string | null>(null);
const otomatikBasladi = useRef(false);
const bolumRef = useRef<HTMLDivElement>(null);
// Funnel CTA'ları (banner olayı dahil) tıklama anındaki bekleyen seçimi
// görsün diye ref'te tutulur; olay dinleyicisi bir kez bağlanır.
const bekleyenRef = useRef<BekleyenSecim | null>(null);
// Mount sonrası storage'dan bekleyen seçimleri + kalıcı kart kapatmayı oku
// Mount sonrası storage'dan bekleyen seçimleri oku
useEffect(() => {
try {
const kaliciKapali = Boolean(sessionStorage.getItem(KAPATMA_KEY));
const ham = localStorage.getItem(SIHIRBAZ_STORAGE_KEY);
let yeniBekleyen: BekleyenSecim | null = null;
if (ham) {
@@ -133,9 +106,8 @@ export function SihirbazBolumu({
};
}
}
// eslint-disable-next-line react-hooks/set-state-in-effect
if (kaliciKapali) setKartGizle(true);
if (yeniBekleyen) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setBekleyen(yeniBekleyen);
bekleyenRef.current = yeniBekleyen;
}
@@ -151,19 +123,6 @@ export function SihirbazBolumu({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
/**
* Bölüm ekran dışındaysa (kullanıcı sihirbazı üstteki banner'dan ya da
* sabit alt karttan açmış olabilir) modal hâlâ perdeyi kaplarken kaydırırız;
* böylece Motion ile giren hedef panel görünür alanda kalır.
*/
function bolumuGorunureAl() {
const el = bolumRef.current;
if (!el) return;
const kutu = el.getBoundingClientRect();
if (kutu.top >= 0 && kutu.bottom <= window.innerHeight) return;
el.scrollIntoView({ block: "center", behavior: "instant" });
}
async function modalCikisiniOynat() {
if (hareketiAzalt) return;
@@ -202,85 +161,26 @@ export function SihirbazBolumu({
await modalAnimasyonu;
}
async function uret(secimler: SihirbazSecimleri) {
if (!requestIdRef.current) requestIdRef.current = crypto.randomUUID();
bolumuGorunureAl();
/**
* Sihirbaz tamamlandı: seçimleri storage'a yaz ve üretimin koştuğu
* /listem'e geç. Girişsiz kullanıcı önce girişe uğrar; callback doğrudan
* /listem'e düştüğünden üretim dönüşte orada otomatik başlar.
*/
async function ilerle(secimler: SihirbazSecimleri) {
tercihProfiliYaz({ sira, tur, secimler });
await modalCikisiniOynat();
setElleAcik(false);
setDurum("uretiliyor");
try {
const sonuc = await listeOlustur({
sira,
tur,
secimler,
requestId: requestIdRef.current,
});
if (sonuc.ok) {
requestIdRef.current = null;
olay("liste_olusturuldu", { tur, sira_kovasi: siraKovasi(sira) });
setRapor({
rapor: sonuc.rapor,
params: sonuc.params,
revisionCount: sonuc.revisionCount,
});
setKredi(sonuc.kredi);
setPaketli(sonuc.hasPaket);
tercihProfiliSil();
// Yapay Zeka çı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");
// Header'daki kredi pill'i layout'ta server-render edilir; push layout'u
// yeniden çizmediği için düşen bakiye refresh ile güncellenir.
router.refresh();
return;
}
// Sunucudan cevap geldiyse harcama ya hiç olmadı ya iade edildi;
// sonraki deneme taze requestId ile başlar (aynı id ledger'daki UNIQUE'e
// takılıp DUPLICATE çıkmazına giriyordu). requestId yalnızca cevap
// alınamayan network hatasında (catch) korunur — orada çifte harcama
// riski gerçek.
requestIdRef.current = null;
if (sonuc.code === "KREDI" || sonuc.code === "PAKET") {
setDurum("kredi-yetersiz");
} else if (sonuc.code === "AUTH") {
secimleriKaydetVeGirise(secimler);
} else {
toast.error(sonuc.error);
modalaDon();
}
} catch {
toast.error("Beklenmeyen bir hata oluştu, tekrar dener misin?");
modalaDon();
}
}
/** Hata sonrası panelden modala dönüş. */
function modalaDon() {
setDurum("bos");
setElleAcik(true);
}
function secimleriKaydetVeGirise(secimler: SihirbazSecimleri) {
tercihProfiliYaz({ sira, tur, secimler });
const geri = sonucHref({ sira, tur }, { sihirbaz: "1" });
router.push(`/giris?callback=${encodeURIComponent(geri)}`);
}
async function ilerle(secimler: SihirbazSecimleri) {
setSonSecimler(secimler);
if (!girisliMi) {
await modalCikisiniOynat();
secimleriKaydetVeGirise(secimler);
router.push(`/giris?callback=${encodeURIComponent(LISTEM_URET_HREF)}`);
return;
}
await uret(secimler);
router.push(LISTEM_URET_HREF);
}
/**
* Funnel CTA'larının (banner, büyük kart, sabit alt kart) ortak girişi:
* Funnel CTA'larının (banner, büyük kart) ortak girişi:
* adımlar zaten tamamlanıp kaydedildiyse modal açılmaz — girişli
* kullanıcıda üretim doğrudan başlar, girişsiz kullanıcı girişe gider.
* kullanıcı doğrudan /listem'e gider, girişsiz kullanıcı girişe gider.
* Kayıtlı seçim yoksa (ya da başka sıra/türe aitse) modal açılır.
*/
function sihirbaziBaslat() {
@@ -293,28 +193,28 @@ export function SihirbazBolumu({
setElleKapandi(false);
}
// Giriş dönüşü: storage'daki eşleşen seçimlerle otomatik üretime başla
// (bir kez, modal açmadan — üretim sayfa gövdesinde oynar).
const otomatikUret = Boolean(bekleyen?.eslesir && girisliMi && !rapor);
// Giriş dönüşü (?sihirbaz=1) storage'da eşleşen seçimlerle geldiyse modal
// ılmaz: seçimler hazır, üretim /listem'de başlasın (bir kez).
const otomatikGonder = Boolean(
otomatikAc && bekleyen?.eslesir && girisliMi && !raporVar,
);
useEffect(() => {
if (otomatikUret && bekleyen && !otomatikBasladi.current) {
if (otomatikGonder && !otomatikBasladi.current) {
otomatikBasladi.current = true;
setSonSecimler(bekleyen.secimler);
void uret(bekleyen.secimler);
// replace: geri tuşu bu sayfaya dönünce tekrar /listem'e sekmesin
router.replace(LISTEM_URET_HREF);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [otomatikUret]);
}, [otomatikGonder]);
// ?sihirbaz=1: otomatik üretim devreye girmediyse (seçim kaybolduysa/
// ?sihirbaz=1: otomatik yönlendirme devreye girmediyse (seçim kaybolduysa/
// eşleşmiyorsa) modalı aç ki kullanıcı adımları tamamlayabilsin. Karar
// storage okunmadan verilmez: kayıtlı seçimlerle gelen kullanıcıya modal
// hiç gösterilmez, üretim doğrudan başlar.
// hiç gösterilmez, /listem'e geçilir.
const acik =
!elleKapandi &&
(elleAcik ||
Boolean(
otomatikAc && depoOkundu && !otomatikUret && !rapor && durum === "bos",
));
Boolean(otomatikAc && depoOkundu && !otomatikGonder && !raporVar));
function acikDegisti(v: boolean) {
if (v) {
@@ -326,245 +226,113 @@ export function SihirbazBolumu({
}
}
const panelde = Boolean(rapor) || durum !== "bos";
// Değer önce: sihirbazı bitirmiş ama girişsiz kullanıcı ücretsiz tabloyu
// zaten görüyor; giriş burada bariyer değil, hazır seçimlerin (ödülün)
// kaydedilmesi olarak sunulur. Girişten dönüşte otomatik üretim başlar.
// Sinyal URL'den (?hazir=1) gelir; storage'a bakılsaydı panel anahtarı
// mount sonrası değişir ve AnimatePresence geçişi donardı.
const girisBekliyor = Boolean(!girisliMi && secimlerHazir && !rapor);
const girisUrl = `/giris?callback=${encodeURIComponent(
sonucHref({ sira, tur }, { sihirbaz: "1" }),
)}`;
const panelAnahtari =
durum !== "bos"
? durum
: rapor
? "rapor-hazir"
: girisBekliyor
? "giris-cta"
: "sihirbaz-cta";
// kaydedilmesi olarak sunulur. Girişten dönüşte üretim /listem'de başlar.
// Sinyal URL'den (?hazir=1) gelir ki SSR'da doğru panel render edilsin.
const girisBekliyor = Boolean(!girisliMi && secimlerHazir && !raporVar);
const girisUrl = `/giris?callback=${encodeURIComponent(LISTEM_URET_HREF)}`;
// Kapı 1 ölçümü: panel yalnızca girisBekliyor dalında render edilir; ref
// başka dalda bağlanmadığından observer da yalnızca o durumda kurulur.
const hazirPanelRef = useGorunumOlayi<HTMLElement>("hazir_panel_goruntulendi");
return (
<>
{/* Yapay Zeka çıktısı (rapor listesi/haritası) bu sayfada render edilmez;
panel yalnızca üretim ve kredi-yetersiz gibi geçici funnel
durumlarını gösterir, listenin kendisi /listem'de yaşar. */}
<div ref={bolumRef} className="relative">
<AnimatePresence mode="popLayout" initial={false}>
<motion.div
key={panelAnahtari}
initial={
hareketiAzalt
? { opacity: 0 }
: {
opacity: 0,
transform: "translate3d(0, 8px, 0)",
}
}
animate={{
opacity: 1,
transform: "translate3d(0, 0, 0)",
transition: hareketiAzalt
? { duration: 0.12, ease: "easeOut" }
: {
duration: 0.28,
ease: [0.23, 1, 0.32, 1],
},
}}
exit={
hareketiAzalt
? {
opacity: 0,
transition: { duration: 0.08, ease: "easeOut" },
}
: {
opacity: 0,
transform: "translate3d(0, -4px, 0)",
transition: {
duration: 0.14,
ease: [0.4, 0, 0.7, 0.2],
},
}
}
{/* Yapay Zeka çıktısı (üretim animasyonu dahil) bu sayfada render
edilmez; panel yalnızca funnel CTA'larını gösterir, üretim ve
listenin kendisi /listem'de yaşar. */}
{raporVar ? (
<section className="mt-16 rounded-2xl border border-slate-200 bg-white p-8 text-center sm:p-12">
<h2 className="font-heading text-2xl font-bold sm:text-3xl">
24 tercihlik listen hazır
</h2>
<p className="mx-auto mt-3 max-w-xl text-slate-600">
Yapay zekânın kurduğu risk analizli listen Listem sayfasında
seni bekliyor.
</p>
<Button
asChild
size="lg"
className="mt-6 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
>
{durum !== "bos" ? (
<ListePaneli
durum={durum}
rapor={null}
hasPaket={paketli}
kredi={kredi}
sira={sira}
acilis={acilis}
onRevize={async (feedback) => {
if (!revizyonIdRef.current) {
revizyonIdRef.current = crypto.randomUUID();
}
const sonuc = await listeRevize({
feedback,
requestId: revizyonIdRef.current,
});
revizyonIdRef.current = null;
if (sonuc.ok) {
olay("liste_revize", { sayfa: "sonuc" });
setRapor({
rapor: sonuc.rapor,
params: sonuc.params,
revisionCount: sonuc.revisionCount,
});
setKredi(sonuc.kredi);
toast.success("Listen yeniden kuruldu.");
} else {
toast.error(sonuc.error);
}
return sonuc.ok;
}}
/>
) : rapor ? (
<section className="mt-16 rounded-2xl border border-slate-200 bg-white p-8 text-center sm:p-12">
<h2 className="font-heading text-2xl font-bold sm:text-3xl">
24 tercihlik listen hazır
</h2>
<p className="mx-auto mt-3 max-w-xl text-slate-600">
Yapay zekânın kurduğu risk analizli listen Listem sayfasında
seni bekliyor.
</p>
<Button
asChild
size="lg"
className="mt-6 h-12 cursor-pointer bg-orange-500 px-8 text-white transition-colors duration-200 hover:bg-orange-600"
>
<Link href="/listem">Listeni gör</Link>
</Button>
</section>
) : girisBekliyor ? (
<section className="relative isolate mt-16 min-w-0 overflow-hidden rounded-2xl bg-primary px-5 py-8 text-center text-white sm:p-12">
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 -inset-y-10 -z-10 text-white"
>
<PixelField seed={13} />
</div>
<h2 className="relative font-heading text-2xl font-bold sm:text-3xl">
Seçimlerin hazır Yapay Zeka listen bir adım uzakta
</h2>
<p className="relative mx-auto mt-3 max-w-xl text-white/90">
Yukarıdaki tablo her zaman ücretsiz. 5 deneme kredinle 24
tercihlik Yapay Zeka listeni de kurabilirsin: ücretsiz giriş yap,
seçimlerin kayıtlı listen otomatik kurulsun (üretim 3
kredi, kalan 2 kredinle danışmana soru sorarsın).
</p>
<Button
asChild
size="lg"
className="relative mt-6 h-auto min-h-12 w-full cursor-pointer whitespace-normal bg-orange-500 px-4 py-3 text-white transition-colors duration-200 hover:bg-orange-600 sm:w-auto sm:px-8"
>
<Link href={girisUrl}>
5 deneme kredinle Yapay Zeka listeni kur giriş yap
<ArrowRight className="size-4" aria-hidden />
</Link>
</Button>
<p className="relative mt-3 text-xs text-white/70">
Kredi kartı gerekmez · Google ya da e-posta ile 30 saniyede
</p>
</section>
) : (
<section className="relative isolate mt-16 min-w-0 overflow-hidden rounded-2xl bg-primary px-5 py-8 text-center text-white sm:p-12">
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 -inset-y-10 -z-10 text-white"
>
<PixelField seed={19} />
</div>
<h2 className="relative font-heading text-2xl font-bold sm:text-3xl">
24 tercihi tek tek elemek yerine, yapay zekâ kursun
</h2>
<p className="relative mx-auto mt-3 max-w-xl text-white/90">
Tablodan elle eklemek her zaman serbest. Ama üç adımda ilgi
alanlarını ve şehirlerini söylersen, yapay zekâ hayal, dengeli
ve garanti dilimlerine dağıtılmış 24 tercihi her satırı
yerleşme riski ve gerekçesiyle senin yerine dizer.
</p>
<Button
size="lg"
onClick={sihirbaziBaslat}
className="relative mt-6 h-auto min-h-12 w-full cursor-pointer whitespace-normal bg-orange-500 px-4 py-3 text-white transition-colors duration-200 hover:bg-orange-600 sm:w-auto sm:px-8"
>
Yapay Zeka listemi kur
<ArrowRight className="size-4" aria-hidden />
</Button>
</section>
)}
</motion.div>
</AnimatePresence>
</div>
{/* Sabit alt çağrı kartı — liste/panel yokken ve kart kapatılmadıysa */}
<ViewportPortal>
{monteEdildi && !panelde && !kartGizle && !acik ? (
<div className="pointer-events-none fixed inset-x-0 bottom-4 z-40 px-4">
<div className="pointer-events-auto mx-auto flex max-w-xl items-center gap-4 rounded-2xl border border-slate-200 bg-white p-4 shadow-xl">
<div className="flex-1">
<p className="font-heading text-sm font-bold text-slate-900">
{girisBekliyor
? "Seçimlerin kayıtlı — Yapay Zeka listen hazır bekliyor"
: "Tek tek ekleme, yapay zekâ kursun"}
</p>
<p className="text-xs text-slate-500">
{girisBekliyor
? "Ücretsiz giriş yap, 5 deneme kredinle listen otomatik kurulsun."
: "Üç adımda seçimlerini söyle; risk analizli, gerekçeli 24 tercih hazır."}
</p>
</div>
{girisBekliyor ? (
<Button
asChild
size="sm"
className="cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
>
<Link href={girisUrl}>Giriş yap</Link>
</Button>
) : (
<Button
size="sm"
onClick={sihirbaziBaslat}
className="cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
>
Başla
</Button>
)}
<button
type="button"
aria-label="Kapat"
onClick={() => {
setKartGizle(true);
try {
sessionStorage.setItem(KAPATMA_KEY, "1");
} catch {}
}}
className="cursor-pointer rounded-full p-1 text-slate-400 transition-colors duration-200 hover:bg-slate-100 hover:text-slate-600"
>
<X className="size-4" aria-hidden />
</button>
</div>
<Link href="/listem">Listeni gör</Link>
</Button>
</section>
) : girisBekliyor ? (
<section
ref={hazirPanelRef}
className="relative isolate mt-16 min-w-0 overflow-hidden rounded-2xl bg-primary px-5 py-8 text-center text-white sm:p-12"
>
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 -inset-y-10 -z-10 text-white"
>
<PixelField seed={13} />
</div>
) : null}
</ViewportPortal>
<h2 className="relative font-heading text-2xl font-bold sm:text-3xl">
Seçimlerin hazır Yapay Zeka listen bir adım uzakta
</h2>
<p className="relative mx-auto mt-3 max-w-xl text-white/90">
Yukarıdaki tablo her zaman ücretsiz. 5 deneme kredinle 24
tercihlik Yapay Zeka listeni de kurabilirsin: ücretsiz giriş yap,
seçimlerin kayıtlı listen otomatik kurulsun (üretim 3
kredi, kalan 2 kredinle danışmana soru sorarsın).
</p>
<Button
asChild
size="lg"
className="relative mt-6 h-auto min-h-12 w-full cursor-pointer whitespace-normal bg-orange-500 px-4 py-3 text-white transition-colors duration-200 hover:bg-orange-600 sm:w-auto sm:px-8"
>
<Link
href={girisUrl}
onClick={() =>
olay("giris_cta_tiklandi", { kaynak: "hazir_panel" })
}
>
5 deneme kredinle Yapay Zeka listeni kur giriş yap
<ArrowRight className="size-4" aria-hidden />
</Link>
</Button>
<p className="relative mt-3 text-xs text-white/70">
Kredi kartı gerekmez · Google ya da e-posta ile 30 saniyede
</p>
</section>
) : (
<section className="relative isolate mt-16 min-w-0 overflow-hidden rounded-2xl bg-primary px-5 py-8 text-center text-white sm:p-12">
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 -inset-y-10 -z-10 text-white"
>
<PixelField seed={19} />
</div>
<h2 className="relative font-heading text-2xl font-bold sm:text-3xl">
24 tercihi tek tek elemek yerine, yapay zekâ kursun
</h2>
<p className="relative mx-auto mt-3 max-w-xl text-white/90">
Tablodan elle eklemek her zaman serbest. Ama üç adımda ilgi
alanlarını ve şehirlerini söylersen, yapay zekâ hayal, dengeli
ve garanti dilimlerine dağıtılmış 24 tercihi her satırı
yerleşme riski ve gerekçesiyle senin yerine dizer.
</p>
<Button
size="lg"
onClick={sihirbaziBaslat}
className="relative mt-6 h-auto min-h-12 w-full cursor-pointer whitespace-normal bg-orange-500 px-4 py-3 text-white transition-colors duration-200 hover:bg-orange-600 sm:w-auto sm:px-8"
>
Yapay Zeka listemi kur
<ArrowRight className="size-4" aria-hidden />
</Button>
</section>
)}
{/* Üretim başladığında modalı kaldır; yeni panelin geçişini Motion
üstlenir ve odağı panel içeriğine taşır. */}
{monteEdildi && durum === "bos" ? (
{monteEdildi ? (
<SihirbazModal
acik={acik}
onAcikDegisti={acikDegisti}
sira={sira}
tur={tur}
facetler={facetler}
baslangicSecimler={
sonSecimler ?? (bekleyen?.eslesir ? bekleyen.secimler : null)
}
baslangicSecimler={bekleyen?.eslesir ? bekleyen.secimler : null}
sonButonEtiketi={
girisliMi ? "Yapay Zeka listemi kur (3 kredi)" : "Giriş yap ve listeni gör"
}

View File

@@ -12,7 +12,7 @@ import type { SihirbazSecimleri } from "@/lib/sihirbaz";
/**
* Sihirbaz modalı artık yalnızca adımları toplar; üretim, önizleme ve liste
* sonuç sayfası gövdesinde yaşar (bkz. liste-paneli.tsx). Akış yönetimi
* /listem'de yaşar (bkz. listem/liste-uretici.tsx). Akış yönetimi
* sihirbaz-cagri-karti.tsx'teki SihirbazBolumu'ndadır.
*/
export function SihirbazModal({

View File

@@ -1,120 +0,0 @@
"use client";
// Yapay Zeka danışman sohbeti — sayfa altında yüzen buton + intercom tarzı popup.
// Motion dili: panel tetikleyicisinden doğar (transform-origin sağ-alt),
// spring ile açılır; çıkış girişten hızlı. Reduced motion'da cross-fade.
import { useEffect, useState } from "react";
import dynamic from "next/dynamic";
import { MessageCircleMore, X } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Skeleton } from "@/components/ui/skeleton";
import { ViewportPortal } from "@/components/viewport-portal";
function SohbetSkeleton() {
return (
<div
aria-busy="true"
aria-live="polite"
className="flex h-full flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-2xl"
>
<div className="flex items-center justify-between border-b border-slate-100 px-4 py-3">
<Skeleton className="h-5 w-36" />
<Skeleton className="h-6 w-20 rounded-full" />
</div>
<div className="flex flex-1 flex-col items-center gap-4 px-4 py-10">
<Skeleton className="size-7 rounded-full" />
<Skeleton className="h-5 w-56 max-w-full" />
<div className="w-full max-w-md space-y-2">
<Skeleton className="mx-auto h-3.5 w-full" />
<Skeleton className="mx-auto h-3.5 w-4/5" />
</div>
<div className="flex max-w-lg flex-wrap justify-center gap-2">
{[112, 148, 96, 132].map((w, i) => (
<Skeleton
key={i}
className="h-7 rounded-full"
style={{ width: w }}
/>
))}
</div>
</div>
<div className="border-t border-slate-100 p-3">
<Skeleton className="h-24 w-full rounded-2xl" />
</div>
</div>
);
}
// Sohbet istemcisi (markdown zinciriyle birlikte) ağır; popup ilk açıldığında
// yüklensin — /sonuc ilk yükünü şişirmesin.
const SohbetClient = dynamic(
() => import("@/app/listem/sohbet-client").then((m) => m.SohbetClient),
{
ssr: false,
loading: () => <SohbetSkeleton />,
},
);
export function SohbetPopup({ kredi }: { kredi: number }) {
const [acik, setAcik] = useState(false);
const azMotion = useReducedMotion();
useEffect(() => {
if (!acik) return;
const dinle = (e: KeyboardEvent) => {
if (e.key === "Escape") setAcik(false);
};
window.addEventListener("keydown", dinle);
return () => window.removeEventListener("keydown", dinle);
}, [acik]);
return (
<ViewportPortal>
<AnimatePresence>
{acik ? (
<motion.div
key="sohbet"
initial={
azMotion ? { opacity: 0 } : { opacity: 0, y: 24, scale: 0.95 }
}
animate={azMotion ? { opacity: 1 } : { opacity: 1, y: 0, scale: 1 }}
exit={
azMotion
? { opacity: 0, transition: { duration: 0.15 } }
: {
opacity: 0,
y: 16,
scale: 0.97,
transition: { duration: 0.18, ease: [0.23, 1, 0.32, 1] },
}
}
transition={{ type: "spring", duration: 0.4, bounce: 0.15 }}
style={{ transformOrigin: "bottom right" }}
className="fixed bottom-24 right-4 z-50 h-[min(72dvh,560px)] w-[min(100vw-2rem,420px)] print:hidden"
>
<SohbetClient
baslangicKredi={kredi}
className="h-full shadow-2xl"
/>
</motion.div>
) : null}
</AnimatePresence>
{/* Yüzen tetikleyici — basışta anında küçülür (pointer-down feedback) */}
<button
type="button"
onClick={() => setAcik((v) => !v)}
aria-expanded={acik}
className="fixed bottom-6 right-4 z-50 flex h-13 cursor-pointer items-center gap-2 rounded-full bg-slate-900 py-3 pl-4 pr-5 font-heading text-sm font-bold text-white shadow-xl transition-[transform,background-color] duration-150 hover:bg-slate-700 active:scale-[0.95] print:hidden"
>
{acik ? (
<X className="size-5" aria-hidden />
) : (
<MessageCircleMore className="size-5" aria-hidden />
)}
{acik ? "Kapat" : "Danışmana sor"}
</button>
</ViewportPortal>
);
}

View File

@@ -0,0 +1,173 @@
"use client";
// Girişsiz yapay zekâ tadımlığı (PRD İP-B): /sonuc'ta, sıra kovası + (varsa)
// sihirbaz alan tercihine uyan TEK örnek tercih satırı — gerçek rapor
// satırının detay bloğuyla (RaporSatirDetayi) birebir aynı formatta.
// İçerik batch üretilmiş havuzdan gelir; bu bileşen LLM'e istek attırmaz.
// SSR "genel" satırı render eder (prop), mount sonrası sihirbaz kategorisi
// varsa /api/tadimlik'ten kategoriye özel satır çekilip yerinde değiştirilir
// (seçim sunucuda kalır). Havuzda içerik yoksa hiç render edilmez.
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import { ArrowRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { UniLogo } from "@/components/uni-logo";
import { SectionEyebrow } from "@/components/pixel-decor";
import { RaporSatirDetayi } from "@/components/rapor-listesi";
import {
ProgramSiraGecmisi,
ProgramTabanTrendi,
programEtkinSira,
raporSiraSerisi,
} from "@/components/program-liste-verileri";
import { useGorunumOlayi } from "@/components/use-gorunum-olayi";
import { olay, siraKovasi } from "@/lib/analitik";
import { dilimdenRisk, riskHesapla } from "@/lib/risk";
import { turkishSlugify } from "@/lib/slug";
import {
PROFIL_DEGISTI_EVENT,
sonucHref,
tercihProfiliOku,
} from "@/lib/sihirbaz";
import type { TadimlikSatir } from "@/lib/tadimlik-havuzu";
const GENEL = "genel";
export function TadimlikSatiri({
sira,
tur,
ilk,
}: {
sira: number;
tur: string;
/** SSR'da seçilmiş "genel" satır; havuz boşsa null. */
ilk: TadimlikSatir | null;
}) {
const [satir, setSatir] = useState<TadimlikSatir | null>(ilk);
const yuklenenSlug = useRef(GENEL);
// Sihirbaz kategorisi yalnızca localStorage'da yaşar; mount'ta ve profil
// değiştikçe okunur, kategoriye özel satır sunucudan çekilir.
useEffect(() => {
const guncelle = () => {
const profil = tercihProfiliOku();
const kategori = profil?.secimler.kategoriler[0];
const slug = kategori ? turkishSlugify(kategori) : GENEL;
if (slug === yuklenenSlug.current) return;
yuklenenSlug.current = slug;
if (slug === GENEL) {
setSatir(ilk);
return;
}
fetch(
`/api/tadimlik?sira=${sira}&tur=${encodeURIComponent(tur)}&kategori=${encodeURIComponent(slug)}`,
)
.then((yanit) => (yanit.ok ? yanit.json() : null))
.then((veri: { tadimlik: TadimlikSatir | null } | null) => {
// Yarış koruması: bu arada başka kategori yüklendiyse dokunma
if (yuklenenSlug.current !== slug) return;
if (veri?.tadimlik) setSatir(veri.tadimlik);
})
.catch(() => {});
};
guncelle();
window.addEventListener(PROFIL_DEGISTI_EVENT, guncelle);
return () => window.removeEventListener(PROFIL_DEGISTI_EVENT, guncelle);
}, [sira, tur, ilk]);
const gorunumRef = useGorunumOlayi<HTMLElement>("tadimlik_goruntulendi", {
kova: siraKovasi(sira),
});
if (!satir) return null;
const p = satir.program;
const risk = riskHesapla(p.efektifSira, sira) ?? dilimdenRisk(satir.dilim);
const seri = raporSiraSerisi(p.siraGecmisi);
const taban = p.efektifSira ?? programEtkinSira(seri);
const devlet = p.unitur === "DEVLET";
const girisUrl = `/giris?callback=${encodeURIComponent(sonucHref({ sira, tur }))}`;
return (
<section ref={gorunumRef} className="mt-16 min-w-0">
<div className="flex flex-col items-center text-center">
<SectionEyebrow>Yapay zekâdan örnek satır</SectionEyebrow>
<h2 className="mt-4 font-heading text-2xl font-bold sm:text-3xl">
Yapay zekâ listendeki her satırı böyle anlatır
</h2>
<p className="mx-auto mt-3 max-w-xl text-slate-600">
Sıralamana uygun bir programın gerekçesi, yerleşme riski ve taban
trendi 24 tercihlik listenin her satırı bu formatta kurulur.
</p>
</div>
<div className="mt-6 rounded-2xl border border-slate-200 bg-white p-4 shadow-sm sm:p-6">
<div className="flex min-w-0 flex-wrap 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">
{p.unitur ? (
<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>
) : null}
<span className="truncate">
{p.universite}
{p.il ? ` · ${p.il.toLocaleLowerCase("tr-TR")}` : ""}
</span>
</span>
</span>
<span className="hidden text-right text-xs md:inline-flex">
<ProgramSiraGecmisi program={seri} />
</span>
<span className="inline-flex items-center gap-1 text-xs tabular-nums text-slate-500">
<ProgramTabanTrendi program={seri} />
{taban != null ? `~${taban.toLocaleString("tr-TR")}.` : "—"}
</span>
</div>
<div className="mt-4 rounded-lg bg-slate-50/60 px-4 py-4">
<RaporSatirDetayi
dilim={satir.dilim}
risk={risk}
unitur={p.unitur}
gerekce={satir.gerekce}
riskNotu={satir.riskNotu}
trendOzeti={satir.trendOzeti}
/>
</div>
<div className="mt-5 flex flex-col items-start gap-3 border-t border-slate-100 pt-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-slate-600">
Bu, 24 satırlık Yapay Zeka listenden yalnızca bir örnek.
<span className="block text-xs text-slate-500">
Kredi kartı gerekmez · Google ya da e-posta ile 30 saniyede
</span>
</p>
<Button
asChild
className="h-11 w-full shrink-0 cursor-pointer bg-orange-500 text-white transition-[background-color,transform] duration-200 active:scale-[0.97] hover:bg-orange-600 sm:w-auto"
>
<Link
href={girisUrl}
onClick={() => olay("giris_cta_tiklandi", { kaynak: "tadimlik" })}
>
24 satırın tamamı için giriş yap 5 deneme kredin hazır
<ArrowRight className="size-4" aria-hidden />
</Link>
</Button>
</div>
</div>
</section>
);
}