Compare commits
10 Commits
7518baccfa
...
d2732828cc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2732828cc | ||
|
|
22e2fde058 | ||
|
|
fbd184e2be | ||
|
|
7d2b2ddd71 | ||
|
|
792e8c7346 | ||
|
|
baebc1fc80 | ||
|
|
8b5aa8c8a6 | ||
|
|
e3cf141f55 | ||
|
|
8d26e375ff | ||
|
|
820eea7eca |
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`.
|
||||||
@@ -25,5 +25,5 @@ Funnel CTA butonlarında ("Listemi oluştur", "Tercihlerimi belirle", giriş/öd
|
|||||||
|
|
||||||
# Meraklısına'daki mermaid şeması mimariyle senkron kalmalı
|
# Meraklısına'daki mermaid şeması mimariyle senkron kalmalı
|
||||||
|
|
||||||
`/meraklisina` sayfasındaki sihirbaz akış şeması (`src/app/meraklisina/huni-semasi.tsx` içindeki `SEMA` sabiti) ürünün gerçek davranışının belgesidir, dekor değildir. Sihirbaz/listeleme mimarisinde davranış değiştiren her değişiklikte bu şema da aynı PR/commit içinde güncellenmeli: sihirbaz adımlarının sayısı-sırası-içeriği (alan → il → devlet/vakıf), aday havuzu kuralları, 24'lük liste iskeleti (hayal/dengeli/garanti dağılımı), havuz 24'ün altına düşünce filtre gevşetme sırası ve yapay zekânın havuz-dışına-çıkamama kuralı. Şemayı güncellerken çevresindeki unsurları da eşitle: bölümdeki anlatım metni (`src/app/meraklisina/page.tsx`, 4. bölüm), kabın `aria-label`'ı ve figcaption. Şema öğrenci diliyle kalmalı (SQL/prompt/teknik detay yok) ve ürünün risk renk dilini korumalı — kırmızı/sarı/yeşil her zaman metin etiketiyle birlikte, renk tek başına anlam taşımaz.
|
`/meraklisina` sayfasındaki sihirbaz akış şeması (`src/features/pazarlama/components/huni-semasi.tsx` içindeki `SEMA` sabiti) ürünün gerçek davranışının belgesidir, dekor değildir. Sihirbaz/listeleme mimarisinde davranış değiştiren her değişiklikte bu şema da aynı PR/commit içinde güncellenmeli: sihirbaz adımlarının sayısı-sırası-içeriği (alan → il → devlet/vakıf), aday havuzu kuralları, 24'lük liste iskeleti (hayal/dengeli/garanti dağılımı), havuz 24'ün altına düşünce filtre gevşetme sırası ve yapay zekânın havuz-dışına-çıkamama kuralı. Şemayı güncellerken çevresindeki unsurları da eşitle: bölümdeki anlatım metni (`src/features/pazarlama/components/meraklisina-icerik.tsx`, 4. bölüm), kabın `aria-label`'ı ve figcaption. Şema öğrenci diliyle kalmalı (SQL/prompt/teknik detay yok) ve ürünün risk renk dilini korumalı — kırmızı/sarı/yeşil her zaman metin etiketiyle birlikte, renk tek başına anlam taşımaz.
|
||||||
<!-- END:nextjs-agent-rules -->
|
<!-- END:nextjs-agent-rules -->
|
||||||
|
|||||||
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;
|
||||||
|
|
||||||
|
|||||||
@@ -8,12 +8,13 @@ import {
|
|||||||
krediBittiIsaretle,
|
krediBittiIsaretle,
|
||||||
} from "@/lib/credits";
|
} from "@/lib/credits";
|
||||||
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 "@/features/rapor/rapor-queries";
|
||||||
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 { secimOzeti } from "@/lib/sihirbaz";
|
import type { RaporParams } from "@/features/rapor/types/rapor";
|
||||||
import { PUAN_TURLERI } from "@/lib/db";
|
import { secimOzeti } from "@/features/sihirbaz/sihirbaz-sabitler";
|
||||||
|
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;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { NextResponse } from "next/server";
|
|||||||
import { sosyalKanitVerisi } from "@/lib/sosyal-kanit";
|
import { sosyalKanitVerisi } from "@/lib/sosyal-kanit";
|
||||||
|
|
||||||
// Fiyat bölümündeki şeridin canlı sayıyı tazelemek için yokladığı uç.
|
// Fiyat bölümündeki şeridin canlı sayıyı tazelemek için yokladığı uç.
|
||||||
// İlk değer sunucudan HTML ile geliyor (bkz. components/landing/sosyal-kanit.tsx);
|
// İlk değer sunucudan HTML ile geliyor (bkz. features/pazarlama/components/sosyal-kanit.tsx);
|
||||||
// burası yalnızca sayfa açık kaldıkça gelen güncellemeler için.
|
// burası yalnızca sayfa açık kaldıkça gelen güncellemeler için.
|
||||||
// Sayılar sosyal kanıt, gizli veri değil — kimlik doğrulaması yok.
|
// Sayılar sosyal kanıt, gizli veri değil — kimlik doğrulaması yok.
|
||||||
// Yük: sosyalKanitVerisi kendi içinde TTL önbelleğine sahip, yoklama
|
// Yük: sosyalKanitVerisi kendi içinde TTL önbelleğine sahip, yoklama
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
|
|||||||
@@ -1,28 +1,32 @@
|
|||||||
|
import { Suspense } from "react";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { getAllBolumSlugs } from "@/lib/katalog";
|
import {
|
||||||
import { BolumIcerik, bolumSayfaMetadata } from "./bolum-icerik";
|
BolumIcerik,
|
||||||
|
BolumIcerikSkeleton,
|
||||||
|
bolumSayfaMetadata,
|
||||||
|
bolumStaticSlugs,
|
||||||
|
} from "@/features/katalog/components/bolum-icerik";
|
||||||
|
|
||||||
// Tüm bölümler build sırasında bilinir (veri ancak ingest + deploy ile
|
// Tüm bölümler build sırasında bilinir (veri ancak ingest + deploy ile
|
||||||
// değişir); bilinmeyen slug getBolumBySlug'dan null dönüp notFound()'a düşer.
|
// değişir); bilinmeyen slug getBolumBySlug'dan null dönüp notFound()'a düşer.
|
||||||
// (dynamicParams cacheComponents ile uyumsuz olduğundan kullanılamıyor.)
|
// (dynamicParams cacheComponents ile uyumsuz olduğundan kullanılamıyor.)
|
||||||
export function generateStaticParams() {
|
export function generateStaticParams() {
|
||||||
return getAllBolumSlugs().map((slug) => ({ slug }));
|
return bolumStaticSlugs().map((slug) => ({ slug }));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata({
|
export async function generateMetadata({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: PageProps<"/bolum/[slug]">): Promise<Metadata> {
|
||||||
params: Promise<{ slug: string }>;
|
|
||||||
}): Promise<Metadata> {
|
|
||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
return bolumSayfaMetadata(slug, 1);
|
return bolumSayfaMetadata(slug, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function BolumPage({
|
export default function BolumPage({ params }: PageProps<"/bolum/[slug]">) {
|
||||||
params,
|
return (
|
||||||
}: {
|
<Suspense fallback={<BolumIcerikSkeleton />}>
|
||||||
params: Promise<{ slug: string }>;
|
{params.then(({ slug }) => (
|
||||||
}) {
|
<BolumIcerik slug={slug} sayfa={1} />
|
||||||
const { slug } = await params;
|
))}
|
||||||
return <BolumIcerik slug={slug} sayfa={1} />;
|
</Suspense>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,17 @@
|
|||||||
|
import { Suspense } from "react";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { getAllBolumler } from "@/lib/katalog";
|
|
||||||
import {
|
import {
|
||||||
BolumIcerik,
|
BolumIcerik,
|
||||||
|
BolumIcerikSkeleton,
|
||||||
bolumSayfaMetadata,
|
bolumSayfaMetadata,
|
||||||
bolumToplamSayfa,
|
bolumSayfaStaticParams,
|
||||||
} from "../../bolum-icerik";
|
} from "@/features/katalog/components/bolum-icerik";
|
||||||
|
|
||||||
// Yalnızca 1'den fazla sayfası olan bölümler için 2..N sayfaları üretilir;
|
// Yalnızca 1'den fazla sayfası olan bölümler için 2..N sayfaları üretilir;
|
||||||
// 1. sayfa taban rota (/bolum/[slug]) olduğundan burada üretilmez.
|
// 1. sayfa taban rota (/bolum/[slug]) olduğundan burada üretilmez.
|
||||||
export function generateStaticParams() {
|
export function generateStaticParams() {
|
||||||
return getAllBolumler().flatMap((b) => {
|
return bolumSayfaStaticParams();
|
||||||
const toplam = bolumToplamSayfa(b.programSayisi);
|
|
||||||
return Array.from({ length: Math.max(0, toplam - 1) }, (_, i) => ({
|
|
||||||
slug: b.slug,
|
|
||||||
no: String(i + 2),
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function sayfaNo(no: string): number | null {
|
function sayfaNo(no: string): number | null {
|
||||||
@@ -26,22 +21,23 @@ function sayfaNo(no: string): number | null {
|
|||||||
|
|
||||||
export async function generateMetadata({
|
export async function generateMetadata({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: PageProps<"/bolum/[slug]/sayfa/[no]">): Promise<Metadata> {
|
||||||
params: Promise<{ slug: string; no: string }>;
|
|
||||||
}): Promise<Metadata> {
|
|
||||||
const { slug, no } = await params;
|
const { slug, no } = await params;
|
||||||
const n = sayfaNo(no);
|
const n = sayfaNo(no);
|
||||||
if (n == null) return {};
|
if (n == null) return {};
|
||||||
return bolumSayfaMetadata(slug, n);
|
return bolumSayfaMetadata(slug, n);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function BolumSayfaPage({
|
export default function BolumSayfaPage({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: PageProps<"/bolum/[slug]/sayfa/[no]">) {
|
||||||
params: Promise<{ slug: string; no: string }>;
|
return (
|
||||||
}) {
|
<Suspense fallback={<BolumIcerikSkeleton />}>
|
||||||
const { slug, no } = await params;
|
{params.then(({ slug, no }) => {
|
||||||
const n = sayfaNo(no);
|
const n = sayfaNo(no);
|
||||||
if (n == null) notFound();
|
if (n == null) notFound();
|
||||||
return <BolumIcerik slug={slug} sayfa={n} />;
|
return <BolumIcerik slug={slug} sayfa={n} />;
|
||||||
|
})}
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
|
import { Suspense } from "react";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
|
||||||
import { getAllBolumler, type BolumOzet } from "@/lib/katalog";
|
|
||||||
import { KATEGORILER } from "@/lib/kategoriler";
|
|
||||||
import { JsonLd, breadcrumbJsonLd } from "@/lib/seo";
|
import { JsonLd, breadcrumbJsonLd } from "@/lib/seo";
|
||||||
import { CtaSiraForm } from "@/components/cta-sira-form";
|
|
||||||
import { SiteFooter } from "@/components/site-footer";
|
import { SiteFooter } from "@/components/site-footer";
|
||||||
import { PagePixelDivider } from "@/components/pixel-decor";
|
import {
|
||||||
|
BolumKatalogu,
|
||||||
|
BolumKataloguSkeleton,
|
||||||
|
} from "@/features/katalog/components/bolum-listesi";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Tüm Bölümlerin Taban Puanları ve Sıralamaları 2026",
|
title: "Tüm Bölümlerin Taban Puanları ve Sıralamaları 2026",
|
||||||
@@ -14,68 +14,7 @@ export const metadata: Metadata = {
|
|||||||
alternates: { canonical: "/bolumler" },
|
alternates: { canonical: "/bolumler" },
|
||||||
};
|
};
|
||||||
|
|
||||||
const sayi = (n: number) => n.toLocaleString("tr-TR");
|
|
||||||
|
|
||||||
function BolumLinkleri({ bolumler }: { bolumler: BolumOzet[] }) {
|
|
||||||
return (
|
|
||||||
<ul className="mt-3 grid gap-x-6 gap-y-1.5 sm:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{bolumler.map((b) => (
|
|
||||||
<li key={b.slug}>
|
|
||||||
<Link
|
|
||||||
href={`/bolum/${b.slug}`}
|
|
||||||
className="group inline-flex items-baseline gap-2 text-sm text-slate-700 hover:text-primary"
|
|
||||||
>
|
|
||||||
<span className="group-hover:underline">{b.ad}</span>
|
|
||||||
<span className="text-xs text-slate-400">
|
|
||||||
{sayi(b.programSayisi)}
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function KategoriGruplari({
|
|
||||||
bolumler,
|
|
||||||
seviye,
|
|
||||||
}: {
|
|
||||||
bolumler: BolumOzet[];
|
|
||||||
seviye: "lisans" | "onlisans";
|
|
||||||
}) {
|
|
||||||
const kalanlar = new Set(bolumler.map((b) => b.slug));
|
|
||||||
const gruplar = KATEGORILER.map((kategori) => {
|
|
||||||
const uyeler = bolumler.filter((b) =>
|
|
||||||
(b.kategoriler as readonly string[]).includes(kategori),
|
|
||||||
);
|
|
||||||
for (const b of uyeler) kalanlar.delete(b.slug);
|
|
||||||
return { kategori: kategori as string, uyeler };
|
|
||||||
}).filter((g) => g.uyeler.length > 0);
|
|
||||||
const diger = bolumler.filter((b) => kalanlar.has(b.slug));
|
|
||||||
if (diger.length > 0) gruplar.push({ kategori: "Diğer", uyeler: diger });
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{gruplar.map(({ kategori, uyeler }) => (
|
|
||||||
<section key={`${seviye}-${kategori}`} className="mt-8">
|
|
||||||
<h3 className="font-heading text-lg font-bold text-slate-900">
|
|
||||||
{kategori}
|
|
||||||
<span className="ml-2 text-xs font-normal text-slate-400">
|
|
||||||
{sayi(uyeler.length)} bölüm
|
|
||||||
</span>
|
|
||||||
</h3>
|
|
||||||
<BolumLinkleri bolumler={uyeler} />
|
|
||||||
</section>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function BolumlerPage() {
|
export default function BolumlerPage() {
|
||||||
const hepsi = getAllBolumler();
|
|
||||||
const lisans = hepsi.filter((b) => b.lisans);
|
|
||||||
const onlisans = hepsi.filter((b) => b.onlisans && !b.lisans);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<main className="mx-auto w-full max-w-5xl px-4 py-12 sm:py-16">
|
<main className="mx-auto w-full max-w-5xl px-4 py-12 sm:py-16">
|
||||||
@@ -89,33 +28,10 @@ export default function BolumlerPage() {
|
|||||||
<h1 className="font-heading text-3xl font-bold text-slate-900 sm:text-4xl">
|
<h1 className="font-heading text-3xl font-bold text-slate-900 sm:text-4xl">
|
||||||
Tüm Bölümlerin Taban Puanları ve Sıralamaları 2026
|
Tüm Bölümlerin Taban Puanları ve Sıralamaları 2026
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-4 max-w-3xl text-sm leading-7 text-slate-600">
|
|
||||||
{sayi(hepsi.length)} bölümün 2025 YKS yerleştirme verileri: taban
|
|
||||||
puanları, başarı sıralamaları, kontenjanlar ve devlet/vakıf dağılımı.
|
|
||||||
Bölüme tıklayınca o bölümü sunan tüm üniversitelerin tablosunu
|
|
||||||
görürsün. Veriler YÖK Atlas 2025 yerleştirme sonuçlarına dayanır; 2026
|
|
||||||
tercihleri için rehber niteliğindedir.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<PagePixelDivider seed={29} className="mt-8" />
|
<Suspense fallback={<BolumKataloguSkeleton />}>
|
||||||
|
<BolumKatalogu />
|
||||||
<section className="mt-8">
|
</Suspense>
|
||||||
<h2 className="font-heading text-2xl font-bold text-slate-900">
|
|
||||||
Lisans Bölümleri (4 Yıllık)
|
|
||||||
</h2>
|
|
||||||
<KategoriGruplari bolumler={lisans} seviye="lisans" />
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div className="mt-12">
|
|
||||||
<CtaSiraForm />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section className="mt-12">
|
|
||||||
<h2 className="font-heading text-2xl font-bold text-slate-900">
|
|
||||||
Önlisans Bölümleri (2 Yıllık — TYT)
|
|
||||||
</h2>
|
|
||||||
<KategoriGruplari bolumler={onlisans} seviye="onlisans" />
|
|
||||||
</section>
|
|
||||||
</main>
|
</main>
|
||||||
<SiteFooter />
|
<SiteFooter />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,30 +1,16 @@
|
|||||||
|
import { Suspense } from "react";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { redirect } from "next/navigation";
|
import {
|
||||||
import { ArrowRight } from "lucide-react";
|
DogrulaKarti,
|
||||||
import { PixelCorner } from "@/components/pixel-decor";
|
DogrulaKartiSkeleton,
|
||||||
import { Button } from "@/components/ui/button";
|
} from "@/features/kullanici/components/dogrula-karti";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Girişini onayla",
|
title: "Girişini onayla",
|
||||||
robots: { index: false, follow: false },
|
robots: { index: false, follow: false },
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Yalnızca site içi (göreli) callback'lere izin ver — open redirect kalkanı. */
|
export default function DogrulaPage({
|
||||||
function guvenliYol(deger: string | undefined, varsayilan: string): string {
|
|
||||||
return deger && deger.startsWith("/") && !deger.startsWith("//")
|
|
||||||
? deger
|
|
||||||
: varsayilan;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Maildeki giriş bağlantısının indiği onay sayfası. Doğrulama ucu
|
|
||||||
* (/api/auth/magic-link/verify) GET'lendiği anda token'ı tüketir; mail
|
|
||||||
* güvenlik tarayıcıları ve tarayıcı ön-yüklemeleri linki kullanıcıdan önce
|
|
||||||
* açıp token'ı yakabildiği için maildeki link token'ı TÜKETMEYEN bu sayfaya
|
|
||||||
* gelir. Verify ancak buradaki form gönderilince tetiklenir — tarayıcı botları
|
|
||||||
* sayfayı GET'lese bile form'u göndermez, token yanmaz.
|
|
||||||
*/
|
|
||||||
export default async function DogrulaPage({
|
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
searchParams: Promise<{
|
searchParams: Promise<{
|
||||||
@@ -34,68 +20,19 @@ export default async function DogrulaPage({
|
|||||||
newUserCallbackURL?: string;
|
newUserCallbackURL?: string;
|
||||||
}>;
|
}>;
|
||||||
}) {
|
}) {
|
||||||
const { token, callbackURL, errorCallbackURL, newUserCallbackURL } =
|
|
||||||
await searchParams;
|
|
||||||
if (!token) redirect("/giris");
|
|
||||||
|
|
||||||
const callback = guvenliYol(callbackURL, "/");
|
|
||||||
const hataCallback = guvenliYol(
|
|
||||||
errorCallbackURL,
|
|
||||||
`/giris?hata=gecersiz-baglanti&callback=${encodeURIComponent(callback)}`,
|
|
||||||
);
|
|
||||||
const yeniKullaniciCallback = guvenliYol(newUserCallbackURL, callback);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="-mb-28 flex min-h-dvh flex-col bg-slate-50 text-slate-900">
|
<div className="-mb-28 flex min-h-dvh flex-col bg-slate-50 text-slate-900">
|
||||||
<main className="mx-auto flex w-full max-w-md flex-1 flex-col items-stretch justify-center px-6 py-8">
|
<main className="mx-auto flex w-full max-w-md flex-1 flex-col items-stretch justify-center px-6 py-8">
|
||||||
<div className="relative">
|
<Suspense fallback={<DogrulaKartiSkeleton />}>
|
||||||
<div className="relative rounded-2xl border border-slate-200 bg-white p-8 shadow-sm">
|
{searchParams.then((sp) => (
|
||||||
<h1 className="font-heading text-xl font-bold">Girişini onayla</h1>
|
<DogrulaKarti
|
||||||
<p className="mt-2 text-sm text-slate-600">
|
token={sp.token}
|
||||||
Bu bağlantı senin için üretildi ve{" "}
|
callbackURL={sp.callbackURL}
|
||||||
<span className="font-semibold">tek kullanımlık</span>. Aşağıdaki
|
errorCallbackURL={sp.errorCallbackURL}
|
||||||
butona tıkladığında KolayTercih hesabına giriş yapacaksın.
|
newUserCallbackURL={sp.newUserCallbackURL}
|
||||||
</p>
|
/>
|
||||||
<form
|
))}
|
||||||
method="GET"
|
</Suspense>
|
||||||
action="/api/auth/magic-link/verify"
|
|
||||||
className="mt-6"
|
|
||||||
>
|
|
||||||
<input type="hidden" name="token" value={token} />
|
|
||||||
<input type="hidden" name="callbackURL" value={callback} />
|
|
||||||
<input
|
|
||||||
type="hidden"
|
|
||||||
name="errorCallbackURL"
|
|
||||||
value={hataCallback}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="hidden"
|
|
||||||
name="newUserCallbackURL"
|
|
||||||
value={yeniKullaniciCallback}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
className="h-11 w-full cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
|
|
||||||
>
|
|
||||||
Giriş yap
|
|
||||||
<ArrowRight className="size-4" aria-hidden />
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
<p className="mt-4 text-xs leading-relaxed text-slate-500">
|
|
||||||
Bu isteği sen yapmadıysan bu sayfayı kapatabilirsin — butona
|
|
||||||
tıklanmadığı sürece hesabında hiçbir şey değişmez.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
aria-hidden
|
|
||||||
className="pointer-events-none absolute inset-0 z-10 text-primary"
|
|
||||||
>
|
|
||||||
<PixelCorner className="absolute -right-4 -top-4 size-9 [&_rect]:[fill-opacity:0.65]" />
|
|
||||||
<PixelCorner className="absolute -bottom-4 -right-4 size-9 rotate-90 [&_rect]:[fill-opacity:0.65]" />
|
|
||||||
<PixelCorner className="absolute -bottom-4 -left-4 size-9 rotate-180 [&_rect]:[fill-opacity:0.65]" />
|
|
||||||
<PixelCorner className="absolute -left-4 -top-4 size-9 -rotate-90 [&_rect]:[fill-opacity:0.65]" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
import { Skeleton } from "@/components/ui/skeleton";
|
|
||||||
|
|
||||||
// /giris açılırken spinner yerine sayfanın kendi iskeleti: kart, başlık,
|
|
||||||
// Google butonu, ayraç, e-posta alanı ve gönder butonu aynı yerde belirir —
|
|
||||||
// içerik gelince zıplama olmaz.
|
|
||||||
export default function GirisLoading() {
|
|
||||||
return (
|
|
||||||
<div className="-mb-28 flex min-h-dvh flex-col bg-slate-50">
|
|
||||||
<main className="mx-auto flex w-full max-w-md flex-1 flex-col items-stretch justify-center px-6 py-8">
|
|
||||||
<div className="rounded-2xl border border-slate-200 bg-white p-8 shadow-sm">
|
|
||||||
{/* Başlık + alt metin */}
|
|
||||||
<Skeleton className="h-8 w-40" />
|
|
||||||
<Skeleton className="mt-3 h-4 w-full" />
|
|
||||||
<Skeleton className="mt-1.5 h-4 w-2/3" />
|
|
||||||
|
|
||||||
{/* Google ile devam et */}
|
|
||||||
<Skeleton className="mt-6 h-11 w-full rounded-lg" />
|
|
||||||
|
|
||||||
{/* "veya" ayracı */}
|
|
||||||
<div className="mt-4 flex items-center gap-3">
|
|
||||||
<Skeleton className="h-px flex-1" />
|
|
||||||
<Skeleton className="h-3 w-8" />
|
|
||||||
<Skeleton className="h-px flex-1" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* E-posta alanı + gönder butonu */}
|
|
||||||
<Skeleton className="mt-4 h-11 w-full rounded-lg" />
|
|
||||||
<Skeleton className="mt-3 h-11 w-full rounded-lg" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Kart altındaki yasal metin */}
|
|
||||||
<div className="mt-6 flex flex-col items-center gap-1.5">
|
|
||||||
<Skeleton className="h-3 w-3/4" />
|
|
||||||
<Skeleton className="h-3 w-1/2" />
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,95 +1,30 @@
|
|||||||
|
import { Suspense } from "react";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
import {
|
||||||
import { redirect } from "next/navigation";
|
GirisKarti,
|
||||||
import { getSession } from "@/lib/session";
|
GirisKartiSkeleton,
|
||||||
import { PixelCorner } from "@/components/pixel-decor";
|
} from "@/features/kullanici/components/giris-karti";
|
||||||
import { GirisForm } from "./giris-form";
|
|
||||||
import { GirisBaslik } from "./giris-baslik";
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Giriş",
|
title: "Giriş",
|
||||||
robots: { index: false, follow: true },
|
robots: { index: false, follow: true },
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function GirisPage({
|
export default function GirisPage({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
searchParams: Promise<{ callback?: string; hata?: string }>;
|
searchParams: Promise<{ callback?: string; hata?: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { callback, hata } = await searchParams;
|
|
||||||
const callbackURL =
|
|
||||||
callback && callback.startsWith("/") ? callback : "/";
|
|
||||||
// Magic link doğrulaması başarısız olunca errorCallbackURL buraya düşürür
|
|
||||||
// (bkz. giris-form.tsx ve /giris/dogrula) — ham INVALID_TOKEN yerine
|
|
||||||
// dostça bir mesaj gösterip yeni bağlantı istemeye yönlendiriyoruz.
|
|
||||||
const baglantiHatasi = hata === "gecersiz-baglanti";
|
|
||||||
|
|
||||||
const session = await getSession();
|
|
||||||
if (session) redirect(callbackURL);
|
|
||||||
|
|
||||||
// Sihirbaz funnel'ı: seçimler yapıldı, listeye giriş kaldı
|
|
||||||
const funnel = callbackURL.startsWith("/sonuc");
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// -mb-28: layout'taki ScrollFlow sarmalayıcısının pb-28'ini nötrler —
|
// -mb-28: layout'taki ScrollFlow sarmalayıcısının pb-28'ini nötrler —
|
||||||
// kart tam dvh içinde ortalanır, sayfa scroll etmez (sığmazsa scroll açılır).
|
// kart tam dvh içinde ortalanır, sayfa scroll etmez (sığmazsa scroll açılır).
|
||||||
<div className="-mb-28 flex min-h-dvh flex-col bg-slate-50 text-slate-900">
|
<div className="-mb-28 flex min-h-dvh flex-col bg-slate-50 text-slate-900">
|
||||||
<main className="mx-auto flex w-full max-w-md flex-1 flex-col items-stretch justify-center px-6 py-8">
|
<main className="mx-auto flex w-full max-w-md flex-1 flex-col items-stretch justify-center px-6 py-8">
|
||||||
<div className="relative">
|
<Suspense fallback={<GirisKartiSkeleton />}>
|
||||||
<div className="relative rounded-2xl border border-slate-200 bg-white p-8 shadow-sm">
|
{searchParams.then(({ callback, hata }) => (
|
||||||
{baglantiHatasi && (
|
<GirisKarti callback={callback} hata={hata} />
|
||||||
<div
|
))}
|
||||||
role="alert"
|
</Suspense>
|
||||||
className="mb-5 rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-800"
|
|
||||||
>
|
|
||||||
<p className="font-semibold">
|
|
||||||
Giriş bağlantısı geçersiz ya da süresi dolmuş
|
|
||||||
</p>
|
|
||||||
<p className="mt-1">
|
|
||||||
Bağlantılar 15 dakika geçerli ve tek kullanımlık. Aşağıdan
|
|
||||||
yeni bir bağlantı isteyebilirsin.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<GirisBaslik funnel={funnel} />
|
|
||||||
<p className="mt-2 text-sm text-slate-600">
|
|
||||||
{funnel ? (
|
|
||||||
<>
|
|
||||||
Seçimlerin hazır — giriş yap,{" "}
|
|
||||||
<span className="font-semibold">5 deneme kredinle</span> listen
|
|
||||||
hemen üretilsin.
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
Kayıt olduğunda{" "}
|
|
||||||
<span className="font-semibold">5 deneme kredisi</span>{" "}
|
|
||||||
hesabına tanımlanır — Yapay Zeka danışmana ücretsiz soru sorabilirsin.
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
<GirisForm callbackURL={callbackURL} />
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
aria-hidden
|
|
||||||
className="pointer-events-none absolute inset-0 z-10 text-primary"
|
|
||||||
>
|
|
||||||
<PixelCorner className="absolute -right-4 -top-4 size-9 [&_rect]:[fill-opacity:0.65]" />
|
|
||||||
<PixelCorner className="absolute -bottom-4 -right-4 size-9 rotate-90 [&_rect]:[fill-opacity:0.65]" />
|
|
||||||
<PixelCorner className="absolute -bottom-4 -left-4 size-9 rotate-180 [&_rect]:[fill-opacity:0.65]" />
|
|
||||||
<PixelCorner className="absolute -left-4 -top-4 size-9 -rotate-90 [&_rect]:[fill-opacity:0.65]" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="mt-6 text-center text-xs leading-relaxed text-slate-500">
|
|
||||||
Giriş yaparak verilerinin tercih önerileri üretmek amacıyla{" "}
|
|
||||||
<Link href="/gizlilik" className="underline hover:text-slate-700">
|
|
||||||
gizlilik metnindeki
|
|
||||||
</Link>{" "}
|
|
||||||
gibi işlenmesini ve{" "}
|
|
||||||
<Link href="/kosullar" className="underline hover:text-slate-700">
|
|
||||||
kullanım koşullarını
|
|
||||||
</Link>{" "}
|
|
||||||
kabul etmiş olursun. KolayTercih yerleşme garantisi vermez.
|
|
||||||
</p>
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import localFont from "next/font/local";
|
|||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
import { SiteHeader } from "@/components/site-header";
|
import { SiteHeader } from "@/components/site-header";
|
||||||
import { SiteTopBanner } from "@/components/site-top-banner";
|
import { SiteTopBanner } from "@/components/site-top-banner";
|
||||||
import { ListeCekmecesiLazy } from "@/components/manuel-liste/liste-cekmecesi-lazy";
|
import { ListeCekmecesiLazy } from "@/features/liste/components/liste-cekmecesi-lazy";
|
||||||
import { KaydetBannerLazy } from "@/components/manuel-liste/kaydet-banner-lazy";
|
import { KaydetBannerLazy } from "@/features/liste/components/kaydet-banner-lazy";
|
||||||
import { TercihProfiliKapisiLazy } from "@/components/manuel-liste/tercih-profili-kapisi-lazy";
|
import { TercihProfiliKapisiLazy } from "@/features/liste/components/tercih-profili-kapisi-lazy";
|
||||||
import { HeaderGate } from "@/components/header-gate";
|
import { HeaderGate } from "@/components/header-gate";
|
||||||
import { ProgressiveBlur } from "@/components/ui/skiper-ui/skiper41";
|
import { ProgressiveBlur } from "@/components/ui/skiper-ui/skiper41";
|
||||||
import { DevPanel } from "@/components/dev/dev-panel";
|
import { DevPanel } from "@/components/dev/dev-panel";
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
import { Skeleton } from "@/components/ui/skeleton";
|
|
||||||
|
|
||||||
export default function ListemLoading() {
|
|
||||||
return (
|
|
||||||
<main className="mx-auto w-full max-w-7xl px-4 py-10">
|
|
||||||
<Skeleton className="h-6 w-40 rounded-full" />
|
|
||||||
<Skeleton className="mt-4 h-9 w-3/4 max-w-xl" />
|
|
||||||
<Skeleton className="mt-3 h-5 w-2/3 max-w-lg" />
|
|
||||||
<div className="mt-8 grid items-start gap-8 lg:grid-cols-[minmax(0,1fr)_340px]">
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
{Array.from({ length: 10 }).map((_, i) => (
|
|
||||||
<Skeleton key={i} className="h-16 w-full rounded-xl" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<Skeleton className="h-[480px] w-full rounded-2xl" />
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,20 +1,9 @@
|
|||||||
|
import { Suspense } from "react";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { asc, eq } from "drizzle-orm";
|
import {
|
||||||
import { redirect } from "next/navigation";
|
ListemIcerik,
|
||||||
import { MessageCircleQuestion, Sparkles } from "lucide-react";
|
ListemIcerikSkeleton,
|
||||||
import { verifySession, getCurrentUser } from "@/lib/session";
|
} from "@/features/rapor/components/listem-icerik";
|
||||||
import { appDb, schema } from "@/lib/appdb";
|
|
||||||
import { kullanicininRaporu } from "@/lib/rapor-kaydi";
|
|
||||||
import { URUNLER, MAX_REVIZYON } from "@/lib/credits";
|
|
||||||
import type { RaporSonuc } from "@/lib/ai/rapor";
|
|
||||||
import type { RaporParams } from "@/lib/rapor-havuzu";
|
|
||||||
import { ViewportPortal } from "@/components/viewport-portal";
|
|
||||||
import { SiteFooter } from "@/components/site-footer";
|
|
||||||
import { raporMaskele, ACIK_SATIR, type MaskeliRapor } from "@/lib/rapor-maske";
|
|
||||||
import type { Mesaj } from "./sohbet-client";
|
|
||||||
import { ListemGovde } from "./listem-govde";
|
|
||||||
import { ListemBosCta } from "./listem-bos-cta";
|
|
||||||
import { ListeUretici } from "./liste-uretici";
|
|
||||||
|
|
||||||
// listeRevize aksiyonu 2 AI denemesi yapabiliyor (deneme başına 60 sn abort
|
// listeRevize aksiyonu 2 AI denemesi yapabiliyor (deneme başına 60 sn abort
|
||||||
// sınırı, bkz. lib/ai/cagri.ts) → en kötü ~120 sn. Platform limiti bundan
|
// sınırı, bkz. lib/ai/cagri.ts) → en kötü ~120 sn. Platform limiti bundan
|
||||||
@@ -28,126 +17,16 @@ export const metadata: Metadata = {
|
|||||||
robots: { index: false, follow: false },
|
robots: { index: false, follow: false },
|
||||||
};
|
};
|
||||||
|
|
||||||
const PAKET_FIYATI = (URUNLER.paket.amountKurus / 100).toLocaleString("tr-TR", {
|
export default function ListemPage({
|
||||||
maximumFractionDigits: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
export default async function ListemPage({
|
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
searchParams: Promise<{ acildi?: string; uret?: string }>;
|
searchParams: Promise<{ acildi?: string; uret?: string }>;
|
||||||
}) {
|
}) {
|
||||||
// Girişsiz gelene callback'i ?uret=1 ile ver: girişten dönüşte bekleyen
|
|
||||||
// seçimlerle üretim kaldığı yerden başlasın.
|
|
||||||
const ilkParams = await searchParams;
|
|
||||||
const geriYol = ilkParams.uret === "1" ? "/listem?uret=1" : "/listem";
|
|
||||||
const session = await verifySession(geriYol);
|
|
||||||
const [user, params, satir, sohbetGecmisi] = await Promise.all([
|
|
||||||
getCurrentUser(),
|
|
||||||
searchParams,
|
|
||||||
kullanicininRaporu(session.user.id),
|
|
||||||
appDb
|
|
||||||
.select({
|
|
||||||
id: schema.chatMessages.id,
|
|
||||||
role: schema.chatMessages.role,
|
|
||||||
content: schema.chatMessages.content,
|
|
||||||
})
|
|
||||||
.from(schema.chatMessages)
|
|
||||||
.where(eq(schema.chatMessages.userId, session.user.id))
|
|
||||||
.orderBy(asc(schema.chatMessages.createdAt))
|
|
||||||
.limit(100),
|
|
||||||
]);
|
|
||||||
if (!user) redirect(`/giris?callback=${encodeURIComponent(geriYol)}`);
|
|
||||||
|
|
||||||
const { acildi } = params;
|
|
||||||
const kilitAcilisi = Boolean(user.hasPaket && acildi === "1");
|
|
||||||
const raporVar = Boolean(satir?.result);
|
|
||||||
|
|
||||||
if (!raporVar) {
|
|
||||||
const bosDurum = (
|
|
||||||
<section className="mx-auto flex max-w-2xl flex-col items-center px-4 py-16 text-center">
|
|
||||||
<Sparkles className="size-10 text-primary" aria-hidden />
|
|
||||||
<h1 className="mt-4 font-heading text-2xl font-bold">
|
|
||||||
Henüz Yapay Zeka listen yok
|
|
||||||
</h1>
|
|
||||||
<ListemBosCta />
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<main className="mx-auto w-full max-w-4xl px-4 py-12">
|
|
||||||
{/* ?uret=1: sihirbazdan yönlendirildi — bekleyen seçimlerle üretim
|
|
||||||
burada koşar, animasyon ve hata/kredi durumları burada yaşar. */}
|
|
||||||
{params.uret === "1" ? (
|
|
||||||
<ListeUretici
|
|
||||||
hasPaket={Boolean(user.hasPaket)}
|
|
||||||
bosDurum={bosDurum}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
bosDurum
|
|
||||||
)}
|
|
||||||
</main>
|
|
||||||
<SiteFooter />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const rapor: MaskeliRapor = user.hasPaket
|
|
||||||
? (satir!.result as RaporSonuc)
|
|
||||||
: raporMaskele(satir!.result as RaporSonuc);
|
|
||||||
const raporParams = satir!.params as RaporParams;
|
|
||||||
// Danışman özeti sohbetin açılış mesajı olarak gösterilir; sohbet
|
|
||||||
// /api/soru üzerinden zaten liste + seçim bağlamıyla çalışıyor.
|
|
||||||
// Paketsizde rapor zaten maskeli: 1 uyarı açık, kalanının yalnızca sayısı var.
|
|
||||||
const danismanOzeti = rapor.genelDegerlendirme
|
|
||||||
? {
|
|
||||||
metin: rapor.genelDegerlendirme,
|
|
||||||
uyarilar: rapor.uyarilar,
|
|
||||||
kilitli: !user.hasPaket,
|
|
||||||
kilitliUyariSayisi: user.hasPaket ? undefined : rapor.kilitliUyariSayisi,
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Suspense fallback={<ListemIcerikSkeleton />}>
|
||||||
<main className="mx-auto w-full max-w-7xl px-4 py-12">
|
{searchParams.then(({ acildi, uret }) => (
|
||||||
{kilitAcilisi ? (
|
<ListemIcerik uret={uret === "1"} acildi={acildi === "1"} />
|
||||||
<div
|
))}
|
||||||
role="status"
|
</Suspense>
|
||||||
className="mb-6 flex items-center gap-3 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-medium text-emerald-800"
|
|
||||||
>
|
|
||||||
<Sparkles className="size-4 shrink-0" aria-hidden />
|
|
||||||
Paketin aktif — listendeki tercihlerin tamamı, gerekçeler, riskler ve
|
|
||||||
PDF artık açık.
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{/* Başlık ve revizyon/PDF aksiyonları listenin kendi başlık satırında
|
|
||||||
(bkz. listem-govde.tsx) — sayfa üstünde ayrı bir header yok. */}
|
|
||||||
<ListemGovde
|
|
||||||
rapor={rapor}
|
|
||||||
adaySira={raporParams.sira}
|
|
||||||
hasPaket={Boolean(user.hasPaket)}
|
|
||||||
kilitAcilisi={kilitAcilisi}
|
|
||||||
kilitliBaslangic={user.hasPaket ? undefined : ACIK_SATIR}
|
|
||||||
kredi={user.creditBalance}
|
|
||||||
sohbetGecmisi={sohbetGecmisi as Mesaj[]}
|
|
||||||
ozet={danismanOzeti}
|
|
||||||
kalanHak={Math.max(0, MAX_REVIZYON - (satir!.revisionCount ?? 0))}
|
|
||||||
paketFiyati={PAKET_FIYATI}
|
|
||||||
paketKredi={URUNLER.paket.credits}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ViewportPortal>
|
|
||||||
<a
|
|
||||||
href="#sohbet"
|
|
||||||
className="fixed bottom-5 right-4 z-40 inline-flex h-11 items-center gap-2 rounded-full bg-primary px-4 text-sm font-semibold text-white shadow-lg transition-transform duration-200 active:scale-[0.97] lg:hidden print:hidden"
|
|
||||||
>
|
|
||||||
<MessageCircleQuestion className="size-4" aria-hidden />
|
|
||||||
Danışmana sor
|
|
||||||
</a>
|
|
||||||
</ViewportPortal>
|
|
||||||
</main>
|
|
||||||
<SiteFooter />
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,12 @@
|
|||||||
|
import { Suspense } from "react";
|
||||||
import type { Metadata } from "next";
|
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 {
|
import {
|
||||||
DemoKapanisCta,
|
MeraklisinaIcerik,
|
||||||
DemoKumanda,
|
MeraklisinaIcerikSkeleton,
|
||||||
DilimSeridiCanli,
|
} from "@/features/pazarlama/components/meraklisina-icerik";
|
||||||
SiralamaDemo,
|
|
||||||
type DemoVeri,
|
|
||||||
} from "./demo";
|
|
||||||
import { HuniSemasi } from "./huni-semasi";
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Meraklısına: KolayTercih nasıl çalışır?",
|
title: "Meraklısına: KolayTercih nasıl çalışır?",
|
||||||
@@ -19,164 +15,30 @@ export const metadata: Metadata = {
|
|||||||
"Sıralamanı girdiğinde arka planda ne oluyor? Dilimler, risk renkleri ve sihirbazın adım adım daralan filtreleri — öğrenci diliyle, sıralamana göre hesaplanan canlı görsellerle.",
|
"Sıralamanı girdiğinde arka planda ne oluyor? Dilimler, risk renkleri ve sihirbazın adım adım daralan filtreleri — öğrenci diliyle, sıralamana göre hesaplanan canlı görsellerle.",
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Algoritmanın öğrenci diline çevrilmiş anlatımı. Teknik detay (SQL, çarpan
|
|
||||||
* mutfağı) bilerek verilmez; amaç güven: veri gerçek, kurallar şeffaf.
|
|
||||||
* Dilim şeridi canlıdır: kullanıcının kayıtlı tercih profilindeki sıralama
|
|
||||||
* okunur, şerit o sıralamanın gerçek sayılarıyla güncellenir (bkz. demo.tsx);
|
|
||||||
* profil yoksa örnek sıralama gösterilir. Sihirbaz hunisi ise statik
|
|
||||||
* bir mermaid şemasıyla anlatılır (bkz. huni-semasi.tsx).
|
|
||||||
* Diyagramlar ürünün risk renk dilini kullanır (yeşil/sarı/kırmızı) ve her
|
|
||||||
* bölge metinle de etiketlenir — renk tek başına anlam taşımaz.
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Profili olmayan ziyaretçi bu örnek sıralamayı görür; SSR'da gerçek veriyle
|
|
||||||
// render edilir ki profil olmadan da sayfa anlamlı olsun.
|
|
||||||
const VARSAYILAN_SIRA = 100_000;
|
|
||||||
|
|
||||||
export default function MeraklisinaPage() {
|
export default function MeraklisinaPage() {
|
||||||
const dilimToplam = Object.fromEntries(
|
|
||||||
(["hayal", "dengeli", "garanti"] as DilimKey[]).map((d) => [
|
|
||||||
d,
|
|
||||||
dilimAra(VARSAYILAN_SIRA, "say", d, { limit: 0 }).toplam,
|
|
||||||
]),
|
|
||||||
) as Record<DilimKey, number>;
|
|
||||||
const baslangicVeri: DemoVeri = {
|
|
||||||
facetler: rankWindowFacets(VARSAYILAN_SIRA, "say"),
|
|
||||||
dilimToplam,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<main className="mx-auto w-full max-w-2xl px-4 py-16">
|
<main className="mx-auto w-full max-w-2xl px-4 py-16">
|
||||||
<Parallax fromStart strength={10}>
|
<Parallax fromStart strength={10}>
|
||||||
<SectionEyebrow>Meraklısına</SectionEyebrow>
|
<SectionEyebrow>Meraklısına</SectionEyebrow>
|
||||||
<h1 className="mt-3 font-heading text-3xl font-bold">
|
<h1 className="mt-3 font-heading text-3xl font-bold">
|
||||||
Sıralamanı girince arka planda ne oluyor?
|
Sıralamanı girince arka planda ne oluyor?
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-3 text-slate-600">
|
<p className="mt-3 text-slate-600">
|
||||||
Sihir yok, kara kutu yok: KolayTercih'in önerdiği her satır resmî
|
Sihir yok, kara kutu yok: KolayTercih'in önerdiği her satır resmî
|
||||||
YÖK Atlas verisinden gelir ve hangi kurala göre önüne geldiği bellidir.
|
YÖK Atlas verisinden gelir ve hangi kurala göre önüne geldiği bellidir.
|
||||||
Bu sayfa o kuralları öğrenci diliyle anlatır — üstelik görseller
|
Bu sayfa o kuralları öğrenci diliyle anlatır — üstelik görseller
|
||||||
canlı: sıralamanı girdiysen sayılar sana göre hesaplanır.
|
canlı: sıralamanı girdiysen sayılar sana göre hesaplanır.
|
||||||
</p>
|
</p>
|
||||||
</Parallax>
|
|
||||||
|
|
||||||
<PagePixelDivider seed={73} className="mt-6" />
|
|
||||||
|
|
||||||
<SiralamaDemo
|
|
||||||
baslangicSira={VARSAYILAN_SIRA}
|
|
||||||
baslangicVeri={baslangicVeri}
|
|
||||||
>
|
|
||||||
<Parallax
|
|
||||||
fromStart
|
|
||||||
strength={4}
|
|
||||||
className="mt-8 space-y-10 text-sm leading-7 text-slate-700"
|
|
||||||
>
|
|
||||||
<section>
|
|
||||||
<h2 className="font-heading text-lg font-bold text-slate-900">
|
|
||||||
1. Her şey taban sıralamasıyla başlar
|
|
||||||
</h2>
|
|
||||||
<p className="mt-2">
|
|
||||||
Bir bölümün <strong>taban sıralaması</strong>, geçen yıl o bölüme
|
|
||||||
yerleşen <em>son</em>{" "}
|
|
||||||
öğrencinin Türkiye sıralamasıdır. Senin sıralaman bu sayıdan
|
|
||||||
küçükse (yani daha iyiysen), geçen yılın koşullarında o bölüme
|
|
||||||
yerleşebilirdin demektir. Sistemde her programın en güncel taban
|
|
||||||
sıralaması kayıtlıdır; bir bölümün 2025 verisi henüz oluşmamışsa
|
|
||||||
bir önceki yılınki kullanılır.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<DemoKumanda />
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h2 className="font-heading text-lg font-bold text-slate-900">
|
|
||||||
2. Programlar üç dilime ayrılır
|
|
||||||
</h2>
|
|
||||||
<p className="mt-2">
|
|
||||||
Sıralamanı girdiğinde tüm programlar senin konumuna göre üç kümeye
|
|
||||||
ayrılır: tabanı senden <strong>daha iyi</strong> olanlar{" "}
|
|
||||||
<span className="font-semibold text-red-600">Hayal</span> (şansını
|
|
||||||
denediğin satırlar), tabanı sıralamana{" "}
|
|
||||||
<strong>yakın</strong> olanlar{" "}
|
|
||||||
<span className="font-semibold text-amber-600">Dengeli</span>{" "}
|
|
||||||
(listenin bel kemiği — sıralamanın yaklaşık yüzde kırk fazlasına
|
|
||||||
kadar pay bırakılır), tabanı{" "}
|
|
||||||
<strong>belirgin şekilde altında</strong> kalan her şey ise{" "}
|
|
||||||
<span className="font-semibold text-emerald-600">Garanti</span>{" "}
|
|
||||||
dilimidir.
|
|
||||||
</p>
|
|
||||||
<DilimSeridiCanli />
|
|
||||||
<p className="mt-4">
|
|
||||||
Garanti diliminin bir sonu yok: 87. sıradaki bir adayın da 500
|
|
||||||
bininci sıradaki bir adayın da ulaşabildiği <em>bütün</em>{" "}
|
|
||||||
programlar listededir. Liste sana en yakın tabandan başlar; aşağı
|
|
||||||
indikçe ve “Daha fazla göster”e bastıkça senden
|
|
||||||
uzaklaşan seçenekler gelir. Böylece ilk gördüklerin her zaman en
|
|
||||||
isabetli adaylardır ama hiçbir seçenek senden saklanmaz.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h2 className="font-heading text-lg font-bold text-slate-900">
|
|
||||||
3. Risk renkleri her yerde aynı dili konuşur
|
|
||||||
</h2>
|
|
||||||
<p className="mt-2">
|
|
||||||
Tabloda, haritada ve yapay zekâ listesinde gördüğün nokta ve
|
|
||||||
renkler tek bir kurala bağlıdır:{" "}
|
|
||||||
<span className="font-semibold text-emerald-600">yeşil</span>{" "}
|
|
||||||
tabanı sıralamanın belirgin altında (güvenli),{" "}
|
|
||||||
<span className="font-semibold text-amber-600">sarı</span>{" "}
|
|
||||||
sıralamana yakın (az riskli),{" "}
|
|
||||||
<span className="font-semibold text-red-600">kırmızı</span>{" "}
|
|
||||||
tabanı senden iyi (riskli). Renk hangi sayfada olursa olsun aynı
|
|
||||||
hesaptan çıkar; bir satır sekme değiştirdi diye renk değiştirmez.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h2 className="font-heading text-lg font-bold text-slate-900">
|
|
||||||
4. Sihirbaz süzer, yapay zekâ kurar
|
|
||||||
</h2>
|
|
||||||
<p className="mt-2">
|
|
||||||
“Yapay Zeka listemi kur” sihirbazındaki üç adım birbirinden
|
|
||||||
bağımsız değildir. İlk adımda yalnızca{" "}
|
|
||||||
<strong>sıralamanla ulaşabildiğin</strong>{" "}
|
|
||||||
alanlar listelenir; ikinci adımdaki iller, seçtiğin alanlardaki
|
|
||||||
program sayısına göre yeniden hesaplanır; üçüncü adımdaki
|
|
||||||
devlet/vakıf sayıları da hem alan hem il seçimini hesaba katar.
|
|
||||||
Adımlar bitince elde kalan gerçek programlar bir{" "}
|
|
||||||
<strong>aday havuzu</strong>{" "}
|
|
||||||
olur ve yapay zekâya öyle gider: ona boş bir sayfa değil, bu havuz
|
|
||||||
verilir. Aşağıdaki şema akışın tamamını gösterir — havuz
|
|
||||||
24'ün altına düşerse filtrelerin nasıl gevşetildiği, yine de
|
|
||||||
yetmezse listenin eldeki programlarla, sana açıkça belirtilerek
|
|
||||||
nasıl kurulduğu da dahil.
|
|
||||||
</p>
|
|
||||||
<HuniSemasi />
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h2 className="font-heading text-lg font-bold text-slate-900">
|
|
||||||
5. Dürüst olalım: bu bir tahmin aracıdır
|
|
||||||
</h2>
|
|
||||||
<p className="mt-2">
|
|
||||||
Tüm bu hesaplar <strong>geçen yılların</strong>{" "}
|
|
||||||
yerleştirme sonuçlarına dayanır. Kontenjanlar, tercih davranışları
|
|
||||||
ve puanlar her yıl değişir; geçen yıl “garanti”
|
|
||||||
görünen bir bölüm bu yıl dolabilir. Bu yüzden satırlarda son beş
|
|
||||||
yılın taban seyrini de gösteririz — çizgi yukarı tırmanıyorsa
|
|
||||||
bölüm zorlaşıyor demektir. KolayTercih karar <em>destek</em>{" "}
|
|
||||||
aracıdır; listenin son hâli ve sorumluluğu her zaman sana aittir.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<DemoKapanisCta />
|
|
||||||
</Parallax>
|
</Parallax>
|
||||||
</SiralamaDemo>
|
|
||||||
</main>
|
<PagePixelDivider seed={73} className="mt-6" />
|
||||||
<SiteFooter />
|
|
||||||
|
<Suspense fallback={<MeraklisinaIcerikSkeleton />}>
|
||||||
|
<MeraklisinaIcerik />
|
||||||
|
</Suspense>
|
||||||
|
</main>
|
||||||
|
<SiteFooter />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
import { Skeleton } from "@/components/ui/skeleton";
|
|
||||||
|
|
||||||
// /odeme/sonuc iyzico doğrulaması bekler — ortalanmış sonuç kartı iskeleti.
|
|
||||||
export default function Loading() {
|
|
||||||
return (
|
|
||||||
<main
|
|
||||||
aria-live="polite"
|
|
||||||
className="mx-auto flex w-full max-w-md flex-1 flex-col justify-center px-4 py-16"
|
|
||||||
>
|
|
||||||
<div className="rounded-2xl border border-slate-200 bg-white p-8 shadow-sm">
|
|
||||||
<Skeleton className="mx-auto size-12 rounded-full" />
|
|
||||||
<Skeleton className="mx-auto mt-5 h-7 w-2/3" />
|
|
||||||
<Skeleton className="mx-auto mt-3 h-5 w-full" />
|
|
||||||
<Skeleton className="mx-auto mt-2 h-5 w-3/4" />
|
|
||||||
<Skeleton className="mx-auto mt-6 h-12 w-full rounded-lg" />
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,126 +1,29 @@
|
|||||||
import Link from "next/link";
|
import { Suspense } from "react";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import { ArrowRight, CheckCircle2, XCircle } from "lucide-react";
|
|
||||||
import { verifySession } from "@/lib/session";
|
|
||||||
import { appDb, schema } from "@/lib/appdb";
|
|
||||||
import { kullanicininRaporu } from "@/lib/rapor-kaydi";
|
|
||||||
import { odemeyiSonuclandir } from "@/lib/odeme";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { PagePixelDivider } from "@/components/pixel-decor";
|
|
||||||
import { SiteFooter } from "@/components/site-footer";
|
import { SiteFooter } from "@/components/site-footer";
|
||||||
import { RaporYokCta } from "./rapor-yok-cta";
|
import {
|
||||||
|
OdemeSonucKarti,
|
||||||
|
OdemeSonucKartiSkeleton,
|
||||||
|
} from "@/features/odeme/components/odeme-sonuc-karti";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Ödeme Sonucu",
|
title: "Ödeme Sonucu",
|
||||||
robots: { index: false, follow: false },
|
robots: { index: false, follow: false },
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function OdemeSonucPage({
|
export default function OdemeSonucPage({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
searchParams: Promise<{ siparis?: string }>;
|
searchParams: Promise<{ siparis?: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { siparis } = await searchParams;
|
|
||||||
// Oturum düşmüşse geri dönüşte ?siparis= kaybolmasın (başarı/unblur deneyimi)
|
|
||||||
const geriDonus = siparis
|
|
||||||
? `/odeme/sonuc?siparis=${encodeURIComponent(siparis)}`
|
|
||||||
: "/paket";
|
|
||||||
const session = await verifySession(geriDonus);
|
|
||||||
|
|
||||||
const order = siparis
|
|
||||||
? await appDb.query.orders.findFirst({
|
|
||||||
where: eq(schema.orders.id, siparis),
|
|
||||||
})
|
|
||||||
: null;
|
|
||||||
|
|
||||||
// Yalnızca kendi siparişi görüntülenebilir
|
|
||||||
const sahibiMi = order?.userId === session.user.id;
|
|
||||||
|
|
||||||
// Self-healing: callback kaybolduysa burada tekrar doğrula
|
|
||||||
const durum =
|
|
||||||
order && sahibiMi
|
|
||||||
? order.status === "paid"
|
|
||||||
? "paid"
|
|
||||||
: await odemeyiSonuclandir(order.id)
|
|
||||||
: "not_found";
|
|
||||||
|
|
||||||
const basarili = durum === "paid";
|
|
||||||
|
|
||||||
// Rapor zaten üretildiyse ödül bir tık uzakta: kullanıcıyı doğrudan
|
|
||||||
// rapor sayfasına döndür ki blur çözülme animasyonuyla tam listeyi görsün.
|
|
||||||
const rapor = basarili ? await kullanicininRaporu(session.user.id) : null;
|
|
||||||
const listeHref = rapor ? "/listem?acildi=1" : null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col bg-slate-50 text-slate-900">
|
<div className="flex min-h-screen flex-col bg-slate-50 text-slate-900">
|
||||||
<main className="mx-auto flex w-full max-w-md flex-1 flex-col justify-center px-4 py-16 text-center">
|
<main className="mx-auto flex w-full max-w-md flex-1 flex-col justify-center px-4 py-16 text-center">
|
||||||
{basarili ? (
|
<Suspense fallback={<OdemeSonucKartiSkeleton />}>
|
||||||
<div className="rounded-2xl border border-emerald-200 bg-white p-8 shadow-sm">
|
{searchParams.then(({ siparis }) => (
|
||||||
<CheckCircle2
|
<OdemeSonucKarti siparis={siparis} />
|
||||||
className="mx-auto size-12 text-emerald-500"
|
))}
|
||||||
aria-hidden
|
</Suspense>
|
||||||
/>
|
|
||||||
<h1 className="mt-4 font-heading text-2xl font-bold">
|
|
||||||
Ödeme alındı 🎉
|
|
||||||
</h1>
|
|
||||||
<p className="mt-2 text-sm text-slate-600">
|
|
||||||
<span className="font-semibold">
|
|
||||||
{order!.credits} kredi
|
|
||||||
</span>{" "}
|
|
||||||
hesabına tanımlandı
|
|
||||||
{order!.product === "paket"
|
|
||||||
? " ve Tercih Dönemi Paketi'n aktif — kişisel listen, risk raporun ve PDF çıktın hazır olduğunda burada."
|
|
||||||
: "."}
|
|
||||||
</p>
|
|
||||||
<PagePixelDivider seed={79} className="mx-auto mt-5" />
|
|
||||||
<div className="mt-6 flex flex-col gap-2">
|
|
||||||
{listeHref ? (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
asChild
|
|
||||||
className="h-11 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
|
|
||||||
>
|
|
||||||
<Link href={listeHref}>
|
|
||||||
Listeni aç — kilidi kaldırdık
|
|
||||||
<ArrowRight className="size-4" aria-hidden />
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
<p className="text-center text-xs text-slate-500">
|
|
||||||
24 tercihin tamamı, gerekçeler, riskler ve PDF artık açık.
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<RaporYokCta />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="rounded-2xl border border-red-200 bg-white p-8 shadow-sm">
|
|
||||||
<XCircle className="mx-auto size-12 text-red-500" aria-hidden />
|
|
||||||
<h1 className="mt-4 font-heading text-2xl font-bold">
|
|
||||||
Ödeme tamamlanamadı
|
|
||||||
</h1>
|
|
||||||
<p className="mt-2 text-sm text-slate-600">
|
|
||||||
{durum === "pending" ? (
|
|
||||||
"Ödemen hâlâ işleniyor. Birkaç saniye sonra bu sayfayı yenile — kartından çekim yapıldıysa krediler mutlaka tanımlanır."
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
Çekim yapılmadı ya da işlem iptal edildi. Kartından para
|
|
||||||
çekildiğini düşünüyorsan{" "}
|
|
||||||
<Link href="/iletisim" className="underline">
|
|
||||||
bizimle iletişime geç
|
|
||||||
</Link>
|
|
||||||
.
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
<PagePixelDivider seed={83} className="mx-auto mt-5" />
|
|
||||||
<Button asChild variant="outline" className="mt-6 h-11 cursor-pointer">
|
|
||||||
<Link href="/paket">Tekrar dene</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</main>
|
</main>
|
||||||
<SiteFooter />
|
<SiteFooter />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import {
|
|||||||
ArrowRight,
|
ArrowRight,
|
||||||
ArrowUpRight,
|
ArrowUpRight,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
CheckCircle2,
|
|
||||||
Coins,
|
Coins,
|
||||||
Compass,
|
Compass,
|
||||||
FileText,
|
FileText,
|
||||||
@@ -12,18 +11,18 @@ import {
|
|||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
Wallet,
|
Wallet,
|
||||||
X,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import { HeroBaslik } from "@/components/hero-baslik";
|
import { ComparisonCell } from "@/features/pazarlama/components/landing-parcalari";
|
||||||
import { KapanisCta } from "@/components/hero-focus-button";
|
import { HeroBaslik } from "@/features/pazarlama/components/hero-baslik";
|
||||||
import { HeroForm } from "@/components/hero-form";
|
import { KapanisCta } from "@/features/pazarlama/components/hero-focus-button";
|
||||||
|
import { HeroForm } from "@/features/sihirbaz/components/hero-form";
|
||||||
import { Parallax } from "@/components/parallax";
|
import { Parallax } from "@/components/parallax";
|
||||||
import { Reveal } from "@/components/landing/reveal";
|
import { Reveal } from "@/features/pazarlama/components/reveal";
|
||||||
import { SosyalKanitBandi } from "@/components/landing/sosyal-kanit";
|
import { SosyalKanitBandi } from "@/features/pazarlama/components/sosyal-kanit";
|
||||||
import { TurkeyPixelMap } from "@/components/turkey-pixel-map";
|
import { TurkeyPixelMap } from "@/features/pazarlama/components/turkey-pixel-map";
|
||||||
import {
|
import {
|
||||||
PixelCorner,
|
PixelCorner,
|
||||||
PixelDivider,
|
PixelDivider,
|
||||||
@@ -188,17 +187,6 @@ const paketOzellikleri = [
|
|||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
function ComparisonCell({ value }: { value: boolean | string }) {
|
|
||||||
if (typeof value === "string") {
|
|
||||||
return <span className="text-sm font-medium">{value}</span>;
|
|
||||||
}
|
|
||||||
return value ? (
|
|
||||||
<CheckCircle2 className="mx-auto size-5 text-primary" aria-label="Var" />
|
|
||||||
) : (
|
|
||||||
<X className="mx-auto size-5 text-muted-foreground/50" aria-label="Yok" />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
import { Skeleton } from "@/components/ui/skeleton";
|
|
||||||
|
|
||||||
// /paket dinamik (kullanıcı kredisi) — statik kabuk için sayfa geometrisine
|
|
||||||
// uygun iskelet: ortalanmış başlık bloğu + paket kartı şeridi.
|
|
||||||
export default function Loading() {
|
|
||||||
return (
|
|
||||||
<main
|
|
||||||
aria-live="polite"
|
|
||||||
className="mx-auto w-full max-w-3xl flex-1 px-4 py-16"
|
|
||||||
>
|
|
||||||
<div className="flex flex-col items-center text-center">
|
|
||||||
<Skeleton className="h-6 w-56 rounded-full" />
|
|
||||||
<Skeleton className="mt-4 h-10 w-3/4 max-w-xl" />
|
|
||||||
<Skeleton className="mt-3 h-5 w-2/3 max-w-lg" />
|
|
||||||
<Skeleton className="mt-2 h-5 w-1/2 max-w-md" />
|
|
||||||
</div>
|
|
||||||
<div className="mt-10 grid gap-6 sm:grid-cols-[1fr_auto]">
|
|
||||||
<Skeleton className="h-80 w-full rounded-2xl" />
|
|
||||||
<Skeleton className="h-80 w-full rounded-2xl sm:w-64" />
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,23 +1,13 @@
|
|||||||
import Image from "next/image";
|
import { Suspense } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import {
|
|
||||||
ArrowRight,
|
|
||||||
CheckCircle2,
|
|
||||||
Coins,
|
|
||||||
FileText,
|
|
||||||
ListChecks,
|
|
||||||
MessageCircleQuestion,
|
|
||||||
RefreshCcw,
|
|
||||||
ShieldCheck,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { getCurrentUser } from "@/lib/session";
|
|
||||||
import { URUNLER, DENEME_KREDISI } from "@/lib/credits";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { SectionEyebrow } from "@/components/pixel-decor";
|
import { SectionEyebrow } from "@/components/pixel-decor";
|
||||||
import { Parallax } from "@/components/parallax";
|
import { Parallax } from "@/components/parallax";
|
||||||
import { SatinAlForm } from "./satin-al-form";
|
|
||||||
import { SiteFooter } from "@/components/site-footer";
|
import { SiteFooter } from "@/components/site-footer";
|
||||||
|
import {
|
||||||
|
PaketSatinAl,
|
||||||
|
PaketSatinAlSkeleton,
|
||||||
|
} from "@/features/odeme/components/paket-satinal";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Tercih Dönemi Paketi",
|
title: "Tercih Dönemi Paketi",
|
||||||
@@ -26,193 +16,57 @@ export const metadata: Metadata = {
|
|||||||
alternates: { canonical: "/paket" },
|
alternates: { canonical: "/paket" },
|
||||||
};
|
};
|
||||||
|
|
||||||
function tl(kurus: number) {
|
export default function PaketPage({
|
||||||
return (kurus / 100).toLocaleString("tr-TR", { maximumFractionDigits: 0 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const OZELLIKLER = [
|
|
||||||
{
|
|
||||||
icon: ListChecks,
|
|
||||||
text: "Sıralamana özel, gerekçeli 24 tercihlik dengeli liste",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: ShieldCheck,
|
|
||||||
text: "Her tercih için yerleşme olasılığı ve risk analizi (son 4 yıl trend)",
|
|
||||||
},
|
|
||||||
{ icon: FileText, text: "Veliyle paylaşılabilir PDF rapor" },
|
|
||||||
{ icon: RefreshCcw, text: "2 liste revizyon hakkı" },
|
|
||||||
{
|
|
||||||
icon: MessageCircleQuestion,
|
|
||||||
text: `${URUNLER.paket.credits} Yapay Zeka danışman sorusu (her mesaj 1 kredi)`,
|
|
||||||
},
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export default async function PaketPage({
|
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
searchParams: Promise<{ hata?: string }>;
|
searchParams: Promise<{ hata?: string }>;
|
||||||
}) {
|
}) {
|
||||||
const [u, { hata }] = await Promise.all([getCurrentUser(), searchParams]);
|
|
||||||
// Ödeme callback'i doğrulama hatasında buraya ?hata=token|siparis ile döner
|
|
||||||
const odemeHatasi = hata === "token" || hata === "siparis";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col bg-slate-50 text-slate-900">
|
<div className="flex min-h-screen flex-col bg-slate-50 text-slate-900">
|
||||||
<main className="mx-auto w-full max-w-3xl flex-1 px-4 py-16">
|
<main className="mx-auto w-full max-w-3xl flex-1 px-4 py-16">
|
||||||
{odemeHatasi ? (
|
{/* Ödeme callback'i doğrulama hatasında buraya ?hata=token|siparis ile
|
||||||
<div
|
döner; hata yokken hiçbir şey çizilmez — fallback bilerek boş. */}
|
||||||
role="alert"
|
<Suspense fallback={null}>
|
||||||
className="mb-8 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800"
|
{searchParams.then(({ hata }) =>
|
||||||
>
|
hata === "token" || hata === "siparis" ? (
|
||||||
Ödemen doğrulanamadı. Kartından çekim yapıldıysa kredilerin birkaç
|
<div
|
||||||
dakika içinde otomatik tanımlanır; tanımlanmazsa aşağıdan tekrar
|
role="alert"
|
||||||
deneyebilirsin — çekim yapılmadıysa hiçbir ücret ödemedin.
|
className="mb-8 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800"
|
||||||
</div>
|
>
|
||||||
) : null}
|
Ödemen doğrulanamadı. Kartından çekim yapıldıysa kredilerin
|
||||||
<Parallax fromStart strength={12} className="text-center">
|
birkaç dakika içinde otomatik tanımlanır; tanımlanmazsa
|
||||||
<SectionEyebrow>Tek seferlik ödeme · Abonelik yok</SectionEyebrow>
|
aşağıdan tekrar deneyebilirsin — çekim yapılmadıysa hiçbir
|
||||||
<h1 className="mt-4 font-heading text-3xl font-bold sm:text-4xl">
|
ücret ödemedin.
|
||||||
İnsan danışmanın işini,{" "}
|
|
||||||
<span className="text-primary">onda bir fiyatına</span>
|
|
||||||
</h1>
|
|
||||||
<p className="mx-auto mt-3 max-w-xl text-slate-600">
|
|
||||||
Tercih danışmanları bu dönemde 2.000–10.000 TL alıyor. KolayTercih
|
|
||||||
aynı işi gerçek YÖK Atlas verisiyle, dakikalar içinde yapar.
|
|
||||||
</p>
|
|
||||||
</Parallax>
|
|
||||||
|
|
||||||
<Parallax
|
|
||||||
strength={6}
|
|
||||||
className="mt-10 grid gap-6 sm:grid-cols-[1fr_auto]"
|
|
||||||
>
|
|
||||||
{/* Ana paket */}
|
|
||||||
<section className="rounded-2xl border-2 border-orange-500 bg-white p-8 shadow-sm">
|
|
||||||
<div className="flex items-baseline justify-between">
|
|
||||||
<h2 className="font-heading text-xl font-bold">
|
|
||||||
Tercih Dönemi Paketi
|
|
||||||
</h2>
|
|
||||||
<p className="font-heading text-3xl font-bold">
|
|
||||||
{tl(URUNLER.paket.amountKurus)} TL
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<ul className="mt-6 space-y-3">
|
) : null,
|
||||||
{OZELLIKLER.map((o) => (
|
)}
|
||||||
<li key={o.text} className="flex items-start gap-3 text-sm">
|
</Suspense>
|
||||||
<o.icon
|
<Parallax fromStart strength={12} className="text-center">
|
||||||
className="mt-0.5 size-4 shrink-0 text-emerald-600"
|
<SectionEyebrow>Tek seferlik ödeme · Abonelik yok</SectionEyebrow>
|
||||||
aria-hidden
|
<h1 className="mt-4 font-heading text-3xl font-bold sm:text-4xl">
|
||||||
/>
|
İnsan danışmanın işini,{" "}
|
||||||
{o.text}
|
<span className="text-primary">onda bir fiyatına</span>
|
||||||
</li>
|
</h1>
|
||||||
))}
|
<p className="mx-auto mt-3 max-w-xl text-slate-600">
|
||||||
</ul>
|
Tercih danışmanları bu dönemde 2.000–10.000 TL alıyor. KolayTercih
|
||||||
<div className="mt-8">
|
aynı işi gerçek YÖK Atlas verisiyle, dakikalar içinde yapar.
|
||||||
{u ? (
|
|
||||||
u.hasPaket ? (
|
|
||||||
<div className="flex items-center gap-2 rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-medium text-emerald-800">
|
|
||||||
<CheckCircle2 className="size-4" aria-hidden />
|
|
||||||
Paketin aktif —{" "}
|
|
||||||
<Link href="/" className="underline">
|
|
||||||
listeni oluştur
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<SatinAlForm
|
|
||||||
urun="paket"
|
|
||||||
label={`${tl(URUNLER.paket.amountKurus)} TL — Paketi al`}
|
|
||||||
vurgulu
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Button
|
|
||||||
asChild
|
|
||||||
className="h-12 w-full cursor-pointer bg-orange-500 px-8 text-white transition-colors duration-200 hover:bg-orange-600"
|
|
||||||
>
|
|
||||||
<Link
|
|
||||||
href={`/giris?callback=${encodeURIComponent("/paket")}`}
|
|
||||||
>
|
|
||||||
Giriş yap — sonra {tl(URUNLER.paket.amountKurus)} TL'ye
|
|
||||||
al
|
|
||||||
<ArrowRight className="size-4" aria-hidden />
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
<p className="text-center text-xs text-slate-500">
|
|
||||||
Girişte {DENEME_KREDISI} deneme kredisi de tanımlanır.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Image
|
|
||||||
src="/odeme/iyzico-logo-band.svg"
|
|
||||||
alt="iyzico ile Öde — Mastercard, Visa, American Express ve Troy ile güvenli ödeme"
|
|
||||||
width={429}
|
|
||||||
height={32}
|
|
||||||
className="mx-auto mt-5 h-5 w-auto"
|
|
||||||
/>
|
|
||||||
<p className="mt-3 text-center text-xs text-slate-500">
|
|
||||||
iyzico güvenli ödeme · Kart bilgilerin bize hiç ulaşmaz ·{" "}
|
|
||||||
<Link
|
|
||||||
href="/kosullar#iade"
|
|
||||||
className="underline hover:text-slate-700"
|
|
||||||
>
|
|
||||||
İade koşulları
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Top-up */}
|
|
||||||
<section className="flex h-fit flex-col rounded-2xl border border-slate-200 bg-white p-6 shadow-sm sm:w-56">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Coins className="size-4 text-amber-500" aria-hidden />
|
|
||||||
<h2 className="font-heading font-bold">Kredi bitti mi?</h2>
|
|
||||||
</div>
|
|
||||||
<p className="mt-2 flex-1 text-sm text-slate-600">
|
|
||||||
+{URUNLER.topup.credits} soru hakkı,{" "}
|
|
||||||
{tl(URUNLER.topup.amountKurus)} TL.
|
|
||||||
</p>
|
|
||||||
<div className="mt-4">
|
|
||||||
{/* Top-up hasPaket vermez: paketsiz biri alırsa parasını verip
|
|
||||||
yine maskeli liste görür. Satın alma yalnızca paketliye
|
|
||||||
açık; paketsize durum dürüstçe açıklanır. */}
|
|
||||||
{u?.hasPaket ? (
|
|
||||||
<SatinAlForm
|
|
||||||
urun="topup"
|
|
||||||
label={`+${URUNLER.topup.credits} kredi — ${tl(URUNLER.topup.amountKurus)} TL`}
|
|
||||||
/>
|
|
||||||
) : u ? (
|
|
||||||
<p className="rounded-xl border border-slate-200 bg-slate-50 px-3 py-2.5 text-xs leading-5 text-slate-600">
|
|
||||||
Ek kredi, kilitli analizleri açmaz — o yüzden önce paket
|
|
||||||
gerekir. Paket zaten {URUNLER.paket.credits} kredi içerir;
|
|
||||||
bitince buradan yükleyebilirsin.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
asChild
|
|
||||||
variant="outline"
|
|
||||||
className="h-11 w-full cursor-pointer"
|
|
||||||
>
|
|
||||||
<Link
|
|
||||||
href={`/giris?callback=${encodeURIComponent("/paket")}`}
|
|
||||||
>
|
|
||||||
Önce giriş yap
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</Parallax>
|
|
||||||
|
|
||||||
<p className="mt-10 text-center text-xs leading-relaxed text-slate-500">
|
|
||||||
KolayTercih bir karar destek aracıdır; öneriler resmî YÖK Atlas
|
|
||||||
verisine dayanır ancak yerleşme garantisi verilmez. Tercih
|
|
||||||
listesinin nihai sorumluluğu adaya aittir. Sorun mu var?{" "}
|
|
||||||
<Link href="/iletisim" className="underline hover:text-slate-700">
|
|
||||||
Bize yaz
|
|
||||||
</Link>
|
|
||||||
.
|
|
||||||
</p>
|
</p>
|
||||||
</main>
|
</Parallax>
|
||||||
|
|
||||||
|
<Suspense fallback={<PaketSatinAlSkeleton />}>
|
||||||
|
<PaketSatinAl />
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
|
<p className="mt-10 text-center text-xs leading-relaxed text-slate-500">
|
||||||
|
KolayTercih bir karar destek aracıdır; öneriler resmî YÖK Atlas
|
||||||
|
verisine dayanır ancak yerleşme garantisi verilmez. Tercih
|
||||||
|
listesinin nihai sorumluluğu adaya aittir. Sorun mu var?{" "}
|
||||||
|
<Link href="/iletisim" className="underline hover:text-slate-700">
|
||||||
|
Bize yaz
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
</main>
|
||||||
<SiteFooter />
|
<SiteFooter />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
import { Skeleton } from "@/components/ui/skeleton";
|
|
||||||
|
|
||||||
// /rapor/yazdir rapor sorgusunu bekler — belge düzeninde iskelet.
|
|
||||||
export default function Loading() {
|
|
||||||
return (
|
|
||||||
<main
|
|
||||||
aria-live="polite"
|
|
||||||
className="mx-auto w-full max-w-3xl bg-white p-8"
|
|
||||||
>
|
|
||||||
<div className="border-b-2 border-slate-200 pb-4">
|
|
||||||
<Skeleton className="h-8 w-64" />
|
|
||||||
<Skeleton className="mt-2 h-4 w-40" />
|
|
||||||
</div>
|
|
||||||
<div className="mt-6 space-y-3">
|
|
||||||
{Array.from({ length: 8 }).map((_, i) => (
|
|
||||||
<Skeleton key={i} className="h-14 w-full rounded-lg" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,142 +1,19 @@
|
|||||||
|
import { Suspense } from "react";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Image from "next/image";
|
import {
|
||||||
import { redirect } from "next/navigation";
|
RaporYazdir,
|
||||||
import { verifySession, getCurrentUser } from "@/lib/session";
|
RaporYazdirSkeleton,
|
||||||
import { kullanicininRaporu } from "@/lib/rapor-kaydi";
|
} from "@/features/rapor/components/rapor-yazdir";
|
||||||
import { PUAN_TURLERI } from "@/lib/db";
|
|
||||||
import type { RaporSonuc } from "@/lib/ai/rapor";
|
|
||||||
import type { RaporParams } from "@/lib/rapor-havuzu";
|
|
||||||
import { YazdirButonu } from "./yazdir-butonu";
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Tercih Raporu (Yazdır)",
|
title: "Tercih Raporu (Yazdır)",
|
||||||
robots: { index: false, follow: false },
|
robots: { index: false, follow: false },
|
||||||
};
|
};
|
||||||
|
|
||||||
const DILIM_LABEL: Record<string, string> = {
|
export default function YazdirPage() {
|
||||||
hayal: "Hayal (Riskli)",
|
|
||||||
dengeli: "Dengeli (Az riskli)",
|
|
||||||
garanti: "Garanti (Güvenli)",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async function YazdirPage() {
|
|
||||||
const session = await verifySession("/rapor/yazdir");
|
|
||||||
// Kullanıcı satırı ile rapor sorgusu bağımsız — paralel çöz
|
|
||||||
const [user, satir] = await Promise.all([
|
|
||||||
getCurrentUser(),
|
|
||||||
kullanicininRaporu(session.user.id),
|
|
||||||
]);
|
|
||||||
if (!user) redirect("/giris");
|
|
||||||
if (!user.hasPaket) redirect("/paket");
|
|
||||||
if (!satir?.result) redirect("/sonuc");
|
|
||||||
|
|
||||||
const rapor = satir.result as RaporSonuc;
|
|
||||||
const params = satir.params as RaporParams;
|
|
||||||
const tarih = new Date(satir.updatedAt).toLocaleDateString("tr-TR", {
|
|
||||||
day: "numeric",
|
|
||||||
month: "long",
|
|
||||||
year: "numeric",
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-3xl bg-white p-8 text-slate-900 print:p-[10mm]">
|
<Suspense fallback={<RaporYazdirSkeleton />}>
|
||||||
<YazdirButonu />
|
<RaporYazdir />
|
||||||
|
</Suspense>
|
||||||
<header className="border-b-2 border-slate-900 pb-4">
|
|
||||||
<h1 className="font-heading text-2xl font-bold">
|
|
||||||
{rapor.tercihler.length} Tercihlik Kişisel Liste ve Risk Raporu
|
|
||||||
</h1>
|
|
||||||
<p className="mt-1 text-sm text-slate-600">
|
|
||||||
Başarı sıralaması: {params.sira.toLocaleString("tr-TR")} · Puan
|
|
||||||
türü: {PUAN_TURLERI[params.tur]}
|
|
||||||
{params.il ? ` · İl: ${params.il}` : ""} · {tarih}
|
|
||||||
</p>
|
|
||||||
{rapor.kapsam?.uyarlandi ? (
|
|
||||||
<p className="mt-1 text-sm text-slate-600">
|
|
||||||
Not: Tercihlerine göre {rapor.kapsam.toplam} uygun program bulundu;
|
|
||||||
liste {rapor.kapsam.dagilim.hayal} hayal ·{" "}
|
|
||||||
{rapor.kapsam.dagilim.dengeli} dengeli ·{" "}
|
|
||||||
{rapor.kapsam.dagilim.garanti} garanti olarak kuruldu.
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section className="mt-5">
|
|
||||||
<h2 className="font-heading text-base font-bold">
|
|
||||||
Genel değerlendirme
|
|
||||||
</h2>
|
|
||||||
<p className="mt-1 whitespace-pre-line text-sm leading-relaxed">
|
|
||||||
{rapor.genelDegerlendirme}
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<table className="mt-5 w-full border-collapse text-xs">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b-2 border-slate-900 text-left">
|
|
||||||
<th className="py-2 pr-2">#</th>
|
|
||||||
<th className="py-2 pr-2">Program / Üniversite</th>
|
|
||||||
<th className="py-2 pr-2">Dilim</th>
|
|
||||||
<th className="py-2 pr-2">Gerekçe</th>
|
|
||||||
<th className="py-2 pr-2">Risk notu</th>
|
|
||||||
<th className="py-2">Trend</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{rapor.tercihler.map((t) => {
|
|
||||||
const p = rapor.programlar[t.programId];
|
|
||||||
return (
|
|
||||||
<tr
|
|
||||||
key={t.sira}
|
|
||||||
className="break-inside-avoid border-b border-slate-200 align-top"
|
|
||||||
>
|
|
||||||
<td className="py-2 pr-2 font-bold">{t.sira}</td>
|
|
||||||
<td className="py-2 pr-2">
|
|
||||||
<span className="font-semibold">{p?.isim}</span>
|
|
||||||
<br />
|
|
||||||
<span className="text-slate-600">
|
|
||||||
{p?.universite} · {p?.il ?? "—"}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="py-2 pr-2">{DILIM_LABEL[t.dilim]}</td>
|
|
||||||
<td className="py-2 pr-2">{t.gerekce}</td>
|
|
||||||
<td className="py-2 pr-2">{t.riskNotu}</td>
|
|
||||||
<td className="py-2">{t.trendOzeti}</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<footer className="mt-6 border-t border-slate-300 pt-3 text-[10px] leading-relaxed text-slate-500">
|
|
||||||
<p>
|
|
||||||
Bu rapor KolayTercih tarafından resmî YÖK Atlas verisi (2021–2025
|
|
||||||
taban sıralamaları, kontenjan ve yerleşme istatistikleri) kullanılarak
|
|
||||||
üretilmiştir. Taban sıralamaları her yıl değişir; bu rapor yerleşme
|
|
||||||
garantisi vermez. Tercih listesinin ÖSYM sistemine girilmesi ve nihai
|
|
||||||
sorumluluğu adaya aittir.
|
|
||||||
</p>
|
|
||||||
<div className="mt-4 flex break-inside-avoid items-center justify-between">
|
|
||||||
<div className="flex items-center font-bricolage text-slate-900">
|
|
||||||
<Image
|
|
||||||
src="/kolay-tercih-mark.svg"
|
|
||||||
alt=""
|
|
||||||
width={44}
|
|
||||||
height={44}
|
|
||||||
aria-hidden="true"
|
|
||||||
className="size-11 shrink-0"
|
|
||||||
/>
|
|
||||||
<span>
|
|
||||||
<span className="font-light text-3xl tracking-tighter">Kolay</span>
|
|
||||||
<span className="font-semibold text-3xl tracking-tighter">
|
|
||||||
Tercih
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span className="font-bricolage text-sm font-medium text-slate-900">
|
|
||||||
kolaytercih.com
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { loadRehberOgFonts } from "@/lib/og-fonts";
|
|||||||
import {
|
import {
|
||||||
rehberBasliginiBol,
|
rehberBasliginiBol,
|
||||||
rehberMetniniParcala,
|
rehberMetniniParcala,
|
||||||
} from "@/components/rehber-kapak";
|
} from "@/features/rehber/components/rehber-kapak";
|
||||||
|
|
||||||
export const alt = "KolayTercih Tercih Rehberi yazı kapağı";
|
export const alt = "KolayTercih Tercih Rehberi yazı kapağı";
|
||||||
export const size = { width: 1200, height: 630 };
|
export const size = { width: 1200, height: 630 };
|
||||||
|
|||||||
@@ -1,153 +1,52 @@
|
|||||||
|
import { Suspense } from "react";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { notFound } from "next/navigation";
|
|
||||||
import { ChevronRight } from "lucide-react";
|
import { ChevronRight } from "lucide-react";
|
||||||
import { getAllRehberSlugs, getAllRehberler, getRehber } from "@/lib/rehber";
|
|
||||||
import { JsonLd, articleJsonLd, breadcrumbJsonLd } from "@/lib/seo";
|
|
||||||
import { CtaSiraForm } from "@/components/cta-sira-form";
|
|
||||||
import { ElCizimiSema } from "@/components/el-cizimi-sema";
|
|
||||||
import { RehberKapak } from "@/components/rehber-kapak";
|
|
||||||
import { SiteFooter } from "@/components/site-footer";
|
import { SiteFooter } from "@/components/site-footer";
|
||||||
|
import {
|
||||||
|
RehberMakale,
|
||||||
|
RehberMakaleSkeleton,
|
||||||
|
rehberStaticSlugs,
|
||||||
|
rehberYaziMetadata,
|
||||||
|
} from "@/features/rehber/components/rehber-makale";
|
||||||
|
|
||||||
export function generateStaticParams() {
|
export function generateStaticParams() {
|
||||||
const slugs = getAllRehberSlugs();
|
return rehberStaticSlugs().map((slug) => ({ slug }));
|
||||||
if (slugs.length === 0) {
|
|
||||||
throw new Error("rehber: content/rehber altında hiç makale yok");
|
|
||||||
}
|
|
||||||
return slugs.map((slug) => ({ slug }));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata({
|
export async function generateMetadata({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: PageProps<"/rehber/[slug]">): Promise<Metadata> {
|
||||||
params: Promise<{ slug: string }>;
|
|
||||||
}): Promise<Metadata> {
|
|
||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
const yazi = getRehber(slug);
|
return rehberYaziMetadata(slug);
|
||||||
if (!yazi) return {};
|
|
||||||
return {
|
|
||||||
title: yazi.baslik,
|
|
||||||
description: yazi.aciklama,
|
|
||||||
alternates: { canonical: `/rehber/${slug}` },
|
|
||||||
openGraph: {
|
|
||||||
type: "article",
|
|
||||||
title: yazi.baslik,
|
|
||||||
description: yazi.aciklama,
|
|
||||||
publishedTime: yazi.tarih,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function RehberYaziPage({
|
export default function RehberYaziPage({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: PageProps<"/rehber/[slug]">) {
|
||||||
params: Promise<{ slug: string }>;
|
|
||||||
}) {
|
|
||||||
const { slug } = await params;
|
|
||||||
const yazi = getRehber(slug);
|
|
||||||
if (!yazi) notFound();
|
|
||||||
|
|
||||||
const digerYazilar = getAllRehberler()
|
|
||||||
.filter((y) => y.slug !== slug)
|
|
||||||
.slice(0, 4);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<main className="mx-auto w-full max-w-3xl px-4 py-12 sm:py-16">
|
<main className="mx-auto w-full max-w-3xl px-4 py-12 sm:py-16">
|
||||||
<JsonLd
|
<nav
|
||||||
data={articleJsonLd({
|
aria-label="breadcrumb"
|
||||||
title: yazi.baslik,
|
className="flex items-center gap-1 text-xs text-slate-500"
|
||||||
description: yazi.aciklama,
|
|
||||||
path: `/rehber/${slug}`,
|
|
||||||
datePublished: yazi.tarih,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
<JsonLd
|
|
||||||
data={breadcrumbJsonLd([
|
|
||||||
{ name: "Ana Sayfa", path: "/" },
|
|
||||||
{ name: "Rehber", path: "/rehber" },
|
|
||||||
{ name: yazi.baslik, path: `/rehber/${slug}` },
|
|
||||||
])}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<nav
|
|
||||||
aria-label="breadcrumb"
|
|
||||||
className="flex items-center gap-1 text-xs text-slate-500"
|
|
||||||
>
|
|
||||||
<Link href="/" className="hover:text-primary hover:underline">
|
|
||||||
Ana Sayfa
|
|
||||||
</Link>
|
|
||||||
<ChevronRight className="size-3" aria-hidden />
|
|
||||||
<Link href="/rehber" className="hover:text-primary hover:underline">
|
|
||||||
Rehber
|
|
||||||
</Link>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<article>
|
|
||||||
<RehberKapak baslik={yazi.baslik} className="mt-6 shadow-sm" />
|
|
||||||
<h1 className="mt-8 font-heading text-3xl font-bold text-slate-900 sm:text-4xl">
|
|
||||||
{yazi.baslik}
|
|
||||||
</h1>
|
|
||||||
<p className="mt-3 text-base leading-7 text-slate-600">
|
|
||||||
{yazi.aciklama}
|
|
||||||
</p>
|
|
||||||
<div
|
|
||||||
className="mt-8 text-[15px] leading-7 text-slate-700
|
|
||||||
[&_h2]:mt-10 [&_h2]:font-heading [&_h2]:text-xl [&_h2]:font-bold [&_h2]:text-slate-900
|
|
||||||
[&_h3]:mt-6 [&_h3]:font-heading [&_h3]:text-base [&_h3]:font-bold [&_h3]:text-slate-900
|
|
||||||
[&_p]:mt-3
|
|
||||||
[&_a]:font-medium [&_a]:text-primary hover:[&_a]:underline
|
|
||||||
[&_strong]:font-semibold [&_strong]:text-slate-900
|
|
||||||
[&_ul]:mt-3 [&_ul]:list-disc [&_ul]:space-y-1.5 [&_ul]:pl-6
|
|
||||||
[&_ol]:mt-3 [&_ol]:list-decimal [&_ol]:space-y-1.5 [&_ol]:pl-6
|
|
||||||
[&_table]:mt-4 [&_table]:w-full [&_table]:border-collapse [&_table]:text-sm
|
|
||||||
[&_th]:border [&_th]:border-slate-200 [&_th]:bg-slate-50 [&_th]:px-3 [&_th]:py-2 [&_th]:text-left [&_th]:font-semibold
|
|
||||||
[&_td]:border [&_td]:border-slate-200 [&_td]:px-3 [&_td]:py-2
|
|
||||||
[&_blockquote]:mt-4 [&_blockquote]:border-l-4 [&_blockquote]:border-primary/30 [&_blockquote]:pl-4 [&_blockquote]:italic [&_blockquote]:text-slate-600"
|
|
||||||
>
|
>
|
||||||
{yazi.parcalar.map((parca, i) =>
|
<Link href="/" className="hover:text-primary hover:underline">
|
||||||
parca.tip === "html" ? (
|
Ana Sayfa
|
||||||
<div
|
</Link>
|
||||||
key={i}
|
<ChevronRight className="size-3" aria-hidden />
|
||||||
dangerouslySetInnerHTML={{ __html: parca.html }}
|
<Link href="/rehber" className="hover:text-primary hover:underline">
|
||||||
/>
|
Rehber
|
||||||
) : (
|
</Link>
|
||||||
<ElCizimiSema
|
</nav>
|
||||||
key={i}
|
|
||||||
tanim={parca.sema.tanim}
|
|
||||||
ariaLabel={parca.sema.ariaLabel}
|
|
||||||
figcaption={parca.sema.altyazi}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<div className="mt-12">
|
<Suspense fallback={<RehberMakaleSkeleton />}>
|
||||||
<CtaSiraForm />
|
{params.then(({ slug }) => (
|
||||||
</div>
|
<RehberMakale slug={slug} />
|
||||||
|
))}
|
||||||
{digerYazilar.length > 0 ? (
|
</Suspense>
|
||||||
<section className="mt-12">
|
</main>
|
||||||
<h2 className="font-heading text-xl font-bold text-slate-900">
|
<SiteFooter />
|
||||||
Diğer rehber yazıları
|
|
||||||
</h2>
|
|
||||||
<ul className="mt-3 space-y-2">
|
|
||||||
{digerYazilar.map((y) => (
|
|
||||||
<li key={y.slug}>
|
|
||||||
<Link
|
|
||||||
href={`/rehber/${y.slug}`}
|
|
||||||
className="text-sm font-medium text-primary hover:underline"
|
|
||||||
>
|
|
||||||
{y.baslik}
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
) : null}
|
|
||||||
</main>
|
|
||||||
<SiteFooter />
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
|
import { Suspense } from "react";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
|
||||||
import { ArrowRight } from "lucide-react";
|
|
||||||
import { getAllRehberler } from "@/lib/rehber";
|
|
||||||
import { JsonLd, breadcrumbJsonLd } from "@/lib/seo";
|
import { JsonLd, breadcrumbJsonLd } from "@/lib/seo";
|
||||||
import { CtaSiraForm } from "@/components/cta-sira-form";
|
import { CtaSiraForm } from "@/features/sihirbaz/components/cta-sira-form";
|
||||||
import { RehberKapak } from "@/components/rehber-kapak";
|
|
||||||
import { SiteFooter } from "@/components/site-footer";
|
import { SiteFooter } from "@/components/site-footer";
|
||||||
import { PagePixelDivider } from "@/components/pixel-decor";
|
import { PagePixelDivider } from "@/components/pixel-decor";
|
||||||
|
import {
|
||||||
|
RehberListesi,
|
||||||
|
RehberListesiSkeleton,
|
||||||
|
} from "@/features/rehber/components/rehber-listesi";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Tercih Rehberi 2026 — YKS Tercih Dönemi Yazıları",
|
title: "Tercih Rehberi 2026 — YKS Tercih Dönemi Yazıları",
|
||||||
@@ -16,62 +17,35 @@ export const metadata: Metadata = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function RehberIndexPage() {
|
export default function RehberIndexPage() {
|
||||||
const yazilar = getAllRehberler();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<main className="mx-auto w-full max-w-6xl px-4 py-12 sm:py-16">
|
<main className="mx-auto w-full max-w-6xl px-4 py-12 sm:py-16">
|
||||||
<JsonLd
|
<JsonLd
|
||||||
data={breadcrumbJsonLd([
|
data={breadcrumbJsonLd([
|
||||||
{ name: "Ana Sayfa", path: "/" },
|
{ name: "Ana Sayfa", path: "/" },
|
||||||
{ name: "Rehber", path: "/rehber" },
|
{ name: "Rehber", path: "/rehber" },
|
||||||
])}
|
])}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<h1 className="font-heading text-3xl font-bold text-slate-900 sm:text-4xl">
|
<h1 className="font-heading text-3xl font-bold text-slate-900 sm:text-4xl">
|
||||||
Tercih Rehberi
|
Tercih Rehberi
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-4 max-w-2xl text-sm leading-7 text-slate-600">
|
<p className="mt-4 max-w-2xl text-sm leading-7 text-slate-600">
|
||||||
Tercih döneminin en çok sorulan soruları, veriye dayalı ve öğrenci
|
Tercih döneminin en çok sorulan soruları, veriye dayalı ve öğrenci
|
||||||
diliyle. Panik yok, pazarlama yok — sadece işine yarayacak bilgi.
|
diliyle. Panik yok, pazarlama yok — sadece işine yarayacak bilgi.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<PagePixelDivider seed={11} className="mt-8" />
|
<PagePixelDivider seed={11} className="mt-8" />
|
||||||
|
|
||||||
<ul className="mt-8 columns-1 gap-5 sm:columns-2 lg:columns-3">
|
<Suspense fallback={<RehberListesiSkeleton />}>
|
||||||
{yazilar.map((y) => (
|
<RehberListesi />
|
||||||
<li key={y.slug} className="mb-5 break-inside-avoid">
|
</Suspense>
|
||||||
<Link
|
|
||||||
href={`/rehber/${y.slug}`}
|
|
||||||
className="block overflow-hidden rounded-3xl border border-slate-200 bg-white p-2 shadow-sm focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-primary"
|
|
||||||
>
|
|
||||||
<RehberKapak
|
|
||||||
baslik={y.baslik}
|
|
||||||
compact
|
|
||||||
className="rounded-[1.15rem]"
|
|
||||||
/>
|
|
||||||
<div className="px-3 pb-3 pt-4">
|
|
||||||
<p className="text-sm leading-6 text-slate-600">
|
|
||||||
{y.aciklama}
|
|
||||||
</p>
|
|
||||||
<span className="mt-4 inline-flex items-center gap-1.5 font-bricolage text-sm font-semibold text-primary">
|
|
||||||
Yazıyı oku
|
|
||||||
<ArrowRight
|
|
||||||
className="size-4"
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<div className="mx-auto mt-16 max-w-3xl">
|
<div className="mx-auto mt-16 max-w-3xl">
|
||||||
<CtaSiraForm />
|
<CtaSiraForm />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<SiteFooter />
|
<SiteFooter />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
import { Skeleton } from "@/components/ui/skeleton";
|
|
||||||
|
|
||||||
// /sonuc senkron SQLite sorgularıyla açılır; yavaş bağlantıda beyaz ekran
|
|
||||||
// yerine sayfanın iskeletini göster.
|
|
||||||
export default function SonucLoading() {
|
|
||||||
return (
|
|
||||||
<main className="mx-auto w-full max-w-6xl px-4 py-10">
|
|
||||||
<Skeleton className="h-6 w-40 rounded-full" />
|
|
||||||
<Skeleton className="mt-4 h-9 w-3/4 max-w-xl" />
|
|
||||||
<Skeleton className="mt-3 h-5 w-2/3 max-w-lg" />
|
|
||||||
<Skeleton className="mt-8 h-36 w-full rounded-2xl" />
|
|
||||||
<div className="mt-8 flex flex-col gap-2">
|
|
||||||
{Array.from({ length: 8 }).map((_, i) => (
|
|
||||||
<Skeleton key={i} className="h-14 w-full rounded-xl" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,19 +1,16 @@
|
|||||||
|
import { Suspense } from "react";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import { PUAN_TURLERI, type PuanTuruKey } from "@/types/yokatlas";
|
||||||
import {
|
import {
|
||||||
PUAN_TURLERI,
|
SonucProgramTablosu,
|
||||||
rankWindowFacets,
|
SonucProgramTablosuSkeleton,
|
||||||
searchByRank,
|
} from "@/features/liste/components/sonuc-program-tablosu";
|
||||||
type PuanTuruKey,
|
import { ManuelHarita } from "@/features/liste/components/manuel-harita";
|
||||||
} from "@/lib/db";
|
import { TadimlikBolumu } from "@/features/rapor/components/tadimlik-bolumu";
|
||||||
import { getSession } from "@/lib/session";
|
import { SonucSihirbazi } from "@/features/sihirbaz/components/sihirbaz-bolumu";
|
||||||
import { kullanicininRaporu } from "@/lib/rapor-kaydi";
|
import { SiraGerekli } from "@/features/sihirbaz/components/sira-gerekli";
|
||||||
import { tadimlikSec } from "@/lib/tadimlik-havuzu";
|
|
||||||
import { SihirbazBolumu } from "./sihirbaz-cagri-karti";
|
|
||||||
import { TadimlikSatiri } from "./tadimlik-satiri";
|
|
||||||
import { ProgramTablosu } from "@/components/program-tablosu";
|
|
||||||
import { ManuelHarita } from "@/components/manuel-liste/manuel-harita";
|
|
||||||
import { SiteFooter } from "@/components/site-footer";
|
import { SiteFooter } from "@/components/site-footer";
|
||||||
import { SiraGerekli } from "./sira-gerekli";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Tercih Listeni Kur",
|
title: "Tercih Listeni Kur",
|
||||||
@@ -23,7 +20,7 @@ export const metadata: Metadata = {
|
|||||||
robots: { index: false, follow: true },
|
robots: { index: false, follow: true },
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function SonucPage({
|
export default function SonucPage({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
searchParams: Promise<{
|
searchParams: Promise<{
|
||||||
@@ -33,76 +30,73 @@ export default async function SonucPage({
|
|||||||
hazir?: string;
|
hazir?: string;
|
||||||
}>;
|
}>;
|
||||||
}) {
|
}) {
|
||||||
const params = await searchParams;
|
|
||||||
const sira = Number.parseInt(params.sira ?? "", 10);
|
|
||||||
const turKey: PuanTuruKey =
|
|
||||||
params.tur && params.tur in PUAN_TURLERI
|
|
||||||
? (params.tur as PuanTuruKey)
|
|
||||||
: "say";
|
|
||||||
|
|
||||||
if (!Number.isFinite(sira) || sira < 1 || sira > 4_000_000) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<SiraGerekli />
|
|
||||||
<SiteFooter />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sihirbaz için: kullanıcının kayıtlı raporu var mı? Listenin kendisi
|
|
||||||
// /listem'de yaşar; burada yalnızca CTA'nın hangi hâlde render edileceği
|
|
||||||
// için varlık bilgisi gerekir.
|
|
||||||
const session = await getSession();
|
|
||||||
const raporSatiri = session
|
|
||||||
? await kullanicininRaporu(session.user.id)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const facetler = rankWindowFacets(sira, turKey);
|
|
||||||
// Yapay Zeka'sız ham liste: yalnızca sıralamayla ulaşılabilen programlar (dilimli)
|
|
||||||
const hamSonuclar = searchByRank(sira, turKey, { limitPerBucket: 20 });
|
|
||||||
|
|
||||||
// Girişsiz tadımlık (İP-B): SSR'da kovaya uyan "genel" satır seçilir;
|
|
||||||
// sihirbaz kategorisi client'ta okunup /api/tadimlik ile kişiselleştirilir.
|
|
||||||
// Girişli kullanıcı gerçek ürünü zaten görebildiği için tadımlık almaz.
|
|
||||||
const tadimlik = session ? null : await tadimlikSec(sira, turKey);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-50 text-slate-900">
|
// Tablo değişken boylu olduğundan altındaki tadımlık, sihirbaz, dipnot ve
|
||||||
<main className="mx-auto max-w-4xl px-4 py-12">
|
// footer aynı sınırda gruplu — bugünkü tek await'li akışla birebir aynı
|
||||||
{/* Sayfa hiyerarşisi: önce Türkiye haritası, sonra liste. Manuel
|
// anda beliriyorlar, ara durumda zıplama yok.
|
||||||
liste store'una abone olduğundan tablodan "+" ile eklenenler
|
<Suspense
|
||||||
buraya canlı yansır. */}
|
fallback={
|
||||||
<ManuelHarita />
|
<main className="mx-auto w-full max-w-6xl px-4 py-10">
|
||||||
|
<Skeleton className="h-6 w-40 rounded-full" />
|
||||||
|
<Skeleton className="mt-4 h-9 w-3/4 max-w-xl" />
|
||||||
|
<Skeleton className="mt-3 h-5 w-2/3 max-w-lg" />
|
||||||
|
<Skeleton className="mt-8 h-36 w-full rounded-2xl" />
|
||||||
|
<SonucProgramTablosuSkeleton />
|
||||||
|
</main>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{searchParams.then((params) => {
|
||||||
|
const sira = Number.parseInt(params.sira ?? "", 10);
|
||||||
|
const turKey: PuanTuruKey =
|
||||||
|
params.tur && params.tur in PUAN_TURLERI
|
||||||
|
? (params.tur as PuanTuruKey)
|
||||||
|
: "say";
|
||||||
|
|
||||||
{/* Yapay Zeka'dan bağımsız ham tablo — haritanın hemen altında, sayfanın ana
|
if (!Number.isFinite(sira) || sira < 1 || sira > 4_000_000) {
|
||||||
gövdesi; puan türü navbar'daki sıralama rozetinden değiştirilir */}
|
return (
|
||||||
<ProgramTablosu sonuclar={hamSonuclar} adaySira={sira} tur={turKey} />
|
<>
|
||||||
|
<SiraGerekli />
|
||||||
|
<SiteFooter />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
{/* Girişsiz tadımlık: gerçek rapor satırı formatında tek örnek.
|
return (
|
||||||
Havuzda içerik yoksa bileşen hiç render edilmez. */}
|
<div className="min-h-screen bg-slate-50 text-slate-900">
|
||||||
{!session ? (
|
<main className="mx-auto max-w-4xl px-4 py-12">
|
||||||
<TadimlikSatiri sira={sira} tur={turKey} ilk={tadimlik} />
|
{/* Sayfa hiyerarşisi: önce Türkiye haritası, sonra liste. Manuel
|
||||||
) : null}
|
liste store'una abone olduğundan tablodan "+" ile eklenenler
|
||||||
|
buraya canlı yansır. */}
|
||||||
|
<ManuelHarita />
|
||||||
|
|
||||||
{/* Sihirbaz akışı (CTA + modal) — üretim ve liste /listem'de yaşar,
|
{/* Yapay Zeka'dan bağımsız ham tablo — haritanın hemen altında,
|
||||||
burada yalnızca funnel kartları render edilir */}
|
sayfanın ana gövdesi; puan türü navbar'daki sıralama
|
||||||
<SihirbazBolumu
|
rozetinden değiştirilir */}
|
||||||
sira={sira}
|
<SonucProgramTablosu sira={sira} tur={turKey} />
|
||||||
tur={turKey}
|
|
||||||
facetler={facetler}
|
|
||||||
girisliMi={Boolean(session)}
|
|
||||||
raporVar={Boolean(raporSatiri?.result)}
|
|
||||||
otomatikAc={params.sihirbaz === "1"}
|
|
||||||
secimlerHazir={params.hazir === "1"}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<p className="mt-10 text-center text-xs leading-relaxed text-slate-500">
|
{/* Girişsiz tadımlık: gerçek rapor satırı formatında tek örnek.
|
||||||
Veriler resmî YÖK Atlas kaynağından derlenmiştir. KolayTercih bir karar
|
Havuzda içerik yoksa bileşen hiç render edilmez. */}
|
||||||
destek aracıdır; yerleşme garantisi vermez, tercih sorumluluğu adaya
|
<TadimlikBolumu sira={sira} tur={turKey} />
|
||||||
aittir.
|
|
||||||
</p>
|
{/* Sihirbaz akışı (CTA + modal) — üretim ve liste /listem'de
|
||||||
</main>
|
yaşar, burada yalnızca funnel kartları render edilir */}
|
||||||
<SiteFooter />
|
<SonucSihirbazi
|
||||||
</div>
|
sira={sira}
|
||||||
|
tur={turKey}
|
||||||
|
otomatikAc={params.sihirbaz === "1"}
|
||||||
|
secimlerHazir={params.hazir === "1"}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p className="mt-10 text-center text-xs leading-relaxed text-slate-500">
|
||||||
|
Veriler resmî YÖK Atlas kaynağından derlenmiştir. KolayTercih
|
||||||
|
bir karar destek aracıdır; yerleşme garantisi vermez, tercih
|
||||||
|
sorumluluğu adaya aittir.
|
||||||
|
</p>
|
||||||
|
</main>
|
||||||
|
<SiteFooter />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Suspense>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,31 @@
|
|||||||
|
import { Suspense } from "react";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { getAllUniversiteSlugs } from "@/lib/katalog";
|
import {
|
||||||
import { UniversiteIcerik, uniSayfaMetadata } from "./universite-icerik";
|
UniversiteIcerik,
|
||||||
|
UniversiteIcerikSkeleton,
|
||||||
|
uniSayfaMetadata,
|
||||||
|
uniStaticSlugs,
|
||||||
|
} from "@/features/katalog/components/universite-icerik";
|
||||||
|
|
||||||
export function generateStaticParams() {
|
export function generateStaticParams() {
|
||||||
return getAllUniversiteSlugs().map((slug) => ({ slug }));
|
return uniStaticSlugs().map((slug) => ({ slug }));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata({
|
export async function generateMetadata({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: PageProps<"/universite/[slug]">): Promise<Metadata> {
|
||||||
params: Promise<{ slug: string }>;
|
|
||||||
}): Promise<Metadata> {
|
|
||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
return uniSayfaMetadata(slug, 1);
|
return uniSayfaMetadata(slug, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function UniversitePage({
|
export default function UniversitePage({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: PageProps<"/universite/[slug]">) {
|
||||||
params: Promise<{ slug: string }>;
|
return (
|
||||||
}) {
|
<Suspense fallback={<UniversiteIcerikSkeleton />}>
|
||||||
const { slug } = await params;
|
{params.then(({ slug }) => (
|
||||||
return <UniversiteIcerik slug={slug} sayfa={1} />;
|
<UniversiteIcerik slug={slug} sayfa={1} />
|
||||||
|
))}
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,17 @@
|
|||||||
|
import { Suspense } from "react";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { getAllUniversiteler } from "@/lib/katalog";
|
|
||||||
import {
|
import {
|
||||||
UniversiteIcerik,
|
UniversiteIcerik,
|
||||||
|
UniversiteIcerikSkeleton,
|
||||||
uniSayfaMetadata,
|
uniSayfaMetadata,
|
||||||
uniToplamSayfa,
|
uniSayfaStaticParams,
|
||||||
} from "../../universite-icerik";
|
} from "@/features/katalog/components/universite-icerik";
|
||||||
|
|
||||||
// Yalnızca 1'den fazla sayfası olan üniversiteler için 2..N üretilir;
|
// Yalnızca 1'den fazla sayfası olan üniversiteler için 2..N üretilir;
|
||||||
// 1. sayfa taban rota (/universite/[slug]) olduğundan burada üretilmez.
|
// 1. sayfa taban rota (/universite/[slug]) olduğundan burada üretilmez.
|
||||||
export function generateStaticParams() {
|
export function generateStaticParams() {
|
||||||
return getAllUniversiteler().flatMap((u) => {
|
return uniSayfaStaticParams();
|
||||||
const toplam = uniToplamSayfa(u.programSayisi);
|
|
||||||
return Array.from({ length: Math.max(0, toplam - 1) }, (_, i) => ({
|
|
||||||
slug: u.slug,
|
|
||||||
no: String(i + 2),
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function sayfaNo(no: string): number | null {
|
function sayfaNo(no: string): number | null {
|
||||||
@@ -26,22 +21,23 @@ function sayfaNo(no: string): number | null {
|
|||||||
|
|
||||||
export async function generateMetadata({
|
export async function generateMetadata({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: PageProps<"/universite/[slug]/sayfa/[no]">): Promise<Metadata> {
|
||||||
params: Promise<{ slug: string; no: string }>;
|
|
||||||
}): Promise<Metadata> {
|
|
||||||
const { slug, no } = await params;
|
const { slug, no } = await params;
|
||||||
const n = sayfaNo(no);
|
const n = sayfaNo(no);
|
||||||
if (n == null) return {};
|
if (n == null) return {};
|
||||||
return uniSayfaMetadata(slug, n);
|
return uniSayfaMetadata(slug, n);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function UniversiteSayfaPage({
|
export default function UniversiteSayfaPage({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: PageProps<"/universite/[slug]/sayfa/[no]">) {
|
||||||
params: Promise<{ slug: string; no: string }>;
|
return (
|
||||||
}) {
|
<Suspense fallback={<UniversiteIcerikSkeleton />}>
|
||||||
const { slug, no } = await params;
|
{params.then(({ slug, no }) => {
|
||||||
const n = sayfaNo(no);
|
const n = sayfaNo(no);
|
||||||
if (n == null) notFound();
|
if (n == null) notFound();
|
||||||
return <UniversiteIcerik slug={slug} sayfa={n} />;
|
return <UniversiteIcerik slug={slug} sayfa={n} />;
|
||||||
|
})}
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
|
import { Suspense } from "react";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
|
||||||
import { getAllUniversiteler, type UniOzet } from "@/lib/katalog";
|
|
||||||
import { trBaslikDuzeni } from "@/lib/slug";
|
|
||||||
import { JsonLd, breadcrumbJsonLd } from "@/lib/seo";
|
import { JsonLd, breadcrumbJsonLd } from "@/lib/seo";
|
||||||
import { CtaSiraForm } from "@/components/cta-sira-form";
|
|
||||||
import { SiteFooter } from "@/components/site-footer";
|
import { SiteFooter } from "@/components/site-footer";
|
||||||
import { PagePixelDivider } from "@/components/pixel-decor";
|
import {
|
||||||
import { UniLogo } from "@/components/uni-logo";
|
UniversiteKatalogu,
|
||||||
|
UniversiteKataloguSkeleton,
|
||||||
|
} from "@/features/katalog/components/universite-listesi";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Üniversitelerin Taban Puanları ve Sıralamaları 2026",
|
title: "Üniversitelerin Taban Puanları ve Sıralamaları 2026",
|
||||||
@@ -15,44 +14,7 @@ export const metadata: Metadata = {
|
|||||||
alternates: { canonical: "/universiteler" },
|
alternates: { canonical: "/universiteler" },
|
||||||
};
|
};
|
||||||
|
|
||||||
const sayi = (n: number) => n.toLocaleString("tr-TR");
|
|
||||||
|
|
||||||
function UniLinkleri({ uniler }: { uniler: UniOzet[] }) {
|
|
||||||
return (
|
|
||||||
<ul className="mt-4 grid gap-x-6 gap-y-2 sm:grid-cols-2">
|
|
||||||
{uniler.map((u) => (
|
|
||||||
<li key={u.slug}>
|
|
||||||
<Link
|
|
||||||
href={`/universite/${u.slug}`}
|
|
||||||
className="group flex items-center gap-3 text-sm text-slate-700 hover:text-primary"
|
|
||||||
>
|
|
||||||
<UniLogo slug={u.slug} ad={u.ad} boy="sm" />
|
|
||||||
<span className="min-w-0">
|
|
||||||
<span className="block truncate group-hover:underline">
|
|
||||||
{u.ad}
|
|
||||||
</span>
|
|
||||||
<span className="block text-xs text-slate-400">
|
|
||||||
{u.il ? `${trBaslikDuzeni(u.il)} · ` : ""}
|
|
||||||
{sayi(u.programSayisi)} program
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function UniversitelerPage() {
|
export default function UniversitelerPage() {
|
||||||
const hepsi = getAllUniversiteler();
|
|
||||||
const devlet = hepsi.filter((u) => u.unitur === "DEVLET");
|
|
||||||
const vakif = hepsi.filter(
|
|
||||||
(u) => u.unitur != null && ["VAKIF", "VAKIF MYO"].includes(u.unitur),
|
|
||||||
);
|
|
||||||
const digerUniler = hepsi.filter(
|
|
||||||
(u) => !devlet.includes(u) && !vakif.includes(u),
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<main className="mx-auto w-full max-w-5xl px-4 py-12 sm:py-16">
|
<main className="mx-auto w-full max-w-5xl px-4 py-12 sm:py-16">
|
||||||
@@ -66,51 +28,10 @@ export default function UniversitelerPage() {
|
|||||||
<h1 className="font-heading text-3xl font-bold text-slate-900 sm:text-4xl">
|
<h1 className="font-heading text-3xl font-bold text-slate-900 sm:text-4xl">
|
||||||
Üniversitelerin Taban Puanları ve Sıralamaları 2026
|
Üniversitelerin Taban Puanları ve Sıralamaları 2026
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-4 max-w-3xl text-sm leading-7 text-slate-600">
|
|
||||||
{sayi(hepsi.length)} üniversitenin 2025 YKS yerleştirme verileri.
|
|
||||||
Üniversiteye tıklayınca tüm programlarının taban puanlarını, başarı
|
|
||||||
sıralamalarını ve kontenjanlarını fakülte fakülte görürsün. Veriler
|
|
||||||
YÖK Atlas 2025 yerleştirme sonuçlarına dayanır; 2026 tercihleri için
|
|
||||||
rehber niteliğindedir.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<PagePixelDivider seed={63} className="mt-8" />
|
<Suspense fallback={<UniversiteKataloguSkeleton />}>
|
||||||
|
<UniversiteKatalogu />
|
||||||
<section className="mt-8">
|
</Suspense>
|
||||||
<h2 className="font-heading text-2xl font-bold text-slate-900">
|
|
||||||
Devlet Üniversiteleri
|
|
||||||
<span className="ml-2 text-sm font-normal text-slate-400">
|
|
||||||
{sayi(devlet.length)}
|
|
||||||
</span>
|
|
||||||
</h2>
|
|
||||||
<UniLinkleri uniler={devlet} />
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="mt-10">
|
|
||||||
<h2 className="font-heading text-2xl font-bold text-slate-900">
|
|
||||||
Vakıf Üniversiteleri
|
|
||||||
<span className="ml-2 text-sm font-normal text-slate-400">
|
|
||||||
{sayi(vakif.length)}
|
|
||||||
</span>
|
|
||||||
</h2>
|
|
||||||
<UniLinkleri uniler={vakif} />
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{digerUniler.length > 0 ? (
|
|
||||||
<section className="mt-10">
|
|
||||||
<h2 className="font-heading text-2xl font-bold text-slate-900">
|
|
||||||
KKTC ve Yurt Dışı
|
|
||||||
<span className="ml-2 text-sm font-normal text-slate-400">
|
|
||||||
{sayi(digerUniler.length)}
|
|
||||||
</span>
|
|
||||||
</h2>
|
|
||||||
<UniLinkleri uniler={digerUniler} />
|
|
||||||
</section>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="mt-12">
|
|
||||||
<CtaSiraForm />
|
|
||||||
</div>
|
|
||||||
</main>
|
</main>
|
||||||
<SiteFooter />
|
<SiteFooter />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -10,11 +10,11 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { TypingAnimation } from "@/components/ui/typing-animation";
|
import { TypingAnimation } from "@/components/ui/typing-animation";
|
||||||
import { SIHIRBAZ_AC_EVENT, sonucHref } from "@/lib/sihirbaz";
|
import { SIHIRBAZ_AC_EVENT, sonucHref } from "@/features/sihirbaz/sihirbaz-sabitler";
|
||||||
import {
|
import {
|
||||||
profilToplamaAc,
|
profilToplamaAc,
|
||||||
useTercihProfili,
|
useTercihProfili,
|
||||||
} from "@/components/manuel-liste/profil-kapisi-store";
|
} from "@/features/liste/hooks/use-tercih-profili";
|
||||||
|
|
||||||
const FUNNEL_METINLERI = [
|
const FUNNEL_METINLERI = [
|
||||||
"Sıralamana en uygun listeyi birlikte kuralım.",
|
"Sıralamana en uygun listeyi birlikte kuralım.",
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { useRouter } from "next/navigation";
|
|||||||
import { Wrench, X } from "lucide-react";
|
import { Wrench, X } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { authClient } from "@/lib/auth-client";
|
import { authClient } from "@/lib/auth-client";
|
||||||
import { tercihProfiliSil } from "@/lib/sihirbaz";
|
import { tercihProfiliSil } from "@/features/sihirbaz/sihirbaz-profil";
|
||||||
import {
|
import {
|
||||||
devAnindaGiris,
|
devAnindaGiris,
|
||||||
devAyarla,
|
devAyarla,
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { KatalogArama } from "@/components/katalog-arama";
|
import { KatalogArama } from "@/features/katalog/components/katalog-arama";
|
||||||
import { UserNav, UserNavFallback } from "@/components/user-nav";
|
import { UserNav, UserNavFallback } from "@/features/kullanici/components/user-nav";
|
||||||
import { getAllRehberler } from "@/lib/rehber";
|
import { getAllRehberler } from "@/lib/rehber";
|
||||||
|
|
||||||
export function SiteHeader() {
|
export function SiteHeader() {
|
||||||
|
|||||||
@@ -2,11 +2,17 @@ import type { Metadata } from "next";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { ChevronRight } from "lucide-react";
|
import { ChevronRight } from "lucide-react";
|
||||||
import { getBolumBySlug, uniSlugFromAd } from "@/lib/katalog";
|
import {
|
||||||
|
getAllBolumler,
|
||||||
|
getAllBolumSlugs,
|
||||||
|
getBolumBySlug,
|
||||||
|
uniSlugFromAd,
|
||||||
|
} from "../katalog-queries";
|
||||||
import { JsonLd, breadcrumbJsonLd } from "@/lib/seo";
|
import { JsonLd, breadcrumbJsonLd } from "@/lib/seo";
|
||||||
import { SAYFA_BOYU } from "@/components/sayfalama";
|
import { SAYFA_BOYU } from "./sayfalama";
|
||||||
import { BolumProgramListesi } from "@/components/bolum-program-listesi";
|
import { BolumProgramListesi } from "./bolum-program-listesi";
|
||||||
import { SiteFooter } from "@/components/site-footer";
|
import { SiteFooter } from "@/components/site-footer";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
|
||||||
const sayi = (n: number) => n.toLocaleString("tr-TR");
|
const sayi = (n: number) => n.toLocaleString("tr-TR");
|
||||||
|
|
||||||
@@ -14,6 +20,22 @@ export function bolumToplamSayfa(programSayisi: number): number {
|
|||||||
return Math.max(1, Math.ceil(programSayisi / SAYFA_BOYU));
|
return Math.max(1, Math.ceil(programSayisi / SAYFA_BOYU));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sayfaların generateStaticParams'ı için — sayfalar *-queries import etmez.
|
||||||
|
export function bolumStaticSlugs(): string[] {
|
||||||
|
return getAllBolumSlugs();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2..N sayfaları; 1. sayfa taban rota (/bolum/[slug]) olduğundan üretilmez.
|
||||||
|
export function bolumSayfaStaticParams(): { slug: string; no: string }[] {
|
||||||
|
return getAllBolumler().flatMap((b) => {
|
||||||
|
const toplam = bolumToplamSayfa(b.programSayisi);
|
||||||
|
return Array.from({ length: Math.max(0, toplam - 1) }, (_, i) => ({
|
||||||
|
slug: b.slug,
|
||||||
|
no: String(i + 2),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function bolumSayfaMetadata(slug: string, sayfa: number): Metadata {
|
export function bolumSayfaMetadata(slug: string, sayfa: number): Metadata {
|
||||||
const bolum = getBolumBySlug(slug);
|
const bolum = getBolumBySlug(slug);
|
||||||
if (!bolum) return {};
|
if (!bolum) return {};
|
||||||
@@ -123,3 +145,21 @@ export function BolumIcerik({ slug, sayfa }: { slug: string; sayfa: number }) {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function BolumIcerikSkeleton() {
|
||||||
|
return (
|
||||||
|
<main className="mx-auto w-full max-w-5xl px-4 py-10 sm:py-14">
|
||||||
|
<Skeleton className="h-5 w-56" />
|
||||||
|
<div className="border-b pb-7 sm:pb-9">
|
||||||
|
<Skeleton className="mt-5 h-10 w-2/3 max-w-xl" />
|
||||||
|
<Skeleton className="mt-3 h-5 w-full max-w-2xl" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-8 space-y-3">
|
||||||
|
<Skeleton className="h-7 w-64" />
|
||||||
|
{Array.from({ length: 5 }, (_, i) => (
|
||||||
|
<Skeleton key={i} className="h-12 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
115
src/features/katalog/components/bolum-listesi.tsx
Normal file
115
src/features/katalog/components/bolum-listesi.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { getAllBolumler, type BolumOzet } from "../katalog-queries";
|
||||||
|
import { KATEGORILER } from "@/lib/kategoriler";
|
||||||
|
import { CtaSiraForm } from "@/features/sihirbaz/components/cta-sira-form";
|
||||||
|
import { PagePixelDivider } from "@/components/pixel-decor";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
|
||||||
|
const sayi = (n: number) => n.toLocaleString("tr-TR");
|
||||||
|
|
||||||
|
function BolumLinkleri({ bolumler }: { bolumler: BolumOzet[] }) {
|
||||||
|
return (
|
||||||
|
<ul className="mt-3 grid gap-x-6 gap-y-1.5 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{bolumler.map((b) => (
|
||||||
|
<li key={b.slug}>
|
||||||
|
<Link
|
||||||
|
href={`/bolum/${b.slug}`}
|
||||||
|
className="group inline-flex items-baseline gap-2 text-sm text-slate-700 hover:text-primary"
|
||||||
|
>
|
||||||
|
<span className="group-hover:underline">{b.ad}</span>
|
||||||
|
<span className="text-xs text-slate-400">
|
||||||
|
{sayi(b.programSayisi)}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function KategoriGruplari({
|
||||||
|
bolumler,
|
||||||
|
seviye,
|
||||||
|
}: {
|
||||||
|
bolumler: BolumOzet[];
|
||||||
|
seviye: "lisans" | "onlisans";
|
||||||
|
}) {
|
||||||
|
const kalanlar = new Set(bolumler.map((b) => b.slug));
|
||||||
|
const gruplar = KATEGORILER.map((kategori) => {
|
||||||
|
const uyeler = bolumler.filter((b) =>
|
||||||
|
(b.kategoriler as readonly string[]).includes(kategori),
|
||||||
|
);
|
||||||
|
for (const b of uyeler) kalanlar.delete(b.slug);
|
||||||
|
return { kategori: kategori as string, uyeler };
|
||||||
|
}).filter((g) => g.uyeler.length > 0);
|
||||||
|
const diger = bolumler.filter((b) => kalanlar.has(b.slug));
|
||||||
|
if (diger.length > 0) gruplar.push({ kategori: "Diğer", uyeler: diger });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{gruplar.map(({ kategori, uyeler }) => (
|
||||||
|
<section key={`${seviye}-${kategori}`} className="mt-8">
|
||||||
|
<h3 className="font-heading text-lg font-bold text-slate-900">
|
||||||
|
{kategori}
|
||||||
|
<span className="ml-2 text-xs font-normal text-slate-400">
|
||||||
|
{sayi(uyeler.length)} bölüm
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
|
<BolumLinkleri bolumler={uyeler} />
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BolumKatalogu() {
|
||||||
|
const hepsi = getAllBolumler();
|
||||||
|
const lisans = hepsi.filter((b) => b.lisans);
|
||||||
|
const onlisans = hepsi.filter((b) => b.onlisans && !b.lisans);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<p className="mt-4 max-w-3xl text-sm leading-7 text-slate-600">
|
||||||
|
{sayi(hepsi.length)} bölümün 2025 YKS yerleştirme verileri: taban
|
||||||
|
puanları, başarı sıralamaları, kontenjanlar ve devlet/vakıf dağılımı.
|
||||||
|
Bölüme tıklayınca o bölümü sunan tüm üniversitelerin tablosunu görürsün.
|
||||||
|
Veriler YÖK Atlas 2025 yerleştirme sonuçlarına dayanır; 2026 tercihleri
|
||||||
|
için rehber niteliğindedir.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<PagePixelDivider seed={29} className="mt-8" />
|
||||||
|
|
||||||
|
<section className="mt-8">
|
||||||
|
<h2 className="font-heading text-2xl font-bold text-slate-900">
|
||||||
|
Lisans Bölümleri (4 Yıllık)
|
||||||
|
</h2>
|
||||||
|
<KategoriGruplari bolumler={lisans} seviye="lisans" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="mt-12">
|
||||||
|
<CtaSiraForm />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="mt-12">
|
||||||
|
<h2 className="font-heading text-2xl font-bold text-slate-900">
|
||||||
|
Önlisans Bölümleri (2 Yıllık — TYT)
|
||||||
|
</h2>
|
||||||
|
<KategoriGruplari bolumler={onlisans} seviye="onlisans" />
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BolumKataloguSkeleton() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Skeleton className="mt-4 h-5 w-full max-w-3xl" />
|
||||||
|
<div className="mt-8 space-y-4">
|
||||||
|
<Skeleton className="h-8 w-72" />
|
||||||
|
{Array.from({ length: 4 }, (_, i) => (
|
||||||
|
<Skeleton key={i} className="h-6 w-full max-w-2xl" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,15 +3,15 @@
|
|||||||
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 "./sayfalama";
|
||||||
import {
|
import {
|
||||||
ProgramSiraGecmisi,
|
ProgramSiraGecmisi,
|
||||||
ProgramTabanTrendi,
|
ProgramTabanTrendi,
|
||||||
} from "@/components/program-liste-verileri";
|
} from "@/components/program-liste-verileri";
|
||||||
import { ProgramEkleButonu } from "@/components/manuel-liste/program-ekle-butonu";
|
import { ProgramEkleButonu } from "@/features/liste/components/program-ekle-butonu";
|
||||||
import { ProgramNetleriButonu } from "@/components/program-netleri";
|
import { ProgramNetleriButonu } from "./program-netleri";
|
||||||
import { UniLogo } from "@/components/uni-logo";
|
import { UniLogo } from "@/components/uni-logo";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
@@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { AramaFunnelKarti } from "@/components/arama-funnel-karti";
|
import { AramaFunnelKarti } from "@/components/arama-funnel-karti";
|
||||||
import { RehberKapak } from "@/components/rehber-kapak";
|
import { RehberKapak } from "@/features/rehber/components/rehber-kapak";
|
||||||
import { UniLogo } from "@/components/uni-logo";
|
import { UniLogo } from "@/components/uni-logo";
|
||||||
import { aramaNormalize, eslesmePuani } from "@/lib/arama";
|
import { aramaNormalize, eslesmePuani } from "@/lib/arama";
|
||||||
import type { RehberOzet } from "@/lib/rehber";
|
import type { RehberOzet } from "@/lib/rehber";
|
||||||
@@ -12,8 +12,8 @@ import {
|
|||||||
profilDuzenlemeyiAc,
|
profilDuzenlemeyiAc,
|
||||||
sonucaGitIste,
|
sonucaGitIste,
|
||||||
useTercihProfili,
|
useTercihProfili,
|
||||||
} from "@/components/manuel-liste/profil-kapisi-store";
|
} from "@/features/liste/hooks/use-tercih-profili";
|
||||||
import { PUAN_TURU_ETIKET } from "@/lib/sihirbaz";
|
import { PUAN_TURU_ETIKET } from "@/features/sihirbaz/sihirbaz-sabitler";
|
||||||
import { olay } from "@/lib/analitik";
|
import { olay } from "@/lib/analitik";
|
||||||
import {
|
import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
@@ -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,
|
||||||
@@ -2,18 +2,24 @@ import type { Metadata } from "next";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { ChevronRight } from "lucide-react";
|
import { ChevronRight } from "lucide-react";
|
||||||
import { getUniversiteBySlug, bolumSlugFromIsim } from "@/lib/katalog";
|
import {
|
||||||
|
getAllUniversiteler,
|
||||||
|
getAllUniversiteSlugs,
|
||||||
|
getUniversiteBySlug,
|
||||||
|
bolumSlugFromIsim,
|
||||||
|
} from "../katalog-queries";
|
||||||
import { trBaslikDuzeni } from "@/lib/slug";
|
import { trBaslikDuzeni } from "@/lib/slug";
|
||||||
import { JsonLd, breadcrumbJsonLd, SITE_URL } from "@/lib/seo";
|
import { JsonLd, breadcrumbJsonLd, SITE_URL } from "@/lib/seo";
|
||||||
import { CtaSiraForm } from "@/components/cta-sira-form";
|
import { CtaSiraForm } from "@/features/sihirbaz/components/cta-sira-form";
|
||||||
import { SiteFooter } from "@/components/site-footer";
|
import { SiteFooter } from "@/components/site-footer";
|
||||||
import { Sayfalama, SAYFA_BOYU } from "@/components/sayfalama";
|
import { Sayfalama, SAYFA_BOYU } from "./sayfalama";
|
||||||
import { PagePixelDivider } from "@/components/pixel-decor";
|
import { PagePixelDivider } from "@/components/pixel-decor";
|
||||||
import { UniversiteKonumHaritasi } from "@/components/universite-konum-haritasi";
|
import { UniversiteKonumHaritasi } from "./universite-konum-haritasi";
|
||||||
import { UniversiteProgramTablosu } from "@/components/universite-program-tablosu";
|
import { UniversiteProgramTablosu } from "./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 { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import type { Program } from "@/types/yokatlas";
|
||||||
|
|
||||||
const sayi = (n: number) => n.toLocaleString("tr-TR");
|
const sayi = (n: number) => n.toLocaleString("tr-TR");
|
||||||
|
|
||||||
@@ -21,6 +27,22 @@ export function uniToplamSayfa(programSayisi: number): number {
|
|||||||
return Math.max(1, Math.ceil(programSayisi / SAYFA_BOYU));
|
return Math.max(1, Math.ceil(programSayisi / SAYFA_BOYU));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sayfaların generateStaticParams'ı için — sayfalar *-queries import etmez.
|
||||||
|
export function uniStaticSlugs(): string[] {
|
||||||
|
return getAllUniversiteSlugs();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2..N sayfaları; 1. sayfa taban rota (/universite/[slug]) olduğundan üretilmez.
|
||||||
|
export function uniSayfaStaticParams(): { slug: string; no: string }[] {
|
||||||
|
return getAllUniversiteler().flatMap((u) => {
|
||||||
|
const toplam = uniToplamSayfa(u.programSayisi);
|
||||||
|
return Array.from({ length: Math.max(0, toplam - 1) }, (_, i) => ({
|
||||||
|
slug: u.slug,
|
||||||
|
no: String(i + 2),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function uniSayfaMetadata(slug: string, sayfa: number): Metadata {
|
export function uniSayfaMetadata(slug: string, sayfa: number): Metadata {
|
||||||
const uni = getUniversiteBySlug(slug);
|
const uni = getUniversiteBySlug(slug);
|
||||||
if (!uni) return {};
|
if (!uni) return {};
|
||||||
@@ -268,3 +290,27 @@ export function UniversiteIcerik({
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function UniversiteIcerikSkeleton() {
|
||||||
|
return (
|
||||||
|
<main className="mx-auto w-full max-w-5xl px-4 py-12 sm:py-16">
|
||||||
|
<Skeleton className="h-4 w-64" />
|
||||||
|
<div className="mt-4 flex items-center gap-4">
|
||||||
|
<Skeleton className="size-14 rounded-lg" />
|
||||||
|
<Skeleton className="h-10 w-2/3 max-w-xl" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="mt-4 h-5 w-full max-w-3xl" />
|
||||||
|
<div className="mt-6 grid grid-cols-2 gap-3 lg:grid-cols-[1fr_1fr_1.35fr] lg:grid-rows-2">
|
||||||
|
{Array.from({ length: 5 }, (_, i) => (
|
||||||
|
<Skeleton key={i} className="h-24 w-full rounded-xl" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-16 space-y-3">
|
||||||
|
<Skeleton className="h-7 w-64" />
|
||||||
|
{Array.from({ length: 5 }, (_, i) => (
|
||||||
|
<Skeleton key={i} className="h-12 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
110
src/features/katalog/components/universite-listesi.tsx
Normal file
110
src/features/katalog/components/universite-listesi.tsx
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { getAllUniversiteler, type UniOzet } from "../katalog-queries";
|
||||||
|
import { trBaslikDuzeni } from "@/lib/slug";
|
||||||
|
import { CtaSiraForm } from "@/features/sihirbaz/components/cta-sira-form";
|
||||||
|
import { PagePixelDivider } from "@/components/pixel-decor";
|
||||||
|
import { UniLogo } from "@/components/uni-logo";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
|
||||||
|
const sayi = (n: number) => n.toLocaleString("tr-TR");
|
||||||
|
|
||||||
|
function UniLinkleri({ uniler }: { uniler: UniOzet[] }) {
|
||||||
|
return (
|
||||||
|
<ul className="mt-4 grid gap-x-6 gap-y-2 sm:grid-cols-2">
|
||||||
|
{uniler.map((u) => (
|
||||||
|
<li key={u.slug}>
|
||||||
|
<Link
|
||||||
|
href={`/universite/${u.slug}`}
|
||||||
|
className="group flex items-center gap-3 text-sm text-slate-700 hover:text-primary"
|
||||||
|
>
|
||||||
|
<UniLogo slug={u.slug} ad={u.ad} boy="sm" />
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block truncate group-hover:underline">
|
||||||
|
{u.ad}
|
||||||
|
</span>
|
||||||
|
<span className="block text-xs text-slate-400">
|
||||||
|
{u.il ? `${trBaslikDuzeni(u.il)} · ` : ""}
|
||||||
|
{sayi(u.programSayisi)} program
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UniversiteKatalogu() {
|
||||||
|
const hepsi = getAllUniversiteler();
|
||||||
|
const devlet = hepsi.filter((u) => u.unitur === "DEVLET");
|
||||||
|
const vakif = hepsi.filter(
|
||||||
|
(u) => u.unitur != null && ["VAKIF", "VAKIF MYO"].includes(u.unitur),
|
||||||
|
);
|
||||||
|
const digerUniler = hepsi.filter(
|
||||||
|
(u) => !devlet.includes(u) && !vakif.includes(u),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<p className="mt-4 max-w-3xl text-sm leading-7 text-slate-600">
|
||||||
|
{sayi(hepsi.length)} üniversitenin 2025 YKS yerleştirme verileri.
|
||||||
|
Üniversiteye tıklayınca tüm programlarının taban puanlarını, başarı
|
||||||
|
sıralamalarını ve kontenjanlarını fakülte fakülte görürsün. Veriler YÖK
|
||||||
|
Atlas 2025 yerleştirme sonuçlarına dayanır; 2026 tercihleri için rehber
|
||||||
|
niteliğindedir.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<PagePixelDivider seed={63} className="mt-8" />
|
||||||
|
|
||||||
|
<section className="mt-8">
|
||||||
|
<h2 className="font-heading text-2xl font-bold text-slate-900">
|
||||||
|
Devlet Üniversiteleri
|
||||||
|
<span className="ml-2 text-sm font-normal text-slate-400">
|
||||||
|
{sayi(devlet.length)}
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
<UniLinkleri uniler={devlet} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mt-10">
|
||||||
|
<h2 className="font-heading text-2xl font-bold text-slate-900">
|
||||||
|
Vakıf Üniversiteleri
|
||||||
|
<span className="ml-2 text-sm font-normal text-slate-400">
|
||||||
|
{sayi(vakif.length)}
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
<UniLinkleri uniler={vakif} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{digerUniler.length > 0 ? (
|
||||||
|
<section className="mt-10">
|
||||||
|
<h2 className="font-heading text-2xl font-bold text-slate-900">
|
||||||
|
KKTC ve Yurt Dışı
|
||||||
|
<span className="ml-2 text-sm font-normal text-slate-400">
|
||||||
|
{sayi(digerUniler.length)}
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
<UniLinkleri uniler={digerUniler} />
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="mt-12">
|
||||||
|
<CtaSiraForm />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UniversiteKataloguSkeleton() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Skeleton className="mt-4 h-5 w-full max-w-3xl" />
|
||||||
|
<div className="mt-8 space-y-4">
|
||||||
|
<Skeleton className="h-8 w-72" />
|
||||||
|
{Array.from({ length: 4 }, (_, i) => (
|
||||||
|
<Skeleton key={i} className="h-9 w-full max-w-2xl" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
"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,
|
||||||
programEtkinSira,
|
programEtkinSira,
|
||||||
} from "@/components/program-liste-verileri";
|
} from "@/components/program-liste-verileri";
|
||||||
import { ProgramEkleButonu } from "@/components/manuel-liste/program-ekle-butonu";
|
import { ProgramEkleButonu } from "@/features/liste/components/program-ekle-butonu";
|
||||||
import { ProgramNetleriButonu } from "@/components/program-netleri";
|
import { ProgramNetleriButonu } from "./program-netleri";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
17
src/features/katalog/katalog-queries.ts
Normal file
17
src/features/katalog/katalog-queries.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
// Katalog feature'ının sorgu kapısı. Veri motoru @/lib/katalog'da yaşar
|
||||||
|
// (yokatlas.db üzerinde salt-okunur, globalThis'te önbellekli); feature
|
||||||
|
// bileşenleri ve sayfa yardımcıları yalnızca buradan okur.
|
||||||
|
export {
|
||||||
|
getAllBolumler,
|
||||||
|
getAllBolumSlugs,
|
||||||
|
getAllUniversiteler,
|
||||||
|
getAllUniversiteSlugs,
|
||||||
|
getBolumBySlug,
|
||||||
|
getUniversiteBySlug,
|
||||||
|
uniSlugFromAd,
|
||||||
|
bolumSlugFromIsim,
|
||||||
|
type BolumOzet,
|
||||||
|
type UniOzet,
|
||||||
|
} from "@/lib/katalog";
|
||||||
104
src/features/kullanici/components/dogrula-karti.tsx
Normal file
104
src/features/kullanici/components/dogrula-karti.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { ArrowRight } from "lucide-react";
|
||||||
|
import { PixelCorner } from "@/components/pixel-decor";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
|
||||||
|
/** Yalnızca site içi (göreli) callback'lere izin ver — open redirect kalkanı. */
|
||||||
|
function guvenliYol(deger: string | undefined, varsayilan: string): string {
|
||||||
|
return deger && deger.startsWith("/") && !deger.startsWith("//")
|
||||||
|
? deger
|
||||||
|
: varsayilan;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maildeki giriş bağlantısının indiği onay kartı. Doğrulama ucu
|
||||||
|
* (/api/auth/magic-link/verify) GET'lendiği anda token'ı tüketir; mail
|
||||||
|
* güvenlik tarayıcıları ve tarayıcı ön-yüklemeleri linki kullanıcıdan önce
|
||||||
|
* açıp token'ı yakabildiği için maildeki link token'ı TÜKETMEYEN bu sayfaya
|
||||||
|
* gelir. Verify ancak buradaki form gönderilince tetiklenir — tarayıcı botları
|
||||||
|
* sayfayı GET'lese bile form'u göndermez, token yanmaz.
|
||||||
|
*/
|
||||||
|
export function DogrulaKarti({
|
||||||
|
token,
|
||||||
|
callbackURL,
|
||||||
|
errorCallbackURL,
|
||||||
|
newUserCallbackURL,
|
||||||
|
}: {
|
||||||
|
token?: string;
|
||||||
|
callbackURL?: string;
|
||||||
|
errorCallbackURL?: string;
|
||||||
|
newUserCallbackURL?: string;
|
||||||
|
}) {
|
||||||
|
if (!token) redirect("/giris");
|
||||||
|
|
||||||
|
const callback = guvenliYol(callbackURL, "/");
|
||||||
|
const hataCallback = guvenliYol(
|
||||||
|
errorCallbackURL,
|
||||||
|
`/giris?hata=gecersiz-baglanti&callback=${encodeURIComponent(callback)}`,
|
||||||
|
);
|
||||||
|
const yeniKullaniciCallback = guvenliYol(newUserCallbackURL, callback);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<div className="relative rounded-2xl border border-slate-200 bg-white p-8 shadow-sm">
|
||||||
|
<h1 className="font-heading text-xl font-bold">Girişini onayla</h1>
|
||||||
|
<p className="mt-2 text-sm text-slate-600">
|
||||||
|
Bu bağlantı senin için üretildi ve{" "}
|
||||||
|
<span className="font-semibold">tek kullanımlık</span>. Aşağıdaki
|
||||||
|
butona tıkladığında KolayTercih hesabına giriş yapacaksın.
|
||||||
|
</p>
|
||||||
|
<form
|
||||||
|
method="GET"
|
||||||
|
action="/api/auth/magic-link/verify"
|
||||||
|
className="mt-6"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="token" value={token} />
|
||||||
|
<input type="hidden" name="callbackURL" value={callback} />
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="errorCallbackURL"
|
||||||
|
value={hataCallback}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="newUserCallbackURL"
|
||||||
|
value={yeniKullaniciCallback}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="h-11 w-full cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||||
|
>
|
||||||
|
Giriş yap
|
||||||
|
<ArrowRight className="size-4" aria-hidden />
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
<p className="mt-4 text-xs leading-relaxed text-slate-500">
|
||||||
|
Bu isteği sen yapmadıysan bu sayfayı kapatabilirsin — butona
|
||||||
|
tıklanmadığı sürece hesabında hiçbir şey değişmez.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none absolute inset-0 z-10 text-primary"
|
||||||
|
>
|
||||||
|
<PixelCorner className="absolute -right-4 -top-4 size-9 [&_rect]:[fill-opacity:0.65]" />
|
||||||
|
<PixelCorner className="absolute -bottom-4 -right-4 size-9 rotate-90 [&_rect]:[fill-opacity:0.65]" />
|
||||||
|
<PixelCorner className="absolute -bottom-4 -left-4 size-9 rotate-180 [&_rect]:[fill-opacity:0.65]" />
|
||||||
|
<PixelCorner className="absolute -left-4 -top-4 size-9 -rotate-90 [&_rect]:[fill-opacity:0.65]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DogrulaKartiSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-slate-200 bg-white p-8 shadow-sm">
|
||||||
|
<Skeleton className="h-7 w-40" />
|
||||||
|
<Skeleton className="mt-3 h-4 w-full" />
|
||||||
|
<Skeleton className="mt-1.5 h-4 w-2/3" />
|
||||||
|
<Skeleton className="mt-6 h-11 w-full rounded-lg" />
|
||||||
|
<Skeleton className="mt-4 h-3 w-3/4" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
122
src/features/kullanici/components/giris-karti.tsx
Normal file
122
src/features/kullanici/components/giris-karti.tsx
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { getSession } from "@/lib/session";
|
||||||
|
import { PixelCorner } from "@/components/pixel-decor";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { GirisForm } from "./giris-form";
|
||||||
|
import { GirisBaslik } from "./giris-baslik";
|
||||||
|
|
||||||
|
export async function GirisKarti({
|
||||||
|
callback,
|
||||||
|
hata,
|
||||||
|
}: {
|
||||||
|
callback?: string;
|
||||||
|
hata?: string;
|
||||||
|
}) {
|
||||||
|
const callbackURL = callback && callback.startsWith("/") ? callback : "/";
|
||||||
|
// Magic link doğrulaması başarısız olunca errorCallbackURL buraya düşürür
|
||||||
|
// (bkz. giris-form.tsx ve /giris/dogrula) — ham INVALID_TOKEN yerine
|
||||||
|
// dostça bir mesaj gösterip yeni bağlantı istemeye yönlendiriyoruz.
|
||||||
|
const baglantiHatasi = hata === "gecersiz-baglanti";
|
||||||
|
|
||||||
|
const session = await getSession();
|
||||||
|
if (session) redirect(callbackURL);
|
||||||
|
|
||||||
|
// Sihirbaz funnel'ı: seçimler yapıldı, listeye giriş kaldı
|
||||||
|
const funnel = callbackURL.startsWith("/sonuc");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="relative">
|
||||||
|
<div className="relative rounded-2xl border border-slate-200 bg-white p-8 shadow-sm">
|
||||||
|
{baglantiHatasi && (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="mb-5 rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-800"
|
||||||
|
>
|
||||||
|
<p className="font-semibold">
|
||||||
|
Giriş bağlantısı geçersiz ya da süresi dolmuş
|
||||||
|
</p>
|
||||||
|
<p className="mt-1">
|
||||||
|
Bağlantılar 15 dakika geçerli ve tek kullanımlık. Aşağıdan
|
||||||
|
yeni bir bağlantı isteyebilirsin.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<GirisBaslik funnel={funnel} />
|
||||||
|
<p className="mt-2 text-sm text-slate-600">
|
||||||
|
{funnel ? (
|
||||||
|
<>
|
||||||
|
Seçimlerin hazır — giriş yap,{" "}
|
||||||
|
<span className="font-semibold">5 deneme kredinle</span> listen
|
||||||
|
hemen üretilsin.
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Kayıt olduğunda{" "}
|
||||||
|
<span className="font-semibold">5 deneme kredisi</span>{" "}
|
||||||
|
hesabına tanımlanır — Yapay Zeka danışmana ücretsiz soru sorabilirsin.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<GirisForm callbackURL={callbackURL} />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none absolute inset-0 z-10 text-primary"
|
||||||
|
>
|
||||||
|
<PixelCorner className="absolute -right-4 -top-4 size-9 [&_rect]:[fill-opacity:0.65]" />
|
||||||
|
<PixelCorner className="absolute -bottom-4 -right-4 size-9 rotate-90 [&_rect]:[fill-opacity:0.65]" />
|
||||||
|
<PixelCorner className="absolute -bottom-4 -left-4 size-9 rotate-180 [&_rect]:[fill-opacity:0.65]" />
|
||||||
|
<PixelCorner className="absolute -left-4 -top-4 size-9 -rotate-90 [&_rect]:[fill-opacity:0.65]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="mt-6 text-center text-xs leading-relaxed text-slate-500">
|
||||||
|
Giriş yaparak verilerinin tercih önerileri üretmek amacıyla{" "}
|
||||||
|
<Link href="/gizlilik" className="underline hover:text-slate-700">
|
||||||
|
gizlilik metnindeki
|
||||||
|
</Link>{" "}
|
||||||
|
gibi işlenmesini ve{" "}
|
||||||
|
<Link href="/kosullar" className="underline hover:text-slate-700">
|
||||||
|
kullanım koşullarını
|
||||||
|
</Link>{" "}
|
||||||
|
kabul etmiş olursun. KolayTercih yerleşme garantisi vermez.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kart, başlık, Google butonu, ayraç, e-posta alanı ve gönder butonu aynı
|
||||||
|
// yerde belirir — içerik gelince zıplama olmaz.
|
||||||
|
export function GirisKartiSkeleton() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="rounded-2xl border border-slate-200 bg-white p-8 shadow-sm">
|
||||||
|
{/* Başlık + alt metin */}
|
||||||
|
<Skeleton className="h-8 w-40" />
|
||||||
|
<Skeleton className="mt-3 h-4 w-full" />
|
||||||
|
<Skeleton className="mt-1.5 h-4 w-2/3" />
|
||||||
|
|
||||||
|
{/* Google ile devam et */}
|
||||||
|
<Skeleton className="mt-6 h-11 w-full rounded-lg" />
|
||||||
|
|
||||||
|
{/* "veya" ayracı */}
|
||||||
|
<div className="mt-4 flex items-center gap-3">
|
||||||
|
<Skeleton className="h-px flex-1" />
|
||||||
|
<Skeleton className="h-3 w-8" />
|
||||||
|
<Skeleton className="h-px flex-1" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* E-posta alanı + gönder butonu */}
|
||||||
|
<Skeleton className="mt-4 h-11 w-full rounded-lg" />
|
||||||
|
<Skeleton className="mt-3 h-11 w-full rounded-lg" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Kart altındaki yasal metin */}
|
||||||
|
<div className="mt-6 flex flex-col items-center gap-1.5">
|
||||||
|
<Skeleton className="h-3 w-3/4" />
|
||||||
|
<Skeleton className="h-3 w-1/2" />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,8 +2,8 @@ import Link from "next/link";
|
|||||||
import { Coins } from "lucide-react";
|
import { Coins } from "lucide-react";
|
||||||
import { getCurrentUser } from "@/lib/session";
|
import { getCurrentUser } from "@/lib/session";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ListemButonu } from "@/components/manuel-liste/listem-butonu";
|
import { ListemButonu } from "@/features/liste/components/listem-butonu";
|
||||||
import { ListeSenkron } from "@/components/manuel-liste/liste-senkron";
|
import { ListeSenkron } from "@/features/liste/components/liste-senkron";
|
||||||
import { RybbitIdentify } from "@/components/rybbit-identify";
|
import { RybbitIdentify } from "@/components/rybbit-identify";
|
||||||
import { SignOutButton } from "./sign-out-button";
|
import { SignOutButton } from "./sign-out-button";
|
||||||
|
|
||||||
@@ -16,9 +16,9 @@ import { useGorunumOlayi } from "@/components/use-gorunum-olayi";
|
|||||||
import { olay } from "@/lib/analitik";
|
import { olay } from "@/lib/analitik";
|
||||||
import { kromGizliMi } from "@/components/header-gate";
|
import { kromGizliMi } from "@/components/header-gate";
|
||||||
import { CihazCifti, IsletimSistemiLogosu } from "./cihaz-ikonlari";
|
import { CihazCifti, IsletimSistemiLogosu } from "./cihaz-ikonlari";
|
||||||
import { useManuelListe } from "./store";
|
import { useManuelListe } from "../liste-store";
|
||||||
import { useOturumDurumu } from "./oturum-sinyali";
|
import { useOturumDurumu } from "../liste-oturum-sinyali";
|
||||||
import { useGirisUrl } from "./giris-url";
|
import { useGirisUrl } from "../liste-giris-url";
|
||||||
|
|
||||||
const KAPATMA_KEY = "kolaytercih.kaydet-banner-kapali";
|
const KAPATMA_KEY = "kolaytercih.kaydet-banner-kapali";
|
||||||
const ESIK = 3;
|
const ESIK = 3;
|
||||||
@@ -47,9 +47,9 @@ import { RISK_ETIKET, type RiskSeviyesi } from "@/lib/risk";
|
|||||||
import { bolumBazAdi, turkishSlugify } from "@/lib/slug";
|
import { bolumBazAdi, turkishSlugify } from "@/lib/slug";
|
||||||
import { olay } from "@/lib/analitik";
|
import { olay } from "@/lib/analitik";
|
||||||
import { CihazCifti, IsletimSistemiLogosu } from "./cihaz-ikonlari";
|
import { CihazCifti, IsletimSistemiLogosu } from "./cihaz-ikonlari";
|
||||||
import { sonucaGitIste } from "./profil-kapisi-store";
|
import { sonucaGitIste } from "../hooks/use-tercih-profili";
|
||||||
import { useOturumDurumu } from "./oturum-sinyali";
|
import { useOturumDurumu } from "../liste-oturum-sinyali";
|
||||||
import { useGirisUrl } from "./giris-url";
|
import { useGirisUrl } from "../liste-giris-url";
|
||||||
import {
|
import {
|
||||||
MANUEL_LISTE_MAX,
|
MANUEL_LISTE_MAX,
|
||||||
cekmeceAc,
|
cekmeceAc,
|
||||||
@@ -60,7 +60,7 @@ import {
|
|||||||
useManuelListe,
|
useManuelListe,
|
||||||
yenidenSirala,
|
yenidenSirala,
|
||||||
type ManuelTercih,
|
type ManuelTercih,
|
||||||
} from "./store";
|
} from "../liste-store";
|
||||||
|
|
||||||
// Slug kuralları katalog.ts'tekiyle aynı (bolumBazAdi/uniAdiNormalize +
|
// Slug kuralları katalog.ts'tekiyle aynı (bolumBazAdi/uniAdiNormalize +
|
||||||
// turkishSlugify); katalog haritaları DB'deki tüm isim/universite
|
// turkishSlugify); katalog haritaları DB'deki tüm isim/universite
|
||||||
@@ -11,14 +11,11 @@
|
|||||||
// - Sonrasında: liste/profil değişimleri ~2 sn debounce ile sunucuya yazılır.
|
// - Sonrasında: liste/profil değişimleri ~2 sn debounce ile sunucuya yazılır.
|
||||||
|
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import {
|
import { PROFIL_DEGISTI_EVENT } from "@/features/sihirbaz/sihirbaz-sabitler";
|
||||||
PROFIL_DEGISTI_EVENT,
|
import { tercihProfiliOku, tercihProfiliYaz } from "@/features/sihirbaz/sihirbaz-profil";
|
||||||
tercihProfiliOku,
|
import { listeOku, sunucuylaBirlestir, useManuelListe } from "../liste-store";
|
||||||
tercihProfiliYaz,
|
import { oturumDurumuBildir } from "../liste-oturum-sinyali";
|
||||||
} from "@/lib/sihirbaz";
|
import { listeSenkronla, listeyiKaydet } from "../liste-actions";
|
||||||
import { listeOku, sunucuylaBirlestir, useManuelListe } from "./store";
|
|
||||||
import { oturumDurumuBildir } from "./oturum-sinyali";
|
|
||||||
import { listeSenkronla, listeyiKaydet } from "./kayitli-liste-actions";
|
|
||||||
|
|
||||||
const DEBOUNCE_MS = 2000;
|
const DEBOUNCE_MS = 2000;
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
cekmeceDegistir,
|
cekmeceDegistir,
|
||||||
useCekmeceAcik,
|
useCekmeceAcik,
|
||||||
useManuelListe,
|
useManuelListe,
|
||||||
} from "./store";
|
} from "../liste-store";
|
||||||
|
|
||||||
export function ListemButonu() {
|
export function ListemButonu() {
|
||||||
const liste = useManuelListe();
|
const liste = useManuelListe();
|
||||||
@@ -5,10 +5,10 @@
|
|||||||
// abonedir; ekleme/çıkarma/sürükleme anında haritaya yansır.
|
// abonedir; ekleme/çıkarma/sürükleme anında haritaya yansır.
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { TercihHaritasi, type HaritaPin } from "@/components/tercih-haritasi";
|
import { TercihHaritasi, type HaritaPin } from "@/features/liste/components/tercih-haritasi";
|
||||||
import { ilKonum, uniAdiNormalize, uniKonum } from "@/lib/harita";
|
import { ilKonum, uniAdiNormalize, uniKonum } from "@/lib/harita";
|
||||||
import type { RiskSeviyesi } from "@/lib/risk";
|
import type { RiskSeviyesi } from "@/lib/risk";
|
||||||
import { useManuelListe } from "./store";
|
import { useManuelListe } from "../liste-store";
|
||||||
|
|
||||||
// Aynı üniversitede birden çok tercih varsa pinde kötü risk baskın olsun
|
// Aynı üniversitede birden çok tercih varsa pinde kötü risk baskın olsun
|
||||||
const RISK_AGIRLIK: Record<RiskSeviyesi, number> = {
|
const RISK_AGIRLIK: Record<RiskSeviyesi, number> = {
|
||||||
@@ -2,15 +2,15 @@
|
|||||||
|
|
||||||
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,
|
||||||
MANUEL_LISTE_MAX,
|
MANUEL_LISTE_MAX,
|
||||||
useManuelListe,
|
useManuelListe,
|
||||||
type ManuelKaynak,
|
type ManuelKaynak,
|
||||||
} from "./store";
|
} from "../liste-store";
|
||||||
import { programEkleIste } from "./profil-kapisi-store";
|
import { programEkleIste } from "../hooks/use-tercih-profili";
|
||||||
|
|
||||||
type ProgramOzet = Pick<
|
type ProgramOzet = Pick<
|
||||||
Program,
|
Program,
|
||||||
@@ -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 {
|
||||||
@@ -30,8 +30,8 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { ProgramEkleButonu } from "@/components/manuel-liste/program-ekle-butonu";
|
import { ProgramEkleButonu } from "@/features/liste/components/program-ekle-butonu";
|
||||||
import { ProgramNetleriButonu } from "@/components/program-netleri";
|
import { ProgramNetleriButonu } from "@/features/katalog/components/program-netleri";
|
||||||
import { UniLogo } from "@/components/uni-logo";
|
import { UniLogo } from "@/components/uni-logo";
|
||||||
|
|
||||||
const DILIMLER: {
|
const DILIMLER: {
|
||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { RISK_ETIKET, type RiskSeviyesi } from "@/lib/risk";
|
import { RISK_ETIKET, type RiskSeviyesi } from "@/lib/risk";
|
||||||
import { sonucHref } from "@/lib/sihirbaz";
|
import { sonucHref } from "@/features/sihirbaz/sihirbaz-sabitler";
|
||||||
import {
|
import {
|
||||||
tercihDegerlendir,
|
tercihDegerlendir,
|
||||||
tercihListesiOzeti,
|
tercihListesiOzeti,
|
||||||
@@ -23,12 +23,12 @@ import {
|
|||||||
MANUEL_LISTE_MAX,
|
MANUEL_LISTE_MAX,
|
||||||
useManuelListe,
|
useManuelListe,
|
||||||
type ManuelTercih,
|
type ManuelTercih,
|
||||||
} from "./store";
|
} from "../liste-store";
|
||||||
import {
|
import {
|
||||||
profilDuzenlemeyiAc,
|
profilDuzenlemeyiAc,
|
||||||
sonucaGitIste,
|
sonucaGitIste,
|
||||||
useTercihProfili,
|
useTercihProfili,
|
||||||
} from "./profil-kapisi-store";
|
} from "../hooks/use-tercih-profili";
|
||||||
|
|
||||||
const RISK_STIL: Record<RiskSeviyesi, { nokta: string; kenar: string }> = {
|
const RISK_STIL: Record<RiskSeviyesi, { nokta: string; kenar: string }> = {
|
||||||
guvenli: { nokta: "bg-emerald-500", kenar: "border-l-emerald-500" },
|
guvenli: { nokta: "bg-emerald-500", kenar: "border-l-emerald-500" },
|
||||||
27
src/features/liste/components/sonuc-program-tablosu.tsx
Normal file
27
src/features/liste/components/sonuc-program-tablosu.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { siraylaProgramAra } from "../liste-queries";
|
||||||
|
import { ProgramTablosu } from "./program-tablosu";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import type { PuanTuruKey } from "@/types/yokatlas";
|
||||||
|
|
||||||
|
// /sonuc'un ham tablosunun sunucu sarmalayıcısı: okuma burada yaşar, sayfa
|
||||||
|
// yalnızca kompoze eder.
|
||||||
|
export function SonucProgramTablosu({
|
||||||
|
sira,
|
||||||
|
tur,
|
||||||
|
}: {
|
||||||
|
sira: number;
|
||||||
|
tur: PuanTuruKey;
|
||||||
|
}) {
|
||||||
|
const sonuclar = siraylaProgramAra(sira, tur);
|
||||||
|
return <ProgramTablosu sonuclar={sonuclar} adaySira={sira} tur={tur} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SonucProgramTablosuSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="mt-8 flex flex-col gap-2">
|
||||||
|
{Array.from({ length: 8 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-14 w-full rounded-xl" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -25,14 +25,11 @@ import {
|
|||||||
import {
|
import {
|
||||||
SihirbazAdimlarLazy,
|
SihirbazAdimlarLazy,
|
||||||
preloadSihirbazAdimlar,
|
preloadSihirbazAdimlar,
|
||||||
} from "@/components/sihirbaz-adimlar-lazy";
|
} from "@/features/sihirbaz/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 { type SihirbazSecimleri, type TercihProfili } from "@/features/sihirbaz/sihirbaz-sabitler";
|
||||||
tercihProfiliYaz,
|
import { tercihProfiliYaz } from "@/features/sihirbaz/sihirbaz-profil";
|
||||||
type SihirbazSecimleri,
|
|
||||||
type TercihProfili,
|
|
||||||
} from "@/lib/sihirbaz";
|
|
||||||
import { authClient } from "@/lib/auth-client";
|
import { authClient } from "@/lib/auth-client";
|
||||||
import {
|
import {
|
||||||
profilKapisiniKapat,
|
profilKapisiniKapat,
|
||||||
@@ -40,7 +37,7 @@ import {
|
|||||||
useProfilKapisi,
|
useProfilKapisi,
|
||||||
type BekleyenHedef,
|
type BekleyenHedef,
|
||||||
type BekleyenProgram,
|
type BekleyenProgram,
|
||||||
} from "./profil-kapisi-store";
|
} from "../hooks/use-tercih-profili";
|
||||||
|
|
||||||
const PUAN_TURLERI_SECENEK = [
|
const PUAN_TURLERI_SECENEK = [
|
||||||
{ deger: "say", etiket: "Sayısal" },
|
{ deger: "say", etiket: "Sayısal" },
|
||||||
@@ -4,14 +4,9 @@
|
|||||||
// 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, sonucHref, type TercihProfili } from "@/features/sihirbaz/sihirbaz-sabitler";
|
||||||
PROFIL_DEGISTI_EVENT,
|
import { tercihProfiliOku, tercihProfiliTamamlandiMi } from "@/features/sihirbaz/sihirbaz-profil";
|
||||||
sonucHref,
|
|
||||||
tercihProfiliOku,
|
|
||||||
tercihProfiliTamamlandiMi,
|
|
||||||
type TercihProfili,
|
|
||||||
} from "@/lib/sihirbaz";
|
|
||||||
import {
|
import {
|
||||||
ekle,
|
ekle,
|
||||||
listedeMi,
|
listedeMi,
|
||||||
@@ -20,7 +15,7 @@ import {
|
|||||||
riskleriYenile,
|
riskleriYenile,
|
||||||
useManuelListe,
|
useManuelListe,
|
||||||
type ManuelKaynak,
|
type ManuelKaynak,
|
||||||
} from "./store";
|
} from "../liste-store";
|
||||||
|
|
||||||
export type BekleyenProgram = {
|
export type BekleyenProgram = {
|
||||||
program: Pick<
|
program: Pick<
|
||||||
@@ -13,12 +13,12 @@
|
|||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { getSession } from "@/lib/session";
|
import { getSession } from "@/lib/session";
|
||||||
import { appDb, schema } from "@/lib/appdb";
|
import { appDb, schema } from "@/lib/appdb";
|
||||||
import { tercihProfiliDogrula, type TercihProfili } from "@/lib/sihirbaz";
|
import { tercihProfiliDogrula, type TercihProfili } from "@/features/sihirbaz/sihirbaz-sabitler";
|
||||||
import {
|
import {
|
||||||
MANUEL_LISTE_MAX,
|
MANUEL_LISTE_MAX,
|
||||||
dogrula,
|
dogrula,
|
||||||
type ManuelTercih,
|
type ManuelTercih,
|
||||||
} from "@/components/manuel-liste/dogrula";
|
} from "@/features/liste/liste-dogrula";
|
||||||
|
|
||||||
export type ListeSenkronSonucu =
|
export type ListeSenkronSonucu =
|
||||||
| { ok: true; items: ManuelTercih[]; profil: TercihProfili | null }
|
| { ok: true; items: ManuelTercih[]; profil: TercihProfili | null }
|
||||||
12
src/features/liste/liste-queries.ts
Normal file
12
src/features/liste/liste-queries.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { searchByRank } from "@/lib/db";
|
||||||
|
import type { PuanTuruKey, RankResults } from "@/types/yokatlas";
|
||||||
|
|
||||||
|
// /sonuc'un ham tablosu: yalnızca sıralamayla ulaşılabilen programlar,
|
||||||
|
// hayal/dengeli/garanti dilimli, dilim başına ilk 20 satır.
|
||||||
|
export function siraylaProgramAra(
|
||||||
|
sira: number,
|
||||||
|
tur: PuanTuruKey,
|
||||||
|
): RankResults {
|
||||||
|
return searchByRank(sira, tur, { limitPerBucket: 20 });
|
||||||
|
}
|
||||||
@@ -6,18 +6,16 @@
|
|||||||
// (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 { type TercihProfili } from "@/features/sihirbaz/sihirbaz-sabitler";
|
||||||
tercihProfiliOku,
|
import { tercihProfiliOku } from "@/features/sihirbaz/sihirbaz-profil";
|
||||||
type TercihProfili,
|
|
||||||
} from "@/lib/sihirbaz";
|
|
||||||
import {
|
import {
|
||||||
MANUEL_LISTE_MAX,
|
MANUEL_LISTE_MAX,
|
||||||
dogrula,
|
dogrula,
|
||||||
type ManuelKaynak,
|
type ManuelKaynak,
|
||||||
type ManuelTercih,
|
type ManuelTercih,
|
||||||
} from "./dogrula";
|
} from "./liste-dogrula";
|
||||||
|
|
||||||
// Doğrulama katmanı sunucu action'larıyla paylaşılır (bkz. dogrula.ts);
|
// Doğrulama katmanı sunucu action'larıyla paylaşılır (bkz. dogrula.ts);
|
||||||
// mevcut import yolları kırılmasın diye buradan re-export edilir.
|
// mevcut import yolları kırılmasın diye buradan re-export edilir.
|
||||||
121
src/features/odeme/components/odeme-sonuc-karti.tsx
Normal file
121
src/features/odeme/components/odeme-sonuc-karti.tsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { ArrowRight, CheckCircle2, XCircle } from "lucide-react";
|
||||||
|
import { verifySession } from "@/lib/session";
|
||||||
|
import { siparisGetir } from "../odeme-queries";
|
||||||
|
import { kullanicininRaporu } from "@/features/rapor/rapor-queries";
|
||||||
|
import { odemeyiSonuclandir } from "@/lib/odeme";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { PagePixelDivider } from "@/components/pixel-decor";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { RaporYokCta } from "./rapor-yok-cta";
|
||||||
|
|
||||||
|
export async function OdemeSonucKarti({ siparis }: { siparis?: string }) {
|
||||||
|
// Oturum düşmüşse geri dönüşte ?siparis= kaybolmasın (başarı/unblur deneyimi)
|
||||||
|
const geriDonus = siparis
|
||||||
|
? `/odeme/sonuc?siparis=${encodeURIComponent(siparis)}`
|
||||||
|
: "/paket";
|
||||||
|
const session = await verifySession(geriDonus);
|
||||||
|
|
||||||
|
const order = siparis ? await siparisGetir(siparis) : null;
|
||||||
|
|
||||||
|
// Yalnızca kendi siparişi görüntülenebilir
|
||||||
|
const sahibiMi = order?.userId === session.user.id;
|
||||||
|
|
||||||
|
// Self-healing: callback kaybolduysa burada tekrar doğrula
|
||||||
|
const durum =
|
||||||
|
order && sahibiMi
|
||||||
|
? order.status === "paid"
|
||||||
|
? "paid"
|
||||||
|
: await odemeyiSonuclandir(order.id)
|
||||||
|
: "not_found";
|
||||||
|
|
||||||
|
const basarili = durum === "paid";
|
||||||
|
|
||||||
|
// Rapor zaten üretildiyse ödül bir tık uzakta: kullanıcıyı doğrudan
|
||||||
|
// rapor sayfasına döndür ki blur çözülme animasyonuyla tam listeyi görsün.
|
||||||
|
const rapor = basarili ? await kullanicininRaporu(session.user.id) : null;
|
||||||
|
const listeHref = rapor ? "/listem?acildi=1" : null;
|
||||||
|
|
||||||
|
return basarili ? (
|
||||||
|
<div className="rounded-2xl border border-emerald-200 bg-white p-8 shadow-sm">
|
||||||
|
<CheckCircle2
|
||||||
|
className="mx-auto size-12 text-emerald-500"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<h1 className="mt-4 font-heading text-2xl font-bold">
|
||||||
|
Ödeme alındı 🎉
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-sm text-slate-600">
|
||||||
|
<span className="font-semibold">
|
||||||
|
{order!.credits} kredi
|
||||||
|
</span>{" "}
|
||||||
|
hesabına tanımlandı
|
||||||
|
{order!.product === "paket"
|
||||||
|
? " ve Tercih Dönemi Paketi'n aktif — kişisel listen, risk raporun ve PDF çıktın hazır olduğunda burada."
|
||||||
|
: "."}
|
||||||
|
</p>
|
||||||
|
<PagePixelDivider seed={79} className="mx-auto mt-5" />
|
||||||
|
<div className="mt-6 flex flex-col gap-2">
|
||||||
|
{listeHref ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
className="h-11 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||||
|
>
|
||||||
|
<Link href={listeHref}>
|
||||||
|
Listeni aç — kilidi kaldırdık
|
||||||
|
<ArrowRight className="size-4" aria-hidden />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<p className="text-center text-xs text-slate-500">
|
||||||
|
24 tercihin tamamı, gerekçeler, riskler ve PDF artık açık.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<RaporYokCta />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-2xl border border-red-200 bg-white p-8 shadow-sm">
|
||||||
|
<XCircle className="mx-auto size-12 text-red-500" aria-hidden />
|
||||||
|
<h1 className="mt-4 font-heading text-2xl font-bold">
|
||||||
|
Ödeme tamamlanamadı
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-sm text-slate-600">
|
||||||
|
{durum === "pending" ? (
|
||||||
|
"Ödemen hâlâ işleniyor. Birkaç saniye sonra bu sayfayı yenile — kartından çekim yapıldıysa krediler mutlaka tanımlanır."
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Çekim yapılmadı ya da işlem iptal edildi. Kartından para
|
||||||
|
çekildiğini düşünüyorsan{" "}
|
||||||
|
<Link href="/iletisim" className="underline">
|
||||||
|
bizimle iletişime geç
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<PagePixelDivider seed={83} className="mx-auto mt-5" />
|
||||||
|
<Button asChild variant="outline" className="mt-6 h-11 cursor-pointer">
|
||||||
|
<Link href="/paket">Tekrar dene</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// iyzico doğrulaması beklenirken ortalanmış sonuç kartı iskeleti.
|
||||||
|
export function OdemeSonucKartiSkeleton() {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
aria-live="polite"
|
||||||
|
className="rounded-2xl border border-slate-200 bg-white p-8 shadow-sm"
|
||||||
|
>
|
||||||
|
<Skeleton className="mx-auto size-12 rounded-full" />
|
||||||
|
<Skeleton className="mx-auto mt-5 h-7 w-2/3" />
|
||||||
|
<Skeleton className="mx-auto mt-3 h-5 w-full" />
|
||||||
|
<Skeleton className="mx-auto mt-2 h-5 w-3/4" />
|
||||||
|
<Skeleton className="mx-auto mt-6 h-12 w-full rounded-lg" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
181
src/features/odeme/components/paket-satinal.tsx
Normal file
181
src/features/odeme/components/paket-satinal.tsx
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
ArrowRight,
|
||||||
|
CheckCircle2,
|
||||||
|
Coins,
|
||||||
|
FileText,
|
||||||
|
ListChecks,
|
||||||
|
MessageCircleQuestion,
|
||||||
|
RefreshCcw,
|
||||||
|
ShieldCheck,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { getCurrentUser } from "@/lib/session";
|
||||||
|
import { URUNLER, DENEME_KREDISI } from "@/lib/credits";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Parallax } from "@/components/parallax";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { SatinAlForm } from "./satin-al-form";
|
||||||
|
|
||||||
|
function tl(kurus: number) {
|
||||||
|
return (kurus / 100).toLocaleString("tr-TR", { maximumFractionDigits: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const OZELLIKLER = [
|
||||||
|
{
|
||||||
|
icon: ListChecks,
|
||||||
|
text: "Sıralamana özel, gerekçeli 24 tercihlik dengeli liste",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: ShieldCheck,
|
||||||
|
text: "Her tercih için yerleşme olasılığı ve risk analizi (son 4 yıl trend)",
|
||||||
|
},
|
||||||
|
{ icon: FileText, text: "Veliyle paylaşılabilir PDF rapor" },
|
||||||
|
{ icon: RefreshCcw, text: "2 liste revizyon hakkı" },
|
||||||
|
{
|
||||||
|
icon: MessageCircleQuestion,
|
||||||
|
text: `${URUNLER.paket.credits} Yapay Zeka danışman sorusu (her mesaj 1 kredi)`,
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// Paket + top-up kartları: kullanıcının paket/kredi durumuna göre CTA'lar
|
||||||
|
// değişir, okuma burada yaşar.
|
||||||
|
export async function PaketSatinAl() {
|
||||||
|
const u = await getCurrentUser();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Parallax
|
||||||
|
strength={6}
|
||||||
|
className="mt-10 grid gap-6 sm:grid-cols-[1fr_auto]"
|
||||||
|
>
|
||||||
|
{/* Ana paket */}
|
||||||
|
<section className="rounded-2xl border-2 border-orange-500 bg-white p-8 shadow-sm">
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
|
<h2 className="font-heading text-xl font-bold">
|
||||||
|
Tercih Dönemi Paketi
|
||||||
|
</h2>
|
||||||
|
<p className="font-heading text-3xl font-bold">
|
||||||
|
{tl(URUNLER.paket.amountKurus)} TL
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ul className="mt-6 space-y-3">
|
||||||
|
{OZELLIKLER.map((o) => (
|
||||||
|
<li key={o.text} className="flex items-start gap-3 text-sm">
|
||||||
|
<o.icon
|
||||||
|
className="mt-0.5 size-4 shrink-0 text-emerald-600"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
{o.text}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<div className="mt-8">
|
||||||
|
{u ? (
|
||||||
|
u.hasPaket ? (
|
||||||
|
<div className="flex items-center gap-2 rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-medium text-emerald-800">
|
||||||
|
<CheckCircle2 className="size-4" aria-hidden />
|
||||||
|
Paketin aktif —{" "}
|
||||||
|
<Link href="/" className="underline">
|
||||||
|
listeni oluştur
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<SatinAlForm
|
||||||
|
urun="paket"
|
||||||
|
label={`${tl(URUNLER.paket.amountKurus)} TL — Paketi al`}
|
||||||
|
vurgulu
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
className="h-12 w-full cursor-pointer bg-orange-500 px-8 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href={`/giris?callback=${encodeURIComponent("/paket")}`}
|
||||||
|
>
|
||||||
|
Giriş yap — sonra {tl(URUNLER.paket.amountKurus)} TL'ye
|
||||||
|
al
|
||||||
|
<ArrowRight className="size-4" aria-hidden />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<p className="text-center text-xs text-slate-500">
|
||||||
|
Girişte {DENEME_KREDISI} deneme kredisi de tanımlanır.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Image
|
||||||
|
src="/odeme/iyzico-logo-band.svg"
|
||||||
|
alt="iyzico ile Öde — Mastercard, Visa, American Express ve Troy ile güvenli ödeme"
|
||||||
|
width={429}
|
||||||
|
height={32}
|
||||||
|
className="mx-auto mt-5 h-5 w-auto"
|
||||||
|
/>
|
||||||
|
<p className="mt-3 text-center text-xs text-slate-500">
|
||||||
|
iyzico güvenli ödeme · Kart bilgilerin bize hiç ulaşmaz ·{" "}
|
||||||
|
<Link
|
||||||
|
href="/kosullar#iade"
|
||||||
|
className="underline hover:text-slate-700"
|
||||||
|
>
|
||||||
|
İade koşulları
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Top-up */}
|
||||||
|
<section className="flex h-fit flex-col rounded-2xl border border-slate-200 bg-white p-6 shadow-sm sm:w-56">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Coins className="size-4 text-amber-500" aria-hidden />
|
||||||
|
<h2 className="font-heading font-bold">Kredi bitti mi?</h2>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 flex-1 text-sm text-slate-600">
|
||||||
|
+{URUNLER.topup.credits} soru hakkı,{" "}
|
||||||
|
{tl(URUNLER.topup.amountKurus)} TL.
|
||||||
|
</p>
|
||||||
|
<div className="mt-4">
|
||||||
|
{/* Top-up hasPaket vermez: paketsiz biri alırsa parasını verip
|
||||||
|
yine maskeli liste görür. Satın alma yalnızca paketliye
|
||||||
|
açık; paketsize durum dürüstçe açıklanır. */}
|
||||||
|
{u?.hasPaket ? (
|
||||||
|
<SatinAlForm
|
||||||
|
urun="topup"
|
||||||
|
label={`+${URUNLER.topup.credits} kredi — ${tl(URUNLER.topup.amountKurus)} TL`}
|
||||||
|
/>
|
||||||
|
) : u ? (
|
||||||
|
<p className="rounded-xl border border-slate-200 bg-slate-50 px-3 py-2.5 text-xs leading-5 text-slate-600">
|
||||||
|
Ek kredi, kilitli analizleri açmaz — o yüzden önce paket
|
||||||
|
gerekir. Paket zaten {URUNLER.paket.credits} kredi içerir;
|
||||||
|
bitince buradan yükleyebilirsin.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
variant="outline"
|
||||||
|
className="h-11 w-full cursor-pointer"
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href={`/giris?callback=${encodeURIComponent("/paket")}`}
|
||||||
|
>
|
||||||
|
Önce giriş yap
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</Parallax>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PaketSatinAlSkeleton() {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
aria-live="polite"
|
||||||
|
className="mt-10 grid gap-6 sm:grid-cols-[1fr_auto]"
|
||||||
|
>
|
||||||
|
<Skeleton className="h-80 w-full rounded-2xl" />
|
||||||
|
<Skeleton className="h-80 w-full rounded-2xl sm:w-64" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,8 +3,8 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ArrowRight } from "lucide-react";
|
import { ArrowRight } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useTercihProfili } from "@/components/manuel-liste/profil-kapisi-store";
|
import { useTercihProfili } from "@/features/liste/hooks/use-tercih-profili";
|
||||||
import { sonucHref } from "@/lib/sihirbaz";
|
import { sonucHref } from "@/features/sihirbaz/sihirbaz-sabitler";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ödeme başarılı ama henüz Yapay Zeka listesi yokken gösterilen CTA: kayıtlı tercih
|
* Ödeme başarılı ama henüz Yapay Zeka listesi yokken gösterilen CTA: kayıtlı tercih
|
||||||
@@ -5,7 +5,7 @@ import { ArrowRight } from "lucide-react";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { olay } from "@/lib/analitik";
|
import { olay } from "@/lib/analitik";
|
||||||
import { baslatOdeme, type OdemeBaslatDurum } from "./actions";
|
import { baslatOdeme, type OdemeBaslatDurum } from "../odeme-actions";
|
||||||
|
|
||||||
export function SatinAlForm({
|
export function SatinAlForm({
|
||||||
urun,
|
urun,
|
||||||
11
src/features/odeme/odeme-queries.ts
Normal file
11
src/features/odeme/odeme-queries.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { appDb, schema } from "@/lib/appdb";
|
||||||
|
|
||||||
|
// Ödeme feature'ının sorgu kapısı. Sahiplik kontrolü çağıran tarafta —
|
||||||
|
// sipariş yalnızca kendi kullanıcısına gösterilir.
|
||||||
|
export function siparisGetir(siparisId: string) {
|
||||||
|
return appDb.query.orders.findFirst({
|
||||||
|
where: eq(schema.orders.id, siparisId),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useTercihProfili } from "@/components/manuel-liste/profil-kapisi-store";
|
import { useTercihProfili } from "@/features/liste/hooks/use-tercih-profili";
|
||||||
|
|
||||||
// Hero başlığı: sıralama girilmemişse tanıtım metni, kayıtlı profil varsa
|
// Hero başlığı: sıralama girilmemişse tanıtım metni, kayıtlı profil varsa
|
||||||
// aynı şablonla (vurgulu italik orta parça) aksiyon metnine dönüşür.
|
// aynı şablonla (vurgulu italik orta parça) aksiyon metnine dönüşür.
|
||||||
@@ -3,8 +3,8 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ArrowRight } from "lucide-react";
|
import { ArrowRight } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useTercihProfili } from "@/components/manuel-liste/profil-kapisi-store";
|
import { useTercihProfili } from "@/features/liste/hooks/use-tercih-profili";
|
||||||
import { sonucHref } from "@/lib/sihirbaz";
|
import { sonucHref } from "@/features/sihirbaz/sihirbaz-sabitler";
|
||||||
|
|
||||||
export function HeroFocusButton() {
|
export function HeroFocusButton() {
|
||||||
function handleClick() {
|
function handleClick() {
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
// Sihirbaz hunisinin statik anlatımı: interaktif demo yerine akışın tamamını
|
// Sihirbaz hunisinin statik anlatımı: interaktif demo yerine akışın tamamını
|
||||||
// tek bakışta gösteren bir mermaid şeması. Çizim, ürün genelindeki ortak
|
// tek bakışta gösteren bir mermaid şeması. Çizim, ürün genelindeki ortak
|
||||||
// "el çizimi" bileşeniyle yapılır (bkz. components/el-cizimi-sema.tsx).
|
// "el çizimi" bileşeniyle yapılır (bkz. features/rehber/components/el-cizimi-sema.tsx).
|
||||||
|
|
||||||
import { ElCizimiSema } from "@/components/el-cizimi-sema";
|
import { ElCizimiSema } from "@/features/rehber/components/el-cizimi-sema";
|
||||||
|
|
||||||
// Renkler ürünün risk dilinden (kırmızı/sarı/yeşil) ve slate paletinden;
|
// Renkler ürünün risk dilinden (kırmızı/sarı/yeşil) ve slate paletinden;
|
||||||
// renk tek başına anlam taşımaz, her düğüm metinle etiketlidir.
|
// renk tek başına anlam taşımaz, her düğüm metinle etiketlidir.
|
||||||
14
src/features/pazarlama/components/landing-parcalari.tsx
Normal file
14
src/features/pazarlama/components/landing-parcalari.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { CheckCircle2, X } from "lucide-react";
|
||||||
|
|
||||||
|
// Ana sayfadaki karşılaştırma tablosunun hücresi: metin değeri düz yazılır,
|
||||||
|
// boolean değer Var/Yok ikonuna çevrilir.
|
||||||
|
export function ComparisonCell({ value }: { value: boolean | string }) {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return <span className="text-sm font-medium">{value}</span>;
|
||||||
|
}
|
||||||
|
return value ? (
|
||||||
|
<CheckCircle2 className="mx-auto size-5 text-primary" aria-label="Var" />
|
||||||
|
) : (
|
||||||
|
<X className="mx-auto size-5 text-muted-foreground/50" aria-label="Yok" />
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,9 +16,9 @@ import {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import Link from "next/link";
|
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 "@/features/liste/hooks/use-tercih-profili";
|
||||||
import { PUAN_TURU_ETIKET, sonucHref } from "@/lib/sihirbaz";
|
import { PUAN_TURU_ETIKET, sonucHref } from "@/features/sihirbaz/sihirbaz-sabitler";
|
||||||
import type { DilimKey, SihirbazFacetleri } from "@/lib/db";
|
import type { DilimKey, SihirbazFacetleri } from "@/types/yokatlas";
|
||||||
|
|
||||||
export type DemoVeri = {
|
export type DemoVeri = {
|
||||||
facetler: SihirbazFacetleri;
|
facetler: SihirbazFacetleri;
|
||||||
158
src/features/pazarlama/components/meraklisina-icerik.tsx
Normal file
158
src/features/pazarlama/components/meraklisina-icerik.tsx
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import { Parallax } from "@/components/parallax";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { meraklisinaDemoVerisi } from "../pazarlama-queries";
|
||||||
|
import {
|
||||||
|
DemoKapanisCta,
|
||||||
|
DemoKumanda,
|
||||||
|
DilimSeridiCanli,
|
||||||
|
SiralamaDemo,
|
||||||
|
} from "./meraklisina-demo";
|
||||||
|
import { HuniSemasi } from "./huni-semasi";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Algoritmanın öğrenci diline çevrilmiş anlatımı. Teknik detay (SQL, çarpan
|
||||||
|
* mutfağı) bilerek verilmez; amaç güven: veri gerçek, kurallar şeffaf.
|
||||||
|
* Dilim şeridi canlıdır: kullanıcının kayıtlı tercih profilindeki sıralama
|
||||||
|
* okunur, şerit o sıralamanın gerçek sayılarıyla güncellenir (bkz.
|
||||||
|
* meraklisina-demo.tsx); profil yoksa örnek sıralama gösterilir. Sihirbaz
|
||||||
|
* hunisi ise statik bir mermaid şemasıyla anlatılır (bkz. huni-semasi.tsx).
|
||||||
|
* Diyagramlar ürünün risk renk dilini kullanır (yeşil/sarı/kırmızı) ve her
|
||||||
|
* bölge metinle de etiketlenir — renk tek başına anlam taşımaz.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Profili olmayan ziyaretçi bu örnek sıralamayı görür; SSR'da gerçek veriyle
|
||||||
|
// render edilir ki profil olmadan da sayfa anlamlı olsun.
|
||||||
|
const VARSAYILAN_SIRA = 100_000;
|
||||||
|
|
||||||
|
export function MeraklisinaIcerik() {
|
||||||
|
const baslangicVeri = meraklisinaDemoVerisi(VARSAYILAN_SIRA);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SiralamaDemo
|
||||||
|
baslangicSira={VARSAYILAN_SIRA}
|
||||||
|
baslangicVeri={baslangicVeri}
|
||||||
|
>
|
||||||
|
<Parallax
|
||||||
|
fromStart
|
||||||
|
strength={4}
|
||||||
|
className="mt-8 space-y-10 text-sm leading-7 text-slate-700"
|
||||||
|
>
|
||||||
|
<section>
|
||||||
|
<h2 className="font-heading text-lg font-bold text-slate-900">
|
||||||
|
1. Her şey taban sıralamasıyla başlar
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2">
|
||||||
|
Bir bölümün <strong>taban sıralaması</strong>, geçen yıl o bölüme
|
||||||
|
yerleşen <em>son</em>{" "}
|
||||||
|
öğrencinin Türkiye sıralamasıdır. Senin sıralaman bu sayıdan
|
||||||
|
küçükse (yani daha iyiysen), geçen yılın koşullarında o bölüme
|
||||||
|
yerleşebilirdin demektir. Sistemde her programın en güncel taban
|
||||||
|
sıralaması kayıtlıdır; bir bölümün 2025 verisi henüz oluşmamışsa
|
||||||
|
bir önceki yılınki kullanılır.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<DemoKumanda />
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 className="font-heading text-lg font-bold text-slate-900">
|
||||||
|
2. Programlar üç dilime ayrılır
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2">
|
||||||
|
Sıralamanı girdiğinde tüm programlar senin konumuna göre üç kümeye
|
||||||
|
ayrılır: tabanı senden <strong>daha iyi</strong> olanlar{" "}
|
||||||
|
<span className="font-semibold text-red-600">Hayal</span> (şansını
|
||||||
|
denediğin satırlar), tabanı sıralamana{" "}
|
||||||
|
<strong>yakın</strong> olanlar{" "}
|
||||||
|
<span className="font-semibold text-amber-600">Dengeli</span>{" "}
|
||||||
|
(listenin bel kemiği — sıralamanın yaklaşık yüzde kırk fazlasına
|
||||||
|
kadar pay bırakılır), tabanı{" "}
|
||||||
|
<strong>belirgin şekilde altında</strong> kalan her şey ise{" "}
|
||||||
|
<span className="font-semibold text-emerald-600">Garanti</span>{" "}
|
||||||
|
dilimidir.
|
||||||
|
</p>
|
||||||
|
<DilimSeridiCanli />
|
||||||
|
<p className="mt-4">
|
||||||
|
Garanti diliminin bir sonu yok: 87. sıradaki bir adayın da 500
|
||||||
|
bininci sıradaki bir adayın da ulaşabildiği <em>bütün</em>{" "}
|
||||||
|
programlar listededir. Liste sana en yakın tabandan başlar; aşağı
|
||||||
|
indikçe ve “Daha fazla göster”e bastıkça senden
|
||||||
|
uzaklaşan seçenekler gelir. Böylece ilk gördüklerin her zaman en
|
||||||
|
isabetli adaylardır ama hiçbir seçenek senden saklanmaz.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 className="font-heading text-lg font-bold text-slate-900">
|
||||||
|
3. Risk renkleri her yerde aynı dili konuşur
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2">
|
||||||
|
Tabloda, haritada ve yapay zekâ listesinde gördüğün nokta ve
|
||||||
|
renkler tek bir kurala bağlıdır:{" "}
|
||||||
|
<span className="font-semibold text-emerald-600">yeşil</span>{" "}
|
||||||
|
tabanı sıralamanın belirgin altında (güvenli),{" "}
|
||||||
|
<span className="font-semibold text-amber-600">sarı</span>{" "}
|
||||||
|
sıralamana yakın (az riskli),{" "}
|
||||||
|
<span className="font-semibold text-red-600">kırmızı</span>{" "}
|
||||||
|
tabanı senden iyi (riskli). Renk hangi sayfada olursa olsun aynı
|
||||||
|
hesaptan çıkar; bir satır sekme değiştirdi diye renk değiştirmez.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 className="font-heading text-lg font-bold text-slate-900">
|
||||||
|
4. Sihirbaz süzer, yapay zekâ kurar
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2">
|
||||||
|
“Yapay Zeka listemi kur” sihirbazındaki üç adım birbirinden
|
||||||
|
bağımsız değildir. İlk adımda yalnızca{" "}
|
||||||
|
<strong>sıralamanla ulaşabildiğin</strong>{" "}
|
||||||
|
alanlar listelenir; ikinci adımdaki iller, seçtiğin alanlardaki
|
||||||
|
program sayısına göre yeniden hesaplanır; üçüncü adımdaki
|
||||||
|
devlet/vakıf sayıları da hem alan hem il seçimini hesaba katar.
|
||||||
|
Adımlar bitince elde kalan gerçek programlar bir{" "}
|
||||||
|
<strong>aday havuzu</strong>{" "}
|
||||||
|
olur ve yapay zekâya öyle gider: ona boş bir sayfa değil, bu havuz
|
||||||
|
verilir. Aşağıdaki şema akışın tamamını gösterir — havuz
|
||||||
|
24'ün altına düşerse filtrelerin nasıl gevşetildiği, yine de
|
||||||
|
yetmezse listenin eldeki programlarla, sana açıkça belirtilerek
|
||||||
|
nasıl kurulduğu da dahil.
|
||||||
|
</p>
|
||||||
|
<HuniSemasi />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 className="font-heading text-lg font-bold text-slate-900">
|
||||||
|
5. Dürüst olalım: bu bir tahmin aracıdır
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2">
|
||||||
|
Tüm bu hesaplar <strong>geçen yılların</strong>{" "}
|
||||||
|
yerleştirme sonuçlarına dayanır. Kontenjanlar, tercih davranışları
|
||||||
|
ve puanlar her yıl değişir; geçen yıl “garanti”
|
||||||
|
görünen bir bölüm bu yıl dolabilir. Bu yüzden satırlarda son beş
|
||||||
|
yılın taban seyrini de gösteririz — çizgi yukarı tırmanıyorsa
|
||||||
|
bölüm zorlaşıyor demektir. KolayTercih karar <em>destek</em>{" "}
|
||||||
|
aracıdır; listenin son hâli ve sorumluluğu her zaman sana aittir.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<DemoKapanisCta />
|
||||||
|
</Parallax>
|
||||||
|
</SiralamaDemo>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MeraklisinaIcerikSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="mt-8 space-y-10">
|
||||||
|
{Array.from({ length: 3 }, (_, i) => (
|
||||||
|
<div key={i}>
|
||||||
|
<Skeleton className="h-6 w-72" />
|
||||||
|
<Skeleton className="mt-3 h-4 w-full" />
|
||||||
|
<Skeleton className="mt-1.5 h-4 w-full" />
|
||||||
|
<Skeleton className="mt-1.5 h-4 w-2/3" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user