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:
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
55
src/features/liste/components/liste-durumu-satiri.tsx
Normal file
55
src/features/liste/components/liste-durumu-satiri.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user