Compare commits
19 Commits
716f7bb3fb
...
claude/nif
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37baeb9172 | ||
|
|
45775c8c3f | ||
|
|
18d5b42136 | ||
|
|
4c618781f6 | ||
|
|
e62c6f07df | ||
|
|
488845ae1f | ||
|
|
9ea006ec22 | ||
|
|
a5c2bcbc4d | ||
|
|
c2aba25814 | ||
|
|
2278d2a218 | ||
|
|
0b92f999bc | ||
|
|
cc9686147e | ||
|
|
bd613adb8a | ||
|
|
205743f3b1 | ||
|
|
a8bd13ef43 | ||
|
|
647a44e46a | ||
|
|
6495369c56 | ||
|
|
b4effc8545 | ||
|
|
6bd210075f |
197
.agents/skills/prototype/PICKER.md
Normal file
197
.agents/skills/prototype/PICKER.md
Normal file
@@ -0,0 +1,197 @@
|
||||
# The Picker
|
||||
|
||||
The picker's appearance is **not a design decision** — it is this spec. Copy the markup, CSS, and wiring below verbatim; the only values that change per run are the variant names and count. It stays identical across every project so it always reads as harness chrome, never as part of the design being judged. Do not restyle it with the project's tokens, fonts, or colors.
|
||||
|
||||
It is a floating dark pill, bottom-center. Dark glass works on top of any page — light or dark — which is why it is not theme-aware.
|
||||
|
||||
## Markup
|
||||
|
||||
The sliding highlight span first, one button per variant, a hairline divider, then the replay button (only when at least one variant has motion to re-trigger):
|
||||
|
||||
```html
|
||||
<nav class="proto-picker" aria-label="Prototype variants">
|
||||
<span class="proto-picker-highlight" aria-hidden="true"></span>
|
||||
<button class="proto-picker-item" data-active aria-current="true">Quiet</button>
|
||||
<button class="proto-picker-item">Editorial</button>
|
||||
<button class="proto-picker-item">Playful</button>
|
||||
<span class="proto-picker-divider" aria-hidden="true"></span>
|
||||
<button class="proto-picker-item proto-picker-replay" aria-label="Replay animation (R)">↻</button>
|
||||
</nav>
|
||||
```
|
||||
|
||||
In a framework, keep the class names and structure; only the rendering syntax changes.
|
||||
|
||||
## Styles
|
||||
|
||||
```css
|
||||
.proto-picker {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 2147483647;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 4px;
|
||||
border-radius: 999px;
|
||||
background: rgba(10, 10, 10, 0.82);
|
||||
-webkit-backdrop-filter: blur(12px) saturate(1.4);
|
||||
backdrop-filter: blur(12px) saturate(1.4);
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(255, 255, 255, 0.08) inset,
|
||||
0 8px 24px rgba(0, 0, 0, 0.24),
|
||||
0 2px 6px rgba(0, 0, 0, 0.12);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.proto-picker-highlight {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 0;
|
||||
height: 28px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* The slide is enabled only after first paint (data-ready), so load doesn't animate. */
|
||||
.proto-picker[data-ready] .proto-picker-highlight {
|
||||
transition:
|
||||
transform 250ms cubic-bezier(0.23, 1, 0.32, 1),
|
||||
width 250ms cubic-bezier(0.23, 1, 0.32, 1);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.proto-picker[data-ready] .proto-picker-highlight { transition: none; }
|
||||
}
|
||||
|
||||
.proto-picker-item {
|
||||
position: relative; /* sits above the highlight */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 28px;
|
||||
padding: 0 12px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
transition: color 150ms ease-out;
|
||||
}
|
||||
|
||||
.proto-picker-item:hover {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
.proto-picker-item:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.proto-picker-item:focus-visible {
|
||||
outline: 2px solid rgba(255, 255, 255, 0.4);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.proto-picker-item[data-active] {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.proto-picker-divider {
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
margin: 0 4px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.proto-picker-replay {
|
||||
padding: 0 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.proto-picker[data-position="top"] {
|
||||
bottom: auto;
|
||||
top: 24px;
|
||||
}
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
- **Verbatim.** These values are the spec. No project fonts, no brand colors, no theme switching, no extra shadows or borders.
|
||||
- **The highlight slides; the variant swap stays instant.** The active pill animates between buttons (250ms, strong ease-out) as spatial feedback on the picker itself — but the variant being previewed still switches with no transition. The `width` transition is a deliberate exception to the transform/opacity rule: the element is 28px tall, absolutely positioned, and has no layout dependents, so the paint cost is negligible.
|
||||
- **One allowed modification:** if a variant occupies the bottom-center of the screen (a toast stack, a bottom sheet, a dock), set `data-position="top"` so the picker never covers the work. Nothing else about it may move or change.
|
||||
- **Replay is conditional.** Render the replay button and its divider only when at least one variant has an entrance or state animation worth re-triggering; a static comparison gets a shorter pill.
|
||||
|
||||
## Behavior contract
|
||||
|
||||
The contract is fixed regardless of how the harness renders:
|
||||
|
||||
- Number keys `1–N` and `←`/`→` switch variants; `R` replays. Ignore key events when focus is in an input, textarea, select, or contenteditable, or when a modifier is held.
|
||||
- Clicking an item switches to it; exactly one item carries `data-active` and `aria-current="true"` at all times, and the highlight slides to it.
|
||||
- Selection persists across reload via a URL param (`?v=2`), falling back to variant 1. The highlight takes its initial position without animating (`data-ready` is added after first paint).
|
||||
- Switching re-mounts the variant (so entrance animations re-run); the replay key re-mounts without switching.
|
||||
|
||||
## Reference wiring
|
||||
|
||||
Verbatim for the standalone-HTML branch; in a framework, keep the same behavior but express it idiomatically (state instead of `innerHTML`, a keyed re-mount instead of `requestAnimationFrame`, refs + a layout effect for the highlight measurement).
|
||||
|
||||
```js
|
||||
// `variants` is an array of render functions, one per variant, in picker order.
|
||||
const stage = document.getElementById('stage');
|
||||
const picker = document.querySelector('.proto-picker');
|
||||
const highlight = picker.querySelector('.proto-picker-highlight');
|
||||
const items = [...picker.querySelectorAll('.proto-picker-item:not(.proto-picker-replay)')];
|
||||
const replay = picker.querySelector('.proto-picker-replay');
|
||||
let current = 0;
|
||||
|
||||
function moveHighlight() {
|
||||
const el = items[current];
|
||||
highlight.style.width = el.offsetWidth + 'px';
|
||||
highlight.style.transform = `translateX(${el.offsetLeft}px)`;
|
||||
}
|
||||
|
||||
function mount(i) {
|
||||
stage.innerHTML = '';
|
||||
// Clear first, render next frame, so entrance animations re-run.
|
||||
requestAnimationFrame(() => { stage.innerHTML = variants[i](); });
|
||||
}
|
||||
|
||||
function setActive(i) {
|
||||
if (i < 0 || i >= variants.length) return;
|
||||
current = i;
|
||||
items.forEach((el, j) => {
|
||||
el.toggleAttribute('data-active', j === i);
|
||||
if (j === i) el.setAttribute('aria-current', 'true');
|
||||
else el.removeAttribute('aria-current');
|
||||
});
|
||||
moveHighlight();
|
||||
const url = new URL(location);
|
||||
url.searchParams.set('v', i + 1);
|
||||
history.replaceState(null, '', url);
|
||||
mount(i);
|
||||
}
|
||||
|
||||
items.forEach((el, i) => el.addEventListener('click', () => setActive(i)));
|
||||
replay?.addEventListener('click', () => mount(current));
|
||||
window.addEventListener('resize', moveHighlight);
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (/^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName) || e.target.isContentEditable) return;
|
||||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||
const num = parseInt(e.key, 10);
|
||||
if (num >= 1 && num <= variants.length) setActive(num - 1);
|
||||
else if (e.key === 'ArrowRight') setActive((current + 1) % variants.length);
|
||||
else if (e.key === 'ArrowLeft') setActive((current - 1 + variants.length) % variants.length);
|
||||
else if (e.key === 'r' || e.key === 'R') mount(current);
|
||||
});
|
||||
|
||||
setActive((parseInt(new URLSearchParams(location.search).get('v'), 10) || 1) - 1);
|
||||
// Enable the slide only after first paint, so load doesn't animate.
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => picker.setAttribute('data-ready', '')));
|
||||
```
|
||||
90
.agents/skills/prototype/SKILL.md
Normal file
90
.agents/skills/prototype/SKILL.md
Normal file
@@ -0,0 +1,90 @@
|
||||
---
|
||||
name: prototype
|
||||
description: Build multiple genuinely different versions of a UI piece you describe, rendered behind a visual picker so you can flip through them live and promote the one that feels right. Only runs when explicitly invoked; it does not trigger on its own.
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
# Prototyping Variants
|
||||
|
||||
A divergence skill. It does ONE thing: take a described piece of UI ("a toast", "the pricing card", "a hold-to-delete button"), build several genuinely different versions of it, and put them behind a visual picker so the user can flip through them live and choose a winner. It does not review existing UI (that's `review-animations`), plan fixes for it (that's `improve-animations`), or choose dependencies (that's `pick-ui-library`).
|
||||
|
||||
## Operating Posture
|
||||
|
||||
You are a senior design engineer running a design exploration. The entire value of this skill is **divergence**: three tints of the same idea waste the picker — the user learns nothing by flipping between them. Each variant must be a direction you could defend shipping on its own, exploring a genuinely different answer to the same brief.
|
||||
|
||||
Divergence is not an excuse to drop the craft bar. Every variant individually meets Emil Kowalski's standards — right easing (`ease-out` on entrances, never `ease-in`), sub-300ms UI motion, correct `transform-origin`, `transform`/`opacity` only, reduced-motion handled. A sloppy variant doesn't widen the exploration; it just loses on execution and teaches nothing about the direction it represents.
|
||||
|
||||
## Hard Rules
|
||||
|
||||
1. **Never touch production code during exploration.** Everything lives in an isolated prototype surface (see Phase 4). Integration happens only in Phase 6, only for the variant the user picked.
|
||||
2. **Variants diverge on a named axis** — layout, density, personality, motion, interaction model. Before building, you must be able to state each variant's axis in a phrase. Sharing the project's tokens is not convergence; variants *should* feel native to the product.
|
||||
3. **Every variant fully works.** Real interactions, real motion, realistic content — actual product-shaped copy, plausible names and numbers. No lorem ipsum, no dead buttons, no "imagine this part".
|
||||
4. **The picker is chrome, not a contestant.** Its exact markup, styles, and behavior are specified in [PICKER.md](PICKER.md) — copy them verbatim. Its look is not a design decision and never adapts to the project.
|
||||
5. **Clean up after the choice.** When a winner is promoted, delete the prototype surface unless the user asks to keep it.
|
||||
|
||||
## Workflow
|
||||
|
||||
### Phase 1 — Scope
|
||||
|
||||
One thing per run. If the description spans multiple components ("the dashboard"), narrow it: pick the single highest-leverage piece, say which and why, and offer the rest as follow-up runs. Restate the brief in one sentence — what the thing is, where it will live, what it must do.
|
||||
|
||||
### Phase 2 — Recon
|
||||
|
||||
Before designing anything, map the ground the variants must stand on:
|
||||
|
||||
- **Stack**: framework, styling system (Tailwind, CSS modules, vanilla), motion library if any.
|
||||
- **Tokens**: colors, radii, spacing, fonts, easing/duration variables. Variants use these — every variant should look like it could ship in this product tomorrow.
|
||||
- **Personality**: playful consumer app or crisp dashboard? This bounds how far the boldest variant may go.
|
||||
- **Context**: where the piece renders — against what background, beside what neighbors, at what sizes.
|
||||
|
||||
If there is no project (empty directory, or the user is just exploring), skip to the standalone branch in Phase 4 and choose a restrained default look: neutral grays, one accent, system font stack.
|
||||
|
||||
### Phase 3 — Choose directions
|
||||
|
||||
Default **3 variants**; up to 5 when the user asks or the design space is genuinely wide. More than 5 dilutes the comparison.
|
||||
|
||||
Before writing any code, list the set: a name and an axis for each. Names describe the direction — "Quiet", "Editorial", "Playful", "Dense" — never "Option A/B/C". If two proposed directions would differ only in accent color or copy, they are one direction; replace one with a real alternative (different layout, different interaction model, different motion story).
|
||||
|
||||
**Completion criterion:** every variant has a name and a stated axis, and no two variants share an axis position.
|
||||
|
||||
### Phase 4 — Build the picker harness
|
||||
|
||||
Two branches, by what exists:
|
||||
|
||||
- **In a project with a dev server** — an isolated route or page (`/prototypes/<slug>`, or the framework's equivalent), one file per variant plus a small harness file. Nothing imports from the prototype surface into production code.
|
||||
- **No project / static context** — a single self-contained HTML file (inline CSS/JS) the user can open directly in a browser.
|
||||
|
||||
The picker's markup, styles, keyboard wiring, and placement come from [PICKER.md](PICKER.md), verbatim — load it now and build exactly that. Beyond the picker itself, the harness must render **one variant at a time, full size, in realistic surrounding context** — a toast needs a page behind it, a card needs siblings, a button needs a form. Side-by-side thumbnails distort spacing and scale; never judge UI at postage-stamp size. Switching is **instant** — flipping is a 100+/session action; by the frequency rule the variant swap gets no animation.
|
||||
|
||||
### Phase 5 — Verify and hand off
|
||||
|
||||
Run the harness. Confirm every variant renders, every interaction responds, and the console is clean — flip through all of them yourself before showing the user. If browser tooling is available, screenshot each variant.
|
||||
|
||||
Then present the set and **stop — the choice belongs to the user**:
|
||||
|
||||
| # | Variant | Axis | When it's the right choice | Its cost |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 1 | Quiet | Minimal motion, borders over shadows | The product is a daily-use tool | Least memorable |
|
||||
| 2 | Editorial | Large type, generous whitespace | The moment deserves weight | Eats vertical space |
|
||||
|
||||
Close with where the picker is running (URL or file path) and the keys to flip.
|
||||
|
||||
**Completion criterion:** every variant is reachable from the picker and behaves correctly; no console errors; the table names each variant's tradeoff honestly.
|
||||
|
||||
### Phase 6 — Promote on selection
|
||||
|
||||
When the user picks: integrate that variant where it belongs, following the project's existing conventions (file layout, naming, token usage), then delete the prototype surface per Hard Rule 5. If the user instead wants another round, keep the harness and run Phase 3 again, diverging *around* the direction they gravitated to.
|
||||
|
||||
## Invocation Variants
|
||||
|
||||
| Invocation | Behavior |
|
||||
| --- | --- |
|
||||
| `<description>` | Full workflow: scope → recon → 3 variants → picker → wait for choice |
|
||||
| `<description> x5` | Same, with that many variants (capped at 5) |
|
||||
| `riff <variant>` | New round: keep the harness, generate a fresh set diverging around the named variant's direction |
|
||||
| `keep <variant>` | Promote that variant into the codebase and delete the prototype surface |
|
||||
| `keep <variant>, leave the picker` | Promote, but keep the prototype surface around |
|
||||
|
||||
## Tone
|
||||
|
||||
Sell each variant honestly — one line on when it wins, one on what it costs. Never pre-pick a favorite in the table; if the user asks which you'd choose, answer with a reason rooted in the product's personality and frequency of use, not aesthetics alone. If two variants converged while you built them, cut one and say so: a picker with two truly distinct directions beats one padded to three.
|
||||
20
.claude/settings.json
Normal file
20
.claude/settings.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"enabledPlugins": {
|
||||
"superpowers@superpowers-marketplace": true,
|
||||
"prd-development@pm-skills": true
|
||||
},
|
||||
"extraKnownMarketplaces": {
|
||||
"superpowers-marketplace": {
|
||||
"source": {
|
||||
"source": "github",
|
||||
"repo": "obra/superpowers-marketplace"
|
||||
}
|
||||
},
|
||||
"pm-skills": {
|
||||
"source": {
|
||||
"source": "github",
|
||||
"repo": "deanpeters/Product-Manager-Skills"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
7
.claude/skills/grill-me/SKILL.md
Normal file
7
.claude/skills/grill-me/SKILL.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
name: grill-me
|
||||
description: A relentless interview to sharpen a plan or design.
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
Run a `/grilling` session.
|
||||
22
.claude/skills/grilling/SKILL.md
Normal file
22
.claude/skills/grilling/SKILL.md
Normal file
@@ -0,0 +1,22 @@
|
||||
---
|
||||
name: grilling
|
||||
description: Grill the user relentlessly about a plan, decision, or idea. Use when the user wants to stress-test their thinking, or uses any 'grill' trigger phrases.
|
||||
---
|
||||
|
||||
Interview the user relentlessly until you reach a shared understanding. Map this as a **design tree**: every decision branches into the decisions that hang off it.
|
||||
|
||||
Work the tree in **rounds**. The **frontier** is every decision whose prerequisites are already settled — the questions you can ask _now_ without guessing at answers you haven't heard yet. Ask the whole frontier in one round: number each question and give your recommended answer. Then wait for the user's answers before the next round.
|
||||
|
||||
Each question should be formatted like so:
|
||||
|
||||
```
|
||||
❓ **Q1** - **<question title>**: <question body, might be multiple paragraphs, including multiple choices>
|
||||
|
||||
➡️ <your recommended answer>
|
||||
```
|
||||
|
||||
Each round the user answers reshapes the tree — settled decisions push the frontier outward and unblock questions that depended on them. Recompute the frontier and ask the next round. A question whose answer depends on another question still open in this round belongs to a _later_ round, not this one.
|
||||
|
||||
Finding _facts_ is your job, never the user's. When a frontier question needs a fact from the environment (filesystem, tools, etc.), dispatch a sub-agent to find it — don't ask the user for anything you could look up yourself. Don't block on it: a running exploration is an unsettled prerequisite, so only the questions downstream of it wait for the sub-agent to report — ask the rest of the frontier now. The _decisions_ are the user's — put each to them and wait.
|
||||
|
||||
The session is done when the frontier is empty: every branch of the design tree visited, nothing left silently assumed. Do not act on it until the user confirms you have reached a shared understanding.
|
||||
1
.claude/skills/prototype
Symbolic link
1
.claude/skills/prototype
Symbolic link
@@ -0,0 +1 @@
|
||||
../../.agents/skills/prototype
|
||||
@@ -1,13 +1,27 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
.gitea
|
||||
.agents
|
||||
.claude
|
||||
.mcp.json
|
||||
# Yalnızca kök dizindeki .md dosyaları (path ayracını geçmez);
|
||||
# content/rehber/*.md build için gerekli, etkilenmez.
|
||||
*.md
|
||||
.env*
|
||||
!.env.example
|
||||
drizzle/
|
||||
docs/
|
||||
# scripts/ build'de kullanılmaz; istisnalar: entrypoint + standalone link
|
||||
# onarımı + açılış migration runner'ı (drizzle/ da onun için imaja girer)
|
||||
scripts/*
|
||||
!scripts/docker-entrypoint.sh
|
||||
!scripts/standalone-linkleri-onar.sh
|
||||
!scripts/db-goc.mjs
|
||||
package-lock.json
|
||||
skills-lock.json
|
||||
components.json
|
||||
eslint.config.mjs
|
||||
drizzle.config.ts
|
||||
data/
|
||||
!data/app.db
|
||||
!data/yokatlas.db
|
||||
|
||||
@@ -26,10 +26,54 @@ jobs:
|
||||
IYZICO_API_KEY=${{ secrets.IYZICO_API_KEY }}
|
||||
IYZICO_SECRET_KEY=${{ secrets.IYZICO_SECRET_KEY }}
|
||||
IYZICO_BASE_URL=${{ secrets.IYZICO_BASE_URL }}
|
||||
ANTHROPIC_API_KEY=${{ secrets.ANTHROPIC_API_KEY }}
|
||||
OPENROUTER_API_KEY=${{ secrets.OPENROUTER_API_KEY }}
|
||||
OPENROUTER_MODEL=${{ secrets.OPENROUTER_MODEL }}
|
||||
EOF
|
||||
|
||||
- name: Build & deploy
|
||||
# BuildKit'te "docker build --memory" YOK SAYILIR (moby/buildkit#1362);
|
||||
# gerçek sınır ancak buildkitd'yi kendi cgroup'u olan bir container'da
|
||||
# (buildx docker-container driver) koşturarak konur. Böylece build OOM
|
||||
# olursa yalnızca builder container'ı ölür — uygulama + Gitea ayakta kalır
|
||||
# ve adım 35-90 dk sessiz kilitlenme yerine hızlı, görünür hata verir.
|
||||
#
|
||||
# Sınırlar container İLK oluşturulduğunda donar; değerleri değiştirmek için
|
||||
# önce: docker buildx rm sinirli && docker rm -f buildx_buildkit_sinirli0
|
||||
- name: Sınırlı builder hazırla
|
||||
run: |
|
||||
docker compose -p kolaytercih up -d --build --remove-orphans
|
||||
docker image prune -f
|
||||
cat > buildkitd.toml <<'EOF'
|
||||
[worker.oci]
|
||||
enabled = true
|
||||
max-parallelism = 1
|
||||
EOF
|
||||
# Act container'ı her koşuda sıfırdan geldiği için client kaydı hep
|
||||
# yeniden oluşturulur; host'taki buildx_buildkit_sinirli0 container'ı
|
||||
# (ve build cache'i) varsa bootstrap'ta aynen yeniden kullanılır.
|
||||
docker buildx create --name sinirli \
|
||||
--driver docker-container \
|
||||
--driver-opt memory=2200m \
|
||||
--driver-opt memory-swap=4200m \
|
||||
--driver-opt cpu-quota=150000 \
|
||||
--buildkitd-config ./buildkitd.toml \
|
||||
2>/dev/null || true
|
||||
docker buildx inspect sinirli --bootstrap >/dev/null
|
||||
|
||||
- name: Build (sınırlı builder)
|
||||
run: |
|
||||
docker compose -p kolaytercih build --builder sinirli
|
||||
# docker-container driver'da imajın daemon'a --load ile düştüğünü doğrula;
|
||||
# düşmediyse aynı cache'ten yeniden build edip elle yükle.
|
||||
docker image inspect kolaytercih-app >/dev/null 2>&1 || \
|
||||
docker buildx build --builder sinirli --load -t kolaytercih-app .
|
||||
|
||||
- name: Deploy
|
||||
run: docker compose -p kolaytercih up -d --no-build --remove-orphans
|
||||
|
||||
- name: Temizlik (başarısız build'de de çalışır)
|
||||
if: always()
|
||||
run: |
|
||||
docker image prune -f || true
|
||||
# sinirli builder'ın cache'ini 4 GB'ta tut (flag adı buildx sürümüne göre değişti)
|
||||
docker buildx prune --builder sinirli -f --max-used-space 4gb 2>/dev/null || \
|
||||
docker buildx prune --builder sinirli -f --keep-storage 4gb || true
|
||||
# eski varsayılan-builder cache artıkları
|
||||
docker builder prune -f --filter "until=72h" || true
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"rybbit": {
|
||||
"type": "http",
|
||||
"url": "https://rybbit.kolaytercih.com/api/mcp"
|
||||
},
|
||||
"shadcn": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
|
||||
20
AGENTS.md
20
AGENTS.md
@@ -3,7 +3,27 @@
|
||||
|
||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
||||
|
||||
# Tarayıcı açma
|
||||
|
||||
UI değişikliklerini tarayıcıda/preview'da doğrulamaya çalışma; kullanıcı kendisi kontrol ediyor. Dev sunucusu veya browser paneli açma — sadece kod değişikliğini yap ve ne değiştiğini raporla.
|
||||
|
||||
# Push öncesi build zorunlu
|
||||
|
||||
Push emri geldiğinde önce lokalde `pnpm build` başarıyla geçmeli; geçmeden asla push etme. Kritik detay: çalışma ağacı kirliyken build, commit'lenmemiş dosyaları da gördüğü için CI'da kırılacak bir commit'i yakalayamayabilir. Bu yüzden build'i **push edilecek commit'in kendisi** üzerinde doğrula (ör. geçici `git worktree add <dir> HEAD` + `node_modules` symlink'i ile). Ayrıca kısmi commit atarken değişikliğin bağımlılık kapanışını kontrol et — bir dosyanın kullandığı prop/export başka bir commit'lenmemiş dosyadan geliyorsa onları da aynı commit'e dahil et.
|
||||
|
||||
# Badge/eyebrow rozetleri: tek kaynak SectionEyebrow
|
||||
|
||||
Site genelindeki tüm badge/rozet/eyebrow elemanları ana sayfadaki section başlıklarının üstünde duran rozetle birebir aynı olmalı. Kanonik bileşen `src/components/pixel-decor.tsx` içindeki `SectionEyebrow`dur (piksel imzalı `PixelMark` ikonu + `rounded-full border border-slate-200 bg-white px-3.5 py-1.5 text-xs font-semibold uppercase tracking-[0.12em] text-primary`). Yeni bir badge/eyebrow gerektiğinde `SectionEyebrow` kullan (`className` prop'u kenar boşluğu için var); `@/components/ui/badge`'deki `Badge` ile ya da elle yazılmış pill span'lerle yeni badge tasarlama. İnteraktif chip/buton pill'leri (filtre çipleri, nav butonları) bu kuralın dışındadır.
|
||||
|
||||
# UI tutarlılığı: ortak elemanlar tek kaynaktan
|
||||
|
||||
Modallar arası ortak elemanlar (kapatma çarpısı, başlık düzeni vb.) görsel olarak birebir aynı olmalı. Kapatma çarpısının kanonik stili `src/components/ui/dialog.tsx` içindeki `DialogContent` varsayılan butonudur (yuvarlak, `size-9`, `border border-slate-200`, `size-4` X ikonu, hover'da `bg-slate-100`, `active:scale-[0.96]`). Yeni bir modal eklerken bu varsayılanı kullan; özel yerleşim gerekiyorsa (ör. katalog-arama'da input satırının içindeki buton) `showCloseButton={false}` ver ama **aynı class setini** kopyala — farklı boyut/renk/varyantta yeni bir çarpı tasarlama. Aynı prensip diğer tekrarlanan UI parçaları için de geçerli: bir eleman iki yerde görünüyorsa stilini tek kaynaktan al veya birebir eşleştir.
|
||||
|
||||
# CTA butonlarında ikon: yıldız yok, sağ ok metnin sonunda
|
||||
|
||||
Funnel CTA butonlarında ("Listemi oluştur", "Tercihlerimi belirle", giriş/ödeme CTA'ları vb.) `Sparkles` (yıldız) ikonu kullanılmaz. Kanonik desen: buton metni önce gelir, sonunda `<ArrowRight className="size-4" aria-hidden />` durur. Yeni bir CTA butonu eklerken bu deseni uygula; metnin başına ikon koyma. Dekoratif `Sparkles` kullanımları (boş durum başlık ikonları, bilgi satırları) bu kuralın dışındadır — kural yalnızca aksiyon butonları/CTA linkleri içindir.
|
||||
|
||||
# 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.
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
|
||||
44
Dockerfile
44
Dockerfile
@@ -1,6 +1,8 @@
|
||||
FROM node:20-alpine AS base
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# pnpm kurulumu
|
||||
FROM node:20-alpine AS base
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable && corepack prepare pnpm@10.8.1 --activate
|
||||
|
||||
# ── Bağımlılık katmanı ──
|
||||
@@ -9,17 +11,36 @@ WORKDIR /app
|
||||
# better-sqlite3 Alpine'de kaynaktan derleniyor (musl için prebuilt yok)
|
||||
RUN apk add --no-cache python3 make g++
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
# pnpm store cache mount'ta yaşar: lockfile değişse bile inen paketler yeniden inmez
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
# ── Derleme katmanı ──
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
# Build girdileri açık listelenir: docs/, scripts/, CI dosyası değişiklikleri
|
||||
# bu katmanı invalide edemez. next build'in okuduğu her şey burada:
|
||||
# content/ (rehber.ts), data/ (db.ts prerender), public/ (opengraph-image).
|
||||
COPY package.json next.config.ts tsconfig.json postcss.config.mjs ./
|
||||
COPY scripts/standalone-linkleri-onar.sh ./scripts/
|
||||
COPY public ./public
|
||||
COPY data ./data
|
||||
COPY content ./content
|
||||
COPY src ./src
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
# data/ .dockerignore'da; libsql build sırasında file:./data/app.db açabilsin diye boş dizin gerekli
|
||||
RUN mkdir -p data && pnpm build
|
||||
# V8 heap sınırı ana süreç + statik üretim worker'larına miras kalır.
|
||||
# Turbopack'ın Rust tarafı bu sınırın dışında; onun hedefi next.config.ts'te
|
||||
# turbopackMemoryLimit ile verilir (BUILD_KAYNAK_KISITLI bloğu).
|
||||
ENV NODE_OPTIONS="--max-old-space-size=1536"
|
||||
ENV BUILD_KAYNAK_KISITLI=1
|
||||
RUN --mount=type=cache,id=next-cache,target=/app/.next/cache \
|
||||
pnpm build
|
||||
# Turbopack, ESM external'ların (@google/genai vb.) kök node_modules symlink'ini
|
||||
# nft trace'ine yazmıyor; eksik linkler tamamlanmazsa runtime MODULE_NOT_FOUND
|
||||
# ile düşer. Ayrıntı script'in başındaki açıklamada.
|
||||
RUN sh scripts/standalone-linkleri-onar.sh .next/standalone
|
||||
|
||||
# ── Çalıştırma katmanı ──
|
||||
FROM node:20-alpine AS runner
|
||||
@@ -31,14 +52,21 @@ ENV NEXT_TELEMETRY_DISABLED=1
|
||||
RUN addgroup --system --gid 1001 nodejs && \
|
||||
adduser --system --uid 1001 nextjs
|
||||
|
||||
# Bağımlılıklar
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
# standalone çıktı, file-tracing ile seçilmiş kendi minimal node_modules'unu
|
||||
# içerir (serverExternalPackages dahil) — tam node_modules kopyalanmaz.
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
COPY --from=builder /app/.next/static ./.next/static
|
||||
COPY --from=builder /app/public ./public
|
||||
|
||||
# Trace, data/yokatlas.db'yi de standalone'a koyuyor; runtime'da /app/data
|
||||
# volume'ü bunu gölgelediği için imajdan atılır (~22M).
|
||||
RUN rm -rf /app/data
|
||||
|
||||
# Seed veritabanları: entrypoint açılışta bunları /app/data volume'üne kopyalar
|
||||
COPY data/ ./seed/
|
||||
# Migration'lar: entrypoint açılışta db-goc.mjs ile volume'deki app.db'ye uygular
|
||||
COPY drizzle ./drizzle
|
||||
COPY scripts/db-goc.mjs ./scripts/db-goc.mjs
|
||||
COPY scripts/docker-entrypoint.sh ./docker-entrypoint.sh
|
||||
|
||||
# SQLite veri dizini
|
||||
|
||||
65
content/rehber/bolum-mu-universite-mi-sehir-mi.md
Normal file
65
content/rehber/bolum-mu-universite-mi-sehir-mi.md
Normal file
@@ -0,0 +1,65 @@
|
||||
---
|
||||
baslik: Bölüm mü, Üniversite mi, Şehir mi? Üçünü Nasıl Dengelersin?
|
||||
aciklama: İyi üniversitede kötü bölüm mü, sıradan üniversitede istediğin bölüm mü? Tercih döneminin en çok sorulan sorusu — karar sırası tek şemada.
|
||||
tarih: 2026-08-03
|
||||
---
|
||||
|
||||
Tercih döneminde herkesin bir cümlesi var: "Bölüm her şeydir", "Hayır, üniversite markası her şeydir", "Asıl şehir önemli". Üçü de eksik. Bu üç başlığın **hangi durumda hangisinin öne geçtiği** belli bir sıraya oturur ve bu sıra herkes için aynı değildir.
|
||||
|
||||
## Karar sırası
|
||||
|
||||
```mermaid
|
||||
%% aria: Bölüm üniversite şehir dengesi şeması. İlk soru mesleğin diplomaya bağlı olup olmadığıdır. Tıp, diş hekimliği, eczacılık, hukuk, öğretmenlik gibi diploma kilidi olan alanlarda bölüm her şeyin önündedir; sonra şehir, en son üniversite adı gelir. Diploma kilidi yoksa ikinci soru bölümün ne kadar net olduğudur. Bölüm netse üniversitenin o bölümdeki kadrosu ve akreditasyonu belirleyicidir. Bölüm net değilse geniş kampüs, çift anadal ve yatay geçiş imkânı olan üniversite öne geçer. Her üç yolda da şehir maliyeti ve barınma son filtredir; bütçe tutmuyorsa satır listeye girmez.
|
||||
%% altyazi: Sıralama herkes için aynı değil — önce mesleğin diplomaya bağlı olup olmadığına bak.
|
||||
flowchart TD
|
||||
A{"Hedeflediğin meslek<br/>belirli bir diplomaya<br/>bağlı mı?"}
|
||||
A -- "evet: tıp, diş, eczacılık,<br/>hukuk, öğretmenlik..." --> B("Önce BÖLÜM<br/>sonra şehir<br/>en son üniversite adı")
|
||||
A -- "hayır: mühendislik,<br/>işletme, sosyal bilimler..." --> C{"Bölümün<br/>kafanda net mi?"}
|
||||
C -- "net" --> D("Önce o bölümün KADROSU<br/>ve akreditasyonu<br/>sonra şehir")
|
||||
C -- "net değil" --> E("Önce GENİŞ ÜNİVERSİTE:<br/>çift anadal, yatay geçiş,<br/>bölüm çeşitliliği")
|
||||
B --> F{"Şehir ve barınma<br/>maliyeti 4 yıl boyunca<br/>karşılanabilir mi?"}
|
||||
D --> F
|
||||
E --> F
|
||||
F -- "evet" --> G("Satır listeye girer")
|
||||
F -- "hayır" --> H("Satırı yazma:<br/>yerleşirsen kayıt<br/>yaptıramazsın")
|
||||
|
||||
classDef uyari fill:#fef3c7,stroke:#f59e0b,color:#92400e
|
||||
classDef garanti fill:#d1fae5,stroke:#10b981,color:#065f46
|
||||
classDef hayal fill:#fee2e2,stroke:#ef4444,color:#991b1b
|
||||
class B,D,G garanti
|
||||
class C,E,F uyari
|
||||
class H hayal
|
||||
```
|
||||
|
||||
## 1) Bölümün diploma kilidi var mı?
|
||||
|
||||
Bazı meslekleri **yalnızca o diplomayla** yapabilirsin. [Tıp](/bolum/tip), [Diş Hekimliği](/bolum/dis-hekimligi), [Eczacılık](/bolum/eczacilik), [Hukuk](/bolum/hukuk), [Hemşirelik](/bolum/hemsirelik), [Fizyoterapi ve Rehabilitasyon](/bolum/fizyoterapi-ve-rehabilitasyon), öğretmenlik programları böyledir. Bu alanlarda tartışma biter: **bölüm birinci sıradadır.** Sıralaman iki programa da yetiyorsa aralarında üniversiteye göre seçersin, ama "iyi üniversitede başka bölüm" bu grupta bir alternatif değildir — çünkü diploma mesleğin kapısıdır.
|
||||
|
||||
Diploma kilidi olmayan alanlarda ise mezuniyet sonrası yollar çatallanır. [İşletme](/bolum/isletme), [İktisat](/bolum/iktisat), [Sosyoloji](/bolum/sosyoloji), [Uluslararası İlişkiler](/bolum/uluslararasi-iliskiler) mezunlarının gittiği yer büyük ölçüde stajlar, ikinci diller ve üniversitenin ağıyla belirlenir. Burada üniversitenin ağırlığı gerçekten artar.
|
||||
|
||||
## 2) Üniversiteyi "marka" olarak değil, "o bölümdeki hâli" olarak oku
|
||||
|
||||
Bir üniversite bir bölümünde çok güçlü, diğerinde vasat olabilir. Marka ismi yerine şunlara bak:
|
||||
|
||||
- **Bölümün öğretim üyesi sayısı ve unvan dağılımı.** Üniversitenin bölüm sayfasındaki akademik kadro listesi bunu doğrudan gösterir. Beş kişilik kadroyla yürüyen bir mühendislik bölümüyle otuz kişilik kadro aynı şey değildir.
|
||||
- **Akreditasyon.** Mühendislikte MÜDEK, eğitim fakültelerinde EPDAD gibi program akreditasyonları, o bölümün belirli bir eğitim standardını sağladığının dışarıdan onayıdır. Üniversitenin bölüm sayfasında yazar.
|
||||
- **Ders katalogu.** Neredeyse her üniversitenin Bologna/ders kataloğu sayfası vardır; dört yıl boyunca hangi dersleri alacağını orada satır satır okuyabilirsin. Aynı adlı iki bölümün müfredatı şaşırtıcı derecede farklı olabilir.
|
||||
- **Taban sıralamanın söyledikleri.** Bir programın tabanı, piyasanın o programa biçtiği değerin toplamıdır. İki üniversite arasında kararsızsan tabanlar sana toplumun ortalama kanaatini söyler — ama bunu tek başına değil, yukarıdaki üç maddeyle birlikte oku. [Taban sıralamalar yıldan yıla nasıl değişir](/rehber/taban-siralamalar-nasil-degisir) yazısı bu okumanın sınırlarını anlatıyor.
|
||||
|
||||
## 3) Şehir bir "tercih kriteri" değil, bir "geçerlilik filtresi"
|
||||
|
||||
Şehri en sona bıraktık ama en zayıf kriter olduğu için değil — **eleyici** olduğu için. Şehir şu üç şeyi belirler:
|
||||
|
||||
1. **Toplam maliyet.** Kira, yurt, ulaşım ve yemek dört yıla vurulduğunda okulun kendisinden büyük bir kalem olur. [Yurt, burs ve yıllık maliyet](/rehber/yurt-burs-ve-yillik-maliyet) yazısında bunu satır satır hesaplıyoruz.
|
||||
2. **Staj ve yarı zamanlı iş erişimi.** Mühendislik, [Mimarlık](/bolum/mimarlik), medya, [Gazetecilik](/bolum/gazetecilik) gibi alanlarda sektörün fiziksel olarak bulunduğu şehir, öğrencilik yıllarında ciddi bir avantajdır.
|
||||
3. **Dört yıl orada yaşayacak olman.** İklimi, eve uzaklığı, sosyal hayatı sana uymayan bir şehirde okulu bırakma ihtimali gerçek bir risktir.
|
||||
|
||||
Şehri "istediğim şehirde okumak istiyorum" diye en başa koyarsan, listeni gereksiz yere daraltırsın. En sona koyarsan da bütçeyi patlatırsın. Doğru yeri **filtre**: bölüm ve üniversite sıralamanı yaptıktan sonra, karşılayamayacağın şehirdeki satırları listeden çıkar.
|
||||
|
||||
## Sık düşülen üç tuzak
|
||||
|
||||
- **"Puanım boşa gitmesin" tuzağı.** Sıralaman yetiyor diye istemediğin bir bölümü listenin üstüne yazmak, en pahalı hatadır: ÖSYM seni yerleşebildiğin **en üst** tercihe koyar, o satır tutarsa aşağıdaki gerçek tercihlerin hiç okunmaz. [Merkezi yerleştirme nasıl çalışır](/rehber/merkezi-yerlestirme-nasil-calisir) yazısı bu mekanizmayı anlatıyor.
|
||||
- **"Sonra yatay geçerim" tuzağı.** Yatay geçiş gerçek bir yoldur ama garanti değildir — koşulları ve kontenjanı vardır. Planını üstüne kurma; [yatay geçiş, ÇAP ve yandal](/rehber/yatay-gecis-cap-yandal) yazısı gerçekçi sınırları çiziyor.
|
||||
- **"Üniversitenin adı beni taşır" tuzağı.** Köklü bir üniversitenin zayıf bir bölümünde dört yıl geçirmek, mezuniyette beklediğin kapıyı açmayabilir. Marka değil, **o bölümün o üniversitedeki hâli** taşır.
|
||||
|
||||
Sıralamana hangi bölüm–üniversite–şehir kombinasyonlarının düştüğünü tek ekranda görmek için [tercih robotuna](/tercih-robotu) sıralamanı gir; şehir ve devlet/vakıf filtreleriyle üç ekseni ayrı ayrı deneyebilirsin.
|
||||
73
content/rehber/bolumun-is-imkanini-nasil-arastirirsin.md
Normal file
73
content/rehber/bolumun-is-imkanini-nasil-arastirirsin.md
Normal file
@@ -0,0 +1,73 @@
|
||||
---
|
||||
baslik: Bir Bölümün İş İmkânını Nasıl Araştırırsın? (Duyduklarına Değil, Kaynağa Bakma Rehberi)
|
||||
aciklama: Bu bölüm iş bulur mu sorusunun cevabı forumda değil. Bir bölümün mezuniyet sonrasını beş adımda kendin araştırma yöntemi — tek şemada.
|
||||
tarih: 2026-07-28
|
||||
---
|
||||
|
||||
"Bu bölüm iş bulur mu?" tercih döneminin en çok sorulan, en kötü cevaplanan sorusu. Forumlarda gördüğün cevaplar birkaç kişinin kendi hikâyesidir; akrabaların söyledikleri on yıl öncesinin piyasasıdır. İyi haber şu: bu soruyu **kendin araştırabilirsin** ve gereken kaynakların hepsi açık. Bu yazı yöntemi veriyor — hangi bölüm olursa olsun işleyen bir yöntem.
|
||||
|
||||
## Beş adımlı araştırma
|
||||
|
||||
```mermaid
|
||||
%% aria: Bölüm araştırma yöntemi şeması, beş adım. Birinci adım mesleğin kapısının nasıl açıldığını anlamaktır: diploma tek başına yetiyor mu, üstüne bir sınav mı gerekiyor, yoksa serbest bir alan mı. İkinci adım o kapının darlığını ölçmektir: sınav varsa kaç kişi giriyor kaç kişi geçiyor, kontenjan ne kadar. Üçüncü adım bölümün müfredatını okumaktır: üniversitenin ders kataloğunda dört yıl boyunca alacağın dersler satır satır yazar. Dördüncü adım arz tarafına bakmaktır: bu bölümden Türkiye genelinde yılda kaç kişi mezun oluyor ve kontenjan artıyor mu azalıyor mu. Beşinci adım gerçek insanlarla konuşmaktır: mezunların gittiği yerleri incelemek ve iki üç mezunla konuşmak. Bu beş adımın sonunda karar duyduklarına değil kaynağa dayanır.
|
||||
%% altyazi: Sıra önemli: önce mesleğin kapısı, en son insan hikâyeleri.
|
||||
flowchart TD
|
||||
A1("1. Kapı nasıl açılıyor?<br/>diploma tek başına mı,<br/>üstüne sınav mı,<br/>serbest alan mı") --> A2("2. Kapı ne kadar dar?<br/>kaç kişi giriyor,<br/>kaç kişi geçiyor")
|
||||
A2 --> A3("3. Müfredatı oku<br/>üniversitenin ders katalogu<br/>4 yılda ne öğreneceksin")
|
||||
A3 --> A4("4. Arz tarafı<br/>yılda kaç mezun veriliyor,<br/>kontenjan artıyor mu")
|
||||
A4 --> A5("5. İnsanla konuş<br/>mezunlar nereye gitmiş,<br/>2-3 kişiyle görüş")
|
||||
A5 --> S{"Beş adımın sonunda"}
|
||||
S -- "cevaplar tutarlı" --> K("Karar kaynağa dayanıyor")
|
||||
S -- "hâlâ belirsiz" --> B("Belirsizlik gerçek:<br/>listeye alternatif de koy")
|
||||
|
||||
classDef uyari fill:#fef3c7,stroke:#f59e0b,color:#92400e
|
||||
classDef garanti fill:#d1fae5,stroke:#10b981,color:#065f46
|
||||
class K garanti
|
||||
class S,B uyari
|
||||
```
|
||||
|
||||
## Adım 1: Mesleğin kapısı nasıl açılıyor?
|
||||
|
||||
Bölümler mezuniyet sonrası açısından üçe ayrılır ve bu ayrım her şeyden önemlidir:
|
||||
|
||||
- **Diploma doğrudan yetki verir.** [Tıp](/bolum/tip), [Diş Hekimliği](/bolum/dis-hekimligi), [Eczacılık](/bolum/eczacilik), [Hemşirelik](/bolum/hemsirelik), [Veteriner](/bolum/veteriner), [Fizyoterapi ve Rehabilitasyon](/bolum/fizyoterapi-ve-rehabilitasyon) gibi. Mesleği yapmak için diploma gereklidir; uzmanlaşma veya kamuda çalışma için ayrıca sınav olabilir.
|
||||
- **Diplomanın üstüne bir kapı sınavı var.** [Hukuk](/bolum/hukuk) mezunu için staj ve baro süreci, öğretmenlik programları için atama sınavı, kamuda çalışmak isteyen [Kamu Yönetimi](/bolum/kamu-yonetimi) veya [İktisat](/bolum/iktisat) mezunu için merkezi sınavlar gibi. Burada kritik soru bölümün kendisi değil, **o kapının genişliğidir.**
|
||||
- **Kapı serbest, ölçü senin portföyün.** [Bilgisayar Mühendisliği](/bolum/bilgisayar-muhendisligi), [Yazılım Mühendisliği](/bolum/yazilim-muhendisligi), [İşletme](/bolum/isletme), [Radyo, Televizyon ve Sinema](/bolum/radyo-televizyon-ve-sinema) gibi alanlarda diploma bir başlangıçtır; işveren staja, projeye, portföye bakar.
|
||||
|
||||
Bu üç grubun "iş imkânı" sorusuna cevabı bambaşkadır. İkinci gruptaki bir bölümü değerlendirirken bölümün kalitesine değil, **atama/geçiş oranlarına** bakman gerekir.
|
||||
|
||||
## Adım 2: Kapı ne kadar dar?
|
||||
|
||||
Sınavla açılan bir kapı varsa sayıları ara: o alanda yılda kaç kişi sınava giriyor, kaç kişi geçiyor, kaç kadro açılıyor? Bu veriler ilgili kurumun (bakanlık, meslek odası, sınavı yapan kurum) kendi duyurularında yayımlanır ve genelde yıllara göre karşılaştırılabilir.
|
||||
|
||||
Burada dikkat: **rakamı bul, yorumu sonra yap.** "Atama yok" ile "geçen yıl şu kadar kadro açıldı, şu kadar mezun vardı" arasında dağlar kadar fark var. İkincisi karar verilebilir bir bilgidir.
|
||||
|
||||
## Adım 3: Müfredatı gerçekten oku
|
||||
|
||||
Neredeyse her üniversitenin ders kataloğu (Bologna bilgi paketi) açıktır. Bölüm sayfasından "ders planı" veya "müfredat" bağlantısını bul ve **sekiz yarıyılın dersini de oku.** Bu on beş dakika, bölüm hakkında forumlarda geçireceğin on saatten fazlasını anlatır.
|
||||
|
||||
Bakman gerekenler:
|
||||
|
||||
- Derslerin ağırlığı beklentine uyuyor mu? Adı aynı olan iki bölümden biri ağırlıklı matematik, diğeri ağırlıklı tasarım olabilir.
|
||||
- Zorunlu staj var mı, kaç gün?
|
||||
- Seçmeli ders havuzu ne kadar geniş? Geniş havuz, alan içinde yön değiştirme imkânı demektir.
|
||||
- Bölümün akreditasyonu var mı? Mühendislikte MÜDEK, eğitimde EPDAD gibi program akreditasyonları üniversitenin bölüm sayfasında ilan edilir.
|
||||
|
||||
## Adım 4: Arz tarafına bak
|
||||
|
||||
Bir alanın doygunluğunu anlamanın en sade yolu: **Türkiye genelinde o bölümden yılda kaç kişi mezun oluyor?** Kontenjan tarafı bunun iyi bir göstergesidir — aynı bölümün kaç üniversitede açık olduğuna ve toplam kontenjanına bakabilirsin. Kontenjanı hızla artan bir bölümde, bugünün mezun sayısıyla dört yıl sonrakinin aynı olmayacağını hesaba kat.
|
||||
|
||||
Tabanların yıllar içindeki seyri de bu tabloyu okur: bir bölümün tabanı birkaç yıl üst üste gevşiyorsa, talep tarafında bir şey değişiyor demektir. [Taban sıralamalar yıldan yıla ne kadar değişir](/rehber/taban-siralamalar-nasil-degisir) yazısı bu okumanın nasıl yapılacağını ve sınırlarını anlatıyor.
|
||||
|
||||
## Adım 5: İnsanla konuş — ama doğru soruyu sorarak
|
||||
|
||||
En son adım bu, çünkü ilk dördü olmadan sorduğun sorular yüzeysel kalır. İki kaynak:
|
||||
|
||||
- **Mezunların gittiği yerler.** Meslek ağlarında üniversite + bölüm araması yapıp mezunların bugün ne iş yaptığına bakmak, tek tek hikâyelerden çok daha sağlıklı bir dağılım verir.
|
||||
- **İki-üç mezunla konuşmak.** "Memnun musun?" diye sorma; şunları sor: *İlk işini nasıl buldun? Okuduğun derslerden hangisi işine yaradı? Bugün yeniden seçsen ne değiştirirdin?* Bu üç soru, on dakikada gerçek bilgi verir.
|
||||
|
||||
## Cevap belirsiz kaldıysa ne yapmalı?
|
||||
|
||||
Bazı alanlarda dört yıl sonrası dürüstçe belirsizdir — ve bu, o bölümü kötü yapmaz. Belirsizlikle baş etmenin tercih listesindeki karşılığı şu: **listeye alternatif de koy.** İstediğin bölümü üst bloğa yaz, ama alt bloğa da seni memnun edecek ikinci ve üçüncü seçenekleri diz. [Tercih listesi nasıl yapılır](/rehber/tercih-listesi-nasil-yapilir) yazısındaki hayal–dengeli–garanti dağılımı tam olarak bu belirsizliği yönetmek için var.
|
||||
|
||||
Bir de bölümlerin kendi sayfalarına bak: [bölüm kataloğunda](/bolumler) her bölümün hangi üniversitelerde açık olduğunu, taban sıralamalarını ve program sayısını görebilir, araştırmanın dördüncü adımını dakikalar içinde tamamlayabilirsin. Sıralamana hangi bölümlerin düştüğünü görmek içinse [tercih robotuna](/tercih-robotu) sıralamanı gir.
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
baslik: Devlet mi Vakıf mı? Burslu ve İndirimli Programlar Nasıl Okunur?
|
||||
aciklama: Aynı bölümün beş farklı fiyat etiketi var — %100 burslu, %50 indirimli, ücretli... Vakıf tablosunu doğru okumanın ve devletle kıyaslamanın rehberi.
|
||||
aciklama: Aynı bölümün beş farklı fiyat etiketi var — burslu, indirimli, ücretli... Vakıf tablosunu doğru okumanın rehberi, tek şemada.
|
||||
tarih: 2026-07-19
|
||||
---
|
||||
|
||||
@@ -8,6 +8,27 @@ Tercih tablolarında aynı bölümün adı beş kez geçebilir: *Psikoloji (Burs
|
||||
|
||||
## Varyantlar ne anlama geliyor?
|
||||
|
||||
```mermaid
|
||||
%% aria: Vakıf program varyantları şeması: aynı bölüm adı altında burslu yüzde yüz, yüzde elli veya yirmi beş indirimli ve ücretli programlar ayrı kontenjan ve ayrı tabanla listelenir. Burslu genelde en sıkı tabana sahiptir ve devletlerle yarışır; indirimli orta bütçe ister; ücretli en geniş tabanlıdır ama aile bütçesine uymuyorsa yazılmamalıdır. İngilizce veya UOLP etiketi burs bilgisi değil öğretim dili bilgisidir.
|
||||
%% altyazi: Aynı bölüm adı beş kez geçebilir — her satır ayrı program, ayrı fiyat, ayrı tabandır.
|
||||
flowchart TD
|
||||
B("Aynı bölüm adı<br/>tabloda birkaç kez geçer") --> V{"Hangi etiket?"}
|
||||
V -- "%100 burslu" --> BU("Ücret yok<br/>taban genelde en sıkı,<br/>devletlerle yarışır")
|
||||
V -- "%50 / %25 indirimli" --> IN("Kısmi ücret<br/>toplam maliyeti baştan hesapla")
|
||||
V -- "ücretli" --> UC("Tam ücret<br/>taban genelde en geniş")
|
||||
V -- "İngilizce / UOLP" --> DI("Burs değil, dil bilgisi<br/>hazırlık yılı ekleyebilir")
|
||||
UC --> K{"Bütçe gerçekten uygun<br/>ve okulu istiyor musun?"}
|
||||
K -- "evet" --> YAZ("Listeye yaz")
|
||||
K -- "hayır" --> YAZMA("Yazma:<br/>yerleşirsen ek hakkın yanar")
|
||||
|
||||
classDef uyari fill:#fef3c7,stroke:#f59e0b,color:#92400e
|
||||
classDef garanti fill:#d1fae5,stroke:#10b981,color:#065f46
|
||||
classDef hayal fill:#fee2e2,stroke:#ef4444,color:#991b1b
|
||||
class BU,YAZ garanti
|
||||
class IN,DI uyari
|
||||
class YAZMA hayal
|
||||
```
|
||||
|
||||
- **Burslu (%100):** Öğrenim ücreti yok. Vakıfların vitrini olduğu için tabanı çoğu zaman aynı üniversitenin ücretli programından *yüz binlerce sıra* daha iyidir; köklü devletlerle yarışır.
|
||||
- **%50 / %25 indirimli:** Ücretin o oranı düşülür. Kalan tutar yıllara ve okula göre ciddi bir bütçedir; 4-6 yıllık toplam maliyeti baştan hesapla.
|
||||
- **Ücretli:** Tam ücret. Tabanlar genelde en geniştir.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
baslik: Ek Yerleştirme Nedir? 2026 Ek Tercih Rehberi
|
||||
aciklama: İlk yerleştirmede boşta kaldıysan oyun bitmedi — ek yerleştirmenin kuralları, kimlerin başvurabileceği ve ikinci şansı doğru kullanmanın yolu.
|
||||
aciklama: İlk yerleştirmede boşta kaldıysan oyun bitmedi — kimlerin başvurabileceği ve ikinci şansı doğru kullanmanın yolu tek şemada.
|
||||
tarih: 2026-07-22
|
||||
---
|
||||
|
||||
@@ -12,6 +12,21 @@ Kayıtlar tamamlandıktan sonra, genellikle **Eylül ayı içinde**. ÖSYM önce
|
||||
|
||||
## Kimler başvurabilir?
|
||||
|
||||
```mermaid
|
||||
%% aria: Ek yerleştirme başvuru şeması: ilk yerleştirmede hiçbir programa yerleşemediysen veya hiç tercih yapmadıysan ek yerleştirmeye başvurabilirsin; Eylül'de kılavuz yayımlanınca yeni bir 24 tercihlik liste kurarsın. İlk turda herhangi bir programa yerleştiysen — kayıt yaptırmasan bile — ek yerleştirme kapısı kapalıdır.
|
||||
%% altyazi: Kritik kural: yerleşmiş sayılmak ek kapıyı kapatır; kayıt yaptırmamak bu durumu değiştirmez.
|
||||
flowchart TD
|
||||
S{"İlk yerleştirmede<br/>ne oldu?"}
|
||||
S -- "hiçbir programa yerleşemedin<br/>ya da hiç tercih yapmadın" --> A("Ek yerleştirme hakkın var:<br/>Eylülde yeni 24 tercih")
|
||||
A --> L("Kılavuzdaki boş kontenjanlardan<br/>gerçekçi bir liste kur")
|
||||
S -- "bir programa yerleştin<br/>kayıt yaptırmasan bile" --> K("Kapı kapalı:<br/>ek yerleştirmeye giremezsin")
|
||||
|
||||
classDef garanti fill:#d1fae5,stroke:#10b981,color:#065f46
|
||||
classDef hayal fill:#fee2e2,stroke:#ef4444,color:#991b1b
|
||||
class A,L garanti
|
||||
class K hayal
|
||||
```
|
||||
|
||||
Kurallar nettir ve en kritik olanı şudur:
|
||||
|
||||
- **İlk yerleştirmede herhangi bir programa yerleşen aday, kayıt yaptırmasa bile ek yerleştirmeye başvuramaz.** "Yerleştim ama gitmeyeceğim, ek tercihte daha iyisini denerim" diye bir yol yoktur.
|
||||
|
||||
65
content/rehber/hazirlik-sinifi-ve-ogretim-dili.md
Normal file
65
content/rehber/hazirlik-sinifi-ve-ogretim-dili.md
Normal file
@@ -0,0 +1,65 @@
|
||||
---
|
||||
baslik: Hazırlık Sınıfı ve Öğretim Dili — (İngilizce), (%30 İngilizce) Ne Demek?
|
||||
aciklama: Program adının yanındaki dil etiketi ne anlatıyor, hazırlık zorunlu mu, atlanabilir mi, bir yıl uzar mı? Dil kararının tamamı tek şemada.
|
||||
tarih: 2026-07-31
|
||||
---
|
||||
|
||||
Tercih tablosunda aynı bölümü üç farklı etiketle görürsün: *Bilgisayar Mühendisliği*, *Bilgisayar Mühendisliği (İngilizce)*, *Bilgisayar Mühendisliği (%30 İngilizce)*. Bu etiket **öğretim dilini** anlatır; burs ya da kalite bilgisi değildir. Ama okuma sürene, hazırlık yılına ve mezuniyetteki dil seviyene doğrudan etki eder — yani listede yerini bilerek belirlemen gerekir.
|
||||
|
||||
## Etiketten hazırlığa: karar akışı
|
||||
|
||||
```mermaid
|
||||
%% aria: Öğretim dili ve hazırlık sınıfı akış şeması. Program adının yanındaki etiket önce okunur. Tamamen İngilizce programlarda derslerin tamamı İngilizcedir ve hazırlık sınıfı genellikle zorunludur. Yüzde otuz İngilizce programlarda derslerin bir kısmı İngilizcedir ve hazırlık üniversiteye göre zorunlu veya isteğe bağlı olabilir. Türkçe programlarda hazırlık genelde isteğe bağlıdır. Hazırlık zorunluysa yıl başında yapılan yeterlik yani muafiyet sınavına girilir. Sınav geçilirse doğrudan birinci sınıftan başlanır. Geçilemezse bir yıl hazırlık okunur ve bu yıl normal öğrenim süresine dahil değildir. Hazırlık sonunda yine başarısız olunursa üniversitenin yönetmeliğine göre ek süre veya Türkçe eşdeğer programa geçiş gündeme gelir; bu koşullar okuldan okula değiştiği için kılavuzdan ve üniversitenin yönetmeliğinden doğrulanmalıdır.
|
||||
%% altyazi: Dil etiketi süreyi değiştirir: hazırlık yılı normal öğrenim süresine dahil değildir.
|
||||
flowchart TD
|
||||
E{"Program adının<br/>yanındaki etiket"}
|
||||
E -- "İngilizce" --> I("Dersler tamamen İngilizce<br/>hazırlık genelde ZORUNLU")
|
||||
E -- "%30 İngilizce" --> O("Derslerin bir kısmı İngilizce<br/>hazırlık okula göre değişir")
|
||||
E -- "etiket yok" --> T("Türkçe program<br/>hazırlık genelde İSTEĞE BAĞLI")
|
||||
I --> M{"Yıl başındaki yeterlik<br/>muafiyet sınavı"}
|
||||
O --> M
|
||||
M -- "geçtin" --> B("Doğrudan 1. sınıf<br/>süre uzamaz")
|
||||
M -- "geçemedin" --> H("1 yıl hazırlık<br/>normal süreye dahil DEĞİL")
|
||||
H --> S{"Hazırlık sonu<br/>başarı"}
|
||||
S -- "başardın" --> B
|
||||
S -- "başaramadın" --> U("Üniversitenin yönetmeliğine göre:<br/>ek süre veya Türkçe<br/>eşdeğer programa geçiş")
|
||||
|
||||
classDef uyari fill:#fef3c7,stroke:#f59e0b,color:#92400e
|
||||
classDef garanti fill:#d1fae5,stroke:#10b981,color:#065f46
|
||||
classDef hayal fill:#fee2e2,stroke:#ef4444,color:#991b1b
|
||||
class B,T garanti
|
||||
class M,S,H,O uyari
|
||||
class U hayal
|
||||
```
|
||||
|
||||
## Etiketler ne anlatıyor?
|
||||
|
||||
- **(İngilizce):** Programdaki derslerin tamamına yakını İngilizce işlenir. Bu programlarda hazırlık sınıfı çoğunlukla zorunludur; yeterlik sınavını geçemezsen bir yıl hazırlık okursun.
|
||||
- **(%30 İngilizce):** Derslerin belirli bir bölümü İngilizce, geri kalanı Türkçedir. Hazırlığın zorunlu mu isteğe bağlı mı olduğu üniversiteden üniversiteye değişir — bunu ÖSYM kılavuzundaki program koşullarından ve üniversitenin kendi yönetmeliğinden doğrulaman gerekir.
|
||||
- **Etiketsiz program:** Türkçe öğretim yapılır. Çoğu üniversitede isteğe bağlı hazırlık imkânı yine de vardır.
|
||||
- **UOLP / ortak program:** Yurt dışındaki bir üniversiteyle yürütülen ortak programdır. Dil etiketinden ayrı bir şeydir; kendi koşulları, kendi ücretlendirmesi ve genelde yurt dışında geçirilecek dönemleri vardır. Kılavuzdaki açıklama maddelerini satır satır okumadan bu satırları listeye yazma.
|
||||
|
||||
Kritik nokta: **etiket bir kalite göstergesi değildir.** Aynı üniversitenin Türkçe ve İngilizce programı arasındaki taban farkı, çoğu zaman eğitim kalitesi farkını değil, adayların dil tercihini yansıtır.
|
||||
|
||||
## Hazırlık yılı süreyi uzatır mı?
|
||||
|
||||
Hazırlık sınıfı **normal öğrenim süresine dahil değildir.** Yani dört yıllık bir lisans programında hazırlık okursan toplam beş yıl kampüste olursun, ama azami öğrenim süren dört yıl üzerinden işlemeye hazırlıktan sonra başlar. Pratik sonuç: hazırlık, mezuniyetini bir yıl öteler.
|
||||
|
||||
Bu bir yıl kayıp mı? Cevap tamamen sana bağlı:
|
||||
|
||||
- **Dilin zaten iyiyse** yeterlik sınavını geçip doğrudan birinci sınıftan başlarsın; hiçbir şey uzamaz. Bu sınav yıl başında yapılır ve girmek ücretsizdir — kaybedecek bir şeyin yok.
|
||||
- **Dilin zayıfsa ve hedefin dilin ağır bastığı bir alansa** ([Bilgisayar Mühendisliği](/bolum/bilgisayar-muhendisligi), [Yazılım Mühendisliği](/bolum/yazilim-muhendisligi), [Uluslararası İlişkiler](/bolum/uluslararasi-iliskiler), [Endüstri Mühendisliği](/bolum/endustri-muhendisligi)) hazırlık yılı büyük ihtimalle hayatındaki en verimli yatırımlardan biridir. Bu alanlarda kaynakların, dokümantasyonun ve iş ilanlarının çoğu İngilizcedir.
|
||||
- **Dilin zayıfsa ve alanın ağırlıklı olarak Türkçe yürüyorsa** ([Hukuk](/bolum/hukuk), [Sınıf Öğretmenliği](/bolum/sinif-ogretmenligi), [Türkçe Öğretmenliği](/bolum/turkce-ogretmenligi), sağlık meslek programlarının çoğu) hazırlığı zorunlu kılan bir programı sırf etiket için tercih etmenin getirisi sınırlıdır.
|
||||
|
||||
## Hazırlıkta başarısız olursam?
|
||||
|
||||
Bu, tercih öncesinde en az konuşulan ama en çok korkulan konu. Genel çerçeve şu: hazırlık sonunda yeterlik sağlanamazsa üniversitenin yönetmeliği devreye girer. Bazı üniversiteler ek süre veya yaz okulu tanır; bazılarında ise Türkçe eşdeğer bir programa geçiş imkânı gündeme gelir. **Bu koşullar okuldan okula ciddi biçimde değişir** — tercih etmeyi düşündüğün üniversitenin "Yabancı Diller Yüksekokulu" veya "Hazırlık Sınıfı Yönetmeliği" sayfasını açıp okumak on dakika sürer ve seni yıllarca ilgilendirir.
|
||||
|
||||
## Listede nasıl konumlanır?
|
||||
|
||||
- İngilizce ve Türkçe programı **ayrı satır** olarak düşün: ayrı kontenjan, ayrı taban, ayrı program kodu.
|
||||
- İkisini de istiyorsan ikisini de yaz; hangisini daha çok istiyorsan üste koy. Aralarındaki taban farkı sıralamana bağlı olarak ikisini de erişilebilir kılabilir.
|
||||
- İngilizce programı **yalnızca hazırlık yılını göze alıyorsan** yaz. "Nasıl olsa muafiyet sınavını geçerim" varsayımıyla yazıp geçemezsen, yine de o yılı okursun.
|
||||
- %30 İngilizce programlarda hazırlığın zorunlu olup olmadığını **program bazında** kontrol et; aynı üniversitenin iki bölümünde bile farklı olabilir.
|
||||
|
||||
Sıralamanla hangi dil varyantlarının açık olduğunu yan yana görmek için [tercih robotuna](/tercih-robotu) sıralamanı gir; aynı bölümün Türkçe ve İngilizce satırlarını tek listede karşılaştırabilirsin.
|
||||
@@ -1,11 +1,28 @@
|
||||
---
|
||||
baslik: Kaç Sıralama ile Hangi Bölüme Girebilirim? (2025 Verileriyle)
|
||||
aciklama: 10 binden 1 milyona — sıralama bandına göre hangi bölümlerin gerçekçi olduğunun veriye dayalı haritası.
|
||||
aciklama: 10 binden 1 milyona — sıralama bandına göre hangi bölümlerin gerçekçi olduğunun haritası ve nasıl okunacağı tek şemada.
|
||||
tarih: 2026-07-23
|
||||
---
|
||||
|
||||
"Sıralamam X, nereyi kazanırım?" tercih döneminin en çok sorulan sorusu. Aşağıdaki bantlar 2025 YKS yerleştirme verilerine (YÖK Atlas) dayanır ve **kabaca bir harita** çizer — kesin sınır değildir; aynı bölümün tabanı üniversiteye, şehre ve burs durumuna göre çok geniş bir aralığa yayılır.
|
||||
|
||||
```mermaid
|
||||
%% aria: Sıralama bandı okuma şeması: önce kendi puan türünün sıralama bandını bulursun; bant sana hangi bölümlere bakmaya değer olduğunu söyler ama hangi üniversitede tutacağını söylemez. Sonra bölüm sayfasındaki tabloya inip kendi sıralamanın düştüğü programları görürsün; en hızlı yol tercih robotuna sıralamanı girip hayal, dengeli ve garanti dilimlerini tek bakışta almaktır. Bant pusuladır, garanti değildir.
|
||||
%% altyazi: Bant "nereye bakayım" der; kesin satırı bölüm tablosu ve tercih robotu söyler.
|
||||
flowchart TD
|
||||
B("Sıralama bandını bul<br/>SAY, EA, sözel veya dil") --> N("Bant sana hangi bölümlere<br/>bakmaya değer olduğunu söyler")
|
||||
N --> T{"Nasıl netleşir?"}
|
||||
T -- "bölüm tablosuna in" --> TAB("Aynı bölümde tabanlar<br/>üniversiteye göre çok açılır")
|
||||
T -- "tercih robotuna gir" --> R("Hayal / dengeli / garanti<br/>dilimleri saniyede çıkar")
|
||||
TAB --> K("Bandına düşen satırları<br/>istek sırana göre diz")
|
||||
R --> K
|
||||
|
||||
classDef uyari fill:#fef3c7,stroke:#f59e0b,color:#92400e
|
||||
classDef garanti fill:#d1fae5,stroke:#10b981,color:#065f46
|
||||
class N,TAB uyari
|
||||
class R,K garanti
|
||||
```
|
||||
|
||||
## Sayısal (SAY) için kaba harita
|
||||
|
||||
| Sıralama bandı | Gerçekçi seçenekler |
|
||||
|
||||
50
content/rehber/merkezi-yerlestirme-nasil-calisir.md
Normal file
50
content/rehber/merkezi-yerlestirme-nasil-calisir.md
Normal file
@@ -0,0 +1,50 @@
|
||||
---
|
||||
baslik: Merkezi Yerleştirme Nasıl Çalışır? ÖSYM Seni Nasıl Yerleştirir?
|
||||
aciklama: "Üst sıraya yazarsam şansımı yakar mıyım?" korkusunun cevabı sistemin kendisinde. ÖSYM'nin yerleştirme mantığı, tek şemada ve öğrenci diliyle.
|
||||
tarih: 2026-07-26
|
||||
---
|
||||
|
||||
Tercih döneminin en çok aranan sorularından biri şu: *"Tercihler nasıl değerlendiriliyor? Üst sıraya iddialı bir bölüm yazarsam alttaki şansımı kaybeder miyim?"* Kısa cevap: **hayır, kaybetmezsin.** Nedenini anlamak için ÖSYM'nin yerleştirme mantığını bir kez görmek yeter — sistem sandığından çok daha basit çalışır.
|
||||
|
||||
## Sistem tek cümlede
|
||||
|
||||
ÖSYM önce **adayları** puana göre sıralar, sonra sıra sana gelince **senin listeni** yukarıdan aşağı okur ve boş kontenjan bulduğu ilk satıra seni yerleştirir. Hepsi bu.
|
||||
|
||||
```mermaid
|
||||
%% aria: Merkezi yerleştirme akış şeması: ÖSYM tüm adayları puana göre sıralar ve sıra puanı yüksekten düşüğe ilerler; sıra sana gelince tercih listen birinci satırdan başlayarak kontrol edilir, boş kontenjan bulunan ilk programa yerleşirsin ve alttaki satırlara hiç bakılmaz; 24 satırın hiçbirinde yer yoksa boşta kalırsın ama ek yerleştirme hakkın devam eder.
|
||||
%% altyazi: Sistem listene her zaman yukarıdan aşağı bakar; bir satıra yerleştiğin an alttaki satırlar devre dışı kalır.
|
||||
flowchart TD
|
||||
P("ÖSYM tüm adayları puana göre sıralar<br/>sıra, puanı yüksekten düşüğe ilerler") --> S("Sıra sana geldi:<br/>listen 1. satırdan okunmaya başlar")
|
||||
S --> T1{"1. tercihinde boş<br/>kontenjan var mı?"}
|
||||
T1 -- "evet" --> Y("Yerleştin<br/>alttaki satırlara hiç bakılmaz")
|
||||
T1 -- "hayır" --> T2{"2. tercihinde<br/>boş yer var mı?"}
|
||||
T2 -- "evet" --> Y
|
||||
T2 -- "hayır" --> D("aynı soru 24. satıra<br/>kadar tekrarlanır")
|
||||
D --> B{"24 satırın hiçbirinde<br/>yer kalmadı mı?"}
|
||||
B -- "boşta kaldın" --> E("Üzülme, bitmedi:<br/>ek yerleştirme hakkın saklı")
|
||||
|
||||
classDef garanti fill:#d1fae5,stroke:#10b981,color:#065f46
|
||||
classDef uyari fill:#fef3c7,stroke:#f59e0b,color:#92400e
|
||||
class Y garanti
|
||||
class E uyari
|
||||
```
|
||||
|
||||
## Bu mantıktan çıkan üç altın kural
|
||||
|
||||
**1) Üst sıradaki "hayal" tercihi alttakilere zarar veremez.** Sistem 1. tercihinde yer bulamazsa hiç duraksamadan 2.'ye geçer. Hayal bölümünü en üste yazmanın tek "maliyeti" bir satırdır; alttaki dengeli ve garanti tercihlerinin ihtimali kılını bile kıpırdatmaz. Bu yüzden listenin başı gönlüne göre, sonu aklına göre kurulur — dilim mantığının tamamı [tercih listesi rehberinde](/rehber/tercih-listesi-nasil-yapilir) var.
|
||||
|
||||
**2) Yerleştiğin satırın altı yok hükmündedir.** 3. tercihine yerleştiysen 4-24 arası hiç okunmaz. Bu yüzden listeyi *yerleşme ihtimaline göre değil, istek sırana göre* dizmelisin: en çok istediğin hep daha üstte durmalı. "Daha çok istediğim bölüm alttaydı, keşke" cümlesinin telafisi yoktur.
|
||||
|
||||
**3) Taban sıralaması yerleştirme bittikten sonra oluşur.** Bir programın tabanı, o programa **yerleşen son kişinin** sıralamasıdır. Yani tabanlar önceden ilan edilen bir eşik değil, o yılın adaylarının tercih davranışının sonucudur — bu yüzden yıldan yıla oynar. Detayı [tabanlar nasıl değişir](/rehber/taban-siralamalar-nasil-degisir) yazısında.
|
||||
|
||||
## Peki neden hâlâ "ölü tercih" diye bir şey var?
|
||||
|
||||
Sistemin yukarıdan aşağı okuması, sıralamanın çok üstünde kapatan bir programı listenin *sonuna* yazmayı anlamsızlaştırır: o satıra sıra geldiyse zaten üstteki 20 satırda yer bulamamışsındır; tabanı senden çok iyi olan o program sana hiç açılmayacaktır. Bu satırlar listede yer kaplayan boş kurşunlardır — tanımı ve temizleme yöntemi için [ölü tercih nedir](/rehber/olu-tercih-nedir) yazısına bak.
|
||||
|
||||
## Pratik sonuç
|
||||
|
||||
- Listenin başına puanın "yetmeyecek gibi" duran ama çok istediğin programları çekinmeden yaz.
|
||||
- Ortayı sıralamana yakın programlarla doldur, sona gerçekten razı olacağın garantileri koy.
|
||||
- Sıralamanla hangi dilime neyin girdiğini görmek için [tercih robotuna](/tercih-robotu) sıralamanı girmen yeterli.
|
||||
|
||||
Sistemi bilen aday, listeyi korkuyla değil stratejiyle kurar. Korku listesi hep aşağı yığılır; strateji listesi yukarıdan kazanır.
|
||||
47
content/rehber/obp-nedir-nasil-hesaplanir.md
Normal file
47
content/rehber/obp-nedir-nasil-hesaplanir.md
Normal file
@@ -0,0 +1,47 @@
|
||||
---
|
||||
baslik: OBP Nedir, Nasıl Hesaplanır? Diploma Notu Kaç Puan Getirir?
|
||||
aciklama: Ortaöğretim başarı puanının hesabı, yerleştirme puanına gerçek katkısı ve "geçen yıl yerleştiysen çarpan yarıya düşer" kuralı — sayılarla ve tek şemada.
|
||||
tarih: 2026-07-27
|
||||
---
|
||||
|
||||
Tercih döneminde en çok aranan hesap sorusu: *"Diploma notum yerleştirme puanıma kaç puan ekler?"* Cevap net bir formüle dayanır ve iki dakikada öğrenilir. Ama formülün ucunda, tercih stratejini doğrudan etkileyen bir kural saklı: **geçen yıl bir programa yerleşenlerin katkısı yarıya düşer.**
|
||||
|
||||
## Hesap üç adımda
|
||||
|
||||
Lise diploma notun 50-100 arasındadır. ÖSYM bunu 5 ile çarpar ve **OBP** (Ortaöğretim Başarı Puanı) 250-500 arasında bir değere dönüşür. Yerleştirme puanına eklenen katkı ise OBP'nin 0,12 ile çarpımıdır:
|
||||
|
||||
```mermaid
|
||||
%% aria: OBP hesap şeması: 50 ile 100 arasındaki diploma notu 5 ile çarpılarak 250 ile 500 arasında OBP değerine dönüşür; OBP 0,12 ile çarpılarak 30 ile 60 puan arasında bir katkı üretir ve ham YKS puanına eklenerek yerleştirme puanını oluşturur; geçen yıl merkezi yerleştirmeyle bir programa yerleştiysen çarpan 0,06'ya düşer ve katkı 15 ile 30 puan arasına iner.
|
||||
%% altyazi: Katkı aralığı 30-60 puandır; iki aday arasındaki gerçek fark ise çoğu zaman birkaç puandır çünkü herkes en az 30 alır.
|
||||
flowchart TD
|
||||
D("Diploma notun<br/>50 ile 100 arası") -- "5 ile çarpılır" --> O("OBP<br/>250 ile 500 arası")
|
||||
O -- "0,12 ile çarpılır" --> K("Yerleştirme puanına katkı<br/>30 ile 60 puan arası")
|
||||
K --> P("Ham YKS puanın + katkı<br/>= yerleştirme puanın")
|
||||
O -. "geçen yıl bir programa yerleştiysen<br/>çarpan 0,06 olur" .-> Z("Katkı yarıya düşer<br/>15 ile 30 puan arası")
|
||||
|
||||
classDef garanti fill:#d1fae5,stroke:#10b981,color:#065f46
|
||||
classDef hayal fill:#fee2e2,stroke:#ef4444,color:#991b1b
|
||||
class P garanti
|
||||
class Z hayal
|
||||
```
|
||||
|
||||
## Sayının doğru okuması: 60 değil, fark önemli
|
||||
|
||||
"60 puana kadar katkı" kulağa devasa gelir ama herkes **en az 30** alır (diploma notu 50 bile olsa). Yani seninle rakibin arasındaki gerçek OBP farkı en fazla 30 puandır ve diploma notları yakınsa bu fark birkaç puana iner. Diploma notu 85 olan bir aday 51 puan katkı alır; 95 olan 57 alır — arada sadece 6 puan vardır. OBP kimseyi tek başına bölüm atlatmaz; **eşit puan bölgesinde sıralamayı birkaç bin kaydırır.** Sıralamanın puandan neden daha önemli olduğu ayrı bir yazının konusu: [taban puan mı, sıralama mı?](/rehber/taban-puan-mi-siralama-mi)
|
||||
|
||||
## Kritik kural: çarpan neden yarıya düşer?
|
||||
|
||||
Geçen yıl merkezi yerleştirmeyle bir programa **yerleştirildiysen** — kayıt yaptırmamış olsan bile — bu yıl OBP çarpanın 0,12 yerine **0,06** uygulanır (kontenjansız açıköğretim programları bu kuralın dışındadır). Yani "bir yere yerleşeyim de garantim olsun, seneye yine denerim" planının görünmez bir bedeli vardır: gelecek yılki puanından 15-30 puan peşinen gider.
|
||||
|
||||
Bunun tercih listene çevirisi şudur: **yerleşsen bile gitmeyeceğin programı listene yazma.** Bu kuralın diğer bedelleriyle birlikte tamamı [tercih listesi rehberinde](/rehber/tercih-listesi-nasil-yapilir) anlatılıyor.
|
||||
|
||||
## Okul birincilerine ayrı kapı
|
||||
|
||||
Okul birincileri için üniversitelerde **ayrı bir kontenjan** ayrılır (2026'da yaklaşık 17 bin 862 kişilik). Okul birincisi, hem genel kontenjandan hem de okul birincisi kontenjanından değerlendirilir — hangisinden yerleşirse o geçerli olur. Ayrıca 2026'da şehit ve gazi yakınları ile 34 yaş üstü kadın adaylar için de özel kontenjanlar var; sonuç belgende bu kontenjanlardan birine hakkın görünüyorsa tercih ekranında ayrıca işaretlemeyi unutma.
|
||||
|
||||
## Özet
|
||||
|
||||
- OBP = diploma notu × 5; katkı = OBP × 0,12 → **30-60 puan arası.**
|
||||
- Herkes en az 30 aldığı için gerçek rekabet farkı küçüktür; asıl belirleyici sınav performansın ve sıralamandır.
|
||||
- Geçen yıl yerleştiysen çarpan yarıya düşer — istemediğin programı listeye yazmanın en somut cezası budur.
|
||||
- Sıralamanla nereye uzanabildiğini görmek için [tercih robotunu](/tercih-robotu) kullanabilirsin.
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
baslik: Ölü Tercih Nedir, Nasıl Anlaşılır? (Ve Nasıl Kaçınılır)
|
||||
aciklama: Listende hiç işe yaramayan satırlar olabilir. Ölü tercihin tanımı, en sık yapılan iki biçimi ve listeni bu tuzaktan temizlemenin yolu.
|
||||
aciklama: Listende hiç işe yaramayan satırlar olabilir. Ölü tercihin iki biçimi ve temizleme yolu — tek şemada.
|
||||
tarih: 2026-07-24
|
||||
---
|
||||
|
||||
@@ -8,6 +8,21 @@ tarih: 2026-07-24
|
||||
|
||||
## Ölü tercihin iki biçimi
|
||||
|
||||
```mermaid
|
||||
%% aria: Ölü tercih tanı şeması: her satır için sorulan soru, bu programa yerleşmenin gerçekçi olup olmadığıdır. Tabanı senden çok daha iyi olan bir program listenin sonundaysa ölü tercihtir — yeri listenin başındaki hayal bölgesidir. Aynı programın kopyası veya üstteki satırdan daha sıkı tabanlı bir program alta yazılmışsa yine ölüdür. Sağlıklı listede tabanlar yukarıdan aşağı giderek gevşer.
|
||||
%% altyazi: Hayal tercihinin yeri listenin başıdır; sona saklanan iddialı satır ölü tercihe dönüşür.
|
||||
flowchart TD
|
||||
S{"Bu satıra yerleşmem<br/>gerçekçi mi?"}
|
||||
S -- "tabanı çok sıkı<br/>ve listenin sonunda" --> O1("Ölü tercih:<br/>yeri baştaki hayal bölgesiydi")
|
||||
S -- "üsttekiyle aynı program<br/>ya da ondan daha sıkı" --> O2("Ölü tercih:<br/>sıra hatası, satırı taşı veya sil")
|
||||
S -- "tabanlar yukarıdan aşağı<br/>giderek gevşiyor" --> T("Sağlıklı satır:<br/>merdiven düzgün iner")
|
||||
|
||||
classDef hayal fill:#fee2e2,stroke:#ef4444,color:#991b1b
|
||||
classDef garanti fill:#d1fae5,stroke:#10b981,color:#065f46
|
||||
class O1,O2 hayal
|
||||
class T garanti
|
||||
```
|
||||
|
||||
**1) Sıralaması yetmeyecek programı listenin sonuna yazmak.**
|
||||
ÖSYM yerleştirme sistemi listeni yukarıdan aşağı okur ve seni *yerleşebildiğin en üst tercihe* koyar. Diyelim sıralaman 200 bin ve 15. sıraya tabanı 40 bin kapatan bir program yazdın. O programa yerleşebilmen için üstteki 14 tercihin de tutmaması VE tabanın 40 binden 200 bine gevşemesi gerekir — pratikte imkânsız. O satır ölüdür. Hayal tercihi meşrudur ama yeri listenin **başıdır**, sonu değil.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
baslik: TYT ile Önlisans Tercihi — Önlisans mı, Lisans mı?
|
||||
aciklama: 2 yıllıklar "yedek plan" değil, başlı başına bir rota. Önlisansın avantajları, DGS köprüsü ve TYT sıralamasıyla akıllı tercih kurmanın yolu.
|
||||
aciklama: 2 yıllıklar "yedek plan" değil, başlı başına bir rota. Önlisans mı lisans mı kararını tek şemada netleştir.
|
||||
tarih: 2026-07-17
|
||||
---
|
||||
|
||||
@@ -8,6 +8,22 @@ AYT'si istediği gibi gitmeyen ya da hedefi zaten uygulamalı bir meslek olan ad
|
||||
|
||||
## Önlisans ne zaman doğru tercih?
|
||||
|
||||
```mermaid
|
||||
%% aria: Önlisans mı lisans mı karar şeması: hedef meslek lisans diploması şart koşuyorsa lisans yoluna bakarsın. Sıralaman gitmekten mutlu olacağın bir lisansa yetiyorsa o yolu seçersin. Hedef meslek uygulamalıysa, TYT güçlü AYT zayıfsa ve DGS köprüsü planlıyorsan ya da bir yıl daha sınav istemiyorsan önlisans rotası güçlenir. İstemediğin lisansa sırf kazanmış olmak için gitmek de gereksiz yere önlisansa razı olmak da hatadır.
|
||||
%% altyazi: Doğru rota hedef mesleğe ve sıralama bandına göre değişir; ikisi de aynı 24'lük listede yan yana yazılabilir.
|
||||
flowchart TD
|
||||
S{"Hedefin ve sıralaman<br/>nereye işaret ediyor?"}
|
||||
S -- "meslek lisans diploması ister<br/>ya da sıralaman mutlu olacağın lisansa yeter" --> L("Lisans rotası:<br/>kendi puan türünün sıralamasıyla kur")
|
||||
S -- "uygulamalı meslek, DGS köprüsü<br/>ya da bir yıl daha sınav istemiyorsun" --> O("Önlisans rotası:<br/>TYT sıralamasıyla kur")
|
||||
L --> UYARI("İstemediğin lisansa<br/>sırf kazanmış olmak için gitme")
|
||||
O --> DGS("DGS planın varsa önce hedef lisansı,<br/>sonra ona köprü önlisansı seç")
|
||||
|
||||
classDef uyari fill:#fef3c7,stroke:#f59e0b,color:#92400e
|
||||
classDef garanti fill:#d1fae5,stroke:#10b981,color:#065f46
|
||||
class L,O garanti
|
||||
class UYARI,DGS uyari
|
||||
```
|
||||
|
||||
- **Hedef meslek uygulamalıysa.** [Bilgisayar Programcılığı](/bolum/bilgisayar-programciligi), [Anestezi](/bolum/anestezi), [İlk ve Acil Yardım](/bolum/ilk-ve-acil-yardim) gibi programlar 2 yılda alana özgü istihdama bağlanır.
|
||||
- **Lisansa köprü olarak.** DGS (Dikey Geçiş Sınavı) ile önlisans mezunları ilgili lisans bölümlerine geçebilir. "TYT'm iyi, AYT'm zayıf" profili için 2+ yıl planı — önlisans + DGS — düşük sıralamayla doğrudan zayıf bir lisansa gitmekten daha iyi sonuç verebilir.
|
||||
- **Bir yıl daha sınav istemiyorsan.** Tekrar hazırlanmak yerine alanda ilerlemek isteyene zaman kazandırır.
|
||||
|
||||
72
content/rehber/ozel-kosullu-programlar.md
Normal file
72
content/rehber/ozel-kosullu-programlar.md
Normal file
@@ -0,0 +1,72 @@
|
||||
---
|
||||
baslik: Özel Koşullu Programlar — Sağlık Raporu, Mülakat, Boy-Kilo ve Özel Yetenek
|
||||
aciklama: Bazı programlara sadece sıralaman yetmez. Kılavuzdaki koşul maddeleri, sağlık raporu isteyen bölümler ve özel yetenek sınavı — tek şemada.
|
||||
tarih: 2026-08-02
|
||||
---
|
||||
|
||||
Tercih tablosunda bazı program satırlarının yanında küçük madde numaraları görürsün. Bunlar süs değil: o programa yerleşmek için **sıralamanın dışında** sağlanması gereken koşulları anlatır. Sağlık kurulu raporu, boy-kilo ölçüsü, mülakat, güvenlik soruşturması, belirli bir lise türünden mezuniyet... Bu koşulu sağlayamayan bir aday yerleşse bile kaydı yapılmaz. Bu yazı, o satırları tercih etmeden önce nasıl okuyacağını anlatıyor.
|
||||
|
||||
## Bir programın önündeki üç kapı
|
||||
|
||||
```mermaid
|
||||
%% aria: Özel koşullu programlar şeması. Bir programa yerleşmenin önünde üç ayrı kapı olabilir. Birinci kapı sıralamadır: taban sıralaması ve varsa başarı sıralaması barajı. İkinci kapı kılavuzdaki koşul maddeleridir: sağlık kurulu raporu, boy ve kilo ölçüsü, mülakat, güvenlik soruşturması, belirli bir lise türünden mezun olma veya yaş sınırı gibi. Üçüncü kapı özel yetenek sınavıdır ve bu programlar merkezi yerleştirmeye dahil değildir; konservatuvar, spor bilimleri ve güzel sanatlar programlarına üniversitenin kendi düzenlediği sınavla girilir, tercih listesine yazılmaz. Bir programı listeye yazmadan önce koşul maddelerinin okunması ve sağlanamayan koşul varsa satırın yazılmaması gerekir; koşul sağlanmazsa yerleşme iptal edilir ve o yıl kaybedilir.
|
||||
%% altyazi: Sıralaman yetse bile koşul sağlanmıyorsa kayıt yapılmaz — madde numaralarını tercihten önce oku.
|
||||
flowchart TD
|
||||
P("Programa yerleşmek") --> K1{"1. Kapı: sıralama<br/>taban ve varsa<br/>başarı sıralaması barajı"}
|
||||
K1 -- "yetmiyor" --> R1("Satır hayal bloğunda<br/>ya da hiç yazılmaz")
|
||||
K1 -- "yetiyor" --> K2{"2. Kapı: kılavuzdaki<br/>koşul maddeleri"}
|
||||
K2 -- "sağlık raporu, boy-kilo,<br/>mülakat, lise türü..." --> D{"Koşulu<br/>sağlıyor musun?"}
|
||||
D -- "evet, belgeli" --> Y("Satır listeye yazılabilir")
|
||||
D -- "emin değilim" --> A("Önce belgeyi/ölçüyü doğrula<br/>sonra yaz")
|
||||
D -- "hayır" --> H("Yazma:<br/>yerleşsen de kayıt yapılmaz,<br/>yıl kaybedilir")
|
||||
K2 -- "koşul yok" --> Y
|
||||
P --> K3("3. Kapı: özel yetenek<br/>konservatuvar, spor bilimleri,<br/>güzel sanatlar<br/>merkezi yerleştirmeye dahil DEĞİL")
|
||||
|
||||
classDef uyari fill:#fef3c7,stroke:#f59e0b,color:#92400e
|
||||
classDef garanti fill:#d1fae5,stroke:#10b981,color:#065f46
|
||||
classDef hayal fill:#fee2e2,stroke:#ef4444,color:#991b1b
|
||||
class Y garanti
|
||||
class K1,K2,D,A,K3 uyari
|
||||
class H,R1 hayal
|
||||
```
|
||||
|
||||
## Kapı 1: Sıralama ve barajlar
|
||||
|
||||
Bazı bölümlere yerleşmek için taban sıralamasını geçmek yetmez; ayrıca **başarı sıralaması barajını** da geçmen gerekir. Tıp, diş hekimliği, eczacılık, hukuk, mimarlık, mühendislik ve öğretmenlik programlarında bu barajlar uygulanır ve barajın altındaki adaylar o programı **tercih edemez**. Güncel eşikler [baraj ve başarı sıralaması şartları](/rehber/siralama-sartlari-2026) yazısında.
|
||||
|
||||
## Kapı 2: Kılavuzdaki koşul maddeleri
|
||||
|
||||
ÖSYM kılavuzunda her program satırının yanındaki numaralar, kılavuzun **"Koşullar ve Açıklamalar"** bölümündeki maddelere karşılık gelir. En sık karşılaşılanlar:
|
||||
|
||||
- **Sağlık kurulu raporu.** Bazı programlar, tam teşekküllü bir hastaneden alınacak sağlık kurulu raporu ister; raporda "bu programda öğrenim görmesinde sakınca yoktur" ifadesi aranır. Denizcilik, pilotaj, itfaiyecilik, bazı sağlık ve teknik programlar bu grupta olabilir. Renk körlüğü, işitme, denge gibi belirli sağlık koşullarını kapsayan maddeler de vardır.
|
||||
- **Boy ve kilo ölçüsü.** [Pilotaj](/bolum/pilotaj) ve bazı güvenlik/denizcilik programlarında belirli boy ve kilo aralıkları aranır.
|
||||
- **Mülakat / güvenlik soruşturması.** Bazı programlarda merkezi yerleştirmenin ardından ek bir değerlendirme aşaması vardır.
|
||||
- **Belirli lise türünden mezuniyet.** Bazı önlisans programları yalnızca ilgili meslek lisesi mezunlarına, bazıları ise sınavsız/ek kontenjanla açıktır.
|
||||
- **Yaş sınırı.** Özellikle havacılık ve güvenlik alanındaki programlarda üst yaş sınırı bulunabilir.
|
||||
- **Yatılılık, üniforma, staj zorunluluğu.** Öğrencilik hayatını doğrudan etkileyen ama tercih anında atlanan maddelerdir.
|
||||
- **Ücretli/ikinci öğretim, uzaktan öğretim bilgisi.** Programın işleyişini değiştirir; koşul maddesinde belirtilir.
|
||||
|
||||
**Nasıl kontrol edilir?** Listeye yazmayı düşündüğün her program için: kılavuzda satırın yanındaki madde numaralarını not al, kılavuzun koşullar bölümünden o maddeleri **tek tek oku**. Bu iş program başına iki dakika sürer ve tercih döneminde yapılabilecek en yüksek getirili iştir.
|
||||
|
||||
Kritik uyarı: koşulu sağlamadığın hâlde yerleştiysen **kaydın yapılmaz ve yerleşme hakkın iptal edilir.** Bu durumda o yılki [ek yerleştirme](/rehber/ek-yerlestirme-2026) süreci dışında elinde bir şey kalmaz.
|
||||
|
||||
## Kapı 3: Özel yetenek sınavıyla öğrenci alan programlar
|
||||
|
||||
Konservatuvarlar, spor bilimleri fakülteleri ve güzel sanatlar programlarının önemli bir kısmı **merkezi yerleştirmeye dahil değildir.** Yani bu programlar tercih listene yazılmaz; üniversitelerin kendi düzenlediği özel yetenek sınavlarına ayrı ayrı başvurursun.
|
||||
|
||||
İşleyişi:
|
||||
|
||||
- Genellikle belirli bir **TYT puanı** şartı aranır; bu eşik üniversiteden üniversiteye ve programdan programa değişir.
|
||||
- Başvuru takvimini, sınav içeriğini ve değerlendirme formülünü **her üniversite kendi ilan eder.** Aynı hafta içinde birkaç okulun sınavına girmek mümkündür ama takvimler çakışabilir.
|
||||
- Yerleştirme, üniversitenin kendi hesapladığı yerleştirme puanına göre yapılır; bu puanda TYT puanı, özel yetenek sınavı sonucu ve bazen orta öğretim başarı puanı birlikte kullanılır.
|
||||
|
||||
Pratik sonuç: özel yetenek hedefliyorsan **iki paralel süreç** yürütürsün — bir yandan merkezi tercih listeni hazırlarsın, bir yandan özel yetenek sınavı başvurularını takip edersin. Bu ikisi birbirini engellemez.
|
||||
|
||||
## Listeye nasıl yansıtılır?
|
||||
|
||||
- Koşulunu **belgeyle doğruladığın** özel koşullu programları normal satır gibi, istek sırana göre diz.
|
||||
- Koşulundan emin olmadığın bir programı listeye yazmadan önce doğrula: sağlık raporu gerektiren bir programda "büyük ihtimalle uygun çıkarım" varsayımıyla ilerlemek risklidir.
|
||||
- Koşulunu sağlamadığın programı hiç yazma. Bu satır, [ölü tercihten](/rehber/olu-tercih-nedir) daha kötüdür: ölü tercih sadece yer kaplar, koşulu sağlanmayan tercih ise yerleşirsen yılını götürür.
|
||||
- Özel yetenek programlarını merkezi listene yazmaya çalışma; ayrı süreçtir.
|
||||
|
||||
Kılavuz koşullarını okuduktan sonra sıralamana hangi programların gerçekten açık olduğunu görmek için [tercih robotuna](/tercih-robotu) sıralamanı gir; listeyi kurarken [tercih listesi nasıl yapılır](/rehber/tercih-listesi-nasil-yapilir) yazısındaki hayal–dengeli–garanti dağılımını temel al.
|
||||
@@ -1,11 +1,29 @@
|
||||
---
|
||||
baslik: Baraj ve Başarı Sıralaması Şartları 2026 (Tıp, Hukuk, Mühendislik, Öğretmenlik...)
|
||||
aciklama: Bazı bölümleri istemek yetmez — YÖK'ün koyduğu başarı sıralaması şartını geçmek gerekir. Hangi bölümde hangi eşik var, tek tabloda.
|
||||
aciklama: Bazı bölümleri istemek yetmez — YÖK'ün başarı sıralaması şartı ile taban farkı tek şemada; hangi bölümde hangi eşik var, tabloda.
|
||||
tarih: 2026-07-18
|
||||
---
|
||||
|
||||
Bazı bölümler için puanın ve sıralaman "yetiyor" görünse bile, YÖK'ün belirlediği **başarı sıralaması şartını** sağlamıyorsan o bölümü tercih bile edemezsin. Bu şart taban sıralamasından farklıdır: taban, yerleşen son kişinin sıralamasıdır ve her yıl piyasada oluşur; **sıralama şartı** ise merkezî bir üst eşiktir — geçemeyen listeye yazamaz.
|
||||
|
||||
```mermaid
|
||||
%% aria: Sıralama şartı ile taban ayrımı şeması: önce hedef bölümün YÖK başarı sıralaması şartını geçip geçmediğine bakarsın. Şartı geçemiyorsan o bölümü listeye hiç yazamazsın ve alternatif rotaya yönelirsin. Şartı geçiyorsan bu yalnızca kapıyı açar; yerleşmek için hâlâ o programın o yılki taban sıralamasına bakman gerekir. Şartı geçmek yerleşmeyi garanti etmez.
|
||||
%% altyazi: Şart kapıyı açar veya kapatır; taban ise kapıdan girip giremeyeceğini söyler.
|
||||
flowchart TD
|
||||
H("Hedef bölümün<br/>sıralama şartı var mı?") --> S{"Şartı geçiyor musun?"}
|
||||
S -- "hayır" --> K("Listeye yazamazsın:<br/>alternatif rotaya yönel")
|
||||
S -- "evet" --> A("Kapı açık — ama bu yerleşmek değil")
|
||||
A --> T("Gözünü tabana çevir:<br/>kendi sıralamanın düştüğü bandı bul")
|
||||
T --> L("Gerçekçi satırları<br/>hayal / dengeli / garanti diz")
|
||||
|
||||
classDef hayal fill:#fee2e2,stroke:#ef4444,color:#991b1b
|
||||
classDef uyari fill:#fef3c7,stroke:#f59e0b,color:#92400e
|
||||
classDef garanti fill:#d1fae5,stroke:#10b981,color:#065f46
|
||||
class K hayal
|
||||
class A,T uyari
|
||||
class L garanti
|
||||
```
|
||||
|
||||
## 2026'da sıralama şartı olan başlıca bölümler
|
||||
|
||||
| Bölüm | Puan türü | Sıralama şartı |
|
||||
|
||||
@@ -1,11 +1,26 @@
|
||||
---
|
||||
baslik: Taban Puan mı, Başarı Sıralaması mı? Tercihte Hangisine Bakılır?
|
||||
aciklama: İki sayı da sonuç belgende yazıyor ama tercih listeni sadece biriyle kurmalısın. Neden sıralamanın kazandığının kısa ve net açıklaması.
|
||||
aciklama: İki sayı da sonuç belgende yazıyor ama tercih listeni sadece biriyle kurmalısın. Neden sıralamanın kazandığı tek şemada.
|
||||
tarih: 2026-07-21
|
||||
---
|
||||
|
||||
Kısa cevap: **başarı sıralamasına bakılır.** Taban puan, tabelada büyük yazan ama seni yanıltan sayıdır. Nedenini iki dakikada anlatalım.
|
||||
|
||||
```mermaid
|
||||
%% aria: Puan ile sıralama karşılaştırma şeması: aynı puan yıldan yıla farklı sıralamalara denk gelebilir çünkü sınav zorluğu puanı şişirir veya söndürür — bu yüzden puana bakarak liste kurmak yanıltır. Başarı sıralaması seni o yılın adaylarıyla kıyaslar ve yıllar arasında karşılaştırılabilir kalır; tercih listesi her zaman kendi puan türünün sıralamasıyla kurulur.
|
||||
%% altyazi: Puan her yıl kayar; sıralama aynı cetvelde kalır. Listeyi sıralama kolonuna göre kur.
|
||||
flowchart TD
|
||||
P("Sonuç belgende iki sayı var:<br/>puan ve başarı sıralaması") --> S{"Hangisiyle<br/>liste kuracaksın?"}
|
||||
S -- "puana bakarsan" --> Y("Yanıltır:<br/>450 puan bir yıl 30 bin,<br/>ertesi yıl 60 bin olabilir")
|
||||
S -- "sıralamaya bakarsan" --> G("Güvenilir:<br/>seni o yılın herkesıyla kıyaslar,<br/>yıllar arası karşılaştırılır")
|
||||
G --> L("Listeni kendi puan türünün<br/>sıralamasıyla kur")
|
||||
|
||||
classDef hayal fill:#fee2e2,stroke:#ef4444,color:#991b1b
|
||||
classDef garanti fill:#d1fae5,stroke:#10b981,color:#065f46
|
||||
class Y hayal
|
||||
class G,L garanti
|
||||
```
|
||||
|
||||
## Puan neden yanıltır?
|
||||
|
||||
YKS puanı, sınavın o yılki zorluğuna ve aday kitlesinin performansına göre şekillenir. Sınav kolay geçtiyse herkesin puanı şişer; zor geçtiyse söner. 450 puan bir yıl ilk 30 bine, ertesi yıl ilk 60 bine denk gelebilir. Yani "geçen yıl bu bölüm 450 ile kapatmış, benim puanım 455, güvendeyim" cümlesi hatalıdır — iki farklı yılın puanları aynı cetvelle ölçülmez.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
baslik: Taban Sıralamalar Yıldan Yıla Ne Kadar Değişir?
|
||||
aciklama: "Geçen yıl 90 bin kapatmış, ben 85 binim, garanti" demeden önce oku — tabanları oynatan beş mekanizma ve trend okumanın doğru yolu.
|
||||
aciklama: "Geçen yıl 90 bin kapatmış, ben 85 binim, garanti" demeden önce oku — tabanları oynatan mekanizmalar ve trend okuma yolu tek şemada.
|
||||
tarih: 2026-07-16
|
||||
---
|
||||
|
||||
@@ -8,6 +8,24 @@ Tercih listeni geçen yılın tabanlarıyla kuruyorsun ama yerleşmeni **bu yıl
|
||||
|
||||
## Tabanı oynatan mekanizmalar
|
||||
|
||||
```mermaid
|
||||
%% aria: Taban trendi okuma şeması: tek yılın tabanı fotoğraftır, dört yılın tabanı filmdir. Dört yıl üst üste dar banttaysa istikrarlı referanstır. Her yıl sıkılaşıyorsa geçen yılın tabanına güvenme ve pay bırak. Bir sıkı bir gevşek testere dişi sürü etkisidir, ortalamayı al. Bazı yıllar veri boşsa belirsizlik yüksektir; o programı dengeli değil hayal bölgesine koy.
|
||||
%% altyazi: Tek yıla bakan, testere dişinin tepesinden ya da dibinden ölçüm alır — en az 3-4 yıla bak.
|
||||
flowchart TD
|
||||
F("Tek yılın tabanı = fotoğraf<br/>dört yılın tabanı = film") --> T{"Film ne diyor?"}
|
||||
T -- "dört yıl dar bantta" --> I("İstikrarlı:<br/>güvenilir referans")
|
||||
T -- "her yıl sıkılaşıyor" --> S("Tek yönlü trend:<br/>pay bırak, geçen yıla güvenme")
|
||||
T -- "bir sıkı bir gevşek" --> TE("Testere dişi:<br/>ortalamayı al, uca değil")
|
||||
T -- "bazı yıllar veri yok" --> B("Belirsiz:<br/>dengeli değil, hayal bölgesine koy")
|
||||
|
||||
classDef garanti fill:#d1fae5,stroke:#10b981,color:#065f46
|
||||
classDef uyari fill:#fef3c7,stroke:#f59e0b,color:#92400e
|
||||
classDef hayal fill:#fee2e2,stroke:#ef4444,color:#991b1b
|
||||
class I garanti
|
||||
class S,TE uyari
|
||||
class B hayal
|
||||
```
|
||||
|
||||
**1) Kontenjan değişimi.** En mekanik etki: bir program kontenjanını 60'tan 90'a çıkarırsa taban gevşer; 90'dan 60'a inerse sıkılaşır. Bölüm sayfalarındaki kontenjan kolonunu yıllar arasında kıyaslamak bu yüzden önemli.
|
||||
|
||||
**2) Aday davranışı (sürü etkisi).** "Geçen yıl çok kapattı" diye herkes bir programdan kaçarsa taban gevşer; "gevşedi" diye ertesi yıl herkes geri dönerse tekrar sıkılaşır. Tabanlar bu yüzden testere dişi gibi salınabilir — tek yıla bakan, testere dişinin tepesinden ya da dibinden ölçüm alır.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
baslik: YKS Tercih Listesi Nasıl Yapılır? (2026 Rehberi)
|
||||
aciklama: 24 tercihlik listeyi dilim dilim kurmanın adım adım yöntemi — hangi tercihi kaçıncı sıraya, neden yazacağını bilerek.
|
||||
aciklama: 24 tercihlik listeyi dilim dilim kurmanın adım adım yöntemi — hayal, dengeli ve garanti bölgeleri tek şemada.
|
||||
tarih: 2026-07-25
|
||||
---
|
||||
|
||||
@@ -12,6 +12,27 @@ Tercihin tek sağlam ölçüsü **başarı sıralamandır**. Puanlar her yıl s
|
||||
|
||||
## Listenin üç bölgesi
|
||||
|
||||
```mermaid
|
||||
%% aria: Tercih listesi dilim şeması: 24 satırlık liste yukarıdan aşağı üç bölgeye ayrılır. İlk 4-8 satır hayal tercihleri — tabanı senden daha iyi kapatan ama çok istediğin programlar; tutmazsa alttakilere zarar vermez. Orta 8-12 satır dengeli tercihler — sıralamana yakın programlar, yerleşme ihtimalinin en yüksek olduğu bölge. Son 4-6 satır garanti tercihler — tabanı belirgin geniş programlar; buraya yalnızca gerçekten kayıt yaptıracağın programları yazarsın.
|
||||
%% altyazi: Üstte hayal (kırmızı), ortada dengeli (sarı), altta garanti (yeşil) — her satır istek sırana göre dizilir, yerleşme ihtimaline göre değil.
|
||||
flowchart TD
|
||||
L("24 tercihlik listen<br/>yukarıdan aşağı okunur") --> H("Hayal · ilk 4-8 satır<br/>çok istediğin, tabanı daha sıkı programlar")
|
||||
H --> D("Dengeli · orta 8-12 satır<br/>sıralamana yakın programlar")
|
||||
D --> G("Garanti · son 4-6 satır<br/>gerçekten razı olacağın programlar")
|
||||
H -. "tutmazsa sıradakine geçer<br/>alttakilere zarar vermez" .-> D
|
||||
G --> K{"Kayıt yaptırır mısın?"}
|
||||
K -- "evet, severek giderim" --> OK("Listeye yaz")
|
||||
K -- "hayır" --> YAZMA("Yazma:<br/>yerleşirsen ek hakkın yanar")
|
||||
|
||||
classDef hayal fill:#fee2e2,stroke:#ef4444,color:#991b1b
|
||||
classDef uyari fill:#fef3c7,stroke:#f59e0b,color:#92400e
|
||||
classDef garanti fill:#d1fae5,stroke:#10b981,color:#065f46
|
||||
class H hayal
|
||||
class D uyari
|
||||
class G,OK garanti
|
||||
class YAZMA hayal
|
||||
```
|
||||
|
||||
**1) Hayal tercihleri (ilk 4-8 sıra).** Taban sıralaması seninkinden *daha iyi* kapatan ama çok istediğin programlar. "Sıralamam yetmez, boşa gider" korkusu yersizdir: bir programı üst sıraya yazmak, alttaki tercihlere yerleşme ihtimalini **azaltmaz**. ÖSYM sistemi yukarıdan aşağı bakar; hayal tutmazsa sıradakine geçer. Tabanlar her yıl oynar — geçen yıl 5 bin kapatmış bir bölüm bu yıl 8 bine gevşeyebilir.
|
||||
|
||||
**2) Dengeli tercihler (orta blok, 8-12 sıra).** Taban sıralaması senin sıralamana yakın programlar. Listenin kalbi burasıdır; yerleşme ihtimalinin en yüksek olduğu bölge. Aynı bölümün farklı şehirlerdeki/üniversitelerdeki versiyonlarını sıralama sırasıyla diz.
|
||||
|
||||
59
content/rehber/universite-kayit-rehberi-2026.md
Normal file
59
content/rehber/universite-kayit-rehberi-2026.md
Normal file
@@ -0,0 +1,59 @@
|
||||
---
|
||||
baslik: Üniversite Kaydı Nasıl Yapılır? 2026 e-Kayıt ve Belge Rehberi
|
||||
aciklama: Yerleştin, tebrikler — şimdi kaydını kaçırma. e-Devlet'ten 5 dakikada e-kayıt, şahsen kayıtta istenen belgeler ve "kayıt yaptırmazsam ne olur" sorusunun net cevabı.
|
||||
tarih: 2026-07-28
|
||||
---
|
||||
|
||||
Yerleştirme sonuçları açıklandıktan sonra en çok aranan soru değişir: "nereye yerleştim"in yerini **"kayıt nasıl yapılır, son gün ne zaman?"** alır. 2026'da kayıtlar **24-28 Ağustos** tarihleri arasında; e-Devlet üzerinden elektronik kayıt ise **24-26 Ağustos** günlerinde yapılabiliyor. Takvimin tamamı için [2026 tercih takvimi](/rehber/yks-tercih-takvimi-2026) yazısına bakabilirsin.
|
||||
|
||||
## Önce e-kayıt dene: belge yok, yol yok, sıra yok
|
||||
|
||||
Üniversitelerin büyük bölümü e-Devlet üzerinden **e-kayıt** kabul ediyor. e-Devlet'e girip *"Üniversite E-Kayıt"* hizmetini açtığında yerleştiğin program listeleniyorsa birkaç onayla kaydın tamamlanır — diploma, fotoğraf, şehirlerarası yolculuk gerekmez. Kayıt belgeni yine e-Devlet'ten barkodlu indirebilirsin.
|
||||
|
||||
```mermaid
|
||||
%% aria: Üniversite kayıt akış şeması: bir programa yerleştikten sonra kayıt yaptırmaya karar verirsen önce e-Devlet'te e-kayıt seçeneğini kontrol edersin; açıksa 24-26 Ağustos arasında belgesiz ve sırasız e-kayıt yaparsın, kapalıysa 24-28 Ağustos arasında belgelerinle üniversiteye giderek şahsen kayıt olursun; iki yol da kaydını tamamlar. Kayıt yaptırmazsan bu yılki hakkın yanar, yerleştiğin için ek yerleştirmeye giremezsin ve gelecek yıl OBP katkın yarıya düşer.
|
||||
%% altyazi: e-kayıt penceresi şahsen kayıttan iki gün önce kapanır; önce e-kaydı dene, olmuyorsa belgelerle yola çık.
|
||||
flowchart TD
|
||||
S("Sonuçlar açıklandı:<br/>bir programa yerleştin") --> K{"Kayıt yaptıracak mısın?"}
|
||||
K -- "evet" --> E{"e-Devlet'te e-kayıt<br/>seçeneğin açık mı?"}
|
||||
E -- "evet" --> E1("24-26 Ağustos: e-kayıt<br/>belge yok, sıra yok, 5 dakika")
|
||||
E -- "hayır" --> E2("24-28 Ağustos: üniversitede şahsen kayıt<br/>belgelerini yanına al")
|
||||
E1 --> T("Kaydın tamam,<br/>artık öğrencisin")
|
||||
E2 --> T
|
||||
K -- "hayır" --> R("Bu yılki hakkın yanar:<br/>ek yerleştirmeye giremezsin,<br/>gelecek yıl OBP katkın yarıya düşer")
|
||||
|
||||
classDef garanti fill:#d1fae5,stroke:#10b981,color:#065f46
|
||||
classDef hayal fill:#fee2e2,stroke:#ef4444,color:#991b1b
|
||||
class T garanti
|
||||
class R hayal
|
||||
```
|
||||
|
||||
## Şahsen kayıtta istenen belgeler
|
||||
|
||||
Üniversiteler kendi listesini yayımlar; standart çekirdek şudur:
|
||||
|
||||
- **Lise diploması** (aslı) ya da yeni mezunlar için mezuniyet belgesi
|
||||
- **ÖSYS yerleştirme sonuç belgesi** (internet çıktısı yeterli)
|
||||
- Nüfus cüzdanı / T.C. kimlik kartı ve fotokopisi
|
||||
- Vesikalık fotoğraf (genelde 4-6 adet)
|
||||
- Bazı programlar için ek koşul belgeleri: sağlık raporu, boy-kilo şartı, mülakat sonucu gibi — bunlar kılavuzda programın yanındaki koşul numaralarında yazar
|
||||
|
||||
İkinci öğretim ya da vakıf programına yerleştiysen ilk taksit/öğrenim ücreti dekontu da istenir. Gitmeden önce üniversitenin kayıt sayfasını mutlaka aç; eksik belge, kayıt döneminin en klasik zaman kaybıdır.
|
||||
|
||||
## "Kayıt yaptırmazsam ne olur?"
|
||||
|
||||
Bu sorunun cevabı üç maddedir ve üçü de önemlidir:
|
||||
|
||||
1. **Bu yılki hakkın yanar.** Yerleştiğin programa sonradan "vazgeçtim, geleyim" diyemezsin.
|
||||
2. **Ek yerleştirmeye giremezsin.** Kayıt yaptırmasan bile *yerleştirilmiş* sayılırsın ve [ek yerleştirme](/rehber/ek-yerlestirme-2026) kapısı sana kapanır.
|
||||
3. **Gelecek yıl OBP çarpanın yarıya düşer.** Seneye tekrar sınava girersen diploma notu katkın 0,12 yerine 0,06 ile hesaplanır — [OBP yazısında](/rehber/obp-nedir-nasil-hesaplanir) sayılarla anlattık.
|
||||
|
||||
Bu üç maddenin tek bir tercih dersi var: **kayıt yaptırmayacağın programı listene hiç yazma.** Liste kurarken bu süzgeci nasıl uygulayacağın [tercih listesi rehberinde](/rehber/tercih-listesi-nasil-yapilir).
|
||||
|
||||
## Kayıt sonrası küçük ama önemli işler
|
||||
|
||||
- Ders kaydı / oryantasyon tarihlerini üniversitenin öğrenci işleri sayfasından not al; kayıt olmak çoğu üniversitede ders seçimini otomatik yapmaz.
|
||||
- Yurt başvurusu (KYK) ayrı bir takvimle yürür — kayıt haftasını beklemeden başvuru dönemini takip et.
|
||||
- Öğrenci belgeni e-Devlet'ten alabilirsin; indirim ve burs başvurularının çoğu bunu ister.
|
||||
|
||||
Yerleşmek maratonun bitişi, kayıt ise ipi göğüslemek. Son güne bırakma — e-kayıt penceresi, şahsen kayıttan **iki gün önce** kapanıyor.
|
||||
60
content/rehber/veliler-icin-tercih-rehberi.md
Normal file
60
content/rehber/veliler-icin-tercih-rehberi.md
Normal file
@@ -0,0 +1,60 @@
|
||||
---
|
||||
baslik: Veliler İçin Tercih Rehberi — Nerede Yardım Etmeli, Nerede Geri Durmalı?
|
||||
aciklama: Tercih listesini kim doldurmalı, veli hangi konuda gerçekten faydalı olur? Görev dağılımı ve o meşhur anlaşmazlık — tek şemada.
|
||||
tarih: 2026-08-02
|
||||
---
|
||||
|
||||
Tercih dönemi evde iki farklı endişenin çarpıştığı iki haftadır. Öğrenci "istediğim bölümü okuyamayacağım" diye endişelenir; veli "yanlış karar verip yıllarını kaybedecek" diye. İkisi de haklı bir yerden bakıyor, ama aynı işi yapmaya çalıştıklarında liste bozulur. Bu yazı, **hangi işin kimde olduğunu** ayırıyor.
|
||||
|
||||
## Görev dağılımı
|
||||
|
||||
```mermaid
|
||||
%% aria: Veli ve öğrenci görev dağılımı şeması. Tercih sürecindeki işler üçe ayrılır. Öğrencinin kararı: hangi bölümü okumak istediği, listenin sırası ve hayal tercihleri. Velinin gerçek katkısı: bütçe gerçeği, barınma ve yurt araştırması, şehir güvenliği ve ulaşım, ailedeki ve çevredeki meslek sahipleriyle görüşme ayarlamak. Ortak karar: bütçenin tutmadığı satırların listeden çıkarılması ve nihai listenin birlikte gözden geçirilmesi. Veliye düşmeyen iş: listeyi öğrencinin yerine doldurmak veya sıralamayı değiştirmek, çünkü dört yıl okulu okuyacak olan öğrencidir.
|
||||
%% altyazi: Bölüm kararı öğrencinin, bütçe ve barınma gerçeği velinin; liste ikisinin birlikte okuduğu son metin.
|
||||
flowchart TD
|
||||
T("Tercih süreci") --> O("ÖĞRENCİDE:<br/>hangi bölüm,<br/>listenin sırası,<br/>hayal tercihleri")
|
||||
T --> V("VELİDE:<br/>bütçe gerçeği,<br/>yurt ve barınma,<br/>şehir ve ulaşım,<br/>meslek sahibiyle görüşme")
|
||||
T --> Y("VELİYE DÜŞMEYEN:<br/>listeyi doldurmak,<br/>sıralamayı değiştirmek")
|
||||
O --> B{"Bütçe bu satırı<br/>4 yıl taşır mı?"}
|
||||
V --> B
|
||||
B -- "evet" --> L("Satır listede kalır")
|
||||
B -- "hayır" --> C("Birlikte konuşulur,<br/>satır çıkarılır<br/>veya burslu eşdeğeri aranır")
|
||||
Y --> R("Sonuç: öğrenci sahiplenmez,<br/>ilk zorlukta okul bırakılır")
|
||||
|
||||
classDef uyari fill:#fef3c7,stroke:#f59e0b,color:#92400e
|
||||
classDef garanti fill:#d1fae5,stroke:#10b981,color:#065f46
|
||||
classDef hayal fill:#fee2e2,stroke:#ef4444,color:#991b1b
|
||||
class O,V,L garanti
|
||||
class B,C uyari
|
||||
class Y,R hayal
|
||||
```
|
||||
|
||||
## Velinin gerçekten fark yarattığı dört alan
|
||||
|
||||
Bölüm seçiminde ısrar etmek yerine, bu dört başlıkta veli katkısı doğrudan işe yarar:
|
||||
|
||||
1. **Bütçe gerçeğini net söylemek.** "Bakarız, hallederiz" en riskli cümledir. Vakıf üniversitelerinde indirimli programların dört-altı yıllık toplam maliyetini, devlet üniversitelerinde ise şehrin kira ve yaşam giderini **listeyi vermeden önce** açıkça konuşun. Yerleştikten sonra ortaya çıkan "ödeyemeyiz" durumu bir yıl kaybettirir. [Devlet mi vakıf mı](/rehber/devlet-mi-vakif-mi) yazısı burs varyantlarının nasıl okunacağını anlatıyor.
|
||||
2. **Barınmayı önceden araştırmak.** Yurt kapasitesi, öğrenci evi kiraları ve kampüsün şehir merkezine uzaklığı velinin rahat araştırabileceği, öğrencinin ise tercih telaşında atladığı konulardır. [Yurt, burs ve yıllık maliyet](/rehber/yurt-burs-ve-yillik-maliyet) yazısındaki kalemleri şehir şehir doldurmak iyi bir veli işidir.
|
||||
3. **Doğru insanla konuşturmak.** "Bence doktor ol" demek yerine, çevrenizdeki bir hekimle, mühendisle, öğretmenle yarım saatlik bir görüşme ayarlamak on kat etkilidir. Öğrenci mesleği sizden değil, o mesleği yapan birinden duysun.
|
||||
4. **Süreci takip etmek.** Tercih işlemi tarihleri, kayıt belgeleri, e-Devlet adımları — bunlar sıkıcı ama kritik. [Tercih takvimi](/rehber/yks-tercih-takvimi-2026) ve [kayıt rehberi](/rehber/universite-kayit-rehberi-2026) yazılarındaki tarihleri takvime işlemek tam bir veli görevidir.
|
||||
|
||||
## Listeyi neden öğrenci doldurmalı?
|
||||
|
||||
Çünkü dört yıl o sıraları okuyacak olan o. Bunun ötesinde pratik bir sebep de var: **ÖSYM seni yerleşebildiğin en üst tercihe koyar.** Yani listenin sırası bir istek beyanıdır, taktik değil. Öğrenci gerçekten istediği sırayı yazmazsa, sistem ona istemediği bir yeri verir ve bu tamamen kendi listesinden kaynaklanır. [Merkezi yerleştirme nasıl çalışır](/rehber/merkezi-yerlestirme-nasil-calisir) yazısı bu mantığı adım adım gösteriyor.
|
||||
|
||||
Velinin listeyi "düzeltmesi" genelde şuraya varır: üst sıralara ailenin prestijli bulduğu bölümler girer, öğrencinin gerçek isteği aşağı iner. Sıralama tutarsa öğrenci sahiplenmediği bir bölümde başlar; ilk dönem sonunda devamsızlık, sonraki yıl ise ya yeniden sınav ya da okulu bırakma gelir. Kazanılan bir şey yoktur.
|
||||
|
||||
## Anlaşmazlık çıktığında: üç adımlı yol
|
||||
|
||||
Öğrenci bir bölümü çok istiyor, siz riskli buluyorsunuz. Tartışmak yerine:
|
||||
|
||||
- **Adım 1 — İddiaları yazıya dökün.** "Bu bölüm iş bulamaz" ya da "bu bölüm harika" cümlelerinin ikisi de kanıtsız. İkiniz de iddianızı somut bir kaynağa dayandırın: ders katalogu, akreditasyon, mezunların gittiği yerler. [Bir bölümün iş imkânını nasıl araştırırsın](/rehber/bolumun-is-imkanini-nasil-arastirirsin) yazısı bunun yöntemini veriyor.
|
||||
- **Adım 2 — Listede ikisine de yer açın.** 24 satırlık liste tam da bunun için var. Öğrencinin çok istediği bölüm üst bloğa, sizin daha güvenli bulduğunuz alternatifler alt bloğa girebilir. İkisi çatışmaz, sıralanır. [Tercih listesi nasıl yapılır](/rehber/tercih-listesi-nasil-yapilir) yazısındaki hayal–dengeli–garanti dağılımı bu pazarlığın zeminidir.
|
||||
- **Adım 3 — Kararı öğrenciye bırakın, gerekçenizi bir kez söyleyin.** Bir kez. Tekrar etmek ikna etmez, sadece kararı duygusallaştırır.
|
||||
|
||||
## Son iki hatırlatma
|
||||
|
||||
- **Kendi dönemini referans almayın.** Yirmi yıl önce güçlü olan bir bölüm bugün öyle olmayabilir, tersi de doğru. Tabanlar ve müfredatlar değişti; karar bugünün verisiyle verilmeli.
|
||||
- **Boş satır bırakmayın, ama zorla da doldurmayın.** Kayıt yaptırmayacağınız bir programı "garanti olsun" diye yazmak, [ölü tercihten](/rehber/olu-tercih-nedir) daha kötüdür: yerleşirseniz ek yerleştirme hakkı da yanar.
|
||||
|
||||
Öğrencinizin sıralamasıyla hangi bölümlerin ve şehirlerin gerçekten masada olduğunu birlikte görmek için [tercih robotuna](/tercih-robotu) sıralamayı girip listeyi ekranda yan yana inceleyebilirsiniz.
|
||||
79
content/rehber/yatay-gecis-cap-yandal.md
Normal file
79
content/rehber/yatay-gecis-cap-yandal.md
Normal file
@@ -0,0 +1,79 @@
|
||||
---
|
||||
baslik: İstemediğim Bölüme Yerleşirsem? Yatay Geçiş, ÇAP ve Yandal Gerçekte Nasıl İşler?
|
||||
aciklama: Sonra yatay geçerim cümlesinin arkasında ne var? Üniversiteye başladıktan sonraki dört yol ve gerçek koşulları — tek şemada.
|
||||
tarih: 2026-07-30
|
||||
---
|
||||
|
||||
Tercih döneminde en çok kurulan cümlelerden biri: "Şimdilik buraya yazayım, sonra yatay geçerim." Bu cümle tamamen yanlış değil — üniversiteye başladıktan sonra gerçekten birkaç çıkış yolu var. Ama her birinin koşulu, takvimi ve kontenjanı var. Bu yazı, o yolları **planlanabilir** hâle getiriyor: hangisi neye bağlı, hangisi ne zaman açılıyor.
|
||||
|
||||
## Başladıktan sonraki dört yol
|
||||
|
||||
```mermaid
|
||||
%% aria: Üniversiteye başladıktan sonraki geçiş yolları şeması. Kayıtlı olduğun bölümden memnun değilsen dört yol vardır. Birincisi merkezi yerleştirme puanıyla yatay geçiş, kısaca Ek Madde 1: kayıt olduğun yıldaki YKS puanın hedef programın aynı yıldaki taban puanına eşit veya ondan yüksekse başvurabilirsin, not ortalaması şartı aranmaz ve bu haktan yalnızca bir kez yararlanılır. İkincisi not ortalamasıyla yatay geçiş: genellikle lisansta üçüncü ve beşinci yarıyıl başında, eşdeğer programlara, kontenjan dahilinde yapılır ve not ortalaması şartı vardır. Üçüncüsü çift anadal ve yandal: mevcut bölümü bırakmadan ikinci bir programın derslerini almaktır, not ortalaması ve sınıf başarı sırası şartı aranır. Dördüncüsü yeniden sınava girmektir; bu her zaman açık olan yoldur ama bir yıl maliyeti vardır. Hiçbir yolun garanti olmadığı için tercih listesi bu yollara güvenilerek kurulmamalıdır.
|
||||
%% altyazi: Dört yol da gerçek, hiçbiri garanti değil — liste bu yolların üstüne kurulmaz.
|
||||
flowchart TD
|
||||
A("Bölümden memnun değilim") --> Y1{"Kayıt yılındaki YKS puanın<br/>hedef programın o yılki<br/>tabanına yetiyor mu?"}
|
||||
Y1 -- "evet" --> E1("Ek Madde 1<br/>merkezi puanla yatay geçiş<br/>not şartı yok, 1 kez")
|
||||
Y1 -- "hayır" --> Y2{"Not ortalaman<br/>yüksek mi?"}
|
||||
Y2 -- "evet, bölümü bırakacağım" --> E2("Not ortalamasıyla yatay geçiş<br/>3. ve 5. yarıyıl<br/>kontenjan sınırlı")
|
||||
Y2 -- "evet, bölümü bırakmayacağım" --> E3("Çift anadal / yandal<br/>ikinci diploma veya sertifika")
|
||||
Y2 -- "hayır" --> E4("Yeniden YKS<br/>her zaman açık,<br/>1 yıl maliyeti var")
|
||||
|
||||
classDef uyari fill:#fef3c7,stroke:#f59e0b,color:#92400e
|
||||
classDef garanti fill:#d1fae5,stroke:#10b981,color:#065f46
|
||||
classDef hayal fill:#fee2e2,stroke:#ef4444,color:#991b1b
|
||||
class E1,E3 garanti
|
||||
class Y1,Y2,E2 uyari
|
||||
class E4 hayal
|
||||
```
|
||||
|
||||
## 1) Merkezi yerleştirme puanıyla yatay geçiş (Ek Madde 1)
|
||||
|
||||
En çok merak edilen ve en yanlış bilinen yol. Mantığı basit: **kayıt olduğun yıldaki YKS puanın**, geçmek istediğin programın **aynı yıldaki taban puanına** eşit ya da ondan yüksekse başvurabilirsin.
|
||||
|
||||
Bilmen gereken çerçeve:
|
||||
|
||||
- **Not ortalaması şartı aranmaz.** Değerlendirmede yalnızca yerleştiğin yıldaki merkezi yerleştirme puanların dikkate alınır.
|
||||
- **Puan türü tutmalı.** Hedef programın puan türünde bir puanın olmalı ve o puan hedefin tabanını geçmeli.
|
||||
- **Bu haktan bir kez yararlanılır.**
|
||||
- **Her üniversite kendi duyurusunu yayımlar.** Başvuru takvimi, istenen belgeler ve varsa ek koşullar okulun öğrenci işleri sayfasında ilan edilir. "Ek Madde 1" veya "merkezi yerleştirme puanıyla yatay geçiş" başlığıyla ararsın.
|
||||
|
||||
Pratik sonuç: bu yol, **puanı yettiği hâlde listeyi yanlış kurduğu için aşağı bir programa düşenler** için gerçek bir ikinci şanstır. Puanı zaten yetmeyenler için bir şey değiştirmez. Yani "sonra yatay geçerim" cümlesi, ancak puanın hedefe zaten yetiyorsa anlamlıdır — ki o durumda en baştan doğru listeyi kurmak çok daha kolaydı. [Tercih listesi nasıl yapılır](/rehber/tercih-listesi-nasil-yapilir) yazısı bu hatayı baştan önlemek için var.
|
||||
|
||||
## 2) Not ortalamasıyla yatay geçiş
|
||||
|
||||
Klasik yatay geçiş. Genel çerçevesi şöyle: lisans programlarında **üçüncü ve beşinci yarıyıl** başında, önlisansta ise ikinci ve üçüncü yarıyılda, **eşdeğer programlar** arasında, **kontenjan dahilinde** yapılır. Not ortalaması şartı vardır ve başvurular genel not ortalamasına göre sıralanır.
|
||||
|
||||
Kritik farklar:
|
||||
|
||||
- **Kurum içi geçiş genelde daha kolaydır.** Üniversiteden memnunsan ama bölümden değilsen, aynı okul içinde geçiş şartları çoğunlukla daha esnektir.
|
||||
- **Kontenjan azdır.** Popüler programlara yatay geçiş kontenjanı bazen tek hanelidir; başvuranlar arasında en yüksek ortalamalar kazanır.
|
||||
- **Ders intibakı sürprizli olabilir.** Geçtiğin bölümdeki derslerin hangilerinden muaf sayılacağına ilgili fakültenin muafiyet ve intibak komisyonu karar verir. Muaf sayılmayan dersler mezuniyetini uzatabilir.
|
||||
- **Hazırlık sınıfı yarıyıl sayılmaz.** Hazırlıkta yatay geçiş yapılmaz; en az bir yarıyıl lisans eğitimi tamamlaman gerekir. [Hazırlık sınıfı ve öğretim dili](/rehber/hazirlik-sinifi-ve-ogretim-dili) yazısında hazırlığın süreye etkisi anlatılıyor.
|
||||
|
||||
## 3) Çift anadal (ÇAP) ve yandal
|
||||
|
||||
Bunlar "kaçış" değil, **ekleme** yollarıdır: mevcut bölümünü bırakmadan ikinci bir programın derslerini alırsın.
|
||||
|
||||
- **Çift anadal:** İki ayrı diplomayla mezun olursun. Genellikle belirli bir genel not ortalaması ve kendi sınıfında belirli bir başarı dilimi şartı aranır; iki programın toplam ders yükü ciddi bir tempodur.
|
||||
- **Yandal:** Diploma değil, sertifika alırsın. Şartları çift anadaldan hafiftir, ders yükü daha azdır.
|
||||
|
||||
Eşik değerler ve hangi bölümler arası açıldığı **üniversitenin kendi yönetmeliğinde** yazar ve okuldan okula değişir. Kararı verirken şuna bak: [İşletme](/bolum/isletme) okuyup [Bilgisayar Mühendisliği](/bolum/bilgisayar-muhendisligi) ÇAP'ı yapmak gibi kombinasyonlar ancak o üniversitede açıksa mümkündür. Tercih aşamasında merak ediyorsan üniversitenin "çift anadal programları" listesine bak — bu, geniş üniversitelerin sessiz avantajıdır.
|
||||
|
||||
## 4) Yeniden sınava girmek
|
||||
|
||||
Her zaman açık olan yol. Ama şu üç şeyi baştan bil:
|
||||
|
||||
- Bir yıl maliyeti vardır ve bu yıl geri gelmez.
|
||||
- Bir yükseköğretim programına kayıtlıysan, ertesi yıl yerleştiğinde eski kaydın silinir; ayrıca kayıtlı olmanın OBP ve bazı programların koşulları açısından sonuçlarını [tercih takvimi](/rehber/yks-tercih-takvimi-2026) ve kılavuz üzerinden kontrol etmelisin.
|
||||
- Sıralamanın ikinci yılda otomatik olarak yükseleceği garantisi yoktur.
|
||||
|
||||
[Yerleşemedim ya da istemediğim bölüme yerleştim](/rehber/yerlesemedim-ne-yapmaliyim) yazısı bu kararı bütün seçenekleriyle birlikte ele alıyor.
|
||||
|
||||
## Peki liste kurulurken bu yollar nasıl hesaba katılır?
|
||||
|
||||
Tek cümleyle: **hesaba katılmaz.** Listeyi, "buraya yerleşirsem burada okurum" varsayımıyla kur. Yatay geçiş, ÇAP ve yeniden sınav, listeyi gevşetmek için değil, iş ters giderse elinde kalan yollar için vardır.
|
||||
|
||||
Somut kural: kayıt yaptırmayacağın bir programı "nasıl olsa geçerim" diye listeye yazma — bu tam olarak [ölü tercih](/rehber/olu-tercih-nedir) mantığındaki hatadır ve yerleşirsen ek yerleştirme hakkın da yanar.
|
||||
|
||||
Sıralamanın gerçekten nereye yettiğini görüp listeyi baştan doğru kurmak için [tercih robotuna](/tercih-robotu) sıralamanı gir; hayal, dengeli ve garanti bloklarını tek ekranda görürsün.
|
||||
55
content/rehber/yerlesemedim-ne-yapmaliyim.md
Normal file
55
content/rehber/yerlesemedim-ne-yapmaliyim.md
Normal file
@@ -0,0 +1,55 @@
|
||||
---
|
||||
baslik: Yerleşemedim ya da İstemediğim Bölüme Yerleştim — Şimdi Ne Olacak?
|
||||
aciklama: Sonuç ekranı istediğin gibi çıkmadıysa önünde hâlâ üç yol var: ek yerleştirme, yatay geçiş ve yeniden deneme. Hangi durumda hangisi mantıklı, tek şemada.
|
||||
tarih: 2026-08-01
|
||||
---
|
||||
|
||||
Sonuçlar açıklandığında iki tür hayal kırıklığı yaşanır: hiçbir tercihe yerleşememek ya da listenin sonlarındaki "keşke yazmasaydım" satırına yerleşmek. İkisi de yolun sonu değil — ama ikisinin kuralları **birbirinden çok farklı** ve yanlış hamle, doğru kapıyı kapatabiliyor.
|
||||
|
||||
## Önce durumunu netleştir
|
||||
|
||||
```mermaid
|
||||
%% aria: Yerleşememe durumunda karar şeması: sonuç ekranında hiçbir tercihe yerleşemediysen ek yerleştirmeye başvurabilirsin ve boş kontenjanlar için yeni bir 24 tercihlik liste yaparsın; istemediğin bir programa yerleştiysen iki yol vardır: kayıt yaptırırsan birinci sınıfta merkezi yerleştirme puanınla yatay geçiş kapısını araştırabilirsin, kayıt yaptırmazsan yerleşmiş sayıldığın için ek yerleştirmeye giremezsin ve kalan tek yol gelecek yıl yeniden sınava girmektir.
|
||||
%% altyazi: Kritik ayrım: "yerleşemedim" ek yerleştirme kapısını açar; "yerleştim ama gitmedim" o kapıyı kapatır.
|
||||
flowchart TD
|
||||
S{"Sonuç ekranında<br/>ne yazıyor?"}
|
||||
S -- "hiçbir tercihe yerleşemedin" --> E("Ek yerleştirme hakkın var:<br/>boş kontenjanlar için yeni bir liste")
|
||||
E --> E2("Eylülde ÖSYM kılavuzu yayımlar,<br/>yeniden 24 tercih yaparsın")
|
||||
S -- "istemediğin bir programa yerleştin" --> K{"Kayıt yaptıracak mısın?"}
|
||||
K -- "evet" --> Y("Kayıt ol; 1. sınıfta merkezi puanla<br/>yatay geçiş kapısını araştır")
|
||||
K -- "hayır" --> R("Yerleşmiş sayılırsın:<br/>ek yerleştirmeye giremezsin,<br/>kalan yol gelecek yıl yeniden YKS")
|
||||
|
||||
classDef uyari fill:#fef3c7,stroke:#f59e0b,color:#92400e
|
||||
classDef garanti fill:#d1fae5,stroke:#10b981,color:#065f46
|
||||
classDef hayal fill:#fee2e2,stroke:#ef4444,color:#991b1b
|
||||
class E,E2 uyari
|
||||
class Y garanti
|
||||
class R hayal
|
||||
```
|
||||
|
||||
## Yol 1: Ek yerleştirme — boşta kalanların ikinci şansı
|
||||
|
||||
İlk yerleştirmede **hiçbir programa yerleşemediysen** ek yerleştirmeye başvurabilirsin. Kayıt dönemi bitince boş kalan ve kayıt yaptırılmadığı için boşalan kontenjanlar ÖSYM'nin ek kılavuzunda ilan edilir; yeniden en fazla 24 tercih yaparsın. Tabanlar ilk yerleştirmeye göre genelde gevşer ama en çok istenen programlar ek yerleştirmeye nadiren kalır. Kimin başvurabileceği, takvimi ve stratejisiyle tüm detaylar [ek yerleştirme rehberinde](/rehber/ek-yerlestirme-2026).
|
||||
|
||||
Kritik kural bir kez daha: bir programa **yerleştirildiysen** — kayıt yaptırmamış olsan bile — ek yerleştirme sana kapalıdır. "İstemediğim yere yerleştim, kayıt olmayayım, ek tercihte daha iyisini yazarım" planı **çalışmaz.**
|
||||
|
||||
## Yol 2: Kayıt ol + yatay geçişi araştır
|
||||
|
||||
İstemediğin programa yerleştiysen çoğu durumda en az riskli hamle **kayıt olmaktır.** Çünkü kayıtlı öğrenciye açık bir kapı var: **merkezi yerleştirme puanıyla (Ek Madde-1) yatay geçiş.** Mantığı şu:
|
||||
|
||||
- YKS'de aldığın puan, yerleştiğin yıl için geçerliliğini korur.
|
||||
- Puanın, geçmek istediğin programın **senin yerleştiğin yıldaki** taban puanına eşit ya da ondan yüksekse başvurabilirsin.
|
||||
- Başvuru, yerleştiğin yıl içinde değil, öğrenimin başladıktan sonraki dönemlerde (üniversitelerin ilan ettiği kontenjan ve takvimle) yapılır ve bu hak **bir kez** kullanılır.
|
||||
|
||||
Yani "puanım aslında X bölümüne yetiyordu ama listeme yazmamıştım" durumundaysan, bir dönem/yıl sonra o bölüme sınavsız geçme ihtimalin var. Kontenjanlar sınırlı ve üniversiteden üniversiteye değişir; hedef üniversitenin yatay geçiş sayfasını şimdiden takibe al.
|
||||
|
||||
## Yol 3: Yeniden denemek — bedelini bilerek
|
||||
|
||||
Gelecek yıl tekrar sınava girmek meşru bir plan; ama iki bedelini baştan bil:
|
||||
|
||||
1. **OBP çarpanı:** bir programa yerleştirildiysen (kayıt yaptırmasan bile) gelecek yıl diploma notu katkın yarıya düşer — hesabı [OBP yazısında](/rehber/obp-nedir-nasil-hesaplanir).
|
||||
2. **Tabanlar sabit durmaz:** bu yıl "az farkla kaçırdığın" bölümün tabanı seneye aynı yerde olmayabilir — [tabanlar nasıl değişir](/rehber/taban-siralamalar-nasil-degisir) yazısı bu oynaklığı verilerle gösteriyor.
|
||||
|
||||
## Bir dahaki listeye ders
|
||||
|
||||
Bu durumların neredeyse tamamı, liste kurulurken önlenebilir: sonuna gerçekten razı olacağın garantiler koymak, kayıt yaptırmayacağın satırı hiç yazmamak ve [ölü tercihleri](/rehber/olu-tercih-nedir) temizlemek. Ek yerleştirme listesi kuracaksan da aynı disiplin geçerli — sıralamana göre gerçekçi seçenekleri [tercih robotunda](/tercih-robotu) saniyeler içinde görebilirsin.
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
baslik: YKS 2026 Tercih Takvimi ve Süreç Adımları
|
||||
aciklama: Sonuçların açıklanmasından ek yerleştirmeye — 2026 tercih döneminin tüm aşamaları, sıralı ve tek sayfada.
|
||||
aciklama: Sonuçların açıklanmasından ek yerleştirmeye — 2026 tercih döneminin tüm aşamaları tek şemada ve tek sayfada.
|
||||
tarih: 2026-07-20
|
||||
---
|
||||
|
||||
@@ -8,6 +8,22 @@ Tercih dönemi kısa ve her adımı tarihli bir süreçtir. 2026'nın akışı
|
||||
|
||||
## Sürecin aşamaları
|
||||
|
||||
```mermaid
|
||||
%% aria: 2026 tercih dönemi akış şeması: önce YKS sonuçları açıklanır ve başarı sıralaman netleşir; ardından tercih kılavuzu ve kontenjanlar yayımlanır; Temmuz sonu ile Ağustos başı arasında en fazla 24 tercih girilir; yerleştirme sonuçları Ağustos'ta açıklanır; kayıt dönemi Ağustos sonu ile Eylül başı arasındadır; boş kontenjanlar için ek yerleştirme Eylül'de açılır ama yalnızca ilk turda hiçbir programa yerleşemeyenler başvurabilir.
|
||||
%% altyazi: Altı aşama sıralı ilerler; kesin tarihler her yıl ÖSYM duyurusuyla netleşir.
|
||||
flowchart TD
|
||||
R("1 · YKS sonuçları<br/>başarı sıralaman netleşir") --> K("2 · Kılavuz ve kontenjanlar<br/>yayımlanır")
|
||||
K --> T("3 · Tercih dönemi<br/>en fazla 24 tercih, ~2 hafta")
|
||||
T --> Y("4 · Yerleştirme sonuçları<br/>Ağustos")
|
||||
Y --> Kayit("5 · Kayıt dönemi<br/>e-Devlet veya üniversite")
|
||||
Kayit --> E("6 · Ek yerleştirme · Eylül<br/>yalnızca boşta kalanlar başvurur")
|
||||
|
||||
classDef uyari fill:#fef3c7,stroke:#f59e0b,color:#92400e
|
||||
classDef garanti fill:#d1fae5,stroke:#10b981,color:#065f46
|
||||
class T uyari
|
||||
class Kayit,E garanti
|
||||
```
|
||||
|
||||
**1) YKS sonuçlarının açıklanması (Temmuz ortası-sonu).**
|
||||
ÖSYM sonuç belgende puanların ve her puan türü için **başarı sıralaman** yazar. Tercihte kullanacağın sayı budur — puan değil ([neden olduğunu burada anlattık](/rehber/taban-puan-mi-siralama-mi)).
|
||||
|
||||
|
||||
72
content/rehber/yurt-burs-ve-yillik-maliyet.md
Normal file
72
content/rehber/yurt-burs-ve-yillik-maliyet.md
Normal file
@@ -0,0 +1,72 @@
|
||||
---
|
||||
baslik: Yurt, Burs ve Yıllık Maliyet — Tercih Listesinin Görünmeyen Kolonu
|
||||
aciklama: Tercih tablosunda maliyet kolonu yok, ama listeyi asıl eleyen o. Barınma, burs ve dört yıllık toplam hesabı — tek şemada.
|
||||
tarih: 2026-07-29
|
||||
---
|
||||
|
||||
Tercih tablosunda kontenjan var, taban var, tür var — ama **maliyet kolonu yok.** Oysa her yıl binlerce öğrenci yerleştiği yere maliyet yüzünden kayıt yaptıramıyor ya da ikinci dönem şehir değiştirmek zorunda kalıyor. Bu yazı o eksik kolonu doldurmak için: barınma, burs ve dört yıllık gerçek hesap.
|
||||
|
||||
## Bir satırın gerçek maliyeti
|
||||
|
||||
```mermaid
|
||||
%% aria: Tercih satırının gerçek maliyeti şeması. Bir programın yıllık maliyeti üç kalemden oluşur. Birincisi öğrenim ücreti: devlet üniversitesi birinci öğretimde normal sürede ücret yoktur, ikinci öğretimde ve süre aşımında katkı payı çıkar; vakıf üniversitesinde burslu programlarda ücret yok, indirimli ve ücretli programlarda ücret vardır. İkincisi barınma: aile yanında kalınıyorsa maliyet en düşüktür, devlet yurdunda orta, özel yurt veya öğrenci evinde en yüksektir. Üçüncüsü yaşam giderleri: yemek, ulaşım, kırtasiye ve şehre gidiş gelişler. Bu üç kalemin toplamı yıllık maliyettir ve öğrenim süresiyle çarpılır. Çıkan sayı ailenin karşılayabileceği tutarın üstündeyse satır listeye yazılmaz veya burslu eşdeğeri aranır. Altındaysa satır listede kalır.
|
||||
%% altyazi: Ücret kaleminin sıfır olması maliyetin sıfır olduğu anlamına gelmiyor — asıl fark barınmada.
|
||||
flowchart TD
|
||||
P("Bir tercih satırı") --> U("1. Öğrenim ücreti<br/>devlet 1. öğretim: yok<br/>vakıf burslu: yok<br/>indirimli / ücretli: var")
|
||||
P --> B("2. Barınma<br/>aile yanı en düşük<br/>devlet yurdu orta<br/>özel yurt / ev en yüksek")
|
||||
P --> Y("3. Yaşam<br/>yemek, ulaşım,<br/>kırtasiye, şehir arası yol")
|
||||
U --> T("Yıllık toplam<br/>x öğrenim süresi")
|
||||
B --> T
|
||||
Y --> T
|
||||
T --> K{"Aile bütçesi<br/>bu tutarı taşır mı?"}
|
||||
K -- "evet" --> L("Satır listede kalır")
|
||||
K -- "sınırda" --> S("Burslu / yurdu güçlü<br/>eşdeğerini de listeye ekle")
|
||||
K -- "hayır" --> C("Satırı yazma:<br/>yerleşirsen kayıt<br/>yaptıramazsın")
|
||||
|
||||
classDef uyari fill:#fef3c7,stroke:#f59e0b,color:#92400e
|
||||
classDef garanti fill:#d1fae5,stroke:#10b981,color:#065f46
|
||||
classDef hayal fill:#fee2e2,stroke:#ef4444,color:#991b1b
|
||||
class L garanti
|
||||
class K,S,T uyari
|
||||
class C hayal
|
||||
```
|
||||
|
||||
## Kalem 1: Öğrenim ücreti
|
||||
|
||||
- **Devlet üniversitesi, birinci öğretim:** Normal öğrenim süresi içinde öğrenim ücreti alınmaz. Süreyi aşarsan veya ikinci öğretimde okursan katkı payı/öğrenim ücreti gündeme gelir. Uzaktan öğretim programlarının kendi ücretlendirmesi vardır.
|
||||
- **Vakıf üniversitesi:** Aynı bölümün burslu, %50 indirimli, %25 indirimli ve ücretli varyantları **ayrı programlardır**; ayrı kontenjan, ayrı taban, ayrı fiyat. Burslu programda öğrenim ücreti yoktur. [Devlet mi vakıf mı](/rehber/devlet-mi-vakif-mi) yazısı bu tabloyu okumayı anlatıyor.
|
||||
- **Bursun sürme koşulu:** Vakıf burslarının çoğu giriş bursudur ve süresizdir, ama bazı okullarda not ortalaması şartı vardır. Üniversitenin **burs yönergesini** tercih etmeden önce oku — bu tek belge dört yıllık planını değiştirebilir.
|
||||
- **Hazırlık yılı:** Hazırlık okuyacaksan bu yılın ücret ve barınma maliyetini de hesaba kat. [Hazırlık sınıfı ve öğretim dili](/rehber/hazirlik-sinifi-ve-ogretim-dili) yazısı hangi programlarda hazırlığın zorunlu olduğunu anlatıyor.
|
||||
|
||||
## Kalem 2: Barınma — asıl fark burada
|
||||
|
||||
Devlet üniversitesi + büyükşehir kirası, bazen vakıf burslusu + yurt kombinasyonundan pahalıya gelir. İki satırı karşılaştırırken ücrete değil, **toplam yıllık çıkışa** bak.
|
||||
|
||||
Barınma seçenekleri ve tercih aşamasında bakman gerekenler:
|
||||
|
||||
- **Devlet yurdu (KYK):** Başvurular yerleştirme sonuçları açıklandıktan sonra e-Devlet üzerinden alınır; burs ve öğrenim kredisi başvuruları ise ayrı bir takvimde açılır. Tarihler her yıl Gençlik ve Spor Bakanlığı tarafından duyurulur, sabit değildir — sonuç açıklandığında resmî duyuruyu takip et. **Yerleştirilme garanti değildir**, öncelik kriterleri vardır.
|
||||
- **Üniversitenin kendi yurdu:** Özellikle vakıf üniversitelerinde kampüs içi yurt kapasitesi ve fiyatı okulun sitesinde yazar. Kapasite küçükse birinci sınıfta yer bulmak zorlaşır.
|
||||
- **Özel yurt / öğrenci evi:** En pahalı ve en değişken kalem. Aynı şehirde kampüse yürüme mesafesindeki fiyatla, 40 dakika uzaktaki fiyat arasında ciddi fark olur.
|
||||
- **Aile yanı:** Kendi şehrindeki bir üniversiteyi tercih etmenin ekonomik ağırlığı budur. Bu seçenek elindeyse, listedeki diğer satırların "prim"ini bunun üstüne koyarak düşün: bu program, yılda şu kadar ek maliyeti hak ediyor mu?
|
||||
|
||||
Pratik bir kontrol: **kampüsün şehir merkezine uzaklığı**. Şehir dışı kampüslerde kira ucuz ama ulaşım ve zaman maliyeti yüksektir; merkezdeki kampüslerde tam tersi.
|
||||
|
||||
## Kalem 3: Yaşam giderleri
|
||||
|
||||
Yemek, ulaşım, kırtasiye ve — sık unutulan — **eve gidiş geliş**. Memleketine ayda bir gidecek bir öğrenci için otobüs/uçak parası yılda ciddi bir kalem olur. Şehir seçerken ulaşım hattının varlığına da bak.
|
||||
|
||||
## Dört yıllık toplamı çıkarmanın en hızlı yolu
|
||||
|
||||
Listeye ciddi olarak yazacağın her satır için üç sayı yaz: **yıllık öğrenim ücreti + yıllık barınma + yıllık yaşam.** Toplamı öğrenim süresiyle (hazırlık varsa +1 yıl) çarp. [Tıp](/bolum/tip) gibi altı yıllık programlarda bu çarpan tek başına belirleyici olabilir.
|
||||
|
||||
Sonuç üç kutudan birine düşer:
|
||||
|
||||
- **Rahat taşınıyor:** Satır listede kalır.
|
||||
- **Sınırda:** Satırı listede tut ama aynı bölümün burslu ya da yurdu güçlü bir eşdeğerini de listeye ekle. Böylece iki senaryoya da hazırlıklı olursun.
|
||||
- **Taşımıyor:** Satırı yazma. "Yerleşirsem bir çaresine bakarız" en pahalı cümledir: yerleştiğin an [ek yerleştirme](/rehber/ek-yerlestirme-2026) hakkın da yanar ve elinde kayıt yaptıramayacağın bir program kalır.
|
||||
|
||||
## Bu konuşmayı listeyi vermeden önce yap
|
||||
|
||||
Maliyet konuşması tercih listesinin **girdisidir**, sonucu değil. Sonuçlar açıklandıktan sonra yapılan "biz bunu ödeyemeyiz" konuşması bir yıl kaybettirir. [Veliler için tercih rehberi](/rehber/veliler-icin-tercih-rehberi) yazısında bu konuşmanın nasıl yapılacağı ve görev dağılımı var.
|
||||
|
||||
Sıralamana düşen programları devlet/vakıf ve şehir filtreleriyle daraltıp maliyet senaryolarını yan yana görmek için [tercih robotuna](/tercih-robotu) sıralamanı girebilirsin.
|
||||
BIN
data/app.db
BIN
data/app.db
Binary file not shown.
@@ -3,6 +3,11 @@ services:
|
||||
build: .
|
||||
container_name: kolaytercih
|
||||
restart: unless-stopped
|
||||
# Host'u korur: uygulama bu sınırın üstüne çıkamaz. OOM anında kernel'ın
|
||||
# önce build süreçlerini (skor 0) öldürmesi için negatif oom_score_adj.
|
||||
mem_limit: 1g
|
||||
mem_reservation: 256m
|
||||
oom_score_adj: -600
|
||||
volumes:
|
||||
- app-data:/app/data
|
||||
env_file:
|
||||
|
||||
106
docs/tasarim/cta-cekmece-tasarimi.md
Normal file
106
docs/tasarim/cta-cekmece-tasarimi.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# CTA çekmecesi tasarım algısı — "Sıralamanla nereye yerleşirsin? Saniyede gör."
|
||||
|
||||
Bu doküman, programatik SEO sayfalarındaki funnel CTA'sının (`src/components/cta-sira-form.tsx`)
|
||||
alttan açılan çekmece (drawer) tasarımının arkasındaki algıyı ve kuralları belgeler.
|
||||
Yeni bir funnel giriş noktası eklerken ya da bu bileşeni değiştirirken buradaki
|
||||
ilkelere uy.
|
||||
|
||||
## 1. Temel algı: vaadi verdiğin yerde tahsil et
|
||||
|
||||
Eski davranış adayı ana sayfaya (`/`) gönderiyordu: "Saniyede gör" vaadi verip
|
||||
saniyeler süren bir sayfa geçişi ve bağlam kaybı yaşatıyordu. Yeni algı şu:
|
||||
|
||||
- **Kart vaat eder** — SEO sayfasının içinde duran kart (`bg-primary/5`,
|
||||
`border-primary/20`) merak uyandırır: "Sıralamanla nereye yerleşirsin?"
|
||||
- **Çekmece tahsil eder** — CTA butonu sayfadan koparmadan alttan bir çekmece
|
||||
açar; sıralama girişi, sihirbaz adımları ve tamamlama hepsi aynı bağlamda akar.
|
||||
- **Ödül `/sonuc`'ta** — seçimler tamamlanınca profil yazılır, çekmece kapanır
|
||||
ve aday doğrudan sonuç sayfasına gider. Sayfa geçişi yalnızca ödül anında olur.
|
||||
|
||||
Akış: **kart (vaat) → çekmece (sıralama) → sihirbaz (kişiselleştirme) → /sonuc (ödül)**.
|
||||
|
||||
Profilli adayda çekmece hiç devreye girmez: kayıtlı sıralamayla doğrudan
|
||||
`/sonuc` linki gösterilir — sıralamasını girmiş adaya bir daha girmesini
|
||||
söylemeyiz.
|
||||
|
||||
## 2. Neden çekmece, modal değil?
|
||||
|
||||
- Çekmece **sayfanın üstüne değil, altına eklenir** algısı verir: aday hâlâ
|
||||
okuduğu üniversite/bölüm sayfasının üzerindedir, içerik kaybolmaz (overlay
|
||||
hafif: `bg-black/10` + blur).
|
||||
- Alttan gelen yüzey mobilde başparmak menzilinde; hedef kitle (YKS adayı)
|
||||
ağırlıkla mobilde.
|
||||
- Swipe-to-dismiss (tutamaktan) ve overlay/Escape ile kaçış ucuz: "bir bakayım"
|
||||
maliyeti düşük tutulur. `handleOnly` kullanılır ki form/sihirbaz içindeki
|
||||
kaydırmalar çekmeceyi yanlışlıkla kapatmasın.
|
||||
- Desktop'ta çekmece tam genişliğe yayılmaz: `sm:max-w-2xl` ile ortalanır,
|
||||
`sm:border-x + sm:shadow-2xl` ile sayfadan yükselen bir panel gibi durur
|
||||
(liste çekmecesiyle aynı reçete).
|
||||
|
||||
## 3. Görsel dil: yeni hiçbir şey icat etme
|
||||
|
||||
Çekmecedeki her parça sitede zaten var olan kanonik kaynaktan gelir:
|
||||
|
||||
| Parça | Kaynak / kural |
|
||||
| --- | --- |
|
||||
| Eyebrow rozeti ("Saniyede gör") | `SectionEyebrow` (`pixel-decor.tsx`) — elle pill yazılmaz |
|
||||
| Kapatma çarpısı | `dialog.tsx`'teki kanonik set: `size-9`, `rounded-full`, `border-slate-200`, `size-4` X, hover `bg-slate-100`, `active:scale-[0.96]` |
|
||||
| CTA butonları | Metin önce, sonda `ArrowRight` (`size-4`); yıldız/Sparkles yok; `bg-orange-500 → hover:bg-orange-600` |
|
||||
| Puan türü çipleri | Hero formundaki radiogroup çipleriyle birebir aynı class seti |
|
||||
| Sıralama girişi | Profil kapısındaki desen: sola `Trophy` ikonu, `h-12`, `pl-11` |
|
||||
| Kanıt üçlüsü | Arama funnel kartıyla aynı üçlü: 24 tercihlik plan / Risk analizi / YÖK Atlas verisi (`ListChecks`, `ChartNoAxesCombined`, `Database`) |
|
||||
| Başlık/kopya tipografisi | `font-heading` bold başlık, `text-slate-600` gövde, `text-slate-900` vurgu |
|
||||
|
||||
Algı hedefi: çekmece açıldığında aday "yeni bir yere geldim" değil, "aynı ürünün
|
||||
bir katmanı kalktı" hissetmeli. Bu yüzden çekmeceye özgü hiçbir yeni renk,
|
||||
rozet, buton varyantı tasarlanmaz.
|
||||
|
||||
## 4. İçerik tonu
|
||||
|
||||
- Öğrenci diliyle, ikinci tekil: "Sıralamanı yaz", "bu sayfadan ayrılmadan gör".
|
||||
- Sürtünme kaldıran güvence her zaman görünür: **"Ücretsiz, üyelik gerekmez."**
|
||||
- Kanıt üçlüsü süs değil, ürün vaadinin özetidir; metin etiketleri ikonsuz da
|
||||
anlamlı olmalı (renk/ikon tek başına anlam taşımaz — ürünün risk renk dili
|
||||
kuralıyla aynı ilke).
|
||||
- Buton etiketleri eylemin sonucunu söyler: "Programları gör" (girdi adımı),
|
||||
"Ücretsiz tablonu gör" / "Yapay Zeka listemi kur (3 kredi)" (sihirbaz sonu, girişe göre).
|
||||
|
||||
## 5. Davranış ve teknik algı
|
||||
|
||||
- **Statik kalır:** Kart RSC sayfalarında prerender edilir; oturum bilgisi
|
||||
mount'ta değil, facet isteğiyle **paralel** çözülür (hero formundaki desen) —
|
||||
landing başına gereksiz auth turu atılmaz.
|
||||
- **Preload:** CTA butonuna `pointerenter`/`focus` ve sıralama inputuna `focus`
|
||||
geldiğinde `preloadSihirbazAdimlar()` çalışır; sihirbaz açıldığında chunk
|
||||
çoktan inmiştir. "Saniyede gör" vaadi teknik olarak da tutulur.
|
||||
- **İki adım, tek yüzey:** çekmece içinde `sira → sihirbaz` adımları vardır;
|
||||
erişilebilir ad sabittir (sr-only `DrawerTitle`/`DrawerDescription`), görünür
|
||||
başlıklar adıma göre değişir.
|
||||
- **Kapanış = sıfırlama, ama nazikçe:** çekmece kapanınca adım ve facet'ler
|
||||
sıfırlanır; adayın yazdığı sıralama ve puan türü **korunur** (tekrar açarsa
|
||||
baştan yazmasın).
|
||||
- **Tamamlanınca:** `tercihProfiliYaz` → çekmece kapanır → `sonucHref` ile
|
||||
`/sonuc` (girişliye `sihirbaz: "1"`, girişsize `hazir: "1"`). Giriş duvarı
|
||||
sihirbazın hemen arkasında değildir; değer önce gösterilir.
|
||||
- **Lenis:** çekmece içeriği ve kaydırma kabı `data-lenis-prevent` taşır;
|
||||
sayfadaki smooth-scroll çekmece içi kaydırmayı ezmez.
|
||||
|
||||
## 6. Erişilebilirlik
|
||||
|
||||
- Sıralama alanı: `inputMode="numeric"`, binlik ayraçlı biçimleme
|
||||
(85000 → 85.000), hata durumunda `aria-invalid` + `aria-describedby` +
|
||||
`role="alert"`.
|
||||
- Puan türü seçimi `role="radiogroup"` / `role="radio"` + `aria-checked`.
|
||||
- Kapatma yolları: çarpı butonu, overlay, Escape, tutamaktan swipe.
|
||||
- `prefers-reduced-motion` vaul/motion tarafında zaten sadeleşir; çekmeceye
|
||||
özel ek animasyon eklenmedi.
|
||||
|
||||
## 7. Bu deseni ne zaman kullan / kullanma
|
||||
|
||||
**Kullan:** İçerik sayfasında duran ve adaydan tek kritik girdi (sıralama)
|
||||
isteyen her funnel girişi. Adayın okuduğu bağlam değerliyse (bölüm, üniversite,
|
||||
rehber) çekmece doğru araçtır.
|
||||
|
||||
**Kullanma:** Zaten modal/dialog içinde olan akışlar (arama modalındaki funnel
|
||||
kartı gibi — modal üstüne çekmece açılmaz, oradaki kart mevcut kapı/dialog
|
||||
akışını kullanır) ve profilli kısayollar (doğrudan link yeterli).
|
||||
189
docs/urun/prd-kapi1-ucretsiz-funnel.md
Normal file
189
docs/urun/prd-kapi1-ucretsiz-funnel.md
Normal file
@@ -0,0 +1,189 @@
|
||||
# Kapı 1 Güçlendirme PRD — Ücretsiz Değerden Ücretli Akışa
|
||||
|
||||
> Durum: Taslak v1 · Sahip: Bilal · Tarih: 6 Ağustos 2026
|
||||
> Gösterim: 🔶 Varsayım (makul ama doğrulanmamış) · 🔵 Açık soru (keşif gerekiyor)
|
||||
|
||||
## 1. Yönetici Özeti
|
||||
|
||||
Sıralamasını girip `/sonuc`'u gören anonim YKS öğrencisine, giriş yapmadan önce ürünün asıl farklılaştırıcı değerini (yapay zekâ gerekçeli tercih satırı) tattıran ve harcadığı emeği (manuel liste + sihirbaz profili) "kaydet, kaybetme" gerekçesiyle girişe bağlayan bir deneyim kuruyoruz. Amaç **Kapı 1 dönüşümünü (sıra girişi → giriş)** artırmak; kapı ölçümleri ve kredi-bitti e-posta dönüşüyle döngüyü kapatmak. Fiyat ve kredi kurgusu (299 TL paket, 5 deneme kredisi, 3 kredi/liste) bu PRD'de sabittir.
|
||||
|
||||
## 2. Problem Tanımı
|
||||
|
||||
### Problemi kim yaşıyor?
|
||||
Sıralamasını girip ücretsiz tabloyu gören ama giriş yapmayan anonim öğrenciler (ve ödemeyi çoğu zaman onaylayan velileri).
|
||||
|
||||
### Problem ne?
|
||||
1. **Ürünün asıl değeri kapıdan önce görünmüyor.** Anonim kullanıcı `/sonuc`'ta yalnızca ham YÖK tablosunu görüyor; "yapay zekâ gerekçeli liste"nin nasıl bir şey olduğunu giriş yapıp 3 kredi harcayana kadar hiç tatmıyor. `?hazir=1` panelinin ikna gücü tamamen metne dayanıyor (`src/app/sonuc/sihirbaz-cagri-karti.tsx:262-292`).
|
||||
2. **Kullanıcı emeği uçucu.** Manuel 24'lük liste ve sihirbaz profili yalnızca localStorage'da (`src/components/manuel-liste/store.ts:16`, `src/lib/sihirbaz.ts:36`); cihaz/tarayıcı değişince kayboluyor ve "listeni kaydet" doğal giriş gerekçesi olarak kullanılmıyor.
|
||||
3. **Kapılarda ölçüm yok.** `hazir=1` panel gösterimi, paywall/kilit satır görüntülenmesi ve kredi bitişi için event yok (`src/lib/analitik.ts:14-25`'teki union'da karşılıkları yok). Hangi kapıda ne kadar döküldüğü bilinmiyor.
|
||||
4. **Kredisi biten kullanıcı çıkmaz sokakta.** 5 deneme kredisi tam 1 liste + 2 soruya yetiyor; sonrasında ödeme yoksa kullanıcıyı geri çağıran hiçbir mekanizma yok.
|
||||
|
||||
### Neden acı verici?
|
||||
- **Kullanıcı:** Tercih dönemi kısa ve stresli; değer görmeden hesap açmak ekstra sürtünme. Emeğinin kaybolması güveni zedeliyor.
|
||||
- **İş:** Kapı 1 funnel'ın en kalabalık geçişi — burada kaybedilen her kullanıcı, alttaki ödeme dönüşümüne hiç ulaşmıyor. Ölçüm olmadığı için iyileştirme de doğrulanamıyor.
|
||||
|
||||
### Kanıt
|
||||
- Kod: maskeleme sunucuda, anonim tarafta hiç AI çıktısı yok (`src/lib/rapor-maske.ts`); liste/profil client-only; event listesi eksik.
|
||||
- 🔶 Kapı 1 dönüşümünün düşük olduğu varsayımdır — baseline yok. (Bu, ölçüm iş paketinin kendisini gerekçelendiriyor: İP-A önce baseline kurar.)
|
||||
- 🔵 Rybbit'te mevcut `sira_girildi` → `giris_denendi` oranına bakılarak kaba bir baseline bugün çıkarılabilir mi?
|
||||
|
||||
## 3. Hedef Kullanıcılar ve Personalar
|
||||
|
||||
### Birincil: Sıralı Öğrenci
|
||||
- YKS sonucu açıklanmış, elinde sıralaması var; tercih penceresi ~2 hafta.
|
||||
- Mobil ağırlıklı, hızlı ve güven veren bir cevap arıyor; hesap açmaya karşı sürtünme eşiği yüksek.
|
||||
- Davranış: sıralamasını girer, tabloyu karıştırır, birkaç program ekler, sekmesini kapatır — geri gelmesi garantili değil.
|
||||
|
||||
### İkincil: Veli
|
||||
- Ödemenin fiili onaycısı; "danışman 2.000–10.000 TL, bu 299 TL" karşılaştırmasına duyarlı.
|
||||
- Çocuğunun kurduğu listeyi görmek ve güvenilirlik sinyali (gerekçe, risk notu) ister.
|
||||
|
||||
## 4. Stratejik Bağlam
|
||||
|
||||
- **İş hedefi:** Tek seferlik 299 TL'lik ürünün geliri doğrudan Kapı 1 hacmiyle çarpılır; funnel'ın en üst geçişini iyileştirmek alttaki her adıma bileşik etki yapar.
|
||||
- **Neden şimdi:** Ürün sezonluk — yoğunluk tercih döneminde. Sezon öncesi funnel'ın oturması gerekiyor; sezon içinde büyük değişiklik riskli.
|
||||
- **Rekabet:** İnsan danışmanlar (2.000–10.000 TL) ve ücretsiz robotlar. Farklılaştırıcı, gerekçeli + risk analizli liste; bu değer şu an kapının arkasında saklı.
|
||||
|
||||
## 5. Çözüm Özeti — 4 İş Paketi
|
||||
|
||||
> Sıralama bilinçli: önce ölç (A), sonra değeri göster (B), emeği bağla (C), teaser'ı parlat (D).
|
||||
|
||||
### İP-A · Kapı ölçümü (temel)
|
||||
Funnel'ın üç kapısına gösterim/sonuç event'leri eklenir; Kapı 1 baseline'ı kurulur.
|
||||
- Yeni event'ler (`OlayAdi` union'ına): `hazir_panel_goruntulendi`, `giris_cta_tiklandi` (prop: `kaynak`), `kilit_goruntulendi` (paywall satırı/kartı viewport'a girince), `kredi_bitti` (KREDI hatası panelinde).
|
||||
- Gösterim event'leri viewport-temelli ve oturum başına 1 kez; `siraKovasi` dışında ham veri gönderilmez (mevcut gizlilik deseni korunur).
|
||||
|
||||
### İP-B · Girişsiz yapay zekâ tadımlığı
|
||||
Anonim kullanıcı `/sonuc`'ta, kendi sıra kovası + alan tercihine uyan **1 örnek tercih satırını gerçek ürün formatında** (gerekçe + risk notu + trend özeti) görür; hemen altında "24 satırın tamamı için giriş yap — 5 deneme kredin hazır" CTA'sı durur.
|
||||
- **Maliyet/istismar tasarımı:** Tadımlık canlı AI çağrısıyla değil, **önceden üretilmiş havuzdan** servis edilir (sıra kovası × alan kombinasyonları için batch üretim; `src/lib/rapor-havuzu.ts` altyapısıyla ilişkisi 🔵 incelenecek). Anonim istek başına LLM maliyeti sıfır, prompt-injection yüzeyi yok.
|
||||
- Tadımlık satır, gerçek rapor bileşeniyle (`rapor-listesi.tsx` satır formatı) birebir aynı görünür — "demo" değil "ürünün kendisi" hissi.
|
||||
- 🔶 Tek satır yeterli tatma sağlar; 2+ satır ücretsiz değeri fazla büyütüp Kapı 1'i zayıflatabilir (guardrail metriğiyle izlenecek).
|
||||
- 🔵 Tadımlık satırın programı nasıl seçilecek? (Kullanıcının manuel listesindeki ilk program mı, kovadaki en popüler program mı?)
|
||||
|
||||
### İP-C · Liste kaydetme = giriş gerekçesi
|
||||
Manuel liste ve sihirbaz profili girişte sunucuya taşınır; anonim kullanıcıya emeği büyüdükçe "listen yalnızca bu tarayıcıda — kaydet" tetikleyicileri gösterilir.
|
||||
- Şema: `savedLists` (userId unique, items JSON, profil JSON) — mevcut `reports` deseniyle aynı upsert yaklaşımı.
|
||||
- Girişte merge: localStorage listesi sunucudakiyle birleşir (çakışmada localStorage kazanır, 24 sınırı korunur 🔶).
|
||||
- Tetikleyiciler: liste 3+ programa ulaştığında bir kez gösterilen yumuşak banner; "Seçimlerim" panelinde kalıcı satır. Agresif exit-intent popup **yok**.
|
||||
- Giriş sonrası dönen kullanıcı listesini her cihazda bulur — ürün "kaydettiğin yer" kimliği kazanır.
|
||||
|
||||
### İP-D · Teaser zenginleştirme (ikincil metrik: ödeme dönüşümü)
|
||||
Maskeli rapor deneyiminde kilidin "ne sakladığı" daha somutlaştırılır:
|
||||
- `uyarilar` tamamen boşaltılmak yerine **1 uyarı açık, kalanı "N uyarı daha kilitli"** göstergesi (`src/lib/rapor-maske.ts:41` değişir; maskeleme sunucuda kalır).
|
||||
- Kilit satırı (`rapor-listesi.tsx:331-337`) satır sayısı + kişiye özel içerik iması taşır ("21 satırda senin sıralamana özel gerekçe ve risk notu hazır").
|
||||
- Açık satır sayısı `ACIK_SATIR = 3` sabit kalır 🔶 (değiştirme kararı ölçüm sonrası ayrı deney).
|
||||
|
||||
### Kullanıcı akışı (hedef durum)
|
||||
```
|
||||
sıra girildi → /sonuc: ham tablo + TADIMLIK AI SATIRI (B)
|
||||
→ manuel liste büyüdükçe "kaydet" tetikleyicisi (C)
|
||||
→ sihirbaz biter → hazir=1 paneli [ölçülüyor] (A)
|
||||
→ giriş (5 kredi) → liste üretimi → maskeli rapor (zengin teaser, D)
|
||||
→ ödeme 299 TL
|
||||
↳ kredi bitti [ölçülüyor] → 48 saat sonra hatırlatma e-postası (A)
|
||||
```
|
||||
|
||||
## 6. Başarı Metrikleri
|
||||
|
||||
### Birincil
|
||||
**Kapı 1 dönüşümü:** `sira_girildi` (tekil oturum) → başarılı giriş oranı.
|
||||
- Mevcut: bilinmiyor → İP-A ilk 2 hafta baseline kurar.
|
||||
- 🔶 Hedef: baseline'a göre **göreli +%30** (baseline netleşince revize edilir).
|
||||
|
||||
### İkincil
|
||||
- `hazir_panel_goruntulendi` → `giris_cta_tiklandi` tıklama oranı.
|
||||
- Giriş → `liste_olusturuldu` aktivasyon oranı.
|
||||
- `kilit_goruntulendi` → `odeme_baslatildi` (İP-D'nin metriği).
|
||||
- Kredi-bitti e-postası → geri dönüş oturumu oranı.
|
||||
|
||||
### Korkuluk (guardrail)
|
||||
- **Ödeme dönüşümü düşmemeli:** tadımlık, ücretsiz katmanı "yeterli" hissettirip paketi kanibalize etmemeli.
|
||||
- **AI maliyeti artmamalı:** tadımlık batch-üretim; anonim trafik LLM'e istek atmaz.
|
||||
- `/sonuc` LCP/performans gerilemez; ham tablo "her zaman ücretsiz" vaadi korunur.
|
||||
|
||||
## 7. Kullanıcı Hikâyeleri ve Gereksinimler
|
||||
|
||||
### Epik hipotezi
|
||||
Anonim kullanıcıya gerçek ürün formatında bir yapay zekâ tadımlığı gösterip emeğini kaydetme gerekçesiyle girişe bağlarsak, Kapı 1 dönüşümü artar; çünkü kullanıcı bugün ne alacağını görmeden ve emeğini korumak için hiçbir neden olmadan hesap açmaya itiliyor. Başarıyı `sira_girildi → giriş` oranıyla ölçeceğiz.
|
||||
|
||||
### Hikâyeler
|
||||
|
||||
**A1 — Kapı event'leri.** Ürün sahibi olarak funnel'ın her kapısında gösterim ve sonuç sayısını görmek istiyorum.
|
||||
- [ ] `hazir_panel_goruntulendi`, `giris_cta_tiklandi(kaynak)`, `kilit_goruntulendi(yer)`, `kredi_bitti` event'leri `OlayAdi` union'ına eklendi ve ilgili bileşenlerde ateşleniyor.
|
||||
- [ ] Gösterim event'leri IntersectionObserver ile, oturum başına en fazla 1 kez.
|
||||
- [ ] Ham sıra gönderilmiyor; `siraKovasi` deseni korunuyor.
|
||||
|
||||
**A2 — Kredi-bitti dönüş e-postası.** Kredisi biten kullanıcı olarak, listemin beni beklediğini hatırlatan bir e-posta almak istiyorum.
|
||||
- [ ] `kredi_bitti` durumuna düşen kullanıcıya 48 saat sonra tek seferlik e-posta (Resend, `eposta.ts` şablon dili).
|
||||
- [ ] E-posta `/listem`'e deep-link verir; ödeme baskısı ikinci planda, "3 açık satırın hazır" birinci planda.
|
||||
- [ ] 🔵 Gönderim zamanlaması için altyapı: cron/queue mu, giriş anında lazy-check mi?
|
||||
- [ ] 🔵 KVKK: işlemsel hatırlatma mı pazarlama izni gerektiren ileti mi — hukuki sınıflandırma netleşecek.
|
||||
|
||||
**B1 — Tadımlık satır.** Anonim kullanıcı olarak, sıralamama uygun bir programın yapay zekâ gerekçesini giriş yapmadan görmek istiyorum.
|
||||
- [ ] `/sonuc`'ta, sıra kovası + (varsa) sihirbaz alan tercihine uyan 1 tadımlık satır gerçek rapor satırı formatında render edilir.
|
||||
- [ ] İçerik önceden üretilmiş havuzdan gelir; anonim istek LLM'e gitmez.
|
||||
- [ ] Satırın altında giriş CTA'sı: metin sonunda `ArrowRight`, `Sparkles` yok (CTA kuralı).
|
||||
- [ ] Havuzda uygun içerik yoksa bileşen hiç render edilmez (boş/generic tadımlık gösterilmez).
|
||||
- [ ] 🔵 Havuz üretim kapsamı: kova × alan kombinasyon sayısı ve tazeleme sıklığı.
|
||||
|
||||
**C1 — Liste sunucuya taşınır.** Giriş yapan kullanıcı olarak, anonimken kurduğum listeyi hesabımda bulmak istiyorum.
|
||||
- [ ] Girişte localStorage listesi + sihirbaz profili sunucuya merge edilir (çakışmada client kazanır, 24 sınırı aşılmaz).
|
||||
- [ ] Farklı cihazdan girişte liste sunucudan yüklenir; localStorage ile iki yönlü senkron kuralı belgelenir.
|
||||
- [ ] Merge idempotent — çift giriş çift kayıt üretmez.
|
||||
|
||||
**C2 — Kaydet tetikleyicisi.** Anonim kullanıcı olarak, listem büyüdüğünde kaybolabileceğini öğrenmek istiyorum.
|
||||
- [ ] Liste 3. programa ulaştığında bir kez yumuşak banner: "Listen yalnızca bu tarayıcıda — giriş yap, kaydet."
|
||||
- [ ] "Seçimlerim" panelinde kalıcı "listeni kaydet" satırı.
|
||||
- [ ] Banner kapatılabilir; kapatma oturumda kalıcı. Exit-intent popup yok.
|
||||
|
||||
**D1 — Zengin teaser.** Maskeli raporu gören kullanıcı olarak, kilidin arkasında ne olduğunu somut görmek istiyorum.
|
||||
- [ ] `uyarilar`dan 1 tanesi açık, kalanı "N uyarı daha kilitli" sayacıyla gösterilir; maskeleme sunucuda kalır.
|
||||
- [ ] Kilit satırı kişiselleştirilmiş metin taşır (kalan satır sayısı + "senin sıralamana özel").
|
||||
- [ ] `?acildi=1` unblur akışı bozulmaz.
|
||||
|
||||
### Kısıtlar
|
||||
- Maskeleme ve tadımlık seçimi **her zaman sunucuda** — kilitli gerçek içerik client'a asla inmez.
|
||||
- Badge/eyebrow, kapatma çarpısı ve CTA ok deseni proje kurallarına uyar (`AGENTS.md`).
|
||||
- Sihirbaz/liste mimarisinde davranış değişirse `/meraklisina` şeması aynı commit'te güncellenir (`src/app/meraklisina/huni-semasi.tsx`).
|
||||
|
||||
## 8. Kapsam Dışı
|
||||
|
||||
- Fiyat, paket yapısı, kredi miktarları (sabit — kullanıcı kararı).
|
||||
- Abonelik, ara paket, referral programı.
|
||||
- Canlı AI tadımlığı (anonim kullanıcıya gerçek zamanlı LLM çağrısı).
|
||||
- Exit-intent popup ve agresif e-posta dizileri.
|
||||
- `ACIK_SATIR` sayısını değiştirme (ölçüm sonrası ayrı deney).
|
||||
- Mobil uygulama, push notification.
|
||||
|
||||
## 9. Bağımlılıklar ve Riskler
|
||||
|
||||
### Bağımlılıklar
|
||||
- **E-posta:** Resend + `eposta.ts` şablonları hazır; yalnızca yeni şablon + tetikleme mekanizması gerekir.
|
||||
- **Tadımlık havuzu:** `rapor-havuzu.ts` mevcut altyapısının kapsamı incelenecek 🔵; batch üretim script'i gerekebilir.
|
||||
- **Şema değişikliği:** `savedLists` tablosu (drizzle migration).
|
||||
- **`/meraklisina` şema senkronu:** İP-B ve İP-C davranış değiştiriyor → şema + anlatım metni aynı PR'da güncellenir.
|
||||
|
||||
### Riskler
|
||||
- **Kanibalizasyon:** Tadımlık + zengin teaser ücretsiz deneyimi "yeterli" hissettirebilir. → Korkuluk metriği; tadımlık 1 satırda sabit başlar.
|
||||
- **Baseline'sız iterasyon:** A'dan önce B/C/D'ye başlanırsa etki ölçülemez. → İP-A her zaman önce gider; B/C/D en az 2 hafta baseline'dan sonra açılır 🔶.
|
||||
- **Merge karmaşası (C1):** İki cihazda farklı listeler → sessiz veri kaybı algısı. → Çakışma kuralı basit ve tek yönlü tutulur, edge case'ler kabul kriterlerinde.
|
||||
- **Sezon zamanlaması:** Yoğun dönemde funnel değişikliği riskli. → Sezon öncesi yayın; sezon içi yalnızca metin/ölçüm ayarı.
|
||||
- **KVKK/e-posta:** Hatırlatma e-postasının hukuki sınıfı belirsiz 🔵 → netleşene dek A2 yalnızca işlemsel dille yazılır.
|
||||
|
||||
## 10. Açık Sorular
|
||||
|
||||
1. 🔵 Rybbit'teki mevcut event'lerden bugün kaba bir Kapı 1 baseline'ı çıkarılabilir mi (`sira_girildi` vs `giris_denendi` tekil oranı)?
|
||||
2. 🔵 Tadımlık satırın program seçimi: manuel listedeki ilk program mı, kova için önceden seçilmiş popüler program mı?
|
||||
3. 🔵 `rapor-havuzu.ts` bugün ne yapıyor; tadımlık havuzu onun üstüne mi kurulur?
|
||||
4. 🔵 Kredi-bitti e-postası için zamanlama altyapısı (cron vs lazy-check) ve KVKK sınıfı.
|
||||
5. 🔵 `savedLists` mi yoksa mevcut `reports.params` üzerinde genişletme mi — şema kararı.
|
||||
6. 🔶 +%30 göreli hedef baseline sonrası revize edilecek.
|
||||
|
||||
---
|
||||
|
||||
## Öz Değerlendirme (şablon gereği)
|
||||
|
||||
- **En güçlü bölüm:** Problem tanımı — kod referanslarıyla somut.
|
||||
- **En zayıf bölüm:** Başarı metrikleri — baseline yok; hedefler İP-A tamamlanana dek varsayımsal.
|
||||
- **Önce doğrulanacak varsayımlar:** (1) Kapı 1 dönüşümünün gerçekten düşük olduğu, (2) tek satır tadımlığın kanibalize etmeden ikna ettiği.
|
||||
- **Önerilen ilk adım:** İP-A'yı uygula (küçük, risksiz, 1-2 gün) + Rybbit'ten retrospektif baseline denemesi; 2 hafta veriyle B/C önceliği netleştir.
|
||||
131
drizzle/0000_baslangic.sql
Normal file
131
drizzle/0000_baslangic.sql
Normal file
@@ -0,0 +1,131 @@
|
||||
CREATE TABLE `account` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`account_id` text NOT NULL,
|
||||
`provider_id` text NOT NULL,
|
||||
`user_id` text NOT NULL,
|
||||
`access_token` text,
|
||||
`refresh_token` text,
|
||||
`id_token` text,
|
||||
`access_token_expires_at` integer,
|
||||
`refresh_token_expires_at` integer,
|
||||
`scope` text,
|
||||
`password` text,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `chat_messages` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`user_id` text NOT NULL,
|
||||
`role` text NOT NULL,
|
||||
`content` text NOT NULL,
|
||||
`client_message_id` text,
|
||||
`created_at` integer NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `chat_messages_client_message_id_unique` ON `chat_messages` (`client_message_id`);--> statement-breakpoint
|
||||
CREATE INDEX `chat_user_created` ON `chat_messages` (`user_id`,`created_at`);--> statement-breakpoint
|
||||
CREATE TABLE `credit_ledger` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`user_id` text NOT NULL,
|
||||
`delta` integer NOT NULL,
|
||||
`reason` text NOT NULL,
|
||||
`ref_id` text,
|
||||
`created_at` integer NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `ledger_reason_ref` ON `credit_ledger` (`reason`,`ref_id`);--> statement-breakpoint
|
||||
CREATE INDEX `ledger_user` ON `credit_ledger` (`user_id`);--> statement-breakpoint
|
||||
CREATE TABLE `orders` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`user_id` text NOT NULL,
|
||||
`product` text NOT NULL,
|
||||
`amount_kurus` integer NOT NULL,
|
||||
`credits` integer NOT NULL,
|
||||
`status` text DEFAULT 'pending' NOT NULL,
|
||||
`iyzico_token` text,
|
||||
`iyzico_payment_id` text,
|
||||
`created_at` integer NOT NULL,
|
||||
`paid_at` integer,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `orders_user` ON `orders` (`user_id`);--> statement-breakpoint
|
||||
CREATE TABLE `reports` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`user_id` text NOT NULL,
|
||||
`params` text NOT NULL,
|
||||
`result` text,
|
||||
`revision_count` integer DEFAULT 0 NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `reports_user_id_unique` ON `reports` (`user_id`);--> statement-breakpoint
|
||||
CREATE TABLE `saved_lists` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`user_id` text NOT NULL,
|
||||
`items` text NOT NULL,
|
||||
`profil` text,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `saved_lists_user_id_unique` ON `saved_lists` (`user_id`);--> statement-breakpoint
|
||||
CREATE TABLE `session` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`expires_at` integer NOT NULL,
|
||||
`token` text NOT NULL,
|
||||
`ip_address` text,
|
||||
`user_agent` text,
|
||||
`user_id` text NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `session_token_unique` ON `session` (`token`);--> statement-breakpoint
|
||||
CREATE TABLE `tadimlik_havuzu` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`kova_slug` text NOT NULL,
|
||||
`tur` text NOT NULL,
|
||||
`kategori_slug` text NOT NULL,
|
||||
`program_id` text NOT NULL,
|
||||
`dilim` text NOT NULL,
|
||||
`gerekce` text NOT NULL,
|
||||
`risk_notu` text NOT NULL,
|
||||
`trend_ozeti` text NOT NULL,
|
||||
`program` text NOT NULL,
|
||||
`ornek_sira` integer NOT NULL,
|
||||
`uretim_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `tadimlik_anahtar` ON `tadimlik_havuzu` (`kova_slug`,`tur`,`kategori_slug`);--> statement-breakpoint
|
||||
CREATE TABLE `user` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`email` text NOT NULL,
|
||||
`email_verified` integer DEFAULT false NOT NULL,
|
||||
`image` text,
|
||||
`credit_balance` integer DEFAULT 0 NOT NULL,
|
||||
`has_paket` integer DEFAULT false NOT NULL,
|
||||
`kredi_bitti_at` integer,
|
||||
`kredi_hatirlatma_gonderildi_at` integer,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `user_email_unique` ON `user` (`email`);--> statement-breakpoint
|
||||
CREATE TABLE `verification` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`identifier` text NOT NULL,
|
||||
`value` text NOT NULL,
|
||||
`expires_at` integer NOT NULL,
|
||||
`created_at` integer,
|
||||
`updated_at` integer
|
||||
);
|
||||
898
drizzle/meta/0000_snapshot.json
Normal file
898
drizzle/meta/0000_snapshot.json
Normal file
@@ -0,0 +1,898 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "a802fded-01d5-4d17-8b6f-fca5dfc2920d",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"tables": {
|
||||
"account": {
|
||||
"name": "account",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"account_id": {
|
||||
"name": "account_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"provider_id": {
|
||||
"name": "provider_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"access_token": {
|
||||
"name": "access_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"refresh_token": {
|
||||
"name": "refresh_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"id_token": {
|
||||
"name": "id_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"access_token_expires_at": {
|
||||
"name": "access_token_expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"refresh_token_expires_at": {
|
||||
"name": "refresh_token_expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"scope": {
|
||||
"name": "scope",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"password": {
|
||||
"name": "password",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"account_user_id_user_id_fk": {
|
||||
"name": "account_user_id_user_id_fk",
|
||||
"tableFrom": "account",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"chat_messages": {
|
||||
"name": "chat_messages",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"role": {
|
||||
"name": "role",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"client_message_id": {
|
||||
"name": "client_message_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"chat_messages_client_message_id_unique": {
|
||||
"name": "chat_messages_client_message_id_unique",
|
||||
"columns": [
|
||||
"client_message_id"
|
||||
],
|
||||
"isUnique": true
|
||||
},
|
||||
"chat_user_created": {
|
||||
"name": "chat_user_created",
|
||||
"columns": [
|
||||
"user_id",
|
||||
"created_at"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"chat_messages_user_id_user_id_fk": {
|
||||
"name": "chat_messages_user_id_user_id_fk",
|
||||
"tableFrom": "chat_messages",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"credit_ledger": {
|
||||
"name": "credit_ledger",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"delta": {
|
||||
"name": "delta",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"reason": {
|
||||
"name": "reason",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"ref_id": {
|
||||
"name": "ref_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"ledger_reason_ref": {
|
||||
"name": "ledger_reason_ref",
|
||||
"columns": [
|
||||
"reason",
|
||||
"ref_id"
|
||||
],
|
||||
"isUnique": true
|
||||
},
|
||||
"ledger_user": {
|
||||
"name": "ledger_user",
|
||||
"columns": [
|
||||
"user_id"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"credit_ledger_user_id_user_id_fk": {
|
||||
"name": "credit_ledger_user_id_user_id_fk",
|
||||
"tableFrom": "credit_ledger",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"orders": {
|
||||
"name": "orders",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"product": {
|
||||
"name": "product",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"amount_kurus": {
|
||||
"name": "amount_kurus",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"credits": {
|
||||
"name": "credits",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'pending'"
|
||||
},
|
||||
"iyzico_token": {
|
||||
"name": "iyzico_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"iyzico_payment_id": {
|
||||
"name": "iyzico_payment_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"paid_at": {
|
||||
"name": "paid_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"orders_user": {
|
||||
"name": "orders_user",
|
||||
"columns": [
|
||||
"user_id"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"orders_user_id_user_id_fk": {
|
||||
"name": "orders_user_id_user_id_fk",
|
||||
"tableFrom": "orders",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"reports": {
|
||||
"name": "reports",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"params": {
|
||||
"name": "params",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"result": {
|
||||
"name": "result",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"revision_count": {
|
||||
"name": "revision_count",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"reports_user_id_unique": {
|
||||
"name": "reports_user_id_unique",
|
||||
"columns": [
|
||||
"user_id"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"reports_user_id_user_id_fk": {
|
||||
"name": "reports_user_id_user_id_fk",
|
||||
"tableFrom": "reports",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"saved_lists": {
|
||||
"name": "saved_lists",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"items": {
|
||||
"name": "items",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"profil": {
|
||||
"name": "profil",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"saved_lists_user_id_unique": {
|
||||
"name": "saved_lists_user_id_unique",
|
||||
"columns": [
|
||||
"user_id"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"saved_lists_user_id_user_id_fk": {
|
||||
"name": "saved_lists_user_id_user_id_fk",
|
||||
"tableFrom": "saved_lists",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"session": {
|
||||
"name": "session",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"token": {
|
||||
"name": "token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"ip_address": {
|
||||
"name": "ip_address",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_agent": {
|
||||
"name": "user_agent",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"session_token_unique": {
|
||||
"name": "session_token_unique",
|
||||
"columns": [
|
||||
"token"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"session_user_id_user_id_fk": {
|
||||
"name": "session_user_id_user_id_fk",
|
||||
"tableFrom": "session",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"tadimlik_havuzu": {
|
||||
"name": "tadimlik_havuzu",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"kova_slug": {
|
||||
"name": "kova_slug",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"tur": {
|
||||
"name": "tur",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"kategori_slug": {
|
||||
"name": "kategori_slug",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"program_id": {
|
||||
"name": "program_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"dilim": {
|
||||
"name": "dilim",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"gerekce": {
|
||||
"name": "gerekce",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"risk_notu": {
|
||||
"name": "risk_notu",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"trend_ozeti": {
|
||||
"name": "trend_ozeti",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"program": {
|
||||
"name": "program",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"ornek_sira": {
|
||||
"name": "ornek_sira",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"uretim_at": {
|
||||
"name": "uretim_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"tadimlik_anahtar": {
|
||||
"name": "tadimlik_anahtar",
|
||||
"columns": [
|
||||
"kova_slug",
|
||||
"tur",
|
||||
"kategori_slug"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"user": {
|
||||
"name": "user",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"email_verified": {
|
||||
"name": "email_verified",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"image": {
|
||||
"name": "image",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"credit_balance": {
|
||||
"name": "credit_balance",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"has_paket": {
|
||||
"name": "has_paket",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"kredi_bitti_at": {
|
||||
"name": "kredi_bitti_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"kredi_hatirlatma_gonderildi_at": {
|
||||
"name": "kredi_hatirlatma_gonderildi_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"user_email_unique": {
|
||||
"name": "user_email_unique",
|
||||
"columns": [
|
||||
"email"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"verification": {
|
||||
"name": "verification",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"identifier": {
|
||||
"name": "identifier",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
13
drizzle/meta/_journal.json
Normal file
13
drizzle/meta/_journal.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1786053613118,
|
||||
"tag": "0000_baslangic",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,11 +1,43 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
// Dockerfile builder katmanı BUILD_KAYNAK_KISITLI=1 verir: imaj build'i prod
|
||||
// VPS'te çalışan uygulamayla aynı makinede koştuğu için bellek/paralellik kısılır.
|
||||
// Lokal build ve dev bu kısıtlardan etkilenmez.
|
||||
const kaynakKisitli = process.env.BUILD_KAYNAK_KISITLI === "1";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
// PPR: statik kabuk anında servis edilir, dinamik parçalar (UserNav vb.)
|
||||
// Suspense sınırlarının içinde akar. Bkz. docs/cacheComponents.md.
|
||||
cacheComponents: true,
|
||||
output: "standalone",
|
||||
serverExternalPackages: ["better-sqlite3", "iyzipay", "@libsql/client"],
|
||||
// Server-only paketler bundle dışında tutulur: Turbopack bunları derlemez
|
||||
// (build belleği ciddi düşer), runtime'da native require ile yüklenir ve
|
||||
// standalone trace'i imaja kopyalar. (Tarihçe: @google/genai'nin sürüklediği
|
||||
// bağımlılık zinciri VPS'te build OOM'unu tetiklemişti; AI katmanı artık
|
||||
// yalnızca openai paketiyle OpenRouter'a gidiyor.)
|
||||
serverExternalPackages: [
|
||||
"better-sqlite3",
|
||||
"iyzipay",
|
||||
"@libsql/client",
|
||||
"openai",
|
||||
"resend",
|
||||
],
|
||||
...(kaynakKisitli
|
||||
? {
|
||||
// cacheComponents açıkken prerender fazında source map üretimi varsayılan
|
||||
// açık; 1900+ statik sayfada ciddi bellek maliyeti var.
|
||||
enablePrerenderSourceMaps: false,
|
||||
experimental: {
|
||||
// Turbopack'ın Rust tarafı NODE_OPTIONS heap sınırını görmez; bellek
|
||||
// hedefi byte cinsinden ayrıca verilir (1.5 GB).
|
||||
turbopackMemoryLimit: 1_610_612_736,
|
||||
// Statik üretim: varsayılan 4 worker × 8 eşzamanlı sayfa yerine tek
|
||||
// worker × 4 sayfa. Her worker ayrı bir Node süreci/heap'idir.
|
||||
cpus: 1,
|
||||
staticGenerationMaxConcurrency: 4,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
async redirects() {
|
||||
// Eski akış /sonuc modalına taşındı. /rapor/yazdir korunur (tam eşleşme).
|
||||
return [
|
||||
|
||||
12500
package-lock.json
generated
12500
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
12
package.json
12
package.json
@@ -10,14 +10,16 @@
|
||||
"ingest": "tsx scripts/ingest.ts",
|
||||
"refresh": "tsx scripts/refresh.ts",
|
||||
"detay": "tsx scripts/detay.ts",
|
||||
"logolar": "tsx scripts/logo-indir.ts"
|
||||
"logolar": "tsx scripts/logo-indir.ts",
|
||||
"db:push": "drizzle-kit push",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "node scripts/db-goc.mjs",
|
||||
"tadimlik": "tsx scripts/tadimlik-uret.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.112.4",
|
||||
"@cds/city": "^1.1.0",
|
||||
"@libsql/client": "^0.17.4",
|
||||
"@number-flow/react": "^0.6.2",
|
||||
"@react-pdf/renderer": "^4.5.1",
|
||||
"better-auth": "^1.6.23",
|
||||
"better-sqlite3": "^12.11.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
@@ -27,9 +29,11 @@
|
||||
"lenis": "^1.3.25",
|
||||
"lucide-react": "^1.25.0",
|
||||
"marked": "^18.0.7",
|
||||
"mermaid": "^11.16.0",
|
||||
"motion": "^12.42.2",
|
||||
"next": "16.2.10",
|
||||
"next-themes": "^0.4.6",
|
||||
"openai": "^7.4.0",
|
||||
"radix-ui": "^1.6.3",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
@@ -39,12 +43,12 @@
|
||||
"remark-gfm": "^4.0.1",
|
||||
"resend": "^6.18.0",
|
||||
"shiki": "^4.3.1",
|
||||
"simple-icons": "^16.27.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"turkey-map-react": "^2.0.5",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"use-stick-to-bottom": "^1.1.6",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
1278
pnpm-lock.yaml
generated
1278
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
BIN
public/kolay-tercih-mark.png
Normal file
BIN
public/kolay-tercih-mark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.0 KiB |
157
scripts/db-goc.mjs
Normal file
157
scripts/db-goc.mjs
Normal file
@@ -0,0 +1,157 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* app.db şemasını drizzle/ altındaki migration'larla eşitler — konteyner
|
||||
* açılışında (docker-entrypoint.sh) node server.js'den ÖNCE koşar, lokalde
|
||||
* `pnpm db:migrate` ile çalıştırılır. Idempotenttir.
|
||||
*
|
||||
* Neden drizzle-orm'un kendi migrate()'i değil: standalone çıktıda drizzle-orm
|
||||
* Turbopack'çe bundle'landığı için /app/node_modules'ta paket olarak yok;
|
||||
* @libsql/client ise serverExternalPackages'ta olduğundan her zaman çözülür.
|
||||
* Bu script drizzle migrator protokolünü birebir taklit eder (__drizzle_migrations
|
||||
* tablosu, sha256 hash, created_at = journal.when) — ileride resmi migrate()'e
|
||||
* geçiş kayıtları bozmaz.
|
||||
*
|
||||
* Benimseme (adoption): migration akışından önce doldurulmuş prod volume'lerinde
|
||||
* __drizzle_migrations tablosu yoktur ama şema (kısmen) vardır. Bu durumda
|
||||
* baseline (0000) bir kez additive uygulanır — CREATE'ler IF NOT EXISTS'e
|
||||
* çevrilir, eksik kolonlar snapshot'tan ALTER TABLE ile eklenir — ve baseline
|
||||
* uygulanmış olarak damgalanır. Sonraki migration'lar (0001+) normal akışla gelir.
|
||||
*/
|
||||
import { createClient } from "@libsql/client";
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import process from "node:process";
|
||||
|
||||
const GOC_KLASORU = process.env.DRIZZLE_DIR ?? join(process.cwd(), "drizzle");
|
||||
const DB_URL = process.env.APP_DB_URL ?? "file:./data/app.db";
|
||||
|
||||
const GOC_TABLOSU_DDL = `CREATE TABLE IF NOT EXISTS "__drizzle_migrations" (
|
||||
id SERIAL PRIMARY KEY,
|
||||
hash text NOT NULL,
|
||||
created_at numeric
|
||||
)`;
|
||||
|
||||
/** Journal'daki her girdiyi { when, tag, hash, ifadeler[] } olarak yükler. */
|
||||
function gocleriOku() {
|
||||
const journal = JSON.parse(
|
||||
readFileSync(join(GOC_KLASORU, "meta", "_journal.json"), "utf8"),
|
||||
);
|
||||
return journal.entries.map((girdi) => {
|
||||
const sql = readFileSync(join(GOC_KLASORU, `${girdi.tag}.sql`), "utf8");
|
||||
return {
|
||||
when: girdi.when,
|
||||
tag: girdi.tag,
|
||||
hash: createHash("sha256").update(sql).digest("hex"),
|
||||
ifadeler: sql
|
||||
.split("--> statement-breakpoint")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function tabloVar(db, ad) {
|
||||
const r = await db.execute({
|
||||
sql: "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?",
|
||||
args: [ad],
|
||||
});
|
||||
return r.rows.length > 0;
|
||||
}
|
||||
|
||||
/** Snapshot'taki default değerini SQL literaline çevirir (string zaten tırnaklı gelir). */
|
||||
function sqlVarsayilan(deger) {
|
||||
return typeof deger === "string" ? deger : String(deger);
|
||||
}
|
||||
|
||||
/**
|
||||
* Benimseme adımı: baseline'ı additive uygular.
|
||||
* 1) CREATE TABLE/INDEX ifadeleri IF NOT EXISTS ile koşulur (eksik tablolar gelir).
|
||||
* 2) Mevcut tablolarda eksik kolonlar baseline snapshot'ından ALTER TABLE ile eklenir.
|
||||
*/
|
||||
async function baselineIleUzlastir(db, baseline) {
|
||||
for (const ifade of baseline.ifadeler) {
|
||||
const korunmus = ifade
|
||||
.replace(/^CREATE TABLE `/, "CREATE TABLE IF NOT EXISTS `")
|
||||
.replace(/^CREATE INDEX `/, "CREATE INDEX IF NOT EXISTS `")
|
||||
.replace(/^CREATE UNIQUE INDEX `/, "CREATE UNIQUE INDEX IF NOT EXISTS `");
|
||||
await db.execute(korunmus);
|
||||
}
|
||||
|
||||
const snapshot = JSON.parse(
|
||||
readFileSync(join(GOC_KLASORU, "meta", "0000_snapshot.json"), "utf8"),
|
||||
);
|
||||
for (const tablo of Object.values(snapshot.tables)) {
|
||||
const mevcut = await db.execute(`PRAGMA table_info(\`${tablo.name}\`)`);
|
||||
const mevcutKolonlar = new Set(mevcut.rows.map((r) => r.name));
|
||||
for (const kolon of Object.values(tablo.columns)) {
|
||||
if (mevcutKolonlar.has(kolon.name)) continue;
|
||||
let ddl = `ALTER TABLE \`${tablo.name}\` ADD COLUMN \`${kolon.name}\` ${kolon.type}`;
|
||||
if (kolon.default !== undefined) {
|
||||
ddl += ` DEFAULT ${sqlVarsayilan(kolon.default)}`;
|
||||
if (kolon.notNull) ddl += " NOT NULL";
|
||||
} else if (kolon.notNull) {
|
||||
// Dolu tabloya default'suz NOT NULL kolon eklenemez (SQLite kısıtı);
|
||||
// nullable ekleyip uyarıyoruz — uygulama zaten değeri hep yazıyor.
|
||||
console.warn(
|
||||
`[db-goc] uyarı: ${tablo.name}.${kolon.name} NOT NULL ama default'suz — nullable eklendi`,
|
||||
);
|
||||
}
|
||||
await db.execute(ddl);
|
||||
console.log(`[db-goc] kolon eklendi: ${tablo.name}.${kolon.name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const gocler = gocleriOku();
|
||||
const db = createClient({
|
||||
url: DB_URL,
|
||||
authToken: process.env.APP_DB_AUTH_TOKEN,
|
||||
});
|
||||
|
||||
try {
|
||||
// Benimseme: şema var ama migration kaydı yok → baseline'ı uzlaştır + damgala.
|
||||
const kayitVar = await tabloVar(db, "__drizzle_migrations");
|
||||
if (!kayitVar && (await tabloVar(db, "user"))) {
|
||||
console.log("[db-goc] migration kaydı yok, mevcut şema benimseniyor...");
|
||||
await baselineIleUzlastir(db, gocler[0]);
|
||||
await db.execute(GOC_TABLOSU_DDL);
|
||||
await db.execute({
|
||||
sql: 'INSERT INTO "__drizzle_migrations" (hash, created_at) VALUES (?, ?)',
|
||||
args: [gocler[0].hash, gocler[0].when],
|
||||
});
|
||||
console.log(`[db-goc] baseline damgalandı: ${gocler[0].tag}`);
|
||||
}
|
||||
|
||||
// Normal akış: son uygulanan migration'dan sonrakileri sırayla uygula.
|
||||
await db.execute(GOC_TABLOSU_DDL);
|
||||
const son = await db.execute(
|
||||
'SELECT created_at FROM "__drizzle_migrations" ORDER BY created_at DESC LIMIT 1',
|
||||
);
|
||||
const sonZaman = son.rows.length ? Number(son.rows[0].created_at) : 0;
|
||||
|
||||
for (const goc of gocler) {
|
||||
if (goc.when <= sonZaman) continue;
|
||||
await db.batch(
|
||||
[
|
||||
...goc.ifadeler,
|
||||
{
|
||||
sql: 'INSERT INTO "__drizzle_migrations" (hash, created_at) VALUES (?, ?)',
|
||||
args: [goc.hash, goc.when],
|
||||
},
|
||||
],
|
||||
"write",
|
||||
);
|
||||
console.log(`[db-goc] uygulandı: ${goc.tag}`);
|
||||
}
|
||||
console.log("[db-goc] şema güncel");
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((hata) => {
|
||||
console.error("[db-goc] migration başarısız:", hata);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -9,9 +9,14 @@ for ek in wal shm; do
|
||||
fi
|
||||
done
|
||||
|
||||
# app.db kullanıcı verisi: sadece volume'de yoksa (ilk kurulum) şema tohumu kopyalanır
|
||||
# app.db kullanıcı verisi: sadece volume'de yoksa (ilk kurulum) tohum kopyalanır
|
||||
# (tohum tadimlik_havuzu içeriği de taşır, salt şema değil)
|
||||
if [ ! -s /app/data/app.db ] && [ -f /app/seed/app.db ]; then
|
||||
cp /app/seed/app.db /app/data/
|
||||
fi
|
||||
|
||||
# Volume'deki app.db şeması güncel şemayla eşitlenir: drizzle/ altındaki
|
||||
# migration'lar uygulanır (idempotent; ilk koşuda mevcut şema benimsenir).
|
||||
node /app/scripts/db-goc.mjs
|
||||
|
||||
exec node server.js
|
||||
|
||||
41
scripts/standalone-linkleri-onar.sh
Normal file
41
scripts/standalone-linkleri-onar.sh
Normal file
@@ -0,0 +1,41 @@
|
||||
#!/bin/sh
|
||||
# Next standalone çıktısında eksik kök node_modules linklerini tamamlar.
|
||||
#
|
||||
# Neden: Turbopack, serverExternalPackages'taki ESM paketleri (openai,
|
||||
# resend) çalışma anında bare import ile yükler ama nft
|
||||
# trace'ine yalnızca .pnpm gerçek yollarını yazar — kök symlink standalone'a
|
||||
# kopyalanmaz ve import MODULE_NOT_FOUND ile düşer. (CJS external'larda —
|
||||
# better-sqlite3, iyzipay — symlink trace'e giriyor, sorun yalnızca ESM'de.)
|
||||
# Bu script .pnpm altındaki her paket için eksik kök girdisini symlink'ler;
|
||||
# mevcut girdilere dokunmaz. Yeni bir external eklendiğinde de kendiliğinden
|
||||
# kapsar, paket adı/versiyon sabitlemez.
|
||||
set -eu
|
||||
|
||||
KOK="${1:-.next/standalone}/node_modules"
|
||||
[ -d "$KOK/.pnpm" ] || { echo "standalone .pnpm yok, atlandi: $KOK"; exit 0; }
|
||||
cd "$KOK"
|
||||
|
||||
for d in .pnpm/*/node_modules/*; do
|
||||
[ -e "$d" ] || continue
|
||||
base=${d##*/}
|
||||
case "$base" in
|
||||
.*) continue ;;
|
||||
@*)
|
||||
for sub in "$d"/*; do
|
||||
[ -d "$sub" ] || continue
|
||||
hedef="$base/${sub##*/}"
|
||||
if [ ! -e "$hedef" ]; then
|
||||
mkdir -p "$base"
|
||||
ln -s "../$sub" "$hedef"
|
||||
echo "link: $hedef"
|
||||
fi
|
||||
done
|
||||
;;
|
||||
*)
|
||||
if [ ! -e "$base" ]; then
|
||||
ln -s "$d" "$base"
|
||||
echo "link: $base"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
done
|
||||
261
scripts/tadimlik-uret.ts
Normal file
261
scripts/tadimlik-uret.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* Tadımlık havuzu batch üretimi: kova × puan türü × (kategori + "genel")
|
||||
* kombinasyonları için 1'er örnek tercih satırı (gerekce/riskNotu/trendOzeti)
|
||||
* üretip app.db'deki tadimlik_havuzu tablosuna yazar.
|
||||
*
|
||||
* pnpm tadimlik # boş anahtarları doldurur (idempotent)
|
||||
* pnpm tadimlik --force # mevcut satırları da yeniden üretir
|
||||
* pnpm tadimlik --kova=10-50k --tur=say --kategori=muhendislik-teknoloji
|
||||
* pnpm tadimlik --mock # anahtar olsa da mock metin (dev/test)
|
||||
* pnpm tadimlik --bekleme=13000 # LLM çağrıları arası bekleme (ms)
|
||||
*
|
||||
* AI anahtarı yoksa (dev) mock metin üretir — akış uçtan uca test edilebilir.
|
||||
* OpenRouter'da katı bir dakikalık limit yok; --bekleme varsayılanı (500 ms)
|
||||
* genelde yeterli, 429 görürsen tempoyu artır.
|
||||
* Anonim trafik LLM'e asla istek atmaz; tek maliyet bu script'in koşusudur.
|
||||
*/
|
||||
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 { KATEGORILER, kategoriEslesir } from "../src/lib/kategoriler";
|
||||
import { havuzuKompaktJson, turLabel } from "../src/lib/rapor-havuzu";
|
||||
import { GENEL_KATEGORI, KOVALAR, kategoriSlugBul } from "../src/lib/tadimlik-havuzu";
|
||||
import { anahtarVar } from "../src/lib/ai/client";
|
||||
import { yapilandirilmisUret } from "../src/lib/ai/cagri";
|
||||
|
||||
// Next dışında koşuyoruz; .env dosyalarını elle yükle (mevcut env kazanır).
|
||||
function envYukle(dosya: string): void {
|
||||
if (!existsSync(dosya)) return;
|
||||
for (const satir of readFileSync(dosya, "utf8").split("\n")) {
|
||||
const m = satir.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
|
||||
if (m && process.env[m[1]] === undefined) {
|
||||
process.env[m[1]] = m[2].replace(/^"(.*)"$/, "$1").trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
envYukle(".env.local");
|
||||
envYukle(".env");
|
||||
|
||||
const TadimlikSchema = z.object({
|
||||
gerekce: z.string(),
|
||||
riskNotu: z.string(),
|
||||
trendOzeti: z.string(),
|
||||
});
|
||||
|
||||
// rapor.ts'teki SISTEM kurallarının tek satırlık versiyonu.
|
||||
const SISTEM = `Sen KolayTercih'in kıdemli YKS tercih danışmanısın. Sana GERÇEK
|
||||
YÖK Atlas verisiyle TEK bir program ve temsili bir aday tanımı verilecek; bu
|
||||
program için tercih listesi satırı metinleri üreteceksin.
|
||||
|
||||
KURALLAR (kesin):
|
||||
- METİN DİYETİ: her alan TEK kısa cümle, dolgu söz yok.
|
||||
- gerekce: bu programın bu aday için neden mantıklı olduğu (TEK cümle,
|
||||
en fazla ~15 kelime, Türkçe).
|
||||
- riskNotu: yerleşme ihtimali değerlendirmesi (TEK kısa cümle). ASLA "garanti",
|
||||
"kesin", "yüzde yüz" deme; "yüksek/orta/düşük ihtimal" dili kullan.
|
||||
- trendOzeti: son 4 yılın taban sıralaması hareketi, TEK cümle (ör. "2022'den
|
||||
beri taban 45B'den 38B'ye indi, bölüm zorlaşıyor").
|
||||
- Adayın sıralaması yaklaşıktır; kesin sıra telaffuz etme, banda göre konuş.`;
|
||||
|
||||
function mockAktif(): boolean {
|
||||
if (process.argv.includes("--mock")) return true;
|
||||
return process.env.NODE_ENV !== "production" && !anahtarVar();
|
||||
}
|
||||
|
||||
function mockMetin(p: Program, temsiliSira: number, kategori: string | null) {
|
||||
return {
|
||||
gerekce: `(Dev mock) ${p.isim}, ${kategori ? `${kategori} ilgisiyle ve ` : ""}~${temsiliSira.toLocaleString("tr-TR")} bandındaki sıralamanla uyumlu bir aday.`,
|
||||
riskNotu:
|
||||
"(Dev mock) Taban sıralaması senin civarında; orta-yüksek ihtimalle yerleşilebilir.",
|
||||
trendOzeti:
|
||||
"(Dev mock) Son 4 yılda taban sıralaması görece stabil seyretti.",
|
||||
};
|
||||
}
|
||||
|
||||
/** rapor.ts programBilgisi ile aynı snapshot şekli. */
|
||||
function programBilgisi(p: Program) {
|
||||
const gecmis = [p.sira2021, p.sira2022, p.sira2023, p.sira2024, p.sira2025];
|
||||
return {
|
||||
isim: p.isim,
|
||||
universite: p.universite,
|
||||
il: p.il,
|
||||
unitur: p.unitur,
|
||||
efektifSira: p.sira2025 ?? p.sira2024,
|
||||
siraGecmisi: gecmis.some((s) => s != null) ? gecmis : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministik program seçimi: kovanın temsili sırası için "dengeli" dilimden
|
||||
* (boşsa "garanti") kategoriye uyan, 2025'te en çok yerleşen alan program.
|
||||
* Tanınır/dolu programlar tadımlıkta daha ikna edici.
|
||||
*/
|
||||
function programSec(
|
||||
temsiliSira: number,
|
||||
tur: PuanTuruKey,
|
||||
kategori: string | null,
|
||||
): { program: Program; dilim: DilimKey } | null {
|
||||
for (const dilim of ["dengeli", "garanti"] as const) {
|
||||
const { programlar } = dilimAra(temsiliSira, tur, dilim, { limit: 60 });
|
||||
const uygunlar = kategori
|
||||
? programlar.filter((p) => kategoriEslesir(p.isim, [kategori]))
|
||||
: programlar;
|
||||
if (uygunlar.length === 0) continue;
|
||||
const secilen = [...uygunlar].sort(
|
||||
(a, b) => (b.yerlesen2025 ?? -1) - (a.yerlesen2025 ?? -1),
|
||||
)[0];
|
||||
return { program: secilen, dilim };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function metinUret(
|
||||
p: Program,
|
||||
dilim: DilimKey,
|
||||
temsiliSira: number,
|
||||
tur: PuanTuruKey,
|
||||
kategori: string | null,
|
||||
): Promise<z.infer<typeof TadimlikSchema> | null> {
|
||||
if (mockAktif()) return mockMetin(p, temsiliSira, kategori);
|
||||
|
||||
const kullanici = [
|
||||
`Temsili aday: başarı sıralaması ~${temsiliSira.toLocaleString("tr-TR")} bandında, puan türü ${turLabel(tur)}.`,
|
||||
kategori ? `Adayın ilgi alanı: ${kategori}.` : null,
|
||||
`PROGRAM (yalnızca bunun için metin üret): ${havuzuKompaktJson([{ ...p, dilim }])}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
|
||||
return yapilandirilmisUret({
|
||||
system: SISTEM,
|
||||
kullanici,
|
||||
sema: TadimlikSchema,
|
||||
maxTokens: 4000,
|
||||
});
|
||||
}
|
||||
|
||||
function bayrak(ad: string): string | undefined {
|
||||
const arg = process.argv.find((a) => a.startsWith(`--${ad}=`));
|
||||
return arg?.slice(ad.length + 3);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const force = process.argv.includes("--force");
|
||||
const kovaFiltre = bayrak("kova");
|
||||
const turFiltre = bayrak("tur");
|
||||
const kategoriFiltre = bayrak("kategori");
|
||||
|
||||
const turlar = (Object.keys(PUAN_TURLERI) as PuanTuruKey[]).filter(
|
||||
(t) => !turFiltre || t === turFiltre,
|
||||
);
|
||||
const kategoriler: { ad: string | null; slug: string }[] = [
|
||||
{ ad: null, slug: GENEL_KATEGORI },
|
||||
...KATEGORILER.map((ad) => ({ ad: ad as string, slug: kategoriSlugBul(ad) })),
|
||||
].filter((k) => !kategoriFiltre || k.slug === kategoriFiltre);
|
||||
const kovalar = KOVALAR.filter((k) => !kovaFiltre || k.slug === kovaFiltre);
|
||||
|
||||
const mevcutlar = new Set(
|
||||
(
|
||||
await appDb
|
||||
.select({
|
||||
kovaSlug: schema.tadimlikHavuzu.kovaSlug,
|
||||
tur: schema.tadimlikHavuzu.tur,
|
||||
kategoriSlug: schema.tadimlikHavuzu.kategoriSlug,
|
||||
})
|
||||
.from(schema.tadimlikHavuzu)
|
||||
).map((s) => `${s.kovaSlug}|${s.tur}|${s.kategoriSlug}`),
|
||||
);
|
||||
|
||||
if (mockAktif()) {
|
||||
console.log("AI anahtarı yok — mock metinlerle üretiliyor (dev).");
|
||||
}
|
||||
|
||||
let uretilen = 0;
|
||||
let atlananMevcut = 0;
|
||||
let atlananBos = 0;
|
||||
|
||||
for (const kova of kovalar) {
|
||||
for (const tur of turlar) {
|
||||
for (const kategori of kategoriler) {
|
||||
const anahtar = `${kova.slug}|${tur}|${kategori.slug}`;
|
||||
if (!force && mevcutlar.has(anahtar)) {
|
||||
atlananMevcut++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const secim = programSec(kova.temsiliSira, tur, kategori.ad);
|
||||
if (!secim) {
|
||||
atlananBos++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const metin = await metinUret(
|
||||
secim.program,
|
||||
secim.dilim,
|
||||
kova.temsiliSira,
|
||||
tur,
|
||||
kategori.ad,
|
||||
);
|
||||
if (!metin) {
|
||||
console.warn(`Şemaya uymayan çıktı, atlandı: ${anahtar}`);
|
||||
atlananBos++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
await appDb
|
||||
.insert(schema.tadimlikHavuzu)
|
||||
.values({
|
||||
id: randomUUID(),
|
||||
kovaSlug: kova.slug,
|
||||
tur,
|
||||
kategoriSlug: kategori.slug,
|
||||
programId: secim.program.id,
|
||||
dilim: secim.dilim,
|
||||
gerekce: metin.gerekce,
|
||||
riskNotu: metin.riskNotu,
|
||||
trendOzeti: metin.trendOzeti,
|
||||
program: programBilgisi(secim.program),
|
||||
ornekSira: kova.temsiliSira,
|
||||
uretimAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [
|
||||
schema.tadimlikHavuzu.kovaSlug,
|
||||
schema.tadimlikHavuzu.tur,
|
||||
schema.tadimlikHavuzu.kategoriSlug,
|
||||
],
|
||||
set: {
|
||||
programId: secim.program.id,
|
||||
dilim: secim.dilim,
|
||||
gerekce: metin.gerekce,
|
||||
riskNotu: metin.riskNotu,
|
||||
trendOzeti: metin.trendOzeti,
|
||||
program: programBilgisi(secim.program),
|
||||
ornekSira: kova.temsiliSira,
|
||||
uretimAt: now,
|
||||
},
|
||||
});
|
||||
|
||||
uretilen++;
|
||||
console.log(`✓ ${anahtar} → ${secim.program.isim} (${secim.dilim})`);
|
||||
// Gerçek LLM'de rate limit dostu ardışık tempo
|
||||
if (!mockAktif()) {
|
||||
const bekleme = Number.parseInt(bayrak("bekleme") ?? "500", 10);
|
||||
await new Promise((r) => setTimeout(r, bekleme));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Bitti: ${uretilen} üretildi, ${atlananMevcut} mevcut atlandı, ${atlananBos} içeriksiz/şemasız atlandı.`,
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((hata) => {
|
||||
console.error(hata);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -43,6 +43,12 @@
|
||||
"skillPath": "skills/pick-ui-library/SKILL.md",
|
||||
"computedHash": "f8d4d2cf4677bf54b14f35f62a741add93541bfcb67863315a40164365161b49"
|
||||
},
|
||||
"prototype": {
|
||||
"source": "emilkowalski/skill",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/prototype/SKILL.md",
|
||||
"computedHash": "f246d1f47566481d9207e1aa574bdee5454257d862919a6591283e9af2f57eab"
|
||||
},
|
||||
"review-animations": {
|
||||
"source": "emilkowalski/skill",
|
||||
"sourceType": "github",
|
||||
|
||||
19
src/app/api/netler/route.ts
Normal file
19
src/app/api/netler/route.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import { netlerGetir } from "@/lib/db";
|
||||
|
||||
// Program satırındaki "netler" butonunun verisi: programa yerleşen son
|
||||
// kişinin yıl bazlı netleri (bkz. components/program-netleri.tsx). Veri
|
||||
// yılda bir değiştiği için istemci tarafında gönül rahatlığıyla cache'lenir.
|
||||
export function GET(request: NextRequest) {
|
||||
const id = request.nextUrl.searchParams.get("id") ?? "";
|
||||
if (!/^\d{1,12}$/.test(id)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Geçerli bir program kimliği gerekli." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ netler: netlerGetir(id) },
|
||||
{ headers: { "Cache-Control": "public, max-age=86400" } },
|
||||
);
|
||||
}
|
||||
@@ -2,8 +2,13 @@ import { eq, desc, asc } from "drizzle-orm";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { getSession } from "@/lib/session";
|
||||
import { appDb, schema } from "@/lib/appdb";
|
||||
import { spendCreditForMessage, grantCredits } from "@/lib/credits";
|
||||
import { getAnthropic, SOHBET_MODEL, SORUMLULUK_REDDI } from "@/lib/ai/client";
|
||||
import {
|
||||
spendCreditForMessage,
|
||||
grantCredits,
|
||||
krediBittiIsaretle,
|
||||
} from "@/lib/credits";
|
||||
import { anahtarVar, SORUMLULUK_REDDI } from "@/lib/ai/client";
|
||||
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";
|
||||
@@ -146,6 +151,7 @@ export async function POST(request: NextRequest) {
|
||||
});
|
||||
if (!harcama.ok) {
|
||||
if (harcama.error === "INSUFFICIENT") {
|
||||
await krediBittiIsaretle(session.user.id);
|
||||
return Response.json(
|
||||
{ error: "Kredin bitti.", code: "INSUFFICIENT" },
|
||||
{ status: 402 },
|
||||
@@ -183,15 +189,11 @@ export async function POST(request: NextRequest) {
|
||||
});
|
||||
|
||||
// Dev'de API key yoksa danışman mock cevap stream'ler (prod'da asla)
|
||||
if (
|
||||
process.env.NODE_ENV !== "production" &&
|
||||
!process.env.ANTHROPIC_API_KEY &&
|
||||
!process.env.ANTHROPIC_AUTH_TOKEN
|
||||
) {
|
||||
if (process.env.NODE_ENV !== "production" && !anahtarVar()) {
|
||||
const mockEncoder = new TextEncoder();
|
||||
const mockUserId = session.user.id;
|
||||
const cevap =
|
||||
`(Dev mock — ANTHROPIC_API_KEY tanımlı değil.) Sorunu aldım: "${mesaj.slice(0, 120)}". ` +
|
||||
`(Dev mock — Yapay Zeka anahtarı tanımlı değil.) Sorunu aldım: "${mesaj.slice(0, 120)}". ` +
|
||||
`Gerçek danışman burada listen ve seçimlerin bağlamında yanıt verecek. ` +
|
||||
`${SORUMLULUK_REDDI}`;
|
||||
const mockReadable = new ReadableStream({
|
||||
@@ -219,17 +221,14 @@ export async function POST(request: NextRequest) {
|
||||
});
|
||||
}
|
||||
|
||||
let client;
|
||||
try {
|
||||
client = getAnthropic();
|
||||
} catch {
|
||||
if (!anahtarVar()) {
|
||||
await grantCredits({
|
||||
userId: session.user.id,
|
||||
delta: 1,
|
||||
reason: "refund",
|
||||
refId: kullaniciMesajId,
|
||||
});
|
||||
console.error("[soru] AI istemcisi kurulamadı (anahtar eksik olabilir)");
|
||||
console.error("[soru] Yapay Zeka anahtarı tanımlı değil");
|
||||
return Response.json(
|
||||
{
|
||||
error:
|
||||
@@ -250,36 +249,20 @@ export async function POST(request: NextRequest) {
|
||||
const encoder = new TextEncoder();
|
||||
const userId = session.user.id;
|
||||
|
||||
let stream;
|
||||
try {
|
||||
stream = client.messages.stream({
|
||||
model: SOHBET_MODEL,
|
||||
max_tokens: 1024,
|
||||
system,
|
||||
messages: mesajlar,
|
||||
});
|
||||
} catch {
|
||||
await grantCredits({ userId, delta: 1, reason: "refund", refId: kullaniciMesajId });
|
||||
return Response.json({ error: "AI'ya ulaşılamadı." }, { status: 502 });
|
||||
}
|
||||
|
||||
const readable = new ReadableStream({
|
||||
async start(controller) {
|
||||
let tamamlandi = false;
|
||||
let metin = "";
|
||||
try {
|
||||
for await (const event of stream) {
|
||||
if (
|
||||
event.type === "content_block_delta" &&
|
||||
event.delta.type === "text_delta"
|
||||
) {
|
||||
controller.enqueue(encoder.encode(event.delta.text));
|
||||
}
|
||||
// Sağlayıcıya bağlanma hatası da ilk parçada burada yakalanır
|
||||
for await (const parca of sohbetAkisi({
|
||||
system,
|
||||
mesajlar,
|
||||
maxTokens: 1024,
|
||||
})) {
|
||||
metin += parca;
|
||||
controller.enqueue(encoder.encode(parca));
|
||||
}
|
||||
const final = await stream.finalMessage();
|
||||
const metin = final.content
|
||||
.filter((b) => b.type === "text")
|
||||
.map((b) => b.text)
|
||||
.join("");
|
||||
await appDb.insert(schema.chatMessages).values({
|
||||
id: crypto.randomUUID(),
|
||||
userId,
|
||||
@@ -291,7 +274,20 @@ export async function POST(request: NextRequest) {
|
||||
controller.close();
|
||||
} catch (err) {
|
||||
if (!tamamlandi) {
|
||||
// Hiç çıktı üretilmeden hata olduysa krediyi iade et
|
||||
// Cevap tamamlanmadı: kredi iade edilir. Yarıda kalan metin varsa
|
||||
// yine de kaydedilir ki kullanıcı ekranda gördüğü kısmı sayfa
|
||||
// yenilenince kaybetmesin.
|
||||
if (metin) {
|
||||
try {
|
||||
await appDb.insert(schema.chatMessages).values({
|
||||
id: crypto.randomUUID(),
|
||||
userId,
|
||||
role: "assistant",
|
||||
content: metin,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
await grantCredits({
|
||||
userId,
|
||||
delta: 1,
|
||||
|
||||
15
src/app/api/sosyal-kanit/route.ts
Normal file
15
src/app/api/sosyal-kanit/route.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
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);
|
||||
// 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
|
||||
// sıklığı DB/Rybbit'e yansımaz.
|
||||
export async function GET() {
|
||||
const veri = await sosyalKanitVerisi();
|
||||
return NextResponse.json(veri, {
|
||||
headers: { "Cache-Control": "no-store" },
|
||||
});
|
||||
}
|
||||
38
src/app/api/tadimlik/route.ts
Normal file
38
src/app/api/tadimlik/route.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
// Tadımlık satırı servisi: client sihirbaz kategorisini (localStorage'da
|
||||
// yaşar, SSR göremez) bildirir; SEÇİM sunucuda yapılır (PRD kısıtı). Havuz
|
||||
// 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 { KATEGORILER } from "@/lib/kategoriler";
|
||||
import { kategoriSlugBul, tadimlikSec } from "@/lib/tadimlik-havuzu";
|
||||
|
||||
const GECERLI_KATEGORI_SLUGLARI = new Set(KATEGORILER.map(kategoriSlugBul));
|
||||
|
||||
export async function GET(istek: Request) {
|
||||
const url = new URL(istek.url);
|
||||
const sira = Number.parseInt(url.searchParams.get("sira") ?? "", 10);
|
||||
const tur = url.searchParams.get("tur") ?? "";
|
||||
|
||||
if (
|
||||
!Number.isFinite(sira) ||
|
||||
sira < 1 ||
|
||||
sira > 4_000_000 ||
|
||||
!(tur in PUAN_TURLERI)
|
||||
) {
|
||||
return NextResponse.json({ tadimlik: null }, { status: 400 });
|
||||
}
|
||||
|
||||
const kategoriParam = url.searchParams.get("kategori");
|
||||
const kategoriSlug =
|
||||
kategoriParam && GECERLI_KATEGORI_SLUGLARI.has(kategoriParam)
|
||||
? kategoriParam
|
||||
: undefined;
|
||||
|
||||
const tadimlik = await tadimlikSec(sira, tur as PuanTuruKey, kategoriSlug);
|
||||
return NextResponse.json(
|
||||
{ tadimlik },
|
||||
// Satırlar kova granülaritesinde statik; 1 saat cache yeterli.
|
||||
{ headers: { "Cache-Control": "public, max-age=3600" } },
|
||||
);
|
||||
}
|
||||
@@ -4,8 +4,9 @@ import { notFound } from "next/navigation";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { getBolumBySlug, uniSlugFromAd } from "@/lib/katalog";
|
||||
import { JsonLd, breadcrumbJsonLd } from "@/lib/seo";
|
||||
import { Sayfalama, SAYFA_BOYU } from "@/components/sayfalama";
|
||||
import { SAYFA_BOYU } from "@/components/sayfalama";
|
||||
import { BolumProgramListesi } from "@/components/bolum-program-listesi";
|
||||
import { SiteFooter } from "@/components/site-footer";
|
||||
|
||||
const sayi = (n: number) => n.toLocaleString("tr-TR");
|
||||
|
||||
@@ -45,15 +46,12 @@ export function BolumIcerik({ slug, sayfa }: { slug: string; sayfa: number }) {
|
||||
];
|
||||
const toplamSayfa = bolumToplamSayfa(tumProgramlar.length);
|
||||
if (sayfa < 1 || sayfa > toplamSayfa) notFound();
|
||||
const dilim = tumProgramlar.slice(
|
||||
(sayfa - 1) * SAYFA_BOYU,
|
||||
sayfa * SAYFA_BOYU,
|
||||
);
|
||||
const ilkSayfa = sayfa === 1;
|
||||
const buYol = ilkSayfa ? `/bolum/${slug}` : `/bolum/${slug}/sayfa/${sayfa}`;
|
||||
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-5xl px-4 py-10 sm:py-14">
|
||||
<>
|
||||
<main className="mx-auto w-full max-w-5xl px-4 py-10 sm:py-14">
|
||||
<JsonLd
|
||||
data={breadcrumbJsonLd([
|
||||
{ name: "Ana Sayfa", path: "/" },
|
||||
@@ -111,19 +109,17 @@ export function BolumIcerik({ slug, sayfa }: { slug: string; sayfa: number }) {
|
||||
</div>
|
||||
|
||||
<BolumProgramListesi
|
||||
programlar={dilim.map((program) => ({
|
||||
programlar={tumProgramlar.map((program) => ({
|
||||
...program,
|
||||
universiteSlug: uniSlugFromAd(program.universite),
|
||||
}))}
|
||||
bazAd={bolum.ad}
|
||||
tabanYol={`/bolum/${slug}`}
|
||||
sayfa={sayfa}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<Sayfalama
|
||||
tabanYol={`/bolum/${slug}`}
|
||||
sayfa={sayfa}
|
||||
toplamSayfa={toplamSayfa}
|
||||
/>
|
||||
</main>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,61 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { TextLoop } from "@/components/core/text-loop";
|
||||
import { TypingAnimation } from "@/components/ui/typing-animation";
|
||||
|
||||
// Sonuç sayfasından gelen funnel'da dönen sempatik mesajlar.
|
||||
// Kısa tutulmalı — başlıkta tek satır, taşma yok.
|
||||
// Hepsi aynı şablon: "Giriş yap, çünkü …"
|
||||
const FUNNEL_MESAJLARI = [
|
||||
"1 adım kaldı tercihine",
|
||||
"Listen seni bekliyor 👀",
|
||||
"24 tercih, hepsi sana göre",
|
||||
"5 kredin hazır, ya sen?",
|
||||
", çünkü listen seni bekliyor",
|
||||
", çünkü tercihine 1 adım kaldı",
|
||||
", çünkü seçimlerin hazır",
|
||||
", çünkü 5 kredin yanında",
|
||||
", çünkü kaçırmak istemezsin",
|
||||
", çünkü listen hemen üretilsin",
|
||||
];
|
||||
|
||||
/**
|
||||
* Giriş sayfası başlığı. Kullanıcı sihirbazdan (callback /sonuc'a dönüyorsa)
|
||||
* geldiyse TextLoop'la funnel mesajları döner; diğer girişlerde sabit başlık.
|
||||
* geldiyse sabit "Giriş yap" + typewriter hook'ları; diğer girişlerde
|
||||
* yalnız sabit başlık.
|
||||
*/
|
||||
export function GirisBaslik({ funnel }: { funnel: boolean }) {
|
||||
if (!funnel) {
|
||||
return <h1 className="font-heading text-2xl font-bold">Giriş yap</h1>;
|
||||
return <h1 className="font-heading text-xl font-bold">Giriş yap</h1>;
|
||||
}
|
||||
|
||||
return (
|
||||
<h1 className="font-heading text-2xl font-bold">
|
||||
<TextLoop
|
||||
className="overflow-y-clip"
|
||||
interval={2.5}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 900,
|
||||
damping: 80,
|
||||
mass: 10,
|
||||
}}
|
||||
variants={{
|
||||
initial: {
|
||||
y: 20,
|
||||
rotateX: 90,
|
||||
opacity: 0,
|
||||
filter: "blur(4px)",
|
||||
},
|
||||
animate: {
|
||||
y: 0,
|
||||
rotateX: 0,
|
||||
opacity: 1,
|
||||
filter: "blur(0px)",
|
||||
},
|
||||
exit: {
|
||||
y: -20,
|
||||
rotateX: -90,
|
||||
opacity: 0,
|
||||
filter: "blur(4px)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{FUNNEL_MESAJLARI.map((mesaj) => (
|
||||
<span key={mesaj}>{mesaj}</span>
|
||||
))}
|
||||
</TextLoop>
|
||||
<h1 className="flex flex-wrap items-baseline font-heading text-xl font-bold leading-tight">
|
||||
<span className="shrink-0">Giriş yap</span>
|
||||
<TypingAnimation
|
||||
as="span"
|
||||
words={FUNNEL_MESAJLARI}
|
||||
loop
|
||||
startOnView={false}
|
||||
typeSpeed={42}
|
||||
deleteSpeed={22}
|
||||
pauseDelay={2400}
|
||||
className="min-h-[1.25em] font-heading text-xl font-bold leading-tight tracking-[-0.02em] text-primary"
|
||||
/>
|
||||
</h1>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState } from "react";
|
||||
import { Mail } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { olay } from "@/lib/analitik";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
@@ -52,6 +53,7 @@ export function GirisForm({ callbackURL }: { callbackURL: string }) {
|
||||
|
||||
async function googleIleGiris() {
|
||||
setBusy("google");
|
||||
olay("giris_denendi", { yontem: "google" });
|
||||
const { error } = await authClient.signIn.social({
|
||||
provider: "google",
|
||||
callbackURL,
|
||||
@@ -80,6 +82,7 @@ export function GirisForm({ callbackURL }: { callbackURL: string }) {
|
||||
}
|
||||
setSent(true);
|
||||
geriSayimBaslat();
|
||||
olay("giris_denendi", { yontem: "eposta" });
|
||||
}
|
||||
|
||||
if (sent) {
|
||||
|
||||
@@ -5,8 +5,8 @@ import { Skeleton } from "@/components/ui/skeleton";
|
||||
// içerik gelince zıplama olmaz.
|
||||
export default function GirisLoading() {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-slate-50">
|
||||
<main className="mx-auto flex w-full max-w-md flex-1 flex-col items-stretch justify-center px-4 py-16">
|
||||
<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" />
|
||||
|
||||
@@ -27,8 +27,10 @@ export default async function GirisPage({
|
||||
const funnel = callbackURL.startsWith("/sonuc");
|
||||
|
||||
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 items-stretch justify-center px-5 py-16 sm:px-4">
|
||||
// -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">
|
||||
<GirisBaslik funnel={funnel} />
|
||||
@@ -43,7 +45,7 @@ export default async function GirisPage({
|
||||
<>
|
||||
Kayıt olduğunda{" "}
|
||||
<span className="font-semibold">5 deneme kredisi</span>{" "}
|
||||
hesabına tanımlanır — AI danışmana ücretsiz soru sorabilirsin.
|
||||
hesabına tanımlanır — Yapay Zeka danışmana ücretsiz soru sorabilirsin.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { Parallax } from "@/components/parallax";
|
||||
import { PagePixelDivider } from "@/components/pixel-decor";
|
||||
import { SiteFooter } from "@/components/site-footer";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Gizlilik ve KVKK",
|
||||
@@ -13,13 +14,14 @@ export const metadata: Metadata = {
|
||||
// NOT: MVP taslağıdır; yayına almadan önce hukuki gözden geçirme gerekir.
|
||||
export default function GizlilikPage() {
|
||||
return (
|
||||
<>
|
||||
<main className="mx-auto w-full max-w-2xl px-4 py-16">
|
||||
<Parallax fromStart strength={10}>
|
||||
<h1 className="font-heading text-3xl font-bold">
|
||||
Gizlilik ve Kişisel Verilerin Korunması
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-slate-500">
|
||||
Son güncelleme: 26 Temmuz 2026
|
||||
Son güncelleme: 2 Ağustos 2026
|
||||
</p>
|
||||
</Parallax>
|
||||
|
||||
@@ -54,6 +56,25 @@ export default function GizlilikPage() {
|
||||
Verilerini üçüncü taraflara satmayız, reklam amacıyla paylaşmayız.
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
<h2 className="font-heading text-lg font-bold text-slate-900">
|
||||
Hizmet sağlayıcılar ve yurt dışına aktarım
|
||||
</h2>
|
||||
<p className="mt-2">
|
||||
Hizmeti çalıştırabilmek için birkaç dış sağlayıcıdan yararlanıyoruz
|
||||
ve verinin bir kısmı yalnızca ilgili iş için bu sağlayıcılara
|
||||
iletilir. Giriş bağlantısı ve bilgilendirme e-postaları Resend
|
||||
üzerinden gönderilir; e-posta adresin bu amaçla Avrupa
|
||||
Birliği’ndeki (İrlanda) sunucularda işlenir. Ödemeler,
|
||||
Türkiye’de yerleşik iyzico altyapısında gerçekleşir. Danışman
|
||||
sohbetine yazdığın mesajlar, yanıt üretilebilmesi için
|
||||
Anthropic’in yapay zekâ servisine (ABD) iletilir; bu mesajlar
|
||||
sağlayıcının yapay zekâ modellerini eğitmek için kullanılmaz.
|
||||
KVKK açısından bunlardan e-posta ve yapay zekâ sağlayıcısına yapılan
|
||||
iletim yurt dışına aktarım sayılır ve hizmetin sunulabilmesi için
|
||||
zorunludur.
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
<h2 className="font-heading text-lg font-bold text-slate-900">
|
||||
Saklama ve silme
|
||||
@@ -69,14 +90,23 @@ export default function GizlilikPage() {
|
||||
</section>
|
||||
<section>
|
||||
<h2 className="font-heading text-lg font-bold text-slate-900">
|
||||
Çerezler
|
||||
Çerezler ve analitik
|
||||
</h2>
|
||||
<p className="mt-2">
|
||||
Yalnızca oturumunu açık tutmak için zorunlu çerezler kullanılır;
|
||||
reklam veya izleme çerezi kullanılmaz.
|
||||
reklam veya izleme çerezi kullanılmaz. Siteyi geliştirebilmek için
|
||||
hangi sayfaların görüntülendiğini ve hangi adımların tamamlandığını
|
||||
ölçen, çerez kullanmayan bir analitik aracı (Rybbit) çalıştırıyoruz;
|
||||
bu araç kendi sunucumuzda barındırılır, veriler üçüncü taraf bir
|
||||
reklam ağına gitmez. Giriş yaptığında ziyaretin hesabınla
|
||||
eşleştirilir. Başarı sıralaman analitiğe ham hâliyle
|
||||
gönderilmez; yalnızca “50-100 bin” gibi geniş bir
|
||||
aralık olarak kaydedilir.
|
||||
</p>
|
||||
</section>
|
||||
</Parallax>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
||||
import { Mail } from "lucide-react";
|
||||
import { Parallax } from "@/components/parallax";
|
||||
import { PagePixelDivider } from "@/components/pixel-decor";
|
||||
import { SiteFooter } from "@/components/site-footer";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "İletişim",
|
||||
@@ -12,6 +13,7 @@ export const metadata: Metadata = {
|
||||
|
||||
export default function IletisimPage() {
|
||||
return (
|
||||
<>
|
||||
<main className="mx-auto w-full max-w-2xl px-4 py-16">
|
||||
<Parallax fromStart strength={10}>
|
||||
<h1 className="font-heading text-3xl font-bold">İletişim</h1>
|
||||
@@ -23,7 +25,7 @@ export default function IletisimPage() {
|
||||
</p>
|
||||
<a
|
||||
href="mailto:destek@kolaytercih.com"
|
||||
className="mt-6 inline-flex items-center gap-2 rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm font-semibold text-slate-900 shadow-xs transition-colors hover:border-slate-300 hover:bg-slate-50"
|
||||
className="mt-6 inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-4 py-3 text-sm font-semibold text-slate-900 shadow-xs transition-colors hover:border-slate-300 hover:bg-slate-50"
|
||||
>
|
||||
<Mail className="size-4 text-primary" aria-hidden />
|
||||
destek@kolaytercih.com
|
||||
@@ -34,5 +36,7 @@ export default function IletisimPage() {
|
||||
asla e-postayla paylaşma.
|
||||
</p>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { Parallax } from "@/components/parallax";
|
||||
import { PagePixelDivider } from "@/components/pixel-decor";
|
||||
import { SiteFooter } from "@/components/site-footer";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Kullanım ve Satış Koşulları",
|
||||
@@ -13,6 +14,7 @@ export const metadata: Metadata = {
|
||||
// NOT: MVP taslağıdır; yayına almadan önce hukuki gözden geçirme gerekir.
|
||||
export default function KosullarPage() {
|
||||
return (
|
||||
<>
|
||||
<main className="mx-auto w-full max-w-2xl px-4 py-16">
|
||||
<Parallax fromStart strength={10}>
|
||||
<h1 className="font-heading text-3xl font-bold">
|
||||
@@ -85,5 +87,7 @@ export default function KosullarPage() {
|
||||
</section>
|
||||
</Parallax>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import Script from "next/script";
|
||||
import { Cal_Sans, Geist_Mono, Bricolage_Grotesque } from "next/font/google";
|
||||
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 { HeaderGate } from "@/components/header-gate";
|
||||
import { ProgressiveBlur } from "@/components/ui/skiper-ui/skiper41";
|
||||
@@ -83,25 +85,38 @@ export default function RootLayout({
|
||||
// içerikle büyümeli, viewport tabanı body'deki min-h-dvh'den gelir.
|
||||
className={`${calSans.variable} ${clarityCity.variable} ${geistMono.variable} ${bricolage.variable} antialiased`}
|
||||
>
|
||||
{/* Rybbit analitik — beforeInteractive her zaman <head> içine defer olarak
|
||||
enjekte edilir. Yalnızca prod build'de: Rybbit localhost'u da takip
|
||||
ettiği için dev trafiği prod verisini kirletmesin (DevPanel ile aynı
|
||||
kapı). mask-patterns: /sonuc URL'i ham başarı sıralamasını,
|
||||
/odeme/sonuc sipariş kimliğini taşıyor — path tek satırda toplanır. */}
|
||||
{process.env.NODE_ENV === "production" ? (
|
||||
<Script
|
||||
src="https://rybbit.kolaytercih.com/api/script.js"
|
||||
data-site-id="f2cb042c657e"
|
||||
data-mask-patterns='["/sonuc", "/odeme/sonuc"]'
|
||||
strategy="beforeInteractive"
|
||||
/>
|
||||
) : null}
|
||||
<body className="min-h-dvh flex flex-col bg-slate-50">
|
||||
{/* Site geneli progressive blur — üst ve alt */}
|
||||
<div className="pointer-events-none fixed inset-x-0 top-0 z-40 h-28 print:hidden">
|
||||
<ProgressiveBlur
|
||||
position="top"
|
||||
backgroundColor="#f8fafc"
|
||||
height="112px"
|
||||
blurAmount="6px"
|
||||
/>
|
||||
</div>
|
||||
<div className="pointer-events-none fixed inset-x-0 bottom-0 z-40 h-28 print:hidden">
|
||||
<ProgressiveBlur
|
||||
position="bottom"
|
||||
backgroundColor="#f8fafc"
|
||||
height="112px"
|
||||
blurAmount="6px"
|
||||
/>
|
||||
</div>
|
||||
<HeaderGate>
|
||||
{/* Site geneli progressive blur — üst ve alt */}
|
||||
<div className="pointer-events-none fixed inset-x-0 top-0 z-40 h-28 print:hidden">
|
||||
<ProgressiveBlur
|
||||
position="top"
|
||||
backgroundColor="#f8fafc"
|
||||
height="112px"
|
||||
blurAmount="6px"
|
||||
/>
|
||||
</div>
|
||||
<div className="pointer-events-none fixed inset-x-0 bottom-0 z-40 h-28 print:hidden">
|
||||
<ProgressiveBlur
|
||||
position="bottom"
|
||||
backgroundColor="#f8fafc"
|
||||
height="112px"
|
||||
blurAmount="6px"
|
||||
/>
|
||||
</div>
|
||||
<SiteTopBanner />
|
||||
<SiteHeader />
|
||||
</HeaderGate>
|
||||
@@ -110,6 +125,8 @@ export default function RootLayout({
|
||||
</ScrollFlow>
|
||||
{/* Manuel 24'lük liste çekmecesi + "+" uçuş katmanı (navbar'dan açılır) */}
|
||||
<ListeCekmecesiLazy />
|
||||
{/* Girişsiz + 3 seçimli kullanıcıya "listen yalnızca bu tarayıcıda" banner'ı */}
|
||||
<KaydetBannerLazy />
|
||||
{/* İlk "+" için sıra + sihirbaz kapısı */}
|
||||
<TercihProfiliKapisiLazy />
|
||||
<Toaster position="top-center" />
|
||||
|
||||
208
src/app/listem/liste-uretici.tsx
Normal file
208
src/app/listem/liste-uretici.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
"use client";
|
||||
|
||||
// /listem üretim gövdesi: sihirbazın kaydettiği bekleyen seçimlerle Yapay
|
||||
// Zeka listesi burada kurulur (sonuç sayfası yalnızca buraya yönlendirir).
|
||||
// Başarıda profil tüketilir ve sayfa yenilenir; kredi-yetersiz ve hata
|
||||
// durumları da bu bileşende karşılanır.
|
||||
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ArrowRight, Lock, RefreshCcw } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { listeOlustur } from "@/app/sonuc/actions";
|
||||
import { olay, siraKovasi } from "@/lib/analitik";
|
||||
import {
|
||||
sonucHref,
|
||||
tercihProfiliOku,
|
||||
tercihProfiliSil,
|
||||
type TercihProfili,
|
||||
} from "@/lib/sihirbaz";
|
||||
|
||||
const URETIM_MESAJLARI = [
|
||||
"Sıralamana uygun programlar taranıyor…",
|
||||
"Gerçek YÖK Atlas verisiyle dengeli liste kuruluyor…",
|
||||
"Her tercih için risk ve trend analizi yazılıyor…",
|
||||
"Son rötuşlar yapılıyor, neredeyse hazır…",
|
||||
];
|
||||
|
||||
type Durum =
|
||||
| { ad: "uretiliyor" }
|
||||
| { ad: "kredi-yetersiz" }
|
||||
| { ad: "hata"; mesaj: string }
|
||||
| { ad: "profil-yok" };
|
||||
|
||||
export function ListeUretici({
|
||||
hasPaket,
|
||||
bosDurum,
|
||||
}: {
|
||||
hasPaket: boolean;
|
||||
/** Storage'da bekleyen seçim yoksa gösterilecek boş-liste durumu */
|
||||
bosDurum: ReactNode;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [durum, setDurum] = useState<Durum>({ ad: "uretiliyor" });
|
||||
const [profil, setProfil] = useState<TercihProfili | null>(null);
|
||||
// Aynı deneme için sabit requestId: cevap kaybolan network hatasında retry
|
||||
// çifte harcamaz. Sunucudan cevap gelince sıfırlanır (harcama ya tamamlandı
|
||||
// ya iade edildi; sonraki deneme taze id ile başlamalı — bkz. sonuc/actions.ts).
|
||||
const requestIdRef = useRef<string | null>(null);
|
||||
const basladi = useRef(false);
|
||||
|
||||
async function uret(profil: TercihProfili) {
|
||||
if (!requestIdRef.current) requestIdRef.current = crypto.randomUUID();
|
||||
setDurum({ ad: "uretiliyor" });
|
||||
|
||||
try {
|
||||
const sonuc = await listeOlustur({
|
||||
sira: profil.sira,
|
||||
tur: profil.tur,
|
||||
secimler: profil.secimler,
|
||||
requestId: requestIdRef.current,
|
||||
});
|
||||
if (sonuc.ok) {
|
||||
requestIdRef.current = null;
|
||||
olay("liste_olusturuldu", {
|
||||
tur: profil.tur,
|
||||
sira_kovasi: siraKovasi(profil.sira),
|
||||
});
|
||||
tercihProfiliSil();
|
||||
// ?uret=1 URL'de kalmasın: yenileme/geri dönüş üretimi tekrar
|
||||
// tetiklemesin. refresh sunucudaki raporu (ve header'daki kredi
|
||||
// pill'ini) ekrana getirir.
|
||||
window.history.replaceState(null, "", "/listem");
|
||||
router.refresh();
|
||||
return;
|
||||
}
|
||||
requestIdRef.current = null;
|
||||
if (sonuc.code === "KREDI" || sonuc.code === "PAKET") {
|
||||
olay("kredi_bitti");
|
||||
setDurum({ ad: "kredi-yetersiz" });
|
||||
} else if (sonuc.code === "AUTH") {
|
||||
router.push(`/giris?callback=${encodeURIComponent("/listem?uret=1")}`);
|
||||
} else {
|
||||
setDurum({ ad: "hata", mesaj: sonuc.error });
|
||||
}
|
||||
} catch {
|
||||
setDurum({
|
||||
ad: "hata",
|
||||
mesaj: "Beklenmeyen bir hata oluştu, tekrar dener misin?",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (basladi.current) return;
|
||||
basladi.current = true;
|
||||
// Bekleyen seçimler yalnızca client storage'da; mount sonrası okunur.
|
||||
const kayitli = tercihProfiliOku();
|
||||
if (!kayitli) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setDurum({ ad: "profil-yok" });
|
||||
return;
|
||||
}
|
||||
setProfil(kayitli);
|
||||
void uret(kayitli);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
if (durum.ad === "profil-yok") return <>{bosDurum}</>;
|
||||
|
||||
if (durum.ad === "kredi-yetersiz") {
|
||||
// Paketli kullanıcıya paket satma: onun ürünü top-up. Liste ve sohbet
|
||||
// geçmişi kalıcı; kredi yükleyince kaldığı yerden devam eder.
|
||||
return (
|
||||
<section className="rounded-2xl border border-slate-200 bg-white p-8 text-center sm:p-12">
|
||||
<Lock className="mx-auto size-8 text-slate-400" aria-hidden />
|
||||
<h1 className="mt-4 font-heading text-xl font-bold">
|
||||
Kredin bu liste için yetmiyor
|
||||
</h1>
|
||||
<p className="mx-auto mt-2 max-w-md text-sm text-slate-600">
|
||||
{hasPaket
|
||||
? "Liste üretimi 3 kredi. +30 kredi yüklediğinde listen ve sohbet geçmişin duruyor, kaldığın yerden devam edersin."
|
||||
: "Liste üretimi 3 kredi. Tercih Dönemi Paketi'yle 60 kredi, tam liste, PDF ve revizyon hakları birlikte gelir."}
|
||||
</p>
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="mt-6 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||
>
|
||||
<Link href="/paket">
|
||||
{hasPaket ? "+30 kredi yükle — 129 TL" : "Paketi aktive et — 299 TL"}
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Link>
|
||||
</Button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (durum.ad === "hata") {
|
||||
return (
|
||||
<section className="rounded-2xl border border-slate-200 bg-white p-8 text-center sm:p-12">
|
||||
<h1 className="font-heading text-xl font-bold">Liste kurulamadı</h1>
|
||||
<p className="mx-auto mt-2 max-w-md text-sm text-slate-600">
|
||||
{durum.mesaj}
|
||||
</p>
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={() => profil && void uret(profil)}
|
||||
className="mt-6 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||
>
|
||||
<RefreshCcw className="size-4" aria-hidden />
|
||||
Tekrar dene
|
||||
</Button>
|
||||
{profil ? (
|
||||
<p className="mt-4 text-xs text-slate-500">
|
||||
Seçimlerin kayıtlı —{" "}
|
||||
<Link href={sonucHref(profil)} className="underline">
|
||||
sonuç sayfasına dönebilirsin
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return <UretimGovdesi />;
|
||||
}
|
||||
|
||||
function UretimGovdesi() {
|
||||
const [mesajIdx, setMesajIdx] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setMesajIdx((i) => Math.min(i + 1, URETIM_MESAJLARI.length - 1));
|
||||
}, 7000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-live="polite"
|
||||
className="rounded-2xl border border-slate-200 bg-white p-6 sm:p-8"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-1.5 text-center">
|
||||
<h1 className="font-heading text-lg font-bold">Listen hazırlanıyor</h1>
|
||||
<p className="max-w-sm text-sm text-slate-500">
|
||||
{URETIM_MESAJLARI[mesajIdx]}
|
||||
</p>
|
||||
<p className="text-xs text-slate-400">
|
||||
Bu işlem yarım dakika kadar sürebilir, sayfayı kapatma.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Gelmekte olan tercih listesinin iskeleti */}
|
||||
<div className="mt-8 flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-6 w-44" />
|
||||
<Skeleton className="h-6 w-24 rounded-full" />
|
||||
</div>
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
38
src/app/listem/listem-bos-cta.tsx
Normal file
38
src/app/listem/listem-bos-cta.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PagePixelDivider } from "@/components/pixel-decor";
|
||||
import { useTercihProfili } from "@/components/manuel-liste/profil-kapisi-store";
|
||||
import { sonucHref } from "@/lib/sihirbaz";
|
||||
|
||||
/**
|
||||
* /listem boş hâlinin metni + CTA'sı: sıralamasını zaten girmiş adaya
|
||||
* "sıralamanı gir" demek yerine sihirbazı açık hâlde sonuç sayfasına
|
||||
* götürür; profil yoksa ana sayfadaki sıralama formuna yönlendirir.
|
||||
*/
|
||||
export function ListemBosCta() {
|
||||
const profil = useTercihProfili();
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className="mt-3 text-slate-600">
|
||||
{profil
|
||||
? "Sıralaman kayıtlı — birkaç adımda 24 tercihlik risk analizli listeni kur; sonra burada görüntüleyip Yapay Zeka danışmanla üzerinde konuşabilirsin."
|
||||
: "Sıralamanı girip birkaç adımda 24 tercihlik risk analizli listeni kur; sonra burada görüntüleyip Yapay Zeka danışmanla üzerinde konuşabilirsin."}
|
||||
</p>
|
||||
<PagePixelDivider seed={67} className="mx-auto mt-6" />
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="mt-6 cursor-pointer bg-orange-500 text-white hover:bg-orange-600"
|
||||
>
|
||||
<Link href={profil ? sonucHref(profil, { sihirbaz: "1" }) : "/"}>
|
||||
{profil ? "Yapay Zeka listeni kur" : "Sıralamanı gir, Yapay Zeka listeni kur"}
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
283
src/app/listem/listem-govde.tsx
Normal file
283
src/app/listem/listem-govde.tsx
Normal file
@@ -0,0 +1,283 @@
|
||||
"use client";
|
||||
|
||||
// /listem gövdesi: Türkiye haritası + 24'lük liste + danışman sohbeti tek
|
||||
// ekranda. Harita ⇄ liste köprüsü (pinden satıra, satırdan haritaya) ve
|
||||
// paketsiz paywall kartları burada; veri çekimi sunucuda (page.tsx) kalır.
|
||||
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
ArrowRight,
|
||||
Bot,
|
||||
Check,
|
||||
FileText,
|
||||
Lock,
|
||||
PencilLine,
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RaporListesi } from "@/components/rapor-listesi";
|
||||
import { useGorunumOlayi } from "@/components/use-gorunum-olayi";
|
||||
import { TercihHaritasi } from "@/components/tercih-haritasi";
|
||||
import { pinleriTuret } from "@/lib/harita-pinler";
|
||||
import type { RaporSonuc } from "@/lib/ai/rapor";
|
||||
import { SohbetClient, type DanismanOzeti, type Mesaj } from "./sohbet-client";
|
||||
import { RevizyonKutusu } from "./revizyon-kutusu";
|
||||
|
||||
export function ListemGovde({
|
||||
rapor,
|
||||
adaySira,
|
||||
hasPaket,
|
||||
kilitAcilisi,
|
||||
kilitliBaslangic,
|
||||
kredi,
|
||||
sohbetGecmisi,
|
||||
ozet,
|
||||
kalanHak,
|
||||
paketFiyati,
|
||||
paketKredi,
|
||||
}: {
|
||||
rapor: RaporSonuc;
|
||||
adaySira: number;
|
||||
hasPaket: boolean;
|
||||
/** Ödeme dönüşü: blur çözülme animasyonuyla aç */
|
||||
kilitAcilisi: boolean;
|
||||
/** Paketsizde ACIK_SATIR; paketlide undefined (tamamı açık) */
|
||||
kilitliBaslangic?: number;
|
||||
kredi: number;
|
||||
sohbetGecmisi: Mesaj[];
|
||||
ozet?: DanismanOzeti;
|
||||
kalanHak: number;
|
||||
paketFiyati: string;
|
||||
paketKredi: number;
|
||||
}) {
|
||||
// Harita ⇄ liste köprüsü: pinden satıra, satırdan haritaya
|
||||
const [seciliIl, setSeciliIl] = useState<string | null>(null);
|
||||
const [acikSira, setAcikSira] = useState<number | null>(null);
|
||||
const haritaRef = useRef<HTMLElement>(null);
|
||||
|
||||
const pinVerisi = useMemo(
|
||||
() =>
|
||||
pinleriTuret(
|
||||
rapor,
|
||||
adaySira,
|
||||
kilitliBaslangic ?? rapor.tercihler.length,
|
||||
),
|
||||
[rapor, adaySira, kilitliBaslangic],
|
||||
);
|
||||
|
||||
const acikSatir = kilitliBaslangic ?? rapor.tercihler.length;
|
||||
|
||||
// Kapı ölçümü: paywall yüzeyleri viewport'a girince (oturumda 1'er kez).
|
||||
// Ref'ler yalnızca paketsiz dallarda bağlanır; paketlide DOM'da olmadıkları
|
||||
// için observer hiç kurulmaz.
|
||||
const kilitSatirRef = useGorunumOlayi<HTMLDivElement>(
|
||||
"kilit_goruntulendi",
|
||||
{ yer: "satir" },
|
||||
"satir",
|
||||
);
|
||||
const kilitKartRef = useGorunumOlayi<HTMLElement>(
|
||||
"kilit_goruntulendi",
|
||||
{ yer: "kart" },
|
||||
"kart",
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mt-8 grid items-start gap-6 lg:grid-cols-[minmax(0,1fr)_360px] xl:grid-cols-[minmax(0,1fr)_400px]">
|
||||
<div className="flex min-w-0 flex-col gap-6">
|
||||
{/* Harita: listenin coğrafyası — ile dokun, üniversiteleri gör */}
|
||||
<section
|
||||
ref={haritaRef}
|
||||
aria-label="Tercih listesinin Türkiye haritası"
|
||||
className="scroll-mt-24"
|
||||
>
|
||||
<div className="mx-auto w-full max-w-xl">
|
||||
<TercihHaritasi
|
||||
pinler={pinVerisi.pinler}
|
||||
seciliIl={seciliIl}
|
||||
onSeciliIlDegisti={setSeciliIl}
|
||||
onTercihSec={setAcikSira}
|
||||
/>
|
||||
</div>
|
||||
{pinVerisi.haritaDisi > 0 ? (
|
||||
<p className="mt-1 text-center text-xs text-slate-500">
|
||||
{pinVerisi.haritaDisi} tercih harita dışında (KKTC vb.)
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{/* Liste: 24 tercih ve taban verileri. Sayfa başlığı ve revizyon/PDF
|
||||
aksiyonları bu kutunun kendi başlık satırında yaşar. */}
|
||||
<section className={`min-w-0${kilitAcilisi ? " kt-unblur" : ""}`}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 pb-1">
|
||||
<h1 className="min-w-0 font-heading text-xl font-bold tracking-tight sm:text-2xl">
|
||||
{hasPaket
|
||||
? "Yapay Zeka tercih listen"
|
||||
: "Veriyi ücretsiz incele. Kararı Yapay Zeka ile netleştir."}
|
||||
</h1>
|
||||
{hasPaket ? (
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Link href="#revizyon">
|
||||
<PencilLine className="size-4" aria-hidden />
|
||||
Revize et
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Link href="/rapor/yazdir" target="_blank">
|
||||
<FileText className="size-4" aria-hidden />
|
||||
PDF
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<RaporListesi
|
||||
rapor={rapor}
|
||||
adaySira={adaySira}
|
||||
acikSira={acikSira}
|
||||
onHaritadaGor={(il) => {
|
||||
if (!il) return;
|
||||
setSeciliIl(il);
|
||||
haritaRef.current?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "start",
|
||||
});
|
||||
}}
|
||||
kilitliBaslangic={kilitliBaslangic}
|
||||
kilitOverlay={
|
||||
<div
|
||||
ref={kilitSatirRef}
|
||||
className="flex flex-col items-start gap-3 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
<span className="flex size-10 shrink-0 items-center justify-center rounded-full bg-orange-100 text-orange-700">
|
||||
<Lock className="size-4" aria-hidden />
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-heading font-bold text-slate-900">
|
||||
İlk {acikSatir} tercihte Yapay Zeka analizini gördün
|
||||
</p>
|
||||
<p className="mt-1 max-w-xl text-sm text-slate-600">
|
||||
Kalan {rapor.tercihler.length - acikSatir} satırda senin
|
||||
sıralamana özel gerekçe ve risk notu hazır — paketle
|
||||
açılır. Program adları ve taban verileri her zaman
|
||||
ücretsiz.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
asChild
|
||||
className="h-11 w-full shrink-0 cursor-pointer bg-orange-500 text-white transition-[background-color,transform] duration-200 active:scale-[0.97] hover:bg-orange-600 sm:w-auto"
|
||||
>
|
||||
<Link href="/paket">
|
||||
Analizleri aç — {paketFiyati} TL
|
||||
<ArrowRight data-icon="inline-end" aria-hidden />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{hasPaket ? <RevizyonKutusu kalanHak={kalanHak} /> : null}
|
||||
</div>
|
||||
|
||||
<div id="sohbet" className="scroll-mt-24 lg:sticky lg:top-28">
|
||||
{hasPaket ? (
|
||||
<SohbetClient
|
||||
baslangicKredi={kredi}
|
||||
initialMesajlar={sohbetGecmisi}
|
||||
ozet={ozet}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Sohbet paket kartının üstünde: #sohbet çapası (ve mobildeki
|
||||
"Danışmana sor" butonu) doğrudan danışmana düşsün. */}
|
||||
<SohbetClient
|
||||
baslangicKredi={kredi}
|
||||
initialMesajlar={sohbetGecmisi}
|
||||
ozet={ozet}
|
||||
className="h-[420px]"
|
||||
/>
|
||||
|
||||
<section
|
||||
ref={kilitKartRef}
|
||||
className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm"
|
||||
>
|
||||
<div className="border-b border-slate-100 p-6">
|
||||
<h3 className="font-heading text-xl font-bold tracking-tight">
|
||||
Tablo ücretsiz.
|
||||
<br />
|
||||
Karar desteği pakette.
|
||||
</h3>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-600">
|
||||
Sadece veriyi göstermez; hangi tercihin neden mantıklı
|
||||
olduğunu sıralamana göre açıklar.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<ul className="flex flex-col gap-3 text-sm">
|
||||
{[
|
||||
"24 tercihin tamamında kişisel risk analizi",
|
||||
"Her program için kısa, gerekçeli Yapay Zeka yorumu",
|
||||
"2 liste revizyonu ve paylaşılabilir PDF",
|
||||
`${paketKredi} Yapay Zeka danışman sorusu`,
|
||||
].map((ozellik) => (
|
||||
<li key={ozellik} className="flex items-start gap-2.5">
|
||||
<Check
|
||||
className="mt-0.5 size-4 shrink-0 text-emerald-600"
|
||||
aria-hidden
|
||||
/>
|
||||
<span>{ozellik}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="mt-6">
|
||||
<p className="text-xs text-slate-500">Tek seferlik</p>
|
||||
<p className="font-heading text-3xl font-bold">
|
||||
{paketFiyati} TL
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="mt-5 w-full cursor-pointer bg-orange-500 text-white transition-[background-color,transform] duration-200 active:scale-[0.97] hover:bg-orange-600"
|
||||
>
|
||||
<Link href="/paket">
|
||||
Kişisel analizi aç
|
||||
<ArrowRight data-icon="inline-end" aria-hidden />
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<div className="mt-4 flex items-center justify-center gap-4 text-[11px] text-slate-500">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<ShieldCheck className="size-3.5" aria-hidden />
|
||||
iyzico güvenli ödeme
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Bot className="size-3.5" aria-hidden />
|
||||
Gerçek YÖK verisi
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { ListChecks, Sparkles } from "lucide-react";
|
||||
import { Tabs as TabsPrimitive } from "radix-ui";
|
||||
import { SecimlerimPaneli } from "@/components/manuel-liste/secimlerim-paneli";
|
||||
import { useManuelListe } from "@/components/manuel-liste/store";
|
||||
|
||||
export type ListemSekme = "secimlerim" | "ai";
|
||||
|
||||
export function ListemSekmeleri({
|
||||
varsayilan,
|
||||
aiIcerik,
|
||||
aiHazir,
|
||||
}: {
|
||||
varsayilan: ListemSekme;
|
||||
aiIcerik: React.ReactNode;
|
||||
aiHazir?: boolean;
|
||||
}) {
|
||||
const [sekme, setSekme] = useState<ListemSekme>(varsayilan);
|
||||
const liste = useManuelListe();
|
||||
|
||||
function degistir(deger: string) {
|
||||
const yeni = deger as ListemSekme;
|
||||
setSekme(yeni);
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("sekme", yeni);
|
||||
window.history.replaceState(null, "", url);
|
||||
}
|
||||
|
||||
return (
|
||||
<TabsPrimitive.Root value={sekme} onValueChange={degistir} className="mt-6">
|
||||
<TabsPrimitive.List
|
||||
aria-label="Liste görünümü"
|
||||
className="relative mx-auto grid w-full max-w-md grid-cols-2 rounded-full border border-slate-200 bg-slate-100 p-1"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute inset-y-1 left-1 w-[calc(50%-0.25rem)] rounded-full bg-white shadow-sm transition-transform duration-200 [transition-timing-function:cubic-bezier(0.23,1,0.32,1)]"
|
||||
style={{
|
||||
transform: sekme === "ai" ? "translateX(100%)" : "translateX(0)",
|
||||
}}
|
||||
/>
|
||||
<TabsPrimitive.Trigger
|
||||
value="secimlerim"
|
||||
className="relative z-10 flex h-10 cursor-pointer items-center justify-center gap-2 rounded-full text-sm font-semibold text-slate-500 transition-colors duration-200 hover:text-slate-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring data-[state=active]:text-slate-900"
|
||||
>
|
||||
<ListChecks className="size-4" aria-hidden />
|
||||
Kendi Listem
|
||||
{liste.length > 0 ? (
|
||||
<span className="min-w-5 rounded-full bg-primary/10 px-1.5 py-0.5 text-center text-[11px] font-bold tabular-nums text-primary">
|
||||
{liste.length}
|
||||
</span>
|
||||
) : null}
|
||||
</TabsPrimitive.Trigger>
|
||||
<TabsPrimitive.Trigger
|
||||
value="ai"
|
||||
className="relative z-10 flex h-10 cursor-pointer items-center justify-center gap-2 rounded-full text-sm font-semibold text-slate-500 transition-colors duration-200 hover:text-slate-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring data-[state=active]:text-slate-900"
|
||||
>
|
||||
<Sparkles className="size-4" aria-hidden />
|
||||
AI Listem
|
||||
{aiHazir ? (
|
||||
<span
|
||||
className="size-1.5 rounded-full bg-emerald-500"
|
||||
aria-hidden
|
||||
title="AI listen hazır"
|
||||
/>
|
||||
) : null}
|
||||
</TabsPrimitive.Trigger>
|
||||
</TabsPrimitive.List>
|
||||
<TabsPrimitive.Content
|
||||
value="secimlerim"
|
||||
className="mt-6 outline-none data-[state=active]:animate-in data-[state=active]:fade-in-0 data-[state=active]:slide-in-from-bottom-1 data-[state=active]:duration-200"
|
||||
>
|
||||
<SecimlerimPaneli />
|
||||
</TabsPrimitive.Content>
|
||||
<TabsPrimitive.Content
|
||||
value="ai"
|
||||
className="mt-6 outline-none data-[state=active]:animate-in data-[state=active]:fade-in-0 data-[state=active]:slide-in-from-bottom-1 data-[state=active]:duration-200"
|
||||
>
|
||||
{aiIcerik}
|
||||
</TabsPrimitive.Content>
|
||||
</TabsPrimitive.Root>
|
||||
);
|
||||
}
|
||||
@@ -1,35 +1,26 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
import { redirect } from "next/navigation";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowRight,
|
||||
Bot,
|
||||
Check,
|
||||
Database,
|
||||
FileText,
|
||||
Lock,
|
||||
MessageCircleQuestion,
|
||||
PencilLine,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import { MessageCircleQuestion, Sparkles } from "lucide-react";
|
||||
import { verifySession, getCurrentUser } from "@/lib/session";
|
||||
import { appDb, schema } from "@/lib/appdb";
|
||||
import { PUAN_TURLERI } from "@/lib/db";
|
||||
import { URUNLER, MAX_REVIZYON } from "@/lib/credits";
|
||||
import type { RaporSonuc } from "@/lib/ai/rapor";
|
||||
import type { RaporParams } from "@/lib/rapor-havuzu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { RaporListesi } from "@/components/rapor-listesi";
|
||||
import { ViewportPortal } from "@/components/viewport-portal";
|
||||
import { PagePixelDivider } from "@/components/pixel-decor";
|
||||
import { raporMaskele, ACIK_SATIR } from "@/lib/rapor-maske";
|
||||
import { SohbetClient, type Mesaj } from "./sohbet-client";
|
||||
import { RevizyonKutusu } from "./revizyon-kutusu";
|
||||
import { ListemSekmeleri, type ListemSekme } from "./listem-sekmeleri";
|
||||
import { SiteFooter } from "@/components/site-footer";
|
||||
import { raporMaskele, ACIK_SATIR, type MaskeliRapor } from "@/lib/rapor-maske";
|
||||
import type { Mesaj } from "./sohbet-client";
|
||||
import { ListemGovde } from "./listem-govde";
|
||||
import { ListemBosCta } from "./listem-bos-cta";
|
||||
import { ListeUretici } from "./liste-uretici";
|
||||
|
||||
// listeRevize aksiyonu 2 AI denemesi yapabiliyor (deneme başına 60 sn abort
|
||||
// sınırı, bkz. lib/ai/cagri.ts) → en kötü ~120 sn. Platform limiti bundan
|
||||
// SONRA dolmalı ki kredi iadesi her zaman çalışabilsin. Self-hosted docker'da
|
||||
// bu değer etkisiz ama invaryant belgeli kalsın; öndeki reverse proxy'nin
|
||||
// istek süresi limiti de >= 200 sn olmalı.
|
||||
export const maxDuration = 300;
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Listem",
|
||||
@@ -43,9 +34,13 @@ const PAKET_FIYATI = (URUNLER.paket.amountKurus / 100).toLocaleString("tr-TR", {
|
||||
export default async function ListemPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ acildi?: string; sekme?: string }>;
|
||||
searchParams: Promise<{ acildi?: string; uret?: string }>;
|
||||
}) {
|
||||
const session = await verifySession("/listem");
|
||||
// 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,
|
||||
@@ -63,66 +58,60 @@ export default async function ListemPage({
|
||||
.orderBy(asc(schema.chatMessages.createdAt))
|
||||
.limit(100),
|
||||
]);
|
||||
if (!user) redirect("/giris?callback=/listem");
|
||||
if (!user) redirect(`/giris?callback=${encodeURIComponent(geriYol)}`);
|
||||
|
||||
const { acildi, sekme } = params;
|
||||
const { acildi } = params;
|
||||
const kilitAcilisi = Boolean(user.hasPaket && acildi === "1");
|
||||
const raporVar = Boolean(satir?.result);
|
||||
|
||||
const varsayilanSekme: ListemSekme =
|
||||
sekme === "secimlerim"
|
||||
? "secimlerim"
|
||||
: sekme === "ai" || acildi === "1" || raporVar
|
||||
? "ai"
|
||||
: "secimlerim";
|
||||
|
||||
const aiBos = (
|
||||
<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 />
|
||||
<h2 className="mt-4 font-heading text-2xl font-bold">
|
||||
Henüz AI listen yok
|
||||
</h2>
|
||||
<p className="mt-3 text-slate-600">
|
||||
Sıralamanı girip birkaç adımda 24 tercihlik risk analizli listeni kur;
|
||||
sonra burada görüntüleyip AI danışmanla üzerinde konuşabilirsin.
|
||||
Kendi Listem sekmesinde ise tablolardan eklediğin programlar durur.
|
||||
</p>
|
||||
<PagePixelDivider seed={67} className="mx-auto mt-6" />
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="mt-6 h-12 cursor-pointer bg-orange-500 px-8 text-white hover:bg-orange-600"
|
||||
>
|
||||
<Link href="/">Sıralamanı gir, AI listeni kur</Link>
|
||||
</Button>
|
||||
</section>
|
||||
);
|
||||
|
||||
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-7xl px-4 py-8 sm:py-10">
|
||||
<div className="mx-auto max-w-2xl text-center">
|
||||
<h1 className="font-heading text-2xl font-bold tracking-tight sm:text-3xl">
|
||||
Tercih karar merkezin
|
||||
</h1>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-600">
|
||||
Kendi listen ve AI listen burada yan yana — ikisi birbirinden
|
||||
bağımsız.
|
||||
</p>
|
||||
<PagePixelDivider seed={71} className="mx-auto mt-6" />
|
||||
</div>
|
||||
<ListemSekmeleri varsayilan={varsayilanSekme} aiIcerik={aiBos} />
|
||||
</main>
|
||||
<>
|
||||
<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 = user.hasPaket
|
||||
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;
|
||||
|
||||
const aiIcerik = (
|
||||
return (
|
||||
<>
|
||||
<main className="mx-auto w-full max-w-7xl px-4 py-12">
|
||||
{kilitAcilisi ? (
|
||||
<div
|
||||
role="status"
|
||||
@@ -133,233 +122,21 @@ export default async function ListemPage({
|
||||
artık açık.
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
{user.hasPaket ? (
|
||||
<Badge variant="secondary" className="mb-3">
|
||||
<ShieldCheck aria-hidden />
|
||||
Paketin aktif
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary" className="mb-3">
|
||||
<Database aria-hidden />
|
||||
YÖK Atlas verileri ücretsiz
|
||||
</Badge>
|
||||
)}
|
||||
<h2 className="max-w-2xl font-heading text-xl font-bold tracking-tight sm:text-2xl">
|
||||
{user.hasPaket
|
||||
? "AI tercih listen"
|
||||
: "Veriyi ücretsiz incele. Kararı AI ile netleştir."}
|
||||
</h2>
|
||||
<p className="mt-2 max-w-2xl text-sm leading-6 text-slate-600">
|
||||
{raporParams.sira.toLocaleString("tr-TR")}. sıra ·{" "}
|
||||
{PUAN_TURLERI[raporParams.tur as keyof typeof PUAN_TURLERI] ??
|
||||
raporParams.tur}{" "}
|
||||
·{" "}
|
||||
{user.hasPaket
|
||||
? "24 tercihinin tamamını gerekçe, risk analizi ve trendleriyle aşağıda inceleyebilirsin."
|
||||
: "24 programın 2024–2025 taban sıralamalarını ve ham trendini aşağıda ücretsiz inceleyebilirsin."}
|
||||
</p>
|
||||
</div>
|
||||
{user.hasPaket ? (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Link href="#revizyon">
|
||||
<PencilLine className="size-4" aria-hidden />
|
||||
Revize et
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Link href="/rapor/yazdir" target="_blank">
|
||||
<FileText className="size-4" aria-hidden />
|
||||
PDF
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{rapor.genelDegerlendirme ? (
|
||||
<section className="mt-6 rounded-2xl border border-slate-200 bg-white p-5">
|
||||
<h3 className="flex items-center gap-2 font-heading text-base font-bold">
|
||||
<Bot className="size-4 text-primary" aria-hidden />
|
||||
Danışman özeti
|
||||
</h3>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-700">
|
||||
{rapor.genelDegerlendirme}
|
||||
</p>
|
||||
{user.hasPaket && rapor.uyarilar?.length ? (
|
||||
<ul className="mt-3 flex flex-col gap-2">
|
||||
{rapor.uyarilar.map((uyari) => (
|
||||
<li
|
||||
key={uyari}
|
||||
className="flex items-start gap-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900"
|
||||
>
|
||||
<AlertTriangle
|
||||
className="mt-0.5 size-4 shrink-0"
|
||||
aria-hidden
|
||||
/>
|
||||
{uyari}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
{!user.hasPaket ? (
|
||||
<p className="mt-3 flex items-center gap-1.5 text-xs text-slate-500">
|
||||
<Lock className="size-3.5" aria-hidden />
|
||||
Değerlendirmenin tamamı ve sıralamana özel uyarılar pakette.
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<div className="mt-8 grid items-start gap-8 lg:grid-cols-[minmax(0,1fr)_340px]">
|
||||
<div className="flex min-w-0 flex-col gap-6">
|
||||
<section
|
||||
className={`min-w-0 rounded-2xl border border-slate-200 bg-slate-50/60 p-3 sm:p-5${
|
||||
kilitAcilisi ? " kt-unblur" : ""
|
||||
}`}
|
||||
>
|
||||
<RaporListesi
|
||||
rapor={rapor}
|
||||
adaySira={raporParams.sira}
|
||||
kilitliBaslangic={user.hasPaket ? undefined : ACIK_SATIR}
|
||||
kilitOverlay={
|
||||
<div className="flex flex-col items-start gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex gap-3">
|
||||
<span className="flex size-10 shrink-0 items-center justify-center rounded-full bg-orange-100 text-orange-700">
|
||||
<Lock className="size-4" aria-hidden />
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-heading font-bold text-slate-900">
|
||||
İlk {ACIK_SATIR} tercihte AI analizini gördün
|
||||
</p>
|
||||
<p className="mt-1 max-w-xl text-sm text-slate-600">
|
||||
Kalan {24 - ACIK_SATIR} tercihin kişisel risk, gerekçe ve
|
||||
AI yorumu paketle açılır. Program adları ve taban
|
||||
verileri her zaman ücretsiz.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
asChild
|
||||
className="h-11 w-full shrink-0 cursor-pointer bg-orange-500 text-white transition-[background-color,transform] duration-200 active:scale-[0.97] hover:bg-orange-600 sm:w-auto"
|
||||
>
|
||||
<Link href="/paket">
|
||||
Analizleri aç — {PAKET_FIYATI} TL
|
||||
<ArrowRight data-icon="inline-end" aria-hidden />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{user.hasPaket ? (
|
||||
<RevizyonKutusu
|
||||
kalanHak={Math.max(0, MAX_REVIZYON - (satir!.revisionCount ?? 0))}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div id="sohbet" className="scroll-mt-24 lg:sticky lg:top-28">
|
||||
{user.hasPaket ? (
|
||||
<SohbetClient
|
||||
baslangicKredi={user.creditBalance}
|
||||
initialMesajlar={sohbetGecmisi as Mesaj[]}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-6">
|
||||
<section className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="border-b border-slate-100 p-6">
|
||||
<div className="flex items-center gap-2 text-primary">
|
||||
<Sparkles className="size-4" aria-hidden />
|
||||
<span className="text-xs font-bold uppercase tracking-wide">
|
||||
Tercih Dönemi Paketi
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="mt-3 font-heading text-xl font-bold tracking-tight">
|
||||
Tablo ücretsiz.
|
||||
<br />
|
||||
Karar desteği pakette.
|
||||
</h3>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-600">
|
||||
Sadece veriyi göstermez; hangi tercihin neden mantıklı
|
||||
olduğunu sıralamana göre açıklar.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<ul className="flex flex-col gap-3 text-sm">
|
||||
{[
|
||||
"24 tercihin tamamında kişisel risk analizi",
|
||||
"Her program için kısa, gerekçeli AI yorumu",
|
||||
"2 liste revizyonu ve paylaşılabilir PDF",
|
||||
`${URUNLER.paket.credits} AI danışman sorusu`,
|
||||
].map((ozellik) => (
|
||||
<li key={ozellik} className="flex items-start gap-2.5">
|
||||
<Check
|
||||
className="mt-0.5 size-4 shrink-0 text-emerald-600"
|
||||
aria-hidden
|
||||
/>
|
||||
<span>{ozellik}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="mt-6 flex items-end justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-slate-500">Tek seferlik</p>
|
||||
<p className="font-heading text-3xl font-bold">
|
||||
{PAKET_FIYATI} TL
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline">Abonelik yok</Badge>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="mt-5 h-12 w-full cursor-pointer bg-orange-500 text-white transition-[background-color,transform] duration-200 active:scale-[0.97] hover:bg-orange-600"
|
||||
>
|
||||
<Link href="/paket">
|
||||
Kişisel analizi aç
|
||||
<ArrowRight data-icon="inline-end" aria-hidden />
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<div className="mt-4 flex items-center justify-center gap-4 text-[11px] text-slate-500">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<ShieldCheck className="size-3.5" aria-hidden />
|
||||
iyzico güvenli ödeme
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Bot className="size-3.5" aria-hidden />
|
||||
Gerçek YÖK verisi
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<SohbetClient
|
||||
baslangicKredi={user.creditBalance}
|
||||
initialMesajlar={sohbetGecmisi as Mesaj[]}
|
||||
className="h-[480px]"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* 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
|
||||
@@ -370,26 +147,8 @@ export default async function ListemPage({
|
||||
Danışmana sor
|
||||
</a>
|
||||
</ViewportPortal>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-7xl px-4 py-8 sm:py-10">
|
||||
<div className="mx-auto max-w-2xl text-center">
|
||||
<h1 className="font-heading text-2xl font-bold tracking-tight sm:text-3xl">
|
||||
Tercih karar merkezin
|
||||
</h1>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-600">
|
||||
Kendi listen ve AI listen burada yan yana — ikisi birbirinden
|
||||
bağımsız.
|
||||
</p>
|
||||
<PagePixelDivider seed={71} className="mx-auto mt-6" />
|
||||
</div>
|
||||
<ListemSekmeleri
|
||||
varsayilan={varsayilanSekme}
|
||||
aiIcerik={aiIcerik}
|
||||
aiHazir
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,17 +4,22 @@
|
||||
// listeRevize sunucu aksiyonunu çağırır; başarıda sayfayı yeniler ki
|
||||
// sunucuda güncellenen rapor ve kredi bakiyesi ekrana yansısın.
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { RefreshCcw } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { listeRevize } from "@/app/sonuc/actions";
|
||||
import { olay } from "@/lib/analitik";
|
||||
|
||||
export function RevizyonKutusu({ kalanHak }: { kalanHak: number }) {
|
||||
const [feedback, setFeedback] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
const router = useRouter();
|
||||
// Aynı deneme için sabit requestId: cevap kaybolan network hatasında retry
|
||||
// çifte harcamaz. Sunucudan cevap gelince sıfırlanır (harcama ya tamamlandı
|
||||
// ya iade edildi; sonraki deneme taze id ile başlamalı — bkz. sonuc/actions.ts).
|
||||
const requestIdRef = useRef<string | null>(null);
|
||||
|
||||
if (kalanHak <= 0) {
|
||||
return (
|
||||
@@ -33,11 +38,14 @@ export function RevizyonKutusu({ kalanHak }: { kalanHak: number }) {
|
||||
async function revizeEt() {
|
||||
setPending(true);
|
||||
try {
|
||||
if (!requestIdRef.current) requestIdRef.current = crypto.randomUUID();
|
||||
const sonuc = await listeRevize({
|
||||
feedback: feedback.trim(),
|
||||
requestId: crypto.randomUUID(),
|
||||
requestId: requestIdRef.current,
|
||||
});
|
||||
requestIdRef.current = null;
|
||||
if (sonuc.ok) {
|
||||
olay("liste_revize", { sayfa: "listem" });
|
||||
toast.success("Listen yeniden kuruldu.");
|
||||
setFeedback("");
|
||||
router.refresh();
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { ArrowUp, Coins, Sparkles } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
import { AlertTriangle, ArrowUp, Coins, Lock, Sparkles } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -22,6 +24,7 @@ import { PromptSuggestion } from "@/components/ui/prompt-suggestion";
|
||||
import { ScrollButton } from "@/components/ui/scroll-button";
|
||||
import { DotsLoader } from "@/components/ui/loader";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { olay } from "@/lib/analitik";
|
||||
|
||||
export interface Mesaj {
|
||||
id: string;
|
||||
@@ -29,6 +32,32 @@ export interface Mesaj {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface DanismanOzeti {
|
||||
metin: string;
|
||||
uyarilar?: string[];
|
||||
kilitli?: boolean;
|
||||
/** Paketsizde açık uyarının ardından kilitli kalan uyarı sayısı. */
|
||||
kilitliUyariSayisi?: number;
|
||||
}
|
||||
|
||||
/** Danışman mesajlarının avatarı — KolayTercih markası. */
|
||||
function DanismanAvatar() {
|
||||
return (
|
||||
<span
|
||||
className="mt-0.5 flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-full border border-slate-200 bg-white"
|
||||
aria-hidden
|
||||
>
|
||||
<Image
|
||||
src="/kolay-tercih-mark.svg"
|
||||
alt=""
|
||||
width={32}
|
||||
height={32}
|
||||
className="size-5"
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const ORNEK_SORULAR = [
|
||||
"Listemdeki kırmızı (riskli) tercihler ne kadar riskli?",
|
||||
"İlk 5 tercihimin taban sıralaması trendi nasıl?",
|
||||
@@ -44,18 +73,22 @@ export function SohbetClient({
|
||||
baslangicKredi,
|
||||
initialMesajlar,
|
||||
className,
|
||||
ozet,
|
||||
}: {
|
||||
baslangicKredi: number;
|
||||
/** Sunucu tarafında çekilmiş geçmiş; verilirse mount fetch'i atlanır */
|
||||
initialMesajlar?: Mesaj[];
|
||||
/** Boyutlandırma override'ı (ör. alt popup'ta h-full) */
|
||||
className?: string;
|
||||
/** Raporun genel değerlendirmesi; sohbetin açılış mesajı olarak gösterilir */
|
||||
ozet?: DanismanOzeti;
|
||||
}) {
|
||||
const [mesajlar, setMesajlar] = useState<Mesaj[]>(initialMesajlar ?? []);
|
||||
const [girdi, setGirdi] = useState("");
|
||||
const [bekliyor, setBekliyor] = useState(false);
|
||||
const [yuklendi, setYuklendi] = useState(initialMesajlar != null);
|
||||
const [kredi, setKredi] = useState(baslangicKredi);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
// Geçmiş sunucudan geldiyse (listem sayfası) ekstra tur atma;
|
||||
@@ -88,6 +121,7 @@ export function SohbetClient({
|
||||
{ id: asistanId, role: "assistant", content: "" },
|
||||
]);
|
||||
|
||||
let birikmis = "";
|
||||
try {
|
||||
const res = await fetch("/api/soru", {
|
||||
method: "POST",
|
||||
@@ -100,6 +134,8 @@ export function SohbetClient({
|
||||
setMesajlar((m) =>
|
||||
m.filter((x) => x.id !== asistanId && x.id !== clientMessageId),
|
||||
);
|
||||
// Mesaj işlenmedi; kullanıcının yazdığı soru kaybolmasın
|
||||
setGirdi(mesaj);
|
||||
if (res.status === 402) {
|
||||
toast.error("Kredin bitti — kredi yükleyerek devam edebilirsin.");
|
||||
setKredi(0);
|
||||
@@ -110,9 +146,12 @@ export function SohbetClient({
|
||||
}
|
||||
|
||||
setKredi((k) => k - 1);
|
||||
// Bu bileşen /sonuc'taki popup'tan da kullanılıyor (sohbet-popup.tsx)
|
||||
olay("danisman_sorusu", {
|
||||
sayfa: location.pathname.startsWith("/listem") ? "listem" : "sonuc",
|
||||
});
|
||||
const reader = res.body!.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let birikmis = "";
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
@@ -122,8 +161,30 @@ export function SohbetClient({
|
||||
m.map((x) => (x.id === asistanId ? { ...x, content: anlik } : x)),
|
||||
);
|
||||
}
|
||||
// Header'daki kredi pill'i layout'ta server-render edilir ve client
|
||||
// harcamasını görmez; bakiye orada da güncellensin.
|
||||
router.refresh();
|
||||
} catch {
|
||||
toast.error("Bağlantı koptu. Cevabı sayfayı yenileyerek görebilirsin.");
|
||||
// Sunucu hata anında krediyi iade edip yarım cevabı kaydetmiş olabilir;
|
||||
// tahmin etme: kayıtlı geçmişi ve bakiyeyi sunucudan çek (boş balon da
|
||||
// bu senkronla temizlenir). Senkron da düşerse boş balonu elle kaldır.
|
||||
try {
|
||||
const res = await fetch("/api/soru");
|
||||
const data = res.ok ? await res.json() : null;
|
||||
if (data?.mesajlar) setMesajlar(data.mesajlar);
|
||||
if (typeof data?.kredi === "number") setKredi(data.kredi);
|
||||
} catch {}
|
||||
if (!birikmis) {
|
||||
setMesajlar((m) => m.filter((x) => x.id !== asistanId));
|
||||
// Cevap hiç gelmedi; kullanıcının yazdığı soru kaybolmasın
|
||||
setGirdi(mesaj);
|
||||
}
|
||||
router.refresh();
|
||||
toast.error(
|
||||
birikmis
|
||||
? "Bağlantı koptu — cevabın buraya kadar üretilen kısmı kaydedildi."
|
||||
: "Bağlantı koptu, cevap alınamadı. Kredin harcandıysa otomatik iade edildi.",
|
||||
);
|
||||
} finally {
|
||||
setBekliyor(false);
|
||||
}
|
||||
@@ -136,7 +197,7 @@ export function SohbetClient({
|
||||
aria-label="Danışman sohbeti"
|
||||
className={cn(
|
||||
"flex flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white",
|
||||
className ?? "h-[calc(100dvh-9rem)] min-h-[480px]",
|
||||
className ?? "h-[calc(100dvh-16rem)] min-h-[400px]",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-slate-100 px-4 py-3">
|
||||
@@ -154,20 +215,82 @@ export function SohbetClient({
|
||||
<div className="relative flex-1 overflow-hidden">
|
||||
<ChatContainerRoot className="h-full">
|
||||
<ChatContainerContent className="gap-4 p-4">
|
||||
{mesajlar.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-4 py-10 text-center">
|
||||
<Sparkles className="size-7 text-primary" aria-hidden />
|
||||
<div>
|
||||
<p className="font-heading font-bold">
|
||||
Listen hakkında her şeyi sorabilirsin
|
||||
</p>
|
||||
<p className="mx-auto mt-1 max-w-md text-sm text-slate-600">
|
||||
Cevaplar senin 24'lük listen, seçimlerin ve gerçek
|
||||
YÖK Atlas verisi bağlamında üretilir. Her mesaj 1 kredi.
|
||||
{ozet ? (
|
||||
<Message className="justify-start">
|
||||
<DanismanAvatar />
|
||||
<div className="flex max-w-[85%] flex-col gap-2">
|
||||
<p className="text-xs font-semibold text-slate-500">
|
||||
Danışman özeti
|
||||
</p>
|
||||
<MessageContent className="bg-slate-100 text-slate-800">
|
||||
{ozet.metin}
|
||||
</MessageContent>
|
||||
{ozet.uyarilar?.length ? (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{ozet.uyarilar.map((uyari) => (
|
||||
<li
|
||||
key={uyari}
|
||||
className="flex items-start gap-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900"
|
||||
>
|
||||
<AlertTriangle
|
||||
className="mt-0.5 size-4 shrink-0"
|
||||
aria-hidden
|
||||
/>
|
||||
{uyari}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
{ozet.kilitli ? (
|
||||
<p className="flex items-center gap-1.5 text-xs text-slate-500">
|
||||
<Lock className="size-3.5" aria-hidden />
|
||||
{ozet.kilitliUyariSayisi ? (
|
||||
<>
|
||||
{ozet.kilitliUyariSayisi} uyarı daha kilitli —
|
||||
sıralamana özel uyarıların pakette.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Değerlendirmenin tamamı ve sıralamana özel uyarılar
|
||||
pakette.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</Message>
|
||||
) : null}
|
||||
{mesajlar.length === 0 ? (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-4",
|
||||
!ozet && "items-center py-10 text-center",
|
||||
)}
|
||||
>
|
||||
{!ozet ? (
|
||||
<>
|
||||
<Sparkles
|
||||
className="mx-auto size-7 text-primary"
|
||||
aria-hidden
|
||||
/>
|
||||
<div>
|
||||
<p className="font-heading font-bold">
|
||||
Listen hakkında her şeyi sorabilirsin
|
||||
</p>
|
||||
<p className="mx-auto mt-1 max-w-md text-sm text-slate-600">
|
||||
Cevaplar senin 24'lük listen, seçimlerin ve gerçek
|
||||
YÖK Atlas verisi bağlamında üretilir. Her mesaj 1 kredi.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
{yuklendi ? (
|
||||
<div className="flex max-w-lg flex-wrap justify-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
"flex max-w-lg flex-wrap gap-2",
|
||||
!ozet && "justify-center",
|
||||
)}
|
||||
>
|
||||
{ORNEK_SORULAR.map((s) => (
|
||||
<PromptSuggestion
|
||||
key={s}
|
||||
@@ -181,7 +304,12 @@ export function SohbetClient({
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex max-w-lg flex-wrap justify-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
"flex max-w-lg flex-wrap gap-2",
|
||||
!ozet && "justify-center",
|
||||
)}
|
||||
>
|
||||
{[112, 148, 96, 132].map((w, i) => (
|
||||
<Skeleton
|
||||
key={i}
|
||||
@@ -202,6 +330,7 @@ export function SohbetClient({
|
||||
</Message>
|
||||
) : (
|
||||
<Message key={m.id} className="justify-start">
|
||||
<DanismanAvatar />
|
||||
{m.content ? (
|
||||
<MessageContent
|
||||
markdown
|
||||
@@ -243,7 +372,17 @@ export function SohbetClient({
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<PromptInput
|
||||
<>
|
||||
{kredi <= 3 ? (
|
||||
<p className="mb-2 px-1 text-xs text-amber-700">
|
||||
Son {kredi} kredin — bitse de listen ve sohbetin kaybolmaz;{" "}
|
||||
<Link href="/paket" className="underline hover:text-amber-800">
|
||||
kredi yükleyip
|
||||
</Link>{" "}
|
||||
kaldığın yerden devam edersin.
|
||||
</p>
|
||||
) : null}
|
||||
<PromptInput
|
||||
value={girdi}
|
||||
onValueChange={setGirdi}
|
||||
onSubmit={() => gonder(girdi)}
|
||||
@@ -270,6 +409,7 @@ export function SohbetClient({
|
||||
</PromptInputAction>
|
||||
</PromptInputActions>
|
||||
</PromptInput>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,28 +1,25 @@
|
||||
"use client";
|
||||
|
||||
// /meraklisina'nın canlı anlatım katmanı: kullanıcı kendi sıralamasını girer,
|
||||
// dilim şeridi ve kademeli filtre hunisi o sıralamanın GERÇEK sayılarıyla
|
||||
// güncellenir. Provider sayfadaki metin bölümlerini (RSC children) sarar;
|
||||
// görseller context'ten okur, böylece tek girişle iki diyagram birden yaşar.
|
||||
// /meraklisina'nın canlı anlatım katmanı: kullanıcının kayıtlı tercih
|
||||
// profilindeki sıralama + puan türü okunur, dilim şeridi o sıralamanın GERÇEK
|
||||
// sayılarıyla güncellenir. Profil yoksa sayfa SSR'daki örnek sıralamayla kalır.
|
||||
// Provider sayfadaki metin bölümlerini (RSC children) sarar; görseller
|
||||
// context'ten okur. (Sihirbaz hunisi statik bir şemayla anlatılır — bkz.
|
||||
// huni-semasi.tsx.)
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} 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";
|
||||
|
||||
const TUR_SECENEKLERI = [
|
||||
{ deger: "say", etiket: "Sayısal" },
|
||||
{ deger: "ea", etiket: "Eşit Ağırlık" },
|
||||
{ deger: "soz", etiket: "Sözel" },
|
||||
{ deger: "dil", etiket: "Dil" },
|
||||
{ deger: "tyt", etiket: "TYT" },
|
||||
] as const;
|
||||
|
||||
export type DemoVeri = {
|
||||
facetler: SihirbazFacetleri;
|
||||
dilimToplam: Record<DilimKey, number>;
|
||||
@@ -33,18 +30,7 @@ type DemoDurum = {
|
||||
tur: string;
|
||||
veri: DemoVeri;
|
||||
yukleniyor: boolean;
|
||||
// Huni seçimleri ve kademeli facet cevapları
|
||||
kategori: string | null;
|
||||
il: string | null;
|
||||
tip: "devlet" | "vakif" | null;
|
||||
kategoriFacet: SihirbazFacetleri | null; // ?kategori= cevabı (iller buradan)
|
||||
ilFacet: SihirbazFacetleri | null; // ?kategori&il cevabı (tipler buradan)
|
||||
kategoriSec: (k: string | null) => void;
|
||||
ilSec: (i: string | null) => void;
|
||||
tipSec: (t: "devlet" | "vakif" | null) => void;
|
||||
siraDegistir: (ham: string) => void;
|
||||
turDegistir: (t: string) => void;
|
||||
siraMetni: string;
|
||||
profilVar: boolean;
|
||||
};
|
||||
|
||||
const DemoContext = createContext<DemoDurum | null>(null);
|
||||
@@ -58,13 +44,9 @@ function useDemo(): DemoDurum {
|
||||
async function facetGetir(
|
||||
sira: number,
|
||||
tur: string,
|
||||
secim: { kategori?: string; il?: string; dilimler?: boolean },
|
||||
signal: AbortSignal,
|
||||
): Promise<{ facetler: SihirbazFacetleri; dilimToplam?: Record<DilimKey, number> } | null> {
|
||||
const params = new URLSearchParams({ sira: String(sira), tur });
|
||||
if (secim.kategori) params.append("kategori", secim.kategori);
|
||||
if (secim.il) params.append("il", secim.il);
|
||||
if (secim.dilimler) params.set("dilimler", "1");
|
||||
const params = new URLSearchParams({ sira: String(sira), tur, dilimler: "1" });
|
||||
try {
|
||||
const res = await fetch(`/api/facetler?${params}`, { signal });
|
||||
if (!res.ok) return null;
|
||||
@@ -86,36 +68,19 @@ export function SiralamaDemo({
|
||||
baslangicVeri: DemoVeri;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [siraMetni, setSiraMetni] = useState(
|
||||
baslangicSira.toLocaleString("tr-TR"),
|
||||
);
|
||||
const [sira, setSira] = useState(baslangicSira);
|
||||
const [tur, setTur] = useState("say");
|
||||
const profil = useTercihProfili();
|
||||
const sira = profil?.sira ?? baslangicSira;
|
||||
const tur = profil?.tur ?? "say";
|
||||
const [veri, setVeri] = useState(baslangicVeri);
|
||||
const [yukleniyor, setYukleniyor] = useState(false);
|
||||
const [kategori, setKategori] = useState<string | null>(null);
|
||||
const [il, setIl] = useState<string | null>(null);
|
||||
const [tip, setTip] = useState<"devlet" | "vakif" | null>(null);
|
||||
const [kategoriFacet, setKategoriFacet] = useState<SihirbazFacetleri | null>(
|
||||
null,
|
||||
);
|
||||
const [ilFacet, setIlFacet] = useState<SihirbazFacetleri | null>(null);
|
||||
const zamanlayici = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Sıralama/tür değişti → ana veriyi tazele, huni seçimlerini sıfırla
|
||||
// Profil yüklendi ve SSR örneğinden farklı → ana veriyi tazele
|
||||
useEffect(() => {
|
||||
if (sira === baslangicSira && tur === "say" && veri === baslangicVeri) {
|
||||
return; // ilk render: sunucudan gelen veri zaten güncel
|
||||
}
|
||||
if (sira === baslangicSira && tur === "say") return;
|
||||
const ctrl = new AbortController();
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- fetch başlangıç işareti
|
||||
setYukleniyor(true);
|
||||
setKategori(null);
|
||||
setIl(null);
|
||||
setTip(null);
|
||||
setKategoriFacet(null);
|
||||
setIlFacet(null);
|
||||
facetGetir(sira, tur, { dilimler: true }, ctrl.signal)
|
||||
facetGetir(sira, tur, ctrl.signal)
|
||||
.then((cevap) => {
|
||||
if (!cevap?.dilimToplam) return;
|
||||
setVeri({ facetler: cevap.facetler, dilimToplam: cevap.dilimToplam });
|
||||
@@ -125,126 +90,51 @@ export function SiralamaDemo({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sira, tur]);
|
||||
|
||||
// Kategori seçildi → o kategorinin illerini getir
|
||||
useEffect(() => {
|
||||
if (!kategori) return;
|
||||
const ctrl = new AbortController();
|
||||
facetGetir(sira, tur, { kategori }, ctrl.signal).then((cevap) => {
|
||||
if (cevap) setKategoriFacet(cevap.facetler);
|
||||
});
|
||||
return () => ctrl.abort();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [kategori]);
|
||||
|
||||
// İl seçildi → kategori+il ile tipleri getir
|
||||
useEffect(() => {
|
||||
if (!kategori || !il) return;
|
||||
const ctrl = new AbortController();
|
||||
facetGetir(sira, tur, { kategori, il }, ctrl.signal).then((cevap) => {
|
||||
if (cevap) setIlFacet(cevap.facetler);
|
||||
});
|
||||
return () => ctrl.abort();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [kategori, il]);
|
||||
|
||||
function siraDegistir(ham: string) {
|
||||
const rakamlar = ham.replace(/\D/g, "").slice(0, 7);
|
||||
setSiraMetni(rakamlar ? Number(rakamlar).toLocaleString("tr-TR") : "");
|
||||
if (zamanlayici.current) clearTimeout(zamanlayici.current);
|
||||
const deger = Number(rakamlar);
|
||||
if (!deger || deger < 1 || deger > 4_000_000) return;
|
||||
zamanlayici.current = setTimeout(() => setSira(deger), 450);
|
||||
}
|
||||
|
||||
const durum: DemoDurum = {
|
||||
sira,
|
||||
tur,
|
||||
veri,
|
||||
yukleniyor,
|
||||
kategori,
|
||||
il,
|
||||
tip,
|
||||
kategoriFacet,
|
||||
ilFacet,
|
||||
kategoriSec: (k) => {
|
||||
setKategori(k);
|
||||
setIl(null);
|
||||
setTip(null);
|
||||
setKategoriFacet(null);
|
||||
setIlFacet(null);
|
||||
},
|
||||
ilSec: (i) => {
|
||||
setIl(i);
|
||||
setTip(null);
|
||||
setIlFacet(null);
|
||||
},
|
||||
tipSec: setTip,
|
||||
siraDegistir,
|
||||
turDegistir: setTur,
|
||||
siraMetni,
|
||||
profilVar: profil !== null,
|
||||
};
|
||||
|
||||
return <DemoContext.Provider value={durum}>{children}</DemoContext.Provider>;
|
||||
}
|
||||
|
||||
/** Sıralama girişi + puan türü — iki diyagramı birden süren tek kumanda. */
|
||||
/** Görsellerin hangi sıralamayla hesaplandığını söyleyen bilgi kartı. */
|
||||
export function DemoKumanda() {
|
||||
const { siraMetni, siraDegistir, tur, turDegistir, sira, yukleniyor } =
|
||||
useDemo();
|
||||
const { sira, tur, yukleniyor, profilVar } = useDemo();
|
||||
const turEtiket = PUAN_TURU_ETIKET[tur] ?? tur;
|
||||
return (
|
||||
<div className="rounded-2xl border border-primary/20 bg-primary/5 p-5">
|
||||
<label
|
||||
htmlFor="demo-sira"
|
||||
className="font-heading text-sm font-bold text-slate-900"
|
||||
>
|
||||
Kendi sıralamanı gir, aşağıdaki görseller sana göre değişsin
|
||||
</label>
|
||||
<div className="mt-3 flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<input
|
||||
id="demo-sira"
|
||||
inputMode="numeric"
|
||||
autoComplete="off"
|
||||
value={siraMetni}
|
||||
onChange={(e) => siraDegistir(e.target.value)}
|
||||
placeholder="ör. 85.000"
|
||||
className="h-11 w-full rounded-xl border border-slate-200 bg-white px-4 text-base font-medium tabular-nums outline-none transition-colors focus:border-primary sm:w-44"
|
||||
/>
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="Puan türü"
|
||||
className="flex flex-wrap gap-1.5"
|
||||
>
|
||||
{TUR_SECENEKLERI.map((t) => (
|
||||
<button
|
||||
key={t.deger}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={tur === t.deger}
|
||||
onClick={() => turDegistir(t.deger)}
|
||||
className={
|
||||
tur === t.deger
|
||||
? "cursor-pointer rounded-full border border-primary bg-primary px-3 py-1.5 text-xs font-medium text-white"
|
||||
: "cursor-pointer rounded-full border border-slate-200 bg-white px-3 py-1.5 text-xs text-slate-600 transition-colors duration-200 hover:border-slate-300"
|
||||
}
|
||||
>
|
||||
{t.etiket}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p className="font-heading text-sm font-bold text-slate-900">
|
||||
{profilVar
|
||||
? "Görseller senin sıralamana göre hesaplandı"
|
||||
: "Görseller örnek bir sıralamayla gösteriliyor"}
|
||||
</p>
|
||||
<p
|
||||
className={`mt-2 text-xs text-slate-500 ${yukleniyor ? "animate-pulse" : ""}`}
|
||||
className={`mt-2 text-sm text-slate-600 ${yukleniyor ? "animate-pulse" : ""}`}
|
||||
aria-live="polite"
|
||||
>
|
||||
{yukleniyor
|
||||
? "Hesaplanıyor…"
|
||||
: `Aşağıdaki tüm sayılar ${sira.toLocaleString("tr-TR")}. sıradaki bir aday için gerçek YÖK Atlas verisidir.`}
|
||||
{yukleniyor ? (
|
||||
"Hesaplanıyor…"
|
||||
) : (
|
||||
<>
|
||||
<span className="font-semibold tabular-nums text-slate-900">
|
||||
{sira.toLocaleString("tr-TR")}.
|
||||
</span>{" "}
|
||||
sıra · {turEtiket} — aşağıdaki tüm sayılar bu aday için gerçek YÖK
|
||||
Atlas verisidir.
|
||||
{!profilVar &&
|
||||
" Ana sayfadan kendi sıralamanı girersen bu sayfa sana göre güncellenir."}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Sıralama ekseni: girilen sıraya göre dilim sınırları ve gerçek adetler. */
|
||||
/** Sıralama ekseni: adayın sırasına göre dilim sınırları ve gerçek adetler. */
|
||||
export function DilimSeridiCanli() {
|
||||
const { sira, veri, yukleniyor } = useDemo();
|
||||
const t = veri.dilimToplam;
|
||||
@@ -334,307 +224,28 @@ export function DilimSeridiCanli() {
|
||||
);
|
||||
}
|
||||
|
||||
function HuniCubugu({
|
||||
etiket,
|
||||
adet,
|
||||
oran,
|
||||
}: {
|
||||
etiket: string;
|
||||
adet: number;
|
||||
oran: number;
|
||||
}) {
|
||||
return (
|
||||
<div className="min-w-0 flex-1">
|
||||
<div
|
||||
className="flex h-9 min-w-36 items-center justify-between gap-2 rounded-lg bg-primary/85 px-3 transition-[width] duration-300"
|
||||
style={{ width: `${Math.min(Math.max(oran * 100, 36), 100)}%` }}
|
||||
>
|
||||
<span className="truncate text-xs font-medium text-white">{etiket}</span>
|
||||
<span className="shrink-0 text-xs font-bold tabular-nums text-white">
|
||||
{adet.toLocaleString("tr-TR")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
/**
|
||||
* Sayfanın kapanış kartı: profili olmayan ziyaretçiyi ana sayfadaki sıralama
|
||||
* formuna, sıralamasını zaten girmiş adayı ise doğrudan sonuç sayfasına
|
||||
* gönderir — "sıralamanı gir" CTA'sı profil varken gösterilmez.
|
||||
*/
|
||||
export function DemoKapanisCta() {
|
||||
const profil = useTercihProfili();
|
||||
|
||||
function SecimCipleri<T extends { anahtar: string; etiket: string; adet: number }>({
|
||||
secenekler,
|
||||
secili,
|
||||
onSec,
|
||||
}: {
|
||||
secenekler: T[];
|
||||
secili: string | null;
|
||||
onSec: (anahtar: string | null) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{secenekler.map((s) => (
|
||||
<button
|
||||
key={s.anahtar}
|
||||
type="button"
|
||||
aria-pressed={secili === s.anahtar}
|
||||
onClick={() => onSec(secili === s.anahtar ? null : s.anahtar)}
|
||||
className={
|
||||
secili === s.anahtar
|
||||
? "cursor-pointer rounded-full border border-orange-500 bg-orange-500 px-3 py-1.5 text-xs font-medium text-white"
|
||||
: "cursor-pointer rounded-full border border-slate-200 bg-white px-3 py-1.5 text-xs text-slate-600 transition-colors duration-200 hover:border-slate-300"
|
||||
}
|
||||
>
|
||||
{s.etiket}
|
||||
<span className="ml-1 text-[10px] opacity-70 tabular-nums">
|
||||
{s.adet.toLocaleString("tr-TR")}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// AI listesinin dilim iskeleti: üstte hayal, ortada dengeli, sonda garanti.
|
||||
// Ürünün risk renkleriyle boyanır; sayılar prompt'taki kuralın orta değeri.
|
||||
const LISTE_ISKELETI: { dilim: string; adet: number; renk: string }[] = [
|
||||
{ dilim: "hayal", adet: 5, renk: "bg-red-400" },
|
||||
{ dilim: "dengeli", adet: 13, renk: "bg-amber-400" },
|
||||
{ dilim: "garanti", adet: 6, renk: "bg-emerald-500" },
|
||||
];
|
||||
|
||||
/** Huninin finali: kalan havuzun yapay zekâya gidişi ve 24'lük listenin
|
||||
iskeleti. Havuz 24'ün altına düşerse gevşetme kuralını canlı gösterir. */
|
||||
function YapayZekaAsamasi({ havuz }: { havuz: number }) {
|
||||
const dar = havuz < 24;
|
||||
return (
|
||||
<div className="border-t border-slate-100 pt-4">
|
||||
<p className="text-xs font-semibold text-slate-500">
|
||||
Sonra sıra yapay zekâda
|
||||
<div className="rounded-2xl border border-slate-200 bg-white p-6 text-center">
|
||||
<p className="font-heading text-base font-bold text-slate-900">
|
||||
{profil
|
||||
? "Kuralları öğrendin — sayılar zaten senin sıralamanla hesaplandı."
|
||||
: "Kuralları öğrendin — şimdi kendi sıralamanla dene."}
|
||||
</p>
|
||||
<p className="mt-1.5 text-xs leading-5 text-slate-600">
|
||||
Seçimlerinden geriye kalan{" "}
|
||||
<span className="font-bold tabular-nums text-slate-900">
|
||||
{havuz.toLocaleString("tr-TR")} programlık havuz
|
||||
</span>{" "}
|
||||
yapay zekâya gider; o da bu havuzdan tam 24 tercih seçer ve şu iskelete
|
||||
oturtur:
|
||||
</p>
|
||||
|
||||
{/* 24 hücrelik liste şeridi — renk + metin birlikte (renk tek başına
|
||||
anlam taşımaz) */}
|
||||
<div
|
||||
role="img"
|
||||
aria-label="24 tercihlik listenin yapısı: en üstte yaklaşık 5 hayal, ortada 13 dengeli, sonda 6 garanti tercih."
|
||||
className="mt-3"
|
||||
<Link
|
||||
href={profil ? sonucHref(profil) : "/"}
|
||||
className="mt-3 inline-flex items-center gap-1.5 rounded-full bg-orange-500 px-5 py-2.5 text-sm font-medium text-white transition-colors duration-200 hover:bg-orange-600"
|
||||
>
|
||||
<div className="flex gap-1">
|
||||
{LISTE_ISKELETI.flatMap((b) =>
|
||||
Array.from({ length: b.adet }, (_, i) => (
|
||||
<span
|
||||
key={`${b.dilim}-${i}`}
|
||||
className={`h-6 min-w-0 flex-1 rounded ${b.renk}`}
|
||||
/>
|
||||
)),
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1.5 flex justify-between text-[11px] text-slate-500">
|
||||
<span>
|
||||
üstte 4–6 <span className="font-semibold text-red-600">hayal</span>
|
||||
</span>
|
||||
<span>
|
||||
ortada 12–14{" "}
|
||||
<span className="font-semibold text-amber-600">dengeli</span>
|
||||
</span>
|
||||
<span>
|
||||
sonda 5–7{" "}
|
||||
<span className="font-semibold text-emerald-600">garanti</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="mt-3 space-y-1 text-xs leading-5 text-slate-600">
|
||||
<li>
|
||||
· Havuzda olmayan bir bölümü yazamaz — listedeki her satırın kimliği
|
||||
havuzla karşılaştırılır, uymayan liste yeniden ürettirilir.
|
||||
</li>
|
||||
<li>
|
||||
· Her satıra tek cümlelik gerekçe ve bir risk notu ekler; sıraya neyi
|
||||
neden koyduğu görünür.
|
||||
</li>
|
||||
<li>
|
||||
· Önceliklerin (iş garantisi, maaş, prestij…) hangi programın öne
|
||||
geçeceğini belirler.
|
||||
</li>
|
||||
<li>
|
||||
· Havuz 24'ün altına düşerse filtreler adım adım gevşetilir —
|
||||
önce il, sonra alan — ve bu, listende açıkça belirtilir.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{dar ? (
|
||||
<p className="mt-3 rounded-lg bg-amber-50 px-3 py-2 text-xs leading-5 text-amber-700">
|
||||
Bu seçimle havuz 24'ün altına düştü. Gerçek sihirbaz bu durumda
|
||||
filtreleri adım adım gevşetir — önce il, sonra alan tercihi esnetilir
|
||||
— ve bunu listende açıkça belirtir; liste her zaman 24'e
|
||||
tamamlanır.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Kademeli filtre hunisi: gerçek sihirbaz mantığının oynanabilir kopyası. */
|
||||
export function HuniCanli() {
|
||||
const {
|
||||
veri,
|
||||
yukleniyor,
|
||||
kategori,
|
||||
il,
|
||||
tip,
|
||||
kategoriFacet,
|
||||
ilFacet,
|
||||
kategoriSec,
|
||||
ilSec,
|
||||
tipSec,
|
||||
} = useDemo();
|
||||
|
||||
const toplam = veri.facetler.toplam;
|
||||
const kategoriAdet =
|
||||
kategori != null
|
||||
? (veri.facetler.kategoriler.find((k) => k.ad === kategori)?.adet ?? 0)
|
||||
: null;
|
||||
const ilAdet =
|
||||
il != null && kategoriFacet
|
||||
? (kategoriFacet.iller.find((i) => i.il === il)?.adet ?? 0)
|
||||
: null;
|
||||
const tipAdet =
|
||||
tip != null && ilFacet
|
||||
? (ilFacet.uniturler.find((u) => u.grup === tip)?.adet ?? 0)
|
||||
: null;
|
||||
|
||||
const kategoriSecenekleri = veri.facetler.kategoriler.slice(0, 6).map((k) => ({
|
||||
anahtar: k.ad,
|
||||
etiket: k.ad,
|
||||
adet: k.adet,
|
||||
}));
|
||||
const ilSecenekleri = (kategoriFacet?.iller ?? []).slice(0, 6).map((i) => ({
|
||||
anahtar: i.il,
|
||||
etiket: i.il.toLocaleLowerCase("tr-TR"),
|
||||
adet: i.adet,
|
||||
}));
|
||||
const tipSecenekleri = (ilFacet?.uniturler ?? []).map((u) => ({
|
||||
anahtar: u.grup,
|
||||
etiket: u.grup === "devlet" ? "Devlet" : "Vakıf",
|
||||
adet: u.adet,
|
||||
}));
|
||||
|
||||
// Yapay zekâya giden havuz: huninin en derin (en son seçilmiş) katmanı
|
||||
const havuz = tipAdet ?? ilAdet ?? kategoriAdet ?? toplam;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`mt-6 space-y-4 rounded-2xl border border-slate-200 bg-white p-5 ${
|
||||
yukleniyor ? "animate-pulse opacity-60" : ""
|
||||
}`}
|
||||
>
|
||||
{/* Huni çubukları — her seçim bir çubuk daha ekler */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-6 shrink-0" />
|
||||
<HuniCubugu
|
||||
etiket="Sıralamanla açılan tüm programlar"
|
||||
adet={toplam}
|
||||
oran={1}
|
||||
/>
|
||||
</div>
|
||||
{kategori != null && kategoriAdet != null ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-6 shrink-0 text-right text-xs font-bold text-slate-400">
|
||||
1.
|
||||
</span>
|
||||
<HuniCubugu
|
||||
etiket={`“${kategori}” seçince`}
|
||||
adet={kategoriAdet}
|
||||
oran={toplam > 0 ? kategoriAdet / toplam : 0}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{il != null && ilAdet != null ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-6 shrink-0 text-right text-xs font-bold text-slate-400">
|
||||
2.
|
||||
</span>
|
||||
<HuniCubugu
|
||||
etiket={`+ “${il.toLocaleLowerCase("tr-TR")}” seçince`}
|
||||
adet={ilAdet}
|
||||
oran={toplam > 0 ? ilAdet / toplam : 0}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{tip != null && tipAdet != null ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-6 shrink-0 text-right text-xs font-bold text-slate-400">
|
||||
3.
|
||||
</span>
|
||||
<HuniCubugu
|
||||
etiket={`+ “${tip === "devlet" ? "Devlet" : "Vakıf"}” seçince`}
|
||||
adet={tipAdet}
|
||||
oran={toplam > 0 ? tipAdet / toplam : 0}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Seçim adımları — sihirbazın küçük, oynanabilir hali */}
|
||||
<div className="space-y-3 border-t border-slate-100 pt-4">
|
||||
<div>
|
||||
<p className="mb-1.5 text-xs font-semibold text-slate-500">
|
||||
1. Bir alan seç
|
||||
</p>
|
||||
<SecimCipleri
|
||||
secenekler={kategoriSecenekleri}
|
||||
secili={kategori}
|
||||
onSec={kategoriSec}
|
||||
/>
|
||||
</div>
|
||||
{kategori != null ? (
|
||||
<div>
|
||||
<p className="mb-1.5 text-xs font-semibold text-slate-500">
|
||||
2. Bir il seç{" "}
|
||||
<span className="font-normal text-slate-400">
|
||||
(iller artık yalnızca “{kategori}” programlarını sayıyor)
|
||||
</span>
|
||||
</p>
|
||||
{ilSecenekleri.length > 0 ? (
|
||||
<SecimCipleri
|
||||
secenekler={ilSecenekleri}
|
||||
secili={il}
|
||||
onSec={ilSec}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-xs text-slate-400">Yükleniyor…</p>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{kategori != null && il != null ? (
|
||||
<div>
|
||||
<p className="mb-1.5 text-xs font-semibold text-slate-500">
|
||||
3. Üniversite tipi seç{" "}
|
||||
<span className="font-normal text-slate-400">
|
||||
(sayılar hem alanı hem ili hesaba katıyor)
|
||||
</span>
|
||||
</p>
|
||||
{tipSecenekleri.length > 0 ? (
|
||||
<SecimCipleri
|
||||
secenekler={tipSecenekleri}
|
||||
secili={tip}
|
||||
onSec={(t) => tipSec(t as "devlet" | "vakif" | null)}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-xs text-slate-400">Yükleniyor…</p>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<YapayZekaAsamasi havuz={havuz} />
|
||||
{profil ? "Sıralamana uygun programları gör" : "Sıralamanı gir"}
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
55
src/app/meraklisina/huni-semasi.tsx
Normal file
55
src/app/meraklisina/huni-semasi.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
// 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).
|
||||
|
||||
import { ElCizimiSema } from "@/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.
|
||||
const SEMA = `---
|
||||
config:
|
||||
look: handDrawn
|
||||
---
|
||||
flowchart TD
|
||||
T("Sıralamanla açılan tüm programlar") --> A("1. adım · Alan seçimi<br/>yalnızca ulaşabildiğin alanlar listelenir")
|
||||
A --> B("2. adım · İl seçimi<br/>iller, seçtiğin alanlardaki program sayısına göre yeniden sayılır")
|
||||
B --> C("3. adım · Devlet / Vakıf<br/>sayılar hem alanı hem ili hesaba katar")
|
||||
C --> H("Aday havuzu<br/>elde kalan gerçek programlar")
|
||||
H --> S{"Havuzda 24'ten az program mı kaldı?"}
|
||||
S -- "evet" --> G("Filtreler adım adım gevşetilir<br/>önce il, sonra alan — listede açıkça belirtilir")
|
||||
G --> H
|
||||
S -- "hayır" --> Y("Yapay zekâ havuzdan tam 24 tercih seçer<br/>havuzda olmayan bölümü yazamaz, her satıra gerekçe yazar")
|
||||
Y --> R("Risk notu ve trend özeti<br/>gerçek sıralama geçmişinden hesaplanır, yapay zekâ yorumu değildir")
|
||||
R --> L("24 tercihlik liste")
|
||||
L --> L1("üstte 4–6 hayal")
|
||||
L --> L2("ortada 12–14 dengeli")
|
||||
L --> L3("sonda 5–7 garanti")
|
||||
|
||||
classDef havuz fill:#eff6ff,stroke:#3b82f6,color:#1e3a8a
|
||||
classDef uyari fill:#fffbeb,stroke:#f59e0b,color:#92400e
|
||||
classDef hayal fill:#fee2e2,stroke:#ef4444,color:#991b1b
|
||||
classDef dengeli fill:#fef3c7,stroke:#f59e0b,color:#92400e
|
||||
classDef garanti fill:#d1fae5,stroke:#10b981,color:#065f46
|
||||
class H,Y havuz
|
||||
class G uyari
|
||||
class L1 hayal
|
||||
class L2 dengeli
|
||||
class L3 garanti`;
|
||||
|
||||
export function HuniSemasi() {
|
||||
return (
|
||||
<ElCizimiSema
|
||||
tanim={SEMA}
|
||||
ariaLabel="Sihirbaz akış şeması: sıralamanla açılan programlar alan, il ve devlet/vakıf adımlarıyla aday havuzuna daraltılır; havuz 24'ün altına düşerse filtreler gevşetilir, sonra yapay zekâ havuzdan üstte hayal, ortada dengeli, sonda garanti olacak şekilde 24 tercih seçip her satıra gerekçe yazar; risk notu ve trend özeti gerçek sıralama geçmişinden hesaplanır."
|
||||
figcaption={
|
||||
<>
|
||||
Her adım havuzu biraz daha daraltır; yapay zekâ listeyi bu havuzun{" "}
|
||||
<span className="font-medium text-slate-700">
|
||||
dışına asla çıkmadan
|
||||
</span>{" "}
|
||||
kurar.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,35 +1,37 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { Parallax } from "@/components/parallax";
|
||||
import { PagePixelDivider } from "@/components/pixel-decor";
|
||||
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,
|
||||
HuniCanli,
|
||||
SiralamaDemo,
|
||||
type DemoVeri,
|
||||
} from "./demo";
|
||||
import { HuniSemasi } from "./huni-semasi";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Meraklısına: KolayTercih nasıl çalışır?",
|
||||
alternates: { canonical: "/meraklisina" },
|
||||
description:
|
||||
"Sıralamanı girdiğinde arka planda ne oluyor? Dilimler, risk renkleri ve sihirbazın adım adım daralan filtreleri — öğrenci diliyle, kendi sıralamanla deneyebileceğin canlı görsellerle.",
|
||||
"Sıralamanı girdiğinde arka planda ne oluyor? Dilimler, risk renkleri ve sihirbazın adım adım daralan filtreleri — öğrenci diliyle, sıralamana göre hesaplanan canlı görsellerle.",
|
||||
};
|
||||
|
||||
/**
|
||||
* Algoritmanın öğrenci diline çevrilmiş anlatımı. Teknik detay (SQL, çarpan
|
||||
* mutfağı) bilerek verilmez; amaç güven: veri gerçek, kurallar şeffaf.
|
||||
* Görseller canlıdır: kullanıcı sıralamasını girer, dilim şeridi ve filtre
|
||||
* hunisi o sıralamanın gerçek sayılarıyla güncellenir (bkz. demo.tsx).
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// Demo ilk açılışta bu sıralamayla dolu gelir; SSR'da gerçek veriyle render
|
||||
// edilir ki kullanıcı girmeden de sayfa anlamlı olsun.
|
||||
// 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() {
|
||||
@@ -45,19 +47,18 @@ export default function MeraklisinaPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<main className="mx-auto w-full max-w-2xl px-4 py-16">
|
||||
<Parallax fromStart strength={10}>
|
||||
<p className="text-sm font-semibold tracking-wide text-primary">
|
||||
Meraklısına
|
||||
</p>
|
||||
<h1 className="mt-1 font-heading text-3xl font-bold">
|
||||
<SectionEyebrow>Meraklısına</SectionEyebrow>
|
||||
<h1 className="mt-3 font-heading text-3xl font-bold">
|
||||
Sıralamanı girince arka planda ne oluyor?
|
||||
</h1>
|
||||
<p className="mt-3 text-slate-600">
|
||||
Sihir yok, kara kutu yok: KolayTercih'in önerdiği her satır resmî
|
||||
YÖK Atlas verisinden gelir ve hangi kurala göre önüne geldiği bellidir.
|
||||
Bu sayfa o kuralları öğrenci diliyle anlatır — üstelik görseller
|
||||
canlı: kendi sıralamanı gir, sayılar sana göre değişsin.
|
||||
canlı: sıralamanı girdiysen sayılar sana göre hesaplanır.
|
||||
</p>
|
||||
</Parallax>
|
||||
|
||||
@@ -139,7 +140,7 @@ export default function MeraklisinaPage() {
|
||||
4. Sihirbaz süzer, yapay zekâ kurar
|
||||
</h2>
|
||||
<p className="mt-2">
|
||||
“Listemi oluştur” sihirbazındaki üç adım birbirinden
|
||||
“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
|
||||
@@ -148,11 +149,10 @@ export default function MeraklisinaPage() {
|
||||
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ğıda kendin dene — her seçimin havuzu nasıl
|
||||
daralttığını ve havuzun 24'lük listeye nasıl dönüştüğünü huni
|
||||
canlı gösterir.
|
||||
verilir. Aşağıdaki şema akışın tamamını gösterir — havuz
|
||||
24'ün altına düşerse filtrelerin nasıl gevşetildiği de dahil.
|
||||
</p>
|
||||
<HuniCanli />
|
||||
<HuniSemasi />
|
||||
</section>
|
||||
|
||||
<section>
|
||||
@@ -170,20 +170,11 @@ export default function MeraklisinaPage() {
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div className="rounded-2xl border border-slate-200 bg-white p-6 text-center">
|
||||
<p className="font-heading text-base font-bold text-slate-900">
|
||||
Kuralları öğrendin — şimdi kendi sıralamanla dene.
|
||||
</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="mt-3 inline-flex items-center gap-1.5 rounded-full bg-orange-500 px-5 py-2.5 text-sm font-medium text-white transition-colors duration-200 hover:bg-orange-600"
|
||||
>
|
||||
Sıralamanı gir
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Link>
|
||||
</div>
|
||||
<DemoKapanisCta />
|
||||
</Parallax>
|
||||
</SiralamaDemo>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,8 +10,8 @@ export default function NotFound() {
|
||||
Böyle bir sayfa yok
|
||||
</h1>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-600">
|
||||
Aradığın sayfa taşınmış ya da hiç var olmamış olabilir. Sıralamanı
|
||||
girip listeni kurmaya ana sayfadan devam edebilirsin.
|
||||
Aradığın sayfa taşınmış ya da hiç var olmamış olabilir. Listeni
|
||||
kurmaya ana sayfadan devam edebilirsin.
|
||||
</p>
|
||||
<Button
|
||||
asChild
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import Link from "next/link";
|
||||
import type { Metadata } from "next";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { CheckCircle2, Sparkles, XCircle } from "lucide-react";
|
||||
import { ArrowRight, CheckCircle2, XCircle } from "lucide-react";
|
||||
import { verifySession } from "@/lib/session";
|
||||
import { appDb, schema } from "@/lib/appdb";
|
||||
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";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Ödeme Sonucu",
|
||||
@@ -19,7 +21,11 @@ export default async function OdemeSonucPage({
|
||||
searchParams: Promise<{ siparis?: string }>;
|
||||
}) {
|
||||
const { siparis } = await searchParams;
|
||||
const session = await verifySession("/paket");
|
||||
// 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({
|
||||
@@ -73,22 +79,24 @@ export default async function OdemeSonucPage({
|
||||
</p>
|
||||
<PagePixelDivider seed={79} className="mx-auto mt-5" />
|
||||
<div className="mt-6 flex flex-col gap-2">
|
||||
<Button
|
||||
asChild
|
||||
className="h-11 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||
>
|
||||
<Link href={listeHref ?? "/"}>
|
||||
<Sparkles className="size-4" aria-hidden />
|
||||
{listeHref
|
||||
? "Listeni aç — kilidi kaldırdık"
|
||||
: "24'lük listeni oluştur"}
|
||||
</Link>
|
||||
</Button>
|
||||
<p className="text-center text-xs text-slate-500">
|
||||
{listeHref
|
||||
? "24 tercihin tamamı, gerekçeler, riskler ve PDF artık açık."
|
||||
: "Sıralamanı girip sonuç sayfasındaki sihirbazla listeni kur."}
|
||||
</p>
|
||||
{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>
|
||||
) : (
|
||||
@@ -118,6 +126,7 @@ export default async function OdemeSonucPage({
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
36
src/app/odeme/sonuc/rapor-yok-cta.tsx
Normal file
36
src/app/odeme/sonuc/rapor-yok-cta.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
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";
|
||||
|
||||
/**
|
||||
* Ödeme başarılı ama henüz Yapay Zeka listesi yokken gösterilen CTA: kayıtlı tercih
|
||||
* profili olan adayı sihirbazı açık hâlde sonuç sayfasına götürür ve
|
||||
* "sıralamanı gir" metni gösterilmez; profil yoksa ana sayfadaki sıralama
|
||||
* formuna yönlendirir.
|
||||
*/
|
||||
export function RaporYokCta() {
|
||||
const profil = useTercihProfili();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
asChild
|
||||
className="h-11 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||
>
|
||||
<Link href={profil ? sonucHref(profil, { sihirbaz: "1" }) : "/"}>
|
||||
24'lük listeni oluştur
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Link>
|
||||
</Button>
|
||||
<p className="text-center text-xs text-slate-500">
|
||||
{profil
|
||||
? "Sıralaman kayıtlı — sonuç sayfasındaki sihirbazla listeni kur."
|
||||
: "Sıralamanı girip sonuç sayfasındaki sihirbazla listeni kur."}
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
160
src/app/page.tsx
160
src/app/page.tsx
@@ -1,10 +1,14 @@
|
||||
import {
|
||||
ArrowRight,
|
||||
ArrowUpRight,
|
||||
BarChart3,
|
||||
CheckCircle2,
|
||||
Coins,
|
||||
Compass,
|
||||
FileText,
|
||||
ListChecks,
|
||||
MessageCircleQuestion,
|
||||
RefreshCcw,
|
||||
ShieldCheck,
|
||||
TrendingUp,
|
||||
Wallet,
|
||||
@@ -12,10 +16,13 @@ import {
|
||||
} from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { HeroFocusButton } from "@/components/hero-focus-button";
|
||||
import { Suspense } from "react";
|
||||
import { HeroBaslik } from "@/components/hero-baslik";
|
||||
import { KapanisCta } from "@/components/hero-focus-button";
|
||||
import { HeroForm } from "@/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 {
|
||||
PixelCorner,
|
||||
@@ -23,14 +30,14 @@ import {
|
||||
PixelField,
|
||||
SectionEyebrow,
|
||||
} from "@/components/pixel-decor";
|
||||
import { URUNLER } from "@/lib/credits";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { MAX_REVIZYON, URUNLER } from "@/lib/credits";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardDescription,
|
||||
@@ -160,10 +167,27 @@ const comparison = [
|
||||
feature: "Fiyat",
|
||||
robot: "Ücretsiz",
|
||||
danisman: "2.000–10.000 TL",
|
||||
tercihai: "İnsan danışmanın ~10'da 1'i",
|
||||
tercihai: `${PAKET_FIYATI} TL · tek seferlik`,
|
||||
},
|
||||
];
|
||||
|
||||
const paketOzellikleri = [
|
||||
{
|
||||
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)",
|
||||
},
|
||||
{ icon: FileText, text: "Veliyle paylaşılabilir PDF rapor" },
|
||||
{ icon: RefreshCcw, text: `${MAX_REVIZYON} liste revizyon hakkı` },
|
||||
{
|
||||
icon: MessageCircleQuestion,
|
||||
text: `${URUNLER.paket.credits} yapay zekâ danışman sorusu`,
|
||||
},
|
||||
] as const;
|
||||
|
||||
function ComparisonCell({ value }: { value: boolean | string }) {
|
||||
if (typeof value === "string") {
|
||||
return <span className="text-sm font-medium">{value}</span>;
|
||||
@@ -209,13 +233,7 @@ export default function Home() {
|
||||
strength={14}
|
||||
className="mx-auto flex min-h-[calc(100svh-5.5rem)] max-w-6xl flex-col items-center justify-center px-4 pb-4 pt-12 text-center sm:pb-36 sm:pt-4"
|
||||
>
|
||||
<h1 className="max-w-3xl font-heading text-5xl font-bold tracking-[0.015em] sm:text-6xl">
|
||||
Dört yıllık geleceğini <br className="hidden sm:block" />
|
||||
<span className="font-bricolage italic tracking-tighter font-bold text-primary">
|
||||
48 saatlik panikle
|
||||
</span>{" "}
|
||||
seçme
|
||||
</h1>
|
||||
<HeroBaslik />
|
||||
{/* <p className="mt-6 max-w-2xl text-lg leading-relaxed text-slate-600">
|
||||
KolayTercih, gerçek YÖK Atlas verisiyle çalışan yapay zekâ tercih
|
||||
danışmanın. İnsan danışmanın binlerce lira aldığı işi — dengeli
|
||||
@@ -227,10 +245,6 @@ export default function Home() {
|
||||
>
|
||||
<HeroForm />
|
||||
</div>
|
||||
<p className="mt-4 text-sm text-slate-500">
|
||||
Temel arama her zaman ücretsiz · Kredi kartı gerekmez · Tam analiz
|
||||
paketi tek seferlik {PAKET_FIYATI} TL
|
||||
</p>
|
||||
<div className="mt-3 inline-flex items-center gap-2.5 rounded-lg border border-slate-200 bg-white px-3 py-2 text-left text-xs text-slate-700 shadow-xs">
|
||||
<Image
|
||||
src="/yok-logo.svg"
|
||||
@@ -462,7 +476,115 @@ export default function Home() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Karşılaştırma → SSS geçişi */}
|
||||
{/* Karşılaştırma → Fiyat geçişi */}
|
||||
<div aria-hidden className="mx-auto w-56">
|
||||
<Parallax strength={26}>
|
||||
<PixelDivider seed={29} />
|
||||
</Parallax>
|
||||
</div>
|
||||
|
||||
{/* Fiyat */}
|
||||
<section id="fiyat" className="scroll-mt-24 py-20 sm:py-24">
|
||||
<div className="mx-auto max-w-4xl px-4">
|
||||
<Parallax strength={16}>
|
||||
<Reveal className="flex flex-col items-center gap-4 text-center">
|
||||
<SectionEyebrow>Tek seferlik · Abonelik yok</SectionEyebrow>
|
||||
<h2 className="font-heading text-3xl font-bold sm:text-4xl">
|
||||
Ne kadar? Ne dahil?
|
||||
</h2>
|
||||
<p className="max-w-xl text-lg leading-relaxed text-slate-600">
|
||||
İnsan danışmanın 2.000–10.000 TL aldığı işi tek seferlik{" "}
|
||||
{PAKET_FIYATI} TL'ye alırsın. Abonelik yok, gizli ücret
|
||||
yok.
|
||||
</p>
|
||||
</Reveal>
|
||||
</Parallax>
|
||||
|
||||
<Parallax
|
||||
strength={7}
|
||||
className="mt-12 grid gap-6 sm:grid-cols-[1fr_auto]"
|
||||
>
|
||||
<Reveal
|
||||
delay={0.08}
|
||||
className="rounded-2xl border-2 border-orange-500 bg-white p-8 shadow-sm"
|
||||
>
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<h3 className="font-heading text-xl font-bold">
|
||||
{URUNLER.paket.label}
|
||||
</h3>
|
||||
<p className="font-heading text-3xl font-bold">
|
||||
{PAKET_FIYATI} TL
|
||||
</p>
|
||||
</div>
|
||||
<ul className="mt-6 space-y-3">
|
||||
{paketOzellikleri.map((o) => (
|
||||
<li
|
||||
key={o.text}
|
||||
className="flex items-start gap-3 text-sm text-slate-700"
|
||||
>
|
||||
<o.icon
|
||||
className="mt-0.5 size-4 shrink-0 text-emerald-600"
|
||||
aria-hidden
|
||||
/>
|
||||
{o.text}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* Boşluk sarmalayıcıda: sosyal kanıt eşik altında kalıp hiç
|
||||
çizilmediğinde buton yine listeden mt-8 uzakta durur. */}
|
||||
<div className="mt-8 flex flex-col gap-4">
|
||||
{/* Karar butonun başında veriliyor; kanıt da orada dursun.
|
||||
İstek anında okunuyor, o yüzden Suspense içinde. */}
|
||||
<Suspense fallback={null}>
|
||||
<SosyalKanitBandi />
|
||||
</Suspense>
|
||||
|
||||
<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="/paket">
|
||||
{PAKET_FIYATI} TL — Paketi incele
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<p className="text-center text-xs text-slate-500">
|
||||
Temel program arama herkes için ücretsiz · iyzico güvenli
|
||||
ödeme
|
||||
</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<Reveal
|
||||
delay={0.16}
|
||||
className="flex h-fit flex-col rounded-2xl border border-slate-200/70 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 />
|
||||
<h3 className="font-heading font-bold">Kredi bitti mi?</h3>
|
||||
</div>
|
||||
<p className="mt-2 flex-1 text-sm leading-relaxed text-slate-600">
|
||||
+{URUNLER.topup.credits} soru hakkı, {TOPUP_FIYATI} TL. Pakete
|
||||
ek olarak, istediğin zaman.
|
||||
</p>
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
className="mt-4 h-11 w-full cursor-pointer"
|
||||
>
|
||||
<Link href="/paket">
|
||||
+{URUNLER.topup.credits} kredi — {TOPUP_FIYATI} TL
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Link>
|
||||
</Button>
|
||||
</Reveal>
|
||||
</Parallax>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Fiyat → SSS geçişi */}
|
||||
<div aria-hidden className="mx-auto w-56">
|
||||
<Parallax strength={26}>
|
||||
<PixelDivider seed={31} />
|
||||
@@ -530,11 +652,7 @@ export default function Home() {
|
||||
<h2 className="mt-6 font-heading text-3xl font-bold sm:text-4xl">
|
||||
Tercih listen, pişmanlık listesi olmasın
|
||||
</h2>
|
||||
<p className="mt-4 max-w-xl text-lg leading-relaxed text-white/90">
|
||||
Sıralamanı gir, yapay zekâ danışmanın veriyle konuşsun.
|
||||
Tercih dönemi bitmeden yerini al.
|
||||
</p>
|
||||
<HeroFocusButton />
|
||||
<KapanisCta />
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Link from "next/link";
|
||||
import type { Metadata } from "next";
|
||||
import {
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
Coins,
|
||||
FileText,
|
||||
@@ -12,10 +13,10 @@ import {
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
import { URUNLER, DENEME_KREDISI } from "@/lib/credits";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { SectionEyebrow } from "@/components/pixel-decor";
|
||||
import { Parallax } from "@/components/parallax";
|
||||
import { PagePixelDivider } from "@/components/pixel-decor";
|
||||
import { SatinAlForm } from "./satin-al-form";
|
||||
import { SiteFooter } from "@/components/site-footer";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Tercih Dönemi Paketi",
|
||||
@@ -41,7 +42,7 @@ const OZELLIKLER = [
|
||||
{ icon: RefreshCcw, text: "2 liste revizyon hakkı" },
|
||||
{
|
||||
icon: MessageCircleQuestion,
|
||||
text: `${URUNLER.paket.credits} AI danışman sorusu (her mesaj 1 kredi)`,
|
||||
text: `${URUNLER.paket.credits} Yapay Zeka danışman sorusu (her mesaj 1 kredi)`,
|
||||
},
|
||||
] as const;
|
||||
|
||||
@@ -68,9 +69,7 @@ export default async function PaketPage({
|
||||
</div>
|
||||
) : null}
|
||||
<Parallax fromStart strength={12} className="text-center">
|
||||
<Badge variant="secondary" className="bg-primary/10 text-primary">
|
||||
Tek seferlik ödeme · Abonelik yok
|
||||
</Badge>
|
||||
<SectionEyebrow>Tek seferlik ödeme · Abonelik yok</SectionEyebrow>
|
||||
<h1 className="mt-4 font-heading text-3xl font-bold sm:text-4xl">
|
||||
İnsan danışmanın işini,{" "}
|
||||
<span className="text-primary">onda bir fiyatına</span>
|
||||
@@ -81,8 +80,6 @@ export default async function PaketPage({
|
||||
</p>
|
||||
</Parallax>
|
||||
|
||||
<PagePixelDivider seed={53} className="mx-auto mt-7" />
|
||||
|
||||
<Parallax
|
||||
strength={6}
|
||||
className="mt-10 grid gap-6 sm:grid-cols-[1fr_auto]"
|
||||
@@ -126,16 +123,23 @@ export default async function PaketPage({
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<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")}`}
|
||||
<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"
|
||||
>
|
||||
Giriş yap ve {DENEME_KREDISI} deneme kredisi kazan
|
||||
</Link>
|
||||
</Button>
|
||||
<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>
|
||||
<p className="mt-4 text-center text-xs text-slate-500">
|
||||
@@ -160,11 +164,20 @@ export default async function PaketPage({
|
||||
{tl(URUNLER.topup.amountKurus)} TL.
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
{u ? (
|
||||
{/* 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 al`}
|
||||
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
|
||||
@@ -192,6 +205,7 @@ export default async function PaketPage({
|
||||
.
|
||||
</p>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState, useEffect } from "react";
|
||||
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";
|
||||
|
||||
export function SatinAlForm({
|
||||
@@ -24,7 +26,10 @@ export function SatinAlForm({
|
||||
}, [state]);
|
||||
|
||||
return (
|
||||
<form action={formAction}>
|
||||
<form
|
||||
action={formAction}
|
||||
onSubmit={() => olay("odeme_baslatildi", { urun })}
|
||||
>
|
||||
<input type="hidden" name="urun" value={urun} />
|
||||
<Button
|
||||
type="submit"
|
||||
@@ -36,7 +41,14 @@ export function SatinAlForm({
|
||||
}
|
||||
variant={vurgulu ? "default" : "outline"}
|
||||
>
|
||||
{pending ? "Ödeme sayfasına yönlendiriliyorsun…" : label}
|
||||
{pending ? (
|
||||
"Ödeme sayfasına yönlendiriliyorsun…"
|
||||
) : (
|
||||
<>
|
||||
{label}
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ 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";
|
||||
|
||||
@@ -103,8 +104,23 @@ export default async function RehberYaziPage({
|
||||
[&_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"
|
||||
dangerouslySetInnerHTML={{ __html: yazi.html }}
|
||||
/>
|
||||
>
|
||||
{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">
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"use server";
|
||||
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { getSession, getCurrentUser } from "@/lib/session";
|
||||
import { appDb, schema } from "@/lib/appdb";
|
||||
import { PUAN_TURLERI, type PuanTuruKey } from "@/lib/db";
|
||||
import {
|
||||
spendCredits,
|
||||
grantCredits,
|
||||
krediBittiIsaretle,
|
||||
RAPOR_KREDI,
|
||||
MAX_REVIZYON,
|
||||
} from "@/lib/credits";
|
||||
@@ -41,9 +42,21 @@ function siraTurGecerli(sira: number, tur: string): tur is PuanTuruKey {
|
||||
|
||||
function aiHataMesaji(err: unknown): string {
|
||||
if (err instanceof RaporUretimHatasi) return err.message;
|
||||
// AbortSignal.timeout: DOMException "TimeoutError"; SDK'lar abort'u kendi
|
||||
// hata sınıflarıyla sarabiliyor (ör. APIUserAbortError) ve openai/anthropic
|
||||
// bu sınıflarda .name'i "Error" bırakıyor — ada ek olarak sınıf adına ve
|
||||
// mesaja da bak ("Request was aborted." / "Request timed out.").
|
||||
if (
|
||||
err instanceof Error &&
|
||||
/abort|timeout|timed out/i.test(
|
||||
`${err.name} ${err.constructor.name} ${err.message}`,
|
||||
)
|
||||
) {
|
||||
return "Liste üretimi bu sefer çok uzun sürdü ve durduruldu; harcanan kredin otomatik iade edildi. Tekrar dener misin?";
|
||||
}
|
||||
if (err instanceof Error && err.message === "AI_KEY_MISSING") {
|
||||
// Yapılandırma detayı log'a; kullanıcıya altyapı sızdırmayan mesaj
|
||||
console.error("[liste] ANTHROPIC_API_KEY eksik — üretim başlatılamadı");
|
||||
console.error("[liste] Yapay Zeka anahtarı eksik — üretim başlatılamadı");
|
||||
return "Liste üretimi şu anda başlatılamıyor. Birazdan tekrar dener misin? Harcanan kredin otomatik iade edilir.";
|
||||
}
|
||||
console.error("[liste] üretim hatası:", err);
|
||||
@@ -108,13 +121,30 @@ export async function listeOlustur(input: {
|
||||
});
|
||||
if (!harcama.ok) {
|
||||
if (harcama.error === "INSUFFICIENT") {
|
||||
await krediBittiIsaretle(user.id);
|
||||
return {
|
||||
ok: false,
|
||||
code: "KREDI",
|
||||
error: `Liste oluşturmak için ${RAPOR_KREDI} kredi gerekli.`,
|
||||
};
|
||||
}
|
||||
// DUPLICATE: bu requestId zaten işlendi → mevcut raporu döndür
|
||||
// DUPLICATE: bu requestId zaten işlendi. Önceki deneme başarısız olup
|
||||
// iade edildiyse mevcut (eski) raporu yeniymiş gibi döndürme; taze bir
|
||||
// requestId ile yeniden denemesini iste.
|
||||
const iade = await appDb.query.creditLedger.findFirst({
|
||||
where: and(
|
||||
eq(schema.creditLedger.reason, "refund"),
|
||||
eq(schema.creditLedger.refId, input.requestId),
|
||||
),
|
||||
});
|
||||
if (iade) {
|
||||
return {
|
||||
ok: false,
|
||||
code: "HATA",
|
||||
error:
|
||||
"Önceki denemen başarısız olduğu için kredin iade edilmişti. Tekrar dener misin?",
|
||||
};
|
||||
}
|
||||
const mevcut = await appDb.query.reports.findFirst({
|
||||
where: eq(schema.reports.userId, user.id),
|
||||
});
|
||||
@@ -220,12 +250,43 @@ export async function listeRevize(input: {
|
||||
});
|
||||
if (!harcama.ok) {
|
||||
if (harcama.error === "INSUFFICIENT") {
|
||||
await krediBittiIsaretle(user.id);
|
||||
return {
|
||||
ok: false,
|
||||
code: "KREDI",
|
||||
error: `Revizyon için ${RAPOR_KREDI} kredi gerekli.`,
|
||||
};
|
||||
}
|
||||
// DUPLICATE: bu requestId zaten işlendi. İade varsa önceki deneme
|
||||
// başarısızdı → taze bir denemeye yönlendir. Yoksa revizyon tamamlanmış
|
||||
// ama cevap istemciye ulaşmamıştır → güncel raporu döndür.
|
||||
const iade = await appDb.query.creditLedger.findFirst({
|
||||
where: and(
|
||||
eq(schema.creditLedger.reason, "refund"),
|
||||
eq(schema.creditLedger.refId, input.requestId),
|
||||
),
|
||||
});
|
||||
if (iade) {
|
||||
return {
|
||||
ok: false,
|
||||
code: "HATA",
|
||||
error:
|
||||
"Önceki denemen başarısız olduğu için kredin iade edilmişti. Tekrar dener misin?",
|
||||
};
|
||||
}
|
||||
const guncel = await appDb.query.reports.findFirst({
|
||||
where: eq(schema.reports.userId, user.id),
|
||||
});
|
||||
if (guncel?.result) {
|
||||
return {
|
||||
ok: true,
|
||||
rapor: guncel.result as RaporSonuc,
|
||||
params: guncel.params as RaporParams,
|
||||
revisionCount: guncel.revisionCount,
|
||||
kredi: user.creditBalance,
|
||||
hasPaket: true,
|
||||
};
|
||||
}
|
||||
return { ok: false, code: "HATA", error: "İstek tekrarlandı." };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,395 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useEffect, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { FileText, Lock, RefreshCcw } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { SohbetPopup } from "./sohbet-popup";
|
||||
import { RaporListesi } from "@/components/rapor-listesi";
|
||||
import { TercihHaritasi, type HaritaPin } from "@/components/tercih-haritasi";
|
||||
import type { RaporSonuc } from "@/lib/ai/rapor";
|
||||
import type { RaporParams } from "@/lib/rapor-havuzu";
|
||||
import { ACIK_SATIR } from "@/lib/rapor-maske";
|
||||
import { ilKonum, uniAdiNormalize, uniKonum } from "@/lib/harita";
|
||||
import { dilimdenRisk, riskHesapla, type RiskSeviyesi } from "@/lib/risk";
|
||||
|
||||
const MAX_REVIZYON = 2;
|
||||
|
||||
export type MevcutRapor = {
|
||||
rapor: RaporSonuc;
|
||||
params: RaporParams;
|
||||
revisionCount: number;
|
||||
};
|
||||
|
||||
/** Panelin liste dışındaki geçici durumları (liste, rapor varlığından türer). */
|
||||
export type PanelDurum = "bos" | "uretiliyor" | "kredi-yetersiz";
|
||||
|
||||
const RISK_AGIRLIK: Record<RiskSeviyesi, number> = {
|
||||
guvenli: 0,
|
||||
"az-riskli": 1,
|
||||
riskli: 2,
|
||||
};
|
||||
|
||||
/**
|
||||
* Rapordan harita pinleri türetir. Açık satırlar üniversite bazında gruplanır
|
||||
* (kampüs konumunda, adıyla); kilitli satırlar il başına tek adsız "?" pinine
|
||||
* toplanır — maskeli veride üniversite adı zaten client'a inmediği için
|
||||
* harita paywall'ı delemez. Haritada karşılığı olmayan tercihler sayılır.
|
||||
*/
|
||||
function pinleriTuret(
|
||||
rapor: RaporSonuc,
|
||||
adaySira: number,
|
||||
kilitliBaslangic: number,
|
||||
): { pinler: HaritaPin[]; haritaDisi: number } {
|
||||
const gruplar = new Map<string, HaritaPin>();
|
||||
let haritaDisi = 0;
|
||||
|
||||
rapor.tercihler.forEach((t, i) => {
|
||||
const p = rapor.programlar[t.programId];
|
||||
if (!p) return;
|
||||
const risk = riskHesapla(p.efektifSira, adaySira) ?? dilimdenRisk(t.dilim);
|
||||
const kilitli = i >= kilitliBaslangic;
|
||||
|
||||
if (kilitli) {
|
||||
const konum = ilKonum(p.il);
|
||||
if (!konum) {
|
||||
haritaDisi += 1;
|
||||
return;
|
||||
}
|
||||
const key = `kilit|${p.il}`;
|
||||
const mevcut = gruplar.get(key);
|
||||
if (mevcut) {
|
||||
mevcut.tercihSiralari.push(t.sira);
|
||||
if (RISK_AGIRLIK[risk] > RISK_AGIRLIK[mevcut.risk]) mevcut.risk = risk;
|
||||
} else {
|
||||
// Açık pinlerle üst üste binmesin diye il merkezinin hafif altına
|
||||
gruplar.set(key, {
|
||||
id: key,
|
||||
universite: "",
|
||||
il: p.il,
|
||||
x: konum.x,
|
||||
y: konum.y + 9,
|
||||
tercihSiralari: [t.sira],
|
||||
risk,
|
||||
kilitli: true,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const konum = uniKonum(p.universite, p.il);
|
||||
if (!konum) {
|
||||
haritaDisi += 1;
|
||||
return;
|
||||
}
|
||||
const key = `${uniAdiNormalize(p.universite)}|${p.il ?? ""}`;
|
||||
const mevcut = gruplar.get(key);
|
||||
if (mevcut) {
|
||||
mevcut.tercihSiralari.push(t.sira);
|
||||
if (RISK_AGIRLIK[risk] > RISK_AGIRLIK[mevcut.risk]) mevcut.risk = risk;
|
||||
} else {
|
||||
gruplar.set(key, {
|
||||
id: key,
|
||||
universite: p.universite,
|
||||
il: p.il,
|
||||
x: konum.x,
|
||||
y: konum.y,
|
||||
tercihSiralari: [t.sira],
|
||||
risk,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return { pinler: [...gruplar.values()], haritaDisi };
|
||||
}
|
||||
|
||||
const URETIM_MESAJLARI = [
|
||||
"Sıralamana uygun programlar taranıyor…",
|
||||
"Gerçek YÖK Atlas verisiyle dengeli liste kuruluyor…",
|
||||
"Her tercih için risk ve trend analizi yazılıyor…",
|
||||
"Son rötuşlar yapılıyor, neredeyse hazır…",
|
||||
];
|
||||
|
||||
/**
|
||||
* Sonuç sayfası gövdesindeki liste paneli: üretim animasyonu, paketli tam
|
||||
* liste veya paketsiz progressive-blur önizleme burada render edilir.
|
||||
* (Sihirbaz modalı yalnızca adımları toplar; sonuç artık sayfanın kendisinde.)
|
||||
*/
|
||||
export function ListePaneli({
|
||||
durum,
|
||||
rapor,
|
||||
hasPaket,
|
||||
kredi,
|
||||
sira,
|
||||
acilis,
|
||||
onRevize,
|
||||
}: {
|
||||
durum: PanelDurum;
|
||||
rapor: MevcutRapor | null;
|
||||
hasPaket: boolean;
|
||||
kredi: number;
|
||||
sira: number;
|
||||
/** Ödeme dönüşü: blur çözülme animasyonuyla aç */
|
||||
acilis?: boolean;
|
||||
onRevize: (feedback: string) => Promise<boolean>;
|
||||
}) {
|
||||
const kilitli = !hasPaket;
|
||||
|
||||
// Harita ⇄ liste köprüsü: pinden satıra, satırdan haritaya
|
||||
const [seciliIl, setSeciliIl] = useState<string | null>(null);
|
||||
const [acikSira, setAcikSira] = useState<number | null>(null);
|
||||
const haritaRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const pinVerisi = useMemo(
|
||||
() =>
|
||||
rapor
|
||||
? pinleriTuret(
|
||||
rapor.rapor,
|
||||
rapor.params.sira,
|
||||
kilitli ? ACIK_SATIR : rapor.rapor.tercihler.length,
|
||||
)
|
||||
: null,
|
||||
[rapor, kilitli],
|
||||
);
|
||||
|
||||
if (durum === "uretiliyor") return <UretimGovdesi />;
|
||||
|
||||
if (durum === "kredi-yetersiz") {
|
||||
return (
|
||||
<section className="mt-16 rounded-2xl border border-slate-200 bg-white p-8 text-center sm:p-12">
|
||||
<Lock className="mx-auto size-8 text-slate-400" aria-hidden />
|
||||
<h2 className="mt-4 font-heading text-xl font-bold">
|
||||
Kredin bu liste için yetmiyor
|
||||
</h2>
|
||||
<p className="mx-auto mt-2 max-w-md text-sm text-slate-600">
|
||||
Liste üretimi 3 kredi. Tercih Dönemi Paketi'yle 60 kredi, tam
|
||||
liste, PDF ve revizyon hakları birlikte gelir.
|
||||
</p>
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="mt-6 h-12 cursor-pointer bg-orange-500 px-8 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||
>
|
||||
<Link href="/paket">299 TL — Paketi aktive et</Link>
|
||||
</Button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (!rapor) return null;
|
||||
|
||||
return (
|
||||
<section
|
||||
className={`mt-16 ${acilis && !kilitli ? "kt-unblur" : ""}`}
|
||||
aria-label="Tercih listen"
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="font-heading text-2xl font-bold">
|
||||
24 Tercihlik Listen
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
{sira.toLocaleString("tr-TR")}. sıra ·{" "}
|
||||
{kilitli
|
||||
? `tüm taban verileri açık · ${ACIK_SATIR} AI analiz örneği`
|
||||
: `${kredi} kredin kaldı`}
|
||||
</p>
|
||||
</div>
|
||||
{!kilitli ? (
|
||||
<Button asChild variant="outline" className="cursor-pointer">
|
||||
<Link href="/rapor/yazdir" target="_blank">
|
||||
<FileText className="size-4" aria-hidden />
|
||||
PDF indir
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Harita: 24 tercihin coğrafyası — ile dokun, üniversiteleri gör */}
|
||||
<div ref={haritaRef} className="mt-5 scroll-mt-24">
|
||||
<TercihHaritasi
|
||||
pinler={pinVerisi?.pinler ?? []}
|
||||
seciliIl={seciliIl}
|
||||
onSeciliIlDegisti={setSeciliIl}
|
||||
onTercihSec={setAcikSira}
|
||||
/>
|
||||
{pinVerisi && pinVerisi.haritaDisi > 0 ? (
|
||||
<p className="mt-1 px-1 text-xs text-slate-500">
|
||||
{pinVerisi.haritaDisi} tercih harita dışında (KKTC vb.)
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<RaporListesi
|
||||
rapor={rapor.rapor}
|
||||
adaySira={rapor.params.sira}
|
||||
acikSira={acikSira}
|
||||
onHaritadaGor={(il) => {
|
||||
if (!il) return;
|
||||
setSeciliIl(il);
|
||||
haritaRef.current?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "start",
|
||||
});
|
||||
}}
|
||||
kilitliBaslangic={kilitli ? ACIK_SATIR : undefined}
|
||||
kilitOverlay={
|
||||
<div className="flex flex-col items-start gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex gap-3">
|
||||
<Lock
|
||||
className="mt-1 size-5 shrink-0 text-orange-700"
|
||||
aria-hidden
|
||||
/>
|
||||
<div>
|
||||
<p className="font-heading font-bold text-slate-900">
|
||||
Tüm program ve taban verileri ücretsiz
|
||||
</p>
|
||||
<p className="mt-1 max-w-xl text-sm text-slate-600">
|
||||
Paket; kalan {24 - ACIK_SATIR} tercih için kişisel risk,
|
||||
gerekçe, PDF, 2 revizyon ve 60 AI danışman sorusunu açar.
|
||||
</p>
|
||||
{kredi > 0 ? (
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
Kalan {kredi} deneme kredin hesabında duruyor.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="h-12 w-full shrink-0 cursor-pointer bg-orange-500 px-8 text-white transition-colors duration-200 hover:bg-orange-600 sm:w-auto"
|
||||
>
|
||||
<Link href="/paket">299 TL — Analizleri aç</Link>
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!kilitli ? (
|
||||
<div className="mt-6">
|
||||
<RevizyonKutusu
|
||||
kalanHak={MAX_REVIZYON - rapor.revisionCount}
|
||||
onRevize={onRevize}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* AI danışman: sağ altta yüzen buton, alttan popup olarak açılır */}
|
||||
<SohbetPopup kredi={kredi} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function UretimGovdesi() {
|
||||
const [mesajIdx, setMesajIdx] = useState(0);
|
||||
const govdeRef = useRef<HTMLElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setMesajIdx((i) => Math.min(i + 1, URETIM_MESAJLARI.length - 1));
|
||||
}, 7000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// Modal geçiş anında unmount edildiğinden Radix odağı tetikleyiciye geri
|
||||
// veremiyor; odağı panele alıp klavye ve ekran okuyucu akışını sürdürüyoruz.
|
||||
useEffect(() => {
|
||||
govdeRef.current?.focus({ preventScroll: true });
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={govdeRef}
|
||||
tabIndex={-1}
|
||||
aria-live="polite"
|
||||
className="mt-16 rounded-2xl border border-slate-200 bg-white p-6 outline-none sm:p-8"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-1.5 text-center">
|
||||
<p className="font-heading text-lg font-bold">Listen hazırlanıyor</p>
|
||||
<p className="max-w-sm text-sm text-slate-500">
|
||||
{URETIM_MESAJLARI[mesajIdx]}
|
||||
</p>
|
||||
<p className="text-xs text-slate-400">
|
||||
Bu işlem yarım dakika kadar sürebilir, sayfayı kapatma.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Gelmekte olan tercih listesinin iskeleti */}
|
||||
<div className="mt-8 flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-6 w-44" />
|
||||
<Skeleton className="h-6 w-24 rounded-full" />
|
||||
</div>
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function RevizyonKutusu({
|
||||
kalanHak,
|
||||
onRevize,
|
||||
}: {
|
||||
kalanHak: number;
|
||||
onRevize: (feedback: string) => Promise<boolean>;
|
||||
}) {
|
||||
const [feedback, setFeedback] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
if (kalanHak <= 0) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-slate-200 bg-white p-5">
|
||||
<p className="text-sm text-slate-500">
|
||||
Revizyon hakların doldu. Aşağıdan listen hakkında soru sorabilirsin.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-slate-200 bg-white p-5">
|
||||
<h3 className="font-heading text-base font-bold">
|
||||
Listede değişiklik mi istiyorsun?
|
||||
</h3>
|
||||
<p className="mb-3 mt-1 text-sm text-slate-600">
|
||||
Ne değişsin istediğini yaz; liste aynı gerçek veriyle yeniden kurulsun
|
||||
(3 kredi).
|
||||
</p>
|
||||
<textarea
|
||||
value={feedback}
|
||||
onChange={(e) => setFeedback(e.target.value)}
|
||||
rows={2}
|
||||
maxLength={1000}
|
||||
placeholder="ör. İlk 5 tercihte daha fazla İstanbul olsun, garanti dilimini genişlet…"
|
||||
disabled={pending}
|
||||
className="w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||
/>
|
||||
<div className="mt-3 flex items-center gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={pending || feedback.trim().length < 5}
|
||||
onClick={async () => {
|
||||
setPending(true);
|
||||
const ok = await onRevize(feedback.trim());
|
||||
setPending(false);
|
||||
if (ok) setFeedback("");
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<RefreshCcw className="size-4" aria-hidden />
|
||||
{pending ? "Yeniden kuruluyor…" : "Revize et (3 kredi)"}
|
||||
</Button>
|
||||
<span className="text-xs text-slate-500">
|
||||
Kalan hak: <span className="font-semibold">{kalanHak}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
import Link from "next/link";
|
||||
import type { Metadata } from "next";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { eq } from "drizzle-orm";
|
||||
import {
|
||||
PUAN_TURLERI,
|
||||
@@ -8,19 +6,15 @@ import {
|
||||
searchByRank,
|
||||
type PuanTuruKey,
|
||||
} from "@/lib/db";
|
||||
import { getSession, getCurrentUser } from "@/lib/session";
|
||||
import { getSession } from "@/lib/session";
|
||||
import { appDb, schema } from "@/lib/appdb";
|
||||
import type { RaporSonuc } from "@/lib/ai/rapor";
|
||||
import type { RaporParams } from "@/lib/rapor-havuzu";
|
||||
import { raporMaskele } from "@/lib/rapor-maske";
|
||||
import { tadimlikSec } from "@/lib/tadimlik-havuzu";
|
||||
import { SihirbazBolumu } from "./sihirbaz-cagri-karti";
|
||||
import { FunnelBanner } from "./funnel-banner";
|
||||
import type { MevcutRapor } from "./liste-paneli";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { TadimlikSatiri } from "./tadimlik-satiri";
|
||||
import { ProgramTablosu } from "@/components/program-tablosu";
|
||||
import { ManuelHarita } from "@/components/manuel-liste/manuel-harita";
|
||||
import { PagePixelDivider } from "@/components/pixel-decor";
|
||||
import { SiteFooter } from "@/components/site-footer";
|
||||
import { SiraGerekli } from "./sira-gerekli";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Tercih Listeni Kur",
|
||||
@@ -30,14 +24,6 @@ export const metadata: Metadata = {
|
||||
robots: { index: false, follow: true },
|
||||
};
|
||||
|
||||
const TUR_LABELS: Record<PuanTuruKey, string> = {
|
||||
say: "Sayısal",
|
||||
ea: "Eşit Ağırlık",
|
||||
soz: "Sözel",
|
||||
dil: "Dil",
|
||||
tyt: "TYT (Önlisans)",
|
||||
};
|
||||
|
||||
export default async function SonucPage({
|
||||
searchParams,
|
||||
}: {
|
||||
@@ -45,7 +31,6 @@ export default async function SonucPage({
|
||||
sira?: string;
|
||||
tur?: string;
|
||||
sihirbaz?: string;
|
||||
acildi?: string;
|
||||
hazir?: string;
|
||||
}>;
|
||||
}) {
|
||||
@@ -58,113 +43,60 @@ export default async function SonucPage({
|
||||
|
||||
if (!Number.isFinite(sira) || sira < 1 || sira > 4_000_000) {
|
||||
return (
|
||||
<main className="mx-auto flex max-w-2xl flex-col items-center px-4 py-24 text-center">
|
||||
<h1 className="font-heading text-2xl font-bold">
|
||||
Geçerli bir sıralama gerekli
|
||||
</h1>
|
||||
<p className="mt-3 text-slate-600">
|
||||
Listeni kurabilmek için ana sayfadan YKS başarı sıralamanı gir.
|
||||
</p>
|
||||
<PagePixelDivider seed={59} className="mx-auto mt-6" />
|
||||
<Button asChild className="mt-6 cursor-pointer">
|
||||
<Link href="/">
|
||||
<ArrowLeft className="size-4" aria-hidden />
|
||||
Ana sayfaya dön
|
||||
</Link>
|
||||
</Button>
|
||||
</main>
|
||||
<>
|
||||
<SiraGerekli />
|
||||
<SiteFooter />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Sihirbaz için: kullanıcı durumu, kayıtlı rapor ve dinamik facet'ler.
|
||||
// Rapor sorgusu yalnızca session.user.id'ye bağlı — getCurrentUser'ı
|
||||
// beklemeden paralel çalışır (seri DB zinciri kurma).
|
||||
// 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 [user, raporSatiri] = session
|
||||
? await Promise.all([
|
||||
getCurrentUser(),
|
||||
appDb.query.reports.findFirst({
|
||||
where: eq(schema.reports.userId, session.user.id),
|
||||
}),
|
||||
])
|
||||
: [null, null];
|
||||
const raporSatiri = session
|
||||
? await appDb.query.reports.findFirst({
|
||||
where: eq(schema.reports.userId, session.user.id),
|
||||
columns: { result: true },
|
||||
})
|
||||
: null;
|
||||
|
||||
const facetler = rankWindowFacets(sira, turKey);
|
||||
// AI'sız ham liste: yalnızca sıralamayla ulaşılabilen programlar (dilimli)
|
||||
// Yapay Zeka'sız ham liste: yalnızca sıralamayla ulaşılabilen programlar (dilimli)
|
||||
const hamSonuclar = searchByRank(sira, turKey, { limitPerBucket: 20 });
|
||||
// Paketsize giden rapor sunucuda maskelenir: blur yalnızca CSS olduğundan
|
||||
// kilitli satırların gerçek içeriği client'a hiç inmez.
|
||||
const hasPaket = Boolean(user?.hasPaket);
|
||||
const mevcutRapor: MevcutRapor | null = raporSatiri?.result
|
||||
? {
|
||||
rapor: hasPaket
|
||||
? (raporSatiri.result as RaporSonuc)
|
||||
: raporMaskele(raporSatiri.result as RaporSonuc),
|
||||
params: raporSatiri.params as RaporParams,
|
||||
revisionCount: raporSatiri.revisionCount,
|
||||
}
|
||||
: null;
|
||||
|
||||
// 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">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Badge variant="secondary" className="bg-primary/10 text-primary">
|
||||
{sira.toLocaleString("tr-TR")}. sıra · {TUR_LABELS[turKey]}
|
||||
</Badge>
|
||||
<Button
|
||||
asChild
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="cursor-pointer rounded-full"
|
||||
>
|
||||
<Link href="/">
|
||||
<ArrowLeft className="size-4" aria-hidden />
|
||||
Yeni arama
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<h1 className="mt-4 font-heading text-3xl font-bold sm:text-4xl">
|
||||
{sira.toLocaleString("tr-TR")}. sıradaki bir aday için tercih planı
|
||||
</h1>
|
||||
<p className="mt-2 max-w-2xl text-slate-600">
|
||||
Birkaç adımda ilgi alanlarını ve şehir tercihini seç; yapay zekâ
|
||||
gerçek YÖK Atlas verisiyle 24 tercihlik dengeli listeni kursun. Her
|
||||
satır yerleşme riskine göre renklenir:{" "}
|
||||
<span className="font-semibold text-emerald-600">yeşil güvenli</span>,{" "}
|
||||
<span className="font-semibold text-amber-600">sarı az riskli</span>,{" "}
|
||||
<span className="font-semibold text-red-600">kırmızı riskli</span>.
|
||||
</p>
|
||||
<PagePixelDivider seed={61} className="mt-7" />
|
||||
|
||||
{/* Sayfa hiyerarşisi: önce Türkiye haritası, sonra liste. Manuel
|
||||
liste store'una abone olduğundan tablodan "+" ile eklenenler
|
||||
buraya canlı yansır. */}
|
||||
<div className="mt-8">
|
||||
<ManuelHarita />
|
||||
</div>
|
||||
<ManuelHarita />
|
||||
|
||||
{/* AI'dan bağımsız ham tablo — haritanın hemen altında, sayfanın ana
|
||||
gövdesi; puan türü seçici de listenin başında yaşar */}
|
||||
{/* 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} />
|
||||
|
||||
{/* Satın alım funnel'ı: değişen cümlelerle adayı sihirbaza (rapor
|
||||
varsa /listem paywall'ına) iten banner — paketli kullanıcıya
|
||||
satacak bir şey kalmadığından gösterilmez */}
|
||||
{!hasPaket ? <FunnelBanner hedefListem={Boolean(mevcutRapor)} /> : null}
|
||||
{/* 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}
|
||||
|
||||
{/* Sihirbaz akışı (CTA + modal + AI listesi) — blurlu AI paneli
|
||||
sayfanın en altında yaşar */}
|
||||
{/* Sihirbaz akışı (CTA + modal) — üretim ve liste /listem'de yaşar,
|
||||
burada yalnızca funnel kartları render edilir */}
|
||||
<SihirbazBolumu
|
||||
sira={sira}
|
||||
tur={turKey}
|
||||
facetler={facetler}
|
||||
girisliMi={Boolean(session)}
|
||||
hasPaket={hasPaket}
|
||||
kredi={user?.creditBalance ?? 0}
|
||||
mevcutRapor={mevcutRapor}
|
||||
raporVar={Boolean(raporSatiri?.result)}
|
||||
otomatikAc={params.sihirbaz === "1"}
|
||||
acilis={params.acildi === "1"}
|
||||
secimlerHazir={params.hazir === "1"}
|
||||
/>
|
||||
|
||||
@@ -174,6 +106,7 @@ export default async function SonucPage({
|
||||
aittir.
|
||||
</p>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,26 +3,26 @@
|
||||
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Sparkles, X } from "lucide-react";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { animate } from "motion";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import { toast } from "sonner";
|
||||
import { useReducedMotion } from "motion/react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ViewportPortal } from "@/components/viewport-portal";
|
||||
import { PixelField } from "@/components/pixel-decor";
|
||||
import { useGorunumOlayi } from "@/components/use-gorunum-olayi";
|
||||
import { olay } from "@/lib/analitik";
|
||||
import type { SihirbazFacetleri } from "@/lib/db";
|
||||
import {
|
||||
SIHIRBAZ_AC_EVENT,
|
||||
SIHIRBAZ_STORAGE_KEY,
|
||||
sihirbazDogrula,
|
||||
tercihProfiliSil,
|
||||
tercihProfiliYaz,
|
||||
type SihirbazSecimleri,
|
||||
} from "@/lib/sihirbaz";
|
||||
import { SihirbazModal } from "./sihirbaz-modal";
|
||||
import { SIHIRBAZ_AC_EVENT } from "./funnel-banner";
|
||||
import { ListePaneli, type MevcutRapor, type PanelDurum } from "./liste-paneli";
|
||||
import { listeOlustur, listeRevize } from "./actions";
|
||||
|
||||
const KAPATMA_KEY = "kolaytercih.sihirbaz-karti-kapali";
|
||||
// Üretim /listem'de koşar (bkz. listem/liste-uretici.tsx); sihirbaz
|
||||
// tamamlanınca seçimler storage'a yazılıp kullanıcı buraya yönlendirilir.
|
||||
const LISTEM_URET_HREF = "/listem?uret=1";
|
||||
|
||||
// SSR'da false, client'ta true — storage'a ancak mount sonrası dokunuruz
|
||||
const bosAbone = () => () => {};
|
||||
@@ -40,42 +40,35 @@ type BekleyenSecim = {
|
||||
|
||||
/**
|
||||
* Sonuç sayfasında sihirbaz akışını yöneten client orkestratör:
|
||||
* - Sayfa ortasındaki büyük CTA + sabit alt çağrı kartı (rapor yokken)
|
||||
* - Sayfa ortasındaki büyük CTA (rapor yokken)
|
||||
* - Adımları toplayan modal
|
||||
* - Üretim/önizleme/liste paneli (sayfa gövdesinde)
|
||||
* Akış: adımlar → kredi ile üretim → paketsizse progressive-blur önizleme,
|
||||
* paketliyse tam liste. Girişsiz kullanıcı hero'dan seçimleri tamamlanmış
|
||||
* gelirse önce ücretsiz tabloyu görür; giriş CTA'sı "hazır seçimlerin
|
||||
* kaydedilmesi" olarak panelde sunulur, girişten dönüşte üretim otomatik
|
||||
* başlar. Modal içinden ilerleyen girişsiz kullanıcı ise butonun söylediği
|
||||
* gibi doğrudan girişe gider.
|
||||
* Liste bu sayfada üretilmez ve gösterilmez: sihirbaz tamamlanınca seçimler
|
||||
* storage'a yazılır ve kullanıcı /listem'e yönlendirilir; üretim animasyonu,
|
||||
* kredi/hata durumları ve listenin kendisi orada yaşar. Girişsiz kullanıcı
|
||||
* hero'dan seçimleri tamamlanmış gelirse önce ücretsiz tabloyu görür; giriş
|
||||
* CTA'sı "hazır seçimlerin kaydedilmesi" olarak panelde sunulur, girişten
|
||||
* dönüş doğrudan /listem'e düşer ve üretim orada başlar.
|
||||
*/
|
||||
export function SihirbazBolumu({
|
||||
sira,
|
||||
tur,
|
||||
facetler,
|
||||
girisliMi,
|
||||
hasPaket,
|
||||
kredi: baslangicKredi,
|
||||
mevcutRapor,
|
||||
raporVar,
|
||||
otomatikAc,
|
||||
acilis,
|
||||
secimlerHazir,
|
||||
}: {
|
||||
sira: number;
|
||||
tur: string;
|
||||
facetler: SihirbazFacetleri;
|
||||
girisliMi: boolean;
|
||||
hasPaket: boolean;
|
||||
kredi: number;
|
||||
mevcutRapor: MevcutRapor | null;
|
||||
/** Kullanıcının kayıtlı bir Yapay Zeka listesi var mı (listenin kendisi /listem'de) */
|
||||
raporVar: boolean;
|
||||
/** ?sihirbaz=1 ile gelindiyse modalı otomatik aç */
|
||||
otomatikAc?: boolean;
|
||||
/** ?acildi=1 (ödeme dönüşü): blur çözülme animasyonuyla aç */
|
||||
acilis?: boolean;
|
||||
/** ?hazir=1 (hero sihirbazından girişsiz geliş): seçimler storage'da,
|
||||
panel giriş CTA'sına döner. URL'den gelir ki SSR'da doğru panel
|
||||
render edilsin — mount sonrası anahtar değişimi animasyonu donduruyor. */
|
||||
render edilsin. */
|
||||
secimlerHazir?: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
@@ -84,28 +77,19 @@ export function SihirbazBolumu({
|
||||
|
||||
const [elleAcik, setElleAcik] = useState(false);
|
||||
const [elleKapandi, setElleKapandi] = useState(false);
|
||||
const [kartGizle, setKartGizle] = useState(false);
|
||||
const [bekleyen, setBekleyen] = useState<BekleyenSecim | null>(null);
|
||||
// ?sihirbaz=1 fallback modalı storage okunmadan açılmasın: hero'dan gelen
|
||||
// kayıtlı seçimler varken modal bir anlığına açılıp kapanıyordu.
|
||||
const [depoOkundu, setDepoOkundu] = useState(false);
|
||||
|
||||
const [rapor, setRapor] = useState<MevcutRapor | null>(mevcutRapor);
|
||||
const [kredi, setKredi] = useState(baslangicKredi);
|
||||
const [paketli, setPaketli] = useState(hasPaket);
|
||||
const [durum, setDurum] = useState<PanelDurum>("bos");
|
||||
|
||||
// Adımlarda yapılan son seçimler (hata sonrası modala dönüşte prefill)
|
||||
const [sonSecimler, setSonSecimler] = useState<SihirbazSecimleri | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// Aynı üretim için sabit requestId (retry çifte harcama yapmaz)
|
||||
const requestIdRef = useRef<string | null>(null);
|
||||
const otomatikBasladi = useRef(false);
|
||||
const bolumRef = useRef<HTMLDivElement>(null);
|
||||
// Funnel CTA'ları (banner olayı dahil) tıklama anındaki bekleyen seçimi
|
||||
// görsün diye ref'te tutulur; olay dinleyicisi bir kez bağlanır.
|
||||
const bekleyenRef = useRef<BekleyenSecim | null>(null);
|
||||
|
||||
// Mount sonrası storage'dan bekleyen seçimleri + kalıcı kart kapatmayı oku
|
||||
// Mount sonrası storage'dan bekleyen seçimleri oku
|
||||
useEffect(() => {
|
||||
try {
|
||||
const kaliciKapali = Boolean(sessionStorage.getItem(KAPATMA_KEY));
|
||||
const ham = localStorage.getItem(SIHIRBAZ_STORAGE_KEY);
|
||||
let yeniBekleyen: BekleyenSecim | null = null;
|
||||
if (ham) {
|
||||
@@ -122,35 +106,23 @@ export function SihirbazBolumu({
|
||||
};
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
if (kaliciKapali) setKartGizle(true);
|
||||
if (yeniBekleyen) setBekleyen(yeniBekleyen);
|
||||
if (yeniBekleyen) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setBekleyen(yeniBekleyen);
|
||||
bekleyenRef.current = yeniBekleyen;
|
||||
}
|
||||
} catch {}
|
||||
setDepoOkundu(true);
|
||||
}, [sira, tur]);
|
||||
|
||||
// Üstteki funnel banner'ının CTA'sı sihirbaz modalını bu olayla açar
|
||||
// Arama modalındaki funnel kartının CTA'sı sihirbazı bu olayla başlatır
|
||||
useEffect(() => {
|
||||
const ac = () => {
|
||||
setElleAcik(true);
|
||||
setElleKapandi(false);
|
||||
};
|
||||
const ac = () => sihirbaziBaslat();
|
||||
window.addEventListener(SIHIRBAZ_AC_EVENT, ac);
|
||||
return () => window.removeEventListener(SIHIRBAZ_AC_EVENT, ac);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Bölüm ekran dışındaysa (kullanıcı sihirbazı üstteki banner'dan ya da
|
||||
* sabit alt karttan açmış olabilir) modal hâlâ perdeyi kaplarken kaydırırız;
|
||||
* böylece Motion ile giren hedef panel görünür alanda kalır.
|
||||
*/
|
||||
function bolumuGorunureAl() {
|
||||
const el = bolumRef.current;
|
||||
if (!el) return;
|
||||
const kutu = el.getBoundingClientRect();
|
||||
if (kutu.top >= 0 && kutu.bottom <= window.innerHeight) return;
|
||||
el.scrollIntoView({ block: "center", behavior: "instant" });
|
||||
}
|
||||
|
||||
async function modalCikisiniOynat() {
|
||||
if (hareketiAzalt) return;
|
||||
|
||||
@@ -189,90 +161,60 @@ export function SihirbazBolumu({
|
||||
await modalAnimasyonu;
|
||||
}
|
||||
|
||||
async function uret(secimler: SihirbazSecimleri) {
|
||||
if (!requestIdRef.current) requestIdRef.current = crypto.randomUUID();
|
||||
bolumuGorunureAl();
|
||||
/**
|
||||
* Sihirbaz tamamlandı: seçimleri storage'a yaz ve üretimin koştuğu
|
||||
* /listem'e geç. Girişsiz kullanıcı önce girişe uğrar; callback doğrudan
|
||||
* /listem'e düştüğünden üretim dönüşte orada otomatik başlar.
|
||||
*/
|
||||
async function ilerle(secimler: SihirbazSecimleri) {
|
||||
tercihProfiliYaz({ sira, tur, secimler });
|
||||
await modalCikisiniOynat();
|
||||
setElleAcik(false);
|
||||
setDurum("uretiliyor");
|
||||
|
||||
try {
|
||||
const sonuc = await listeOlustur({
|
||||
sira,
|
||||
tur,
|
||||
secimler,
|
||||
requestId: requestIdRef.current,
|
||||
});
|
||||
if (sonuc.ok) {
|
||||
requestIdRef.current = null;
|
||||
setRapor({
|
||||
rapor: sonuc.rapor,
|
||||
params: sonuc.params,
|
||||
revisionCount: sonuc.revisionCount,
|
||||
});
|
||||
setKredi(sonuc.kredi);
|
||||
setPaketli(sonuc.hasPaket);
|
||||
tercihProfiliSil();
|
||||
// AI çıktısı bu sayfada gösterilmez: üretim animasyonu açık kalır,
|
||||
// liste (paketsizse paywall'lı hâliyle) /listem'de karşılar.
|
||||
router.push("/listem");
|
||||
return;
|
||||
}
|
||||
// Hata: aynı requestId'yi koru ki tekrar denemede çifte harcama olmasın
|
||||
if (sonuc.code === "KREDI" || sonuc.code === "PAKET") {
|
||||
setDurum("kredi-yetersiz");
|
||||
} else if (sonuc.code === "AUTH") {
|
||||
secimleriKaydetVeGirise(secimler);
|
||||
} else {
|
||||
toast.error(sonuc.error);
|
||||
modalaDon();
|
||||
}
|
||||
} catch {
|
||||
toast.error("Beklenmeyen bir hata oluştu, tekrar dener misin?");
|
||||
modalaDon();
|
||||
}
|
||||
}
|
||||
|
||||
/** Hata sonrası panelden modala dönüş. */
|
||||
function modalaDon() {
|
||||
setDurum("bos");
|
||||
setElleAcik(true);
|
||||
}
|
||||
|
||||
function secimleriKaydetVeGirise(secimler: SihirbazSecimleri) {
|
||||
tercihProfiliYaz({ sira, tur, secimler });
|
||||
const geri = `/sonuc?sira=${sira}&tur=${tur}&sihirbaz=1`;
|
||||
router.push(`/giris?callback=${encodeURIComponent(geri)}`);
|
||||
}
|
||||
|
||||
async function ilerle(secimler: SihirbazSecimleri) {
|
||||
setSonSecimler(secimler);
|
||||
if (!girisliMi) {
|
||||
await modalCikisiniOynat();
|
||||
secimleriKaydetVeGirise(secimler);
|
||||
router.push(`/giris?callback=${encodeURIComponent(LISTEM_URET_HREF)}`);
|
||||
return;
|
||||
}
|
||||
await uret(secimler);
|
||||
router.push(LISTEM_URET_HREF);
|
||||
}
|
||||
|
||||
// Giriş dönüşü: storage'daki eşleşen seçimlerle otomatik üretime başla
|
||||
// (bir kez, modal açmadan — üretim sayfa gövdesinde oynar).
|
||||
const otomatikUret = Boolean(bekleyen?.eslesir && girisliMi && !rapor);
|
||||
/**
|
||||
* Funnel CTA'larının (banner, büyük kart) ortak girişi:
|
||||
* adımlar zaten tamamlanıp kaydedildiyse modal açılmaz — girişli
|
||||
* kullanıcı doğrudan /listem'e gider, girişsiz kullanıcı girişe gider.
|
||||
* Kayıtlı seçim yoksa (ya da başka sıra/türe aitse) modal açılır.
|
||||
*/
|
||||
function sihirbaziBaslat() {
|
||||
const hazir = bekleyenRef.current;
|
||||
if (hazir?.eslesir) {
|
||||
void ilerle(hazir.secimler);
|
||||
return;
|
||||
}
|
||||
setElleAcik(true);
|
||||
setElleKapandi(false);
|
||||
}
|
||||
|
||||
// Giriş dönüşü (?sihirbaz=1) storage'da eşleşen seçimlerle geldiyse modal
|
||||
// açılmaz: seçimler hazır, üretim /listem'de başlasın (bir kez).
|
||||
const otomatikGonder = Boolean(
|
||||
otomatikAc && bekleyen?.eslesir && girisliMi && !raporVar,
|
||||
);
|
||||
useEffect(() => {
|
||||
if (otomatikUret && bekleyen && !otomatikBasladi.current) {
|
||||
if (otomatikGonder && !otomatikBasladi.current) {
|
||||
otomatikBasladi.current = true;
|
||||
setSonSecimler(bekleyen.secimler);
|
||||
void uret(bekleyen.secimler);
|
||||
// replace: geri tuşu bu sayfaya dönünce tekrar /listem'e sekmesin
|
||||
router.replace(LISTEM_URET_HREF);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [otomatikUret]);
|
||||
}, [otomatikGonder]);
|
||||
|
||||
// ?sihirbaz=1: otomatik üretim devreye girmediyse (seçim kaybolduysa/
|
||||
// eşleşmiyorsa) modalı aç ki kullanıcı adımları tamamlayabilsin.
|
||||
// ?sihirbaz=1: otomatik yönlendirme devreye girmediyse (seçim kaybolduysa/
|
||||
// eşleşmiyorsa) modalı aç ki kullanıcı adımları tamamlayabilsin. Karar
|
||||
// storage okunmadan verilmez: kayıtlı seçimlerle gelen kullanıcıya modal
|
||||
// hiç gösterilmez, /listem'e geçilir.
|
||||
const acik =
|
||||
!elleKapandi &&
|
||||
(elleAcik ||
|
||||
Boolean(otomatikAc && !otomatikUret && !rapor && durum === "bos"));
|
||||
Boolean(otomatikAc && depoOkundu && !otomatikGonder && !raporVar));
|
||||
|
||||
function acikDegisti(v: boolean) {
|
||||
if (v) {
|
||||
@@ -284,229 +226,115 @@ export function SihirbazBolumu({
|
||||
}
|
||||
}
|
||||
|
||||
const panelde = Boolean(rapor) || durum !== "bos";
|
||||
|
||||
// Değer önce: sihirbazı bitirmiş ama girişsiz kullanıcı ücretsiz tabloyu
|
||||
// zaten görüyor; giriş burada bariyer değil, hazır seçimlerin (ödülün)
|
||||
// kaydedilmesi olarak sunulur. Girişten dönüşte otomatik üretim başlar.
|
||||
// Sinyal URL'den (?hazir=1) gelir; storage'a bakılsaydı panel anahtarı
|
||||
// mount sonrası değişir ve AnimatePresence geçişi donardı.
|
||||
const girisBekliyor = Boolean(!girisliMi && secimlerHazir && !rapor);
|
||||
const girisUrl = `/giris?callback=${encodeURIComponent(
|
||||
`/sonuc?sira=${sira}&tur=${tur}&sihirbaz=1`,
|
||||
)}`;
|
||||
|
||||
const panelAnahtari =
|
||||
durum !== "bos"
|
||||
? durum
|
||||
: rapor
|
||||
? "rapor-hazir"
|
||||
: girisBekliyor
|
||||
? "giris-cta"
|
||||
: "sihirbaz-cta";
|
||||
// kaydedilmesi olarak sunulur. Girişten dönüşte üretim /listem'de başlar.
|
||||
// Sinyal URL'den (?hazir=1) gelir ki SSR'da doğru panel render edilsin.
|
||||
const girisBekliyor = Boolean(!girisliMi && secimlerHazir && !raporVar);
|
||||
const girisUrl = `/giris?callback=${encodeURIComponent(LISTEM_URET_HREF)}`;
|
||||
// Kapı 1 ölçümü: panel yalnızca girisBekliyor dalında render edilir; ref
|
||||
// başka dalda bağlanmadığından observer da yalnızca o durumda kurulur.
|
||||
const hazirPanelRef = useGorunumOlayi<HTMLElement>("hazir_panel_goruntulendi");
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* AI çıktısı (rapor listesi/haritası) bu sayfada render edilmez;
|
||||
panel yalnızca üretim ve kredi-yetersiz gibi geçici funnel
|
||||
durumlarını gösterir, listenin kendisi /listem'de yaşar. */}
|
||||
<div ref={bolumRef} className="relative">
|
||||
<AnimatePresence mode="popLayout" initial={false}>
|
||||
<motion.div
|
||||
key={panelAnahtari}
|
||||
initial={
|
||||
hareketiAzalt
|
||||
? { opacity: 0 }
|
||||
: {
|
||||
opacity: 0,
|
||||
transform: "translate3d(0, 8px, 0)",
|
||||
}
|
||||
}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
transform: "translate3d(0, 0, 0)",
|
||||
transition: hareketiAzalt
|
||||
? { duration: 0.12, ease: "easeOut" }
|
||||
: {
|
||||
duration: 0.28,
|
||||
ease: [0.23, 1, 0.32, 1],
|
||||
},
|
||||
}}
|
||||
exit={
|
||||
hareketiAzalt
|
||||
? {
|
||||
opacity: 0,
|
||||
transition: { duration: 0.08, ease: "easeOut" },
|
||||
}
|
||||
: {
|
||||
opacity: 0,
|
||||
transform: "translate3d(0, -4px, 0)",
|
||||
transition: {
|
||||
duration: 0.14,
|
||||
ease: [0.4, 0, 0.7, 0.2],
|
||||
},
|
||||
}
|
||||
}
|
||||
{/* Yapay Zeka çıktısı (üretim animasyonu dahil) bu sayfada render
|
||||
edilmez; panel yalnızca funnel CTA'larını gösterir, üretim ve
|
||||
listenin kendisi /listem'de yaşar. */}
|
||||
{raporVar ? (
|
||||
<section className="mt-16 rounded-2xl border border-slate-200 bg-white p-8 text-center sm:p-12">
|
||||
<h2 className="font-heading text-2xl font-bold sm:text-3xl">
|
||||
24 tercihlik listen hazır
|
||||
</h2>
|
||||
<p className="mx-auto mt-3 max-w-xl text-slate-600">
|
||||
Yapay zekânın kurduğu risk analizli listen Listem sayfasında
|
||||
seni bekliyor.
|
||||
</p>
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="mt-6 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||
>
|
||||
{durum !== "bos" ? (
|
||||
<ListePaneli
|
||||
durum={durum}
|
||||
rapor={null}
|
||||
hasPaket={paketli}
|
||||
kredi={kredi}
|
||||
sira={sira}
|
||||
acilis={acilis}
|
||||
onRevize={async (feedback) => {
|
||||
const sonuc = await listeRevize({
|
||||
feedback,
|
||||
requestId: crypto.randomUUID(),
|
||||
});
|
||||
if (sonuc.ok) {
|
||||
setRapor({
|
||||
rapor: sonuc.rapor,
|
||||
params: sonuc.params,
|
||||
revisionCount: sonuc.revisionCount,
|
||||
});
|
||||
setKredi(sonuc.kredi);
|
||||
toast.success("Listen yeniden kuruldu.");
|
||||
} else {
|
||||
toast.error(sonuc.error);
|
||||
}
|
||||
return sonuc.ok;
|
||||
}}
|
||||
/>
|
||||
) : rapor ? (
|
||||
<section className="mt-16 rounded-2xl border border-slate-200 bg-white p-8 text-center sm:p-12">
|
||||
<h2 className="font-heading text-2xl font-bold sm:text-3xl">
|
||||
24 tercihlik listen hazır
|
||||
</h2>
|
||||
<p className="mx-auto mt-3 max-w-xl text-slate-600">
|
||||
Yapay zekânın kurduğu risk analizli listen Listem sayfasında
|
||||
seni bekliyor.
|
||||
</p>
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="mt-6 h-12 cursor-pointer bg-orange-500 px-8 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||
>
|
||||
<Link href="/listem">Listeni gör</Link>
|
||||
</Button>
|
||||
</section>
|
||||
) : girisBekliyor ? (
|
||||
<section className="mt-16 min-w-0 rounded-2xl bg-primary px-5 py-8 text-center text-white sm:p-12">
|
||||
<h2 className="font-heading text-2xl font-bold sm:text-3xl">
|
||||
Seçimlerin hazır — AI listen bir adım uzakta
|
||||
</h2>
|
||||
<p className="mx-auto mt-3 max-w-xl text-white/90">
|
||||
Yukarıdaki tablo her zaman ücretsiz. 5 deneme kredinle 24
|
||||
tercihlik AI listeni de kurabilirsin: ücretsiz giriş yap,
|
||||
seçimlerin kayıtlı — listen otomatik kurulsun (üretim 3
|
||||
kredi, kalan 2 kredinle danışmana soru sorarsın).
|
||||
</p>
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="mt-6 h-auto min-h-12 w-full cursor-pointer whitespace-normal bg-orange-500 px-4 py-3 text-white transition-colors duration-200 hover:bg-orange-600 sm:w-auto sm:px-8"
|
||||
>
|
||||
<Link href={girisUrl}>
|
||||
<Sparkles className="size-4" aria-hidden />5 deneme kredinle
|
||||
AI listeni kur — giriş yap
|
||||
</Link>
|
||||
</Button>
|
||||
<p className="mt-3 text-xs text-white/70">
|
||||
Kredi kartı gerekmez · Google ya da e-posta ile 30 saniyede
|
||||
</p>
|
||||
</section>
|
||||
) : (
|
||||
<section className="mt-16 min-w-0 rounded-2xl bg-primary px-5 py-8 text-center text-white sm:p-12">
|
||||
<h2 className="font-heading text-2xl font-bold sm:text-3xl">
|
||||
Bu listeyi 24 tercihlik plana çevirelim mi?
|
||||
</h2>
|
||||
<p className="mx-auto mt-3 max-w-xl text-white/90">
|
||||
Birkaç adımda ilgi alanların ve şehir tercihine göre
|
||||
gerekçeli, dengeli bir tercih listesi kuralım — her satır için
|
||||
yerleşme riski analiziyle.
|
||||
</p>
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={() => acikDegisti(true)}
|
||||
className="mt-6 h-auto min-h-12 w-full cursor-pointer whitespace-normal bg-orange-500 px-4 py-3 text-white transition-colors duration-200 hover:bg-orange-600 sm:w-auto sm:px-8"
|
||||
>
|
||||
<Sparkles className="size-4" aria-hidden />
|
||||
Listemi oluştur
|
||||
</Button>
|
||||
</section>
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Sabit alt çağrı kartı — liste/panel yokken ve kart kapatılmadıysa */}
|
||||
<ViewportPortal>
|
||||
{monteEdildi && !panelde && !kartGizle && !acik ? (
|
||||
<div className="pointer-events-none fixed inset-x-0 bottom-4 z-40 px-4">
|
||||
<div className="pointer-events-auto mx-auto flex max-w-xl items-center gap-4 rounded-2xl border border-slate-200 bg-white p-4 shadow-xl">
|
||||
<div className="flex-1">
|
||||
<p className="font-heading text-sm font-bold text-slate-900">
|
||||
{girisBekliyor
|
||||
? "Seçimlerin kayıtlı — AI listen hazır bekliyor"
|
||||
: "3 adımda 24 tercihlik listen"}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{girisBekliyor
|
||||
? "Ücretsiz giriş yap, 5 deneme kredinle listen otomatik kurulsun."
|
||||
: "Sıralamana uygun bölümleri seç, yapay zekâ listeni kursun."}
|
||||
</p>
|
||||
</div>
|
||||
{girisBekliyor ? (
|
||||
<Button
|
||||
asChild
|
||||
size="sm"
|
||||
className="cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||
>
|
||||
<Link href={girisUrl}>Giriş yap</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => acikDegisti(true)}
|
||||
className="cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||
>
|
||||
Başla
|
||||
</Button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Kapat"
|
||||
onClick={() => {
|
||||
setKartGizle(true);
|
||||
try {
|
||||
sessionStorage.setItem(KAPATMA_KEY, "1");
|
||||
} catch {}
|
||||
}}
|
||||
className="cursor-pointer rounded-full p-1 text-slate-400 transition-colors duration-200 hover:bg-slate-100 hover:text-slate-600"
|
||||
>
|
||||
<X className="size-4" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
<Link href="/listem">Listeni gör</Link>
|
||||
</Button>
|
||||
</section>
|
||||
) : girisBekliyor ? (
|
||||
<section
|
||||
ref={hazirPanelRef}
|
||||
className="relative isolate mt-16 min-w-0 overflow-hidden rounded-2xl bg-primary px-5 py-8 text-center text-white sm:p-12"
|
||||
>
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-x-0 -inset-y-10 -z-10 text-white"
|
||||
>
|
||||
<PixelField seed={13} />
|
||||
</div>
|
||||
) : null}
|
||||
</ViewportPortal>
|
||||
<h2 className="relative font-heading text-2xl font-bold sm:text-3xl">
|
||||
Seçimlerin hazır — Yapay Zeka listen bir adım uzakta
|
||||
</h2>
|
||||
<p className="relative mx-auto mt-3 max-w-xl text-white/90">
|
||||
Yukarıdaki tablo her zaman ücretsiz. 5 deneme kredinle 24
|
||||
tercihlik Yapay Zeka listeni de kurabilirsin: ücretsiz giriş yap,
|
||||
seçimlerin kayıtlı — listen otomatik kurulsun (üretim 3
|
||||
kredi, kalan 2 kredinle danışmana soru sorarsın).
|
||||
</p>
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="relative mt-6 h-auto min-h-12 w-full cursor-pointer whitespace-normal bg-orange-500 px-4 py-3 text-white transition-colors duration-200 hover:bg-orange-600 sm:w-auto sm:px-8"
|
||||
>
|
||||
<Link
|
||||
href={girisUrl}
|
||||
onClick={() =>
|
||||
olay("giris_cta_tiklandi", { kaynak: "hazir_panel" })
|
||||
}
|
||||
>
|
||||
5 deneme kredinle Yapay Zeka listeni kur — giriş yap
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Link>
|
||||
</Button>
|
||||
<p className="relative mt-3 text-xs text-white/70">
|
||||
Kredi kartı gerekmez · Google ya da e-posta ile 30 saniyede
|
||||
</p>
|
||||
</section>
|
||||
) : (
|
||||
<section className="relative isolate mt-16 min-w-0 overflow-hidden rounded-2xl bg-primary px-5 py-8 text-center text-white sm:p-12">
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-x-0 -inset-y-10 -z-10 text-white"
|
||||
>
|
||||
<PixelField seed={19} />
|
||||
</div>
|
||||
<h2 className="relative font-heading text-2xl font-bold sm:text-3xl">
|
||||
24 tercihi tek tek elemek yerine, yapay zekâ kursun
|
||||
</h2>
|
||||
<p className="relative mx-auto mt-3 max-w-xl text-white/90">
|
||||
Tablodan elle eklemek her zaman serbest. Ama üç adımda ilgi
|
||||
alanlarını ve şehirlerini söylersen, yapay zekâ hayal, dengeli
|
||||
ve garanti dilimlerine dağıtılmış 24 tercihi — her satırı
|
||||
yerleşme riski ve gerekçesiyle — senin yerine dizer.
|
||||
</p>
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={sihirbaziBaslat}
|
||||
className="relative mt-6 h-auto min-h-12 w-full cursor-pointer whitespace-normal bg-orange-500 px-4 py-3 text-white transition-colors duration-200 hover:bg-orange-600 sm:w-auto sm:px-8"
|
||||
>
|
||||
Yapay Zeka listemi kur
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Button>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Üretim başladığında modalı kaldır; yeni panelin geçişini Motion
|
||||
üstlenir ve odağı panel içeriğine taşır. */}
|
||||
{monteEdildi && durum === "bos" ? (
|
||||
{monteEdildi ? (
|
||||
<SihirbazModal
|
||||
acik={acik}
|
||||
onAcikDegisti={acikDegisti}
|
||||
sira={sira}
|
||||
tur={tur}
|
||||
facetler={facetler}
|
||||
baslangicSecimler={
|
||||
sonSecimler ?? (bekleyen?.eslesir ? bekleyen.secimler : null)
|
||||
}
|
||||
baslangicSecimler={bekleyen?.eslesir ? bekleyen.secimler : null}
|
||||
sonButonEtiketi={
|
||||
girisliMi ? "Listemi oluştur (3 kredi)" : "Giriş yap ve listeni gör"
|
||||
girisliMi ? "Yapay Zeka listemi kur (3 kredi)" : "Giriş yap ve listeni gör"
|
||||
}
|
||||
onTamamla={ilerle}
|
||||
/>
|
||||
|
||||
@@ -12,7 +12,7 @@ import type { SihirbazSecimleri } from "@/lib/sihirbaz";
|
||||
|
||||
/**
|
||||
* Sihirbaz modalı artık yalnızca adımları toplar; üretim, önizleme ve liste
|
||||
* sonuç sayfası gövdesinde yaşar (bkz. liste-paneli.tsx). Akış yönetimi
|
||||
* /listem'de yaşar (bkz. listem/liste-uretici.tsx). Akış yönetimi
|
||||
* sihirbaz-cagri-karti.tsx'teki SihirbazBolumu'ndadır.
|
||||
*/
|
||||
export function SihirbazModal({
|
||||
|
||||
70
src/app/sonuc/sira-gerekli.tsx
Normal file
70
src/app/sonuc/sira-gerekli.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
// /sonuc?sira= yokken: kayıtlı profil varsa oraya taşı; yoksa kapıyı aç.
|
||||
// Sıra bilgisi localStorage'daki TercihProfili ile tüm uygulamada ortak.
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft, Trophy } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PagePixelDivider } from "@/components/pixel-decor";
|
||||
import { sonucHref } from "@/lib/sihirbaz";
|
||||
import {
|
||||
sonucaGitIste,
|
||||
useProfilKapisi,
|
||||
} from "@/components/manuel-liste/profil-kapisi-store";
|
||||
|
||||
export function SiraGerekli() {
|
||||
const router = useRouter();
|
||||
const { profil, profilYuklendi } = useProfilKapisi();
|
||||
const [yonleniyor, setYonleniyor] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!profilYuklendi) return;
|
||||
if (profil) {
|
||||
setYonleniyor(true);
|
||||
router.replace(sonucHref(profil));
|
||||
}
|
||||
}, [profil, profilYuklendi, router]);
|
||||
|
||||
if (!profilYuklendi || yonleniyor || profil) {
|
||||
return (
|
||||
<main className="mx-auto flex max-w-2xl flex-col items-center px-4 py-24 text-center">
|
||||
<p className="text-sm text-slate-500">Sıralaman kontrol ediliyor…</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex max-w-2xl flex-col items-center px-4 py-24 text-center">
|
||||
<h1 className="font-heading text-2xl font-bold">
|
||||
Geçerli bir sıralama gerekli
|
||||
</h1>
|
||||
<p className="mt-3 text-slate-600">
|
||||
Listeni kurabilmek için YKS başarı sıralamanı gir. Kaydettiğin sıra tüm
|
||||
uygulamada kullanılır.
|
||||
</p>
|
||||
<PagePixelDivider seed={59} className="mx-auto mt-6" />
|
||||
<div className="mt-6 flex flex-wrap items-center justify-center gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const { href } = sonucaGitIste();
|
||||
if (href) router.push(href);
|
||||
}}
|
||||
className="cursor-pointer bg-orange-500 text-white hover:bg-orange-600"
|
||||
>
|
||||
<Trophy className="size-4" aria-hidden />
|
||||
Sıralamamı gir
|
||||
</Button>
|
||||
<Button asChild variant="outline" className="cursor-pointer">
|
||||
<Link href="/">
|
||||
<ArrowLeft className="size-4" aria-hidden />
|
||||
Ana sayfaya dön
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
"use client";
|
||||
|
||||
// AI danışman sohbeti — sayfa altında yüzen buton + intercom tarzı popup.
|
||||
// Motion dili: panel tetikleyicisinden doğar (transform-origin sağ-alt),
|
||||
// spring ile açılır; çıkış girişten hızlı. Reduced motion'da cross-fade.
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { MessageCircleMore, X } from "lucide-react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import { ViewportPortal } from "@/components/viewport-portal";
|
||||
|
||||
// Sohbet istemcisi (markdown zinciriyle birlikte) ağır; popup ilk açıldığında
|
||||
// yüklensin — /sonuc ilk yükünü şişirmesin.
|
||||
const SohbetClient = dynamic(
|
||||
() => import("@/app/listem/sohbet-client").then((m) => m.SohbetClient),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="flex h-full items-center justify-center rounded-2xl border border-slate-200 bg-white text-sm text-slate-500 shadow-2xl">
|
||||
Sohbet yükleniyor…
|
||||
</div>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
export function SohbetPopup({ kredi }: { kredi: number }) {
|
||||
const [acik, setAcik] = useState(false);
|
||||
const azMotion = useReducedMotion();
|
||||
|
||||
useEffect(() => {
|
||||
if (!acik) return;
|
||||
const dinle = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setAcik(false);
|
||||
};
|
||||
window.addEventListener("keydown", dinle);
|
||||
return () => window.removeEventListener("keydown", dinle);
|
||||
}, [acik]);
|
||||
|
||||
return (
|
||||
<ViewportPortal>
|
||||
<AnimatePresence>
|
||||
{acik ? (
|
||||
<motion.div
|
||||
key="sohbet"
|
||||
initial={
|
||||
azMotion ? { opacity: 0 } : { opacity: 0, y: 24, scale: 0.95 }
|
||||
}
|
||||
animate={azMotion ? { opacity: 1 } : { opacity: 1, y: 0, scale: 1 }}
|
||||
exit={
|
||||
azMotion
|
||||
? { opacity: 0, transition: { duration: 0.15 } }
|
||||
: {
|
||||
opacity: 0,
|
||||
y: 16,
|
||||
scale: 0.97,
|
||||
transition: { duration: 0.18, ease: [0.23, 1, 0.32, 1] },
|
||||
}
|
||||
}
|
||||
transition={{ type: "spring", duration: 0.4, bounce: 0.15 }}
|
||||
style={{ transformOrigin: "bottom right" }}
|
||||
className="fixed bottom-24 right-4 z-50 h-[min(72dvh,560px)] w-[min(100vw-2rem,420px)] print:hidden"
|
||||
>
|
||||
<SohbetClient
|
||||
baslangicKredi={kredi}
|
||||
className="h-full shadow-2xl"
|
||||
/>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Yüzen tetikleyici — basışta anında küçülür (pointer-down feedback) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAcik((v) => !v)}
|
||||
aria-expanded={acik}
|
||||
className="fixed bottom-6 right-4 z-50 flex h-13 cursor-pointer items-center gap-2 rounded-full bg-slate-900 py-3 pl-4 pr-5 font-heading text-sm font-bold text-white shadow-xl transition-[transform,background-color] duration-150 hover:bg-slate-700 active:scale-[0.95] print:hidden"
|
||||
>
|
||||
{acik ? (
|
||||
<X className="size-5" aria-hidden />
|
||||
) : (
|
||||
<MessageCircleMore className="size-5" aria-hidden />
|
||||
)}
|
||||
{acik ? "Kapat" : "Danışmana sor"}
|
||||
</button>
|
||||
</ViewportPortal>
|
||||
);
|
||||
}
|
||||
173
src/app/sonuc/tadimlik-satiri.tsx
Normal file
173
src/app/sonuc/tadimlik-satiri.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
"use client";
|
||||
|
||||
// Girişsiz yapay zekâ tadımlığı (PRD İP-B): /sonuc'ta, sıra kovası + (varsa)
|
||||
// sihirbaz alan tercihine uyan TEK örnek tercih satırı — gerçek rapor
|
||||
// satırının detay bloğuyla (RaporSatirDetayi) birebir aynı formatta.
|
||||
// İçerik batch üretilmiş havuzdan gelir; bu bileşen LLM'e istek attırmaz.
|
||||
// SSR "genel" satırı render eder (prop), mount sonrası sihirbaz kategorisi
|
||||
// varsa /api/tadimlik'ten kategoriye özel satır çekilip yerinde değiştirilir
|
||||
// (seçim sunucuda kalır). Havuzda içerik yoksa hiç render edilmez.
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { UniLogo } from "@/components/uni-logo";
|
||||
import { SectionEyebrow } from "@/components/pixel-decor";
|
||||
import { RaporSatirDetayi } from "@/components/rapor-listesi";
|
||||
import {
|
||||
ProgramSiraGecmisi,
|
||||
ProgramTabanTrendi,
|
||||
programEtkinSira,
|
||||
raporSiraSerisi,
|
||||
} from "@/components/program-liste-verileri";
|
||||
import { useGorunumOlayi } from "@/components/use-gorunum-olayi";
|
||||
import { olay, siraKovasi } from "@/lib/analitik";
|
||||
import { dilimdenRisk, riskHesapla } from "@/lib/risk";
|
||||
import { turkishSlugify } from "@/lib/slug";
|
||||
import {
|
||||
PROFIL_DEGISTI_EVENT,
|
||||
sonucHref,
|
||||
tercihProfiliOku,
|
||||
} from "@/lib/sihirbaz";
|
||||
import type { TadimlikSatir } from "@/lib/tadimlik-havuzu";
|
||||
|
||||
const GENEL = "genel";
|
||||
|
||||
export function TadimlikSatiri({
|
||||
sira,
|
||||
tur,
|
||||
ilk,
|
||||
}: {
|
||||
sira: number;
|
||||
tur: string;
|
||||
/** SSR'da seçilmiş "genel" satır; havuz boşsa null. */
|
||||
ilk: TadimlikSatir | null;
|
||||
}) {
|
||||
const [satir, setSatir] = useState<TadimlikSatir | null>(ilk);
|
||||
const yuklenenSlug = useRef(GENEL);
|
||||
|
||||
// Sihirbaz kategorisi yalnızca localStorage'da yaşar; mount'ta ve profil
|
||||
// değiştikçe okunur, kategoriye özel satır sunucudan çekilir.
|
||||
useEffect(() => {
|
||||
const guncelle = () => {
|
||||
const profil = tercihProfiliOku();
|
||||
const kategori = profil?.secimler.kategoriler[0];
|
||||
const slug = kategori ? turkishSlugify(kategori) : GENEL;
|
||||
if (slug === yuklenenSlug.current) return;
|
||||
yuklenenSlug.current = slug;
|
||||
if (slug === GENEL) {
|
||||
setSatir(ilk);
|
||||
return;
|
||||
}
|
||||
fetch(
|
||||
`/api/tadimlik?sira=${sira}&tur=${encodeURIComponent(tur)}&kategori=${encodeURIComponent(slug)}`,
|
||||
)
|
||||
.then((yanit) => (yanit.ok ? yanit.json() : null))
|
||||
.then((veri: { tadimlik: TadimlikSatir | null } | null) => {
|
||||
// Yarış koruması: bu arada başka kategori yüklendiyse dokunma
|
||||
if (yuklenenSlug.current !== slug) return;
|
||||
if (veri?.tadimlik) setSatir(veri.tadimlik);
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
guncelle();
|
||||
window.addEventListener(PROFIL_DEGISTI_EVENT, guncelle);
|
||||
return () => window.removeEventListener(PROFIL_DEGISTI_EVENT, guncelle);
|
||||
}, [sira, tur, ilk]);
|
||||
|
||||
const gorunumRef = useGorunumOlayi<HTMLElement>("tadimlik_goruntulendi", {
|
||||
kova: siraKovasi(sira),
|
||||
});
|
||||
|
||||
if (!satir) return null;
|
||||
|
||||
const p = satir.program;
|
||||
const risk = riskHesapla(p.efektifSira, sira) ?? dilimdenRisk(satir.dilim);
|
||||
const seri = raporSiraSerisi(p.siraGecmisi);
|
||||
const taban = p.efektifSira ?? programEtkinSira(seri);
|
||||
const devlet = p.unitur === "DEVLET";
|
||||
const girisUrl = `/giris?callback=${encodeURIComponent(sonucHref({ sira, tur }))}`;
|
||||
|
||||
return (
|
||||
<section ref={gorunumRef} className="mt-16 min-w-0">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<SectionEyebrow>Yapay zekâdan örnek satır</SectionEyebrow>
|
||||
<h2 className="mt-4 font-heading text-2xl font-bold sm:text-3xl">
|
||||
Yapay zekâ listendeki her satırı böyle anlatır
|
||||
</h2>
|
||||
<p className="mx-auto mt-3 max-w-xl text-slate-600">
|
||||
Sıralamana uygun bir programın gerekçesi, yerleşme riski ve taban
|
||||
trendi — 24 tercihlik listenin her satırı bu formatta kurulur.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 rounded-2xl border border-slate-200 bg-white p-4 shadow-sm sm:p-6">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2.5">
|
||||
<UniLogo ad={p.universite} boy="sm" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-semibold">
|
||||
{p.isim}
|
||||
</span>
|
||||
<span className="mt-0.5 flex items-center gap-1.5 text-xs text-slate-500">
|
||||
{p.unitur ? (
|
||||
<span
|
||||
className={`shrink-0 rounded px-1 py-px text-[10px] font-medium leading-4 ${
|
||||
devlet
|
||||
? "bg-slate-100 text-slate-600"
|
||||
: "bg-violet-50 text-violet-600"
|
||||
}`}
|
||||
>
|
||||
{devlet ? "Devlet" : "Vakıf"}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="truncate">
|
||||
{p.universite}
|
||||
{p.il ? ` · ${p.il.toLocaleLowerCase("tr-TR")}` : ""}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className="hidden text-right text-xs md:inline-flex">
|
||||
<ProgramSiraGecmisi program={seri} />
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1 text-xs tabular-nums text-slate-500">
|
||||
<ProgramTabanTrendi program={seri} />
|
||||
{taban != null ? `~${taban.toLocaleString("tr-TR")}.` : "—"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 rounded-lg bg-slate-50/60 px-4 py-4">
|
||||
<RaporSatirDetayi
|
||||
dilim={satir.dilim}
|
||||
risk={risk}
|
||||
unitur={p.unitur}
|
||||
gerekce={satir.gerekce}
|
||||
riskNotu={satir.riskNotu}
|
||||
trendOzeti={satir.trendOzeti}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex flex-col items-start gap-3 border-t border-slate-100 pt-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-sm text-slate-600">
|
||||
Bu, 24 satırlık Yapay Zeka listenden yalnızca bir örnek.
|
||||
<span className="block text-xs text-slate-500">
|
||||
Kredi kartı gerekmez · Google ya da e-posta ile 30 saniyede
|
||||
</span>
|
||||
</p>
|
||||
<Button
|
||||
asChild
|
||||
className="h-11 w-full shrink-0 cursor-pointer bg-orange-500 text-white transition-[background-color,transform] duration-200 active:scale-[0.97] hover:bg-orange-600 sm:w-auto"
|
||||
>
|
||||
<Link
|
||||
href={girisUrl}
|
||||
onClick={() => olay("giris_cta_tiklandi", { kaynak: "tadimlik" })}
|
||||
>
|
||||
24 satırın tamamı için giriş yap — 5 deneme kredin hazır
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import {
|
||||
ArrowRight,
|
||||
ChartNoAxesCombined,
|
||||
@@ -10,9 +10,11 @@ import {
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { TypingAnimation } from "@/components/ui/typing-animation";
|
||||
|
||||
/** Banner CTA'sının sihirbaz modalını açmak için yayınladığı olay. */
|
||||
export const SIHIRBAZ_AC_EVENT = "kolaytercih:sihirbaz-ac";
|
||||
import { SIHIRBAZ_AC_EVENT, sonucHref } from "@/lib/sihirbaz";
|
||||
import {
|
||||
profilToplamaAc,
|
||||
useTercihProfili,
|
||||
} from "@/components/manuel-liste/profil-kapisi-store";
|
||||
|
||||
const FUNNEL_METINLERI = [
|
||||
"Sıralamana en uygun listeyi birlikte kuralım.",
|
||||
@@ -47,7 +49,7 @@ const FUNNEL_METINLERI = [
|
||||
"İyi bir liste, iyi bir başlangıçtır.",
|
||||
];
|
||||
|
||||
/** Fisher-Yates ile kopya üzerinde karıştırır; her ziyarette farklı sıra. */
|
||||
/** Fisher-Yates ile kopya üzerinde karıştırır; her açılışta farklı sıra. */
|
||||
function karistir<T>(dizi: readonly T[]): T[] {
|
||||
const kopya = [...dizi];
|
||||
for (let i = kopya.length - 1; i > 0; i--) {
|
||||
@@ -64,47 +66,64 @@ const KANITLAR = [
|
||||
];
|
||||
|
||||
/**
|
||||
* Puan türü badge'lerinin hemen altında yaşayan satın alım funnel banner'ı.
|
||||
* Rastgele sırayla yazılıp silinen samimi ve profesyonel funnel
|
||||
* metinleriyle adayı sihirbaza; rapor zaten varsa kilitli listenin
|
||||
* durduğu /listem paywall'ına iter.
|
||||
* Tasarım: güven veren lacivert yüzey, veri kanıtları ve turuncu CTA.
|
||||
* Arama modalının açılış ekranında yaşayan satın alım funnel kartı.
|
||||
* Rastgele sırayla yazılıp silinen funnel metinleriyle adayı sihirbaza
|
||||
* iter: /sonuc üzerindeyse oradaki orkestratörü olayla açar, başka
|
||||
* sayfadaysa profile göre /sonuc'a (sihirbaz açık) yönlendirir ya da
|
||||
* sıralama kapısını açar. Modal yalnızca açıkken mount olduğundan
|
||||
* karıştırma her açılışta yenilenir ve hydration sorunu yaratmaz.
|
||||
*/
|
||||
export function FunnelBanner({ hedefListem }: { hedefListem: boolean }) {
|
||||
// İlk render'da bir kez karıştırılır; SSR boş metinle başladığı için
|
||||
// hydration uyuşmazlığı yaratmaz.
|
||||
export function AramaFunnelKarti({ onSec }: { onSec: () => void }) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const profil = useTercihProfili();
|
||||
const [metinler] = useState(() => karistir(FUNNEL_METINLERI));
|
||||
|
||||
const ctaSinif =
|
||||
"h-12 w-full shrink-0 cursor-pointer rounded-xl bg-orange-500 px-5 font-semibold text-white shadow-[0_8px_24px_rgba(249,115,22,0.28)] transition-[background-color,transform,box-shadow] duration-200 hover:bg-orange-400 hover:shadow-[0_10px_28px_rgba(249,115,22,0.36)] active:scale-[0.98] focus-visible:ring-white/70 sm:w-auto";
|
||||
function sihirbazaGit() {
|
||||
onSec();
|
||||
// Orkestratör (SihirbazBolumu) yalnızca /sonuc?sira= geçerliyken monte;
|
||||
// sırasız /sonuc'ta (SiraGerekli görünümü) olay boşa gider — kapıya düş.
|
||||
if (pathname === "/sonuc" && searchParams.get("sira")) {
|
||||
window.dispatchEvent(new Event(SIHIRBAZ_AC_EVENT));
|
||||
return;
|
||||
}
|
||||
if (profil) {
|
||||
router.push(sonucHref(profil, { sihirbaz: "1" }));
|
||||
return;
|
||||
}
|
||||
// Yapay Zeka niyetli CTA: kapı tamamlanınca /sonuc'ta üretim akışı devam
|
||||
// etmeli ("sonuc" hedefi ücretsiz tabloda bırakıyor, liste hiç kurulmuyordu).
|
||||
profilToplamaAc("sihirbaz");
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label="Tercih listeni kur"
|
||||
className="relative mt-6 overflow-hidden rounded-3xl border border-blue-400/20 bg-[#0b1f3a] px-5 py-6 text-white shadow-[0_20px_50px_-28px_rgba(15,71,150,0.65)] sm:px-7 sm:py-7"
|
||||
className="relative mt-5 overflow-hidden rounded-2xl border border-blue-400/20 bg-[#0b1f3a] px-4 py-5 text-white shadow-[0_20px_50px_-28px_rgba(15,71,150,0.65)] sm:px-5"
|
||||
>
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute -right-16 -top-24 size-64 rounded-full bg-blue-500/25 blur-3xl"
|
||||
className="pointer-events-none absolute -right-12 -top-16 size-44 rounded-full bg-blue-500/25 blur-3xl"
|
||||
/>
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute -bottom-20 left-1/3 size-48 rounded-full bg-cyan-400/10 blur-3xl"
|
||||
className="pointer-events-none absolute -bottom-14 left-1/3 size-36 rounded-full bg-cyan-400/10 blur-3xl"
|
||||
/>
|
||||
|
||||
<div className="relative grid items-center gap-6 sm:grid-cols-[minmax(0,1fr)_auto] sm:gap-8">
|
||||
<div className="relative grid items-center gap-4 sm:grid-cols-[minmax(0,1fr)_auto] sm:gap-5">
|
||||
<div className="min-w-0">
|
||||
<TypingAnimation
|
||||
as="h2"
|
||||
as="h3"
|
||||
words={metinler}
|
||||
loop
|
||||
typeSpeed={38}
|
||||
deleteSpeed={18}
|
||||
pauseDelay={2600}
|
||||
className="min-h-[2.5em] max-w-2xl font-heading text-2xl font-bold leading-tight tracking-[-0.02em] sm:min-h-[1.25em] sm:text-3xl"
|
||||
className="min-h-[2.75em] font-heading text-base font-bold leading-snug tracking-[-0.02em] sm:min-h-[1.4em] sm:text-lg"
|
||||
/>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-x-4 gap-y-2">
|
||||
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-2">
|
||||
{KANITLAR.map(({ ikon: Ikon, etiket }) => (
|
||||
<span
|
||||
key={etiket}
|
||||
@@ -117,23 +136,14 @@ export function FunnelBanner({ hedefListem }: { hedefListem: boolean }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hedefListem ? (
|
||||
<Button asChild size="lg" className={ctaSinif}>
|
||||
<Link href="/listem">
|
||||
Listeni aç
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={() => window.dispatchEvent(new Event(SIHIRBAZ_AC_EVENT))}
|
||||
className={ctaSinif}
|
||||
>
|
||||
Listemi oluştur
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={sihirbazaGit}
|
||||
className="w-full shrink-0 cursor-pointer bg-orange-500 font-semibold text-white shadow-[0_8px_24px_rgba(249,115,22,0.28)] transition-[background-color,transform,box-shadow] duration-200 hover:bg-orange-400 hover:shadow-[0_10px_28px_rgba(249,115,22,0.36)] active:scale-[0.98] focus-visible:ring-white/70 sm:w-auto"
|
||||
>
|
||||
Yapay Zeka listemi kur
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
@@ -1,15 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
import type { Program } from "@/lib/db";
|
||||
import { trBaslikDuzeni } from "@/lib/slug";
|
||||
import { Sayfalama, SAYFA_BOYU } from "@/components/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 { UniLogo } from "@/components/uni-logo";
|
||||
import {
|
||||
Table,
|
||||
@@ -54,7 +56,7 @@ function SiralamaDugmesi({
|
||||
aktif && yon === "artan" ? "azalan" : "artan"
|
||||
} sırala`}
|
||||
aria-pressed={aktif}
|
||||
className="inline-flex min-h-11 cursor-pointer items-center gap-1 rounded-md px-1.5 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
className="inline-flex min-h-11 cursor-pointer items-center gap-1 rounded-full px-1.5 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<span>{etiket}</span>
|
||||
<span className="flex flex-col -space-y-1" aria-hidden>
|
||||
@@ -82,12 +84,22 @@ function SiralamaDugmesi({
|
||||
export function BolumProgramListesi({
|
||||
programlar,
|
||||
bazAd,
|
||||
tabanYol,
|
||||
sayfa,
|
||||
}: {
|
||||
/** Bölümün TÜM programları — sıralama liste geneline uygulanır. */
|
||||
programlar: BolumProgrami[];
|
||||
bazAd: string;
|
||||
tabanYol: string;
|
||||
sayfa: number;
|
||||
}) {
|
||||
const [aktifAlan, setAktifAlan] = useState<SiralamaAlani | null>(null);
|
||||
const [yon, setYon] = useState<SiralamaYonu>("artan");
|
||||
// Sıralama aktifken URL değişmeden istemci tarafında gezilen sayfa
|
||||
const [istemciSayfa, setIstemciSayfa] = useState(1);
|
||||
const listeRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const toplamSayfa = Math.max(1, Math.ceil(programlar.length / SAYFA_BOYU));
|
||||
|
||||
const siraliProgramlar = useMemo(() => {
|
||||
if (!aktifAlan) return programlar;
|
||||
@@ -107,7 +119,18 @@ export function BolumProgramListesi({
|
||||
.map(({ program }) => program);
|
||||
}, [aktifAlan, programlar, yon]);
|
||||
|
||||
const gosterilenSayfa = aktifAlan ? istemciSayfa : sayfa;
|
||||
const gosterilenProgramlar = useMemo(
|
||||
() =>
|
||||
siraliProgramlar.slice(
|
||||
(gosterilenSayfa - 1) * SAYFA_BOYU,
|
||||
gosterilenSayfa * SAYFA_BOYU,
|
||||
),
|
||||
[siraliProgramlar, gosterilenSayfa],
|
||||
);
|
||||
|
||||
function sirala(alan: SiralamaAlani) {
|
||||
setIstemciSayfa(1);
|
||||
if (aktifAlan === alan) {
|
||||
setYon((mevcut) => (mevcut === "artan" ? "azalan" : "artan"));
|
||||
return;
|
||||
@@ -116,124 +139,148 @@ export function BolumProgramListesi({
|
||||
setYon("artan");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-2xl border border-slate-200 bg-white">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Üniversite</TableHead>
|
||||
<TableHead className="hidden text-right md:table-cell">
|
||||
Son 5 yıl
|
||||
</TableHead>
|
||||
<TableHead className="hidden text-right lg:table-cell">
|
||||
<SiralamaDugmesi
|
||||
alan="puan2025"
|
||||
aktifAlan={aktifAlan}
|
||||
yon={yon}
|
||||
onSirala={sirala}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="hidden text-right sm:table-cell">
|
||||
<SiralamaDugmesi
|
||||
alan="kontenjan2025"
|
||||
aktifAlan={aktifAlan}
|
||||
yon={yon}
|
||||
onSirala={sirala}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
<SiralamaDugmesi
|
||||
alan="sira2025"
|
||||
aktifAlan={aktifAlan}
|
||||
yon={yon}
|
||||
onSirala={sirala}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="w-14 text-right">
|
||||
<span className="sr-only">Listeye ekle</span>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{siraliProgramlar.map((p) => {
|
||||
const uniAd = trBaslikDuzeni(p.universite);
|
||||
const varyant = p.isim.trim() !== bazAd ? p.isim.trim() : null;
|
||||
const devlet = p.unitur === "DEVLET";
|
||||
function istemciSayfaDegistir(n: number) {
|
||||
setIstemciSayfa(n);
|
||||
listeRef.current?.scrollIntoView({ block: "start" });
|
||||
}
|
||||
|
||||
return (
|
||||
<TableRow key={p.id}>
|
||||
<TableCell className="max-w-0 w-full">
|
||||
<span className="flex min-w-0 items-center gap-2.5">
|
||||
{p.universiteSlug ? (
|
||||
<UniLogo slug={p.universiteSlug} ad={uniAd} boy="sm" />
|
||||
) : null}
|
||||
<span className="min-w-0 flex-1">
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={listeRef}
|
||||
className="scroll-mt-24 overflow-hidden rounded-2xl border border-slate-200 bg-white"
|
||||
>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Üniversite</TableHead>
|
||||
<TableHead className="hidden text-right md:table-cell">
|
||||
Son 5 yıl
|
||||
</TableHead>
|
||||
<TableHead className="hidden text-right lg:table-cell">
|
||||
<SiralamaDugmesi
|
||||
alan="puan2025"
|
||||
aktifAlan={aktifAlan}
|
||||
yon={yon}
|
||||
onSirala={sirala}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="hidden text-right sm:table-cell">
|
||||
<SiralamaDugmesi
|
||||
alan="kontenjan2025"
|
||||
aktifAlan={aktifAlan}
|
||||
yon={yon}
|
||||
onSirala={sirala}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
<SiralamaDugmesi
|
||||
alan="sira2025"
|
||||
aktifAlan={aktifAlan}
|
||||
yon={yon}
|
||||
onSirala={sirala}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="w-24 text-right">
|
||||
<span className="sr-only">Netler ve listeye ekle</span>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{gosterilenProgramlar.map((p) => {
|
||||
const uniAd = trBaslikDuzeni(p.universite);
|
||||
const varyant = p.isim.trim() !== bazAd ? p.isim.trim() : null;
|
||||
const devlet = p.unitur === "DEVLET";
|
||||
|
||||
return (
|
||||
<TableRow key={p.id}>
|
||||
<TableCell className="max-w-0 w-full">
|
||||
<span className="flex min-w-0 items-center gap-2.5">
|
||||
{p.universiteSlug ? (
|
||||
<Link
|
||||
href={`/universite/${p.universiteSlug}`}
|
||||
className="block truncate text-sm font-semibold hover:text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{uniAd}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="block truncate text-sm font-semibold">
|
||||
{uniAd}
|
||||
</span>
|
||||
)}
|
||||
<span className="mt-0.5 flex min-w-0 items-center gap-1.5 text-xs text-slate-500">
|
||||
{p.unitur ? (
|
||||
<span
|
||||
className={`shrink-0 rounded px-1 py-px text-[10px] font-medium leading-4 ${
|
||||
devlet
|
||||
? "bg-slate-100 text-slate-600"
|
||||
: "bg-violet-50 text-violet-600"
|
||||
}`}
|
||||
<UniLogo slug={p.universiteSlug} ad={uniAd} boy="sm" />
|
||||
) : null}
|
||||
<span className="min-w-0 flex-1">
|
||||
{p.universiteSlug ? (
|
||||
<Link
|
||||
href={`/universite/${p.universiteSlug}`}
|
||||
className="block truncate text-sm font-semibold hover:text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{devlet ? "Devlet" : trBaslikDuzeni(p.unitur)}
|
||||
{uniAd}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="block truncate text-sm font-semibold">
|
||||
{uniAd}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="truncate">
|
||||
{p.il ? trBaslikDuzeni(p.il) : null}
|
||||
{varyant ? `${p.il ? " · " : ""}${varyant}` : ""}
|
||||
)}
|
||||
<span className="mt-0.5 flex min-w-0 items-center gap-1.5 text-xs text-slate-500">
|
||||
{p.unitur ? (
|
||||
<span
|
||||
className={`shrink-0 rounded px-1 py-px text-[10px] font-medium leading-4 ${
|
||||
devlet
|
||||
? "bg-slate-100 text-slate-600"
|
||||
: "bg-violet-50 text-violet-600"
|
||||
}`}
|
||||
>
|
||||
{devlet ? "Devlet" : trBaslikDuzeni(p.unitur)}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="truncate">
|
||||
{p.il ? trBaslikDuzeni(p.il) : null}
|
||||
{varyant ? `${p.il ? " · " : ""}${varyant}` : ""}
|
||||
</span>
|
||||
{p.sure ? (
|
||||
<span className="shrink-0 text-slate-400">
|
||||
· {p.sure} yıl
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
{p.sure ? (
|
||||
<span className="shrink-0 text-slate-400">
|
||||
· {p.sure} yıl
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="hidden text-right text-xs md:table-cell">
|
||||
<ProgramSiraGecmisi program={p} />
|
||||
</TableCell>
|
||||
<TableCell className="hidden text-right text-xs tabular-nums text-slate-500 lg:table-cell">
|
||||
{p.puan2025 != null
|
||||
? p.puan2025.toFixed(2).replace(".", ",")
|
||||
: "—"}
|
||||
</TableCell>
|
||||
<TableCell className="hidden text-right text-xs tabular-nums text-slate-500 sm:table-cell">
|
||||
{p.kontenjan2025 != null ? `${sayi(p.kontenjan2025)} kişi` : "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-xs tabular-nums text-slate-500">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<ProgramTabanTrendi program={p} />
|
||||
{p.sira2025 != null ? `~${sayi(p.sira2025)}.` : "—"}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="pr-3 text-right">
|
||||
<ProgramEkleButonu
|
||||
program={p}
|
||||
kaynak="bolum"
|
||||
className="size-8 min-h-8 min-w-8"
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="hidden text-right text-xs md:table-cell">
|
||||
<ProgramSiraGecmisi program={p} />
|
||||
</TableCell>
|
||||
<TableCell className="hidden text-right text-xs tabular-nums text-slate-500 lg:table-cell">
|
||||
{p.puan2025 != null
|
||||
? p.puan2025.toFixed(2).replace(".", ",")
|
||||
: "—"}
|
||||
</TableCell>
|
||||
<TableCell className="hidden text-right text-xs tabular-nums text-slate-500 sm:table-cell">
|
||||
{p.kontenjan2025 != null ? `${sayi(p.kontenjan2025)} kişi` : "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-xs tabular-nums text-slate-500">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<ProgramTabanTrendi program={p} />
|
||||
{p.sira2025 != null ? `~${sayi(p.sira2025)}.` : "—"}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="pr-3 text-right">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<ProgramNetleriButonu
|
||||
programId={p.id}
|
||||
isim={p.isim.trim()}
|
||||
universite={uniAd}
|
||||
className="size-8 min-h-8 min-w-8"
|
||||
/>
|
||||
<ProgramEkleButonu
|
||||
program={p}
|
||||
kaynak="bolum"
|
||||
className="size-8 min-h-8 min-w-8"
|
||||
/>
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<Sayfalama
|
||||
tabanYol={tabanYol}
|
||||
sayfa={gosterilenSayfa}
|
||||
toplamSayfa={toplamSayfa}
|
||||
onSayfa={aktifAlan ? istemciSayfaDegistir : undefined}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,167 @@
|
||||
import { ArrowRight } from "lucide-react";
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
ArrowRight,
|
||||
ChartNoAxesCombined,
|
||||
Database,
|
||||
ListChecks,
|
||||
Trophy,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerTitle,
|
||||
} from "@/components/ui/drawer";
|
||||
import { SectionEyebrow } from "@/components/pixel-decor";
|
||||
import {
|
||||
SihirbazAdimlarLazy,
|
||||
preloadSihirbazAdimlar,
|
||||
} from "@/components/sihirbaz-adimlar-lazy";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { olay, siraKovasi } from "@/lib/analitik";
|
||||
import { useTercihProfili } from "@/components/manuel-liste/profil-kapisi-store";
|
||||
import type { SihirbazFacetleri } from "@/lib/db";
|
||||
import {
|
||||
sonucHref,
|
||||
tercihProfiliYaz,
|
||||
type SihirbazSecimleri,
|
||||
} from "@/lib/sihirbaz";
|
||||
|
||||
// lib/db sunucu tarafı; etiketler client bundle'a sızmasın diye burada.
|
||||
const PUAN_TURLERI_SECENEK = [
|
||||
{ deger: "say", etiket: "Sayısal" },
|
||||
{ deger: "ea", etiket: "Eşit Ağırlık" },
|
||||
{ deger: "soz", etiket: "Sözel" },
|
||||
{ deger: "dil", etiket: "Dil" },
|
||||
{ deger: "tyt", etiket: "TYT" },
|
||||
] as const;
|
||||
|
||||
// Kanıt üçlüsü arama funnel kartıyla aynı dili konuşur (bkz. arama-funnel-karti)
|
||||
const KANITLAR = [
|
||||
{ ikon: ListChecks, etiket: "24 tercihlik plan" },
|
||||
{ ikon: ChartNoAxesCombined, etiket: "Risk analizi" },
|
||||
{ ikon: Database, etiket: "YÖK Atlas verisi" },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Programatik SEO sayfalarındaki funnel girişi: JS'siz düz GET formu,
|
||||
* /sonuc?sira=&tur= akışına düşer. Client bileşen DEĞİL — sayfalar tamamen
|
||||
* statik prerender edilir, form tarayıcının kendi submit'iyle çalışır.
|
||||
* Programatik SEO sayfalarındaki funnel girişi. Statik/SSR çıktı her zaman
|
||||
* profilsiz hâldir (sayfalar prerender edilmeye devam eder); hydration
|
||||
* sonrası kayıtlı tercih profili bulunursa "sıralamanı gir" yerine doğrudan
|
||||
* sonuç sayfasına götüren CTA gösterilir — sıralamasını girmiş adaya bir
|
||||
* daha girmesini söylemeyiz.
|
||||
*
|
||||
* Profilsiz CTA adayı ana sayfaya göndermez: buton alttan bir çekmece açar,
|
||||
* sıralama girişi ve sihirbaz adayın bulunduğu sayfanın bağlamında akar.
|
||||
* Tasarım gerekçesi için bkz. docs/tasarim/cta-cekmece-tasarimi.md.
|
||||
*/
|
||||
export function CtaSiraForm({ baslik }: { baslik?: string }) {
|
||||
const profil = useTercihProfili();
|
||||
const router = useRouter();
|
||||
|
||||
const [acik, setAcik] = useState(false);
|
||||
const [adim, setAdim] = useState<"sira" | "sihirbaz">("sira");
|
||||
const [siralama, setSiralama] = useState("");
|
||||
const [tur, setTur] = useState<string>("say");
|
||||
const [sira, setSira] = useState<number | null>(null);
|
||||
const [facetler, setFacetler] = useState<SihirbazFacetleri | null>(null);
|
||||
const [yukleniyor, setYukleniyor] = useState(false);
|
||||
const [hata, setHata] = useState<string | null>(null);
|
||||
const [girisli, setGirisli] = useState(false);
|
||||
|
||||
// Yazarken binlik ayracıyla biçimle (85000 → 85.000)
|
||||
function siralamaDegisti(ham: string) {
|
||||
setHata(null);
|
||||
const rakamlar = ham.replace(/\D/g, "").slice(0, 7);
|
||||
setSiralama(rakamlar ? Number(rakamlar).toLocaleString("tr-TR") : "");
|
||||
}
|
||||
|
||||
async function siraGonder(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const value = Number(siralama.replace(/\./g, ""));
|
||||
if (!value || value < 1 || value > 4_000_000) {
|
||||
setHata("Geçerli bir başarı sıralaması gir (ör. 85.000).");
|
||||
return;
|
||||
}
|
||||
setYukleniyor(true);
|
||||
try {
|
||||
// Oturum bilgisi facet isteğiyle PARALEL çözülür; sayfa statik kalır
|
||||
// (hero formundaki desenle aynı — bkz. hero-form.tsx).
|
||||
const [res, oturum] = await Promise.all([
|
||||
fetch(`/api/facetler?sira=${value}&tur=${tur}`),
|
||||
authClient.getSession().catch(() => null),
|
||||
]);
|
||||
if (!res.ok) throw new Error();
|
||||
const veri = (await res.json()) as { facetler: SihirbazFacetleri };
|
||||
setGirisli(Boolean(oturum?.data?.user));
|
||||
setSira(value);
|
||||
setFacetler(veri.facetler);
|
||||
setAdim("sihirbaz");
|
||||
olay("sira_girildi", {
|
||||
kaynak: "cta",
|
||||
tur,
|
||||
sira_kovasi: siraKovasi(value),
|
||||
});
|
||||
} catch {
|
||||
setHata("Bir şeyler ters gitti, tekrar dener misin?");
|
||||
} finally {
|
||||
setYukleniyor(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Seçimler tamam: sakla, çekmeceyi kapat ve /sonuc'a git. Girişli
|
||||
// kullanıcıda orada otomatik üretim başlar; girişsiz kullanıcı önce
|
||||
// ücretsiz tabloyu görür (değer önce — giriş duvarı sihirbazın hemen
|
||||
// arkasında değil).
|
||||
function secimleriTamamla(secimler: SihirbazSecimleri) {
|
||||
if (sira == null) return;
|
||||
const yeniProfil = { sira, tur, secimler };
|
||||
tercihProfiliYaz(yeniProfil);
|
||||
setAcik(false);
|
||||
router.push(
|
||||
sonucHref(yeniProfil, girisli ? { sihirbaz: "1" } : { hazir: "1" }),
|
||||
);
|
||||
}
|
||||
|
||||
function acikDegisti(v: boolean) {
|
||||
setAcik(v);
|
||||
if (!v) {
|
||||
// Yeniden açılışta sıfırdan başla; yazılan sıralama kaybolmasın.
|
||||
setAdim("sira");
|
||||
setFacetler(null);
|
||||
setHata(null);
|
||||
}
|
||||
}
|
||||
|
||||
if (profil) {
|
||||
return (
|
||||
<section className="rounded-2xl border border-primary/20 bg-primary/5 p-6 sm:p-8">
|
||||
<h2 className="font-heading text-xl font-bold text-slate-900">
|
||||
{baslik ?? "Sıralamanla nereye yerleşirsin? Saniyede gör."}
|
||||
</h2>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-600">
|
||||
Sıralaman kayıtlı ({profil.sira.toLocaleString("tr-TR")}. sıra) —
|
||||
gerçek YÖK Atlas verisiyle hayal, dengeli ve garanti dilimlerindeki
|
||||
programların hazır.
|
||||
</p>
|
||||
<Link
|
||||
href={sonucHref(profil)}
|
||||
className="mt-4 inline-flex h-12 items-center justify-center gap-2 rounded-full bg-orange-500 px-7 text-sm font-semibold text-white transition-colors duration-200 hover:bg-orange-600"
|
||||
>
|
||||
Sıralamana uygun programları gör
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Link>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="rounded-2xl border border-primary/20 bg-primary/5 p-6 sm:p-8">
|
||||
<h2 className="font-heading text-xl font-bold text-slate-900">
|
||||
@@ -16,42 +172,151 @@ export function CtaSiraForm({ baslik }: { baslik?: string }) {
|
||||
hayal, dengeli ve garanti dilimlerindeki programları anında listele.
|
||||
Ücretsiz, üyelik gerekmez.
|
||||
</p>
|
||||
<form
|
||||
action="/sonuc"
|
||||
method="get"
|
||||
className="mt-4 flex flex-col gap-3 sm:flex-row"
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setAcik(true)}
|
||||
onPointerEnter={preloadSihirbazAdimlar}
|
||||
onFocus={preloadSihirbazAdimlar}
|
||||
className="mt-4 h-11 cursor-pointer bg-orange-500 px-6 text-sm font-semibold text-white transition-colors duration-200 hover:bg-orange-600"
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
name="sira"
|
||||
required
|
||||
min={1}
|
||||
max={4000000}
|
||||
inputMode="numeric"
|
||||
placeholder="Başarı sıralaman (ör. 85000)"
|
||||
aria-label="YKS başarı sıralaması"
|
||||
className="h-11 flex-1 rounded-lg border border-slate-300 bg-white px-4 text-sm text-slate-900 placeholder:text-slate-400 focus:border-primary focus:outline-none"
|
||||
/>
|
||||
<select
|
||||
name="tur"
|
||||
aria-label="Puan türü"
|
||||
defaultValue="say"
|
||||
className="h-11 rounded-lg border border-slate-300 bg-white px-3 text-sm text-slate-900 focus:border-primary focus:outline-none"
|
||||
Sıralamanı gir, programları gör
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Button>
|
||||
|
||||
<Drawer open={acik} onOpenChange={acikDegisti} handleOnly>
|
||||
<DrawerContent
|
||||
data-lenis-prevent
|
||||
className="max-h-[92dvh] bg-white sm:mx-auto sm:max-w-2xl sm:border-x sm:border-slate-200 sm:shadow-2xl"
|
||||
>
|
||||
<option value="say">Sayısal</option>
|
||||
<option value="ea">Eşit Ağırlık</option>
|
||||
<option value="soz">Sözel</option>
|
||||
<option value="dil">Dil</option>
|
||||
<option value="tyt">TYT (2 yıllık)</option>
|
||||
</select>
|
||||
<button
|
||||
type="submit"
|
||||
className="inline-flex h-11 items-center justify-center gap-2 rounded-lg bg-primary px-6 text-sm font-semibold text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
>
|
||||
Programları gör
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</button>
|
||||
</form>
|
||||
{/* Görünür başlıklar adıma göre değişir; erişilebilir ad sabit kalır. */}
|
||||
<DrawerTitle className="sr-only">
|
||||
Sıralamanla nereye yerleşirsin?
|
||||
</DrawerTitle>
|
||||
<DrawerDescription className="sr-only">
|
||||
YKS başarı sıralamanı gir; sıralamana uygun programları bu sayfadan
|
||||
ayrılmadan gör.
|
||||
</DrawerDescription>
|
||||
<DrawerClose className="absolute top-3 right-3 z-10 flex size-9 cursor-pointer items-center justify-center rounded-full border border-slate-200 bg-popover text-slate-500 transition-[background-color,transform] duration-150 hover:bg-slate-100 hover:text-slate-800 active:scale-[0.96]">
|
||||
<X className="size-4" aria-hidden />
|
||||
<span className="sr-only">Kapat</span>
|
||||
</DrawerClose>
|
||||
|
||||
<div
|
||||
data-lenis-prevent
|
||||
className="min-h-0 flex-1 overflow-y-auto px-4 pt-5 pb-[max(1.25rem,env(safe-area-inset-bottom))] sm:px-6"
|
||||
>
|
||||
{adim === "sira" ? (
|
||||
<div className="flex flex-col gap-5">
|
||||
<div>
|
||||
<SectionEyebrow>Saniyede gör</SectionEyebrow>
|
||||
<h2 className="mt-3 font-heading text-2xl font-bold text-slate-900">
|
||||
Sıralamanla nereye yerleşirsin?
|
||||
</h2>
|
||||
<p className="mt-2 max-w-xl text-sm leading-6 text-slate-600">
|
||||
Puan türünü seç, başarı sıralamanı yaz; hayal, dengeli ve
|
||||
garanti dilimlerindeki programları bu sayfadan ayrılmadan
|
||||
gör. Ücretsiz, üyelik gerekmez.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={siraGonder} className="flex flex-col gap-3">
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="Puan türü"
|
||||
className="flex flex-wrap gap-1.5"
|
||||
>
|
||||
{PUAN_TURLERI_SECENEK.map((t) => (
|
||||
<button
|
||||
key={t.deger}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={tur === t.deger}
|
||||
onClick={() => setTur(t.deger)}
|
||||
className={
|
||||
tur === t.deger
|
||||
? "cursor-pointer rounded-full border border-primary bg-primary px-3 py-1.5 text-xs font-medium text-white transition-colors duration-200"
|
||||
: "cursor-pointer rounded-full border border-slate-200 bg-white px-3 py-1.5 text-xs text-slate-600 transition-colors duration-200 hover:border-slate-300 hover:bg-slate-50"
|
||||
}
|
||||
>
|
||||
{t.etiket}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<label htmlFor="cta-siralama" className="sr-only">
|
||||
YKS başarı sıralaması
|
||||
</label>
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<div className="relative sm:flex-1">
|
||||
<Trophy
|
||||
className="pointer-events-none absolute left-4 top-1/2 size-4 -translate-y-1/2 text-slate-400"
|
||||
aria-hidden
|
||||
/>
|
||||
<Input
|
||||
id="cta-siralama"
|
||||
inputMode="numeric"
|
||||
autoComplete="off"
|
||||
placeholder="YKS başarı sıralaman (ör. 85.000)"
|
||||
value={siralama}
|
||||
onChange={(e) => siralamaDegisti(e.target.value)}
|
||||
onFocus={preloadSihirbazAdimlar}
|
||||
aria-invalid={hata ? true : undefined}
|
||||
aria-describedby={
|
||||
hata ? "cta-siralama-hata" : undefined
|
||||
}
|
||||
className="h-12 bg-background pl-11 text-base"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={yukleniyor}
|
||||
className="h-12 cursor-pointer bg-orange-500 px-6 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||
>
|
||||
{yukleniyor ? "Hazırlanıyor…" : "Programları gör"}
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
{hata ? (
|
||||
<p
|
||||
id="cta-siralama-hata"
|
||||
role="alert"
|
||||
className="text-sm font-medium text-red-600"
|
||||
>
|
||||
{hata}
|
||||
</p>
|
||||
) : null}
|
||||
</form>
|
||||
|
||||
<ul
|
||||
aria-label="Neler alacaksın"
|
||||
className="flex flex-wrap gap-x-4 gap-y-2 border-t border-slate-100 pt-4"
|
||||
>
|
||||
{KANITLAR.map(({ ikon: Ikon, etiket }) => (
|
||||
<li
|
||||
key={etiket}
|
||||
className="inline-flex items-center gap-1.5 text-xs font-medium text-slate-600"
|
||||
>
|
||||
<Ikon className="size-3.5 text-primary" aria-hidden />
|
||||
{etiket}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : facetler && sira != null ? (
|
||||
<SihirbazAdimlarLazy
|
||||
key={`${sira}-${tur}`}
|
||||
sira={sira}
|
||||
tur={tur}
|
||||
facetler={facetler}
|
||||
sonButonEtiketi={
|
||||
girisli ? "Yapay Zeka listemi kur (3 kredi)" : "Ücretsiz tablonu gör"
|
||||
}
|
||||
onTamamla={secimleriTamamla}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,17 +5,14 @@
|
||||
// ayrıca korunur). Amaç: satın alma / giriş / kredi durumlarını tek
|
||||
// tıkla taklit edip uygulamanın tüm sayfalarını gerçek akışta gezmek.
|
||||
//
|
||||
// Hareket dili (Apple "Designing Fluid Interfaces"): panel DEV çipinden
|
||||
// büyür ve yine ona küçülür (paylaşılan layoutId ile mekânsal bağ), yay
|
||||
// kritik sönümlüdür (bounce 0) — açılış ortasında kapatılırsa mevcut
|
||||
// değerden geri döner. Yüzey opak kutu değil, yarı saydam malzemedir.
|
||||
// Yüzey opak kutu değil, yarı saydam malzemedir.
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Wrench, X } from "lucide-react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import { toast } from "sonner";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { tercihProfiliSil } from "@/lib/sihirbaz";
|
||||
import {
|
||||
devAnindaGiris,
|
||||
devAyarla,
|
||||
@@ -27,6 +24,7 @@ import {
|
||||
|
||||
const SAYFALAR: { ad: string; href: string }[] = [
|
||||
{ ad: "Ana sayfa", href: "/" },
|
||||
{ ad: "Sonuç (sırasız)", href: "/sonuc" },
|
||||
{ ad: "Sonuç (85k · SAY)", href: "/sonuc?sira=85000&tur=say&sihirbaz=1" },
|
||||
{ ad: "Sonuç (girişsiz hazır)", href: "/sonuc?sira=85000&tur=say&hazir=1" },
|
||||
{ ad: "Listem", href: "/listem" },
|
||||
@@ -34,6 +32,13 @@ const SAYFALAR: { ad: string; href: string }[] = [
|
||||
{ ad: "Paket", href: "/paket" },
|
||||
{ ad: "Paket (ödeme hatası)", href: "/paket?hata=token" },
|
||||
{ ad: "PDF / yazdır", href: "/rapor/yazdir" },
|
||||
{ ad: "Bölümler", href: "/bolumler" },
|
||||
{ ad: "Bölüm (bilg. müh.)", href: "/bolum/bilgisayar-muhendisligi" },
|
||||
{ ad: "Üniversiteler", href: "/universiteler" },
|
||||
{ ad: "Üniversite (Boğaziçi)", href: "/universite/bogazici-universitesi" },
|
||||
{ ad: "Rehber", href: "/rehber" },
|
||||
{ ad: "Rehber (devlet/vakıf)", href: "/rehber/devlet-mi-vakif-mi" },
|
||||
{ ad: "Meraklısına", href: "/meraklisina" },
|
||||
{ ad: "Giriş", href: "/giris" },
|
||||
{ ad: "Gizlilik", href: "/gizlilik" },
|
||||
{ ad: "Koşullar", href: "/kosullar" },
|
||||
@@ -41,10 +46,6 @@ const SAYFALAR: { ad: string; href: string }[] = [
|
||||
{ ad: "404", href: "/boyle-bir-sayfa-yok" },
|
||||
];
|
||||
|
||||
// Apple değerleri: damping 1.0 (bounce 0), response ~0.35 sn.
|
||||
// Yay süreli değildir; hedef değişirse mevcut değer + hızdan devam eder.
|
||||
const YAY = { type: "spring", bounce: 0, duration: 0.35 } as const;
|
||||
|
||||
// Basış anında tepki (pointer-down feedback) — bırakınca değil.
|
||||
const BASIS =
|
||||
"active:scale-[0.97] transition-[transform,background-color,border-color,opacity] duration-150 ease-out";
|
||||
@@ -60,7 +61,6 @@ export function DevPanel() {
|
||||
const [email, setEmail] = useState("test@kolaytercih.dev");
|
||||
const [mesgul, setMesgul] = useState(false);
|
||||
const router = useRouter();
|
||||
const azMotion = useReducedMotion();
|
||||
|
||||
const yenile = useCallback(async () => {
|
||||
try {
|
||||
@@ -131,50 +131,27 @@ export function DevPanel() {
|
||||
|
||||
const g = durum?.girisli ? durum : null;
|
||||
|
||||
// Azaltılmış hareket: morph yerine kısa çapraz solma (yaysız, kaymasız).
|
||||
const paylasilan = azMotion ? {} : { layoutId: "dev-panel" };
|
||||
const gecis = azMotion ? { duration: 0.15 } : YAY;
|
||||
|
||||
return (
|
||||
<AnimatePresence initial={false}>
|
||||
<>
|
||||
{!acik ? (
|
||||
<motion.button
|
||||
key="cip"
|
||||
{...paylasilan}
|
||||
<button
|
||||
type="button"
|
||||
onClick={paneliAc}
|
||||
title="Dev süperadmin paneli"
|
||||
style={{ borderRadius: 18 }}
|
||||
transition={gecis}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0, transition: { duration: 0.15, ease: "easeOut" } }}
|
||||
className={`fixed bottom-20 left-4 z-50 inline-flex h-9 cursor-pointer items-center gap-1.5 px-3 text-xs font-bold tracking-wide text-white ${MALZEME} !bg-slate-900/85 print:hidden ${BASIS} hover:!bg-slate-800/85`}
|
||||
>
|
||||
<Wrench className="size-3.5 text-violet-300" aria-hidden />
|
||||
DEV
|
||||
</motion.button>
|
||||
</button>
|
||||
) : (
|
||||
<motion.div
|
||||
key="panel"
|
||||
{...paylasilan}
|
||||
<div
|
||||
role="dialog"
|
||||
aria-label="Dev süperadmin paneli"
|
||||
style={{ borderRadius: 24, transformOrigin: "bottom left" }}
|
||||
transition={gecis}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0, transition: { duration: 0.15, ease: "easeOut" } }}
|
||||
style={{ borderRadius: 24 }}
|
||||
className={`fixed bottom-20 left-4 z-50 flex max-h-[72vh] w-80 flex-col overflow-hidden ${MALZEME} print:hidden`}
|
||||
>
|
||||
{/* İçerik morph sırasında bozulmasın: yüzey oturduktan hemen
|
||||
sonra belirir (malzeme önce, içerik sonra). */}
|
||||
<motion.div
|
||||
initial={azMotion ? false : { opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.2, delay: azMotion ? 0 : 0.12 }}
|
||||
className="flex min-h-0 flex-col"
|
||||
>
|
||||
<div className="flex min-h-0 flex-col">
|
||||
{/* Başlık: opak şerit değil, aynı malzemenin üstünde ince
|
||||
bir kenar çizgisiyle ayrışan katman. */}
|
||||
<div className="flex items-center justify-between border-b border-slate-900/5 px-4 py-3">
|
||||
@@ -319,6 +296,24 @@ export function DevPanel() {
|
||||
gider
|
||||
</span>
|
||||
</button>
|
||||
{/* Sıra + sihirbaz profili localStorage'ta yaşar; girişten
|
||||
bağımsızdır — bu yüzden !g ile kilitlenmez. */}
|
||||
<button
|
||||
type="button"
|
||||
disabled={mesgul}
|
||||
onClick={() => {
|
||||
tercihProfiliSil();
|
||||
toast.success("Sıralama ve tercih profili temizlendi.");
|
||||
router.refresh();
|
||||
}}
|
||||
className={`mt-1.5 w-full cursor-pointer rounded-xl border border-rose-600/15 bg-rose-50/80 px-2.5 py-2 text-left text-xs font-semibold text-rose-800 hover:bg-rose-100/80 disabled:cursor-default disabled:opacity-40 ${BASIS}`}
|
||||
>
|
||||
Sıralamayı temizle
|
||||
<span className="block text-[10px] font-normal text-rose-700">
|
||||
localStorage'taki sıra + sihirbaz profilini siler
|
||||
(navbar rozeti dahil)
|
||||
</span>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{/* Sayfalar */}
|
||||
@@ -339,9 +334,9 @@ export function DevPanel() {
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
85
src/components/el-cizimi-sema.tsx
Normal file
85
src/components/el-cizimi-sema.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
// Ürün genelindeki "el çizimi" mermaid şemalarının tek kaynağı: /meraklisina
|
||||
// hunisi de rehber yazılarındaki şemalar da bu bileşenle çizilir, böylece
|
||||
// görünüm (font, renk teması, çizgi titremesi) her yerde birebir aynı kalır.
|
||||
// Mermaid ağır bir paket olduğu için useEffect içinde dinamik import edilir;
|
||||
// sayfa paketine girmez.
|
||||
|
||||
import { useEffect, useId, useState } from "react";
|
||||
import localFont from "next/font/local";
|
||||
|
||||
// Şemanın "el çizimi" görünümüne uyan el yazısı fontu (Sweet Cucumber
|
||||
// Mocktail, Misti's Fonts). Türkçe glifler doğrulandı (ş, ğ, ı, İ… hepsi
|
||||
// var). DİKKAT: lisans "kişisel kullanım için ücretsiz" — canlıya çıkmadan
|
||||
// mistifonts.com/sweet-cucumber-mocktail adresinden ticari lisans alınmalı.
|
||||
const elYazisi = localFont({
|
||||
src: "../fonts/SweetCucumberMocktail.woff2",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
export function ElCizimiSema({
|
||||
tanim,
|
||||
ariaLabel,
|
||||
figcaption,
|
||||
}: {
|
||||
/** Mermaid tanımı (flowchart vb.) */
|
||||
tanim: string;
|
||||
/** Şemanın tamamını anlatan erişilebilirlik metni */
|
||||
ariaLabel: string;
|
||||
figcaption?: React.ReactNode;
|
||||
}) {
|
||||
const [svg, setSvg] = useState<string | null>(null);
|
||||
// mermaid.render'ın istediği DOM id'si: useId'nin ":" karakterleri geçersiz
|
||||
const semaId = `sema-${useId().replace(/[^a-zA-Z0-9-]/g, "")}`;
|
||||
|
||||
useEffect(() => {
|
||||
let iptal = false;
|
||||
import("mermaid").then(async ({ default: mermaid }) => {
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
look: "handDrawn",
|
||||
handDrawnSeed: 7, // her render'da aynı çizgi titremesi (deterministik)
|
||||
theme: "base",
|
||||
fontFamily: `${elYazisi.style.fontFamily}, cursive`,
|
||||
themeVariables: {
|
||||
primaryColor: "#ffffff",
|
||||
primaryBorderColor: "#e2e8f0",
|
||||
primaryTextColor: "#0f172a",
|
||||
lineColor: "#94a3b8",
|
||||
edgeLabelBackground: "#ffffff",
|
||||
fontFamily: `${elYazisi.style.fontFamily}, cursive`,
|
||||
fontSize: "17px", // script fontun x-yüksekliği düşük; okunurluk için büyütüldü
|
||||
},
|
||||
});
|
||||
const sonuc = await mermaid.render(semaId, tanim);
|
||||
if (!iptal) setSvg(sonuc.svg);
|
||||
});
|
||||
return () => {
|
||||
iptal = true;
|
||||
};
|
||||
}, [semaId, tanim]);
|
||||
|
||||
return (
|
||||
<figure className="mt-6">
|
||||
{svg ? (
|
||||
<div
|
||||
role="img"
|
||||
aria-label={ariaLabel}
|
||||
className={`${elYazisi.className} overflow-x-auto [&_svg]:mx-auto [&_svg]:h-auto [&_svg]:max-w-full`}
|
||||
dangerouslySetInnerHTML={{ __html: svg }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
aria-hidden
|
||||
className="h-96 animate-pulse rounded-xl bg-slate-100"
|
||||
/>
|
||||
)}
|
||||
{figcaption ? (
|
||||
<figcaption className="mt-3 text-xs text-slate-500">
|
||||
{figcaption}
|
||||
</figcaption>
|
||||
) : null}
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
@@ -2,14 +2,18 @@
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
// Header'ın gizleneceği rotalar (tam eşleşme veya alt yol)
|
||||
// Site kromunun (banner, header, üst/alt blur) gizleneceği rotalar
|
||||
// (tam eşleşme veya alt yol). KaydetBanner da aynı listeye uyar.
|
||||
const HIDDEN_PREFIXES = ["/giris"];
|
||||
|
||||
export function kromGizliMi(pathname: string) {
|
||||
return HIDDEN_PREFIXES.some(
|
||||
(p) => pathname === p || pathname.startsWith(`${p}/`),
|
||||
);
|
||||
}
|
||||
|
||||
export function HeaderGate({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const gizli = HIDDEN_PREFIXES.some(
|
||||
(p) => pathname === p || pathname.startsWith(`${p}/`),
|
||||
);
|
||||
if (gizli) return null;
|
||||
if (kromGizliMi(pathname)) return null;
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
33
src/components/hero-baslik.tsx
Normal file
33
src/components/hero-baslik.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import { useTercihProfili } from "@/components/manuel-liste/profil-kapisi-store";
|
||||
|
||||
// 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.
|
||||
// SSR/ilk boyamada profil bilinmediği için varsayılan metin render edilir;
|
||||
// store yüklenince client'ta aksiyon haline geçer.
|
||||
export function HeroBaslik() {
|
||||
const profil = useTercihProfili();
|
||||
|
||||
return (
|
||||
<h1 className="max-w-3xl font-heading text-5xl font-bold tracking-[0.015em] sm:text-6xl">
|
||||
{profil ? (
|
||||
<>
|
||||
Sıralaman kayıtlı, <br className="hidden sm:block" />
|
||||
<span className="font-bricolage italic tracking-tighter font-bold text-primary">
|
||||
tercih listen
|
||||
</span>{" "}
|
||||
seni bekliyor
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Dört yıllık geleceğini <br className="hidden sm:block" />
|
||||
<span className="font-bricolage italic tracking-tighter font-bold text-primary">
|
||||
48 saatlik panikle
|
||||
</span>{" "}
|
||||
seçme
|
||||
</>
|
||||
)}
|
||||
</h1>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
"use client";
|
||||
|
||||
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";
|
||||
|
||||
export function HeroFocusButton() {
|
||||
function handleClick() {
|
||||
@@ -18,9 +22,49 @@ export function HeroFocusButton() {
|
||||
type="button"
|
||||
size="lg"
|
||||
onClick={handleClick}
|
||||
className="mt-8 h-12 cursor-pointer bg-orange-500 px-8 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||
className="mt-8 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||
>
|
||||
Ücretsiz dene
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ana sayfanın kapanış CTA'sı: kayıtlı tercih profili olan adaya
|
||||
* "sıralamanı gir" demek yerine doğrudan sonuç sayfasına götürür.
|
||||
* Profil yoksa mevcut davranış — hero'daki sıralama formuna odaklanır.
|
||||
*/
|
||||
export function KapanisCta() {
|
||||
const profil = useTercihProfili();
|
||||
|
||||
if (profil) {
|
||||
return (
|
||||
<>
|
||||
<p className="mt-4 max-w-xl text-lg leading-relaxed text-white/90">
|
||||
Sıralaman kayıtlı — yapay zekâ danışmanın veriyle konuşsun. Tercih
|
||||
dönemi bitmeden yerini al.
|
||||
</p>
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="mt-8 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||
>
|
||||
<Link href={sonucHref(profil)}>
|
||||
Üniversitelere göz at
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className="mt-4 max-w-xl text-lg leading-relaxed text-white/90">
|
||||
Sıralamanı gir, yapay zekâ danışmanın veriyle konuşsun. Tercih dönemi
|
||||
bitmeden yerini al.
|
||||
</p>
|
||||
<HeroFocusButton />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -16,8 +17,18 @@ import {
|
||||
preloadSihirbazAdimlar,
|
||||
} from "@/components/sihirbaz-adimlar-lazy";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { olay, siraKovasi } from "@/lib/analitik";
|
||||
import {
|
||||
profilDuzenlemeyiAc,
|
||||
useTercihProfili,
|
||||
} from "@/components/manuel-liste/profil-kapisi-store";
|
||||
import type { SihirbazFacetleri } from "@/lib/db";
|
||||
import { tercihProfiliYaz, type SihirbazSecimleri } from "@/lib/sihirbaz";
|
||||
import {
|
||||
PUAN_TURU_ETIKET,
|
||||
sonucHref,
|
||||
tercihProfiliYaz,
|
||||
type SihirbazSecimleri,
|
||||
} from "@/lib/sihirbaz";
|
||||
|
||||
// lib/db sunucu tarafı; etiketler client bundle'a sızmasın diye burada.
|
||||
const PUAN_TURLERI_SECENEK = [
|
||||
@@ -37,15 +48,16 @@ export function HeroForm() {
|
||||
const [modalAcik, setModalAcik] = useState(false);
|
||||
const [hata, setHata] = useState<string | null>(null);
|
||||
const [girisli, setGirisli] = useState(false);
|
||||
// Daha önce sıralama girmiş ziyaretçi formu yeniden doldurmasın:
|
||||
// kayıtlı profil varsa doğrudan /sonuc CTA'sı gösterilir.
|
||||
const kayitliProfil = useTercihProfili();
|
||||
const router = useRouter();
|
||||
|
||||
// Yazarken binlik ayracıyla biçimle (85000 → 85.000)
|
||||
function siralamaDegisti(ham: string) {
|
||||
setHata(null);
|
||||
const rakamlar = ham.replace(/\D/g, "").slice(0, 7);
|
||||
setSiralama(
|
||||
rakamlar ? Number(rakamlar).toLocaleString("tr-TR") : "",
|
||||
);
|
||||
setSiralama(rakamlar ? Number(rakamlar).toLocaleString("tr-TR") : "");
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
@@ -69,6 +81,11 @@ export function HeroForm() {
|
||||
setSira(value);
|
||||
setFacetler(veri.facetler);
|
||||
setModalAcik(true);
|
||||
olay("sira_girildi", {
|
||||
kaynak: "hero",
|
||||
tur,
|
||||
sira_kovasi: siraKovasi(value),
|
||||
});
|
||||
} catch {
|
||||
setHata("Bir şeyler ters gitti, tekrar dener misin?");
|
||||
} finally {
|
||||
@@ -82,17 +99,50 @@ export function HeroForm() {
|
||||
// oradan gelir (değer önce — giriş duvarı sihirbazın hemen arkasında değil).
|
||||
function secimleriTamamla(secimler: SihirbazSecimleri) {
|
||||
if (!sira) return;
|
||||
tercihProfiliYaz({ sira, tur, secimler });
|
||||
const profil = { sira, tur, secimler };
|
||||
tercihProfiliYaz(profil);
|
||||
setModalAcik(false);
|
||||
router.push(
|
||||
girisli
|
||||
? `/sonuc?sira=${sira}&tur=${tur}&sihirbaz=1`
|
||||
: `/sonuc?sira=${sira}&tur=${tur}&hazir=1`,
|
||||
sonucHref(profil, girisli ? { sihirbaz: "1" } : { hazir: "1" }),
|
||||
);
|
||||
}
|
||||
|
||||
// Kayıtlı profil varsa sihirbazı atla, doğrudan sonuç sayfasına götür.
|
||||
// "Farklı sıralama gir" navbar'daki Değiştir ile aynı düzenleme modalını
|
||||
// (TercihProfiliKapisi, layout'ta monte) açar.
|
||||
if (kayitliProfil) {
|
||||
return (
|
||||
<div className="flex w-full max-w-md flex-col items-center gap-3">
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="w-full cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600 sm:w-auto"
|
||||
>
|
||||
<Link href={sonucHref(kayitliProfil)}>
|
||||
Üniversitelere göz at
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Link>
|
||||
</Button>
|
||||
<p className="text-sm text-slate-600">
|
||||
{kayitliProfil.sira.toLocaleString("tr-TR")} ·{" "}
|
||||
{PUAN_TURU_ETIKET[kayitliProfil.tur] ?? kayitliProfil.tur} sıralamanla
|
||||
devam edersin.{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={profilDuzenlemeyiAc}
|
||||
className="cursor-pointer font-medium text-slate-900 underline underline-offset-2 hover:text-slate-700"
|
||||
>
|
||||
Farklı sıralama gir
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-md flex-col items-center gap-3">
|
||||
// max-w-xl: yatay dizilimde placeholder'ın tamamı inputa sığsın diye
|
||||
// (md'de buton geniş olduğundan inputa ~200px kalıyor, metin kesiliyordu).
|
||||
<div className="flex w-full max-w-xl flex-col items-center gap-3">
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="Puan türü"
|
||||
@@ -142,9 +192,9 @@ export function HeroForm() {
|
||||
type="submit"
|
||||
size="lg"
|
||||
disabled={yukleniyor}
|
||||
className="h-12 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||
className="cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||
>
|
||||
{yukleniyor ? "Hazırlanıyor…" : "Listemi oluştur"}
|
||||
Yapay Zeka listemi kur
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Button>
|
||||
</form>
|
||||
@@ -172,7 +222,7 @@ export function HeroForm() {
|
||||
tur={tur}
|
||||
facetler={facetler}
|
||||
sonButonEtiketi={
|
||||
girisli ? "Listemi oluştur (3 kredi)" : "Ücretsiz tablonu gör"
|
||||
girisli ? "Yapay Zeka listemi kur (3 kredi)" : "Ücretsiz tablonu gör"
|
||||
}
|
||||
onTamamla={secimleriTamamla}
|
||||
/>
|
||||
|
||||
@@ -82,14 +82,11 @@ export function IlSecimHaritasi({
|
||||
</svg>
|
||||
<div className="mt-2 flex items-center justify-end gap-2 text-xs text-slate-500">
|
||||
<span>az</span>
|
||||
<span
|
||||
aria-hidden
|
||||
className="h-2 w-24 rounded-full"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(to right, oklch(0.623 0.188 259.8 / 0.25), oklch(0.623 0.188 259.8 / 0.9))",
|
||||
}}
|
||||
/>
|
||||
<span aria-hidden className="flex items-center gap-1">
|
||||
<span className="size-2.5 rounded-full bg-primary/25" />
|
||||
<span className="size-2.5 rounded-full bg-primary/55" />
|
||||
<span className="size-2.5 rounded-full bg-primary/90" />
|
||||
</span>
|
||||
<span>çok program</span>
|
||||
<span className="ml-3 inline-flex items-center gap-1.5">
|
||||
<span
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user