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,
|
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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
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,
|
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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user