/sonuc'taki otomatik üretim kaynağı sabit "giris_donusu" yazıyordu; oysa ?sihirbaz=1 üreten hiçbir yer giriş dönüşü değil — gerçek giriş dönüşü /listem'e doğrudan iniyor, /sonuc'a uğramıyor. Hero, CTA formu, arama kartı, + kapısı, boş liste ekranı ve rapor-yok CTA'sı tek yanlış kovada toplanıyordu, yani P2'nin ölçmek istediği soru ölçülmüyordu. Etiket artık ?sihirbaz=1'i üreten tarafta kuruluyor ve /sonuc üzerinden geçiriliyor; kapıdan gelenler tetikten türetiliyor. giris_donusu yalnız gerçek callback yolunda kalıyor. Güvenlik denetimi G3 · docs/ekip-raporlari/2026-09-22/07-guvenlik-denetimi.md Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
371 lines
14 KiB
TypeScript
371 lines
14 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
|
||
import Link from "next/link";
|
||
import { useRouter } from "next/navigation";
|
||
import { ArrowRight } from "lucide-react";
|
||
import { animate } from "motion/mini";
|
||
import { useReducedMotion } from "motion/react";
|
||
import { Button } from "@/components/ui/button";
|
||
import { PixelField } from "@/components/pixel-decor";
|
||
import { useGorunumOlayi } from "@/components/use-gorunum-olayi";
|
||
import { olay, olayBirKez, siraKovasi } from "@/lib/analitik";
|
||
import type { SihirbazFacetleri } from "@/types/yokatlas";
|
||
import { SIHIRBAZ_AC_EVENT, SIHIRBAZ_STORAGE_KEY, sihirbazDogrula, type SihirbazSecimleri } from "@/features/sihirbaz/sihirbaz-sabitler";
|
||
import { tercihProfiliYaz } from "@/features/sihirbaz/sihirbaz-profil";
|
||
import { SihirbazModal } from "./sihirbaz-modal";
|
||
|
||
// Ü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.
|
||
// `kaynak` denemenin hangi yüzeyden başladığını ölçer (bkz.
|
||
// rapor/components/uretim-basladi-olayi.tsx — izinli değerler orada).
|
||
// `uret=1` HER ZAMAN ilk parametre kalmalı: giris-karti.tsx:31 funnel
|
||
// metnini `callbackURL.startsWith("/listem?uret=1")` ile seçiyor, sıra
|
||
// bozulursa girişteki "Seçimlerin hazır" metni kaybolur.
|
||
function listemUretHref(kaynak: string) {
|
||
return `/listem?uret=1&kaynak=${kaynak}`;
|
||
}
|
||
|
||
// SSR'da false, client'ta true — storage'a ancak mount sonrası dokunuruz
|
||
const bosAbone = () => () => {};
|
||
const useMonteEdildi = () =>
|
||
useSyncExternalStore(
|
||
bosAbone,
|
||
() => true,
|
||
() => false,
|
||
);
|
||
|
||
type BekleyenSecim = {
|
||
secimler: SihirbazSecimleri;
|
||
eslesir: boolean; // storage'daki sıra/tur bu sayfayla aynı mı
|
||
};
|
||
|
||
/**
|
||
* Sonuç sayfasında sihirbaz akışını yöneten client orkestratör:
|
||
* - Sayfa ortasındaki büyük CTA (rapor yokken)
|
||
* - Adımları toplayan modal
|
||
* 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,
|
||
raporVar,
|
||
otomatikAc,
|
||
kaynak,
|
||
secimlerHazir,
|
||
}: {
|
||
sira: number;
|
||
tur: string;
|
||
facetler: SihirbazFacetleri;
|
||
girisliMi: boolean;
|
||
/** 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;
|
||
kaynak?: string;
|
||
/** ?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. */
|
||
secimlerHazir?: boolean;
|
||
}) {
|
||
const router = useRouter();
|
||
const monteEdildi = useMonteEdildi();
|
||
const hareketiAzalt = useReducedMotion();
|
||
|
||
const [elleAcik, setElleAcik] = useState(false);
|
||
const [elleKapandi, setElleKapandi] = 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 otomatikBasladi = useRef(false);
|
||
// 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);
|
||
|
||
// Huni adımı: sonuç sayfası görüntülendi. URL sıralamayı taşıdığı için
|
||
// pathname maskeli (bkz. app/layout.tsx); huni bu olayla kurulur. Oturum
|
||
// başına girişli/anonim için birer kez — girişten dönen ziyaretçi ikinci
|
||
// kez "girisli" olarak sayılır. Ham sıra gönderilmez, yalnızca kovası.
|
||
useEffect(() => {
|
||
olayBirKez(
|
||
"sonuc_goruntulendi",
|
||
{
|
||
girisli: girisliMi ? "evet" : "hayir",
|
||
sira_kovasi: siraKovasi(sira),
|
||
},
|
||
girisliMi ? "girisli" : "anonim",
|
||
);
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
// Mount sonrası storage'dan bekleyen seçimleri oku
|
||
useEffect(() => {
|
||
try {
|
||
const ham = localStorage.getItem(SIHIRBAZ_STORAGE_KEY);
|
||
let yeniBekleyen: BekleyenSecim | null = null;
|
||
if (ham) {
|
||
const veri = JSON.parse(ham) as {
|
||
sira?: number;
|
||
tur?: string;
|
||
secimler?: unknown;
|
||
};
|
||
const secimler = sihirbazDogrula(veri.secimler);
|
||
if (secimler) {
|
||
yeniBekleyen = {
|
||
secimler,
|
||
eslesir: veri.sira === sira && veri.tur === tur,
|
||
};
|
||
}
|
||
}
|
||
if (yeniBekleyen) {
|
||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||
setBekleyen(yeniBekleyen);
|
||
bekleyenRef.current = yeniBekleyen;
|
||
}
|
||
} catch {}
|
||
setDepoOkundu(true);
|
||
}, [sira, tur]);
|
||
|
||
// Arama modalındaki funnel kartının CTA'sı sihirbazı bu olayla başlatır
|
||
useEffect(() => {
|
||
const ac = () => sihirbaziBaslat();
|
||
window.addEventListener(SIHIRBAZ_AC_EVENT, ac);
|
||
return () => window.removeEventListener(SIHIRBAZ_AC_EVENT, ac);
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
async function modalCikisiniOynat() {
|
||
if (hareketiAzalt) return;
|
||
|
||
const modal = document.querySelector<HTMLElement>("[data-sihirbaz-modal]");
|
||
if (!modal) return;
|
||
|
||
const mevcutTransform = getComputedStyle(modal).transform;
|
||
const hedefTransform =
|
||
mevcutTransform === "none"
|
||
? "translate3d(0, 48px, 0)"
|
||
: new DOMMatrixReadOnly(mevcutTransform).translate(0, 48).toString();
|
||
const perde = document.querySelector<HTMLElement>(
|
||
'[data-slot="dialog-overlay"]',
|
||
);
|
||
|
||
const modalAnimasyonu = animate(
|
||
modal,
|
||
{
|
||
opacity: 0,
|
||
transform: hedefTransform,
|
||
},
|
||
{
|
||
duration: 0.24,
|
||
ease: [0.4, 0, 0.7, 0.2],
|
||
},
|
||
);
|
||
|
||
if (perde) {
|
||
await Promise.all([
|
||
modalAnimasyonu,
|
||
animate(perde, { opacity: 0 }, { duration: 0.2, ease: "easeOut" }),
|
||
]);
|
||
return;
|
||
}
|
||
|
||
await modalAnimasyonu;
|
||
}
|
||
|
||
/**
|
||
* 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);
|
||
if (!girisliMi) {
|
||
router.push(
|
||
`/giris?callback=${encodeURIComponent(listemUretHref("sihirbaz"))}`,
|
||
);
|
||
return;
|
||
}
|
||
router.push(listemUretHref("sihirbaz"));
|
||
}
|
||
|
||
/**
|
||
* 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ı 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() {
|
||
const hazir = bekleyenRef.current;
|
||
if (hazir?.eslesir) {
|
||
void ilerle(hazir.secimler);
|
||
return;
|
||
}
|
||
setElleAcik(true);
|
||
setElleKapandi(false);
|
||
}
|
||
|
||
// 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 (otomatikGonder && !otomatikBasladi.current) {
|
||
otomatikBasladi.current = true;
|
||
// replace: geri tuşu bu sayfaya dönünce tekrar /listem'e sekmesin
|
||
// Etiket ?sihirbaz=1'i ÜRETEN yüzeyden gelir. Eskiden burası sabit
|
||
// "giris_donusu" yazıyordu; oysa gerçek giriş dönüşü /listem'e
|
||
// doğrudan iner, /sonuc'a hiç uğramaz — hero, katalog, arama, + ve
|
||
// ödeme-sonrası tek yanlış kovada toplanıyordu.
|
||
router.replace(listemUretHref(kaynak ?? "bilinmiyor"));
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [otomatikGonder]);
|
||
|
||
// ?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, /listem'e geçilir.
|
||
const acik =
|
||
!elleKapandi &&
|
||
(elleAcik ||
|
||
Boolean(otomatikAc && depoOkundu && !otomatikGonder && !raporVar));
|
||
|
||
function acikDegisti(v: boolean) {
|
||
if (v) {
|
||
setElleAcik(true);
|
||
setElleKapandi(false);
|
||
} else {
|
||
setElleKapandi(true);
|
||
setElleAcik(false);
|
||
}
|
||
}
|
||
|
||
// 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 ü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(listemUretHref("hazir_cta"))}`;
|
||
// 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ı (ü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"
|
||
>
|
||
<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>
|
||
<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 güvenli 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>
|
||
)}
|
||
|
||
{monteEdildi ? (
|
||
<SihirbazModal
|
||
acik={acik}
|
||
onAcikDegisti={acikDegisti}
|
||
sira={sira}
|
||
tur={tur}
|
||
facetler={facetler}
|
||
baslangicSecimler={bekleyen?.eslesir ? bekleyen.secimler : null}
|
||
sonButonEtiketi={
|
||
girisliMi ? "Yapay Zeka listemi kur (3 kredi)" : "Giriş yap ve listeni gör"
|
||
}
|
||
onTamamla={ilerle}
|
||
/>
|
||
) : null}
|
||
</>
|
||
);
|
||
}
|