diff --git a/data/app.db b/data/app.db index 6fd4f28..e6f6f63 100644 Binary files a/data/app.db and b/data/app.db differ diff --git a/src/app/api/soru/route.ts b/src/app/api/soru/route.ts index d77d610..305f284 100644 --- a/src/app/api/soru/route.ts +++ b/src/app/api/soru/route.ts @@ -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). diff --git a/src/features/rapor/components/listem-govde.tsx b/src/features/rapor/components/listem-govde.tsx index bf7c2ea..63050be 100644 --- a/src/features/rapor/components/listem-govde.tsx +++ b/src/features/rapor/components/listem-govde.tsx @@ -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,18 +180,37 @@ export function ListemGovde({
{/* 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 ? ( + + ) : ( + + )} {hasPaket ? ( <>
); } diff --git a/src/features/rapor/components/listem-icerik.tsx b/src/features/rapor/components/listem-icerik.tsx index 2aa36e1..9cd06ed 100644 --- a/src/features/rapor/components/listem-icerik.tsx +++ b/src/features/rapor/components/listem-icerik.tsx @@ -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. ) : 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. */} - {/* 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. */} (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]", )} > -
-

Danışmanla konuş

+
+ {bildikleri?.length ? ( + + + + + + +

+ Danışmanın bildikleri — cevaplar bu bağlamla üretilir: +

+
    + {bildikleri.map((b) => ( +
  • {b}
  • + ))} +
+

+ Adın, e-postan ve netlerin danışmanla paylaşılmaz. +

+
+
+
+ ) : null} + {mesajlar.length > 0 ? ( + + ) : null} @@ -228,6 +311,36 @@ export function SohbetClient({
+ + + + Sohbeti temizle + + Danışmanla olan tüm yazışman silinir ve geri getirilemez. Listen + ve kredilerin etkilenmez; yeni bir soruyla sıfırdan devam + edebilirsin. + + + + + + + + +
@@ -241,36 +354,10 @@ export function SohbetClient({ {ozet.metin} - {ozet.uyarilar?.length ? ( -
    - {ozet.uyarilar.map((uyari) => ( -
  • - - {uyari} -
  • - ))} -
- ) : null} {ozet.kilitli ? (

- {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.

) : null}
diff --git a/src/features/rapor/components/tercih-degisti-modali.tsx b/src/features/rapor/components/tercih-degisti-modali.tsx new file mode 100644 index 0000000..0d81fd6 --- /dev/null +++ b/src/features/rapor/components/tercih-degisti-modali.tsx @@ -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({ 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(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 ( + { + if (kilitli) return; + onOpenChange(v); + }} + > + { + 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" ? ( +
+ + + +
+ Listen güncellendi + + Yeni tercihlerine göre yeniden kuruldu — arkadaki liste birkaç + saniye içinde tazelenir. + +
+ +
+ ) : ( +
+
+ + + +
+ + Tercihlerin değişmiş + + {/* Hangi alanların değiştiği hemen altındaki diff + tablosunda zaten duruyor; açıklama yalnızca sonucu ve + bedeli söylüyor. */} + + Listen eski tercihlerinle kuruldu. Yeni liste bunun yerine + geçer ve {raporKredi} kredi düşer. + +
+
+ + {/* 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. */} +
+ {satirlar.map((satir, i) => ( +
0 ? "border-t border-slate-200" : undefined} + > +
+ {satir.etiket} +
+
+ + + − + + Eski: + + {satir.eski} + + + + + + + + Yeni: + + {satir.yeni} + + +
+
+ ))} +
+ + {durum.ad === "uretiliyor" ? ( + + ) : durum.ad === "kredi-yetersiz" ? ( + + ) : ( + // 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. +
+ {durum.ad === "hata" ? ( +

{durum.mesaj}

+ ) : null} + {!krediYeter && durum.ad !== "hata" ? ( +

+ Güncelleme {raporKredi} kredi; bakiyen {kredi} kredi. +

+ ) : null} +
+ + {durum.ad === "hata" ? ( + + ) : krediYeter ? ( + + ) : ( + + )} +
+
+ )} +
+ )} +
+
+ ); +} + +function UretimSatiri() { + const mesaj = useUretimMesaji(); + return ( +
+

+ + Listen yeni tercihlerine göre kuruluyor… +

+

{mesaj}

+

+ Bu işlem yarım dakika kadar sürebilir, sayfayı kapatma. +

+
+ ); +} + +function KrediYetersiz({ + hasPaket, + raporKredi, +}: { + hasPaket: boolean; + raporKredi: number; +}) { + return ( +
+

+ {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.`} +

+ +
+ ); +} diff --git a/src/features/rapor/components/uretim-mesaji.ts b/src/features/rapor/components/uretim-mesaji.ts index 11d8715..cba71a0 100644 --- a/src/features/rapor/components/uretim-mesaji.ts +++ b/src/features/rapor/components/uretim-mesaji.ts @@ -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"; diff --git a/src/features/rapor/tercih-farki.ts b/src/features/rapor/tercih-farki.ts index 12897ef..0ca77c2 100644 --- a/src/features/rapor/tercih-farki.ts +++ b/src/features/rapor/tercih-farki.ts @@ -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 = { 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. */