diff --git a/data/app.db b/data/app.db index 8254db5..5a5081f 100644 Binary files a/data/app.db and b/data/app.db differ diff --git a/src/components/animasyonlu-mark.tsx b/src/components/animasyonlu-mark.tsx new file mode 100644 index 0000000..fb6ff7a --- /dev/null +++ b/src/components/animasyonlu-mark.tsx @@ -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(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 ( + + ); +} diff --git a/src/components/site-header.tsx b/src/components/site-header.tsx index 72c5fd3..6c54015 100644 --- a/src/components/site-header.tsx +++ b/src/components/site-header.tsx @@ -1,6 +1,6 @@ import { Suspense } from "react"; -import Image from "next/image"; import Link from "next/link"; +import { AnimasyonluMark } from "@/components/animasyonlu-mark"; import { KatalogArama } from "@/features/katalog/components/katalog-arama"; import { UserNav, UserNavFallback } from "@/features/kullanici/components/user-nav"; import { getRehberAramaKayitlari } from "@/lib/rehber"; @@ -15,14 +15,9 @@ export function SiteHeader() { aria-label="KolayTercih" className="group order-1 flex min-w-0 shrink items-center font-bricolage" > - + {/* Inline animasyonlu mark: Link'teki `group` sayesinde hover'da + kareler danışman panelindeki nabız animasyonuyla canlanır. */} + Kolay diff --git a/src/features/odeme/components/paket-satinal.tsx b/src/features/odeme/components/paket-satinal.tsx index fa05aff..6693d6f 100644 --- a/src/features/odeme/components/paket-satinal.tsx +++ b/src/features/odeme/components/paket-satinal.tsx @@ -75,7 +75,10 @@ export async function PaketSatinAl() {
Paketin aktif —{" "} - + {/* ?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. */} + listeni oluştur
diff --git a/src/features/rapor/components/liste-uretici.tsx b/src/features/rapor/components/liste-uretici.tsx index b189758..b5f36dc 100644 --- a/src/features/rapor/components/liste-uretici.tsx +++ b/src/features/rapor/components/liste-uretici.tsx @@ -21,6 +21,8 @@ import { listeOlustur } from "@/features/rapor/rapor-actions"; import { olay, siraKovasi } from "@/lib/analitik"; import { sonucHref, type TercihProfili } from "@/features/sihirbaz/sihirbaz-sabitler"; import { tercihProfiliOku } from "@/features/sihirbaz/sihirbaz-profil"; +import { Message } from "@/components/ui/message"; +import { DanismanAvatar, DanismanBaslik } from "./sohbet-client"; import { useUretimMesaji } from "./uretim-mesaji"; type Durum = @@ -114,7 +116,7 @@ export function ListeUretici({ // 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. return ( -
+

Kredin bu liste için yetmiyor @@ -140,7 +142,7 @@ export function ListeUretici({ if (durum.ad === "hata") { return ( -
+

Liste kurulamadı

{durum.mesaj} @@ -173,27 +175,78 @@ function UretimGovdesi() { const mesaj = useUretimMesaji(); return ( -

-
-

Listen hazırlanıyor

-

{mesaj}

+
+
+

+ Listen hazırlanıyor +

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

-
+ - {/* Gelmekte olan tercih listesinin iskeleti */} -
-
- - + {/* Nihai sayfanın (listem-govde) grid'iyle aynı düzen: liste solda, + danışman sağda — üretim biter bitmez ekran aynı yerlere oturur. */} +
+ {/* Gelmekte olan tercih listesinin iskeleti */} +
+
+ + +
+ {Array.from({ length: 8 }).map((_, i) => ( + + ))}
- {Array.from({ length: 6 }).map((_, i) => ( - - ))} + + {/* 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. */} +
); diff --git a/src/features/rapor/components/listem-govde.tsx b/src/features/rapor/components/listem-govde.tsx index 63050be..026a86c 100644 --- a/src/features/rapor/components/listem-govde.tsx +++ b/src/features/rapor/components/listem-govde.tsx @@ -26,7 +26,12 @@ import { TercihHaritasiLazy } from "@/features/liste/components/tercih-haritasi- import { YurtdisiHaritalarLazy } from "@/features/liste/components/yurtdisi-haritalar-lazy"; import { pinleriTuret } from "@/lib/harita-pinler"; 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 { profilDuzenlemeyiAc, @@ -46,6 +51,7 @@ export function ListemGovde({ raporKredi, sohbetGecmisi, ozet, + ilkYorum, kalanHak, paketFiyati, paketKredi, @@ -66,6 +72,8 @@ export function ListemGovde({ raporKredi: number; sohbetGecmisi: Mesaj[]; ozet?: DanismanOzeti; + /** Sohbet boşken açılışta gösterilen 1. tercih yorumu */ + ilkYorum?: IlkTercihYorumu; kalanHak: number; paketFiyati: string; paketKredi: number; @@ -140,7 +148,7 @@ export function ListemGovde({ ); return ( -
+
{/* Harita: listenin coğrafyası — ile dokun, üniversiteleri gör */}
) : ( @@ -306,8 +315,9 @@ export function ListemGovde({ baslangicKredi={kredi} initialMesajlar={sohbetGecmisi} ozet={ozet} + ilkYorum={ilkYorum} bildikleri={danismaninBildikleri} - className="h-[360px]" + className="h-[480px]" />
-
+ {/* Ü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. */} +
{/* ?uret=1: sihirbazdan yönlendirildi — bekleyen seçimlerle üretim burada koşar, animasyon ve hata/kredi durumları burada yaşar. */} {uret ? ( @@ -95,6 +98,23 @@ export async function ListemIcerik({ kilitli: !user.hasPaket, } : 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 ( <> @@ -126,20 +146,11 @@ export async function ListemIcerik({ raporKredi={RAPOR_KREDI} sohbetGecmisi={sohbetGecmisi as Mesaj[]} ozet={danismanOzeti} + ilkYorum={ilkYorum} kalanHak={Math.max(0, MAX_REVIZYON - (satir!.revisionCount ?? 0))} paketFiyati={PAKET_FIYATI} paketKredi={URUNLER.paket.credits} /> - - - - - Danışmana sor - -
@@ -152,7 +163,7 @@ export function ListemIcerikSkeleton() { -
+
{Array.from({ length: 10 }).map((_, i) => ( diff --git a/src/features/rapor/components/sohbet-client.tsx b/src/features/rapor/components/sohbet-client.tsx index e94196a..5e317a2 100644 --- a/src/features/rapor/components/sohbet-client.tsx +++ b/src/features/rapor/components/sohbet-client.tsx @@ -7,6 +7,7 @@ import Image from "next/image"; import { ArrowUp, Coins, Info, Lock, Sparkles, Trash2 } from "lucide-react"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; +import { AnimasyonluMark } from "@/components/animasyonlu-mark"; import { Button } from "@/components/ui/button"; import { ChatContainerContent, @@ -53,20 +54,53 @@ export interface DanismanOzeti { kilitli?: boolean; } -/** Danışman mesajlarının avatarı — KolayTercih markası. */ -function DanismanAvatar() { +/** Liste kurulunca sohbetin açılışında gösterilen 1. tercih yorumu. + * 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. +

+ + + Danışmanla + Konuş + +

+ ); +} + +/** 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 ( - + {canli ? ( + + ) : ( + + )} ); } @@ -87,6 +121,7 @@ export function SohbetClient({ initialMesajlar, className, ozet, + ilkYorum, bildikleri, }: { baslangicKredi: number; @@ -96,6 +131,10 @@ export function SohbetClient({ className?: string; /** Raporun genel değerlendirmesi; sohbetin açılış mesajı olarak gösterilir */ 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 bildikleri" ipucunda listelenir. Boşsa ipucu çizilmez. */ bildikleri?: string[]; @@ -253,62 +292,70 @@ 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-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. */}
- {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} + - - Sohbeti temizle - - ) : null} - - - {kredi} kredi - + + {kredi} kredi + +
@@ -363,6 +410,19 @@ export function SohbetClient({
) : null} + {ilkYorum && mesajlar.length === 0 ? ( + + +
+

+ 1. tercihin: {ilkYorum.program} — {ilkYorum.universite} +

+ + {ilkYorum.metin} + +
+
+ ) : null} {mesajlar.length === 0 ? (
) : ( - mesajlar.map((m) => + mesajlar.map((m, i) => m.role === "user" ? ( @@ -433,7 +493,11 @@ export function SohbetClient({ ) : ( - + {/* Cevap beklenirken/stream sürerken (son mesaj) avatar + nabız animasyonuna geçer. */} + {m.content ? ( Listen yeni tercihlerine göre kuruluyor…

-

{mesaj}

+

{mesaj}

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