feat: implement chat history deletion functionality
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:
bilalgursen
2026-08-09 04:44:19 +03:00
parent 8870845fa1
commit 15e2c4c961
8 changed files with 591 additions and 89 deletions

Binary file not shown.

View File

@@ -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).

View File

@@ -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ıı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,18 +180,37 @@ 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. */}
<Button
type="button"
variant="outline"
size="sm"
onClick={profilDuzenlemeyiAc}
className="cursor-pointer"
>
<SlidersHorizontal className="size-4" aria-hidden />
Tercihlerimi düzenle
</Button>
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"
size="sm"
onClick={profilDuzenlemeyiAc}
className="cursor-pointer"
>
<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>
);
}

View File

@@ -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ıı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 ı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))}

View File

@@ -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>

View 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>
);
}

View File

@@ -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";

View File

@@ -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. */