Update package.json scripts, enhance layout with new components, and improve API error handling
Some checks failed
Deploy / deploy (push) Failing after 1h28m6s
Some checks failed
Deploy / deploy (push) Failing after 1h28m6s
- Added new scripts for database operations and temporary production in package.json. - Integrated KaydetBannerLazy component into the layout for improved user notifications. - Enhanced API error handling in the soru route to mark credit exhaustion. - Updated the IletisimPage for better button styling and user experience. - Refactored ListemPage to streamline user flow and improve session handling. - Removed unused ListePaneli component to clean up the codebase.
This commit is contained in:
@@ -7,6 +7,7 @@ import { PUAN_TURLERI, type PuanTuruKey } from "@/lib/db";
|
||||
import {
|
||||
spendCredits,
|
||||
grantCredits,
|
||||
krediBittiIsaretle,
|
||||
RAPOR_KREDI,
|
||||
MAX_REVIZYON,
|
||||
} from "@/lib/credits";
|
||||
@@ -113,6 +114,7 @@ export async function listeOlustur(input: {
|
||||
});
|
||||
if (!harcama.ok) {
|
||||
if (harcama.error === "INSUFFICIENT") {
|
||||
await krediBittiIsaretle(user.id);
|
||||
return {
|
||||
ok: false,
|
||||
code: "KREDI",
|
||||
@@ -241,6 +243,7 @@ export async function listeRevize(input: {
|
||||
});
|
||||
if (!harcama.ok) {
|
||||
if (harcama.error === "INSUFFICIENT") {
|
||||
await krediBittiIsaretle(user.id);
|
||||
return {
|
||||
ok: false,
|
||||
code: "KREDI",
|
||||
|
||||
@@ -1,401 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useEffect, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { ArrowRight, FileText, Lock, RefreshCcw } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { SohbetPopup } from "./sohbet-popup";
|
||||
import { RaporListesi } from "@/components/rapor-listesi";
|
||||
import { TercihHaritasi, type HaritaPin } from "@/components/tercih-haritasi";
|
||||
import type { RaporSonuc } from "@/lib/ai/rapor";
|
||||
import type { RaporParams } from "@/lib/rapor-havuzu";
|
||||
import { ACIK_SATIR } from "@/lib/rapor-maske";
|
||||
import { ilKonum, uniAdiNormalize, uniKonum } from "@/lib/harita";
|
||||
import { dilimdenRisk, riskHesapla, type RiskSeviyesi } from "@/lib/risk";
|
||||
|
||||
const MAX_REVIZYON = 2;
|
||||
|
||||
export type MevcutRapor = {
|
||||
rapor: RaporSonuc;
|
||||
params: RaporParams;
|
||||
revisionCount: number;
|
||||
};
|
||||
|
||||
/** Panelin liste dışındaki geçici durumları (liste, rapor varlığından türer). */
|
||||
export type PanelDurum = "bos" | "uretiliyor" | "kredi-yetersiz";
|
||||
|
||||
const RISK_AGIRLIK: Record<RiskSeviyesi, number> = {
|
||||
guvenli: 0,
|
||||
"az-riskli": 1,
|
||||
riskli: 2,
|
||||
};
|
||||
|
||||
/**
|
||||
* Rapordan harita pinleri türetir. Açık satırlar üniversite bazında gruplanır
|
||||
* (kampüs konumunda, adıyla); kilitli satırlar il başına tek adsız "?" pinine
|
||||
* toplanır — maskeli veride üniversite adı zaten client'a inmediği için
|
||||
* harita paywall'ı delemez. Haritada karşılığı olmayan tercihler sayılır.
|
||||
*/
|
||||
function pinleriTuret(
|
||||
rapor: RaporSonuc,
|
||||
adaySira: number,
|
||||
kilitliBaslangic: number,
|
||||
): { pinler: HaritaPin[]; haritaDisi: number } {
|
||||
const gruplar = new Map<string, HaritaPin>();
|
||||
let haritaDisi = 0;
|
||||
|
||||
rapor.tercihler.forEach((t, i) => {
|
||||
const p = rapor.programlar[t.programId];
|
||||
if (!p) return;
|
||||
const risk = riskHesapla(p.efektifSira, adaySira) ?? dilimdenRisk(t.dilim);
|
||||
const kilitli = i >= kilitliBaslangic;
|
||||
|
||||
if (kilitli) {
|
||||
const konum = ilKonum(p.il);
|
||||
if (!konum) {
|
||||
haritaDisi += 1;
|
||||
return;
|
||||
}
|
||||
const key = `kilit|${p.il}`;
|
||||
const mevcut = gruplar.get(key);
|
||||
if (mevcut) {
|
||||
mevcut.tercihSiralari.push(t.sira);
|
||||
if (RISK_AGIRLIK[risk] > RISK_AGIRLIK[mevcut.risk]) mevcut.risk = risk;
|
||||
} else {
|
||||
// Açık pinlerle üst üste binmesin diye il merkezinin hafif altına
|
||||
gruplar.set(key, {
|
||||
id: key,
|
||||
universite: "",
|
||||
il: p.il,
|
||||
x: konum.x,
|
||||
y: konum.y + 9,
|
||||
tercihSiralari: [t.sira],
|
||||
risk,
|
||||
kilitli: true,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const konum = uniKonum(p.universite, p.il);
|
||||
if (!konum) {
|
||||
haritaDisi += 1;
|
||||
return;
|
||||
}
|
||||
const key = `${uniAdiNormalize(p.universite)}|${p.il ?? ""}`;
|
||||
const mevcut = gruplar.get(key);
|
||||
if (mevcut) {
|
||||
mevcut.tercihSiralari.push(t.sira);
|
||||
if (RISK_AGIRLIK[risk] > RISK_AGIRLIK[mevcut.risk]) mevcut.risk = risk;
|
||||
} else {
|
||||
gruplar.set(key, {
|
||||
id: key,
|
||||
universite: p.universite,
|
||||
il: p.il,
|
||||
x: konum.x,
|
||||
y: konum.y,
|
||||
tercihSiralari: [t.sira],
|
||||
risk,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return { pinler: [...gruplar.values()], haritaDisi };
|
||||
}
|
||||
|
||||
const URETIM_MESAJLARI = [
|
||||
"Sıralamana uygun programlar taranıyor…",
|
||||
"Gerçek YÖK Atlas verisiyle dengeli liste kuruluyor…",
|
||||
"Her tercih için risk ve trend analizi yazılıyor…",
|
||||
"Son rötuşlar yapılıyor, neredeyse hazır…",
|
||||
];
|
||||
|
||||
/**
|
||||
* Sonuç sayfası gövdesindeki liste paneli: üretim animasyonu, paketli tam
|
||||
* liste veya paketsiz progressive-blur önizleme burada render edilir.
|
||||
* (Sihirbaz modalı yalnızca adımları toplar; sonuç artık sayfanın kendisinde.)
|
||||
*/
|
||||
export function ListePaneli({
|
||||
durum,
|
||||
rapor,
|
||||
hasPaket,
|
||||
kredi,
|
||||
sira,
|
||||
acilis,
|
||||
onRevize,
|
||||
}: {
|
||||
durum: PanelDurum;
|
||||
rapor: MevcutRapor | null;
|
||||
hasPaket: boolean;
|
||||
kredi: number;
|
||||
sira: number;
|
||||
/** Ödeme dönüşü: blur çözülme animasyonuyla aç */
|
||||
acilis?: boolean;
|
||||
onRevize: (feedback: string) => Promise<boolean>;
|
||||
}) {
|
||||
const kilitli = !hasPaket;
|
||||
|
||||
// Harita ⇄ liste köprüsü: pinden satıra, satırdan haritaya
|
||||
const [seciliIl, setSeciliIl] = useState<string | null>(null);
|
||||
const [acikSira, setAcikSira] = useState<number | null>(null);
|
||||
const haritaRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const pinVerisi = useMemo(
|
||||
() =>
|
||||
rapor
|
||||
? pinleriTuret(
|
||||
rapor.rapor,
|
||||
rapor.params.sira,
|
||||
kilitli ? ACIK_SATIR : rapor.rapor.tercihler.length,
|
||||
)
|
||||
: null,
|
||||
[rapor, kilitli],
|
||||
);
|
||||
|
||||
if (durum === "uretiliyor") return <UretimGovdesi />;
|
||||
|
||||
if (durum === "kredi-yetersiz") {
|
||||
// Paketli kullanıcıya paket satma: onun ürünü top-up. Liste ve sohbet
|
||||
// geçmişi kalıcı; kredi yükleyince kaldığı yerden devam eder.
|
||||
return (
|
||||
<section className="mt-16 rounded-2xl border border-slate-200 bg-white p-8 text-center sm:p-12">
|
||||
<Lock className="mx-auto size-8 text-slate-400" aria-hidden />
|
||||
<h2 className="mt-4 font-heading text-xl font-bold">
|
||||
Kredin bu liste için yetmiyor
|
||||
</h2>
|
||||
<p className="mx-auto mt-2 max-w-md text-sm text-slate-600">
|
||||
{hasPaket
|
||||
? "Liste üretimi 3 kredi. +30 kredi yüklediğinde listen ve sohbet geçmişin duruyor, kaldığın yerden devam edersin."
|
||||
: "Liste üretimi 3 kredi. Tercih Dönemi Paketi'yle 60 kredi, tam liste, PDF ve revizyon hakları birlikte gelir."}
|
||||
</p>
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="mt-6 h-12 cursor-pointer bg-orange-500 px-8 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||
>
|
||||
<Link href="/paket">
|
||||
{hasPaket ? "+30 kredi yükle — 129 TL" : "Paketi aktive et — 299 TL"}
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Link>
|
||||
</Button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (!rapor) return null;
|
||||
|
||||
return (
|
||||
<section
|
||||
className={`mt-16 ${acilis && !kilitli ? "kt-unblur" : ""}`}
|
||||
aria-label="Tercih listen"
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="font-heading text-2xl font-bold">
|
||||
24 Tercihlik Listen
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
{sira.toLocaleString("tr-TR")}. sıra ·{" "}
|
||||
{kilitli
|
||||
? `tüm taban verileri açık · ${ACIK_SATIR} Yapay Zeka analiz örneği`
|
||||
: `${kredi} kredin kaldı`}
|
||||
</p>
|
||||
</div>
|
||||
{!kilitli ? (
|
||||
<Button asChild variant="outline" className="cursor-pointer">
|
||||
<Link href="/rapor/yazdir" target="_blank">
|
||||
<FileText className="size-4" aria-hidden />
|
||||
PDF indir
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Harita: 24 tercihin coğrafyası — ile dokun, üniversiteleri gör */}
|
||||
<div ref={haritaRef} className="mt-5 scroll-mt-24">
|
||||
<TercihHaritasi
|
||||
pinler={pinVerisi?.pinler ?? []}
|
||||
seciliIl={seciliIl}
|
||||
onSeciliIlDegisti={setSeciliIl}
|
||||
onTercihSec={setAcikSira}
|
||||
/>
|
||||
{pinVerisi && pinVerisi.haritaDisi > 0 ? (
|
||||
<p className="mt-1 px-1 text-xs text-slate-500">
|
||||
{pinVerisi.haritaDisi} tercih harita dışında (KKTC vb.)
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<RaporListesi
|
||||
rapor={rapor.rapor}
|
||||
adaySira={rapor.params.sira}
|
||||
acikSira={acikSira}
|
||||
onHaritadaGor={(il) => {
|
||||
if (!il) return;
|
||||
setSeciliIl(il);
|
||||
haritaRef.current?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "start",
|
||||
});
|
||||
}}
|
||||
kilitliBaslangic={kilitli ? ACIK_SATIR : undefined}
|
||||
kilitOverlay={
|
||||
<div className="flex flex-col items-start gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex gap-3">
|
||||
<Lock
|
||||
className="mt-1 size-5 shrink-0 text-orange-700"
|
||||
aria-hidden
|
||||
/>
|
||||
<div>
|
||||
<p className="font-heading font-bold text-slate-900">
|
||||
Tüm program ve taban verileri ücretsiz
|
||||
</p>
|
||||
<p className="mt-1 max-w-xl text-sm text-slate-600">
|
||||
Paket; kalan {24 - ACIK_SATIR} tercih için kişisel risk,
|
||||
gerekçe, PDF, 2 revizyon ve 60 Yapay Zeka danışman sorusunu açar.
|
||||
</p>
|
||||
{kredi > 0 ? (
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
Kalan {kredi} deneme kredin hesabında duruyor.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="h-12 w-full shrink-0 cursor-pointer bg-orange-500 px-8 text-white transition-colors duration-200 hover:bg-orange-600 sm:w-auto"
|
||||
>
|
||||
<Link href="/paket">299 TL — Analizleri aç</Link>
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!kilitli ? (
|
||||
<div className="mt-6">
|
||||
<RevizyonKutusu
|
||||
kalanHak={MAX_REVIZYON - rapor.revisionCount}
|
||||
onRevize={onRevize}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Yapay Zeka danışman: sağ altta yüzen buton, alttan popup olarak açılır */}
|
||||
<SohbetPopup kredi={kredi} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function UretimGovdesi() {
|
||||
const [mesajIdx, setMesajIdx] = useState(0);
|
||||
const govdeRef = useRef<HTMLElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setMesajIdx((i) => Math.min(i + 1, URETIM_MESAJLARI.length - 1));
|
||||
}, 7000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// Modal geçiş anında unmount edildiğinden Radix odağı tetikleyiciye geri
|
||||
// veremiyor; odağı panele alıp klavye ve ekran okuyucu akışını sürdürüyoruz.
|
||||
useEffect(() => {
|
||||
govdeRef.current?.focus({ preventScroll: true });
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={govdeRef}
|
||||
tabIndex={-1}
|
||||
aria-live="polite"
|
||||
className="mt-16 rounded-2xl border border-slate-200 bg-white p-6 outline-none sm:p-8"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-1.5 text-center">
|
||||
<p className="font-heading text-lg font-bold">Listen hazırlanıyor</p>
|
||||
<p className="max-w-sm text-sm text-slate-500">
|
||||
{URETIM_MESAJLARI[mesajIdx]}
|
||||
</p>
|
||||
<p className="text-xs text-slate-400">
|
||||
Bu işlem yarım dakika kadar sürebilir, sayfayı kapatma.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Gelmekte olan tercih listesinin iskeleti */}
|
||||
<div className="mt-8 flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-6 w-44" />
|
||||
<Skeleton className="h-6 w-24 rounded-full" />
|
||||
</div>
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function RevizyonKutusu({
|
||||
kalanHak,
|
||||
onRevize,
|
||||
}: {
|
||||
kalanHak: number;
|
||||
onRevize: (feedback: string) => Promise<boolean>;
|
||||
}) {
|
||||
const [feedback, setFeedback] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
if (kalanHak <= 0) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-slate-200 bg-white p-5">
|
||||
<p className="text-sm text-slate-500">
|
||||
Revizyon hakların doldu. Aşağıdan listen hakkında soru sorabilirsin.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-slate-200 bg-white p-5">
|
||||
<h3 className="font-heading text-base font-bold">
|
||||
Listede değişiklik mi istiyorsun?
|
||||
</h3>
|
||||
<p className="mb-3 mt-1 text-sm text-slate-600">
|
||||
Ne değişsin istediğini yaz; liste aynı gerçek veriyle yeniden kurulsun
|
||||
(3 kredi).
|
||||
</p>
|
||||
<textarea
|
||||
value={feedback}
|
||||
onChange={(e) => setFeedback(e.target.value)}
|
||||
rows={2}
|
||||
maxLength={1000}
|
||||
placeholder="ör. İlk 5 tercihte daha fazla İstanbul olsun, garanti dilimini genişlet…"
|
||||
disabled={pending}
|
||||
className="w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||
/>
|
||||
<div className="mt-3 flex items-center gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={pending || feedback.trim().length < 5}
|
||||
onClick={async () => {
|
||||
setPending(true);
|
||||
const ok = await onRevize(feedback.trim());
|
||||
setPending(false);
|
||||
if (ok) setFeedback("");
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<RefreshCcw className="size-4" aria-hidden />
|
||||
{pending ? "Yeniden kuruluyor…" : "Revize et (3 kredi)"}
|
||||
</Button>
|
||||
<span className="text-xs text-slate-500">
|
||||
Kalan hak: <span className="font-semibold">{kalanHak}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,23 +6,16 @@ import {
|
||||
searchByRank,
|
||||
type PuanTuruKey,
|
||||
} from "@/lib/db";
|
||||
import { getSession, getCurrentUser } from "@/lib/session";
|
||||
import { getSession } from "@/lib/session";
|
||||
import { appDb, schema } from "@/lib/appdb";
|
||||
import type { RaporSonuc } from "@/lib/ai/rapor";
|
||||
import type { RaporParams } from "@/lib/rapor-havuzu";
|
||||
import { raporMaskele } from "@/lib/rapor-maske";
|
||||
import { tadimlikSec } from "@/lib/tadimlik-havuzu";
|
||||
import { SihirbazBolumu } from "./sihirbaz-cagri-karti";
|
||||
import type { MevcutRapor } from "./liste-paneli";
|
||||
import { TadimlikSatiri } from "./tadimlik-satiri";
|
||||
import { ProgramTablosu } from "@/components/program-tablosu";
|
||||
import { ManuelHarita } from "@/components/manuel-liste/manuel-harita";
|
||||
import { SiteFooter } from "@/components/site-footer";
|
||||
import { SiraGerekli } from "./sira-gerekli";
|
||||
|
||||
// listeOlustur aksiyonu 2 AI denemesi yapabiliyor (deneme başına 50 sn abort
|
||||
// sınırı, bkz. lib/ai/cagri.ts); platform limiti AI zaman aşımından SONRA
|
||||
// dolmalı ki kredi iadesi her zaman çalışabilsin.
|
||||
export const maxDuration = 120;
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Tercih Listeni Kur",
|
||||
// Param uzayı sonsuz + içerik sıralamaya göre kişisel: indexlenmez ama
|
||||
@@ -38,7 +31,6 @@ export default async function SonucPage({
|
||||
sira?: string;
|
||||
tur?: string;
|
||||
sihirbaz?: string;
|
||||
acildi?: string;
|
||||
hazir?: string;
|
||||
}>;
|
||||
}) {
|
||||
@@ -58,34 +50,25 @@ export default async function SonucPage({
|
||||
);
|
||||
}
|
||||
|
||||
// Sihirbaz için: kullanıcı durumu, kayıtlı rapor ve dinamik facet'ler.
|
||||
// Rapor sorgusu yalnızca session.user.id'ye bağlı — getCurrentUser'ı
|
||||
// beklemeden paralel çalışır (seri DB zinciri kurma).
|
||||
// Sihirbaz için: kullanıcının kayıtlı raporu var mı? Listenin kendisi
|
||||
// /listem'de yaşar; burada yalnızca CTA'nın hangi hâlde render edileceği
|
||||
// için varlık bilgisi gerekir.
|
||||
const session = await getSession();
|
||||
const [user, raporSatiri] = session
|
||||
? await Promise.all([
|
||||
getCurrentUser(),
|
||||
appDb.query.reports.findFirst({
|
||||
where: eq(schema.reports.userId, session.user.id),
|
||||
}),
|
||||
])
|
||||
: [null, null];
|
||||
const raporSatiri = session
|
||||
? await appDb.query.reports.findFirst({
|
||||
where: eq(schema.reports.userId, session.user.id),
|
||||
columns: { result: true },
|
||||
})
|
||||
: null;
|
||||
|
||||
const facetler = rankWindowFacets(sira, turKey);
|
||||
// Yapay Zeka'sız ham liste: yalnızca sıralamayla ulaşılabilen programlar (dilimli)
|
||||
const hamSonuclar = searchByRank(sira, turKey, { limitPerBucket: 20 });
|
||||
// Paketsize giden rapor sunucuda maskelenir: blur yalnızca CSS olduğundan
|
||||
// kilitli satırların gerçek içeriği client'a hiç inmez.
|
||||
const hasPaket = Boolean(user?.hasPaket);
|
||||
const mevcutRapor: MevcutRapor | null = raporSatiri?.result
|
||||
? {
|
||||
rapor: hasPaket
|
||||
? (raporSatiri.result as RaporSonuc)
|
||||
: raporMaskele(raporSatiri.result as RaporSonuc),
|
||||
params: raporSatiri.params as RaporParams,
|
||||
revisionCount: raporSatiri.revisionCount,
|
||||
}
|
||||
: null;
|
||||
|
||||
// Girişsiz tadımlık (İP-B): SSR'da kovaya uyan "genel" satır seçilir;
|
||||
// sihirbaz kategorisi client'ta okunup /api/tadimlik ile kişiselleştirilir.
|
||||
// Girişli kullanıcı gerçek ürünü zaten görebildiği için tadımlık almaz.
|
||||
const tadimlik = session ? null : await tadimlikSec(sira, turKey);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 text-slate-900">
|
||||
@@ -99,18 +82,21 @@ export default async function SonucPage({
|
||||
gövdesi; puan türü navbar'daki sıralama rozetinden değiştirilir */}
|
||||
<ProgramTablosu sonuclar={hamSonuclar} adaySira={sira} tur={turKey} />
|
||||
|
||||
{/* Sihirbaz akışı (CTA + modal + Yapay Zeka listesi) — blurlu Yapay Zeka paneli
|
||||
sayfanın en altında yaşar */}
|
||||
{/* Girişsiz tadımlık: gerçek rapor satırı formatında tek örnek.
|
||||
Havuzda içerik yoksa bileşen hiç render edilmez. */}
|
||||
{!session ? (
|
||||
<TadimlikSatiri sira={sira} tur={turKey} ilk={tadimlik} />
|
||||
) : null}
|
||||
|
||||
{/* Sihirbaz akışı (CTA + modal) — üretim ve liste /listem'de yaşar,
|
||||
burada yalnızca funnel kartları render edilir */}
|
||||
<SihirbazBolumu
|
||||
sira={sira}
|
||||
tur={turKey}
|
||||
facetler={facetler}
|
||||
girisliMi={Boolean(session)}
|
||||
hasPaket={hasPaket}
|
||||
kredi={user?.creditBalance ?? 0}
|
||||
mevcutRapor={mevcutRapor}
|
||||
raporVar={Boolean(raporSatiri?.result)}
|
||||
otomatikAc={params.sihirbaz === "1"}
|
||||
acilis={params.acildi === "1"}
|
||||
secimlerHazir={params.hazir === "1"}
|
||||
/>
|
||||
|
||||
|
||||
@@ -3,29 +3,26 @@
|
||||
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ArrowRight, X } from "lucide-react";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { animate } from "motion";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import { toast } from "sonner";
|
||||
import { useReducedMotion } from "motion/react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ViewportPortal } from "@/components/viewport-portal";
|
||||
import { PixelField } from "@/components/pixel-decor";
|
||||
import { useGorunumOlayi } from "@/components/use-gorunum-olayi";
|
||||
import { olay } from "@/lib/analitik";
|
||||
import type { SihirbazFacetleri } from "@/lib/db";
|
||||
import {
|
||||
SIHIRBAZ_AC_EVENT,
|
||||
SIHIRBAZ_STORAGE_KEY,
|
||||
sihirbazDogrula,
|
||||
sonucHref,
|
||||
tercihProfiliSil,
|
||||
tercihProfiliYaz,
|
||||
type SihirbazSecimleri,
|
||||
} from "@/lib/sihirbaz";
|
||||
import { olay, siraKovasi } from "@/lib/analitik";
|
||||
import { SihirbazModal } from "./sihirbaz-modal";
|
||||
import { ListePaneli, type MevcutRapor, type PanelDurum } from "./liste-paneli";
|
||||
import { listeOlustur, listeRevize } from "./actions";
|
||||
|
||||
const KAPATMA_KEY = "kolaytercih.sihirbaz-karti-kapali";
|
||||
// Üretim /listem'de koşar (bkz. listem/liste-uretici.tsx); sihirbaz
|
||||
// tamamlanınca seçimler storage'a yazılıp kullanıcı buraya yönlendirilir.
|
||||
const LISTEM_URET_HREF = "/listem?uret=1";
|
||||
|
||||
// SSR'da false, client'ta true — storage'a ancak mount sonrası dokunuruz
|
||||
const bosAbone = () => () => {};
|
||||
@@ -43,42 +40,35 @@ type BekleyenSecim = {
|
||||
|
||||
/**
|
||||
* Sonuç sayfasında sihirbaz akışını yöneten client orkestratör:
|
||||
* - Sayfa ortasındaki büyük CTA + sabit alt çağrı kartı (rapor yokken)
|
||||
* - Sayfa ortasındaki büyük CTA (rapor yokken)
|
||||
* - Adımları toplayan modal
|
||||
* - Üretim/önizleme/liste paneli (sayfa gövdesinde)
|
||||
* Akış: adımlar → kredi ile üretim → paketsizse progressive-blur önizleme,
|
||||
* paketliyse tam liste. Girişsiz kullanıcı hero'dan seçimleri tamamlanmış
|
||||
* gelirse önce ücretsiz tabloyu görür; giriş CTA'sı "hazır seçimlerin
|
||||
* kaydedilmesi" olarak panelde sunulur, girişten dönüşte üretim otomatik
|
||||
* başlar. Modal içinden ilerleyen girişsiz kullanıcı ise butonun söylediği
|
||||
* gibi doğrudan girişe gider.
|
||||
* Liste bu sayfada üretilmez ve gösterilmez: sihirbaz tamamlanınca seçimler
|
||||
* storage'a yazılır ve kullanıcı /listem'e yönlendirilir; üretim animasyonu,
|
||||
* kredi/hata durumları ve listenin kendisi orada yaşar. Girişsiz kullanıcı
|
||||
* hero'dan seçimleri tamamlanmış gelirse önce ücretsiz tabloyu görür; giriş
|
||||
* CTA'sı "hazır seçimlerin kaydedilmesi" olarak panelde sunulur, girişten
|
||||
* dönüş doğrudan /listem'e düşer ve üretim orada başlar.
|
||||
*/
|
||||
export function SihirbazBolumu({
|
||||
sira,
|
||||
tur,
|
||||
facetler,
|
||||
girisliMi,
|
||||
hasPaket,
|
||||
kredi: baslangicKredi,
|
||||
mevcutRapor,
|
||||
raporVar,
|
||||
otomatikAc,
|
||||
acilis,
|
||||
secimlerHazir,
|
||||
}: {
|
||||
sira: number;
|
||||
tur: string;
|
||||
facetler: SihirbazFacetleri;
|
||||
girisliMi: boolean;
|
||||
hasPaket: boolean;
|
||||
kredi: number;
|
||||
mevcutRapor: MevcutRapor | null;
|
||||
/** Kullanıcının kayıtlı bir Yapay Zeka listesi var mı (listenin kendisi /listem'de) */
|
||||
raporVar: boolean;
|
||||
/** ?sihirbaz=1 ile gelindiyse modalı otomatik aç */
|
||||
otomatikAc?: boolean;
|
||||
/** ?acildi=1 (ödeme dönüşü): blur çözülme animasyonuyla aç */
|
||||
acilis?: boolean;
|
||||
/** ?hazir=1 (hero sihirbazından girişsiz geliş): seçimler storage'da,
|
||||
panel giriş CTA'sına döner. URL'den gelir ki SSR'da doğru panel
|
||||
render edilsin — mount sonrası anahtar değişimi animasyonu donduruyor. */
|
||||
render edilsin. */
|
||||
secimlerHazir?: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
@@ -87,36 +77,19 @@ export function SihirbazBolumu({
|
||||
|
||||
const [elleAcik, setElleAcik] = useState(false);
|
||||
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);
|
||||
const [paketli, setPaketli] = useState(hasPaket);
|
||||
const [durum, setDurum] = useState<PanelDurum>("bos");
|
||||
|
||||
// Adımlarda yapılan son seçimler (hata sonrası modala dönüşte prefill)
|
||||
const [sonSecimler, setSonSecimler] = useState<SihirbazSecimleri | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// Aynı üretim için sabit requestId (retry çifte harcama yapmaz)
|
||||
const requestIdRef = useRef<string | null>(null);
|
||||
// Revizyon için aynı desen (bkz. listem/revizyon-kutusu.tsx)
|
||||
const revizyonIdRef = 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
|
||||
// Mount sonrası storage'dan bekleyen seçimleri oku
|
||||
useEffect(() => {
|
||||
try {
|
||||
const kaliciKapali = Boolean(sessionStorage.getItem(KAPATMA_KEY));
|
||||
const ham = localStorage.getItem(SIHIRBAZ_STORAGE_KEY);
|
||||
let yeniBekleyen: BekleyenSecim | null = null;
|
||||
if (ham) {
|
||||
@@ -133,9 +106,8 @@ export function SihirbazBolumu({
|
||||
};
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
if (kaliciKapali) setKartGizle(true);
|
||||
if (yeniBekleyen) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setBekleyen(yeniBekleyen);
|
||||
bekleyenRef.current = yeniBekleyen;
|
||||
}
|
||||
@@ -151,19 +123,6 @@ export function SihirbazBolumu({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Bölüm ekran dışındaysa (kullanıcı sihirbazı üstteki banner'dan ya da
|
||||
* sabit alt karttan açmış olabilir) modal hâlâ perdeyi kaplarken kaydırırız;
|
||||
* böylece Motion ile giren hedef panel görünür alanda kalır.
|
||||
*/
|
||||
function bolumuGorunureAl() {
|
||||
const el = bolumRef.current;
|
||||
if (!el) return;
|
||||
const kutu = el.getBoundingClientRect();
|
||||
if (kutu.top >= 0 && kutu.bottom <= window.innerHeight) return;
|
||||
el.scrollIntoView({ block: "center", behavior: "instant" });
|
||||
}
|
||||
|
||||
async function modalCikisiniOynat() {
|
||||
if (hareketiAzalt) return;
|
||||
|
||||
@@ -202,85 +161,26 @@ export function SihirbazBolumu({
|
||||
await modalAnimasyonu;
|
||||
}
|
||||
|
||||
async function uret(secimler: SihirbazSecimleri) {
|
||||
if (!requestIdRef.current) requestIdRef.current = crypto.randomUUID();
|
||||
bolumuGorunureAl();
|
||||
/**
|
||||
* Sihirbaz tamamlandı: seçimleri storage'a yaz ve üretimin koştuğu
|
||||
* /listem'e geç. Girişsiz kullanıcı önce girişe uğrar; callback doğrudan
|
||||
* /listem'e düştüğünden üretim dönüşte orada otomatik başlar.
|
||||
*/
|
||||
async function ilerle(secimler: SihirbazSecimleri) {
|
||||
tercihProfiliYaz({ sira, tur, secimler });
|
||||
await modalCikisiniOynat();
|
||||
setElleAcik(false);
|
||||
setDurum("uretiliyor");
|
||||
|
||||
try {
|
||||
const sonuc = await listeOlustur({
|
||||
sira,
|
||||
tur,
|
||||
secimler,
|
||||
requestId: requestIdRef.current,
|
||||
});
|
||||
if (sonuc.ok) {
|
||||
requestIdRef.current = null;
|
||||
olay("liste_olusturuldu", { tur, sira_kovasi: siraKovasi(sira) });
|
||||
setRapor({
|
||||
rapor: sonuc.rapor,
|
||||
params: sonuc.params,
|
||||
revisionCount: sonuc.revisionCount,
|
||||
});
|
||||
setKredi(sonuc.kredi);
|
||||
setPaketli(sonuc.hasPaket);
|
||||
tercihProfiliSil();
|
||||
// Yapay Zeka çıktısı bu sayfada gösterilmez: üretim animasyonu açık kalır,
|
||||
// liste (paketsizse paywall'lı hâliyle) /listem'de karşılar.
|
||||
router.push("/listem");
|
||||
// Header'daki kredi pill'i layout'ta server-render edilir; push layout'u
|
||||
// yeniden çizmediği için düşen bakiye refresh ile güncellenir.
|
||||
router.refresh();
|
||||
return;
|
||||
}
|
||||
// Sunucudan cevap geldiyse harcama ya hiç olmadı ya iade edildi;
|
||||
// sonraki deneme taze requestId ile başlar (aynı id ledger'daki UNIQUE'e
|
||||
// takılıp DUPLICATE çıkmazına giriyordu). requestId yalnızca cevap
|
||||
// alınamayan network hatasında (catch) korunur — orada çifte harcama
|
||||
// riski gerçek.
|
||||
requestIdRef.current = null;
|
||||
if (sonuc.code === "KREDI" || sonuc.code === "PAKET") {
|
||||
setDurum("kredi-yetersiz");
|
||||
} else if (sonuc.code === "AUTH") {
|
||||
secimleriKaydetVeGirise(secimler);
|
||||
} else {
|
||||
toast.error(sonuc.error);
|
||||
modalaDon();
|
||||
}
|
||||
} catch {
|
||||
toast.error("Beklenmeyen bir hata oluştu, tekrar dener misin?");
|
||||
modalaDon();
|
||||
}
|
||||
}
|
||||
|
||||
/** Hata sonrası panelden modala dönüş. */
|
||||
function modalaDon() {
|
||||
setDurum("bos");
|
||||
setElleAcik(true);
|
||||
}
|
||||
|
||||
function secimleriKaydetVeGirise(secimler: SihirbazSecimleri) {
|
||||
tercihProfiliYaz({ sira, tur, secimler });
|
||||
const geri = sonucHref({ sira, tur }, { sihirbaz: "1" });
|
||||
router.push(`/giris?callback=${encodeURIComponent(geri)}`);
|
||||
}
|
||||
|
||||
async function ilerle(secimler: SihirbazSecimleri) {
|
||||
setSonSecimler(secimler);
|
||||
if (!girisliMi) {
|
||||
await modalCikisiniOynat();
|
||||
secimleriKaydetVeGirise(secimler);
|
||||
router.push(`/giris?callback=${encodeURIComponent(LISTEM_URET_HREF)}`);
|
||||
return;
|
||||
}
|
||||
await uret(secimler);
|
||||
router.push(LISTEM_URET_HREF);
|
||||
}
|
||||
|
||||
/**
|
||||
* Funnel CTA'larının (banner, büyük kart, sabit alt kart) ortak girişi:
|
||||
* Funnel CTA'larının (banner, büyük 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.
|
||||
* kullanıcı doğrudan /listem'e gider, 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() {
|
||||
@@ -293,28 +193,28 @@ export function SihirbazBolumu({
|
||||
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);
|
||||
// Giriş dönüşü (?sihirbaz=1) storage'da eşleşen seçimlerle geldiyse modal
|
||||
// açılmaz: seçimler hazır, üretim /listem'de başlasın (bir kez).
|
||||
const otomatikGonder = Boolean(
|
||||
otomatikAc && bekleyen?.eslesir && girisliMi && !raporVar,
|
||||
);
|
||||
useEffect(() => {
|
||||
if (otomatikUret && bekleyen && !otomatikBasladi.current) {
|
||||
if (otomatikGonder && !otomatikBasladi.current) {
|
||||
otomatikBasladi.current = true;
|
||||
setSonSecimler(bekleyen.secimler);
|
||||
void uret(bekleyen.secimler);
|
||||
// replace: geri tuşu bu sayfaya dönünce tekrar /listem'e sekmesin
|
||||
router.replace(LISTEM_URET_HREF);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [otomatikUret]);
|
||||
}, [otomatikGonder]);
|
||||
|
||||
// ?sihirbaz=1: otomatik üretim devreye girmediyse (seçim kaybolduysa/
|
||||
// ?sihirbaz=1: otomatik yönlendirme devreye girmediyse (seçim kaybolduysa/
|
||||
// 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.
|
||||
// hiç gösterilmez, /listem'e geçilir.
|
||||
const acik =
|
||||
!elleKapandi &&
|
||||
(elleAcik ||
|
||||
Boolean(
|
||||
otomatikAc && depoOkundu && !otomatikUret && !rapor && durum === "bos",
|
||||
));
|
||||
Boolean(otomatikAc && depoOkundu && !otomatikGonder && !raporVar));
|
||||
|
||||
function acikDegisti(v: boolean) {
|
||||
if (v) {
|
||||
@@ -326,245 +226,113 @@ export function SihirbazBolumu({
|
||||
}
|
||||
}
|
||||
|
||||
const panelde = Boolean(rapor) || durum !== "bos";
|
||||
|
||||
// Değer önce: sihirbazı bitirmiş ama girişsiz kullanıcı ücretsiz tabloyu
|
||||
// zaten görüyor; giriş burada bariyer değil, hazır seçimlerin (ödülün)
|
||||
// kaydedilmesi olarak sunulur. Girişten dönüşte otomatik üretim başlar.
|
||||
// Sinyal URL'den (?hazir=1) gelir; storage'a bakılsaydı panel anahtarı
|
||||
// mount sonrası değişir ve AnimatePresence geçişi donardı.
|
||||
const girisBekliyor = Boolean(!girisliMi && secimlerHazir && !rapor);
|
||||
const girisUrl = `/giris?callback=${encodeURIComponent(
|
||||
sonucHref({ sira, tur }, { sihirbaz: "1" }),
|
||||
)}`;
|
||||
|
||||
const panelAnahtari =
|
||||
durum !== "bos"
|
||||
? durum
|
||||
: rapor
|
||||
? "rapor-hazir"
|
||||
: girisBekliyor
|
||||
? "giris-cta"
|
||||
: "sihirbaz-cta";
|
||||
// kaydedilmesi olarak sunulur. Girişten dönüşte üretim /listem'de başlar.
|
||||
// Sinyal URL'den (?hazir=1) gelir ki SSR'da doğru panel render edilsin.
|
||||
const girisBekliyor = Boolean(!girisliMi && secimlerHazir && !raporVar);
|
||||
const girisUrl = `/giris?callback=${encodeURIComponent(LISTEM_URET_HREF)}`;
|
||||
// Kapı 1 ölçümü: panel yalnızca girisBekliyor dalında render edilir; ref
|
||||
// başka dalda bağlanmadığından observer da yalnızca o durumda kurulur.
|
||||
const hazirPanelRef = useGorunumOlayi<HTMLElement>("hazir_panel_goruntulendi");
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Yapay Zeka çıktısı (rapor listesi/haritası) bu sayfada render edilmez;
|
||||
panel yalnızca üretim ve kredi-yetersiz gibi geçici funnel
|
||||
durumlarını gösterir, listenin kendisi /listem'de yaşar. */}
|
||||
<div ref={bolumRef} className="relative">
|
||||
<AnimatePresence mode="popLayout" initial={false}>
|
||||
<motion.div
|
||||
key={panelAnahtari}
|
||||
initial={
|
||||
hareketiAzalt
|
||||
? { opacity: 0 }
|
||||
: {
|
||||
opacity: 0,
|
||||
transform: "translate3d(0, 8px, 0)",
|
||||
}
|
||||
}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
transform: "translate3d(0, 0, 0)",
|
||||
transition: hareketiAzalt
|
||||
? { duration: 0.12, ease: "easeOut" }
|
||||
: {
|
||||
duration: 0.28,
|
||||
ease: [0.23, 1, 0.32, 1],
|
||||
},
|
||||
}}
|
||||
exit={
|
||||
hareketiAzalt
|
||||
? {
|
||||
opacity: 0,
|
||||
transition: { duration: 0.08, ease: "easeOut" },
|
||||
}
|
||||
: {
|
||||
opacity: 0,
|
||||
transform: "translate3d(0, -4px, 0)",
|
||||
transition: {
|
||||
duration: 0.14,
|
||||
ease: [0.4, 0, 0.7, 0.2],
|
||||
},
|
||||
}
|
||||
}
|
||||
{/* Yapay Zeka çıktısı (üretim animasyonu dahil) bu sayfada render
|
||||
edilmez; panel yalnızca funnel CTA'larını gösterir, üretim ve
|
||||
listenin kendisi /listem'de yaşar. */}
|
||||
{raporVar ? (
|
||||
<section className="mt-16 rounded-2xl border border-slate-200 bg-white p-8 text-center sm:p-12">
|
||||
<h2 className="font-heading text-2xl font-bold sm:text-3xl">
|
||||
24 tercihlik listen hazır
|
||||
</h2>
|
||||
<p className="mx-auto mt-3 max-w-xl text-slate-600">
|
||||
Yapay zekânın kurduğu risk analizli listen Listem sayfasında
|
||||
seni bekliyor.
|
||||
</p>
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="mt-6 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||
>
|
||||
{durum !== "bos" ? (
|
||||
<ListePaneli
|
||||
durum={durum}
|
||||
rapor={null}
|
||||
hasPaket={paketli}
|
||||
kredi={kredi}
|
||||
sira={sira}
|
||||
acilis={acilis}
|
||||
onRevize={async (feedback) => {
|
||||
if (!revizyonIdRef.current) {
|
||||
revizyonIdRef.current = crypto.randomUUID();
|
||||
}
|
||||
const sonuc = await listeRevize({
|
||||
feedback,
|
||||
requestId: revizyonIdRef.current,
|
||||
});
|
||||
revizyonIdRef.current = null;
|
||||
if (sonuc.ok) {
|
||||
olay("liste_revize", { sayfa: "sonuc" });
|
||||
setRapor({
|
||||
rapor: sonuc.rapor,
|
||||
params: sonuc.params,
|
||||
revisionCount: sonuc.revisionCount,
|
||||
});
|
||||
setKredi(sonuc.kredi);
|
||||
toast.success("Listen yeniden kuruldu.");
|
||||
} else {
|
||||
toast.error(sonuc.error);
|
||||
}
|
||||
return sonuc.ok;
|
||||
}}
|
||||
/>
|
||||
) : rapor ? (
|
||||
<section className="mt-16 rounded-2xl border border-slate-200 bg-white p-8 text-center sm:p-12">
|
||||
<h2 className="font-heading text-2xl font-bold sm:text-3xl">
|
||||
24 tercihlik listen hazır
|
||||
</h2>
|
||||
<p className="mx-auto mt-3 max-w-xl text-slate-600">
|
||||
Yapay zekânın kurduğu risk analizli listen Listem sayfasında
|
||||
seni bekliyor.
|
||||
</p>
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="mt-6 h-12 cursor-pointer bg-orange-500 px-8 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||
>
|
||||
<Link href="/listem">Listeni gör</Link>
|
||||
</Button>
|
||||
</section>
|
||||
) : girisBekliyor ? (
|
||||
<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 — Yapay Zeka listen bir adım uzakta
|
||||
</h2>
|
||||
<p className="relative mx-auto mt-3 max-w-xl text-white/90">
|
||||
Yukarıdaki tablo her zaman ücretsiz. 5 deneme kredinle 24
|
||||
tercihlik Yapay Zeka listeni de kurabilirsin: ücretsiz giriş yap,
|
||||
seçimlerin kayıtlı — listen otomatik kurulsun (üretim 3
|
||||
kredi, kalan 2 kredinle danışmana soru sorarsın).
|
||||
</p>
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
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}>
|
||||
5 deneme kredinle Yapay Zeka listeni kur — giriş yap
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Link>
|
||||
</Button>
|
||||
<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="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="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={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"
|
||||
>
|
||||
Yapay Zeka listemi kur
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Button>
|
||||
</section>
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Sabit alt çağrı kartı — liste/panel yokken ve kart kapatılmadıysa */}
|
||||
<ViewportPortal>
|
||||
{monteEdildi && !panelde && !kartGizle && !acik ? (
|
||||
<div className="pointer-events-none fixed inset-x-0 bottom-4 z-40 px-4">
|
||||
<div className="pointer-events-auto mx-auto flex max-w-xl items-center gap-4 rounded-2xl border border-slate-200 bg-white p-4 shadow-xl">
|
||||
<div className="flex-1">
|
||||
<p className="font-heading text-sm font-bold text-slate-900">
|
||||
{girisBekliyor
|
||||
? "Seçimlerin kayıtlı — Yapay Zeka listen hazır bekliyor"
|
||||
: "Tek tek ekleme, yapay zekâ kursun"}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{girisBekliyor
|
||||
? "Ücretsiz giriş yap, 5 deneme kredinle listen otomatik kurulsun."
|
||||
: "Üç adımda seçimlerini söyle; risk analizli, gerekçeli 24 tercih hazır."}
|
||||
</p>
|
||||
</div>
|
||||
{girisBekliyor ? (
|
||||
<Button
|
||||
asChild
|
||||
size="sm"
|
||||
className="cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||
>
|
||||
<Link href={girisUrl}>Giriş yap</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={sihirbaziBaslat}
|
||||
className="cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||
>
|
||||
Başla
|
||||
</Button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Kapat"
|
||||
onClick={() => {
|
||||
setKartGizle(true);
|
||||
try {
|
||||
sessionStorage.setItem(KAPATMA_KEY, "1");
|
||||
} catch {}
|
||||
}}
|
||||
className="cursor-pointer rounded-full p-1 text-slate-400 transition-colors duration-200 hover:bg-slate-100 hover:text-slate-600"
|
||||
>
|
||||
<X className="size-4" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
<Link href="/listem">Listeni gör</Link>
|
||||
</Button>
|
||||
</section>
|
||||
) : girisBekliyor ? (
|
||||
<section
|
||||
ref={hazirPanelRef}
|
||||
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>
|
||||
) : null}
|
||||
</ViewportPortal>
|
||||
<h2 className="relative font-heading text-2xl font-bold sm:text-3xl">
|
||||
Seçimlerin hazır — Yapay Zeka listen bir adım uzakta
|
||||
</h2>
|
||||
<p className="relative mx-auto mt-3 max-w-xl text-white/90">
|
||||
Yukarıdaki tablo her zaman ücretsiz. 5 deneme kredinle 24
|
||||
tercihlik Yapay Zeka listeni de kurabilirsin: ücretsiz giriş yap,
|
||||
seçimlerin kayıtlı — listen otomatik kurulsun (üretim 3
|
||||
kredi, kalan 2 kredinle danışmana soru sorarsın).
|
||||
</p>
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
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}
|
||||
onClick={() =>
|
||||
olay("giris_cta_tiklandi", { kaynak: "hazir_panel" })
|
||||
}
|
||||
>
|
||||
5 deneme kredinle Yapay Zeka listeni kur — giriş yap
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Link>
|
||||
</Button>
|
||||
<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="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="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={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"
|
||||
>
|
||||
Yapay Zeka listemi kur
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Button>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Üretim başladığında modalı kaldır; yeni panelin geçişini Motion
|
||||
üstlenir ve odağı panel içeriğine taşır. */}
|
||||
{monteEdildi && durum === "bos" ? (
|
||||
{monteEdildi ? (
|
||||
<SihirbazModal
|
||||
acik={acik}
|
||||
onAcikDegisti={acikDegisti}
|
||||
sira={sira}
|
||||
tur={tur}
|
||||
facetler={facetler}
|
||||
baslangicSecimler={
|
||||
sonSecimler ?? (bekleyen?.eslesir ? bekleyen.secimler : null)
|
||||
}
|
||||
baslangicSecimler={bekleyen?.eslesir ? bekleyen.secimler : null}
|
||||
sonButonEtiketi={
|
||||
girisliMi ? "Yapay Zeka listemi kur (3 kredi)" : "Giriş yap ve listeni gör"
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import type { SihirbazSecimleri } from "@/lib/sihirbaz";
|
||||
|
||||
/**
|
||||
* Sihirbaz modalı artık yalnızca adımları toplar; üretim, önizleme ve liste
|
||||
* sonuç sayfası gövdesinde yaşar (bkz. liste-paneli.tsx). Akış yönetimi
|
||||
* /listem'de yaşar (bkz. listem/liste-uretici.tsx). Akış yönetimi
|
||||
* sihirbaz-cagri-karti.tsx'teki SihirbazBolumu'ndadır.
|
||||
*/
|
||||
export function SihirbazModal({
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
"use client";
|
||||
|
||||
// Yapay Zeka danışman sohbeti — sayfa altında yüzen buton + intercom tarzı popup.
|
||||
// Motion dili: panel tetikleyicisinden doğar (transform-origin sağ-alt),
|
||||
// spring ile açılır; çıkış girişten hızlı. Reduced motion'da cross-fade.
|
||||
|
||||
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: () => <SohbetSkeleton />,
|
||||
},
|
||||
);
|
||||
|
||||
export function SohbetPopup({ kredi }: { kredi: number }) {
|
||||
const [acik, setAcik] = useState(false);
|
||||
const azMotion = useReducedMotion();
|
||||
|
||||
useEffect(() => {
|
||||
if (!acik) return;
|
||||
const dinle = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setAcik(false);
|
||||
};
|
||||
window.addEventListener("keydown", dinle);
|
||||
return () => window.removeEventListener("keydown", dinle);
|
||||
}, [acik]);
|
||||
|
||||
return (
|
||||
<ViewportPortal>
|
||||
<AnimatePresence>
|
||||
{acik ? (
|
||||
<motion.div
|
||||
key="sohbet"
|
||||
initial={
|
||||
azMotion ? { opacity: 0 } : { opacity: 0, y: 24, scale: 0.95 }
|
||||
}
|
||||
animate={azMotion ? { opacity: 1 } : { opacity: 1, y: 0, scale: 1 }}
|
||||
exit={
|
||||
azMotion
|
||||
? { opacity: 0, transition: { duration: 0.15 } }
|
||||
: {
|
||||
opacity: 0,
|
||||
y: 16,
|
||||
scale: 0.97,
|
||||
transition: { duration: 0.18, ease: [0.23, 1, 0.32, 1] },
|
||||
}
|
||||
}
|
||||
transition={{ type: "spring", duration: 0.4, bounce: 0.15 }}
|
||||
style={{ transformOrigin: "bottom right" }}
|
||||
className="fixed bottom-24 right-4 z-50 h-[min(72dvh,560px)] w-[min(100vw-2rem,420px)] print:hidden"
|
||||
>
|
||||
<SohbetClient
|
||||
baslangicKredi={kredi}
|
||||
className="h-full shadow-2xl"
|
||||
/>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Yüzen tetikleyici — basışta anında küçülür (pointer-down feedback) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAcik((v) => !v)}
|
||||
aria-expanded={acik}
|
||||
className="fixed bottom-6 right-4 z-50 flex h-13 cursor-pointer items-center gap-2 rounded-full bg-slate-900 py-3 pl-4 pr-5 font-heading text-sm font-bold text-white shadow-xl transition-[transform,background-color] duration-150 hover:bg-slate-700 active:scale-[0.95] print:hidden"
|
||||
>
|
||||
{acik ? (
|
||||
<X className="size-5" aria-hidden />
|
||||
) : (
|
||||
<MessageCircleMore className="size-5" aria-hidden />
|
||||
)}
|
||||
{acik ? "Kapat" : "Danışmana sor"}
|
||||
</button>
|
||||
</ViewportPortal>
|
||||
);
|
||||
}
|
||||
173
src/app/sonuc/tadimlik-satiri.tsx
Normal file
173
src/app/sonuc/tadimlik-satiri.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
"use client";
|
||||
|
||||
// Girişsiz yapay zekâ tadımlığı (PRD İP-B): /sonuc'ta, sıra kovası + (varsa)
|
||||
// sihirbaz alan tercihine uyan TEK örnek tercih satırı — gerçek rapor
|
||||
// satırının detay bloğuyla (RaporSatirDetayi) birebir aynı formatta.
|
||||
// İçerik batch üretilmiş havuzdan gelir; bu bileşen LLM'e istek attırmaz.
|
||||
// SSR "genel" satırı render eder (prop), mount sonrası sihirbaz kategorisi
|
||||
// varsa /api/tadimlik'ten kategoriye özel satır çekilip yerinde değiştirilir
|
||||
// (seçim sunucuda kalır). Havuzda içerik yoksa hiç render edilmez.
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { UniLogo } from "@/components/uni-logo";
|
||||
import { SectionEyebrow } from "@/components/pixel-decor";
|
||||
import { RaporSatirDetayi } from "@/components/rapor-listesi";
|
||||
import {
|
||||
ProgramSiraGecmisi,
|
||||
ProgramTabanTrendi,
|
||||
programEtkinSira,
|
||||
raporSiraSerisi,
|
||||
} from "@/components/program-liste-verileri";
|
||||
import { useGorunumOlayi } from "@/components/use-gorunum-olayi";
|
||||
import { olay, siraKovasi } from "@/lib/analitik";
|
||||
import { dilimdenRisk, riskHesapla } from "@/lib/risk";
|
||||
import { turkishSlugify } from "@/lib/slug";
|
||||
import {
|
||||
PROFIL_DEGISTI_EVENT,
|
||||
sonucHref,
|
||||
tercihProfiliOku,
|
||||
} from "@/lib/sihirbaz";
|
||||
import type { TadimlikSatir } from "@/lib/tadimlik-havuzu";
|
||||
|
||||
const GENEL = "genel";
|
||||
|
||||
export function TadimlikSatiri({
|
||||
sira,
|
||||
tur,
|
||||
ilk,
|
||||
}: {
|
||||
sira: number;
|
||||
tur: string;
|
||||
/** SSR'da seçilmiş "genel" satır; havuz boşsa null. */
|
||||
ilk: TadimlikSatir | null;
|
||||
}) {
|
||||
const [satir, setSatir] = useState<TadimlikSatir | null>(ilk);
|
||||
const yuklenenSlug = useRef(GENEL);
|
||||
|
||||
// Sihirbaz kategorisi yalnızca localStorage'da yaşar; mount'ta ve profil
|
||||
// değiştikçe okunur, kategoriye özel satır sunucudan çekilir.
|
||||
useEffect(() => {
|
||||
const guncelle = () => {
|
||||
const profil = tercihProfiliOku();
|
||||
const kategori = profil?.secimler.kategoriler[0];
|
||||
const slug = kategori ? turkishSlugify(kategori) : GENEL;
|
||||
if (slug === yuklenenSlug.current) return;
|
||||
yuklenenSlug.current = slug;
|
||||
if (slug === GENEL) {
|
||||
setSatir(ilk);
|
||||
return;
|
||||
}
|
||||
fetch(
|
||||
`/api/tadimlik?sira=${sira}&tur=${encodeURIComponent(tur)}&kategori=${encodeURIComponent(slug)}`,
|
||||
)
|
||||
.then((yanit) => (yanit.ok ? yanit.json() : null))
|
||||
.then((veri: { tadimlik: TadimlikSatir | null } | null) => {
|
||||
// Yarış koruması: bu arada başka kategori yüklendiyse dokunma
|
||||
if (yuklenenSlug.current !== slug) return;
|
||||
if (veri?.tadimlik) setSatir(veri.tadimlik);
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
guncelle();
|
||||
window.addEventListener(PROFIL_DEGISTI_EVENT, guncelle);
|
||||
return () => window.removeEventListener(PROFIL_DEGISTI_EVENT, guncelle);
|
||||
}, [sira, tur, ilk]);
|
||||
|
||||
const gorunumRef = useGorunumOlayi<HTMLElement>("tadimlik_goruntulendi", {
|
||||
kova: siraKovasi(sira),
|
||||
});
|
||||
|
||||
if (!satir) return null;
|
||||
|
||||
const p = satir.program;
|
||||
const risk = riskHesapla(p.efektifSira, sira) ?? dilimdenRisk(satir.dilim);
|
||||
const seri = raporSiraSerisi(p.siraGecmisi);
|
||||
const taban = p.efektifSira ?? programEtkinSira(seri);
|
||||
const devlet = p.unitur === "DEVLET";
|
||||
const girisUrl = `/giris?callback=${encodeURIComponent(sonucHref({ sira, tur }))}`;
|
||||
|
||||
return (
|
||||
<section ref={gorunumRef} className="mt-16 min-w-0">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<SectionEyebrow>Yapay zekâdan örnek satır</SectionEyebrow>
|
||||
<h2 className="mt-4 font-heading text-2xl font-bold sm:text-3xl">
|
||||
Yapay zekâ listendeki her satırı böyle anlatır
|
||||
</h2>
|
||||
<p className="mx-auto mt-3 max-w-xl text-slate-600">
|
||||
Sıralamana uygun bir programın gerekçesi, yerleşme riski ve taban
|
||||
trendi — 24 tercihlik listenin her satırı bu formatta kurulur.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 rounded-2xl border border-slate-200 bg-white p-4 shadow-sm sm:p-6">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2.5">
|
||||
<UniLogo ad={p.universite} boy="sm" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-semibold">
|
||||
{p.isim}
|
||||
</span>
|
||||
<span className="mt-0.5 flex items-center gap-1.5 text-xs text-slate-500">
|
||||
{p.unitur ? (
|
||||
<span
|
||||
className={`shrink-0 rounded px-1 py-px text-[10px] font-medium leading-4 ${
|
||||
devlet
|
||||
? "bg-slate-100 text-slate-600"
|
||||
: "bg-violet-50 text-violet-600"
|
||||
}`}
|
||||
>
|
||||
{devlet ? "Devlet" : "Vakıf"}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="truncate">
|
||||
{p.universite}
|
||||
{p.il ? ` · ${p.il.toLocaleLowerCase("tr-TR")}` : ""}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className="hidden text-right text-xs md:inline-flex">
|
||||
<ProgramSiraGecmisi program={seri} />
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1 text-xs tabular-nums text-slate-500">
|
||||
<ProgramTabanTrendi program={seri} />
|
||||
{taban != null ? `~${taban.toLocaleString("tr-TR")}.` : "—"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 rounded-lg bg-slate-50/60 px-4 py-4">
|
||||
<RaporSatirDetayi
|
||||
dilim={satir.dilim}
|
||||
risk={risk}
|
||||
unitur={p.unitur}
|
||||
gerekce={satir.gerekce}
|
||||
riskNotu={satir.riskNotu}
|
||||
trendOzeti={satir.trendOzeti}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex flex-col items-start gap-3 border-t border-slate-100 pt-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-sm text-slate-600">
|
||||
Bu, 24 satırlık Yapay Zeka listenden yalnızca bir örnek.
|
||||
<span className="block text-xs text-slate-500">
|
||||
Kredi kartı gerekmez · Google ya da e-posta ile 30 saniyede
|
||||
</span>
|
||||
</p>
|
||||
<Button
|
||||
asChild
|
||||
className="h-11 w-full shrink-0 cursor-pointer bg-orange-500 text-white transition-[background-color,transform] duration-200 active:scale-[0.97] hover:bg-orange-600 sm:w-auto"
|
||||
>
|
||||
<Link
|
||||
href={girisUrl}
|
||||
onClick={() => olay("giris_cta_tiklandi", { kaynak: "tadimlik" })}
|
||||
>
|
||||
24 satırın tamamı için giriş yap — 5 deneme kredin hazır
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user