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:
101
.claude/skills/nextjs-app-architecture/SKILL.md
Normal file
101
.claude/skills/nextjs-app-architecture/SKILL.md
Normal 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.
|
||||||
20
.claude/skills/nextjs-app-architecture/metadata.json
Normal file
20
.claude/skills/nextjs-app-architecture/metadata.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
214
.claude/skills/nextjs-app-architecture/references/components.md
Normal file
214
.claude/skills/nextjs-app-architecture/references/components.md
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
# Components
|
||||||
|
|
||||||
|
How to build server and client components inside a feature folder.
|
||||||
|
|
||||||
|
## Default: async server component
|
||||||
|
|
||||||
|
Server components await their own queries directly — no `useEffect`, no client-side fetching, no manual loading state. See the [Server Components docs](https://preview.nextjs.org/docs/app/getting-started/server-and-client-components) for the model.
|
||||||
|
|
||||||
|
Prefer minimal, stable props: IDs, slugs, handles, parsed filters, or records the parent already fetched. Do not pass raw route `params` or `searchParams` into feature components. Pages resolve those promises and pass plain values.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// features/notifications/components/notifications-badge.tsx
|
||||||
|
import { getUnreadNotificationCount } from "@/features/notifications/notifications-queries";
|
||||||
|
|
||||||
|
export async function NotificationsBadge() {
|
||||||
|
const count = await getUnreadNotificationCount();
|
||||||
|
if (count === 0) return null;
|
||||||
|
return <span aria-label={`${count} unread`}>{count}</span>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The page (not this file) wraps it in `<Suspense fallback={<NotificationsBadgeSkeleton />}>` — see `references/pages-suspense.md`.
|
||||||
|
|
||||||
|
For parameterized routes, the page resolves `params` and the feature receives an ID:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/post/[id]/page.tsx
|
||||||
|
<Suspense fallback={<PostDetailSkeleton />}>
|
||||||
|
{params.then(({ id }) => (
|
||||||
|
<PostDetail id={id} />
|
||||||
|
))}
|
||||||
|
</Suspense>
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// features/post/components/post-detail.tsx
|
||||||
|
export async function PostDetail({ id }: { id: string }) {
|
||||||
|
const post = await getPost(id);
|
||||||
|
return <article>{post.body}</article>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Skeletons live in the same file
|
||||||
|
|
||||||
|
Export the main component and its skeleton from the same file. Pages import both. Define the skeleton **at the end of the file**, below the real component(s) — never above. Function declarations are hoisted, so a skeleton referenced by a component earlier in the file still works when defined last.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export async function Feed({ userId }: { userId: string }) {
|
||||||
|
const posts = await getFeed(userId);
|
||||||
|
return (
|
||||||
|
<ul>
|
||||||
|
{posts.map((p) => (
|
||||||
|
<Post key={p.id} post={p} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FeedSkeleton() {
|
||||||
|
return (
|
||||||
|
<ul>
|
||||||
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<li key={i}>
|
||||||
|
<Skeleton className="h-24" />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Don't export a second skeleton whose whole job is to rename or preconfigure another skeleton:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Wrong — alias wrapper adds an import surface but no behavior
|
||||||
|
export function CompactGridSkeleton() {
|
||||||
|
return <GridSkeleton dense />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Import the real skeleton and pass the prop inline at the `<Suspense>` boundary: `fallback={<GridSkeleton dense />}`.
|
||||||
|
|
||||||
|
### Skeleton design checklist
|
||||||
|
|
||||||
|
1. Match the real component's layout: flex direction, gaps, padding, breakpoints.
|
||||||
|
2. Include all structural elements: avatar circles, action button placeholders, image squares.
|
||||||
|
3. Responsive visibility must match (`hidden sm:block` in the real component → same in the skeleton).
|
||||||
|
4. Show 2–5 placeholders for variable-length lists, not the real count.
|
||||||
|
5. Don't include skeletons for inner Suspense content — those have their own boundaries.
|
||||||
|
6. Reserve the right height. CLS comes from skeletons that are shorter than the real content.
|
||||||
|
|
||||||
|
## Group related components in one file
|
||||||
|
|
||||||
|
A card and its grid live in the same file. For example, `genre-card.tsx` exports `GenrePill`, `GenreCard`, `GenreGrid`, `GenreGridSkeleton`. Variants should reuse that skeleton inline instead of exporting alias skeletons. Don't split shared UI primitives prematurely — wait until three call sites need the same shape before extracting.
|
||||||
|
|
||||||
|
Two sidebar widgets that happen to look similar but render different data shapes are **not** the same component. The visuals diverge as soon as one needs an extra slot.
|
||||||
|
|
||||||
|
### Single-use sub-components stay inlined
|
||||||
|
|
||||||
|
For a metadata strip inside one card, a header used only by one detail view, a list item only rendered by its list — inline them as **non-exported** functions in the same file:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export async function EventDetails({ slug }: { slug: string }) {
|
||||||
|
const event = await getEventBySlug(slug);
|
||||||
|
return (
|
||||||
|
<article>
|
||||||
|
<MetaStrip event={event} />
|
||||||
|
<Speaker speaker={event.speaker} />
|
||||||
|
<p>{event.description}</p>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetaStrip({ event }: { event: Event }) { ... }
|
||||||
|
function Speaker({ speaker }: { speaker: string }) { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
Exports are for things other files will import. Internal structure is for readability inside one file.
|
||||||
|
|
||||||
|
## The server/client boundary
|
||||||
|
|
||||||
|
`'use client'` only when you need:
|
||||||
|
|
||||||
|
- Hooks (`useState`, `useReducer`, `useOptimistic`, `useTransition`, `useEffect`)
|
||||||
|
- Event handlers (`onClick`, `onChange`, `onSubmit`)
|
||||||
|
- Browser APIs (`window`, `localStorage`, refs to DOM)
|
||||||
|
|
||||||
|
If the component needs interactive pieces, keep the server component as the parent and render client leaves:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
async function PostDetail({ id }: { id: string }) {
|
||||||
|
const [post, userState] = await Promise.all([
|
||||||
|
getPost(id),
|
||||||
|
getPostUserState(id),
|
||||||
|
]);
|
||||||
|
return (
|
||||||
|
<article>
|
||||||
|
<PostBody body={post.body} />
|
||||||
|
<PostActions userState={userState} /> {/* 'use client' leaf */}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Server content as children of client components
|
||||||
|
|
||||||
|
Composition crosses the boundary. A client component can accept server-rendered JSX as children or props:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<ComposerForm
|
||||||
|
avatar={
|
||||||
|
<Suspense fallback={<AvatarSkeleton />}>
|
||||||
|
<CurrentUserAvatar />
|
||||||
|
</Suspense>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
`ComposerForm` is `'use client'`. It doesn't know where the avatar JSX came from. The Suspense boundary streams the avatar in without the form re-rendering.
|
||||||
|
|
||||||
|
### Pass server children resolved values, not promises
|
||||||
|
|
||||||
|
Prefer passing plain values (strings, IDs, resolved data) to a server child. A server component _can_ `await` a promise prop, but resolve route promises in the page instead — pass an unresolved promise down only to a _client_ component that reads it with `use()` (see below). When a parent already has the data from its own query, pass it as a prop instead of having the child refetch.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Right — parent fetches the list, passes each item
|
||||||
|
async function Feed({ userId }: { userId: string }) {
|
||||||
|
const posts = await getFeed(userId);
|
||||||
|
return posts.map((post) => <Post key={post.id} post={post} />);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function Post({ post }: { post: Post }) {
|
||||||
|
return <article>{post.body}</article>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Wrong — child refetches what the parent already had
|
||||||
|
async function Post({ id }: { id: string }) {
|
||||||
|
const post = await getPost(id);
|
||||||
|
return <article>{post.body}</article>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Client components that own their loading state
|
||||||
|
|
||||||
|
When a client component needs server data but should manage its own loading (a sidebar badge, a popover that opens on hover), pass an **unresolved promise** from the server and resolve it with [`use()`](https://react.dev/reference/react/use) on the client. Wrap the consumer in `<Suspense>`.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// page: pass the unresolved promise, wrap in Suspense
|
||||||
|
<Suspense fallback={<TagListSkeleton />}>
|
||||||
|
<TagPicker itemsPromise={getTags()} />
|
||||||
|
</Suspense>
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
"use client";
|
||||||
|
import { use } from "react";
|
||||||
|
|
||||||
|
export function TagPicker({ itemsPromise }: { itemsPromise: Promise<Tag[]> }) {
|
||||||
|
const items = use(itemsPromise);
|
||||||
|
// render interactive UI from items
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The opinionated bit: name promise props with a `Promise` suffix (`itemsPromise`, `userPromise`) so the contract is obvious at the call site.
|
||||||
|
|
||||||
|
### Client data libraries (SWR, TanStack Query)
|
||||||
|
|
||||||
|
Follow `references/single-page-applications.md` when a feature uses a browser data cache or needs externally authored updates. It covers when to use a library, where its files live, server seeding, Cache Components coordination, hydration, and mutations.
|
||||||
|
|
||||||
|
## Mutations
|
||||||
|
|
||||||
|
For client-side reactions to a server mutation (instant feedback, pending state, success/error toasts), see `references/ux-patterns.md`. To cache rendered output across requests, see `references/cache-components.md`.
|
||||||
34
.claude/skills/nextjs-app-architecture/references/example.md
Normal file
34
.claude/skills/nextjs-app-architecture/references/example.md
Normal 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.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
# Pages and Suspense
|
||||||
|
|
||||||
|
How to compose pages, place Suspense boundaries, and prevent layout shift.
|
||||||
|
|
||||||
|
## Pages are composition only
|
||||||
|
|
||||||
|
Pages in `app/` import feature components and place `<Suspense>` boundaries. They never:
|
||||||
|
|
||||||
|
- Fetch data directly (queries live in feature folders)
|
||||||
|
- Define new components except thin transition wrappers (e.g. `<ViewTransition>`)
|
||||||
|
- Inline route-specific UI (extract it into the feature folder)
|
||||||
|
- Pass raw `params` / `searchParams` to features
|
||||||
|
|
||||||
|
## Page function signatures
|
||||||
|
|
||||||
|
Type page and layout functions with the auto-generated `PageProps<'/route'>` / `LayoutProps<'/route'>` helpers — no import, regenerated on `next dev` / `next build` / `next typegen`. See [route type helpers](https://preview.nextjs.org/docs/app/api-reference/config/typescript#route-type-helpers).
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export default function PostPage({ params }: PageProps<'/post/[id]'>) { /* ... */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
Don't hand-write `{ params: Promise<{ id: string }> }` — the generated types stay in sync with the route (catch-all, optional segments). Route handlers use `RouteContext<'/api/...'>`. `typedRoutes: true` is a *separate* feature (statically-typed `href`s), not the source of these helpers.
|
||||||
|
|
||||||
|
## Keep pages synchronous
|
||||||
|
|
||||||
|
Use `params.then()` instead of `await params`. Content above the `.then()` pre-renders into the static shell; content inside it suspends.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Suspense } from 'react';
|
||||||
|
import { PostDetail, PostDetailSkeleton } from '@/features/post/components/post-detail';
|
||||||
|
|
||||||
|
export default function PostPage({ params }: PageProps<'/post/[id]'>) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>Post</h1>
|
||||||
|
<Suspense fallback={<PostDetailSkeleton />}>
|
||||||
|
{params.then(({ id }) => (
|
||||||
|
<PostDetail id={id} />
|
||||||
|
))}
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `<h1>` sits **above** the `params.then()` so it paints instantly. The `Suspense` fallback covers only the dynamic section.
|
||||||
|
|
||||||
|
Resolve route props to plain values at this boundary. Feature components receive `id`, `slug`, `query`, or parsed filter values — not `params`, `searchParams`, or unresolved server promises.
|
||||||
|
|
||||||
|
### Implicit return inside `.then()`
|
||||||
|
|
||||||
|
Use an implicit-return arrow function when the callback just renders JSX — e.g. `({ id }) => <PostDetail id={id} />`. Only switch to a block body with `return` when you need to do work first (destructure with defaults, parse a `searchParams` value, branch on a condition). This keeps the JSX-in-page shape readable and matches how the resolved tree will look.
|
||||||
|
|
||||||
|
### `searchParams` and combined params
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// searchParams only
|
||||||
|
export default function SearchPage({ searchParams }: PageProps<'/search'>) {
|
||||||
|
return searchParams.then(sp => {
|
||||||
|
const q = typeof sp.q === 'string' ? sp.q : '';
|
||||||
|
return q ? <SearchResults query={q} /> : <EmptyState />;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both params and searchParams
|
||||||
|
export default function ProfilePage({ params, searchParams }: PageProps<'/u/[handle]'>) {
|
||||||
|
return Promise.all([params, searchParams]).then(([{ handle }, sp]) => (
|
||||||
|
<ProfileFeed handle={handle} tab={parseTab(sp.tab)} />
|
||||||
|
));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Metadata, static params, and `notFound()`
|
||||||
|
|
||||||
|
- [`generateMetadata`](https://preview.nextjs.org/docs/app/api-reference/functions/generate-metadata) runs before render, so `await params` is fine there — it's a separate async function, not the page body, so it doesn't make the page dynamic.
|
||||||
|
- Export [`generateStaticParams`](https://preview.nextjs.org/docs/app/api-reference/functions/generate-static-params) from a `[slug]` page/layout to pre-build a known set of slugs; with `cacheComponents` + `'use cache'` they land in the static shell. It does **not** change the page signature — `params` is still a Promise, still consumed with `params.then()`.
|
||||||
|
- A query that can't find its resource calls [`notFound()`](https://preview.nextjs.org/docs/app/api-reference/functions/not-found), which bubbles to the nearest [`not-found.tsx`](https://preview.nextjs.org/docs/app/api-reference/file-conventions/not-found). Don't try/catch it — use [`unstable_rethrow`](https://preview.nextjs.org/docs/app/api-reference/functions/unstable_rethrow) if you must catch nearby.
|
||||||
|
|
||||||
|
## The page owns the Suspense boundary
|
||||||
|
|
||||||
|
The feature exports the async component **and** its skeleton. The page imports both and places the boundary. Don't pre-wrap inside the feature — that hides the boundary and prevents grouping siblings.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// features/post/components/post-detail.tsx
|
||||||
|
export async function PostDetail({ id }: { id: string }) { ... }
|
||||||
|
export function PostDetailSkeleton() { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/post/[id]/page.tsx
|
||||||
|
<Suspense fallback={<PostDetailSkeleton />}>
|
||||||
|
{params.then(({ id }) => (
|
||||||
|
<>
|
||||||
|
<PostDetail id={id} />
|
||||||
|
<ErrorBoundary title="Replies didn't load">
|
||||||
|
<Suspense fallback={<RepliesSkeleton />}>
|
||||||
|
<Replies postId={id} />
|
||||||
|
</Suspense>
|
||||||
|
</ErrorBoundary>
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
</Suspense>
|
||||||
|
```
|
||||||
|
|
||||||
|
If a page uses a transition wrapper (e.g. `<ViewTransition>`), place it in the page next to the `<Suspense>` boundary. Feature components render content and skeletons, not transition wrappers.
|
||||||
|
|
||||||
|
## Audit smells
|
||||||
|
|
||||||
|
When auditing an existing app, flag and fix these first:
|
||||||
|
|
||||||
|
- `export default async function Page(...)` that only awaits `params`, `searchParams`, or page-level queries.
|
||||||
|
- `import { getSomething } from '@/features/.../*-queries'` inside `app/**/page.tsx` or `layout.tsx`.
|
||||||
|
- Feature components whose props are `params`, `searchParams`, or a route-shaped object.
|
||||||
|
- Page-local components like `HomeContent`, `PostShell`, or `ResultsSection` that only exist to fetch data or group a Suspense fallback.
|
||||||
|
- `<Suspense>` inside feature components that prevents the page from grouping reveal behavior.
|
||||||
|
|
||||||
|
## Don't create page-local wrapper components
|
||||||
|
|
||||||
|
Avoid components whose only job is to group boundary content, like `HomeLists` or `HomeListsSkeleton`. Keep the resolved JSX and fallback JSX **inline in the page** so the loading shape, headings, and grouped reveal behavior are visible at the boundary.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Wrong — hides the structure behind a wrapper
|
||||||
|
<Suspense fallback={<HomeListsSkeleton />}>
|
||||||
|
<HomeLists searchParams={searchParams} />
|
||||||
|
</Suspense>
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Right — structure visible at the page level
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<>
|
||||||
|
<FeaturedSkeleton />
|
||||||
|
<RecentSkeleton />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{searchParams.then(sp => (
|
||||||
|
<>
|
||||||
|
<Featured filter={sp.filter} />
|
||||||
|
<Recent filter={sp.filter} />
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
</Suspense>
|
||||||
|
```
|
||||||
|
|
||||||
|
The same applies to feature-level skeleton aliases. If a variant only passes props to a base skeleton, import the base skeleton and pass those props inline in `fallback={...}`.
|
||||||
|
|
||||||
|
## Suspense boundary placement rules
|
||||||
|
|
||||||
|
1. **First section gets its own Suspense** with a known-height skeleton fallback.
|
||||||
|
2. **Section headings stay outside Suspense** when their final position is stable.
|
||||||
|
3. **Variable-height sections: group everything below them** in the same Suspense, including any headings that would otherwise paint in the wrong vertical position.
|
||||||
|
4. **Fixed-height sections: own boundary is safe.**
|
||||||
|
5. **Variable-length lists: show 2–5 skeleton items**, not the real count.
|
||||||
|
6. **Inner Suspense content stays out of the outer skeleton.** Each boundary owns its own.
|
||||||
|
7. **Never `fallback={null}` for visible UI.** If a boundary covers UI, give it a real shaped fallback, or group it with a sibling boundary that already has the correct fallback.
|
||||||
|
8. **If the top section's final height is unknown, group the following sections** in the same boundary so they reveal together and don't jump underneath.
|
||||||
|
|
||||||
|
## Error boundaries
|
||||||
|
|
||||||
|
Wrap fallible sections in a Next.js-aware error boundary so one failure doesn't take down the page. Build it on [`catchError`](https://preview.nextjs.org/docs/app/api-reference/functions/catchError) from `next/error` (its `ErrorInfo` gives you a `retry()` that re-fetches server data) — it understands Next's control-flow throws (`notFound()`, `redirect()`, `unauthorized()`, `forbidden()`) and won't swallow them. Place the boundary around the suspending section, in the page:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<ErrorBoundary title="Replies didn't load">
|
||||||
|
<Suspense fallback={<RepliesSkeleton />}>
|
||||||
|
<Replies postId={id} />
|
||||||
|
</Suspense>
|
||||||
|
</ErrorBoundary>
|
||||||
|
```
|
||||||
|
|
||||||
|
Why not plain `react-error-boundary`? It catches Next's framework throws (so `notFound()` never reaches `not-found.tsx`), and its reset doesn't re-fetch server data. Background: [Error Handling in Next.js with catchError](https://aurorascharff.no/posts/error-handling-in-nextjs-with-catch-error/).
|
||||||
|
|
||||||
|
Pair component-level boundaries with route-segment [`error.tsx`](https://preview.nextjs.org/docs/app/api-reference/file-conventions/error) for unrecoverable errors; it also receives a `retry` callback.
|
||||||
|
|
||||||
|
## Layout-level Suspense
|
||||||
|
|
||||||
|
Layouts compose feature components the same way pages do. Use `<Suspense>` for slots that fetch data (auth badge, sidebar):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export default function RootLayout({ children }: LayoutProps<'/'>) {
|
||||||
|
return (
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<Suspense>
|
||||||
|
<AuthGate userPromise={getCurrentUser()} />
|
||||||
|
</Suspense>
|
||||||
|
<main>{children}</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`AuthGate` is a client component that resolves the promise with `use()` so the dialog can render conditionally without server-side branching.
|
||||||
|
|
||||||
|
## CLS prevention
|
||||||
|
|
||||||
|
Layout shift happens when:
|
||||||
|
|
||||||
|
- A skeleton is shorter than the real content
|
||||||
|
- A heading sits inside a Suspense boundary whose final height is unknown — it paints in the wrong place, then jumps
|
||||||
|
- A variable-length list streams in without a fallback that reserves space
|
||||||
|
|
||||||
|
Fixes:
|
||||||
|
|
||||||
|
- Match skeleton height to the typical real content height.
|
||||||
|
- Move headings **outside** boundaries when their position depends on data above them.
|
||||||
|
- For unknown-height top sections, group everything below in one boundary so siblings stream together.
|
||||||
|
|
||||||
|
To audit CLS, use React DevTools' Suspense panel to pin each boundary in its loading state and check vertical positions.
|
||||||
|
|
||||||
|
## Runtime prefetch for high-value routes
|
||||||
|
|
||||||
|
With `cacheComponents` + [`partialPrefetching`](https://preview.nextjs.org/docs/app/api-reference/config/next-config-js/partialPrefetching) enabled, a visible `<Link>` prefetches the destination's shared [App Shell](https://preview.nextjs.org/docs/app/glossary#app-shell) — enough to commit navigation instantly, with link-specific content streaming after. The default (`'auto'`) already does this; don't write `prefetch = 'auto'`.
|
||||||
|
|
||||||
|
Use `<Link prefetch={true}>` on high-value links to also resolve the destination's per-link runtime data (`params`, `searchParams`, the full URL) before the click. Each such link can wake the server for a runtime prerender, so reserve it for routes users predictably visit next. See [runtime prefetching](https://preview.nextjs.org/docs/app/guides/runtime-prefetching).
|
||||||
|
|
||||||
|
Can't enable `partialPrefetching` app-wide yet? Opt in per route with `export const prefetch = 'partial'` on the destination, then drop the per-route exports once the global flag is on — see [Adopting Partial Prefetching](https://preview.nextjs.org/docs/app/guides/adopting-partial-prefetching) for the incremental path and [prefetch config](https://preview.nextjs.org/docs/app/api-reference/file-conventions/route-segment-config/prefetch) for the options. To validate navigation feels instant, see the [`instant` config](https://preview.nextjs.org/docs/app/api-reference/file-conventions/route-segment-config/instant) and [Instant Navigation guide](https://preview.nextjs.org/docs/app/guides/instant-navigation).
|
||||||
|
|
||||||
|
## Never wrap the entire page in a Suspense fallback
|
||||||
|
|
||||||
|
Page chrome (header, nav, surrounding layout) should paint instantly. Only data-dependent sections suspend. If you find yourself wrapping `<div>` and everything in it with `<Suspense fallback={<FullPageSkeleton />}>`, restructure: pull static elements out, narrow the boundary to just the dynamic part.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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`.
|
||||||
BIN
data/app.db
BIN
data/app.db
Binary file not shown.
@@ -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
8
pnpm-lock.yaml
generated
@@ -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:
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
69
src/features/rapor/types/rapor.ts
Normal file
69
src/features/rapor/types/rapor.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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`;
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
@@ -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ı açılmasın diye global cache
|
// Dev'de hot-reload başına yeni bağlantı açı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'ı
|
||||||
|
|||||||
@@ -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}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -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";
|
||||||
|
|||||||
@@ -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(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -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";
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
@@ -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 };
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -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 =
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
});
|
});
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
@@ -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
78
src/types/yokatlas.ts
Normal 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;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user