Compare commits
2 Commits
437e2bc96a
...
15e2c4c961
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15e2c4c961 | ||
|
|
8870845fa1 |
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) {
|
export async function POST(request: NextRequest) {
|
||||||
// Oturum ve gövde ayrıştırma bağımsız — paralel başlar, hata önceliği aynı
|
// Oturum ve gövde ayrıştırma bağımsız — paralel başlar, hata önceliği aynı
|
||||||
// kalır (önce 401, sonra 400).
|
// kalır (önce 401, sonra 400).
|
||||||
|
|||||||
@@ -13,27 +13,37 @@ import {
|
|||||||
FileText,
|
FileText,
|
||||||
Lock,
|
Lock,
|
||||||
PencilLine,
|
PencilLine,
|
||||||
|
RefreshCcw,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { RaporListesi } from "./rapor-listesi";
|
import { RaporListesi } from "./rapor-listesi";
|
||||||
import { useGorunumOlayi } from "@/components/use-gorunum-olayi";
|
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 { TercihHaritasiLazy } from "@/features/liste/components/tercih-haritasi-lazy";
|
||||||
import { YurtdisiHaritalarLazy } from "@/features/liste/components/yurtdisi-haritalar-lazy";
|
import { YurtdisiHaritalarLazy } from "@/features/liste/components/yurtdisi-haritalar-lazy";
|
||||||
import { pinleriTuret } from "@/lib/harita-pinler";
|
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 { SohbetClient, type DanismanOzeti, type Mesaj } from "./sohbet-client";
|
||||||
import { RevizyonKutusu } from "./revizyon-kutusu";
|
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({
|
export function ListemGovde({
|
||||||
rapor,
|
rapor,
|
||||||
|
params,
|
||||||
adaySira,
|
adaySira,
|
||||||
hasPaket,
|
hasPaket,
|
||||||
kilitAcilisi,
|
kilitAcilisi,
|
||||||
kilitliBaslangic,
|
kilitliBaslangic,
|
||||||
kredi,
|
kredi,
|
||||||
|
raporKredi,
|
||||||
sohbetGecmisi,
|
sohbetGecmisi,
|
||||||
ozet,
|
ozet,
|
||||||
kalanHak,
|
kalanHak,
|
||||||
@@ -41,6 +51,9 @@ export function ListemGovde({
|
|||||||
paketKredi,
|
paketKredi,
|
||||||
}: {
|
}: {
|
||||||
rapor: RaporSonuc;
|
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;
|
adaySira: number;
|
||||||
hasPaket: boolean;
|
hasPaket: boolean;
|
||||||
/** Ödeme dönüşü: blur çözülme animasyonuyla aç */
|
/** Ödeme dönüşü: blur çözülme animasyonuyla aç */
|
||||||
@@ -48,6 +61,9 @@ export function ListemGovde({
|
|||||||
/** Paketsizde ACIK_SATIR; paketlide undefined (tamamı açık) */
|
/** Paketsizde ACIK_SATIR; paketlide undefined (tamamı açık) */
|
||||||
kilitliBaslangic?: number;
|
kilitliBaslangic?: number;
|
||||||
kredi: 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[];
|
sohbetGecmisi: Mesaj[];
|
||||||
ozet?: DanismanOzeti;
|
ozet?: DanismanOzeti;
|
||||||
kalanHak: number;
|
kalanHak: number;
|
||||||
@@ -71,6 +87,44 @@ export function ListemGovde({
|
|||||||
|
|
||||||
const acikSatir = kilitliBaslangic ?? rapor.tercihler.length;
|
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).
|
// 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ı
|
// Ref'ler yalnızca paketsiz dallarda bağlanır; paketlide DOM'da olmadıkları
|
||||||
// için observer hiç kurulmaz.
|
// için observer hiç kurulmaz.
|
||||||
@@ -126,18 +180,37 @@ export function ListemGovde({
|
|||||||
</h1>
|
</h1>
|
||||||
<div className="flex shrink-0 flex-wrap gap-2">
|
<div className="flex shrink-0 flex-wrap gap-2">
|
||||||
{/* Tercih düzenleme her kullanıcıya açık: kapı (layout'ta monte
|
{/* Tercih düzenleme her kullanıcıya açık: kapı (layout'ta monte
|
||||||
TercihProfiliKapisi) profili günceller, /listem'e döner ve
|
TercihProfiliKapisi) profili günceller, /listem'e döner.
|
||||||
değişiklik varsa "tercihlerin değişti" uyarısı karşılar. */}
|
Profil kayıtlı listeden ayrıldığı anda bu buton "Tercihlerin
|
||||||
<Button
|
değişmiş"e döner ve TercihDegistiModali'nı açar — fark
|
||||||
type="button"
|
dökümü ve kredi kapısı orada tek yerde yaşar.
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
Renk: varsayılan (primary) buton. Kardeşleri outline olduğu
|
||||||
onClick={profilDuzenlemeyiAc}
|
için tek dolu buton olması dikkati zaten çekiyor; turuncu
|
||||||
className="cursor-pointer"
|
bu sayfada paywall CTA'sının rengi, amber ise risk dilinin
|
||||||
>
|
"az riskli" tonu — ikisi de burada yanlış sinyal olurdu. */}
|
||||||
<SlidersHorizontal className="size-4" aria-hidden />
|
{farkVar ? (
|
||||||
Tercihlerimi düzenle
|
<Button
|
||||||
</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 ? (
|
{hasPaket ? (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
@@ -223,6 +296,7 @@ export function ListemGovde({
|
|||||||
baslangicKredi={kredi}
|
baslangicKredi={kredi}
|
||||||
initialMesajlar={sohbetGecmisi}
|
initialMesajlar={sohbetGecmisi}
|
||||||
ozet={ozet}
|
ozet={ozet}
|
||||||
|
bildikleri={danismaninBildikleri}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
@@ -232,7 +306,8 @@ export function ListemGovde({
|
|||||||
baslangicKredi={kredi}
|
baslangicKredi={kredi}
|
||||||
initialMesajlar={sohbetGecmisi}
|
initialMesajlar={sohbetGecmisi}
|
||||||
ozet={ozet}
|
ozet={ozet}
|
||||||
className="h-[420px]"
|
bildikleri={danismaninBildikleri}
|
||||||
|
className="h-[360px]"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<section
|
<section
|
||||||
@@ -302,6 +377,18 @@ export function ListemGovde({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import type { Mesaj } from "./sohbet-client";
|
|||||||
import { ListemGovde } from "./listem-govde";
|
import { ListemGovde } from "./listem-govde";
|
||||||
import { ListemBosCta } from "./listem-bos-cta";
|
import { ListemBosCta } from "./listem-bos-cta";
|
||||||
import { ListeUretici } from "./liste-uretici";
|
import { ListeUretici } from "./liste-uretici";
|
||||||
import { TercihDegistiUyarisi } from "./tercih-degisti-uyarisi";
|
|
||||||
|
|
||||||
const PAKET_FIYATI = (URUNLER.paket.amountKurus / 100).toLocaleString("tr-TR", {
|
const PAKET_FIYATI = (URUNLER.paket.amountKurus / 100).toLocaleString("tr-TR", {
|
||||||
maximumFractionDigits: 0,
|
maximumFractionDigits: 0,
|
||||||
@@ -89,13 +88,11 @@ export async function ListemIcerik({
|
|||||||
const raporParams = satir!.params as RaporParams;
|
const raporParams = satir!.params as RaporParams;
|
||||||
// Danışman özeti sohbetin açılış mesajı olarak gösterilir; sohbet
|
// Danışman özeti sohbetin açılış mesajı olarak gösterilir; sohbet
|
||||||
// /api/soru üzerinden zaten liste + seçim bağlamıyla çalışıyor.
|
// /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
|
const danismanOzeti = rapor.genelDegerlendirme
|
||||||
? {
|
? {
|
||||||
metin: rapor.genelDegerlendirme,
|
metin: rapor.genelDegerlendirme,
|
||||||
uyarilar: rapor.uyarilar,
|
|
||||||
kilitli: !user.hasPaket,
|
kilitli: !user.hasPaket,
|
||||||
kilitliUyariSayisi: user.hasPaket ? undefined : rapor.kilitliUyariSayisi,
|
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
@@ -112,25 +109,21 @@ export async function ListemIcerik({
|
|||||||
PDF artık açık.
|
PDF artık açık.
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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
|
{/* 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
|
<ListemGovde
|
||||||
rapor={rapor}
|
rapor={rapor}
|
||||||
|
params={raporParams}
|
||||||
adaySira={raporParams.sira}
|
adaySira={raporParams.sira}
|
||||||
hasPaket={Boolean(user.hasPaket)}
|
hasPaket={Boolean(user.hasPaket)}
|
||||||
kilitAcilisi={kilitAcilisi}
|
kilitAcilisi={kilitAcilisi}
|
||||||
kilitliBaslangic={user.hasPaket ? undefined : ACIK_SATIR}
|
kilitliBaslangic={user.hasPaket ? undefined : ACIK_SATIR}
|
||||||
kredi={user.creditBalance}
|
kredi={user.creditBalance}
|
||||||
|
raporKredi={RAPOR_KREDI}
|
||||||
sohbetGecmisi={sohbetGecmisi as Mesaj[]}
|
sohbetGecmisi={sohbetGecmisi as Mesaj[]}
|
||||||
ozet={danismanOzeti}
|
ozet={danismanOzeti}
|
||||||
kalanHak={Math.max(0, MAX_REVIZYON - (satir!.revisionCount ?? 0))}
|
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 Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import Image from "next/image";
|
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 { toast } from "sonner";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -13,6 +13,14 @@ import {
|
|||||||
ChatContainerRoot,
|
ChatContainerRoot,
|
||||||
ChatContainerScrollAnchor,
|
ChatContainerScrollAnchor,
|
||||||
} from "@/components/ui/chat-container";
|
} from "@/components/ui/chat-container";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
import { Message, MessageContent } from "@/components/ui/message";
|
import { Message, MessageContent } from "@/components/ui/message";
|
||||||
import { preloadMarkdown } from "@/components/ui/markdown-lazy";
|
import { preloadMarkdown } from "@/components/ui/markdown-lazy";
|
||||||
import { onbellekliJsonGetir } from "@/lib/istek-cache";
|
import { onbellekliJsonGetir } from "@/lib/istek-cache";
|
||||||
@@ -24,6 +32,12 @@ import {
|
|||||||
} from "@/components/ui/prompt-input";
|
} from "@/components/ui/prompt-input";
|
||||||
import { PromptSuggestion } from "@/components/ui/prompt-suggestion";
|
import { PromptSuggestion } from "@/components/ui/prompt-suggestion";
|
||||||
import { ScrollButton } from "@/components/ui/scroll-button";
|
import { ScrollButton } from "@/components/ui/scroll-button";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
import { DotsLoader } from "@/components/ui/loader";
|
import { DotsLoader } from "@/components/ui/loader";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { olay } from "@/lib/analitik";
|
import { olay } from "@/lib/analitik";
|
||||||
@@ -36,10 +50,7 @@ export interface Mesaj {
|
|||||||
|
|
||||||
export interface DanismanOzeti {
|
export interface DanismanOzeti {
|
||||||
metin: string;
|
metin: string;
|
||||||
uyarilar?: string[];
|
|
||||||
kilitli?: boolean;
|
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ı. */
|
/** Danışman mesajlarının avatarı — KolayTercih markası. */
|
||||||
@@ -76,6 +87,7 @@ export function SohbetClient({
|
|||||||
initialMesajlar,
|
initialMesajlar,
|
||||||
className,
|
className,
|
||||||
ozet,
|
ozet,
|
||||||
|
bildikleri,
|
||||||
}: {
|
}: {
|
||||||
baslangicKredi: number;
|
baslangicKredi: number;
|
||||||
/** Sunucu tarafında çekilmiş geçmiş; verilirse mount fetch'i atlanır */
|
/** Sunucu tarafında çekilmiş geçmiş; verilirse mount fetch'i atlanır */
|
||||||
@@ -84,12 +96,17 @@ export function SohbetClient({
|
|||||||
className?: string;
|
className?: string;
|
||||||
/** Raporun genel değerlendirmesi; sohbetin açılış mesajı olarak gösterilir */
|
/** Raporun genel değerlendirmesi; sohbetin açılış mesajı olarak gösterilir */
|
||||||
ozet?: DanismanOzeti;
|
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 [mesajlar, setMesajlar] = useState<Mesaj[]>(initialMesajlar ?? []);
|
||||||
const [girdi, setGirdi] = useState("");
|
const [girdi, setGirdi] = useState("");
|
||||||
const [bekliyor, setBekliyor] = useState(false);
|
const [bekliyor, setBekliyor] = useState(false);
|
||||||
const [yuklendi, setYuklendi] = useState(initialMesajlar != null);
|
const [yuklendi, setYuklendi] = useState(initialMesajlar != null);
|
||||||
const [kredi, setKredi] = useState(baslangicKredi);
|
const [kredi, setKredi] = useState(baslangicKredi);
|
||||||
|
const [temizleAcik, setTemizleAcik] = useState(false);
|
||||||
|
const [temizleniyor, setTemizleniyor] = useState(false);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
// Markdown chunk'ını sohbet görünür olur olmaz ısıt: ilk danışman cevabı
|
// 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;
|
const krediYok = kredi < 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -213,14 +253,57 @@ export function SohbetClient({
|
|||||||
aria-label="Danışman sohbeti"
|
aria-label="Danışman sohbeti"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white",
|
"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">
|
<div className="flex items-center gap-2 border-b border-slate-100 px-4 py-3">
|
||||||
<h2 className="font-heading text-base font-bold">Danışmanla konuş</h2>
|
{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
|
<Link
|
||||||
href="/paket"
|
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"
|
title="Kredilerin — yüklemek için tıkla"
|
||||||
>
|
>
|
||||||
<Coins className="size-3.5" aria-hidden />
|
<Coins className="size-3.5" aria-hidden />
|
||||||
@@ -228,6 +311,36 @@ export function SohbetClient({
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</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">
|
<div className="relative flex-1 overflow-hidden">
|
||||||
<ChatContainerRoot className="h-full">
|
<ChatContainerRoot className="h-full">
|
||||||
<ChatContainerContent className="gap-4 p-4">
|
<ChatContainerContent className="gap-4 p-4">
|
||||||
@@ -241,36 +354,10 @@ export function SohbetClient({
|
|||||||
<MessageContent className="bg-slate-100 text-slate-800">
|
<MessageContent className="bg-slate-100 text-slate-800">
|
||||||
{ozet.metin}
|
{ozet.metin}
|
||||||
</MessageContent>
|
</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 ? (
|
{ozet.kilitli ? (
|
||||||
<p className="flex items-center gap-1.5 text-xs text-slate-500">
|
<p className="flex items-center gap-1.5 text-xs text-slate-500">
|
||||||
<Lock className="size-3.5" aria-hidden />
|
<Lock className="size-3.5" aria-hidden />
|
||||||
{ozet.kilitliUyariSayisi ? (
|
Değerlendirmenin tamamı pakette.
|
||||||
<>
|
|
||||||
{ozet.kilitliUyariSayisi} uyarı daha kilitli —
|
|
||||||
sıralamana özel uyarıların pakette.
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
Değerlendirmenin tamamı ve sıralamana özel uyarılar
|
|
||||||
pakette.
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</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,302 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
// /listem'deki "tercihlerin değişti" uyarısı: kayıtlı liste hangi tercihlerle
|
|
||||||
// kurulduysa (rapor params) kullanıcının güncel profili ondan ayrıldığında
|
|
||||||
// belirir 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.
|
|
||||||
//
|
|
||||||
// Uyarı kapatılabilir: kapatma kararı o anki profil imzasına yazılır, tercih
|
|
||||||
// yeniden değişirse uyarı geri gelir (bkz. profilImzasi).
|
|
||||||
|
|
||||||
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, X } from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
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 {
|
|
||||||
farkCumlesi,
|
|
||||||
farkSatirlari,
|
|
||||||
profilImzasi,
|
|
||||||
tercihFarki,
|
|
||||||
} from "@/features/rapor/tercih-farki";
|
|
||||||
import { useUretimMesaji } from "./uretim-mesaji";
|
|
||||||
|
|
||||||
const GIZLE_ANAHTARI = "kolaytercih.listem-fark-gizle";
|
|
||||||
|
|
||||||
function gizlenenImzayiOku(): string | null {
|
|
||||||
if (typeof window === "undefined") return null;
|
|
||||||
try {
|
|
||||||
return localStorage.getItem(GIZLE_ANAHTARI);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type Durum =
|
|
||||||
| { ad: "bekliyor" }
|
|
||||||
| { ad: "uretiliyor" }
|
|
||||||
| { ad: "tamamlandi" }
|
|
||||||
| { ad: "kredi-yetersiz" }
|
|
||||||
| { ad: "hata"; mesaj: string };
|
|
||||||
|
|
||||||
export function TercihDegistiUyarisi({
|
|
||||||
params,
|
|
||||||
kredi,
|
|
||||||
raporKredi,
|
|
||||||
hasPaket,
|
|
||||||
}: {
|
|
||||||
/** 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" });
|
|
||||||
// Profil client'ta çözüldüğü için bu bileşen SSR'da zaten hiçbir şey
|
|
||||||
// render etmiyor; gizleme kaydı ilk render'da güvenle okunabilir.
|
|
||||||
const [gizlenenImza, setGizlenenImza] = useState<string | null>(
|
|
||||||
gizlenenImzayiOku,
|
|
||||||
);
|
|
||||||
// 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],
|
|
||||||
);
|
|
||||||
|
|
||||||
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 uyarı kendiliğinden
|
|
||||||
// kaybolur; refresh dönene kadar "güncellendi" hâli görünür.
|
|
||||||
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?",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function gizle() {
|
|
||||||
if (!profil) return;
|
|
||||||
const imza = profilImzasi(profil);
|
|
||||||
try {
|
|
||||||
localStorage.setItem(GIZLE_ANAHTARI, imza);
|
|
||||||
} catch {}
|
|
||||||
setGizlenenImza(imza);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!profil || farklar.length === 0) return null;
|
|
||||||
if (durum.ad === "tamamlandi") {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
role="status"
|
|
||||||
className="mb-6 flex items-center gap-3 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-medium text-emerald-800"
|
|
||||||
>
|
|
||||||
<Check className="size-4 shrink-0" aria-hidden />
|
|
||||||
Listen yeni tercihlerine göre güncellendi.
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// Kapatılmış değişiklik: kullanıcı aynı tercihlerle bir daha rahatsız
|
|
||||||
// edilmez. Üretim/hata hâlleri kapatmadan bağımsız görünür kalır.
|
|
||||||
if (durum.ad === "bekliyor" && gizlenenImza === profilImzasi(profil)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const krediYeter = kredi >= raporKredi;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section
|
|
||||||
aria-live="polite"
|
|
||||||
className="mb-6 rounded-2xl border border-amber-200 bg-amber-50 p-4 sm:p-5"
|
|
||||||
>
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<span className="flex size-9 shrink-0 items-center justify-center rounded-full bg-amber-100 text-amber-700">
|
|
||||||
<SlidersHorizontal className="size-4" aria-hidden />
|
|
||||||
</span>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<h2 className="font-heading font-bold text-amber-900">
|
|
||||||
Tercihlerin değişti
|
|
||||||
</h2>
|
|
||||||
<p className="mt-1 text-sm text-amber-900/80">
|
|
||||||
Aşağıdaki liste eski tercihlerinle kuruldu; {farkCumlesi(farklar)}{" "}
|
|
||||||
o zamandan beri değişti. Yeni tercihlerine göre yeniden kurabilirsin
|
|
||||||
— yeni liste bunun yerine geçer ve {raporKredi} kredi düşer.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<dl className="mt-3 flex flex-col gap-1.5 text-sm">
|
|
||||||
{satirlar.map((satir) => (
|
|
||||||
<div
|
|
||||||
key={satir.alan}
|
|
||||||
className="flex flex-col gap-0.5 sm:flex-row sm:items-baseline sm:gap-2"
|
|
||||||
>
|
|
||||||
<dt className="shrink-0 text-xs font-medium uppercase tracking-wide text-amber-700">
|
|
||||||
{satir.etiket}
|
|
||||||
</dt>
|
|
||||||
<dd className="min-w-0 text-amber-900">
|
|
||||||
<span className="text-amber-900/60">{satir.eski}</span>
|
|
||||||
<span className="px-1.5 text-amber-700" aria-label="yerine">
|
|
||||||
→
|
|
||||||
</span>
|
|
||||||
<span className="font-semibold">{satir.yeni}</span>
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</dl>
|
|
||||||
|
|
||||||
{durum.ad === "uretiliyor" ? (
|
|
||||||
<UretimSatiri />
|
|
||||||
) : durum.ad === "kredi-yetersiz" ? (
|
|
||||||
<KrediYetersiz hasPaket={hasPaket} raporKredi={raporKredi} />
|
|
||||||
) : (
|
|
||||||
<div className="mt-4 flex flex-wrap items-center gap-3">
|
|
||||||
{durum.ad === "hata" ? (
|
|
||||||
<>
|
|
||||||
<p className="w-full text-sm text-red-700">{durum.mesaj}</p>
|
|
||||||
<Button
|
|
||||||
onClick={() => void guncelle()}
|
|
||||||
className="cursor-pointer bg-orange-500 text-white transition-colors duration-200 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>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={gizle}
|
|
||||||
className="cursor-pointer text-sm font-medium text-amber-800 underline underline-offset-2 transition-colors hover:text-amber-900"
|
|
||||||
>
|
|
||||||
Şimdilik kalsın
|
|
||||||
</button>
|
|
||||||
{!krediYeter && durum.ad !== "hata" ? (
|
|
||||||
<p className="w-full text-xs text-amber-900/70">
|
|
||||||
Güncelleme {raporKredi} kredi; bakiyen {kredi} kredi.
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{durum.ad === "bekliyor" ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={gizle}
|
|
||||||
className="flex size-9 shrink-0 cursor-pointer items-center justify-center rounded-full border border-amber-200 text-amber-800 transition-colors duration-200 active:scale-[0.96] hover:bg-amber-100"
|
|
||||||
>
|
|
||||||
<X className="size-4" aria-hidden />
|
|
||||||
<span className="sr-only">Uyarıyı kapat</span>
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function UretimSatiri() {
|
|
||||||
const mesaj = useUretimMesaji();
|
|
||||||
return (
|
|
||||||
<div className="mt-4 flex flex-col gap-1">
|
|
||||||
<p className="flex items-center gap-2 text-sm font-medium text-amber-900">
|
|
||||||
<RefreshCcw className="size-4 animate-spin" aria-hidden />
|
|
||||||
Listen yeni tercihlerine göre kuruluyor…
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-amber-900/70">{mesaj}</p>
|
|
||||||
<p className="text-xs text-amber-900/60">
|
|
||||||
Bu işlem yarım dakika kadar sürebilir, sayfayı kapatma.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function KrediYetersiz({
|
|
||||||
hasPaket,
|
|
||||||
raporKredi,
|
|
||||||
}: {
|
|
||||||
hasPaket: boolean;
|
|
||||||
raporKredi: number;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="mt-4 flex flex-wrap items-center gap-3">
|
|
||||||
<p className="w-full text-sm text-amber-900">
|
|
||||||
{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-colors duration-200 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";
|
"use client";
|
||||||
|
|
||||||
// Liste üretiminin bekleme metinleri tek kaynakta: ilk kurulum (ListeUretici)
|
// 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.
|
// dili ve ritmi paylaşsın diye.
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Kayıtlı listenin üretildiği tercihler (rapor params) ile kullanıcının şu
|
// 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"
|
// anki tercih profilini karşılaştırır. /listem başlığındaki "Tercihlerin
|
||||||
// uyarısı ve güncelleme CTA'sı bu farktan doğar.
|
// 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
|
// İzomorfik: yalnızca tip ve etiket sabitlerine bağlı — hem client bileşeni
|
||||||
// hem sunucu tarafı tüketebilir.
|
// hem sunucu tarafı tüketebilir.
|
||||||
@@ -35,20 +35,6 @@ export const FARK_ETIKET: Record<FarkAlani, string> = {
|
|||||||
oncelikler: "önceliklerin",
|
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
|
// 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
|
// (bkz. sihirbazDogrula), aynı seçimlerin farklı sırayla kaydedilmesi
|
||||||
// "değişiklik" değildir.
|
// "değişiklik" değildir.
|
||||||
@@ -88,13 +74,6 @@ export function tercihFarki(
|
|||||||
return farklar;
|
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 };
|
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. */
|
/** Değişen her alanın eski → yeni değeri; uyarıda tek tek gösterilir. */
|
||||||
|
|||||||
Reference in New Issue
Block a user