refactor: update site header and chat components for improved user experience
All checks were successful
Deploy / deploy (push) Successful in 12m16s

Replaced the static logo in the SiteHeader with an animated component for enhanced visual feedback. Updated the PaketSatinAl component to include a dynamic link based on user context. Enhanced the ListeUretici and ListemGovde components with improved layout and messaging features, including the addition of a new initial preference comment display in the SohbetClient. Adjusted styling for consistency across components and improved loading states with skeleton screens.
This commit is contained in:
bilalgursen
2026-08-11 12:54:46 +03:00
parent 951caa3c93
commit 30b9b41a22
9 changed files with 437 additions and 108 deletions

Binary file not shown.

View File

@@ -0,0 +1,193 @@
"use client";
import { useEffect, useRef, useState, type CSSProperties } from "react";
import { motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
// public/kolay-tercih-mark.svg'nin inline, animasyonlu karşılığı (Motion).
// İki varyant:
//
// - "rota" (varsayılan): her kare sıradakinin pozisyonuna süzülür (mavi →
// şeftalinin yeri, şeftali → turuncunun, turuncu → mavinin), üç adımda
// herkes başlangıç formuna döner. Uçuş sırasında kare hafifçe küçülüp
// solar, varışta toparlanır. Site header'daki logo hover'da bunu oynatır.
// - "nabiz": kareler yerlerinden ayrılmaz; 120° faz farkıyla dalga gibi
// küçülüp büyür (danışman panel başlığının sakin, sürekli hâli).
//
// Not: motion.rect'te `x`/`y` transform değeridir (attribute değil) — kareler
// attribute yerine translate ile konumlanır; scale, transform-box: fill-box
// sayesinde karenin kendi merkezinden uygulanır.
const POZISYONLAR = [
[27, 56],
[91, 29],
[91, 95],
] as const;
const KARELER = [
{ boyut: 57, renk: "#3b82f6", baslangic: 0 },
{ boyut: 56, renk: "#fdc7a7", baslangic: 1 },
{ boyut: 56, renk: "#ff5a00", baslangic: 2 },
] as const;
// Rota: tam tur 3.6s — 3 etap × (%25 yol = 0.9s + %8.3 varışta soluklanma).
// 0.9s'lik yolculuk, nabız varyantının küçül-büyü temposuyla aynı hızda.
const ROTA_SURE = 3.6;
// Kalkışlar kademeli: kareler eş zamanlı değil, danışman başlığındaki gibi
// sırayla (mavi → şeftali → turuncu) yola çıkıp küçülür.
const ROTA_GECIKME_ADIMI = 0.2;
const KONUM_ZAMANLARI = [0, 0.25, 1 / 3, 0.583, 2 / 3, 0.917, 1];
// Nabız (rota içi): her yolculuğun ortasında dibe vurur, varışta tam form.
const ROTA_NABIZ_ZAMANLARI = [
0, 0.125, 0.25, 1 / 3, 0.458, 0.583, 2 / 3, 0.792, 0.917, 1,
];
const ROTA_NABIZ_SCALE = [1, 0.84, 1, 1, 0.84, 1, 1, 0.84, 1, 1];
const ROTA_NABIZ_OPACITY = [1, 0.6, 1, 1, 0.6, 1, 1, 0.6, 1, 1];
// Yerinde nabız: kesintisiz tam-sinüs (dinlenme yok), delay'ler döngüyü üçe
// böler — biri büyürken sıradaki küçülür, hiçbir kare beklemez.
const NABIZ_SURE = 1.8;
const NABIZ_GECIKMELER = [0, 0.6, 1.2] as const;
const SINE = [0.37, 0, 0.63, 1] as const;
/** Kare i için durak koordinatları: kendi yeri → sıradaki → ondan sonraki →
* kendi yeri. Duraklarda bekleme için her hedef iki kez tekrarlanır
* (KONUM_ZAMANLARI ile hizalı). */
function rota(baslangic: number, eksen: 0 | 1): number[] {
const [p0, p1, p2] = [0, 1, 2].map(
(adim) => POZISYONLAR[(baslangic + adim) % 3][eksen],
);
return [p0, p1, p1, p2, p2, p0, p0];
}
/**
* Animasyonlu KolayTercih işareti. Varsayılanda koreografi sürekli döner;
* `sadeceHover` ile yalnızca işaretin üzerine gelindiğinde oynar (ör. site
* header'daki logo linki) ve hover bitince kareler yumuşakça yerine oturur.
*/
export function AnimasyonluMark({
className,
sadeceHover = false,
varyant = "rota",
renk = "marka",
}: {
className?: string;
sadeceHover?: boolean;
varyant?: "rota" | "nabiz";
/** "marka": logodaki üç renk; "turuncu": tüm kareler sitenin CTA turuncusu
* (orange-500) — ör. danışman panel başlığı. */
renk?: "marka" | "turuncu";
}) {
const [hover, setHover] = useState(false);
const svgRef = useRef<SVGSVGElement>(null);
const azHareket = useReducedMotion();
const aktif = !azHareket && (!sadeceHover || hover);
// Hover hedefi işaretin kendisi değil, en yakın `group` ebeveyni (site
// header'da logo + "KolayTercih" yazısını saran Link): yazının üzerine
// gelindiğinde de animasyon başlar. `group`'suz kullanımda svg'ye düşer.
useEffect(() => {
if (!sadeceHover) return;
const hedef = svgRef.current?.closest(".group") ?? svgRef.current;
if (!hedef) return;
const ac = () => setHover(true);
const kapat = () => setHover(false);
hedef.addEventListener("mouseenter", ac);
hedef.addEventListener("mouseleave", kapat);
return () => {
hedef.removeEventListener("mouseenter", ac);
hedef.removeEventListener("mouseleave", kapat);
};
}, [sadeceHover]);
return (
<svg
ref={svgRef}
viewBox="0 0 180 180"
aria-hidden="true"
focusable="false"
className={cn("shrink-0", className)}
>
{KARELER.map((k, i) => {
const [px, py] = POZISYONLAR[k.baslangic];
const duruk = { x: px, y: py, scale: 1, opacity: 1 };
const animate = !aktif
? duruk
: varyant === "rota"
? {
x: rota(k.baslangic, 0),
y: rota(k.baslangic, 1),
scale: ROTA_NABIZ_SCALE,
opacity: ROTA_NABIZ_OPACITY,
}
: {
x: px,
y: py,
scale: [1, 0.82, 1],
opacity: [1, 0.55, 1],
};
// Kademeli kalkış: delay yalnızca ilk turdan önce uygulanır, sonraki
// turlarda faz farkı kendiliğinden korunur.
const rotaGecikme = i * ROTA_GECIKME_ADIMI;
const konumGecis = {
duration: ROTA_SURE,
times: KONUM_ZAMANLARI,
ease: "easeInOut" as const,
repeat: Infinity,
delay: rotaGecikme,
};
const rotaNabizGecis = {
duration: ROTA_SURE,
times: ROTA_NABIZ_ZAMANLARI,
ease: "easeInOut" as const,
repeat: Infinity,
delay: rotaGecikme,
};
const nabizGecis = {
duration: NABIZ_SURE,
times: [0, 0.5, 1],
ease: SINE as unknown as [number, number, number, number],
repeat: Infinity,
delay: NABIZ_GECIKMELER[i],
};
const transition = !aktif
? { duration: 0.4, ease: "easeOut" as const }
: varyant === "rota"
? {
x: konumGecis,
y: konumGecis,
scale: rotaNabizGecis,
opacity: rotaNabizGecis,
}
: {
x: { duration: 0 },
y: { duration: 0 },
scale: nabizGecis,
opacity: nabizGecis,
};
return (
<motion.rect
key={k.renk}
width={k.boyut}
height={k.boyut}
rx={13}
fill={k.renk}
className={renk === "turuncu" ? "fill-orange-500" : undefined}
animate={animate}
transition={transition}
style={
{
transformBox: "fill-box",
transformOrigin: "center",
} as CSSProperties
}
/>
);
})}
</svg>
);
}

View File

@@ -1,6 +1,6 @@
import { Suspense } from "react"; import { Suspense } from "react";
import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { AnimasyonluMark } from "@/components/animasyonlu-mark";
import { KatalogArama } from "@/features/katalog/components/katalog-arama"; import { KatalogArama } from "@/features/katalog/components/katalog-arama";
import { UserNav, UserNavFallback } from "@/features/kullanici/components/user-nav"; import { UserNav, UserNavFallback } from "@/features/kullanici/components/user-nav";
import { getRehberAramaKayitlari } from "@/lib/rehber"; import { getRehberAramaKayitlari } from "@/lib/rehber";
@@ -15,14 +15,9 @@ export function SiteHeader() {
aria-label="KolayTercih" aria-label="KolayTercih"
className="group order-1 flex min-w-0 shrink items-center font-bricolage" className="group order-1 flex min-w-0 shrink items-center font-bricolage"
> >
<Image {/* Inline animasyonlu mark: Link'teki `group` sayesinde hover'da
src="/kolay-tercih-mark.svg" kareler danışman panelindeki nabız animasyonuyla canlanır. */}
alt="" <AnimasyonluMark sadeceHover className="size-8 sm:size-11" />
width={44}
height={44}
aria-hidden="true"
className="size-8 shrink-0 sm:size-11"
/>
<span className="truncate"> <span className="truncate">
<span className="text-lg font-light tracking-tighter sm:text-3xl"> <span className="text-lg font-light tracking-tighter sm:text-3xl">
Kolay Kolay

View File

@@ -75,7 +75,10 @@ export async function PaketSatinAl() {
<div className="flex items-center gap-2 rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-medium text-emerald-800"> <div className="flex items-center gap-2 rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-medium text-emerald-800">
<CheckCircle2 className="size-4" aria-hidden /> <CheckCircle2 className="size-4" aria-hidden />
Paketin aktif —{" "} Paketin aktif —{" "}
<Link href="/" className="underline"> {/* ?uret=1: rapor varsa /listem raporu gösterir (bayrak yok
sayılır), yoksa bekleyen seçimlerle üretim başlar; seçim
de yoksa boş durum sihirbaza yönlendirir. */}
<Link href="/listem?uret=1" className="underline">
listeni oluştur listeni oluştur
</Link> </Link>
</div> </div>

View File

@@ -21,6 +21,8 @@ import { listeOlustur } from "@/features/rapor/rapor-actions";
import { olay, siraKovasi } from "@/lib/analitik"; import { olay, siraKovasi } from "@/lib/analitik";
import { sonucHref, type TercihProfili } from "@/features/sihirbaz/sihirbaz-sabitler"; import { sonucHref, type TercihProfili } from "@/features/sihirbaz/sihirbaz-sabitler";
import { tercihProfiliOku } from "@/features/sihirbaz/sihirbaz-profil"; import { tercihProfiliOku } from "@/features/sihirbaz/sihirbaz-profil";
import { Message } from "@/components/ui/message";
import { DanismanAvatar, DanismanBaslik } from "./sohbet-client";
import { useUretimMesaji } from "./uretim-mesaji"; import { useUretimMesaji } from "./uretim-mesaji";
type Durum = type Durum =
@@ -114,7 +116,7 @@ export function ListeUretici({
// Paketli kullanıcıya paket satma: onun ürünü top-up. Liste ve sohbet // Paketli kullanıcıya paket satma: onun ürünü top-up. Liste ve sohbet
// geçmişi kalıcı; kredi yükleyince kaldığı yerden devam eder. // geçmişi kalıcı; kredi yükleyince kaldığı yerden devam eder.
return ( return (
<section className="rounded-2xl border border-slate-200 bg-white p-8 text-center sm:p-12"> <section className="mx-auto w-full max-w-2xl rounded-2xl border border-slate-200 bg-white p-8 text-center sm:p-12">
<Lock className="mx-auto size-8 text-slate-400" aria-hidden /> <Lock className="mx-auto size-8 text-slate-400" aria-hidden />
<h1 className="mt-4 font-heading text-xl font-bold"> <h1 className="mt-4 font-heading text-xl font-bold">
Kredin bu liste için yetmiyor Kredin bu liste için yetmiyor
@@ -140,7 +142,7 @@ export function ListeUretici({
if (durum.ad === "hata") { if (durum.ad === "hata") {
return ( return (
<section className="rounded-2xl border border-slate-200 bg-white p-8 text-center sm:p-12"> <section className="mx-auto w-full max-w-2xl rounded-2xl border border-slate-200 bg-white p-8 text-center sm:p-12">
<h1 className="font-heading text-xl font-bold">Liste kurulamadı</h1> <h1 className="font-heading text-xl font-bold">Liste kurulamadı</h1>
<p className="mx-auto mt-2 max-w-md text-sm text-slate-600"> <p className="mx-auto mt-2 max-w-md text-sm text-slate-600">
{durum.mesaj} {durum.mesaj}
@@ -173,27 +175,78 @@ function UretimGovdesi() {
const mesaj = useUretimMesaji(); const mesaj = useUretimMesaji();
return ( return (
<section <section aria-live="polite">
aria-live="polite" <header className="flex flex-col items-center gap-1.5 text-center">
className="rounded-2xl border border-slate-200 bg-white p-6 sm:p-8" <h1 className="font-heading text-xl font-bold sm:text-2xl">
> Listen hazırlanıyor
<div className="flex flex-col items-center gap-1.5 text-center"> </h1>
<h1 className="font-heading text-lg font-bold">Listen hazırlanıyor</h1>
<p className="max-w-sm text-sm text-slate-500">{mesaj}</p>
<p className="text-xs text-slate-400"> <p className="text-xs text-slate-400">
Bu işlem yarım dakika kadar sürebilir, sayfayı kapatma. Bu işlem yarım dakika kadar sürebilir, sayfayı kapatma.
</p> </p>
</div> </header>
{/* Gelmekte olan tercih listesinin iskeleti */} {/* Nihai sayfanın (listem-govde) grid'iyle aynı düzen: liste solda,
<div className="mt-8 flex flex-col gap-2"> danışman sağda — üretim biter bitmez ekran aynı yerlere oturur. */}
<div className="flex items-center justify-between"> <div className="mt-8 grid items-start gap-6 lg:grid-cols-[minmax(0,1fr)_380px] xl:grid-cols-[minmax(0,1fr)_440px]">
<Skeleton className="h-6 w-44" /> {/* Gelmekte olan tercih listesinin iskeleti */}
<Skeleton className="h-6 w-24 rounded-full" /> <div className="flex min-w-0 flex-col gap-2 rounded-2xl border border-slate-200 bg-white p-6 sm:p-8">
<div className="flex items-center justify-between pb-2">
<Skeleton className="h-6 w-44" />
<Skeleton className="h-6 w-24 rounded-full" />
</div>
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full rounded-xl" />
))}
</div> </div>
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full rounded-xl" /> {/* Danışman paneli üretim sırasında da açık: aşama mesajları danışman
))} balonunda shimmer ile akar; liste kurulunca aynı bölgede 1.
tercihin yorumu açılış balonu olarak bekler (bkz. sohbet-client
ilkYorum). Mobilde panel iskeletin üstüne alınır — bekleyenin
gördüğü ilk şey danışmanın çalıştığı olsun. */}
<aside
aria-label="Danışman paneli — listen hazırlanıyor"
className="flex flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white max-lg:order-first lg:sticky lg:top-28"
>
{/* SohbetClient başlığıyla aynı ibare/stil: üretim bitince panel
yerini sohbete bırakırken başlık değişmez. */}
<div className="flex items-center border-b border-slate-100 px-4 py-3">
<DanismanBaslik />
</div>
<div className="flex flex-col gap-4 p-4">
<Message className="justify-start">
{/* Liste üretimi de bir bekleme hâli: avatar, sohbetteki
"cevap yazılıyor" durumuyla aynı nabzı atar. */}
<DanismanAvatar canli />
<div className="flex max-w-[85%] flex-col gap-2">
{/* MessageContent'in class setiyle aynı balon; shimmer
background-clip:text kullandığı için balonun bg'sini
ezmesin diye içteki span'e uygulanır — MessageContent
string children beklediğinden burada div açık yazıldı. */}
<div className="prose whitespace-normal break-words rounded-lg bg-slate-100 p-2 text-slate-800">
<span className="shimmer font-medium">{mesaj}</span>
</div>
<p className="text-xs text-slate-500">
Listen hazır olunca ilk tercihinin yorumunu burada
okuyacaksın.
</p>
</div>
</Message>
{/* Örnek soru çiplerinin yer tutucusu */}
<div className="flex flex-wrap gap-2">
{[112, 148, 96].map((w, i) => (
<Skeleton
key={i}
className="h-7 rounded-full"
style={{ width: w }}
/>
))}
</div>
</div>
<div className="mt-auto border-t border-slate-100 p-3">
<Skeleton className="h-[52px] w-full rounded-2xl" />
</div>
</aside>
</div> </div>
</section> </section>
); );

View File

@@ -26,7 +26,12 @@ import { TercihHaritasiLazy } from "@/features/liste/components/tercih-haritasi-
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 { RaporParams, 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 IlkTercihYorumu,
type Mesaj,
} from "./sohbet-client";
import { RevizyonKutusu } from "./revizyon-kutusu"; import { RevizyonKutusu } from "./revizyon-kutusu";
import { import {
profilDuzenlemeyiAc, profilDuzenlemeyiAc,
@@ -46,6 +51,7 @@ export function ListemGovde({
raporKredi, raporKredi,
sohbetGecmisi, sohbetGecmisi,
ozet, ozet,
ilkYorum,
kalanHak, kalanHak,
paketFiyati, paketFiyati,
paketKredi, paketKredi,
@@ -66,6 +72,8 @@ export function ListemGovde({
raporKredi: number; raporKredi: number;
sohbetGecmisi: Mesaj[]; sohbetGecmisi: Mesaj[];
ozet?: DanismanOzeti; ozet?: DanismanOzeti;
/** Sohbet boşken açılışta gösterilen 1. tercih yorumu */
ilkYorum?: IlkTercihYorumu;
kalanHak: number; kalanHak: number;
paketFiyati: string; paketFiyati: string;
paketKredi: number; paketKredi: number;
@@ -140,7 +148,7 @@ export function ListemGovde({
); );
return ( return (
<div className="mt-8 grid items-start gap-6 lg:grid-cols-[minmax(0,1fr)_360px] xl:grid-cols-[minmax(0,1fr)_400px]"> <div className="mt-8 grid items-start gap-6 lg:grid-cols-[minmax(0,1fr)_380px] xl:grid-cols-[minmax(0,1fr)_440px]">
<div className="flex min-w-0 flex-col gap-6"> <div className="flex min-w-0 flex-col gap-6">
{/* Harita: listenin coğrafyası — ile dokun, üniversiteleri gör */} {/* Harita: listenin coğrafyası — ile dokun, üniversiteleri gör */}
<section <section
@@ -296,6 +304,7 @@ export function ListemGovde({
baslangicKredi={kredi} baslangicKredi={kredi}
initialMesajlar={sohbetGecmisi} initialMesajlar={sohbetGecmisi}
ozet={ozet} ozet={ozet}
ilkYorum={ilkYorum}
bildikleri={danismaninBildikleri} bildikleri={danismaninBildikleri}
/> />
) : ( ) : (
@@ -306,8 +315,9 @@ export function ListemGovde({
baslangicKredi={kredi} baslangicKredi={kredi}
initialMesajlar={sohbetGecmisi} initialMesajlar={sohbetGecmisi}
ozet={ozet} ozet={ozet}
ilkYorum={ilkYorum}
bildikleri={danismaninBildikleri} bildikleri={danismaninBildikleri}
className="h-[360px]" className="h-[480px]"
/> />
<section <section

View File

@@ -1,6 +1,6 @@
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { MessageCircleQuestion, Sparkles } from "lucide-react"; import { Sparkles } from "lucide-react";
import { verifySession, getCurrentUser } from "@/lib/session"; import { verifySession, getCurrentUser } from "@/lib/session";
import { import {
PROFIL_CEREZ_ADI, PROFIL_CEREZ_ADI,
@@ -13,7 +13,6 @@ import type {
RaporParams, RaporParams,
RaporSonuc, RaporSonuc,
} from "../types/rapor"; } from "../types/rapor";
import { ViewportPortal } from "@/components/viewport-portal";
import { SiteFooter } from "@/components/site-footer"; import { SiteFooter } from "@/components/site-footer";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { raporMaskele, ACIK_SATIR } from "@/lib/rapor-maske"; import { raporMaskele, ACIK_SATIR } from "@/lib/rapor-maske";
@@ -64,7 +63,11 @@ export async function ListemIcerik({
); );
return ( return (
<> <>
<main className="mx-auto w-full max-w-4xl px-4 py-12"> {/* Üretim gövdesi nihai sayfanın iki kolonlu grid'ini kurduğu için
main de nihai sayfayla aynı genişlikte (7xl) — üretim bitince
içerik zıplamadan yerine oturur. Boş durum kendi max-w'siyle
ortalanır. */}
<main className="mx-auto w-full max-w-7xl px-4 py-12">
{/* ?uret=1: sihirbazdan yönlendirildi — bekleyen seçimlerle üretim {/* ?uret=1: sihirbazdan yönlendirildi — bekleyen seçimlerle üretim
burada koşar, animasyon ve hata/kredi durumları burada yaşar. */} burada koşar, animasyon ve hata/kredi durumları burada yaşar. */}
{uret ? ( {uret ? (
@@ -95,6 +98,23 @@ export async function ListemIcerik({
kilitli: !user.hasPaket, kilitli: !user.hasPaket,
} }
: undefined; : undefined;
// 1. tercihin yorumu sohbetin açılış balonu olur: üretim panelindeki
// "ilk tercihinin yorumunu burada okuyacaksın" sözünün karşılığı. İlk
// satır ACIK_SATIR maskesinde her zaman açık, yani paketsizde de gerçek
// Yapay Zeka metnidir.
const ilkTercih =
rapor.tercihler.find((t) => t.sira === 1) ?? rapor.tercihler[0];
const ilkProgram = ilkTercih
? rapor.programlar[ilkTercih.programId]
: undefined;
const ilkYorum =
ilkTercih && ilkProgram
? {
program: ilkProgram.isim,
universite: ilkProgram.universite,
metin: ilkTercih.gerekce,
}
: undefined;
return ( return (
<> <>
@@ -126,20 +146,11 @@ export async function ListemIcerik({
raporKredi={RAPOR_KREDI} raporKredi={RAPOR_KREDI}
sohbetGecmisi={sohbetGecmisi as Mesaj[]} sohbetGecmisi={sohbetGecmisi as Mesaj[]}
ozet={danismanOzeti} ozet={danismanOzeti}
ilkYorum={ilkYorum}
kalanHak={Math.max(0, MAX_REVIZYON - (satir!.revisionCount ?? 0))} kalanHak={Math.max(0, MAX_REVIZYON - (satir!.revisionCount ?? 0))}
paketFiyati={PAKET_FIYATI} paketFiyati={PAKET_FIYATI}
paketKredi={URUNLER.paket.credits} paketKredi={URUNLER.paket.credits}
/> />
<ViewportPortal>
<a
href="#sohbet"
className="fixed bottom-5 right-4 z-40 inline-flex h-11 items-center gap-2 rounded-full bg-primary px-4 text-sm font-semibold text-white shadow-lg transition-transform duration-200 active:scale-[0.97] lg:hidden print:hidden"
>
<MessageCircleQuestion className="size-4" aria-hidden />
Danışmana sor
</a>
</ViewportPortal>
</main> </main>
<SiteFooter /> <SiteFooter />
</> </>
@@ -152,7 +163,7 @@ export function ListemIcerikSkeleton() {
<Skeleton className="h-6 w-40 rounded-full" /> <Skeleton className="h-6 w-40 rounded-full" />
<Skeleton className="mt-4 h-9 w-3/4 max-w-xl" /> <Skeleton className="mt-4 h-9 w-3/4 max-w-xl" />
<Skeleton className="mt-3 h-5 w-2/3 max-w-lg" /> <Skeleton className="mt-3 h-5 w-2/3 max-w-lg" />
<div className="mt-8 grid items-start gap-8 lg:grid-cols-[minmax(0,1fr)_340px]"> <div className="mt-8 grid items-start gap-6 lg:grid-cols-[minmax(0,1fr)_380px] xl:grid-cols-[minmax(0,1fr)_440px]">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{Array.from({ length: 10 }).map((_, i) => ( {Array.from({ length: 10 }).map((_, i) => (
<Skeleton key={i} className="h-16 w-full rounded-xl" /> <Skeleton key={i} className="h-16 w-full rounded-xl" />

View File

@@ -7,6 +7,7 @@ import Image from "next/image";
import { ArrowUp, Coins, Info, Lock, Sparkles, Trash2 } 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 { AnimasyonluMark } from "@/components/animasyonlu-mark";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
ChatContainerContent, ChatContainerContent,
@@ -53,20 +54,53 @@ export interface DanismanOzeti {
kilitli?: boolean; kilitli?: boolean;
} }
/** Danışman mesajlarının avatarı — KolayTercih markası. */ /** Liste kurulunca sohbetin açılışında gösterilen 1. tercih yorumu.
function DanismanAvatar() { * Metin ACIK_SATIR maskesinin her zaman açık bıraktığı ilk satırdan gelir,
* yani paketsiz kullanıcıda da gerçek Yapay Zeka yorumudur. */
export interface IlkTercihYorumu {
program: string;
universite: string;
metin: string;
}
/** Danışman panellerinin ortak başlığı: animasyonlu logo + site header'daki
* KolayTercih wordmark deseninde "DanışmanlaKonuş" (ilk kelime ince, ikinci
* kalın, boşluksuz). İşaret ortak AnimasyonluMark bileşeni — header
* logosuyla aynı nabız animasyonu, burada sürekli atar. */
export function DanismanBaslik() {
return (
// gap yok denecek kadar az (0.5): SVG viewBox'ının kendi iç boşluğu
// zaten nefes payı bırakıyor.
<h2 className="flex min-w-0 flex-1 items-center gap-0.5 font-bricolage text-base tracking-tighter">
<AnimasyonluMark varyant="nabiz" renk="turuncu" className="size-5" />
<span className="truncate">
<span className="font-light">Danışmanla</span>
<span className="font-semibold">Konuş</span>
</span>
</h2>
);
}
/** Danışman mesajlarının avatarı — KolayTercih markası. `canli` iken (cevap
* beklenirken/yazılırken) statik görsel yerine başlıktaki nabız animasyonu
* oynar: danışmanın "düşündüğü" avatardan okunur. */
export function DanismanAvatar({ canli = false }: { canli?: boolean }) {
return ( return (
<span <span
className="mt-0.5 flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-full border border-slate-200 bg-white" className="mt-0.5 flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-full border border-slate-200 bg-white"
aria-hidden aria-hidden
> >
<Image {canli ? (
src="/kolay-tercih-mark.svg" <AnimasyonluMark varyant="nabiz" className="size-5" />
alt="" ) : (
width={32} <Image
height={32} src="/kolay-tercih-mark.svg"
className="size-5" alt=""
/> width={32}
height={32}
className="size-5"
/>
)}
</span> </span>
); );
} }
@@ -87,6 +121,7 @@ export function SohbetClient({
initialMesajlar, initialMesajlar,
className, className,
ozet, ozet,
ilkYorum,
bildikleri, bildikleri,
}: { }: {
baslangicKredi: number; baslangicKredi: number;
@@ -96,6 +131,10 @@ 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;
/** 1. tercihin Yapay Zeka yorumu; sohbet boşken (taze kurulmuş liste)
* özetin altında açılış balonu olarak durur — üretim panelindeki "ilk
* tercihinin yorumunu burada okuyacaksın" sözünün karşılığı. */
ilkYorum?: IlkTercihYorumu;
/** Danışmanın prompt'unda gerçekten yer alan bağlam maddeleri; başlıktaki /** 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. */ * "Danışmanın bildikleri" ipucunda listelenir. Boşsa ipucu çizilmez. */
bildikleri?: string[]; bildikleri?: string[];
@@ -253,62 +292,70 @@ 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-24rem)] min-h-[340px]", // 18rem: sticky ofset (top-28 = 7rem) + geniş alt boşluk — eski 24rem
// sohbeti sıkıştırıyordu, 10-14rem denemeleri fazla yüksek geldi;
// panel görünür ama sayfaya baskın değil.
className ?? "h-[calc(100dvh-18rem)] min-h-[380px]",
)} )}
> >
{/* Başlık solda, aksiyonlar sağda tek grup: dar panelde de sığar.
Temizle butonu ikon-only — metinli hâli başlıkla yarışıp taşıyordu. */}
<div className="flex items-center gap-2 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">
{bildikleri?.length ? ( <DanismanBaslik />
<TooltipProvider delayDuration={120}> <div className="flex shrink-0 items-center gap-1.5">
<Tooltip> {bildikleri?.length ? (
<TooltipTrigger asChild> <TooltipProvider delayDuration={120}>
<button <Tooltip>
type="button" <TooltipTrigger asChild>
aria-label="Danışmanın bildikleri" <button
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" 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"
> >
<Info className="size-3.5" aria-hidden /> <p className="font-semibold">
</button> Danışmanın bildikleri — cevaplar bu bağlamla üretilir:
</TooltipTrigger> </p>
<TooltipContent <ul className="flex list-disc flex-col gap-1 pl-4">
side="bottom" {bildikleri.map((b) => (
sideOffset={6} <li key={b}>{b}</li>
className="max-w-xs flex-col items-start gap-1.5 p-3 text-left leading-relaxed" ))}
> </ul>
<p className="font-semibold"> <p className="text-background/70">
Danışmanın bildikleri — cevaplar bu bağlamla üretilir: Adın, e-postan ve netlerin danışmanla paylaşılmaz.
</p> </p>
<ul className="flex list-disc flex-col gap-1 pl-4"> </TooltipContent>
{bildikleri.map((b) => ( </Tooltip>
<li key={b}>{b}</li> </TooltipProvider>
))} ) : null}
</ul> {mesajlar.length > 0 ? (
<p className="text-background/70"> <button
Adın, e-postan ve netlerin danışmanla paylaşılmaz. type="button"
</p> onClick={() => setTemizleAcik(true)}
</TooltipContent> disabled={bekliyor || temizleniyor}
</Tooltip> aria-label="Sohbeti temizle"
</TooltipProvider> className="inline-flex size-7 cursor-pointer items-center justify-center rounded-full border border-slate-200 bg-white 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"
) : null} title="Sohbet geçmişini sil"
{mesajlar.length > 0 ? ( >
<button <Trash2 className="size-3.5" aria-hidden />
type="button" </button>
onClick={() => setTemizleAcik(true)} ) : null}
disabled={bekliyor || temizleniyor} <Link
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" href="/paket"
title="Sohbet geçmişini sil" className="inline-flex items-center gap-1.5 rounded-full border border-amber-200 bg-amber-50 px-2.5 py-1 text-xs font-semibold text-amber-700 transition-colors hover:bg-amber-100"
title="Kredilerin — yüklemek için tıkla"
> >
<Trash2 className="size-3.5" aria-hidden /> <Coins className="size-3.5" aria-hidden />
Sohbeti temizle {kredi} kredi
</button> </Link>
) : null} </div>
<Link
href="/paket"
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 />
{kredi} kredi
</Link>
</div> </div>
<Dialog open={temizleAcik} onOpenChange={setTemizleAcik}> <Dialog open={temizleAcik} onOpenChange={setTemizleAcik}>
@@ -363,6 +410,19 @@ export function SohbetClient({
</div> </div>
</Message> </Message>
) : null} ) : null}
{ilkYorum && mesajlar.length === 0 ? (
<Message className="justify-start">
<DanismanAvatar />
<div className="flex max-w-[85%] flex-col gap-2">
<p className="text-xs font-semibold text-slate-500">
1. tercihin: {ilkYorum.program} — {ilkYorum.universite}
</p>
<MessageContent className="bg-slate-100 text-slate-800">
{ilkYorum.metin}
</MessageContent>
</div>
</Message>
) : null}
{mesajlar.length === 0 ? ( {mesajlar.length === 0 ? (
<div <div
className={cn( className={cn(
@@ -424,7 +484,7 @@ export function SohbetClient({
)} )}
</div> </div>
) : ( ) : (
mesajlar.map((m) => mesajlar.map((m, i) =>
m.role === "user" ? ( m.role === "user" ? (
<Message key={m.id} className="justify-end"> <Message key={m.id} className="justify-end">
<MessageContent className="max-w-[85%] bg-primary text-white"> <MessageContent className="max-w-[85%] bg-primary text-white">
@@ -433,7 +493,11 @@ export function SohbetClient({
</Message> </Message>
) : ( ) : (
<Message key={m.id} className="justify-start"> <Message key={m.id} className="justify-start">
<DanismanAvatar /> {/* Cevap beklenirken/stream sürerken (son mesaj) avatar
nabız animasyonuna geçer. */}
<DanismanAvatar
canli={bekliyor && i === mesajlar.length - 1}
/>
{m.content ? ( {m.content ? (
<MessageContent <MessageContent
markdown markdown

View File

@@ -306,7 +306,7 @@ function UretimSatiri() {
<RefreshCcw className="size-4 animate-spin text-primary" aria-hidden /> <RefreshCcw className="size-4 animate-spin text-primary" aria-hidden />
Listen yeni tercihlerine göre kuruluyor… Listen yeni tercihlerine göre kuruluyor…
</p> </p>
<p className="text-sm text-slate-600">{mesaj}</p> <p className="shimmer text-sm text-slate-600">{mesaj}</p>
<p className="text-xs text-slate-500"> <p className="text-xs text-slate-500">
Bu işlem yarım dakika kadar sürebilir, sayfayı kapatma. Bu işlem yarım dakika kadar sürebilir, sayfayı kapatma.
</p> </p>