feat(ux): /sonuc'a tek cümlelik cevap, liste durumu ve kilometre taşları

- Sayfanın h1'i yoktu: "Sıralamanla açılan programlar" + "85.000.
  sıradasın (Sayısal). 4.760 program sana açık" başlığı en üstte;
  tadımlık altında (375 px'te hâlâ ilk ekranda, 474 px)
- Başlık altında liste durumu: boşsa + ipucu, doluysa "Listende 3/24
  program · 21 yer kaldı" düğmesi çekmeceyi açar
- + ile ilk programda ve 24/24'te tek seferlik sakin onay; liste
  doluyken ne yapılacağını söyleyen mesaj
- Bildirimler 16 px başlık, okunur açıklama, sitenin köşesiyle
- Başlık ve tablo aynı sorguyu React cache ile paylaşıyor

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bilalgursen
2026-09-24 01:23:20 +03:00
parent d82d59df10
commit f4df52000b
7 changed files with 172 additions and 16 deletions

View File

@@ -7,6 +7,7 @@ import {
PROFIL_CEREZ_ADI, PROFIL_CEREZ_ADI,
} from "@/features/sihirbaz/sihirbaz-sabitler"; } from "@/features/sihirbaz/sihirbaz-sabitler";
import { import {
SonucBasligi,
SonucProgramTablosu, SonucProgramTablosu,
SonucProgramTablosuSkeleton, SonucProgramTablosuSkeleton,
} from "@/features/liste/components/sonuc-program-tablosu"; } from "@/features/liste/components/sonuc-program-tablosu";
@@ -133,7 +134,16 @@ export default function SonucPage({
boyada haritanın tam ölçülü kutusunu çizer, chunk inince boyada haritanın tam ölçülü kutusunu çizer, chunk inince
kutunun yalnızca İÇİ dolar. Haritanın yerini bir daha kutunun yalnızca İÇİ dolar. Haritanın yerini bir daha
aşağı almadan önce bu mekanizmayı kontrol et. */} aşağı almadan önce bu mekanizmayı kontrol et. */}
<ManuelHarita yerAyir={haritayaYerAyir} /> {/* Sayfanın tek cümlelik cevabı ve h1'i: öğrenci önce kendi
sonucunu görür (DESIGN.md §10). Harita ve tadımlık altında. */}
<SonucBasligi
sira={sira}
tur={turKey}
iller={iller}
uniturGrubu={uniturGrubu}
/>
<ManuelHarita yerAyir={haritayaYerAyir} className="mt-10" />
{/* Girişsiz tadımlık: gerçek rapor satırı formatında tek örnek. {/* Girişsiz tadımlık: gerçek rapor satırı formatında tek örnek.
Havuzda içerik yoksa bileşen hiç render edilmez. Listesi Havuzda içerik yoksa bileşen hiç render edilmez. Listesi

View File

@@ -37,8 +37,13 @@ const Toaster = ({ ...props }: ToasterProps) => {
} as React.CSSProperties } as React.CSSProperties
} }
toastOptions={{ toastOptions={{
// Şişko ve sade (DESIGN.md §10): başlık 16 px, açıklama okunur
// koyulukta; kutu sitenin kart köşesiyle aynı.
classNames: { classNames: {
toast: "cn-toast", toast: "cn-toast !rounded-2xl !border-slate-200 !p-4 !shadow-lg",
title: "!text-base !font-semibold !text-slate-900",
description: "!text-sm !leading-relaxed !text-slate-600",
icon: "!text-primary",
}, },
}} }}
{...props} {...props}

View File

@@ -0,0 +1,55 @@
"use client";
import { ArrowRight, Plus } from "lucide-react";
import {
MANUEL_LISTE_MAX,
cekmeceAc,
useListeHidreMi,
useManuelListe,
} from "../liste-store";
/**
* /sonuc başlığının altındaki "listen nerede?" satırı (DESIGN.md §10):
* öğrenci kendi kurduğu listenin durumunu sayıyla görür (hedef eğimi:
* "5/24"), tek dokunuşla çekmeceyi açar. Liste boşsa ne yapılacağını söyler.
* Liste localStorage'da yaşadığı için hidrasyondan önce yer tutucu çizilir,
* satır zıplamasın.
*/
export function ListeDurumuSatiri() {
const hidre = useListeHidreMi();
const liste = useManuelListe();
if (!hidre) return <div className="mt-5 h-11" aria-hidden />;
if (liste.length === 0) {
return (
<p className="mx-auto mt-5 max-w-sm text-sm leading-relaxed text-slate-500 lg:mx-0 lg:max-w-none">
<span className="mr-1.5 inline-flex size-6 items-center justify-center rounded-full border border-slate-200 bg-white align-middle text-slate-500">
<Plus className="size-3.5" aria-hidden />
<span className="sr-only">Artı düğmesi</span>
</span>
ile beğendiğin programı listene ekle; sırasını sonra sen belirlersin.
</p>
);
}
const kalan = MANUEL_LISTE_MAX - liste.length;
return (
<div className="mt-5 flex justify-center lg:justify-start">
<button
type="button"
onClick={cekmeceAc}
className="group inline-flex h-11 cursor-pointer items-center gap-2 rounded-full border border-slate-200 bg-white px-4 text-sm font-semibold text-slate-800 transition-[background-color,border-color,color,transform] duration-150 hover:border-orange-500 hover:bg-orange-50/60 hover:text-orange-500 active:scale-[0.97]"
>
Listende {liste.length}/{MANUEL_LISTE_MAX} program
<span className="font-medium text-slate-500 group-hover:text-orange-500">
{kalan > 0 ? `· ${kalan} yer kaldı` : "· liste tamam"}
</span>
<ArrowRight
className="size-4 transition-transform duration-150 group-hover:translate-x-0.5"
aria-hidden
/>
</button>
</div>
);
}

View File

@@ -20,7 +20,13 @@ const RISK_AGIRLIK: Record<RiskSeviyesi, number> = {
riskli: 2, riskli: 2,
}; };
export function ManuelHarita({ yerAyir = false }: { yerAyir?: boolean }) { export function ManuelHarita({
yerAyir = false,
className,
}: {
yerAyir?: boolean;
className?: string;
}) {
const liste = useManuelListe(); const liste = useManuelListe();
const hidre = useListeHidreMi(); const hidre = useListeHidreMi();
const [seciliIl, setSeciliIl] = useState<string | null>(null); const [seciliIl, setSeciliIl] = useState<string | null>(null);
@@ -85,10 +91,10 @@ export function ManuelHarita({ yerAyir = false }: { yerAyir?: boolean }) {
// durumunda boş harita ekranda kalmaz. // durumunda boş harita ekranda kalmaz.
if (liste.length === 0 && (hidre || !yerAyir)) return null; if (liste.length === 0 && (hidre || !yerAyir)) return null;
// Margin yok: harita /sonuc'ta main'in İLK çocuğudur, üst boşluğunu // Üst boşluk çağırandan gelir: /sonuc'ta harita sonuç başlığının altında
// main'in py-12'sinden alır (bkz. sonuc/page.tsx). // durur (bkz. sonuc/page.tsx). Harita çizilmezse boşluk da oluşmaz.
return ( return (
<div> <div className={className}>
<TercihHaritasiLazy <TercihHaritasiLazy
pinler={pinler} pinler={pinler}
seciliIl={seciliIl} seciliIl={seciliIl}

View File

@@ -6,6 +6,8 @@ import type { Program } from "@/types/yokatlas";
import { olay } from "@/lib/analitik"; import { olay } from "@/lib/analitik";
import { import {
MANUEL_LISTE_MAX, MANUEL_LISTE_MAX,
cekmeceAc,
listeOku,
useListeDoluMu, useListeDoluMu,
useListedeMi, useListedeMi,
type ManuelKaynak, type ManuelKaynak,
@@ -53,7 +55,11 @@ export function ProgramEkleButonu({
e.stopPropagation(); e.stopPropagation();
const sonuc = programEkleIste({ program, kaynak, adaySira, adayTur }); const sonuc = programEkleIste({ program, kaynak, adaySira, adayTur });
if (sonuc.doldu) { if (sonuc.doldu) {
toast.info(`Listen ${MANUEL_LISTE_MAX} tercihe ulaştı.`); // Ne olduğunu ve ne yapılacağını söyle (DESIGN.md §10)
toast.info(`Listen dolu (${MANUEL_LISTE_MAX}/${MANUEL_LISTE_MAX})`, {
description: "Yeni program eklemek için listeden birini çıkar.",
action: { label: "Listeyi aç", onClick: cekmeceAc },
});
} else if (sonuc.zatenVar) { } else if (sonuc.zatenVar) {
toast.info("Bu program zaten seçtiklerinde."); toast.info("Bu program zaten seçtiklerinde.");
} else if (sonuc.eklendi) { } else if (sonuc.eklendi) {
@@ -62,11 +68,27 @@ export function ProgramEkleButonu({
yol: "dogrudan", yol: "dogrudan",
profilli: sonuc.profilsiz ? "hayir" : "evet", profilli: sonuc.profilsiz ? "hayir" : "evet",
}); });
// Kilometre taşları (DESIGN.md §10): ilk program ve dolu liste bir kez,
// sakin bir onayla söylenir; aradaki eklemeler sessiz kalır (düğme ✓
// olur, navbar sayacı artar). Profilsiz eklemede aşağıdaki toast
// zaten açıklama taşıyor.
const adet = listeOku().length;
if (!sonuc.profilsiz && adet === 1) {
toast.success(`İlk programın listende · 1/${MANUEL_LISTE_MAX}`, {
description: "Beğendiklerini ekledikçe sırasını çekmeceden değiştirebilirsin.",
action: { label: "Listeyi aç", onClick: cekmeceAc },
});
} else if (!sonuc.profilsiz && adet === MANUEL_LISTE_MAX) {
toast.success(`Listen tamam · ${MANUEL_LISTE_MAX}/${MANUEL_LISTE_MAX}`, {
description: "Şimdi sırasına bir göz at: en çok istediğin en üstte olsun.",
action: { label: "Listeyi aç", onClick: cekmeceAc },
});
}
if (sonuc.profilsiz) { if (sonuc.profilsiz) {
// Önce ver, sonra iste: program eklendi; sıralama sorusu değer // Önce ver, sonra iste: program eklendi; sıralama sorusu değer
// karşılığında gelir. Sabit id → art arda +'larda toast yığılmaz, // karşılığında gelir. Sabit id → art arda +'larda toast yığılmaz,
// aynı toast tazelenir. // aynı toast tazelenir.
toast.success("Seçtiklerine eklendi", { toast.success(`Listene eklendi · ${adet}/${MANUEL_LISTE_MAX}`, {
id: PROFILSIZ_EKLEME_TOAST_ID, id: PROFILSIZ_EKLEME_TOAST_ID,
duration: 8000, duration: 8000,
// Vaat, akışın gerçek sonucuna eşit olmalı: bu kapı artık yalnız // Vaat, akışın gerçek sonucuna eşit olmalı: bu kapı artık yalnız

View File

@@ -1,8 +1,69 @@
import { cache } from "react";
import { siraylaProgramAra } from "../liste-queries"; import { siraylaProgramAra } from "../liste-queries";
import { ProgramTablosu } from "./program-tablosu"; import { ProgramTablosu } from "./program-tablosu";
import { ListeDurumuSatiri } from "./liste-durumu-satiri";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { PUAN_TURU_ETIKET } from "@/features/sihirbaz/sihirbaz-sabitler";
import { trBaslikDuzeni } from "@/lib/slug";
import type { PuanTuruKey, UniturGrubu } from "@/types/yokatlas"; import type { PuanTuruKey, UniturGrubu } from "@/types/yokatlas";
const sayi = (n: number) => n.toLocaleString("tr-TR");
// Başlık ve tablo aynı isteği okur; cache tek istek içinde bir kez sorgular.
// Anahtar ilkel olsun diye iller birleştirilmiş metin olarak geçer.
const sonuclariOku = cache(
(sira: number, tur: PuanTuruKey, illerAnahtari: string, tip?: UniturGrubu) =>
siraylaProgramAra(sira, tur, {
iller: illerAnahtari ? illerAnahtari.split("|") : [],
uniturGrubu: tip,
}),
);
/**
* /sonuc'un tek cümlelik cevabı (DESIGN.md §10): sayfanın h1'i, öğrencinin
* sırası ve kaç programın açık olduğu. Tadımlık ve tablo bunun altında.
* Geniş ekran altında ortalı (§5.2).
*/
export function SonucBasligi({
sira,
tur,
iller,
uniturGrubu,
}: {
sira: number;
tur: PuanTuruKey;
iller?: string[];
uniturGrubu?: UniturGrubu;
}) {
const { toplam } = sonuclariOku(sira, tur, (iller ?? []).join("|"), uniturGrubu);
const acik = toplam.hayal + toplam.dengeli + toplam.garanti;
const filtre = [
...(iller ?? []).map(trBaslikDuzeni),
...(uniturGrubu === "devlet"
? ["yalnız devlet"]
: uniturGrubu === "vakif"
? ["yalnız vakıf"]
: []),
];
return (
<header className="text-center lg:text-left">
<h1 className="font-heading text-3xl font-bold leading-tight text-slate-900 sm:text-5xl">
Sıralamanla açılan programlar
</h1>
<p className="mx-auto mt-4 max-w-2xl text-base leading-relaxed text-slate-600 sm:text-lg lg:mx-0">
<strong className="font-semibold text-slate-900">{sayi(sira)}.</strong>{" "}
sıradasın ({PUAN_TURU_ETIKET[tur] ?? tur}).{" "}
{filtre.length > 0 ? `Seçimlerine göre (${filtre.join(", ")}) ` : ""}
<strong className="font-semibold text-slate-900">
{sayi(acik)} program
</strong>{" "}
sana açık; aşağıda riskine göre ayrıldı.
</p>
<ListeDurumuSatiri />
</header>
);
}
// /sonuc'un ham tablosunun sunucu sarmalayıcısı: okuma burada yaşar, sayfa // /sonuc'un ham tablosunun sunucu sarmalayıcısı: okuma burada yaşar, sayfa
// yalnızca kompoze eder. // yalnızca kompoze eder.
export function SonucProgramTablosu({ export function SonucProgramTablosu({
@@ -16,7 +77,7 @@ export function SonucProgramTablosu({
iller?: string[]; iller?: string[];
uniturGrubu?: UniturGrubu; uniturGrubu?: UniturGrubu;
}) { }) {
const sonuclar = siraylaProgramAra(sira, tur, { iller, uniturGrubu }); const sonuclar = sonuclariOku(sira, tur, (iller ?? []).join("|"), uniturGrubu);
return ( return (
<ProgramTablosu <ProgramTablosu
sonuclar={sonuclar} sonuclar={sonuclar}

View File

@@ -126,14 +126,11 @@ export function TadimlikSatiri({
const girisUrl = `/giris?callback=${encodeURIComponent(listemUretHref("tadimlik"))}`; const girisUrl = `/giris?callback=${encodeURIComponent(listemUretHref("tadimlik"))}`;
return ( return (
// first:mt-0 — kayıtlı listesi olan ziyaretçide /sonuc tadımlığı haritanın // /sonuc'ta artık ilk öğe sonuç başlığı (h1); tadımlık onun altında
// ve tablonun ALTINA koyar (sonuc/page.tsx), orada bölüm ritmini veren // (listesi olanda haritanın ve tablonun altında) mt-12 ile durur.
// mt-16'yı alır. Liste yoksa tadımlık main'in ilk DOM çocuğudur (JSX
// yorumları düğüm üretmez) ve main'in py-12'si tek başına yeterli üst
// boşluğu verir.
// Tadımlığın render edilmediği durumlarda (girişli kullanıcı, boş havuz) // Tadımlığın render edilmediği durumlarda (girişli kullanıcı, boş havuz)
// bu bileşen hiç dönmediği için kural ilk gerçek bölüme geçer. // bu bileşen hiç dönmez.
<section ref={gorunumRef} className="mt-16 min-w-0 first:mt-0"> <section ref={gorunumRef} className="mt-12 min-w-0">
<div className="flex flex-col items-center text-center"> <div className="flex flex-col items-center text-center">
<SectionEyebrow>Yapay zekâdan örnek satır</SectionEyebrow> <SectionEyebrow>Yapay zekâdan örnek satır</SectionEyebrow>
<h2 className="mt-4 font-heading text-2xl font-bold sm:text-3xl"> <h2 className="mt-4 font-heading text-2xl font-bold sm:text-3xl">