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,
} from "@/features/sihirbaz/sihirbaz-sabitler";
import {
SonucBasligi,
SonucProgramTablosu,
SonucProgramTablosuSkeleton,
} 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
kutunun yalnızca İÇİ dolar. Haritanın yerini bir daha
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.
Havuzda içerik yoksa bileşen hiç render edilmez. Listesi

View File

@@ -37,8 +37,13 @@ const Toaster = ({ ...props }: ToasterProps) => {
} as React.CSSProperties
}
toastOptions={{
// Şişko ve sade (DESIGN.md §10): başlık 16 px, açıklama okunur
// koyulukta; kutu sitenin kart köşesiyle aynı.
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}

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

View File

@@ -6,6 +6,8 @@ import type { Program } from "@/types/yokatlas";
import { olay } from "@/lib/analitik";
import {
MANUEL_LISTE_MAX,
cekmeceAc,
listeOku,
useListeDoluMu,
useListedeMi,
type ManuelKaynak,
@@ -53,7 +55,11 @@ export function ProgramEkleButonu({
e.stopPropagation();
const sonuc = programEkleIste({ program, kaynak, adaySira, adayTur });
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) {
toast.info("Bu program zaten seçtiklerinde.");
} else if (sonuc.eklendi) {
@@ -62,11 +68,27 @@ export function ProgramEkleButonu({
yol: "dogrudan",
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) {
// Önce ver, sonra iste: program eklendi; sıralama sorusu değer
// karşılığında gelir. Sabit id → art arda +'larda toast yığılmaz,
// aynı toast tazelenir.
toast.success("Seçtiklerine eklendi", {
toast.success(`Listene eklendi · ${adet}/${MANUEL_LISTE_MAX}`, {
id: PROFILSIZ_EKLEME_TOAST_ID,
duration: 8000,
// 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 { ProgramTablosu } from "./program-tablosu";
import { ListeDurumuSatiri } from "./liste-durumu-satiri";
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";
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
// yalnızca kompoze eder.
export function SonucProgramTablosu({
@@ -16,7 +77,7 @@ export function SonucProgramTablosu({
iller?: string[];
uniturGrubu?: UniturGrubu;
}) {
const sonuclar = siraylaProgramAra(sira, tur, { iller, uniturGrubu });
const sonuclar = sonuclariOku(sira, tur, (iller ?? []).join("|"), uniturGrubu);
return (
<ProgramTablosu
sonuclar={sonuclar}

View File

@@ -126,14 +126,11 @@ export function TadimlikSatiri({
const girisUrl = `/giris?callback=${encodeURIComponent(listemUretHref("tadimlik"))}`;
return (
// first:mt-0 — kayıtlı listesi olan ziyaretçide /sonuc tadımlığı haritanın
// ve tablonun ALTINA koyar (sonuc/page.tsx), orada bölüm ritmini veren
// 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.
// /sonuc'ta artık ilk öğe sonuç başlığı (h1); tadımlık onun altında
// (listesi olanda haritanın ve tablonun altında) mt-12 ile durur.
// 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.
<section ref={gorunumRef} className="mt-16 min-w-0 first:mt-0">
// bu bileşen hiç dönmez.
<section ref={gorunumRef} className="mt-12 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">