feat: update app.db and BACKLOG.md with new decisions and adjustments
All checks were successful
Deploy / deploy (push) Successful in 7m45s

- Updated the app.db file to reflect recent changes in the application state.
- Modified BACKLOG.md to include new entries regarding the removal of certain wizard steps, adjustments to priority chips, and the introduction of special quota conditions for candidates.
- Added details on the decision to link priority chips to data sources and clarified the implications for user experience and data handling.
This commit is contained in:
bilalgursen
2026-10-06 17:17:49 +03:00
parent c0696e2cbc
commit a3cb058a3b
74 changed files with 10334 additions and 445 deletions

View File

@@ -4,14 +4,19 @@ import { useEffect, useRef } from "react";
import { cn } from "@/lib/utils";
// public/kolay-tercih-mark.svg'nin inline, animasyonlu karşılığı.
// İki varyant:
// Üç varyant:
//
// - "rota" (varsayılan): her kare sıradakinin pozisyonuna süzülür (mavi →
// şeftalinin yeri, şeftali → turuncunun, turuncu → mavinin), üç adımda
// herkes başlangıç formuna döner. Uçuş sırasında kare hafifçe küçülüp
// solar, varışta toparlanır. Site header'daki logo hover'da bunu oynatır.
// - "zincir": aynı rota, ama kareler aynı anda değil sırayla yürür — bir kare
// hedefindeki karenin üzerine konarken alttaki yola çıkar, üçüncü kare boş
// kalan yere oturur ve zincir baştan başlar. Danışman avatarı yalnız
// danışman veriye bakarken (araç çalışırken) bunu oynatır.
// - "nabiz": kareler yerlerinden ayrılmaz; 120° faz farkıyla dalga gibi
// küçülüp büyür (danışman panel başlığının sakin, sürekli hâli).
// küçülüp büyür (danışman panel başlığının sakin, sürekli hâli; danışman
// avatarı da cevap beklenirken böyle atar).
//
// Performans notu: kareler SVG <rect> değil HTML <span>, animasyon da Web
// Animations API ile yalnızca `translate` / `scale` / `opacity` üzerinden
@@ -49,6 +54,26 @@ const ROTA_NABIZ_ZAMANLARI = [
const ROTA_NABIZ_SCALE = [1, 0.84, 1, 1, 0.84, 1, 1, 0.84, 1, 1];
const ROTA_NABIZ_OPACITY = [1, 0.6, 1, 1, 0.6, 1, 1, 0.6, 1, 1];
// Zincir: iki kalkış arası 450 ms, tek yolculuk 600 ms. Sıradaki kare, gelen
// kare yolun ~%94'ündeyken (üzerine konmuşken) kalkar; son 150 ms'de biri
// inerken diğeri hızlanır, akış hiç durmaz. Her kare turda 3 yolculuk yapar.
const ZINCIR_ADIM = 450;
const ZINCIR_YOL = 600;
const ZINCIR_SURE = ZINCIR_ADIM * 9;
const ZINCIR_YOL_PAYI = ZINCIR_YOL / ZINCIR_SURE;
const ZINCIR_KONUM_ZAMANLARI = [
...[0, 1, 2].flatMap((etap) => [etap / 3, etap / 3 + ZINCIR_YOL_PAYI]),
1,
];
const ZINCIR_NABIZ_ZAMANLARI = [
...[0, 1, 2].flatMap((etap) => [
etap / 3,
etap / 3 + ZINCIR_YOL_PAYI / 2,
etap / 3 + ZINCIR_YOL_PAYI,
]),
1,
];
// Yerinde nabız: kesintisiz tam-sinüs (dinlenme yok), delay'ler döngüyü üçe
// böler — biri büyürken sıradaki küçülür, hiçbir kare beklemez.
const NABIZ_SURE = 1800;
@@ -60,6 +85,10 @@ const YOL = "cubic-bezier(0.65, 0, 0.35, 1)";
// Hover bitince yerine oturma (easeOutQuint): hızlı toparlanır, sarsmadan durur.
const DONUS = "cubic-bezier(0.22, 1, 0.36, 1)";
const DONUS_SURE = 500;
// `oynat` kapanınca yerine oturma: hover'daki kadar aceleci değil — duran kare
// yumuşak kalkar, uzun ve yumuşak iner.
const YERLES = "cubic-bezier(0.4, 0, 0.2, 1)";
const YERLES_SURE = 600;
/** Karenin `translate` değeri: yüzdeler karenin kendi boyutuna göredir, bu
* yüzden işaret hangi boyutta çizilirse çizilsin aynı oran korunur. */
@@ -76,10 +105,12 @@ function rota(baslangic: number, boyut: number): string[] {
return [p0, p1, p1, p2, p2, p0, p0];
}
type Varyant = "rota" | "zincir" | "nabiz";
function donguyuBaslat(
el: HTMLElement,
i: number,
varyant: "rota" | "nabiz",
varyant: Varyant,
): Animation[] {
const k = KARELER[i];
if (varyant === "nabiz") {
@@ -94,9 +125,10 @@ function donguyuBaslat(
),
];
}
const zincir = varyant === "zincir";
const secenek = {
duration: ROTA_SURE,
delay: i * ROTA_GECIKME_ADIMI,
duration: zincir ? ZINCIR_SURE : ROTA_SURE,
delay: i * (zincir ? ZINCIR_ADIM : ROTA_GECIKME_ADIMI),
iterations: Infinity,
};
// Konum ve nabız ayrı animasyonlar: konum etabı tek parça eğriyle akar,
@@ -105,7 +137,7 @@ function donguyuBaslat(
el.animate(
{
translate: rota(k.baslangic, k.boyut),
offset: KONUM_ZAMANLARI,
offset: zincir ? ZINCIR_KONUM_ZAMANLARI : KONUM_ZAMANLARI,
easing: YOL,
},
secenek,
@@ -113,8 +145,10 @@ function donguyuBaslat(
el.animate(
{
scale: ROTA_NABIZ_SCALE.map(String),
opacity: ROTA_NABIZ_OPACITY,
offset: ROTA_NABIZ_ZAMANLARI,
// Zincirde kare solmaz: başka bir karenin üzerine konar, yarı saydam
// olursa alttaki renk içinden görünür.
...(zincir ? {} : { opacity: ROTA_NABIZ_OPACITY }),
offset: zincir ? ZINCIR_NABIZ_ZAMANLARI : ROTA_NABIZ_ZAMANLARI,
easing: SINE,
},
secenek,
@@ -126,22 +160,35 @@ function donguyuBaslat(
* Animasyonlu KolayTercih işareti. Varsayılanda koreografi sürekli döner;
* `sadeceHover` ile yalnızca işaretin üzerine gelindiğinde oynar (ör. site
* header'daki logo linki) ve hover bitince kareler yumuşakça yerine oturur.
* `oynat` ile döngü dışarıdan açılıp kapatılır, `varyant` da çalışırken
* değişebilir (ör. danışman avatarı: cevap beklenirken nabız, veriye bakarken
* zincir); her geçişte kareler önce yumuşakça yerine oturur, yeni döngü
* yerinden başlar.
*/
export function AnimasyonluMark({
className,
sadeceHover = false,
oynat = true,
varyant = "rota",
renk = "marka",
}: {
className?: string;
sadeceHover?: boolean;
varyant?: "rota" | "nabiz";
/** `sadeceHover` yokken döngü açık mı. `false` iken işaret durağandır. */
oynat?: boolean;
varyant?: Varyant;
/** "marka": logodaki üç renk; "turuncu": tüm kareler sitenin CTA turuncusu
* (orange-500) — ör. danışman panel başlığı. */
renk?: "marka" | "turuncu";
}) {
const kokRef = useRef<HTMLSpanElement>(null);
const kontrolRef = useRef<{
ayarla: (hedef: Varyant | null, cevik?: boolean) => void;
} | null>(null);
// Animasyonların sahibi: bileşen yaşadıkça bir kez kurulur. Prop değişimi
// bunu söküp kurmaz (kareler sıçrardı); aşağıdaki efekt `ayarla` ile hedefi
// bildirir.
useEffect(() => {
const kok = kokRef.current;
if (!kok || typeof kok.animate !== "function") return;
@@ -150,32 +197,25 @@ export function AnimasyonluMark({
let dongu: Animation[] = [];
let donus: Animation[] = [];
let istekli = false;
/** Şu an dönen döngü ve istenen döngü (`null`: durağan). */
let aktif: Varyant | null = null;
let hedef: Varyant | null = null;
// Döngü bir mikro görev sonra başlar; o arada efekt sökülmüşse (React
// geliştirme modunda her efekti kur-sök-kur yapar) başlamamalı — yoksa
// kimsenin durduramadığı sahipsiz bir döngü kalır.
let sokuldu = false;
const baslat = () => {
if (istekli) return;
istekli = true;
if (azHareket.matches) return;
// Kareler hâlâ yerine dönüyorsa (hızlı çık-gir) dönüşün bitmesi
// beklenir; döngü hep yerinden başlar, sıçrama olmaz.
Promise.all(donus.map((a) => a.finished))
.then(() => {
if (!istekli || dongu.length > 0) return;
dongu = kareler.flatMap((el, i) => donguyuBaslat(el, i, varyant));
})
.catch(() => {});
};
const durdur = () => {
istekli = false;
if (dongu.length === 0) return;
// Kareler o an neredeyse oradan yerlerine süzülür.
/** Döngüyü keser; kareler o an neredeyse oradan yerlerine süzülür. */
const yerles = (cevik: boolean) => {
const anlik = kareler.map((el) => {
const cs = getComputedStyle(el);
return { translate: cs.translate, scale: cs.scale, opacity: cs.opacity };
});
// Nabızda kareler zaten yerindedir, yalnız boyut toparlanır: kısa sürer.
const sure = aktif === "nabiz" ? YERLES_SURE / 2 : YERLES_SURE;
dongu.forEach((a) => a.cancel());
dongu = [];
aktif = null;
donus = kareler.map((el, i) =>
el.animate(
[
@@ -186,38 +226,73 @@ export function AnimasyonluMark({
opacity: 1,
},
],
{ duration: DONUS_SURE, easing: DONUS },
cevik
? { duration: DONUS_SURE, easing: DONUS }
: { duration: sure, easing: YERLES },
),
);
};
if (!sadeceHover) {
baslat();
return () => {
dongu.forEach((a) => a.cancel());
};
}
const ayarla = (yeni: Varyant | null, cevik = false) => {
if (yeni === hedef) return;
hedef = yeni;
if (aktif !== null) yerles(cevik);
if (yeni === null || azHareket.matches) return;
// Kareler hâlâ yerine dönüyorsa dönüşün bitmesi beklenir; döngü hep
// yerinden başlar, sıçrama olmaz.
Promise.all(donus.map((a) => a.finished))
.then(() => {
if (sokuldu || hedef !== yeni || aktif !== null) return;
dongu = kareler.flatMap((el, i) => donguyuBaslat(el, i, yeni));
aktif = yeni;
})
.catch(() => {});
};
const kontrol = { ayarla };
kontrolRef.current = kontrol;
return () => {
sokuldu = true;
if (kontrolRef.current === kontrol) kontrolRef.current = null;
[...dongu, ...donus].forEach((a) => a.cancel());
};
}, []);
// Üstteki efektten sonra koşar (aynı bileşende efektler sırayla çalışır),
// o yüzden kontrolRef burada doludur.
useEffect(() => {
const kok = kokRef.current;
if (!kok) return;
if (!sadeceHover) {
kontrolRef.current?.ayarla(oynat ? varyant : null);
return;
}
kontrolRef.current?.ayarla(null, true);
// Hover hedefi işaretin kendisi değil, en yakın `group` ebeveyni (site
// header'da logo + "KolayTercih" yazısını saran Link): yazının üzerine
// gelindiğinde de animasyon başlar. `group`'suz kullanımda köke düşer.
const hedef = kok.closest(".group") ?? kok;
hedef.addEventListener("mouseenter", baslat);
hedef.addEventListener("mouseleave", durdur);
const hoverHedefi = kok.closest(".group") ?? kok;
const gir = () => kontrolRef.current?.ayarla(varyant, true);
const cik = () => kontrolRef.current?.ayarla(null, true);
hoverHedefi.addEventListener("mouseenter", gir);
hoverHedefi.addEventListener("mouseleave", cik);
return () => {
hedef.removeEventListener("mouseenter", baslat);
hedef.removeEventListener("mouseleave", durdur);
[...dongu, ...donus].forEach((a) => a.cancel());
hoverHedefi.removeEventListener("mouseenter", gir);
hoverHedefi.removeEventListener("mouseleave", cik);
};
}, [sadeceHover, varyant]);
}, [oynat, sadeceHover, varyant]);
return (
<span
ref={kokRef}
aria-hidden="true"
className={cn("relative block shrink-0", className)}
className={cn(
"relative block shrink-0",
varyant !== "rota" && "isolate",
className,
)}
>
{KARELER.map((k) => (
{KARELER.map((k, i) => (
<span
key={k.renk}
className={cn(
@@ -230,6 +305,11 @@ export function AnimasyonluMark({
borderRadius: `${(13 / k.boyut) * 100}%`,
backgroundColor: renk === "marka" ? k.renk : undefined,
translate: yer(k.baslangic, k.boyut),
// Zincirde yürüyen kare hep hedefindekinin üstüne konar: sırası
// önce gelen üstte durur (mavi > şeftali > turuncu). Avatar nabız
// ile zincir arasında gidip geldiği için sıra nabızda da aynıdır
// (orada kareler üst üste gelmez); header'daki rota'ya dokunulmaz.
zIndex: varyant !== "rota" ? KARELER.length - i : undefined,
}}
/>
))}

View File

@@ -110,6 +110,9 @@ export function DevPanel() {
}
// Verify URL'i oturumu kurup callback'e yönlendirir
window.location.assign(sonuc.url);
} catch (e) {
// Sunucu hatası (ör. e-posta gönderimi) sessiz kalmasın (6 Eki 2026 QA)
toast.error(e instanceof Error ? e.message : "Anında giriş başarısız.");
} finally {
setMesgul(false);
}

View File

@@ -1,6 +1,32 @@
import type { ReactNode } from "react";
import type { LucideIcon } from "lucide-react";
const sayi = (n: number) => n.toLocaleString("tr-TR");
/**
* Özet kartında iri sayı telefonda ~94 px'e sığmalı (DESIGN.md §4.8):
* 36 px Cal Sans'ta "1.618.988" 123 px ölçüldü, ikonun altına giriyor. 6 ve
* 7 haneli sıralar birimle kısalır: 245.300 → "245" + "bin",
* 1.618.988 → "1,62" + "milyon". Üniversite ve bölüm sayfası buradan okur.
*/
export function kartSayisi(n: number): { deger: string; birim?: string } {
// 999.500+ "1.000 bin" olmasın diye milyona geçer
if (n >= 999_500) {
return {
deger: (n / 1e6).toLocaleString("tr-TR", { maximumFractionDigits: 2 }),
birim: "milyon",
};
}
if (n >= 100_000) return { deger: sayi(Math.round(n / 1000)), birim: "bin" };
return { deger: sayi(n) };
}
/** Metin içinde kısa sıra: 1.618.988 → "1,62 milyon", 105.215 → "105 bin", 4.560 → "4.560". */
export function kisaSayi(n: number): string {
const k = kartSayisi(n);
return k.birim ? `${k.deger} ${k.birim}` : k.deger;
}
/**
* Özet kartı (DESIGN.md §4.8 — Apple Fitness özet kartlarının düzeni,
* sitenin ölçüleriyle): kartın en baskın öğesi iri sayı; ikonu sağında,

View File

@@ -0,0 +1,37 @@
"use client"
import { Collapsible as CollapsiblePrimitive } from "radix-ui"
// shadcn `collapsible` (radix-ui tek paket). prompt-kit `steps.tsx` bunu ister;
// stil taşımaz, açılma/kapanma animasyonu tw-animate-css'in
// `animate-collapsible-down/up` sınıflarıyla içerikte verilir.
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
)
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

View File

@@ -41,7 +41,8 @@ function extractLanguage(className?: string): string {
return match ? match[1] : "plaintext"
}
const INITIAL_COMPONENTS: Partial<Components> = {
// Dışa açık: sohbet (sohbet-baglanti.tsx) bu varsayılanı bağlantı haplarıyla genişletir.
export const INITIAL_COMPONENTS: Partial<Components> = {
code: function CodeComponent({ className, children, ...props }) {
const isInline =
!props.node?.position?.start.line ||

123
src/components/ui/steps.tsx Normal file
View File

@@ -0,0 +1,123 @@
"use client"
// prompt-kit `steps` (https://prompt-kit.com/c/steps.json), sitenin paletine
// çevrildi: `muted/foreground/popover` token'ları yerine slate ölçeği
// (DESIGN.md §2, §2.3 — koyu temada token'lar çevrilir, `dark:` yok). Yapı
// aynı: Steps (Collapsible) → StepsTrigger (başlık + ok) → StepsContent
// (solda dikey çubuk, sağda adımlar). Danışman sohbetinde araç adımlarını
// listeler (features/rapor/components/arac-pilleri.tsx, §4.20).
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible"
import { cn } from "@/lib/utils"
import { ChevronDown } from "lucide-react"
export type StepsItemProps = React.ComponentProps<"div">
export const StepsItem = ({
children,
className,
...props
}: StepsItemProps) => (
<div className={cn("text-sm text-slate-600", className)} {...props}>
{children}
</div>
)
export type StepsTriggerProps = React.ComponentProps<
typeof CollapsibleTrigger
> & {
leftIcon?: React.ReactNode
swapIconOnHover?: boolean
}
export const StepsTrigger = ({
children,
className,
leftIcon,
swapIconOnHover = true,
...props
}: StepsTriggerProps) => (
<CollapsibleTrigger
className={cn(
"group flex w-full cursor-pointer items-center justify-start gap-1 text-sm text-slate-500 transition-colors hover:text-slate-800",
className
)}
{...props}
>
<div className="flex items-center gap-2">
{leftIcon ? (
<span className="relative inline-flex size-4 items-center justify-center">
<span
className={cn(
"transition-opacity",
swapIconOnHover && "group-hover:opacity-0"
)}
>
{leftIcon}
</span>
{swapIconOnHover && (
<ChevronDown className="absolute size-4 opacity-0 transition-opacity group-hover:opacity-100 group-data-[state=open]:rotate-180" />
)}
</span>
) : null}
<span>{children}</span>
</div>
{!leftIcon && (
<ChevronDown className="size-4 transition-transform group-data-[state=open]:rotate-180" />
)}
</CollapsibleTrigger>
)
export type StepsContentProps = React.ComponentProps<
typeof CollapsibleContent
> & {
bar?: React.ReactNode
}
export const StepsContent = ({
children,
className,
bar,
...props
}: StepsContentProps) => {
return (
<CollapsibleContent
className={cn(
"overflow-hidden text-slate-800 data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down motion-reduce:animate-none",
className
)}
{...props}
>
<div className="mt-2 grid max-w-full min-w-0 grid-cols-[min-content_minmax(0,1fr)] items-start gap-x-3">
<div className="min-w-0 self-stretch">{bar ?? <StepsBar />}</div>
<div className="min-w-0 space-y-1.5">{children}</div>
</div>
</CollapsibleContent>
)
}
export type StepsBarProps = React.HTMLAttributes<HTMLDivElement>
export const StepsBar = ({ className, ...props }: StepsBarProps) => (
<div
className={cn("h-full w-0.5 rounded-full bg-slate-200", className)}
aria-hidden
{...props}
/>
)
export type StepsProps = React.ComponentProps<typeof Collapsible>
export function Steps({ defaultOpen = true, className, ...props }: StepsProps) {
return (
<Collapsible
className={cn(className)}
defaultOpen={defaultOpen}
{...props}
/>
)
}