Compare commits

...

10 Commits

Author SHA1 Message Date
bilalgursen
d2732828cc refactor: odeme feature'ı + son süpürme — geçiş tamamlandı (Faz 10)
Some checks failed
Deploy / deploy (push) Failing after 3m46s
- paket/actions.ts → odeme-actions.ts; satin-al-form + rapor-yok-cta
  features/odeme/components/ altına; odeme-queries.ts (siparisGetir)
- Yeni async OdemeSonucKarti: verifySession + sahiplik kontrolü + self-healing
  odemeyiSonuclandir çağrısı akıştaki yerinde (artık Suspense altındaki feature
  bileşeninde, aynı render semantiği); başarı/hata kartları içeride
- Yeni async PaketSatinAl: paket + top-up kartları, getCurrentUser okuması
  içeride; statik hero/dipnot sayfada kaldı
- İki sayfa senkron searchParams.then(); paket hata uyarısı sayfada çözülmüş
  JSX (hata yokken hiçbir şey çizilmediği için fallback bilerek null);
  paket/loading.tsx + odeme/sonuc/loading.tsx silindi
- Süpürme doğrulandı: app/ altında route-dışı bileşen dosyası kalmadı; eski
  yol grep'leri sıfır; 7 *-queries server-only, 3 *-actions 'use server';
  async sayfa sıfır; /api/odeme/callback el değmedi
- Davranış değişikliği yok

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 01:46:23 +03:00
bilalgursen
22e2fde058 refactor: /sonuc senkron kompozisyona geçti (Faz 9)
- liste-queries.ts (siraylaProgramAra) ve sihirbaz-queries.ts
  (getSihirbazFacetleri) eklendi — /sonuc'un SQLite okumaları feature
  sorgularına indi
- Yeni sunucu sarmalayıcıları: SonucProgramTablosu (+skeleton),
  TadimlikBolumu (girişliyse null — gruplu sınırda skeleton bilerek yok),
  SonucSihirbazi (session + rapor-varlığı + facetler içeride; client
  orkestratöre çözülmüş değerler iner)
- Sayfa senkron: searchParams.then() içinde sıra doğrulaması; geçersiz sıra
  SiraGerekli'ye düşer; geçerli akışta tablo+tadımlık+sihirbaz+dipnot+footer
  tek gruplu Suspense'te (bugünkü tek await'li akışla aynı anda belirir)
- sonuc/loading.tsx silindi; geometrisi sayfadaki fallback'te yaşıyor
- Davranış değişikliği yok (getSession React cache ile tek okuma)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 01:42:32 +03:00
bilalgursen
fbd184e2be refactor: rapor feature'ı — listem, yazdır, sohbet ve actions taşındı (Faz 8)
- app/sonuc/actions.ts → features/rapor/rapor-actions.ts birebir taşındı
  (kredi harcama/iade idempotency yolları bayt bayt aynı, yalnızca import
  yolları değişti); liste-uretici/revizyon-kutusu action'ı doğrudan import
  ediyor
- lib/rapor-kaydi.ts rapor-queries.ts'e emildi: kullanicininRaporu aynı tek
  okuma kapısı; listem sayfasındaki inline drizzle sorgusu sohbetGecmisiGetir
  oldu; tadimlikSec re-export'u eklendi
- listem-govde, sohbet-client, revizyon-kutusu, liste-uretici, listem-bos-cta,
  rapor-listesi, tadimlik-satiri, yazdir-butonu features/rapor/components/'a
- Yeni async ListemIcerik (verifySession + maskeleme + bosDurum dalı içeride;
  çift await searchParams kalktı) ve RaporYazdir (3 redirect + yazdırılabilir
  belge); skeleton'lar eski loading.tsx geometrileriyle aynı dosyada
- /listem ve /rapor/yazdir senkron: searchParams.then() + sayfada Suspense;
  maxDuration=300 ve metadata sayfada kaldı; iki loading.tsx silindi
- Davranış değişikliği yok; /api/soru el değmedi

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 01:39:33 +03:00
bilalgursen
7d2b2ddd71 refactor: liste feature'ı — manuel-liste toptan taşındı (Faz 7)
- src/components/manuel-liste/ → src/features/liste/: kayitli-liste-actions →
  liste-actions, store → liste-store, dogrula → liste-dogrula, giris-url →
  liste-giris-url, oturum-sinyali → liste-oturum-sinyali,
  profil-kapisi-store → hooks/use-tercih-profili; 12 tsx → components/
- program-tablosu.tsx ve tercih-haritasi.tsx da features/liste/components'a
  geldi (rapor ve katalog cross-import ediyor)
- app/layout.tsx'in 3 global mount'u (ListeCekmecesiLazy, KaydetBannerLazy,
  TercihProfiliKapisiLazy) yeni feature yollarından import ediliyor
- liste-queries.ts /sonuc dönüşümüyle Faz 9'da gelecek
- Davranış değişikliği yok; store/localStorage anahtarları aynı

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 01:34:50 +03:00
bilalgursen
792e8c7346 refactor: sihirbaz feature'ı — lib/sihirbaz bölündü, bileşenler taşındı (Faz 6)
- lib/sihirbaz.ts ikiye ayrıldı: sihirbaz-sabitler.ts (şema + doğrulayıcılar +
  event/storage sabitleri + sonucHref/secimOzeti, izomorfik) ve
  sihirbaz-profil.ts (localStorage oku/yaz/sil); 25 tüketicinin importu
  sembol bazında bölünerek güncellendi, localStorage anahtar/event değerleri
  birebir aynı
- sihirbaz-adimlar(+lazy), hero-form, cta-sira-form, il-secim-haritasi ve
  /sonuc'un sihirbaz-modal/sihirbaz-cagri-karti/sira-gerekli bileşenleri
  features/sihirbaz/components/ altına taşındı
- sihirbaz-queries.ts ve async SihirbazBolumu sarmalayıcısı /sonuc sayfa
  dönüşümüyle birlikte Faz 9'da gelecek (o zamana dek ölü kod olurlardı)
- Davranış değişikliği yok

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 01:31:32 +03:00
bilalgursen
baebc1fc80 refactor: pazarlama feature'ı — landing + meraklısına taşındı (Faz 5)
- landing/{sosyal-kanit,sosyal-kanit-govde,reveal} + hero-baslik +
  hero-focus-button + turkey-pixel-map features/pazarlama/components/ altına
- app/page.tsx'teki inline ComparisonCell landing-parcalari.tsx'e çıkarıldı
- /meraklisina içeriği MeraklisinaIcerik'e (okumayı pazarlama-queries'in
  meraklisinaDemoVerisi'nden alıyor); sayfa senkron + Suspense; demo.tsx →
  meraklisina-demo.tsx, huni-semasi.tsx feature'a taşındı
- AGENTS.md'deki huni-semasi ve anlatım metni yol referansları aynı commit'te
  güncellendi; SEMA içeriği değişmedi
- Davranış değişikliği yok

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 01:27:53 +03:00
bilalgursen
8b5aa8c8a6 refactor: kullanici feature'ı — giriş kartları feature bileşenlerine çıktı (Faz 4)
- giris-form, giris-baslik, user-nav, sign-out-button
  features/kullanici/components/ altına taşındı
- /giris'in session+redirect+callback temizliği GirisKarti'ya (async),
  /giris/dogrula'nın inline doğrulama formu + guvenliYol kalkanı
  DogrulaKarti'ya çıkarıldı; skeleton'lar aynı dosyada
