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ı
|
||||
|
||||
`/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 -->
|
||||
|
||||
BIN
data/app.db
BIN
data/app.db
Binary file not shown.
@@ -42,6 +42,7 @@
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"resend": "^6.18.0",
|
||||
"server-only": "^0.0.1",
|
||||
"shiki": "^4.3.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
|
||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@@ -83,6 +83,9 @@ importers:
|
||||
resend:
|
||||
specifier: ^6.18.0
|
||||
version: 6.18.0
|
||||
server-only:
|
||||
specifier: ^0.0.1
|
||||
version: 0.0.1
|
||||
shiki:
|
||||
specifier: ^4.3.1
|
||||
version: 4.3.1
|
||||
@@ -5133,6 +5136,9 @@ packages:
|
||||
resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
server-only@0.0.1:
|
||||
resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==}
|
||||
|
||||
set-cookie-parser@3.1.2:
|
||||
resolution: {integrity: sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==}
|
||||
|
||||
@@ -10947,6 +10953,8 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
server-only@0.0.1: {}
|
||||
|
||||
set-cookie-parser@3.1.2: {}
|
||||
|
||||
set-function-length@1.2.2:
|
||||
|
||||
@@ -18,7 +18,13 @@ import { randomUUID } from "node:crypto";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { z } from "zod";
|
||||
import { appDb, schema } from "../src/lib/appdb";
|
||||
import { dilimAra, type DilimKey, type Program, type PuanTuruKey, PUAN_TURLERI } from "../src/lib/db";
|
||||
import { dilimAra } from "../src/lib/db";
|
||||
import {
|
||||
PUAN_TURLERI,
|
||||
type DilimKey,
|
||||
type Program,
|
||||
type PuanTuruKey,
|
||||
} from "../src/types/yokatlas";
|
||||
import { KATEGORILER, kategoriEslesir } from "../src/lib/kategoriler";
|
||||
import { havuzuKompaktJson, turLabel } from "../src/lib/rapor-havuzu";
|
||||
import { GENEL_KATEGORI, KOVALAR, kategoriSlugBul } from "../src/lib/tadimlik-havuzu";
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import { dilimAra, rankWindowFacets } from "@/lib/db";
|
||||
import {
|
||||
PUAN_TURLERI,
|
||||
dilimAra,
|
||||
rankWindowFacets,
|
||||
type DilimKey,
|
||||
type PuanTuruKey,
|
||||
} from "@/lib/db";
|
||||
} from "@/types/yokatlas";
|
||||
import { KATEGORILER } from "@/lib/kategoriler";
|
||||
|
||||
// Sihirbaz modalı için: sıralamaya uygun kategori/il/üniversite tipi
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import { dilimAra } from "@/lib/db";
|
||||
import {
|
||||
PUAN_TURLERI,
|
||||
dilimAra,
|
||||
type DilimKey,
|
||||
type PuanTuruKey,
|
||||
} from "@/lib/db";
|
||||
} from "@/types/yokatlas";
|
||||
|
||||
const SAYFA_BOYU = 20;
|
||||
|
||||
|
||||
@@ -8,12 +8,13 @@ import {
|
||||
krediBittiIsaretle,
|
||||
} from "@/lib/credits";
|
||||
import { anahtarVar, SORUMLULUK_REDDI } from "@/lib/ai/client";
|
||||
import { kullanicininRaporu } from "@/lib/rapor-kaydi";
|
||||
import { kullanicininRaporu } from "@/features/rapor/rapor-queries";
|
||||
import { sohbetAkisi } from "@/lib/ai/cagri";
|
||||
import { listeOzetiCikar, type RaporSonuc } from "@/lib/ai/rapor";
|
||||
import type { RaporParams } from "@/lib/rapor-havuzu";
|
||||
import { secimOzeti } from "@/lib/sihirbaz";
|
||||
import { PUAN_TURLERI } from "@/lib/db";
|
||||
import { listeOzetiCikar } from "@/lib/ai/rapor";
|
||||
import type { RaporSonuc } from "@/features/rapor/types/rapor";
|
||||
import type { RaporParams } from "@/features/rapor/types/rapor";
|
||||
import { secimOzeti } from "@/features/sihirbaz/sihirbaz-sabitler";
|
||||
import { PUAN_TURLERI } from "@/types/yokatlas";
|
||||
import { ACIK_SATIR } from "@/lib/rapor-maske";
|
||||
|
||||
const MAX_GECMIS = 12;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { NextResponse } from "next/server";
|
||||
import { sosyalKanitVerisi } from "@/lib/sosyal-kanit";
|
||||
|
||||
// Fiyat bölümündeki şeridin canlı sayıyı tazelemek için yokladığı uç.
|
||||
// İlk değer sunucudan HTML ile geliyor (bkz. components/landing/sosyal-kanit.tsx);
|
||||
// İlk değer sunucudan HTML ile geliyor (bkz. features/pazarlama/components/sosyal-kanit.tsx);
|
||||
// burası yalnızca sayfa açık kaldıkça gelen güncellemeler için.
|
||||
// Sayılar sosyal kanıt, gizli veri değil — kimlik doğrulaması yok.
|
||||
// Yük: sosyalKanitVerisi kendi içinde TTL önbelleğine sahip, yoklama
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// batch üretilmiştir — bu uç LLM'e istek atmaz, salt SELECT'tir.
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { PUAN_TURLERI, type PuanTuruKey } from "@/lib/db";
|
||||
import { PUAN_TURLERI, type PuanTuruKey } from "@/types/yokatlas";
|
||||
import { KATEGORILER } from "@/lib/kategoriler";
|
||||
import { kategoriSlugBul, tadimlikSec } from "@/lib/tadimlik-havuzu";
|
||||
|
||||
|
||||
@@ -1,28 +1,32 @@
|
||||
import { Suspense } from "react";
|
||||
import type { Metadata } from "next";
|
||||
import { getAllBolumSlugs } from "@/lib/katalog";
|
||||
import { BolumIcerik, bolumSayfaMetadata } from "./bolum-icerik";
|
||||
import {
|
||||
BolumIcerik,
|
||||
BolumIcerikSkeleton,
|
||||
bolumSayfaMetadata,
|
||||
bolumStaticSlugs,
|
||||
} from "@/features/katalog/components/bolum-icerik";
|
||||
|
||||
// Tüm bölümler build sırasında bilinir (veri ancak ingest + deploy ile
|
||||
// değişir); bilinmeyen slug getBolumBySlug'dan null dönüp notFound()'a düşer.
|
||||
// (dynamicParams cacheComponents ile uyumsuz olduğundan kullanılamıyor.)
|
||||
export function generateStaticParams() {
|
||||
return getAllBolumSlugs().map((slug) => ({ slug }));
|
||||
return bolumStaticSlugs().map((slug) => ({ slug }));
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>;
|
||||
}): Promise<Metadata> {
|
||||
}: PageProps<"/bolum/[slug]">): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
return bolumSayfaMetadata(slug, 1);
|
||||
}
|
||||
|
||||
export default async function BolumPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>;
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
return <BolumIcerik slug={slug} sayfa={1} />;
|
||||
export default function BolumPage({ params }: PageProps<"/bolum/[slug]">) {
|
||||
return (
|
||||
<Suspense fallback={<BolumIcerikSkeleton />}>
|
||||
{params.then(({ slug }) => (
|
||||
<BolumIcerik slug={slug} sayfa={1} />
|
||||
))}
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,22 +1,17 @@
|
||||
import { Suspense } from "react";
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getAllBolumler } from "@/lib/katalog";
|
||||
import {
|
||||
BolumIcerik,
|
||||
BolumIcerikSkeleton,
|
||||
bolumSayfaMetadata,
|
||||
bolumToplamSayfa,
|
||||
} from "../../bolum-icerik";
|
||||
bolumSayfaStaticParams,
|
||||
} from "@/features/katalog/components/bolum-icerik";
|
||||
|
||||
// Yalnızca 1'den fazla sayfası olan bölümler için 2..N sayfaları üretilir;
|
||||
// 1. sayfa taban rota (/bolum/[slug]) olduğundan burada üretilmez.
|
||||
export function generateStaticParams() {
|
||||
return getAllBolumler().flatMap((b) => {
|
||||
const toplam = bolumToplamSayfa(b.programSayisi);
|
||||
return Array.from({ length: Math.max(0, toplam - 1) }, (_, i) => ({
|
||||
slug: b.slug,
|
||||
no: String(i + 2),
|
||||
}));
|
||||
});
|
||||
return bolumSayfaStaticParams();
|
||||
}
|
||||
|
||||
function sayfaNo(no: string): number | null {
|
||||
@@ -26,22 +21,23 @@ function sayfaNo(no: string): number | null {
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string; no: string }>;
|
||||
}): Promise<Metadata> {
|
||||
}: PageProps<"/bolum/[slug]/sayfa/[no]">): Promise<Metadata> {
|
||||
const { slug, no } = await params;
|
||||
const n = sayfaNo(no);
|
||||
if (n == null) return {};
|
||||
return bolumSayfaMetadata(slug, n);
|
||||
}
|
||||
|
||||
export default async function BolumSayfaPage({
|
||||
export default function BolumSayfaPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string; no: string }>;
|
||||
}) {
|
||||
const { slug, no } = await params;
|
||||
}: PageProps<"/bolum/[slug]/sayfa/[no]">) {
|
||||
return (
|
||||
<Suspense fallback={<BolumIcerikSkeleton />}>
|
||||
{params.then(({ slug, no }) => {
|
||||
const n = sayfaNo(no);
|
||||
if (n == null) notFound();
|
||||
return <BolumIcerik slug={slug} sayfa={n} />;
|
||||
})}
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Suspense } from "react";
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { getAllBolumler, type BolumOzet } from "@/lib/katalog";
|
||||
import { KATEGORILER } from "@/lib/kategoriler";
|
||||
import { JsonLd, breadcrumbJsonLd } from "@/lib/seo";
|
||||
import { CtaSiraForm } from "@/components/cta-sira-form";
|
||||
import { SiteFooter } from "@/components/site-footer";
|
||||
import { PagePixelDivider } from "@/components/pixel-decor";
|
||||
import {
|
||||
BolumKatalogu,
|
||||
BolumKataloguSkeleton,
|
||||
} from "@/features/katalog/components/bolum-listesi";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Tüm Bölümlerin Taban Puanları ve Sıralamaları 2026",
|
||||
@@ -14,68 +14,7 @@ export const metadata: Metadata = {
|
||||
alternates: { canonical: "/bolumler" },
|
||||
};
|
||||
|
||||
const sayi = (n: number) => n.toLocaleString("tr-TR");
|
||||
|
||||
function BolumLinkleri({ bolumler }: { bolumler: BolumOzet[] }) {
|
||||
return (
|
||||
<ul className="mt-3 grid gap-x-6 gap-y-1.5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{bolumler.map((b) => (
|
||||
<li key={b.slug}>
|
||||
<Link
|
||||
href={`/bolum/${b.slug}`}
|
||||
className="group inline-flex items-baseline gap-2 text-sm text-slate-700 hover:text-primary"
|
||||
>
|
||||
<span className="group-hover:underline">{b.ad}</span>
|
||||
<span className="text-xs text-slate-400">
|
||||
{sayi(b.programSayisi)}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function KategoriGruplari({
|
||||
bolumler,
|
||||
seviye,
|
||||
}: {
|
||||
bolumler: BolumOzet[];
|
||||
seviye: "lisans" | "onlisans";
|
||||
}) {
|
||||
const kalanlar = new Set(bolumler.map((b) => b.slug));
|
||||
const gruplar = KATEGORILER.map((kategori) => {
|
||||
const uyeler = bolumler.filter((b) =>
|
||||
(b.kategoriler as readonly string[]).includes(kategori),
|
||||
);
|
||||
for (const b of uyeler) kalanlar.delete(b.slug);
|
||||
return { kategori: kategori as string, uyeler };
|
||||
}).filter((g) => g.uyeler.length > 0);
|
||||
const diger = bolumler.filter((b) => kalanlar.has(b.slug));
|
||||
if (diger.length > 0) gruplar.push({ kategori: "Diğer", uyeler: diger });
|
||||
|
||||
return (
|
||||
<>
|
||||
{gruplar.map(({ kategori, uyeler }) => (
|
||||
<section key={`${seviye}-${kategori}`} className="mt-8">
|
||||
<h3 className="font-heading text-lg font-bold text-slate-900">
|
||||
{kategori}
|
||||
<span className="ml-2 text-xs font-normal text-slate-400">
|
||||
{sayi(uyeler.length)} bölüm
|
||||
</span>
|
||||
</h3>
|
||||
<BolumLinkleri bolumler={uyeler} />
|
||||
</section>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BolumlerPage() {
|
||||
const hepsi = getAllBolumler();
|
||||
const lisans = hepsi.filter((b) => b.lisans);
|
||||
const onlisans = hepsi.filter((b) => b.onlisans && !b.lisans);
|
||||
|
||||
return (
|
||||
<>
|
||||
<main className="mx-auto w-full max-w-5xl px-4 py-12 sm:py-16">
|
||||
@@ -89,33 +28,10 @@ export default function BolumlerPage() {
|
||||
<h1 className="font-heading text-3xl font-bold text-slate-900 sm:text-4xl">
|
||||
Tüm Bölümlerin Taban Puanları ve Sıralamaları 2026
|
||||
</h1>
|
||||
<p className="mt-4 max-w-3xl text-sm leading-7 text-slate-600">
|
||||
{sayi(hepsi.length)} bölümün 2025 YKS yerleştirme verileri: taban
|
||||
puanları, başarı sıralamaları, kontenjanlar ve devlet/vakıf dağılımı.
|
||||
Bölüme tıklayınca o bölümü sunan tüm üniversitelerin tablosunu
|
||||
görürsün. Veriler YÖK Atlas 2025 yerleştirme sonuçlarına dayanır; 2026
|
||||
tercihleri için rehber niteliğindedir.
|
||||
</p>
|
||||
|
||||
<PagePixelDivider seed={29} className="mt-8" />
|
||||
|
||||
<section className="mt-8">
|
||||
<h2 className="font-heading text-2xl font-bold text-slate-900">
|
||||
Lisans Bölümleri (4 Yıllık)
|
||||
</h2>
|
||||
<KategoriGruplari bolumler={lisans} seviye="lisans" />
|
||||
</section>
|
||||
|
||||
<div className="mt-12">
|
||||
<CtaSiraForm />
|
||||
</div>
|
||||
|
||||
<section className="mt-12">
|
||||
<h2 className="font-heading text-2xl font-bold text-slate-900">
|
||||
Önlisans Bölümleri (2 Yıllık — TYT)
|
||||
</h2>
|
||||
<KategoriGruplari bolumler={onlisans} seviye="onlisans" />
|
||||
</section>
|
||||
<Suspense fallback={<BolumKataloguSkeleton />}>
|
||||
<BolumKatalogu />
|
||||
</Suspense>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</>
|
||||
|
||||
@@ -1,30 +1,16 @@
|
||||
import { Suspense } from "react";
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { PixelCorner } from "@/components/pixel-decor";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DogrulaKarti,
|
||||
DogrulaKartiSkeleton,
|
||||
} from "@/features/kullanici/components/dogrula-karti";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Girişini onayla",
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
/** Yalnızca site içi (göreli) callback'lere izin ver — open redirect kalkanı. */
|
||||
function guvenliYol(deger: string | undefined, varsayilan: string): string {
|
||||
return deger && deger.startsWith("/") && !deger.startsWith("//")
|
||||
? deger
|
||||
: varsayilan;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maildeki giriş bağlantısının indiği onay sayfası. Doğrulama ucu
|
||||
* (/api/auth/magic-link/verify) GET'lendiği anda token'ı tüketir; mail
|
||||
* güvenlik tarayıcıları ve tarayıcı ön-yüklemeleri linki kullanıcıdan önce
|
||||
* açıp token'ı yakabildiği için maildeki link token'ı TÜKETMEYEN bu sayfaya
|
||||
* gelir. Verify ancak buradaki form gönderilince tetiklenir — tarayıcı botları
|
||||
* sayfayı GET'lese bile form'u göndermez, token yanmaz.
|
||||
*/
|
||||
export default async function DogrulaPage({
|
||||
export default function DogrulaPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{
|
||||
@@ -34,68 +20,19 @@ export default async function DogrulaPage({
|
||||
newUserCallbackURL?: string;
|
||||
}>;
|
||||
}) {
|
||||
const { token, callbackURL, errorCallbackURL, newUserCallbackURL } =
|
||||
await searchParams;
|
||||
if (!token) redirect("/giris");
|
||||
|
||||
const callback = guvenliYol(callbackURL, "/");
|
||||
const hataCallback = guvenliYol(
|
||||
errorCallbackURL,
|
||||
`/giris?hata=gecersiz-baglanti&callback=${encodeURIComponent(callback)}`,
|
||||
);
|
||||
const yeniKullaniciCallback = guvenliYol(newUserCallbackURL, callback);
|
||||
|
||||
return (
|
||||
<div className="-mb-28 flex min-h-dvh flex-col bg-slate-50 text-slate-900">
|
||||
<main className="mx-auto flex w-full max-w-md flex-1 flex-col items-stretch justify-center px-6 py-8">
|
||||
<div className="relative">
|
||||
<div className="relative rounded-2xl border border-slate-200 bg-white p-8 shadow-sm">
|
||||
<h1 className="font-heading text-xl font-bold">Girişini onayla</h1>
|
||||
<p className="mt-2 text-sm text-slate-600">
|
||||
Bu bağlantı senin için üretildi ve{" "}
|
||||
<span className="font-semibold">tek kullanımlık</span>. Aşağıdaki
|
||||
butona tıkladığında KolayTercih hesabına giriş yapacaksın.
|
||||
</p>
|
||||
<form
|
||||
method="GET"
|
||||
action="/api/auth/magic-link/verify"
|
||||
className="mt-6"
|
||||
>
|
||||
<input type="hidden" name="token" value={token} />
|
||||
<input type="hidden" name="callbackURL" value={callback} />
|
||||
<input
|
||||
type="hidden"
|
||||
name="errorCallbackURL"
|
||||
value={hataCallback}
|
||||
<Suspense fallback={<DogrulaKartiSkeleton />}>
|
||||
{searchParams.then((sp) => (
|
||||
<DogrulaKarti
|
||||
token={sp.token}
|
||||
callbackURL={sp.callbackURL}
|
||||
errorCallbackURL={sp.errorCallbackURL}
|
||||
newUserCallbackURL={sp.newUserCallbackURL}
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
name="newUserCallbackURL"
|
||||
value={yeniKullaniciCallback}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
className="h-11 w-full cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||
>
|
||||
Giriş yap
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Button>
|
||||
</form>
|
||||
<p className="mt-4 text-xs leading-relaxed text-slate-500">
|
||||
Bu isteği sen yapmadıysan bu sayfayı kapatabilirsin — butona
|
||||
tıklanmadığı sürece hesabında hiçbir şey değişmez.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 z-10 text-primary"
|
||||
>
|
||||
<PixelCorner className="absolute -right-4 -top-4 size-9 [&_rect]:[fill-opacity:0.65]" />
|
||||
<PixelCorner className="absolute -bottom-4 -right-4 size-9 rotate-90 [&_rect]:[fill-opacity:0.65]" />
|
||||
<PixelCorner className="absolute -bottom-4 -left-4 size-9 rotate-180 [&_rect]:[fill-opacity:0.65]" />
|
||||
<PixelCorner className="absolute -left-4 -top-4 size-9 -rotate-90 [&_rect]:[fill-opacity:0.65]" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Suspense>
|
||||
</main>
|
||||
</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 Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getSession } from "@/lib/session";
|
||||
import { PixelCorner } from "@/components/pixel-decor";
|
||||
import { GirisForm } from "./giris-form";
|
||||
import { GirisBaslik } from "./giris-baslik";
|
||||
import {
|
||||
GirisKarti,
|
||||
GirisKartiSkeleton,
|
||||
} from "@/features/kullanici/components/giris-karti";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Giriş",
|
||||
robots: { index: false, follow: true },
|
||||
};
|
||||
|
||||
export default async function GirisPage({
|
||||
export default function GirisPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ callback?: string; hata?: string }>;
|
||||
}) {
|
||||
const { callback, hata } = await searchParams;
|
||||
const callbackURL =
|
||||
callback && callback.startsWith("/") ? callback : "/";
|
||||
// Magic link doğrulaması başarısız olunca errorCallbackURL buraya düşürür
|
||||
// (bkz. giris-form.tsx ve /giris/dogrula) — ham INVALID_TOKEN yerine
|
||||
// dostça bir mesaj gösterip yeni bağlantı istemeye yönlendiriyoruz.
|
||||
const baglantiHatasi = hata === "gecersiz-baglanti";
|
||||
|
||||
const session = await getSession();
|
||||
if (session) redirect(callbackURL);
|
||||
|
||||
// Sihirbaz funnel'ı: seçimler yapıldı, listeye giriş kaldı
|
||||
const funnel = callbackURL.startsWith("/sonuc");
|
||||
|
||||
return (
|
||||
// -mb-28: layout'taki ScrollFlow sarmalayıcısının pb-28'ini nötrler —
|
||||
// kart tam dvh içinde ortalanır, sayfa scroll etmez (sığmazsa scroll açılır).
|
||||
<div className="-mb-28 flex min-h-dvh flex-col bg-slate-50 text-slate-900">
|
||||
<main className="mx-auto flex w-full max-w-md flex-1 flex-col items-stretch justify-center px-6 py-8">
|
||||
<div className="relative">
|
||||
<div className="relative rounded-2xl border border-slate-200 bg-white p-8 shadow-sm">
|
||||
{baglantiHatasi && (
|
||||
<div
|
||||
role="alert"
|
||||
className="mb-5 rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-800"
|
||||
>
|
||||
<p className="font-semibold">
|
||||
Giriş bağlantısı geçersiz ya da süresi dolmuş
|
||||
</p>
|
||||
<p className="mt-1">
|
||||
Bağlantılar 15 dakika geçerli ve tek kullanımlık. Aşağıdan
|
||||
yeni bir bağlantı isteyebilirsin.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<GirisBaslik funnel={funnel} />
|
||||
<p className="mt-2 text-sm text-slate-600">
|
||||
{funnel ? (
|
||||
<>
|
||||
Seçimlerin hazır — giriş yap,{" "}
|
||||
<span className="font-semibold">5 deneme kredinle</span> listen
|
||||
hemen üretilsin.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Kayıt olduğunda{" "}
|
||||
<span className="font-semibold">5 deneme kredisi</span>{" "}
|
||||
hesabına tanımlanır — Yapay Zeka danışmana ücretsiz soru sorabilirsin.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<GirisForm callbackURL={callbackURL} />
|
||||
</div>
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 z-10 text-primary"
|
||||
>
|
||||
<PixelCorner className="absolute -right-4 -top-4 size-9 [&_rect]:[fill-opacity:0.65]" />
|
||||
<PixelCorner className="absolute -bottom-4 -right-4 size-9 rotate-90 [&_rect]:[fill-opacity:0.65]" />
|
||||
<PixelCorner className="absolute -bottom-4 -left-4 size-9 rotate-180 [&_rect]:[fill-opacity:0.65]" />
|
||||
<PixelCorner className="absolute -left-4 -top-4 size-9 -rotate-90 [&_rect]:[fill-opacity:0.65]" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-6 text-center text-xs leading-relaxed text-slate-500">
|
||||
Giriş yaparak verilerinin tercih önerileri üretmek amacıyla{" "}
|
||||
<Link href="/gizlilik" className="underline hover:text-slate-700">
|
||||
gizlilik metnindeki
|
||||
</Link>{" "}
|
||||
gibi işlenmesini ve{" "}
|
||||
<Link href="/kosullar" className="underline hover:text-slate-700">
|
||||
kullanım koşullarını
|
||||
</Link>{" "}
|
||||
kabul etmiş olursun. KolayTercih yerleşme garantisi vermez.
|
||||
</p>
|
||||
<Suspense fallback={<GirisKartiSkeleton />}>
|
||||
{searchParams.then(({ callback, hata }) => (
|
||||
<GirisKarti callback={callback} hata={hata} />
|
||||
))}
|
||||
</Suspense>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,9 +5,9 @@ import localFont from "next/font/local";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { SiteHeader } from "@/components/site-header";
|
||||
import { SiteTopBanner } from "@/components/site-top-banner";
|
||||
import { ListeCekmecesiLazy } from "@/components/manuel-liste/liste-cekmecesi-lazy";
|
||||
import { KaydetBannerLazy } from "@/components/manuel-liste/kaydet-banner-lazy";
|
||||
import { TercihProfiliKapisiLazy } from "@/components/manuel-liste/tercih-profili-kapisi-lazy";
|
||||
import { ListeCekmecesiLazy } from "@/features/liste/components/liste-cekmecesi-lazy";
|
||||
import { KaydetBannerLazy } from "@/features/liste/components/kaydet-banner-lazy";
|
||||
import { TercihProfiliKapisiLazy } from "@/features/liste/components/tercih-profili-kapisi-lazy";
|
||||
import { HeaderGate } from "@/components/header-gate";
|
||||
import { ProgressiveBlur } from "@/components/ui/skiper-ui/skiper41";
|
||||
import { DevPanel } from "@/components/dev/dev-panel";
|
||||
|
||||
@@ -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 { asc, eq } from "drizzle-orm";
|
||||
import { redirect } from "next/navigation";
|
||||
import { MessageCircleQuestion, Sparkles } from "lucide-react";
|
||||
import { verifySession, getCurrentUser } from "@/lib/session";
|
||||
import { appDb, schema } from "@/lib/appdb";
|
||||
import { kullanicininRaporu } from "@/lib/rapor-kaydi";
|
||||
import { URUNLER, MAX_REVIZYON } from "@/lib/credits";
|
||||
import type { RaporSonuc } from "@/lib/ai/rapor";
|
||||
import type { RaporParams } from "@/lib/rapor-havuzu";
|
||||
import { ViewportPortal } from "@/components/viewport-portal";
|
||||
import { SiteFooter } from "@/components/site-footer";
|
||||
import { raporMaskele, ACIK_SATIR, type MaskeliRapor } from "@/lib/rapor-maske";
|
||||
import type { Mesaj } from "./sohbet-client";
|
||||
import { ListemGovde } from "./listem-govde";
|
||||
import { ListemBosCta } from "./listem-bos-cta";
|
||||
import { ListeUretici } from "./liste-uretici";
|
||||
import {
|
||||
ListemIcerik,
|
||||
ListemIcerikSkeleton,
|
||||
} from "@/features/rapor/components/listem-icerik";
|
||||
|
||||
// listeRevize aksiyonu 2 AI denemesi yapabiliyor (deneme başına 60 sn abort
|
||||
// sınırı, bkz. lib/ai/cagri.ts) → en kötü ~120 sn. Platform limiti bundan
|
||||
@@ -28,126 +17,16 @@ export const metadata: Metadata = {
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
const PAKET_FIYATI = (URUNLER.paket.amountKurus / 100).toLocaleString("tr-TR", {
|
||||
maximumFractionDigits: 0,
|
||||
});
|
||||
|
||||
export default async function ListemPage({
|
||||
export default function ListemPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ acildi?: string; uret?: string }>;
|
||||
}) {
|
||||
// Girişsiz gelene callback'i ?uret=1 ile ver: girişten dönüşte bekleyen
|
||||
// seçimlerle üretim kaldığı yerden başlasın.
|
||||
const ilkParams = await searchParams;
|
||||
const geriYol = ilkParams.uret === "1" ? "/listem?uret=1" : "/listem";
|
||||
const session = await verifySession(geriYol);
|
||||
const [user, params, satir, sohbetGecmisi] = await Promise.all([
|
||||
getCurrentUser(),
|
||||
searchParams,
|
||||
kullanicininRaporu(session.user.id),
|
||||
appDb
|
||||
.select({
|
||||
id: schema.chatMessages.id,
|
||||
role: schema.chatMessages.role,
|
||||
content: schema.chatMessages.content,
|
||||
})
|
||||
.from(schema.chatMessages)
|
||||
.where(eq(schema.chatMessages.userId, session.user.id))
|
||||
.orderBy(asc(schema.chatMessages.createdAt))
|
||||
.limit(100),
|
||||
]);
|
||||
if (!user) redirect(`/giris?callback=${encodeURIComponent(geriYol)}`);
|
||||
|
||||
const { acildi } = params;
|
||||
const kilitAcilisi = Boolean(user.hasPaket && acildi === "1");
|
||||
const raporVar = Boolean(satir?.result);
|
||||
|
||||
if (!raporVar) {
|
||||
const bosDurum = (
|
||||
<section className="mx-auto flex max-w-2xl flex-col items-center px-4 py-16 text-center">
|
||||
<Sparkles className="size-10 text-primary" aria-hidden />
|
||||
<h1 className="mt-4 font-heading text-2xl font-bold">
|
||||
Henüz Yapay Zeka listen yok
|
||||
</h1>
|
||||
<ListemBosCta />
|
||||
</section>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<main className="mx-auto w-full max-w-4xl px-4 py-12">
|
||||
{/* ?uret=1: sihirbazdan yönlendirildi — bekleyen seçimlerle üretim
|
||||
burada koşar, animasyon ve hata/kredi durumları burada yaşar. */}
|
||||
{params.uret === "1" ? (
|
||||
<ListeUretici
|
||||
hasPaket={Boolean(user.hasPaket)}
|
||||
bosDurum={bosDurum}
|
||||
/>
|
||||
) : (
|
||||
bosDurum
|
||||
)}
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const rapor: MaskeliRapor = user.hasPaket
|
||||
? (satir!.result as RaporSonuc)
|
||||
: raporMaskele(satir!.result as RaporSonuc);
|
||||
const raporParams = satir!.params as RaporParams;
|
||||
// Danışman özeti sohbetin açılış mesajı olarak gösterilir; sohbet
|
||||
// /api/soru üzerinden zaten liste + seçim bağlamıyla çalışıyor.
|
||||
// Paketsizde rapor zaten maskeli: 1 uyarı açık, kalanının yalnızca sayısı var.
|
||||
const danismanOzeti = rapor.genelDegerlendirme
|
||||
? {
|
||||
metin: rapor.genelDegerlendirme,
|
||||
uyarilar: rapor.uyarilar,
|
||||
kilitli: !user.hasPaket,
|
||||
kilitliUyariSayisi: user.hasPaket ? undefined : rapor.kilitliUyariSayisi,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<main className="mx-auto w-full max-w-7xl px-4 py-12">
|
||||
{kilitAcilisi ? (
|
||||
<div
|
||||
role="status"
|
||||
className="mb-6 flex items-center gap-3 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-medium text-emerald-800"
|
||||
>
|
||||
<Sparkles className="size-4 shrink-0" aria-hidden />
|
||||
Paketin aktif — listendeki tercihlerin tamamı, gerekçeler, riskler ve
|
||||
PDF artık açık.
|
||||
</div>
|
||||
) : null}
|
||||
{/* Başlık ve revizyon/PDF aksiyonları listenin kendi başlık satırında
|
||||
(bkz. listem-govde.tsx) — sayfa üstünde ayrı bir header yok. */}
|
||||
<ListemGovde
|
||||
rapor={rapor}
|
||||
adaySira={raporParams.sira}
|
||||
hasPaket={Boolean(user.hasPaket)}
|
||||
kilitAcilisi={kilitAcilisi}
|
||||
kilitliBaslangic={user.hasPaket ? undefined : ACIK_SATIR}
|
||||
kredi={user.creditBalance}
|
||||
sohbetGecmisi={sohbetGecmisi as Mesaj[]}
|
||||
ozet={danismanOzeti}
|
||||
kalanHak={Math.max(0, MAX_REVIZYON - (satir!.revisionCount ?? 0))}
|
||||
paketFiyati={PAKET_FIYATI}
|
||||
paketKredi={URUNLER.paket.credits}
|
||||
/>
|
||||
|
||||
<ViewportPortal>
|
||||
<a
|
||||
href="#sohbet"
|
||||
className="fixed bottom-5 right-4 z-40 inline-flex h-11 items-center gap-2 rounded-full bg-primary px-4 text-sm font-semibold text-white shadow-lg transition-transform duration-200 active:scale-[0.97] lg:hidden print:hidden"
|
||||
>
|
||||
<MessageCircleQuestion className="size-4" aria-hidden />
|
||||
Danışmana sor
|
||||
</a>
|
||||
</ViewportPortal>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</>
|
||||
<Suspense fallback={<ListemIcerikSkeleton />}>
|
||||
{searchParams.then(({ acildi, uret }) => (
|
||||
<ListemIcerik uret={uret === "1"} acildi={acildi === "1"} />
|
||||
))}
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
import { Suspense } from "react";
|
||||
import type { Metadata } from "next";
|
||||
import { Parallax } from "@/components/parallax";
|
||||
import { PagePixelDivider, SectionEyebrow } from "@/components/pixel-decor";
|
||||
import { SiteFooter } from "@/components/site-footer";
|
||||
import { dilimAra, rankWindowFacets, type DilimKey } from "@/lib/db";
|
||||
import {
|
||||
DemoKapanisCta,
|
||||
DemoKumanda,
|
||||
DilimSeridiCanli,
|
||||
SiralamaDemo,
|
||||
type DemoVeri,
|
||||
} from "./demo";
|
||||
import { HuniSemasi } from "./huni-semasi";
|
||||
MeraklisinaIcerik,
|
||||
MeraklisinaIcerikSkeleton,
|
||||
} from "@/features/pazarlama/components/meraklisina-icerik";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Meraklısına: KolayTercih nasıl çalışır?",
|
||||
@@ -19,33 +15,7 @@ export const metadata: Metadata = {
|
||||
"Sıralamanı girdiğinde arka planda ne oluyor? Dilimler, risk renkleri ve sihirbazın adım adım daralan filtreleri — öğrenci diliyle, sıralamana göre hesaplanan canlı görsellerle.",
|
||||
};
|
||||
|
||||
/**
|
||||
* Algoritmanın öğrenci diline çevrilmiş anlatımı. Teknik detay (SQL, çarpan
|
||||
* mutfağı) bilerek verilmez; amaç güven: veri gerçek, kurallar şeffaf.
|
||||
* Dilim şeridi canlıdır: kullanıcının kayıtlı tercih profilindeki sıralama
|
||||
* okunur, şerit o sıralamanın gerçek sayılarıyla güncellenir (bkz. demo.tsx);
|
||||
* profil yoksa örnek sıralama gösterilir. Sihirbaz hunisi ise statik
|
||||
* bir mermaid şemasıyla anlatılır (bkz. huni-semasi.tsx).
|
||||
* Diyagramlar ürünün risk renk dilini kullanır (yeşil/sarı/kırmızı) ve her
|
||||
* bölge metinle de etiketlenir — renk tek başına anlam taşımaz.
|
||||
*/
|
||||
|
||||
// Profili olmayan ziyaretçi bu örnek sıralamayı görür; SSR'da gerçek veriyle
|
||||
// render edilir ki profil olmadan da sayfa anlamlı olsun.
|
||||
const VARSAYILAN_SIRA = 100_000;
|
||||
|
||||
export default function MeraklisinaPage() {
|
||||
const dilimToplam = Object.fromEntries(
|
||||
(["hayal", "dengeli", "garanti"] as DilimKey[]).map((d) => [
|
||||
d,
|
||||
dilimAra(VARSAYILAN_SIRA, "say", d, { limit: 0 }).toplam,
|
||||
]),
|
||||
) as Record<DilimKey, number>;
|
||||
const baslangicVeri: DemoVeri = {
|
||||
facetler: rankWindowFacets(VARSAYILAN_SIRA, "say"),
|
||||
dilimToplam,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<main className="mx-auto w-full max-w-2xl px-4 py-16">
|
||||
@@ -64,117 +34,9 @@ export default function MeraklisinaPage() {
|
||||
|
||||
<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>
|
||||
</SiralamaDemo>
|
||||
<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 { eq } from "drizzle-orm";
|
||||
import { ArrowRight, CheckCircle2, XCircle } from "lucide-react";
|
||||
import { verifySession } from "@/lib/session";
|
||||
import { appDb, schema } from "@/lib/appdb";
|
||||
import { kullanicininRaporu } from "@/lib/rapor-kaydi";
|
||||
import { odemeyiSonuclandir } from "@/lib/odeme";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PagePixelDivider } from "@/components/pixel-decor";
|
||||
import { SiteFooter } from "@/components/site-footer";
|
||||
import { RaporYokCta } from "./rapor-yok-cta";
|
||||
import {
|
||||
OdemeSonucKarti,
|
||||
OdemeSonucKartiSkeleton,
|
||||
} from "@/features/odeme/components/odeme-sonuc-karti";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Ödeme Sonucu",
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
export default async function OdemeSonucPage({
|
||||
export default function OdemeSonucPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ siparis?: string }>;
|
||||
}) {
|
||||
const { siparis } = await searchParams;
|
||||
// Oturum düşmüşse geri dönüşte ?siparis= kaybolmasın (başarı/unblur deneyimi)
|
||||
const geriDonus = siparis
|
||||
? `/odeme/sonuc?siparis=${encodeURIComponent(siparis)}`
|
||||
: "/paket";
|
||||
const session = await verifySession(geriDonus);
|
||||
|
||||
const order = siparis
|
||||
? await appDb.query.orders.findFirst({
|
||||
where: eq(schema.orders.id, siparis),
|
||||
})
|
||||
: null;
|
||||
|
||||
// Yalnızca kendi siparişi görüntülenebilir
|
||||
const sahibiMi = order?.userId === session.user.id;
|
||||
|
||||
// Self-healing: callback kaybolduysa burada tekrar doğrula
|
||||
const durum =
|
||||
order && sahibiMi
|
||||
? order.status === "paid"
|
||||
? "paid"
|
||||
: await odemeyiSonuclandir(order.id)
|
||||
: "not_found";
|
||||
|
||||
const basarili = durum === "paid";
|
||||
|
||||
// Rapor zaten üretildiyse ödül bir tık uzakta: kullanıcıyı doğrudan
|
||||
// rapor sayfasına döndür ki blur çözülme animasyonuyla tam listeyi görsün.
|
||||
const rapor = basarili ? await kullanicininRaporu(session.user.id) : null;
|
||||
const listeHref = rapor ? "/listem?acildi=1" : null;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-slate-50 text-slate-900">
|
||||
<main className="mx-auto flex w-full max-w-md flex-1 flex-col justify-center px-4 py-16 text-center">
|
||||
{basarili ? (
|
||||
<div className="rounded-2xl border border-emerald-200 bg-white p-8 shadow-sm">
|
||||
<CheckCircle2
|
||||
className="mx-auto size-12 text-emerald-500"
|
||||
aria-hidden
|
||||
/>
|
||||
<h1 className="mt-4 font-heading text-2xl font-bold">
|
||||
Ödeme alındı 🎉
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-slate-600">
|
||||
<span className="font-semibold">
|
||||
{order!.credits} kredi
|
||||
</span>{" "}
|
||||
hesabına tanımlandı
|
||||
{order!.product === "paket"
|
||||
? " ve Tercih Dönemi Paketi'n aktif — kişisel listen, risk raporun ve PDF çıktın hazır olduğunda burada."
|
||||
: "."}
|
||||
</p>
|
||||
<PagePixelDivider seed={79} className="mx-auto mt-5" />
|
||||
<div className="mt-6 flex flex-col gap-2">
|
||||
{listeHref ? (
|
||||
<>
|
||||
<Button
|
||||
asChild
|
||||
className="h-11 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||
>
|
||||
<Link href={listeHref}>
|
||||
Listeni aç — kilidi kaldırdık
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Link>
|
||||
</Button>
|
||||
<p className="text-center text-xs text-slate-500">
|
||||
24 tercihin tamamı, gerekçeler, riskler ve PDF artık açık.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<RaporYokCta />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-red-200 bg-white p-8 shadow-sm">
|
||||
<XCircle className="mx-auto size-12 text-red-500" aria-hidden />
|
||||
<h1 className="mt-4 font-heading text-2xl font-bold">
|
||||
Ödeme tamamlanamadı
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-slate-600">
|
||||
{durum === "pending" ? (
|
||||
"Ödemen hâlâ işleniyor. Birkaç saniye sonra bu sayfayı yenile — kartından çekim yapıldıysa krediler mutlaka tanımlanır."
|
||||
) : (
|
||||
<>
|
||||
Çekim yapılmadı ya da işlem iptal edildi. Kartından para
|
||||
çekildiğini düşünüyorsan{" "}
|
||||
<Link href="/iletisim" className="underline">
|
||||
bizimle iletişime geç
|
||||
</Link>
|
||||
.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<PagePixelDivider seed={83} className="mx-auto mt-5" />
|
||||
<Button asChild variant="outline" className="mt-6 h-11 cursor-pointer">
|
||||
<Link href="/paket">Tekrar dene</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<Suspense fallback={<OdemeSonucKartiSkeleton />}>
|
||||
{searchParams.then(({ siparis }) => (
|
||||
<OdemeSonucKarti siparis={siparis} />
|
||||
))}
|
||||
</Suspense>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
ArrowRight,
|
||||
ArrowUpRight,
|
||||
BarChart3,
|
||||
CheckCircle2,
|
||||
Coins,
|
||||
Compass,
|
||||
FileText,
|
||||
@@ -12,18 +11,18 @@ import {
|
||||
ShieldCheck,
|
||||
TrendingUp,
|
||||
Wallet,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { Suspense } from "react";
|
||||
import { HeroBaslik } from "@/components/hero-baslik";
|
||||
import { KapanisCta } from "@/components/hero-focus-button";
|
||||
import { HeroForm } from "@/components/hero-form";
|
||||
import { ComparisonCell } from "@/features/pazarlama/components/landing-parcalari";
|
||||
import { HeroBaslik } from "@/features/pazarlama/components/hero-baslik";
|
||||
import { KapanisCta } from "@/features/pazarlama/components/hero-focus-button";
|
||||
import { HeroForm } from "@/features/sihirbaz/components/hero-form";
|
||||
import { Parallax } from "@/components/parallax";
|
||||
import { Reveal } from "@/components/landing/reveal";
|
||||
import { SosyalKanitBandi } from "@/components/landing/sosyal-kanit";
|
||||
import { TurkeyPixelMap } from "@/components/turkey-pixel-map";
|
||||
import { Reveal } from "@/features/pazarlama/components/reveal";
|
||||
import { SosyalKanitBandi } from "@/features/pazarlama/components/sosyal-kanit";
|
||||
import { TurkeyPixelMap } from "@/features/pazarlama/components/turkey-pixel-map";
|
||||
import {
|
||||
PixelCorner,
|
||||
PixelDivider,
|
||||
@@ -188,17 +187,6 @@ const paketOzellikleri = [
|
||||
},
|
||||
] as const;
|
||||
|
||||
function ComparisonCell({ value }: { value: boolean | string }) {
|
||||
if (typeof value === "string") {
|
||||
return <span className="text-sm font-medium">{value}</span>;
|
||||
}
|
||||
return value ? (
|
||||
<CheckCircle2 className="mx-auto size-5 text-primary" aria-label="Var" />
|
||||
) : (
|
||||
<X className="mx-auto size-5 text-muted-foreground/50" aria-label="Yok" />
|
||||
);
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -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 type { Metadata } from "next";
|
||||
import {
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
Coins,
|
||||
FileText,
|
||||
ListChecks,
|
||||
MessageCircleQuestion,
|
||||
RefreshCcw,
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
import { URUNLER, DENEME_KREDISI } from "@/lib/credits";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SectionEyebrow } from "@/components/pixel-decor";
|
||||
import { Parallax } from "@/components/parallax";
|
||||
import { SatinAlForm } from "./satin-al-form";
|
||||
import { SiteFooter } from "@/components/site-footer";
|
||||
import {
|
||||
PaketSatinAl,
|
||||
PaketSatinAlSkeleton,
|
||||
} from "@/features/odeme/components/paket-satinal";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Tercih Dönemi Paketi",
|
||||
@@ -26,49 +16,31 @@ export const metadata: Metadata = {
|
||||
alternates: { canonical: "/paket" },
|
||||
};
|
||||
|
||||
function tl(kurus: number) {
|
||||
return (kurus / 100).toLocaleString("tr-TR", { maximumFractionDigits: 0 });
|
||||
}
|
||||
|
||||
const OZELLIKLER = [
|
||||
{
|
||||
icon: ListChecks,
|
||||
text: "Sıralamana özel, gerekçeli 24 tercihlik dengeli liste",
|
||||
},
|
||||
{
|
||||
icon: ShieldCheck,
|
||||
text: "Her tercih için yerleşme olasılığı ve risk analizi (son 4 yıl trend)",
|
||||
},
|
||||
{ icon: FileText, text: "Veliyle paylaşılabilir PDF rapor" },
|
||||
{ icon: RefreshCcw, text: "2 liste revizyon hakkı" },
|
||||
{
|
||||
icon: MessageCircleQuestion,
|
||||
text: `${URUNLER.paket.credits} Yapay Zeka danışman sorusu (her mesaj 1 kredi)`,
|
||||
},
|
||||
] as const;
|
||||
|
||||
export default async function PaketPage({
|
||||
export default function PaketPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ hata?: string }>;
|
||||
}) {
|
||||
const [u, { hata }] = await Promise.all([getCurrentUser(), searchParams]);
|
||||
// Ödeme callback'i doğrulama hatasında buraya ?hata=token|siparis ile döner
|
||||
const odemeHatasi = hata === "token" || hata === "siparis";
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-slate-50 text-slate-900">
|
||||
<main className="mx-auto w-full max-w-3xl flex-1 px-4 py-16">
|
||||
{odemeHatasi ? (
|
||||
{/* Ödeme callback'i doğrulama hatasında buraya ?hata=token|siparis ile
|
||||
döner; hata yokken hiçbir şey çizilmez — fallback bilerek boş. */}
|
||||
<Suspense fallback={null}>
|
||||
{searchParams.then(({ hata }) =>
|
||||
hata === "token" || hata === "siparis" ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="mb-8 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800"
|
||||
>
|
||||
Ödemen doğrulanamadı. Kartından çekim yapıldıysa kredilerin birkaç
|
||||
dakika içinde otomatik tanımlanır; tanımlanmazsa aşağıdan tekrar
|
||||
deneyebilirsin — çekim yapılmadıysa hiçbir ücret ödemedin.
|
||||
Ödemen doğrulanamadı. Kartından çekim yapıldıysa kredilerin
|
||||
birkaç dakika içinde otomatik tanımlanır; tanımlanmazsa
|
||||
aşağıdan tekrar deneyebilirsin — çekim yapılmadıysa hiçbir
|
||||
ücret ödemedin.
|
||||
</div>
|
||||
) : null}
|
||||
) : null,
|
||||
)}
|
||||
</Suspense>
|
||||
<Parallax fromStart strength={12} className="text-center">
|
||||
<SectionEyebrow>Tek seferlik ödeme · Abonelik yok</SectionEyebrow>
|
||||
<h1 className="mt-4 font-heading text-3xl font-bold sm:text-4xl">
|
||||
@@ -81,127 +53,9 @@ export default async function PaketPage({
|
||||
</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>
|
||||
<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>
|
||||
<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
|
||||
|
||||
@@ -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 Image from "next/image";
|
||||
import { redirect } from "next/navigation";
|
||||
import { verifySession, getCurrentUser } from "@/lib/session";
|
||||
import { kullanicininRaporu } from "@/lib/rapor-kaydi";
|
||||
import { PUAN_TURLERI } from "@/lib/db";
|
||||
import type { RaporSonuc } from "@/lib/ai/rapor";
|
||||
import type { RaporParams } from "@/lib/rapor-havuzu";
|
||||
import { YazdirButonu } from "./yazdir-butonu";
|
||||
import {
|
||||
RaporYazdir,
|
||||
RaporYazdirSkeleton,
|
||||
} from "@/features/rapor/components/rapor-yazdir";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Tercih Raporu (Yazdır)",
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
const DILIM_LABEL: Record<string, string> = {
|
||||
hayal: "Hayal (Riskli)",
|
||||
dengeli: "Dengeli (Az riskli)",
|
||||
garanti: "Garanti (Güvenli)",
|
||||
};
|
||||
|
||||
export default async function YazdirPage() {
|
||||
const session = await verifySession("/rapor/yazdir");
|
||||
// Kullanıcı satırı ile rapor sorgusu bağımsız — paralel çöz
|
||||
const [user, satir] = await Promise.all([
|
||||
getCurrentUser(),
|
||||
kullanicininRaporu(session.user.id),
|
||||
]);
|
||||
if (!user) redirect("/giris");
|
||||
if (!user.hasPaket) redirect("/paket");
|
||||
if (!satir?.result) redirect("/sonuc");
|
||||
|
||||
const rapor = satir.result as RaporSonuc;
|
||||
const params = satir.params as RaporParams;
|
||||
const tarih = new Date(satir.updatedAt).toLocaleDateString("tr-TR", {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
export default function YazdirPage() {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl bg-white p-8 text-slate-900 print:p-[10mm]">
|
||||
<YazdirButonu />
|
||||
|
||||
<header className="border-b-2 border-slate-900 pb-4">
|
||||
<h1 className="font-heading text-2xl font-bold">
|
||||
{rapor.tercihler.length} Tercihlik Kişisel Liste ve Risk Raporu
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-slate-600">
|
||||
Başarı sıralaması: {params.sira.toLocaleString("tr-TR")} · Puan
|
||||
türü: {PUAN_TURLERI[params.tur]}
|
||||
{params.il ? ` · İl: ${params.il}` : ""} · {tarih}
|
||||
</p>
|
||||
{rapor.kapsam?.uyarlandi ? (
|
||||
<p className="mt-1 text-sm text-slate-600">
|
||||
Not: Tercihlerine göre {rapor.kapsam.toplam} uygun program bulundu;
|
||||
liste {rapor.kapsam.dagilim.hayal} hayal ·{" "}
|
||||
{rapor.kapsam.dagilim.dengeli} dengeli ·{" "}
|
||||
{rapor.kapsam.dagilim.garanti} garanti olarak kuruldu.
|
||||
</p>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
<section className="mt-5">
|
||||
<h2 className="font-heading text-base font-bold">
|
||||
Genel değerlendirme
|
||||
</h2>
|
||||
<p className="mt-1 whitespace-pre-line text-sm leading-relaxed">
|
||||
{rapor.genelDegerlendirme}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<table className="mt-5 w-full border-collapse text-xs">
|
||||
<thead>
|
||||
<tr className="border-b-2 border-slate-900 text-left">
|
||||
<th className="py-2 pr-2">#</th>
|
||||
<th className="py-2 pr-2">Program / Üniversite</th>
|
||||
<th className="py-2 pr-2">Dilim</th>
|
||||
<th className="py-2 pr-2">Gerekçe</th>
|
||||
<th className="py-2 pr-2">Risk notu</th>
|
||||
<th className="py-2">Trend</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rapor.tercihler.map((t) => {
|
||||
const p = rapor.programlar[t.programId];
|
||||
return (
|
||||
<tr
|
||||
key={t.sira}
|
||||
className="break-inside-avoid border-b border-slate-200 align-top"
|
||||
>
|
||||
<td className="py-2 pr-2 font-bold">{t.sira}</td>
|
||||
<td className="py-2 pr-2">
|
||||
<span className="font-semibold">{p?.isim}</span>
|
||||
<br />
|
||||
<span className="text-slate-600">
|
||||
{p?.universite} · {p?.il ?? "—"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 pr-2">{DILIM_LABEL[t.dilim]}</td>
|
||||
<td className="py-2 pr-2">{t.gerekce}</td>
|
||||
<td className="py-2 pr-2">{t.riskNotu}</td>
|
||||
<td className="py-2">{t.trendOzeti}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<footer className="mt-6 border-t border-slate-300 pt-3 text-[10px] leading-relaxed text-slate-500">
|
||||
<p>
|
||||
Bu rapor KolayTercih tarafından resmî YÖK Atlas verisi (2021–2025
|
||||
taban sıralamaları, kontenjan ve yerleşme istatistikleri) kullanılarak
|
||||
üretilmiştir. Taban sıralamaları her yıl değişir; bu rapor yerleşme
|
||||
garantisi vermez. Tercih listesinin ÖSYM sistemine girilmesi ve nihai
|
||||
sorumluluğu adaya aittir.
|
||||
</p>
|
||||
<div className="mt-4 flex break-inside-avoid items-center justify-between">
|
||||
<div className="flex items-center font-bricolage text-slate-900">
|
||||
<Image
|
||||
src="/kolay-tercih-mark.svg"
|
||||
alt=""
|
||||
width={44}
|
||||
height={44}
|
||||
aria-hidden="true"
|
||||
className="size-11 shrink-0"
|
||||
/>
|
||||
<span>
|
||||
<span className="font-light text-3xl tracking-tighter">Kolay</span>
|
||||
<span className="font-semibold text-3xl tracking-tighter">
|
||||
Tercih
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<span className="font-bricolage text-sm font-medium text-slate-900">
|
||||
kolaytercih.com
|
||||
</span>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
<Suspense fallback={<RaporYazdirSkeleton />}>
|
||||
<RaporYazdir />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { loadRehberOgFonts } from "@/lib/og-fonts";
|
||||
import {
|
||||
rehberBasliginiBol,
|
||||
rehberMetniniParcala,
|
||||
} from "@/components/rehber-kapak";
|
||||
} from "@/features/rehber/components/rehber-kapak";
|
||||
|
||||
export const alt = "KolayTercih Tercih Rehberi yazı kapağı";
|
||||
export const size = { width: 1200, height: 630 };
|
||||
|
||||
@@ -1,75 +1,32 @@
|
||||
import { Suspense } from "react";
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { getAllRehberSlugs, getAllRehberler, getRehber } from "@/lib/rehber";
|
||||
import { JsonLd, articleJsonLd, breadcrumbJsonLd } from "@/lib/seo";
|
||||
import { CtaSiraForm } from "@/components/cta-sira-form";
|
||||
import { ElCizimiSema } from "@/components/el-cizimi-sema";
|
||||
import { RehberKapak } from "@/components/rehber-kapak";
|
||||
import { SiteFooter } from "@/components/site-footer";
|
||||
import {
|
||||
RehberMakale,
|
||||
RehberMakaleSkeleton,
|
||||
rehberStaticSlugs,
|
||||
rehberYaziMetadata,
|
||||
} from "@/features/rehber/components/rehber-makale";
|
||||
|
||||
export function generateStaticParams() {
|
||||
const slugs = getAllRehberSlugs();
|
||||
if (slugs.length === 0) {
|
||||
throw new Error("rehber: content/rehber altında hiç makale yok");
|
||||
}
|
||||
return slugs.map((slug) => ({ slug }));
|
||||
return rehberStaticSlugs().map((slug) => ({ slug }));
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>;
|
||||
}): Promise<Metadata> {
|
||||
}: PageProps<"/rehber/[slug]">): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const yazi = getRehber(slug);
|
||||
if (!yazi) return {};
|
||||
return {
|
||||
title: yazi.baslik,
|
||||
description: yazi.aciklama,
|
||||
alternates: { canonical: `/rehber/${slug}` },
|
||||
openGraph: {
|
||||
type: "article",
|
||||
title: yazi.baslik,
|
||||
description: yazi.aciklama,
|
||||
publishedTime: yazi.tarih,
|
||||
},
|
||||
};
|
||||
return rehberYaziMetadata(slug);
|
||||
}
|
||||
|
||||
export default async function RehberYaziPage({
|
||||
export default function RehberYaziPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>;
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
const yazi = getRehber(slug);
|
||||
if (!yazi) notFound();
|
||||
|
||||
const digerYazilar = getAllRehberler()
|
||||
.filter((y) => y.slug !== slug)
|
||||
.slice(0, 4);
|
||||
|
||||
}: PageProps<"/rehber/[slug]">) {
|
||||
return (
|
||||
<>
|
||||
<main className="mx-auto w-full max-w-3xl px-4 py-12 sm:py-16">
|
||||
<JsonLd
|
||||
data={articleJsonLd({
|
||||
title: yazi.baslik,
|
||||
description: yazi.aciklama,
|
||||
path: `/rehber/${slug}`,
|
||||
datePublished: yazi.tarih,
|
||||
})}
|
||||
/>
|
||||
<JsonLd
|
||||
data={breadcrumbJsonLd([
|
||||
{ name: "Ana Sayfa", path: "/" },
|
||||
{ name: "Rehber", path: "/rehber" },
|
||||
{ name: yazi.baslik, path: `/rehber/${slug}` },
|
||||
])}
|
||||
/>
|
||||
|
||||
<nav
|
||||
aria-label="breadcrumb"
|
||||
className="flex items-center gap-1 text-xs text-slate-500"
|
||||
@@ -83,69 +40,11 @@ export default async function RehberYaziPage({
|
||||
</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) =>
|
||||
parca.tip === "html" ? (
|
||||
<div
|
||||
key={i}
|
||||
dangerouslySetInnerHTML={{ __html: parca.html }}
|
||||
/>
|
||||
) : (
|
||||
<ElCizimiSema
|
||||
key={i}
|
||||
tanim={parca.sema.tanim}
|
||||
ariaLabel={parca.sema.ariaLabel}
|
||||
figcaption={parca.sema.altyazi}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div className="mt-12">
|
||||
<CtaSiraForm />
|
||||
</div>
|
||||
|
||||
{digerYazilar.length > 0 ? (
|
||||
<section className="mt-12">
|
||||
<h2 className="font-heading text-xl font-bold text-slate-900">
|
||||
Diğer rehber yazıları
|
||||
</h2>
|
||||
<ul className="mt-3 space-y-2">
|
||||
{digerYazilar.map((y) => (
|
||||
<li key={y.slug}>
|
||||
<Link
|
||||
href={`/rehber/${y.slug}`}
|
||||
className="text-sm font-medium text-primary hover:underline"
|
||||
>
|
||||
{y.baslik}
|
||||
</Link>
|
||||
</li>
|
||||
<Suspense fallback={<RehberMakaleSkeleton />}>
|
||||
{params.then(({ slug }) => (
|
||||
<RehberMakale slug={slug} />
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
) : null}
|
||||
</Suspense>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { Suspense } from "react";
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { getAllRehberler } from "@/lib/rehber";
|
||||
import { JsonLd, breadcrumbJsonLd } from "@/lib/seo";
|
||||
import { CtaSiraForm } from "@/components/cta-sira-form";
|
||||
import { RehberKapak } from "@/components/rehber-kapak";
|
||||
import { CtaSiraForm } from "@/features/sihirbaz/components/cta-sira-form";
|
||||
import { SiteFooter } from "@/components/site-footer";
|
||||
import { PagePixelDivider } from "@/components/pixel-decor";
|
||||
import {
|
||||
RehberListesi,
|
||||
RehberListesiSkeleton,
|
||||
} from "@/features/rehber/components/rehber-listesi";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Tercih Rehberi 2026 — YKS Tercih Dönemi Yazıları",
|
||||
@@ -16,8 +17,6 @@ export const metadata: Metadata = {
|
||||
};
|
||||
|
||||
export default function RehberIndexPage() {
|
||||
const yazilar = getAllRehberler();
|
||||
|
||||
return (
|
||||
<>
|
||||
<main className="mx-auto w-full max-w-6xl px-4 py-12 sm:py-16">
|
||||
@@ -38,34 +37,9 @@ export default function RehberIndexPage() {
|
||||
|
||||
<PagePixelDivider seed={11} className="mt-8" />
|
||||
|
||||
<ul className="mt-8 columns-1 gap-5 sm:columns-2 lg:columns-3">
|
||||
{yazilar.map((y) => (
|
||||
<li key={y.slug} className="mb-5 break-inside-avoid">
|
||||
<Link
|
||||
href={`/rehber/${y.slug}`}
|
||||
className="block overflow-hidden rounded-3xl border border-slate-200 bg-white p-2 shadow-sm focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-primary"
|
||||
>
|
||||
<RehberKapak
|
||||
baslik={y.baslik}
|
||||
compact
|
||||
className="rounded-[1.15rem]"
|
||||
/>
|
||||
<div className="px-3 pb-3 pt-4">
|
||||
<p className="text-sm leading-6 text-slate-600">
|
||||
{y.aciklama}
|
||||
</p>
|
||||
<span className="mt-4 inline-flex items-center gap-1.5 font-bricolage text-sm font-semibold text-primary">
|
||||
Yazıyı oku
|
||||
<ArrowRight
|
||||
className="size-4"
|
||||
aria-hidden
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Suspense fallback={<RehberListesiSkeleton />}>
|
||||
<RehberListesi />
|
||||
</Suspense>
|
||||
|
||||
<div className="mx-auto mt-16 max-w-3xl">
|
||||
<CtaSiraForm />
|
||||
|
||||
@@ -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 { PUAN_TURLERI, type PuanTuruKey } from "@/types/yokatlas";
|
||||
import {
|
||||
PUAN_TURLERI,
|
||||
rankWindowFacets,
|
||||
searchByRank,
|
||||
type PuanTuruKey,
|
||||
} from "@/lib/db";
|
||||
import { getSession } from "@/lib/session";
|
||||
import { kullanicininRaporu } from "@/lib/rapor-kaydi";
|
||||
import { tadimlikSec } from "@/lib/tadimlik-havuzu";
|
||||
import { SihirbazBolumu } from "./sihirbaz-cagri-karti";
|
||||
import { TadimlikSatiri } from "./tadimlik-satiri";
|
||||
import { ProgramTablosu } from "@/components/program-tablosu";
|
||||
import { ManuelHarita } from "@/components/manuel-liste/manuel-harita";
|
||||
SonucProgramTablosu,
|
||||
SonucProgramTablosuSkeleton,
|
||||
} from "@/features/liste/components/sonuc-program-tablosu";
|
||||
import { ManuelHarita } from "@/features/liste/components/manuel-harita";
|
||||
import { TadimlikBolumu } from "@/features/rapor/components/tadimlik-bolumu";
|
||||
import { SonucSihirbazi } from "@/features/sihirbaz/components/sihirbaz-bolumu";
|
||||
import { SiraGerekli } from "@/features/sihirbaz/components/sira-gerekli";
|
||||
import { SiteFooter } from "@/components/site-footer";
|
||||
import { SiraGerekli } from "./sira-gerekli";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Tercih Listeni Kur",
|
||||
@@ -23,7 +20,7 @@ export const metadata: Metadata = {
|
||||
robots: { index: false, follow: true },
|
||||
};
|
||||
|
||||
export default async function SonucPage({
|
||||
export default function SonucPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{
|
||||
@@ -33,7 +30,22 @@ export default async function SonucPage({
|
||||
hazir?: string;
|
||||
}>;
|
||||
}) {
|
||||
const params = await searchParams;
|
||||
return (
|
||||
// Tablo değişken boylu olduğundan altındaki tadımlık, sihirbaz, dipnot ve
|
||||
// footer aynı sınırda gruplu — bugünkü tek await'li akışla birebir aynı
|
||||
// anda beliriyorlar, ara durumda zıplama yok.
|
||||
<Suspense
|
||||
fallback={
|
||||
<main className="mx-auto w-full max-w-6xl px-4 py-10">
|
||||
<Skeleton className="h-6 w-40 rounded-full" />
|
||||
<Skeleton className="mt-4 h-9 w-3/4 max-w-xl" />
|
||||
<Skeleton className="mt-3 h-5 w-2/3 max-w-lg" />
|
||||
<Skeleton className="mt-8 h-36 w-full rounded-2xl" />
|
||||
<SonucProgramTablosuSkeleton />
|
||||
</main>
|
||||
}
|
||||
>
|
||||
{searchParams.then((params) => {
|
||||
const sira = Number.parseInt(params.sira ?? "", 10);
|
||||
const turKey: PuanTuruKey =
|
||||
params.tur && params.tur in PUAN_TURLERI
|
||||
@@ -49,23 +61,6 @@ export default async function SonucPage({
|
||||
);
|
||||
}
|
||||
|
||||
// Sihirbaz için: kullanıcının kayıtlı raporu var mı? Listenin kendisi
|
||||
// /listem'de yaşar; burada yalnızca CTA'nın hangi hâlde render edileceği
|
||||
// için varlık bilgisi gerekir.
|
||||
const session = await getSession();
|
||||
const raporSatiri = session
|
||||
? await kullanicininRaporu(session.user.id)
|
||||
: null;
|
||||
|
||||
const facetler = rankWindowFacets(sira, turKey);
|
||||
// Yapay Zeka'sız ham liste: yalnızca sıralamayla ulaşılabilen programlar (dilimli)
|
||||
const hamSonuclar = searchByRank(sira, turKey, { limitPerBucket: 20 });
|
||||
|
||||
// Girişsiz tadımlık (İP-B): SSR'da kovaya uyan "genel" satır seçilir;
|
||||
// sihirbaz kategorisi client'ta okunup /api/tadimlik ile kişiselleştirilir.
|
||||
// Girişli kullanıcı gerçek ürünü zaten görebildiği için tadımlık almaz.
|
||||
const tadimlik = session ? null : await tadimlikSec(sira, turKey);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 text-slate-900">
|
||||
<main className="mx-auto max-w-4xl px-4 py-12">
|
||||
@@ -74,35 +69,34 @@ export default async function SonucPage({
|
||||
buraya canlı yansır. */}
|
||||
<ManuelHarita />
|
||||
|
||||
{/* Yapay Zeka'dan bağımsız ham tablo — haritanın hemen altında, sayfanın ana
|
||||
gövdesi; puan türü navbar'daki sıralama rozetinden değiştirilir */}
|
||||
<ProgramTablosu sonuclar={hamSonuclar} adaySira={sira} tur={turKey} />
|
||||
{/* Yapay Zeka'dan bağımsız ham tablo — haritanın hemen altında,
|
||||
sayfanın ana gövdesi; puan türü navbar'daki sıralama
|
||||
rozetinden değiştirilir */}
|
||||
<SonucProgramTablosu sira={sira} tur={turKey} />
|
||||
|
||||
{/* Girişsiz tadımlık: gerçek rapor satırı formatında tek örnek.
|
||||
Havuzda içerik yoksa bileşen hiç render edilmez. */}
|
||||
{!session ? (
|
||||
<TadimlikSatiri sira={sira} tur={turKey} ilk={tadimlik} />
|
||||
) : null}
|
||||
<TadimlikBolumu sira={sira} tur={turKey} />
|
||||
|
||||
{/* Sihirbaz akışı (CTA + modal) — üretim ve liste /listem'de yaşar,
|
||||
burada yalnızca funnel kartları render edilir */}
|
||||
<SihirbazBolumu
|
||||
{/* Sihirbaz akışı (CTA + modal) — üretim ve liste /listem'de
|
||||
yaşar, burada yalnızca funnel kartları render edilir */}
|
||||
<SonucSihirbazi
|
||||
sira={sira}
|
||||
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">
|
||||
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.
|
||||
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 { getAllUniversiteSlugs } from "@/lib/katalog";
|
||||
import { UniversiteIcerik, uniSayfaMetadata } from "./universite-icerik";
|
||||
import {
|
||||
UniversiteIcerik,
|
||||
UniversiteIcerikSkeleton,
|
||||
uniSayfaMetadata,
|
||||
uniStaticSlugs,
|
||||
} from "@/features/katalog/components/universite-icerik";
|
||||
|
||||
export function generateStaticParams() {
|
||||
return getAllUniversiteSlugs().map((slug) => ({ slug }));
|
||||
return uniStaticSlugs().map((slug) => ({ slug }));
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>;
|
||||
}): Promise<Metadata> {
|
||||
}: PageProps<"/universite/[slug]">): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
return uniSayfaMetadata(slug, 1);
|
||||
}
|
||||
|
||||
export default async function UniversitePage({
|
||||
export default function UniversitePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>;
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
return <UniversiteIcerik slug={slug} sayfa={1} />;
|
||||
}: PageProps<"/universite/[slug]">) {
|
||||
return (
|
||||
<Suspense fallback={<UniversiteIcerikSkeleton />}>
|
||||
{params.then(({ slug }) => (
|
||||
<UniversiteIcerik slug={slug} sayfa={1} />
|
||||
))}
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,22 +1,17 @@
|
||||
import { Suspense } from "react";
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getAllUniversiteler } from "@/lib/katalog";
|
||||
import {
|
||||
UniversiteIcerik,
|
||||
UniversiteIcerikSkeleton,
|
||||
uniSayfaMetadata,
|
||||
uniToplamSayfa,
|
||||
} from "../../universite-icerik";
|
||||
uniSayfaStaticParams,
|
||||
} from "@/features/katalog/components/universite-icerik";
|
||||
|
||||
// Yalnızca 1'den fazla sayfası olan üniversiteler için 2..N üretilir;
|
||||
// 1. sayfa taban rota (/universite/[slug]) olduğundan burada üretilmez.
|
||||
export function generateStaticParams() {
|
||||
return getAllUniversiteler().flatMap((u) => {
|
||||
const toplam = uniToplamSayfa(u.programSayisi);
|
||||
return Array.from({ length: Math.max(0, toplam - 1) }, (_, i) => ({
|
||||
slug: u.slug,
|
||||
no: String(i + 2),
|
||||
}));
|
||||
});
|
||||
return uniSayfaStaticParams();
|
||||
}
|
||||
|
||||
function sayfaNo(no: string): number | null {
|
||||
@@ -26,22 +21,23 @@ function sayfaNo(no: string): number | null {
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string; no: string }>;
|
||||
}): Promise<Metadata> {
|
||||
}: PageProps<"/universite/[slug]/sayfa/[no]">): Promise<Metadata> {
|
||||
const { slug, no } = await params;
|
||||
const n = sayfaNo(no);
|
||||
if (n == null) return {};
|
||||
return uniSayfaMetadata(slug, n);
|
||||
}
|
||||
|
||||
export default async function UniversiteSayfaPage({
|
||||
export default function UniversiteSayfaPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string; no: string }>;
|
||||
}) {
|
||||
const { slug, no } = await params;
|
||||
}: PageProps<"/universite/[slug]/sayfa/[no]">) {
|
||||
return (
|
||||
<Suspense fallback={<UniversiteIcerikSkeleton />}>
|
||||
{params.then(({ slug, no }) => {
|
||||
const n = sayfaNo(no);
|
||||
if (n == null) notFound();
|
||||
return <UniversiteIcerik slug={slug} sayfa={n} />;
|
||||
})}
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { Suspense } from "react";
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { getAllUniversiteler, type UniOzet } from "@/lib/katalog";
|
||||
import { trBaslikDuzeni } from "@/lib/slug";
|
||||
import { JsonLd, breadcrumbJsonLd } from "@/lib/seo";
|
||||
import { CtaSiraForm } from "@/components/cta-sira-form";
|
||||
import { SiteFooter } from "@/components/site-footer";
|
||||
import { PagePixelDivider } from "@/components/pixel-decor";
|
||||
import { UniLogo } from "@/components/uni-logo";
|
||||
import {
|
||||
UniversiteKatalogu,
|
||||
UniversiteKataloguSkeleton,
|
||||
} from "@/features/katalog/components/universite-listesi";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Üniversitelerin Taban Puanları ve Sıralamaları 2026",
|
||||
@@ -15,44 +14,7 @@ export const metadata: Metadata = {
|
||||
alternates: { canonical: "/universiteler" },
|
||||
};
|
||||
|
||||
const sayi = (n: number) => n.toLocaleString("tr-TR");
|
||||
|
||||
function UniLinkleri({ uniler }: { uniler: UniOzet[] }) {
|
||||
return (
|
||||
<ul className="mt-4 grid gap-x-6 gap-y-2 sm:grid-cols-2">
|
||||
{uniler.map((u) => (
|
||||
<li key={u.slug}>
|
||||
<Link
|
||||
href={`/universite/${u.slug}`}
|
||||
className="group flex items-center gap-3 text-sm text-slate-700 hover:text-primary"
|
||||
>
|
||||
<UniLogo slug={u.slug} ad={u.ad} boy="sm" />
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate group-hover:underline">
|
||||
{u.ad}
|
||||
</span>
|
||||
<span className="block text-xs text-slate-400">
|
||||
{u.il ? `${trBaslikDuzeni(u.il)} · ` : ""}
|
||||
{sayi(u.programSayisi)} program
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export default function UniversitelerPage() {
|
||||
const hepsi = getAllUniversiteler();
|
||||
const devlet = hepsi.filter((u) => u.unitur === "DEVLET");
|
||||
const vakif = hepsi.filter(
|
||||
(u) => u.unitur != null && ["VAKIF", "VAKIF MYO"].includes(u.unitur),
|
||||
);
|
||||
const digerUniler = hepsi.filter(
|
||||
(u) => !devlet.includes(u) && !vakif.includes(u),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<main className="mx-auto w-full max-w-5xl px-4 py-12 sm:py-16">
|
||||
@@ -66,51 +28,10 @@ export default function UniversitelerPage() {
|
||||
<h1 className="font-heading text-3xl font-bold text-slate-900 sm:text-4xl">
|
||||
Üniversitelerin Taban Puanları ve Sıralamaları 2026
|
||||
</h1>
|
||||
<p className="mt-4 max-w-3xl text-sm leading-7 text-slate-600">
|
||||
{sayi(hepsi.length)} üniversitenin 2025 YKS yerleştirme verileri.
|
||||
Üniversiteye tıklayınca tüm programlarının taban puanlarını, başarı
|
||||
sıralamalarını ve kontenjanlarını fakülte fakülte görürsün. Veriler
|
||||
YÖK Atlas 2025 yerleştirme sonuçlarına dayanır; 2026 tercihleri için
|
||||
rehber niteliğindedir.
|
||||
</p>
|
||||
|
||||
<PagePixelDivider seed={63} className="mt-8" />
|
||||
|
||||
<section className="mt-8">
|
||||
<h2 className="font-heading text-2xl font-bold text-slate-900">
|
||||
Devlet Üniversiteleri
|
||||
<span className="ml-2 text-sm font-normal text-slate-400">
|
||||
{sayi(devlet.length)}
|
||||
</span>
|
||||
</h2>
|
||||
<UniLinkleri uniler={devlet} />
|
||||
</section>
|
||||
|
||||
<section className="mt-10">
|
||||
<h2 className="font-heading text-2xl font-bold text-slate-900">
|
||||
Vakıf Üniversiteleri
|
||||
<span className="ml-2 text-sm font-normal text-slate-400">
|
||||
{sayi(vakif.length)}
|
||||
</span>
|
||||
</h2>
|
||||
<UniLinkleri uniler={vakif} />
|
||||
</section>
|
||||
|
||||
{digerUniler.length > 0 ? (
|
||||
<section className="mt-10">
|
||||
<h2 className="font-heading text-2xl font-bold text-slate-900">
|
||||
KKTC ve Yurt Dışı
|
||||
<span className="ml-2 text-sm font-normal text-slate-400">
|
||||
{sayi(digerUniler.length)}
|
||||
</span>
|
||||
</h2>
|
||||
<UniLinkleri uniler={digerUniler} />
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<div className="mt-12">
|
||||
<CtaSiraForm />
|
||||
</div>
|
||||
<Suspense fallback={<UniversiteKataloguSkeleton />}>
|
||||
<UniversiteKatalogu />
|
||||
</Suspense>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</>
|
||||
|
||||
@@ -10,11 +10,11 @@ import {
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { TypingAnimation } from "@/components/ui/typing-animation";
|
||||
import { SIHIRBAZ_AC_EVENT, sonucHref } from "@/lib/sihirbaz";
|
||||
import { SIHIRBAZ_AC_EVENT, sonucHref } from "@/features/sihirbaz/sihirbaz-sabitler";
|
||||
import {
|
||||
profilToplamaAc,
|
||||
useTercihProfili,
|
||||
} from "@/components/manuel-liste/profil-kapisi-store";
|
||||
} from "@/features/liste/hooks/use-tercih-profili";
|
||||
|
||||
const FUNNEL_METINLERI = [
|
||||
"Sıralamana en uygun listeyi birlikte kuralım.",
|
||||
|
||||
@@ -12,7 +12,7 @@ import { useRouter } from "next/navigation";
|
||||
import { Wrench, X } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { tercihProfiliSil } from "@/lib/sihirbaz";
|
||||
import { tercihProfiliSil } from "@/features/sihirbaz/sihirbaz-profil";
|
||||
import {
|
||||
devAnindaGiris,
|
||||
devAyarla,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { TrendingDown, TrendingUp } from "lucide-react";
|
||||
import type { Program } from "@/lib/db";
|
||||
import type { Program } from "@/types/yokatlas";
|
||||
|
||||
/** Taban sıra sütunlarının yapısal alt kümesi: hem DB programı hem de
|
||||
* rapordaki kaydedilmiş sıra geçmişi (bkz. raporSiraSerisi) bu tipe uyar. */
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Suspense } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { KatalogArama } from "@/components/katalog-arama";
|
||||
import { UserNav, UserNavFallback } from "@/components/user-nav";
|
||||
import { KatalogArama } from "@/features/katalog/components/katalog-arama";
|
||||
import { UserNav, UserNavFallback } from "@/features/kullanici/components/user-nav";
|
||||
import { getAllRehberler } from "@/lib/rehber";
|
||||
|
||||
export function SiteHeader() {
|
||||
|
||||
@@ -2,11 +2,17 @@ import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { getBolumBySlug, uniSlugFromAd } from "@/lib/katalog";
|
||||
import {
|
||||
getAllBolumler,
|
||||
getAllBolumSlugs,
|
||||
getBolumBySlug,
|
||||
uniSlugFromAd,
|
||||
} from "../katalog-queries";
|
||||
import { JsonLd, breadcrumbJsonLd } from "@/lib/seo";
|
||||
import { SAYFA_BOYU } from "@/components/sayfalama";
|
||||
import { BolumProgramListesi } from "@/components/bolum-program-listesi";
|
||||
import { SAYFA_BOYU } from "./sayfalama";
|
||||
import { BolumProgramListesi } from "./bolum-program-listesi";
|
||||
import { SiteFooter } from "@/components/site-footer";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
const sayi = (n: number) => n.toLocaleString("tr-TR");
|
||||
|
||||
@@ -14,6 +20,22 @@ export function bolumToplamSayfa(programSayisi: number): number {
|
||||
return Math.max(1, Math.ceil(programSayisi / SAYFA_BOYU));
|
||||
}
|
||||
|
||||
// Sayfaların generateStaticParams'ı için — sayfalar *-queries import etmez.
|
||||
export function bolumStaticSlugs(): string[] {
|
||||
return getAllBolumSlugs();
|
||||
}
|
||||
|
||||
// 2..N sayfaları; 1. sayfa taban rota (/bolum/[slug]) olduğundan üretilmez.
|
||||
export function bolumSayfaStaticParams(): { slug: string; no: string }[] {
|
||||
return getAllBolumler().flatMap((b) => {
|
||||
const toplam = bolumToplamSayfa(b.programSayisi);
|
||||
return Array.from({ length: Math.max(0, toplam - 1) }, (_, i) => ({
|
||||
slug: b.slug,
|
||||
no: String(i + 2),
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
export function bolumSayfaMetadata(slug: string, sayfa: number): Metadata {
|
||||
const bolum = getBolumBySlug(slug);
|
||||
if (!bolum) return {};
|
||||
@@ -123,3 +145,21 @@ export function BolumIcerik({ slug, sayfa }: { slug: string; sayfa: number }) {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function BolumIcerikSkeleton() {
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-5xl px-4 py-10 sm:py-14">
|
||||
<Skeleton className="h-5 w-56" />
|
||||
<div className="border-b pb-7 sm:pb-9">
|
||||
<Skeleton className="mt-5 h-10 w-2/3 max-w-xl" />
|
||||
<Skeleton className="mt-3 h-5 w-full max-w-2xl" />
|
||||
</div>
|
||||
<div className="mt-8 space-y-3">
|
||||
<Skeleton className="h-7 w-64" />
|
||||
{Array.from({ length: 5 }, (_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
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 Link from "next/link";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
import type { Program } from "@/lib/db";
|
||||
import type { Program } from "@/types/yokatlas";
|
||||
import { trBaslikDuzeni } from "@/lib/slug";
|
||||
import { Sayfalama, SAYFA_BOYU } from "@/components/sayfalama";
|
||||
import { Sayfalama, SAYFA_BOYU } from "./sayfalama";
|
||||
import {
|
||||
ProgramSiraGecmisi,
|
||||
ProgramTabanTrendi,
|
||||
} from "@/components/program-liste-verileri";
|
||||
import { ProgramEkleButonu } from "@/components/manuel-liste/program-ekle-butonu";
|
||||
import { ProgramNetleriButonu } from "@/components/program-netleri";
|
||||
import { ProgramEkleButonu } from "@/features/liste/components/program-ekle-butonu";
|
||||
import { ProgramNetleriButonu } from "./program-netleri";
|
||||
import { UniLogo } from "@/components/uni-logo";
|
||||
import {
|
||||
Table,
|
||||
@@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { AramaFunnelKarti } from "@/components/arama-funnel-karti";
|
||||
import { RehberKapak } from "@/components/rehber-kapak";
|
||||
import { RehberKapak } from "@/features/rehber/components/rehber-kapak";
|
||||
import { UniLogo } from "@/components/uni-logo";
|
||||
import { aramaNormalize, eslesmePuani } from "@/lib/arama";
|
||||
import type { RehberOzet } from "@/lib/rehber";
|
||||
@@ -12,8 +12,8 @@ import {
|
||||
profilDuzenlemeyiAc,
|
||||
sonucaGitIste,
|
||||
useTercihProfili,
|
||||
} from "@/components/manuel-liste/profil-kapisi-store";
|
||||
import { PUAN_TURU_ETIKET } from "@/lib/sihirbaz";
|
||||
} from "@/features/liste/hooks/use-tercih-profili";
|
||||
import { PUAN_TURU_ETIKET } from "@/features/sihirbaz/sihirbaz-sabitler";
|
||||
import { olay } from "@/lib/analitik";
|
||||
import {
|
||||
ArrowRight,
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { ListChecks } from "lucide-react";
|
||||
import type { ProgramNetSatiri } from "@/lib/db";
|
||||
import type { ProgramNetSatiri } from "@/types/yokatlas";
|
||||
import { olay } from "@/lib/analitik";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -2,18 +2,24 @@ import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { getUniversiteBySlug, bolumSlugFromIsim } from "@/lib/katalog";
|
||||
import {
|
||||
getAllUniversiteler,
|
||||
getAllUniversiteSlugs,
|
||||
getUniversiteBySlug,
|
||||
bolumSlugFromIsim,
|
||||
} from "../katalog-queries";
|
||||
import { trBaslikDuzeni } from "@/lib/slug";
|
||||
import { JsonLd, breadcrumbJsonLd, SITE_URL } from "@/lib/seo";
|
||||
import { CtaSiraForm } from "@/components/cta-sira-form";
|
||||
import { CtaSiraForm } from "@/features/sihirbaz/components/cta-sira-form";
|
||||
import { SiteFooter } from "@/components/site-footer";
|
||||
import { Sayfalama, SAYFA_BOYU } from "@/components/sayfalama";
|
||||
import { Sayfalama, SAYFA_BOYU } from "./sayfalama";
|
||||
import { PagePixelDivider } from "@/components/pixel-decor";
|
||||
import { UniversiteKonumHaritasi } from "@/components/universite-konum-haritasi";
|
||||
import { UniversiteProgramTablosu } from "@/components/universite-program-tablosu";
|
||||
import { UniversiteKonumHaritasi } from "./universite-konum-haritasi";
|
||||
import { UniversiteProgramTablosu } from "./universite-program-tablosu";
|
||||
import { UniLogo } from "@/components/uni-logo";
|
||||
import { uniLogoYolu } from "@/lib/uni-logolar";
|
||||
import type { Program } from "@/lib/db";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import type { Program } from "@/types/yokatlas";
|
||||
|
||||
const sayi = (n: number) => n.toLocaleString("tr-TR");
|
||||
|
||||
@@ -21,6 +27,22 @@ export function uniToplamSayfa(programSayisi: number): number {
|
||||
return Math.max(1, Math.ceil(programSayisi / SAYFA_BOYU));
|
||||
}
|
||||
|
||||
// Sayfaların generateStaticParams'ı için — sayfalar *-queries import etmez.
|
||||
export function uniStaticSlugs(): string[] {
|
||||
return getAllUniversiteSlugs();
|
||||
}
|
||||
|
||||
// 2..N sayfaları; 1. sayfa taban rota (/universite/[slug]) olduğundan üretilmez.
|
||||
export function uniSayfaStaticParams(): { slug: string; no: string }[] {
|
||||
return getAllUniversiteler().flatMap((u) => {
|
||||
const toplam = uniToplamSayfa(u.programSayisi);
|
||||
return Array.from({ length: Math.max(0, toplam - 1) }, (_, i) => ({
|
||||
slug: u.slug,
|
||||
no: String(i + 2),
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
export function uniSayfaMetadata(slug: string, sayfa: number): Metadata {
|
||||
const uni = getUniversiteBySlug(slug);
|
||||
if (!uni) return {};
|
||||
@@ -268,3 +290,27 @@ export function UniversiteIcerik({
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function UniversiteIcerikSkeleton() {
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-5xl px-4 py-12 sm:py-16">
|
||||
<Skeleton className="h-4 w-64" />
|
||||
<div className="mt-4 flex items-center gap-4">
|
||||
<Skeleton className="size-14 rounded-lg" />
|
||||
<Skeleton className="h-10 w-2/3 max-w-xl" />
|
||||
</div>
|
||||
<Skeleton className="mt-4 h-5 w-full max-w-3xl" />
|
||||
<div className="mt-6 grid grid-cols-2 gap-3 lg:grid-cols-[1fr_1fr_1.35fr] lg:grid-rows-2">
|
||||
{Array.from({ length: 5 }, (_, i) => (
|
||||
<Skeleton key={i} className="h-24 w-full rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-16 space-y-3">
|
||||
<Skeleton className="h-7 w-64" />
|
||||
{Array.from({ length: 5 }, (_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
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";
|
||||
|
||||
import Link from "next/link";
|
||||
import type { Program } from "@/lib/db";
|
||||
import type { Program } from "@/types/yokatlas";
|
||||
import {
|
||||
ProgramSiraGecmisi,
|
||||
ProgramTabanTrendi,
|
||||
programEtkinSira,
|
||||
} from "@/components/program-liste-verileri";
|
||||
import { ProgramEkleButonu } from "@/components/manuel-liste/program-ekle-butonu";
|
||||
import { ProgramNetleriButonu } from "@/components/program-netleri";
|
||||
import { ProgramEkleButonu } from "@/features/liste/components/program-ekle-butonu";
|
||||
import { ProgramNetleriButonu } from "./program-netleri";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
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 { getCurrentUser } from "@/lib/session";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ListemButonu } from "@/components/manuel-liste/listem-butonu";
|
||||
import { ListeSenkron } from "@/components/manuel-liste/liste-senkron";
|
||||
import { ListemButonu } from "@/features/liste/components/listem-butonu";
|
||||
import { ListeSenkron } from "@/features/liste/components/liste-senkron";
|
||||
import { RybbitIdentify } from "@/components/rybbit-identify";
|
||||
import { SignOutButton } from "./sign-out-button";
|
||||
|
||||
@@ -16,9 +16,9 @@ import { useGorunumOlayi } from "@/components/use-gorunum-olayi";
|
||||
import { olay } from "@/lib/analitik";
|
||||
import { kromGizliMi } from "@/components/header-gate";
|
||||
import { CihazCifti, IsletimSistemiLogosu } from "./cihaz-ikonlari";
|
||||
import { useManuelListe } from "./store";
|
||||
import { useOturumDurumu } from "./oturum-sinyali";
|
||||
import { useGirisUrl } from "./giris-url";
|
||||
import { useManuelListe } from "../liste-store";
|
||||
import { useOturumDurumu } from "../liste-oturum-sinyali";
|
||||
import { useGirisUrl } from "../liste-giris-url";
|
||||
|
||||
const KAPATMA_KEY = "kolaytercih.kaydet-banner-kapali";
|
||||
const ESIK = 3;
|
||||
@@ -47,9 +47,9 @@ import { RISK_ETIKET, type RiskSeviyesi } from "@/lib/risk";
|
||||
import { bolumBazAdi, turkishSlugify } from "@/lib/slug";
|
||||
import { olay } from "@/lib/analitik";
|
||||
import { CihazCifti, IsletimSistemiLogosu } from "./cihaz-ikonlari";
|
||||
import { sonucaGitIste } from "./profil-kapisi-store";
|
||||
import { useOturumDurumu } from "./oturum-sinyali";
|
||||
import { useGirisUrl } from "./giris-url";
|
||||
import { sonucaGitIste } from "../hooks/use-tercih-profili";
|
||||
import { useOturumDurumu } from "../liste-oturum-sinyali";
|
||||
import { useGirisUrl } from "../liste-giris-url";
|
||||
import {
|
||||
MANUEL_LISTE_MAX,
|
||||
cekmeceAc,
|
||||
@@ -60,7 +60,7 @@ import {
|
||||
useManuelListe,
|
||||
yenidenSirala,
|
||||
type ManuelTercih,
|
||||
} from "./store";
|
||||
} from "../liste-store";
|
||||
|
||||
// Slug kuralları katalog.ts'tekiyle aynı (bolumBazAdi/uniAdiNormalize +
|
||||
// turkishSlugify); katalog haritaları DB'deki tüm isim/universite
|
||||
@@ -11,14 +11,11 @@
|
||||
// - Sonrasında: liste/profil değişimleri ~2 sn debounce ile sunucuya yazılır.
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import {
|
||||
PROFIL_DEGISTI_EVENT,
|
||||
tercihProfiliOku,
|
||||
tercihProfiliYaz,
|
||||
} from "@/lib/sihirbaz";
|
||||
import { listeOku, sunucuylaBirlestir, useManuelListe } from "./store";
|
||||
import { oturumDurumuBildir } from "./oturum-sinyali";
|
||||
import { listeSenkronla, listeyiKaydet } from "./kayitli-liste-actions";
|
||||
import { PROFIL_DEGISTI_EVENT } from "@/features/sihirbaz/sihirbaz-sabitler";
|
||||
import { tercihProfiliOku, tercihProfiliYaz } from "@/features/sihirbaz/sihirbaz-profil";
|
||||
import { listeOku, sunucuylaBirlestir, useManuelListe } from "../liste-store";
|
||||
import { oturumDurumuBildir } from "../liste-oturum-sinyali";
|
||||
import { listeSenkronla, listeyiKaydet } from "../liste-actions";
|
||||
|
||||
const DEBOUNCE_MS = 2000;
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
cekmeceDegistir,
|
||||
useCekmeceAcik,
|
||||
useManuelListe,
|
||||
} from "./store";
|
||||
} from "../liste-store";
|
||||
|
||||
export function ListemButonu() {
|
||||
const liste = useManuelListe();
|
||||
@@ -5,10 +5,10 @@
|
||||
// abonedir; ekleme/çıkarma/sürükleme anında haritaya yansır.
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { TercihHaritasi, type HaritaPin } from "@/components/tercih-haritasi";
|
||||
import { TercihHaritasi, type HaritaPin } from "@/features/liste/components/tercih-haritasi";
|
||||
import { ilKonum, uniAdiNormalize, uniKonum } from "@/lib/harita";
|
||||
import type { RiskSeviyesi } from "@/lib/risk";
|
||||
import { useManuelListe } from "./store";
|
||||
import { useManuelListe } from "../liste-store";
|
||||
|
||||
// Aynı üniversitede birden çok tercih varsa pinde kötü risk baskın olsun
|
||||
const RISK_AGIRLIK: Record<RiskSeviyesi, number> = {
|
||||
@@ -2,15 +2,15 @@
|
||||
|
||||
import { Check, Plus } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import type { Program } from "@/lib/db";
|
||||
import type { Program } from "@/types/yokatlas";
|
||||
import { olay } from "@/lib/analitik";
|
||||
import {
|
||||
listedeMi,
|
||||
MANUEL_LISTE_MAX,
|
||||
useManuelListe,
|
||||
type ManuelKaynak,
|
||||
} from "./store";
|
||||
import { programEkleIste } from "./profil-kapisi-store";
|
||||
} from "../liste-store";
|
||||
import { programEkleIste } from "../hooks/use-tercih-profili";
|
||||
|
||||
type ProgramOzet = Pick<
|
||||
Program,
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
Scale,
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
import type { DilimKey, Program, PuanTuruKey, RankResults } from "@/lib/db";
|
||||
import type { DilimKey, Program, PuanTuruKey, RankResults } from "@/types/yokatlas";
|
||||
import { riskHesapla, type RiskSeviyesi } from "@/lib/risk";
|
||||
import { bolumSayfaSlug, uniSayfaSlug } from "@/lib/slug";
|
||||
import {
|
||||
@@ -30,8 +30,8 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { ProgramEkleButonu } from "@/components/manuel-liste/program-ekle-butonu";
|
||||
import { ProgramNetleriButonu } from "@/components/program-netleri";
|
||||
import { ProgramEkleButonu } from "@/features/liste/components/program-ekle-butonu";
|
||||
import { ProgramNetleriButonu } from "@/features/katalog/components/program-netleri";
|
||||
import { UniLogo } from "@/components/uni-logo";
|
||||
|
||||
const DILIMLER: {
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RISK_ETIKET, type RiskSeviyesi } from "@/lib/risk";
|
||||
import { sonucHref } from "@/lib/sihirbaz";
|
||||
import { sonucHref } from "@/features/sihirbaz/sihirbaz-sabitler";
|
||||
import {
|
||||
tercihDegerlendir,
|
||||
tercihListesiOzeti,
|
||||
@@ -23,12 +23,12 @@ import {
|
||||
MANUEL_LISTE_MAX,
|
||||
useManuelListe,
|
||||
type ManuelTercih,
|
||||
} from "./store";
|
||||
} from "../liste-store";
|
||||
import {
|
||||
profilDuzenlemeyiAc,
|
||||
sonucaGitIste,
|
||||
useTercihProfili,
|
||||
} from "./profil-kapisi-store";
|
||||
} from "../hooks/use-tercih-profili";
|
||||
|
||||
const RISK_STIL: Record<RiskSeviyesi, { nokta: string; kenar: string }> = {
|
||||
guvenli: { nokta: "bg-emerald-500", kenar: "border-l-emerald-500" },
|
||||
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 {
|
||||
SihirbazAdimlarLazy,
|
||||
preloadSihirbazAdimlar,
|
||||
} from "@/components/sihirbaz-adimlar-lazy";
|
||||
import type { SihirbazFacetleri } from "@/lib/db";
|
||||
} from "@/features/sihirbaz/components/sihirbaz-adimlar-lazy";
|
||||
import type { SihirbazFacetleri } from "@/types/yokatlas";
|
||||
import { olay, siraKovasi } from "@/lib/analitik";
|
||||
import {
|
||||
tercihProfiliYaz,
|
||||
type SihirbazSecimleri,
|
||||
type TercihProfili,
|
||||
} from "@/lib/sihirbaz";
|
||||
import { type SihirbazSecimleri, type TercihProfili } from "@/features/sihirbaz/sihirbaz-sabitler";
|
||||
import { tercihProfiliYaz } from "@/features/sihirbaz/sihirbaz-profil";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import {
|
||||
profilKapisiniKapat,
|
||||
@@ -40,7 +37,7 @@ import {
|
||||
useProfilKapisi,
|
||||
type BekleyenHedef,
|
||||
type BekleyenProgram,
|
||||
} from "./profil-kapisi-store";
|
||||
} from "../hooks/use-tercih-profili";
|
||||
|
||||
const PUAN_TURLERI_SECENEK = [
|
||||
{ deger: "say", etiket: "Sayısal" },
|
||||
@@ -4,14 +4,9 @@
|
||||
// Layout'ta bir kez monte edilen TercihProfiliKapisi bu store'a abone olur.
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
import type { Program } from "@/lib/db";
|
||||
import {
|
||||
PROFIL_DEGISTI_EVENT,
|
||||
sonucHref,
|
||||
tercihProfiliOku,
|
||||
tercihProfiliTamamlandiMi,
|
||||
type TercihProfili,
|
||||
} from "@/lib/sihirbaz";
|
||||
import type { Program } from "@/types/yokatlas";
|
||||
import { PROFIL_DEGISTI_EVENT, sonucHref, type TercihProfili } from "@/features/sihirbaz/sihirbaz-sabitler";
|
||||
import { tercihProfiliOku, tercihProfiliTamamlandiMi } from "@/features/sihirbaz/sihirbaz-profil";
|
||||
import {
|
||||
ekle,
|
||||
listedeMi,
|
||||
@@ -20,7 +15,7 @@ import {
|
||||
riskleriYenile,
|
||||
useManuelListe,
|
||||
type ManuelKaynak,
|
||||
} from "./store";
|
||||
} from "../liste-store";
|
||||
|
||||
export type BekleyenProgram = {
|
||||
program: Pick<
|
||||
@@ -13,12 +13,12 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { getSession } from "@/lib/session";
|
||||
import { appDb, schema } from "@/lib/appdb";
|
||||
import { tercihProfiliDogrula, type TercihProfili } from "@/lib/sihirbaz";
|
||||
import { tercihProfiliDogrula, type TercihProfili } from "@/features/sihirbaz/sihirbaz-sabitler";
|
||||
import {
|
||||
MANUEL_LISTE_MAX,
|
||||
dogrula,
|
||||
type ManuelTercih,
|
||||
} from "@/components/manuel-liste/dogrula";
|
||||
} from "@/features/liste/liste-dogrula";
|
||||
|
||||
export type ListeSenkronSonucu =
|
||||
| { ok: true; items: ManuelTercih[]; profil: TercihProfili | null }
|
||||
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).
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
import type { Program } from "@/lib/db";
|
||||
import type { Program } from "@/types/yokatlas";
|
||||
import { riskHesapla } from "@/lib/risk";
|
||||
import {
|
||||
tercihProfiliOku,
|
||||
type TercihProfili,
|
||||
} from "@/lib/sihirbaz";
|
||||
import { type TercihProfili } from "@/features/sihirbaz/sihirbaz-sabitler";
|
||||
import { tercihProfiliOku } from "@/features/sihirbaz/sihirbaz-profil";
|
||||
import {
|
||||
MANUEL_LISTE_MAX,
|
||||
dogrula,
|
||||
type ManuelKaynak,
|
||||
type ManuelTercih,
|
||||
} from "./dogrula";
|
||||
} from "./liste-dogrula";
|
||||
|
||||
// Doğrulama katmanı sunucu action'larıyla paylaşılır (bkz. dogrula.ts);
|
||||
// mevcut import yolları kırılmasın diye buradan re-export edilir.
|
||||
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 { ArrowRight } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTercihProfili } from "@/components/manuel-liste/profil-kapisi-store";
|
||||
import { sonucHref } from "@/lib/sihirbaz";
|
||||
import { useTercihProfili } from "@/features/liste/hooks/use-tercih-profili";
|
||||
import { sonucHref } from "@/features/sihirbaz/sihirbaz-sabitler";
|
||||
|
||||
/**
|
||||
* Ödeme başarılı ama henüz Yapay Zeka listesi yokken gösterilen CTA: kayıtlı tercih
|
||||
@@ -5,7 +5,7 @@ import { ArrowRight } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { olay } from "@/lib/analitik";
|
||||
import { baslatOdeme, type OdemeBaslatDurum } from "./actions";
|
||||
import { baslatOdeme, type OdemeBaslatDurum } from "../odeme-actions";
|
||||
|
||||
export function SatinAlForm({
|
||||
urun,
|
||||
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";
|
||||
|
||||
import { useTercihProfili } from "@/components/manuel-liste/profil-kapisi-store";
|
||||
import { useTercihProfili } from "@/features/liste/hooks/use-tercih-profili";
|
||||
|
||||
// Hero başlığı: sıralama girilmemişse tanıtım metni, kayıtlı profil varsa
|
||||
// aynı şablonla (vurgulu italik orta parça) aksiyon metnine dönüşür.
|
||||
@@ -3,8 +3,8 @@
|
||||
import Link from "next/link";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTercihProfili } from "@/components/manuel-liste/profil-kapisi-store";
|
||||
import { sonucHref } from "@/lib/sihirbaz";
|
||||
import { useTercihProfili } from "@/features/liste/hooks/use-tercih-profili";
|
||||
import { sonucHref } from "@/features/sihirbaz/sihirbaz-sabitler";
|
||||
|
||||
export function HeroFocusButton() {
|
||||
function handleClick() {
|
||||
@@ -1,8 +1,8 @@
|
||||
// Sihirbaz hunisinin statik anlatımı: interaktif demo yerine akışın tamamını
|
||||
// tek bakışta gösteren bir mermaid şeması. Çizim, ürün genelindeki ortak
|
||||
// "el çizimi" bileşeniyle yapılır (bkz. components/el-cizimi-sema.tsx).
|
||||
// "el çizimi" bileşeniyle yapılır (bkz. features/rehber/components/el-cizimi-sema.tsx).
|
||||
|
||||
import { ElCizimiSema } from "@/components/el-cizimi-sema";
|
||||
import { ElCizimiSema } from "@/features/rehber/components/el-cizimi-sema";
|
||||
|
||||
// Renkler ürünün risk dilinden (kırmızı/sarı/yeşil) ve slate paletinden;
|
||||
// renk tek başına anlam taşımaz, her düğüm metinle etiketlidir.
|
||||
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";
|
||||
import Link from "next/link";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { useTercihProfili } from "@/components/manuel-liste/profil-kapisi-store";
|
||||
import { PUAN_TURU_ETIKET, sonucHref } from "@/lib/sihirbaz";
|
||||
import type { DilimKey, SihirbazFacetleri } from "@/lib/db";
|
||||
import { useTercihProfili } from "@/features/liste/hooks/use-tercih-profili";
|
||||
import { PUAN_TURU_ETIKET, sonucHref } from "@/features/sihirbaz/sihirbaz-sabitler";
|
||||
import type { DilimKey, SihirbazFacetleri } from "@/types/yokatlas";
|
||||
|
||||
export type DemoVeri = {
|
||||
facetler: SihirbazFacetleri;
|
||||
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