Update Next.js configuration and package dependencies
All checks were successful
Deploy / deploy (push) Successful in 9m29s

- Modified redirects in next.config.ts to change the destination for "/sohbet" to "/listem".
- Added new dependencies in package.json: marked, motion, react-markdown, remark-breaks, remark-gfm, shiki, and use-stick-to-bottom.
- Updated pnpm-lock.yaml to reflect the new package versions and dependencies.
- Removed obsolete stack files for Flutter, Nuxt, Nuxt.js, React Native, and Svelte from the UI/UX Pro Max skill data.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
bilalgursen
2026-07-26 04:26:52 +03:00
parent 1d0328d8eb
commit 209d203fe8
59 changed files with 6599 additions and 1446 deletions

View File

@@ -8,11 +8,16 @@ import { listeOzetiCikar, type RaporSonuc } from "@/lib/ai/rapor";
import type { RaporParams } from "@/lib/rapor-havuzu";
import { secimOzeti } from "@/lib/sihirbaz";
import { PUAN_TURLERI } from "@/lib/db";
import { ACIK_SATIR } from "@/lib/rapor-maske";
const MAX_GECMIS = 12;
const MAX_MESAJ_UZUNLUK = 2000;
function sistemPromptu(opts: { params: RaporParams; listeOzeti: string }): string {
function sistemPromptu(opts: {
params: RaporParams;
listeOzeti: string;
kilitli?: boolean;
}): string {
const { params } = opts;
const secimVar =
(params.kategoriler?.length ?? 0) > 0 ||
@@ -29,6 +34,10 @@ iş imkânları, tercih sırası) net ve dürüst yanıtlamak.
KURALLAR:
- Cevaplarını adayın 24'lük listesi ve seçimleri bağlamında ver; listedeki
programlara referans verebilirsin.
- Listedeki her satırda dilim ve risk etiketi var: Güvenli (yeşil) = taban
sıralaması adayın belirgin altında, Az riskli (sarı) = adayın sıralaması
civarında, Riskli (kırmızı) = geçen yıl adayın sıralamasının üzerinde
kapandı. Risk sorularında bu etiketleri ve taban sıralarını kullan.
- ASLA yerleşme garantisi verme; "yüksek/orta/düşük ihtimal" dili kullan.
- Bilmediğin güncel veriyi uydurma; emin değilsen YÖK Atlas'a bakmasını söyle.
- Taban sıralamalarının her yıl değiştiğini gerektiğinde hatırlat.
@@ -39,6 +48,15 @@ KURALLAR:
`ADAY BİLGİSİ: başarı sıralaması ${params.sira.toLocaleString("tr-TR")}, puan türü ${PUAN_TURLERI[params.tur]}.`,
`ADAYIN 24'LÜK LİSTESİ:\n${opts.listeOzeti}`,
];
if (opts.kilitli) {
parcalar.push(
`ÖNEMLİ: Adayın paketi yok; listenin yalnızca ilk ${ACIK_SATIR} tercihi açık.
Sana da yalnızca bu satırlar verildi — kilitli tercihlerin içeriğini BİLMİYORSUN,
asla tahmin edip söyleme. Kilitli satırlar sorulursa sempatik biçimde tam listenin
Tercih Dönemi Paketi'yle (299 TL, /paket) açıldığını söyle; açık satırlar ve genel
tercih soruları üzerinden yardımcı olmaya devam et.`,
);
}
if (secimVar) {
parcalar.push(
`ADAYIN SİHİRBAZ SEÇİMLERİ:\n${secimOzeti({
@@ -140,11 +158,67 @@ export async function POST(request: NextRequest) {
}
const kullaniciMesajId = harcama.messageId!;
// Paketsiz kullanıcının prompt'una yalnızca açık satırlar girer — aksi
// halde danışman kilitli listeyi sözle sızdırabilir.
const kullanici = await appDb.query.user.findFirst({
where: eq(schema.user.id, session.user.id),
columns: { hasPaket: true },
});
const kilitli = !kullanici?.hasPaket;
const tamOzet = listeOzetiCikar(
rapor.result as RaporSonuc,
(rapor.params as RaporParams).sira,
);
const listeOzeti = kilitli
? [
...tamOzet.split("\n").slice(0, ACIK_SATIR),
`… kalan ${24 - ACIK_SATIR} tercih kilitli.`,
].join("\n")
: tamOzet;
const system = sistemPromptu({
params: rapor.params as RaporParams,
listeOzeti: listeOzetiCikar(rapor.result as RaporSonuc),
listeOzeti,
kilitli,
});
// Dev'de API key yoksa danışman mock cevap stream'ler (prod'da asla)
if (
process.env.NODE_ENV !== "production" &&
!process.env.ANTHROPIC_API_KEY &&
!process.env.ANTHROPIC_AUTH_TOKEN
) {
const mockEncoder = new TextEncoder();
const mockUserId = session.user.id;
const cevap =
`(Dev mock — ANTHROPIC_API_KEY tanımlı değil.) Sorunu aldım: "${mesaj.slice(0, 120)}". ` +
`Gerçek danışman burada listen ve seçimlerin bağlamında yanıt verecek. ` +
`${SORUMLULUK_REDDI}`;
const mockReadable = new ReadableStream({
async start(controller) {
for (const parca of cevap.split(/(?<= )/)) {
controller.enqueue(mockEncoder.encode(parca));
await new Promise((r) => setTimeout(r, 25));
}
await appDb.insert(schema.chatMessages).values({
id: crypto.randomUUID(),
userId: mockUserId,
role: "assistant",
content: cevap,
createdAt: new Date(),
});
controller.close();
},
});
return new Response(mockReadable, {
headers: {
"Content-Type": "text/plain; charset=utf-8",
"X-Content-Type-Options": "nosniff",
"Cache-Control": "no-store",
},
});
}
let client;
try {
client = getAnthropic();

View File

@@ -0,0 +1,61 @@
"use client";
import { TextLoop } from "@/components/core/text-loop";
// Sonuç sayfasından gelen funnel'da dönen sempatik mesajlar.
// Kısa tutulmalı — başlıkta tek satır, taşma yok.
const FUNNEL_MESAJLARI = [
"1 adım kaldı tercihine",
"Listen seni bekliyor 👀",
"24 tercih, hepsi sana göre",
"5 kredin hazır, ya sen?",
];
/**
* Giriş sayfası başlığı. Kullanıcı sihirbazdan (callback /sonuc'a dönüyorsa)
* geldiyse TextLoop'la funnel mesajları döner; diğer girişlerde sabit başlık.
*/
export function GirisBaslik({ funnel }: { funnel: boolean }) {
if (!funnel) {
return <h1 className="font-heading text-2xl font-bold">Giriş yap</h1>;
}
return (
<h1 className="font-heading text-2xl font-bold">
<TextLoop
className="overflow-y-clip"
interval={2.5}
transition={{
type: "spring",
stiffness: 900,
damping: 80,
mass: 10,
}}
variants={{
initial: {
y: 20,
rotateX: 90,
opacity: 0,
filter: "blur(4px)",
},
animate: {
y: 0,
rotateX: 0,
opacity: 1,
filter: "blur(0px)",
},
exit: {
y: -20,
rotateX: -90,
opacity: 0,
filter: "blur(4px)",
},
}}
>
{FUNNEL_MESAJLARI.map((mesaj) => (
<span key={mesaj}>{mesaj}</span>
))}
</TextLoop>
</h1>
);
}

View File

@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { getSession } from "@/lib/session";
import { GirisForm } from "./giris-form";
import { GirisBaslik } from "./giris-baslik";
export const metadata: Metadata = {
title: "Giriş — KolayTercih",
@@ -19,14 +20,28 @@ export default async function GirisPage({
const session = await getSession();
if (session) redirect(callbackURL);
// Sihirbaz funnel'ı: seçimler yapıldı, listeye giriş kaldı
const funnel = callbackURL.startsWith("/sonuc");
return (
<div className="flex min-h-screen flex-col bg-slate-50 text-slate-900">
<main className="mx-auto flex w-full max-w-md flex-1 flex-col items-stretch justify-center px-4 py-16">
<div className="rounded-2xl border border-slate-200 bg-white p-8 shadow-sm">
<h1 className="font-heading text-2xl font-bold">Giriş yap</h1>
<GirisBaslik funnel={funnel} />
<p className="mt-2 text-sm text-slate-600">
Kayıt olduğunda <span className="font-semibold">5 deneme kredisi</span>{" "}
hesabına tanımlanır AI danışmana ücretsiz soru sorabilirsin.
{funnel ? (
<>
Seçimlerin hazır giriş yap,{" "}
<span className="font-semibold">5 deneme kredinle</span> listen
hemen üretilsin.
</>
) : (
<>
Kayıt olduğunda{" "}
<span className="font-semibold">5 deneme kredisi</span> hesabına
tanımlanır AI danışmana ücretsiz soru sorabilirsin.
</>
)}
</p>
<GirisForm callbackURL={callbackURL} />
</div>

View File

@@ -117,6 +117,33 @@
--sidebar-ring: oklch(0.556 0 0);
}
/* TypingAnimation imleci (animate-blink-cursor utility'si) */
@theme {
--animate-blink-cursor: blink-cursor 1s step-end infinite;
@keyframes blink-cursor {
50% {
opacity: 0;
}
}
}
/* Ödeme dönüşünde kilidi açılan listenin blur çözülme animasyonu */
@keyframes kt-unblur {
from {
filter: blur(12px);
opacity: 0.4;
}
to {
filter: blur(0);
opacity: 1;
}
}
.kt-unblur {
animation: kt-unblur 0.9s ease-out both;
}
@layer base {
* {
@apply border-border outline-ring/50;

View File

@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { Outfit, Work_Sans, Geist_Mono } from "next/font/google";
import { Toaster } from "@/components/ui/sonner";
import { SiteHeader } from "@/components/site-header";
import { ListeCekmecesi } from "@/components/manuel-liste/liste-cekmecesi";
import { HeaderGate } from "@/components/header-gate";
import { ProgressiveBlur } from "@/components/ui/skiper-ui/skiper41";
import "./globals.css";
@@ -59,6 +60,8 @@ export default function RootLayout({
<SiteHeader />
</HeaderGate>
{children}
{/* Manuel 24'lük liste çekmecesi + "+" uçuş katmanı (navbar'dan açılır) */}
<ListeCekmecesi />
<Toaster position="top-center" />
</body>
</html>

121
src/app/listem/page.tsx Normal file
View File

@@ -0,0 +1,121 @@
import type { Metadata } from "next";
import Link from "next/link";
import { eq } from "drizzle-orm";
import { redirect } from "next/navigation";
import { FileText, PencilLine, Sparkles } from "lucide-react";
import { verifySession, getCurrentUser } from "@/lib/session";
import { appDb, schema } from "@/lib/appdb";
import { PUAN_TURLERI } from "@/lib/db";
import type { RaporSonuc } from "@/lib/ai/rapor";
import type { RaporParams } from "@/lib/rapor-havuzu";
import { Button } from "@/components/ui/button";
import { Lock } from "lucide-react";
import { RaporListesi } from "@/components/rapor-listesi";
import { raporMaskele, ACIK_SATIR } from "@/lib/rapor-maske";
import { SohbetClient } from "./sohbet-client";
export const metadata: Metadata = {
title: "Listem — KolayTercih",
};
export default async function ListemPage() {
await verifySession("/listem");
const user = await getCurrentUser();
if (!user) redirect("/giris?callback=/listem");
const satir = await appDb.query.reports.findFirst({
where: eq(schema.reports.userId, user.id),
});
// Liste yoksa önce sihirbazdan oluşturulmalı
if (!satir?.result) {
return (
<main className="mx-auto flex max-w-2xl flex-col items-center px-4 py-24 text-center">
<Sparkles className="size-10 text-primary" aria-hidden />
<h1 className="mt-4 font-heading text-2xl font-bold">
Henüz bir listen yok
</h1>
<p className="mt-3 text-slate-600">
Sıralamanı girip birkaç adımda 24 tercihlik risk analizli listeni
kur; sonra burada görüntüleyip AI danışmanla üzerinde konuşabilirsin.
</p>
<Button
asChild
size="lg"
className="mt-6 h-12 cursor-pointer bg-orange-500 px-8 text-white hover:bg-orange-600"
>
<Link href="/">Sıralamanı gir, listeni kur</Link>
</Button>
</main>
);
}
// Paketsize giden rapor burada da maskelenir (bkz. rapor-maske)
const rapor = user.hasPaket
? (satir.result as RaporSonuc)
: raporMaskele(satir.result as RaporSonuc);
const params = satir.params as RaporParams;
return (
<main className="mx-auto w-full max-w-6xl px-4 py-8">
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="font-heading text-2xl font-bold sm:text-3xl">
Listem
</h1>
<p className="mt-1 text-sm text-slate-600">
{params.sira.toLocaleString("tr-TR")}. sıra ·{" "}
{PUAN_TURLERI[params.tur]} · 24 tercih, yerleşme riskine göre
renkli. Sorularını yandaki danışmana sorabilirsin (1 mesaj = 1
kredi).
</p>
</div>
{user.hasPaket ? (
<div className="flex gap-2">
<Button asChild variant="outline" size="sm" className="cursor-pointer">
<Link
href={`/sonuc?sira=${params.sira}&tur=${params.tur}&sihirbaz=1`}
>
<PencilLine className="size-4" aria-hidden />
Revize et
</Link>
</Button>
<Button asChild variant="outline" size="sm" className="cursor-pointer">
<Link href="/rapor/yazdir" target="_blank">
<FileText className="size-4" aria-hidden />
PDF
</Link>
</Button>
</div>
) : null}
</div>
<div className="mt-6 grid items-start gap-6 lg:grid-cols-[minmax(0,1fr)_400px]">
<RaporListesi
rapor={rapor}
adaySira={params.sira}
kilitliBaslangic={user.hasPaket ? undefined : ACIK_SATIR}
kilitOverlay={
<>
<Lock className="size-8 text-slate-500" aria-hidden />
<p className="max-w-sm text-center font-heading text-lg font-bold text-slate-900">
24 tercihin {24 - ACIK_SATIR}&apos;i kilidin ardında
</p>
<Button
asChild
size="lg"
className="h-12 cursor-pointer bg-orange-500 px-8 text-white transition-colors duration-200 hover:bg-orange-600"
>
<Link href="/paket">299 TL Tüm listeyi </Link>
</Button>
</>
}
/>
<div className="lg:sticky lg:top-28">
<SohbetClient baslangicKredi={user.creditBalance} />
</div>
</div>
</main>
);
}

View File

@@ -0,0 +1,260 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { ArrowUp, Coins, Sparkles } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
ChatContainerContent,
ChatContainerRoot,
ChatContainerScrollAnchor,
} from "@/components/ui/chat-container";
import { Message, MessageContent } from "@/components/ui/message";
import {
PromptInput,
PromptInputAction,
PromptInputActions,
PromptInputTextarea,
} from "@/components/ui/prompt-input";
import { PromptSuggestion } from "@/components/ui/prompt-suggestion";
import { ScrollButton } from "@/components/ui/scroll-button";
import { DotsLoader } from "@/components/ui/loader";
interface Mesaj {
id: string;
role: "user" | "assistant";
content: string;
}
const ORNEK_SORULAR = [
"Listemdeki kırmızı (riskli) tercihler ne kadar riskli?",
"İlk 5 tercihimin taban sıralaması trendi nasıl?",
"Güvenli dilimden bir tercihi daha üste alsam mantıklı mı?",
"X bölümü ile Y bölümü arasında kalırsam neye bakmalıyım?",
];
/**
* Listem sayfasındaki danışman sohbeti. /api/soru ile konuşur:
* GET geçmiş + kredi, POST 1 kredi düşerek cevabı stream eder.
*/
export function SohbetClient({
baslangicKredi,
className,
}: {
baslangicKredi: number;
/** Boyutlandırma override'ı (ör. alt popup'ta h-full) */
className?: string;
}) {
const [mesajlar, setMesajlar] = useState<Mesaj[]>([]);
const [girdi, setGirdi] = useState("");
const [bekliyor, setBekliyor] = useState(false);
const [yuklendi, setYuklendi] = useState(false);
const [kredi, setKredi] = useState(baslangicKredi);
useEffect(() => {
fetch("/api/soru")
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
if (data?.mesajlar) setMesajlar(data.mesajlar);
if (typeof data?.kredi === "number") setKredi(data.kredi);
})
.catch(() => {})
.finally(() => setYuklendi(true));
}, []);
async function gonder(metin: string) {
const mesaj = metin.trim();
if (!mesaj || bekliyor) return;
if (kredi < 1) {
toast.error("Kredin bitti — devam etmek için kredi yükle.");
return;
}
setGirdi("");
setBekliyor(true);
const clientMessageId = crypto.randomUUID();
const asistanId = crypto.randomUUID();
setMesajlar((m) => [
...m,
{ id: clientMessageId, role: "user", content: mesaj },
{ id: asistanId, role: "assistant", content: "" },
]);
try {
const res = await fetch("/api/soru", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: mesaj, clientMessageId }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setMesajlar((m) =>
m.filter((x) => x.id !== asistanId && x.id !== clientMessageId),
);
if (res.status === 402) {
toast.error("Kredin bitti — kredi yükleyerek devam edebilirsin.");
setKredi(0);
} else {
toast.error(data.error ?? "Bir sorun oluştu, tekrar dener misin?");
}
return;
}
setKredi((k) => k - 1);
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let birikmis = "";
for (;;) {
const { done, value } = await reader.read();
if (done) break;
birikmis += decoder.decode(value, { stream: true });
const anlik = birikmis;
setMesajlar((m) =>
m.map((x) => (x.id === asistanId ? { ...x, content: anlik } : x)),
);
}
} catch {
toast.error("Bağlantı koptu. Cevabı sayfayı yenileyerek görebilirsin.");
} finally {
setBekliyor(false);
}
}
const krediYok = kredi < 1;
return (
<section
aria-label="Danışman sohbeti"
className={cn(
"flex flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white",
className ?? "h-[calc(100dvh-9rem)] min-h-[480px]",
)}
>
<div className="flex items-center justify-between border-b border-slate-100 px-4 py-3">
<h2 className="font-heading text-base font-bold">Danışmanla konuş</h2>
<Link
href="/paket"
className="inline-flex items-center gap-1.5 rounded-full border border-amber-200 bg-amber-50 px-2.5 py-1 text-xs font-semibold text-amber-700 transition-colors hover:bg-amber-100"
title="Kredilerin — yüklemek için tıkla"
>
<Coins className="size-3.5" aria-hidden />
{kredi} kredi
</Link>
</div>
<div className="relative flex-1 overflow-hidden">
<ChatContainerRoot className="h-full">
<ChatContainerContent className="gap-4 p-4">
{mesajlar.length === 0 ? (
<div className="flex flex-col items-center gap-4 py-10 text-center">
<Sparkles className="size-7 text-primary" aria-hidden />
<div>
<p className="font-heading font-bold">
Listen hakkında her şeyi sorabilirsin
</p>
<p className="mx-auto mt-1 max-w-md text-sm text-slate-600">
Cevaplar senin 24&apos;lük listen, seçimlerin ve gerçek
YÖK Atlas verisi bağlamında üretilir. Her mesaj 1 kredi.
</p>
</div>
{yuklendi ? (
<div className="flex max-w-lg flex-wrap justify-center gap-2">
{ORNEK_SORULAR.map((s) => (
<PromptSuggestion
key={s}
size="sm"
className="cursor-pointer text-xs"
disabled={krediYok}
onClick={() => gonder(s)}
>
{s}
</PromptSuggestion>
))}
</div>
) : (
<DotsLoader />
)}
</div>
) : (
mesajlar.map((m) =>
m.role === "user" ? (
<Message key={m.id} className="justify-end">
<MessageContent className="max-w-[85%] bg-primary text-white">
{m.content}
</MessageContent>
</Message>
) : (
<Message key={m.id} className="justify-start">
{m.content ? (
<MessageContent
markdown
className="max-w-[85%] bg-slate-100 text-slate-800 prose-sm"
>
{m.content}
</MessageContent>
) : (
<div className="rounded-lg bg-slate-100 px-4 py-3">
<DotsLoader size="sm" />
</div>
)}
</Message>
),
)
)}
<ChatContainerScrollAnchor />
</ChatContainerContent>
<div className="absolute bottom-4 right-4">
<ScrollButton className="cursor-pointer bg-white shadow-md" />
</div>
</ChatContainerRoot>
</div>
<div className="border-t border-slate-100 p-3">
{krediYok ? (
<div className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3">
<p className="text-sm font-medium text-amber-800">
Kredin bitti sohbete devam etmek için kredi yükle.
</p>
<Button
asChild
size="sm"
className="cursor-pointer bg-orange-500 text-white hover:bg-orange-600"
>
<Link href="/paket">Kredi yükle</Link>
</Button>
</div>
) : (
<PromptInput
value={girdi}
onValueChange={setGirdi}
onSubmit={() => gonder(girdi)}
isLoading={bekliyor}
disabled={bekliyor}
maxHeight={160}
className="rounded-2xl border-slate-200"
>
<PromptInputTextarea
placeholder="Sorunu yaz… (1 kredi)"
maxLength={2000}
/>
<PromptInputActions className="justify-end pt-1">
<PromptInputAction tooltip="Gönder (Enter)">
<Button
size="icon"
onClick={() => gonder(girdi)}
disabled={bekliyor || !girdi.trim()}
className="size-9 cursor-pointer rounded-full bg-orange-500 text-white hover:bg-orange-600"
aria-label="Gönder"
>
{bekliyor ? <DotsLoader size="sm" /> : <ArrowUp className="size-4" aria-hidden />}
</Button>
</PromptInputAction>
</PromptInputActions>
</PromptInput>
)}
</div>
</section>
);
}

View File

@@ -38,6 +38,22 @@ export default async function OdemeSonucPage({
const basarili = durum === "paid";
// Rapor zaten üretildiyse ödül bir tık uzakta: kullanıcıyı doğrudan
// sonuç sayfasına döndür ki blur çözülme animasyonuyla tam listeyi görsün.
const rapor = basarili
? await appDb.query.reports.findFirst({
where: eq(schema.reports.userId, session.user.id),
columns: { params: true },
})
: null;
const raporParams = rapor?.params as
| { sira?: number; tur?: string }
| undefined;
const listeHref =
raporParams?.sira && raporParams?.tur
? `/sonuc?sira=${raporParams.sira}&tur=${raporParams.tur}&acildi=1`
: null;
return (
<div className="flex min-h-screen flex-col bg-slate-50 text-slate-900">
<main className="mx-auto flex w-full max-w-md flex-1 flex-col justify-center px-4 py-16 text-center">
@@ -64,13 +80,17 @@ export default async function OdemeSonucPage({
asChild
className="h-11 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
>
<Link href="/">
<Link href={listeHref ?? "/"}>
<Sparkles className="size-4" aria-hidden />
24&apos;lük listeni oluştur
{listeHref
? "Listeni aç — kilidi kaldırdık"
: "24'lük listeni oluştur"}
</Link>
</Button>
<p className="text-center text-xs text-slate-500">
Sıralamanı girip sonuç sayfasındaki sihirbazla listeni kur.
{listeHref
? "24 tercihin tamamı, gerekçeler, riskler ve PDF artık açık."
: "Sıralamanı girip sonuç sayfasındaki sihirbazla listeni kur."}
</p>
</div>
</div>

View File

@@ -13,6 +13,7 @@ import {
} from "@/lib/ai/rapor";
import type { RaporParams } from "@/lib/rapor-havuzu";
import { sihirbazDogrula, type SihirbazSecimleri } from "@/lib/sihirbaz";
import { raporMaskele } from "@/lib/rapor-maske";
const MAX_REVIZYON = 2;
@@ -24,6 +25,7 @@ export type ListeSonuc =
params: RaporParams;
revisionCount: number;
kredi: number;
hasPaket: boolean;
}
| { ok: false; error: string; code: "AUTH" | "PAKET" | "KREDI" | "HATA" };
@@ -71,9 +73,8 @@ export async function listeOlustur(input: {
if (!user) {
return { ok: false, code: "AUTH", error: "Giriş yapmalısın." };
}
if (!user.hasPaket) {
return { ok: false, code: "PAKET", error: "Paket gerekli." };
}
// Paket şartı yok: üretim kredi bazlı (yeni kullanıcının 5 deneme kredisi
// burada yanmaya başlar). Paketsize dönen rapor maskelenir.
if (!siraTurGecerli(input.sira, input.tur)) {
return { ok: false, code: "HATA", error: "Geçerli bir sıralama gerekli." };
@@ -112,12 +113,14 @@ export async function listeOlustur(input: {
where: eq(schema.reports.userId, user.id),
});
if (mevcut?.result) {
const rapor = mevcut.result as RaporSonuc;
return {
ok: true,
rapor: mevcut.result as RaporSonuc,
rapor: user.hasPaket ? rapor : raporMaskele(rapor),
params: mevcut.params as RaporParams,
revisionCount: mevcut.revisionCount,
kredi: user.creditBalance,
hasPaket: user.hasPaket,
};
}
return { ok: false, code: "HATA", error: "İstek tekrarlandı." };
@@ -156,10 +159,11 @@ export async function listeOlustur(input: {
return {
ok: true,
rapor: sonuc,
rapor: user.hasPaket ? sonuc : raporMaskele(sonuc),
params,
revisionCount: 0,
kredi: user.creditBalance - RAPOR_KREDI,
hasPaket: user.hasPaket,
};
}
@@ -221,7 +225,11 @@ export async function listeRevize(input: {
let sonuc: RaporSonuc;
try {
sonuc = await raporUret(params, feedback.slice(0, 1000), listeOzetiCikar(onceki));
sonuc = await raporUret(
params,
feedback.slice(0, 1000),
listeOzetiCikar(onceki, params.sira),
);
} catch (err) {
await grantCredits({
userId: user.id,
@@ -248,5 +256,6 @@ export async function listeRevize(input: {
params,
revisionCount: rapor.revisionCount + 1,
kredi: user.creditBalance - RAPOR_KREDI,
hasPaket: true,
};
}

View File

@@ -0,0 +1,68 @@
"use client";
import Link from "next/link";
import { ArrowRight, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { TypingAnimation } from "@/components/ui/typing-animation";
/** Banner CTA'sının sihirbaz modalını açmak için yayınladığı olay. */
export const SIHIRBAZ_AC_EVENT = "kolaytercih:sihirbaz-ac";
const CUMLELER = [
"Seçmekte zorlanıyor musunuz?",
"Hangi bölüm size uygun, emin değil misiniz?",
"Yapay zekâ 24 tercihlik listenizi kursun.",
"Her satırda gerekçe ve yerleşme riski analizi.",
];
/**
* Puan türü badge'lerinin hemen altında yaşayan satın alım funnel banner'ı.
* Sürekli değişen (yazılıp silinen) cümlelerle adayı sihirbaza; rapor zaten
* varsa kilitli listenin durduğu /listem paywall'ına iter.
* Tasarım: flat indigo — gradient/gölge yok, CTA funnel turuncusu.
*/
export function FunnelBanner({ hedefListem }: { hedefListem: boolean }) {
const ctaSinif =
"h-10 shrink-0 cursor-pointer bg-orange-500 px-5 font-semibold text-white transition-colors duration-200 hover:bg-orange-600 focus-visible:ring-white/70";
return (
<section
aria-label="Tercih listeni kur"
className="mt-6 flex flex-col gap-4 rounded-2xl bg-indigo-600 p-5 sm:flex-row sm:items-center sm:gap-6"
>
<div className="flex min-w-0 flex-1 items-center gap-4">
<div
className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-indigo-500"
aria-hidden
>
<Sparkles className="size-5 text-white" />
</div>
<TypingAnimation
as="p"
words={CUMLELER}
loop
typeSpeed={45}
deleteSpeed={20}
pauseDelay={2400}
className="min-h-14 min-w-0 flex-1 font-heading text-lg font-bold leading-snug text-white sm:min-h-8 sm:text-2xl"
/>
</div>
{hedefListem ? (
<Button asChild className={ctaSinif}>
<Link href="/listem">
Listeni
<ArrowRight className="size-4" aria-hidden />
</Link>
</Button>
) : (
<Button
onClick={() => window.dispatchEvent(new Event(SIHIRBAZ_AC_EVENT))}
className={ctaSinif}
>
Listemi oluştur
<ArrowRight className="size-4" aria-hidden />
</Button>
)}
</section>
);
}

View File

@@ -0,0 +1,369 @@
"use client";
import { useMemo, useEffect, useRef, useState } from "react";
import Link from "next/link";
import { FileText, Loader2, Lock, RefreshCcw } from "lucide-react";
import { Button } from "@/components/ui/button";
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") {
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">
Liste üretimi 3 kredi. Tercih Dönemi Paketi&apos;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">299 TL Paketi aktive et</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
? `ilk ${ACIK_SATIR} tercih açık`
: `${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={
<>
<Lock className="size-8 text-slate-500" aria-hidden />
<p className="max-w-sm text-center font-heading text-lg font-bold text-slate-900">
24 tercihin {24 - ACIK_SATIR}&apos;i kilidin ardında
</p>
<p className="max-w-sm text-center text-sm text-slate-600">
Her satır için gerekçe, risk notu ve 4 yıllık trend + PDF çıktı
+ 2 revizyon hakkı + 60 kredi seni bekliyor.
</p>
<Button
asChild
size="lg"
className="h-12 cursor-pointer bg-orange-500 px-8 text-white transition-colors duration-200 hover:bg-orange-600"
>
<Link href="/paket">299 TL Tüm listeyi </Link>
</Button>
{kredi > 0 ? (
<p className="text-xs text-slate-500">
Kalan {kredi} kredin AI danışmana soru sormak için cebinde
duruyor.
</p>
) : null}
</>
}
/>
</div>
{!kilitli ? (
<div className="mt-6">
<RevizyonKutusu
kalanHak={MAX_REVIZYON - rapor.revisionCount}
onRevize={onRevize}
/>
</div>
) : null}
{/* AI danışman: sağ altta yüzen buton, alttan popup olarak açılır */}
<SohbetPopup kredi={kredi} />
</section>
);
}
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);
}, []);
return (
<section className="mt-16 flex flex-col items-center justify-center gap-4 rounded-2xl border border-slate-200 bg-white py-16 text-center">
<Loader2 className="size-10 animate-spin text-primary" aria-hidden />
<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>
</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"
>
{pending ? (
<Loader2 className="size-4 animate-spin" aria-hidden />
) : (
<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

@@ -4,18 +4,21 @@ import { ArrowLeft } from "lucide-react";
import { eq } from "drizzle-orm";
import {
PUAN_TURLERI,
searchByRank,
rankWindowFacets,
searchByRank,
type PuanTuruKey,
} from "@/lib/db";
import { getSession, getCurrentUser } 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 { SihirbazBolumu } from "./sihirbaz-cagri-karti";
import type { MevcutRapor, OnizlemeSatir } from "./sihirbaz-modal";
import { FunnelBanner } from "./funnel-banner";
import type { MevcutRapor } from "./liste-paneli";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { ProgramTablosu } from "@/components/program-tablosu";
export const metadata: Metadata = {
title: "Tercih Listeni Kur — KolayTercih",
@@ -36,6 +39,7 @@ export default async function SonucPage({
sira?: string;
tur?: string;
sihirbaz?: string;
acildi?: string;
}>;
}) {
const params = await searchParams;
@@ -74,24 +78,20 @@ export default async function SonucPage({
: null;
const facetler = rankWindowFacets(sira, turKey);
// AI'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: raporSatiri.result as RaporSonuc,
rapor: hasPaket
? (raporSatiri.result as RaporSonuc)
: raporMaskele(raporSatiri.result as RaporSonuc),
params: raporSatiri.params as RaporParams,
revisionCount: raporSatiri.revisionCount,
}
: null;
// Paketsiz kullanıcıya blur önizlemede gösterilecek gerçek dengeli satırlar
const onizlemeSatirlari: OnizlemeSatir[] = searchByRank(sira, turKey, {
limitPerBucket: 5,
})
.dengeli.slice(0, 5)
.map((p) => ({
id: p.id,
isim: p.isim,
universite: p.universite,
il: p.il,
}));
return (
<div className="min-h-screen bg-slate-50 text-slate-900">
@@ -141,18 +141,27 @@ export default async function SonucPage({
))}
</nav>
{/* Sihirbaz akışı (CTA + modal) — seçimler ana sayfadaki modalda yapılır,
burada yalnızca giriş dönüşünde (?sihirbaz=1) otomatik açılır */}
{/* Satın alım funnel'ı: değişen cümlelerle adayı sihirbaza (rapor
varsa /listem paywall'ına) iten banner — paketli kullanıcıya
satacak bir şey kalmadığından gösterilmez */}
{!hasPaket ? <FunnelBanner hedefListem={Boolean(mevcutRapor)} /> : null}
{/* AI'dan bağımsız ham tablo + manuel liste haritası — sayfanın ana
gövdesi; + ile manuel 24'lük liste kurulur */}
<ProgramTablosu sonuclar={hamSonuclar} adaySira={sira} />
{/* Sihirbaz akışı (CTA + modal + AI listesi) — blurlu AI paneli
sayfanın en altında yaşar */}
<SihirbazBolumu
sira={sira}
tur={turKey}
facetler={facetler}
girisliMi={Boolean(session)}
hasPaket={Boolean(user?.hasPaket)}
hasPaket={hasPaket}
kredi={user?.creditBalance ?? 0}
mevcutRapor={mevcutRapor}
onizlemeSatirlari={onizlemeSatirlari}
otomatikAc={params.sihirbaz === "1"}
acilis={params.acildi === "1"}
/>
<p className="mt-10 text-center text-xs leading-relaxed text-slate-500">

View File

@@ -1,7 +1,15 @@
"use client";
import { useEffect, useState, useSyncExternalStore } from "react";
import {
useEffect,
useRef,
useState,
useSyncExternalStore,
} from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Sparkles, X } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import type { SihirbazFacetleri } from "@/lib/db";
import {
@@ -9,11 +17,14 @@ import {
sihirbazDogrula,
type SihirbazSecimleri,
} from "@/lib/sihirbaz";
import { SihirbazModal } from "./sihirbaz-modal";
import { SIHIRBAZ_AC_EVENT } from "./funnel-banner";
import {
SihirbazModal,
ListePaneli,
type MevcutRapor,
type OnizlemeSatir,
} from "./sihirbaz-modal";
type PanelDurum,
} from "./liste-paneli";
import { listeOlustur, listeRevize } from "./actions";
const KAPATMA_KEY = "kolaytercih.sihirbaz-karti-kapali";
@@ -32,11 +43,12 @@ type BekleyenSecim = {
};
/**
* Sonuç sayfasında sihirbaz akışını yöneten client wrapper:
* - Sayfa ortasındaki büyük CTA (satır içi)
* - Sabit alt çağrı kartı
* - Modal
* hepsi tek `acik` state'ini paylaşır.
* 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)
* - Adımları toplayan modal
* - Üretim/önizleme/liste paneli (sayfa gövdesinde)
* Akış: adımlar → (girişsizse /giris) → kredi ile üretim → paketsizse
* progressive-blur önizleme, paketliyse tam liste.
*/
export function SihirbazBolumu({
sira,
@@ -44,10 +56,10 @@ export function SihirbazBolumu({
facetler,
girisliMi,
hasPaket,
kredi,
kredi: baslangicKredi,
mevcutRapor,
onizlemeSatirlari,
otomatikAc,
acilis,
}: {
sira: number;
tur: string;
@@ -56,18 +68,34 @@ export function SihirbazBolumu({
hasPaket: boolean;
kredi: number;
mevcutRapor: MevcutRapor | null;
onizlemeSatirlari: OnizlemeSatir[];
/** ?sihirbaz=1 ile gelindiyse modalı otomatik aç */
otomatikAc?: boolean;
/** ?acildi=1 (ödeme dönüşü): blur çözülme animasyonuyla aç */
acilis?: boolean;
}) {
const router = useRouter();
const monteEdildi = useMonteEdildi();
const [elleAcik, setElleAcik] = useState(false);
const [elleKapandi, setElleKapandi] = useState(false);
const [kartGizle, setKartGizle] = useState(false);
const [bekleyen, setBekleyen] = useState<BekleyenSecim | null>(null);
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);
const otomatikBasladi = useRef(false);
// Mount sonrası storage'dan bekleyen seçimleri + kalıcı kart kapatmayı oku
// (dış store'dan başlangıç senkronizasyonu — bir kez).
useEffect(() => {
try {
const kaliciKapali = Boolean(sessionStorage.getItem(KAPATMA_KEY));
@@ -93,11 +121,100 @@ export function SihirbazBolumu({
} catch {}
}, [sira, tur]);
// Giriş/ödeme dönüşünde otomatik üretim: eşleşen bekleyen seçim + paket
const otomatikUret = Boolean(bekleyen?.eslesir && girisliMi && hasPaket);
// Açık durumu türetilmiş: elle veya otomatik tetik, kullanıcı kapatmadıkça
const acik = !elleKapandi && (elleAcik || Boolean(otomatikAc) || otomatikUret);
const baslangicSecimler = bekleyen?.eslesir ? bekleyen.secimler : null;
// Üstteki funnel banner'ının CTA'sı sihirbaz modalını bu olayla açar
useEffect(() => {
const ac = () => {
setElleAcik(true);
setElleKapandi(false);
};
window.addEventListener(SIHIRBAZ_AC_EVENT, ac);
return () => window.removeEventListener(SIHIRBAZ_AC_EVENT, ac);
}, []);
async function uret(secimler: SihirbazSecimleri) {
if (!requestIdRef.current) requestIdRef.current = crypto.randomUUID();
setElleAcik(false);
setDurum("uretiliyor");
try {
const sonuc = await listeOlustur({
sira,
tur,
secimler,
requestId: requestIdRef.current,
});
if (sonuc.ok) {
requestIdRef.current = null;
setRapor({
rapor: sonuc.rapor,
params: sonuc.params,
revisionCount: sonuc.revisionCount,
});
setKredi(sonuc.kredi);
setPaketli(sonuc.hasPaket);
try {
localStorage.removeItem(SIHIRBAZ_STORAGE_KEY);
} catch {}
// AI çıktısı bu sayfada gösterilmez: üretim animasyonu açık kalır,
// liste (paketsizse paywall'lı hâliyle) /listem'de karşılar.
router.push("/listem");
return;
}
// Hata: aynı requestId'yi koru ki tekrar denemede çifte harcama olmasın
if (sonuc.code === "KREDI" || sonuc.code === "PAKET") {
setDurum("kredi-yetersiz");
} else if (sonuc.code === "AUTH") {
secimleriKaydetVeGirise(secimler);
} else {
toast.error(sonuc.error);
setDurum("bos");
setElleAcik(true);
}
} catch {
toast.error("Beklenmeyen bir hata oluştu, tekrar dener misin?");
setDurum("bos");
setElleAcik(true);
}
}
function secimleriKaydetVeGirise(secimler: SihirbazSecimleri) {
try {
localStorage.setItem(
SIHIRBAZ_STORAGE_KEY,
JSON.stringify({ sira, tur, secimler }),
);
} catch {}
const geri = `/sonuc?sira=${sira}&tur=${tur}&sihirbaz=1`;
router.push(`/giris?callback=${encodeURIComponent(geri)}`);
}
function ilerle(secimler: SihirbazSecimleri) {
setSonSecimler(secimler);
if (!girisliMi) {
secimleriKaydetVeGirise(secimler);
return;
}
void uret(secimler);
}
// 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);
useEffect(() => {
if (otomatikUret && bekleyen && !otomatikBasladi.current) {
otomatikBasladi.current = true;
setSonSecimler(bekleyen.secimler);
void uret(bekleyen.secimler);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [otomatikUret]);
// ?sihirbaz=1: otomatik üretim devreye girmediyse (seçim kaybolduysa/
// eşleşmiyorsa) modalı aç ki kullanıcı adımları tamamlayabilsin.
const acik =
!elleKapandi &&
(elleAcik ||
Boolean(otomatikAc && !otomatikUret && !rapor && durum === "bos"));
function acikDegisti(v: boolean) {
if (v) {
@@ -109,43 +226,80 @@ export function SihirbazBolumu({
}
}
// Üretim başladıysa storage'ı temizle (modal artık state'te tutuyor)
useEffect(() => {
if (acik && otomatikUret) {
try {
localStorage.removeItem(SIHIRBAZ_STORAGE_KEY);
} catch {}
}
}, [acik, otomatikUret]);
const raporVar = Boolean(mevcutRapor);
const panelde = Boolean(rapor) || durum !== "bos";
return (
<>
{/* Sayfa ortasındaki büyük CTA */}
<section className="mt-16 rounded-2xl bg-primary p-8 text-center text-white sm:p-12">
<h2 className="font-heading text-2xl font-bold sm:text-3xl">
{raporVar
? "24 tercihlik listen hazır"
: "Bu listeyi 24 tercihlik plana çevirelim mi?"}
</h2>
<p className="mx-auto mt-3 max-w-xl text-white/90">
{raporVar
? "Listeni görüntüle, revize et veya listen hakkında soru sor."
: "Birkaç adımda ilgi alanların ve şehir tercihine göre gerekçeli, dengeli bir tercih listesi kuralım — her satır için yerleşme riski analiziyle."}
</p>
<Button
size="lg"
onClick={() => acikDegisti(true)}
className="mt-6 h-12 cursor-pointer bg-orange-500 px-8 text-white transition-colors duration-200 hover:bg-orange-600"
>
<Sparkles className="size-4" aria-hidden />
{raporVar ? "Listemi görüntüle" : "Listemi oluştur"}
</Button>
</section>
{/* AI çı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. */}
{durum !== "bos" ? (
<ListePaneli
durum={durum}
rapor={null}
hasPaket={paketli}
kredi={kredi}
sira={sira}
acilis={acilis}
onRevize={async (feedback) => {
const sonuc = await listeRevize({
feedback,
requestId: crypto.randomUUID(),
});
if (sonuc.ok) {
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>
) : (
<section className="mt-16 rounded-2xl bg-primary p-8 text-center text-white sm:p-12">
<h2 className="font-heading text-2xl font-bold sm:text-3xl">
Bu listeyi 24 tercihlik plana çevirelim mi?
</h2>
<p className="mx-auto mt-3 max-w-xl text-white/90">
Birkaç adımda ilgi alanların ve şehir tercihine göre gerekçeli,
dengeli bir tercih listesi kuralım her satır için yerleşme riski
analiziyle.
</p>
<Button
size="lg"
onClick={() => acikDegisti(true)}
className="mt-6 h-12 cursor-pointer bg-orange-500 px-8 text-white transition-colors duration-200 hover:bg-orange-600"
>
<Sparkles className="size-4" aria-hidden />
Listemi oluştur
</Button>
</section>
)}
{/* Sabit alt çağrı kartırapor yoksa ve kart kapatılmadıysa */}
{monteEdildi && !raporVar && !kartGizle && !acik ? (
{/* Sabit alt çağrı kartıliste/panel yokken ve kart kapatılmadıysa */}
{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">
@@ -184,16 +338,14 @@ export function SihirbazBolumu({
<SihirbazModal
acik={acik}
onAcikDegisti={acikDegisti}
sira={sira}
tur={tur}
facetler={facetler}
girisliMi={girisliMi}
hasPaket={hasPaket}
kredi={kredi}
mevcutRapor={mevcutRapor}
onizlemeSatirlari={onizlemeSatirlari}
otomatikUret={otomatikUret}
baslangicSecimler={baslangicSecimler}
baslangicSecimler={
sonSecimler ?? (bekleyen?.eslesir ? bekleyen.secimler : null)
}
sonButonEtiketi={
girisliMi ? "Listemi oluştur (3 kredi)" : "Listemi gör"
}
onTamamla={ilerle}
/>
) : null}
</>

View File

@@ -1,365 +1,35 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import {
ArrowLeft,
FileText,
Loader2,
Lock,
RefreshCcw,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
} from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge";
import { RaporListesi } from "@/components/rapor-listesi";
import { SihirbazAdimlar } from "@/components/sihirbaz-adimlar";
import type { SihirbazFacetleri } from "@/lib/db";
import type { RaporSonuc } from "@/lib/ai/rapor";
import type { RaporParams } from "@/lib/rapor-havuzu";
import {
SIHIRBAZ_STORAGE_KEY,
type SihirbazSecimleri,
} from "@/lib/sihirbaz";
import { listeOlustur, listeRevize } from "./actions";
const MAX_REVIZYON = 2;
export type OnizlemeSatir = {
id: string;
isim: string;
universite: string;
il: string | null;
};
export type MevcutRapor = {
rapor: RaporSonuc;
params: RaporParams;
revisionCount: number;
};
type Faz = "adimlar" | "onizleme" | "uretiliyor" | "liste";
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 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
* sihirbaz-cagri-karti.tsx'teki SihirbazBolumu'ndadır.
*/
export function SihirbazModal({
acik,
onAcikDegisti,
sira,
tur,
facetler,
girisliMi,
hasPaket,
kredi: baslangicKredi,
mevcutRapor,
onizlemeSatirlari,
otomatikUret,
baslangicSecimler,
sonButonEtiketi,
onTamamla,
}: {
acik: boolean;
onAcikDegisti: (acik: boolean) => void;
sira: number;
tur: string;
facetler: SihirbazFacetleri;
girisliMi: boolean;
hasPaket: boolean;
kredi: number;
mevcutRapor: MevcutRapor | null;
onizlemeSatirlari: OnizlemeSatir[];
/** Giriş dönüşü: storage'daki seçimlerle otomatik üretime başla */
otomatikUret?: boolean;
baslangicSecimler?: SihirbazSecimleri | null;
sonButonEtiketi: string;
onTamamla: (secimler: SihirbazSecimleri) => void;
}) {
const router = useRouter();
const [faz, setFaz] = useState<Faz>(mevcutRapor ? "liste" : "adimlar");
const [kredi, setKredi] = useState(baslangicKredi);
const [rapor, setRapor] = useState<MevcutRapor | null>(mevcutRapor);
const [uretimMesajIdx, setUretimMesajIdx] = useState(0);
// Adımlarda yapılan son seçimler (önizlemeden geri dönüşte prefill için)
const [sonSecimler, setSonSecimler] = useState<SihirbazSecimleri | null>(
baslangicSecimler ?? null,
);
// Aynı üretim için sabit requestId (retry çifte harcama yapmaz)
const requestIdRef = useRef<string | null>(null);
const otomatikBasladi = useRef(false);
async function uret(secimler: SihirbazSecimleri) {
if (!requestIdRef.current) requestIdRef.current = crypto.randomUUID();
setFaz("uretiliyor");
setUretimMesajIdx(0);
const interval = setInterval(() => {
setUretimMesajIdx((i) => Math.min(i + 1, URETIM_MESAJLARI.length - 1));
}, 7000);
try {
const sonuc = await listeOlustur({
sira,
tur,
secimler,
requestId: requestIdRef.current,
});
if (sonuc.ok) {
requestIdRef.current = null;
setRapor({
rapor: sonuc.rapor,
params: sonuc.params,
revisionCount: sonuc.revisionCount,
});
setKredi(sonuc.kredi);
setFaz("liste");
return;
}
// Hata: aynı requestId'yi koru ki tekrar denemede çifte harcama olmasın
if (sonuc.code === "KREDI") {
toast.error(sonuc.error);
setFaz("adimlar");
} else if (sonuc.code === "PAKET") {
setFaz("onizleme");
} else if (sonuc.code === "AUTH") {
secimleriKaydetVeGirise(secimler);
} else {
toast.error(sonuc.error);
setFaz("adimlar");
}
} catch {
toast.error("Beklenmeyen bir hata oluştu, tekrar dener misin?");
setFaz("adimlar");
} finally {
clearInterval(interval);
}
}
function secimleriKaydetVeGirise(secimler: SihirbazSecimleri) {
try {
localStorage.setItem(
SIHIRBAZ_STORAGE_KEY,
JSON.stringify({ sira, tur, secimler }),
);
} catch {}
const geri = `/sonuc?sira=${sira}&tur=${tur}&sihirbaz=1`;
router.push(`/giris?callback=${encodeURIComponent(geri)}`);
}
// Giriş dönüşü: storage'daki seçimlerle otomatik üretime başla (bir kez)
useEffect(() => {
if (
otomatikUret &&
baslangicSecimler &&
hasPaket &&
girisliMi &&
!otomatikBasladi.current &&
!mevcutRapor
) {
otomatikBasladi.current = true;
void uret(baslangicSecimler);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Paketsiz kullanıcı önizlemeye geçerken seçimleri sakla ki ödeme dönüşünde
// (aynı sıralamayla) sihirbaz kaldığı yerden devam edebilsin.
function secimleriSakla(secimler: SihirbazSecimleri) {
try {
localStorage.setItem(
SIHIRBAZ_STORAGE_KEY,
JSON.stringify({ sira, tur, secimler }),
);
} catch {}
}
function ilerle(secimler: SihirbazSecimleri) {
setSonSecimler(secimler);
if (!girisliMi) {
secimleriKaydetVeGirise(secimler);
return;
}
if (!hasPaket) {
secimleriSakla(secimler);
setFaz("onizleme");
return;
}
void uret(secimler);
}
// ---- Faz gövdeleri ----
const adimlarGovde = (
<SihirbazAdimlar
facetler={facetler}
baslangicSecimler={sonSecimler}
sonButonEtiketi={
hasPaket ? "24'lük listemi oluştur (3 kredi)" : "Listemi gör"
}
onTamamla={ilerle}
/>
);
const onizlemeGovde = (
<div>
<h3 className="font-heading text-xl font-bold">
24 tercihlik listen hazır olmak üzere
</h3>
<p className="mt-2 text-sm text-slate-600">
Aşağıda ilk birkaç satırın ön izlemesi var. Tam listeyi, gerekçeleri, risk
analizini ve PDF çıktıyı görmek için paketi aktive et.
</p>
<div className="mt-6 overflow-hidden rounded-2xl border border-slate-200">
{onizlemeSatirlari.length > 0 ? (
<ul className="divide-y divide-slate-100">
{onizlemeSatirlari.map((p, i) => (
<li key={p.id} className="flex items-center gap-4 px-5 py-3">
<span className="w-6 text-right font-heading font-bold text-slate-400">
{i + 1}
</span>
<div className="min-w-0 flex-1">
<p className="truncate font-medium">{p.isim}</p>
<p className="truncate text-sm text-slate-500">
{p.universite} · {p.il ?? "—"}
</p>
</div>
<Badge variant="secondary" className="bg-blue-100 text-blue-700">
Dengeli
</Badge>
</li>
))}
</ul>
) : null}
<div className="relative">
<ul className="divide-y divide-slate-100 blur-sm select-none" aria-hidden>
{Array.from({ length: 7 }).map((_, i) => (
<li key={i} className="flex items-center gap-4 px-5 py-3">
<span className="w-6 text-right font-heading font-bold text-slate-300">
{onizlemeSatirlari.length + i + 1}
</span>
<div className="flex-1 space-y-1.5">
<div className="h-3.5 w-2/3 rounded bg-slate-200" />
<div className="h-3 w-1/2 rounded bg-slate-100" />
</div>
<div className="h-5 w-16 rounded-full bg-slate-100" />
</li>
))}
</ul>
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-gradient-to-b from-white/30 via-white/80 to-white">
<Lock className="size-8 text-slate-400" aria-hidden />
<p className="max-w-sm text-center text-sm font-medium text-slate-700">
+19 tercih, her satır için gerekçe ve risk analizi, PDF çıktı ve AI
soru hakkı seni bekliyor.
</p>
<Button
asChild
size="lg"
className="h-12 cursor-pointer bg-orange-500 px-8 text-white transition-colors duration-200 hover:bg-orange-600"
>
<Link href="/paket">299 TL Paketi aktive et</Link>
</Button>
</div>
</div>
</div>
<Button
type="button"
variant="ghost"
onClick={() => setFaz("adimlar")}
className="mt-4 cursor-pointer"
>
<ArrowLeft className="size-4" aria-hidden />
Seçimlere dön
</Button>
</div>
);
const uretiliyorGovde = (
<div className="flex flex-col items-center justify-center gap-4 py-16 text-center">
<Loader2 className="size-10 animate-spin text-primary" aria-hidden />
<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[uretimMesajIdx]}
</p>
<p className="text-xs text-slate-400">
Bu işlem yarım dakika kadar sürebilir, sayfayı kapatma.
</p>
</div>
);
const listeGovde = rapor ? (
<div>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 className="font-heading text-xl font-bold">24 Tercihlik Listen</h3>
<p className="mt-1 text-sm text-slate-500">
{sira.toLocaleString("tr-TR")}. sıra · {kredi} kredin kaldı
</p>
</div>
<Button asChild variant="outline" className="cursor-pointer">
<Link href="/rapor/yazdir" target="_blank">
<FileText className="size-4" aria-hidden />
PDF indir
</Link>
</Button>
</div>
<div className="mt-5">
<RaporListesi rapor={rapor.rapor} adaySira={rapor.params.sira} />
</div>
<div className="mt-6">
<RevizyonKutusu
kalanHak={MAX_REVIZYON - rapor.revisionCount}
onRevize={async (feedback) => {
const sonuc = await listeRevize({
feedback,
requestId: crypto.randomUUID(),
});
if (sonuc.ok) {
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;
}}
/>
</div>
<div className="mt-6 flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-slate-200 bg-white px-5 py-4">
<div>
<p className="font-heading text-sm font-bold">
Listen hakkında soruların mı var?
</p>
<p className="text-xs text-slate-500">
AI danışman listeni ve seçimlerini bilir; her mesaj 1 kredi.
</p>
</div>
<Button
asChild
size="sm"
className="cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
>
<Link href="/listem">Danışmana sor</Link>
</Button>
</div>
</div>
) : null;
return (
<Dialog open={acik} onOpenChange={onAcikDegisti}>
<DialogContent className="max-h-[92dvh] gap-0 overflow-y-auto p-6 sm:max-w-3xl">
@@ -367,80 +37,13 @@ export function SihirbazModal({
<DialogDescription className="sr-only">
Sıralamana uygun 24 tercihlik listeni oluştur.
</DialogDescription>
{faz === "adimlar"
? adimlarGovde
: faz === "onizleme"
? onizlemeGovde
: faz === "uretiliyor"
? uretiliyorGovde
: listeGovde}
<SihirbazAdimlar
facetler={facetler}
baslangicSecimler={baslangicSecimler}
sonButonEtiketi={sonButonEtiketi}
onTamamla={onTamamla}
/>
</DialogContent>
</Dialog>
);
}
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"
>
{pending ? (
<Loader2 className="size-4 animate-spin" aria-hidden />
) : (
<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

@@ -0,0 +1,73 @@
"use client";
// AI 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 { MessageCircleMore, X } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { SohbetClient } from "@/app/listem/sohbet-client";
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 (
<>
<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>
</>
);
}

View File

@@ -1,222 +0,0 @@
"use client";
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import { Coins, Loader2, SendHorizonal, Sparkles } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
interface Mesaj {
id: string;
role: "user" | "assistant";
content: string;
}
const ORNEK_SORULAR = [
"Listemdeki ilk 5 tercih çok mu riskli?",
"Bu sıralamayla hangi bölüme kesin girerim?",
"İlk tercihimle ikinciyi neden bu sırada koydun?",
];
/**
* Liste oluşturulduktan sonra, o listeye bağlı bağlamsal soru-cevap kutusu.
* Serbest sohbet değil: her soru 1 kredi, cevap /api/soru'dan stream edilir.
*/
export function SoruKutusu({
kredi,
onKrediDegisti,
}: {
// Kredi tümüyle kontrollü: kaynak modaldaki state (tek doğruluk kaynağı)
kredi: number;
onKrediDegisti: (kredi: number) => void;
}) {
const [mesajlar, setMesajlar] = useState<Mesaj[]>([]);
const [girdi, setGirdi] = useState("");
const [bekliyor, setBekliyor] = useState(false);
const [yuklendi, setYuklendi] = useState(false);
const altRef = useRef<HTMLDivElement>(null);
// Geçmiş soru-cevapları ve güncel krediyi yükle
useEffect(() => {
fetch("/api/soru")
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
if (data?.mesajlar) setMesajlar(data.mesajlar);
if (typeof data?.kredi === "number") onKrediDegisti(data.kredi);
})
.catch(() => {})
.finally(() => setYuklendi(true));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (mesajlar.length > 0) {
altRef.current?.scrollIntoView({ behavior: "smooth" });
}
}, [mesajlar]);
function krediGuncelle(yeni: number) {
onKrediDegisti(yeni);
}
async function gonder(metin: string) {
const mesaj = metin.trim();
if (!mesaj || bekliyor) return;
if (kredi < 1) {
toast.error("Kredin bitti — devam etmek için kredi yükle.");
return;
}
setGirdi("");
setBekliyor(true);
const clientMessageId = crypto.randomUUID();
const asistanId = crypto.randomUUID();
setMesajlar((m) => [
...m,
{ id: clientMessageId, role: "user", content: mesaj },
{ id: asistanId, role: "assistant", content: "" },
]);
try {
const res = await fetch("/api/soru", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: mesaj, clientMessageId }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setMesajlar((m) =>
m.filter((x) => x.id !== asistanId && x.id !== clientMessageId),
);
if (res.status === 402) {
toast.error("Kredin bitti — kredi yükleyerek devam edebilirsin.");
krediGuncelle(0);
} else {
toast.error(data.error ?? "Bir sorun oluştu, tekrar dener misin?");
}
return;
}
krediGuncelle(kredi - 1);
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let birikmis = "";
for (;;) {
const { done, value } = await reader.read();
if (done) break;
birikmis += decoder.decode(value, { stream: true });
const anlik = birikmis;
setMesajlar((m) =>
m.map((x) => (x.id === asistanId ? { ...x, content: anlik } : x)),
);
}
} catch {
toast.error("Bağlantı koptu. Cevabı sayfayı yenileyerek görebilirsin.");
} finally {
setBekliyor(false);
}
}
return (
<div className="rounded-2xl border border-slate-200 bg-white">
<div className="flex items-center justify-between border-b border-slate-100 px-4 py-3">
<h3 className="font-heading text-base font-bold">Listen hakkında sor</h3>
<span className="inline-flex items-center gap-1.5 rounded-full border border-amber-200 bg-amber-50 px-2.5 py-1 text-xs font-semibold text-amber-700">
<Coins className="size-3.5" aria-hidden />
{kredi} kredi
</span>
</div>
<div className="max-h-72 space-y-3 overflow-y-auto p-4">
{mesajlar.length === 0 ? (
<div className="flex flex-col items-center gap-3 py-4 text-center">
<Sparkles className="size-6 text-primary" aria-hidden />
<p className="max-w-sm text-sm text-slate-600">
Bu listeye özel soru sorabilirsin. Her soru 1 kredi kullanır.
</p>
{yuklendi ? (
<div className="flex flex-wrap justify-center gap-2">
{ORNEK_SORULAR.map((s) => (
<button
key={s}
onClick={() => gonder(s)}
disabled={kredi < 1}
className="cursor-pointer rounded-full border border-slate-200 bg-slate-50 px-3 py-1.5 text-xs text-slate-700 transition-colors duration-200 hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-50"
>
{s}
</button>
))}
</div>
) : null}
</div>
) : (
mesajlar.map((m) => (
<div
key={m.id}
className={
m.role === "user" ? "flex justify-end" : "flex justify-start"
}
>
<div
className={
m.role === "user"
? "max-w-[85%] rounded-2xl rounded-br-sm bg-primary px-4 py-2.5 text-sm text-white"
: "max-w-[85%] whitespace-pre-line rounded-2xl rounded-bl-sm bg-slate-100 px-4 py-2.5 text-sm text-slate-800"
}
>
{m.content || (
<Loader2
className="size-4 animate-spin text-slate-400"
aria-hidden
/>
)}
</div>
</div>
))
)}
<div ref={altRef} />
</div>
<form
className="flex gap-2 border-t border-slate-100 p-3"
onSubmit={(e) => {
e.preventDefault();
gonder(girdi);
}}
>
<Input
value={girdi}
onChange={(e) => setGirdi(e.target.value)}
placeholder={
kredi < 1 ? "Kredin bitti" : "Sorunu yaz… (1 kredi)"
}
disabled={bekliyor || kredi < 1}
maxLength={2000}
className="h-11 flex-1"
/>
{kredi < 1 ? (
<Button
asChild
className="h-11 cursor-pointer bg-orange-500 px-4 text-white hover:bg-orange-600"
>
<Link href="/paket">Kredi yükle</Link>
</Button>
) : (
<Button
type="submit"
disabled={bekliyor || !girdi.trim()}
className="h-11 cursor-pointer bg-orange-500 px-4 text-white transition-colors duration-200 hover:bg-orange-600"
aria-label="Gönder"
>
{bekliyor ? (
<Loader2 className="size-4 animate-spin" aria-hidden />
) : (
<SendHorizonal className="size-4" aria-hidden />
)}
</Button>
)}
</form>
</div>
);
}