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>
This commit is contained in:
bilalgursen
2026-08-08 01:06:06 +03:00
parent 7518baccfa
commit 820eea7eca
62 changed files with 1318 additions and 215 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 25 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 25 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`.

Binary file not shown.

View File

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

8
pnpm-lock.yaml generated
View File

@@ -83,6 +83,9 @@ importers:
resend: resend:
specifier: ^6.18.0 specifier: ^6.18.0
version: 6.18.0 version: 6.18.0
server-only:
specifier: ^0.0.1
version: 0.0.1
shiki: shiki:
specifier: ^4.3.1 specifier: ^4.3.1
version: 4.3.1 version: 4.3.1
@@ -5133,6 +5136,9 @@ packages:
resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==}
engines: {node: '>= 18'} engines: {node: '>= 18'}
server-only@0.0.1:
resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==}
set-cookie-parser@3.1.2: set-cookie-parser@3.1.2:
resolution: {integrity: sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==} resolution: {integrity: sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==}
@@ -10947,6 +10953,8 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
server-only@0.0.1: {}
set-cookie-parser@3.1.2: {} set-cookie-parser@3.1.2: {}
set-function-length@1.2.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 { existsSync, readFileSync } from "node:fs";
import { z } from "zod"; import { z } from "zod";
import { appDb, schema } from "../src/lib/appdb"; 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 { KATEGORILER, kategoriEslesir } from "../src/lib/kategoriler";
import { havuzuKompaktJson, turLabel } from "../src/lib/rapor-havuzu"; import { havuzuKompaktJson, turLabel } from "../src/lib/rapor-havuzu";
import { GENEL_KATEGORI, KOVALAR, kategoriSlugBul } from "../src/lib/tadimlik-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 { NextResponse, type NextRequest } from "next/server";
import { dilimAra, rankWindowFacets } from "@/lib/db";
import { import {
PUAN_TURLERI, PUAN_TURLERI,
dilimAra,
rankWindowFacets,
type DilimKey, type DilimKey,
type PuanTuruKey, type PuanTuruKey,
} from "@/lib/db"; } from "@/types/yokatlas";
import { KATEGORILER } from "@/lib/kategoriler"; import { KATEGORILER } from "@/lib/kategoriler";
// Sihirbaz modalı için: sıralamaya uygun kategori/il/üniversite tipi // 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 { NextResponse, type NextRequest } from "next/server";
import { dilimAra } from "@/lib/db";
import { import {
PUAN_TURLERI, PUAN_TURLERI,
dilimAra,
type DilimKey, type DilimKey,
type PuanTuruKey, type PuanTuruKey,
} from "@/lib/db"; } from "@/types/yokatlas";
const SAYFA_BOYU = 20; const SAYFA_BOYU = 20;

View File

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

View File

@@ -3,7 +3,7 @@
// batch üretilmiştir — bu uç LLM'e istek atmaz, salt SELECT'tir. // batch üretilmiştir — bu uç LLM'e istek atmaz, salt SELECT'tir.
import { NextResponse } from "next/server"; 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 { KATEGORILER } from "@/lib/kategoriler";
import { kategoriSlugBul, tadimlikSec } from "@/lib/tadimlik-havuzu"; import { kategoriSlugBul, tadimlikSec } from "@/lib/tadimlik-havuzu";

View File

@@ -20,7 +20,7 @@ import { RaporListesi } from "@/components/rapor-listesi";
import { useGorunumOlayi } from "@/components/use-gorunum-olayi"; import { useGorunumOlayi } from "@/components/use-gorunum-olayi";
import { TercihHaritasi } from "@/components/tercih-haritasi"; import { TercihHaritasi } from "@/components/tercih-haritasi";
import { pinleriTuret } from "@/lib/harita-pinler"; import { pinleriTuret } from "@/lib/harita-pinler";
import type { RaporSonuc } from "@/lib/ai/rapor"; import type { RaporSonuc } from "@/features/rapor/types/rapor";
import { SohbetClient, type DanismanOzeti, type Mesaj } from "./sohbet-client"; import { SohbetClient, type DanismanOzeti, type Mesaj } from "./sohbet-client";
import { RevizyonKutusu } from "./revizyon-kutusu"; import { RevizyonKutusu } from "./revizyon-kutusu";

View File

@@ -6,11 +6,12 @@ import { verifySession, getCurrentUser } from "@/lib/session";
import { appDb, schema } from "@/lib/appdb"; import { appDb, schema } from "@/lib/appdb";
import { kullanicininRaporu } from "@/lib/rapor-kaydi"; import { kullanicininRaporu } from "@/lib/rapor-kaydi";
import { URUNLER, MAX_REVIZYON } from "@/lib/credits"; import { URUNLER, MAX_REVIZYON } from "@/lib/credits";
import type { RaporSonuc } from "@/lib/ai/rapor"; import type { RaporSonuc } from "@/features/rapor/types/rapor";
import type { RaporParams } from "@/lib/rapor-havuzu"; import type { RaporParams } from "@/features/rapor/types/rapor";
import { ViewportPortal } from "@/components/viewport-portal"; import { ViewportPortal } from "@/components/viewport-portal";
import { SiteFooter } from "@/components/site-footer"; import { SiteFooter } from "@/components/site-footer";
import { raporMaskele, ACIK_SATIR, type MaskeliRapor } from "@/lib/rapor-maske"; import { raporMaskele, ACIK_SATIR } from "@/lib/rapor-maske";
import type { MaskeliRapor } from "@/features/rapor/types/rapor";
import type { Mesaj } from "./sohbet-client"; import type { Mesaj } from "./sohbet-client";
import { ListemGovde } from "./listem-govde"; import { ListemGovde } from "./listem-govde";
import { ListemBosCta } from "./listem-bos-cta"; import { ListemBosCta } from "./listem-bos-cta";

View File

@@ -18,7 +18,7 @@ import Link from "next/link";
import { ArrowRight } from "lucide-react"; import { ArrowRight } from "lucide-react";
import { useTercihProfili } from "@/components/manuel-liste/profil-kapisi-store"; import { useTercihProfili } from "@/components/manuel-liste/profil-kapisi-store";
import { PUAN_TURU_ETIKET, sonucHref } from "@/lib/sihirbaz"; import { PUAN_TURU_ETIKET, sonucHref } from "@/lib/sihirbaz";
import type { DilimKey, SihirbazFacetleri } from "@/lib/db"; import type { DilimKey, SihirbazFacetleri } from "@/types/yokatlas";
export type DemoVeri = { export type DemoVeri = {
facetler: SihirbazFacetleri; facetler: SihirbazFacetleri;

View File

@@ -2,7 +2,8 @@ import type { Metadata } from "next";
import { Parallax } from "@/components/parallax"; import { Parallax } from "@/components/parallax";
import { PagePixelDivider, SectionEyebrow } from "@/components/pixel-decor"; import { PagePixelDivider, SectionEyebrow } from "@/components/pixel-decor";
import { SiteFooter } from "@/components/site-footer"; import { SiteFooter } from "@/components/site-footer";
import { dilimAra, rankWindowFacets, type DilimKey } from "@/lib/db"; import { dilimAra, rankWindowFacets } from "@/lib/db";
import type { DilimKey } from "@/types/yokatlas";
import { import {
DemoKapanisCta, DemoKapanisCta,
DemoKumanda, DemoKumanda,

View File

@@ -3,9 +3,9 @@ import Image from "next/image";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { verifySession, getCurrentUser } from "@/lib/session"; import { verifySession, getCurrentUser } from "@/lib/session";
import { kullanicininRaporu } from "@/lib/rapor-kaydi"; import { kullanicininRaporu } from "@/lib/rapor-kaydi";
import { PUAN_TURLERI } from "@/lib/db"; import { PUAN_TURLERI } from "@/types/yokatlas";
import type { RaporSonuc } from "@/lib/ai/rapor"; import type { RaporSonuc } from "@/features/rapor/types/rapor";
import type { RaporParams } from "@/lib/rapor-havuzu"; import type { RaporParams } from "@/features/rapor/types/rapor";
import { YazdirButonu } from "./yazdir-butonu"; import { YazdirButonu } from "./yazdir-butonu";
export const metadata: Metadata = { export const metadata: Metadata = {

View File

@@ -4,7 +4,7 @@ import { and, eq, sql } from "drizzle-orm";
import { getSession, getCurrentUser } from "@/lib/session"; import { getSession, getCurrentUser } from "@/lib/session";
import { appDb, schema } from "@/lib/appdb"; import { appDb, schema } from "@/lib/appdb";
import { kullanicininRaporu } from "@/lib/rapor-kaydi"; import { kullanicininRaporu } from "@/lib/rapor-kaydi";
import { PUAN_TURLERI, type PuanTuruKey } from "@/lib/db"; import { PUAN_TURLERI, type PuanTuruKey } from "@/types/yokatlas";
import { import {
spendCredits, spendCredits,
grantCredits, grantCredits,
@@ -16,9 +16,11 @@ import {
raporUret, raporUret,
listeOzetiCikar, listeOzetiCikar,
RaporUretimHatasi, RaporUretimHatasi,
type RaporSonuc,
} from "@/lib/ai/rapor"; } from "@/lib/ai/rapor";
import type { RaporParams } from "@/lib/rapor-havuzu"; import type {
RaporParams,
RaporSonuc,
} from "@/features/rapor/types/rapor";
import { sihirbazDogrula, type SihirbazSecimleri } from "@/lib/sihirbaz"; import { sihirbazDogrula, type SihirbazSecimleri } from "@/lib/sihirbaz";
import { raporMaskele } from "@/lib/rapor-maske"; import { raporMaskele } from "@/lib/rapor-maske";

View File

@@ -1,10 +1,6 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { import { rankWindowFacets, searchByRank } from "@/lib/db";
PUAN_TURLERI, import { PUAN_TURLERI, type PuanTuruKey } from "@/types/yokatlas";
rankWindowFacets,
searchByRank,
type PuanTuruKey,
} from "@/lib/db";
import { getSession } from "@/lib/session"; import { getSession } from "@/lib/session";
import { kullanicininRaporu } from "@/lib/rapor-kaydi"; import { kullanicininRaporu } from "@/lib/rapor-kaydi";
import { tadimlikSec } from "@/lib/tadimlik-havuzu"; import { tadimlikSec } from "@/lib/tadimlik-havuzu";

View File

@@ -10,7 +10,7 @@ import { Button } from "@/components/ui/button";
import { PixelField } from "@/components/pixel-decor"; import { PixelField } from "@/components/pixel-decor";
import { useGorunumOlayi } from "@/components/use-gorunum-olayi"; import { useGorunumOlayi } from "@/components/use-gorunum-olayi";
import { olay } from "@/lib/analitik"; import { olay } from "@/lib/analitik";
import type { SihirbazFacetleri } from "@/lib/db"; import type { SihirbazFacetleri } from "@/types/yokatlas";
import { import {
SIHIRBAZ_AC_EVENT, SIHIRBAZ_AC_EVENT,
SIHIRBAZ_STORAGE_KEY, SIHIRBAZ_STORAGE_KEY,

View File

@@ -7,7 +7,7 @@ import {
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { SihirbazAdimlarLazy } from "@/components/sihirbaz-adimlar-lazy"; import { SihirbazAdimlarLazy } from "@/components/sihirbaz-adimlar-lazy";
import type { SihirbazFacetleri } from "@/lib/db"; import type { SihirbazFacetleri } from "@/types/yokatlas";
import type { SihirbazSecimleri } from "@/lib/sihirbaz"; import type { SihirbazSecimleri } from "@/lib/sihirbaz";
/** /**

View File

@@ -13,7 +13,7 @@ import { UniversiteKonumHaritasi } from "@/components/universite-konum-haritasi"
import { UniversiteProgramTablosu } from "@/components/universite-program-tablosu"; import { UniversiteProgramTablosu } from "@/components/universite-program-tablosu";
import { UniLogo } from "@/components/uni-logo"; import { UniLogo } from "@/components/uni-logo";
import { uniLogoYolu } from "@/lib/uni-logolar"; import { uniLogoYolu } from "@/lib/uni-logolar";
import type { Program } from "@/lib/db"; import type { Program } from "@/types/yokatlas";
const sayi = (n: number) => n.toLocaleString("tr-TR"); const sayi = (n: number) => n.toLocaleString("tr-TR");

View File

@@ -3,7 +3,7 @@
import { useMemo, useRef, useState } from "react"; import { useMemo, useRef, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { ChevronDown, ChevronUp } from "lucide-react"; import { ChevronDown, ChevronUp } from "lucide-react";
import type { Program } from "@/lib/db"; import type { Program } from "@/types/yokatlas";
import { trBaslikDuzeni } from "@/lib/slug"; import { trBaslikDuzeni } from "@/lib/slug";
import { Sayfalama, SAYFA_BOYU } from "@/components/sayfalama"; import { Sayfalama, SAYFA_BOYU } from "@/components/sayfalama";
import { import {

View File

@@ -28,7 +28,7 @@ import {
import { authClient } from "@/lib/auth-client"; import { authClient } from "@/lib/auth-client";
import { olay, siraKovasi } from "@/lib/analitik"; import { olay, siraKovasi } from "@/lib/analitik";
import { useTercihProfili } from "@/components/manuel-liste/profil-kapisi-store"; import { useTercihProfili } from "@/components/manuel-liste/profil-kapisi-store";
import type { SihirbazFacetleri } from "@/lib/db"; import type { SihirbazFacetleri } from "@/types/yokatlas";
import { import {
sonucHref, sonucHref,
tercihProfiliYaz, tercihProfiliYaz,

View File

@@ -22,7 +22,7 @@ import {
profilDuzenlemeyiAc, profilDuzenlemeyiAc,
useTercihProfili, useTercihProfili,
} from "@/components/manuel-liste/profil-kapisi-store"; } from "@/components/manuel-liste/profil-kapisi-store";
import type { SihirbazFacetleri } from "@/lib/db"; import type { SihirbazFacetleri } from "@/types/yokatlas";
import { import {
PUAN_TURU_ETIKET, PUAN_TURU_ETIKET,
sonucHref, sonucHref,

View File

@@ -4,7 +4,7 @@
// Layout'ta bir kez monte edilen TercihProfiliKapisi bu store'a abone olur. // Layout'ta bir kez monte edilen TercihProfiliKapisi bu store'a abone olur.
import { useSyncExternalStore } from "react"; import { useSyncExternalStore } from "react";
import type { Program } from "@/lib/db"; import type { Program } from "@/types/yokatlas";
import { import {
PROFIL_DEGISTI_EVENT, PROFIL_DEGISTI_EVENT,
sonucHref, sonucHref,

View File

@@ -2,7 +2,7 @@
import { Check, Plus } from "lucide-react"; import { Check, Plus } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import type { Program } from "@/lib/db"; import type { Program } from "@/types/yokatlas";
import { olay } from "@/lib/analitik"; import { olay } from "@/lib/analitik";
import { import {
listedeMi, listedeMi,

View File

@@ -6,7 +6,7 @@
// (girişsiz kullanıcı da liste kurabilsin diye sunucuya yazmıyoruz). // (girişsiz kullanıcı da liste kurabilsin diye sunucuya yazmıyoruz).
import { useSyncExternalStore } from "react"; import { useSyncExternalStore } from "react";
import type { Program } from "@/lib/db"; import type { Program } from "@/types/yokatlas";
import { riskHesapla } from "@/lib/risk"; import { riskHesapla } from "@/lib/risk";
import { import {
tercihProfiliOku, tercihProfiliOku,

View File

@@ -26,7 +26,7 @@ import {
SihirbazAdimlarLazy, SihirbazAdimlarLazy,
preloadSihirbazAdimlar, preloadSihirbazAdimlar,
} from "@/components/sihirbaz-adimlar-lazy"; } from "@/components/sihirbaz-adimlar-lazy";
import type { SihirbazFacetleri } from "@/lib/db"; import type { SihirbazFacetleri } from "@/types/yokatlas";
import { olay, siraKovasi } from "@/lib/analitik"; import { olay, siraKovasi } from "@/lib/analitik";
import { import {
tercihProfiliYaz, tercihProfiliYaz,

View File

@@ -1,5 +1,5 @@
import { TrendingDown, TrendingUp } from "lucide-react"; 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 /** 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. */ * rapordaki kaydedilmiş sıra geçmişi (bkz. raporSiraSerisi) bu tipe uyar. */

View File

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

View File

@@ -13,7 +13,7 @@ import {
Scale, Scale,
ShieldCheck, ShieldCheck,
} from "lucide-react"; } 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 { riskHesapla, type RiskSeviyesi } from "@/lib/risk";
import { bolumSayfaSlug, uniSayfaSlug } from "@/lib/slug"; import { bolumSayfaSlug, uniSayfaSlug } from "@/lib/slug";
import { import {

View File

@@ -27,7 +27,7 @@ import {
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { bolumSayfaSlug, uniSayfaSlug } from "@/lib/slug"; import { bolumSayfaSlug, uniSayfaSlug } from "@/lib/slug";
import type { RaporSonuc } from "@/lib/ai/rapor"; import type { RaporSonuc } from "@/features/rapor/types/rapor";
import { import {
RISK_ETIKET, RISK_ETIKET,
dilimdenRisk, dilimdenRisk,

View File

@@ -19,7 +19,7 @@ import { IlSecimHaritasi } from "@/components/il-secim-haritasi";
import { normalizeIlAdi } from "@/lib/harita"; import { normalizeIlAdi } from "@/lib/harita";
import { olay } from "@/lib/analitik"; import { olay } from "@/lib/analitik";
import { trBaslikDuzeni } from "@/lib/slug"; import { trBaslikDuzeni } from "@/lib/slug";
import type { SihirbazFacetleri } from "@/lib/db"; import type { SihirbazFacetleri } from "@/types/yokatlas";
import { import {
ONCELIKLER, ONCELIKLER,
UNIVERSITE_TIPI_ETIKET, UNIVERSITE_TIPI_ETIKET,

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import type { Program } from "@/lib/db"; import type { Program } from "@/types/yokatlas";
import { import {
ProgramSiraGecmisi, ProgramSiraGecmisi,
ProgramTabanTrendi, ProgramTabanTrendi,

View File

@@ -0,0 +1,69 @@
// Rapor domain'inin ortak tipleri — client bileşenleri ve server katmanı
// birlikte tüketir. Üretim mantığı @/lib/ai/rapor.ts'te (server-only) yaşar
// ve bu tiplere karşı derlenir; alan ekleme/çıkarma burada yapılır.
import type { DilimKey, PuanTuruKey, UniturGrubu } from "@/types/yokatlas";
export type RaporTercih = {
programId: string;
sira: number;
dilim: DilimKey;
gerekce: string;
riskNotu: string;
trendOzeti: string;
};
/**
* Listenin havuza uyarlanma bilgisi. uyarlandi=true ise dağılım ideal
* 5/13/6'dan sapmıştır (havuz yetersizdi) ve UI dürüst bir not gösterir.
* Eski kayıtlı raporlarda yok → opsiyonel.
*/
export type RaporKapsam = {
toplam: number;
dagilim: Record<DilimKey, number>;
uyarlandi: boolean;
};
// uyarilar şemada zorunlu ama eski kayıtlı raporlarda yok → tipte opsiyonel
export type RaporSonuc = {
tercihler: RaporTercih[];
genelDegerlendirme: string;
uyarilar?: string[];
kapsam?: RaporKapsam;
// Dev mock üretimi işareti: gerçek AI aktifken bayat mock kayıtları
// ayıklamak için (bkz. src/lib/rapor-kaydi.ts)
devMock?: boolean;
// Render için havuzdan zenginleştirilmiş program bilgisi
// efektifSira: COALESCE(sira2025, sira2024) — eski kayıtlı raporlarda yok
// siraGecmisi: [2021..2025] taban sıralamaları — deterministik trend oku
programlar: Record<
string,
{
isim: string;
universite: string;
il: string | null;
unitur: string | null;
efektifSira?: number | null;
siraGecmisi?: (number | null)[];
}
>;
};
/**
* Maske çıktısı: kilitli uyarıların yalnızca SAYISI iner, metinleri inmez.
* RaporSchema'ya (LLM çıktısı) eklenmez — bu alan yalnızca maskede doğar.
*/
export type MaskeliRapor = RaporSonuc & { kilitliUyariSayisi?: number };
export interface RaporParams {
sira: number;
tur: PuanTuruKey;
// Sihirbaz seçimleri (yeni akış)
kategoriler?: string[];
iller?: string[];
universiteTipi?: UniturGrubu | "farketmez";
oncelikler?: string[];
// Eski akış alanları (geriye uyum — eski kayıtlı raporlar)
il?: string;
notlar?: string;
}

View File

@@ -1,4 +1,10 @@
import "server-only";
import { z } from "zod"; import { z } from "zod";
import type {
RaporKapsam,
RaporParams,
RaporSonuc,
} from "@/features/rapor/types/rapor";
import { anahtarVar } from "./client"; import { anahtarVar } from "./client";
import { yapilandirilmisUret } from "./cagri"; import { yapilandirilmisUret } from "./cagri";
import { import {
@@ -9,27 +15,11 @@ import {
IDEAL_HEDEF, IDEAL_HEDEF,
type DilimHedef, type DilimHedef,
type Dilim, type Dilim,
type RaporParams,
type AdayProgram, type AdayProgram,
} from "../rapor-havuzu"; } from "../rapor-havuzu";
import { secimOzeti } from "../sihirbaz"; import { secimOzeti } from "../sihirbaz";
import { RISK_ETIKET, dilimdenRisk, riskHesapla } from "../risk"; import { RISK_ETIKET, dilimdenRisk, riskHesapla } from "../risk";
const TercihSchema = z.object({
programId: z.string(),
sira: z.number().int(),
dilim: z.enum(["hayal", "dengeli", "garanti"]),
gerekce: z.string(),
riskNotu: z.string(),
trendOzeti: z.string(),
});
const RaporSchema = z.object({
tercihler: z.array(TercihSchema),
genelDegerlendirme: z.string(),
uyarilar: z.array(z.string()).min(2).max(3),
});
// Yapay zekâdan istenen küçük şema (kullanıcı kararı, 6 Ağu 2026): riskNotu // Yapay zekâdan istenen küçük şema (kullanıcı kararı, 6 Ağu 2026): riskNotu
// ve trendOzeti gerçek sıra geçmişinden deterministik üretilir; dilim iskeleti // ve trendOzeti gerçek sıra geçmişinden deterministik üretilir; dilim iskeleti
// ve tercih sırası da koddan gelir. Dilim başına SABİT uzunluklu ayrı listeler // ve tercih sırası da koddan gelir. Dilim başına SABİT uzunluklu ayrı listeler
@@ -63,40 +53,6 @@ const AiRaporGevsek = z.object({
uyarilar: z.array(z.string()), uyarilar: z.array(z.string()),
}); });
/**
* Listenin havuza uyarlanma bilgisi. uyarlandi=true ise dağılım ideal
* 5/13/6'dan sapmıştır (havuz yetersizdi) ve UI dürüst bir not gösterir.
* Eski kayıtlı raporlarda yok → opsiyonel.
*/
export type RaporKapsam = {
toplam: number;
dagilim: Record<Dilim, number>;
uyarlandi: boolean;
};
// uyarilar şemada zorunlu ama eski kayıtlı raporlarda yok → tipte opsiyonel
export type RaporSonuc = Omit<z.infer<typeof RaporSchema>, "uyarilar"> & {
uyarilar?: string[];
kapsam?: RaporKapsam;
// Dev mock üretimi işareti: gerçek AI aktifken bayat mock kayıtları
// ayıklamak için (bkz. src/lib/rapor-kaydi.ts)
devMock?: boolean;
// Render için havuzdan zenginleştirilmiş program bilgisi
// efektifSira: COALESCE(sira2025, sira2024) — eski kayıtlı raporlarda yok
// siraGecmisi: [2021..2025] taban sıralamaları — deterministik trend oku
programlar: Record<
string,
{
isim: string;
universite: string;
il: string | null;
unitur: string | null;
efektifSira?: number | null;
siraGecmisi?: (number | null)[];
}
>;
};
function sistemMetni(hedef: DilimHedef): string { function sistemMetni(hedef: DilimHedef): string {
const adet = (d: Dilim) => const adet = (d: Dilim) =>
hedef[d] === 0 ? "BOŞ liste [] döndür" : `TAM ${hedef[d]} program`; hedef[d] === 0 ? "BOŞ liste [] döndür" : `TAM ${hedef[d]} program`;

View File

@@ -1,3 +1,4 @@
import "server-only";
/** /**
* Rybbit'e sunucudan olay gönderimi (POST /api/track). Yalnızca sunucu * Rybbit'e sunucudan olay gönderimi (POST /api/track). Yalnızca sunucu
* tarafından çağrılır (lib/odeme.ts) — anahtar NEXT_PUBLIC_ değil. * tarafından çağrılır (lib/odeme.ts) — anahtar NEXT_PUBLIC_ değil.
@@ -50,4 +51,4 @@ export async function sunucuOlayi(
} catch { } catch {
// Yut: analitik hatası ödeme sonucunu etkilemez // Yut: analitik hatası ödeme sonucunu etkilemez
} }
} }

View File

@@ -1,3 +1,4 @@
import "server-only";
import { betterAuth } from "better-auth"; import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { magicLink } from "better-auth/plugins"; import { magicLink } from "better-auth/plugins";
@@ -97,4 +98,4 @@ export const auth = betterAuth({
}, },
}); });
export type Session = typeof auth.$Infer.Session; export type Session = typeof auth.$Infer.Session;

View File

@@ -1,45 +1,18 @@
// server-only guard'ı bilinçli yok: scripts/tadimlik-uret.ts bu modülü düz
// node (tsx) altında import ediyor; server-only paketi orada fırlar.
import path from "node:path"; import path from "node:path";
import Database from "better-sqlite3"; import Database from "better-sqlite3";
import { KATEGORILER, kategoriBul } from "./kategoriler"; import { KATEGORILER, kategoriBul } from "./kategoriler";
import {
export type Program = { PUAN_TURLERI,
id: string; type DilimKey,
isim: string; type Program,
universite: string; type ProgramNetSatiri,
unitur: string | null; type PuanTuruKey,
il: string | null; type RankResults,
fakulte: string | null; type SihirbazFacetleri,
tur: string; type UniturGrubu,
sure: number | null; } from "@/types/yokatlas";
sira2025: number | null;
sira2024: number | null;
sira2023: number | null;
sira2022: number | null;
sira2021: number | null;
puan2025: number | null;
kontenjan2025: number | null;
yerlesen2025: number | null;
};
export const PUAN_TURLERI = {
say: "SAYISAL",
ea: "EŞİT AĞIRLIK",
soz: "SÖZEL",
dil: "DİL",
tyt: "TYT",
} as const;
export type PuanTuruKey = keyof typeof PUAN_TURLERI;
export type DilimKey = "hayal" | "dengeli" | "garanti";
export type RankResults = {
hayal: Program[];
dengeli: Program[];
garanti: Program[];
/** Dilim başına pencereye düşen TOPLAM program sayısı (limit'ten bağımsız) */
toplam: Record<DilimKey, number>;
};
// Dev'de hot-reload başına yeni bağlantıılmasın diye global cache // Dev'de hot-reload başına yeni bağlantıılmasın diye global cache
const globalForDb = globalThis as unknown as { yokatlasDb?: Database.Database }; const globalForDb = globalThis as unknown as { yokatlasDb?: Database.Database };
@@ -67,10 +40,6 @@ export const EFEKTIF_SIRA = "COALESCE(sira2025, sira2024)";
* sıralamasından küçükse geçen yıl oraya yerleşmek daha iyi dereceyle mümkündü * sıralamasından küçükse geçen yıl oraya yerleşmek daha iyi dereceyle mümkündü
* demektir (hayal), büyükse daha güvenli demektir (garanti). * demektir (hayal), büyükse daha güvenli demektir (garanti).
*/ */
export type UniturGrubu = "devlet" | "vakif";
// Vakıf sayılan unitur değerleri (bkz. DISTINCT unitur: VAKIF, VAKIF MYO,
// YURTDISI VAKIF). Devlet dışı her şey "vakıf" grubuna girer.
function uniturFiltreSql(grup?: UniturGrubu): { sql: string; args: string[] } { function uniturFiltreSql(grup?: UniturGrubu): { sql: string; args: string[] } {
if (grup === "devlet") return { sql: "AND unitur = ?", args: ["DEVLET"] }; if (grup === "devlet") return { sql: "AND unitur = ?", args: ["DEVLET"] };
if (grup === "vakif") if (grup === "vakif")
@@ -189,30 +158,6 @@ export function searchByRank(
}; };
} }
/** netler tablosunun bir yılı: yerleşen son kişinin ders bazlı netleri. */
export type ProgramNetSatiri = {
yil: number;
puanTuru: string | null;
tabanPuan: number | null;
obp: number | null;
tytTrkNet: number | null;
tytSosNet: number | null;
tytMatNet: number | null;
tytFenNet: number | null;
aytMatNet: number | null;
aytFizNet: number | null;
aytKimNet: number | null;
aytBioNet: number | null;
aytTdeNet: number | null;
aytTrh1Net: number | null;
aytCog1Net: number | null;
aytTrh2Net: number | null;
aytCog2Net: number | null;
aytDinNet: number | null;
aytFelNet: number | null;
ydtYdilNet: number | null;
};
/** /**
* Programa yerleşen son kişinin yıl bazlı netleri, yeniden eskiye sıralı. * Programa yerleşen son kişinin yıl bazlı netleri, yeniden eskiye sıralı.
* Veri scripts/detay.ts ile YÖK Atlas /api/netler/search'ten doldurulur. * Veri scripts/detay.ts ile YÖK Atlas /api/netler/search'ten doldurulur.
@@ -230,14 +175,6 @@ export function netlerGetir(programId: string): ProgramNetSatiri[] {
.all(programId) as ProgramNetSatiri[]; .all(programId) as ProgramNetSatiri[];
} }
export type SihirbazFacetleri = {
kategoriler: { ad: string; adet: number }[];
iller: { il: string; adet: number }[];
uniturler: { grup: UniturGrubu; adet: number }[];
/** Penceredeki (filtre uygulanmamış) toplam program sayısı */
toplam: number;
};
// Sihirbaz bubble'ları için erişilebilir program penceresi: hayal için // Sihirbaz bubble'ları için erişilebilir program penceresi: hayal için
// gerçekçi alt sınırdan (sira*0.5) itibaren AÇIK uçlu — garanti tarafı artık // gerçekçi alt sınırdan (sira*0.5) itibaren AÇIK uçlu — garanti tarafı artık
// sınırsız olduğundan üst kesim yok. Böylece 500B sıradaki aday Tıp bubble'ı // sınırsız olduğundan üst kesim yok. Böylece 500B sıradaki aday Tıp bubble'ı

View File

@@ -1,3 +1,4 @@
import "server-only";
import type { EpostaIcerik } from "./eposta"; import type { EpostaIcerik } from "./eposta";
/** /**
@@ -33,4 +34,4 @@ export async function epostaGonder(
console.error(`[eposta] Gönderilemedi (${to}):`, error); console.error(`[eposta] Gönderilemedi (${to}):`, error);
throw new Error(`Resend: ${error.name}${error.message}`); throw new Error(`Resend: ${error.name}${error.message}`);
} }
} }

View File

@@ -1,3 +1,4 @@
import "server-only";
/** /**
* E-posta şablonları. * E-posta şablonları.
* *
@@ -202,4 +203,4 @@ export function krediHatirlatmaEpostasi(): EpostaIcerik {
"yükleyebilirsin — listen ve sohbet geçmişin olduğu gibi durur.", "yükleyebilirsin — listen ve sohbet geçmişin olduğu gibi durur.",
].join("\n"), ].join("\n"),
}; };
} }

View File

@@ -1,6 +1,6 @@
// Rapordan Türkiye haritası pinleri türetir — /sonuc ve /listem ortak kullanır. // Rapordan Türkiye haritası pinleri türetir — /sonuc ve /listem ortak kullanır.
import type { RaporSonuc } from "@/lib/ai/rapor"; import type { RaporSonuc } from "@/features/rapor/types/rapor";
import type { HaritaPin } from "@/components/tercih-haritasi"; import type { HaritaPin } from "@/components/tercih-haritasi";
import { ilKonum, uniAdiNormalize, uniKonum } from "@/lib/harita"; import { ilKonum, uniAdiNormalize, uniKonum } from "@/lib/harita";
import { dilimdenRisk, riskHesapla, type RiskSeviyesi } from "@/lib/risk"; import { dilimdenRisk, riskHesapla, type RiskSeviyesi } from "@/lib/risk";

View File

@@ -1,3 +1,4 @@
import "server-only";
import Iyzipay from "iyzipay"; import Iyzipay from "iyzipay";
// iyzipay CJS + callback tabanlı; burada promisify'lı ince bir katman var. // iyzipay CJS + callback tabanlı; burada promisify'lı ince bir katman var.
@@ -109,4 +110,4 @@ export function retrieveCheckoutForm(
}, },
); );
}); });
} }

View File

@@ -1,9 +1,6 @@
import { import "server-only";
getDb, import { getDb, SELECT_COLS, EFEKTIF_SIRA } from "./db";
SELECT_COLS, import type { Program } from "@/types/yokatlas";
EFEKTIF_SIRA,
type Program,
} from "./db";
import { kategoriBul, type Kategori } from "./kategoriler"; import { kategoriBul, type Kategori } from "./kategoriler";
import { uniAdiNormalize } from "./harita"; import { uniAdiNormalize } from "./harita";
import { bolumBazAdi, turkishSlugify, trBaslikDuzeni } from "./slug"; import { bolumBazAdi, turkishSlugify, trBaslikDuzeni } from "./slug";

View File

@@ -1,3 +1,4 @@
import "server-only";
import { and, eq, isNull, lt, lte } from "drizzle-orm"; import { and, eq, isNull, lt, lte } from "drizzle-orm";
import { appDb, schema } from "./appdb"; import { appDb, schema } from "./appdb";
import { RAPOR_KREDI } from "./credits"; import { RAPOR_KREDI } from "./credits";
@@ -84,4 +85,4 @@ export function krediHatirlatmaBaslat(): void {
// gecikmeyle açılış taraması da yapılır. // gecikmeyle açılış taraması da yapılır.
setTimeout(tik, 60_000); setTimeout(tik, 60_000);
setInterval(tik, ARALIK_MS); setInterval(tik, ARALIK_MS);
} }

View File

@@ -1,3 +1,4 @@
import "server-only";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import { appDb, schema } from "./appdb"; import { appDb, schema } from "./appdb";
import { grantCredits, URUNLER } from "./credits"; import { grantCredits, URUNLER } from "./credits";
@@ -73,4 +74,4 @@ export async function odemeyiSonuclandir(
return "paid"; return "paid";
} }
export { URUNLER }; export { URUNLER };

View File

@@ -1,10 +1,12 @@
// server-only guard'ı bilinçli yok: scripts/tadimlik-uret.ts bu modülü düz
// node (tsx) altında import ediyor; server-only paketi orada fırlar.
import { searchByRank } from "./db";
import { import {
searchByRank, PUAN_TURLERI,
type Program, type Program,
type PuanTuruKey, type PuanTuruKey,
type UniturGrubu, } from "@/types/yokatlas";
PUAN_TURLERI, import type { RaporParams } from "@/features/rapor/types/rapor";
} from "./db";
import { kategoriEslesir } from "./kategoriler"; import { kategoriEslesir } from "./kategoriler";
export type Dilim = "hayal" | "dengeli" | "garanti"; export type Dilim = "hayal" | "dengeli" | "garanti";
@@ -13,19 +15,6 @@ export interface AdayProgram extends Program {
dilim: Dilim; dilim: Dilim;
} }
export interface RaporParams {
sira: number;
tur: PuanTuruKey;
// Sihirbaz seçimleri (yeni akış)
kategoriler?: string[];
iller?: string[];
universiteTipi?: UniturGrubu | "farketmez";
oncelikler?: string[];
// Eski akış alanları (geriye uyum — eski kayıtlı raporlar)
il?: string;
notlar?: string;
}
export interface HavuzSonuc { export interface HavuzSonuc {
havuz: AdayProgram[]; havuz: AdayProgram[];
// Havuz 24'e ulaşmak için hangi filtreler gevşetildi // Havuz 24'e ulaşmak için hangi filtreler gevşetildi

View File

@@ -1,7 +1,8 @@
import "server-only";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { appDb, schema } from "@/lib/appdb"; import { appDb, schema } from "@/lib/appdb";
import { anahtarVar } from "@/lib/ai/client"; import { anahtarVar } from "@/lib/ai/client";
import type { RaporSonuc } from "@/lib/ai/rapor"; import type { RaporSonuc } from "@/features/rapor/types/rapor";
// Kullanıcının kayıtlı raporunu okuyan TEK kapı. Rapor tablosunu okuyan her // Kullanıcının kayıtlı raporunu okuyan TEK kapı. Rapor tablosunu okuyan her
// yer (sayfalar, action'lar, soru API'si) buradan geçmeli; doğrudan // yer (sayfalar, action'lar, soru API'si) buradan geçmeli; doğrudan
@@ -31,4 +32,4 @@ export async function kullanicininRaporu(userId: string) {
}); });
if (satir?.result && bayatMockMu(satir.result as RaporSonuc)) return null; if (satir?.result && bayatMockMu(satir.result as RaporSonuc)) return null;
return satir ?? null; return satir ?? null;
} }

View File

@@ -3,16 +3,14 @@
// Yalnızca Yapay Zeka'nın ürettiği kişisel gerekçe/risk/trend yorumu maskelenir; // Yalnızca Yapay Zeka'nın ürettiği kişisel gerekçe/risk/trend yorumu maskelenir;
// böylece istemciye ücretli analiz metni hiç inmez. // böylece istemciye ücretli analiz metni hiç inmez.
import type { RaporSonuc } from "./ai/rapor"; import "server-only";
import type {
MaskeliRapor,
RaporSonuc,
} from "@/features/rapor/types/rapor";
export const ACIK_SATIR = 3; export const ACIK_SATIR = 3;
/**
* Maske çıktısı: kilitli uyarıların yalnızca SAYISI iner, metinleri inmez.
* RaporSchema'ya (LLM çıktısı) eklenmez — bu alan yalnızca maskede doğar.
*/
export type MaskeliRapor = RaporSonuc & { kilitliUyariSayisi?: number };
const SAHTE_GEREKCE = const SAHTE_GEREKCE =
"Bu tercih için kişisel gerekçe paketle açılır."; "Bu tercih için kişisel gerekçe paketle açılır.";
const SAHTE_RISK = const SAHTE_RISK =

View File

@@ -1,3 +1,4 @@
import "server-only";
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { marked } from "marked"; import { marked } from "marked";
@@ -148,4 +149,4 @@ export function getAllRehberler(): RehberOzet[] {
tarih, tarih,
})) }))
.sort((a, b) => b.tarih.localeCompare(a.tarih)); .sort((a, b) => b.tarih.localeCompare(a.tarih));
} }

View File

@@ -1,3 +1,4 @@
import "server-only";
import { cache } from "react"; import { cache } from "react";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
@@ -32,4 +33,4 @@ export const getCurrentUser = cache(async () => {
.where(eq(schema.user.id, session.user.id)) .where(eq(schema.user.id, session.user.id))
.limit(1); .limit(1);
return rows[0] ?? null; return rows[0] ?? null;
}); });

View File

@@ -3,7 +3,7 @@
// öncelikler statik kalır (veriye bağlı değil, tercih niyeti). // öncelikler statik kalır (veriye bağlı değil, tercih niyeti).
import { KATEGORILER } from "./kategoriler"; import { KATEGORILER } from "./kategoriler";
import type { UniturGrubu } from "./db"; import type { UniturGrubu } from "@/types/yokatlas";
export const ONCELIKLER = [ export const ONCELIKLER = [
"İş garantisi", "İş garantisi",

View File

@@ -1,3 +1,4 @@
import "server-only";
import { count } from "drizzle-orm"; import { count } from "drizzle-orm";
import { connection } from "next/server"; import { connection } from "next/server";
import { appDb, schema } from "@/lib/appdb"; import { appDb, schema } from "@/lib/appdb";
@@ -107,4 +108,4 @@ export async function sosyalKanitVerisi(): Promise<SosyalKanitVerisi> {
anlikZiyaretci(), anlikZiyaretci(),
]); ]);
return { liste, canli }; return { liste, canli };
} }

View File

@@ -5,9 +5,9 @@
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import { appDb, schema } from "./appdb"; import { appDb, schema } from "./appdb";
import { turkishSlugify } from "./slug"; import { turkishSlugify } from "./slug";
import type { PuanTuruKey } from "./db"; import type { PuanTuruKey } from "@/types/yokatlas";
import type { Dilim } from "./rapor-havuzu"; import type { Dilim } from "./rapor-havuzu";
import type { RaporSonuc } from "./ai/rapor"; import type { RaporSonuc } from "@/features/rapor/types/rapor";
/** Sihirbaz kategorisi seçilmemiş kullanıcı için kova×tür başına fallback satır. */ /** Sihirbaz kategorisi seçilmemiş kullanıcı için kova×tür başına fallback satır. */
export const GENEL_KATEGORI = "genel"; export const GENEL_KATEGORI = "genel";

78
src/types/yokatlas.ts Normal file
View File

@@ -0,0 +1,78 @@
// YÖK Atlas veri tipleri ve puan türü sabiti — client ve server ortak tüketir.
// Sorgu fonksiyonları @/lib/db'de (server-only) yaşar; buraya runtime bağımlılığı
// olan hiçbir şey eklenmez.
export type Program = {
id: string;
isim: string;
universite: string;
unitur: string | null;
il: string | null;
fakulte: string | null;
tur: string;
sure: number | null;
sira2025: number | null;
sira2024: number | null;
sira2023: number | null;
sira2022: number | null;
sira2021: number | null;
puan2025: number | null;
kontenjan2025: number | null;
yerlesen2025: number | null;
};
export const PUAN_TURLERI = {
say: "SAYISAL",
ea: "EŞİT AĞIRLIK",
soz: "SÖZEL",
dil: "DİL",
tyt: "TYT",
} as const;
export type PuanTuruKey = keyof typeof PUAN_TURLERI;
export type DilimKey = "hayal" | "dengeli" | "garanti";
export type RankResults = {
hayal: Program[];
dengeli: Program[];
garanti: Program[];
/** Dilim başına pencereye düşen TOPLAM program sayısı (limit'ten bağımsız) */
toplam: Record<DilimKey, number>;
};
// Vakıf sayılan unitur değerleri (bkz. DISTINCT unitur: VAKIF, VAKIF MYO,
// YURTDISI VAKIF). Devlet dışı her şey "vakıf" grubuna girer.
export type UniturGrubu = "devlet" | "vakif";
/** netler tablosunun bir yılı: yerleşen son kişinin ders bazlı netleri. */
export type ProgramNetSatiri = {
yil: number;
puanTuru: string | null;
tabanPuan: number | null;
obp: number | null;
tytTrkNet: number | null;
tytSosNet: number | null;
tytMatNet: number | null;
tytFenNet: number | null;
aytMatNet: number | null;
aytFizNet: number | null;
aytKimNet: number | null;
aytBioNet: number | null;
aytTdeNet: number | null;
aytTrh1Net: number | null;
aytCog1Net: number | null;
aytTrh2Net: number | null;
aytCog2Net: number | null;
aytDinNet: number | null;
aytFelNet: number | null;
ydtYdilNet: number | null;
};
export type SihirbazFacetleri = {
kategoriler: { ad: string; adet: number }[];
iller: { il: string; adet: number }[];
uniturler: { grup: UniturGrubu; adet: number }[];
/** Penceredeki (filtre uygulanmamış) toplam program sayısı */
toplam: number;
};