feat: implement chat history deletion functionality
All checks were successful
Deploy / deploy (push) Successful in 8m4s
All checks were successful
Deploy / deploy (push) Successful in 8m4s
Added a new DELETE endpoint to allow users to clear their chat history. This functionality ensures that while the chat messages are removed, any credits spent remain intact. Additionally, updated the ListemGovde component to reflect changes in user preferences and integrated a modal for notifying users about preference changes. Enhanced the SohbetClient component to include a button for clearing chat history, improving user experience and interface clarity.
This commit is contained in:
BIN
data/app.db
BIN
data/app.db
Binary file not shown.
@@ -105,6 +105,20 @@ export async function GET() {
|
||||
});
|
||||
}
|
||||
|
||||
// Kullanıcının sohbet geçmişini siler. Kredi iadesi YOK: harcanan krediler
|
||||
// ledger'da kalır, yalnızca mesajlar temizlenir (bir sonraki soruda danışman
|
||||
// sıfırdan başlar; liste/özet bağlamı prompt'tan geldiği için korunur).
|
||||
export async function DELETE() {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return Response.json({ error: "Oturum gerekli." }, { status: 401 });
|
||||
}
|
||||
await appDb
|
||||
.delete(schema.chatMessages)
|
||||
.where(eq(schema.chatMessages.userId, session.user.id));
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
// Oturum ve gövde ayrıştırma bağımsız — paralel başlar, hata önceliği aynı
|
||||
// kalır (önce 401, sonra 400).
|
||||
|
||||
@@ -13,27 +13,37 @@ import {
|
||||
FileText,
|
||||
Lock,
|
||||
PencilLine,
|
||||
RefreshCcw,
|
||||
ShieldCheck,
|
||||
SlidersHorizontal,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RaporListesi } from "./rapor-listesi";
|
||||
import { useGorunumOlayi } from "@/components/use-gorunum-olayi";
|
||||
import { tercihFarki } from "@/features/rapor/tercih-farki";
|
||||
import { TercihDegistiModali } from "./tercih-degisti-modali";
|
||||
import { TercihHaritasiLazy } from "@/features/liste/components/tercih-haritasi-lazy";
|
||||
import { YurtdisiHaritalarLazy } from "@/features/liste/components/yurtdisi-haritalar-lazy";
|
||||
import { pinleriTuret } from "@/lib/harita-pinler";
|
||||
import type { RaporSonuc } from "@/features/rapor/types/rapor";
|
||||
import type { RaporParams, RaporSonuc } from "@/features/rapor/types/rapor";
|
||||
import { SohbetClient, type DanismanOzeti, type Mesaj } from "./sohbet-client";
|
||||
import { RevizyonKutusu } from "./revizyon-kutusu";
|
||||
import { profilDuzenlemeyiAc } from "@/features/liste/hooks/use-tercih-profili";
|
||||
import {
|
||||
profilDuzenlemeyiAc,
|
||||
useTercihProfili,
|
||||
} from "@/features/liste/hooks/use-tercih-profili";
|
||||
import { UNIVERSITE_TIPI_ETIKET } from "@/features/sihirbaz/sihirbaz-sabitler";
|
||||
import { PUAN_TURLERI } from "@/types/yokatlas";
|
||||
|
||||
export function ListemGovde({
|
||||
rapor,
|
||||
params,
|
||||
adaySira,
|
||||
hasPaket,
|
||||
kilitAcilisi,
|
||||
kilitliBaslangic,
|
||||
kredi,
|
||||
raporKredi,
|
||||
sohbetGecmisi,
|
||||
ozet,
|
||||
kalanHak,
|
||||
@@ -41,6 +51,9 @@ export function ListemGovde({
|
||||
paketKredi,
|
||||
}: {
|
||||
rapor: RaporSonuc;
|
||||
/** Kayıtlı listenin üretildiği tercihler — başlıktaki butonun "düzenle" mi
|
||||
* "güncelle" mi olacağı bunun güncel profille farkından çıkar. */
|
||||
params: RaporParams;
|
||||
adaySira: number;
|
||||
hasPaket: boolean;
|
||||
/** Ödeme dönüşü: blur çözülme animasyonuyla aç */
|
||||
@@ -48,6 +61,9 @@ export function ListemGovde({
|
||||
/** Paketsizde ACIK_SATIR; paketlide undefined (tamamı açık) */
|
||||
kilitliBaslangic?: number;
|
||||
kredi: number;
|
||||
/** Bir liste üretiminin kredi bedeli — fark modalındaki güncelleme aynı
|
||||
* kapıdan geçtiği için aynı bedeli gösterir. */
|
||||
raporKredi: number;
|
||||
sohbetGecmisi: Mesaj[];
|
||||
ozet?: DanismanOzeti;
|
||||
kalanHak: number;
|
||||
@@ -71,6 +87,44 @@ export function ListemGovde({
|
||||
|
||||
const acikSatir = kilitliBaslangic ?? rapor.tercihler.length;
|
||||
|
||||
// Profil client'ta (localStorage) çözülür: ilk render'da null döner, yani
|
||||
// SSR ile aynı "Tercihlerimi düzenle" hâli basılır — hidrasyon uyuşmazlığı
|
||||
// olmaz, fark varsa buton sonraki render'da "güncelle"ye döner.
|
||||
const profil = useTercihProfili();
|
||||
const farkVar = useMemo(
|
||||
() => (profil ? tercihFarki(params, profil).length > 0 : false),
|
||||
[params, profil],
|
||||
);
|
||||
const [farkModali, setFarkModali] = useState(false);
|
||||
|
||||
// Danışmanın sistem prompt'una gerçekten giren bağlam (bkz. app/api/soru).
|
||||
// Sohbet başlığındaki "Danışmanın bildikleri" ipucunda gösterilir; prompt
|
||||
// değişirse bu liste de güncellenmeli.
|
||||
const danismaninBildikleri = useMemo(() => {
|
||||
const satirlar = [
|
||||
`Başarı sıralaman ${adaySira.toLocaleString("tr-TR")} ve puan türün ${PUAN_TURLERI[params.tur]}`,
|
||||
hasPaket
|
||||
? "24'lük listenin tamamı: program, üniversite, şehir, taban sıralaması ve risk etiketi"
|
||||
: `Listenin açık ilk ${acikSatir} satırı — kilitli tercihleri danışman da görmüyor`,
|
||||
];
|
||||
if (params.kategoriler?.length) {
|
||||
satirlar.push(`İlgi alanların: ${params.kategoriler.join(", ")}`);
|
||||
}
|
||||
if (params.iller?.length) {
|
||||
satirlar.push(`Seçtiğin iller: ${params.iller.join(", ")}`);
|
||||
}
|
||||
if (params.universiteTipi && params.universiteTipi !== "farketmez") {
|
||||
satirlar.push(
|
||||
`Üniversite tipi tercihin: ${UNIVERSITE_TIPI_ETIKET[params.universiteTipi]}`,
|
||||
);
|
||||
}
|
||||
if (params.oncelikler?.length) {
|
||||
satirlar.push(`Önceliklerin: ${params.oncelikler.join(", ")}`);
|
||||
}
|
||||
satirlar.push("Bu sohbetteki son mesajların");
|
||||
return satirlar;
|
||||
}, [adaySira, params, hasPaket, acikSatir]);
|
||||
|
||||
// Kapı ölçümü: paywall yüzeyleri viewport'a girince (oturumda 1'er kez).
|
||||
// Ref'ler yalnızca paketsiz dallarda bağlanır; paketlide DOM'da olmadıkları
|
||||
// için observer hiç kurulmaz.
|
||||
@@ -126,8 +180,26 @@ export function ListemGovde({
|
||||
</h1>
|
||||
<div className="flex shrink-0 flex-wrap gap-2">
|
||||
{/* Tercih düzenleme her kullanıcıya açık: kapı (layout'ta monte
|
||||
TercihProfiliKapisi) profili günceller, /listem'e döner ve
|
||||
değişiklik varsa "tercihlerin değişti" uyarısı karşılar. */}
|
||||
TercihProfiliKapisi) profili günceller, /listem'e döner.
|
||||
Profil kayıtlı listeden ayrıldığı anda bu buton "Tercihlerin
|
||||
değişmiş"e döner ve TercihDegistiModali'nı açar — fark
|
||||
dökümü ve kredi kapısı orada tek yerde yaşar.
|
||||
|
||||
Renk: varsayılan (primary) buton. Kardeşleri outline olduğu
|
||||
için tek dolu buton olması dikkati zaten çekiyor; turuncu
|
||||
bu sayfada paywall CTA'sının rengi, amber ise risk dilinin
|
||||
"az riskli" tonu — ikisi de burada yanlış sinyal olurdu. */}
|
||||
{farkVar ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => setFarkModali(true)}
|
||||
className="cursor-pointer transition-[background-color,transform] duration-200 active:scale-[0.97]"
|
||||
>
|
||||
<RefreshCcw className="size-4" aria-hidden />
|
||||
Tercihlerin değişmiş
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@@ -138,6 +210,7 @@ export function ListemGovde({
|
||||
<SlidersHorizontal className="size-4" aria-hidden />
|
||||
Tercihlerimi düzenle
|
||||
</Button>
|
||||
)}
|
||||
{hasPaket ? (
|
||||
<>
|
||||
<Button
|
||||
@@ -223,6 +296,7 @@ export function ListemGovde({
|
||||
baslangicKredi={kredi}
|
||||
initialMesajlar={sohbetGecmisi}
|
||||
ozet={ozet}
|
||||
bildikleri={danismaninBildikleri}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-6">
|
||||
@@ -232,7 +306,8 @@ export function ListemGovde({
|
||||
baslangicKredi={kredi}
|
||||
initialMesajlar={sohbetGecmisi}
|
||||
ozet={ozet}
|
||||
className="h-[420px]"
|
||||
bildikleri={danismaninBildikleri}
|
||||
className="h-[360px]"
|
||||
/>
|
||||
|
||||
<section
|
||||
@@ -302,6 +377,18 @@ export function ListemGovde({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* farkVar dallanmasının dışında: güncelleme başarılı olunca fark
|
||||
kapanıyor, modal "listen güncellendi" ekranını gösterirken
|
||||
unmount olmasın. */}
|
||||
<TercihDegistiModali
|
||||
open={farkModali}
|
||||
onOpenChange={setFarkModali}
|
||||
params={params}
|
||||
kredi={kredi}
|
||||
raporKredi={raporKredi}
|
||||
hasPaket={hasPaket}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ import type { Mesaj } from "./sohbet-client";
|
||||
import { ListemGovde } from "./listem-govde";
|
||||
import { ListemBosCta } from "./listem-bos-cta";
|
||||
import { ListeUretici } from "./liste-uretici";
|
||||
import { TercihDegistiUyarisi } from "./tercih-degisti-uyarisi";
|
||||
|
||||
const PAKET_FIYATI = (URUNLER.paket.amountKurus / 100).toLocaleString("tr-TR", {
|
||||
maximumFractionDigits: 0,
|
||||
@@ -89,13 +88,11 @@ export async function ListemIcerik({
|
||||
const raporParams = satir!.params as RaporParams;
|
||||
// Danışman özeti sohbetin açılış mesajı olarak gösterilir; sohbet
|
||||
// /api/soru üzerinden zaten liste + seçim bağlamıyla çalışıyor.
|
||||
// Paketsizde rapor zaten maskeli: 1 uyarı açık, kalanının yalnızca sayısı var.
|
||||
// Paketsizde genelDegerlendirme zaten kırpılmış gelir (bkz. raporMaskele).
|
||||
const danismanOzeti = rapor.genelDegerlendirme
|
||||
? {
|
||||
metin: rapor.genelDegerlendirme,
|
||||
uyarilar: rapor.uyarilar,
|
||||
kilitli: !user.hasPaket,
|
||||
kilitliUyariSayisi: user.hasPaket ? undefined : rapor.kilitliUyariSayisi,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
@@ -112,25 +109,21 @@ export async function ListemIcerik({
|
||||
PDF artık açık.
|
||||
</div>
|
||||
) : null}
|
||||
{/* Tercihler liste kurulduktan sonra değiştiyse (sıra, alan, il, tip,
|
||||
öncelik) uyarı burada karşılar ve listeyi yeni tercihlerle yeniden
|
||||
kurmayı önerir — üretimle aynı kredi kapısından geçerek. Fark
|
||||
hesabı client'ta yapılır: güncel profil localStorage'ta yaşar. */}
|
||||
<TercihDegistiUyarisi
|
||||
params={raporParams}
|
||||
kredi={user.creditBalance}
|
||||
raporKredi={RAPOR_KREDI}
|
||||
hasPaket={Boolean(user.hasPaket)}
|
||||
/>
|
||||
{/* Başlık ve revizyon/PDF aksiyonları listenin kendi başlık satırında
|
||||
(bkz. listem-govde.tsx) — sayfa üstünde ayrı bir header yok. */}
|
||||
(bkz. listem-govde.tsx) — sayfa üstünde ayrı bir header yok.
|
||||
Tercihler liste kurulduktan sonra değiştiyse (sıra, alan, il, tip,
|
||||
öncelik) başlıktaki buton "Tercihlerin değişmiş"e döner ve fark
|
||||
modalını açar; fark hesabı client'ta yapılır (güncel profil
|
||||
localStorage'ta yaşar), o yüzden params buraya prop olarak iner. */}
|
||||
<ListemGovde
|
||||
rapor={rapor}
|
||||
params={raporParams}
|
||||
adaySira={raporParams.sira}
|
||||
hasPaket={Boolean(user.hasPaket)}
|
||||
kilitAcilisi={kilitAcilisi}
|
||||
kilitliBaslangic={user.hasPaket ? undefined : ACIK_SATIR}
|
||||
kredi={user.creditBalance}
|
||||
raporKredi={RAPOR_KREDI}
|
||||
sohbetGecmisi={sohbetGecmisi as Mesaj[]}
|
||||
ozet={danismanOzeti}
|
||||
kalanHak={Math.max(0, MAX_REVIZYON - (satir!.revisionCount ?? 0))}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { startTransition, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
import { AlertTriangle, ArrowUp, Coins, Lock, Sparkles } from "lucide-react";
|
||||
import { ArrowUp, Coins, Info, Lock, Sparkles, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -13,6 +13,14 @@ import {
|
||||
ChatContainerRoot,
|
||||
ChatContainerScrollAnchor,
|
||||
} from "@/components/ui/chat-container";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Message, MessageContent } from "@/components/ui/message";
|
||||
import { preloadMarkdown } from "@/components/ui/markdown-lazy";
|
||||
import { onbellekliJsonGetir } from "@/lib/istek-cache";
|
||||
@@ -24,6 +32,12 @@ import {
|
||||
} from "@/components/ui/prompt-input";
|
||||
import { PromptSuggestion } from "@/components/ui/prompt-suggestion";
|
||||
import { ScrollButton } from "@/components/ui/scroll-button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { DotsLoader } from "@/components/ui/loader";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { olay } from "@/lib/analitik";
|
||||
@@ -36,10 +50,7 @@ export interface Mesaj {
|
||||
|
||||
export interface DanismanOzeti {
|
||||
metin: string;
|
||||
uyarilar?: string[];
|
||||
kilitli?: boolean;
|
||||
/** Paketsizde açık uyarının ardından kilitli kalan uyarı sayısı. */
|
||||
kilitliUyariSayisi?: number;
|
||||
}
|
||||
|
||||
/** Danışman mesajlarının avatarı — KolayTercih markası. */
|
||||
@@ -76,6 +87,7 @@ export function SohbetClient({
|
||||
initialMesajlar,
|
||||
className,
|
||||
ozet,
|
||||
bildikleri,
|
||||
}: {
|
||||
baslangicKredi: number;
|
||||
/** Sunucu tarafında çekilmiş geçmiş; verilirse mount fetch'i atlanır */
|
||||
@@ -84,12 +96,17 @@ export function SohbetClient({
|
||||
className?: string;
|
||||
/** Raporun genel değerlendirmesi; sohbetin açılış mesajı olarak gösterilir */
|
||||
ozet?: DanismanOzeti;
|
||||
/** Danışmanın prompt'unda gerçekten yer alan bağlam maddeleri; başlıktaki
|
||||
* "Danışmanın bildikleri" ipucunda listelenir. Boşsa ipucu çizilmez. */
|
||||
bildikleri?: string[];
|
||||
}) {
|
||||
const [mesajlar, setMesajlar] = useState<Mesaj[]>(initialMesajlar ?? []);
|
||||
const [girdi, setGirdi] = useState("");
|
||||
const [bekliyor, setBekliyor] = useState(false);
|
||||
const [yuklendi, setYuklendi] = useState(initialMesajlar != null);
|
||||
const [kredi, setKredi] = useState(baslangicKredi);
|
||||
const [temizleAcik, setTemizleAcik] = useState(false);
|
||||
const [temizleniyor, setTemizleniyor] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
// Markdown chunk'ını sohbet görünür olur olmaz ısıt: ilk danışman cevabı
|
||||
@@ -206,6 +223,29 @@ export function SohbetClient({
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sohbet geçmişini hem sunucudan hem ekrandan siler. Krediler iade
|
||||
* edilmez; danışman bir sonraki soruda sıfır geçmişle başlar (rapor özeti
|
||||
* ve liste bağlamı prompt'tan geldiği için korunur).
|
||||
*/
|
||||
async function sohbetiTemizle() {
|
||||
if (temizleniyor) return;
|
||||
setTemizleniyor(true);
|
||||
try {
|
||||
const res = await fetch("/api/soru", { method: "DELETE" });
|
||||
if (!res.ok) throw new Error("silinemedi");
|
||||
setMesajlar([]);
|
||||
setTemizleAcik(false);
|
||||
// Sunucudan gelen geçmiş (listem sayfasının initialMesajlar'ı) da boşalsın
|
||||
router.refresh();
|
||||
toast.success("Sohbet temizlendi.");
|
||||
} catch {
|
||||
toast.error("Sohbet temizlenemedi, tekrar dener misin?");
|
||||
} finally {
|
||||
setTemizleniyor(false);
|
||||
}
|
||||
}
|
||||
|
||||
const krediYok = kredi < 1;
|
||||
|
||||
return (
|
||||
@@ -213,14 +253,57 @@ export function SohbetClient({
|
||||
aria-label="Danışman sohbeti"
|
||||
className={cn(
|
||||
"flex flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white",
|
||||
className ?? "h-[calc(100dvh-16rem)] min-h-[400px]",
|
||||
className ?? "h-[calc(100dvh-24rem)] min-h-[340px]",
|
||||
)}
|
||||
>
|
||||
<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>
|
||||
<div className="flex items-center gap-2 border-b border-slate-100 px-4 py-3">
|
||||
{bildikleri?.length ? (
|
||||
<TooltipProvider delayDuration={120}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Danışmanın bildikleri"
|
||||
className="inline-flex size-7 cursor-help items-center justify-center rounded-full border border-slate-200 bg-white text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-800"
|
||||
>
|
||||
<Info className="size-3.5" aria-hidden />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="bottom"
|
||||
sideOffset={6}
|
||||
className="max-w-xs flex-col items-start gap-1.5 p-3 text-left leading-relaxed"
|
||||
>
|
||||
<p className="font-semibold">
|
||||
Danışmanın bildikleri — cevaplar bu bağlamla üretilir:
|
||||
</p>
|
||||
<ul className="flex list-disc flex-col gap-1 pl-4">
|
||||
{bildikleri.map((b) => (
|
||||
<li key={b}>{b}</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="text-background/70">
|
||||
Adın, e-postan ve netlerin danışmanla paylaşılmaz.
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : null}
|
||||
{mesajlar.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTemizleAcik(true)}
|
||||
disabled={bekliyor || temizleniyor}
|
||||
className="inline-flex cursor-pointer items-center gap-1.5 rounded-full border border-slate-200 bg-white px-2.5 py-1 text-xs font-semibold text-slate-600 transition-[background-color,transform] duration-150 hover:bg-slate-100 hover:text-slate-800 active:scale-[0.96] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
title="Sohbet geçmişini sil"
|
||||
>
|
||||
<Trash2 className="size-3.5" aria-hidden />
|
||||
Sohbeti temizle
|
||||
</button>
|
||||
) : null}
|
||||
<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"
|
||||
className="ml-auto 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 />
|
||||
@@ -228,6 +311,36 @@ export function SohbetClient({
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Dialog open={temizleAcik} onOpenChange={setTemizleAcik}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Sohbeti temizle</DialogTitle>
|
||||
<DialogDescription>
|
||||
Danışmanla olan tüm yazışman silinir ve geri getirilemez. Listen
|
||||
ve kredilerin etkilenmez; yeni bir soruyla sıfırdan devam
|
||||
edebilirsin.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="cursor-pointer"
|
||||
onClick={() => setTemizleAcik(false)}
|
||||
disabled={temizleniyor}
|
||||
>
|
||||
Vazgeç
|
||||
</Button>
|
||||
<Button
|
||||
className="cursor-pointer bg-orange-500 text-white hover:bg-orange-600"
|
||||
onClick={sohbetiTemizle}
|
||||
disabled={temizleniyor}
|
||||
>
|
||||
{temizleniyor ? <DotsLoader size="sm" /> : "Sohbeti temizle"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<div className="relative flex-1 overflow-hidden">
|
||||
<ChatContainerRoot className="h-full">
|
||||
<ChatContainerContent className="gap-4 p-4">
|
||||
@@ -241,36 +354,10 @@ export function SohbetClient({
|
||||
<MessageContent className="bg-slate-100 text-slate-800">
|
||||
{ozet.metin}
|
||||
</MessageContent>
|
||||
{ozet.uyarilar?.length ? (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{ozet.uyarilar.map((uyari) => (
|
||||
<li
|
||||
key={uyari}
|
||||
className="flex items-start gap-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900"
|
||||
>
|
||||
<AlertTriangle
|
||||
className="mt-0.5 size-4 shrink-0"
|
||||
aria-hidden
|
||||
/>
|
||||
{uyari}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
{ozet.kilitli ? (
|
||||
<p className="flex items-center gap-1.5 text-xs text-slate-500">
|
||||
<Lock className="size-3.5" aria-hidden />
|
||||
{ozet.kilitliUyariSayisi ? (
|
||||
<>
|
||||
{ozet.kilitliUyariSayisi} uyarı daha kilitli —
|
||||
sıralamana özel uyarıların pakette.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Değerlendirmenin tamamı ve sıralamana özel uyarılar
|
||||
pakette.
|
||||
</>
|
||||
)}
|
||||
Değerlendirmenin tamamı pakette.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
342
src/features/rapor/components/tercih-degisti-modali.tsx
Normal file
342
src/features/rapor/components/tercih-degisti-modali.tsx
Normal file
@@ -0,0 +1,342 @@
|
||||
"use client";
|
||||
|
||||
// /listem'deki "tercihlerin değişmiş" modalı: kayıtlı liste hangi tercihlerle
|
||||
// kurulduysa (rapor params) kullanıcının güncel profili ondan ayrıldığında
|
||||
// liste başlığındaki buton belirir; bu modal o butonun içeriğidir — hangi
|
||||
// alanların değiştiğini gösterir ve listeyi yeni tercihlere göre yeniden
|
||||
// kurmayı önerir.
|
||||
//
|
||||
// Yeniden kurulum normal üretimle AYNI kapıdan geçer (listeOlustur): 3 kredi,
|
||||
// aynı requestId idempotency'si, aynı kredi-yetersiz/iade davranışı. Ayrı bir
|
||||
// "güncelleme" ürünü ya da indirimli yol yok — kullanıcı yeni bir liste alıyor,
|
||||
// bedeli de yeni liste bedeli.
|
||||
//
|
||||
// Renk: modalın kabuğu marka primary'si (bg-primary/10 ikon hücresi), ana
|
||||
// aksiyon ise ürünün CTA turuncusu — kilidi açan, kredi harcayan, ödemeye
|
||||
// götüren butonlar site genelinde bu tonda. Amber bilerek kullanılmıyor:
|
||||
// sarı bu üründe risk dilinin "az riskli" tonu (bkz. rapor-listesi
|
||||
// RISK_STIL).
|
||||
//
|
||||
// Fark tablosunun içi ayrı bir dil: kırmızı/yeşil burada risk değil diff
|
||||
// anlamında (eski değer / yeni değer). Karışmasın diye tablo kendi kutusuna
|
||||
// kapalı duruyor ve her hücrede −/+ işareti var — risk noktalarında olduğu
|
||||
// gibi renk tek başına anlam taşımıyor.
|
||||
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { ArrowRight, Check, RefreshCcw, SlidersHorizontal } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { olay, siraKovasi } from "@/lib/analitik";
|
||||
import { useTercihProfili } from "@/features/liste/hooks/use-tercih-profili";
|
||||
import { listeOlustur } from "@/features/rapor/rapor-actions";
|
||||
import type { RaporParams } from "@/features/rapor/types/rapor";
|
||||
import { farkSatirlari, tercihFarki } from "@/features/rapor/tercih-farki";
|
||||
import { useUretimMesaji } from "./uretim-mesaji";
|
||||
|
||||
type Durum =
|
||||
| { ad: "bekliyor" }
|
||||
| { ad: "uretiliyor" }
|
||||
| { ad: "tamamlandi" }
|
||||
| { ad: "kredi-yetersiz" }
|
||||
| { ad: "hata"; mesaj: string };
|
||||
|
||||
export function TercihDegistiModali({
|
||||
open,
|
||||
onOpenChange,
|
||||
params,
|
||||
kredi,
|
||||
raporKredi,
|
||||
hasPaket,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (acik: boolean) => void;
|
||||
/** Kayıtlı listenin üretildiği tercihler */
|
||||
params: RaporParams;
|
||||
kredi: number;
|
||||
raporKredi: number;
|
||||
hasPaket: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const profil = useTercihProfili();
|
||||
const [durum, setDurum] = useState<Durum>({ ad: "bekliyor" });
|
||||
// Aynı deneme için sabit requestId: cevabı kaybolan istekte retry çifte
|
||||
// harcamaz (bkz. liste-uretici.tsx ve spendCredits idempotency'si).
|
||||
const requestIdRef = useRef<string | null>(null);
|
||||
|
||||
const farklar = useMemo(
|
||||
() => (profil ? tercihFarki(params, profil) : []),
|
||||
[params, profil],
|
||||
);
|
||||
const satirlar = useMemo(
|
||||
() => (profil ? farkSatirlari(params, profil, farklar) : []),
|
||||
[params, profil, farklar],
|
||||
);
|
||||
|
||||
// Üretim sırasında modal kapanmaz: istek 30 sn kadar sürüyor ve kredi çoktan
|
||||
// harcanmış oluyor — kullanıcı yanlışlıkla ESC'leyip sonucu kaçırmasın.
|
||||
const kilitli = durum.ad === "uretiliyor";
|
||||
|
||||
async function guncelle() {
|
||||
if (!profil) return;
|
||||
if (!requestIdRef.current) requestIdRef.current = crypto.randomUUID();
|
||||
setDurum({ ad: "uretiliyor" });
|
||||
|
||||
try {
|
||||
const sonuc = await listeOlustur({
|
||||
sira: profil.sira,
|
||||
tur: profil.tur,
|
||||
secimler: profil.secimler,
|
||||
requestId: requestIdRef.current,
|
||||
});
|
||||
requestIdRef.current = null;
|
||||
if (sonuc.ok) {
|
||||
olay("liste_guncellendi", {
|
||||
tur: profil.tur,
|
||||
sira_kovasi: siraKovasi(profil.sira),
|
||||
degisen_alan: farklar.length,
|
||||
});
|
||||
toast.success("Listen yeni tercihlerine göre yeniden kuruldu.");
|
||||
// Sunucu params'ı tazelenince fark kalmaz ve başlıktaki buton
|
||||
// kendiliğinden "Tercihlerimi düzenle"ye döner.
|
||||
setDurum({ ad: "tamamlandi" });
|
||||
router.refresh();
|
||||
return;
|
||||
}
|
||||
if (sonuc.code === "KREDI" || sonuc.code === "PAKET") {
|
||||
olay("kredi_bitti");
|
||||
setDurum({ ad: "kredi-yetersiz" });
|
||||
} else if (sonuc.code === "AUTH") {
|
||||
router.push(`/giris?callback=${encodeURIComponent("/listem")}`);
|
||||
} else {
|
||||
setDurum({ ad: "hata", mesaj: sonuc.error });
|
||||
}
|
||||
} catch {
|
||||
setDurum({
|
||||
ad: "hata",
|
||||
mesaj: "Beklenmeyen bir hata oluştu, tekrar dener misin?",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const krediYeter = kredi >= raporKredi;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(v) => {
|
||||
if (kilitli) return;
|
||||
onOpenChange(v);
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
showCloseButton={!kilitli}
|
||||
onEscapeKeyDown={(e) => {
|
||||
if (kilitli) e.preventDefault();
|
||||
}}
|
||||
onPointerDownOutside={(e) => {
|
||||
if (kilitli) e.preventDefault();
|
||||
}}
|
||||
onInteractOutside={(e) => {
|
||||
if (kilitli) e.preventDefault();
|
||||
}}
|
||||
className="max-h-[92dvh] overflow-y-auto p-6 sm:max-w-md"
|
||||
>
|
||||
{durum.ad === "tamamlandi" ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
<span className="flex size-10 items-center justify-center rounded-full bg-emerald-100 text-emerald-700">
|
||||
<Check className="size-5" aria-hidden />
|
||||
</span>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<DialogTitle className="text-lg">Listen güncellendi</DialogTitle>
|
||||
<DialogDescription>
|
||||
Yeni tercihlerine göre yeniden kuruldu — arkadaki liste birkaç
|
||||
saniye içinde tazelenir.
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="cursor-pointer self-start transition-[background-color,transform] duration-200 active:scale-[0.97]"
|
||||
>
|
||||
Listeme dön
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-start gap-3 pr-9">
|
||||
<span className="flex size-10 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
<SlidersHorizontal className="size-5" aria-hidden />
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<DialogTitle className="text-lg">
|
||||
Tercihlerin değişmiş
|
||||
</DialogTitle>
|
||||
{/* Hangi alanların değiştiği hemen altındaki diff
|
||||
tablosunda zaten duruyor; açıklama yalnızca sonucu ve
|
||||
bedeli söylüyor. */}
|
||||
<DialogDescription>
|
||||
Listen eski tercihlerinle kuruldu. Yeni liste bunun yerine
|
||||
geçer ve {raporKredi} kredi düşer.
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Diff yerleşimi: alan adı üstte, eski ve yeni değer yan yana
|
||||
iki hücrede — klasik diff dili (eski kırmızı, yeni yeşil
|
||||
zemin). −/+ işaretleri ve sr-only "Eski:/Yeni:" etiketleri
|
||||
sayesinde renk tek başına anlam taşımaz. */}
|
||||
<dl className="overflow-hidden rounded-xl border border-slate-200 text-sm">
|
||||
{satirlar.map((satir, i) => (
|
||||
<div
|
||||
key={satir.alan}
|
||||
className={i > 0 ? "border-t border-slate-200" : undefined}
|
||||
>
|
||||
<dt className="bg-slate-50 px-3 py-1.5 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
|
||||
{satir.etiket}
|
||||
</dt>
|
||||
<dd className="grid grid-cols-2 divide-x divide-slate-200">
|
||||
<span className="flex min-w-0 gap-1.5 bg-red-50 px-3 py-2">
|
||||
<span
|
||||
aria-hidden
|
||||
className="select-none font-mono text-red-400"
|
||||
>
|
||||
−
|
||||
</span>
|
||||
<span className="sr-only">Eski:</span>
|
||||
<span className="min-w-0 break-words text-red-900/80">
|
||||
{satir.eski}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex min-w-0 gap-1.5 bg-emerald-50 px-3 py-2">
|
||||
<span
|
||||
aria-hidden
|
||||
className="select-none font-mono text-emerald-600"
|
||||
>
|
||||
+
|
||||
</span>
|
||||
<span className="sr-only">Yeni:</span>
|
||||
<span className="min-w-0 break-words font-semibold text-emerald-900">
|
||||
{satir.yeni}
|
||||
</span>
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
|
||||
{durum.ad === "uretiliyor" ? (
|
||||
<UretimSatiri />
|
||||
) : durum.ad === "kredi-yetersiz" ? (
|
||||
<KrediYetersiz hasPaket={hasPaket} raporKredi={raporKredi} />
|
||||
) : (
|
||||
// Aksiyon satırı: solda ikincil çıkış (outline), sağda ana
|
||||
// aksiyon (turuncu). Turuncu ürünün kendi CTA rengi — kilidi
|
||||
// açan, kredi harcayan, ödeme yaptıran yollar hep bu tonda.
|
||||
<div className="flex flex-col gap-3">
|
||||
{durum.ad === "hata" ? (
|
||||
<p className="text-sm text-red-700">{durum.mesaj}</p>
|
||||
) : null}
|
||||
{!krediYeter && durum.ad !== "hata" ? (
|
||||
<p className="text-xs text-slate-500">
|
||||
Güncelleme {raporKredi} kredi; bakiyen {kredi} kredi.
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="cursor-pointer transition-[background-color,transform] duration-200 active:scale-[0.97]"
|
||||
>
|
||||
Şimdilik kalsın
|
||||
</Button>
|
||||
{durum.ad === "hata" ? (
|
||||
<Button
|
||||
onClick={() => void guncelle()}
|
||||
className="cursor-pointer bg-orange-500 text-white transition-[background-color,transform] duration-200 active:scale-[0.97] hover:bg-orange-600"
|
||||
>
|
||||
<RefreshCcw className="size-4" aria-hidden />
|
||||
Tekrar dene
|
||||
</Button>
|
||||
) : krediYeter ? (
|
||||
<Button
|
||||
onClick={() => void guncelle()}
|
||||
className="cursor-pointer bg-orange-500 text-white transition-[background-color,transform] duration-200 active:scale-[0.97] hover:bg-orange-600"
|
||||
>
|
||||
Listemi güncelle — {raporKredi} kredi
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
asChild
|
||||
className="cursor-pointer bg-orange-500 text-white transition-[background-color,transform] duration-200 active:scale-[0.97] hover:bg-orange-600"
|
||||
>
|
||||
<Link href="/paket">
|
||||
{hasPaket
|
||||
? "Kredi yükle ve güncelle"
|
||||
: "Paketi aktive et"}
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function UretimSatiri() {
|
||||
const mesaj = useUretimMesaji();
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="flex items-center gap-2 text-sm font-medium text-slate-900">
|
||||
<RefreshCcw className="size-4 animate-spin text-primary" aria-hidden />
|
||||
Listen yeni tercihlerine göre kuruluyor…
|
||||
</p>
|
||||
<p className="text-sm text-slate-600">{mesaj}</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
Bu işlem yarım dakika kadar sürebilir, sayfayı kapatma.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function KrediYetersiz({
|
||||
hasPaket,
|
||||
raporKredi,
|
||||
}: {
|
||||
hasPaket: boolean;
|
||||
raporKredi: number;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<p className="w-full text-sm text-slate-600">
|
||||
{hasPaket
|
||||
? `Güncelleme ${raporKredi} kredi ve bakiyen yetmedi. Kredi yüklediğinde listen ve sohbet geçmişin duruyor, kaldığın yerden devam edersin.`
|
||||
: `Güncelleme ${raporKredi} kredi ve bakiyen yetmedi. Tercih Dönemi Paketi'yle kredilerin, tam liste, PDF ve revizyon hakları birlikte gelir.`}
|
||||
</p>
|
||||
<Button
|
||||
asChild
|
||||
className="cursor-pointer bg-orange-500 text-white transition-[background-color,transform] duration-200 active:scale-[0.97] hover:bg-orange-600"
|
||||
>
|
||||
<Link href="/paket">
|
||||
{hasPaket ? "Kredi yükle" : "Paketi aktive et"}
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
// Liste üretiminin bekleme metinleri tek kaynakta: ilk kurulum (ListeUretici)
|
||||
// ve tercih değişikliği sonrası yeniden kurulum (TercihDegistiUyarisi) aynı
|
||||
// ve tercih değişikliği sonrası yeniden kurulum (TercihDegistiModali) aynı
|
||||
// dili ve ritmi paylaşsın diye.
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Kayıtlı listenin üretildiği tercihler (rapor params) ile kullanıcının şu
|
||||
// anki tercih profilini karşılaştırır. /listem'deki "tercihlerin değişti"
|
||||
// uyarısı ve güncelleme CTA'sı bu farktan doğar.
|
||||
// anki tercih profilini karşılaştırır. /listem başlığındaki "Tercihlerin
|
||||
// değişmiş" butonu ve açtığı güncelleme modalı bu farktan doğar.
|
||||
//
|
||||
// İzomorfik: yalnızca tip ve etiket sabitlerine bağlı — hem client bileşeni
|
||||
// hem sunucu tarafı tüketebilir.
|
||||
@@ -35,20 +35,6 @@ export const FARK_ETIKET: Record<FarkAlani, string> = {
|
||||
oncelikler: "önceliklerin",
|
||||
};
|
||||
|
||||
/** Sıra/tür/seçim üçlüsünün kararlı imzası — uyarının "bu değişikliği
|
||||
* gizle" kaydı bu imzaya bağlanır (tercih yine değişirse uyarı geri gelir). */
|
||||
export function profilImzasi(profil: TercihProfili): string {
|
||||
const s = profil.secimler;
|
||||
return JSON.stringify([
|
||||
Math.round(profil.sira),
|
||||
profil.tur,
|
||||
[...s.kategoriler].sort(),
|
||||
[...s.iller].sort(),
|
||||
s.universiteTipi,
|
||||
[...s.oncelikler].sort(),
|
||||
]);
|
||||
}
|
||||
|
||||
// Sıra bağımsız küme kıyası: sihirbaz seçimleri tekrarsız olarak doğrulanır
|
||||
// (bkz. sihirbazDogrula), aynı seçimlerin farklı sırayla kaydedilmesi
|
||||
// "değişiklik" değildir.
|
||||
@@ -88,13 +74,6 @@ export function tercihFarki(
|
||||
return farklar;
|
||||
}
|
||||
|
||||
/** "başarı sıralaman ve ilgi alanların" — uyarı cümlesinin ortası. */
|
||||
export function farkCumlesi(farklar: FarkAlani[]): string {
|
||||
const etiketler = farklar.map((f) => FARK_ETIKET[f]);
|
||||
if (etiketler.length <= 1) return etiketler[0] ?? "";
|
||||
return `${etiketler.slice(0, -1).join(", ")} ve ${etiketler[etiketler.length - 1]}`;
|
||||
}
|
||||
|
||||
export type FarkSatiri = { alan: FarkAlani; etiket: string; eski: string; yeni: string };
|
||||
|
||||
/** Değişen her alanın eski → yeni değeri; uyarıda tek tek gösterilir. */
|
||||
|
||||
Reference in New Issue
Block a user