- İki sayfa senkron: searchParams.then() + sayfada Suspense;
  app/giris/loading.tsx silindi (geometrisi GirisKartiSkeleton'da yaşıyor)
- site-header'ın UserNav importu güncellendi; mevcut Suspense sınırı yerinde
- Davranış değişikliği yok

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 01:22:31 +03:00
bilalgursen
e3cf141f55 refactor: rehber feature'ı — makale ve liste feature bileşenlerine çıktı (Faz 3)
- rehber-kapak + el-cizimi-sema features/rehber/components/ altına taşındı
  (el-cizimi-sema'nın göreli font yolu yeni konuma göre düzeltildi)
- /rehber'in kart gridi RehberListesi'ne, /rehber/[slug]'ın makale gövdesi
  (JsonLd + içerik + ilgili yazılar + notFound) RehberMakale'ye çıkarıldı;
  metadata/staticParams yardımcıları bileşen dosyasından export ediliyor
- İki sayfa senkron: params.then() + sayfada Suspense, skeleton'lar colocated
- rehber-queries.ts (server-only) lib/rehber'in feature kapısı
- Davranış değişikliği yok; içerik build-time statik

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 01:18:22 +03:00
bilalgursen
8d26e375ff refactor: katalog feature'ı — sayfalar senkron kompozisyona geçti (Faz 2)
- 8 katalog bileşeni src/features/katalog/components/ altına taşındı;
  katalog-queries.ts (server-only) lib/katalog'un feature kapısı oldu
- /bolumler ve /universiteler'in sayfa içi fetch+inline bileşenleri
  BolumKatalogu/UniversiteKatalogu feature bileşenlerine çıkarıldı
- 6 sayfa async'ten senkron'a: params.then() + sayfada Suspense,
  skeleton'lar feature bileşeninin yanında (BolumIcerikSkeleton vb.)
- generateStaticParams artık *-queries yerine bileşen dosyasının
  helper'larını kullanıyor (bolumStaticSlugs, uniSayfaStaticParams)
- Davranış değişikliği yok; route durumları aynı (◐), 1917 statik sayfa

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 01:14:00 +03:00
bilalgursen
820eea7eca refactor: ortak tipler @/types/yokatlas + features/rapor/types'a çıkarıldı, server-only korumaları eklendi (Faz 1)
- Program/PUAN_TURLERI/DilimKey/RankResults/UniturGrubu/ProgramNetSatiri/
  SihirbazFacetleri artık src/types/yokatlas.ts'te; lib/db.ts yalnızca sorgu
  fonksiyonları barındırıyor
- RaporSonuc/RaporKapsam/RaporParams/MaskeliRapor src/features/rapor/types/
  rapor.ts'e taşındı; ölü kalan TercihSchema/RaporSchema silindi
- 11 lib dosyasına import "server-only" eklendi; scripts'in düz node ile
  import ettiği dosyalara (db, appdb, rapor-havuzu, tadimlik-havuzu, ai/client,
  ai/cagri, credits) bilinçli eklenmedi
- Davranış değişikliği yok; pnpm build yeşil

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 01:06:06 +03:00
156 changed files with 3529 additions and 1949 deletions

View File

@@ -0,0 +1,101 @@
---
name: nextjs-app-architecture
description: Build or audit Next.js 16 App Router apps using a next-beats-style React Server Components architecture. Use when scaffolding a new app, adding a feature, reviewing an existing app, refactoring route-loader-shaped pages into feature-owned async server components, deciding where queries/actions/components live, keeping pages synchronous with `params.then()`, placing Suspense boundaries, choosing the client/server boundary, designing skeletons, preventing CLS, or enabling Cache Components. Also use when the user asks about RSC composition, components receiving IDs instead of route params, `'use cache'`, `cacheTag`, `updateTag`, static-shell prerendering, or making an app easier for AI agents to modify.
license: MIT
metadata:
author: aurorascharff
version: "1.3.7"
---
# Next.js App Architecture
A workflow for building and auditing Next.js 16+ App Router apps so they follow one consistent, feature-sliced RSC architecture like `next-beats`.
**Follow the workflow below step by step** — it produces the invariants by construction. Load the reference a step names for the decision it depends on. Get framework _mechanics_ (API signatures, config options, hook contracts) from the linked docs — don't restate or improvise them.
## Prerequisite
Before changing a Next.js app, make sure the project is set up for AI agents to read version-matched docs. Follow the [AI Coding Agents guide](https://preview.nextjs.org/docs/app/guides/ai-agents): prefer the project's `AGENTS.md` / bundled docs, and create or refresh them when missing. Then use this skill for architecture decisions.
## Architecture target
Build pages that describe the loading experience, not pages that act like route loaders:
- `app/**/page.tsx` and `layout.tsx` are synchronous composition surfaces: static chrome, section headings, `<Suspense>` boundaries, error boundaries, and transition wrappers.
- Feature components own their reads on the server. They receive minimal stable inputs (`id`, `slug`, `handle`, parsed filter values) or already-fetched records, never raw `params` / `searchParams`.
- Queries and actions live in the feature folder. Components import queries; client leaves import actions directly.
- When server tags and client query keys describe the same feature data, a pure feature-local cache contract owns those identities.
- Skeletons mirror the component tree and live beside the component they represent.
## Invariants (what every change must satisfy)
The non-negotiables. The workflow produces them; the final check verifies them.
1. **Pages compose, they never fetch.** A page/layout imports feature components and places `<Suspense>`. No queries, no domain logic, no route-specific components defined inline.
2. **Pages stay synchronous.** Use `params.then()` / `searchParams.then()`, never `await params` at the top — so chrome paints into the static shell and only data-dependent sections suspend.
3. **Feature components receive IDs, not route props.** Resolve `params` / `searchParams` at the page boundary and pass plain values (`id`, `slug`, `query`) into features.
4. **Async server component is the default.** `'use client'` only for hooks, event handlers, or browser APIs — and only on leaves, never on parents of server content.
5. **The page owns the Suspense boundary; the feature owns the skeleton.** Features never pre-wrap themselves in `<Suspense>`.
6. **Skeletons live in the same file as the component**, exported alongside it, defined at the end. `Feed` and `FeedSkeleton` are siblings.
7. **Queries live in `<domain>-queries.ts`** (`import 'server-only'`); **actions live in `<domain>-actions.ts`** (`'use server'`). The file name matches the folder, even for sub-concepts.
8. **One feature folder per real domain noun.** Sub-concepts (favorite, like, vote, bookmark, search) fold into the parent feature, never their own folder.
9. **Client components import actions directly** — never receive a server action as a prop just to call it.
10. **Feature-local cache coordination stays with its domain.** Put pure tags/keys in `<domain>-cache.ts`, client query definitions in `<domain>-query-options.ts`, hook wrappers in `hooks/use-*.ts`, and tiny client leaves in `components/`; promote support code only after real cross-feature reuse.
## Workflow
Run these in order for build-from-scratch, feature work, or audits. Each step names the reference to consult and the check it must pass.
1. **Choose mode.**
- **Build from scratch:** sketch routes, real domain nouns, static shell, and expected loading groups before writing code.
- **Audit/refactor:** scan current `app/` pages first; list every async page, page-level query import, route prop leak, missing Suspense boundary, and feature folder mismatch.
→ `references/example.md` for the target shape; `references/feature-folders.md` for placement.
✓ You know whether you are creating the architecture or converting loader-shaped code into it.
2. **Place the work.** Decide the feature folder before writing anything.
→ `references/feature-folders.md` (decision tree + merge rules).
✓ A real domain, or folded into the right parent.
3. **Write the query and, when a client cache shares its data, the cache contract.** Put server reads in `<domain>-queries.ts` with `import 'server-only'`; keep shared tag/key identities in a pure `<domain>-cache.ts`.
→ `references/queries-actions.md`; for SWR/TanStack Query → `references/single-page-applications.md`; with `cacheComponents: true`, also → `references/cache-components.md`.
✓ Cache identities are defined once; server reads are server-only, cached/tagged/lifetimed under Cache Components, and return domain types rather than ORM rows.
4. **Write the action** (if there's a mutation). `features/<domain>/<domain>-actions.ts`, `'use server'` at the top.
→ `references/queries-actions.md`.
✓ Re-checks auth, validates input, invalidates matching cache tags under Cache Components (`refresh()` only for justified dynamic reads), returns a discriminated union.
5. **Build the component + skeleton.** `features/<domain>/components/<name>.tsx`: an async server component that awaits its own query from minimal props; `'use client'` only on interactive leaves.
→ `references/components.md`; for a client data library or strict-SPA/CSR feature → `references/single-page-applications.md`.
✓ Component receives IDs/handles/parsed filters or already-resolved records, not `params`; skeleton is a sibling export at the end; no alias skeleton wrappers.
6. **Compose the page.** `app/<route>/page.tsx`: synchronous, `params.then()`, place `<Suspense fallback={<NameSkeleton />}><Name /></Suspense>`, and wrap fallible sections in an error boundary.
→ `references/pages-suspense.md`.
✓ The page only composes; the boundary lives here, not in the feature; route props are resolved to plain values before reaching feature components.
7. **Add interaction** (if any): optimistic updates, pending state, toasts, confirmation.
→ `references/ux-patterns.md`.
✓ Feedback isn't doubled; destructive actions confirm; feature-owned client coordination stays with that feature instead of leaking into unrelated domains.
8. **Verify** against the checklist below before declaring done.
## Verify before done
Inspect the diff against every invariant — each is checkable by reading the changed files:
- [ ] No page/layout imports a `*-queries` file or defines a route-specific component inline.
- [ ] Every page with params is synchronous and uses `params.then()` / `searchParams.then()`.
- [ ] Feature components receive plain IDs/handles/parsed filters or resolved records; no feature prop is named `params` or `searchParams`.
- [ ] Every `<Suspense>` for page data sits in the page; no feature pre-wraps itself.
- [ ] Every component has its real `*Skeleton` in the same file, at the end; no tiny skeleton aliases just to pass props.
- [ ] Every `*-queries.ts` starts with `import 'server-only'`; every `*-actions.ts` with `'use server'`.
- [ ] With `cacheComponents: true`, reusable reads use `'use cache'` / `cacheTag` / `cacheLife`, or `'use cache: private'` / `'use cache: remote'` when appropriate; any dynamic read is intentional and justified.
- [ ] Mutations touching cached reads call `updateTag()` / `revalidateTag(..., 'max')` for the matching tags; `refresh()` is not a substitute for tag invalidation.
- [ ] Action files are named `<folder>-actions.ts`; no sub-concept spawned its own folder.
- [ ] Features with both server tags and client query keys define them once in a pure `<domain>-cache.ts`; queries, actions, hydration, query options, and hooks import from it.
- [ ] Feature-local client-support files sit in the smallest fitting place: query options at the feature root, `use-*` hook wrappers in `hooks/`, leaf components in `components/`, and shared support only after real cross-feature reuse.
- [ ] `'use client'` components are leaves — they import actions/hooks/providers, not async server components.
- [ ] Mutations validate their input and invalidate the affected data.
## Reference index
- **`references/feature-folders.md`** — where code goes: folder layout, cache contracts, naming, and merging sub-concepts.
- **`references/queries-actions.md`** — query/action rules: server-only, dedup, validation, invalidation, return shape.
- **`references/components.md`** — server/client boundary, skeletons, `use()`, single-use helpers, live data.
- **`references/pages-suspense.md`** — page composition, `params.then()`, Suspense placement, CLS, error boundaries, prefetch.
- **`references/cache-components.md`** — the `cacheComponents` decisions: which reads to cache, which directive to use, how to invalidate.
- **`references/single-page-applications.md`** — client cache decisions: placement, server seeding, Cache Components coordination, hydration, and mutations.
- **`references/ux-patterns.md`** — interaction decisions: optimistic vs pending vs inline error, toasts, action-prop, confirmations.
- **`references/example.md`** — the next-beats reference app: invariant → file map, for seeing any rule in real code.

View File

@@ -0,0 +1,20 @@
{
"version": "1.3.7",
"organization": "Aurora Scharff",
"date": "July 2026",
"abstract": "Build and audit Next.js 16 App Router apps with a next-beats-style RSC architecture: synchronous pages that compose static shells, Suspense, and error boundaries; feature-owned async server components that receive IDs or parsed values; co-located skeletons; server-only queries; server actions; enforced Cache Components practice when enabled; and build/runtime verification of the resulting loading shape. References cover each topic in depth so the agent only loads what's needed for the task.",
"references": [
"https://preview.nextjs.org/docs/app",
"https://preview.nextjs.org/docs/app/guides/ai-agents",
"https://preview.nextjs.org/docs/app/api-reference/config/next-config-js/cacheComponents",
"https://preview.nextjs.org/docs/app/api-reference/directives/use-cache",
"https://preview.nextjs.org/docs/app/api-reference/functions/cacheTag",
"https://preview.nextjs.org/docs/app/api-reference/config/next-config-js/cacheLife",
"https://preview.nextjs.org/docs/app/api-reference/functions/updateTag",
"https://preview.nextjs.org/docs/app/guides/adopting-partial-prefetching",
"https://preview.nextjs.org/docs/app/guides/migrating-to-cache-components",
"https://preview.nextjs.org/docs/app/guides/interactive-apps",
"https://aurorascharff.no/posts/component-architecture-for-react-server-components/",
"https://github.com/vercel-labs/next-beats"
]
}

View File

@@ -0,0 +1,80 @@
# Cache Components
Decisions for when [`cacheComponents: true`](https://preview.nextjs.org/docs/app/api-reference/config/next-config-js/cacheComponents) is set in `next.config.ts`. This file is about *which reads to cache, which directive to use, and how to invalidate* — for the mechanics of each directive, follow the doc links.
## When this reference applies
Use this reference when an app already has `cacheComponents: true`, the user wants this architecture while enabling it, or you are reviewing/refactoring an app that targets Cache Components.
If the project has not adopted Cache Components yet and the user asks to enable, migrate, or work through adoption blockers, use the `next-cache-components-adoption` skill first. It owns the route-by-route migration loop, opt-out strategy, and build/dev overlay workflow. Then return here for steady-state query/action/component architecture.
If `cacheComponents` is not enabled and the task is ordinary feature work, follow the core references without adding cache directives. Do not recommend skipping Cache Components based on app category alone; adoption is a migration/project decision, not a per-feature shortcut.
Adopting these in an existing app: follow [Migrating to Cache Components](https://preview.nextjs.org/docs/app/guides/migrating-to-cache-components) and [Adopting Partial Prefetching](https://preview.nextjs.org/docs/app/guides/adopting-partial-prefetching) — they cover the incremental path (per-route `prefetch = 'partial'`, fixing dynamic-usage build errors) rather than a big-bang switch.
## The model
```ts
// next.config.ts
const nextConfig: NextConfig = {
cacheComponents: true,
partialPrefetching: true, // prefetch the static shell of linked routes
};
```
- **Static shell** — synchronous content, `'use cache'` output, and Suspense fallbacks prerender at build time.
- **Dynamic holes** — async work without `'use cache'` streams in behind `<Suspense>` at request time.
- **Build constraint** — any async work without `'use cache'` must sit inside `<Suspense>`, or the build fails (wrap it, or add `'use cache'`).
`cacheComponents` implies Partial Prerendering — it replaced `experimental.ppr` / `dynamicIO` / `useCache`, so don't set those. See [caching](https://preview.nextjs.org/docs/app/getting-started/caching).
With `cacheComponents: true`, the skill practice is **cache reusable reads**. Do not leave a database/API read dynamic just because Suspense makes the build pass. If a read has a stable key and a mutation can name what changed, give it a cache directive, tags, and a lifetime.
Dynamic reads are the exception: use them for values that must be recomputed for every request or cannot be invalidated coherently. When you leave a read dynamic, note the reason in the surrounding code/review and invalidate its mutations with `refresh()` because there is no tag to update.
## Decide what to cache
| Data | Directive | Notes |
| ---- | --------- | ----- |
| Cacheable across users (public listings, computed pages) | [`'use cache'`](https://preview.nextjs.org/docs/app/api-reference/directives/use-cache) | Add [`cacheTag`](https://preview.nextjs.org/docs/app/api-reference/functions/cacheTag) (a global + a scoped tag) and a [`cacheLife`](https://preview.nextjs.org/docs/app/api-reference/config/next-config-js/cacheLife) profile. |
| Per-user / reads cookies, headers, session | [`'use cache: private'`](https://preview.nextjs.org/docs/app/api-reference/directives/use-cache-private) | Cached in the browser only, doesn't persist across reloads; never stored on the server. |
| Remote service, safe across users, worth durable storage | [`'use cache: remote'`](https://preview.nextjs.org/docs/app/api-reference/directives/use-cache-remote) | Protects against rate-limited third-party APIs. |
| Genuinely dynamic per request | none | Must be justified. Read inside `<Suspense>`; mutations use `refresh()` because no tag exists. |
Cache the **query** when its result should be reused across requests. Cache the **component** when rendering is expensive and props are stable (a nav, a trending sidebar). Don't `'use cache'` a component that already calls a `'use cache'` query — double-caching, no benefit.
## Keep a synchronous value out of the shell
You usually don't need this. A query that reads `cookies()`/`headers()` or awaits a DB/`fetch` inside `<Suspense>` already stays out of the shell on its own. Only a *synchronous* request-time read (`new Date()`, `Math.random()`, a sync sqlite read) needs help: `await` [`io()`](https://preview.nextjs.org/docs/app/api-reference/functions/io) before it, with the caller inside `<Suspense>`.
Prefer `io()` over [`connection()`](https://preview.nextjs.org/docs/app/api-reference/functions/connection): both exclude what follows from the shell, but `connection()` blocks prefetches while `io()` stays prefetchable. Reach for `connection()` only when rendering must wait for a real user request.
## Decide how to invalidate
- [`updateTag(tag)`](https://preview.nextjs.org/docs/app/api-reference/functions/updateTag) — in **server actions**, when the user should see the result immediately (read-your-own-writes). Requires the query to carry a matching `cacheTag`.
- [`revalidateTag(tag, 'max')`](https://preview.nextjs.org/docs/app/api-reference/functions/revalidateTag) — in **route handlers** (webhooks, cron) for stale-while-revalidate. The single-arg `revalidateTag(tag)` form is deprecated.
- [`refresh()`](https://preview.nextjs.org/docs/app/api-reference/functions/refresh) — re-render the current route for the current user. Use it for deliberately dynamic reads with no tag; don't use it instead of `updateTag()` for cached reads.
Tag, cache, invalidate: the `cacheTag` in the query and the `updateTag` in the action use the same string and live in the same feature folder.
## Coordinate hydrated client data
When cached server data seeds SWR, TanStack Query, or another browser cache, follow `references/single-page-applications.md`. Server and client freshness policies are independent; hydration adds library-specific constraints.
## Build failure map
When `next build` fails under Cache Components, map the error back to an architecture rule instead of patching locally:
- Async work without `'use cache'` and without an ancestor `<Suspense>` → cache the reusable read, or wrap a justified dynamic read in a page-owned `<Suspense>`.
- Request data inside `'use cache'` → switch to `'use cache: private'` when it is per-user cacheable, or keep it dynamic with a documented reason.
- `await params` / `await searchParams` at the top of a page → keep the page synchronous and move the read into `params.then()` / `searchParams.then()`.
- Sync request-time values (`new Date()`, `Math.random()`, sync storage reads) captured in the shell → cache stable values, or use [`io()`](https://preview.nextjs.org/docs/app/api-reference/functions/io) for per-request values.
For adoption-wide blocker triage, use `next-cache-components-adoption`. For API-specific recipes, follow the [Migrating to Cache Components guide](https://preview.nextjs.org/docs/app/guides/migrating-to-cache-components).
## Without Cache Components
- Don't use `'use cache'` / `cacheTag` / `cacheLife` — they require the flag.
- Use React `cache()` only for proven same-request dedup needs; plain `server-only` async queries are the default.
- Invalidate with `refresh()` from server actions.
- Pages still use `params.then()` in this architecture. Without Cache Components there is no build-time static shell to preserve, but keeping pages synchronous still lets chrome paint before route-specific data resolves and keeps the app consistent.

View File

@@ -0,0 +1,214 @@
# Components
How to build server and client components inside a feature folder.
## Default: async server component
Server components await their own queries directly — no `useEffect`, no client-side fetching, no manual loading state. See the [Server Components docs](https://preview.nextjs.org/docs/app/getting-started/server-and-client-components) for the model.
Prefer minimal, stable props: IDs, slugs, handles, parsed filters, or records the parent already fetched. Do not pass raw route `params` or `searchParams` into feature components. Pages resolve those promises and pass plain values.
```tsx
// features/notifications/components/notifications-badge.tsx
import { getUnreadNotificationCount } from "@/features/notifications/notifications-queries";
export async function NotificationsBadge() {
const count = await getUnreadNotificationCount();
if (count === 0) return null;
return <span aria-label={`${count} unread`}>{count}</span>;
}
```
The page (not this file) wraps it in `<Suspense fallback={<NotificationsBadgeSkeleton />}>` — see `references/pages-suspense.md`.
For parameterized routes, the page resolves `params` and the feature receives an ID:
```tsx
// app/post/[id]/page.tsx
<Suspense fallback={<PostDetailSkeleton />}>
{params.then(({ id }) => (
<PostDetail id={id} />
))}
</Suspense>
```
```tsx
// features/post/components/post-detail.tsx
export async function PostDetail({ id }: { id: string }) {
const post = await getPost(id);
return <article>{post.body}</article>;
}
```
## Skeletons live in the same file
Export the main component and its skeleton from the same file. Pages import both. Define the skeleton **at the end of the file**, below the real component(s) — never above. Function declarations are hoisted, so a skeleton referenced by a component earlier in the file still works when defined last.
```tsx
export async function Feed({ userId }: { userId: string }) {
const posts = await getFeed(userId);
return (
<ul>
{posts.map((p) => (
<Post key={p.id} post={p} />
))}
</ul>
);
}
export function FeedSkeleton() {
return (
<ul>
{Array.from({ length: 3 }).map((_, i) => (
<li key={i}>
<Skeleton className="h-24" />
</li>
))}
</ul>
);
}
```
Don't export a second skeleton whose whole job is to rename or preconfigure another skeleton:
```tsx
// Wrong — alias wrapper adds an import surface but no behavior
export function CompactGridSkeleton() {
return <GridSkeleton dense />;
}
```
Import the real skeleton and pass the prop inline at the `<Suspense>` boundary: `fallback={<GridSkeleton dense />}`.
### Skeleton design checklist
1. Match the real component's layout: flex direction, gaps, padding, breakpoints.
2. Include all structural elements: avatar circles, action button placeholders, image squares.
3. Responsive visibility must match (`hidden sm:block` in the real component → same in the skeleton).
4. Show 2–5 placeholders for variable-length lists, not the real count.
5. Don't include skeletons for inner Suspense content — those have their own boundaries.
6. Reserve the right height. CLS comes from skeletons that are shorter than the real content.
## Group related components in one file
A card and its grid live in the same file. For example, `genre-card.tsx` exports `GenrePill`, `GenreCard`, `GenreGrid`, `GenreGridSkeleton`. Variants should reuse that skeleton inline instead of exporting alias skeletons. Don't split shared UI primitives prematurely — wait until three call sites need the same shape before extracting.
Two sidebar widgets that happen to look similar but render different data shapes are **not** the same component. The visuals diverge as soon as one needs an extra slot.
### Single-use sub-components stay inlined
For a metadata strip inside one card, a header used only by one detail view, a list item only rendered by its list — inline them as **non-exported** functions in the same file:
```tsx
export async function EventDetails({ slug }: { slug: string }) {
const event = await getEventBySlug(slug);
return (
<article>
<MetaStrip event={event} />
<Speaker speaker={event.speaker} />
<p>{event.description}</p>
</article>
);
}
function MetaStrip({ event }: { event: Event }) { ... }
function Speaker({ speaker }: { speaker: string }) { ... }
```
Exports are for things other files will import. Internal structure is for readability inside one file.
## The server/client boundary
`'use client'` only when you need:
- Hooks (`useState`, `useReducer`, `useOptimistic`, `useTransition`, `useEffect`)
- Event handlers (`onClick`, `onChange`, `onSubmit`)
- Browser APIs (`window`, `localStorage`, refs to DOM)
If the component needs interactive pieces, keep the server component as the parent and render client leaves:
```tsx
async function PostDetail({ id }: { id: string }) {
const [post, userState] = await Promise.all([
getPost(id),
getPostUserState(id),
]);
return (
<article>
<PostBody body={post.body} />
<PostActions userState={userState} /> {/* 'use client' leaf */}
</article>
);
}
```
### Server content as children of client components
Composition crosses the boundary. A client component can accept server-rendered JSX as children or props:
```tsx
<ComposerForm
avatar={
<Suspense fallback={<AvatarSkeleton />}>
<CurrentUserAvatar />
</Suspense>
}
/>
```
`ComposerForm` is `'use client'`. It doesn't know where the avatar JSX came from. The Suspense boundary streams the avatar in without the form re-rendering.
### Pass server children resolved values, not promises
Prefer passing plain values (strings, IDs, resolved data) to a server child. A server component _can_ `await` a promise prop, but resolve route promises in the page instead — pass an unresolved promise down only to a _client_ component that reads it with `use()` (see below). When a parent already has the data from its own query, pass it as a prop instead of having the child refetch.
```tsx
// Right — parent fetches the list, passes each item
async function Feed({ userId }: { userId: string }) {
const posts = await getFeed(userId);
return posts.map((post) => <Post key={post.id} post={post} />);
}
async function Post({ post }: { post: Post }) {
return <article>{post.body}</article>;
}
```
```tsx
// Wrong — child refetches what the parent already had
async function Post({ id }: { id: string }) {
const post = await getPost(id);
return <article>{post.body}</article>;
}
```
## Client components that own their loading state
When a client component needs server data but should manage its own loading (a sidebar badge, a popover that opens on hover), pass an **unresolved promise** from the server and resolve it with [`use()`](https://react.dev/reference/react/use) on the client. Wrap the consumer in `<Suspense>`.
```tsx
// page: pass the unresolved promise, wrap in Suspense
<Suspense fallback={<TagListSkeleton />}>
<TagPicker itemsPromise={getTags()} />
</Suspense>
```
```tsx
"use client";
import { use } from "react";
export function TagPicker({ itemsPromise }: { itemsPromise: Promise<Tag[]> }) {
const items = use(itemsPromise);
// render interactive UI from items
}
```
The opinionated bit: name promise props with a `Promise` suffix (`itemsPromise`, `userPromise`) so the contract is obvious at the call site.
### Client data libraries (SWR, TanStack Query)
Follow `references/single-page-applications.md` when a feature uses a browser data cache or needs externally authored updates. It covers when to use a library, where its files live, server seeding, Cache Components coordination, hydration, and mutations.
## Mutations
For client-side reactions to a server mutation (instant feedback, pending state, success/error toasts), see `references/ux-patterns.md`. To cache rendered output across requests, see `references/cache-components.md`.

View File

@@ -0,0 +1,34 @@
# Reference app: next-beats
A working app that follows this architecture: **<https://github.com/vercel-labs/next-beats>** (Next.js 16, `cacheComponents` + `partialPrefetching`, a music player). Use it to see any invariant in real code rather than restating the code here. Paths are as of this writing — verify against the current repo.
For the reasoning behind this architecture, read [Component Architecture for React Server Components](https://aurorascharff.no/posts/component-architecture-for-react-server-components/). The skill's target is the same shape: pages describe layout and loading; feature components own server reads; route params become IDs before they reach components.
## Invariant → where to see it
| Invariant | File(s) |
| --------- | ------- |
| 1. Pages compose, never fetch | `app/(app)/search/page.tsx`, `app/(app)/genre/[genre]/page.tsx` — import feature components, place `<Suspense>`, no queries. |
| 2. Pages stay synchronous (`params.then` / `searchParams.then`) | `app/(app)/track/[id]/page.tsx`, `app/(app)/genre/[genre]/page.tsx`, `app/(app)/search/page.tsx`. |
| 3. Feature components receive IDs, not route props | Track/genre pages resolve `params` / `searchParams` and pass `id`, `genre`, or parsed values into feature components. |
| 4. Async server component default; `'use client'` on leaves | Server: `features/track/components/discover.tsx`, `most-played.tsx`. Client leaves: `features/track/components/track-interactions.tsx`, `play-button.tsx`. |
| 5. Page owns Suspense; feature owns skeleton | Pages place the boundary (e.g. `app/(app)/genre/[genre]/page.tsx`); features export the skeleton (below). |
| 6. Skeleton in the same file, at the end | `features/track/components/track-row.tsx` (`TrackRow` … `TrackListSkeleton`), `features/genre/components/genre-card.tsx`. |
| 7. `<domain>-queries.ts` (`server-only`) / `<domain>-actions.ts` (`'use server'`) | `features/track/track-queries.ts`, `features/playlist/playlist-actions.ts`. |
| 8. One folder per domain; sub-concepts folded in | `toggleFavorite` in `features/track/track-actions.ts` (no `favorite` folder); `searchTracks` in `features/track/track-queries.ts` (no `search` folder). |
| 9. Client components import actions directly | `features/track/components/track-interactions.tsx` imports `toggleFavorite` directly. |
## Supporting patterns
| Pattern | File |
| ------- | ---- |
| Error boundary on `catchError` (`ErrorInfo` `retry`) | `components/ui/error-boundary.tsx` |
| `useOptimistic` for an unlikely-to-fail toggle | `features/track/components/track-interactions.tsx` |
| Action-prop / `*Action` convention + confirm dialog | `features/playlist/components/playlist-interactions.tsx`, `components/ui/confirm-dialog.tsx` |
| `useFormStatus` submit button | `components/ui/button.tsx` |
| `useActionState` inline field errors | `features/user/components/sign-in-form.tsx` |
| Client-owned live data via a provider (not `<Poller>`) | `providers/player-provider.tsx` → `components/now-playing-bar.tsx` |
| `use()` on an unresolved promise prop | `features/playlist/components/add-to-playlist-menu.tsx` |
| Purpose-named `components/scripts/` subfolder | `components/scripts/` |
> The repo is a live app, not a golden reference — spots may drift from the invariants (a page may fetch inline, a route `error.tsx` may lag an API rename). When the app and the invariants disagree, the invariants win; treat the mismatch as a fix for the app.

View File

@@ -0,0 +1,147 @@
# Feature folders
How to organize code under `features/` and `app/`.
## Folder layout
```
features/<domain>/
<domain>-cache.ts # Pure server tags + client query keys, when shared
<domain>-queries.ts # Server-only queries
<domain>-actions.ts # Server actions
<domain>-query-options.ts # Client data-library query definitions, when needed
components/ # Server + client components, each with its skeleton
types/ # Feature-local public types, when needed by multiple files
hooks/ # Actual feature-local React hooks and hook wrappers
providers/ # Feature-local providers, only when the provider belongs to this domain
```
The folder name **is** the domain. The query and action filenames match the folder.
## How many features?
Keep the feature list short. One folder per **domain noun a user would recognize**, not per database table or technical concern.
A new folder is justified when **all three** are true:
1. The concept has its own queries.
2. The concept has its own pages or routes.
3. The concept is referenced from at least two other features.
If you find yourself making a feature folder with one query, one action, and one button, fold it into the parent feature instead.
### Merge aggressively
Concepts that exist only in service of a parent entity belong inside the parent's feature folder:
- A `favorite` or `bookmark` concept that only attaches to one parent entity (events, posts) → inside that parent's folder.
- A `like`, `repost`, `vote`, or `reaction` concept on a piece of content → with that content's feature.
- `auth` / `session` / `current user` → a single `user` folder, not split.
- A cross-cutting concern like `search` folds into the primary content feature it queries (`searchTracks` in `features/track/`), not a `features/search/` folder — the page composes it.
Concrete example: `toggleFavorite` is a mutation about events ("I favorite an event"), not its own domain. It lives in `features/event/event-actions.ts`, not `features/favorite/favorite-actions.ts`.
## File naming
Filenames inside the folder always start with the folder name:
```
features/event/
event-queries.ts
event-actions.ts
components/
event-grid.tsx
event-details.tsx
favorite-button.tsx ← OK: a component, not a "favorite" feature
```
- `<folder>-queries.ts` — even if the file has only one query.
- `<folder>-actions.ts` — even if a mutation is about a sub-concept.
- Other `<folder>-*.ts` files are fine when the folder needs them (`playlist-constants.ts`, `<folder>-schema.ts`), as long as they keep the folder-name prefix. Don't put reusable domain types in a root `*-types.ts` file; use `features/<domain>/types/` once a type is imported by multiple files.
- Component files use any descriptive name. The component (not the feature) is the unit here.
## Local vs shared support folders
Use a local support folder when the code belongs to one feature:
```
features/message/
message-cache.ts
message-query-options.ts
types/
message.ts
hooks/
use-message-mutations.ts
use-message-draft.ts
providers/
message-draft-provider.tsx
```
Feature-owned client coordination stays with the feature. Place each file by the shape it exports and the domain it belongs to:
- Client data-library cache contracts and query definitions follow `references/single-page-applications.md`.
- Mutation wrappers that export hooks live in `hooks/use-*.ts` (`use-message-mutations.ts` exporting `useSendMessage`).
- Browser-only state helpers live in `hooks/` when their public API is a hook (`use-thread.ts`, `use-message-draft.ts`).
- Client leaf components that coordinate a server write live in `components/` next to the UI they support (`mark-activity-read.tsx` posts read activity in the background while the current `/activity` tree stays stable).
Keep the file prefix aligned with the feature folder when a file exports a grouped feature contract (`workspace-cache.ts` and `workspace-query-options.ts`, not `activity-cache.ts` in `features/workspace/`). Support code for a sub-concept still lives with the parent feature: reactions on messages belong in `features/message/`; unread activity chrome belongs in `features/workspace/`.
Promote only when there are real cross-feature consumers:
- `types/` at the project root — shared domain/application types imported across features.
- `hooks/` at the project root — shared client hooks used across features.
- `app/providers.tsx` or `components/*-provider.tsx` — app-shell providers that wrap the whole app.
Avoid root-level miscellany like `message-types.ts`, `shared-hooks.ts`, or `common-provider.tsx`; the folder name should explain the scope.
## What goes in `components/`
Each component file exports the main component **plus its skeleton**:
```tsx
// features/event/components/event-grid.tsx
export async function EventGrid(...) { ... }
export function EventGridSkeleton() { ... }
```
Group related components in one file when they're always used together or one is a natural building block for another. A card and its grid live together. For example, `genre-card.tsx` exports `GenrePill`, `GenreCard`, `GenreGrid`, `GenreGridSkeleton`.
Split into separate files only when:
- A component is consumed by multiple sibling components (one shared use is not enough — wait until three call sites need it).
- A component is `'use client'` and a sibling is a server component (the server/client boundary forbids sharing a file).
See `references/components.md` for inlining rules and the skeleton design checklist.
## What pages do
Pages in `app/` compose feature components with Suspense and transition wrappers. They never:
- Contain domain logic
- Define new components except thin transition wrappers (e.g. `<ViewTransition>`)
- Fetch data directly
- Inline route-specific components — extract them into the feature folder
See `references/pages-suspense.md` for page composition details.
## Top-level layout
```
app/ # Pages and layouts
features/ # Domain folders
components/ # UI primitives, theme, and app-shell singletons
hooks/ # Shared client hooks used across features
types/ # Shared cross-feature types only
lib/ # Utilities and cohesive non-domain subsystems
```
`lib/` holds flat helpers (`db.ts`, `utils.ts`) but may also group a cohesive non-domain subsystem in its own subfolder (e.g. `lib/audio/` for an audio engine). Cross-feature client hooks live in top-level `hooks/`; a hook used by a single feature co-locates in that feature's `hooks/`. Types follow the same rule: shared types at top-level `types/`, feature-only exported types in `features/<domain>/types/`.
`components/` holds:
- **`components/ui/`** — primitives. Low-level building blocks and action-prop components.
- **`components/theme/`** — theme provider and toggle, paired.
- **Top-level files** (`site-header.tsx`, `auth-gate.tsx`, `poller.tsx`) — app-shell singletons used once each. No `common/` folder — "common" is not a category. If a component is used everywhere it's a primitive (→ `ui/`); if it's used once it lives at the top level.
- **Purpose-named subfolders** are fine when several files share a clear technical role — e.g. `components/scripts/` for pre-hydration inline `<script>` seed components. This is distinct from the rejected `common/`: a `scripts/` folder names _what the files are_, not "miscellaneous."
Conventions for filenames and casing live in the project's `AGENTS.md`. This skill doesn't impose one.

View File

@@ -0,0 +1,223 @@
# Pages and Suspense
How to compose pages, place Suspense boundaries, and prevent layout shift.
## Pages are composition only
Pages in `app/` import feature components and place `<Suspense>` boundaries. They never:
- Fetch data directly (queries live in feature folders)
- Define new components except thin transition wrappers (e.g. `<ViewTransition>`)
- Inline route-specific UI (extract it into the feature folder)
- Pass raw `params` / `searchParams` to features
## Page function signatures
Type page and layout functions with the auto-generated `PageProps<'/route'>` / `LayoutProps<'/route'>` helpers — no import, regenerated on `next dev` / `next build` / `next typegen`. See [route type helpers](https://preview.nextjs.org/docs/app/api-reference/config/typescript#route-type-helpers).
```tsx
export default function PostPage({ params }: PageProps<'/post/[id]'>) { /* ... */ }
```
Don't hand-write `{ params: Promise<{ id: string }> }` — the generated types stay in sync with the route (catch-all, optional segments). Route handlers use `RouteContext<'/api/...'>`. `typedRoutes: true` is a *separate* feature (statically-typed `href`s), not the source of these helpers.
## Keep pages synchronous
Use `params.then()` instead of `await params`. Content above the `.then()` pre-renders into the static shell; content inside it suspends.
```tsx
import { Suspense } from 'react';
import { PostDetail, PostDetailSkeleton } from '@/features/post/components/post-detail';
export default function PostPage({ params }: PageProps<'/post/[id]'>) {
return (
<div>
<h1>Post</h1>
<Suspense fallback={<PostDetailSkeleton />}>
{params.then(({ id }) => (
<PostDetail id={id} />
))}
</Suspense>
</div>
);
}
```
The `<h1>` sits **above** the `params.then()` so it paints instantly. The `Suspense` fallback covers only the dynamic section.
Resolve route props to plain values at this boundary. Feature components receive `id`, `slug`, `query`, or parsed filter values — not `params`, `searchParams`, or unresolved server promises.
### Implicit return inside `.then()`
Use an implicit-return arrow function when the callback just renders JSX — e.g. `({ id }) => <PostDetail id={id} />`. Only switch to a block body with `return` when you need to do work first (destructure with defaults, parse a `searchParams` value, branch on a condition). This keeps the JSX-in-page shape readable and matches how the resolved tree will look.
### `searchParams` and combined params
```tsx
// searchParams only
export default function SearchPage({ searchParams }: PageProps<'/search'>) {
return searchParams.then(sp => {
const q = typeof sp.q === 'string' ? sp.q : '';
return q ? <SearchResults query={q} /> : <EmptyState />;
});
}
// Both params and searchParams
export default function ProfilePage({ params, searchParams }: PageProps<'/u/[handle]'>) {
return Promise.all([params, searchParams]).then(([{ handle }, sp]) => (
<ProfileFeed handle={handle} tab={parseTab(sp.tab)} />
));
}
```
### Metadata, static params, and `notFound()`
- [`generateMetadata`](https://preview.nextjs.org/docs/app/api-reference/functions/generate-metadata) runs before render, so `await params` is fine there — it's a separate async function, not the page body, so it doesn't make the page dynamic.
- Export [`generateStaticParams`](https://preview.nextjs.org/docs/app/api-reference/functions/generate-static-params) from a `[slug]` page/layout to pre-build a known set of slugs; with `cacheComponents` + `'use cache'` they land in the static shell. It does **not** change the page signature — `params` is still a Promise, still consumed with `params.then()`.
- A query that can't find its resource calls [`notFound()`](https://preview.nextjs.org/docs/app/api-reference/functions/not-found), which bubbles to the nearest [`not-found.tsx`](https://preview.nextjs.org/docs/app/api-reference/file-conventions/not-found). Don't try/catch it — use [`unstable_rethrow`](https://preview.nextjs.org/docs/app/api-reference/functions/unstable_rethrow) if you must catch nearby.
## The page owns the Suspense boundary
The feature exports the async component **and** its skeleton. The page imports both and places the boundary. Don't pre-wrap inside the feature — that hides the boundary and prevents grouping siblings.
```tsx
// features/post/components/post-detail.tsx
export async function PostDetail({ id }: { id: string }) { ... }
export function PostDetailSkeleton() { ... }
```
```tsx
// app/post/[id]/page.tsx
<Suspense fallback={<PostDetailSkeleton />}>
{params.then(({ id }) => (
<>
<PostDetail id={id} />
<ErrorBoundary title="Replies didn't load">
<Suspense fallback={<RepliesSkeleton />}>
<Replies postId={id} />
</Suspense>
</ErrorBoundary>
</>
))}
</Suspense>
```
If a page uses a transition wrapper (e.g. `<ViewTransition>`), place it in the page next to the `<Suspense>` boundary. Feature components render content and skeletons, not transition wrappers.
## Audit smells
When auditing an existing app, flag and fix these first:
- `export default async function Page(...)` that only awaits `params`, `searchParams`, or page-level queries.
- `import { getSomething } from '@/features/.../*-queries'` inside `app/**/page.tsx` or `layout.tsx`.
- Feature components whose props are `params`, `searchParams`, or a route-shaped object.
- Page-local components like `HomeContent`, `PostShell`, or `ResultsSection` that only exist to fetch data or group a Suspense fallback.
- `<Suspense>` inside feature components that prevents the page from grouping reveal behavior.
## Don't create page-local wrapper components
Avoid components whose only job is to group boundary content, like `HomeLists` or `HomeListsSkeleton`. Keep the resolved JSX and fallback JSX **inline in the page** so the loading shape, headings, and grouped reveal behavior are visible at the boundary.
```tsx
// Wrong — hides the structure behind a wrapper
<Suspense fallback={<HomeListsSkeleton />}>
<HomeLists searchParams={searchParams} />
</Suspense>
```
```tsx
// Right — structure visible at the page level
<Suspense
fallback={
<>
<FeaturedSkeleton />
<RecentSkeleton />
</>
}
>
{searchParams.then(sp => (
<>
<Featured filter={sp.filter} />
<Recent filter={sp.filter} />
</>
))}
</Suspense>
```
The same applies to feature-level skeleton aliases. If a variant only passes props to a base skeleton, import the base skeleton and pass those props inline in `fallback={...}`.
## Suspense boundary placement rules
1. **First section gets its own Suspense** with a known-height skeleton fallback.
2. **Section headings stay outside Suspense** when their final position is stable.
3. **Variable-height sections: group everything below them** in the same Suspense, including any headings that would otherwise paint in the wrong vertical position.
4. **Fixed-height sections: own boundary is safe.**
5. **Variable-length lists: show 2–5 skeleton items**, not the real count.
6. **Inner Suspense content stays out of the outer skeleton.** Each boundary owns its own.
7. **Never `fallback={null}` for visible UI.** If a boundary covers UI, give it a real shaped fallback, or group it with a sibling boundary that already has the correct fallback.
8. **If the top section's final height is unknown, group the following sections** in the same boundary so they reveal together and don't jump underneath.
## Error boundaries
Wrap fallible sections in a Next.js-aware error boundary so one failure doesn't take down the page. Build it on [`catchError`](https://preview.nextjs.org/docs/app/api-reference/functions/catchError) from `next/error` (its `ErrorInfo` gives you a `retry()` that re-fetches server data) — it understands Next's control-flow throws (`notFound()`, `redirect()`, `unauthorized()`, `forbidden()`) and won't swallow them. Place the boundary around the suspending section, in the page:
```tsx
<ErrorBoundary title="Replies didn't load">
<Suspense fallback={<RepliesSkeleton />}>
<Replies postId={id} />
</Suspense>
</ErrorBoundary>
```
Why not plain `react-error-boundary`? It catches Next's framework throws (so `notFound()` never reaches `not-found.tsx`), and its reset doesn't re-fetch server data. Background: [Error Handling in Next.js with catchError](https://aurorascharff.no/posts/error-handling-in-nextjs-with-catch-error/).
Pair component-level boundaries with route-segment [`error.tsx`](https://preview.nextjs.org/docs/app/api-reference/file-conventions/error) for unrecoverable errors; it also receives a `retry` callback.
## Layout-level Suspense
Layouts compose feature components the same way pages do. Use `<Suspense>` for slots that fetch data (auth badge, sidebar):
```tsx
export default function RootLayout({ children }: LayoutProps<'/'>) {
return (
<html>
<body>
<Suspense>
<AuthGate userPromise={getCurrentUser()} />
</Suspense>
<main>{children}</main>
</body>
</html>
);
}
```
`AuthGate` is a client component that resolves the promise with `use()` so the dialog can render conditionally without server-side branching.
## CLS prevention
Layout shift happens when:
- A skeleton is shorter than the real content
- A heading sits inside a Suspense boundary whose final height is unknown — it paints in the wrong place, then jumps
- A variable-length list streams in without a fallback that reserves space
Fixes:
- Match skeleton height to the typical real content height.
- Move headings **outside** boundaries when their position depends on data above them.
- For unknown-height top sections, group everything below in one boundary so siblings stream together.
To audit CLS, use React DevTools' Suspense panel to pin each boundary in its loading state and check vertical positions.
## Runtime prefetch for high-value routes
With `cacheComponents` + [`partialPrefetching`](https://preview.nextjs.org/docs/app/api-reference/config/next-config-js/partialPrefetching) enabled, a visible `<Link>` prefetches the destination's shared [App Shell](https://preview.nextjs.org/docs/app/glossary#app-shell) — enough to commit navigation instantly, with link-specific content streaming after. The default (`'auto'`) already does this; don't write `prefetch = 'auto'`.
Use `<Link prefetch={true}>` on high-value links to also resolve the destination's per-link runtime data (`params`, `searchParams`, the full URL) before the click. Each such link can wake the server for a runtime prerender, so reserve it for routes users predictably visit next. See [runtime prefetching](https://preview.nextjs.org/docs/app/guides/runtime-prefetching).
Can't enable `partialPrefetching` app-wide yet? Opt in per route with `export const prefetch = 'partial'` on the destination, then drop the per-route exports once the global flag is on — see [Adopting Partial Prefetching](https://preview.nextjs.org/docs/app/guides/adopting-partial-prefetching) for the incremental path and [prefetch config](https://preview.nextjs.org/docs/app/api-reference/file-conventions/route-segment-config/prefetch) for the options. To validate navigation feels instant, see the [`instant` config](https://preview.nextjs.org/docs/app/api-reference/file-conventions/route-segment-config/instant) and [Instant Navigation guide](https://preview.nextjs.org/docs/app/guides/instant-navigation).
## Never wrap the entire page in a Suspense fallback
Page chrome (header, nav, surrounding layout) should paint instantly. Only data-dependent sections suspend. If you find yourself wrapping `<div>` and everything in it with `<Suspense fallback={<FullPageSkeleton />}>`, restructure: pull static elements out, narrow the boundary to just the dynamic part.

View File

@@ -0,0 +1,118 @@
# Queries and actions
The data layer. Every feature has both: queries to read, actions to write.
This page covers the universal data layer that applies to every Next.js App Router app. When `cacheComponents: true` is enabled, follow `references/cache-components.md`: reusable reads are cached/tagged/lifetimed, and mutations update matching tags.
## Cache identities
When a server read also seeds a browser data cache, follow `references/single-page-applications.md` for the feature-local cache contract and client-library placement.
## Queries
Create `features/<domain>/<domain>-queries.ts`. Mark it `import 'server-only'` — that's the invariant. Default to plain async exports.
```ts
import 'server-only';
export async function getFeed(userId: string) {
return db.post.findMany({ where: { userId } });
}
```
Use [`cache()`](https://react.dev/reference/react/cache) from React only for **request-level deduplication** when the same dynamic query is called multiple times with the same arguments in one render. Highest-value cases: a session/user lookup used by many queries, or a shared expensive read used by metadata + page sections. Don't wrap every query "just in case" — it adds indirection and can hide when data is intentionally dynamic.
`cache()` dedups within a request; `'use cache'` + `cacheTag` (Cache Components) shares results *across* requests. Don't add React `cache()` to a function only because it already uses `'use cache'`; that is double-caching unless you have a separate, proven same-request duplication problem. See `references/cache-components.md`.
## Actions
Create `features/<domain>/<domain>-actions.ts`. Mark with `'use server'` at the top. Always:
1. Verify auth.
2. Validate input with your schema validator.
3. Run the mutation.
4. Invalidate cached data so the next render sees the new state.
5. Return a result (`{ ok }` or `{ error }`).
```tsx
'use server';
import { refresh } from 'next/cache';
export async function createPost(formData: FormData) {
const user = await verifyUser();
const parsed = schema.safeParse({ body: formData.get('body') });
if (!parsed.success) {
return { ok: false as const, error: parsed.error.issues[0].message };
}
await db.post.create({ data: { body: parsed.data.body, userId: user.id } });
refresh();
return { ok: true as const };
}
```
[`refresh()`](https://preview.nextjs.org/docs/app/api-reference/functions/refresh) re-renders the current route for the current user. Use it when the affected read is deliberately dynamic and has no tag. With Cache Components enabled, reusable reads should have matching `cacheTag()` calls, so server actions normally call `updateTag()` for read-your-own-writes. See `references/cache-components.md`.
### Action file naming
Actions for a feature always go in `<folder>-actions.ts`, matching the folder name — even when the mutation operates on a sub-concept. `toggleFavorite` in `features/event/` lives in `event-actions.ts`, not `favorite-actions.ts`. The folder is the source of truth for the name.
## Calling actions from client components
Client components import server actions directly. **Don't** pass an action as a prop just to call it:
```tsx
// Right
'use client';
import { likePost } from '@/features/post/post-actions';
export function LikeButton({ postId }: { postId: string }) {
return <button onClick={() => likePost(postId)}>Like</button>;
}
```
```tsx
// Wrong — adds indirection with no benefit
async function Post({ id }: { id: string }) {
return <LikeButton postId={id} onLike={likePost} />;
}
```
Design components (`<BottomNav>`, `<ToggleGroup>`, `<SubmitButton>`) take this further with the **action-prop pattern** — `action` is a callback wrapped in `useTransition` / `useOptimistic` internally. See `references/ux-patterns.md`.
## Form actions vs onClick handlers
Prefer [`<form action={serverAction}>`](https://react.dev/reference/react-dom/components/form#action) for form mutations — React wraps the call in a transition and surfaces pending state automatically.
For one-off buttons, `onClick={() => action(args)}` is fine. Wrap in [`startTransition`](https://react.dev/reference/react/startTransition) if you need pending state.
## Return shape
Return a discriminated union from actions that can fail:
```tsx
export type ActionResult<T = void> = { ok: true; data?: T } | { ok: false; error: string };
```
Toast on `ok: false` from the client. Skip success toasts when an optimistic UI already shows the result.
A shared `ActionResult<T>` is optional — a per-action inline union is just as good, and often clearer when the payload has a natural name: `return { ok: true as const, playlist }` reads better than a generic `data`. What matters is that fallible actions return a discriminated union the client can narrow on, not that every action shares one type.
## Mappers and domain types
If your DB rows have shapes you don't want to leak to components (extra columns, ORM-specific types), write a mapper inside the query:
```ts
export async function getPost(id: string) {
const row = await db.post.findUnique({ where: { id }, include: { author: true } });
if (!row) notFound();
return toPost(row);
}
function toPost(row: PostRow & { author: UserRow }): Post {
return { id: row.id, body: row.body, author: row.author.handle };
}
```
Components see `Post`, not the ORM row. If that type is imported by multiple files in the feature, put it under `features/<domain>/types/` (for example `features/post/types/post.ts`). Promote it to top-level `types/` only when multiple features import it.

View File

@@ -0,0 +1,48 @@
# Single-page application patterns
Use this reference when a feature adds SWR, TanStack Query, or another browser data cache. For complete library APIs and runnable examples, follow the [Single-page applications guide](https://preview.nextjs.org/docs/app/guides/single-page-applications).
## Decide whether a client cache is needed
Use a client data library when the browser needs revalidation, optimistic mutations, request deduplication, or shared live data. If a Client Component only reads server data once, pass a Promise from its Server Component and unwrap it with `use()` instead.
## Keep ownership with the feature
```text
features/<domain>/
<domain>-cache.ts # Pure server tags + client keys
<domain>-queries.ts # Server reads and cacheLife
<domain>-query-options.ts # Client fetcher/query options
hooks/use-*.ts # Client mutations and coordination
components/ # Async server owner + client leaves
```
The cache contract imports neither Next.js nor the client library. Queries, actions, route handlers, hydration code, query options, and hooks import identities from it. This prevents a key or tag spelling from drifting between a read and its invalidation.
Keep behavior in the layer that owns it:
- Server `cacheLife`, `cacheTag`, and database reads belong in `<domain>-queries.ts`.
- Browser freshness and refetch behavior belong in `<domain>-query-options.ts` or the SWR hook.
- Optimistic mutation behavior belongs in `hooks/use-*.ts`.
- Tiny effect-only or interactive leaves belong in `components/`.
## Seed from the server
The async feature component owns the initial read and the library's hydration provider. The page remains a synchronous composition surface and owns the feature's Suspense boundary.
- With SWR, seed the exact key read by `useSWR`. Use `preload` with `cacheData` when later `mutate(key)` calls must update the seeded entry itself.
- With TanStack Query, seed the same query key read by the client query and render the client subtree inside `HydrationBoundary`.
Do not move the initial read to the browser just because the feature also has a client cache.
## Coordinate Cache Components
The server cache and browser cache have independent freshness policies. Do not mirror `cacheLife` into `staleTime`, polling intervals, or SWR revalidation settings. Coordinate identities and invalidation, not durations.
For tag-driven data, a mutation updates the client cache for immediate feedback and invalidates the same server tag used by the seeded read. For a time-driven server read, choose its `cacheLife` from the server data's freshness requirement.
TanStack Query hydration adds one coupling: the hydration timestamp must advance whenever the seeded data advances. For tag-driven reads, cache the timestamp with the same tags as the data. For time-driven reads, derive the data and timestamp from the same cached snapshot. Do not cache a `QueryClient` or dehydrated payload.
## Mutate without drift
Let the client library own the optimistic browser update, rollback, and authoritative response. Let the write invalidate the server tag only after stored data changes. Do not add polling as a cache-coordination mechanism; add focus revalidation, intervals, SSE, or WebSockets only when the product actually needs external updates to appear automatically.

View File

@@ -0,0 +1,68 @@
# UX patterns
Interaction decisions on top of the architecture: which feedback mechanism to reach for, and the boundary edge-cases that trip agents up. Hook mechanics live in the React / Next docs — linked, not restated. The deeper end-to-end picture is the [Building interactive apps guide](https://preview.nextjs.org/docs/app/guides/interactive-apps).
## Choose the feedback mechanism
| Situation | Reach for | Key rule |
| --------- | --------- | -------- |
| Mutation unlikely to fail (favorite, vote, follow) | [`useOptimistic`](https://react.dev/reference/react/useOptimistic) | Update immediately, roll back on throw. Set it inside a transition; inside `<form action>` React opens the transition for you. Use a reducer for counters. |
| No optimistic fit (filters, sort, navigation) | [`useTransition`](https://react.dev/reference/react/useTransition) + `data-pending` | Put `data-pending` on the pending node; let ancestors react with CSS (`has-data-pending:` for a direct parent, `group-has-data-pending:` further up) so it bubbles without prop drilling. |
| Form field validation ("fix this field") | [`useActionState`](https://react.dev/reference/react/useActionState) | Action returns `{ error }`; render inline with `aria-invalid` + `role="alert"`. |
| Submit disable + spinner | [`useFormStatus`](https://react.dev/reference/react-dom/hooks/useFormStatus) | Call it from a child of `<form>`, not the form component itself. |
| One-shot result with no visible change | toast | See below. |
`useOptimistic(false)` also works as a transition-scoped **pending flag** that resets automatically when the transition settles — handy when you don't need the `data-pending` bubbling.
## Toasts
- **Toast only on error** when an optimistic UI already shows the result — a success toast next to an optimistic checkmark/removal is double feedback, which is noise.
- **Toast on success** only for non-visible side effects (email sent, link copied, file uploaded).
- **Don't toast for routine navigation** — the page change is the feedback.
- **Don't toast inside a server action.** Toasts are client-side; return a result and toast at the call site.
## View transitions: portaled / floating UI
Portaled elements (toasts, dialogs, popovers, dropdowns, tooltips) flicker during route transitions unless excluded. Apply `viewTransitionName: 'none'` to the portal root. When the portal also needs stacking control (z-index) or has translucent layers (backdrop-blur), give it a *named* transition and neutralize it in CSS instead — `::view-transition-group(name) { animation: none; z-index: … }` can do things `'none'` can't. See the [React View Transitions skill](https://github.com/vercel-labs/agent-skills/tree/main/skills/react-view-transitions).
## Destructive actions (delete / leave / unsubscribe)
Gate behind a confirmation dialog, and mind two edge cases:
- **Don't `redirect()` inside the action.** It throws, which stops the client from toasting or closing the dialog. Return `{ ok: true }` and navigate with `router.push()`.
- **Don't wrap the whole action call in `useTransition`** inside the dialog — with view transitions on, that animates the background UI behind the dialog. Track pending with `useState` / `useOptimistic(false)` and reserve `startTransition` for the post-success navigation only.
## The action-prop pattern
A reusable design component (`<ToggleGroup>`, `<BottomNav>`, `<SubmitButton>`) can take an action-style prop and own the async coordination (optimistic update, pending, dimming) so consumers pass a plain callback. Convention: an `action` / `*Action` prop signals "triggers a mutation this component coordinates," versus a plain `onChange` / `onClick` — renaming between them is a contract change. Not every such prop is transition-wrapped: a destructive `confirmAction` is awaited *without* a transition (see above). Transition-wrapping is the default for optimistic/navigation actions, not a rule tied to the name.
## URL-based pagination
Drive the page number through `searchParams`, render each page as its own `<Suspense>` boundary so pages stream independently, and add "load more" with `<Link scroll={false}>`. See [linking and navigating](https://preview.nextjs.org/docs/app/getting-started/linking-and-navigating).
```tsx
import { Suspense } from 'react';
export function Feed({ page = 1 }: { page?: number }) {
return (
<ul>
{Array.from({ length: page }).map((_, i) => {
const p = i + 1;
return p === 1 ? (
<FeedPage key={p} page={p} />
) : (
<Suspense key={p} fallback={<FeedPageSkeleton />}>
<FeedPage page={p} />
</Suspense>
);
})}
</ul>
);
}
```
The first page can render in the parent boundary; later pages get their own fallbacks so "load more" streams only the newly requested page. If the URL update should preserve scroll, use [`<Link scroll={false}>`](https://preview.nextjs.org/docs/app/api-reference/components/link).
## Global client state
For truly global client state (audio player, cart, system-reactive theme), wrap a [context provider](https://react.dev/reference/react/createContext) at the root; the provider is `'use client'` but `children` stays server-rendered, and only leaf components read the context. **Don't push server data into it** — server data stays in queries; client state is for ephemeral UI (open menus, playback position, optimistic drafts). See the live-data decision in `references/components.md`.

View File

@@ -25,5 +25,5 @@ Funnel CTA butonlarında ("Listemi oluştur", "Tercihlerimi belirle", giriş/öd
# Meraklısına'daki mermaid şeması mimariyle senkron kalmalı
`/meraklisina` sayfasındaki sihirbaz akış şeması (`src/app/meraklisina/huni-semasi.tsx` içindeki `SEMA` sabiti) ürünün gerçek davranışının belgesidir, dekor değildir. Sihirbaz/listeleme mimarisinde davranış değiştiren her değişiklikte bu şema da aynı PR/commit içinde güncellenmeli: sihirbaz adımlarının sayısı-sırası-içeriği (alan → il → devlet/vakıf), aday havuzu kuralları, 24'lük liste iskeleti (hayal/dengeli/garanti dağılımı), havuz 24'ün altına düşünce filtre gevşetme sırası ve yapay zekânın havuz-dışına-çıkamama kuralı. Şemayı güncellerken çevresindeki unsurları da eşitle: bölümdeki anlatım metni (`src/app/meraklisina/page.tsx`, 4. bölüm), kabın `aria-label`'ı ve figcaption. Şema öğrenci diliyle kalmalı (SQL/prompt/teknik detay yok) ve ürünün risk renk dilini korumalı — kırmızı/sarı/yeşil her zaman metin etiketiyle birlikte, renk tek başına anlam taşımaz.
`/meraklisina` sayfasındaki sihirbaz akış şeması (`src/features/pazarlama/components/huni-semasi.tsx` içindeki `SEMA` sabiti) ürünün gerçek davranışının belgesidir, dekor değildir. Sihirbaz/listeleme mimarisinde davranış değiştiren her değişiklikte bu şema da aynı PR/commit içinde güncellenmeli: sihirbaz adımlarının sayısı-sırası-içeriği (alan → il → devlet/vakıf), aday havuzu kuralları, 24'lük liste iskeleti (hayal/dengeli/garanti dağılımı), havuz 24'ün altına düşünce filtre gevşetme sırası ve yapay zekânın havuz-dışına-çıkamama kuralı. Şemayı güncellerken çevresindeki unsurları da eşitle: bölümdeki anlatım metni (`src/features/pazarlama/components/meraklisina-icerik.tsx`, 4. bölüm), kabın `aria-label`'ı ve figcaption. Şema öğrenci diliyle kalmalı (SQL/prompt/teknik detay yok) ve ürünün risk renk dilini korumalı — kırmızı/sarı/yeşil her zaman metin etiketiyle birlikte, renk tek başına anlam taşımaz.
<!-- END:nextjs-agent-rules -->

Binary file not shown.

View File

@@ -42,6 +42,7 @@
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"resend": "^6.18.0",
"server-only": "^0.0.1",
"shiki": "^4.3.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",

8
pnpm-lock.yaml generated
View File

@@ -83,6 +83,9 @@ importers:
resend:
specifier: ^6.18.0
version: 6.18.0
server-only:
specifier: ^0.0.1
version: 0.0.1
shiki:
specifier: ^4.3.1
version: 4.3.1
@@ -5133,6 +5136,9 @@ packages:
resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==}
engines: {node: '>= 18'}
server-only@0.0.1:
resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==}
set-cookie-parser@3.1.2:
resolution: {integrity: sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==}
@@ -10947,6 +10953,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
server-only@0.0.1: {}
set-cookie-parser@3.1.2: {}
set-function-length@1.2.2:

View File

@@ -18,7 +18,13 @@ import { randomUUID } from "node:crypto";
import { existsSync, readFileSync } from "node:fs";
import { z } from "zod";
import { appDb, schema } from "../src/lib/appdb";
import { dilimAra, type DilimKey, type Program, type PuanTuruKey, PUAN_TURLERI } from "../src/lib/db";
import { dilimAra } from "../src/lib/db";
import {
PUAN_TURLERI,
type DilimKey,
type Program,
type PuanTuruKey,
} from "../src/types/yokatlas";
import { KATEGORILER, kategoriEslesir } from "../src/lib/kategoriler";
import { havuzuKompaktJson, turLabel } from "../src/lib/rapor-havuzu";
import { GENEL_KATEGORI, KOVALAR, kategoriSlugBul } from "../src/lib/tadimlik-havuzu";

View File

@@ -1,11 +1,10 @@
import { NextResponse, type NextRequest } from "next/server";
import { dilimAra, rankWindowFacets } from "@/lib/db";
import {
PUAN_TURLERI,
dilimAra,
rankWindowFacets,
type DilimKey,
type PuanTuruKey,
} from "@/lib/db";
} from "@/types/yokatlas";
import { KATEGORILER } from "@/lib/kategoriler";
// Sihirbaz modalı için: sıralamaya uygun kategori/il/üniversite tipi

View File

@@ -1,10 +1,10 @@
import { NextResponse, type NextRequest } from "next/server";
import { dilimAra } from "@/lib/db";
import {
PUAN_TURLERI,
dilimAra,
type DilimKey,
type PuanTuruKey,
} from "@/lib/db";
} from "@/types/yokatlas";
const SAYFA_BOYU = 20;

View File

@@ -8,12 +8,13 @@ import {
krediBittiIsaretle,
} from "@/lib/credits";
import { anahtarVar, SORUMLULUK_REDDI } from "@/lib/ai/client";
import { kullanicininRaporu } from "@/lib/rapor-kaydi";
import { kullanicininRaporu } from "@/features/rapor/rapor-queries";
import { sohbetAkisi } from "@/lib/ai/cagri";
import { listeOzetiCikar, type RaporSonuc } from "@/lib/ai/rapor";
import type { RaporParams } from "@/lib/rapor-havuzu";
import { secimOzeti } from "@/lib/sihirbaz";
import { PUAN_TURLERI } from "@/lib/db";
import { listeOzetiCikar } from "@/lib/ai/rapor";
import type { RaporSonuc } from "@/features/rapor/types/rapor";
import type { RaporParams } from "@/features/rapor/types/rapor";
import { secimOzeti } from "@/features/sihirbaz/sihirbaz-sabitler";
import { PUAN_TURLERI } from "@/types/yokatlas";
import { ACIK_SATIR } from "@/lib/rapor-maske";
const MAX_GECMIS = 12;

View File

@@ -2,7 +2,7 @@ import { NextResponse } from "next/server";
import { sosyalKanitVerisi } from "@/lib/sosyal-kanit";
// Fiyat bölümündeki şeridin canlı sayıyı tazelemek için yokladığı uç.
// İlk değer sunucudan HTML ile geliyor (bkz. components/landing/sosyal-kanit.tsx);
// İlk değer sunucudan HTML ile geliyor (bkz. features/pazarlama/components/sosyal-kanit.tsx);
// burası yalnızca sayfa açık kaldıkça gelen güncellemeler için.
// Sayılar sosyal kanıt, gizli veri değil — kimlik doğrulaması yok.
// Yük: sosyalKanitVerisi kendi içinde TTL önbelleğine sahip, yoklama

View File

@@ -3,7 +3,7 @@
// batch üretilmiştir — bu uç LLM'e istek atmaz, salt SELECT'tir.
import { NextResponse } from "next/server";
import { PUAN_TURLERI, type PuanTuruKey } from "@/lib/db";
import { PUAN_TURLERI, type PuanTuruKey } from "@/types/yokatlas";
import { KATEGORILER } from "@/lib/kategoriler";
import { kategoriSlugBul, tadimlikSec } from "@/lib/tadimlik-havuzu";

View File

@@ -1,28 +1,32 @@
import { Suspense } from "react";
import type { Metadata } from "next";
import { getAllBolumSlugs } from "@/lib/katalog";
import { BolumIcerik, bolumSayfaMetadata } from "./bolum-icerik";
import {
BolumIcerik,
BolumIcerikSkeleton,
bolumSayfaMetadata,
bolumStaticSlugs,
} from "@/features/katalog/components/bolum-icerik";
// Tüm bölümler build sırasında bilinir (veri ancak ingest + deploy ile
// değişir); bilinmeyen slug getBolumBySlug'dan null dönüp notFound()'a düşer.
// (dynamicParams cacheComponents ile uyumsuz olduğundan kullanılamıyor.)
export function generateStaticParams() {
return getAllBolumSlugs().map((slug) => ({ slug }));
return bolumStaticSlugs().map((slug) => ({ slug }));
}
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
}: PageProps<"/bolum/[slug]">): Promise<Metadata> {
const { slug } = await params;
return bolumSayfaMetadata(slug, 1);
}
export default async function BolumPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
return <BolumIcerik slug={slug} sayfa={1} />;
export default function BolumPage({ params }: PageProps<"/bolum/[slug]">) {
return (
<Suspense fallback={<BolumIcerikSkeleton />}>
{params.then(({ slug }) => (
<BolumIcerik slug={slug} sayfa={1} />
))}
</Suspense>
);
}

View File

@@ -1,22 +1,17 @@
import { Suspense } from "react";
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { getAllBolumler } from "@/lib/katalog";
import {
BolumIcerik,
BolumIcerikSkeleton,
bolumSayfaMetadata,
bolumToplamSayfa,
} from "../../bolum-icerik";
bolumSayfaStaticParams,
} from "@/features/katalog/components/bolum-icerik";
// Yalnızca 1'den fazla sayfası olan bölümler için 2..N sayfaları üretilir;
// 1. sayfa taban rota (/bolum/[slug]) olduğundan burada üretilmez.
export function generateStaticParams() {
return getAllBolumler().flatMap((b) => {
const toplam = bolumToplamSayfa(b.programSayisi);
return Array.from({ length: Math.max(0, toplam - 1) }, (_, i) => ({
slug: b.slug,
no: String(i + 2),
}));
});
return bolumSayfaStaticParams();
}
function sayfaNo(no: string): number | null {
@@ -26,22 +21,23 @@ function sayfaNo(no: string): number | null {
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string; no: string }>;
}): Promise<Metadata> {
}: PageProps<"/bolum/[slug]/sayfa/[no]">): Promise<Metadata> {
const { slug, no } = await params;
const n = sayfaNo(no);
if (n == null) return {};
return bolumSayfaMetadata(slug, n);
}
export default async function BolumSayfaPage({
export default function BolumSayfaPage({
params,
}: {
params: Promise<{ slug: string; no: string }>;
}) {
const { slug, no } = await params;
const n = sayfaNo(no);
if (n == null) notFound();
return <BolumIcerik slug={slug} sayfa={n} />;
}: PageProps<"/bolum/[slug]/sayfa/[no]">) {
return (
<Suspense fallback={<BolumIcerikSkeleton />}>
{params.then(({ slug, no }) => {
const n = sayfaNo(no);
if (n == null) notFound();
return <BolumIcerik slug={slug} sayfa={n} />;
})}
</Suspense>
);
}

View File

@@ -1,11 +1,11 @@
import { Suspense } from "react";
import type { Metadata } from "next";
import Link from "next/link";
import { getAllBolumler, type BolumOzet } from "@/lib/katalog";
import { KATEGORILER } from "@/lib/kategoriler";
import { JsonLd, breadcrumbJsonLd } from "@/lib/seo";
import { CtaSiraForm } from "@/components/cta-sira-form";
import { SiteFooter } from "@/components/site-footer";
import { PagePixelDivider } from "@/components/pixel-decor";
import {
BolumKatalogu,
BolumKataloguSkeleton,
} from "@/features/katalog/components/bolum-listesi";
export const metadata: Metadata = {
title: "Tüm Bölümlerin Taban Puanları ve Sıralamaları 2026",
@@ -14,68 +14,7 @@ export const metadata: Metadata = {
alternates: { canonical: "/bolumler" },
};
const sayi = (n: number) => n.toLocaleString("tr-TR");
function BolumLinkleri({ bolumler }: { bolumler: BolumOzet[] }) {
return (
<ul className="mt-3 grid gap-x-6 gap-y-1.5 sm:grid-cols-2 lg:grid-cols-3">
{bolumler.map((b) => (
<li key={b.slug}>
<Link
href={`/bolum/${b.slug}`}
className="group inline-flex items-baseline gap-2 text-sm text-slate-700 hover:text-primary"
>
<span className="group-hover:underline">{b.ad}</span>
<span className="text-xs text-slate-400">
{sayi(b.programSayisi)}
</span>
</Link>
</li>
))}
</ul>
);
}
function KategoriGruplari({
bolumler,
seviye,
}: {
bolumler: BolumOzet[];
seviye: "lisans" | "onlisans";
}) {
const kalanlar = new Set(bolumler.map((b) => b.slug));
const gruplar = KATEGORILER.map((kategori) => {
const uyeler = bolumler.filter((b) =>
(b.kategoriler as readonly string[]).includes(kategori),
);
for (const b of uyeler) kalanlar.delete(b.slug);
return { kategori: kategori as string, uyeler };
}).filter((g) => g.uyeler.length > 0);
const diger = bolumler.filter((b) => kalanlar.has(b.slug));
if (diger.length > 0) gruplar.push({ kategori: "Diğer", uyeler: diger });
return (
<>
{gruplar.map(({ kategori, uyeler }) => (
<section key={`${seviye}-${kategori}`} className="mt-8">
<h3 className="font-heading text-lg font-bold text-slate-900">
{kategori}
<span className="ml-2 text-xs font-normal text-slate-400">
{sayi(uyeler.length)} bölüm
</span>
</h3>
<BolumLinkleri bolumler={uyeler} />
</section>
))}
</>
);
}
export default function BolumlerPage() {
const hepsi = getAllBolumler();
const lisans = hepsi.filter((b) => b.lisans);
const onlisans = hepsi.filter((b) => b.onlisans && !b.lisans);
return (
<>
<main className="mx-auto w-full max-w-5xl px-4 py-12 sm:py-16">
@@ -89,33 +28,10 @@ export default function BolumlerPage() {
<h1 className="font-heading text-3xl font-bold text-slate-900 sm:text-4xl">
Tüm Bölümlerin Taban Puanları ve Sıralamaları 2026
</h1>
<p className="mt-4 max-w-3xl text-sm leading-7 text-slate-600">
{sayi(hepsi.length)} bölümün 2025 YKS yerleştirme verileri: taban
puanları, başarı sıralamaları, kontenjanlar ve devlet/vakıf dağılımı.
Bölüme tıklayınca o bölümü sunan tüm üniversitelerin tablosunu
görürsün. Veriler YÖK Atlas 2025 yerleştirme sonuçlarına dayanır; 2026
tercihleri için rehber niteliğindedir.
</p>
<PagePixelDivider seed={29} className="mt-8" />
<section className="mt-8">
<h2 className="font-heading text-2xl font-bold text-slate-900">
Lisans Bölümleri (4 Yıllık)
</h2>
<KategoriGruplari bolumler={lisans} seviye="lisans" />
</section>
<div className="mt-12">
<CtaSiraForm />
</div>
<section className="mt-12">
<h2 className="font-heading text-2xl font-bold text-slate-900">
Önlisans Bölümleri (2 Yıllık — TYT)
</h2>
<KategoriGruplari bolumler={onlisans} seviye="onlisans" />
</section>
<Suspense fallback={<BolumKataloguSkeleton />}>
<BolumKatalogu />
</Suspense>
</main>
<SiteFooter />
</>

View File

@@ -1,30 +1,16 @@
import { Suspense } from "react";
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { ArrowRight } from "lucide-react";
import { PixelCorner } from "@/components/pixel-decor";
import { Button } from "@/components/ui/button";
import {
DogrulaKarti,
DogrulaKartiSkeleton,
} from "@/features/kullanici/components/dogrula-karti";
export const metadata: Metadata = {
title: "Girişini onayla",
robots: { index: false, follow: false },
};
/** Yalnızca site içi (göreli) callback'lere izin ver — open redirect kalkanı. */
function guvenliYol(deger: string | undefined, varsayilan: string): string {
return deger && deger.startsWith("/") && !deger.startsWith("//")
? deger
: varsayilan;
}
/**
* Maildeki giriş bağlantısının indiği onay sayfası. Doğrulama ucu
* (/api/auth/magic-link/verify) GET'lendiği anda token'ı tüketir; mail
* güvenlik tarayıcıları ve tarayıcı ön-yüklemeleri linki kullanıcıdan önce
* açıp token'ı yakabildiği için maildeki link token'ı TÜKETMEYEN bu sayfaya
* gelir. Verify ancak buradaki form gönderilince tetiklenir — tarayıcı botları
* sayfayı GET'lese bile form'u göndermez, token yanmaz.
*/
export default async function DogrulaPage({
export default function DogrulaPage({
searchParams,
}: {
searchParams: Promise<{
@@ -34,68 +20,19 @@ export default async function DogrulaPage({
newUserCallbackURL?: string;
}>;
}) {
const { token, callbackURL, errorCallbackURL, newUserCallbackURL } =
await searchParams;
if (!token) redirect("/giris");
const callback = guvenliYol(callbackURL, "/");
const hataCallback = guvenliYol(
errorCallbackURL,
`/giris?hata=gecersiz-baglanti&callback=${encodeURIComponent(callback)}`,
);
const yeniKullaniciCallback = guvenliYol(newUserCallbackURL, callback);
return (
<div className="-mb-28 flex min-h-dvh flex-col bg-slate-50 text-slate-900">
<main className="mx-auto flex w-full max-w-md flex-1 flex-col items-stretch justify-center px-6 py-8">
<div className="relative">
<div className="relative rounded-2xl border border-slate-200 bg-white p-8 shadow-sm">
<h1 className="font-heading text-xl font-bold">Girişini onayla</h1>
<p className="mt-2 text-sm text-slate-600">
Bu bağlantı senin için üretildi ve{" "}
<span className="font-semibold">tek kullanımlık</span>. Aşağıdaki
butona tıkladığında KolayTercih hesabına giriş yapacaksın.
</p>
<form
method="GET"
action="/api/auth/magic-link/verify"
className="mt-6"
>
<input type="hidden" name="token" value={token} />
<input type="hidden" name="callbackURL" value={callback} />
<input
type="hidden"
name="errorCallbackURL"
value={hataCallback}
/>
<input
type="hidden"
name="newUserCallbackURL"
value={yeniKullaniciCallback}
/>
<Button
type="submit"
className="h-11 w-full cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
>
Giriş yap
<ArrowRight className="size-4" aria-hidden />
</Button>
</form>
<p className="mt-4 text-xs leading-relaxed text-slate-500">
Bu isteği sen yapmadıysan bu sayfayı kapatabilirsin — butona
tıklanmadığı sürece hesabında hiçbir şey değişmez.
</p>
</div>
<div
aria-hidden
className="pointer-events-none absolute inset-0 z-10 text-primary"
>
<PixelCorner className="absolute -right-4 -top-4 size-9 [&_rect]:[fill-opacity:0.65]" />
<PixelCorner className="absolute -bottom-4 -right-4 size-9 rotate-90 [&_rect]:[fill-opacity:0.65]" />
<PixelCorner className="absolute -bottom-4 -left-4 size-9 rotate-180 [&_rect]:[fill-opacity:0.65]" />
<PixelCorner className="absolute -left-4 -top-4 size-9 -rotate-90 [&_rect]:[fill-opacity:0.65]" />
</div>
</div>
<Suspense fallback={<DogrulaKartiSkeleton />}>
{searchParams.then((sp) => (
<DogrulaKarti
token={sp.token}
callbackURL={sp.callbackURL}
errorCallbackURL={sp.errorCallbackURL}
newUserCallbackURL={sp.newUserCallbackURL}
/>
))}
</Suspense>
</main>
</div>
);

View File

@@ -1,39 +0,0 @@
import { Skeleton } from "@/components/ui/skeleton";
// /giris açılırken spinner yerine sayfanın kendi iskeleti: kart, başlık,
// Google butonu, ayraç, e-posta alanı ve gönder butonu aynı yerde belirir —
// içerik gelince zıplama olmaz.
export default function GirisLoading() {
return (
<div className="-mb-28 flex min-h-dvh flex-col bg-slate-50">
<main className="mx-auto flex w-full max-w-md flex-1 flex-col items-stretch justify-center px-6 py-8">
<div className="rounded-2xl border border-slate-200 bg-white p-8 shadow-sm">
{/* Başlık + alt metin */}
<Skeleton className="h-8 w-40" />
<Skeleton className="mt-3 h-4 w-full" />
<Skeleton className="mt-1.5 h-4 w-2/3" />
{/* Google ile devam et */}
<Skeleton className="mt-6 h-11 w-full rounded-lg" />
{/* "veya" ayracı */}
<div className="mt-4 flex items-center gap-3">
<Skeleton className="h-px flex-1" />
<Skeleton className="h-3 w-8" />
<Skeleton className="h-px flex-1" />
</div>
{/* E-posta alanı + gönder butonu */}
<Skeleton className="mt-4 h-11 w-full rounded-lg" />
<Skeleton className="mt-3 h-11 w-full rounded-lg" />
</div>
{/* Kart altındaki yasal metin */}
<div className="mt-6 flex flex-col items-center gap-1.5">
<Skeleton className="h-3 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</div>
</main>
</div>
);
}

View File

@@ -1,95 +1,30 @@
import { Suspense } from "react";
import type { Metadata } from "next";
import Link from "next/link";
import { redirect } from "next/navigation";
import { getSession } from "@/lib/session";
import { PixelCorner } from "@/components/pixel-decor";
import { GirisForm } from "./giris-form";
import { GirisBaslik } from "./giris-baslik";
import {
GirisKarti,
GirisKartiSkeleton,
} from "@/features/kullanici/components/giris-karti";
export const metadata: Metadata = {
title: "Giriş",
robots: { index: false, follow: true },
};
export default async function GirisPage({
export default function GirisPage({
searchParams,
}: {
searchParams: Promise<{ callback?: string; hata?: string }>;
}) {
const { callback, hata } = await searchParams;
const callbackURL =
callback && callback.startsWith("/") ? callback : "/";
// Magic link doğrulaması başarısız olunca errorCallbackURL buraya düşürür
// (bkz. giris-form.tsx ve /giris/dogrula) — ham INVALID_TOKEN yerine
// dostça bir mesaj gösterip yeni bağlantı istemeye yönlendiriyoruz.
const baglantiHatasi = hata === "gecersiz-baglanti";
const session = await getSession();
if (session) redirect(callbackURL);
// Sihirbaz funnel'ı: seçimler yapıldı, listeye giriş kaldı
const funnel = callbackURL.startsWith("/sonuc");
return (
// -mb-28: layout'taki ScrollFlow sarmalayıcısının pb-28'ini nötrler —
// kart tam dvh içinde ortalanır, sayfa scroll etmez (sığmazsa scroll açılır).
<div className="-mb-28 flex min-h-dvh flex-col bg-slate-50 text-slate-900">
<main className="mx-auto flex w-full max-w-md flex-1 flex-col items-stretch justify-center px-6 py-8">
<div className="relative">
<div className="relative rounded-2xl border border-slate-200 bg-white p-8 shadow-sm">
{baglantiHatasi && (
<div
role="alert"
className="mb-5 rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-800"
>
<p className="font-semibold">
Giriş bağlantısı geçersiz ya da süresi dolmuş
</p>
<p className="mt-1">
Bağlantılar 15 dakika geçerli ve tek kullanımlık. Aşağıdan
yeni bir bağlantı isteyebilirsin.
</p>
</div>
)}
<GirisBaslik funnel={funnel} />
<p className="mt-2 text-sm text-slate-600">
{funnel ? (
<>
Seçimlerin hazır — giriş yap,{" "}
<span className="font-semibold">5 deneme kredinle</span> listen
hemen üretilsin.
</>
) : (
<>
Kayıt olduğunda{" "}
<span className="font-semibold">5 deneme kredisi</span>{" "}
hesabına tanımlanır — Yapay Zeka danışmana ücretsiz soru sorabilirsin.
</>
)}
</p>
<GirisForm callbackURL={callbackURL} />
</div>
<div
aria-hidden
className="pointer-events-none absolute inset-0 z-10 text-primary"
>
<PixelCorner className="absolute -right-4 -top-4 size-9 [&_rect]:[fill-opacity:0.65]" />
<PixelCorner className="absolute -bottom-4 -right-4 size-9 rotate-90 [&_rect]:[fill-opacity:0.65]" />
<PixelCorner className="absolute -bottom-4 -left-4 size-9 rotate-180 [&_rect]:[fill-opacity:0.65]" />
<PixelCorner className="absolute -left-4 -top-4 size-9 -rotate-90 [&_rect]:[fill-opacity:0.65]" />
</div>
</div>
<p className="mt-6 text-center text-xs leading-relaxed text-slate-500">
Giriş yaparak verilerinin tercih önerileri üretmek amacıyla{" "}
<Link href="/gizlilik" className="underline hover:text-slate-700">
gizlilik metnindeki
</Link>{" "}
gibi işlenmesini ve{" "}
<Link href="/kosullar" className="underline hover:text-slate-700">
kullanım koşullarını
</Link>{" "}
kabul etmiş olursun. KolayTercih yerleşme garantisi vermez.
</p>
<Suspense fallback={<GirisKartiSkeleton />}>
{searchParams.then(({ callback, hata }) => (
<GirisKarti callback={callback} hata={hata} />
))}
</Suspense>
</main>
</div>
);

View File

@@ -5,9 +5,9 @@ import localFont from "next/font/local";
import { Toaster } from "@/components/ui/sonner";
import { SiteHeader } from "@/components/site-header";
import { SiteTopBanner } from "@/components/site-top-banner";
import { ListeCekmecesiLazy } from "@/components/manuel-liste/liste-cekmecesi-lazy";
import { KaydetBannerLazy } from "@/components/manuel-liste/kaydet-banner-lazy";
import { TercihProfiliKapisiLazy } from "@/components/manuel-liste/tercih-profili-kapisi-lazy";
import { ListeCekmecesiLazy } from "@/features/liste/components/liste-cekmecesi-lazy";
import { KaydetBannerLazy } from "@/features/liste/components/kaydet-banner-lazy";
import { TercihProfiliKapisiLazy } from "@/features/liste/components/tercih-profili-kapisi-lazy";
import { HeaderGate } from "@/components/header-gate";
import { ProgressiveBlur } from "@/components/ui/skiper-ui/skiper41";
import { DevPanel } from "@/components/dev/dev-panel";

View File

@@ -1,19 +0,0 @@
import { Skeleton } from "@/components/ui/skeleton";
export default function ListemLoading() {
return (
<main className="mx-auto w-full max-w-7xl px-4 py-10">
<Skeleton className="h-6 w-40 rounded-full" />
<Skeleton className="mt-4 h-9 w-3/4 max-w-xl" />
<Skeleton className="mt-3 h-5 w-2/3 max-w-lg" />
<div className="mt-8 grid items-start gap-8 lg:grid-cols-[minmax(0,1fr)_340px]">
<div className="flex flex-col gap-2">
{Array.from({ length: 10 }).map((_, i) => (
<Skeleton key={i} className="h-16 w-full rounded-xl" />
))}
</div>
<Skeleton className="h-[480px] w-full rounded-2xl" />
</div>
</main>
);
}

View File

@@ -1,20 +1,9 @@
import { Suspense } from "react";
import type { Metadata } from "next";
import { asc, eq } from "drizzle-orm";
import { redirect } from "next/navigation";
import { MessageCircleQuestion, Sparkles } from "lucide-react";
import { verifySession, getCurrentUser } from "@/lib/session";
import { appDb, schema } from "@/lib/appdb";
import { kullanicininRaporu } from "@/lib/rapor-kaydi";
import { URUNLER, MAX_REVIZYON } from "@/lib/credits";
import type { RaporSonuc } from "@/lib/ai/rapor";
import type { RaporParams } from "@/lib/rapor-havuzu";
import { ViewportPortal } from "@/components/viewport-portal";
import { SiteFooter } from "@/components/site-footer";
import { raporMaskele, ACIK_SATIR, type MaskeliRapor } from "@/lib/rapor-maske";
import type { Mesaj } from "./sohbet-client";
import { ListemGovde } from "./listem-govde";
import { ListemBosCta } from "./listem-bos-cta";
import { ListeUretici } from "./liste-uretici";
import {
ListemIcerik,
ListemIcerikSkeleton,
} from "@/features/rapor/components/listem-icerik";
// listeRevize aksiyonu 2 AI denemesi yapabiliyor (deneme başına 60 sn abort
// sınırı, bkz. lib/ai/cagri.ts) → en kötü ~120 sn. Platform limiti bundan
@@ -28,126 +17,16 @@ export const metadata: Metadata = {
robots: { index: false, follow: false },
};
const PAKET_FIYATI = (URUNLER.paket.amountKurus / 100).toLocaleString("tr-TR", {
maximumFractionDigits: 0,
});
export default async function ListemPage({
export default function ListemPage({
searchParams,
}: {
searchParams: Promise<{ acildi?: string; uret?: string }>;
}) {
// Girişsiz gelene callback'i ?uret=1 ile ver: girişten dönüşte bekleyen
// seçimlerle üretim kaldığı yerden başlasın.
const ilkParams = await searchParams;
const geriYol = ilkParams.uret === "1" ? "/listem?uret=1" : "/listem";
const session = await verifySession(geriYol);
const [user, params, satir, sohbetGecmisi] = await Promise.all([
getCurrentUser(),
searchParams,
kullanicininRaporu(session.user.id),
appDb
.select({
id: schema.chatMessages.id,
role: schema.chatMessages.role,
content: schema.chatMessages.content,
})
.from(schema.chatMessages)
.where(eq(schema.chatMessages.userId, session.user.id))
.orderBy(asc(schema.chatMessages.createdAt))
.limit(100),
]);
if (!user) redirect(`/giris?callback=${encodeURIComponent(geriYol)}`);
const { acildi } = params;
const kilitAcilisi = Boolean(user.hasPaket && acildi === "1");
const raporVar = Boolean(satir?.result);
if (!raporVar) {
const bosDurum = (
<section className="mx-auto flex max-w-2xl flex-col items-center px-4 py-16 text-center">
<Sparkles className="size-10 text-primary" aria-hidden />
<h1 className="mt-4 font-heading text-2xl font-bold">
Henüz Yapay Zeka listen yok
</h1>
<ListemBosCta />
</section>
);
return (
<>
<main className="mx-auto w-full max-w-4xl px-4 py-12">
{/* ?uret=1: sihirbazdan yönlendirildi — bekleyen seçimlerle üretim
burada koşar, animasyon ve hata/kredi durumları burada yaşar. */}
{params.uret === "1" ? (
<ListeUretici
hasPaket={Boolean(user.hasPaket)}
bosDurum={bosDurum}
/>
) : (
bosDurum
)}
</main>
<SiteFooter />
</>
);
}
const rapor: MaskeliRapor = user.hasPaket
? (satir!.result as RaporSonuc)
: raporMaskele(satir!.result as RaporSonuc);
const raporParams = satir!.params as RaporParams;
// Danışman özeti sohbetin açılış mesajı olarak gösterilir; sohbet
// /api/soru üzerinden zaten liste + seçim bağlamıyla çalışıyor.
// Paketsizde rapor zaten maskeli: 1 uyarı açık, kalanının yalnızca sayısı var.
const danismanOzeti = rapor.genelDegerlendirme
? {
metin: rapor.genelDegerlendirme,
uyarilar: rapor.uyarilar,
kilitli: !user.hasPaket,
kilitliUyariSayisi: user.hasPaket ? undefined : rapor.kilitliUyariSayisi,
}
: undefined;
return (
<>
<main className="mx-auto w-full max-w-7xl px-4 py-12">
{kilitAcilisi ? (
<div
role="status"
className="mb-6 flex items-center gap-3 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-medium text-emerald-800"
>
<Sparkles className="size-4 shrink-0" aria-hidden />
Paketin aktif — listendeki tercihlerin tamamı, gerekçeler, riskler ve
PDF artık açık.
</div>
) : null}
{/* Başlık ve revizyon/PDF aksiyonları listenin kendi başlık satırında
(bkz. listem-govde.tsx) — sayfa üstünde ayrı bir header yok. */}
<ListemGovde
rapor={rapor}
adaySira={raporParams.sira}
hasPaket={Boolean(user.hasPaket)}
kilitAcilisi={kilitAcilisi}
kilitliBaslangic={user.hasPaket ? undefined : ACIK_SATIR}
kredi={user.creditBalance}
sohbetGecmisi={sohbetGecmisi as Mesaj[]}
ozet={danismanOzeti}
kalanHak={Math.max(0, MAX_REVIZYON - (satir!.revisionCount ?? 0))}
paketFiyati={PAKET_FIYATI}
paketKredi={URUNLER.paket.credits}
/>
<ViewportPortal>
<a
href="#sohbet"
className="fixed bottom-5 right-4 z-40 inline-flex h-11 items-center gap-2 rounded-full bg-primary px-4 text-sm font-semibold text-white shadow-lg transition-transform duration-200 active:scale-[0.97] lg:hidden print:hidden"
>
<MessageCircleQuestion className="size-4" aria-hidden />
Danışmana sor
</a>
</ViewportPortal>
</main>
<SiteFooter />
</>
<Suspense fallback={<ListemIcerikSkeleton />}>
{searchParams.then(({ acildi, uret }) => (
<ListemIcerik uret={uret === "1"} acildi={acildi === "1"} />
))}
</Suspense>
);
}

View File

@@ -1,16 +1,12 @@
import { Suspense } from "react";
import type { Metadata } from "next";
import { Parallax } from "@/components/parallax";
import { PagePixelDivider, SectionEyebrow } from "@/components/pixel-decor";
import { SiteFooter } from "@/components/site-footer";
import { dilimAra, rankWindowFacets, type DilimKey } from "@/lib/db";
import {
DemoKapanisCta,
DemoKumanda,
DilimSeridiCanli,
SiralamaDemo,
type DemoVeri,
} from "./demo";
import { HuniSemasi } from "./huni-semasi";
MeraklisinaIcerik,
MeraklisinaIcerikSkeleton,
} from "@/features/pazarlama/components/meraklisina-icerik";
export const metadata: Metadata = {
title: "Meraklısına: KolayTercih nasıl çalışır?",
@@ -19,164 +15,30 @@ export const metadata: Metadata = {
"Sıralamanı girdiğinde arka planda ne oluyor? Dilimler, risk renkleri ve sihirbazın adım adım daralan filtreleri — öğrenci diliyle, sıralamana göre hesaplanan canlı görsellerle.",
};
/**
* Algoritmanın öğrenci diline çevrilmiş anlatımı. Teknik detay (SQL, çarpan
* mutfağı) bilerek verilmez; amaç güven: veri gerçek, kurallar şeffaf.
* Dilim şeridi canlıdır: kullanıcının kayıtlı tercih profilindeki sıralama
* okunur, şerit o sıralamanın gerçek sayılarıyla güncellenir (bkz. demo.tsx);
* profil yoksa örnek sıralama gösterilir. Sihirbaz hunisi ise statik
* bir mermaid şemasıyla anlatılır (bkz. huni-semasi.tsx).
* Diyagramlar ürünün risk renk dilini kullanır (yeşil/sarı/kırmızı) ve her
* bölge metinle de etiketlenir — renk tek başına anlam taşımaz.
*/
// Profili olmayan ziyaretçi bu örnek sıralamayı görür; SSR'da gerçek veriyle
// render edilir ki profil olmadan da sayfa anlamlı olsun.
const VARSAYILAN_SIRA = 100_000;
export default function MeraklisinaPage() {
const dilimToplam = Object.fromEntries(
(["hayal", "dengeli", "garanti"] as DilimKey[]).map((d) => [
d,
dilimAra(VARSAYILAN_SIRA, "say", d, { limit: 0 }).toplam,
]),
) as Record<DilimKey, number>;
const baslangicVeri: DemoVeri = {
facetler: rankWindowFacets(VARSAYILAN_SIRA, "say"),
dilimToplam,
};
return (
<>
<main className="mx-auto w-full max-w-2xl px-4 py-16">
<Parallax fromStart strength={10}>
<SectionEyebrow>Meraklısına</SectionEyebrow>
<h1 className="mt-3 font-heading text-3xl font-bold">
Sıralamanı girince arka planda ne oluyor?
</h1>
<p className="mt-3 text-slate-600">
Sihir yok, kara kutu yok: KolayTercih&apos;in önerdiği her satır resmî
YÖK Atlas verisinden gelir ve hangi kurala göre önüne geldiği bellidir.
Bu sayfa o kuralları öğrenci diliyle anlatır — üstelik görseller
canlı: sıralamanı girdiysen sayılar sana göre hesaplanır.
</p>
</Parallax>
<PagePixelDivider seed={73} className="mt-6" />
<SiralamaDemo
baslangicSira={VARSAYILAN_SIRA}
baslangicVeri={baslangicVeri}
>
<Parallax
fromStart
strength={4}
className="mt-8 space-y-10 text-sm leading-7 text-slate-700"
>
<section>
<h2 className="font-heading text-lg font-bold text-slate-900">
1. Her şey taban sıralamasıyla başlar
</h2>
<p className="mt-2">
Bir bölümün <strong>taban sıralaması</strong>, geçen yıl o bölüme
yerleşen <em>son</em>{" "}
öğrencinin Türkiye sıralamasıdır. Senin sıralaman bu sayıdan
küçükse (yani daha iyiysen), geçen yılın koşullarında o bölüme
yerleşebilirdin demektir. Sistemde her programın en güncel taban
sıralaması kayıtlıdır; bir bölümün 2025 verisi henüz oluşmamışsa
bir önceki yılınki kullanılır.
</p>
</section>
<DemoKumanda />
<section>
<h2 className="font-heading text-lg font-bold text-slate-900">
2. Programlar üç dilime ayrılır
</h2>
<p className="mt-2">
Sıralamanı girdiğinde tüm programlar senin konumuna göre üç kümeye
ayrılır: tabanı senden <strong>daha iyi</strong> olanlar{" "}
<span className="font-semibold text-red-600">Hayal</span> (şansını
denediğin satırlar), tabanı sıralamana{" "}
<strong>yakın</strong> olanlar{" "}
<span className="font-semibold text-amber-600">Dengeli</span>{" "}
(listenin bel kemiği — sıralamanın yaklaşık yüzde kırk fazlasına
kadar pay bırakılır), tabanı{" "}
<strong>belirgin şekilde altında</strong> kalan her şey ise{" "}
<span className="font-semibold text-emerald-600">Garanti</span>{" "}
dilimidir.
</p>
<DilimSeridiCanli />
<p className="mt-4">
Garanti diliminin bir sonu yok: 87. sıradaki bir adayın da 500
bininci sıradaki bir adayın da ulaşabildiği <em>bütün</em>{" "}
programlar listededir. Liste sana en yakın tabandan başlar; aşağı
indikçe ve &ldquo;Daha fazla göster&rdquo;e bastıkça senden
uzaklaşan seçenekler gelir. Böylece ilk gördüklerin her zaman en
isabetli adaylardır ama hiçbir seçenek senden saklanmaz.
</p>
</section>
<section>
<h2 className="font-heading text-lg font-bold text-slate-900">
3. Risk renkleri her yerde aynı dili konuşur
</h2>
<p className="mt-2">
Tabloda, haritada ve yapay zekâ listesinde gördüğün nokta ve
renkler tek bir kurala bağlıdır:{" "}
<span className="font-semibold text-emerald-600">yeşil</span>{" "}
tabanı sıralamanın belirgin altında (güvenli),{" "}
<span className="font-semibold text-amber-600">sarı</span>{" "}
sıralamana yakın (az riskli),{" "}
<span className="font-semibold text-red-600">kırmızı</span>{" "}
tabanı senden iyi (riskli). Renk hangi sayfada olursa olsun aynı
hesaptan çıkar; bir satır sekme değiştirdi diye renk değiştirmez.
</p>
</section>
<section>
<h2 className="font-heading text-lg font-bold text-slate-900">
4. Sihirbaz süzer, yapay zekâ kurar
</h2>
<p className="mt-2">
&ldquo;Yapay Zeka listemi kur&rdquo; sihirbazındaki üç adım birbirinden
bağımsız değildir. İlk adımda yalnızca{" "}
<strong>sıralamanla ulaşabildiğin</strong>{" "}
alanlar listelenir; ikinci adımdaki iller, seçtiğin alanlardaki
program sayısına göre yeniden hesaplanır; üçüncü adımdaki
devlet/vakıf sayıları da hem alan hem il seçimini hesaba katar.
Adımlar bitince elde kalan gerçek programlar bir{" "}
<strong>aday havuzu</strong>{" "}
olur ve yapay zekâya öyle gider: ona boş bir sayfa değil, bu havuz
verilir. Aşağıdaki şema akışın tamamını gösterir — havuz
24&apos;ün altına düşerse filtrelerin nasıl gevşetildiği, yine de
yetmezse listenin eldeki programlarla, sana açıkça belirtilerek
nasıl kurulduğu da dahil.
</p>
<HuniSemasi />
</section>
<section>
<h2 className="font-heading text-lg font-bold text-slate-900">
5. Dürüst olalım: bu bir tahmin aracıdır
</h2>
<p className="mt-2">
Tüm bu hesaplar <strong>geçen yılların</strong>{" "}
yerleştirme sonuçlarına dayanır. Kontenjanlar, tercih davranışları
ve puanlar her yıl değişir; geçen yıl &ldquo;garanti&rdquo;
görünen bir bölüm bu yıl dolabilir. Bu yüzden satırlarda son beş
yılın taban seyrini de gösteririz — çizgi yukarı tırmanıyorsa
bölüm zorlaşıyor demektir. KolayTercih karar <em>destek</em>{" "}
aracıdır; listenin son hâli ve sorumluluğu her zaman sana aittir.
</p>
</section>
<DemoKapanisCta />
<main className="mx-auto w-full max-w-2xl px-4 py-16">
<Parallax fromStart strength={10}>
<SectionEyebrow>Meraklısına</SectionEyebrow>
<h1 className="mt-3 font-heading text-3xl font-bold">
Sıralamanı girince arka planda ne oluyor?
</h1>
<p className="mt-3 text-slate-600">
Sihir yok, kara kutu yok: KolayTercih&apos;in önerdiği her satır resmî
YÖK Atlas verisinden gelir ve hangi kurala göre önüne geldiği bellidir.
Bu sayfa o kuralları öğrenci diliyle anlatır — üstelik görseller
canlı: sıralamanı girdiysen sayılar sana göre hesaplanır.
</p>
</Parallax>
</SiralamaDemo>
</main>
<SiteFooter />
<PagePixelDivider seed={73} className="mt-6" />
<Suspense fallback={<MeraklisinaIcerikSkeleton />}>
<MeraklisinaIcerik />
</Suspense>
</main>
<SiteFooter />
</>
);
}

View File

@@ -1,19 +0,0 @@
import { Skeleton } from "@/components/ui/skeleton";
// /odeme/sonuc iyzico doğrulaması bekler — ortalanmış sonuç kartı iskeleti.
export default function Loading() {
return (
<main
aria-live="polite"
className="mx-auto flex w-full max-w-md flex-1 flex-col justify-center px-4 py-16"
>
<div className="rounded-2xl border border-slate-200 bg-white p-8 shadow-sm">
<Skeleton className="mx-auto size-12 rounded-full" />
<Skeleton className="mx-auto mt-5 h-7 w-2/3" />
<Skeleton className="mx-auto mt-3 h-5 w-full" />
<Skeleton className="mx-auto mt-2 h-5 w-3/4" />
<Skeleton className="mx-auto mt-6 h-12 w-full rounded-lg" />
</div>
</main>
);
}

View File

@@ -1,126 +1,29 @@
import Link from "next/link";
import { Suspense } from "react";
import type { Metadata } from "next";
import { eq } from "drizzle-orm";
import { ArrowRight, CheckCircle2, XCircle } from "lucide-react";
import { verifySession } from "@/lib/session";
import { appDb, schema } from "@/lib/appdb";
import { kullanicininRaporu } from "@/lib/rapor-kaydi";
import { odemeyiSonuclandir } from "@/lib/odeme";
import { Button } from "@/components/ui/button";
import { PagePixelDivider } from "@/components/pixel-decor";
import { SiteFooter } from "@/components/site-footer";
import { RaporYokCta } from "./rapor-yok-cta";
import {
OdemeSonucKarti,
OdemeSonucKartiSkeleton,
} from "@/features/odeme/components/odeme-sonuc-karti";
export const metadata: Metadata = {
title: "Ödeme Sonucu",
robots: { index: false, follow: false },
};
export default async function OdemeSonucPage({
export default function OdemeSonucPage({
searchParams,
}: {
searchParams: Promise<{ siparis?: string }>;
}) {
const { siparis } = await searchParams;
// Oturum düşmüşse geri dönüşte ?siparis= kaybolmasın (başarı/unblur deneyimi)
const geriDonus = siparis
? `/odeme/sonuc?siparis=${encodeURIComponent(siparis)}`
: "/paket";
const session = await verifySession(geriDonus);
const order = siparis
? await appDb.query.orders.findFirst({
where: eq(schema.orders.id, siparis),
})
: null;
// Yalnızca kendi siparişi görüntülenebilir
const sahibiMi = order?.userId === session.user.id;
// Self-healing: callback kaybolduysa burada tekrar doğrula
const durum =
order && sahibiMi
? order.status === "paid"
? "paid"
: await odemeyiSonuclandir(order.id)
: "not_found";
const basarili = durum === "paid";
// Rapor zaten üretildiyse ödül bir tık uzakta: kullanıcıyı doğrudan
// rapor sayfasına döndür ki blur çözülme animasyonuyla tam listeyi görsün.
const rapor = basarili ? await kullanicininRaporu(session.user.id) : null;
const listeHref = rapor ? "/listem?acildi=1" : null;
return (
<div className="flex min-h-screen flex-col bg-slate-50 text-slate-900">
<main className="mx-auto flex w-full max-w-md flex-1 flex-col justify-center px-4 py-16 text-center">
{basarili ? (
<div className="rounded-2xl border border-emerald-200 bg-white p-8 shadow-sm">
<CheckCircle2
className="mx-auto size-12 text-emerald-500"
aria-hidden
/>
<h1 className="mt-4 font-heading text-2xl font-bold">
Ödeme alındı 🎉
</h1>
<p className="mt-2 text-sm text-slate-600">
<span className="font-semibold">
{order!.credits} kredi
</span>{" "}
hesabına tanımlandı
{order!.product === "paket"
? " ve Tercih Dönemi Paketi'n aktif — kişisel listen, risk raporun ve PDF çıktın hazır olduğunda burada."
: "."}
</p>
<PagePixelDivider seed={79} className="mx-auto mt-5" />
<div className="mt-6 flex flex-col gap-2">
{listeHref ? (
<>
<Button
asChild
className="h-11 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
>
<Link href={listeHref}>
Listeni aç — kilidi kaldırdık
<ArrowRight className="size-4" aria-hidden />
</Link>
</Button>
<p className="text-center text-xs text-slate-500">
24 tercihin tamamı, gerekçeler, riskler ve PDF artık açık.
</p>
</>
) : (
<RaporYokCta />
)}
</div>
</div>
) : (
<div className="rounded-2xl border border-red-200 bg-white p-8 shadow-sm">
<XCircle className="mx-auto size-12 text-red-500" aria-hidden />
<h1 className="mt-4 font-heading text-2xl font-bold">
Ödeme tamamlanamadı
</h1>
<p className="mt-2 text-sm text-slate-600">
{durum === "pending" ? (
"Ödemen hâlâ işleniyor. Birkaç saniye sonra bu sayfayı yenile — kartından çekim yapıldıysa krediler mutlaka tanımlanır."
) : (
<>
Çekim yapılmadı ya da işlem iptal edildi. Kartından para
çekildiğini düşünüyorsan{" "}
<Link href="/iletisim" className="underline">
bizimle iletişime geç
</Link>
.
</>
)}
</p>
<PagePixelDivider seed={83} className="mx-auto mt-5" />
<Button asChild variant="outline" className="mt-6 h-11 cursor-pointer">
<Link href="/paket">Tekrar dene</Link>
</Button>
</div>
)}
<Suspense fallback={<OdemeSonucKartiSkeleton />}>
{searchParams.then(({ siparis }) => (
<OdemeSonucKarti siparis={siparis} />
))}
</Suspense>
</main>
<SiteFooter />
</div>

View File

@@ -2,7 +2,6 @@ import {
ArrowRight,
ArrowUpRight,
BarChart3,
CheckCircle2,
Coins,
Compass,
FileText,
@@ -12,18 +11,18 @@ import {
ShieldCheck,
TrendingUp,
Wallet,
X,
} from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { Suspense } from "react";
import { HeroBaslik } from "@/components/hero-baslik";
import { KapanisCta } from "@/components/hero-focus-button";
import { HeroForm } from "@/components/hero-form";
import { ComparisonCell } from "@/features/pazarlama/components/landing-parcalari";
import { HeroBaslik } from "@/features/pazarlama/components/hero-baslik";
import { KapanisCta } from "@/features/pazarlama/components/hero-focus-button";
import { HeroForm } from "@/features/sihirbaz/components/hero-form";
import { Parallax } from "@/components/parallax";
import { Reveal } from "@/components/landing/reveal";
import { SosyalKanitBandi } from "@/components/landing/sosyal-kanit";
import { TurkeyPixelMap } from "@/components/turkey-pixel-map";
import { Reveal } from "@/features/pazarlama/components/reveal";
import { SosyalKanitBandi } from "@/features/pazarlama/components/sosyal-kanit";
import { TurkeyPixelMap } from "@/features/pazarlama/components/turkey-pixel-map";
import {
PixelCorner,
PixelDivider,
@@ -188,17 +187,6 @@ const paketOzellikleri = [
},
] as const;
function ComparisonCell({ value }: { value: boolean | string }) {
if (typeof value === "string") {
return <span className="text-sm font-medium">{value}</span>;
}
return value ? (
<CheckCircle2 className="mx-auto size-5 text-primary" aria-label="Var" />
) : (
<X className="mx-auto size-5 text-muted-foreground/50" aria-label="Yok" />
);
}
export default function Home() {
return (
<div

View File

@@ -1,23 +0,0 @@
import { Skeleton } from "@/components/ui/skeleton";
// /paket dinamik (kullanıcı kredisi) — statik kabuk için sayfa geometrisine
// uygun iskelet: ortalanmış başlık bloğu + paket kartı şeridi.
export default function Loading() {
return (
<main
aria-live="polite"
className="mx-auto w-full max-w-3xl flex-1 px-4 py-16"
>
<div className="flex flex-col items-center text-center">
<Skeleton className="h-6 w-56 rounded-full" />
<Skeleton className="mt-4 h-10 w-3/4 max-w-xl" />
<Skeleton className="mt-3 h-5 w-2/3 max-w-lg" />
<Skeleton className="mt-2 h-5 w-1/2 max-w-md" />
</div>
<div className="mt-10 grid gap-6 sm:grid-cols-[1fr_auto]">
<Skeleton className="h-80 w-full rounded-2xl" />
<Skeleton className="h-80 w-full rounded-2xl sm:w-64" />
</div>
</main>
);
}

View File

@@ -1,23 +1,13 @@
import Image from "next/image";
import { Suspense } from "react";
import Link from "next/link";
import type { Metadata } from "next";
import {
ArrowRight,
CheckCircle2,
Coins,
FileText,
ListChecks,
MessageCircleQuestion,
RefreshCcw,
ShieldCheck,
} from "lucide-react";
import { getCurrentUser } from "@/lib/session";
import { URUNLER, DENEME_KREDISI } from "@/lib/credits";
import { Button } from "@/components/ui/button";
import { SectionEyebrow } from "@/components/pixel-decor";
import { Parallax } from "@/components/parallax";
import { SatinAlForm } from "./satin-al-form";
import { SiteFooter } from "@/components/site-footer";
import {
PaketSatinAl,
PaketSatinAlSkeleton,
} from "@/features/odeme/components/paket-satinal";
export const metadata: Metadata = {
title: "Tercih Dönemi Paketi",
@@ -26,193 +16,57 @@ export const metadata: Metadata = {
alternates: { canonical: "/paket" },
};
function tl(kurus: number) {
return (kurus / 100).toLocaleString("tr-TR", { maximumFractionDigits: 0 });
}
const OZELLIKLER = [
{
icon: ListChecks,
text: "Sıralamana özel, gerekçeli 24 tercihlik dengeli liste",
},
{
icon: ShieldCheck,
text: "Her tercih için yerleşme olasılığı ve risk analizi (son 4 yıl trend)",
},
{ icon: FileText, text: "Veliyle paylaşılabilir PDF rapor" },
{ icon: RefreshCcw, text: "2 liste revizyon hakkı" },
{
icon: MessageCircleQuestion,
text: `${URUNLER.paket.credits} Yapay Zeka danışman sorusu (her mesaj 1 kredi)`,
},
] as const;
export default async function PaketPage({
export default function PaketPage({
searchParams,
}: {
searchParams: Promise<{ hata?: string }>;
}) {
const [u, { hata }] = await Promise.all([getCurrentUser(), searchParams]);
// Ödeme callback'i doğrulama hatasında buraya ?hata=token|siparis ile döner
const odemeHatasi = hata === "token" || hata === "siparis";
return (
<div className="flex min-h-screen flex-col bg-slate-50 text-slate-900">
<main className="mx-auto w-full max-w-3xl flex-1 px-4 py-16">
{odemeHatasi ? (
<div
role="alert"
className="mb-8 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800"
>
Ödemen doğrulanamadı. Kartından çekim yapıldıysa kredilerin birkaç
dakika içinde otomatik tanımlanır; tanımlanmazsa aşağıdan tekrar
deneyebilirsin — çekim yapılmadıysa hiçbir ücret ödemedin.
</div>
) : null}
<Parallax fromStart strength={12} className="text-center">
<SectionEyebrow>Tek seferlik ödeme · Abonelik yok</SectionEyebrow>
<h1 className="mt-4 font-heading text-3xl font-bold sm:text-4xl">
İnsan danışmanın işini,{" "}
<span className="text-primary">onda bir fiyatına</span>
</h1>
<p className="mx-auto mt-3 max-w-xl text-slate-600">
Tercih danışmanları bu dönemde 2.000–10.000 TL alıyor. KolayTercih
aynı işi gerçek YÖK Atlas verisiyle, dakikalar içinde yapar.
</p>
</Parallax>
<Parallax
strength={6}
className="mt-10 grid gap-6 sm:grid-cols-[1fr_auto]"
>
{/* Ana paket */}
<section className="rounded-2xl border-2 border-orange-500 bg-white p-8 shadow-sm">
<div className="flex items-baseline justify-between">
<h2 className="font-heading text-xl font-bold">
Tercih Dönemi Paketi
</h2>
<p className="font-heading text-3xl font-bold">
{tl(URUNLER.paket.amountKurus)} TL
</p>
<main className="mx-auto w-full max-w-3xl flex-1 px-4 py-16">
{/* Ödeme callback'i doğrulama hatasında buraya ?hata=token|siparis ile
döner; hata yokken hiçbir şey çizilmez — fallback bilerek boş. */}
<Suspense fallback={null}>
{searchParams.then(({ hata }) =>
hata === "token" || hata === "siparis" ? (
<div
role="alert"
className="mb-8 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800"
>
Ödemen doğrulanamadı. Kartından çekim yapıldıysa kredilerin
birkaç dakika içinde otomatik tanımlanır; tanımlanmazsa
aşağıdan tekrar deneyebilirsin — çekim yapılmadıysa hiçbir
ücret ödemedin.
</div>
<ul className="mt-6 space-y-3">
{OZELLIKLER.map((o) => (
<li key={o.text} className="flex items-start gap-3 text-sm">
<o.icon
className="mt-0.5 size-4 shrink-0 text-emerald-600"
aria-hidden
/>
{o.text}
</li>
))}
</ul>
<div className="mt-8">
{u ? (
u.hasPaket ? (
<div className="flex items-center gap-2 rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-medium text-emerald-800">
<CheckCircle2 className="size-4" aria-hidden />
Paketin aktif —{" "}
<Link href="/" className="underline">
listeni oluştur
</Link>
</div>
) : (
<SatinAlForm
urun="paket"
label={`${tl(URUNLER.paket.amountKurus)} TL — Paketi al`}
vurgulu
/>
)
) : (
<div className="space-y-2">
<Button
asChild
className="h-12 w-full cursor-pointer bg-orange-500 px-8 text-white transition-colors duration-200 hover:bg-orange-600"
>
<Link
href={`/giris?callback=${encodeURIComponent("/paket")}`}
>
Giriş yap — sonra {tl(URUNLER.paket.amountKurus)} TL&apos;ye
al
<ArrowRight className="size-4" aria-hidden />
</Link>
</Button>
<p className="text-center text-xs text-slate-500">
Girişte {DENEME_KREDISI} deneme kredisi de tanımlanır.
</p>
</div>
)}
</div>
<Image
src="/odeme/iyzico-logo-band.svg"
alt="iyzico ile Öde — Mastercard, Visa, American Express ve Troy ile güvenli ödeme"
width={429}
height={32}
className="mx-auto mt-5 h-5 w-auto"
/>
<p className="mt-3 text-center text-xs text-slate-500">
iyzico güvenli ödeme · Kart bilgilerin bize hiç ulaşmaz ·{" "}
<Link
href="/kosullar#iade"
className="underline hover:text-slate-700"
>
İade koşulları
</Link>
</p>
</section>
{/* Top-up */}
<section className="flex h-fit flex-col rounded-2xl border border-slate-200 bg-white p-6 shadow-sm sm:w-56">
<div className="flex items-center gap-2">
<Coins className="size-4 text-amber-500" aria-hidden />
<h2 className="font-heading font-bold">Kredi bitti mi?</h2>
</div>
<p className="mt-2 flex-1 text-sm text-slate-600">
+{URUNLER.topup.credits} soru hakkı,{" "}
{tl(URUNLER.topup.amountKurus)} TL.
</p>
<div className="mt-4">
{/* Top-up hasPaket vermez: paketsiz biri alırsa parasını verip
yine maskeli liste görür. Satın alma yalnızca paketliye
açık; paketsize durum dürüstçe açıklanır. */}
{u?.hasPaket ? (
<SatinAlForm
urun="topup"
label={`+${URUNLER.topup.credits} kredi — ${tl(URUNLER.topup.amountKurus)} TL`}
/>
) : u ? (
<p className="rounded-xl border border-slate-200 bg-slate-50 px-3 py-2.5 text-xs leading-5 text-slate-600">
Ek kredi, kilitli analizleri açmaz — o yüzden önce paket
gerekir. Paket zaten {URUNLER.paket.credits} kredi içerir;
bitince buradan yükleyebilirsin.
</p>
) : (
<Button
asChild
variant="outline"
className="h-11 w-full cursor-pointer"
>
<Link
href={`/giris?callback=${encodeURIComponent("/paket")}`}
>
Önce giriş yap
</Link>
</Button>
)}
</div>
</section>
</Parallax>
<p className="mt-10 text-center text-xs leading-relaxed text-slate-500">
KolayTercih bir karar destek aracıdır; öneriler resmî YÖK Atlas
verisine dayanır ancak yerleşme garantisi verilmez. Tercih
listesinin nihai sorumluluğu adaya aittir. Sorun mu var?{" "}
<Link href="/iletisim" className="underline hover:text-slate-700">
Bize yaz
</Link>
.
) : null,
)}
</Suspense>
<Parallax fromStart strength={12} className="text-center">
<SectionEyebrow>Tek seferlik ödeme · Abonelik yok</SectionEyebrow>
<h1 className="mt-4 font-heading text-3xl font-bold sm:text-4xl">
İnsan danışmanın işini,{" "}
<span className="text-primary">onda bir fiyatına</span>
</h1>
<p className="mx-auto mt-3 max-w-xl text-slate-600">
Tercih danışmanları bu dönemde 2.000–10.000 TL alıyor. KolayTercih
aynı işi gerçek YÖK Atlas verisiyle, dakikalar içinde yapar.
</p>
</main>
</Parallax>
<Suspense fallback={<PaketSatinAlSkeleton />}>
<PaketSatinAl />
</Suspense>
<p className="mt-10 text-center text-xs leading-relaxed text-slate-500">
KolayTercih bir karar destek aracıdır; öneriler resmî YÖK Atlas
verisine dayanır ancak yerleşme garantisi verilmez. Tercih
listesinin nihai sorumluluğu adaya aittir. Sorun mu var?{" "}
<Link href="/iletisim" className="underline hover:text-slate-700">
Bize yaz
</Link>
.
</p>
</main>
<SiteFooter />
</div>
);

View File

@@ -1,21 +0,0 @@
import { Skeleton } from "@/components/ui/skeleton";
// /rapor/yazdir rapor sorgusunu bekler — belge düzeninde iskelet.
export default function Loading() {
return (
<main
aria-live="polite"
className="mx-auto w-full max-w-3xl bg-white p-8"
>
<div className="border-b-2 border-slate-200 pb-4">
<Skeleton className="h-8 w-64" />
<Skeleton className="mt-2 h-4 w-40" />
</div>
<div className="mt-6 space-y-3">
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full rounded-lg" />
))}
</div>
</main>
);
}

View File

@@ -1,142 +1,19 @@
import { Suspense } from "react";
import type { Metadata } from "next";
import Image from "next/image";
import { redirect } from "next/navigation";
import { verifySession, getCurrentUser } from "@/lib/session";
import { kullanicininRaporu } from "@/lib/rapor-kaydi";
import { PUAN_TURLERI } from "@/lib/db";
import type { RaporSonuc } from "@/lib/ai/rapor";
import type { RaporParams } from "@/lib/rapor-havuzu";
import { YazdirButonu } from "./yazdir-butonu";
import {
RaporYazdir,
RaporYazdirSkeleton,
} from "@/features/rapor/components/rapor-yazdir";
export const metadata: Metadata = {
title: "Tercih Raporu (Yazdır)",
robots: { index: false, follow: false },
};
const DILIM_LABEL: Record<string, string> = {
hayal: "Hayal (Riskli)",
dengeli: "Dengeli (Az riskli)",
garanti: "Garanti (Güvenli)",
};
export default async function YazdirPage() {
const session = await verifySession("/rapor/yazdir");
// Kullanıcı satırı ile rapor sorgusu bağımsız — paralel çöz
const [user, satir] = await Promise.all([
getCurrentUser(),
kullanicininRaporu(session.user.id),
]);
if (!user) redirect("/giris");
if (!user.hasPaket) redirect("/paket");
if (!satir?.result) redirect("/sonuc");
const rapor = satir.result as RaporSonuc;
const params = satir.params as RaporParams;
const tarih = new Date(satir.updatedAt).toLocaleDateString("tr-TR", {
day: "numeric",
month: "long",
year: "numeric",
});
export default function YazdirPage() {
return (
<div className="mx-auto max-w-3xl bg-white p-8 text-slate-900 print:p-[10mm]">
<YazdirButonu />
<header className="border-b-2 border-slate-900 pb-4">
<h1 className="font-heading text-2xl font-bold">
{rapor.tercihler.length} Tercihlik Kişisel Liste ve Risk Raporu
</h1>
<p className="mt-1 text-sm text-slate-600">
Başarı sıralaması: {params.sira.toLocaleString("tr-TR")} · Puan
türü: {PUAN_TURLERI[params.tur]}
{params.il ? ` · İl: ${params.il}` : ""} · {tarih}
</p>
{rapor.kapsam?.uyarlandi ? (
<p className="mt-1 text-sm text-slate-600">
Not: Tercihlerine göre {rapor.kapsam.toplam} uygun program bulundu;
liste {rapor.kapsam.dagilim.hayal} hayal ·{" "}
{rapor.kapsam.dagilim.dengeli} dengeli ·{" "}
{rapor.kapsam.dagilim.garanti} garanti olarak kuruldu.
</p>
) : null}
</header>
<section className="mt-5">
<h2 className="font-heading text-base font-bold">
Genel değerlendirme
</h2>
<p className="mt-1 whitespace-pre-line text-sm leading-relaxed">
{rapor.genelDegerlendirme}
</p>
</section>
<table className="mt-5 w-full border-collapse text-xs">
<thead>
<tr className="border-b-2 border-slate-900 text-left">
<th className="py-2 pr-2">#</th>
<th className="py-2 pr-2">Program / Üniversite</th>
<th className="py-2 pr-2">Dilim</th>
<th className="py-2 pr-2">Gerekçe</th>
<th className="py-2 pr-2">Risk notu</th>
<th className="py-2">Trend</th>
</tr>
</thead>
<tbody>
{rapor.tercihler.map((t) => {
const p = rapor.programlar[t.programId];
return (
<tr
key={t.sira}
className="break-inside-avoid border-b border-slate-200 align-top"
>
<td className="py-2 pr-2 font-bold">{t.sira}</td>
<td className="py-2 pr-2">
<span className="font-semibold">{p?.isim}</span>
<br />
<span className="text-slate-600">
{p?.universite} · {p?.il ?? "—"}
</span>
</td>
<td className="py-2 pr-2">{DILIM_LABEL[t.dilim]}</td>
<td className="py-2 pr-2">{t.gerekce}</td>
<td className="py-2 pr-2">{t.riskNotu}</td>
<td className="py-2">{t.trendOzeti}</td>
</tr>
);
})}
</tbody>
</table>
<footer className="mt-6 border-t border-slate-300 pt-3 text-[10px] leading-relaxed text-slate-500">
<p>
Bu rapor KolayTercih tarafından resmî YÖK Atlas verisi (2021–2025
taban sıralamaları, kontenjan ve yerleşme istatistikleri) kullanılarak
üretilmiştir. Taban sıralamaları her yıl değişir; bu rapor yerleşme
garantisi vermez. Tercih listesinin ÖSYM sistemine girilmesi ve nihai
sorumluluğu adaya aittir.
</p>
<div className="mt-4 flex break-inside-avoid items-center justify-between">
<div className="flex items-center font-bricolage text-slate-900">
<Image
src="/kolay-tercih-mark.svg"
alt=""
width={44}
height={44}
aria-hidden="true"
className="size-11 shrink-0"
/>
<span>
<span className="font-light text-3xl tracking-tighter">Kolay</span>
<span className="font-semibold text-3xl tracking-tighter">
Tercih
</span>
</span>
</div>
<span className="font-bricolage text-sm font-medium text-slate-900">
kolaytercih.com
</span>
</div>
</footer>
</div>
<Suspense fallback={<RaporYazdirSkeleton />}>
<RaporYazdir />
</Suspense>
);
}

View File

@@ -4,7 +4,7 @@ import { loadRehberOgFonts } from "@/lib/og-fonts";
import {
rehberBasliginiBol,
rehberMetniniParcala,
} from "@/components/rehber-kapak";
} from "@/features/rehber/components/rehber-kapak";
export const alt = "KolayTercih Tercih Rehberi yazı kapağı";
export const size = { width: 1200, height: 630 };

View File

@@ -1,153 +1,52 @@
import { Suspense } from "react";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { ChevronRight } from "lucide-react";
import { getAllRehberSlugs, getAllRehberler, getRehber } from "@/lib/rehber";
import { JsonLd, articleJsonLd, breadcrumbJsonLd } from "@/lib/seo";
import { CtaSiraForm } from "@/components/cta-sira-form";
import { ElCizimiSema } from "@/components/el-cizimi-sema";
import { RehberKapak } from "@/components/rehber-kapak";
import { SiteFooter } from "@/components/site-footer";
import {
RehberMakale,
RehberMakaleSkeleton,
rehberStaticSlugs,
rehberYaziMetadata,
} from "@/features/rehber/components/rehber-makale";
export function generateStaticParams() {
const slugs = getAllRehberSlugs();
if (slugs.length === 0) {
throw new Error("rehber: content/rehber altında hiç makale yok");
}
return slugs.map((slug) => ({ slug }));
return rehberStaticSlugs().map((slug) => ({ slug }));
}
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
}: PageProps<"/rehber/[slug]">): Promise<Metadata> {
const { slug } = await params;
const yazi = getRehber(slug);
if (!yazi) return {};
return {
title: yazi.baslik,
description: yazi.aciklama,
alternates: { canonical: `/rehber/${slug}` },
openGraph: {
type: "article",
title: yazi.baslik,
description: yazi.aciklama,
publishedTime: yazi.tarih,
},
};
return rehberYaziMetadata(slug);
}
export default async function RehberYaziPage({
export default function RehberYaziPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const yazi = getRehber(slug);
if (!yazi) notFound();
const digerYazilar = getAllRehberler()
.filter((y) => y.slug !== slug)
.slice(0, 4);
}: PageProps<"/rehber/[slug]">) {
return (
<>
<main className="mx-auto w-full max-w-3xl px-4 py-12 sm:py-16">
<JsonLd
data={articleJsonLd({
title: yazi.baslik,
description: yazi.aciklama,
path: `/rehber/${slug}`,
datePublished: yazi.tarih,
})}
/>
<JsonLd
data={breadcrumbJsonLd([
{ name: "Ana Sayfa", path: "/" },
{ name: "Rehber", path: "/rehber" },
{ name: yazi.baslik, path: `/rehber/${slug}` },
])}
/>
<nav
aria-label="breadcrumb"
className="flex items-center gap-1 text-xs text-slate-500"
>
<Link href="/" className="hover:text-primary hover:underline">
Ana Sayfa
</Link>
<ChevronRight className="size-3" aria-hidden />
<Link href="/rehber" className="hover:text-primary hover:underline">
Rehber
</Link>
</nav>
<article>
<RehberKapak baslik={yazi.baslik} className="mt-6 shadow-sm" />
<h1 className="mt-8 font-heading text-3xl font-bold text-slate-900 sm:text-4xl">
{yazi.baslik}
</h1>
<p className="mt-3 text-base leading-7 text-slate-600">
{yazi.aciklama}
</p>
<div
className="mt-8 text-[15px] leading-7 text-slate-700
[&_h2]:mt-10 [&_h2]:font-heading [&_h2]:text-xl [&_h2]:font-bold [&_h2]:text-slate-900
[&_h3]:mt-6 [&_h3]:font-heading [&_h3]:text-base [&_h3]:font-bold [&_h3]:text-slate-900
[&_p]:mt-3
[&_a]:font-medium [&_a]:text-primary hover:[&_a]:underline
[&_strong]:font-semibold [&_strong]:text-slate-900
[&_ul]:mt-3 [&_ul]:list-disc [&_ul]:space-y-1.5 [&_ul]:pl-6
[&_ol]:mt-3 [&_ol]:list-decimal [&_ol]:space-y-1.5 [&_ol]:pl-6
[&_table]:mt-4 [&_table]:w-full [&_table]:border-collapse [&_table]:text-sm
[&_th]:border [&_th]:border-slate-200 [&_th]:bg-slate-50 [&_th]:px-3 [&_th]:py-2 [&_th]:text-left [&_th]:font-semibold
[&_td]:border [&_td]:border-slate-200 [&_td]:px-3 [&_td]:py-2
[&_blockquote]:mt-4 [&_blockquote]:border-l-4 [&_blockquote]:border-primary/30 [&_blockquote]:pl-4 [&_blockquote]:italic [&_blockquote]:text-slate-600"
<main className="mx-auto w-full max-w-3xl px-4 py-12 sm:py-16">
<nav
aria-label="breadcrumb"
className="flex items-center gap-1 text-xs text-slate-500"
>
{yazi.parcalar.map((parca, i) =>
parca.tip === "html" ? (
<div
key={i}
dangerouslySetInnerHTML={{ __html: parca.html }}
/>
) : (
<ElCizimiSema
key={i}
tanim={parca.sema.tanim}
ariaLabel={parca.sema.ariaLabel}
figcaption={parca.sema.altyazi}
/>
),
)}
</div>
</article>
<Link href="/" className="hover:text-primary hover:underline">
Ana Sayfa
</Link>
<ChevronRight className="size-3" aria-hidden />
<Link href="/rehber" className="hover:text-primary hover:underline">
Rehber
</Link>
</nav>
<div className="mt-12">
<CtaSiraForm />
</div>
{digerYazilar.length > 0 ? (
<section className="mt-12">
<h2 className="font-heading text-xl font-bold text-slate-900">
Diğer rehber yazıları
</h2>
<ul className="mt-3 space-y-2">
{digerYazilar.map((y) => (
<li key={y.slug}>
<Link
href={`/rehber/${y.slug}`}
className="text-sm font-medium text-primary hover:underline"
>
{y.baslik}
</Link>
</li>
))}
</ul>
</section>
) : null}
</main>
<SiteFooter />
<Suspense fallback={<RehberMakaleSkeleton />}>
{params.then(({ slug }) => (
<RehberMakale slug={slug} />
))}
</Suspense>
</main>
<SiteFooter />
</>
);
}

View File

@@ -1,12 +1,13 @@
import { Suspense } from "react";
import type { Metadata } from "next";
import Link from "next/link";
import { ArrowRight } from "lucide-react";
import { getAllRehberler } from "@/lib/rehber";
import { JsonLd, breadcrumbJsonLd } from "@/lib/seo";
import { CtaSiraForm } from "@/components/cta-sira-form";
import { RehberKapak } from "@/components/rehber-kapak";
import { CtaSiraForm } from "@/features/sihirbaz/components/cta-sira-form";
import { SiteFooter } from "@/components/site-footer";
import { PagePixelDivider } from "@/components/pixel-decor";
import {
RehberListesi,
RehberListesiSkeleton,
} from "@/features/rehber/components/rehber-listesi";
export const metadata: Metadata = {
title: "Tercih Rehberi 2026 — YKS Tercih Dönemi Yazıları",
@@ -16,62 +17,35 @@ export const metadata: Metadata = {
};
export default function RehberIndexPage() {
const yazilar = getAllRehberler();
return (
<>
<main className="mx-auto w-full max-w-6xl px-4 py-12 sm:py-16">
<JsonLd
data={breadcrumbJsonLd([
{ name: "Ana Sayfa", path: "/" },
{ name: "Rehber", path: "/rehber" },
])}
/>
<main className="mx-auto w-full max-w-6xl px-4 py-12 sm:py-16">
<JsonLd
data={breadcrumbJsonLd([
{ name: "Ana Sayfa", path: "/" },
{ name: "Rehber", path: "/rehber" },
])}
/>
<h1 className="font-heading text-3xl font-bold text-slate-900 sm:text-4xl">
Tercih Rehberi
</h1>
<p className="mt-4 max-w-2xl text-sm leading-7 text-slate-600">
Tercih döneminin en çok sorulan soruları, veriye dayalı ve öğrenci
diliyle. Panik yok, pazarlama yok — sadece işine yarayacak bilgi.
</p>
<h1 className="font-heading text-3xl font-bold text-slate-900 sm:text-4xl">
Tercih Rehberi
</h1>
<p className="mt-4 max-w-2xl text-sm leading-7 text-slate-600">
Tercih döneminin en çok sorulan soruları, veriye dayalı ve öğrenci
diliyle. Panik yok, pazarlama yok — sadece işine yarayacak bilgi.
</p>
<PagePixelDivider seed={11} className="mt-8" />
<PagePixelDivider seed={11} className="mt-8" />
<ul className="mt-8 columns-1 gap-5 sm:columns-2 lg:columns-3">
{yazilar.map((y) => (
<li key={y.slug} className="mb-5 break-inside-avoid">
<Link
href={`/rehber/${y.slug}`}
className="block overflow-hidden rounded-3xl border border-slate-200 bg-white p-2 shadow-sm focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-primary"
>
<RehberKapak
baslik={y.baslik}
compact
className="rounded-[1.15rem]"
/>
<div className="px-3 pb-3 pt-4">
<p className="text-sm leading-6 text-slate-600">
{y.aciklama}
</p>
<span className="mt-4 inline-flex items-center gap-1.5 font-bricolage text-sm font-semibold text-primary">
Yazıyı oku
<ArrowRight
className="size-4"
aria-hidden
/>
</span>
</div>
</Link>
</li>
))}
</ul>
<Suspense fallback={<RehberListesiSkeleton />}>
<RehberListesi />
</Suspense>
<div className="mx-auto mt-16 max-w-3xl">
<CtaSiraForm />
</div>
</main>
<SiteFooter />
<div className="mx-auto mt-16 max-w-3xl">
<CtaSiraForm />
</div>
</main>
<SiteFooter />
</>
);
}

View File

@@ -1,19 +0,0 @@
import { Skeleton } from "@/components/ui/skeleton";
// /sonuc senkron SQLite sorgularıyla açılır; yavaş bağlantıda beyaz ekran
// yerine sayfanın iskeletini göster.
export default function SonucLoading() {
return (
<main className="mx-auto w-full max-w-6xl px-4 py-10">
<Skeleton className="h-6 w-40 rounded-full" />
<Skeleton className="mt-4 h-9 w-3/4 max-w-xl" />
<Skeleton className="mt-3 h-5 w-2/3 max-w-lg" />
<Skeleton className="mt-8 h-36 w-full rounded-2xl" />
<div className="mt-8 flex flex-col gap-2">
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full rounded-xl" />
))}
</div>
</main>
);
}

View File

@@ -1,19 +1,16 @@
import { Suspense } from "react";
import type { Metadata } from "next";
import { PUAN_TURLERI, type PuanTuruKey } from "@/types/yokatlas";
import {
PUAN_TURLERI,
rankWindowFacets,
searchByRank,
type PuanTuruKey,
} from "@/lib/db";
import { getSession } from "@/lib/session";
import { kullanicininRaporu } from "@/lib/rapor-kaydi";
import { tadimlikSec } from "@/lib/tadimlik-havuzu";
import { SihirbazBolumu } from "./sihirbaz-cagri-karti";
import { TadimlikSatiri } from "./tadimlik-satiri";
import { ProgramTablosu } from "@/components/program-tablosu";
import { ManuelHarita } from "@/components/manuel-liste/manuel-harita";
SonucProgramTablosu,
SonucProgramTablosuSkeleton,
} from "@/features/liste/components/sonuc-program-tablosu";
import { ManuelHarita } from "@/features/liste/components/manuel-harita";
import { TadimlikBolumu } from "@/features/rapor/components/tadimlik-bolumu";
import { SonucSihirbazi } from "@/features/sihirbaz/components/sihirbaz-bolumu";
import { SiraGerekli } from "@/features/sihirbaz/components/sira-gerekli";
import { SiteFooter } from "@/components/site-footer";
import { SiraGerekli } from "./sira-gerekli";
import { Skeleton } from "@/components/ui/skeleton";
export const metadata: Metadata = {
title: "Tercih Listeni Kur",
@@ -23,7 +20,7 @@ export const metadata: Metadata = {
robots: { index: false, follow: true },
};
export default async function SonucPage({
export default function SonucPage({
searchParams,
}: {
searchParams: Promise<{
@@ -33,76 +30,73 @@ export default async function SonucPage({
hazir?: string;
}>;
}) {
const params = await searchParams;
const sira = Number.parseInt(params.sira ?? "", 10);
const turKey: PuanTuruKey =
params.tur && params.tur in PUAN_TURLERI
? (params.tur as PuanTuruKey)
: "say";
if (!Number.isFinite(sira) || sira < 1 || sira > 4_000_000) {
return (
<>
<SiraGerekli />
<SiteFooter />
</>
);
}
// Sihirbaz için: kullanıcının kayıtlı raporu var mı? Listenin kendisi
// /listem'de yaşar; burada yalnızca CTA'nın hangi hâlde render edileceği
// için varlık bilgisi gerekir.
const session = await getSession();
const raporSatiri = session
? await kullanicininRaporu(session.user.id)
: null;
const facetler = rankWindowFacets(sira, turKey);
// Yapay Zeka'sız ham liste: yalnızca sıralamayla ulaşılabilen programlar (dilimli)
const hamSonuclar = searchByRank(sira, turKey, { limitPerBucket: 20 });
// Girişsiz tadımlık (İP-B): SSR'da kovaya uyan "genel" satır seçilir;
// sihirbaz kategorisi client'ta okunup /api/tadimlik ile kişiselleştirilir.
// Girişli kullanıcı gerçek ürünü zaten görebildiği için tadımlık almaz.
const tadimlik = session ? null : await tadimlikSec(sira, turKey);
return (
<div className="min-h-screen bg-slate-50 text-slate-900">
<main className="mx-auto max-w-4xl px-4 py-12">
{/* Sayfa hiyerarşisi: önce Türkiye haritası, sonra liste. Manuel
liste store'una abone olduğundan tablodan "+" ile eklenenler
buraya canlı yansır. */}
<ManuelHarita />
// Tablo değişken boylu olduğundan altındaki tadımlık, sihirbaz, dipnot ve
// footer aynı sınırda gruplu — bugünkü tek await'li akışla birebir aynı
// anda beliriyorlar, ara durumda zıplama yok.
<Suspense
fallback={
<main className="mx-auto w-full max-w-6xl px-4 py-10">
<Skeleton className="h-6 w-40 rounded-full" />
<Skeleton className="mt-4 h-9 w-3/4 max-w-xl" />
<Skeleton className="mt-3 h-5 w-2/3 max-w-lg" />
<Skeleton className="mt-8 h-36 w-full rounded-2xl" />
<SonucProgramTablosuSkeleton />
</main>
}
>
{searchParams.then((params) => {
const sira = Number.parseInt(params.sira ?? "", 10);
const turKey: PuanTuruKey =
params.tur && params.tur in PUAN_TURLERI
? (params.tur as PuanTuruKey)
: "say";
{/* Yapay Zeka'dan bağımsız ham tablo — haritanın hemen altında, sayfanın ana
gövdesi; puan türü navbar'daki sıralama rozetinden değiştirilir */}
<ProgramTablosu sonuclar={hamSonuclar} adaySira={sira} tur={turKey} />
if (!Number.isFinite(sira) || sira < 1 || sira > 4_000_000) {
return (
<>
<SiraGerekli />
<SiteFooter />
</>
);
}
{/* Girişsiz tadımlık: gerçek rapor satırı formatında tek örnek.
Havuzda içerik yoksa bileşen hiç render edilmez. */}
{!session ? (
<TadimlikSatiri sira={sira} tur={turKey} ilk={tadimlik} />
) : null}
return (
<div className="min-h-screen bg-slate-50 text-slate-900">
<main className="mx-auto max-w-4xl px-4 py-12">
{/* Sayfa hiyerarşisi: önce Türkiye haritası, sonra liste. Manuel
liste store'una abone olduğundan tablodan "+" ile eklenenler
buraya canlı yansır. */}
<ManuelHarita />
{/* Sihirbaz akışı (CTA + modal) — üretim ve liste /listem'de yaşar,
burada yalnızca funnel kartları render edilir */}
<SihirbazBolumu
sira={sira}
tur={turKey}
facetler={facetler}
girisliMi={Boolean(session)}
raporVar={Boolean(raporSatiri?.result)}
otomatikAc={params.sihirbaz === "1"}
secimlerHazir={params.hazir === "1"}
/>
{/* Yapay Zeka'dan bağımsız ham tablo — haritanın hemen altında,
sayfanın ana gövdesi; puan türü navbar'daki sıralama
rozetinden değiştirilir */}
<SonucProgramTablosu sira={sira} tur={turKey} />
<p className="mt-10 text-center text-xs leading-relaxed text-slate-500">
Veriler resmî YÖK Atlas kaynağından derlenmiştir. KolayTercih bir karar
destek aracıdır; yerleşme garantisi vermez, tercih sorumluluğu adaya
aittir.
</p>
</main>
<SiteFooter />
</div>
{/* Girişsiz tadımlık: gerçek rapor satırı formatında tek örnek.
Havuzda içerik yoksa bileşen hiç render edilmez. */}
<TadimlikBolumu sira={sira} tur={turKey} />
{/* Sihirbaz akışı (CTA + modal) — üretim ve liste /listem'de
yaşar, burada yalnızca funnel kartları render edilir */}
<SonucSihirbazi
sira={sira}
tur={turKey}
otomatikAc={params.sihirbaz === "1"}
secimlerHazir={params.hazir === "1"}
/>
<p className="mt-10 text-center text-xs leading-relaxed text-slate-500">
Veriler resmî YÖK Atlas kaynağından derlenmiştir. KolayTercih
bir karar destek aracıdır; yerleşme garantisi vermez, tercih
sorumluluğu adaya aittir.
</p>
</main>
<SiteFooter />
</div>
);
})}
</Suspense>
);
}

View File

@@ -1,25 +1,31 @@
import { Suspense } from "react";
import type { Metadata } from "next";
import { getAllUniversiteSlugs } from "@/lib/katalog";
import { UniversiteIcerik, uniSayfaMetadata } from "./universite-icerik";
import {
UniversiteIcerik,
UniversiteIcerikSkeleton,
uniSayfaMetadata,
uniStaticSlugs,
} from "@/features/katalog/components/universite-icerik";
export function generateStaticParams() {
return getAllUniversiteSlugs().map((slug) => ({ slug }));
return uniStaticSlugs().map((slug) => ({ slug }));
}
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
}: PageProps<"/universite/[slug]">): Promise<Metadata> {
const { slug } = await params;
return uniSayfaMetadata(slug, 1);
}
export default async function UniversitePage({
export default function UniversitePage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
return <UniversiteIcerik slug={slug} sayfa={1} />;
}: PageProps<"/universite/[slug]">) {
return (
<Suspense fallback={<UniversiteIcerikSkeleton />}>
{params.then(({ slug }) => (
<UniversiteIcerik slug={slug} sayfa={1} />
))}
</Suspense>
);
}

View File

@@ -1,22 +1,17 @@
import { Suspense } from "react";
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { getAllUniversiteler } from "@/lib/katalog";
import {
UniversiteIcerik,
UniversiteIcerikSkeleton,
uniSayfaMetadata,
uniToplamSayfa,
} from "../../universite-icerik";
uniSayfaStaticParams,
} from "@/features/katalog/components/universite-icerik";
// Yalnızca 1'den fazla sayfası olan üniversiteler için 2..N üretilir;
// 1. sayfa taban rota (/universite/[slug]) olduğundan burada üretilmez.
export function generateStaticParams() {
return getAllUniversiteler().flatMap((u) => {
const toplam = uniToplamSayfa(u.programSayisi);
return Array.from({ length: Math.max(0, toplam - 1) }, (_, i) => ({
slug: u.slug,
no: String(i + 2),
}));
});
return uniSayfaStaticParams();
}
function sayfaNo(no: string): number | null {
@@ -26,22 +21,23 @@ function sayfaNo(no: string): number | null {
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string; no: string }>;
}): Promise<Metadata> {
}: PageProps<"/universite/[slug]/sayfa/[no]">): Promise<Metadata> {
const { slug, no } = await params;
const n = sayfaNo(no);
if (n == null) return {};
return uniSayfaMetadata(slug, n);
}
export default async function UniversiteSayfaPage({
export default function UniversiteSayfaPage({
params,
}: {
params: Promise<{ slug: string; no: string }>;
}) {
const { slug, no } = await params;
const n = sayfaNo(no);
if (n == null) notFound();
return <UniversiteIcerik slug={slug} sayfa={n} />;
}: PageProps<"/universite/[slug]/sayfa/[no]">) {
return (
<Suspense fallback={<UniversiteIcerikSkeleton />}>
{params.then(({ slug, no }) => {
const n = sayfaNo(no);
if (n == null) notFound();
return <UniversiteIcerik slug={slug} sayfa={n} />;
})}
</Suspense>
);
}

View File

@@ -1,12 +1,11 @@
import { Suspense } from "react";
import type { Metadata } from "next";
import Link from "next/link";
import { getAllUniversiteler, type UniOzet } from "@/lib/katalog";
import { trBaslikDuzeni } from "@/lib/slug";
import { JsonLd, breadcrumbJsonLd } from "@/lib/seo";
import { CtaSiraForm } from "@/components/cta-sira-form";
import { SiteFooter } from "@/components/site-footer";
import { PagePixelDivider } from "@/components/pixel-decor";
import { UniLogo } from "@/components/uni-logo";
import {
UniversiteKatalogu,
UniversiteKataloguSkeleton,
} from "@/features/katalog/components/universite-listesi";
export const metadata: Metadata = {
title: "Üniversitelerin Taban Puanları ve Sıralamaları 2026",
@@ -15,44 +14,7 @@ export const metadata: Metadata = {
alternates: { canonical: "/universiteler" },
};
const sayi = (n: number) => n.toLocaleString("tr-TR");
function UniLinkleri({ uniler }: { uniler: UniOzet[] }) {
return (
<ul className="mt-4 grid gap-x-6 gap-y-2 sm:grid-cols-2">
{uniler.map((u) => (
<li key={u.slug}>
<Link
href={`/universite/${u.slug}`}
className="group flex items-center gap-3 text-sm text-slate-700 hover:text-primary"
>
<UniLogo slug={u.slug} ad={u.ad} boy="sm" />
<span className="min-w-0">
<span className="block truncate group-hover:underline">
{u.ad}
</span>
<span className="block text-xs text-slate-400">
{u.il ? `${trBaslikDuzeni(u.il)} · ` : ""}
{sayi(u.programSayisi)} program
</span>
</span>
</Link>
</li>
))}
</ul>
);
}
export default function UniversitelerPage() {
const hepsi = getAllUniversiteler();
const devlet = hepsi.filter((u) => u.unitur === "DEVLET");
const vakif = hepsi.filter(
(u) => u.unitur != null && ["VAKIF", "VAKIF MYO"].includes(u.unitur),
);
const digerUniler = hepsi.filter(
(u) => !devlet.includes(u) && !vakif.includes(u),
);
return (
<>
<main className="mx-auto w-full max-w-5xl px-4 py-12 sm:py-16">
@@ -66,51 +28,10 @@ export default function UniversitelerPage() {
<h1 className="font-heading text-3xl font-bold text-slate-900 sm:text-4xl">
Üniversitelerin Taban Puanları ve Sıralamaları 2026
</h1>
<p className="mt-4 max-w-3xl text-sm leading-7 text-slate-600">
{sayi(hepsi.length)} üniversitenin 2025 YKS yerleştirme verileri.
Üniversiteye tıklayınca tüm programlarının taban puanlarını, başarı
sıralamalarını ve kontenjanlarını fakülte fakülte görürsün. Veriler
YÖK Atlas 2025 yerleştirme sonuçlarına dayanır; 2026 tercihleri için
rehber niteliğindedir.
</p>
<PagePixelDivider seed={63} className="mt-8" />
<section className="mt-8">
<h2 className="font-heading text-2xl font-bold text-slate-900">
Devlet Üniversiteleri
<span className="ml-2 text-sm font-normal text-slate-400">
{sayi(devlet.length)}
</span>
</h2>
<UniLinkleri uniler={devlet} />
</section>
<section className="mt-10">
<h2 className="font-heading text-2xl font-bold text-slate-900">
Vakıf Üniversiteleri
<span className="ml-2 text-sm font-normal text-slate-400">
{sayi(vakif.length)}
</span>
</h2>
<UniLinkleri uniler={vakif} />
</section>
{digerUniler.length > 0 ? (
<section className="mt-10">
<h2 className="font-heading text-2xl font-bold text-slate-900">
KKTC ve Yurt Dışı
<span className="ml-2 text-sm font-normal text-slate-400">
{sayi(digerUniler.length)}
</span>
</h2>
<UniLinkleri uniler={digerUniler} />
</section>
) : null}
<div className="mt-12">
<CtaSiraForm />
</div>
<Suspense fallback={<UniversiteKataloguSkeleton />}>
<UniversiteKatalogu />
</Suspense>
</main>
<SiteFooter />
</>

View File

@@ -10,11 +10,11 @@ import {
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { TypingAnimation } from "@/components/ui/typing-animation";
import { SIHIRBAZ_AC_EVENT, sonucHref } from "@/lib/sihirbaz";
import { SIHIRBAZ_AC_EVENT, sonucHref } from "@/features/sihirbaz/sihirbaz-sabitler";
import {
profilToplamaAc,
useTercihProfili,
} from "@/components/manuel-liste/profil-kapisi-store";
} from "@/features/liste/hooks/use-tercih-profili";
const FUNNEL_METINLERI = [
"Sıralamana en uygun listeyi birlikte kuralım.",

View File

@@ -12,7 +12,7 @@ import { useRouter } from "next/navigation";
import { Wrench, X } from "lucide-react";
import { toast } from "sonner";
import { authClient } from "@/lib/auth-client";
import { tercihProfiliSil } from "@/lib/sihirbaz";
import { tercihProfiliSil } from "@/features/sihirbaz/sihirbaz-profil";
import {
devAnindaGiris,
devAyarla,

View File

@@ -1,5 +1,5 @@
import { TrendingDown, TrendingUp } from "lucide-react";
import type { Program } from "@/lib/db";
import type { Program } from "@/types/yokatlas";
/** Taban sıra sütunlarının yapısal alt kümesi: hem DB programı hem de
* rapordaki kaydedilmiş sıra geçmişi (bkz. raporSiraSerisi) bu tipe uyar. */

View File

@@ -1,8 +1,8 @@
import { Suspense } from "react";
import Image from "next/image";
import Link from "next/link";
import { KatalogArama } from "@/components/katalog-arama";
import { UserNav, UserNavFallback } from "@/components/user-nav";
import { KatalogArama } from "@/features/katalog/components/katalog-arama";
import { UserNav, UserNavFallback } from "@/features/kullanici/components/user-nav";
import { getAllRehberler } from "@/lib/rehber";
export function SiteHeader() {

View File

@@ -2,11 +2,17 @@ import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { ChevronRight } from "lucide-react";
import { getBolumBySlug, uniSlugFromAd } from "@/lib/katalog";
import {
getAllBolumler,
getAllBolumSlugs,
getBolumBySlug,
uniSlugFromAd,
} from "../katalog-queries";
import { JsonLd, breadcrumbJsonLd } from "@/lib/seo";
import { SAYFA_BOYU } from "@/components/sayfalama";
import { BolumProgramListesi } from "@/components/bolum-program-listesi";
import { SAYFA_BOYU } from "./sayfalama";
import { BolumProgramListesi } from "./bolum-program-listesi";
import { SiteFooter } from "@/components/site-footer";
import { Skeleton } from "@/components/ui/skeleton";
const sayi = (n: number) => n.toLocaleString("tr-TR");
@@ -14,6 +20,22 @@ export function bolumToplamSayfa(programSayisi: number): number {
return Math.max(1, Math.ceil(programSayisi / SAYFA_BOYU));
}
// Sayfaların generateStaticParams'ı için — sayfalar *-queries import etmez.
export function bolumStaticSlugs(): string[] {
return getAllBolumSlugs();
}
// 2..N sayfaları; 1. sayfa taban rota (/bolum/[slug]) olduğundan üretilmez.
export function bolumSayfaStaticParams(): { slug: string; no: string }[] {
return getAllBolumler().flatMap((b) => {
const toplam = bolumToplamSayfa(b.programSayisi);
return Array.from({ length: Math.max(0, toplam - 1) }, (_, i) => ({
slug: b.slug,
no: String(i + 2),
}));
});
}
export function bolumSayfaMetadata(slug: string, sayfa: number): Metadata {
const bolum = getBolumBySlug(slug);
if (!bolum) return {};
@@ -123,3 +145,21 @@ export function BolumIcerik({ slug, sayfa }: { slug: string; sayfa: number }) {
</>
);
}
export function BolumIcerikSkeleton() {
return (
<main className="mx-auto w-full max-w-5xl px-4 py-10 sm:py-14">
<Skeleton className="h-5 w-56" />
<div className="border-b pb-7 sm:pb-9">
<Skeleton className="mt-5 h-10 w-2/3 max-w-xl" />
<Skeleton className="mt-3 h-5 w-full max-w-2xl" />
</div>
<div className="mt-8 space-y-3">
<Skeleton className="h-7 w-64" />
{Array.from({ length: 5 }, (_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
</main>
);
}

View File

@@ -0,0 +1,115 @@
import Link from "next/link";
import { getAllBolumler, type BolumOzet } from "../katalog-queries";
import { KATEGORILER } from "@/lib/kategoriler";
import { CtaSiraForm } from "@/features/sihirbaz/components/cta-sira-form";
import { PagePixelDivider } from "@/components/pixel-decor";
import { Skeleton } from "@/components/ui/skeleton";
const sayi = (n: number) => n.toLocaleString("tr-TR");
function BolumLinkleri({ bolumler }: { bolumler: BolumOzet[] }) {
return (
<ul className="mt-3 grid gap-x-6 gap-y-1.5 sm:grid-cols-2 lg:grid-cols-3">
{bolumler.map((b) => (
<li key={b.slug}>
<Link
href={`/bolum/${b.slug}`}
className="group inline-flex items-baseline gap-2 text-sm text-slate-700 hover:text-primary"
>
<span className="group-hover:underline">{b.ad}</span>
<span className="text-xs text-slate-400">
{sayi(b.programSayisi)}
</span>
</Link>
</li>
))}
</ul>
);
}
function KategoriGruplari({
bolumler,
seviye,
}: {
bolumler: BolumOzet[];
seviye: "lisans" | "onlisans";
}) {
const kalanlar = new Set(bolumler.map((b) => b.slug));
const gruplar = KATEGORILER.map((kategori) => {
const uyeler = bolumler.filter((b) =>
(b.kategoriler as readonly string[]).includes(kategori),
);
for (const b of uyeler) kalanlar.delete(b.slug);
return { kategori: kategori as string, uyeler };
}).filter((g) => g.uyeler.length > 0);
const diger = bolumler.filter((b) => kalanlar.has(b.slug));
if (diger.length > 0) gruplar.push({ kategori: "Diğer", uyeler: diger });
return (
<>
{gruplar.map(({ kategori, uyeler }) => (
<section key={`${seviye}-${kategori}`} className="mt-8">
<h3 className="font-heading text-lg font-bold text-slate-900">
{kategori}
<span className="ml-2 text-xs font-normal text-slate-400">
{sayi(uyeler.length)} bölüm
</span>
</h3>
<BolumLinkleri bolumler={uyeler} />
</section>
))}
</>
);
}
export function BolumKatalogu() {
const hepsi = getAllBolumler();
const lisans = hepsi.filter((b) => b.lisans);
const onlisans = hepsi.filter((b) => b.onlisans && !b.lisans);
return (
<>
<p className="mt-4 max-w-3xl text-sm leading-7 text-slate-600">
{sayi(hepsi.length)} bölümün 2025 YKS yerleştirme verileri: taban
puanları, başarı sıralamaları, kontenjanlar ve devlet/vakıf dağılımı.
Bölüme tıklayınca o bölümü sunan tüm üniversitelerin tablosunu görürsün.
Veriler YÖK Atlas 2025 yerleştirme sonuçlarına dayanır; 2026 tercihleri
için rehber niteliğindedir.
</p>
<PagePixelDivider seed={29} className="mt-8" />
<section className="mt-8">
<h2 className="font-heading text-2xl font-bold text-slate-900">
Lisans Bölümleri (4 Yıllık)
</h2>
<KategoriGruplari bolumler={lisans} seviye="lisans" />
</section>
<div className="mt-12">
<CtaSiraForm />
</div>
<section className="mt-12">
<h2 className="font-heading text-2xl font-bold text-slate-900">
Önlisans Bölümleri (2 Yıllık — TYT)
</h2>
<KategoriGruplari bolumler={onlisans} seviye="onlisans" />
</section>
</>
);
}
export function BolumKataloguSkeleton() {
return (
<div>
<Skeleton className="mt-4 h-5 w-full max-w-3xl" />
<div className="mt-8 space-y-4">
<Skeleton className="h-8 w-72" />
{Array.from({ length: 4 }, (_, i) => (
<Skeleton key={i} className="h-6 w-full max-w-2xl" />
))}
</div>
</div>
);
}

View File

@@ -3,15 +3,15 @@
import { useMemo, useRef, useState } from "react";
import Link from "next/link";
import { ChevronDown, ChevronUp } from "lucide-react";
import type { Program } from "@/lib/db";
import type { Program } from "@/types/yokatlas";
import { trBaslikDuzeni } from "@/lib/slug";
import { Sayfalama, SAYFA_BOYU } from "@/components/sayfalama";
import { Sayfalama, SAYFA_BOYU } from "./sayfalama";
import {
ProgramSiraGecmisi,
ProgramTabanTrendi,
} from "@/components/program-liste-verileri";
import { ProgramEkleButonu } from "@/components/manuel-liste/program-ekle-butonu";
import { ProgramNetleriButonu } from "@/components/program-netleri";
import { ProgramEkleButonu } from "@/features/liste/components/program-ekle-butonu";
import { ProgramNetleriButonu } from "./program-netleri";
import { UniLogo } from "@/components/uni-logo";
import {
Table,

View File

@@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { AramaFunnelKarti } from "@/components/arama-funnel-karti";
import { RehberKapak } from "@/components/rehber-kapak";
import { RehberKapak } from "@/features/rehber/components/rehber-kapak";
import { UniLogo } from "@/components/uni-logo";
import { aramaNormalize, eslesmePuani } from "@/lib/arama";
import type { RehberOzet } from "@/lib/rehber";
@@ -12,8 +12,8 @@ import {
profilDuzenlemeyiAc,
sonucaGitIste,
useTercihProfili,
} from "@/components/manuel-liste/profil-kapisi-store";
import { PUAN_TURU_ETIKET } from "@/lib/sihirbaz";
} from "@/features/liste/hooks/use-tercih-profili";
import { PUAN_TURU_ETIKET } from "@/features/sihirbaz/sihirbaz-sabitler";
import { olay } from "@/lib/analitik";
import {
ArrowRight,

View File

@@ -8,7 +8,7 @@
import { useState } from "react";
import { ListChecks } from "lucide-react";
import type { ProgramNetSatiri } from "@/lib/db";
import type { ProgramNetSatiri } from "@/types/yokatlas";
import { olay } from "@/lib/analitik";
import {
Dialog,

View File

@@ -2,18 +2,24 @@ import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { ChevronRight } from "lucide-react";
import { getUniversiteBySlug, bolumSlugFromIsim } from "@/lib/katalog";
import {
getAllUniversiteler,
getAllUniversiteSlugs,
getUniversiteBySlug,
bolumSlugFromIsim,
} from "../katalog-queries";
import { trBaslikDuzeni } from "@/lib/slug";
import { JsonLd, breadcrumbJsonLd, SITE_URL } from "@/lib/seo";
import { CtaSiraForm } from "@/components/cta-sira-form";
import { CtaSiraForm } from "@/features/sihirbaz/components/cta-sira-form";
import { SiteFooter } from "@/components/site-footer";
import { Sayfalama, SAYFA_BOYU } from "@/components/sayfalama";
import { Sayfalama, SAYFA_BOYU } from "./sayfalama";
import { PagePixelDivider } from "@/components/pixel-decor";
import { UniversiteKonumHaritasi } from "@/components/universite-konum-haritasi";
import { UniversiteProgramTablosu } from "@/components/universite-program-tablosu";
import { UniversiteKonumHaritasi } from "./universite-konum-haritasi";
import { UniversiteProgramTablosu } from "./universite-program-tablosu";
import { UniLogo } from "@/components/uni-logo";
import { uniLogoYolu } from "@/lib/uni-logolar";
import type { Program } from "@/lib/db";
import { Skeleton } from "@/components/ui/skeleton";
import type { Program } from "@/types/yokatlas";
const sayi = (n: number) => n.toLocaleString("tr-TR");
@@ -21,6 +27,22 @@ export function uniToplamSayfa(programSayisi: number): number {
return Math.max(1, Math.ceil(programSayisi / SAYFA_BOYU));
}
// Sayfaların generateStaticParams'ı için — sayfalar *-queries import etmez.
export function uniStaticSlugs(): string[] {
return getAllUniversiteSlugs();
}
// 2..N sayfaları; 1. sayfa taban rota (/universite/[slug]) olduğundan üretilmez.
export function uniSayfaStaticParams(): { slug: string; no: string }[] {
return getAllUniversiteler().flatMap((u) => {
const toplam = uniToplamSayfa(u.programSayisi);
return Array.from({ length: Math.max(0, toplam - 1) }, (_, i) => ({
slug: u.slug,
no: String(i + 2),
}));
});
}
export function uniSayfaMetadata(slug: string, sayfa: number): Metadata {
const uni = getUniversiteBySlug(slug);
if (!uni) return {};
@@ -268,3 +290,27 @@ export function UniversiteIcerik({
</>
);
}
export function UniversiteIcerikSkeleton() {
return (
<main className="mx-auto w-full max-w-5xl px-4 py-12 sm:py-16">
<Skeleton className="h-4 w-64" />
<div className="mt-4 flex items-center gap-4">
<Skeleton className="size-14 rounded-lg" />
<Skeleton className="h-10 w-2/3 max-w-xl" />
</div>
<Skeleton className="mt-4 h-5 w-full max-w-3xl" />
<div className="mt-6 grid grid-cols-2 gap-3 lg:grid-cols-[1fr_1fr_1.35fr] lg:grid-rows-2">
{Array.from({ length: 5 }, (_, i) => (
<Skeleton key={i} className="h-24 w-full rounded-xl" />
))}
</div>
<div className="mt-16 space-y-3">
<Skeleton className="h-7 w-64" />
{Array.from({ length: 5 }, (_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
</main>
);
}

View File

@@ -0,0 +1,110 @@
import Link from "next/link";
import { getAllUniversiteler, type UniOzet } from "../katalog-queries";
import { trBaslikDuzeni } from "@/lib/slug";
import { CtaSiraForm } from "@/features/sihirbaz/components/cta-sira-form";
import { PagePixelDivider } from "@/components/pixel-decor";
import { UniLogo } from "@/components/uni-logo";
import { Skeleton } from "@/components/ui/skeleton";
const sayi = (n: number) => n.toLocaleString("tr-TR");
function UniLinkleri({ uniler }: { uniler: UniOzet[] }) {
return (
<ul className="mt-4 grid gap-x-6 gap-y-2 sm:grid-cols-2">
{uniler.map((u) => (
<li key={u.slug}>
<Link
href={`/universite/${u.slug}`}
className="group flex items-center gap-3 text-sm text-slate-700 hover:text-primary"
>
<UniLogo slug={u.slug} ad={u.ad} boy="sm" />
<span className="min-w-0">
<span className="block truncate group-hover:underline">
{u.ad}
</span>
<span className="block text-xs text-slate-400">
{u.il ? `${trBaslikDuzeni(u.il)} · ` : ""}
{sayi(u.programSayisi)} program
</span>
</span>
</Link>
</li>
))}
</ul>
);
}
export function UniversiteKatalogu() {
const hepsi = getAllUniversiteler();
const devlet = hepsi.filter((u) => u.unitur === "DEVLET");
const vakif = hepsi.filter(
(u) => u.unitur != null && ["VAKIF", "VAKIF MYO"].includes(u.unitur),
);
const digerUniler = hepsi.filter(
(u) => !devlet.includes(u) && !vakif.includes(u),
);
return (
<>
<p className="mt-4 max-w-3xl text-sm leading-7 text-slate-600">
{sayi(hepsi.length)} üniversitenin 2025 YKS yerleştirme verileri.
Üniversiteye tıklayınca tüm programlarının taban puanlarını, başarı
sıralamalarını ve kontenjanlarını fakülte fakülte görürsün. Veriler YÖK
Atlas 2025 yerleştirme sonuçlarına dayanır; 2026 tercihleri için rehber
niteliğindedir.
</p>
<PagePixelDivider seed={63} className="mt-8" />
<section className="mt-8">
<h2 className="font-heading text-2xl font-bold text-slate-900">
Devlet Üniversiteleri
<span className="ml-2 text-sm font-normal text-slate-400">
{sayi(devlet.length)}
</span>
</h2>
<UniLinkleri uniler={devlet} />
</section>
<section className="mt-10">
<h2 className="font-heading text-2xl font-bold text-slate-900">
Vakıf Üniversiteleri
<span className="ml-2 text-sm font-normal text-slate-400">
{sayi(vakif.length)}
</span>
</h2>
<UniLinkleri uniler={vakif} />
</section>
{digerUniler.length > 0 ? (
<section className="mt-10">
<h2 className="font-heading text-2xl font-bold text-slate-900">
KKTC ve Yurt Dışı
<span className="ml-2 text-sm font-normal text-slate-400">
{sayi(digerUniler.length)}
</span>
</h2>
<UniLinkleri uniler={digerUniler} />
</section>
) : null}
<div className="mt-12">
<CtaSiraForm />
</div>
</>
);
}
export function UniversiteKataloguSkeleton() {
return (
<div>
<Skeleton className="mt-4 h-5 w-full max-w-3xl" />
<div className="mt-8 space-y-4">
<Skeleton className="h-8 w-72" />
{Array.from({ length: 4 }, (_, i) => (
<Skeleton key={i} className="h-9 w-full max-w-2xl" />
))}
</div>
</div>
);
}

View File

@@ -1,14 +1,14 @@
"use client";
import Link from "next/link";
import type { Program } from "@/lib/db";
import type { Program } from "@/types/yokatlas";
import {
ProgramSiraGecmisi,
ProgramTabanTrendi,
programEtkinSira,
} from "@/components/program-liste-verileri";
import { ProgramEkleButonu } from "@/components/manuel-liste/program-ekle-butonu";
import { ProgramNetleriButonu } from "@/components/program-netleri";
import { ProgramEkleButonu } from "@/features/liste/components/program-ekle-butonu";
import { ProgramNetleriButonu } from "./program-netleri";
import {
Table,
TableBody,

View File

@@ -0,0 +1,17 @@
import "server-only";
// Katalog feature'ının sorgu kapısı. Veri motoru @/lib/katalog'da yaşar
// (yokatlas.db üzerinde salt-okunur, globalThis'te önbellekli); feature
// bileşenleri ve sayfa yardımcıları yalnızca buradan okur.
export {
getAllBolumler,
getAllBolumSlugs,
getAllUniversiteler,
getAllUniversiteSlugs,
getBolumBySlug,
getUniversiteBySlug,
uniSlugFromAd,
bolumSlugFromIsim,
type BolumOzet,
type UniOzet,
} from "@/lib/katalog";

View File

@@ -0,0 +1,104 @@
import { redirect } from "next/navigation";
import { ArrowRight } from "lucide-react";
import { PixelCorner } from "@/components/pixel-decor";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
/** Yalnızca site içi (göreli) callback'lere izin ver — open redirect kalkanı. */
function guvenliYol(deger: string | undefined, varsayilan: string): string {
return deger && deger.startsWith("/") && !deger.startsWith("//")
? deger
: varsayilan;
}
/**
* Maildeki giriş bağlantısının indiği onay kartı. Doğrulama ucu
* (/api/auth/magic-link/verify) GET'lendiği anda token'ı tüketir; mail
* güvenlik tarayıcıları ve tarayıcı ön-yüklemeleri linki kullanıcıdan önce
* açıp token'ı yakabildiği için maildeki link token'ı TÜKETMEYEN bu sayfaya
* gelir. Verify ancak buradaki form gönderilince tetiklenir — tarayıcı botları
* sayfayı GET'lese bile form'u göndermez, token yanmaz.
*/
export function DogrulaKarti({
token,
callbackURL,
errorCallbackURL,
newUserCallbackURL,
}: {
token?: string;
callbackURL?: string;
errorCallbackURL?: string;
newUserCallbackURL?: string;
}) {
if (!token) redirect("/giris");
const callback = guvenliYol(callbackURL, "/");
const hataCallback = guvenliYol(
errorCallbackURL,
`/giris?hata=gecersiz-baglanti&callback=${encodeURIComponent(callback)}`,
);
const yeniKullaniciCallback = guvenliYol(newUserCallbackURL, callback);
return (
<div className="relative">
<div className="relative rounded-2xl border border-slate-200 bg-white p-8 shadow-sm">
<h1 className="font-heading text-xl font-bold">Girişini onayla</h1>
<p className="mt-2 text-sm text-slate-600">
Bu bağlantı senin için üretildi ve{" "}
<span className="font-semibold">tek kullanımlık</span>. Aşağıdaki
butona tıkladığında KolayTercih hesabına giriş yapacaksın.
</p>
<form
method="GET"
action="/api/auth/magic-link/verify"
className="mt-6"
>
<input type="hidden" name="token" value={token} />
<input type="hidden" name="callbackURL" value={callback} />
<input
type="hidden"
name="errorCallbackURL"
value={hataCallback}
/>
<input
type="hidden"
name="newUserCallbackURL"
value={yeniKullaniciCallback}
/>
<Button
type="submit"
className="h-11 w-full cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
>
Giriş yap
<ArrowRight className="size-4" aria-hidden />
</Button>
</form>
<p className="mt-4 text-xs leading-relaxed text-slate-500">
Bu isteği sen yapmadıysan bu sayfayı kapatabilirsin — butona
tıklanmadığı sürece hesabında hiçbir şey değişmez.
</p>
</div>
<div
aria-hidden
className="pointer-events-none absolute inset-0 z-10 text-primary"
>
<PixelCorner className="absolute -right-4 -top-4 size-9 [&_rect]:[fill-opacity:0.65]" />
<PixelCorner className="absolute -bottom-4 -right-4 size-9 rotate-90 [&_rect]:[fill-opacity:0.65]" />
<PixelCorner className="absolute -bottom-4 -left-4 size-9 rotate-180 [&_rect]:[fill-opacity:0.65]" />
<PixelCorner className="absolute -left-4 -top-4 size-9 -rotate-90 [&_rect]:[fill-opacity:0.65]" />
</div>
</div>
);
}
export function DogrulaKartiSkeleton() {
return (
<div className="rounded-2xl border border-slate-200 bg-white p-8 shadow-sm">
<Skeleton className="h-7 w-40" />
<Skeleton className="mt-3 h-4 w-full" />
<Skeleton className="mt-1.5 h-4 w-2/3" />
<Skeleton className="mt-6 h-11 w-full rounded-lg" />
<Skeleton className="mt-4 h-3 w-3/4" />
</div>
);
}

View File

@@ -0,0 +1,122 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { getSession } from "@/lib/session";
import { PixelCorner } from "@/components/pixel-decor";
import { Skeleton } from "@/components/ui/skeleton";
import { GirisForm } from "./giris-form";
import { GirisBaslik } from "./giris-baslik";
export async function GirisKarti({
callback,
hata,
}: {
callback?: string;
hata?: string;
}) {
const callbackURL = callback && callback.startsWith("/") ? callback : "/";
// Magic link doğrulaması başarısız olunca errorCallbackURL buraya düşürür
// (bkz. giris-form.tsx ve /giris/dogrula) — ham INVALID_TOKEN yerine
// dostça bir mesaj gösterip yeni bağlantı istemeye yönlendiriyoruz.
const baglantiHatasi = hata === "gecersiz-baglanti";
const session = await getSession();
if (session) redirect(callbackURL);
// Sihirbaz funnel'ı: seçimler yapıldı, listeye giriş kaldı
const funnel = callbackURL.startsWith("/sonuc");
return (
<>
<div className="relative">
<div className="relative rounded-2xl border border-slate-200 bg-white p-8 shadow-sm">
{baglantiHatasi && (
<div
role="alert"
className="mb-5 rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-800"
>
<p className="font-semibold">
Giriş bağlantısı geçersiz ya da süresi dolmuş
</p>
<p className="mt-1">
Bağlantılar 15 dakika geçerli ve tek kullanımlık. Aşağıdan
yeni bir bağlantı isteyebilirsin.
</p>
</div>
)}
<GirisBaslik funnel={funnel} />
<p className="mt-2 text-sm text-slate-600">
{funnel ? (
<>
Seçimlerin hazır — giriş yap,{" "}
<span className="font-semibold">5 deneme kredinle</span> listen
hemen üretilsin.
</>
) : (
<>
Kayıt olduğunda{" "}
<span className="font-semibold">5 deneme kredisi</span>{" "}
hesabına tanımlanır — Yapay Zeka danışmana ücretsiz soru sorabilirsin.
</>
)}
</p>
<GirisForm callbackURL={callbackURL} />
</div>
<div
aria-hidden
className="pointer-events-none absolute inset-0 z-10 text-primary"
>
<PixelCorner className="absolute -right-4 -top-4 size-9 [&_rect]:[fill-opacity:0.65]" />
<PixelCorner className="absolute -bottom-4 -right-4 size-9 rotate-90 [&_rect]:[fill-opacity:0.65]" />
<PixelCorner className="absolute -bottom-4 -left-4 size-9 rotate-180 [&_rect]:[fill-opacity:0.65]" />
<PixelCorner className="absolute -left-4 -top-4 size-9 -rotate-90 [&_rect]:[fill-opacity:0.65]" />
</div>
</div>
<p className="mt-6 text-center text-xs leading-relaxed text-slate-500">
Giriş yaparak verilerinin tercih önerileri üretmek amacıyla{" "}
<Link href="/gizlilik" className="underline hover:text-slate-700">
gizlilik metnindeki
</Link>{" "}
gibi işlenmesini ve{" "}
<Link href="/kosullar" className="underline hover:text-slate-700">
kullanım koşullarını
</Link>{" "}
kabul etmiş olursun. KolayTercih yerleşme garantisi vermez.
</p>
</>
);
}
// Kart, başlık, Google butonu, ayraç, e-posta alanı ve gönder butonu aynı
// yerde belirir — içerik gelince zıplama olmaz.
export function GirisKartiSkeleton() {
return (
<>
<div className="rounded-2xl border border-slate-200 bg-white p-8 shadow-sm">
{/* Başlık + alt metin */}
<Skeleton className="h-8 w-40" />
<Skeleton className="mt-3 h-4 w-full" />
<Skeleton className="mt-1.5 h-4 w-2/3" />
{/* Google ile devam et */}
<Skeleton className="mt-6 h-11 w-full rounded-lg" />
{/* "veya" ayracı */}
<div className="mt-4 flex items-center gap-3">
<Skeleton className="h-px flex-1" />
<Skeleton className="h-3 w-8" />
<Skeleton className="h-px flex-1" />
</div>
{/* E-posta alanı + gönder butonu */}
<Skeleton className="mt-4 h-11 w-full rounded-lg" />
<Skeleton className="mt-3 h-11 w-full rounded-lg" />
</div>
{/* Kart altındaki yasal metin */}
<div className="mt-6 flex flex-col items-center gap-1.5">
<Skeleton className="h-3 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</div>
</>
);
}

View File

@@ -2,8 +2,8 @@ import Link from "next/link";
import { Coins } from "lucide-react";
import { getCurrentUser } from "@/lib/session";
import { Button } from "@/components/ui/button";
import { ListemButonu } from "@/components/manuel-liste/listem-butonu";
import { ListeSenkron } from "@/components/manuel-liste/liste-senkron";
import { ListemButonu } from "@/features/liste/components/listem-butonu";
import { ListeSenkron } from "@/features/liste/components/liste-senkron";
import { RybbitIdentify } from "@/components/rybbit-identify";
import { SignOutButton } from "./sign-out-button";

View File

@@ -16,9 +16,9 @@ import { useGorunumOlayi } from "@/components/use-gorunum-olayi";
import { olay } from "@/lib/analitik";
import { kromGizliMi } from "@/components/header-gate";
import { CihazCifti, IsletimSistemiLogosu } from "./cihaz-ikonlari";
import { useManuelListe } from "./store";
import { useOturumDurumu } from "./oturum-sinyali";
import { useGirisUrl } from "./giris-url";
import { useManuelListe } from "../liste-store";
import { useOturumDurumu } from "../liste-oturum-sinyali";
import { useGirisUrl } from "../liste-giris-url";
const KAPATMA_KEY = "kolaytercih.kaydet-banner-kapali";
const ESIK = 3;

View File

@@ -47,9 +47,9 @@ import { RISK_ETIKET, type RiskSeviyesi } from "@/lib/risk";
import { bolumBazAdi, turkishSlugify } from "@/lib/slug";
import { olay } from "@/lib/analitik";
import { CihazCifti, IsletimSistemiLogosu } from "./cihaz-ikonlari";
import { sonucaGitIste } from "./profil-kapisi-store";
import { useOturumDurumu } from "./oturum-sinyali";
import { useGirisUrl } from "./giris-url";
import { sonucaGitIste } from "../hooks/use-tercih-profili";
import { useOturumDurumu } from "../liste-oturum-sinyali";
import { useGirisUrl } from "../liste-giris-url";
import {
MANUEL_LISTE_MAX,
cekmeceAc,
@@ -60,7 +60,7 @@ import {
useManuelListe,
yenidenSirala,
type ManuelTercih,
} from "./store";
} from "../liste-store";
// Slug kuralları katalog.ts'tekiyle aynı (bolumBazAdi/uniAdiNormalize +
// turkishSlugify); katalog haritaları DB'deki tüm isim/universite

View File

@@ -11,14 +11,11 @@
// - Sonrasında: liste/profil değişimleri ~2 sn debounce ile sunucuya yazılır.
import { useEffect, useRef } from "react";
import {
PROFIL_DEGISTI_EVENT,
tercihProfiliOku,
tercihProfiliYaz,
} from "@/lib/sihirbaz";
import { listeOku, sunucuylaBirlestir, useManuelListe } from "./store";
import { oturumDurumuBildir } from "./oturum-sinyali";
import { listeSenkronla, listeyiKaydet } from "./kayitli-liste-actions";
import { PROFIL_DEGISTI_EVENT } from "@/features/sihirbaz/sihirbaz-sabitler";
import { tercihProfiliOku, tercihProfiliYaz } from "@/features/sihirbaz/sihirbaz-profil";
import { listeOku, sunucuylaBirlestir, useManuelListe } from "../liste-store";
import { oturumDurumuBildir } from "../liste-oturum-sinyali";
import { listeSenkronla, listeyiKaydet } from "../liste-actions";
const DEBOUNCE_MS = 2000;

View File

@@ -11,7 +11,7 @@ import {
cekmeceDegistir,
useCekmeceAcik,
useManuelListe,
} from "./store";
} from "../liste-store";
export function ListemButonu() {
const liste = useManuelListe();

View File

@@ -5,10 +5,10 @@
// abonedir; ekleme/çıkarma/sürükleme anında haritaya yansır.
import { useMemo, useState } from "react";
import { TercihHaritasi, type HaritaPin } from "@/components/tercih-haritasi";
import { TercihHaritasi, type HaritaPin } from "@/features/liste/components/tercih-haritasi";
import { ilKonum, uniAdiNormalize, uniKonum } from "@/lib/harita";
import type { RiskSeviyesi } from "@/lib/risk";
import { useManuelListe } from "./store";
import { useManuelListe } from "../liste-store";
// Aynı üniversitede birden çok tercih varsa pinde kötü risk baskın olsun
const RISK_AGIRLIK: Record<RiskSeviyesi, number> = {

View File

@@ -2,15 +2,15 @@
import { Check, Plus } from "lucide-react";
import { toast } from "sonner";
import type { Program } from "@/lib/db";
import type { Program } from "@/types/yokatlas";
import { olay } from "@/lib/analitik";
import {
listedeMi,
MANUEL_LISTE_MAX,
useManuelListe,
type ManuelKaynak,
} from "./store";
import { programEkleIste } from "./profil-kapisi-store";
} from "../liste-store";
import { programEkleIste } from "../hooks/use-tercih-profili";
type ProgramOzet = Pick<
Program,

View File

@@ -13,7 +13,7 @@ import {
Scale,
ShieldCheck,
} from "lucide-react";
import type { DilimKey, Program, PuanTuruKey, RankResults } from "@/lib/db";
import type { DilimKey, Program, PuanTuruKey, RankResults } from "@/types/yokatlas";
import { riskHesapla, type RiskSeviyesi } from "@/lib/risk";
import { bolumSayfaSlug, uniSayfaSlug } from "@/lib/slug";
import {
@@ -30,8 +30,8 @@ import {
TableRow,
} from "@/components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ProgramEkleButonu } from "@/components/manuel-liste/program-ekle-butonu";
import { ProgramNetleriButonu } from "@/components/program-netleri";
import { ProgramEkleButonu } from "@/features/liste/components/program-ekle-butonu";
import { ProgramNetleriButonu } from "@/features/katalog/components/program-netleri";
import { UniLogo } from "@/components/uni-logo";
const DILIMLER: {

View File

@@ -13,7 +13,7 @@ import {
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { RISK_ETIKET, type RiskSeviyesi } from "@/lib/risk";
import { sonucHref } from "@/lib/sihirbaz";
import { sonucHref } from "@/features/sihirbaz/sihirbaz-sabitler";
import {
tercihDegerlendir,
tercihListesiOzeti,
@@ -23,12 +23,12 @@ import {
MANUEL_LISTE_MAX,
useManuelListe,
type ManuelTercih,
} from "./store";
} from "../liste-store";
import {
profilDuzenlemeyiAc,
sonucaGitIste,
useTercihProfili,
} from "./profil-kapisi-store";
} from "../hooks/use-tercih-profili";
const RISK_STIL: Record<RiskSeviyesi, { nokta: string; kenar: string }> = {
guvenli: { nokta: "bg-emerald-500", kenar: "border-l-emerald-500" },

View File

@@ -0,0 +1,27 @@
import { siraylaProgramAra } from "../liste-queries";
import { ProgramTablosu } from "./program-tablosu";
import { Skeleton } from "@/components/ui/skeleton";
import type { PuanTuruKey } from "@/types/yokatlas";
// /sonuc'un ham tablosunun sunucu sarmalayıcısı: okuma burada yaşar, sayfa
// yalnızca kompoze eder.
export function SonucProgramTablosu({
sira,
tur,
}: {
sira: number;
tur: PuanTuruKey;
}) {
const sonuclar = siraylaProgramAra(sira, tur);
return <ProgramTablosu sonuclar={sonuclar} adaySira={sira} tur={tur} />;
}
export function SonucProgramTablosuSkeleton() {
return (
<div className="mt-8 flex flex-col gap-2">
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full rounded-xl" />
))}
</div>
);
}

View File

@@ -25,14 +25,11 @@ import {
import {
SihirbazAdimlarLazy,
preloadSihirbazAdimlar,
} from "@/components/sihirbaz-adimlar-lazy";
import type { SihirbazFacetleri } from "@/lib/db";
} from "@/features/sihirbaz/components/sihirbaz-adimlar-lazy";
import type { SihirbazFacetleri } from "@/types/yokatlas";
import { olay, siraKovasi } from "@/lib/analitik";
import {
tercihProfiliYaz,
type SihirbazSecimleri,
type TercihProfili,
} from "@/lib/sihirbaz";
import { type SihirbazSecimleri, type TercihProfili } from "@/features/sihirbaz/sihirbaz-sabitler";
import { tercihProfiliYaz } from "@/features/sihirbaz/sihirbaz-profil";
import { authClient } from "@/lib/auth-client";
import {
profilKapisiniKapat,
@@ -40,7 +37,7 @@ import {
useProfilKapisi,
type BekleyenHedef,
type BekleyenProgram,
} from "./profil-kapisi-store";
} from "../hooks/use-tercih-profili";
const PUAN_TURLERI_SECENEK = [
{ deger: "say", etiket: "Sayısal" },

View File

@@ -4,14 +4,9 @@
// Layout'ta bir kez monte edilen TercihProfiliKapisi bu store'a abone olur.
import { useSyncExternalStore } from "react";
import type { Program } from "@/lib/db";
import {
PROFIL_DEGISTI_EVENT,
sonucHref,
tercihProfiliOku,
tercihProfiliTamamlandiMi,
type TercihProfili,
} from "@/lib/sihirbaz";
import type { Program } from "@/types/yokatlas";
import { PROFIL_DEGISTI_EVENT, sonucHref, type TercihProfili } from "@/features/sihirbaz/sihirbaz-sabitler";
import { tercihProfiliOku, tercihProfiliTamamlandiMi } from "@/features/sihirbaz/sihirbaz-profil";
import {
ekle,
listedeMi,
@@ -20,7 +15,7 @@ import {
riskleriYenile,
useManuelListe,
type ManuelKaynak,
} from "./store";
} from "../liste-store";
export type BekleyenProgram = {
program: Pick<

View File

@@ -13,12 +13,12 @@
import { eq } from "drizzle-orm";
import { getSession } from "@/lib/session";
import { appDb, schema } from "@/lib/appdb";
import { tercihProfiliDogrula, type TercihProfili } from "@/lib/sihirbaz";
import { tercihProfiliDogrula, type TercihProfili } from "@/features/sihirbaz/sihirbaz-sabitler";
import {
MANUEL_LISTE_MAX,
dogrula,
type ManuelTercih,
} from "@/components/manuel-liste/dogrula";
} from "@/features/liste/liste-dogrula";
export type ListeSenkronSonucu =
| { ok: true; items: ManuelTercih[]; profil: TercihProfili | null }

View File

@@ -0,0 +1,12 @@
import "server-only";
import { searchByRank } from "@/lib/db";
import type { PuanTuruKey, RankResults } from "@/types/yokatlas";
// /sonuc'un ham tablosu: yalnızca sıralamayla ulaşılabilen programlar,
// hayal/dengeli/garanti dilimli, dilim başına ilk 20 satır.
export function siraylaProgramAra(
sira: number,
tur: PuanTuruKey,
): RankResults {
return searchByRank(sira, tur, { limitPerBucket: 20 });
}

View File

@@ -6,18 +6,16 @@
// (girişsiz kullanıcı da liste kurabilsin diye sunucuya yazmıyoruz).
import { useSyncExternalStore } from "react";
import type { Program } from "@/lib/db";
import type { Program } from "@/types/yokatlas";
import { riskHesapla } from "@/lib/risk";
import {
tercihProfiliOku,
type TercihProfili,
} from "@/lib/sihirbaz";
import { type TercihProfili } from "@/features/sihirbaz/sihirbaz-sabitler";
import { tercihProfiliOku } from "@/features/sihirbaz/sihirbaz-profil";
import {
MANUEL_LISTE_MAX,
dogrula,
type ManuelKaynak,
type ManuelTercih,
} from "./dogrula";
} from "./liste-dogrula";
// Doğrulama katmanı sunucu action'larıyla paylaşılır (bkz. dogrula.ts);
// mevcut import yolları kırılmasın diye buradan re-export edilir.

View File

@@ -0,0 +1,121 @@
import Link from "next/link";
import { ArrowRight, CheckCircle2, XCircle } from "lucide-react";
import { verifySession } from "@/lib/session";
import { siparisGetir } from "../odeme-queries";
import { kullanicininRaporu } from "@/features/rapor/rapor-queries";
import { odemeyiSonuclandir } from "@/lib/odeme";
import { Button } from "@/components/ui/button";
import { PagePixelDivider } from "@/components/pixel-decor";
import { Skeleton } from "@/components/ui/skeleton";
import { RaporYokCta } from "./rapor-yok-cta";
export async function OdemeSonucKarti({ siparis }: { siparis?: string }) {
// Oturum düşmüşse geri dönüşte ?siparis= kaybolmasın (başarı/unblur deneyimi)
const geriDonus = siparis
? `/odeme/sonuc?siparis=${encodeURIComponent(siparis)}`
: "/paket";
const session = await verifySession(geriDonus);
const order = siparis ? await siparisGetir(siparis) : null;
// Yalnızca kendi siparişi görüntülenebilir
const sahibiMi = order?.userId === session.user.id;
// Self-healing: callback kaybolduysa burada tekrar doğrula
const durum =
order && sahibiMi
? order.status === "paid"
? "paid"
: await odemeyiSonuclandir(order.id)
: "not_found";
const basarili = durum === "paid";
// Rapor zaten üretildiyse ödül bir tık uzakta: kullanıcıyı doğrudan
// rapor sayfasına döndür ki blur çözülme animasyonuyla tam listeyi görsün.
const rapor = basarili ? await kullanicininRaporu(session.user.id) : null;
const listeHref = rapor ? "/listem?acildi=1" : null;
return basarili ? (
<div className="rounded-2xl border border-emerald-200 bg-white p-8 shadow-sm">
<CheckCircle2
className="mx-auto size-12 text-emerald-500"
aria-hidden
/>
<h1 className="mt-4 font-heading text-2xl font-bold">
Ödeme alındı 🎉
</h1>
<p className="mt-2 text-sm text-slate-600">
<span className="font-semibold">
{order!.credits} kredi
</span>{" "}
hesabına tanımlandı
{order!.product === "paket"
? " ve Tercih Dönemi Paketi'n aktif — kişisel listen, risk raporun ve PDF çıktın hazır olduğunda burada."
: "."}
</p>
<PagePixelDivider seed={79} className="mx-auto mt-5" />
<div className="mt-6 flex flex-col gap-2">
{listeHref ? (
<>
<Button
asChild
className="h-11 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
>
<Link href={listeHref}>
Listeni aç — kilidi kaldırdık
<ArrowRight className="size-4" aria-hidden />
</Link>
</Button>
<p className="text-center text-xs text-slate-500">
24 tercihin tamamı, gerekçeler, riskler ve PDF artık açık.
</p>
</>
) : (
<RaporYokCta />
)}
</div>
</div>
) : (
<div className="rounded-2xl border border-red-200 bg-white p-8 shadow-sm">
<XCircle className="mx-auto size-12 text-red-500" aria-hidden />
<h1 className="mt-4 font-heading text-2xl font-bold">
Ödeme tamamlanamadı
</h1>
<p className="mt-2 text-sm text-slate-600">
{durum === "pending" ? (
"Ödemen hâlâ işleniyor. Birkaç saniye sonra bu sayfayı yenile — kartından çekim yapıldıysa krediler mutlaka tanımlanır."
) : (
<>
Çekim yapılmadı ya da işlem iptal edildi. Kartından para
çekildiğini düşünüyorsan{" "}
<Link href="/iletisim" className="underline">
bizimle iletişime geç
</Link>
.
</>
)}
</p>
<PagePixelDivider seed={83} className="mx-auto mt-5" />
<Button asChild variant="outline" className="mt-6 h-11 cursor-pointer">
<Link href="/paket">Tekrar dene</Link>
</Button>
</div>
);
}
// iyzico doğrulaması beklenirken ortalanmış sonuç kartı iskeleti.
export function OdemeSonucKartiSkeleton() {
return (
<div
aria-live="polite"
className="rounded-2xl border border-slate-200 bg-white p-8 shadow-sm"
>
<Skeleton className="mx-auto size-12 rounded-full" />
<Skeleton className="mx-auto mt-5 h-7 w-2/3" />
<Skeleton className="mx-auto mt-3 h-5 w-full" />
<Skeleton className="mx-auto mt-2 h-5 w-3/4" />
<Skeleton className="mx-auto mt-6 h-12 w-full rounded-lg" />
</div>
);
}

View File

@@ -0,0 +1,181 @@
import Image from "next/image";
import Link from "next/link";
import {
ArrowRight,
CheckCircle2,
Coins,
FileText,
ListChecks,
MessageCircleQuestion,
RefreshCcw,
ShieldCheck,
} from "lucide-react";
import { getCurrentUser } from "@/lib/session";
import { URUNLER, DENEME_KREDISI } from "@/lib/credits";
import { Button } from "@/components/ui/button";
import { Parallax } from "@/components/parallax";
import { Skeleton } from "@/components/ui/skeleton";
import { SatinAlForm } from "./satin-al-form";
function tl(kurus: number) {
return (kurus / 100).toLocaleString("tr-TR", { maximumFractionDigits: 0 });
}
const OZELLIKLER = [
{
icon: ListChecks,
text: "Sıralamana özel, gerekçeli 24 tercihlik dengeli liste",
},
{
icon: ShieldCheck,
text: "Her tercih için yerleşme olasılığı ve risk analizi (son 4 yıl trend)",
},
{ icon: FileText, text: "Veliyle paylaşılabilir PDF rapor" },
{ icon: RefreshCcw, text: "2 liste revizyon hakkı" },
{
icon: MessageCircleQuestion,
text: `${URUNLER.paket.credits} Yapay Zeka danışman sorusu (her mesaj 1 kredi)`,
},
] as const;
// Paket + top-up kartları: kullanıcının paket/kredi durumuna göre CTA'lar
// değişir, okuma burada yaşar.
export async function PaketSatinAl() {
const u = await getCurrentUser();
return (
<Parallax
strength={6}
className="mt-10 grid gap-6 sm:grid-cols-[1fr_auto]"
>
{/* Ana paket */}
<section className="rounded-2xl border-2 border-orange-500 bg-white p-8 shadow-sm">
<div className="flex items-baseline justify-between">
<h2 className="font-heading text-xl font-bold">
Tercih Dönemi Paketi
</h2>
<p className="font-heading text-3xl font-bold">
{tl(URUNLER.paket.amountKurus)} TL
</p>
</div>
<ul className="mt-6 space-y-3">
{OZELLIKLER.map((o) => (
<li key={o.text} className="flex items-start gap-3 text-sm">
<o.icon
className="mt-0.5 size-4 shrink-0 text-emerald-600"
aria-hidden
/>
{o.text}
</li>
))}
</ul>
<div className="mt-8">
{u ? (
u.hasPaket ? (
<div className="flex items-center gap-2 rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-medium text-emerald-800">
<CheckCircle2 className="size-4" aria-hidden />
Paketin aktif —{" "}
<Link href="/" className="underline">
listeni oluştur
</Link>
</div>
) : (
<SatinAlForm
urun="paket"
label={`${tl(URUNLER.paket.amountKurus)} TL — Paketi al`}
vurgulu
/>
)
) : (
<div className="space-y-2">
<Button
asChild
className="h-12 w-full cursor-pointer bg-orange-500 px-8 text-white transition-colors duration-200 hover:bg-orange-600"
>
<Link
href={`/giris?callback=${encodeURIComponent("/paket")}`}
>
Giriş yap — sonra {tl(URUNLER.paket.amountKurus)} TL&apos;ye
al
<ArrowRight className="size-4" aria-hidden />
</Link>
</Button>
<p className="text-center text-xs text-slate-500">
Girişte {DENEME_KREDISI} deneme kredisi de tanımlanır.
</p>
</div>
)}
</div>
<Image
src="/odeme/iyzico-logo-band.svg"
alt="iyzico ile Öde — Mastercard, Visa, American Express ve Troy ile güvenli ödeme"
width={429}
height={32}
className="mx-auto mt-5 h-5 w-auto"
/>
<p className="mt-3 text-center text-xs text-slate-500">
iyzico güvenli ödeme · Kart bilgilerin bize hiç ulaşmaz ·{" "}
<Link
href="/kosullar#iade"
className="underline hover:text-slate-700"
>
İade koşulları
</Link>
</p>
</section>
{/* Top-up */}
<section className="flex h-fit flex-col rounded-2xl border border-slate-200 bg-white p-6 shadow-sm sm:w-56">
<div className="flex items-center gap-2">
<Coins className="size-4 text-amber-500" aria-hidden />
<h2 className="font-heading font-bold">Kredi bitti mi?</h2>
</div>
<p className="mt-2 flex-1 text-sm text-slate-600">
+{URUNLER.topup.credits} soru hakkı,{" "}
{tl(URUNLER.topup.amountKurus)} TL.
</p>
<div className="mt-4">
{/* Top-up hasPaket vermez: paketsiz biri alırsa parasını verip
yine maskeli liste görür. Satın alma yalnızca paketliye
açık; paketsize durum dürüstçe açıklanır. */}
{u?.hasPaket ? (
<SatinAlForm
urun="topup"
label={`+${URUNLER.topup.credits} kredi — ${tl(URUNLER.topup.amountKurus)} TL`}
/>
) : u ? (
<p className="rounded-xl border border-slate-200 bg-slate-50 px-3 py-2.5 text-xs leading-5 text-slate-600">
Ek kredi, kilitli analizleri açmaz — o yüzden önce paket
gerekir. Paket zaten {URUNLER.paket.credits} kredi içerir;
bitince buradan yükleyebilirsin.
</p>
) : (
<Button
asChild
variant="outline"
className="h-11 w-full cursor-pointer"
>
<Link
href={`/giris?callback=${encodeURIComponent("/paket")}`}
>
Önce giriş yap
</Link>
</Button>
)}
</div>
</section>
</Parallax>
);
}
export function PaketSatinAlSkeleton() {
return (
<div
aria-live="polite"
className="mt-10 grid gap-6 sm:grid-cols-[1fr_auto]"
>
<Skeleton className="h-80 w-full rounded-2xl" />
<Skeleton className="h-80 w-full rounded-2xl sm:w-64" />
</div>
);
}

View File

@@ -3,8 +3,8 @@
import Link from "next/link";
import { ArrowRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTercihProfili } from "@/components/manuel-liste/profil-kapisi-store";
import { sonucHref } from "@/lib/sihirbaz";
import { useTercihProfili } from "@/features/liste/hooks/use-tercih-profili";
import { sonucHref } from "@/features/sihirbaz/sihirbaz-sabitler";
/**
* Ödeme başarılı ama henüz Yapay Zeka listesi yokken gösterilen CTA: kayıtlı tercih

View File

@@ -5,7 +5,7 @@ import { ArrowRight } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { olay } from "@/lib/analitik";
import { baslatOdeme, type OdemeBaslatDurum } from "./actions";
import { baslatOdeme, type OdemeBaslatDurum } from "../odeme-actions";
export function SatinAlForm({
urun,

View File

@@ -0,0 +1,11 @@
import "server-only";
import { eq } from "drizzle-orm";
import { appDb, schema } from "@/lib/appdb";
// Ödeme feature'ının sorgu kapısı. Sahiplik kontrolü çağıran tarafta —
// sipariş yalnızca kendi kullanıcısına gösterilir.
export function siparisGetir(siparisId: string) {
return appDb.query.orders.findFirst({
where: eq(schema.orders.id, siparisId),
});
}

View File

@@ -1,6 +1,6 @@
"use client";
import { useTercihProfili } from "@/components/manuel-liste/profil-kapisi-store";
import { useTercihProfili } from "@/features/liste/hooks/use-tercih-profili";
// Hero başlığı: sıralama girilmemişse tanıtım metni, kayıtlı profil varsa
// aynı şablonla (vurgulu italik orta parça) aksiyon metnine dönüşür.

View File

@@ -3,8 +3,8 @@
import Link from "next/link";
import { ArrowRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTercihProfili } from "@/components/manuel-liste/profil-kapisi-store";
import { sonucHref } from "@/lib/sihirbaz";
import { useTercihProfili } from "@/features/liste/hooks/use-tercih-profili";
import { sonucHref } from "@/features/sihirbaz/sihirbaz-sabitler";
export function HeroFocusButton() {
function handleClick() {

View File

@@ -1,8 +1,8 @@
// Sihirbaz hunisinin statik anlatımı: interaktif demo yerine akışın tamamını
// tek bakışta gösteren bir mermaid şeması. Çizim, ürün genelindeki ortak
// "el çizimi" bileşeniyle yapılır (bkz. components/el-cizimi-sema.tsx).
// "el çizimi" bileşeniyle yapılır (bkz. features/rehber/components/el-cizimi-sema.tsx).
import { ElCizimiSema } from "@/components/el-cizimi-sema";
import { ElCizimiSema } from "@/features/rehber/components/el-cizimi-sema";
// Renkler ürünün risk dilinden (kırmızı/sarı/yeşil) ve slate paletinden;
// renk tek başına anlam taşımaz, her düğüm metinle etiketlidir.

View File

@@ -0,0 +1,14 @@
import { CheckCircle2, X } from "lucide-react";
// Ana sayfadaki karşılaştırma tablosunun hücresi: metin değeri düz yazılır,
// boolean değer Var/Yok ikonuna çevrilir.
export function ComparisonCell({ value }: { value: boolean | string }) {
if (typeof value === "string") {
return <span className="text-sm font-medium">{value}</span>;
}
return value ? (
<CheckCircle2 className="mx-auto size-5 text-primary" aria-label="Var" />
) : (
<X className="mx-auto size-5 text-muted-foreground/50" aria-label="Yok" />
);
}

View File

@@ -16,9 +16,9 @@ import {
} from "react";
import Link from "next/link";
import { ArrowRight } from "lucide-react";
import { useTercihProfili } from "@/components/manuel-liste/profil-kapisi-store";
import { PUAN_TURU_ETIKET, sonucHref } from "@/lib/sihirbaz";
import type { DilimKey, SihirbazFacetleri } from "@/lib/db";
import { useTercihProfili } from "@/features/liste/hooks/use-tercih-profili";
import { PUAN_TURU_ETIKET, sonucHref } from "@/features/sihirbaz/sihirbaz-sabitler";
import type { DilimKey, SihirbazFacetleri } from "@/types/yokatlas";
export type DemoVeri = {
facetler: SihirbazFacetleri;

View File

@@ -0,0 +1,158 @@
import { Parallax } from "@/components/parallax";
import { Skeleton } from "@/components/ui/skeleton";
import { meraklisinaDemoVerisi } from "../pazarlama-queries";
import {
DemoKapanisCta,
DemoKumanda,
DilimSeridiCanli,
SiralamaDemo,
} from "./meraklisina-demo";
import { HuniSemasi } from "./huni-semasi";
/**
* Algoritmanın öğrenci diline çevrilmiş anlatımı. Teknik detay (SQL, çarpan
* mutfağı) bilerek verilmez; amaç güven: veri gerçek, kurallar şeffaf.
* Dilim şeridi canlıdır: kullanıcının kayıtlı tercih profilindeki sıralama
* okunur, şerit o sıralamanın gerçek sayılarıyla güncellenir (bkz.
* meraklisina-demo.tsx); profil yoksa örnek sıralama gösterilir. Sihirbaz
* hunisi ise statik bir mermaid şemasıyla anlatılır (bkz. huni-semasi.tsx).
* Diyagramlar ürünün risk renk dilini kullanır (yeşil/sarı/kırmızı) ve her
* bölge metinle de etiketlenir — renk tek başına anlam taşımaz.
*/
// Profili olmayan ziyaretçi bu örnek sıralamayı görür; SSR'da gerçek veriyle
// render edilir ki profil olmadan da sayfa anlamlı olsun.
const VARSAYILAN_SIRA = 100_000;
export function MeraklisinaIcerik() {
const baslangicVeri = meraklisinaDemoVerisi(VARSAYILAN_SIRA);
return (
<SiralamaDemo
baslangicSira={VARSAYILAN_SIRA}
baslangicVeri={baslangicVeri}
>
<Parallax
fromStart
strength={4}
className="mt-8 space-y-10 text-sm leading-7 text-slate-700"
>
<section>
<h2 className="font-heading text-lg font-bold text-slate-900">
1. Her şey taban sıralamasıyla başlar
</h2>
<p className="mt-2">
Bir bölümün <strong>taban sıralaması</strong>, geçen yıl o bölüme
yerleşen <em>son</em>{" "}
öğrencinin Türkiye sıralamasıdır. Senin sıralaman bu sayıdan
küçükse (yani daha iyiysen), geçen yılın koşullarında o bölüme
yerleşebilirdin demektir. Sistemde her programın en güncel taban
sıralaması kayıtlıdır; bir bölümün 2025 verisi henüz oluşmamışsa
bir önceki yılınki kullanılır.
</p>
</section>
<DemoKumanda />
<section>
<h2 className="font-heading text-lg font-bold text-slate-900">
2. Programlar üç dilime ayrılır
</h2>
<p className="mt-2">
Sıralamanı girdiğinde tüm programlar senin konumuna göre üç kümeye
ayrılır: tabanı senden <strong>daha iyi</strong> olanlar{" "}
<span className="font-semibold text-red-600">Hayal</span> (şansını
denediğin satırlar), tabanı sıralamana{" "}
<strong>yakın</strong> olanlar{" "}
<span className="font-semibold text-amber-600">Dengeli</span>{" "}
(listenin bel kemiği — sıralamanın yaklaşık yüzde kırk fazlasına
kadar pay bırakılır), tabanı{" "}
<strong>belirgin şekilde altında</strong> kalan her şey ise{" "}
<span className="font-semibold text-emerald-600">Garanti</span>{" "}
dilimidir.
</p>
<DilimSeridiCanli />
<p className="mt-4">
Garanti diliminin bir sonu yok: 87. sıradaki bir adayın da 500
bininci sıradaki bir adayın da ulaşabildiği <em>bütün</em>{" "}
programlar listededir. Liste sana en yakın tabandan başlar; aşağı
indikçe ve &ldquo;Daha fazla göster&rdquo;e bastıkça senden
uzaklaşan seçenekler gelir. Böylece ilk gördüklerin her zaman en
isabetli adaylardır ama hiçbir seçenek senden saklanmaz.
</p>
</section>
<section>
<h2 className="font-heading text-lg font-bold text-slate-900">
3. Risk renkleri her yerde aynı dili konuşur
</h2>
<p className="mt-2">
Tabloda, haritada ve yapay zekâ listesinde gördüğün nokta ve
renkler tek bir kurala bağlıdır:{" "}
<span className="font-semibold text-emerald-600">yeşil</span>{" "}
tabanı sıralamanın belirgin altında (güvenli),{" "}
<span className="font-semibold text-amber-600">sarı</span>{" "}
sıralamana yakın (az riskli),{" "}
<span className="font-semibold text-red-600">kırmızı</span>{" "}
tabanı senden iyi (riskli). Renk hangi sayfada olursa olsun aynı
hesaptan çıkar; bir satır sekme değiştirdi diye renk değiştirmez.
</p>
</section>
<section>
<h2 className="font-heading text-lg font-bold text-slate-900">
4. Sihirbaz süzer, yapay zekâ kurar
</h2>
<p className="mt-2">
&ldquo;Yapay Zeka listemi kur&rdquo; sihirbazındaki üç adım birbirinden
bağımsız değildir. İlk adımda yalnızca{" "}
<strong>sıralamanla ulaşabildiğin</strong>{" "}
alanlar listelenir; ikinci adımdaki iller, seçtiğin alanlardaki
program sayısına göre yeniden hesaplanır; üçüncü adımdaki
devlet/vakıf sayıları da hem alan hem il seçimini hesaba katar.
Adımlar bitince elde kalan gerçek programlar bir{" "}
<strong>aday havuzu</strong>{" "}
olur ve yapay zekâya öyle gider: ona boş bir sayfa değil, bu havuz
verilir. Aşağıdaki şema akışın tamamını gösterir — havuz
24&apos;ün altına düşerse filtrelerin nasıl gevşetildiği, yine de
yetmezse listenin eldeki programlarla, sana açıkça belirtilerek
nasıl kurulduğu da dahil.
</p>
<HuniSemasi />
</section>
<section>
<h2 className="font-heading text-lg font-bold text-slate-900">
5. Dürüst olalım: bu bir tahmin aracıdır
</h2>
<p className="mt-2">
Tüm bu hesaplar <strong>geçen yılların</strong>{" "}
yerleştirme sonuçlarına dayanır. Kontenjanlar, tercih davranışları
ve puanlar her yıl değişir; geçen yıl &ldquo;garanti&rdquo;
görünen bir bölüm bu yıl dolabilir. Bu yüzden satırlarda son beş
yılın taban seyrini de gösteririz — çizgi yukarı tırmanıyorsa
bölüm zorlaşıyor demektir. KolayTercih karar <em>destek</em>{" "}
aracıdır; listenin son hâli ve sorumluluğu her zaman sana aittir.
</p>
</section>
<DemoKapanisCta />
</Parallax>
</SiralamaDemo>
);
}
export function MeraklisinaIcerikSkeleton() {
return (
<div className="mt-8 space-y-10">
{Array.from({ length: 3 }, (_, i) => (
<div key={i}>
<Skeleton className="h-6 w-72" />
<Skeleton className="mt-3 h-4 w-full" />
<Skeleton className="mt-1.5 h-4 w-full" />
<Skeleton className="mt-1.5 h-4 w-2/3" />
</div>
))}
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More