Refactor UI components for consistency and clarity across the application
All checks were successful
Deploy / deploy (push) Successful in 18m31s
All checks were successful
Deploy / deploy (push) Successful in 18m31s
- Updated AGENTS.md with new guidelines for UI consistency, including badge and modal styles. - Refactored layout.tsx to improve the placement of the progressive blur effect. - Enhanced NotFound component text for better user guidance. - Replaced HeroFocusButton with HeroBaslik and KapanisCta in the Home component for improved clarity. - Added SiteFooter to multiple pages for consistent footer display. - Removed ListemSekmeleri component and adjusted ListemPage to streamline functionality. - Updated various components to ensure consistent styling and user experience.
This commit is contained in:
@@ -6,6 +6,7 @@ import { getBolumBySlug, uniSlugFromAd } from "@/lib/katalog";
|
||||
import { JsonLd, breadcrumbJsonLd } from "@/lib/seo";
|
||||
import { Sayfalama, SAYFA_BOYU } from "@/components/sayfalama";
|
||||
import { BolumProgramListesi } from "@/components/bolum-program-listesi";
|
||||
import { SiteFooter } from "@/components/site-footer";
|
||||
|
||||
const sayi = (n: number) => n.toLocaleString("tr-TR");
|
||||
|
||||
@@ -53,7 +54,8 @@ export function BolumIcerik({ slug, sayfa }: { slug: string; sayfa: number }) {
|
||||
const buYol = ilkSayfa ? `/bolum/${slug}` : `/bolum/${slug}/sayfa/${sayfa}`;
|
||||
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-5xl px-4 py-10 sm:py-14">
|
||||
<>
|
||||
<main className="mx-auto w-full max-w-5xl px-4 py-10 sm:py-14">
|
||||
<JsonLd
|
||||
data={breadcrumbJsonLd([
|
||||
{ name: "Ana Sayfa", path: "/" },
|
||||
@@ -124,6 +126,8 @@ export function BolumIcerik({ slug, sayfa }: { slug: string; sayfa: number }) {
|
||||
sayfa={sayfa}
|
||||
toplamSayfa={toplamSayfa}
|
||||
/>
|
||||
</main>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,61 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { TextLoop } from "@/components/core/text-loop";
|
||||
import { TypingAnimation } from "@/components/ui/typing-animation";
|
||||
|
||||
// Sonuç sayfasından gelen funnel'da dönen sempatik mesajlar.
|
||||
// Kısa tutulmalı — başlıkta tek satır, taşma yok.
|
||||
// Hepsi aynı şablon: "Giriş yap, çünkü …"
|
||||
const FUNNEL_MESAJLARI = [
|
||||
"1 adım kaldı tercihine",
|
||||
"Listen seni bekliyor 👀",
|
||||
"24 tercih, hepsi sana göre",
|
||||
"5 kredin hazır, ya sen?",
|
||||
", çünkü listen seni bekliyor",
|
||||
", çünkü tercihine 1 adım kaldı",
|
||||
", çünkü seçimlerin hazır",
|
||||
", çünkü 5 kredin yanında",
|
||||
", çünkü kaçırmak istemezsin",
|
||||
", çünkü listen hemen üretilsin",
|
||||
];
|
||||
|
||||
/**
|
||||
* Giriş sayfası başlığı. Kullanıcı sihirbazdan (callback /sonuc'a dönüyorsa)
|
||||
* geldiyse TextLoop'la funnel mesajları döner; diğer girişlerde sabit başlık.
|
||||
* geldiyse sabit "Giriş yap" + typewriter hook'ları; diğer girişlerde
|
||||
* yalnız sabit başlık.
|
||||
*/
|
||||
export function GirisBaslik({ funnel }: { funnel: boolean }) {
|
||||
if (!funnel) {
|
||||
return <h1 className="font-heading text-2xl font-bold">Giriş yap</h1>;
|
||||
return <h1 className="font-heading text-xl font-bold">Giriş yap</h1>;
|
||||
}
|
||||
|
||||
return (
|
||||
<h1 className="font-heading text-2xl font-bold">
|
||||
<TextLoop
|
||||
className="overflow-y-clip"
|
||||
interval={2.5}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 900,
|
||||
damping: 80,
|
||||
mass: 10,
|
||||
}}
|
||||
variants={{
|
||||
initial: {
|
||||
y: 20,
|
||||
rotateX: 90,
|
||||
opacity: 0,
|
||||
filter: "blur(4px)",
|
||||
},
|
||||
animate: {
|
||||
y: 0,
|
||||
rotateX: 0,
|
||||
opacity: 1,
|
||||
filter: "blur(0px)",
|
||||
},
|
||||
exit: {
|
||||
y: -20,
|
||||
rotateX: -90,
|
||||
opacity: 0,
|
||||
filter: "blur(4px)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{FUNNEL_MESAJLARI.map((mesaj) => (
|
||||
<span key={mesaj}>{mesaj}</span>
|
||||
))}
|
||||
</TextLoop>
|
||||
<h1 className="flex flex-wrap items-baseline font-heading text-xl font-bold leading-tight">
|
||||
<span className="shrink-0">Giriş yap</span>
|
||||
<TypingAnimation
|
||||
as="span"
|
||||
words={FUNNEL_MESAJLARI}
|
||||
loop
|
||||
startOnView={false}
|
||||
typeSpeed={42}
|
||||
deleteSpeed={22}
|
||||
pauseDelay={2400}
|
||||
className="min-h-[1.25em] font-heading text-xl font-bold leading-tight tracking-[-0.02em] text-primary"
|
||||
/>
|
||||
</h1>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@ import { Skeleton } from "@/components/ui/skeleton";
|
||||
// içerik gelince zıplama olmaz.
|
||||
export default function GirisLoading() {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-slate-50">
|
||||
<main className="mx-auto flex w-full max-w-md flex-1 flex-col items-stretch justify-center px-4 py-16">
|
||||
<div className="-mb-28 flex min-h-dvh flex-col bg-slate-50">
|
||||
<main className="mx-auto flex w-full max-w-md flex-1 flex-col items-stretch justify-center px-6 py-8">
|
||||
<div className="rounded-2xl border border-slate-200 bg-white p-8 shadow-sm">
|
||||
{/* Başlık + alt metin */}
|
||||
<Skeleton className="h-8 w-40" />
|
||||
|
||||
@@ -27,8 +27,10 @@ export default async function GirisPage({
|
||||
const funnel = callbackURL.startsWith("/sonuc");
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-slate-50 text-slate-900">
|
||||
<main className="mx-auto flex w-full max-w-md flex-1 flex-col items-stretch justify-center px-5 py-16 sm:px-4">
|
||||
// -mb-28: layout'taki ScrollFlow sarmalayıcısının pb-28'ini nötrler —
|
||||
// kart tam dvh içinde ortalanır, sayfa scroll etmez (sığmazsa scroll açılır).
|
||||
<div className="-mb-28 flex min-h-dvh flex-col bg-slate-50 text-slate-900">
|
||||
<main className="mx-auto flex w-full max-w-md flex-1 flex-col items-stretch justify-center px-6 py-8">
|
||||
<div className="relative">
|
||||
<div className="relative rounded-2xl border border-slate-200 bg-white p-8 shadow-sm">
|
||||
<GirisBaslik funnel={funnel} />
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { Parallax } from "@/components/parallax";
|
||||
import { PagePixelDivider } from "@/components/pixel-decor";
|
||||
import { SiteFooter } from "@/components/site-footer";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Gizlilik ve KVKK",
|
||||
@@ -13,6 +14,7 @@ export const metadata: Metadata = {
|
||||
// NOT: MVP taslağıdır; yayına almadan önce hukuki gözden geçirme gerekir.
|
||||
export default function GizlilikPage() {
|
||||
return (
|
||||
<>
|
||||
<main className="mx-auto w-full max-w-2xl px-4 py-16">
|
||||
<Parallax fromStart strength={10}>
|
||||
<h1 className="font-heading text-3xl font-bold">
|
||||
@@ -78,5 +80,7 @@ export default function GizlilikPage() {
|
||||
</section>
|
||||
</Parallax>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
||||
import { Mail } from "lucide-react";
|
||||
import { Parallax } from "@/components/parallax";
|
||||
import { PagePixelDivider } from "@/components/pixel-decor";
|
||||
import { SiteFooter } from "@/components/site-footer";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "İletişim",
|
||||
@@ -12,6 +13,7 @@ export const metadata: Metadata = {
|
||||
|
||||
export default function IletisimPage() {
|
||||
return (
|
||||
<>
|
||||
<main className="mx-auto w-full max-w-2xl px-4 py-16">
|
||||
<Parallax fromStart strength={10}>
|
||||
<h1 className="font-heading text-3xl font-bold">İletişim</h1>
|
||||
@@ -34,5 +36,7 @@ export default function IletisimPage() {
|
||||
asla e-postayla paylaşma.
|
||||
</p>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { Parallax } from "@/components/parallax";
|
||||
import { PagePixelDivider } from "@/components/pixel-decor";
|
||||
import { SiteFooter } from "@/components/site-footer";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Kullanım ve Satış Koşulları",
|
||||
@@ -13,6 +14,7 @@ export const metadata: Metadata = {
|
||||
// NOT: MVP taslağıdır; yayına almadan önce hukuki gözden geçirme gerekir.
|
||||
export default function KosullarPage() {
|
||||
return (
|
||||
<>
|
||||
<main className="mx-auto w-full max-w-2xl px-4 py-16">
|
||||
<Parallax fromStart strength={10}>
|
||||
<h1 className="font-heading text-3xl font-bold">
|
||||
@@ -85,5 +87,7 @@ export default function KosullarPage() {
|
||||
</section>
|
||||
</Parallax>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -84,24 +84,24 @@ export default function RootLayout({
|
||||
className={`${calSans.variable} ${clarityCity.variable} ${geistMono.variable} ${bricolage.variable} antialiased`}
|
||||
>
|
||||
<body className="min-h-dvh flex flex-col bg-slate-50">
|
||||
{/* Site geneli progressive blur — üst ve alt */}
|
||||
<div className="pointer-events-none fixed inset-x-0 top-0 z-40 h-28 print:hidden">
|
||||
<ProgressiveBlur
|
||||
position="top"
|
||||
backgroundColor="#f8fafc"
|
||||
height="112px"
|
||||
blurAmount="6px"
|
||||
/>
|
||||
</div>
|
||||
<div className="pointer-events-none fixed inset-x-0 bottom-0 z-40 h-28 print:hidden">
|
||||
<ProgressiveBlur
|
||||
position="bottom"
|
||||
backgroundColor="#f8fafc"
|
||||
height="112px"
|
||||
blurAmount="6px"
|
||||
/>
|
||||
</div>
|
||||
<HeaderGate>
|
||||
{/* Site geneli progressive blur — üst ve alt */}
|
||||
<div className="pointer-events-none fixed inset-x-0 top-0 z-40 h-28 print:hidden">
|
||||
<ProgressiveBlur
|
||||
position="top"
|
||||
backgroundColor="#f8fafc"
|
||||
height="112px"
|
||||
blurAmount="6px"
|
||||
/>
|
||||
</div>
|
||||
<div className="pointer-events-none fixed inset-x-0 bottom-0 z-40 h-28 print:hidden">
|
||||
<ProgressiveBlur
|
||||
position="bottom"
|
||||
backgroundColor="#f8fafc"
|
||||
height="112px"
|
||||
blurAmount="6px"
|
||||
/>
|
||||
</div>
|
||||
<SiteTopBanner />
|
||||
<SiteHeader />
|
||||
</HeaderGate>
|
||||
|
||||
38
src/app/listem/listem-bos-cta.tsx
Normal file
38
src/app/listem/listem-bos-cta.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PagePixelDivider } from "@/components/pixel-decor";
|
||||
import { useTercihProfili } from "@/components/manuel-liste/profil-kapisi-store";
|
||||
import { sonucHref } from "@/lib/sihirbaz";
|
||||
|
||||
/**
|
||||
* /listem boş hâlinin metni + CTA'sı: sıralamasını zaten girmiş adaya
|
||||
* "sıralamanı gir" demek yerine sihirbazı açık hâlde sonuç sayfasına
|
||||
* götürür; profil yoksa ana sayfadaki sıralama formuna yönlendirir.
|
||||
*/
|
||||
export function ListemBosCta() {
|
||||
const profil = useTercihProfili();
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className="mt-3 text-slate-600">
|
||||
{profil
|
||||
? "Sıralaman kayıtlı — birkaç adımda 24 tercihlik risk analizli listeni kur; sonra burada görüntüleyip AI danışmanla üzerinde konuşabilirsin."
|
||||
: "Sıralamanı girip birkaç adımda 24 tercihlik risk analizli listeni kur; sonra burada görüntüleyip AI danışmanla üzerinde konuşabilirsin."}
|
||||
</p>
|
||||
<PagePixelDivider seed={67} className="mx-auto mt-6" />
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="mt-6 h-12 cursor-pointer bg-orange-500 px-8 text-white hover:bg-orange-600"
|
||||
>
|
||||
<Link href={profil ? sonucHref(profil, { sihirbaz: "1" }) : "/"}>
|
||||
{profil ? "AI listeni kur" : "Sıralamanı gir, AI listeni kur"}
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { ListChecks, Sparkles } from "lucide-react";
|
||||
import { Tabs as TabsPrimitive } from "radix-ui";
|
||||
import { SecimlerimPaneli } from "@/components/manuel-liste/secimlerim-paneli";
|
||||
import { useManuelListe } from "@/components/manuel-liste/store";
|
||||
|
||||
export type ListemSekme = "secimlerim" | "ai";
|
||||
|
||||
export function ListemSekmeleri({
|
||||
varsayilan,
|
||||
aiIcerik,
|
||||
aiHazir,
|
||||
}: {
|
||||
varsayilan: ListemSekme;
|
||||
aiIcerik: React.ReactNode;
|
||||
aiHazir?: boolean;
|
||||
}) {
|
||||
const [sekme, setSekme] = useState<ListemSekme>(varsayilan);
|
||||
const liste = useManuelListe();
|
||||
|
||||
function degistir(deger: string) {
|
||||
const yeni = deger as ListemSekme;
|
||||
setSekme(yeni);
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("sekme", yeni);
|
||||
window.history.replaceState(null, "", url);
|
||||
}
|
||||
|
||||
return (
|
||||
<TabsPrimitive.Root value={sekme} onValueChange={degistir} className="mt-6">
|
||||
<TabsPrimitive.List
|
||||
aria-label="Liste görünümü"
|
||||
className="relative mx-auto grid w-full max-w-md grid-cols-2 rounded-full border border-slate-200 bg-slate-100 p-1"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute inset-y-1 left-1 w-[calc(50%-0.25rem)] rounded-full bg-white shadow-sm transition-transform duration-200 [transition-timing-function:cubic-bezier(0.23,1,0.32,1)]"
|
||||
style={{
|
||||
transform: sekme === "ai" ? "translateX(100%)" : "translateX(0)",
|
||||
}}
|
||||
/>
|
||||
<TabsPrimitive.Trigger
|
||||
value="secimlerim"
|
||||
className="relative z-10 flex h-10 cursor-pointer items-center justify-center gap-2 rounded-full text-sm font-semibold text-slate-500 transition-colors duration-200 hover:text-slate-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring data-[state=active]:text-slate-900"
|
||||
>
|
||||
<ListChecks className="size-4" aria-hidden />
|
||||
Kendi Listem
|
||||
{liste.length > 0 ? (
|
||||
<span className="min-w-5 rounded-full bg-primary/10 px-1.5 py-0.5 text-center text-[11px] font-bold tabular-nums text-primary">
|
||||
{liste.length}
|
||||
</span>
|
||||
) : null}
|
||||
</TabsPrimitive.Trigger>
|
||||
<TabsPrimitive.Trigger
|
||||
value="ai"
|
||||
className="relative z-10 flex h-10 cursor-pointer items-center justify-center gap-2 rounded-full text-sm font-semibold text-slate-500 transition-colors duration-200 hover:text-slate-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring data-[state=active]:text-slate-900"
|
||||
>
|
||||
<Sparkles className="size-4" aria-hidden />
|
||||
AI Listem
|
||||
{aiHazir ? (
|
||||
<span
|
||||
className="size-1.5 rounded-full bg-emerald-500"
|
||||
aria-hidden
|
||||
title="AI listen hazır"
|
||||
/>
|
||||
) : null}
|
||||
</TabsPrimitive.Trigger>
|
||||
</TabsPrimitive.List>
|
||||
<TabsPrimitive.Content
|
||||
value="secimlerim"
|
||||
className="mt-6 outline-none data-[state=active]:animate-in data-[state=active]:fade-in-0 data-[state=active]:slide-in-from-bottom-1 data-[state=active]:duration-200"
|
||||
>
|
||||
<SecimlerimPaneli />
|
||||
</TabsPrimitive.Content>
|
||||
<TabsPrimitive.Content
|
||||
value="ai"
|
||||
className="mt-6 outline-none data-[state=active]:animate-in data-[state=active]:fade-in-0 data-[state=active]:slide-in-from-bottom-1 data-[state=active]:duration-200"
|
||||
>
|
||||
{aiIcerik}
|
||||
</TabsPrimitive.Content>
|
||||
</TabsPrimitive.Root>
|
||||
);
|
||||
}
|
||||
@@ -3,11 +3,9 @@ import Link from "next/link";
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
import { redirect } from "next/navigation";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowRight,
|
||||
Bot,
|
||||
Check,
|
||||
Database,
|
||||
FileText,
|
||||
Lock,
|
||||
MessageCircleQuestion,
|
||||
@@ -22,14 +20,14 @@ import { URUNLER, MAX_REVIZYON } from "@/lib/credits";
|
||||
import type { RaporSonuc } from "@/lib/ai/rapor";
|
||||
import type { RaporParams } from "@/lib/rapor-havuzu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { RaporListesi } from "@/components/rapor-listesi";
|
||||
import { ViewportPortal } from "@/components/viewport-portal";
|
||||
import { PagePixelDivider } from "@/components/pixel-decor";
|
||||
import { SiteFooter } from "@/components/site-footer";
|
||||
import { SectionEyebrow } from "@/components/pixel-decor";
|
||||
import { raporMaskele, ACIK_SATIR } from "@/lib/rapor-maske";
|
||||
import { SohbetClient, type Mesaj } from "./sohbet-client";
|
||||
import { RevizyonKutusu } from "./revizyon-kutusu";
|
||||
import { ListemSekmeleri, type ListemSekme } from "./listem-sekmeleri";
|
||||
import { ListemBosCta } from "./listem-bos-cta";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Listem",
|
||||
@@ -43,7 +41,7 @@ const PAKET_FIYATI = (URUNLER.paket.amountKurus / 100).toLocaleString("tr-TR", {
|
||||
export default async function ListemPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ acildi?: string; sekme?: string }>;
|
||||
searchParams: Promise<{ acildi?: string }>;
|
||||
}) {
|
||||
const session = await verifySession("/listem");
|
||||
const [user, params, satir, sohbetGecmisi] = await Promise.all([
|
||||
@@ -65,54 +63,24 @@ export default async function ListemPage({
|
||||
]);
|
||||
if (!user) redirect("/giris?callback=/listem");
|
||||
|
||||
const { acildi, sekme } = params;
|
||||
const { acildi } = params;
|
||||
const kilitAcilisi = Boolean(user.hasPaket && acildi === "1");
|
||||
const raporVar = Boolean(satir?.result);
|
||||
|
||||
const varsayilanSekme: ListemSekme =
|
||||
sekme === "secimlerim"
|
||||
? "secimlerim"
|
||||
: sekme === "ai" || acildi === "1" || raporVar
|
||||
? "ai"
|
||||
: "secimlerim";
|
||||
|
||||
const aiBos = (
|
||||
<section className="mx-auto flex max-w-2xl flex-col items-center px-4 py-16 text-center">
|
||||
<Sparkles className="size-10 text-primary" aria-hidden />
|
||||
<h2 className="mt-4 font-heading text-2xl font-bold">
|
||||
Henüz AI listen yok
|
||||
</h2>
|
||||
<p className="mt-3 text-slate-600">
|
||||
Sıralamanı girip birkaç adımda 24 tercihlik risk analizli listeni kur;
|
||||
sonra burada görüntüleyip AI danışmanla üzerinde konuşabilirsin.
|
||||
Kendi Listem sekmesinde ise tablolardan eklediğin programlar durur.
|
||||
</p>
|
||||
<PagePixelDivider seed={67} className="mx-auto mt-6" />
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="mt-6 h-12 cursor-pointer bg-orange-500 px-8 text-white hover:bg-orange-600"
|
||||
>
|
||||
<Link href="/">Sıralamanı gir, AI listeni kur</Link>
|
||||
</Button>
|
||||
</section>
|
||||
);
|
||||
|
||||
if (!raporVar) {
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-7xl px-4 py-8 sm:py-10">
|
||||
<div className="mx-auto max-w-2xl text-center">
|
||||
<h1 className="font-heading text-2xl font-bold tracking-tight sm:text-3xl">
|
||||
Tercih karar merkezin
|
||||
</h1>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-600">
|
||||
Kendi listen ve AI listen burada yan yana — ikisi birbirinden
|
||||
bağımsız.
|
||||
</p>
|
||||
<PagePixelDivider seed={71} className="mx-auto mt-6" />
|
||||
</div>
|
||||
<ListemSekmeleri varsayilan={varsayilanSekme} aiIcerik={aiBos} />
|
||||
</main>
|
||||
<>
|
||||
<main className="mx-auto w-full max-w-4xl px-4 py-12">
|
||||
<section className="mx-auto flex max-w-2xl flex-col items-center px-4 py-16 text-center">
|
||||
<Sparkles className="size-10 text-primary" aria-hidden />
|
||||
<h1 className="mt-4 font-heading text-2xl font-bold">
|
||||
Henüz AI listen yok
|
||||
</h1>
|
||||
<ListemBosCta />
|
||||
</section>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -120,9 +88,19 @@ export default async function ListemPage({
|
||||
? (satir!.result as RaporSonuc)
|
||||
: raporMaskele(satir!.result as RaporSonuc);
|
||||
const raporParams = satir!.params as RaporParams;
|
||||
// Danışman özeti sohbetin açılış mesajı olarak gösterilir; sohbet
|
||||
// /api/soru üzerinden zaten liste + seçim bağlamıyla çalışıyor.
|
||||
const danismanOzeti = rapor.genelDegerlendirme
|
||||
? {
|
||||
metin: rapor.genelDegerlendirme,
|
||||
uyarilar: user.hasPaket ? rapor.uyarilar : undefined,
|
||||
kilitli: !user.hasPaket,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const aiIcerik = (
|
||||
return (
|
||||
<>
|
||||
<main className="mx-auto w-full max-w-4xl px-4 py-12">
|
||||
{kilitAcilisi ? (
|
||||
<div
|
||||
role="status"
|
||||
@@ -136,30 +114,22 @@ export default async function ListemPage({
|
||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
{user.hasPaket ? (
|
||||
<Badge variant="secondary" className="mb-3">
|
||||
<ShieldCheck aria-hidden />
|
||||
Paketin aktif
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary" className="mb-3">
|
||||
<Database aria-hidden />
|
||||
YÖK Atlas verileri ücretsiz
|
||||
</Badge>
|
||||
)}
|
||||
<h2 className="max-w-2xl font-heading text-xl font-bold tracking-tight sm:text-2xl">
|
||||
<SectionEyebrow className="mb-3">Paketin aktif</SectionEyebrow>
|
||||
) : null}
|
||||
<h1 className="max-w-2xl font-heading text-xl font-bold tracking-tight sm:text-2xl">
|
||||
{user.hasPaket
|
||||
? "AI tercih listen"
|
||||
: "Veriyi ücretsiz incele. Kararı AI ile netleştir."}
|
||||
</h2>
|
||||
<p className="mt-2 max-w-2xl text-sm leading-6 text-slate-600">
|
||||
{raporParams.sira.toLocaleString("tr-TR")}. sıra ·{" "}
|
||||
{PUAN_TURLERI[raporParams.tur as keyof typeof PUAN_TURLERI] ??
|
||||
raporParams.tur}{" "}
|
||||
·{" "}
|
||||
{user.hasPaket
|
||||
? "24 tercihinin tamamını gerekçe, risk analizi ve trendleriyle aşağıda inceleyebilirsin."
|
||||
: "24 programın 2024–2025 taban sıralamalarını ve ham trendini aşağıda ücretsiz inceleyebilirsin."}
|
||||
</p>
|
||||
</h1>
|
||||
{user.hasPaket ? (
|
||||
<p className="mt-2 max-w-2xl text-sm leading-6 text-slate-600">
|
||||
{raporParams.sira.toLocaleString("tr-TR")}. sıra ·{" "}
|
||||
{PUAN_TURLERI[raporParams.tur as keyof typeof PUAN_TURLERI] ??
|
||||
raporParams.tur}{" "}
|
||||
· 24 tercihinin tamamını gerekçe, risk analizi ve trendleriyle
|
||||
aşağıda inceleyebilirsin.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{user.hasPaket ? (
|
||||
<div className="flex gap-2">
|
||||
@@ -189,40 +159,6 @@ export default async function ListemPage({
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{rapor.genelDegerlendirme ? (
|
||||
<section className="mt-6 rounded-2xl border border-slate-200 bg-white p-5">
|
||||
<h3 className="flex items-center gap-2 font-heading text-base font-bold">
|
||||
<Bot className="size-4 text-primary" aria-hidden />
|
||||
Danışman özeti
|
||||
</h3>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-700">
|
||||
{rapor.genelDegerlendirme}
|
||||
</p>
|
||||
{user.hasPaket && rapor.uyarilar?.length ? (
|
||||
<ul className="mt-3 flex flex-col gap-2">
|
||||
{rapor.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}
|
||||
{!user.hasPaket ? (
|
||||
<p className="mt-3 flex items-center gap-1.5 text-xs text-slate-500">
|
||||
<Lock className="size-3.5" aria-hidden />
|
||||
Değerlendirmenin tamamı ve sıralamana özel uyarılar pakette.
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<div className="mt-8 grid items-start gap-8 lg:grid-cols-[minmax(0,1fr)_340px]">
|
||||
<div className="flex min-w-0 flex-col gap-6">
|
||||
<section
|
||||
@@ -277,18 +213,13 @@ export default async function ListemPage({
|
||||
<SohbetClient
|
||||
baslangicKredi={user.creditBalance}
|
||||
initialMesajlar={sohbetGecmisi as Mesaj[]}
|
||||
ozet={danismanOzeti}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-6">
|
||||
<section className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="border-b border-slate-100 p-6">
|
||||
<div className="flex items-center gap-2 text-primary">
|
||||
<Sparkles className="size-4" aria-hidden />
|
||||
<span className="text-xs font-bold uppercase tracking-wide">
|
||||
Tercih Dönemi Paketi
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="mt-3 font-heading text-xl font-bold tracking-tight">
|
||||
<h3 className="font-heading text-xl font-bold tracking-tight">
|
||||
Tablo ücretsiz.
|
||||
<br />
|
||||
Karar desteği pakette.
|
||||
@@ -317,14 +248,11 @@ export default async function ListemPage({
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="mt-6 flex items-end justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-slate-500">Tek seferlik</p>
|
||||
<p className="font-heading text-3xl font-bold">
|
||||
{PAKET_FIYATI} TL
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline">Abonelik yok</Badge>
|
||||
<div className="mt-6">
|
||||
<p className="text-xs text-slate-500">Tek seferlik</p>
|
||||
<p className="font-heading text-3xl font-bold">
|
||||
{PAKET_FIYATI} TL
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
@@ -354,7 +282,8 @@ export default async function ListemPage({
|
||||
<SohbetClient
|
||||
baslangicKredi={user.creditBalance}
|
||||
initialMesajlar={sohbetGecmisi as Mesaj[]}
|
||||
className="h-[480px]"
|
||||
ozet={danismanOzeti}
|
||||
className="h-[520px]"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -370,26 +299,8 @@ export default async function ListemPage({
|
||||
Danışmana sor
|
||||
</a>
|
||||
</ViewportPortal>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-7xl px-4 py-8 sm:py-10">
|
||||
<div className="mx-auto max-w-2xl text-center">
|
||||
<h1 className="font-heading text-2xl font-bold tracking-tight sm:text-3xl">
|
||||
Tercih karar merkezin
|
||||
</h1>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-600">
|
||||
Kendi listen ve AI listen burada yan yana — ikisi birbirinden
|
||||
bağımsız.
|
||||
</p>
|
||||
<PagePixelDivider seed={71} className="mx-auto mt-6" />
|
||||
</div>
|
||||
<ListemSekmeleri
|
||||
varsayilan={varsayilanSekme}
|
||||
aiIcerik={aiIcerik}
|
||||
aiHazir
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { ArrowUp, Coins, Sparkles } from "lucide-react";
|
||||
import { AlertTriangle, ArrowUp, Bot, Coins, Lock, Sparkles } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -29,6 +29,12 @@ export interface Mesaj {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface DanismanOzeti {
|
||||
metin: string;
|
||||
uyarilar?: string[];
|
||||
kilitli?: boolean;
|
||||
}
|
||||
|
||||
const ORNEK_SORULAR = [
|
||||
"Listemdeki kırmızı (riskli) tercihler ne kadar riskli?",
|
||||
"İlk 5 tercihimin taban sıralaması trendi nasıl?",
|
||||
@@ -44,12 +50,15 @@ export function SohbetClient({
|
||||
baslangicKredi,
|
||||
initialMesajlar,
|
||||
className,
|
||||
ozet,
|
||||
}: {
|
||||
baslangicKredi: number;
|
||||
/** Sunucu tarafında çekilmiş geçmiş; verilirse mount fetch'i atlanır */
|
||||
initialMesajlar?: Mesaj[];
|
||||
/** Boyutlandırma override'ı (ör. alt popup'ta h-full) */
|
||||
className?: string;
|
||||
/** Raporun genel değerlendirmesi; sohbetin açılış mesajı olarak gösterilir */
|
||||
ozet?: DanismanOzeti;
|
||||
}) {
|
||||
const [mesajlar, setMesajlar] = useState<Mesaj[]>(initialMesajlar ?? []);
|
||||
const [girdi, setGirdi] = useState("");
|
||||
@@ -154,20 +163,73 @@ export function SohbetClient({
|
||||
<div className="relative flex-1 overflow-hidden">
|
||||
<ChatContainerRoot className="h-full">
|
||||
<ChatContainerContent className="gap-4 p-4">
|
||||
{mesajlar.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-4 py-10 text-center">
|
||||
<Sparkles className="size-7 text-primary" aria-hidden />
|
||||
<div>
|
||||
<p className="font-heading font-bold">
|
||||
Listen hakkında her şeyi sorabilirsin
|
||||
</p>
|
||||
<p className="mx-auto mt-1 max-w-md text-sm text-slate-600">
|
||||
Cevaplar senin 24'lük listen, seçimlerin ve gerçek
|
||||
YÖK Atlas verisi bağlamında üretilir. Her mesaj 1 kredi.
|
||||
{ozet ? (
|
||||
<Message className="justify-start">
|
||||
<div className="flex max-w-[85%] flex-col gap-2">
|
||||
<p className="flex items-center gap-1.5 text-xs font-semibold text-slate-500">
|
||||
<Bot className="size-3.5 text-primary" aria-hidden />
|
||||
Danışman özeti
|
||||
</p>
|
||||
<MessageContent className="bg-slate-100 text-slate-800">
|
||||
{ozet.metin}
|
||||
</MessageContent>
|
||||
{ozet.uyarilar?.length ? (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{ozet.uyarilar.map((uyari) => (
|
||||
<li
|
||||
key={uyari}
|
||||
className="flex items-start gap-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900"
|
||||
>
|
||||
<AlertTriangle
|
||||
className="mt-0.5 size-4 shrink-0"
|
||||
aria-hidden
|
||||
/>
|
||||
{uyari}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
{ozet.kilitli ? (
|
||||
<p className="flex items-center gap-1.5 text-xs text-slate-500">
|
||||
<Lock className="size-3.5" aria-hidden />
|
||||
Değerlendirmenin tamamı ve sıralamana özel uyarılar
|
||||
pakette.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</Message>
|
||||
) : null}
|
||||
{mesajlar.length === 0 ? (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-4",
|
||||
!ozet && "items-center py-10 text-center",
|
||||
)}
|
||||
>
|
||||
{!ozet ? (
|
||||
<>
|
||||
<Sparkles
|
||||
className="mx-auto size-7 text-primary"
|
||||
aria-hidden
|
||||
/>
|
||||
<div>
|
||||
<p className="font-heading font-bold">
|
||||
Listen hakkında her şeyi sorabilirsin
|
||||
</p>
|
||||
<p className="mx-auto mt-1 max-w-md text-sm text-slate-600">
|
||||
Cevaplar senin 24'lük listen, seçimlerin ve gerçek
|
||||
YÖK Atlas verisi bağlamında üretilir. Her mesaj 1 kredi.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
{yuklendi ? (
|
||||
<div className="flex max-w-lg flex-wrap justify-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
"flex max-w-lg flex-wrap gap-2",
|
||||
!ozet && "justify-center",
|
||||
)}
|
||||
>
|
||||
{ORNEK_SORULAR.map((s) => (
|
||||
<PromptSuggestion
|
||||
key={s}
|
||||
@@ -181,7 +243,12 @@ export function SohbetClient({
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex max-w-lg flex-wrap justify-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
"flex max-w-lg flex-wrap gap-2",
|
||||
!ozet && "justify-center",
|
||||
)}
|
||||
>
|
||||
{[112, 148, 96, 132].map((w, i) => (
|
||||
<Skeleton
|
||||
key={i}
|
||||
|
||||
@@ -1,28 +1,25 @@
|
||||
"use client";
|
||||
|
||||
// /meraklisina'nın canlı anlatım katmanı: kullanıcı kendi sıralamasını girer,
|
||||
// dilim şeridi ve kademeli filtre hunisi o sıralamanın GERÇEK sayılarıyla
|
||||
// güncellenir. Provider sayfadaki metin bölümlerini (RSC children) sarar;
|
||||
// görseller context'ten okur, böylece tek girişle iki diyagram birden yaşar.
|
||||
// /meraklisina'nın canlı anlatım katmanı: kullanıcının kayıtlı tercih
|
||||
// profilindeki sıralama + puan türü okunur, dilim şeridi o sıralamanın GERÇEK
|
||||
// sayılarıyla güncellenir. Profil yoksa sayfa SSR'daki örnek sıralamayla kalır.
|
||||
// Provider sayfadaki metin bölümlerini (RSC children) sarar; görseller
|
||||
// context'ten okur. (Sihirbaz hunisi statik bir şemayla anlatılır — bkz.
|
||||
// huni-semasi.tsx.)
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import Link from "next/link";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { useTercihProfili } from "@/components/manuel-liste/profil-kapisi-store";
|
||||
import { PUAN_TURU_ETIKET, sonucHref } from "@/lib/sihirbaz";
|
||||
import type { DilimKey, SihirbazFacetleri } from "@/lib/db";
|
||||
|
||||
const TUR_SECENEKLERI = [
|
||||
{ deger: "say", etiket: "Sayısal" },
|
||||
{ deger: "ea", etiket: "Eşit Ağırlık" },
|
||||
{ deger: "soz", etiket: "Sözel" },
|
||||
{ deger: "dil", etiket: "Dil" },
|
||||
{ deger: "tyt", etiket: "TYT" },
|
||||
] as const;
|
||||
|
||||
export type DemoVeri = {
|
||||
facetler: SihirbazFacetleri;
|
||||
dilimToplam: Record<DilimKey, number>;
|
||||
@@ -33,18 +30,7 @@ type DemoDurum = {
|
||||
tur: string;
|
||||
veri: DemoVeri;
|
||||
yukleniyor: boolean;
|
||||
// Huni seçimleri ve kademeli facet cevapları
|
||||
kategori: string | null;
|
||||
il: string | null;
|
||||
tip: "devlet" | "vakif" | null;
|
||||
kategoriFacet: SihirbazFacetleri | null; // ?kategori= cevabı (iller buradan)
|
||||
ilFacet: SihirbazFacetleri | null; // ?kategori&il cevabı (tipler buradan)
|
||||
kategoriSec: (k: string | null) => void;
|
||||
ilSec: (i: string | null) => void;
|
||||
tipSec: (t: "devlet" | "vakif" | null) => void;
|
||||
siraDegistir: (ham: string) => void;
|
||||
turDegistir: (t: string) => void;
|
||||
siraMetni: string;
|
||||
profilVar: boolean;
|
||||
};
|
||||
|
||||
const DemoContext = createContext<DemoDurum | null>(null);
|
||||
@@ -58,13 +44,9 @@ function useDemo(): DemoDurum {
|
||||
async function facetGetir(
|
||||
sira: number,
|
||||
tur: string,
|
||||
secim: { kategori?: string; il?: string; dilimler?: boolean },
|
||||
signal: AbortSignal,
|
||||
): Promise<{ facetler: SihirbazFacetleri; dilimToplam?: Record<DilimKey, number> } | null> {
|
||||
const params = new URLSearchParams({ sira: String(sira), tur });
|
||||
if (secim.kategori) params.append("kategori", secim.kategori);
|
||||
if (secim.il) params.append("il", secim.il);
|
||||
if (secim.dilimler) params.set("dilimler", "1");
|
||||
const params = new URLSearchParams({ sira: String(sira), tur, dilimler: "1" });
|
||||
try {
|
||||
const res = await fetch(`/api/facetler?${params}`, { signal });
|
||||
if (!res.ok) return null;
|
||||
@@ -86,36 +68,19 @@ export function SiralamaDemo({
|
||||
baslangicVeri: DemoVeri;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [siraMetni, setSiraMetni] = useState(
|
||||
baslangicSira.toLocaleString("tr-TR"),
|
||||
);
|
||||
const [sira, setSira] = useState(baslangicSira);
|
||||
const [tur, setTur] = useState("say");
|
||||
const profil = useTercihProfili();
|
||||
const sira = profil?.sira ?? baslangicSira;
|
||||
const tur = profil?.tur ?? "say";
|
||||
const [veri, setVeri] = useState(baslangicVeri);
|
||||
const [yukleniyor, setYukleniyor] = useState(false);
|
||||
const [kategori, setKategori] = useState<string | null>(null);
|
||||
const [il, setIl] = useState<string | null>(null);
|
||||
const [tip, setTip] = useState<"devlet" | "vakif" | null>(null);
|
||||
const [kategoriFacet, setKategoriFacet] = useState<SihirbazFacetleri | null>(
|
||||
null,
|
||||
);
|
||||
const [ilFacet, setIlFacet] = useState<SihirbazFacetleri | null>(null);
|
||||
const zamanlayici = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Sıralama/tür değişti → ana veriyi tazele, huni seçimlerini sıfırla
|
||||
// Profil yüklendi ve SSR örneğinden farklı → ana veriyi tazele
|
||||
useEffect(() => {
|
||||
if (sira === baslangicSira && tur === "say" && veri === baslangicVeri) {
|
||||
return; // ilk render: sunucudan gelen veri zaten güncel
|
||||
}
|
||||
if (sira === baslangicSira && tur === "say") return;
|
||||
const ctrl = new AbortController();
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- fetch başlangıç işareti
|
||||
setYukleniyor(true);
|
||||
setKategori(null);
|
||||
setIl(null);
|
||||
setTip(null);
|
||||
setKategoriFacet(null);
|
||||
setIlFacet(null);
|
||||
facetGetir(sira, tur, { dilimler: true }, ctrl.signal)
|
||||
facetGetir(sira, tur, ctrl.signal)
|
||||
.then((cevap) => {
|
||||
if (!cevap?.dilimToplam) return;
|
||||
setVeri({ facetler: cevap.facetler, dilimToplam: cevap.dilimToplam });
|
||||
@@ -125,126 +90,51 @@ export function SiralamaDemo({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sira, tur]);
|
||||
|
||||
// Kategori seçildi → o kategorinin illerini getir
|
||||
useEffect(() => {
|
||||
if (!kategori) return;
|
||||
const ctrl = new AbortController();
|
||||
facetGetir(sira, tur, { kategori }, ctrl.signal).then((cevap) => {
|
||||
if (cevap) setKategoriFacet(cevap.facetler);
|
||||
});
|
||||
return () => ctrl.abort();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [kategori]);
|
||||
|
||||
// İl seçildi → kategori+il ile tipleri getir
|
||||
useEffect(() => {
|
||||
if (!kategori || !il) return;
|
||||
const ctrl = new AbortController();
|
||||
facetGetir(sira, tur, { kategori, il }, ctrl.signal).then((cevap) => {
|
||||
if (cevap) setIlFacet(cevap.facetler);
|
||||
});
|
||||
return () => ctrl.abort();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [kategori, il]);
|
||||
|
||||
function siraDegistir(ham: string) {
|
||||
const rakamlar = ham.replace(/\D/g, "").slice(0, 7);
|
||||
setSiraMetni(rakamlar ? Number(rakamlar).toLocaleString("tr-TR") : "");
|
||||
if (zamanlayici.current) clearTimeout(zamanlayici.current);
|
||||
const deger = Number(rakamlar);
|
||||
if (!deger || deger < 1 || deger > 4_000_000) return;
|
||||
zamanlayici.current = setTimeout(() => setSira(deger), 450);
|
||||
}
|
||||
|
||||
const durum: DemoDurum = {
|
||||
sira,
|
||||
tur,
|
||||
veri,
|
||||
yukleniyor,
|
||||
kategori,
|
||||
il,
|
||||
tip,
|
||||
kategoriFacet,
|
||||
ilFacet,
|
||||
kategoriSec: (k) => {
|
||||
setKategori(k);
|
||||
setIl(null);
|
||||
setTip(null);
|
||||
setKategoriFacet(null);
|
||||
setIlFacet(null);
|
||||
},
|
||||
ilSec: (i) => {
|
||||
setIl(i);
|
||||
setTip(null);
|
||||
setIlFacet(null);
|
||||
},
|
||||
tipSec: setTip,
|
||||
siraDegistir,
|
||||
turDegistir: setTur,
|
||||
siraMetni,
|
||||
profilVar: profil !== null,
|
||||
};
|
||||
|
||||
return <DemoContext.Provider value={durum}>{children}</DemoContext.Provider>;
|
||||
}
|
||||
|
||||
/** Sıralama girişi + puan türü — iki diyagramı birden süren tek kumanda. */
|
||||
/** Görsellerin hangi sıralamayla hesaplandığını söyleyen bilgi kartı. */
|
||||
export function DemoKumanda() {
|
||||
const { siraMetni, siraDegistir, tur, turDegistir, sira, yukleniyor } =
|
||||
useDemo();
|
||||
const { sira, tur, yukleniyor, profilVar } = useDemo();
|
||||
const turEtiket = PUAN_TURU_ETIKET[tur] ?? tur;
|
||||
return (
|
||||
<div className="rounded-2xl border border-primary/20 bg-primary/5 p-5">
|
||||
<label
|
||||
htmlFor="demo-sira"
|
||||
className="font-heading text-sm font-bold text-slate-900"
|
||||
>
|
||||
Kendi sıralamanı gir, aşağıdaki görseller sana göre değişsin
|
||||
</label>
|
||||
<div className="mt-3 flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<input
|
||||
id="demo-sira"
|
||||
inputMode="numeric"
|
||||
autoComplete="off"
|
||||
value={siraMetni}
|
||||
onChange={(e) => siraDegistir(e.target.value)}
|
||||
placeholder="ör. 85.000"
|
||||
className="h-11 w-full rounded-xl border border-slate-200 bg-white px-4 text-base font-medium tabular-nums outline-none transition-colors focus:border-primary sm:w-44"
|
||||
/>
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="Puan türü"
|
||||
className="flex flex-wrap gap-1.5"
|
||||
>
|
||||
{TUR_SECENEKLERI.map((t) => (
|
||||
<button
|
||||
key={t.deger}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={tur === t.deger}
|
||||
onClick={() => turDegistir(t.deger)}
|
||||
className={
|
||||
tur === t.deger
|
||||
? "cursor-pointer rounded-full border border-primary bg-primary px-3 py-1.5 text-xs font-medium text-white"
|
||||
: "cursor-pointer rounded-full border border-slate-200 bg-white px-3 py-1.5 text-xs text-slate-600 transition-colors duration-200 hover:border-slate-300"
|
||||
}
|
||||
>
|
||||
{t.etiket}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p className="font-heading text-sm font-bold text-slate-900">
|
||||
{profilVar
|
||||
? "Görseller senin sıralamana göre hesaplandı"
|
||||
: "Görseller örnek bir sıralamayla gösteriliyor"}
|
||||
</p>
|
||||
<p
|
||||
className={`mt-2 text-xs text-slate-500 ${yukleniyor ? "animate-pulse" : ""}`}
|
||||
className={`mt-2 text-sm text-slate-600 ${yukleniyor ? "animate-pulse" : ""}`}
|
||||
aria-live="polite"
|
||||
>
|
||||
{yukleniyor
|
||||
? "Hesaplanıyor…"
|
||||
: `Aşağıdaki tüm sayılar ${sira.toLocaleString("tr-TR")}. sıradaki bir aday için gerçek YÖK Atlas verisidir.`}
|
||||
{yukleniyor ? (
|
||||
"Hesaplanıyor…"
|
||||
) : (
|
||||
<>
|
||||
<span className="font-semibold tabular-nums text-slate-900">
|
||||
{sira.toLocaleString("tr-TR")}.
|
||||
</span>{" "}
|
||||
sıra · {turEtiket} — aşağıdaki tüm sayılar bu aday için gerçek YÖK
|
||||
Atlas verisidir.
|
||||
{!profilVar &&
|
||||
" Ana sayfadan kendi sıralamanı girersen bu sayfa sana göre güncellenir."}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Sıralama ekseni: girilen sıraya göre dilim sınırları ve gerçek adetler. */
|
||||
/** Sıralama ekseni: adayın sırasına göre dilim sınırları ve gerçek adetler. */
|
||||
export function DilimSeridiCanli() {
|
||||
const { sira, veri, yukleniyor } = useDemo();
|
||||
const t = veri.dilimToplam;
|
||||
@@ -334,307 +224,28 @@ export function DilimSeridiCanli() {
|
||||
);
|
||||
}
|
||||
|
||||
function HuniCubugu({
|
||||
etiket,
|
||||
adet,
|
||||
oran,
|
||||
}: {
|
||||
etiket: string;
|
||||
adet: number;
|
||||
oran: number;
|
||||
}) {
|
||||
return (
|
||||
<div className="min-w-0 flex-1">
|
||||
<div
|
||||
className="flex h-9 min-w-36 items-center justify-between gap-2 rounded-lg bg-primary/85 px-3 transition-[width] duration-300"
|
||||
style={{ width: `${Math.min(Math.max(oran * 100, 36), 100)}%` }}
|
||||
>
|
||||
<span className="truncate text-xs font-medium text-white">{etiket}</span>
|
||||
<span className="shrink-0 text-xs font-bold tabular-nums text-white">
|
||||
{adet.toLocaleString("tr-TR")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
/**
|
||||
* Sayfanın kapanış kartı: profili olmayan ziyaretçiyi ana sayfadaki sıralama
|
||||
* formuna, sıralamasını zaten girmiş adayı ise doğrudan sonuç sayfasına
|
||||
* gönderir — "sıralamanı gir" CTA'sı profil varken gösterilmez.
|
||||
*/
|
||||
export function DemoKapanisCta() {
|
||||
const profil = useTercihProfili();
|
||||
|
||||
function SecimCipleri<T extends { anahtar: string; etiket: string; adet: number }>({
|
||||
secenekler,
|
||||
secili,
|
||||
onSec,
|
||||
}: {
|
||||
secenekler: T[];
|
||||
secili: string | null;
|
||||
onSec: (anahtar: string | null) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{secenekler.map((s) => (
|
||||
<button
|
||||
key={s.anahtar}
|
||||
type="button"
|
||||
aria-pressed={secili === s.anahtar}
|
||||
onClick={() => onSec(secili === s.anahtar ? null : s.anahtar)}
|
||||
className={
|
||||
secili === s.anahtar
|
||||
? "cursor-pointer rounded-full border border-orange-500 bg-orange-500 px-3 py-1.5 text-xs font-medium text-white"
|
||||
: "cursor-pointer rounded-full border border-slate-200 bg-white px-3 py-1.5 text-xs text-slate-600 transition-colors duration-200 hover:border-slate-300"
|
||||
}
|
||||
>
|
||||
{s.etiket}
|
||||
<span className="ml-1 text-[10px] opacity-70 tabular-nums">
|
||||
{s.adet.toLocaleString("tr-TR")}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// AI listesinin dilim iskeleti: üstte hayal, ortada dengeli, sonda garanti.
|
||||
// Ürünün risk renkleriyle boyanır; sayılar prompt'taki kuralın orta değeri.
|
||||
const LISTE_ISKELETI: { dilim: string; adet: number; renk: string }[] = [
|
||||
{ dilim: "hayal", adet: 5, renk: "bg-red-400" },
|
||||
{ dilim: "dengeli", adet: 13, renk: "bg-amber-400" },
|
||||
{ dilim: "garanti", adet: 6, renk: "bg-emerald-500" },
|
||||
];
|
||||
|
||||
/** Huninin finali: kalan havuzun yapay zekâya gidişi ve 24'lük listenin
|
||||
iskeleti. Havuz 24'ün altına düşerse gevşetme kuralını canlı gösterir. */
|
||||
function YapayZekaAsamasi({ havuz }: { havuz: number }) {
|
||||
const dar = havuz < 24;
|
||||
return (
|
||||
<div className="border-t border-slate-100 pt-4">
|
||||
<p className="text-xs font-semibold text-slate-500">
|
||||
Sonra sıra yapay zekâda
|
||||
<div className="rounded-2xl border border-slate-200 bg-white p-6 text-center">
|
||||
<p className="font-heading text-base font-bold text-slate-900">
|
||||
{profil
|
||||
? "Kuralları öğrendin — sayılar zaten senin sıralamanla hesaplandı."
|
||||
: "Kuralları öğrendin — şimdi kendi sıralamanla dene."}
|
||||
</p>
|
||||
<p className="mt-1.5 text-xs leading-5 text-slate-600">
|
||||
Seçimlerinden geriye kalan{" "}
|
||||
<span className="font-bold tabular-nums text-slate-900">
|
||||
{havuz.toLocaleString("tr-TR")} programlık havuz
|
||||
</span>{" "}
|
||||
yapay zekâya gider; o da bu havuzdan tam 24 tercih seçer ve şu iskelete
|
||||
oturtur:
|
||||
</p>
|
||||
|
||||
{/* 24 hücrelik liste şeridi — renk + metin birlikte (renk tek başına
|
||||
anlam taşımaz) */}
|
||||
<div
|
||||
role="img"
|
||||
aria-label="24 tercihlik listenin yapısı: en üstte yaklaşık 5 hayal, ortada 13 dengeli, sonda 6 garanti tercih."
|
||||
className="mt-3"
|
||||
<Link
|
||||
href={profil ? sonucHref(profil) : "/"}
|
||||
className="mt-3 inline-flex items-center gap-1.5 rounded-full bg-orange-500 px-5 py-2.5 text-sm font-medium text-white transition-colors duration-200 hover:bg-orange-600"
|
||||
>
|
||||
<div className="flex gap-1">
|
||||
{LISTE_ISKELETI.flatMap((b) =>
|
||||
Array.from({ length: b.adet }, (_, i) => (
|
||||
<span
|
||||
key={`${b.dilim}-${i}`}
|
||||
className={`h-6 min-w-0 flex-1 rounded ${b.renk}`}
|
||||
/>
|
||||
)),
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1.5 flex justify-between text-[11px] text-slate-500">
|
||||
<span>
|
||||
üstte 4–6 <span className="font-semibold text-red-600">hayal</span>
|
||||
</span>
|
||||
<span>
|
||||
ortada 12–14{" "}
|
||||
<span className="font-semibold text-amber-600">dengeli</span>
|
||||
</span>
|
||||
<span>
|
||||
sonda 5–7{" "}
|
||||
<span className="font-semibold text-emerald-600">garanti</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="mt-3 space-y-1 text-xs leading-5 text-slate-600">
|
||||
<li>
|
||||
· Havuzda olmayan bir bölümü yazamaz — listedeki her satırın kimliği
|
||||
havuzla karşılaştırılır, uymayan liste yeniden ürettirilir.
|
||||
</li>
|
||||
<li>
|
||||
· Her satıra tek cümlelik gerekçe ve bir risk notu ekler; sıraya neyi
|
||||
neden koyduğu görünür.
|
||||
</li>
|
||||
<li>
|
||||
· Önceliklerin (iş garantisi, maaş, prestij…) hangi programın öne
|
||||
geçeceğini belirler.
|
||||
</li>
|
||||
<li>
|
||||
· Havuz 24'ün altına düşerse filtreler adım adım gevşetilir —
|
||||
önce il, sonra alan — ve bu, listende açıkça belirtilir.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{dar ? (
|
||||
<p className="mt-3 rounded-lg bg-amber-50 px-3 py-2 text-xs leading-5 text-amber-700">
|
||||
Bu seçimle havuz 24'ün altına düştü. Gerçek sihirbaz bu durumda
|
||||
filtreleri adım adım gevşetir — önce il, sonra alan tercihi esnetilir
|
||||
— ve bunu listende açıkça belirtir; liste her zaman 24'e
|
||||
tamamlanır.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Kademeli filtre hunisi: gerçek sihirbaz mantığının oynanabilir kopyası. */
|
||||
export function HuniCanli() {
|
||||
const {
|
||||
veri,
|
||||
yukleniyor,
|
||||
kategori,
|
||||
il,
|
||||
tip,
|
||||
kategoriFacet,
|
||||
ilFacet,
|
||||
kategoriSec,
|
||||
ilSec,
|
||||
tipSec,
|
||||
} = useDemo();
|
||||
|
||||
const toplam = veri.facetler.toplam;
|
||||
const kategoriAdet =
|
||||
kategori != null
|
||||
? (veri.facetler.kategoriler.find((k) => k.ad === kategori)?.adet ?? 0)
|
||||
: null;
|
||||
const ilAdet =
|
||||
il != null && kategoriFacet
|
||||
? (kategoriFacet.iller.find((i) => i.il === il)?.adet ?? 0)
|
||||
: null;
|
||||
const tipAdet =
|
||||
tip != null && ilFacet
|
||||
? (ilFacet.uniturler.find((u) => u.grup === tip)?.adet ?? 0)
|
||||
: null;
|
||||
|
||||
const kategoriSecenekleri = veri.facetler.kategoriler.slice(0, 6).map((k) => ({
|
||||
anahtar: k.ad,
|
||||
etiket: k.ad,
|
||||
adet: k.adet,
|
||||
}));
|
||||
const ilSecenekleri = (kategoriFacet?.iller ?? []).slice(0, 6).map((i) => ({
|
||||
anahtar: i.il,
|
||||
etiket: i.il.toLocaleLowerCase("tr-TR"),
|
||||
adet: i.adet,
|
||||
}));
|
||||
const tipSecenekleri = (ilFacet?.uniturler ?? []).map((u) => ({
|
||||
anahtar: u.grup,
|
||||
etiket: u.grup === "devlet" ? "Devlet" : "Vakıf",
|
||||
adet: u.adet,
|
||||
}));
|
||||
|
||||
// Yapay zekâya giden havuz: huninin en derin (en son seçilmiş) katmanı
|
||||
const havuz = tipAdet ?? ilAdet ?? kategoriAdet ?? toplam;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`mt-6 space-y-4 rounded-2xl border border-slate-200 bg-white p-5 ${
|
||||
yukleniyor ? "animate-pulse opacity-60" : ""
|
||||
}`}
|
||||
>
|
||||
{/* Huni çubukları — her seçim bir çubuk daha ekler */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-6 shrink-0" />
|
||||
<HuniCubugu
|
||||
etiket="Sıralamanla açılan tüm programlar"
|
||||
adet={toplam}
|
||||
oran={1}
|
||||
/>
|
||||
</div>
|
||||
{kategori != null && kategoriAdet != null ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-6 shrink-0 text-right text-xs font-bold text-slate-400">
|
||||
1.
|
||||
</span>
|
||||
<HuniCubugu
|
||||
etiket={`“${kategori}” seçince`}
|
||||
adet={kategoriAdet}
|
||||
oran={toplam > 0 ? kategoriAdet / toplam : 0}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{il != null && ilAdet != null ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-6 shrink-0 text-right text-xs font-bold text-slate-400">
|
||||
2.
|
||||
</span>
|
||||
<HuniCubugu
|
||||
etiket={`+ “${il.toLocaleLowerCase("tr-TR")}” seçince`}
|
||||
adet={ilAdet}
|
||||
oran={toplam > 0 ? ilAdet / toplam : 0}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{tip != null && tipAdet != null ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-6 shrink-0 text-right text-xs font-bold text-slate-400">
|
||||
3.
|
||||
</span>
|
||||
<HuniCubugu
|
||||
etiket={`+ “${tip === "devlet" ? "Devlet" : "Vakıf"}” seçince`}
|
||||
adet={tipAdet}
|
||||
oran={toplam > 0 ? tipAdet / toplam : 0}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Seçim adımları — sihirbazın küçük, oynanabilir hali */}
|
||||
<div className="space-y-3 border-t border-slate-100 pt-4">
|
||||
<div>
|
||||
<p className="mb-1.5 text-xs font-semibold text-slate-500">
|
||||
1. Bir alan seç
|
||||
</p>
|
||||
<SecimCipleri
|
||||
secenekler={kategoriSecenekleri}
|
||||
secili={kategori}
|
||||
onSec={kategoriSec}
|
||||
/>
|
||||
</div>
|
||||
{kategori != null ? (
|
||||
<div>
|
||||
<p className="mb-1.5 text-xs font-semibold text-slate-500">
|
||||
2. Bir il seç{" "}
|
||||
<span className="font-normal text-slate-400">
|
||||
(iller artık yalnızca “{kategori}” programlarını sayıyor)
|
||||
</span>
|
||||
</p>
|
||||
{ilSecenekleri.length > 0 ? (
|
||||
<SecimCipleri
|
||||
secenekler={ilSecenekleri}
|
||||
secili={il}
|
||||
onSec={ilSec}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-xs text-slate-400">Yükleniyor…</p>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{kategori != null && il != null ? (
|
||||
<div>
|
||||
<p className="mb-1.5 text-xs font-semibold text-slate-500">
|
||||
3. Üniversite tipi seç{" "}
|
||||
<span className="font-normal text-slate-400">
|
||||
(sayılar hem alanı hem ili hesaba katıyor)
|
||||
</span>
|
||||
</p>
|
||||
{tipSecenekleri.length > 0 ? (
|
||||
<SecimCipleri
|
||||
secenekler={tipSecenekleri}
|
||||
secili={tip}
|
||||
onSec={(t) => tipSec(t as "devlet" | "vakif" | null)}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-xs text-slate-400">Yükleniyor…</p>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<YapayZekaAsamasi havuz={havuz} />
|
||||
{profil ? "Sıralamana uygun programları gör" : "Sıralamanı gir"}
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
54
src/app/meraklisina/huni-semasi.tsx
Normal file
54
src/app/meraklisina/huni-semasi.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
// Sihirbaz hunisinin statik anlatımı: interaktif demo yerine akışın tamamını
|
||||
// tek bakışta gösteren bir mermaid şeması. Çizim, ürün genelindeki ortak
|
||||
// "el çizimi" bileşeniyle yapılır (bkz. components/el-cizimi-sema.tsx).
|
||||
|
||||
import { ElCizimiSema } from "@/components/el-cizimi-sema";
|
||||
|
||||
// Renkler ürünün risk dilinden (kırmızı/sarı/yeşil) ve slate paletinden;
|
||||
// renk tek başına anlam taşımaz, her düğüm metinle etiketlidir.
|
||||
const SEMA = `---
|
||||
config:
|
||||
look: handDrawn
|
||||
---
|
||||
flowchart TD
|
||||
T("Sıralamanla açılan tüm programlar") --> A("1. adım · Alan seçimi<br/>yalnızca ulaşabildiğin alanlar listelenir")
|
||||
A --> B("2. adım · İl seçimi<br/>iller, seçtiğin alanlardaki program sayısına göre yeniden sayılır")
|
||||
B --> C("3. adım · Devlet / Vakıf<br/>sayılar hem alanı hem ili hesaba katar")
|
||||
C --> H("Aday havuzu<br/>elde kalan gerçek programlar")
|
||||
H --> S{"Havuzda 24'ten az program mı kaldı?"}
|
||||
S -- "evet" --> G("Filtreler adım adım gevşetilir<br/>önce il, sonra alan — listede açıkça belirtilir")
|
||||
G --> H
|
||||
S -- "hayır" --> Y("Yapay zekâ havuzdan tam 24 tercih seçer<br/>havuzda olmayan bölümü yazamaz, her satıra gerekçe + risk notu ekler")
|
||||
Y --> L("24 tercihlik liste")
|
||||
L --> L1("üstte 4–6 hayal")
|
||||
L --> L2("ortada 12–14 dengeli")
|
||||
L --> L3("sonda 5–7 garanti")
|
||||
|
||||
classDef havuz fill:#eff6ff,stroke:#3b82f6,color:#1e3a8a
|
||||
classDef uyari fill:#fffbeb,stroke:#f59e0b,color:#92400e
|
||||
classDef hayal fill:#fee2e2,stroke:#ef4444,color:#991b1b
|
||||
classDef dengeli fill:#fef3c7,stroke:#f59e0b,color:#92400e
|
||||
classDef garanti fill:#d1fae5,stroke:#10b981,color:#065f46
|
||||
class H,Y havuz
|
||||
class G uyari
|
||||
class L1 hayal
|
||||
class L2 dengeli
|
||||
class L3 garanti`;
|
||||
|
||||
export function HuniSemasi() {
|
||||
return (
|
||||
<ElCizimiSema
|
||||
tanim={SEMA}
|
||||
ariaLabel="Sihirbaz akış şeması: sıralamanla açılan programlar alan, il ve devlet/vakıf adımlarıyla aday havuzuna daraltılır; havuz 24'ün altına düşerse filtreler gevşetilir, sonra yapay zekâ havuzdan üstte hayal, ortada dengeli, sonda garanti olacak şekilde 24 tercih seçer."
|
||||
figcaption={
|
||||
<>
|
||||
Her adım havuzu biraz daha daraltır; yapay zekâ listeyi bu havuzun{" "}
|
||||
<span className="font-medium text-slate-700">
|
||||
dışına asla çıkmadan
|
||||
</span>{" "}
|
||||
kurar.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,35 +1,37 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { Parallax } from "@/components/parallax";
|
||||
import { PagePixelDivider } from "@/components/pixel-decor";
|
||||
import { PagePixelDivider, SectionEyebrow } from "@/components/pixel-decor";
|
||||
import { SiteFooter } from "@/components/site-footer";
|
||||
import { dilimAra, rankWindowFacets, type DilimKey } from "@/lib/db";
|
||||
import {
|
||||
DemoKapanisCta,
|
||||
DemoKumanda,
|
||||
DilimSeridiCanli,
|
||||
HuniCanli,
|
||||
SiralamaDemo,
|
||||
type DemoVeri,
|
||||
} from "./demo";
|
||||
import { HuniSemasi } from "./huni-semasi";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Meraklısına: KolayTercih nasıl çalışır?",
|
||||
alternates: { canonical: "/meraklisina" },
|
||||
description:
|
||||
"Sıralamanı girdiğinde arka planda ne oluyor? Dilimler, risk renkleri ve sihirbazın adım adım daralan filtreleri — öğrenci diliyle, kendi sıralamanla deneyebileceğin canlı görsellerle.",
|
||||
"Sıralamanı girdiğinde arka planda ne oluyor? Dilimler, risk renkleri ve sihirbazın adım adım daralan filtreleri — öğrenci diliyle, sıralamana göre hesaplanan canlı görsellerle.",
|
||||
};
|
||||
|
||||
/**
|
||||
* Algoritmanın öğrenci diline çevrilmiş anlatımı. Teknik detay (SQL, çarpan
|
||||
* mutfağı) bilerek verilmez; amaç güven: veri gerçek, kurallar şeffaf.
|
||||
* Görseller canlıdır: kullanıcı sıralamasını girer, dilim şeridi ve filtre
|
||||
* hunisi o sıralamanın gerçek sayılarıyla güncellenir (bkz. demo.tsx).
|
||||
* Dilim şeridi canlıdır: kullanıcının kayıtlı tercih profilindeki sıralama
|
||||
* okunur, şerit o sıralamanın gerçek sayılarıyla güncellenir (bkz. demo.tsx);
|
||||
* profil yoksa örnek sıralama gösterilir. Sihirbaz hunisi ise statik
|
||||
* bir mermaid şemasıyla anlatılır (bkz. huni-semasi.tsx).
|
||||
* Diyagramlar ürünün risk renk dilini kullanır (yeşil/sarı/kırmızı) ve her
|
||||
* bölge metinle de etiketlenir — renk tek başına anlam taşımaz.
|
||||
*/
|
||||
|
||||
// Demo ilk açılışta bu sıralamayla dolu gelir; SSR'da gerçek veriyle render
|
||||
// edilir ki kullanıcı girmeden de sayfa anlamlı olsun.
|
||||
// Profili olmayan ziyaretçi bu örnek sıralamayı görür; SSR'da gerçek veriyle
|
||||
// render edilir ki profil olmadan da sayfa anlamlı olsun.
|
||||
const VARSAYILAN_SIRA = 100_000;
|
||||
|
||||
export default function MeraklisinaPage() {
|
||||
@@ -45,19 +47,18 @@ export default function MeraklisinaPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<main className="mx-auto w-full max-w-2xl px-4 py-16">
|
||||
<Parallax fromStart strength={10}>
|
||||
<p className="text-sm font-semibold tracking-wide text-primary">
|
||||
Meraklısına
|
||||
</p>
|
||||
<h1 className="mt-1 font-heading text-3xl font-bold">
|
||||
<SectionEyebrow>Meraklısına</SectionEyebrow>
|
||||
<h1 className="mt-3 font-heading text-3xl font-bold">
|
||||
Sıralamanı girince arka planda ne oluyor?
|
||||
</h1>
|
||||
<p className="mt-3 text-slate-600">
|
||||
Sihir yok, kara kutu yok: KolayTercih'in önerdiği her satır resmî
|
||||
YÖK Atlas verisinden gelir ve hangi kurala göre önüne geldiği bellidir.
|
||||
Bu sayfa o kuralları öğrenci diliyle anlatır — üstelik görseller
|
||||
canlı: kendi sıralamanı gir, sayılar sana göre değişsin.
|
||||
canlı: sıralamanı girdiysen sayılar sana göre hesaplanır.
|
||||
</p>
|
||||
</Parallax>
|
||||
|
||||
@@ -139,7 +140,7 @@ export default function MeraklisinaPage() {
|
||||
4. Sihirbaz süzer, yapay zekâ kurar
|
||||
</h2>
|
||||
<p className="mt-2">
|
||||
“Listemi oluştur” sihirbazındaki üç adım birbirinden
|
||||
“AI listemi kur” sihirbazındaki üç adım birbirinden
|
||||
bağımsız değildir. İlk adımda yalnızca{" "}
|
||||
<strong>sıralamanla ulaşabildiğin</strong>{" "}
|
||||
alanlar listelenir; ikinci adımdaki iller, seçtiğin alanlardaki
|
||||
@@ -148,11 +149,10 @@ export default function MeraklisinaPage() {
|
||||
Adımlar bitince elde kalan gerçek programlar bir{" "}
|
||||
<strong>aday havuzu</strong>{" "}
|
||||
olur ve yapay zekâya öyle gider: ona boş bir sayfa değil, bu havuz
|
||||
verilir. Aşağıda kendin dene — her seçimin havuzu nasıl
|
||||
daralttığını ve havuzun 24'lük listeye nasıl dönüştüğünü huni
|
||||
canlı gösterir.
|
||||
verilir. Aşağıdaki şema akışın tamamını gösterir — havuz
|
||||
24'ün altına düşerse filtrelerin nasıl gevşetildiği de dahil.
|
||||
</p>
|
||||
<HuniCanli />
|
||||
<HuniSemasi />
|
||||
</section>
|
||||
|
||||
<section>
|
||||
@@ -170,20 +170,11 @@ export default function MeraklisinaPage() {
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div className="rounded-2xl border border-slate-200 bg-white p-6 text-center">
|
||||
<p className="font-heading text-base font-bold text-slate-900">
|
||||
Kuralları öğrendin — şimdi kendi sıralamanla dene.
|
||||
</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="mt-3 inline-flex items-center gap-1.5 rounded-full bg-orange-500 px-5 py-2.5 text-sm font-medium text-white transition-colors duration-200 hover:bg-orange-600"
|
||||
>
|
||||
Sıralamanı gir
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Link>
|
||||
</div>
|
||||
<DemoKapanisCta />
|
||||
</Parallax>
|
||||
</SiralamaDemo>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,8 +10,8 @@ export default function NotFound() {
|
||||
Böyle bir sayfa yok
|
||||
</h1>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-600">
|
||||
Aradığın sayfa taşınmış ya da hiç var olmamış olabilir. Sıralamanı
|
||||
girip listeni kurmaya ana sayfadan devam edebilirsin.
|
||||
Aradığın sayfa taşınmış ya da hiç var olmamış olabilir. Listeni
|
||||
kurmaya ana sayfadan devam edebilirsin.
|
||||
</p>
|
||||
<Button
|
||||
asChild
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import Link from "next/link";
|
||||
import type { Metadata } from "next";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { CheckCircle2, Sparkles, XCircle } from "lucide-react";
|
||||
import { ArrowRight, CheckCircle2, XCircle } from "lucide-react";
|
||||
import { verifySession } from "@/lib/session";
|
||||
import { appDb, schema } from "@/lib/appdb";
|
||||
import { odemeyiSonuclandir } from "@/lib/odeme";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PagePixelDivider } from "@/components/pixel-decor";
|
||||
import { SiteFooter } from "@/components/site-footer";
|
||||
import { RaporYokCta } from "./rapor-yok-cta";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Ödeme Sonucu",
|
||||
@@ -73,22 +75,24 @@ export default async function OdemeSonucPage({
|
||||
</p>
|
||||
<PagePixelDivider seed={79} className="mx-auto mt-5" />
|
||||
<div className="mt-6 flex flex-col gap-2">
|
||||
<Button
|
||||
asChild
|
||||
className="h-11 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||
>
|
||||
<Link href={listeHref ?? "/"}>
|
||||
<Sparkles className="size-4" aria-hidden />
|
||||
{listeHref
|
||||
? "Listeni aç — kilidi kaldırdık"
|
||||
: "24'lük listeni oluştur"}
|
||||
</Link>
|
||||
</Button>
|
||||
<p className="text-center text-xs text-slate-500">
|
||||
{listeHref
|
||||
? "24 tercihin tamamı, gerekçeler, riskler ve PDF artık açık."
|
||||
: "Sıralamanı girip sonuç sayfasındaki sihirbazla listeni kur."}
|
||||
</p>
|
||||
{listeHref ? (
|
||||
<>
|
||||
<Button
|
||||
asChild
|
||||
className="h-11 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||
>
|
||||
<Link href={listeHref}>
|
||||
Listeni aç — kilidi kaldırdık
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Link>
|
||||
</Button>
|
||||
<p className="text-center text-xs text-slate-500">
|
||||
24 tercihin tamamı, gerekçeler, riskler ve PDF artık açık.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<RaporYokCta />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -118,6 +122,7 @@ export default async function OdemeSonucPage({
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
36
src/app/odeme/sonuc/rapor-yok-cta.tsx
Normal file
36
src/app/odeme/sonuc/rapor-yok-cta.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTercihProfili } from "@/components/manuel-liste/profil-kapisi-store";
|
||||
import { sonucHref } from "@/lib/sihirbaz";
|
||||
|
||||
/**
|
||||
* Ödeme başarılı ama henüz AI listesi yokken gösterilen CTA: kayıtlı tercih
|
||||
* profili olan adayı sihirbazı açık hâlde sonuç sayfasına götürür ve
|
||||
* "sıralamanı gir" metni gösterilmez; profil yoksa ana sayfadaki sıralama
|
||||
* formuna yönlendirir.
|
||||
*/
|
||||
export function RaporYokCta() {
|
||||
const profil = useTercihProfili();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
asChild
|
||||
className="h-11 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||
>
|
||||
<Link href={profil ? sonucHref(profil, { sihirbaz: "1" }) : "/"}>
|
||||
24'lük listeni oluştur
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Link>
|
||||
</Button>
|
||||
<p className="text-center text-xs text-slate-500">
|
||||
{profil
|
||||
? "Sıralaman kayıtlı — sonuç sayfasındaki sihirbazla listeni kur."
|
||||
: "Sıralamanı girip sonuç sayfasındaki sihirbazla listeni kur."}
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
} from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { HeroFocusButton } from "@/components/hero-focus-button";
|
||||
import { HeroBaslik } from "@/components/hero-baslik";
|
||||
import { KapanisCta } from "@/components/hero-focus-button";
|
||||
import { HeroForm } from "@/components/hero-form";
|
||||
import { Parallax } from "@/components/parallax";
|
||||
import { Reveal } from "@/components/landing/reveal";
|
||||
@@ -24,7 +25,6 @@ import {
|
||||
SectionEyebrow,
|
||||
} from "@/components/pixel-decor";
|
||||
import { URUNLER } from "@/lib/credits";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
@@ -209,13 +209,7 @@ export default function Home() {
|
||||
strength={14}
|
||||
className="mx-auto flex min-h-[calc(100svh-5.5rem)] max-w-6xl flex-col items-center justify-center px-4 pb-4 pt-12 text-center sm:pb-36 sm:pt-4"
|
||||
>
|
||||
<h1 className="max-w-3xl font-heading text-5xl font-bold tracking-[0.015em] sm:text-6xl">
|
||||
Dört yıllık geleceğini <br className="hidden sm:block" />
|
||||
<span className="font-bricolage italic tracking-tighter font-bold text-primary">
|
||||
48 saatlik panikle
|
||||
</span>{" "}
|
||||
seçme
|
||||
</h1>
|
||||
<HeroBaslik />
|
||||
{/* <p className="mt-6 max-w-2xl text-lg leading-relaxed text-slate-600">
|
||||
KolayTercih, gerçek YÖK Atlas verisiyle çalışan yapay zekâ tercih
|
||||
danışmanın. İnsan danışmanın binlerce lira aldığı işi — dengeli
|
||||
@@ -227,10 +221,6 @@ export default function Home() {
|
||||
>
|
||||
<HeroForm />
|
||||
</div>
|
||||
<p className="mt-4 text-sm text-slate-500">
|
||||
Temel arama her zaman ücretsiz · Kredi kartı gerekmez · Tam analiz
|
||||
paketi tek seferlik {PAKET_FIYATI} TL
|
||||
</p>
|
||||
<div className="mt-3 inline-flex items-center gap-2.5 rounded-lg border border-slate-200 bg-white px-3 py-2 text-left text-xs text-slate-700 shadow-xs">
|
||||
<Image
|
||||
src="/yok-logo.svg"
|
||||
@@ -530,11 +520,7 @@ export default function Home() {
|
||||
<h2 className="mt-6 font-heading text-3xl font-bold sm:text-4xl">
|
||||
Tercih listen, pişmanlık listesi olmasın
|
||||
</h2>
|
||||
<p className="mt-4 max-w-xl text-lg leading-relaxed text-white/90">
|
||||
Sıralamanı gir, yapay zekâ danışmanın veriyle konuşsun.
|
||||
Tercih dönemi bitmeden yerini al.
|
||||
</p>
|
||||
<HeroFocusButton />
|
||||
<KapanisCta />
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
@@ -12,10 +12,10 @@ import {
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
import { URUNLER, DENEME_KREDISI } from "@/lib/credits";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { SectionEyebrow } from "@/components/pixel-decor";
|
||||
import { Parallax } from "@/components/parallax";
|
||||
import { PagePixelDivider } from "@/components/pixel-decor";
|
||||
import { SatinAlForm } from "./satin-al-form";
|
||||
import { SiteFooter } from "@/components/site-footer";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Tercih Dönemi Paketi",
|
||||
@@ -68,9 +68,7 @@ export default async function PaketPage({
|
||||
</div>
|
||||
) : null}
|
||||
<Parallax fromStart strength={12} className="text-center">
|
||||
<Badge variant="secondary" className="bg-primary/10 text-primary">
|
||||
Tek seferlik ödeme · Abonelik yok
|
||||
</Badge>
|
||||
<SectionEyebrow>Tek seferlik ödeme · Abonelik yok</SectionEyebrow>
|
||||
<h1 className="mt-4 font-heading text-3xl font-bold sm:text-4xl">
|
||||
İnsan danışmanın işini,{" "}
|
||||
<span className="text-primary">onda bir fiyatına</span>
|
||||
@@ -81,8 +79,6 @@ export default async function PaketPage({
|
||||
</p>
|
||||
</Parallax>
|
||||
|
||||
<PagePixelDivider seed={53} className="mx-auto mt-7" />
|
||||
|
||||
<Parallax
|
||||
strength={6}
|
||||
className="mt-10 grid gap-6 sm:grid-cols-[1fr_auto]"
|
||||
@@ -192,6 +188,7 @@ export default async function PaketPage({
|
||||
.
|
||||
</p>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
ArrowRight,
|
||||
ChartNoAxesCombined,
|
||||
Database,
|
||||
ListChecks,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { TypingAnimation } from "@/components/ui/typing-animation";
|
||||
|
||||
/** Banner CTA'sının sihirbaz modalını açmak için yayınladığı olay. */
|
||||
export const SIHIRBAZ_AC_EVENT = "kolaytercih:sihirbaz-ac";
|
||||
|
||||
const FUNNEL_METINLERI = [
|
||||
"Sıralamana en uygun listeyi birlikte kuralım.",
|
||||
"Geleceğin 24 satırlık bir plana sığar.",
|
||||
"Gerçek YÖK Atlas verisiyle güvenle seç.",
|
||||
"Her tercihinin bir nedeni olsun.",
|
||||
"Riskleri biz hesaplayalım, kararı sen ver.",
|
||||
"Sana uygun bölümler bir adım uzağında.",
|
||||
"Dengeli bir liste, rahat bir tercih dönemi.",
|
||||
"Tercihlerini şansa bırakma.",
|
||||
"Doğru strateji, doğru yerleşme.",
|
||||
"Hayalindeki bölüm için doğru plan.",
|
||||
"Verilerle konuşan bir tercih listesi.",
|
||||
"Üç adımda sana özel 24 tercih.",
|
||||
"Tercih dönemini stressiz geçir.",
|
||||
"Geçen yılın verisi, senin avantajın.",
|
||||
"Güvenli ve iddialı tercihleri dengele.",
|
||||
"Sıralaman ne söylüyor? Birlikte bakalım.",
|
||||
"Her satırı senin için hesaplıyoruz.",
|
||||
"Akıllı tercih, içi rahat bir bekleyiş.",
|
||||
"Yerleşme ihtimalini görerek seç.",
|
||||
"Sana özel liste birkaç dakikada hazır.",
|
||||
"İlgi alanların ve şehrin, tek listede.",
|
||||
"Boş kalan tercih, kaçan fırsattır.",
|
||||
"Uzman işi bir liste, senin onayınla.",
|
||||
"Kararını veriye dayandır.",
|
||||
"Doğru tercih, doğru bilgiyle başlar.",
|
||||
"Listeni bugün kur, için rahat olsun.",
|
||||
"Yerleşme riskin renklerle önünde.",
|
||||
"En önemli 24 kararını birlikte verelim.",
|
||||
"Senin sıralaman, sana özel plan.",
|
||||
"İyi bir liste, iyi bir başlangıçtır.",
|
||||
];
|
||||
|
||||
/** Fisher-Yates ile kopya üzerinde karıştırır; her ziyarette farklı sıra. */
|
||||
function karistir<T>(dizi: readonly T[]): T[] {
|
||||
const kopya = [...dizi];
|
||||
for (let i = kopya.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[kopya[i], kopya[j]] = [kopya[j], kopya[i]];
|
||||
}
|
||||
return kopya;
|
||||
}
|
||||
|
||||
const KANITLAR = [
|
||||
{ ikon: ListChecks, etiket: "24 tercih" },
|
||||
{ ikon: ChartNoAxesCombined, etiket: "Risk analizi" },
|
||||
{ ikon: Database, etiket: "YÖK Atlas verisi" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Puan türü badge'lerinin hemen altında yaşayan satın alım funnel banner'ı.
|
||||
* Rastgele sırayla yazılıp silinen samimi ve profesyonel funnel
|
||||
* metinleriyle adayı sihirbaza; rapor zaten varsa kilitli listenin
|
||||
* durduğu /listem paywall'ına iter.
|
||||
* Tasarım: güven veren lacivert yüzey, veri kanıtları ve turuncu CTA.
|
||||
*/
|
||||
export function FunnelBanner({ hedefListem }: { hedefListem: boolean }) {
|
||||
// İlk render'da bir kez karıştırılır; SSR boş metinle başladığı için
|
||||
// hydration uyuşmazlığı yaratmaz.
|
||||
const [metinler] = useState(() => karistir(FUNNEL_METINLERI));
|
||||
|
||||
const ctaSinif =
|
||||
"h-12 w-full shrink-0 cursor-pointer rounded-xl bg-orange-500 px-5 font-semibold text-white shadow-[0_8px_24px_rgba(249,115,22,0.28)] transition-[background-color,transform,box-shadow] duration-200 hover:bg-orange-400 hover:shadow-[0_10px_28px_rgba(249,115,22,0.36)] active:scale-[0.98] focus-visible:ring-white/70 sm:w-auto";
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label="Tercih listeni kur"
|
||||
className="relative mt-6 overflow-hidden rounded-3xl border border-blue-400/20 bg-[#0b1f3a] px-5 py-6 text-white shadow-[0_20px_50px_-28px_rgba(15,71,150,0.65)] sm:px-7 sm:py-7"
|
||||
>
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute -right-16 -top-24 size-64 rounded-full bg-blue-500/25 blur-3xl"
|
||||
/>
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute -bottom-20 left-1/3 size-48 rounded-full bg-cyan-400/10 blur-3xl"
|
||||
/>
|
||||
|
||||
<div className="relative grid items-center gap-6 sm:grid-cols-[minmax(0,1fr)_auto] sm:gap-8">
|
||||
<div className="min-w-0">
|
||||
<TypingAnimation
|
||||
as="h2"
|
||||
words={metinler}
|
||||
loop
|
||||
typeSpeed={38}
|
||||
deleteSpeed={18}
|
||||
pauseDelay={2600}
|
||||
className="min-h-[2.5em] max-w-2xl font-heading text-2xl font-bold leading-tight tracking-[-0.02em] sm:min-h-[1.25em] sm:text-3xl"
|
||||
/>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-x-4 gap-y-2">
|
||||
{KANITLAR.map(({ ikon: Ikon, etiket }) => (
|
||||
<span
|
||||
key={etiket}
|
||||
className="inline-flex items-center gap-1.5 text-xs font-medium text-slate-200"
|
||||
>
|
||||
<Ikon className="size-3.5 text-cyan-300" aria-hidden />
|
||||
{etiket}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hedefListem ? (
|
||||
<Button asChild size="lg" className={ctaSinif}>
|
||||
<Link href="/listem">
|
||||
Listeni aç
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={() => window.dispatchEvent(new Event(SIHIRBAZ_AC_EVENT))}
|
||||
className={ctaSinif}
|
||||
>
|
||||
Listemi oluştur
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
import Link from "next/link";
|
||||
import type { Metadata } from "next";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { eq } from "drizzle-orm";
|
||||
import {
|
||||
PUAN_TURLERI,
|
||||
@@ -14,13 +12,11 @@ import type { RaporSonuc } from "@/lib/ai/rapor";
|
||||
import type { RaporParams } from "@/lib/rapor-havuzu";
|
||||
import { raporMaskele } from "@/lib/rapor-maske";
|
||||
import { SihirbazBolumu } from "./sihirbaz-cagri-karti";
|
||||
import { FunnelBanner } from "./funnel-banner";
|
||||
import type { MevcutRapor } from "./liste-paneli";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ProgramTablosu } from "@/components/program-tablosu";
|
||||
import { ManuelHarita } from "@/components/manuel-liste/manuel-harita";
|
||||
import { PagePixelDivider } from "@/components/pixel-decor";
|
||||
import { SiteFooter } from "@/components/site-footer";
|
||||
import { SiraGerekli } from "./sira-gerekli";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Tercih Listeni Kur",
|
||||
@@ -30,14 +26,6 @@ export const metadata: Metadata = {
|
||||
robots: { index: false, follow: true },
|
||||
};
|
||||
|
||||
const TUR_LABELS: Record<PuanTuruKey, string> = {
|
||||
say: "Sayısal",
|
||||
ea: "Eşit Ağırlık",
|
||||
soz: "Sözel",
|
||||
dil: "Dil",
|
||||
tyt: "TYT (Önlisans)",
|
||||
};
|
||||
|
||||
export default async function SonucPage({
|
||||
searchParams,
|
||||
}: {
|
||||
@@ -58,21 +46,10 @@ export default async function SonucPage({
|
||||
|
||||
if (!Number.isFinite(sira) || sira < 1 || sira > 4_000_000) {
|
||||
return (
|
||||
<main className="mx-auto flex max-w-2xl flex-col items-center px-4 py-24 text-center">
|
||||
<h1 className="font-heading text-2xl font-bold">
|
||||
Geçerli bir sıralama gerekli
|
||||
</h1>
|
||||
<p className="mt-3 text-slate-600">
|
||||
Listeni kurabilmek için ana sayfadan YKS başarı sıralamanı gir.
|
||||
</p>
|
||||
<PagePixelDivider seed={59} className="mx-auto mt-6" />
|
||||
<Button asChild className="mt-6 cursor-pointer">
|
||||
<Link href="/">
|
||||
<ArrowLeft className="size-4" aria-hidden />
|
||||
Ana sayfaya dön
|
||||
</Link>
|
||||
</Button>
|
||||
</main>
|
||||
<>
|
||||
<SiraGerekli />
|
||||
<SiteFooter />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -108,51 +85,15 @@ export default async function SonucPage({
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 text-slate-900">
|
||||
<main className="mx-auto max-w-4xl px-4 py-12">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Badge variant="secondary" className="bg-primary/10 text-primary">
|
||||
{sira.toLocaleString("tr-TR")}. sıra · {TUR_LABELS[turKey]}
|
||||
</Badge>
|
||||
<Button
|
||||
asChild
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="cursor-pointer rounded-full"
|
||||
>
|
||||
<Link href="/">
|
||||
<ArrowLeft className="size-4" aria-hidden />
|
||||
Yeni arama
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<h1 className="mt-4 font-heading text-3xl font-bold sm:text-4xl">
|
||||
{sira.toLocaleString("tr-TR")}. sıradaki bir aday için tercih planı
|
||||
</h1>
|
||||
<p className="mt-2 max-w-2xl text-slate-600">
|
||||
Birkaç adımda ilgi alanlarını ve şehir tercihini seç; yapay zekâ
|
||||
gerçek YÖK Atlas verisiyle 24 tercihlik dengeli listeni kursun. Her
|
||||
satır yerleşme riskine göre renklenir:{" "}
|
||||
<span className="font-semibold text-emerald-600">yeşil güvenli</span>,{" "}
|
||||
<span className="font-semibold text-amber-600">sarı az riskli</span>,{" "}
|
||||
<span className="font-semibold text-red-600">kırmızı riskli</span>.
|
||||
</p>
|
||||
<PagePixelDivider seed={61} className="mt-7" />
|
||||
|
||||
{/* Sayfa hiyerarşisi: önce Türkiye haritası, sonra liste. Manuel
|
||||
liste store'una abone olduğundan tablodan "+" ile eklenenler
|
||||
buraya canlı yansır. */}
|
||||
<div className="mt-8">
|
||||
<ManuelHarita />
|
||||
</div>
|
||||
<ManuelHarita />
|
||||
|
||||
{/* AI'dan bağımsız ham tablo — haritanın hemen altında, sayfanın ana
|
||||
gövdesi; puan türü seçici de listenin başında yaşar */}
|
||||
gövdesi; puan türü navbar'daki sıralama rozetinden değiştirilir */}
|
||||
<ProgramTablosu sonuclar={hamSonuclar} adaySira={sira} tur={turKey} />
|
||||
|
||||
{/* Satın alım funnel'ı: değişen cümlelerle adayı sihirbaza (rapor
|
||||
varsa /listem paywall'ına) iten banner — paketli kullanıcıya
|
||||
satacak bir şey kalmadığından gösterilmez */}
|
||||
{!hasPaket ? <FunnelBanner hedefListem={Boolean(mevcutRapor)} /> : null}
|
||||
|
||||
{/* Sihirbaz akışı (CTA + modal + AI listesi) — blurlu AI paneli
|
||||
sayfanın en altında yaşar */}
|
||||
<SihirbazBolumu
|
||||
@@ -174,6 +115,7 @@ export default async function SonucPage({
|
||||
aittir.
|
||||
</p>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,22 +3,24 @@
|
||||
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Sparkles, X } from "lucide-react";
|
||||
import { ArrowRight, X } from "lucide-react";
|
||||
import { animate } from "motion";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ViewportPortal } from "@/components/viewport-portal";
|
||||
import { PixelField } from "@/components/pixel-decor";
|
||||
import type { SihirbazFacetleri } from "@/lib/db";
|
||||
import {
|
||||
SIHIRBAZ_AC_EVENT,
|
||||
SIHIRBAZ_STORAGE_KEY,
|
||||
sihirbazDogrula,
|
||||
sonucHref,
|
||||
tercihProfiliSil,
|
||||
tercihProfiliYaz,
|
||||
type SihirbazSecimleri,
|
||||
} from "@/lib/sihirbaz";
|
||||
import { SihirbazModal } from "./sihirbaz-modal";
|
||||
import { SIHIRBAZ_AC_EVENT } from "./funnel-banner";
|
||||
import { ListePaneli, type MevcutRapor, type PanelDurum } from "./liste-paneli";
|
||||
import { listeOlustur, listeRevize } from "./actions";
|
||||
|
||||
@@ -86,6 +88,9 @@ export function SihirbazBolumu({
|
||||
const [elleKapandi, setElleKapandi] = useState(false);
|
||||
const [kartGizle, setKartGizle] = useState(false);
|
||||
const [bekleyen, setBekleyen] = useState<BekleyenSecim | null>(null);
|
||||
// ?sihirbaz=1 fallback modalı storage okunmadan açılmasın: hero'dan gelen
|
||||
// kayıtlı seçimler varken modal bir anlığına açılıp kapanıyordu.
|
||||
const [depoOkundu, setDepoOkundu] = useState(false);
|
||||
|
||||
const [rapor, setRapor] = useState<MevcutRapor | null>(mevcutRapor);
|
||||
const [kredi, setKredi] = useState(baslangicKredi);
|
||||
@@ -101,6 +106,9 @@ export function SihirbazBolumu({
|
||||
const requestIdRef = useRef<string | null>(null);
|
||||
const otomatikBasladi = useRef(false);
|
||||
const bolumRef = useRef<HTMLDivElement>(null);
|
||||
// Funnel CTA'ları (banner olayı dahil) tıklama anındaki bekleyen seçimi
|
||||
// görsün diye ref'te tutulur; olay dinleyicisi bir kez bağlanır.
|
||||
const bekleyenRef = useRef<BekleyenSecim | null>(null);
|
||||
|
||||
// Mount sonrası storage'dan bekleyen seçimleri + kalıcı kart kapatmayı oku
|
||||
useEffect(() => {
|
||||
@@ -124,18 +132,20 @@ export function SihirbazBolumu({
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
if (kaliciKapali) setKartGizle(true);
|
||||
if (yeniBekleyen) setBekleyen(yeniBekleyen);
|
||||
if (yeniBekleyen) {
|
||||
setBekleyen(yeniBekleyen);
|
||||
bekleyenRef.current = yeniBekleyen;
|
||||
}
|
||||
} catch {}
|
||||
setDepoOkundu(true);
|
||||
}, [sira, tur]);
|
||||
|
||||
// Üstteki funnel banner'ının CTA'sı sihirbaz modalını bu olayla açar
|
||||
// Arama modalındaki funnel kartının CTA'sı sihirbazı bu olayla başlatır
|
||||
useEffect(() => {
|
||||
const ac = () => {
|
||||
setElleAcik(true);
|
||||
setElleKapandi(false);
|
||||
};
|
||||
const ac = () => sihirbaziBaslat();
|
||||
window.addEventListener(SIHIRBAZ_AC_EVENT, ac);
|
||||
return () => window.removeEventListener(SIHIRBAZ_AC_EVENT, ac);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
/**
|
||||
@@ -241,7 +251,7 @@ export function SihirbazBolumu({
|
||||
|
||||
function secimleriKaydetVeGirise(secimler: SihirbazSecimleri) {
|
||||
tercihProfiliYaz({ sira, tur, secimler });
|
||||
const geri = `/sonuc?sira=${sira}&tur=${tur}&sihirbaz=1`;
|
||||
const geri = sonucHref({ sira, tur }, { sihirbaz: "1" });
|
||||
router.push(`/giris?callback=${encodeURIComponent(geri)}`);
|
||||
}
|
||||
|
||||
@@ -255,6 +265,22 @@ export function SihirbazBolumu({
|
||||
await uret(secimler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Funnel CTA'larının (banner, büyük kart, sabit alt kart) ortak girişi:
|
||||
* adımlar zaten tamamlanıp kaydedildiyse modal açılmaz — girişli
|
||||
* kullanıcıda üretim doğrudan başlar, girişsiz kullanıcı girişe gider.
|
||||
* Kayıtlı seçim yoksa (ya da başka sıra/türe aitse) modal açılır.
|
||||
*/
|
||||
function sihirbaziBaslat() {
|
||||
const hazir = bekleyenRef.current;
|
||||
if (hazir?.eslesir) {
|
||||
void ilerle(hazir.secimler);
|
||||
return;
|
||||
}
|
||||
setElleAcik(true);
|
||||
setElleKapandi(false);
|
||||
}
|
||||
|
||||
// Giriş dönüşü: storage'daki eşleşen seçimlerle otomatik üretime başla
|
||||
// (bir kez, modal açmadan — üretim sayfa gövdesinde oynar).
|
||||
const otomatikUret = Boolean(bekleyen?.eslesir && girisliMi && !rapor);
|
||||
@@ -268,11 +294,15 @@ export function SihirbazBolumu({
|
||||
}, [otomatikUret]);
|
||||
|
||||
// ?sihirbaz=1: otomatik üretim devreye girmediyse (seçim kaybolduysa/
|
||||
// eşleşmiyorsa) modalı aç ki kullanıcı adımları tamamlayabilsin.
|
||||
// eşleşmiyorsa) modalı aç ki kullanıcı adımları tamamlayabilsin. Karar
|
||||
// storage okunmadan verilmez: kayıtlı seçimlerle gelen kullanıcıya modal
|
||||
// hiç gösterilmez, üretim doğrudan başlar.
|
||||
const acik =
|
||||
!elleKapandi &&
|
||||
(elleAcik ||
|
||||
Boolean(otomatikAc && !otomatikUret && !rapor && durum === "bos"));
|
||||
Boolean(
|
||||
otomatikAc && depoOkundu && !otomatikUret && !rapor && durum === "bos",
|
||||
));
|
||||
|
||||
function acikDegisti(v: boolean) {
|
||||
if (v) {
|
||||
@@ -293,7 +323,7 @@ export function SihirbazBolumu({
|
||||
// mount sonrası değişir ve AnimatePresence geçişi donardı.
|
||||
const girisBekliyor = Boolean(!girisliMi && secimlerHazir && !rapor);
|
||||
const girisUrl = `/giris?callback=${encodeURIComponent(
|
||||
`/sonuc?sira=${sira}&tur=${tur}&sihirbaz=1`,
|
||||
sonucHref({ sira, tur }, { sihirbaz: "1" }),
|
||||
)}`;
|
||||
|
||||
const panelAnahtari =
|
||||
@@ -393,11 +423,17 @@ export function SihirbazBolumu({
|
||||
</Button>
|
||||
</section>
|
||||
) : girisBekliyor ? (
|
||||
<section className="mt-16 min-w-0 rounded-2xl bg-primary px-5 py-8 text-center text-white sm:p-12">
|
||||
<h2 className="font-heading text-2xl font-bold sm:text-3xl">
|
||||
<section className="relative isolate mt-16 min-w-0 overflow-hidden rounded-2xl bg-primary px-5 py-8 text-center text-white sm:p-12">
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-x-0 -inset-y-10 -z-10 text-white"
|
||||
>
|
||||
<PixelField seed={13} />
|
||||
</div>
|
||||
<h2 className="relative font-heading text-2xl font-bold sm:text-3xl">
|
||||
Seçimlerin hazır — AI listen bir adım uzakta
|
||||
</h2>
|
||||
<p className="mx-auto mt-3 max-w-xl text-white/90">
|
||||
<p className="relative mx-auto mt-3 max-w-xl text-white/90">
|
||||
Yukarıdaki tablo her zaman ücretsiz. 5 deneme kredinle 24
|
||||
tercihlik AI listeni de kurabilirsin: ücretsiz giriş yap,
|
||||
seçimlerin kayıtlı — listen otomatik kurulsun (üretim 3
|
||||
@@ -406,34 +442,41 @@ export function SihirbazBolumu({
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="mt-6 h-auto min-h-12 w-full cursor-pointer whitespace-normal bg-orange-500 px-4 py-3 text-white transition-colors duration-200 hover:bg-orange-600 sm:w-auto sm:px-8"
|
||||
className="relative mt-6 h-auto min-h-12 w-full cursor-pointer whitespace-normal bg-orange-500 px-4 py-3 text-white transition-colors duration-200 hover:bg-orange-600 sm:w-auto sm:px-8"
|
||||
>
|
||||
<Link href={girisUrl}>
|
||||
<Sparkles className="size-4" aria-hidden />5 deneme kredinle
|
||||
AI listeni kur — giriş yap
|
||||
5 deneme kredinle AI listeni kur — giriş yap
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Link>
|
||||
</Button>
|
||||
<p className="mt-3 text-xs text-white/70">
|
||||
<p className="relative mt-3 text-xs text-white/70">
|
||||
Kredi kartı gerekmez · Google ya da e-posta ile 30 saniyede
|
||||
</p>
|
||||
</section>
|
||||
) : (
|
||||
<section className="mt-16 min-w-0 rounded-2xl bg-primary px-5 py-8 text-center text-white sm:p-12">
|
||||
<h2 className="font-heading text-2xl font-bold sm:text-3xl">
|
||||
Bu listeyi 24 tercihlik plana çevirelim mi?
|
||||
<section className="relative isolate mt-16 min-w-0 overflow-hidden rounded-2xl bg-primary px-5 py-8 text-center text-white sm:p-12">
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-x-0 -inset-y-10 -z-10 text-white"
|
||||
>
|
||||
<PixelField seed={19} />
|
||||
</div>
|
||||
<h2 className="relative font-heading text-2xl font-bold sm:text-3xl">
|
||||
24 tercihi tek tek elemek yerine, yapay zekâ kursun
|
||||
</h2>
|
||||
<p className="mx-auto mt-3 max-w-xl text-white/90">
|
||||
Birkaç adımda ilgi alanların ve şehir tercihine göre
|
||||
gerekçeli, dengeli bir tercih listesi kuralım — her satır için
|
||||
yerleşme riski analiziyle.
|
||||
<p className="relative mx-auto mt-3 max-w-xl text-white/90">
|
||||
Tablodan elle eklemek her zaman serbest. Ama üç adımda ilgi
|
||||
alanlarını ve şehirlerini söylersen, yapay zekâ hayal, dengeli
|
||||
ve garanti dilimlerine dağıtılmış 24 tercihi — her satırı
|
||||
yerleşme riski ve gerekçesiyle — senin yerine dizer.
|
||||
</p>
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={() => acikDegisti(true)}
|
||||
className="mt-6 h-auto min-h-12 w-full cursor-pointer whitespace-normal bg-orange-500 px-4 py-3 text-white transition-colors duration-200 hover:bg-orange-600 sm:w-auto sm:px-8"
|
||||
onClick={sihirbaziBaslat}
|
||||
className="relative mt-6 h-auto min-h-12 w-full cursor-pointer whitespace-normal bg-orange-500 px-4 py-3 text-white transition-colors duration-200 hover:bg-orange-600 sm:w-auto sm:px-8"
|
||||
>
|
||||
<Sparkles className="size-4" aria-hidden />
|
||||
Listemi oluştur
|
||||
AI listemi kur
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Button>
|
||||
</section>
|
||||
)}
|
||||
@@ -450,12 +493,12 @@ export function SihirbazBolumu({
|
||||
<p className="font-heading text-sm font-bold text-slate-900">
|
||||
{girisBekliyor
|
||||
? "Seçimlerin kayıtlı — AI listen hazır bekliyor"
|
||||
: "3 adımda 24 tercihlik listen"}
|
||||
: "Tek tek ekleme, yapay zekâ kursun"}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{girisBekliyor
|
||||
? "Ücretsiz giriş yap, 5 deneme kredinle listen otomatik kurulsun."
|
||||
: "Sıralamana uygun bölümleri seç, yapay zekâ listeni kursun."}
|
||||
: "Üç adımda seçimlerini söyle; risk analizli, gerekçeli 24 tercih hazır."}
|
||||
</p>
|
||||
</div>
|
||||
{girisBekliyor ? (
|
||||
@@ -469,7 +512,7 @@ export function SihirbazBolumu({
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => acikDegisti(true)}
|
||||
onClick={sihirbaziBaslat}
|
||||
className="cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||
>
|
||||
Başla
|
||||
@@ -506,7 +549,7 @@ export function SihirbazBolumu({
|
||||
sonSecimler ?? (bekleyen?.eslesir ? bekleyen.secimler : null)
|
||||
}
|
||||
sonButonEtiketi={
|
||||
girisliMi ? "Listemi oluştur (3 kredi)" : "Giriş yap ve listeni gör"
|
||||
girisliMi ? "AI listemi kur (3 kredi)" : "Giriş yap ve listeni gör"
|
||||
}
|
||||
onTamamla={ilerle}
|
||||
/>
|
||||
|
||||
70
src/app/sonuc/sira-gerekli.tsx
Normal file
70
src/app/sonuc/sira-gerekli.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
// /sonuc?sira= yokken: kayıtlı profil varsa oraya taşı; yoksa kapıyı aç.
|
||||
// Sıra bilgisi localStorage'daki TercihProfili ile tüm uygulamada ortak.
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft, Trophy } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PagePixelDivider } from "@/components/pixel-decor";
|
||||
import { sonucHref } from "@/lib/sihirbaz";
|
||||
import {
|
||||
sonucaGitIste,
|
||||
useProfilKapisi,
|
||||
} from "@/components/manuel-liste/profil-kapisi-store";
|
||||
|
||||
export function SiraGerekli() {
|
||||
const router = useRouter();
|
||||
const { profil, profilYuklendi } = useProfilKapisi();
|
||||
const [yonleniyor, setYonleniyor] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!profilYuklendi) return;
|
||||
if (profil) {
|
||||
setYonleniyor(true);
|
||||
router.replace(sonucHref(profil));
|
||||
}
|
||||
}, [profil, profilYuklendi, router]);
|
||||
|
||||
if (!profilYuklendi || yonleniyor || profil) {
|
||||
return (
|
||||
<main className="mx-auto flex max-w-2xl flex-col items-center px-4 py-24 text-center">
|
||||
<p className="text-sm text-slate-500">Sıralaman kontrol ediliyor…</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex max-w-2xl flex-col items-center px-4 py-24 text-center">
|
||||
<h1 className="font-heading text-2xl font-bold">
|
||||
Geçerli bir sıralama gerekli
|
||||
</h1>
|
||||
<p className="mt-3 text-slate-600">
|
||||
Listeni kurabilmek için YKS başarı sıralamanı gir. Kaydettiğin sıra tüm
|
||||
uygulamada kullanılır.
|
||||
</p>
|
||||
<PagePixelDivider seed={59} className="mx-auto mt-6" />
|
||||
<div className="mt-6 flex flex-wrap items-center justify-center gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const { href } = sonucaGitIste();
|
||||
if (href) router.push(href);
|
||||
}}
|
||||
className="cursor-pointer bg-orange-500 text-white hover:bg-orange-600"
|
||||
>
|
||||
<Trophy className="size-4" aria-hidden />
|
||||
Sıralamamı gir
|
||||
</Button>
|
||||
<Button asChild variant="outline" className="cursor-pointer">
|
||||
<Link href="/">
|
||||
<ArrowLeft className="size-4" aria-hidden />
|
||||
Ana sayfaya dön
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -8,19 +8,51 @@ import { useEffect, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { MessageCircleMore, X } from "lucide-react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { ViewportPortal } from "@/components/viewport-portal";
|
||||
|
||||
function SohbetSkeleton() {
|
||||
return (
|
||||
<div
|
||||
aria-busy="true"
|
||||
aria-live="polite"
|
||||
className="flex h-full flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-2xl"
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-slate-100 px-4 py-3">
|
||||
<Skeleton className="h-5 w-36" />
|
||||
<Skeleton className="h-6 w-20 rounded-full" />
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col items-center gap-4 px-4 py-10">
|
||||
<Skeleton className="size-7 rounded-full" />
|
||||
<Skeleton className="h-5 w-56 max-w-full" />
|
||||
<div className="w-full max-w-md space-y-2">
|
||||
<Skeleton className="mx-auto h-3.5 w-full" />
|
||||
<Skeleton className="mx-auto h-3.5 w-4/5" />
|
||||
</div>
|
||||
<div className="flex max-w-lg flex-wrap justify-center gap-2">
|
||||
{[112, 148, 96, 132].map((w, i) => (
|
||||
<Skeleton
|
||||
key={i}
|
||||
className="h-7 rounded-full"
|
||||
style={{ width: w }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-slate-100 p-3">
|
||||
<Skeleton className="h-24 w-full rounded-2xl" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Sohbet istemcisi (markdown zinciriyle birlikte) ağır; popup ilk açıldığında
|
||||
// yüklensin — /sonuc ilk yükünü şişirmesin.
|
||||
const SohbetClient = dynamic(
|
||||
() => import("@/app/listem/sohbet-client").then((m) => m.SohbetClient),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="flex h-full items-center justify-center rounded-2xl border border-slate-200 bg-white text-sm text-slate-500 shadow-2xl">
|
||||
Sohbet yükleniyor…
|
||||
</div>
|
||||
),
|
||||
loading: () => <SohbetSkeleton />,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user