Compare commits
11 Commits
a5c2bcbc4d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7518baccfa | ||
|
|
620e41edae | ||
|
|
50dc415987 | ||
|
|
be4b2c6000 | ||
|
|
37baeb9172 | ||
|
|
45775c8c3f | ||
|
|
18d5b42136 | ||
|
|
4c618781f6 | ||
|
|
e62c6f07df | ||
|
|
488845ae1f | ||
|
|
9ea006ec22 |
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": false,
|
||||||
|
"prd-development@pm-skills": false
|
||||||
|
},
|
||||||
|
"extraKnownMarketplaces": {
|
||||||
|
"superpowers-marketplace": {
|
||||||
|
"source": {
|
||||||
|
"source": "github",
|
||||||
|
"repo": "obra/superpowers-marketplace"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pm-skills": {
|
||||||
|
"source": {
|
||||||
|
"source": "github",
|
||||||
|
"repo": "deanpeters/Product-Manager-Skills"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
.claude/skills/prototype
Symbolic link
1
.claude/skills/prototype
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../../.agents/skills/prototype
|
||||||
@@ -1,13 +1,27 @@
|
|||||||
node_modules
|
node_modules
|
||||||
.next
|
.next
|
||||||
.git
|
.git
|
||||||
|
.gitea
|
||||||
.agents
|
.agents
|
||||||
.claude
|
.claude
|
||||||
.mcp.json
|
.mcp.json
|
||||||
|
# Yalnızca kök dizindeki .md dosyaları (path ayracını geçmez);
|
||||||
|
# content/rehber/*.md build için gerekli, etkilenmez.
|
||||||
*.md
|
*.md
|
||||||
.env*
|
.env*
|
||||||
!.env.example
|
!.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/
|
||||||
!data/app.db
|
!data/app.db
|
||||||
!data/yokatlas.db
|
!data/yokatlas.db
|
||||||
|
|||||||
@@ -26,12 +26,54 @@ jobs:
|
|||||||
IYZICO_API_KEY=${{ secrets.IYZICO_API_KEY }}
|
IYZICO_API_KEY=${{ secrets.IYZICO_API_KEY }}
|
||||||
IYZICO_SECRET_KEY=${{ secrets.IYZICO_SECRET_KEY }}
|
IYZICO_SECRET_KEY=${{ secrets.IYZICO_SECRET_KEY }}
|
||||||
IYZICO_BASE_URL=${{ secrets.IYZICO_BASE_URL }}
|
IYZICO_BASE_URL=${{ secrets.IYZICO_BASE_URL }}
|
||||||
AI_SAGLAYICI=${{ secrets.AI_SAGLAYICI }}
|
OPENROUTER_API_KEY=${{ secrets.OPENROUTER_API_KEY }}
|
||||||
ANTHROPIC_API_KEY=${{ secrets.ANTHROPIC_API_KEY }}
|
OPENROUTER_MODEL=${{ secrets.OPENROUTER_MODEL }}
|
||||||
GEMINI_API_KEY=${{ secrets.GEMINI_API_KEY }}
|
|
||||||
EOF
|
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: |
|
run: |
|
||||||
docker compose -p kolaytercih up -d --build --remove-orphans
|
cat > buildkitd.toml <<'EOF'
|
||||||
docker image prune -f
|
[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
|
||||||
|
|||||||
51
Dockerfile
51
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
|
RUN corepack enable && corepack prepare pnpm@10.8.1 --activate
|
||||||
|
|
||||||
# ── Bağımlılık katmanı ──
|
# ── Bağımlılık katmanı ──
|
||||||
@@ -9,17 +11,36 @@ WORKDIR /app
|
|||||||
# better-sqlite3 Alpine'de kaynaktan derleniyor (musl için prebuilt yok)
|
# better-sqlite3 Alpine'de kaynaktan derleniyor (musl için prebuilt yok)
|
||||||
RUN apk add --no-cache python3 make g++
|
RUN apk add --no-cache python3 make g++
|
||||||
COPY package.json pnpm-lock.yaml ./
|
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ı ──
|
# ── Derleme katmanı ──
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
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
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
# data/ .dockerignore'da; libsql build sırasında file:./data/app.db açabilsin diye boş dizin gerekli
|
# V8 heap sınırı ana süreç + statik üretim worker'larına miras kalır.
|
||||||
RUN mkdir -p data && pnpm build
|
# 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ı ──
|
# ── Çalıştırma katmanı ──
|
||||||
FROM node:20-alpine AS runner
|
FROM node:20-alpine AS runner
|
||||||
@@ -31,20 +52,28 @@ ENV NEXT_TELEMETRY_DISABLED=1
|
|||||||
RUN addgroup --system --gid 1001 nodejs && \
|
RUN addgroup --system --gid 1001 nodejs && \
|
||||||
adduser --system --uid 1001 nextjs
|
adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
# Bağımlılıklar
|
# standalone çıktı, file-tracing ile seçilmiş kendi minimal node_modules'unu
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
# içerir (serverExternalPackages dahil) — tam node_modules kopyalanmaz.
|
||||||
COPY --from=builder /app/.next/standalone ./
|
COPY --from=builder /app/.next/standalone ./
|
||||||
COPY --from=builder /app/.next/static ./.next/static
|
COPY --from=builder /app/.next/static ./.next/static
|
||||||
COPY --from=builder /app/public ./public
|
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
|
# Seed veritabanları: entrypoint açılışta bunları /app/data volume'üne kopyalar
|
||||||
COPY data/ ./seed/
|
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
|
COPY scripts/docker-entrypoint.sh ./docker-entrypoint.sh
|
||||||
|
|
||||||
# SQLite veri dizini
|
# SQLite veri dizini + Next'in runtime'da yazdığı image/fetch cache dizini
|
||||||
|
# (.next'in geri kalanı root'ta kalır; salt-okunur olması sorun değil)
|
||||||
RUN chmod +x /app/docker-entrypoint.sh && \
|
RUN chmod +x /app/docker-entrypoint.sh && \
|
||||||
mkdir -p /app/data && \
|
mkdir -p /app/data /app/.next/cache && \
|
||||||
chown -R nextjs:nodejs /app/data /app/seed
|
chown -R nextjs:nodejs /app/data /app/seed /app/.next/cache
|
||||||
|
|
||||||
USER nextjs
|
USER nextjs
|
||||||
|
|
||||||
|
|||||||
58
content/rehber/ais-tercih-nasil-yapilir-onaylama.md
Normal file
58
content/rehber/ais-tercih-nasil-yapilir-onaylama.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
---
|
||||||
|
baslik: AİS'te Tercih Nasıl Yapılır, Nasıl Onaylanır? (Son Kontrol Listesi)
|
||||||
|
aciklama: ÖSYM AİS tercih ekranı adım adım — kod girme, sıralama, "Tercihleri Bildir" onayı ve göndermeden önceki 5 maddelik son kontrol tek şemada.
|
||||||
|
tarih: 2026-08-05
|
||||||
|
---
|
||||||
|
|
||||||
|
Listeni kurdun; şimdi işin resmî kısmı var: tercihleri ÖSYM'nin sistemine girmek ve **onaylamak**. Her yıl adayların bir kısmı listeyi özenle kurup son adımda takılıyor — tercihler girilir ama bildirilmez, kod yanlış yazılır, değişiklikten sonra yeniden onay unutulur. Bu yazı AİS ekranını adım adım anlatıyor ve göndermeden önceki son kontrol listesini veriyor.
|
||||||
|
|
||||||
|
## Nereden, ne zamana kadar?
|
||||||
|
|
||||||
|
Tercihler yalnızca iki resmî kanaldan girilir: **ais.osym.gov.tr** ve **ÖSYM Aday İşlemleri mobil uygulaması**. Giriş T.C. kimlik numarası ve aday şifresiyle. 2026'da pencere **29 Temmuz - 10 Ağustos, saat 23.59** ([takvimin tamamı](/rehber/yks-tercih-takvimi-2026)). Son saate bırakma: kapanış akşamı sistem yoğunluğu gerçektir ve 23.59'dan sonra hiçbir işlem alınmaz.
|
||||||
|
|
||||||
|
## Adım adım AİS tercih ekranı
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
%% aria: ÖSYM AİS tercih bildirme akış şeması. Adımlar sırayla: aday şifresiyle AİS'e giriş yapılır, 2026 YKS Tercih ekranı açılır, kılavuzdaki program kodları tek tek listeye eklenir, satırlar istek sırasına göre dizilir, ardından son kontrol yapılır. Son kontrolden sonra Tercihleri Bildir butonuna basılır. Kritik doğrulama şudur: ekranda tercihleriniz ÖSYM'ye bildirilmiştir uyarısı görünüyorsa işlem tamamdır ve bu kutu yeşildir; uyarı görünmüyorsa tercihler kaydedilmiş ama bildirilmemiş olabilir, bu durumda tercihlerin geçersiz sayılır ve bu kutu kırmızıdır. Son tarihe kadar listede değişiklik yapılabilir ama her değişiklikten sonra Tercihleri Bildir adımı yeniden yapılmalıdır; bu döngü sarı ile işaretlidir çünkü en sık unutulan adımdır.
|
||||||
|
%% altyazi: Kaydetmek yetmez — ekranda "bildirilmiştir" uyarısını görmeden sistemden çıkma.
|
||||||
|
flowchart TD
|
||||||
|
A("AİS'e gir:<br/>T.C. kimlik + aday şifresi") --> B("2026-YKS Tercih<br/>ekranını aç")
|
||||||
|
B --> C("Program kodlarını<br/>kılavuzdan tek tek ekle")
|
||||||
|
C --> D("Satırları İSTEK sırana<br/>göre diz")
|
||||||
|
D --> E("Son kontrolü yap") --> F("Tercihleri Bildir<br/>butonuna bas")
|
||||||
|
F --> G{"Ekranda 'Tercihleriniz<br/>ÖSYM'ye bildirilmiştir'<br/>uyarısı var mı?"}
|
||||||
|
G -- "evet" --> H("İşlem tamam:<br/>ekran görüntüsü al")
|
||||||
|
G -- "hayır" --> I("Tercihler GEÇERSİZ:<br/>bildirme adımını<br/>tamamla")
|
||||||
|
H --> J{"Son güne kadar<br/>değişiklik yaptın mı?"}
|
||||||
|
J -- "evet" --> F
|
||||||
|
J -- "hayır" --> K("10 Ağustos 23.59'da<br/>liste kilitlenir")
|
||||||
|
|
||||||
|
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,G,J uyari
|
||||||
|
class H,K garanti
|
||||||
|
class I hayal
|
||||||
|
```
|
||||||
|
|
||||||
|
**1) Giriş.** ais.osym.gov.tr adresine T.C. kimlik numaran ve aday şifrenle gir. Şifren yoksa/unuttuysan hemen yenile — bunu son güne bırakmak en gereksiz risk.
|
||||||
|
|
||||||
|
**2) Tercih ekranı.** Açılan sayfada "2026-YKS Tercih" seçeneğine tıkla.
|
||||||
|
|
||||||
|
**3) Kodları ekle.** Her programın kılavuzda ve [tercih robotu](/tercih-robotu) çıktısında görünen bir **program kodu** var. Kodu ekleyince ekranda çıkan program adını ve üniversiteyi kendi listenle karşılaştır — tek rakam farkı seni başka şehre, başka bölüme yazar.
|
||||||
|
|
||||||
|
**4) İstek sırana göre diz.** Merkezi yerleştirme listeyi yukarıdan aşağı okur ve seni yerleştirebileceği **en üstteki** satıra yerleştirir ([sistem nasıl çalışır](/rehber/merkezi-yerlestirme-nasil-calisir)). Sıra yerleşme ihtimaline göre değil, *istemene* göre olmalı.
|
||||||
|
|
||||||
|
**5) Bildir ve doğrula.** "Tercihleri Bildir" butonuna bas ve ekranda **"2026-YKS Tercihleriniz ÖSYM'ye bildirilmiştir"** uyarısını gör. Bu uyarıyı görmeden kapattığın oturumda tercihlerin kaydedilmiş ama **bildirilmemiş** olabilir — bildirilmeyen tercih geçersizdir. Uyarı ekranının görüntüsünü al, sakla.
|
||||||
|
|
||||||
|
**6) Değişiklik yaptıysan yeniden bildir.** Son tarihe kadar listeyi istediğin kadar güncelleyebilirsin; ama her değişiklikten sonra bildirme adımı **yeniden** yapılır. Sistemdeki geçerli liste, en son "bildirilmiştir" uyarısını aldığın listedir.
|
||||||
|
|
||||||
|
## Göndermeden önce: 5 maddelik son kontrol
|
||||||
|
|
||||||
|
1. **Her satırın koşulunu okudun mu?** Yaş, cinsiyet, sağlık raporu gibi [özel koşullar](/rehber/ozel-kosullu-programlar) ve [başarı sırası şartları](/rehber/siralama-sartlari-2026) sağlanmıyorsa o satır ya boşa gider ya da yerleşsen bile kayıtta yanar.
|
||||||
|
2. **Üst sıralarda ölü tercih var mı?** Tabanı senden çok iyi programı listenin *sonuna* yazmak satırı çöpe atar ([ölü tercih nedir](/rehber/olu-tercih-nedir)).
|
||||||
|
3. **Listenin sonu gerçekten garanti mi?** Son 4-6 satır, yerleşirsen kayıt yaptıracağın programlar olmalı ([hayal/dengeli/garanti dağılımı](/rehber/tercih-listesi-nasil-yapilir)).
|
||||||
|
4. **Kayıt yaptırmayacağın satır var mı?** Varsa sil: oraya yerleşirsen hem yılın gider hem [ek yerleştirmeye](/rehber/ek-yerlestirme-2026) başvuramazsın.
|
||||||
|
5. **Kodları tek tek doğruladın mı?** Ekrandaki program adı + üniversite + şehir üçlüsünü kendi listenle satır satır karşılaştır.
|
||||||
|
|
||||||
|
Listen henüz oturmadıysa önce evreni gör: [tercih robotuna](/tercih-robotu) sıralamanı gir, sıralamana düşen programları hayal/dengeli/garanti dilimleriyle çıkar, sonra AİS'e kodları bu listeden aktar. AİS'te düşünülmez — AİS'e *bitmiş* liste girilir.
|
||||||
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.
|
||||||
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.
|
||||||
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.
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
---
|
||||||
|
baslik: Tercih Robotu Nedir? YÖK Atlas Tercih Sihirbazı Nasıl Kullanılır?
|
||||||
|
aciklama: Tercih robotu neyi çözer, neyi çözemez? YÖK Atlas'ı adım adım kullanma rehberi ve robota liste teslim etmeden önce bilmen gereken dört tuzak — tek şemada.
|
||||||
|
tarih: 2026-08-06
|
||||||
|
---
|
||||||
|
|
||||||
|
Tercih döneminin en çok aranan araçlarından biri "tercih robotu". Ama iki farklı şey aynı adla anılıyor: YÖK'ün resmî veri ekranı **YÖK Atlas tercih sihirbazı** ve sıralamanı girince sana program listeleyen **tercih robotları**. Bu yazı ikisinin de ne işe yaradığını, YÖK Atlas'ın adım adım nasıl kullanıldığını ve bir robotun çıktısını hangi süzgeçten geçirmen gerektiğini anlatıyor.
|
||||||
|
|
||||||
|
## Tercih robotu ne yapar?
|
||||||
|
|
||||||
|
Özünde her tercih robotu aynı işi yapar: **başarı sıralamanı** alır, geçmiş yılların taban sıralamalarıyla karşılaştırır ve 23.000'i aşkın programın içinden senin bandına düşenleri süzer. Elle günler süren tarama işini saniyelere indirir. İyi bir robot bunun üstüne üç şey daha katar:
|
||||||
|
|
||||||
|
- Programları tek yılın tabanına göre değil, **çok yıllı trende** göre dilimlemek ([tabanlar yıldan yıla nasıl oynuyor](/rehber/taban-siralamalar-nasil-degisir)),
|
||||||
|
- Sonuçları **hayal / dengeli / garanti** mantığıyla ayırmak ([listenin üç bölgesi](/rehber/tercih-listesi-nasil-yapilir)),
|
||||||
|
- Kılavuzdaki [özel koşulları ve sıralama şartlarını](/rehber/siralama-sartlari-2026) süzgece dahil etmek.
|
||||||
|
|
||||||
|
Robotun yapamayacağı şey ise senin yerine **istemek**: hangi bölümü gerçekten okuyacağını, hangi şehirde dört yıl yaşayabileceğini, bütçenin neye yettiğini robot bilemez. Robot evreni daraltır; sırayı sen kurarsın.
|
||||||
|
|
||||||
|
## YÖK Atlas tercih sihirbazı adım adım
|
||||||
|
|
||||||
|
YÖK Atlas (yokatlas.yok.gov.tr), YÖK'ün resmî yerleştirme verilerini açtığı ücretsiz ekrandır. Kullanımı:
|
||||||
|
|
||||||
|
1. **Düzeyini seç.** 4 yıllık için "Lisans Tercih Sihirbazı", 2 yıllık için "Önlisans Tercih Sihirbazı" ([önlisans mı lisans mı](/rehber/onlisans-mi-lisans-mi)).
|
||||||
|
2. **Puan türünü ve sıralamanı gir.** SAY, EA, SÖZ, DİL ya da TYT — ve puanını değil, **başarı sıralamanı** ([neden sıralama](/rehber/taban-puan-mi-siralama-mi)).
|
||||||
|
3. **Filtrele.** Şehir, devlet/vakıf, öğretim türü, burs oranı gibi filtrelerle evreni daralt.
|
||||||
|
4. **Program sayfalarına in.** Her programın sayfasında kontenjan, taban, yerleşenlerin sıralama aralığı, akademik kadro ve doluluk geçmişi var. Asıl hazine listede değil, bu detay sayfalarındadır.
|
||||||
|
|
||||||
|
## Robot çıktısını okurken dört tuzak
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
%% aria: Tercih robotu kullanım akışı şeması. Doğru akış şöyledir: başarı sıralamanı girersin, filtrelerle aday havuzunu daraltırsın, robot programları hayal, dengeli ve garanti dilimlerine ayırır, sonra son kontrol sana geçer: kılavuz koşullarını, çok yıllı taban trendini ve kendi isteklerini kendin doğrularsın; bu kontrol sarı ile işaretlidir çünkü atlanması en sık hatadır. Akışın dışında dört tuzak kutusu kırmızı ile işaretlidir: puanla arama yapmak, tek yılın tabanına güvenmek, listeyi robotun verdiği sırayla göndermek ve kılavuz koşullarını hiç okumamak. Bu dört hatadan herhangi biri listeyi çürütür. Son kontrolden geçen liste yeşil ile işaretlenmiş sağlam liste kutusuna ulaşır.
|
||||||
|
%% altyazi: Robot evreni daraltır, sırayı sen kurarsın — son kontrol devredilemez.
|
||||||
|
flowchart TD
|
||||||
|
A("Başarı sıralamanı gir<br/>puanı değil") --> B("Filtrele:<br/>şehir, devlet/vakıf,<br/>öğretim dili")
|
||||||
|
B --> C("Robot dilimler:<br/>hayal / dengeli / garanti")
|
||||||
|
C --> D{"Son kontrol sende:<br/>kılavuz koşulları,<br/>çok yıllı trend,<br/>gerçek isteklerin"}
|
||||||
|
D -- "kontrolü yaptın" --> E("Sağlam liste:<br/>her satırın gerekçesi var")
|
||||||
|
D -- "atladın" --> F("Tuzaklar devrede")
|
||||||
|
T1("Puanla arama") -.-> F
|
||||||
|
T2("Tek yılın tabanına<br/>güvenmek") -.-> F
|
||||||
|
T3("Robotun sırasını<br/>aynen göndermek") -.-> F
|
||||||
|
T4("Kılavuz koşullarını<br/>okumamak") -.-> F
|
||||||
|
|
||||||
|
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 D uyari
|
||||||
|
class E garanti
|
||||||
|
class F,T1,T2,T3,T4 hayal
|
||||||
|
```
|
||||||
|
|
||||||
|
**1) Puanla arama yapmak.** Puanlar sınavın zorluğuyla her yıl kayar; aynı puan bir yıl 80 bininci, ertesi yıl 120 bininci sıraya denk gelir. Robota da YÖK Atlas'a da **sıralamanı** ver.
|
||||||
|
|
||||||
|
**2) Tek yılın tabanına güvenmek.** Geçen yılın tabanı tek başına bir fotoğraftır, film değil. Kontenjanı artan program gevşer, kapanan varyantın talebi komşusuna taşar. En az son 3-4 yılın trendine bakmayan çıktı eksiktir.
|
||||||
|
|
||||||
|
**3) Robotun sırasını aynen göndermek.** Robotlar genelde yerleşme ihtimaline göre sıralar; oysa merkezi yerleştirmede liste **istek sırana** göre dizilir ([sistem nasıl çalışır](/rehber/merkezi-yerlestirme-nasil-calisir)). İhtimal sırasıyla gönderilen liste, üst sıralara [ölü tercih](/rehber/olu-tercih-nedir) gömer.
|
||||||
|
|
||||||
|
**4) Kılavuz koşullarını okumamak.** Yaş şartı, cinsiyet şartı, sağlık raporu, zorunlu hizmet gibi [özel koşullar](/rehber/ozel-kosullu-programlar) robota her zaman tam yansımaz. Yazacağın her satırın kılavuz koşul maddelerini bir kez kendin oku.
|
||||||
|
|
||||||
|
## YÖK Atlas mı, tercih robotu mu?
|
||||||
|
|
||||||
|
İkisi rakip değil, ardışık: robot tarama işini saniyeye indirir, YÖK Atlas tek programın derinine iner. Verimli akış şöyle: [tercih robotuna](/tercih-robotu) sıralamanı gir, hayal/dengeli/garanti dilimlerine ayrılmış aday havuzunu çıkar; sonra listende ciddi yer verdiğin programların YÖK Atlas sayfalarını açıp kadro, doluluk geçmişi ve koşulları tek tek doğrula. Robotla dakikada evreni gör, Atlas'la satır satır emin ol — liste ikisinin kesişiminden çıkar.
|
||||||
63
content/rehber/tercih-sonuclari-ne-zaman-2026.md
Normal file
63
content/rehber/tercih-sonuclari-ne-zaman-2026.md
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
---
|
||||||
|
baslik: 2026 YKS Tercih Sonuçları Ne Zaman Açıklanacak? (Sonuç Ekranı Rehberi)
|
||||||
|
aciklama: Yerleştirme sonuçları için beklenen tarih aralığı, sonucun nereden sorgulanacağı ve ekranı gördüğün anda yapman gerekenler — iki senaryo tek şemada.
|
||||||
|
tarih: 2026-08-07
|
||||||
|
---
|
||||||
|
|
||||||
|
Tercihler 10 Ağustos 23.59'da kapandı mı, arama motorlarındaki soru değişir: **"Tercih sonuçları ne zaman açıklanacak?"** Bu yazı üç şeyi netleştiriyor: sonuçların beklendiği tarih aralığı, sonucu nereden öğreneceğin ve ekranı gördüğün anda — yerleşmiş ya da yerleşememiş olarak — atman gereken adımlar.
|
||||||
|
|
||||||
|
## Ne zaman açıklanacak?
|
||||||
|
|
||||||
|
ÖSYM, yerleştirme sonuçları için gün ilan etmez; sonuçlar hazır olduğunda duyuruyla erişime açılır. Ama takvimin iki ucu belli olduğu için aralığı daraltmak kolay:
|
||||||
|
|
||||||
|
- Tercihler **10 Ağustos 2026** gecesi kapandı.
|
||||||
|
- Üniversite kayıtları **24-28 Ağustos** tarihlerinde yapılacak (e-kayıt 24-26 Ağustos).
|
||||||
|
|
||||||
|
Kayıt 24 Ağustos'ta başladığına göre sonuçların **ondan önce, Ağustos'un ikinci yarısında** açıklanması gerekiyor. Geçmiş yıllarda da yerleştirme sonuçları tercih bitiminden yaklaşık 1,5-2 hafta sonra geldi. Yani beklenen pencere dar: **15-23 Ağustos arası.** Kesin gün için tek resmî kaynak ÖSYM duyurusudur; takvimin tamamı [2026 tercih takvimi](/rehber/yks-tercih-takvimi-2026) yazısında.
|
||||||
|
|
||||||
|
## Nereden öğreneceksin?
|
||||||
|
|
||||||
|
Sonuç açıklandığında iki resmî kanaldan sorgulayabilirsin:
|
||||||
|
|
||||||
|
- **sonuc.osym.gov.tr** — T.C. kimlik numaran ve aday şifrenle.
|
||||||
|
- **ÖSYM Aday İşlemleri Mobil Uygulaması** — aynı girişle telefonda.
|
||||||
|
|
||||||
|
Şifreni unuttuysan bugünden yenile; sonuç günü şifre sıfırlama ekranları yoğunluktan yavaşlar. Sonuç belgesinde yerleştiğin programın adı, kodu ve yerleştirme puanın yazar; hiçbir tercihine yerleşemediysen bunu da açıkça belirtir.
|
||||||
|
|
||||||
|
## Ekranı gördüğün an: iki senaryo
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
%% aria: Yerleştirme sonucu açıklandıktan sonraki akış şeması. Sonuç ekranında iki ihtimal vardır. Bir programa yerleştiysen önce yerleştiğin satırın hangi tercihin olduğuna bakarsın; ardından kayıt penceresini kaçırmaman gerekir: 24 ile 26 Ağustos arasında e-Devlet üzerinden belgesiz e-kayıt, 24 ile 28 Ağustos arasında üniversitede şahsen kayıt yapılabilir. Kayıt yaptırırsan öğrencilik başlar. Kayıt yaptırmazsan bu yılki hakkın yanar ve yerleştiğin için ek yerleştirmeye de başvuramazsın; bu kutu kırmızı ile işaretlidir. Hiçbir programa yerleşemediysen yol kapanmaz: Eylül'de açılan ek yerleştirme dönemine yalnızca boşta kalanlar başvurabilir ve bu ikinci şans yeşil ile işaretlidir. Ek yerleştirme hazırlığına sonuç gününden itibaren başlamak gerekir.
|
||||||
|
%% altyazi: Yerleşenin tek riski kaydı kaçırmak; yerleşemeyenin tek işi ek yerleştirmeye hazırlanmak.
|
||||||
|
flowchart TD
|
||||||
|
S("Sonuç ekranı açıldı") --> Q{"Bir programa<br/>yerleştin mi?"}
|
||||||
|
Q -- "evet" --> Y("Yerleştiğin satırı oku:<br/>kaçıncı tercihindi,<br/>program kodu ne")
|
||||||
|
Y --> K{"Kayıt penceresi:<br/>e-kayıt 24-26 Ağustos<br/>şahsen 24-28 Ağustos"}
|
||||||
|
K -- "kayıt yaptırdın" --> O("Öğrenciliğin başladı")
|
||||||
|
K -- "kayıt yaptırmadın" --> H("Bu yılki hak yandı:<br/>ek yerleştirmeye de<br/>başvuramazsın")
|
||||||
|
Q -- "hayır" --> E("Ek yerleştirme · Eylül<br/>yalnızca boşta kalanlar<br/>başvurabilir")
|
||||||
|
E --> P("Hazırlığa bugün başla:<br/>sıralamana uygun<br/>alternatif havuzu çıkar")
|
||||||
|
|
||||||
|
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,E garanti
|
||||||
|
class K,P uyari
|
||||||
|
class H hayal
|
||||||
|
```
|
||||||
|
|
||||||
|
## Yerleştiysen: tek işin kaydı kaçırmamak
|
||||||
|
|
||||||
|
Tebrikler — ama süreç kayıtla biter, sonuç ekranıyla değil. Üniversitelerin çoğu e-Devlet üzerinden **belgesiz, yolsuz, sırasız e-kayıt** kabul ediyor; penceresi şahsen kayıttan iki gün önce kapanıyor. Hangi belgelerin gerektiği, e-kaydın adım adım nasıl yapıldığı ve "kayıt yaptırmazsam ne olur" sorusunun net cevabı [üniversite kayıt rehberinde](/rehber/universite-kayit-rehberi-2026).
|
||||||
|
|
||||||
|
Kritik uyarı: **yerleştiğin programa kayıt yaptırmazsan ek yerleştirmeye başvuramazsın** ve gelecek yıl OBP katkın yarıya düşer ([OBP nasıl hesaplanır](/rehber/obp-nedir-nasil-hesaplanir)). "Beğenmedim, ek tercihte daha iyisini yazarım" planı bu yüzden çalışmaz — ek yerleştirme kapısı yalnızca **hiçbir programa yerleşememiş** adaylara açıktır.
|
||||||
|
|
||||||
|
## Yerleşemediysen: oyun bitmedi, ikinci yarı Eylül'de
|
||||||
|
|
||||||
|
Hiçbir tercihe yerleşememek yılın sonu değil. Kayıtlar kapandığında binlerce kontenjan boş kalır ve ÖSYM bunlar için **ek yerleştirme** açar — genellikle Eylül içinde, birkaç günlük dar bir pencereyle. Kimlerin başvurabileceği ve ikinci şansı doğru kullanmanın yolu [ek yerleştirme rehberinde](/rehber/ek-yerlestirme-2026); boşta kalmanın tipik sebepleri ve her birinin çıkış yolu ise [Yerleşemedim, ne yapmalıyım?](/rehber/yerlesemedim-ne-yapmaliyim) yazısında.
|
||||||
|
|
||||||
|
Ek yerleştirmede en büyük hata kılavuzu bekleyip son gün panikle liste kurmak. Boş kontenjan kılavuzu yayımlanmadan da hazırlanabilirsin: sıralamana uygun program evrenini şimdiden çıkarır, kılavuz gelince yalnızca kesişimi alırsın.
|
||||||
|
|
||||||
|
## Beklerken yapılacak en akıllıca iş
|
||||||
|
|
||||||
|
Sonuç ekranı açılana kadar elindeki en değerli veri hâlâ **başarı sıralaman**. Hangi senaryoda olursan ol — ek yerleştirme ihtimaline hazırlık ya da sadece "nereye yerleşmiş olabilirim" merakı — [tercih robotuna](/tercih-robotu) sıralamanı girerek sıralamana düşen programları hayal/dengeli/garanti dilimleriyle görebilirsin. Sonuç günü geldiğinde sürpriz değil, doğrulama yaşarsın.
|
||||||
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.
|
||||||
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: .
|
build: .
|
||||||
container_name: kolaytercih
|
container_name: kolaytercih
|
||||||
restart: unless-stopped
|
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:
|
volumes:
|
||||||
- app-data:/app/data
|
- app-data:/app/data
|
||||||
env_file:
|
env_file:
|
||||||
|
|||||||
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";
|
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 = {
|
const nextConfig: NextConfig = {
|
||||||
// PPR: statik kabuk anında servis edilir, dinamik parçalar (UserNav vb.)
|
// 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.
|
// Suspense sınırlarının içinde akar. Bkz. docs/cacheComponents.md.
|
||||||
cacheComponents: true,
|
cacheComponents: true,
|
||||||
output: "standalone",
|
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() {
|
async redirects() {
|
||||||
// Eski akış /sonuc modalına taşındı. /rapor/yazdir korunur (tam eşleşme).
|
// Eski akış /sonuc modalına taşındı. /rapor/yazdir korunur (tam eşleşme).
|
||||||
return [
|
return [
|
||||||
|
|||||||
12500
package-lock.json
generated
12500
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
11
package.json
11
package.json
@@ -10,15 +10,16 @@
|
|||||||
"ingest": "tsx scripts/ingest.ts",
|
"ingest": "tsx scripts/ingest.ts",
|
||||||
"refresh": "tsx scripts/refresh.ts",
|
"refresh": "tsx scripts/refresh.ts",
|
||||||
"detay": "tsx scripts/detay.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": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sdk": "^0.112.4",
|
|
||||||
"@cds/city": "^1.1.0",
|
"@cds/city": "^1.1.0",
|
||||||
"@google/genai": "^2.15.0",
|
|
||||||
"@libsql/client": "^0.17.4",
|
"@libsql/client": "^0.17.4",
|
||||||
"@number-flow/react": "^0.6.2",
|
"@number-flow/react": "^0.6.2",
|
||||||
"@react-pdf/renderer": "^4.5.1",
|
|
||||||
"better-auth": "^1.6.23",
|
"better-auth": "^1.6.23",
|
||||||
"better-sqlite3": "^12.11.1",
|
"better-sqlite3": "^12.11.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
@@ -32,6 +33,7 @@
|
|||||||
"motion": "^12.42.2",
|
"motion": "^12.42.2",
|
||||||
"next": "16.2.10",
|
"next": "16.2.10",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
"openai": "^7.4.0",
|
||||||
"radix-ui": "^1.6.3",
|
"radix-ui": "^1.6.3",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
@@ -41,7 +43,6 @@
|
|||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"resend": "^6.18.0",
|
"resend": "^6.18.0",
|
||||||
"shiki": "^4.3.1",
|
"shiki": "^4.3.1",
|
||||||
"simple-icons": "^16.27.1",
|
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"turkey-map-react": "^2.0.5",
|
"turkey-map-react": "^2.0.5",
|
||||||
|
|||||||
752
pnpm-lock.yaml
generated
752
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
29
public/odeme/iyzico-ile-ode.svg
Normal file
29
public/odeme/iyzico-ile-ode.svg
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg width="210px" height="31px" viewBox="0 0 210 31" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
|
<title>iyzico_ile_ode_colored_horizontal</title>
|
||||||
|
<defs>
|
||||||
|
<linearGradient x1="90.2554899%" y1="50%" x2="0%" y2="50%" id="linearGradient-1">
|
||||||
|
<stop stop-color="#1E64FF" offset="0%"></stop>
|
||||||
|
<stop stop-color="#1E64FF" stop-opacity="0" offset="100%"></stop>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<g id="05---Landings" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||||
|
<g id="004.0.0-Brand-Kit" transform="translate(-1015.000000, -1867.000000)">
|
||||||
|
<g id="Group-2-Copy" transform="translate(135.000000, 1674.000000)">
|
||||||
|
<g id="Group-4-Copy-3" transform="translate(800.000000, 83.000000)">
|
||||||
|
<g id="Group" transform="translate(80.000000, 86.000000)">
|
||||||
|
<g id="iyzico-ile-Ode_OneLine_TR" transform="translate(0.000000, 24.452381)">
|
||||||
|
<g id="Group-3" transform="translate(-0.000000, 3.761905)" fill="url(#linearGradient-1)">
|
||||||
|
<path d="M2.8125,0 C1.25925,0 0,1.26299683 0,2.82142857 L0,19.75 C0,21.3084317 1.25925,22.5714286 2.8125,22.5714286 L24.0455,22.5714286 C25.09425,22.5714286 26.05575,21.986327 26.54,21.052873 L30.9325,12.5888381 C31.1405,12.1875683 31.24625,11.7489302 31.25,11.3097905 L31.25,11.2616381 C31.24625,10.8224984 31.1405,10.3838603 30.9325,9.98284127 L26.54,1.51855556 C26.05575,0.585352381 25.09425,0 24.0455,0 L2.8125,0 Z" id="Fill-1"></path>
|
||||||
|
</g>
|
||||||
|
<g id="Group-21" transform="translate(39.814286, 0.000000)" fill="#1E64FF">
|
||||||
|
<path d="M20.3121807,8.01261426 C20.8578941,6.95906777 22.1521777,6.54811119 23.2041402,7.09665045 C24.2564599,7.64626456 24.6648521,8.94595505 24.116103,10.0014721 L24.116103,10.0014721 L14.334155,28.9375416 C13.9659413,29.6455191 13.2602282,30.0614917 12.5195152,30.0926628 C12.1554086,30.1105772 11.7832664,30.0355158 11.4379098,29.8522514 C10.3857687,29.3044287 9.97773368,28.0047382 10.5248756,26.949042 L10.5248756,26.949042 L13.2909424,21.5890503 L7.30005957,10.0014721 C6.75291762,8.94595505 7.16380979,7.64626456 8.2147009,7.09665045 C9.26541343,6.54811119 10.562197,6.95906777 11.1070175,8.01261426 L11.1070175,8.01261426 L15.7093313,16.9135683 Z M59.6828651,6.56683175 C62.0035755,6.56683175 64.185179,7.47330093 65.8267835,9.12035184 C66.6614249,9.96143342 66.6614249,11.3241826 65.8267835,12.1654434 C64.9873206,13.0040169 63.6285728,13.0040169 62.7914314,12.1654434 C61.959647,11.3306318 60.8589346,10.8734561 59.6828651,10.8734561 C58.5100099,10.8734561 57.4075118,11.3306318 56.5780489,12.1654434 C55.7476931,12.9948806 55.2914439,14.1028869 55.2914439,15.2809385 C55.2914439,16.4579153 55.7476931,17.5646676 56.5780489,18.3978668 C57.4075118,19.2276623 58.5100099,19.6914664 59.6828651,19.6914664 C60.8589346,19.6914664 61.959647,19.2276623 62.7914314,18.3978668 C63.6285728,17.5558895 64.9873206,17.5558895 65.8267835,18.3978668 C66.6614249,19.238411 66.6614249,20.6020559 65.8267835,21.4391963 C64.185179,23.0866055 62.0035755,23.9945078 59.6828651,23.9945078 C57.3650118,23.9945078 55.1844798,23.0866055 53.5441254,21.4391963 C51.9035924,19.7971614 50.9994867,17.6073039 50.9994867,15.2809385 C50.9994867,12.9520651 51.9035924,10.7674028 53.5441254,9.12035184 C55.1844798,7.47330093 57.3650118,6.56683175 59.6828651,6.56683175 Z M77.0293898,6.56677801 C81.8183104,6.56677801 85.7141967,10.4755233 85.7141967,15.2808848 C85.7141967,20.0853505 81.8183104,23.9946332 77.0293898,23.9946332 C72.2424335,23.9946332 68.3470828,20.0853505 68.3470828,15.2808848 C68.3470828,10.4755233 72.2424335,6.56677801 77.0293898,6.56677801 Z M2.36954993,6.85444761 C3.55436938,6.85444761 4.5149035,7.81824291 4.5149035,9.0038186 L4.5149035,9.0038186 L4.5149035,21.5929915 C4.5149035,22.7826875 3.55436938,23.7439748 2.36954993,23.7439748 C1.18473047,23.7439748 0.223839218,22.7826875 0.223839218,21.5929915 L0.223839218,21.5929915 L0.223839218,9.0038186 C0.223839218,7.81824291 1.18473047,6.85444761 2.36954993,6.85444761 Z M38.4392951,6.85393075 C38.4637799,6.85397691 38.4845421,6.85402542 38.5013999,6.85407635 L38.5542396,6.85441179 C39.0669173,6.83685566 39.584595,7.00095166 40.0072729,7.35816501 C40.9137,8.11827345 41.0322712,9.47815636 40.2722725,10.387671 L40.2722725,10.387671 L32.6990708,19.4394644 L38.6254895,19.4394644 C39.8112018,19.4394644 40.7731645,20.4057677 40.7731645,21.5929557 C40.7731645,22.7828308 39.8112018,23.743939 38.6254895,23.743939 L38.6254895,23.743939 L28.0990785,23.743939 C27.6122936,23.743939 27.1215801,23.5814553 26.7197951,23.2403649 C25.813368,22.4781067 25.6937253,21.1218067 26.4560455,20.2090675 L26.4560455,20.2090675 L34.0279972,11.1603195 L29.0778268,11.1603195 C27.8915788,11.1603195 26.9324733,10.1940162 26.9324733,9.00396191 C26.9324733,7.81820708 27.8915788,6.85441179 29.0778268,6.85441179 L29.0778268,6.85441179 L30.3890569,6.85405101 C30.4611906,6.85403248 30.535603,6.85401357 30.6121121,6.85399436 Z M46.0431021,6.85444761 C47.230243,6.85444761 48.1884557,7.81824291 48.1884557,9.0038186 L48.1884557,9.0038186 L48.1884557,21.5929915 C48.1884557,22.7826875 47.230243,23.7439748 46.0431021,23.7439748 C44.8593541,23.7439748 43.9002486,22.7826875 43.9002486,21.5929915 L43.9002486,21.5929915 L43.9002486,9.0038186 C43.9002486,7.81824291 44.8593541,6.85444761 46.0431021,6.85444761 Z M77.0293898,10.8734023 C74.6077867,10.8734023 72.63904,12.8500784 72.63904,15.2808848 C72.63904,17.7109746 74.6077867,19.6914127 77.0293898,19.6914127 C79.4526,19.6914127 81.4242039,17.7109746 81.4242039,15.2808848 C81.4242039,12.8500784 79.4526,10.8734023 77.0293898,10.8734023 Z M2.36954993,-2.34732868e-15 C3.67776203,-2.34732868e-15 4.74008169,1.06519085 4.74008169,2.37921287 C4.74008169,3.69054773 3.67776203,4.75770917 2.36954993,4.75770917 C1.06133782,4.75770917 -8.89798461e-05,3.69054773 -8.89798461e-05,2.37921287 C-8.89798461e-05,1.06519085 1.06133782,-2.34732868e-15 2.36954993,-2.34732868e-15 Z M46.0431021,-2.34732868e-15 C47.3538142,-2.34732868e-15 48.4150625,1.06519085 48.4150625,2.37921287 C48.4150625,3.69054773 47.3538142,4.75770917 46.0431021,4.75770917 C44.7334615,4.75770917 43.6722132,3.69054773 43.6722132,2.37921287 C43.6722132,1.06519085 44.7334615,-2.34732868e-15 46.0431021,-2.34732868e-15 Z" id="Combined-Shape"></path>
|
||||||
|
</g>
|
||||||
|
<path d="M171.256395,5.40878903 C175.631543,5.40878903 179.956126,9.08690718 179.956126,14.4030587 C179.956126,19.7192102 175.631543,23.3954641 171.256395,23.3954641 C166.881864,23.3954641 162.557281,19.7192102 162.557281,14.4030587 C162.557281,9.08690718 166.881864,5.40878903 171.256395,5.40878903 Z M193.107162,5.23261966 C193.831728,5.23261966 194.457014,5.83787174 194.457014,6.56802698 L194.457014,22.0355112 C194.457014,22.7669092 193.831728,23.3709185 193.107162,23.3709185 C192.381979,23.3709185 191.781976,22.7669092 191.781976,22.0355112 L191.781976,20.6752475 C191.607463,21.808697 190.056583,23.3951534 187.681855,23.3951534 C184.907536,23.3951534 181.583163,21.2289227 181.583163,16.6708898 C181.583163,12.1855617 184.932202,9.96899684 187.681855,9.96899684 C189.531811,9.96899684 191.132024,10.9272091 191.781976,12.3868981 L191.781976,11.3795946 C191.781976,11.3044042 191.781976,11.2031145 191.807259,11.1279241 L191.807259,6.56802698 C191.807259,5.83787174 192.407878,5.23261966 193.107162,5.23261966 Z M135.174825,10.0454301 C135.724262,10.0454301 136.199084,10.5239148 136.199084,11.1036891 L136.199084,22.2871817 C136.199084,22.8930552 135.724262,23.3709185 135.174825,23.3709185 C134.600105,23.3709185 134.124666,22.8930552 134.124666,22.2871817 L134.124666,11.1036891 C134.124666,10.5239148 134.600105,10.0454301 135.174825,10.0454301 Z M140.024302,5.23274394 C140.574973,5.23274394 141.049795,5.71247146 141.049795,6.26614662 L141.049795,22.287306 C141.049795,22.8416026 140.574973,23.3449436 140.024302,23.3449436 C139.448966,23.3449436 138.97476,22.8416026 138.97476,22.287306 L138.97476,6.26614662 C138.97476,5.71247146 139.448966,5.23274394 140.024302,5.23274394 Z M149.70007,9.96912113 C152.549621,9.96912113 155.973274,12.2366415 155.973274,16.3180539 C155.973274,17.1227782 155.498452,17.5018375 154.649322,17.5018375 L145.450103,17.4515034 C145.674564,20.2726989 147.824831,21.6074849 149.974481,21.6074849 C151.123921,21.6074849 152.048899,21.2800025 152.873363,20.6492726 C153.248904,20.3982235 153.473983,20.2726989 153.748393,20.2726989 C154.224448,20.2726989 154.523525,20.6001813 154.523525,21.0531883 C154.523525,21.3303366 154.398961,21.6074849 153.973471,21.9853014 C152.973877,22.8664589 151.500078,23.3449436 149.923915,23.3449436 C146.724106,23.3449436 143.300453,21.229047 143.300453,16.695249 C143.300453,12.1614511 146.849903,9.96912113 149.70007,9.96912113 Z M203.182706,9.96899684 C206.032256,9.96899684 209.457143,12.2365172 209.457143,16.2414964 C209.457143,17.274899 208.881806,17.6781933 207.806981,17.6781933 L199.432843,17.6520941 C199.707253,19.994805 201.532544,21.1543536 203.532347,21.1543536 C204.55784,21.1543536 205.257741,20.9026831 206.032256,20.4241984 C206.457129,20.1973842 206.732156,20.0209041 207.081798,20.0209041 C207.657135,20.0209041 208.007393,20.4241984 208.007393,20.9778736 C208.007393,21.3059773 207.882213,21.6583161 207.332159,22.1107016 C206.382514,22.8930552 205.006763,23.3448193 203.431833,23.3448193 C200.183309,23.3448193 196.708473,21.2550218 196.708473,16.6951247 C196.708473,12.1613268 200.333155,9.96899684 203.182706,9.96899684 Z M188.107345,12.2607522 C186.28267,12.2607522 184.232301,13.6980705 184.232301,16.6708898 C184.232301,19.667944 186.28267,21.1033981 188.107345,21.1033981 C189.807455,21.1033981 191.907156,19.7934685 191.907156,16.6708898 C191.907156,13.5215904 189.807455,12.2607522 188.107345,12.2607522 Z M171.256395,7.97893545 C168.206433,7.97893545 165.382165,10.5745596 165.382165,14.4030587 C165.382165,18.2315578 168.206433,20.8010828 171.256395,20.8010828 C174.306357,20.8010828 177.131242,18.2315578 177.131242,14.4030587 C177.131242,10.5745596 174.331024,7.97893545 171.256395,7.97893545 Z M149.70007,11.8091123 C147.849497,11.8091123 145.674564,13.067465 145.450103,15.8886605 L153.948805,15.8886605 C153.724343,13.067465 151.574077,11.8091123 149.70007,11.8091123 Z M203.182706,12.2868513 C201.532544,12.2868513 199.657921,13.3451104 199.432843,15.7381553 L206.882003,15.7381553 C206.657541,13.3451104 204.85815,12.2868513 203.182706,12.2868513 Z M135.174825,4.98094913 C135.724262,4.98094913 136.224367,5.45943384 136.224367,6.03920816 C136.224367,6.61836108 135.724262,7.0974672 135.174825,7.0974672 C134.600105,7.0974672 134.1,6.61836108 134.1,6.03920816 C134.1,5.45943384 134.600105,4.98094913 135.174825,4.98094913 Z M174.331024,0.824035565 C175.130822,0.824035565 175.780773,1.47900034 175.780773,2.26135391 C175.780773,3.04184325 175.130822,3.69680803 174.331024,3.69680803 C173.531225,3.69680803 172.881274,3.04184325 172.881274,2.26135391 C172.881274,1.47900034 173.531225,0.824035565 174.331024,0.824035565 Z M168.182383,0.824035565 C168.981565,0.824035565 169.6309,1.47900034 169.6309,2.26135391 C169.6309,3.04184325 168.981565,3.69680803 168.182383,3.69680803 C167.381969,3.69680803 166.732017,3.04184325 166.732017,2.26135391 C166.732017,1.47900034 167.381969,0.824035565 168.182383,0.824035565 Z" id="Combined-Shape" fill="#495057"></path>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 11 KiB |
92
public/odeme/iyzico-logo-band.svg
Normal file
92
public/odeme/iyzico-logo-band.svg
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg width="429px" height="32px" viewBox="0 0 429 32" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
|
<title>logo-band</title>
|
||||||
|
<defs>
|
||||||
|
<radialGradient cx="17.5632448%" cy="17.5027381%" fx="17.5632448%" fy="17.5027381%" r="134.355667%" gradientTransform="translate(0.175632,0.175027),scale(0.694444,1.000000),translate(-0.175632,-0.175027)" id="radialGradient-1">
|
||||||
|
<stop stop-color="#9DD5F6" offset="0%"></stop>
|
||||||
|
<stop stop-color="#98D3F5" offset="7.11%"></stop>
|
||||||
|
<stop stop-color="#89CEF3" offset="15.75%"></stop>
|
||||||
|
<stop stop-color="#70C6EF" offset="25.16%"></stop>
|
||||||
|
<stop stop-color="#4EBBEA" offset="35.14%"></stop>
|
||||||
|
<stop stop-color="#23ADE3" offset="45.46%"></stop>
|
||||||
|
<stop stop-color="#0DA6E0" offset="50%"></stop>
|
||||||
|
<stop stop-color="#2E77BC" offset="100%"></stop>
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient x1="-0.000322580643%" y1="49.995%" x2="99.9996774%" y2="49.995%" id="linearGradient-2">
|
||||||
|
<stop stop-color="#1D1C45" offset="0%"></stop>
|
||||||
|
<stop stop-color="#174489" offset="100%"></stop>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient x1="90.2554899%" y1="50%" x2="0%" y2="50%" id="linearGradient-3">
|
||||||
|
<stop stop-color="#1E64FF" offset="0%"></stop>
|
||||||
|
<stop stop-color="#1E64FF" stop-opacity="0" offset="100%"></stop>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<g id="👉Method-View" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||||
|
<g id="PWI_Method_02_FooterAlternatives" transform="translate(-746.000000, -914.000000)">
|
||||||
|
<g id="logo-band-iyzicoileode" transform="translate(746.000000, 914.000000)">
|
||||||
|
<g id="Troy-Logo" transform="translate(370.000000, 3.000000)">
|
||||||
|
<g id="Group_1_" transform="translate(1.437077, 22.724340)" fill="#323E48">
|
||||||
|
<path d="M0.8,0.7 L0.4,0.7 C0.4,0.7 0.3,0.7 0.3,0.8 L0.3,0.9 L0.1,0.9 L0.1,0.7 C0.1,0.6 0.2,0.5 0.3,0.5 L1.6,0.5 C1.7,0.5 1.8,0.5 1.7,0.7 L1.7,0.9 L1.5,0.9 L1.5,0.8 C1.5,0.8 1.5,0.7 1.5,0.7 L1,0.7 L0.6,2.5 L0.4,2.5 L0.8,0.7 Z" id="Path_6_"></path>
|
||||||
|
<path d="M1.8,1.3 C1.8,1.3 1.7,1.2 1.8,1.3 L1.6,1.2 L1.6,1 L1.8,1 C1.9,1 2,1 1.9,1.2 L1.8,1.9 C1.8,2.1 1.8,2.3 2,2.3 C2.3,2.3 2.6,2 2.6,1.7 L2.7,1 L3,1 L2.7,2.2 C2.7,2.2 2.7,2.3 2.7,2.3 L2.8,2.3 L2.8,2.5 L2.7,2.5 C2.6,2.5 2.5,2.4 2.6,2.3 L2.6,2.2 C2.6,2.1 2.6,2.1 2.6,2.1 L2.6,2.1 C2.5,2.3 2.3,2.5 2,2.5 C1.6,2.5 1.5,2.3 1.6,2 L1.8,1.3 Z M2.2,0.5 L2.4,0.5 L2.3,0.8 L2.2,0.8 L2.2,0.5 Z M2.6,0.5 L2.8,0.5 L2.8,0.8 L2.6,0.8 L2.6,0.5 Z" id="Shape_5_" fill-rule="nonzero"></path>
|
||||||
|
<path d="M3.4,1.3 C3.4,1.3 3.4,1.2 3.4,1.3 L3.3,1.2 L3.3,1 L3.5,1 C3.6,1 3.7,1 3.6,1.2 L3.6,1.3 C3.6,1.4 3.6,1.4 3.6,1.4 L3.6,1.4 C3.7,1.2 3.9,1 4.1,1 C4.1,1 4.2,1 4.2,1 L4.2,1.2 C4.2,1.2 4.2,1.2 4.1,1.2 C3.9,1.2 3.7,1.4 3.6,1.6 C3.6,1.7 3.5,1.8 3.5,1.9 L3.4,2.5 L3.2,2.5 L3.4,1.3 Z" id="Path_7_"></path>
|
||||||
|
<path d="M4.5,0.8 C4.5,0.7 4.5,0.7 4.5,0.8 L4.3,0.7 L4.3,0.5 L4.5,0.5 C4.6,0.5 4.7,0.5 4.6,0.7 L4.5,1.6 L4.6,1.6 C4.7,1.6 4.7,1.6 4.8,1.5 L5.3,1 L5.5,1 L5,1.6 C4.9,1.7 4.9,1.7 4.9,1.7 L4.9,1.7 C4.9,1.7 4.9,1.7 5,1.8 L5.1,2.3 C5.1,2.3 5.1,2.4 5.2,2.4 L5.3,2.4 L5.3,2.6 L5.1,2.6 C5,2.6 4.9,2.6 4.9,2.5 L4.7,1.8 C4.7,1.7 4.6,1.7 4.6,1.7 L4.5,1.7 L4.3,2.5 L4.1,2.5 L4.5,0.8 Z" id="Path_8_"></path>
|
||||||
|
<path d="M6.1,1.3 C6.1,1.3 6.1,1.2 6,1.2 L5.7,1.2 L5.8,1 L6.3,1 C6.6,1 6.7,1 6.6,1.2 L6,2.2 C6,2.2 6,2.3 6.1,2.3 L6.4,2.3 L6.3,2.5 L5.9,2.5 C5.6,2.5 5.5,2.4 5.6,2.3 L6.1,1.3 Z M6.5,0.5 L6.9,0.5 L6.8,0.8 L6.4,0.8 L6.5,0.5 Z" id="Shape_6_" fill-rule="nonzero"></path>
|
||||||
|
<path d="M6.2,2.9 C6.3,2.9 6.4,2.8 6.5,2.7 L6.6,2.5 L6.4,1.3 C6.4,1.3 6.4,1.2 6.3,1.2 L6.3,1.2 L6.3,1 L6.4,1 C6.5,1 6.6,1 6.6,1.1 L6.8,2 C6.8,2.1 6.8,2.2 6.8,2.2 L6.8,2.2 C6.8,2.2 6.8,2.1 6.9,2 L7.4,1.1 C7.5,1 7.5,1 7.6,1 L7.7,1 L7.7,1.2 L7.7,1.2 C7.7,1.2 7.6,1.2 7.6,1.3 L6.6,2.8 C6.5,2.9 6.3,3 6.1,3 C5.9,3 5.9,2.9 5.9,2.9 L6,2.8 C6,2.8 6,2.9 6.2,2.9" id="Path_9_"></path>
|
||||||
|
<path d="M8.5,1 C8.9,1 9.1,1.3 9,1.7 C9,1.7 9,1.8 9,1.8 L7.9,1.8 C7.8,2.1 8,2.3 8.3,2.3 C8.6,2.3 8.8,2.1 8.8,2.1 L8.9,2.2 C8.9,2.2 8.7,2.4 8.3,2.4 C7.9,2.4 7.6,2.1 7.7,1.7 C7.8,1.3 8.1,1 8.5,1 Z M8.8,1.6 C8.9,1.3 8.7,1.2 8.5,1.2 C8.3,1.2 8,1.4 7.9,1.6 L8.8,1.6 Z" id="Shape_7_" fill-rule="nonzero"></path>
|
||||||
|
<polygon id="Path_10_" points="9.5 0.5 9.7 0.5 9.5 1 9.3 1"></polygon>
|
||||||
|
<path d="M9.9,1.3 C9.9,1.3 9.9,1.2 9.9,1.3 L9.8,1.2 L9.8,1 L10,1 C10.1,1 10.2,1 10.1,1.2 L10.1,1.3 C10.1,1.4 10.1,1.4 10.1,1.4 L10.1,1.4 C10.2,1.3 10.4,1 10.7,1 C11.1,1 11.1,1.2 11.1,1.5 L11,2.2 C11,2.2 11,2.3 11,2.3 L11.1,2.3 L11.1,2.5 L10.9,2.5 C10.8,2.5 10.7,2.4 10.8,2.3 L10.9,1.6 C10.9,1.4 11,1.2 10.7,1.2 C10.5,1.2 10.2,1.4 10.1,1.6 C10.1,1.7 10.1,1.7 10,1.8 L9.9,2.5 L9.7,2.5 L9.9,1.3 Z" id="Path_11_"></path>
|
||||||
|
<path d="M11.9,1.3 C11.9,1.3 11.9,1.2 11.8,1.2 L11.5,1.2 L11.6,1 L12.1,1 C12.4,1 12.5,1 12.4,1.2 L11.9,2.2 C11.9,2.2 11.9,2.3 12,2.3 L12.3,2.3 L12.2,2.5 L11.7,2.5 C11.4,2.5 11.3,2.4 11.4,2.3 L11.9,1.3 Z M12.3,0.5 L12.7,0.5 L12.6,0.7 L12.2,0.7 L12.3,0.5 Z" id="Shape_8_" fill-rule="nonzero"></path>
|
||||||
|
<path d="M12.3,1.3 C12.3,1.3 12.3,1.2 12.3,1.3 L12.1,1.2 L12.1,1 L12.3,1 C12.4,1 12.5,1 12.4,1.2 L12.4,1.3 C12.4,1.4 12.4,1.4 12.4,1.4 L12.4,1.4 C12.5,1.3 12.8,1 13.1,1 C13.4,1 13.5,1.2 13.4,1.5 L13.3,2.2 C13.3,2.2 13.3,2.3 13.3,2.3 L13.4,2.3 L13.4,2.5 L13.2,2.5 C13.1,2.5 13,2.4 13.1,2.3 L13.2,1.6 C13.2,1.4 13.3,1.2 13,1.2 C12.8,1.2 12.5,1.4 12.4,1.6 C12.4,1.7 12.4,1.7 12.3,1.8 L12.1,2.5 L12,2.5 L12.3,1.3 Z" id="Path_12_"></path>
|
||||||
|
<path d="M15.8,0.5 C16.4,0.5 16.7,0.9 16.6,1.5 C16.5,2.1 15.9,2.5 15.4,2.5 C14.8,2.5 14.5,2.1 14.6,1.5 C14.7,0.9 15.2,0.5 15.8,0.5 Z M15.3,2.3 C15.7,2.3 16.2,1.9 16.3,1.5 C16.4,1 16.1,0.7 15.7,0.7 C15.3,0.7 14.8,1.1 14.7,1.5 C14.6,2 14.9,2.3 15.3,2.3 Z M15.6,0.1 L15.8,0.1 L15.7,0.3 L15.5,0.3 L15.6,0.1 Z M16,0.1 L16.2,0.1 L16.1,0.3 L15.9,0.3 L16,0.1 Z" id="Shape_9_" fill-rule="nonzero"></path>
|
||||||
|
<path d="M17.5,1 C17.8,1 17.9,1.3 17.9,1.3 L17.9,1.3 C17.9,1.3 17.9,1.3 17.9,1.2 L18,0.7 C18,0.7 18,0.6 18,0.6 L17.9,0.6 L17.9,0.4 L18.1,0.4 C18.2,0.4 18.3,0.4 18.2,0.6 L17.9,2.1 C17.9,2.1 17.9,2.2 17.9,2.2 L18,2.2 L18,2.4 L17.8,2.4 C17.7,2.4 17.6,2.3 17.7,2.2 C17.7,2.1 17.7,2.1 17.7,2.1 L17.7,2.1 C17.7,2.1 17.5,2.4 17.2,2.4 C16.8,2.4 16.7,2.1 16.8,1.7 C16.8,1.3 17.1,1 17.5,1 M17.8,1.8 C17.9,1.5 17.8,1.2 17.5,1.2 C17.3,1.2 17,1.4 16.9,1.8 C16.8,2.1 17,2.4 17.2,2.4 C17.4,2.3 17.7,2.2 17.8,1.8" id="Shape_10_" fill-rule="nonzero"></path>
|
||||||
|
<path d="M19.2,1 C19.6,1 19.7,1.3 19.7,1.6 C19.7,1.6 19.7,1.7 19.7,1.7 L18.6,1.7 C18.5,2 18.7,2.3 19,2.3 C19.3,2.3 19.5,2.1 19.5,2.1 L19.6,2.2 C19.6,2.2 19.3,2.4 19,2.4 C18.6,2.4 18.3,2.1 18.4,1.7 C18.4,1.3 18.8,1 19.2,1 Z M19.4,1.6 C19.5,1.3 19.3,1.2 19.1,1.2 C18.9,1.2 18.6,1.3 18.5,1.6 L19.4,1.6 Z" id="Shape_11_" fill-rule="nonzero"></path>
|
||||||
|
<path d="M20,1.3 C20.1,1.3 20,1.2 20,1.3 L19.8,1.2 L19.8,1 L20,1 C20.1,1 20.2,1 20.1,1.2 L20.1,1.3 C20.1,1.4 20.1,1.4 20.1,1.4 L20.1,1.4 C20.3,1.2 20.6,1 20.8,1 C21.1,1 21.2,1.1 21.2,1.3 L21.2,1.3 C21.3,1.2 21.5,1 21.8,1 C22.1,1 22.2,1.2 22.1,1.5 L22,2.2 C22,2.2 22,2.3 22,2.3 L22.1,2.3 L22.1,2.5 L21.9,2.5 C21.8,2.5 21.7,2.4 21.8,2.3 L22,1.6 C22.1,1.4 22,1.2 21.8,1.2 C21.6,1.2 21.4,1.4 21.3,1.6 C21.3,1.7 21.2,1.7 21.2,1.8 L21.1,2.5 L20.9,2.5 L21.1,1.7 C21.1,1.5 21.2,1.3 20.9,1.3 C20.7,1.3 20.5,1.5 20.4,1.7 C20.4,1.8 20.3,1.8 20.3,1.9 L20,2.5 L19.8,2.5 L20,1.3 Z" id="Path_13_"></path>
|
||||||
|
<path d="M23.3,1 C23.7,1 23.8,1.3 23.8,1.6 C23.8,1.6 23.8,1.7 23.8,1.7 L22.7,1.7 C22.6,2 22.8,2.3 23.1,2.3 C23.4,2.3 23.6,2.1 23.6,2.1 L23.7,2.2 C23.7,2.2 23.4,2.4 23.1,2.4 C22.7,2.4 22.4,2.1 22.5,1.7 C22.5,1.3 22.9,1 23.3,1 Z M23.5,1.6 C23.6,1.3 23.4,1.2 23.2,1.2 C23,1.2 22.7,1.3 22.6,1.6 L23.5,1.6 Z" id="Shape_12_" fill-rule="nonzero"></path>
|
||||||
|
<path d="M25.2,1.6 L24.9,0.7 C24.9,0.6 24.8,0.6 24.8,0.6 L24.8,0.6 L24.8,0.4 L24.9,0.4 C25,0.4 25.1,0.4 25.1,0.6 L25.3,1.1 C25.3,1.2 25.4,1.3 25.4,1.3 L25.4,1.3 C25.4,1.3 25.5,1.2 25.6,1.1 L26,0.7 C26.1,0.6 26.2,0.5 26.3,0.5 L26.4,0.5 L26.4,0.7 L26.4,0.7 C26.3,0.7 26.3,0.7 26.3,0.8 L25.6,1.7 L25.4,2.5 L25,2.5 L25.2,1.6 Z" id="Path_14_"></path>
|
||||||
|
<path d="M26.9,1 C27.3,1 27.6,1.3 27.5,1.7 C27.4,2.1 27,2.4 26.6,2.4 C26.2,2.4 25.9,2.1 26,1.7 C26.1,1.4 26.5,1 26.9,1 Z M26.6,2.3 C26.9,2.3 27.2,2.1 27.3,1.7 C27.4,1.4 27.2,1.2 26.9,1.2 C26.6,1.2 26.3,1.4 26.2,1.7 C26.1,2.1 26.3,2.3 26.6,2.3 Z M26.7,0.5 L26.9,0.5 L26.8,0.7 L26.6,0.7 L26.7,0.5 Z M27.1,0.5 L27.3,0.5 L27.3,0.7 L27.1,0.7 L27.1,0.5 Z" id="Shape_13_" fill-rule="nonzero"></path>
|
||||||
|
<path d="M27.9,1.3 C27.9,1.3 27.9,1.2 27.9,1.3 L27.7,1.2 L27.7,1 L28,1 C28.1,1 28.2,1 28.1,1.2 L28.1,1.3 C28.1,1.4 28.1,1.4 28.1,1.4 L28.1,1.4 C28.2,1.3 28.4,1 28.7,1 C29,1 29.1,1.2 29,1.5 L28.9,2.2 C28.9,2.2 28.9,2.3 29,2.3 L29.1,2.3 L29.1,2.5 L28.9,2.5 C28.8,2.5 28.7,2.4 28.8,2.3 L28.9,1.6 C28.9,1.4 29,1.2 28.7,1.2 C28.5,1.2 28.2,1.4 28.1,1.6 C28,1.7 28,1.7 28,1.8 L27.9,2.5 L27.7,2.5 L27.9,1.3 Z" id="Path_15_"></path>
|
||||||
|
<path d="M29.6,1.2 L29.4,1.2 L29.4,1 L29.6,1 L29.7,0.6 L29.9,0.6 L29.8,1 L30.2,1 L30.2,1.2 L29.8,1.2 L29.6,1.9 C29.5,2.2 29.7,2.3 29.8,2.3 C29.8,2.3 29.9,2.3 29.9,2.3 L29.9,2.5 C29.9,2.5 29.9,2.5 29.8,2.5 C29.6,2.5 29.3,2.4 29.4,2 L29.6,1.2 Z" id="Path_16_"></path>
|
||||||
|
<path d="M31.1,1 C31.5,1 31.6,1.3 31.6,1.6 C31.6,1.6 31.6,1.7 31.6,1.7 L30.5,1.7 C30.4,2 30.6,2.3 30.9,2.3 C31.2,2.3 31.4,2.1 31.4,2.1 L31.5,2.2 C31.5,2.2 31.3,2.4 30.9,2.4 C30.5,2.4 30.2,2.1 30.3,1.7 C30.3,1.3 30.7,1 31.1,1 Z M31.4,1.6 C31.5,1.3 31.3,1.2 31.1,1.2 C30.9,1.2 30.6,1.3 30.5,1.6 L31.4,1.6 Z" id="Shape_14_" fill-rule="nonzero"></path>
|
||||||
|
<path d="M32,1.3 C32,1.3 32,1.2 32,1.3 L31.8,1.2 L31.8,1 L32,1 C32.1,1 32.2,1 32.1,1.2 L32.1,1.3 C32.1,1.4 32.1,1.4 32.1,1.4 L32.1,1.4 C32.3,1.2 32.5,1 32.7,1 C33,1 33.1,1.1 33.1,1.3 L33.1,1.3 C33.2,1.2 33.5,1 33.7,1 C34,1 34.1,1.2 34,1.6 L33.9,2.3 C33.9,2.3 33.9,2.4 33.9,2.4 L34,2.4 L34,2.6 L33.8,2.6 C33.7,2.6 33.6,2.5 33.7,2.4 L33.8,1.7 C33.9,1.5 33.8,1.3 33.6,1.3 C33.4,1.3 33.2,1.5 33.1,1.7 C33.1,1.8 33,1.8 33,1.9 L32.9,2.6 L32.7,2.6 L32.9,1.8 C32.9,1.6 33,1.4 32.7,1.4 C32.5,1.4 32.3,1.6 32.2,1.8 C32.2,1.9 32.2,1.9 32.1,2 L32,2.7 L31.8,2.7 L32,1.3 Z" id="Path_17_"></path>
|
||||||
|
<path d="M34.9,1.3 C34.9,1.3 34.9,1.2 34.8,1.2 L34.5,1.2 L34.6,1 L35.1,1 C35.4,1 35.5,1 35.4,1.2 L34.9,2.2 C34.9,2.2 34.9,2.3 35,2.3 L35.3,2.3 L35.2,2.5 L34.7,2.5 C34.4,2.5 34.3,2.4 34.4,2.3 L34.9,1.3 Z M35.3,0.5 L35.7,0.5 L35.6,0.7 L35.2,0.7 L35.3,0.5 Z" id="Shape_15_" fill-rule="nonzero"></path>
|
||||||
|
</g>
|
||||||
|
<g id="Group_2_">
|
||||||
|
<g id="Path_18_">
|
||||||
|
<path d="M54,4.4 C53.2,4.4 52.3,4.9 51.9,5.7 L48.1,14.4 L46.7,5.7 C46.5,4.9 46,4.4 45.1,4.4 L40.2,4.4 L44.4,18.9 C44.5,19.2 44.5,19.5 44.4,19.8 C44.2,21 43.1,22 41.9,22 L39.2,22 C38.5,22 38.1,22.4 37.8,23.4 L37.2,27 L42,27 C44.5,27 47.4,25.8 49.4,22.3 L59,4.4 L54,4.4 Z" id="Path" fill="#323E48"></path>
|
||||||
|
<path d="M6.8,0 C8.2,0 8.9,0.6 8.7,2 L8.3,4.3 L11.6,4.3 L10.9,8.4 L7.5,8.4 L6.6,13.5 C6.3,15.3 8,15.5 9.1,15.5 C9.3,15.5 9.5,15.5 9.6,15.5 L8.8,20 C8.6,20 8.3,20.1 7.8,20.1 C5.3,20.1 0.6,19.4 1.5,14.2 L2.5,8.4 L0,8.4 L0.7,4.3 L3.1,4.3 L3.9,0 L6.8,0 Z" id="Path" fill="#323E48"></path>
|
||||||
|
<g id="Group" transform="translate(24.190798, 4.196481)" fill="#00ADBB">
|
||||||
|
<path d="M10.4,0.3 L9.5,5.1 C10.5,5.6 11.3,6.7 11.3,7.9 C11.3,9.5 10.1,10.9 8.5,11.1 L7.7,15.9 C7.8,15.9 8,15.9 8.2,15.9 C12.6,15.9 16.2,12.3 16.2,7.9 C16.1,4.4 13.7,1.3 10.4,0.3 Z" id="Path"></path>
|
||||||
|
<path d="M6.6,10.8 C5.6,10.3 4.8,9.2 4.8,8 C4.8,6.4 6,5 7.6,4.8 L8.5,0 C8.4,0 8.2,0 8,0 C3.6,0 0,3.6 0,8 C0,11.6 2.4,14.6 5.7,15.6 L6.6,10.8 Z" id="Path"></path>
|
||||||
|
</g>
|
||||||
|
<path d="M13.5,4.4 L16.3,4.4 C17.7,4.4 18.4,5 18.2,6.4 L17.9,8.1 C19,6 21.2,4.2 23.6,4.2 C23.9,4.2 24.2,4.3 24.2,4.3 L23.3,9.5 C23.3,9.5 22.9,9.4 22.2,9.4 C20.9,9.4 18.8,9.8 17.6,12.1 C17.3,12.7 17.1,13.4 17,14.2 L16,20 L10.7,20 L13.5,4.4 Z" id="Path" fill="#323E48"></path>
|
||||||
|
</g>
|
||||||
|
<path d="M59,20.7 C59,21.2 58.8,21.6 58.5,21.9 C58.2,22.2 57.8,22.4 57.3,22.4 C56.8,22.4 56.4,22.2 56.1,21.9 C55.8,21.6 55.6,21.2 55.6,20.7 C55.6,20.2 55.8,19.8 56.1,19.5 C56.4,19.2 56.8,19 57.3,19 C57.8,19 58.2,19.2 58.5,19.5 C58.8,19.8 59,20.2 59,20.7 Z M58.7,20.7 C58.7,20.3 58.6,20 58.3,19.7 C58,19.4 57.7,19.3 57.3,19.3 C56.9,19.3 56.6,19.4 56.3,19.7 C56,20 55.9,20.3 55.9,20.7 C55.9,21.1 56,21.4 56.3,21.7 C56.6,22 56.9,22.1 57.3,22.1 C57.7,22.1 58,22 58.3,21.7 C58.6,21.4 58.7,21.1 58.7,20.7 Z M58,21.6 L57.7,21.6 L57.3,20.8 L57,20.8 L57,21.6 L56.7,21.6 L56.7,19.8 L57.3,19.8 C57.5,19.8 57.6,19.9 57.7,20 C57.8,20.1 57.9,20.2 57.9,20.4 C57.9,20.7 57.8,20.8 57.5,20.9 L58,21.6 Z M57.7,20.3 C57.7,20.2 57.7,20.1 57.6,20.1 C57.5,20.1 57.4,20 57.3,20 L57,20 L57,20.6 L57.3,20.6 C57.4,20.6 57.5,20.6 57.6,20.5 C57.6,20.5 57.7,20.4 57.7,20.3 Z" id="Shape_16_" fill="#323E48" fill-rule="nonzero"></path>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g id="American_Express" transform="translate(294.000000, 0.000000)">
|
||||||
|
<polyline id="Path_5_" fill="url(#radialGradient-1)" points="43.2 0 0 0 0 30 43.2 30 43.2 20.1"></polyline>
|
||||||
|
<g id="Group" transform="translate(0.000000, 6.600000)" fill="#FFFFFF" fill-rule="nonzero">
|
||||||
|
<path d="M3.9,4 L3.1,2 L2.3,4 L3.9,4 Z M22,3.2 C21.8,3.3 21.6,3.3 21.4,3.3 L20,3.3 L20,2.2 L21.5,2.2 C21.7,2.2 21.9,2.2 22.1,2.3 C22.3,2.4 22.4,2.5 22.4,2.7 C22.3,2.9 22.2,3.1 22,3.2 Z M32.4,4 L31.6,2 L30.8,4 L32.4,4 Z M13,6.2 L11.8,6.2 L11.8,2.2 L10.1,6.2 L9,6.2 L7.3,2.2 L7.3,6.2 L4.8,6.2 L4.4,5.1 L1.9,5.1 L1.4,6.2 L0.1,6.2 L2.2,1.1 L4,1.1 L6,5.9 L6,1.1 L8,1.1 L9.6,4.5 L11,1.1 L13,1.1 L13,6.2 Z M17.9,6.2 L13.9,6.2 L13.9,1.1 L17.9,1.1 L17.9,2.2 L15.1,2.2 L15.1,3.1 L17.8,3.1 L17.8,4.1 L15.1,4.1 L15.1,5.1 L17.9,5.1 L17.9,6.2 Z M23.6,2.5 C23.6,3.3 23.1,3.7 22.8,3.8 C23.1,3.9 23.3,4.1 23.4,4.2 C23.6,4.5 23.6,4.7 23.6,5.1 L23.6,6.1 L22.4,6.1 L22.4,5.5 C22.4,5.2 22.4,4.8 22.2,4.5 C22,4.3 21.8,4.3 21.3,4.3 L20,4.3 L20,6.1 L18.8,6.1 L18.8,1.1 L21.6,1.1 C22.2,1.1 22.7,1.1 23.1,1.3 C23.4,1.6 23.6,1.9 23.6,2.5 Z M25.5,6.2 L24.3,6.2 L24.3,1.1 L25.5,1.1 L25.5,6.2 Z M39.7,6.2 L38,6.2 L35.7,2.4 L35.7,6.2 L33.3,6.2 L32.8,5.1 L30.3,5.1 L29.8,6.2 L28.4,6.2 C27.8,6.2 27.1,6.1 26.7,5.6 C26.3,5.2 26.1,4.6 26.1,3.7 C26.1,2.9 26.2,2.3 26.7,1.7 C27.1,1.3 27.7,1.1 28.5,1.1 L29.7,1.1 L29.7,2.2 L28.6,2.2 C28.2,2.2 27.9,2.3 27.7,2.5 C27.5,2.7 27.4,3.1 27.4,3.6 C27.4,4.1 27.5,4.5 27.7,4.8 C27.9,5 28.2,5.1 28.5,5.1 L29,5.1 L30.7,1.1 L32.5,1.1 L34.5,5.9 L34.5,1.1 L36.3,1.1 L38.4,4.6 L38.4,1.1 L39.6,1.1 L39.7,6.2 L39.7,6.2 Z M0.1,7.2 L2.1,7.2 L2.6,6.1 L3.6,6.1 L4.1,7.2 L8.1,7.2 L8.1,6.3 L8.5,7.2 L10.6,7.2 L11,6.3 L11,7.2 L21,7.2 L21,5.4 L21.2,5.4 C21.3,5.4 21.4,5.4 21.4,5.6 L21.4,7.2 L26.6,7.2 L26.6,6.8 C27,7 27.7,7.2 28.5,7.2 L30.7,7.2 L31.2,6.1 L32.2,6.1 L32.7,7.2 L36.9,7.2 L36.9,6.1 L37.5,7.2 L40.9,7.2 L40.9,0.1 L37.6,0.1 L37.6,1 L37,0.1 L33.6,0.1 L33.6,1 L33.2,0.2 L28.6,0.2 C27.8,0.2 27.1,0.3 26.6,0.6 L26.6,0.1 L23.4,0.1 L23.4,0.5 C23,0.2 22.5,0.1 22,0.1 L10.3,0.1 L9.6,1.9 L8.8,0.1 L5.1,0.1 L5.1,1 L4.7,0.1 L1.6,0.1 L0.1,3.5 L0.1,7.2 Z" id="Shape_3_"></path>
|
||||||
|
<path d="M42.8,10.9 L40.6,10.9 C40.4,10.9 40.2,10.9 40.1,11 C40,11.1 39.9,11.2 39.9,11.4 C39.9,11.6 40,11.7 40.2,11.8 C40.3,11.8 40.5,11.9 40.7,11.9 L41.3,11.9 C42,11.9 42.4,12 42.7,12.3 C42.7,12.3 42.8,12.4 42.8,12.4 L42.8,10.9 Z M42.8,14.3 C42.5,14.7 41.9,14.9 41.2,14.9 L38.9,14.9 L38.9,13.8 L41.2,13.8 C41.4,13.8 41.6,13.8 41.7,13.7 C41.8,13.6 41.8,13.5 41.8,13.4 C41.8,13.3 41.7,13.1 41.7,13.1 C41.6,13 41.5,13 41.3,13 C40.2,13 38.8,13 38.8,11.4 C38.8,10.7 39.3,9.9 40.5,9.9 L42.9,9.9 L42.9,8.9 L40.7,8.9 C40,8.9 39.5,9.1 39.2,9.3 L39.2,8.8 L35.9,8.8 C35.4,8.8 34.8,8.9 34.5,9.2 L34.5,8.8 L28.6,8.8 L28.6,9.2 C28.1,8.9 27.3,8.8 27,8.8 L23,8.8 L23,9.2 C22.6,8.8 21.8,8.8 21.3,8.8 L17,8.8 L16,9.9 L15.1,8.8 L8.6,8.8 L8.6,15.9 L14.9,15.9 L15.9,14.8 L16.9,15.9 L20.8,15.9 L20.8,14.2 L21.2,14.2 C21.7,14.2 22.3,14.2 22.9,14 L22.9,15.9 L26.1,15.9 L26.1,14.1 L26.3,14.1 C26.5,14.1 26.5,14.1 26.5,14.3 L26.5,15.9 L36.3,15.9 C36.9,15.9 37.6,15.7 37.9,15.4 L37.9,15.9 L41,15.9 C41.6,15.9 42.3,15.8 42.8,15.6 L42.8,14.3 L42.8,14.3 Z M38,12.2 C38.2,12.4 38.4,12.8 38.4,13.3 C38.4,14.4 37.7,14.9 36.5,14.9 L34.2,14.9 L34.2,13.8 L36.5,13.8 C36.7,13.8 36.9,13.8 37,13.7 C37.1,13.6 37.1,13.5 37.1,13.4 C37.1,13.3 37,13.1 37,13.1 C36.9,13 36.8,13 36.6,13 C35.5,13 34.1,13 34.1,11.4 C34.1,10.7 34.6,9.9 35.8,9.9 L38.2,9.9 L38.2,11 L36,11 C35.8,11 35.6,11 35.5,11.1 C35.4,11.2 35.3,11.3 35.3,11.5 C35.3,11.7 35.4,11.8 35.6,11.9 C35.7,11.9 35.9,12 36.1,12 L36.7,12 C37.3,11.8 37.7,12 38,12.2 Z M27.2,11.9 C27,12 26.8,12 26.6,12 L25.1,12 L25.1,10.9 L26.6,10.9 C26.8,10.9 27,10.9 27.2,11 C27.4,11.1 27.4,11.2 27.4,11.5 C27.4,11.7 27.3,11.8 27.2,11.9 Z M27.9,12.5 C28.2,12.6 28.4,12.8 28.5,12.9 C28.7,13.1 28.7,13.4 28.7,13.8 L28.7,14.8 L27.5,14.8 L27.5,14.2 C27.5,13.9 27.5,13.4 27.3,13.2 C27.1,13 26.9,13 26.4,13 L25.1,13 L25.1,14.8 L23.9,14.8 L23.9,9.8 L26.7,9.8 C27.3,9.8 27.7,9.8 28.1,10 C28.5,10.2 28.7,10.5 28.7,11.1 C28.7,12 28.2,12.4 27.9,12.5 Z M29.4,9.8 L33.4,9.8 L33.4,10.8 L30.6,10.8 L30.6,11.7 L33.3,11.7 L33.3,12.7 L30.6,12.7 L30.6,13.7 L33.4,13.7 L33.4,14.8 L29.4,14.8 L29.4,9.8 Z M21.3,12.2 L19.8,12.2 L19.8,10.9 L21.4,10.9 C21.8,10.9 22.1,11.1 22.1,11.5 C22.1,11.9 21.8,12.2 21.3,12.2 Z M18.6,14.4 L16.8,12.3 L18.6,10.3 L18.6,14.4 Z M13.8,13.8 L10.9,13.8 L10.9,12.8 L13.5,12.8 L13.5,11.8 L10.9,11.8 L10.9,10.9 L13.9,10.9 L15.2,12.4 L13.8,13.8 Z M23.3,11.5 C23.3,12.9 22.3,13.2 21.2,13.2 L19.7,13.2 L19.7,14.9 L17.4,14.9 L16,13.2 L14.5,14.9 L9.7,14.9 L9.7,9.8 L14.5,9.8 L16,11.5 L17.5,9.8 L21.3,9.8 C22.3,9.8 23.3,10.1 23.3,11.5 Z" id="Shape_4_"></path>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g id="visa" transform="translate(200.000000, 4.000000)" fill="url(#linearGradient-2)">
|
||||||
|
<path d="M30.7,0.4 L26.6,19.7 L21.6,19.7 L25.7,0.4 L30.7,0.4 Z M51.8,12.8 L54.4,5.5 L55.9,12.8 L51.8,12.8 Z M57.4,19.7 L62,19.7 L58,0.4 L53.7,0.4 C52.7,0.4 51.9,1 51.6,1.8 L44,19.7 L49.3,19.7 L50.3,16.8 L56.7,16.8 L57.4,19.7 Z M44.3,13.4 C44.3,8.3 37.2,8 37.3,5.7 C37.3,5 38,4.3 39.4,4.1 C40.1,4 42.1,3.9 44.3,5 L45.2,0.9 C44,0.4 42.5,0 40.5,0 C35.5,0 32.1,2.6 32,6.4 C32,9.2 34.5,10.7 36.4,11.7 C38.4,12.6 39,13.3 39,14.1 C39,15.4 37.4,16 36,16 C33.5,16 32,15.3 30.9,14.8 L30,19 C31.2,19.5 33.3,20 35.6,20 C40.8,20 44.3,17.4 44.3,13.4 Z M23.5,0.4 L15.4,19.7 L10.1,19.7 L6.1,4.3 C5.9,3.3 5.6,3 4.9,2.6 C3.7,1.9 1.7,1.3 0,1 L0.1,0.4 L8.6,0.4 C9.7,0.4 10.7,1.1 10.9,2.4 L13,13.6 L18.2,0.4 L23.5,0.4 Z" id="Fill-1"></path>
|
||||||
|
</g>
|
||||||
|
<g id="mastercard-seeklogo.com" transform="translate(129.000000, 0.000000)">
|
||||||
|
<path d="M7.1,29.9 L7.1,27.9 C7.1,27.1 6.6,26.6 5.8,26.6 C5.4,26.6 5,26.7 4.7,27.2 C4.5,26.8 4.1,26.6 3.6,26.6 C3.3,26.6 2.9,26.7 2.7,27.1 L2.7,26.7 L2,26.7 L2,29.9 L2.7,29.9 L2.7,28.1 C2.7,27.5 3,27.3 3.5,27.3 C4,27.3 4.2,27.6 4.2,28.1 L4.2,29.9 L4.9,29.9 L4.9,28.1 C4.9,27.5 5.2,27.3 5.7,27.3 C6.2,27.3 6.4,27.6 6.4,28.1 L6.4,29.9 L7.1,29.9 Z M17.5,26.7 L16.4,26.7 L16.4,25.7 L15.7,25.7 L15.7,26.7 L15,26.7 L15,27.3 L15.6,27.3 L15.6,28.8 C15.6,29.5 15.9,30 16.7,30 C17,30 17.3,29.9 17.6,29.8 L17.4,29.2 C17.2,29.3 17,29.4 16.8,29.4 C16.5,29.4 16.3,29.2 16.3,28.9 L16.3,27.5 L17.4,27.5 L17.4,26.7 L17.5,26.7 Z M23.4,26.6 C23,26.6 22.7,26.8 22.6,27.1 L22.6,26.7 L21.9,26.7 L21.9,29.9 L22.6,29.9 L22.6,28.1 C22.6,27.6 22.8,27.3 23.3,27.3 C23.4,27.3 23.6,27.3 23.7,27.4 L23.9,26.7 C23.8,26.6 23.6,26.6 23.4,26.6 Z M14.4,26.9 C14.1,26.7 13.6,26.6 13.1,26.6 C12.3,26.6 11.8,27 11.8,27.6 C11.8,28.1 12.2,28.4 12.9,28.5 L13.2,28.5 C13.6,28.6 13.8,28.7 13.8,28.8 C13.8,29 13.5,29.2 13.1,29.2 C12.6,29.2 12.3,29 12,28.9 L11.7,29.4 C12.1,29.7 12.6,29.8 13.1,29.8 C14,29.8 14.6,29.4 14.6,28.8 C14.6,28.2 14.2,27.9 13.5,27.8 L13.2,27.8 C12.9,27.8 12.7,27.7 12.7,27.5 C12.7,27.3 12.9,27.1 13.3,27.1 C13.7,27.1 14.1,27.3 14.3,27.4 L14.4,26.9 Z M33.1,26.6 C32.7,26.6 32.4,26.8 32.3,27.1 L32.3,26.7 L31.6,26.7 L31.6,29.9 L32.3,29.9 L32.3,28.1 C32.3,27.6 32.5,27.3 33,27.3 C33.1,27.3 33.3,27.3 33.4,27.4 L33.6,26.7 C33.4,26.6 33.2,26.6 33.1,26.6 Z M24.1,28.3 C24.1,29.3 24.8,30 25.8,30 C26.3,30 26.6,29.9 26.9,29.6 L26.7,29 C26.4,29.2 26.2,29.3 25.9,29.3 C25.3,29.3 24.9,28.9 24.9,28.3 C24.9,27.7 25.3,27.3 25.9,27.3 C26.2,27.3 26.5,27.4 26.7,27.6 L27,27 C26.7,26.7 26.3,26.6 25.9,26.6 C24.8,26.6 24.1,27.3 24.1,28.3 Z M30.6,28.3 L30.6,26.7 L29.9,26.7 L29.9,27.1 C29.7,26.8 29.3,26.6 28.9,26.6 C28,26.6 27.3,27.3 27.3,28.3 C27.3,29.3 28,30 28.9,30 C29.4,30 29.7,29.8 29.9,29.5 L29.9,29.9 L30.6,29.9 L30.6,28.3 Z M28.1,28.3 C28.1,27.7 28.5,27.3 29.1,27.3 C29.7,27.3 30.1,27.7 30.1,28.3 C30.1,28.9 29.7,29.3 29.1,29.3 C28.4,29.3 28.1,28.8 28.1,28.3 Z M19.7,26.6 C18.8,26.6 18.1,27.3 18.1,28.3 C18.1,29.3 18.8,30 19.7,30 C20.2,30 20.6,29.9 21,29.6 L20.7,29 C20.4,29.2 20.1,29.3 19.8,29.3 C19.4,29.3 18.9,29.1 18.8,28.5 L21.2,28.5 L21.2,28.2 C21.2,27.3 20.6,26.6 19.7,26.6 Z M19.7,27.2 C20.1,27.2 20.4,27.5 20.5,28 L18.8,28 C18.9,27.5 19.2,27.2 19.7,27.2 Z M37.1,28.3 L37.1,25.5 L36.4,25.5 L36.4,27.2 C36.2,26.9 35.8,26.7 35.4,26.7 C34.5,26.7 33.8,27.4 33.8,28.4 C33.8,29.4 34.5,30.1 35.4,30.1 C35.9,30.1 36.2,29.9 36.4,29.6 L36.4,30 L37.1,30 L37.1,28.3 Z M34.5,28.3 C34.5,27.7 34.9,27.3 35.5,27.3 C36.1,27.3 36.5,27.7 36.5,28.3 C36.5,28.9 36.1,29.3 35.5,29.3 C34.9,29.3 34.5,28.8 34.5,28.3 Z M11,28.3 L11,26.7 L10.3,26.7 L10.3,27.1 C10.1,26.8 9.7,26.6 9.3,26.6 C8.4,26.6 7.7,27.3 7.7,28.3 C7.7,29.3 8.4,30 9.3,30 C9.8,30 10.1,29.8 10.3,29.5 L10.3,29.9 L11,29.9 L11,28.3 Z M8.4,28.3 C8.4,27.7 8.8,27.3 9.4,27.3 C10,27.3 10.4,27.7 10.4,28.3 C10.4,28.9 10,29.3 9.4,29.3 C8.8,29.3 8.4,28.8 8.4,28.3 Z" id="Shape" fill="#000000" fill-rule="nonzero"></path>
|
||||||
|
<rect id="Rectangle" fill="#FF5F00" x="14.2" y="2.5" width="10.5" height="18.7"></rect>
|
||||||
|
<path d="M14.9,11.9 C14.9,8.1 16.7,4.7 19.5,2.5 C17.4,1 14.8,0 12,0 C5.4,0 0,5.3 0,11.9 C0,18.5 5.4,23.8 12,23.8 C14.8,23.8 17.4,22.8 19.4,21.3 C16.7,19.1 14.9,15.7 14.9,11.9 Z" id="Path" fill="#EB001B"></path>
|
||||||
|
<path d="M39,11.9 C39,18.5 33.6,23.8 27,23.8 C24.2,23.8 21.6,22.8 19.6,21.3 C22.4,19.1 24.2,15.7 24.2,11.9 C24.2,8.1 22.4,4.7 19.6,2.5 C21.5,1 24.1,0 26.9,0 C33.6,0 39,5.4 39,11.9 Z" id="Path_1_" fill="#F79E1B"></path>
|
||||||
|
</g>
|
||||||
|
<g id="Group" transform="translate(0.000000, 0.761905)">
|
||||||
|
<path d="M52.80358,3.95309044 C53.4023794,3.64708353 54.1410993,3.8755569 54.4534379,4.46280716 L54.4534379,4.46280716 L57.0770217,9.41994225 L59.7009065,4.46280716 C60.013245,3.8755569 60.7519649,3.64708353 61.3513661,3.95309044 C61.9510682,4.25850775 62.1836671,4.98284203 61.8716295,5.57009228 L61.8716295,5.57009228 L56.2922635,16.1170126 C56.0822324,16.5105764 55.6811271,16.7434718 55.2589584,16.7605705 C55.0513345,16.770299 54.8385953,16.7269628 54.6412021,16.6261397 C54.0418009,16.3210172 53.808901,15.5966829 54.1212396,15.0091379 L54.1212396,15.0091379 L55.6976768,12.0227698 L52.2824139,5.57009228 C51.9700754,4.98284203 52.2029752,4.25909736 52.80358,3.95309044 Z M82.1554924,3.65852194 C83.4779664,3.65852194 84.7216035,4.16322698 85.6568138,5.07918409 C86.1355524,5.547923 86.1355524,6.30704419 85.6568138,6.77489869 C85.1789779,7.24304799 84.4047514,7.24304799 83.9266146,6.77489869 C83.4532922,6.31117145 82.8247033,6.05587089 82.1554924,6.05587089 C81.4856796,6.05587089 80.8570907,6.31117145 80.3834674,6.77489869 C79.910446,7.23862592 79.649562,7.85506181 79.649562,8.51070668 C79.649562,9.16605674 79.910446,9.78219783 80.3834674,10.2465147 C80.8570907,10.7096523 81.4856796,10.9652477 82.1554924,10.9652477 C82.8247033,10.9652477 83.4532922,10.7096523 83.9266146,10.2465147 C84.4047514,9.77807057 85.1789779,9.77807057 85.6568138,10.2465147 C86.1355524,10.714664 86.1355524,11.4731956 85.6568138,11.9413449 C84.7216035,12.857302 83.4779664,13.362007 82.1554924,13.362007 C80.8324165,13.362007 79.5887794,12.857302 78.6535691,11.9413449 C77.7183588,11.025093 77.2032108,9.80666659 77.2032108,8.51070668 C77.2032108,7.21504158 77.7183588,5.9957308 78.6535691,5.07918409 C79.5887794,4.16322698 80.8324165,3.65852194 82.1554924,3.65852194 Z M92.0477184,3.65861038 C94.7781233,3.65861038 97,5.83574048 97,8.51079512 C97,11.1858498 94.7781233,13.3620954 92.0477184,13.3620954 C89.3176145,13.3620954 87.0957378,11.1858498 87.0957378,8.51079512 C87.0957378,5.83574048 89.3176145,3.65861038 92.0477184,3.65861038 Z M49.4700729,3.81748044 C50.1453019,3.81748044 50.6932485,4.35372955 50.6932485,5.01586011 L50.6932485,5.01586011 L50.6932485,12.0263075 C50.6932485,12.6878484 50.1453019,13.2246871 49.4700729,13.2246871 C48.7945429,13.2246871 48.2465963,12.6878484 48.2465963,12.0263075 L48.2465963,12.0263075 L48.2465963,5.01586011 C48.2465963,4.35372955 48.7945429,3.81748044 49.4700729,3.81748044 Z M70.1055423,3.817392 C70.3968176,3.80736865 70.6926065,3.89993722 70.9333298,4.09745613 C71.451186,4.52315361 71.5182876,5.27903195 71.0843837,5.78668504 L71.0843837,5.78668504 L66.7667092,10.8275446 L70.147368,10.8275446 C70.822898,10.8275446 71.3708446,11.3643833 71.3708446,12.026219 C71.3708446,12.68776 70.822898,13.2245987 70.147368,13.2245987 L70.147368,13.2245987 L64.1428244,13.2245987 C63.8647889,13.2245987 63.5858507,13.1332094 63.3562608,12.9445346 C62.8387055,12.5191319 62.7719048,11.7629588 63.2058087,11.2561901 L63.2058087,11.2561901 L67.5240851,6.21415134 L64.6997981,6.21415134 C64.0248699,6.21415134 63.4769234,5.67790223 63.4769234,5.01606648 C63.4769234,4.35393591 64.0248699,3.817392 64.6997981,3.817392 L64.6997981,3.817392 Z M74.376336,3.81748044 C75.0521669,3.81748044 75.5995117,4.35372955 75.5995117,5.01586011 L75.5995117,5.01586011 L75.5995117,12.0263075 C75.5995117,12.6878484 75.0521669,13.2246871 74.376336,13.2246871 C73.7008061,13.2246871 73.1528595,12.6878484 73.1528595,12.0263075 L73.1528595,12.0263075 L73.1528595,5.01586011 C73.1528595,4.35372955 73.7008061,3.81748044 74.376336,3.81748044 Z M92.0477184,6.05595933 C90.6665681,6.05595933 89.542089,7.15705356 89.542089,8.51079512 C89.542089,9.86394707 90.6665681,10.9653361 92.0477184,10.9653361 C93.4291697,10.9653361 94.5530469,9.86394707 94.5530469,8.51079512 C94.5530469,7.15705356 93.4291697,6.05595933 92.0477184,6.05595933 Z M49.4700729,-1.99840144e-13 C50.2160144,-1.99840144e-13 50.8217346,0.59344115 50.8217346,1.32485073 C50.8217346,2.05655512 50.2160144,2.64970147 49.4700729,2.64970147 C48.7229277,2.64970147 48.1181102,2.05655512 48.1181102,1.32485073 C48.1181102,0.59344115 48.7229277,-1.99840144e-13 49.4700729,-1.99840144e-13 Z M74.376336,-1.99840144e-13 C75.1228794,-1.99840144e-13 75.7279978,0.59344115 75.7279978,1.32485073 C75.7279978,2.05655512 75.1228794,2.64970147 74.376336,2.64970147 C73.6294918,2.64970147 73.0243734,2.05655512 73.0243734,1.32485073 C73.0243734,0.59344115 73.6294918,-1.99840144e-13 74.376336,-1.99840144e-13 Z" id="Combined-Shape" fill="#1E64FF"></path>
|
||||||
|
<path d="M66.708643,20.4523488 C68.7783092,20.4523488 70.8240554,22.1905313 70.8240554,24.7028054 C70.8240554,27.2150795 68.7783092,28.952381 66.708643,28.952381 C64.6392684,28.952381 62.5935222,27.2150795 62.5935222,24.7028054 C62.5935222,22.1905313 64.6392684,20.4523488 66.708643,20.4523488 Z M77.04516,20.3690958 C77.3879166,20.3690958 77.6837082,20.6551221 77.6837082,21.0001744 L77.6837082,28.3097028 C77.6837082,28.6553423 77.3879166,28.9407813 77.04516,28.9407813 C76.7021117,28.9407813 76.4182801,28.6553423 76.4182801,28.3097028 L76.4182801,27.6668777 C76.3357268,28.2025163 75.6020819,28.9522341 74.4787155,28.9522341 C73.1663225,28.9522341 71.5937262,27.9285301 71.5937262,25.774523 C71.5937262,23.6548743 73.1779908,22.6073838 74.4787155,22.6073838 C75.3538387,22.6073838 76.1108202,23.0602098 76.4182801,23.7500206 L76.4182801,23.2739954 C76.4182801,23.2384623 76.4182801,23.1905955 76.4302401,23.1550624 L76.4302401,21.0001744 C76.4302401,20.6551221 76.7143635,20.3690958 77.04516,20.3690958 Z M48.6265568,22.6435041 C48.8864684,22.6435041 49.1110833,22.8696235 49.1110833,23.1436097 L49.1110833,28.4286357 C49.1110833,28.7149556 48.8864684,28.9407813 48.6265568,28.9407813 C48.3546852,28.9407813 48.1297785,28.7149556 48.1297785,28.4286357 L48.1297785,23.1436097 C48.1297785,22.8696235 48.3546852,22.6435041 48.6265568,22.6435041 Z M50.9206046,20.3691545 C51.1810996,20.3691545 51.4057145,20.5958612 51.4057145,20.8575136 L51.4057145,28.4286944 C51.4057145,28.6906405 51.1810996,28.9285063 50.9206046,28.9285063 C50.6484413,28.9285063 50.4241181,28.6906405 50.4241181,28.4286944 L50.4241181,20.8575136 C50.4241181,20.5958612 50.6484413,20.3691545 50.9206046,20.3691545 Z M55.497732,22.6074425 C56.8457134,22.6074425 58.4652746,23.6790133 58.4652746,25.607782 C58.4652746,25.9880737 58.2406597,26.1672072 57.8389782,26.1672072 L53.4872825,26.1434206 C53.5934641,27.4766438 54.6106489,28.1074287 55.627542,28.1074287 C56.1712851,28.1074287 56.6088467,27.9526691 56.9988599,27.6546027 C57.1765099,27.5359634 57.2829832,27.4766438 57.4127932,27.4766438 C57.6379915,27.4766438 57.7794698,27.6314034 57.7794698,27.8454827 C57.7794698,27.9764557 57.7205448,28.1074287 57.5192665,28.2859749 C57.0464083,28.7023869 56.3492269,28.9285063 55.6036219,28.9285063 C54.0899506,28.9285063 52.4703894,27.9285889 52.4703894,25.7860346 C52.4703894,23.6434803 54.149459,22.6074425 55.497732,22.6074425 Z M81.8114016,22.6073838 C83.1593829,22.6073838 84.7795276,23.6789546 84.7795276,25.5716029 C84.7795276,26.059962 84.5073643,26.2505483 83.9989177,26.2505483 L80.0375269,26.2382145 C80.1673369,27.3453184 81.0307917,27.8932908 81.9767998,27.8932908 C82.4619098,27.8932908 82.792998,27.7743578 83.1593829,27.5482385 C83.3603695,27.441052 83.4904712,27.3576522 83.6558694,27.3576522 C83.9280327,27.3576522 84.0937227,27.5482385 84.0937227,27.8098909 C84.0937227,27.9649442 84.0345061,28.1314502 83.7743028,28.3452358 C83.3250729,28.7149556 82.674273,28.9284475 81.9292515,28.9284475 C80.3925352,28.9284475 78.7487622,27.9408639 78.7487622,25.7859758 C78.7487622,23.6434215 80.4634202,22.6073838 81.8114016,22.6073838 Z M74.6799939,23.6904074 C73.8168307,23.6904074 72.8469026,24.3696464 72.8469026,25.774523 C72.8469026,27.1908524 73.8168307,27.8692105 74.6799939,27.8692105 C75.484232,27.8692105 76.4774968,27.2501721 76.4774968,25.774523 C76.4774968,24.2862465 75.484232,23.6904074 74.6799939,23.6904074 Z M66.708643,21.6669328 C65.2658566,21.6669328 63.9298353,22.8935569 63.9298353,24.7028054 C63.9298353,26.5120539 65.2658566,27.7263442 66.708643,27.7263442 C68.1514293,27.7263442 69.4877424,26.5120539 69.4877424,24.7028054 C69.4877424,22.8935569 68.1630976,21.6669328 66.708643,21.6669328 Z M55.497732,23.4769742 C54.6223172,23.4769742 53.5934641,24.0716387 53.4872825,25.4048619 L57.5075982,25.4048619 C57.4014166,24.0716387 56.3842318,23.4769742 55.497732,23.4769742 Z M81.8114016,23.7027411 C81.0307917,23.7027411 80.1440002,24.2028467 80.0375269,25.3337371 L83.5613561,25.3337371 C83.4551745,24.2028467 82.6039714,23.7027411 81.8114016,23.7027411 Z M48.6265568,20.2501629 C48.8864684,20.2501629 49.1230433,20.4762822 49.1230433,20.7502684 C49.1230433,21.0239609 48.8864684,21.250374 48.6265568,21.250374 C48.3546852,21.250374 48.1181102,21.0239609 48.1181102,20.7502684 C48.1181102,20.4762822 48.3546852,20.2501629 48.6265568,20.2501629 Z M68.1630976,18.2857143 C68.5414425,18.2857143 68.8489025,18.5952335 68.8489025,18.9649533 C68.8489025,19.3337922 68.5414425,19.6433114 68.1630976,19.6433114 C67.7847527,19.6433114 67.4772928,19.3337922 67.4772928,18.9649533 C67.4772928,18.5952335 67.7847527,18.2857143 68.1630976,18.2857143 Z M65.25448,18.2857143 C65.6325332,18.2857143 65.9397014,18.5952335 65.9397014,18.9649533 C65.9397014,19.3337922 65.6325332,19.6433114 65.25448,19.6433114 C64.8758434,19.6433114 64.5683834,19.3337922 64.5683834,18.9649533 C64.5683834,18.5952335 64.8758434,18.2857143 65.25448,18.2857143 Z" id="Combined-Shape" fill="#495057"></path>
|
||||||
|
<g fill="url(#linearGradient-3)" id="Group-3">
|
||||||
|
<path d="M3.78070866,0 C1.69274929,0 0,1.70531217 0,3.80952381 L0,26.6666667 C0,28.7708783 1.69274929,30.4761905 3.78070866,30.4761905 L32.3232107,30.4761905 C33.732995,30.4761905 35.0254932,29.6861799 35.6764472,28.4258201 L41.581074,16.9975873 C41.8606784,16.4557884 42.0028331,15.8635344 42.007874,15.2706032 L42.007874,15.2055873 C42.0028331,14.6126561 41.8606784,14.0204021 41.581074,13.4789418 L35.6764472,2.05037037 C35.0254932,0.790349206 33.732995,0 32.3232107,0 L3.78070866,0 Z" id="Fill-1"></path>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 32 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
|
fi
|
||||||
done
|
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
|
if [ ! -s /app/data/app.db ] && [ -f /app/seed/app.db ]; then
|
||||||
cp /app/seed/app.db /app/data/
|
cp /app/seed/app.db /app/data/
|
||||||
fi
|
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
|
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",
|
"skillPath": "skills/pick-ui-library/SKILL.md",
|
||||||
"computedHash": "f8d4d2cf4677bf54b14f35f62a741add93541bfcb67863315a40164365161b49"
|
"computedHash": "f8d4d2cf4677bf54b14f35f62a741add93541bfcb67863315a40164365161b49"
|
||||||
},
|
},
|
||||||
|
"prototype": {
|
||||||
|
"source": "emilkowalski/skill",
|
||||||
|
"sourceType": "github",
|
||||||
|
"skillPath": "skills/prototype/SKILL.md",
|
||||||
|
"computedHash": "f246d1f47566481d9207e1aa574bdee5454257d862919a6591283e9af2f57eab"
|
||||||
|
},
|
||||||
"review-animations": {
|
"review-animations": {
|
||||||
"source": "emilkowalski/skill",
|
"source": "emilkowalski/skill",
|
||||||
"sourceType": "github",
|
"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 type { NextRequest } from "next/server";
|
||||||
import { getSession } from "@/lib/session";
|
import { getSession } from "@/lib/session";
|
||||||
import { appDb, schema } from "@/lib/appdb";
|
import { appDb, schema } from "@/lib/appdb";
|
||||||
import { spendCreditForMessage, grantCredits } from "@/lib/credits";
|
import {
|
||||||
|
spendCreditForMessage,
|
||||||
|
grantCredits,
|
||||||
|
krediBittiIsaretle,
|
||||||
|
} from "@/lib/credits";
|
||||||
import { anahtarVar, SORUMLULUK_REDDI } from "@/lib/ai/client";
|
import { anahtarVar, SORUMLULUK_REDDI } from "@/lib/ai/client";
|
||||||
|
import { kullanicininRaporu } from "@/lib/rapor-kaydi";
|
||||||
import { sohbetAkisi } from "@/lib/ai/cagri";
|
import { sohbetAkisi } from "@/lib/ai/cagri";
|
||||||
import { listeOzetiCikar, type RaporSonuc } from "@/lib/ai/rapor";
|
import { listeOzetiCikar, type RaporSonuc } from "@/lib/ai/rapor";
|
||||||
import type { RaporParams } from "@/lib/rapor-havuzu";
|
import type { RaporParams } from "@/lib/rapor-havuzu";
|
||||||
@@ -118,10 +123,8 @@ export async function POST(request: NextRequest) {
|
|||||||
return Response.json({ error: "Geçersiz mesaj." }, { status: 400 });
|
return Response.json({ error: "Geçersiz mesaj." }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Soru sormak için önce liste oluşturulmuş olmalı
|
// Soru sormak için önce liste oluşturulmuş olmalı (bayat dev mock sayılmaz)
|
||||||
const rapor = await appDb.query.reports.findFirst({
|
const rapor = await kullanicininRaporu(session.user.id);
|
||||||
where: eq(schema.reports.userId, session.user.id),
|
|
||||||
});
|
|
||||||
if (!rapor?.result) {
|
if (!rapor?.result) {
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{ error: "Önce listeni oluşturmalısın.", code: "NO_REPORT" },
|
{ error: "Önce listeni oluşturmalısın.", code: "NO_REPORT" },
|
||||||
@@ -147,6 +150,7 @@ export async function POST(request: NextRequest) {
|
|||||||
});
|
});
|
||||||
if (!harcama.ok) {
|
if (!harcama.ok) {
|
||||||
if (harcama.error === "INSUFFICIENT") {
|
if (harcama.error === "INSUFFICIENT") {
|
||||||
|
await krediBittiIsaretle(session.user.id);
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{ error: "Kredin bitti.", code: "INSUFFICIENT" },
|
{ error: "Kredin bitti.", code: "INSUFFICIENT" },
|
||||||
{ status: 402 },
|
{ status: 402 },
|
||||||
|
|||||||
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" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
102
src/app/giris/dogrula/page.tsx
Normal file
102
src/app/giris/dogrula/page.tsx
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { ArrowRight } from "lucide-react";
|
||||||
|
import { PixelCorner } from "@/components/pixel-decor";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Girişini onayla",
|
||||||
|
robots: { index: false, follow: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Yalnızca site içi (göreli) callback'lere izin ver — open redirect kalkanı. */
|
||||||
|
function guvenliYol(deger: string | undefined, varsayilan: string): string {
|
||||||
|
return deger && deger.startsWith("/") && !deger.startsWith("//")
|
||||||
|
? deger
|
||||||
|
: varsayilan;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maildeki giriş bağlantısının indiği onay sayfası. Doğrulama ucu
|
||||||
|
* (/api/auth/magic-link/verify) GET'lendiği anda token'ı tüketir; mail
|
||||||
|
* güvenlik tarayıcıları ve tarayıcı ön-yüklemeleri linki kullanıcıdan önce
|
||||||
|
* açıp token'ı yakabildiği için maildeki link token'ı TÜKETMEYEN bu sayfaya
|
||||||
|
* gelir. Verify ancak buradaki form gönderilince tetiklenir — tarayıcı botları
|
||||||
|
* sayfayı GET'lese bile form'u göndermez, token yanmaz.
|
||||||
|
*/
|
||||||
|
export default async function DogrulaPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{
|
||||||
|
token?: string;
|
||||||
|
callbackURL?: string;
|
||||||
|
errorCallbackURL?: string;
|
||||||
|
newUserCallbackURL?: string;
|
||||||
|
}>;
|
||||||
|
}) {
|
||||||
|
const { token, callbackURL, errorCallbackURL, newUserCallbackURL } =
|
||||||
|
await searchParams;
|
||||||
|
if (!token) redirect("/giris");
|
||||||
|
|
||||||
|
const callback = guvenliYol(callbackURL, "/");
|
||||||
|
const hataCallback = guvenliYol(
|
||||||
|
errorCallbackURL,
|
||||||
|
`/giris?hata=gecersiz-baglanti&callback=${encodeURIComponent(callback)}`,
|
||||||
|
);
|
||||||
|
const yeniKullaniciCallback = guvenliYol(newUserCallbackURL, callback);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="-mb-28 flex min-h-dvh flex-col bg-slate-50 text-slate-900">
|
||||||
|
<main className="mx-auto flex w-full max-w-md flex-1 flex-col items-stretch justify-center px-6 py-8">
|
||||||
|
<div className="relative">
|
||||||
|
<div className="relative rounded-2xl border border-slate-200 bg-white p-8 shadow-sm">
|
||||||
|
<h1 className="font-heading text-xl font-bold">Girişini onayla</h1>
|
||||||
|
<p className="mt-2 text-sm text-slate-600">
|
||||||
|
Bu bağlantı senin için üretildi ve{" "}
|
||||||
|
<span className="font-semibold">tek kullanımlık</span>. Aşağıdaki
|
||||||
|
butona tıkladığında KolayTercih hesabına giriş yapacaksın.
|
||||||
|
</p>
|
||||||
|
<form
|
||||||
|
method="GET"
|
||||||
|
action="/api/auth/magic-link/verify"
|
||||||
|
className="mt-6"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="token" value={token} />
|
||||||
|
<input type="hidden" name="callbackURL" value={callback} />
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="errorCallbackURL"
|
||||||
|
value={hataCallback}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="newUserCallbackURL"
|
||||||
|
value={yeniKullaniciCallback}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="h-11 w-full cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||||
|
>
|
||||||
|
Giriş yap
|
||||||
|
<ArrowRight className="size-4" aria-hidden />
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
<p className="mt-4 text-xs leading-relaxed text-slate-500">
|
||||||
|
Bu isteği sen yapmadıysan bu sayfayı kapatabilirsin — butona
|
||||||
|
tıklanmadığı sürece hesabında hiçbir şey değişmez.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none absolute inset-0 z-10 text-primary"
|
||||||
|
>
|
||||||
|
<PixelCorner className="absolute -right-4 -top-4 size-9 [&_rect]:[fill-opacity:0.65]" />
|
||||||
|
<PixelCorner className="absolute -bottom-4 -right-4 size-9 rotate-90 [&_rect]:[fill-opacity:0.65]" />
|
||||||
|
<PixelCorner className="absolute -bottom-4 -left-4 size-9 rotate-180 [&_rect]:[fill-opacity:0.65]" />
|
||||||
|
<PixelCorner className="absolute -left-4 -top-4 size-9 -rotate-90 [&_rect]:[fill-opacity:0.65]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -74,6 +74,10 @@ export function GirisForm({ callbackURL }: { callbackURL: string }) {
|
|||||||
const { error } = await authClient.signIn.magicLink({
|
const { error } = await authClient.signIn.magicLink({
|
||||||
email,
|
email,
|
||||||
callbackURL,
|
callbackURL,
|
||||||
|
// Geçersiz/süresi dolmuş token'da kullanıcı ham ?error=INVALID_TOKEN
|
||||||
|
// parametresiyle baş başa kalmasın: giriş sayfası bu değeri dostça bir
|
||||||
|
// mesaja çevirir, callback korunur (bkz. src/app/giris/page.tsx).
|
||||||
|
errorCallbackURL: `/giris?hata=gecersiz-baglanti&callback=${encodeURIComponent(callbackURL)}`,
|
||||||
});
|
});
|
||||||
setBusy(null);
|
setBusy(null);
|
||||||
if (error) {
|
if (error) {
|
||||||
@@ -93,7 +97,7 @@ export function GirisForm({ callbackURL }: { callbackURL: string }) {
|
|||||||
<p className="mt-1">
|
<p className="mt-1">
|
||||||
<span className="font-medium">{email}</span> adresine bir giriş
|
<span className="font-medium">{email}</span> adresine bir giriş
|
||||||
bağlantısı gönderdik. Gelen kutunu (ve spam klasörünü) kontrol et —
|
bağlantısı gönderdik. Gelen kutunu (ve spam klasörünü) kontrol et —
|
||||||
bağlantı 5 dakika geçerli.
|
bağlantı 15 dakika geçerli.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-center gap-4 text-sm">
|
<div className="flex items-center justify-center gap-4 text-sm">
|
||||||
|
|||||||
@@ -14,11 +14,15 @@ export const metadata: Metadata = {
|
|||||||
export default async function GirisPage({
|
export default async function GirisPage({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
searchParams: Promise<{ callback?: string }>;
|
searchParams: Promise<{ callback?: string; hata?: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { callback } = await searchParams;
|
const { callback, hata } = await searchParams;
|
||||||
const callbackURL =
|
const callbackURL =
|
||||||
callback && callback.startsWith("/") ? callback : "/";
|
callback && callback.startsWith("/") ? callback : "/";
|
||||||
|
// Magic link doğrulaması başarısız olunca errorCallbackURL buraya düşürür
|
||||||
|
// (bkz. giris-form.tsx ve /giris/dogrula) — ham INVALID_TOKEN yerine
|
||||||
|
// dostça bir mesaj gösterip yeni bağlantı istemeye yönlendiriyoruz.
|
||||||
|
const baglantiHatasi = hata === "gecersiz-baglanti";
|
||||||
|
|
||||||
const session = await getSession();
|
const session = await getSession();
|
||||||
if (session) redirect(callbackURL);
|
if (session) redirect(callbackURL);
|
||||||
@@ -33,6 +37,20 @@ export default async function GirisPage({
|
|||||||
<main className="mx-auto flex w-full max-w-md flex-1 flex-col items-stretch justify-center px-6 py-8">
|
<main className="mx-auto flex w-full max-w-md flex-1 flex-col items-stretch justify-center px-6 py-8">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="relative rounded-2xl border border-slate-200 bg-white p-8 shadow-sm">
|
<div className="relative rounded-2xl border border-slate-200 bg-white p-8 shadow-sm">
|
||||||
|
{baglantiHatasi && (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="mb-5 rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-800"
|
||||||
|
>
|
||||||
|
<p className="font-semibold">
|
||||||
|
Giriş bağlantısı geçersiz ya da süresi dolmuş
|
||||||
|
</p>
|
||||||
|
<p className="mt-1">
|
||||||
|
Bağlantılar 15 dakika geçerli ve tek kullanımlık. Aşağıdan
|
||||||
|
yeni bir bağlantı isteyebilirsin.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<GirisBaslik funnel={funnel} />
|
<GirisBaslik funnel={funnel} />
|
||||||
<p className="mt-2 text-sm text-slate-600">
|
<p className="mt-2 text-sm text-slate-600">
|
||||||
{funnel ? (
|
{funnel ? (
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export default function IletisimPage() {
|
|||||||
</p>
|
</p>
|
||||||
<a
|
<a
|
||||||
href="mailto:destek@kolaytercih.com"
|
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 />
|
<Mail className="size-4 text-primary" aria-hidden />
|
||||||
destek@kolaytercih.com
|
destek@kolaytercih.com
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Toaster } from "@/components/ui/sonner";
|
|||||||
import { SiteHeader } from "@/components/site-header";
|
import { SiteHeader } from "@/components/site-header";
|
||||||
import { SiteTopBanner } from "@/components/site-top-banner";
|
import { SiteTopBanner } from "@/components/site-top-banner";
|
||||||
import { ListeCekmecesiLazy } from "@/components/manuel-liste/liste-cekmecesi-lazy";
|
import { ListeCekmecesiLazy } from "@/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 { TercihProfiliKapisiLazy } from "@/components/manuel-liste/tercih-profili-kapisi-lazy";
|
||||||
import { HeaderGate } from "@/components/header-gate";
|
import { HeaderGate } from "@/components/header-gate";
|
||||||
import { ProgressiveBlur } from "@/components/ui/skiper-ui/skiper41";
|
import { ProgressiveBlur } from "@/components/ui/skiper-ui/skiper41";
|
||||||
@@ -124,6 +125,8 @@ export default function RootLayout({
|
|||||||
</ScrollFlow>
|
</ScrollFlow>
|
||||||
{/* Manuel 24'lük liste çekmecesi + "+" uçuş katmanı (navbar'dan açılır) */}
|
{/* Manuel 24'lük liste çekmecesi + "+" uçuş katmanı (navbar'dan açılır) */}
|
||||||
<ListeCekmecesiLazy />
|
<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ı */}
|
{/* İlk "+" için sıra + sihirbaz kapısı */}
|
||||||
<TercihProfiliKapisiLazy />
|
<TercihProfiliKapisiLazy />
|
||||||
<Toaster position="top-center" />
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -26,7 +26,7 @@ export function ListemBosCta() {
|
|||||||
<Button
|
<Button
|
||||||
asChild
|
asChild
|
||||||
size="lg"
|
size="lg"
|
||||||
className="mt-6 h-12 cursor-pointer bg-orange-500 px-8 text-white hover:bg-orange-600"
|
className="mt-6 cursor-pointer bg-orange-500 text-white hover:bg-orange-600"
|
||||||
>
|
>
|
||||||
<Link href={profil ? sonucHref(profil, { sihirbaz: "1" }) : "/"}>
|
<Link href={profil ? sonucHref(profil, { sihirbaz: "1" }) : "/"}>
|
||||||
{profil ? "Yapay Zeka listeni kur" : "Sıralamanı gir, Yapay Zeka listeni kur"}
|
{profil ? "Yapay Zeka listeni kur" : "Sıralamanı gir, Yapay Zeka listeni kur"}
|
||||||
|
|||||||
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,38 +1,27 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
|
||||||
import { asc, eq } from "drizzle-orm";
|
import { asc, eq } from "drizzle-orm";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import {
|
import { MessageCircleQuestion, Sparkles } from "lucide-react";
|
||||||
ArrowRight,
|
|
||||||
Bot,
|
|
||||||
Check,
|
|
||||||
FileText,
|
|
||||||
Lock,
|
|
||||||
MessageCircleQuestion,
|
|
||||||
PencilLine,
|
|
||||||
ShieldCheck,
|
|
||||||
Sparkles,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { verifySession, getCurrentUser } from "@/lib/session";
|
import { verifySession, getCurrentUser } from "@/lib/session";
|
||||||
import { appDb, schema } from "@/lib/appdb";
|
import { appDb, schema } from "@/lib/appdb";
|
||||||
import { PUAN_TURLERI } from "@/lib/db";
|
import { kullanicininRaporu } from "@/lib/rapor-kaydi";
|
||||||
import { URUNLER, MAX_REVIZYON } from "@/lib/credits";
|
import { URUNLER, MAX_REVIZYON } from "@/lib/credits";
|
||||||
import type { RaporSonuc } from "@/lib/ai/rapor";
|
import type { RaporSonuc } from "@/lib/ai/rapor";
|
||||||
import type { RaporParams } from "@/lib/rapor-havuzu";
|
import type { RaporParams } from "@/lib/rapor-havuzu";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { RaporListesi } from "@/components/rapor-listesi";
|
|
||||||
import { ViewportPortal } from "@/components/viewport-portal";
|
import { ViewportPortal } from "@/components/viewport-portal";
|
||||||
import { SiteFooter } from "@/components/site-footer";
|
import { SiteFooter } from "@/components/site-footer";
|
||||||
import { SectionEyebrow } from "@/components/pixel-decor";
|
import { raporMaskele, ACIK_SATIR, type MaskeliRapor } from "@/lib/rapor-maske";
|
||||||
import { raporMaskele, ACIK_SATIR } from "@/lib/rapor-maske";
|
import type { Mesaj } from "./sohbet-client";
|
||||||
import { SohbetClient, type Mesaj } from "./sohbet-client";
|
import { ListemGovde } from "./listem-govde";
|
||||||
import { RevizyonKutusu } from "./revizyon-kutusu";
|
|
||||||
import { ListemBosCta } from "./listem-bos-cta";
|
import { ListemBosCta } from "./listem-bos-cta";
|
||||||
|
import { ListeUretici } from "./liste-uretici";
|
||||||
|
|
||||||
// listeRevize aksiyonu 2 AI denemesi yapabiliyor (deneme başına 50 sn abort
|
// listeRevize aksiyonu 2 AI denemesi yapabiliyor (deneme başına 60 sn abort
|
||||||
// sınırı, bkz. lib/ai/cagri.ts); platform limiti AI zaman aşımından SONRA
|
// sınırı, bkz. lib/ai/cagri.ts) → en kötü ~120 sn. Platform limiti bundan
|
||||||
// dolmalı ki kredi iadesi her zaman çalışabilsin.
|
// SONRA dolmalı ki kredi iadesi her zaman çalışabilsin. Self-hosted docker'da
|
||||||
export const maxDuration = 120;
|
// 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 = {
|
export const metadata: Metadata = {
|
||||||
title: "Listem",
|
title: "Listem",
|
||||||
@@ -46,15 +35,17 @@ const PAKET_FIYATI = (URUNLER.paket.amountKurus / 100).toLocaleString("tr-TR", {
|
|||||||
export default async function ListemPage({
|
export default async function ListemPage({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
searchParams: Promise<{ acildi?: 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([
|
const [user, params, satir, sohbetGecmisi] = await Promise.all([
|
||||||
getCurrentUser(),
|
getCurrentUser(),
|
||||||
searchParams,
|
searchParams,
|
||||||
appDb.query.reports.findFirst({
|
kullanicininRaporu(session.user.id),
|
||||||
where: eq(schema.reports.userId, session.user.id),
|
|
||||||
}),
|
|
||||||
appDb
|
appDb
|
||||||
.select({
|
.select({
|
||||||
id: schema.chatMessages.id,
|
id: schema.chatMessages.id,
|
||||||
@@ -66,234 +57,85 @@ export default async function ListemPage({
|
|||||||
.orderBy(asc(schema.chatMessages.createdAt))
|
.orderBy(asc(schema.chatMessages.createdAt))
|
||||||
.limit(100),
|
.limit(100),
|
||||||
]);
|
]);
|
||||||
if (!user) redirect("/giris?callback=/listem");
|
if (!user) redirect(`/giris?callback=${encodeURIComponent(geriYol)}`);
|
||||||
|
|
||||||
const { acildi } = params;
|
const { acildi } = params;
|
||||||
const kilitAcilisi = Boolean(user.hasPaket && acildi === "1");
|
const kilitAcilisi = Boolean(user.hasPaket && acildi === "1");
|
||||||
const raporVar = Boolean(satir?.result);
|
const raporVar = Boolean(satir?.result);
|
||||||
|
|
||||||
if (!raporVar) {
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<main className="mx-auto w-full max-w-4xl px-4 py-12">
|
<main className="mx-auto w-full max-w-4xl px-4 py-12">
|
||||||
<section className="mx-auto flex max-w-2xl flex-col items-center px-4 py-16 text-center">
|
{/* ?uret=1: sihirbazdan yönlendirildi — bekleyen seçimlerle üretim
|
||||||
<Sparkles className="size-10 text-primary" aria-hidden />
|
burada koşar, animasyon ve hata/kredi durumları burada yaşar. */}
|
||||||
<h1 className="mt-4 font-heading text-2xl font-bold">
|
{params.uret === "1" ? (
|
||||||
Henüz Yapay Zeka listen yok
|
<ListeUretici
|
||||||
</h1>
|
hasPaket={Boolean(user.hasPaket)}
|
||||||
<ListemBosCta />
|
bosDurum={bosDurum}
|
||||||
</section>
|
/>
|
||||||
|
) : (
|
||||||
|
bosDurum
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
<SiteFooter />
|
<SiteFooter />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const rapor = user.hasPaket
|
const rapor: MaskeliRapor = user.hasPaket
|
||||||
? (satir!.result as RaporSonuc)
|
? (satir!.result as RaporSonuc)
|
||||||
: raporMaskele(satir!.result as RaporSonuc);
|
: raporMaskele(satir!.result as RaporSonuc);
|
||||||
const raporParams = satir!.params as RaporParams;
|
const raporParams = satir!.params as RaporParams;
|
||||||
// Danışman özeti sohbetin açılış mesajı olarak gösterilir; sohbet
|
// Danışman özeti sohbetin açılış mesajı olarak gösterilir; sohbet
|
||||||
// /api/soru üzerinden zaten liste + seçim bağlamıyla çalışıyor.
|
// /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
|
const danismanOzeti = rapor.genelDegerlendirme
|
||||||
? {
|
? {
|
||||||
metin: rapor.genelDegerlendirme,
|
metin: rapor.genelDegerlendirme,
|
||||||
uyarilar: user.hasPaket ? rapor.uyarilar : undefined,
|
uyarilar: rapor.uyarilar,
|
||||||
kilitli: !user.hasPaket,
|
kilitli: !user.hasPaket,
|
||||||
|
kilitliUyariSayisi: user.hasPaket ? undefined : rapor.kilitliUyariSayisi,
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<main className="mx-auto w-full max-w-4xl px-4 py-12">
|
<main className="mx-auto w-full max-w-7xl px-4 py-12">
|
||||||
{kilitAcilisi ? (
|
{kilitAcilisi ? (
|
||||||
<div
|
<div
|
||||||
role="status"
|
role="status"
|
||||||
className="mb-6 flex items-center gap-3 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-medium text-emerald-800"
|
className="mb-6 flex items-center gap-3 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-medium text-emerald-800"
|
||||||
>
|
>
|
||||||
<Sparkles className="size-4 shrink-0" aria-hidden />
|
<Sparkles className="size-4 shrink-0" aria-hidden />
|
||||||
Paketin aktif — 24 tercihinin tamamı, gerekçeler, riskler ve PDF
|
Paketin aktif — listendeki tercihlerin tamamı, gerekçeler, riskler ve
|
||||||
artık açık.
|
PDF artık açık.
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
{/* Başlık ve revizyon/PDF aksiyonları listenin kendi başlık satırında
|
||||||
<div>
|
(bkz. listem-govde.tsx) — sayfa üstünde ayrı bir header yok. */}
|
||||||
{user.hasPaket ? (
|
<ListemGovde
|
||||||
<SectionEyebrow className="mb-3">Paketin aktif</SectionEyebrow>
|
rapor={rapor}
|
||||||
) : null}
|
adaySira={raporParams.sira}
|
||||||
<h1 className="max-w-2xl font-heading text-xl font-bold tracking-tight sm:text-2xl">
|
hasPaket={Boolean(user.hasPaket)}
|
||||||
{user.hasPaket
|
kilitAcilisi={kilitAcilisi}
|
||||||
? "Yapay Zeka tercih listen"
|
kilitliBaslangic={user.hasPaket ? undefined : ACIK_SATIR}
|
||||||
: "Veriyi ücretsiz incele. Kararı Yapay Zeka ile netleştir."}
|
kredi={user.creditBalance}
|
||||||
</h1>
|
sohbetGecmisi={sohbetGecmisi as Mesaj[]}
|
||||||
{user.hasPaket ? (
|
ozet={danismanOzeti}
|
||||||
<p className="mt-2 max-w-2xl text-sm leading-6 text-slate-600">
|
kalanHak={Math.max(0, MAX_REVIZYON - (satir!.revisionCount ?? 0))}
|
||||||
{raporParams.sira.toLocaleString("tr-TR")}. sıra ·{" "}
|
paketFiyati={PAKET_FIYATI}
|
||||||
{PUAN_TURLERI[raporParams.tur as keyof typeof PUAN_TURLERI] ??
|
paketKredi={URUNLER.paket.credits}
|
||||||
raporParams.tur}{" "}
|
/>
|
||||||
· 24 tercihinin tamamını gerekçe, risk analizi ve trendleriyle
|
|
||||||
aşağıda inceleyebilirsin.
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</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>
|
|
||||||
|
|
||||||
<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 Yapay Zeka 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
|
|
||||||
Yapay Zeka 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[]}
|
|
||||||
ozet={danismanOzeti}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<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">
|
|
||||||
<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",
|
|
||||||
`${URUNLER.paket.credits} 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">
|
|
||||||
{PAKET_FIYATI} TL
|
|
||||||
</p>
|
|
||||||
</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[]}
|
|
||||||
ozet={danismanOzeti}
|
|
||||||
className="h-[520px]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ViewportPortal>
|
<ViewportPortal>
|
||||||
<a
|
<a
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { AlertTriangle, ArrowUp, Bot, Coins, Lock, Sparkles } from "lucide-react";
|
import Image from "next/image";
|
||||||
|
import { AlertTriangle, ArrowUp, Coins, Lock, Sparkles } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -35,6 +36,26 @@ export interface DanismanOzeti {
|
|||||||
metin: string;
|
metin: string;
|
||||||
uyarilar?: string[];
|
uyarilar?: string[];
|
||||||
kilitli?: boolean;
|
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 = [
|
const ORNEK_SORULAR = [
|
||||||
@@ -100,6 +121,7 @@ export function SohbetClient({
|
|||||||
{ id: asistanId, role: "assistant", content: "" },
|
{ id: asistanId, role: "assistant", content: "" },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
let birikmis = "";
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/soru", {
|
const res = await fetch("/api/soru", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -130,7 +152,6 @@ export function SohbetClient({
|
|||||||
});
|
});
|
||||||
const reader = res.body!.getReader();
|
const reader = res.body!.getReader();
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
let birikmis = "";
|
|
||||||
for (;;) {
|
for (;;) {
|
||||||
const { done, value } = await reader.read();
|
const { done, value } = await reader.read();
|
||||||
if (done) break;
|
if (done) break;
|
||||||
@@ -144,7 +165,26 @@ export function SohbetClient({
|
|||||||
// harcamasını görmez; bakiye orada da güncellensin.
|
// harcamasını görmez; bakiye orada da güncellensin.
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} catch {
|
} 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 {
|
} finally {
|
||||||
setBekliyor(false);
|
setBekliyor(false);
|
||||||
}
|
}
|
||||||
@@ -157,7 +197,7 @@ export function SohbetClient({
|
|||||||
aria-label="Danışman sohbeti"
|
aria-label="Danışman sohbeti"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white",
|
"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">
|
<div className="flex items-center justify-between border-b border-slate-100 px-4 py-3">
|
||||||
@@ -177,9 +217,9 @@ export function SohbetClient({
|
|||||||
<ChatContainerContent className="gap-4 p-4">
|
<ChatContainerContent className="gap-4 p-4">
|
||||||
{ozet ? (
|
{ozet ? (
|
||||||
<Message className="justify-start">
|
<Message className="justify-start">
|
||||||
|
<DanismanAvatar />
|
||||||
<div className="flex max-w-[85%] flex-col gap-2">
|
<div className="flex max-w-[85%] flex-col gap-2">
|
||||||
<p className="flex items-center gap-1.5 text-xs font-semibold text-slate-500">
|
<p className="text-xs font-semibold text-slate-500">
|
||||||
<Bot className="size-3.5 text-primary" aria-hidden />
|
|
||||||
Danışman özeti
|
Danışman özeti
|
||||||
</p>
|
</p>
|
||||||
<MessageContent className="bg-slate-100 text-slate-800">
|
<MessageContent className="bg-slate-100 text-slate-800">
|
||||||
@@ -204,8 +244,17 @@ export function SohbetClient({
|
|||||||
{ozet.kilitli ? (
|
{ozet.kilitli ? (
|
||||||
<p className="flex items-center gap-1.5 text-xs text-slate-500">
|
<p className="flex items-center gap-1.5 text-xs text-slate-500">
|
||||||
<Lock className="size-3.5" aria-hidden />
|
<Lock className="size-3.5" aria-hidden />
|
||||||
Değerlendirmenin tamamı ve sıralamana özel uyarılar
|
{ozet.kilitliUyariSayisi ? (
|
||||||
pakette.
|
<>
|
||||||
|
{ozet.kilitliUyariSayisi} uyarı daha kilitli —
|
||||||
|
sıralamana özel uyarıların pakette.
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Değerlendirmenin tamamı ve sıralamana özel uyarılar
|
||||||
|
pakette.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -281,6 +330,7 @@ export function SohbetClient({
|
|||||||
</Message>
|
</Message>
|
||||||
) : (
|
) : (
|
||||||
<Message key={m.id} className="justify-start">
|
<Message key={m.id} className="justify-start">
|
||||||
|
<DanismanAvatar />
|
||||||
{m.content ? (
|
{m.content ? (
|
||||||
<MessageContent
|
<MessageContent
|
||||||
markdown
|
markdown
|
||||||
|
|||||||
@@ -18,11 +18,14 @@ flowchart TD
|
|||||||
H --> S{"Havuzda 24'ten az program mı kaldı?"}
|
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")
|
S -- "evet" --> G("Filtreler adım adım gevşetilir<br/>önce il, sonra alan — listede açıkça belirtilir")
|
||||||
G --> H
|
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 + risk notu ekler")
|
H --> U("Havuz yine de dar kalırsa<br/>liste eldeki programlarla kurulur, dağılım havuza uyarlanır ve sana açıkça söylenir")
|
||||||
Y --> L("24 tercihlik liste")
|
S -- "hayır" --> Y("Yapay zekâ havuzdan en fazla 24 tercih seçer<br/>havuzda olmayan bölümü yazamaz, her satıra gerekçe yazar")
|
||||||
L --> L1("üstte 4–6 hayal")
|
U --> Y
|
||||||
L --> L2("ortada 12–14 dengeli")
|
Y --> R("Risk notu ve trend özeti<br/>gerçek sıralama geçmişinden hesaplanır, yapay zekâ yorumu değildir")
|
||||||
L --> L3("sonda 5–7 garanti")
|
R --> L("En fazla 24 tercihlik liste")
|
||||||
|
L --> L1("üstte hayal — hedef 5, havuz elverdiğince")
|
||||||
|
L --> L2("ortada dengeli — hedef 13, havuz elverdiğince")
|
||||||
|
L --> L3("sonda garanti — hedef 6, havuz elverdiğince")
|
||||||
|
|
||||||
classDef havuz fill:#eff6ff,stroke:#3b82f6,color:#1e3a8a
|
classDef havuz fill:#eff6ff,stroke:#3b82f6,color:#1e3a8a
|
||||||
classDef uyari fill:#fffbeb,stroke:#f59e0b,color:#92400e
|
classDef uyari fill:#fffbeb,stroke:#f59e0b,color:#92400e
|
||||||
@@ -30,7 +33,7 @@ flowchart TD
|
|||||||
classDef dengeli fill:#fef3c7,stroke:#f59e0b,color:#92400e
|
classDef dengeli fill:#fef3c7,stroke:#f59e0b,color:#92400e
|
||||||
classDef garanti fill:#d1fae5,stroke:#10b981,color:#065f46
|
classDef garanti fill:#d1fae5,stroke:#10b981,color:#065f46
|
||||||
class H,Y havuz
|
class H,Y havuz
|
||||||
class G uyari
|
class G,U uyari
|
||||||
class L1 hayal
|
class L1 hayal
|
||||||
class L2 dengeli
|
class L2 dengeli
|
||||||
class L3 garanti`;
|
class L3 garanti`;
|
||||||
@@ -39,14 +42,15 @@ export function HuniSemasi() {
|
|||||||
return (
|
return (
|
||||||
<ElCizimiSema
|
<ElCizimiSema
|
||||||
tanim={SEMA}
|
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çer."
|
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, yine de dar kalırsa liste eldeki programlarla kurulur ve dağılım havuza uyarlanır; yapay zekâ havuzdan üstte hayal, ortada dengeli, sonda garanti olacak şekilde en fazla 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={
|
figcaption={
|
||||||
<>
|
<>
|
||||||
Her adım havuzu biraz daha daraltır; yapay zekâ listeyi bu havuzun{" "}
|
Her adım havuzu biraz daha daraltır; yapay zekâ listeyi bu havuzun{" "}
|
||||||
<span className="font-medium text-slate-700">
|
<span className="font-medium text-slate-700">
|
||||||
dışına asla çıkmadan
|
dışına asla çıkmadan
|
||||||
</span>{" "}
|
</span>{" "}
|
||||||
kurar.
|
kurar — havuz dar kalırsa liste eldeki programlarla kurulur ve bunu
|
||||||
|
listende açıkça görürsün.
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -150,7 +150,9 @@ export default function MeraklisinaPage() {
|
|||||||
<strong>aday havuzu</strong>{" "}
|
<strong>aday havuzu</strong>{" "}
|
||||||
olur ve yapay zekâya öyle gider: ona boş bir sayfa değil, bu havuz
|
olur ve yapay zekâya öyle gider: ona boş bir sayfa değil, bu havuz
|
||||||
verilir. Aşağıdaki şema akışın tamamını gösterir — havuz
|
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.
|
24'ün altına düşerse filtrelerin nasıl gevşetildiği, yine de
|
||||||
|
yetmezse listenin eldeki programlarla, sana açıkça belirtilerek
|
||||||
|
nasıl kurulduğu da dahil.
|
||||||
</p>
|
</p>
|
||||||
<HuniSemasi />
|
<HuniSemasi />
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { eq } from "drizzle-orm";
|
|||||||
import { ArrowRight, CheckCircle2, XCircle } from "lucide-react";
|
import { ArrowRight, CheckCircle2, XCircle } from "lucide-react";
|
||||||
import { verifySession } from "@/lib/session";
|
import { verifySession } from "@/lib/session";
|
||||||
import { appDb, schema } from "@/lib/appdb";
|
import { appDb, schema } from "@/lib/appdb";
|
||||||
|
import { kullanicininRaporu } from "@/lib/rapor-kaydi";
|
||||||
import { odemeyiSonuclandir } from "@/lib/odeme";
|
import { odemeyiSonuclandir } from "@/lib/odeme";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { PagePixelDivider } from "@/components/pixel-decor";
|
import { PagePixelDivider } from "@/components/pixel-decor";
|
||||||
@@ -48,12 +49,7 @@ export default async function OdemeSonucPage({
|
|||||||
|
|
||||||
// Rapor zaten üretildiyse ödül bir tık uzakta: kullanıcıyı doğrudan
|
// Rapor zaten üretildiyse ödül bir tık uzakta: kullanıcıyı doğrudan
|
||||||
// rapor sayfasına döndür ki blur çözülme animasyonuyla tam listeyi görsün.
|
// rapor sayfasına döndür ki blur çözülme animasyonuyla tam listeyi görsün.
|
||||||
const rapor = basarili
|
const rapor = basarili ? await kullanicininRaporu(session.user.id) : null;
|
||||||
? await appDb.query.reports.findFirst({
|
|
||||||
where: eq(schema.reports.userId, session.user.id),
|
|
||||||
columns: { id: true },
|
|
||||||
})
|
|
||||||
: null;
|
|
||||||
const listeHref = rapor ? "/listem?acildi=1" : null;
|
const listeHref = rapor ? "/listem?acildi=1" : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import {
|
import {
|
||||||
@@ -142,7 +143,14 @@ export default async function PaketPage({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-4 text-center text-xs text-slate-500">
|
<Image
|
||||||
|
src="/odeme/iyzico-logo-band.svg"
|
||||||
|
alt="iyzico ile Öde — Mastercard, Visa, American Express ve Troy ile güvenli ödeme"
|
||||||
|
width={429}
|
||||||
|
height={32}
|
||||||
|
className="mx-auto mt-5 h-5 w-auto"
|
||||||
|
/>
|
||||||
|
<p className="mt-3 text-center text-xs text-slate-500">
|
||||||
iyzico güvenli ödeme · Kart bilgilerin bize hiç ulaşmaz ·{" "}
|
iyzico güvenli ödeme · Kart bilgilerin bize hiç ulaşmaz ·{" "}
|
||||||
<Link
|
<Link
|
||||||
href="/kosullar#iade"
|
href="/kosullar#iade"
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { verifySession, getCurrentUser } from "@/lib/session";
|
import { verifySession, getCurrentUser } from "@/lib/session";
|
||||||
import { appDb, schema } from "@/lib/appdb";
|
import { kullanicininRaporu } from "@/lib/rapor-kaydi";
|
||||||
import { PUAN_TURLERI } from "@/lib/db";
|
import { PUAN_TURLERI } from "@/lib/db";
|
||||||
import type { RaporSonuc } from "@/lib/ai/rapor";
|
import type { RaporSonuc } from "@/lib/ai/rapor";
|
||||||
import type { RaporParams } from "@/lib/rapor-havuzu";
|
import type { RaporParams } from "@/lib/rapor-havuzu";
|
||||||
@@ -25,9 +24,7 @@ export default async function YazdirPage() {
|
|||||||
// Kullanıcı satırı ile rapor sorgusu bağımsız — paralel çöz
|
// Kullanıcı satırı ile rapor sorgusu bağımsız — paralel çöz
|
||||||
const [user, satir] = await Promise.all([
|
const [user, satir] = await Promise.all([
|
||||||
getCurrentUser(),
|
getCurrentUser(),
|
||||||
appDb.query.reports.findFirst({
|
kullanicininRaporu(session.user.id),
|
||||||
where: eq(schema.reports.userId, session.user.id),
|
|
||||||
}),
|
|
||||||
]);
|
]);
|
||||||
if (!user) redirect("/giris");
|
if (!user) redirect("/giris");
|
||||||
if (!user.hasPaket) redirect("/paket");
|
if (!user.hasPaket) redirect("/paket");
|
||||||
@@ -47,13 +44,21 @@ export default async function YazdirPage() {
|
|||||||
|
|
||||||
<header className="border-b-2 border-slate-900 pb-4">
|
<header className="border-b-2 border-slate-900 pb-4">
|
||||||
<h1 className="font-heading text-2xl font-bold">
|
<h1 className="font-heading text-2xl font-bold">
|
||||||
24 Tercihlik Kişisel Liste ve Risk Raporu
|
{rapor.tercihler.length} Tercihlik Kişisel Liste ve Risk Raporu
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-1 text-sm text-slate-600">
|
<p className="mt-1 text-sm text-slate-600">
|
||||||
Başarı sıralaması: {params.sira.toLocaleString("tr-TR")} · Puan
|
Başarı sıralaması: {params.sira.toLocaleString("tr-TR")} · Puan
|
||||||
türü: {PUAN_TURLERI[params.tur]}
|
türü: {PUAN_TURLERI[params.tur]}
|
||||||
{params.il ? ` · İl: ${params.il}` : ""} · {tarih}
|
{params.il ? ` · İl: ${params.il}` : ""} · {tarih}
|
||||||
</p>
|
</p>
|
||||||
|
{rapor.kapsam?.uyarlandi ? (
|
||||||
|
<p className="mt-1 text-sm text-slate-600">
|
||||||
|
Not: Tercihlerine göre {rapor.kapsam.toplam} uygun program bulundu;
|
||||||
|
liste {rapor.kapsam.dagilim.hayal} hayal ·{" "}
|
||||||
|
{rapor.kapsam.dagilim.dengeli} dengeli ·{" "}
|
||||||
|
{rapor.kapsam.dagilim.garanti} garanti olarak kuruldu.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section className="mt-5">
|
<section className="mt-5">
|
||||||
|
|||||||
@@ -3,10 +3,12 @@
|
|||||||
import { and, eq, sql } from "drizzle-orm";
|
import { and, eq, sql } from "drizzle-orm";
|
||||||
import { getSession, getCurrentUser } from "@/lib/session";
|
import { getSession, getCurrentUser } from "@/lib/session";
|
||||||
import { appDb, schema } from "@/lib/appdb";
|
import { appDb, schema } from "@/lib/appdb";
|
||||||
|
import { kullanicininRaporu } from "@/lib/rapor-kaydi";
|
||||||
import { PUAN_TURLERI, type PuanTuruKey } from "@/lib/db";
|
import { PUAN_TURLERI, type PuanTuruKey } from "@/lib/db";
|
||||||
import {
|
import {
|
||||||
spendCredits,
|
spendCredits,
|
||||||
grantCredits,
|
grantCredits,
|
||||||
|
krediBittiIsaretle,
|
||||||
RAPOR_KREDI,
|
RAPOR_KREDI,
|
||||||
MAX_REVIZYON,
|
MAX_REVIZYON,
|
||||||
} from "@/lib/credits";
|
} from "@/lib/credits";
|
||||||
@@ -42,8 +44,15 @@ function siraTurGecerli(sira: number, tur: string): tur is PuanTuruKey {
|
|||||||
function aiHataMesaji(err: unknown): string {
|
function aiHataMesaji(err: unknown): string {
|
||||||
if (err instanceof RaporUretimHatasi) return err.message;
|
if (err instanceof RaporUretimHatasi) return err.message;
|
||||||
// AbortSignal.timeout: DOMException "TimeoutError"; SDK'lar abort'u kendi
|
// AbortSignal.timeout: DOMException "TimeoutError"; SDK'lar abort'u kendi
|
||||||
// hata sınıflarıyla sarabiliyor (ör. APIUserAbortError) — ada göre yakala.
|
// hata sınıflarıyla sarabiliyor (ör. APIUserAbortError) ve openai/anthropic
|
||||||
if (err instanceof Error && /abort|timeout/i.test(err.name)) {
|
// 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?";
|
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") {
|
if (err instanceof Error && err.message === "AI_KEY_MISSING") {
|
||||||
@@ -113,6 +122,7 @@ export async function listeOlustur(input: {
|
|||||||
});
|
});
|
||||||
if (!harcama.ok) {
|
if (!harcama.ok) {
|
||||||
if (harcama.error === "INSUFFICIENT") {
|
if (harcama.error === "INSUFFICIENT") {
|
||||||
|
await krediBittiIsaretle(user.id);
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
code: "KREDI",
|
code: "KREDI",
|
||||||
@@ -136,9 +146,7 @@ export async function listeOlustur(input: {
|
|||||||
"Önceki denemen başarısız olduğu için kredin iade edilmişti. Tekrar dener misin?",
|
"Önceki denemen başarısız olduğu için kredin iade edilmişti. Tekrar dener misin?",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const mevcut = await appDb.query.reports.findFirst({
|
const mevcut = await kullanicininRaporu(user.id);
|
||||||
where: eq(schema.reports.userId, user.id),
|
|
||||||
});
|
|
||||||
if (mevcut?.result) {
|
if (mevcut?.result) {
|
||||||
const rapor = mevcut.result as RaporSonuc;
|
const rapor = mevcut.result as RaporSonuc;
|
||||||
return {
|
return {
|
||||||
@@ -216,9 +224,7 @@ export async function listeRevize(input: {
|
|||||||
return { ok: false, code: "HATA", error: "Geçersiz istek." };
|
return { ok: false, code: "HATA", error: "Geçersiz istek." };
|
||||||
}
|
}
|
||||||
|
|
||||||
const rapor = await appDb.query.reports.findFirst({
|
const rapor = await kullanicininRaporu(user.id);
|
||||||
where: eq(schema.reports.userId, user.id),
|
|
||||||
});
|
|
||||||
if (!rapor?.result) {
|
if (!rapor?.result) {
|
||||||
return { ok: false, code: "HATA", error: "Önce bir liste oluşturmalısın." };
|
return { ok: false, code: "HATA", error: "Önce bir liste oluşturmalısın." };
|
||||||
}
|
}
|
||||||
@@ -241,6 +247,7 @@ export async function listeRevize(input: {
|
|||||||
});
|
});
|
||||||
if (!harcama.ok) {
|
if (!harcama.ok) {
|
||||||
if (harcama.error === "INSUFFICIENT") {
|
if (harcama.error === "INSUFFICIENT") {
|
||||||
|
await krediBittiIsaretle(user.id);
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
code: "KREDI",
|
code: "KREDI",
|
||||||
@@ -264,9 +271,7 @@ export async function listeRevize(input: {
|
|||||||
"Önceki denemen başarısız olduğu için kredin iade edilmişti. Tekrar dener misin?",
|
"Önceki denemen başarısız olduğu için kredin iade edilmişti. Tekrar dener misin?",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const guncel = await appDb.query.reports.findFirst({
|
const guncel = await kullanicininRaporu(user.id);
|
||||||
where: eq(schema.reports.userId, user.id),
|
|
||||||
});
|
|
||||||
if (guncel?.result) {
|
if (guncel?.result) {
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
|
|||||||
@@ -1,401 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useMemo, useEffect, useRef, useState } from "react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { ArrowRight, 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") {
|
|
||||||
// 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="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">
|
|
||||||
{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 h-12 cursor-pointer bg-orange-500 px-8 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 (!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} Yapay Zeka 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 Yapay Zeka 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}
|
|
||||||
|
|
||||||
{/* Yapay Zeka 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,28 +1,20 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import {
|
import {
|
||||||
PUAN_TURLERI,
|
PUAN_TURLERI,
|
||||||
rankWindowFacets,
|
rankWindowFacets,
|
||||||
searchByRank,
|
searchByRank,
|
||||||
type PuanTuruKey,
|
type PuanTuruKey,
|
||||||
} from "@/lib/db";
|
} from "@/lib/db";
|
||||||
import { getSession, getCurrentUser } from "@/lib/session";
|
import { getSession } from "@/lib/session";
|
||||||
import { appDb, schema } from "@/lib/appdb";
|
import { kullanicininRaporu } from "@/lib/rapor-kaydi";
|
||||||
import type { RaporSonuc } from "@/lib/ai/rapor";
|
import { tadimlikSec } from "@/lib/tadimlik-havuzu";
|
||||||
import type { RaporParams } from "@/lib/rapor-havuzu";
|
|
||||||
import { raporMaskele } from "@/lib/rapor-maske";
|
|
||||||
import { SihirbazBolumu } from "./sihirbaz-cagri-karti";
|
import { SihirbazBolumu } from "./sihirbaz-cagri-karti";
|
||||||
import type { MevcutRapor } from "./liste-paneli";
|
import { TadimlikSatiri } from "./tadimlik-satiri";
|
||||||
import { ProgramTablosu } from "@/components/program-tablosu";
|
import { ProgramTablosu } from "@/components/program-tablosu";
|
||||||
import { ManuelHarita } from "@/components/manuel-liste/manuel-harita";
|
import { ManuelHarita } from "@/components/manuel-liste/manuel-harita";
|
||||||
import { SiteFooter } from "@/components/site-footer";
|
import { SiteFooter } from "@/components/site-footer";
|
||||||
import { SiraGerekli } from "./sira-gerekli";
|
import { SiraGerekli } from "./sira-gerekli";
|
||||||
|
|
||||||
// listeOlustur aksiyonu 2 AI denemesi yapabiliyor (deneme başına 50 sn abort
|
|
||||||
// sınırı, bkz. lib/ai/cagri.ts); platform limiti AI zaman aşımından SONRA
|
|
||||||
// dolmalı ki kredi iadesi her zaman çalışabilsin.
|
|
||||||
export const maxDuration = 120;
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Tercih Listeni Kur",
|
title: "Tercih Listeni Kur",
|
||||||
// Param uzayı sonsuz + içerik sıralamaya göre kişisel: indexlenmez ama
|
// Param uzayı sonsuz + içerik sıralamaya göre kişisel: indexlenmez ama
|
||||||
@@ -38,7 +30,6 @@ export default async function SonucPage({
|
|||||||
sira?: string;
|
sira?: string;
|
||||||
tur?: string;
|
tur?: string;
|
||||||
sihirbaz?: string;
|
sihirbaz?: string;
|
||||||
acildi?: string;
|
|
||||||
hazir?: string;
|
hazir?: string;
|
||||||
}>;
|
}>;
|
||||||
}) {
|
}) {
|
||||||
@@ -58,34 +49,22 @@ export default async function SonucPage({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sihirbaz için: kullanıcı durumu, kayıtlı rapor ve dinamik facet'ler.
|
// Sihirbaz için: kullanıcının kayıtlı raporu var mı? Listenin kendisi
|
||||||
// Rapor sorgusu yalnızca session.user.id'ye bağlı — getCurrentUser'ı
|
// /listem'de yaşar; burada yalnızca CTA'nın hangi hâlde render edileceği
|
||||||
// beklemeden paralel çalışır (seri DB zinciri kurma).
|
// için varlık bilgisi gerekir.
|
||||||
const session = await getSession();
|
const session = await getSession();
|
||||||
const [user, raporSatiri] = session
|
const raporSatiri = session
|
||||||
? await Promise.all([
|
? await kullanicininRaporu(session.user.id)
|
||||||
getCurrentUser(),
|
: null;
|
||||||
appDb.query.reports.findFirst({
|
|
||||||
where: eq(schema.reports.userId, session.user.id),
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
: [null, null];
|
|
||||||
|
|
||||||
const facetler = rankWindowFacets(sira, turKey);
|
const facetler = rankWindowFacets(sira, turKey);
|
||||||
// Yapay Zeka'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 });
|
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.
|
// Girişsiz tadımlık (İP-B): SSR'da kovaya uyan "genel" satır seçilir;
|
||||||
const hasPaket = Boolean(user?.hasPaket);
|
// sihirbaz kategorisi client'ta okunup /api/tadimlik ile kişiselleştirilir.
|
||||||
const mevcutRapor: MevcutRapor | null = raporSatiri?.result
|
// 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);
|
||||||
rapor: hasPaket
|
|
||||||
? (raporSatiri.result as RaporSonuc)
|
|
||||||
: raporMaskele(raporSatiri.result as RaporSonuc),
|
|
||||||
params: raporSatiri.params as RaporParams,
|
|
||||||
revisionCount: raporSatiri.revisionCount,
|
|
||||||
}
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-50 text-slate-900">
|
<div className="min-h-screen bg-slate-50 text-slate-900">
|
||||||
@@ -99,18 +78,21 @@ export default async function SonucPage({
|
|||||||
gövdesi; puan türü navbar'daki sıralama rozetinden değiştirilir */}
|
gövdesi; puan türü navbar'daki sıralama rozetinden değiştirilir */}
|
||||||
<ProgramTablosu sonuclar={hamSonuclar} adaySira={sira} tur={turKey} />
|
<ProgramTablosu sonuclar={hamSonuclar} adaySira={sira} tur={turKey} />
|
||||||
|
|
||||||
{/* Sihirbaz akışı (CTA + modal + Yapay Zeka listesi) — blurlu Yapay Zeka paneli
|
{/* Girişsiz tadımlık: gerçek rapor satırı formatında tek örnek.
|
||||||
sayfanın en altında yaşar */}
|
Havuzda içerik yoksa bileşen hiç render edilmez. */}
|
||||||
|
{!session ? (
|
||||||
|
<TadimlikSatiri sira={sira} tur={turKey} ilk={tadimlik} />
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Sihirbaz akışı (CTA + modal) — üretim ve liste /listem'de yaşar,
|
||||||
|
burada yalnızca funnel kartları render edilir */}
|
||||||
<SihirbazBolumu
|
<SihirbazBolumu
|
||||||
sira={sira}
|
sira={sira}
|
||||||
tur={turKey}
|
tur={turKey}
|
||||||
facetler={facetler}
|
facetler={facetler}
|
||||||
girisliMi={Boolean(session)}
|
girisliMi={Boolean(session)}
|
||||||
hasPaket={hasPaket}
|
raporVar={Boolean(raporSatiri?.result)}
|
||||||
kredi={user?.creditBalance ?? 0}
|
|
||||||
mevcutRapor={mevcutRapor}
|
|
||||||
otomatikAc={params.sihirbaz === "1"}
|
otomatikAc={params.sihirbaz === "1"}
|
||||||
acilis={params.acildi === "1"}
|
|
||||||
secimlerHazir={params.hazir === "1"}
|
secimlerHazir={params.hazir === "1"}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -3,29 +3,26 @@
|
|||||||
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
|
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { ArrowRight, X } from "lucide-react";
|
import { ArrowRight } from "lucide-react";
|
||||||
import { animate } from "motion";
|
import { animate } from "motion";
|
||||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
import { useReducedMotion } from "motion/react";
|
||||||
import { toast } from "sonner";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ViewportPortal } from "@/components/viewport-portal";
|
|
||||||
import { PixelField } from "@/components/pixel-decor";
|
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 type { SihirbazFacetleri } from "@/lib/db";
|
||||||
import {
|
import {
|
||||||
SIHIRBAZ_AC_EVENT,
|
SIHIRBAZ_AC_EVENT,
|
||||||
SIHIRBAZ_STORAGE_KEY,
|
SIHIRBAZ_STORAGE_KEY,
|
||||||
sihirbazDogrula,
|
sihirbazDogrula,
|
||||||
sonucHref,
|
|
||||||
tercihProfiliSil,
|
|
||||||
tercihProfiliYaz,
|
tercihProfiliYaz,
|
||||||
type SihirbazSecimleri,
|
type SihirbazSecimleri,
|
||||||
} from "@/lib/sihirbaz";
|
} from "@/lib/sihirbaz";
|
||||||
import { olay, siraKovasi } from "@/lib/analitik";
|
|
||||||
import { SihirbazModal } from "./sihirbaz-modal";
|
import { SihirbazModal } from "./sihirbaz-modal";
|
||||||
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
|
// SSR'da false, client'ta true — storage'a ancak mount sonrası dokunuruz
|
||||||
const bosAbone = () => () => {};
|
const bosAbone = () => () => {};
|
||||||
@@ -43,42 +40,35 @@ type BekleyenSecim = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Sonuç sayfasında sihirbaz akışını yöneten client orkestratör:
|
* 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
|
* - Adımları toplayan modal
|
||||||
* - Üretim/önizleme/liste paneli (sayfa gövdesinde)
|
* Liste bu sayfada üretilmez ve gösterilmez: sihirbaz tamamlanınca seçimler
|
||||||
* Akış: adımlar → kredi ile üretim → paketsizse progressive-blur önizleme,
|
* storage'a yazılır ve kullanıcı /listem'e yönlendirilir; üretim animasyonu,
|
||||||
* paketliyse tam liste. Girişsiz kullanıcı hero'dan seçimleri tamamlanmış
|
* kredi/hata durumları ve listenin kendisi orada yaşar. Girişsiz kullanıcı
|
||||||
* gelirse önce ücretsiz tabloyu görür; giriş CTA'sı "hazır seçimlerin
|
* hero'dan seçimleri tamamlanmış gelirse önce ücretsiz tabloyu görür; giriş
|
||||||
* kaydedilmesi" olarak panelde sunulur, girişten dönüşte üretim otomatik
|
* CTA'sı "hazır seçimlerin kaydedilmesi" olarak panelde sunulur, girişten
|
||||||
* başlar. Modal içinden ilerleyen girişsiz kullanıcı ise butonun söylediği
|
* dönüş doğrudan /listem'e düşer ve üretim orada başlar.
|
||||||
* gibi doğrudan girişe gider.
|
|
||||||
*/
|
*/
|
||||||
export function SihirbazBolumu({
|
export function SihirbazBolumu({
|
||||||
sira,
|
sira,
|
||||||
tur,
|
tur,
|
||||||
facetler,
|
facetler,
|
||||||
girisliMi,
|
girisliMi,
|
||||||
hasPaket,
|
raporVar,
|
||||||
kredi: baslangicKredi,
|
|
||||||
mevcutRapor,
|
|
||||||
otomatikAc,
|
otomatikAc,
|
||||||
acilis,
|
|
||||||
secimlerHazir,
|
secimlerHazir,
|
||||||
}: {
|
}: {
|
||||||
sira: number;
|
sira: number;
|
||||||
tur: string;
|
tur: string;
|
||||||
facetler: SihirbazFacetleri;
|
facetler: SihirbazFacetleri;
|
||||||
girisliMi: boolean;
|
girisliMi: boolean;
|
||||||
hasPaket: boolean;
|
/** Kullanıcının kayıtlı bir Yapay Zeka listesi var mı (listenin kendisi /listem'de) */
|
||||||
kredi: number;
|
raporVar: boolean;
|
||||||
mevcutRapor: MevcutRapor | null;
|
|
||||||
/** ?sihirbaz=1 ile gelindiyse modalı otomatik aç */
|
/** ?sihirbaz=1 ile gelindiyse modalı otomatik aç */
|
||||||
otomatikAc?: boolean;
|
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,
|
/** ?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
|
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;
|
secimlerHazir?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -87,36 +77,19 @@ export function SihirbazBolumu({
|
|||||||
|
|
||||||
const [elleAcik, setElleAcik] = useState(false);
|
const [elleAcik, setElleAcik] = useState(false);
|
||||||
const [elleKapandi, setElleKapandi] = useState(false);
|
const [elleKapandi, setElleKapandi] = useState(false);
|
||||||
const [kartGizle, setKartGizle] = useState(false);
|
|
||||||
const [bekleyen, setBekleyen] = useState<BekleyenSecim | null>(null);
|
const [bekleyen, setBekleyen] = useState<BekleyenSecim | null>(null);
|
||||||
// ?sihirbaz=1 fallback modalı storage okunmadan açılmasın: hero'dan gelen
|
// ?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.
|
// kayıtlı seçimler varken modal bir anlığına açılıp kapanıyordu.
|
||||||
const [depoOkundu, setDepoOkundu] = useState(false);
|
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);
|
|
||||||
// Revizyon için aynı desen (bkz. listem/revizyon-kutusu.tsx)
|
|
||||||
const revizyonIdRef = useRef<string | null>(null);
|
|
||||||
const otomatikBasladi = useRef(false);
|
const otomatikBasladi = useRef(false);
|
||||||
const bolumRef = useRef<HTMLDivElement>(null);
|
|
||||||
// Funnel CTA'ları (banner olayı dahil) tıklama anındaki bekleyen seçimi
|
// 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.
|
// görsün diye ref'te tutulur; olay dinleyicisi bir kez bağlanır.
|
||||||
const bekleyenRef = useRef<BekleyenSecim | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
const kaliciKapali = Boolean(sessionStorage.getItem(KAPATMA_KEY));
|
|
||||||
const ham = localStorage.getItem(SIHIRBAZ_STORAGE_KEY);
|
const ham = localStorage.getItem(SIHIRBAZ_STORAGE_KEY);
|
||||||
let yeniBekleyen: BekleyenSecim | null = null;
|
let yeniBekleyen: BekleyenSecim | null = null;
|
||||||
if (ham) {
|
if (ham) {
|
||||||
@@ -133,9 +106,8 @@ export function SihirbazBolumu({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
||||||
if (kaliciKapali) setKartGizle(true);
|
|
||||||
if (yeniBekleyen) {
|
if (yeniBekleyen) {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
setBekleyen(yeniBekleyen);
|
setBekleyen(yeniBekleyen);
|
||||||
bekleyenRef.current = yeniBekleyen;
|
bekleyenRef.current = yeniBekleyen;
|
||||||
}
|
}
|
||||||
@@ -151,19 +123,6 @@ export function SihirbazBolumu({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// 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() {
|
async function modalCikisiniOynat() {
|
||||||
if (hareketiAzalt) return;
|
if (hareketiAzalt) return;
|
||||||
|
|
||||||
@@ -202,85 +161,26 @@ export function SihirbazBolumu({
|
|||||||
await modalAnimasyonu;
|
await modalAnimasyonu;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function uret(secimler: SihirbazSecimleri) {
|
/**
|
||||||
if (!requestIdRef.current) requestIdRef.current = crypto.randomUUID();
|
* Sihirbaz tamamlandı: seçimleri storage'a yaz ve üretimin koştuğu
|
||||||
bolumuGorunureAl();
|
* /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();
|
await modalCikisiniOynat();
|
||||||
setElleAcik(false);
|
setElleAcik(false);
|
||||||
setDurum("uretiliyor");
|
|
||||||
|
|
||||||
try {
|
|
||||||
const sonuc = await listeOlustur({
|
|
||||||
sira,
|
|
||||||
tur,
|
|
||||||
secimler,
|
|
||||||
requestId: requestIdRef.current,
|
|
||||||
});
|
|
||||||
if (sonuc.ok) {
|
|
||||||
requestIdRef.current = null;
|
|
||||||
olay("liste_olusturuldu", { tur, sira_kovasi: siraKovasi(sira) });
|
|
||||||
setRapor({
|
|
||||||
rapor: sonuc.rapor,
|
|
||||||
params: sonuc.params,
|
|
||||||
revisionCount: sonuc.revisionCount,
|
|
||||||
});
|
|
||||||
setKredi(sonuc.kredi);
|
|
||||||
setPaketli(sonuc.hasPaket);
|
|
||||||
tercihProfiliSil();
|
|
||||||
// Yapay Zeka çı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");
|
|
||||||
// Header'daki kredi pill'i layout'ta server-render edilir; push layout'u
|
|
||||||
// yeniden çizmediği için düşen bakiye refresh ile güncellenir.
|
|
||||||
router.refresh();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Sunucudan cevap geldiyse harcama ya hiç olmadı ya iade edildi;
|
|
||||||
// sonraki deneme taze requestId ile başlar (aynı id ledger'daki UNIQUE'e
|
|
||||||
// takılıp DUPLICATE çıkmazına giriyordu). requestId yalnızca cevap
|
|
||||||
// alınamayan network hatasında (catch) korunur — orada çifte harcama
|
|
||||||
// riski gerçek.
|
|
||||||
requestIdRef.current = null;
|
|
||||||
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 = sonucHref({ sira, tur }, { sihirbaz: "1" });
|
|
||||||
router.push(`/giris?callback=${encodeURIComponent(geri)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ilerle(secimler: SihirbazSecimleri) {
|
|
||||||
setSonSecimler(secimler);
|
|
||||||
if (!girisliMi) {
|
if (!girisliMi) {
|
||||||
await modalCikisiniOynat();
|
router.push(`/giris?callback=${encodeURIComponent(LISTEM_URET_HREF)}`);
|
||||||
secimleriKaydetVeGirise(secimler);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await uret(secimler);
|
router.push(LISTEM_URET_HREF);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Funnel CTA'larının (banner, büyük kart, sabit alt kart) ortak girişi:
|
* Funnel CTA'larının (banner, büyük kart) ortak girişi:
|
||||||
* adımlar zaten tamamlanıp kaydedildiyse modal açılmaz — girişli
|
* adımlar zaten tamamlanıp kaydedildiyse modal açılmaz — girişli
|
||||||
* kullanıcıda üretim doğrudan başlar, girişsiz kullanıcı girişe gider.
|
* 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.
|
* Kayıtlı seçim yoksa (ya da başka sıra/türe aitse) modal açılır.
|
||||||
*/
|
*/
|
||||||
function sihirbaziBaslat() {
|
function sihirbaziBaslat() {
|
||||||
@@ -293,28 +193,28 @@ export function SihirbazBolumu({
|
|||||||
setElleKapandi(false);
|
setElleKapandi(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Giriş dönüşü: storage'daki eşleşen seçimlerle otomatik üretime başla
|
// Giriş dönüşü (?sihirbaz=1) storage'da eşleşen seçimlerle geldiyse modal
|
||||||
// (bir kez, modal açmadan — üretim sayfa gövdesinde oynar).
|
// açılmaz: seçimler hazır, üretim /listem'de başlasın (bir kez).
|
||||||
const otomatikUret = Boolean(bekleyen?.eslesir && girisliMi && !rapor);
|
const otomatikGonder = Boolean(
|
||||||
|
otomatikAc && bekleyen?.eslesir && girisliMi && !raporVar,
|
||||||
|
);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (otomatikUret && bekleyen && !otomatikBasladi.current) {
|
if (otomatikGonder && !otomatikBasladi.current) {
|
||||||
otomatikBasladi.current = true;
|
otomatikBasladi.current = true;
|
||||||
setSonSecimler(bekleyen.secimler);
|
// replace: geri tuşu bu sayfaya dönünce tekrar /listem'e sekmesin
|
||||||
void uret(bekleyen.secimler);
|
router.replace(LISTEM_URET_HREF);
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [otomatikUret]);
|
}, [otomatikGonder]);
|
||||||
|
|
||||||
// ?sihirbaz=1: otomatik üretim devreye girmediyse (seçim kaybolduysa/
|
// ?sihirbaz=1: otomatik yönlendirme devreye girmediyse (seçim kaybolduysa/
|
||||||
// eşleşmiyorsa) modalı aç ki kullanıcı adımları tamamlayabilsin. Karar
|
// eşleşmiyorsa) modalı aç ki kullanıcı adımları tamamlayabilsin. Karar
|
||||||
// storage okunmadan verilmez: kayıtlı seçimlerle gelen kullanıcıya modal
|
// storage okunmadan verilmez: kayıtlı seçimlerle gelen kullanıcıya modal
|
||||||
// hiç gösterilmez, üretim doğrudan başlar.
|
// hiç gösterilmez, /listem'e geçilir.
|
||||||
const acik =
|
const acik =
|
||||||
!elleKapandi &&
|
!elleKapandi &&
|
||||||
(elleAcik ||
|
(elleAcik ||
|
||||||
Boolean(
|
Boolean(otomatikAc && depoOkundu && !otomatikGonder && !raporVar));
|
||||||
otomatikAc && depoOkundu && !otomatikUret && !rapor && durum === "bos",
|
|
||||||
));
|
|
||||||
|
|
||||||
function acikDegisti(v: boolean) {
|
function acikDegisti(v: boolean) {
|
||||||
if (v) {
|
if (v) {
|
||||||
@@ -326,245 +226,113 @@ export function SihirbazBolumu({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const panelde = Boolean(rapor) || durum !== "bos";
|
|
||||||
|
|
||||||
// Değer önce: sihirbazı bitirmiş ama girişsiz kullanıcı ücretsiz tabloyu
|
// 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)
|
// 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.
|
// kaydedilmesi olarak sunulur. Girişten dönüşte üretim /listem'de başlar.
|
||||||
// Sinyal URL'den (?hazir=1) gelir; storage'a bakılsaydı panel anahtarı
|
// Sinyal URL'den (?hazir=1) gelir ki SSR'da doğru panel render edilsin.
|
||||||
// mount sonrası değişir ve AnimatePresence geçişi donardı.
|
const girisBekliyor = Boolean(!girisliMi && secimlerHazir && !raporVar);
|
||||||
const girisBekliyor = Boolean(!girisliMi && secimlerHazir && !rapor);
|
const girisUrl = `/giris?callback=${encodeURIComponent(LISTEM_URET_HREF)}`;
|
||||||
const girisUrl = `/giris?callback=${encodeURIComponent(
|
// Kapı 1 ölçümü: panel yalnızca girisBekliyor dalında render edilir; ref
|
||||||
sonucHref({ sira, tur }, { sihirbaz: "1" }),
|
// başka dalda bağlanmadığından observer da yalnızca o durumda kurulur.
|
||||||
)}`;
|
const hazirPanelRef = useGorunumOlayi<HTMLElement>("hazir_panel_goruntulendi");
|
||||||
|
|
||||||
const panelAnahtari =
|
|
||||||
durum !== "bos"
|
|
||||||
? durum
|
|
||||||
: rapor
|
|
||||||
? "rapor-hazir"
|
|
||||||
: girisBekliyor
|
|
||||||
? "giris-cta"
|
|
||||||
: "sihirbaz-cta";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Yapay Zeka çıktısı (rapor listesi/haritası) bu sayfada render edilmez;
|
{/* Yapay Zeka çıktısı (üretim animasyonu dahil) bu sayfada render
|
||||||
panel yalnızca üretim ve kredi-yetersiz gibi geçici funnel
|
edilmez; panel yalnızca funnel CTA'larını gösterir, üretim ve
|
||||||
durumlarını gösterir, listenin kendisi /listem'de yaşar. */}
|
listenin kendisi /listem'de yaşar. */}
|
||||||
<div ref={bolumRef} className="relative">
|
{raporVar ? (
|
||||||
<AnimatePresence mode="popLayout" initial={false}>
|
<section className="mt-16 rounded-2xl border border-slate-200 bg-white p-8 text-center sm:p-12">
|
||||||
<motion.div
|
<h2 className="font-heading text-2xl font-bold sm:text-3xl">
|
||||||
key={panelAnahtari}
|
24 tercihlik listen hazır
|
||||||
initial={
|
</h2>
|
||||||
hareketiAzalt
|
<p className="mx-auto mt-3 max-w-xl text-slate-600">
|
||||||
? { opacity: 0 }
|
Yapay zekânın kurduğu risk analizli listen Listem sayfasında
|
||||||
: {
|
seni bekliyor.
|
||||||
opacity: 0,
|
</p>
|
||||||
transform: "translate3d(0, 8px, 0)",
|
<Button
|
||||||
}
|
asChild
|
||||||
}
|
size="lg"
|
||||||
animate={{
|
className="mt-6 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||||
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],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{durum !== "bos" ? (
|
<Link href="/listem">Listeni gör</Link>
|
||||||
<ListePaneli
|
</Button>
|
||||||
durum={durum}
|
</section>
|
||||||
rapor={null}
|
) : girisBekliyor ? (
|
||||||
hasPaket={paketli}
|
<section
|
||||||
kredi={kredi}
|
ref={hazirPanelRef}
|
||||||
sira={sira}
|
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"
|
||||||
acilis={acilis}
|
>
|
||||||
onRevize={async (feedback) => {
|
<div
|
||||||
if (!revizyonIdRef.current) {
|
aria-hidden
|
||||||
revizyonIdRef.current = crypto.randomUUID();
|
className="pointer-events-none absolute inset-x-0 -inset-y-10 -z-10 text-white"
|
||||||
}
|
>
|
||||||
const sonuc = await listeRevize({
|
<PixelField seed={13} />
|
||||||
feedback,
|
|
||||||
requestId: revizyonIdRef.current,
|
|
||||||
});
|
|
||||||
revizyonIdRef.current = null;
|
|
||||||
if (sonuc.ok) {
|
|
||||||
olay("liste_revize", { sayfa: "sonuc" });
|
|
||||||
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="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>
|
|
||||||
<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}>
|
|
||||||
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>
|
|
||||||
)}
|
|
||||||
</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ı — Yapay Zeka listen hazır bekliyor"
|
|
||||||
: "Tek tek ekleme, yapay zekâ kursun"}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-slate-500">
|
|
||||||
{girisBekliyor
|
|
||||||
? "Ücretsiz giriş yap, 5 deneme kredinle listen otomatik kurulsun."
|
|
||||||
: "Üç adımda seçimlerini söyle; risk analizli, gerekçeli 24 tercih hazır."}
|
|
||||||
</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={sihirbaziBaslat}
|
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
<h2 className="relative font-heading text-2xl font-bold sm:text-3xl">
|
||||||
</ViewportPortal>
|
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
|
{monteEdildi ? (
|
||||||
üstlenir ve odağı panel içeriğine taşır. */}
|
|
||||||
{monteEdildi && durum === "bos" ? (
|
|
||||||
<SihirbazModal
|
<SihirbazModal
|
||||||
acik={acik}
|
acik={acik}
|
||||||
onAcikDegisti={acikDegisti}
|
onAcikDegisti={acikDegisti}
|
||||||
sira={sira}
|
sira={sira}
|
||||||
tur={tur}
|
tur={tur}
|
||||||
facetler={facetler}
|
facetler={facetler}
|
||||||
baslangicSecimler={
|
baslangicSecimler={bekleyen?.eslesir ? bekleyen.secimler : null}
|
||||||
sonSecimler ?? (bekleyen?.eslesir ? bekleyen.secimler : null)
|
|
||||||
}
|
|
||||||
sonButonEtiketi={
|
sonButonEtiketi={
|
||||||
girisliMi ? "Yapay Zeka listemi kur (3 kredi)" : "Giriş yap ve listeni gör"
|
girisliMi ? "Yapay Zeka listemi kur (3 kredi)" : "Giriş yap ve listeni gör"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import type { SihirbazSecimleri } from "@/lib/sihirbaz";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Sihirbaz modalı artık yalnızca adımları toplar; üretim, önizleme ve liste
|
* 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.
|
* sihirbaz-cagri-karti.tsx'teki SihirbazBolumu'ndadır.
|
||||||
*/
|
*/
|
||||||
export function SihirbazModal({
|
export function SihirbazModal({
|
||||||
|
|||||||
@@ -1,120 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
// Yapay Zeka 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 { Skeleton } from "@/components/ui/skeleton";
|
|
||||||
import { ViewportPortal } from "@/components/viewport-portal";
|
|
||||||
|
|
||||||
function SohbetSkeleton() {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
aria-busy="true"
|
|
||||||
aria-live="polite"
|
|
||||||
className="flex h-full flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-2xl"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between border-b border-slate-100 px-4 py-3">
|
|
||||||
<Skeleton className="h-5 w-36" />
|
|
||||||
<Skeleton className="h-6 w-20 rounded-full" />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-1 flex-col items-center gap-4 px-4 py-10">
|
|
||||||
<Skeleton className="size-7 rounded-full" />
|
|
||||||
<Skeleton className="h-5 w-56 max-w-full" />
|
|
||||||
<div className="w-full max-w-md space-y-2">
|
|
||||||
<Skeleton className="mx-auto h-3.5 w-full" />
|
|
||||||
<Skeleton className="mx-auto h-3.5 w-4/5" />
|
|
||||||
</div>
|
|
||||||
<div className="flex max-w-lg flex-wrap justify-center gap-2">
|
|
||||||
{[112, 148, 96, 132].map((w, i) => (
|
|
||||||
<Skeleton
|
|
||||||
key={i}
|
|
||||||
className="h-7 rounded-full"
|
|
||||||
style={{ width: w }}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="border-t border-slate-100 p-3">
|
|
||||||
<Skeleton className="h-24 w-full rounded-2xl" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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: () => <SohbetSkeleton />,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
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";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
ChartNoAxesCombined,
|
ChartNoAxesCombined,
|
||||||
@@ -76,12 +76,15 @@ const KANITLAR = [
|
|||||||
export function AramaFunnelKarti({ onSec }: { onSec: () => void }) {
|
export function AramaFunnelKarti({ onSec }: { onSec: () => void }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
const profil = useTercihProfili();
|
const profil = useTercihProfili();
|
||||||
const [metinler] = useState(() => karistir(FUNNEL_METINLERI));
|
const [metinler] = useState(() => karistir(FUNNEL_METINLERI));
|
||||||
|
|
||||||
function sihirbazaGit() {
|
function sihirbazaGit() {
|
||||||
onSec();
|
onSec();
|
||||||
if (pathname === "/sonuc") {
|
// 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));
|
window.dispatchEvent(new Event(SIHIRBAZ_AC_EVENT));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -89,7 +92,9 @@ export function AramaFunnelKarti({ onSec }: { onSec: () => void }) {
|
|||||||
router.push(sonucHref(profil, { sihirbaz: "1" }));
|
router.push(sonucHref(profil, { sihirbaz: "1" }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
profilToplamaAc("sonuc");
|
// 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 (
|
return (
|
||||||
@@ -134,7 +139,7 @@ export function AramaFunnelKarti({ onSec }: { onSec: () => void }) {
|
|||||||
<Button
|
<Button
|
||||||
size="lg"
|
size="lg"
|
||||||
onClick={sihirbazaGit}
|
onClick={sihirbazaGit}
|
||||||
className="h-11 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"
|
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
|
Yapay Zeka listemi kur
|
||||||
<ArrowRight className="size-4" aria-hidden />
|
<ArrowRight className="size-4" aria-hidden />
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
ProgramTabanTrendi,
|
ProgramTabanTrendi,
|
||||||
} from "@/components/program-liste-verileri";
|
} from "@/components/program-liste-verileri";
|
||||||
import { ProgramEkleButonu } from "@/components/manuel-liste/program-ekle-butonu";
|
import { ProgramEkleButonu } from "@/components/manuel-liste/program-ekle-butonu";
|
||||||
|
import { ProgramNetleriButonu } from "@/components/program-netleri";
|
||||||
import { UniLogo } from "@/components/uni-logo";
|
import { UniLogo } from "@/components/uni-logo";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
@@ -55,7 +56,7 @@ function SiralamaDugmesi({
|
|||||||
aktif && yon === "artan" ? "azalan" : "artan"
|
aktif && yon === "artan" ? "azalan" : "artan"
|
||||||
} sırala`}
|
} sırala`}
|
||||||
aria-pressed={aktif}
|
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>{etiket}</span>
|
||||||
<span className="flex flex-col -space-y-1" aria-hidden>
|
<span className="flex flex-col -space-y-1" aria-hidden>
|
||||||
@@ -180,8 +181,8 @@ export function BolumProgramListesi({
|
|||||||
onSirala={sirala}
|
onSirala={sirala}
|
||||||
/>
|
/>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="w-14 text-right">
|
<TableHead className="w-24 text-right">
|
||||||
<span className="sr-only">Listeye ekle</span>
|
<span className="sr-only">Netler ve listeye ekle</span>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
@@ -254,11 +255,19 @@ export function BolumProgramListesi({
|
|||||||
</span>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="pr-3 text-right">
|
<TableCell className="pr-3 text-right">
|
||||||
<ProgramEkleButonu
|
<span className="inline-flex items-center gap-1.5">
|
||||||
program={p}
|
<ProgramNetleriButonu
|
||||||
kaynak="bolum"
|
programId={p.id}
|
||||||
className="size-8 min-h-8 min-w-8"
|
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>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ const KANITLAR = [
|
|||||||
*
|
*
|
||||||
* Profilsiz CTA adayı ana sayfaya göndermez: buton alttan bir çekmece açar,
|
* 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.
|
* sıralama girişi ve sihirbaz adayın bulunduğu sayfanın bağlamında akar.
|
||||||
* Tasarım gerekçesi için bkz. CTA-CEKMECE-TASARIMI.md.
|
* Tasarım gerekçesi için bkz. docs/tasarim/cta-cekmece-tasarimi.md.
|
||||||
*/
|
*/
|
||||||
export function CtaSiraForm({ baslik }: { baslik?: string }) {
|
export function CtaSiraForm({ baslik }: { baslik?: string }) {
|
||||||
const profil = useTercihProfili();
|
const profil = useTercihProfili();
|
||||||
@@ -153,7 +153,7 @@ export function CtaSiraForm({ baslik }: { baslik?: string }) {
|
|||||||
</p>
|
</p>
|
||||||
<Link
|
<Link
|
||||||
href={sonucHref(profil)}
|
href={sonucHref(profil)}
|
||||||
className="mt-4 inline-flex h-11 items-center justify-center gap-2 rounded-lg bg-orange-500 px-6 text-sm font-semibold text-white transition-colors duration-200 hover:bg-orange-600"
|
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
|
Sıralamana uygun programları gör
|
||||||
<ArrowRight className="size-4" aria-hidden />
|
<ArrowRight className="size-4" aria-hidden />
|
||||||
|
|||||||
@@ -3,14 +3,17 @@
|
|||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
// Site kromunun (banner, header, üst/alt blur) gizleneceği rotalar
|
// Site kromunun (banner, header, üst/alt blur) gizleneceği rotalar
|
||||||
// (tam eşleşme veya alt yol)
|
// (tam eşleşme veya alt yol). KaydetBanner da aynı listeye uyar.
|
||||||
const HIDDEN_PREFIXES = ["/giris"];
|
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 }) {
|
export function HeaderGate({ children }: { children: React.ReactNode }) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const gizli = HIDDEN_PREFIXES.some(
|
if (kromGizliMi(pathname)) return null;
|
||||||
(p) => pathname === p || pathname.startsWith(`${p}/`),
|
|
||||||
);
|
|
||||||
if (gizli) return null;
|
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export function HeroFocusButton() {
|
|||||||
type="button"
|
type="button"
|
||||||
size="lg"
|
size="lg"
|
||||||
onClick={handleClick}
|
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
|
Ücretsiz dene
|
||||||
</Button>
|
</Button>
|
||||||
@@ -47,7 +47,7 @@ export function KapanisCta() {
|
|||||||
<Button
|
<Button
|
||||||
asChild
|
asChild
|
||||||
size="lg"
|
size="lg"
|
||||||
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"
|
||||||
>
|
>
|
||||||
<Link href={sonucHref(profil)}>
|
<Link href={sonucHref(profil)}>
|
||||||
Üniversitelere göz at
|
Üniversitelere göz at
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ export function HeroForm() {
|
|||||||
<Button
|
<Button
|
||||||
asChild
|
asChild
|
||||||
size="lg"
|
size="lg"
|
||||||
className="h-12 w-full cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600 sm:w-auto"
|
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)}>
|
<Link href={sonucHref(kayitliProfil)}>
|
||||||
Üniversitelere göz at
|
Üniversitelere göz at
|
||||||
@@ -140,7 +140,9 @@ export function HeroForm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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
|
<div
|
||||||
role="radiogroup"
|
role="radiogroup"
|
||||||
aria-label="Puan türü"
|
aria-label="Puan türü"
|
||||||
@@ -190,9 +192,9 @@ export function HeroForm() {
|
|||||||
type="submit"
|
type="submit"
|
||||||
size="lg"
|
size="lg"
|
||||||
disabled={yukleniyor}
|
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…" : "Yapay Zeka listemi kur"}
|
Yapay Zeka listemi kur
|
||||||
<ArrowRight className="size-4" aria-hidden />
|
<ArrowRight className="size-4" aria-hidden />
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -87,7 +87,9 @@ export function KatalogArama({ rehberler }: { rehberler: RehberOzet[] }) {
|
|||||||
const [acik, setAcik] = useState(false);
|
const [acik, setAcik] = useState(false);
|
||||||
|
|
||||||
function siralamaGirVeyaSonucaGit() {
|
function siralamaGirVeyaSonucaGit() {
|
||||||
const { href } = sonucaGitIste();
|
// Navbar CTA'sı "Sıralamanı gir" dediği için kapı tanıtım adımını
|
||||||
|
// atlayıp doğrudan başarı sıralaması adımıyla açılır.
|
||||||
|
const { href } = sonucaGitIste({ dogrudanSira: true });
|
||||||
if (href) router.push(href);
|
if (href) router.push(href);
|
||||||
}
|
}
|
||||||
const [sorgu, setSorgu] = useState("");
|
const [sorgu, setSorgu] = useState("");
|
||||||
|
|||||||
87
src/components/manuel-liste/cihaz-ikonlari.tsx
Normal file
87
src/components/manuel-liste/cihaz-ikonlari.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// "Listen yalnızca bu tarayıcıda" satırları için cihaz ikonları.
|
||||||
|
//
|
||||||
|
// - IsletimSistemiLogosu: kullanıcının şu an kullandığı işletim sisteminin
|
||||||
|
// logosu (Apple / Windows / Android). UA yalnızca client'ta okunur; ilk
|
||||||
|
// boyada nötr Monitor ikonu basılır ki SSR/hydration uyuşmazlığı olmasın.
|
||||||
|
// iPad "Mac" olarak görünebilir ama ikisi de Apple logosuna düştüğü için
|
||||||
|
// sorun değil; Linux ve bilinmeyenler Monitor'da kalır.
|
||||||
|
// - CihazCifti: "her cihazdan eriş" vurgusu için telefon + bilgisayar ikonu.
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Monitor, Smartphone } from "lucide-react";
|
||||||
|
|
||||||
|
type Isletim = "apple" | "windows" | "android" | null;
|
||||||
|
|
||||||
|
function isletimSistemi(): Isletim {
|
||||||
|
const ua = navigator.userAgent;
|
||||||
|
if (/iPhone|iPad|iPod|Mac/i.test(ua)) return "apple";
|
||||||
|
if (/Android/i.test(ua)) return "android";
|
||||||
|
if (/Win/i.test(ua)) return "windows";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppleLogo({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
className={className}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<path d="M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WindowsLogo({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
className={className}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<path d="M3 3h8.6v8.6H3zM12.4 3H21v8.6h-8.6zM3 12.4h8.6V21H3zM12.4 12.4H21V21h-8.6z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AndroidLogo({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
className={className}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<path d="M17.523 15.3414c-.5511 0-.9993-.4486-.9993-.9997s.4482-.9993.9993-.9993c.5511 0 .9993.4482.9993.9993.0001.5511-.4482.9997-.9993.9997m-11.046 0c-.5511 0-.9993-.4486-.9993-.9997s.4482-.9993.9993-.9993c.5511 0 .9993.4482.9993.9993 0 .5511-.4482.9997-.9993.9997m11.4045-6.02l1.9973-3.4592a.416.416 0 0 0-.1521-.5676.416.416 0 0 0-.5676.1521l-2.0223 3.503C15.5902 8.2439 13.8533 7.8508 12 7.8508s-3.5902.3931-5.1367 1.0989L4.841 5.4467a.4161.4161 0 0 0-.5677-.1521.4157.4157 0 0 0-.1521.5676l1.9973 3.4592C2.6889 11.1867.3432 14.6589 0 18.761h24c-.3435-4.1021-2.6892-7.5743-6.1185-9.4396" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IsletimSistemiLogosu({ className }: { className?: string }) {
|
||||||
|
const [os, setOs] = useState<Isletim>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
setOs(isletimSistemi());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (os === "apple") return <AppleLogo className={className} />;
|
||||||
|
if (os === "windows") return <WindowsLogo className={className} />;
|
||||||
|
if (os === "android") return <AndroidLogo className={className} />;
|
||||||
|
return <Monitor className={className} aria-hidden />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CihazCifti({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center gap-1 ${className ?? ""}`}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<Smartphone className="size-3.5" />
|
||||||
|
<Monitor className="size-3.5" />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
63
src/components/manuel-liste/dogrula.ts
Normal file
63
src/components/manuel-liste/dogrula.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
// Manuel liste kaydının saf doğrulama katmanı. store.ts ("use client")
|
||||||
|
// buradan re-export eder; kayitli-liste-actions.ts ("use server") sunucuda
|
||||||
|
// aynı süzgeci kullanır — client/sunucu iki ayrı doğrulama yazılmaz.
|
||||||
|
|
||||||
|
import type { RiskSeviyesi } from "@/lib/risk";
|
||||||
|
|
||||||
|
export const MANUEL_LISTE_MAX = 24;
|
||||||
|
|
||||||
|
export type ManuelKaynak = "sonuc" | "bolum" | "universite";
|
||||||
|
|
||||||
|
export type ManuelTercih = {
|
||||||
|
/** yokatlas program id'si */
|
||||||
|
id: string;
|
||||||
|
isim: string;
|
||||||
|
universite: string;
|
||||||
|
il: string | null;
|
||||||
|
unitur: string | null;
|
||||||
|
tur: string | null;
|
||||||
|
efektifSira: number | null;
|
||||||
|
/** Ekleme / profil anındaki aday sıralamasına göre risk */
|
||||||
|
risk: RiskSeviyesi | null;
|
||||||
|
kaynak?: ManuelKaynak;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Güvenilmeyen JSON'u (localStorage veya sunucu kaydı) listeye süzer. */
|
||||||
|
export function dogrula(ham: unknown): ManuelTercih[] {
|
||||||
|
if (!Array.isArray(ham)) return [];
|
||||||
|
const sonuc: ManuelTercih[] = [];
|
||||||
|
for (const t of ham) {
|
||||||
|
if (typeof t !== "object" || t === null) continue;
|
||||||
|
const kayit = t as Record<string, unknown>;
|
||||||
|
if (typeof kayit.id !== "string" || typeof kayit.isim !== "string") continue;
|
||||||
|
const risk: RiskSeviyesi | null =
|
||||||
|
kayit.risk === "guvenli" ||
|
||||||
|
kayit.risk === "az-riskli" ||
|
||||||
|
kayit.risk === "riskli"
|
||||||
|
? kayit.risk
|
||||||
|
: null;
|
||||||
|
const kaynak: ManuelKaynak | undefined =
|
||||||
|
kayit.kaynak === "sonuc" ||
|
||||||
|
kayit.kaynak === "bolum" ||
|
||||||
|
kayit.kaynak === "universite"
|
||||||
|
? kayit.kaynak
|
||||||
|
: undefined;
|
||||||
|
sonuc.push({
|
||||||
|
id: kayit.id,
|
||||||
|
isim: kayit.isim,
|
||||||
|
universite: typeof kayit.universite === "string" ? kayit.universite : "",
|
||||||
|
il: typeof kayit.il === "string" ? kayit.il : null,
|
||||||
|
unitur: typeof kayit.unitur === "string" ? kayit.unitur : null,
|
||||||
|
tur: typeof kayit.tur === "string" ? kayit.tur : null,
|
||||||
|
efektifSira:
|
||||||
|
typeof kayit.efektifSira === "number" &&
|
||||||
|
Number.isFinite(kayit.efektifSira)
|
||||||
|
? kayit.efektifSira
|
||||||
|
: null,
|
||||||
|
risk,
|
||||||
|
kaynak,
|
||||||
|
});
|
||||||
|
if (sonuc.length >= MANUEL_LISTE_MAX) break;
|
||||||
|
}
|
||||||
|
return sonuc;
|
||||||
|
}
|
||||||
21
src/components/manuel-liste/giris-url.ts
Normal file
21
src/components/manuel-liste/giris-url.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// Mevcut sayfaya (query dahil) geri dönen /giris URL'i. Render'ı saf tutmak
|
||||||
|
// için window.location yalnızca effect'te okunur; ilk render callback'siz
|
||||||
|
// /giris ile başlar (banner/çekmece zaten mount sonrası görünür).
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
|
export function useGirisUrl(): string {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const [url, setUrl] = useState("/giris");
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const hedef = `${window.location.pathname}${window.location.search}`;
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
setUrl(`/giris?callback=${encodeURIComponent(hedef)}`);
|
||||||
|
} catch {}
|
||||||
|
}, [pathname]);
|
||||||
|
return url;
|
||||||
|
}
|
||||||
11
src/components/manuel-liste/kaydet-banner-lazy.tsx
Normal file
11
src/components/manuel-liste/kaydet-banner-lazy.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// Banner yalnızca girişsiz + 3 seçimli kullanıcıda görünür; baseline
|
||||||
|
// bundle'a girmesin diye ssr'siz dynamic import (liste-cekmecesi-lazy deseni).
|
||||||
|
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
|
||||||
|
export const KaydetBannerLazy = dynamic(
|
||||||
|
() => import("./kaydet-banner").then((m) => m.KaydetBanner),
|
||||||
|
{ ssr: false },
|
||||||
|
);
|
||||||
110
src/components/manuel-liste/kaydet-banner.tsx
Normal file
110
src/components/manuel-liste/kaydet-banner.tsx
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// C2 yumuşak banner: girişsiz kullanıcının manuel listesi 3. programa
|
||||||
|
// ulaşınca bir kez görünen "listen yalnızca bu tarayıcıda" hatırlatması.
|
||||||
|
// /sonuc (funnel panelleriyle yarışmasın) ve giriş gibi kromsuz sayfalar
|
||||||
|
// hariç her yerde görünür; kapatma oturum boyunca kalıcıdır (sessionStorage).
|
||||||
|
// Exit-intent/agresif popup YOK (PRD kuralı).
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { ArrowRight, X } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ViewportPortal } from "@/components/viewport-portal";
|
||||||
|
import { useGorunumOlayi } from "@/components/use-gorunum-olayi";
|
||||||
|
import { olay } from "@/lib/analitik";
|
||||||
|
import { kromGizliMi } from "@/components/header-gate";
|
||||||
|
import { CihazCifti, IsletimSistemiLogosu } from "./cihaz-ikonlari";
|
||||||
|
import { useManuelListe } from "./store";
|
||||||
|
import { useOturumDurumu } from "./oturum-sinyali";
|
||||||
|
import { useGirisUrl } from "./giris-url";
|
||||||
|
|
||||||
|
const KAPATMA_KEY = "kolaytercih.kaydet-banner-kapali";
|
||||||
|
const ESIK = 3;
|
||||||
|
|
||||||
|
export function KaydetBanner() {
|
||||||
|
const liste = useManuelListe();
|
||||||
|
const girisli = useOturumDurumu();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const girisUrl = useGirisUrl();
|
||||||
|
|
||||||
|
// SSR/ilk boyada gizli başla; kapatma durumu mount'ta okunur.
|
||||||
|
const [kapali, setKapali] = useState(true);
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
setKapali(Boolean(sessionStorage.getItem(KAPATMA_KEY)));
|
||||||
|
} catch {
|
||||||
|
setKapali(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const gorunumRef = useGorunumOlayi<HTMLDivElement>(
|
||||||
|
"kaydet_banner_goruntulendi",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (girisli !== false || liste.length < ESIK || kapali) return null;
|
||||||
|
|
||||||
|
// /sonuc'ta gösterilmez: sayfanın kendi funnel panelleri var.
|
||||||
|
if (pathname?.startsWith("/sonuc")) return null;
|
||||||
|
|
||||||
|
// Giriş gibi kromsuz sayfalarda gösterilmez: kullanıcı zaten giriş
|
||||||
|
// akışındayken "Giriş yap" CTA'sı gereksiz gürültü olur.
|
||||||
|
if (pathname && kromGizliMi(pathname)) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ViewportPortal>
|
||||||
|
<div className="pointer-events-none fixed inset-x-0 bottom-4 z-40 px-4">
|
||||||
|
<div
|
||||||
|
ref={gorunumRef}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{/* Kullanıcının o anki işletim sisteminin logosu: "bu tarayıcıda"
|
||||||
|
mesajını somutlaştırır */}
|
||||||
|
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl border border-slate-200 bg-slate-50 text-slate-700">
|
||||||
|
<IsletimSistemiLogosu className="size-5" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="font-heading text-sm font-bold text-slate-900">
|
||||||
|
Listen yalnızca bu tarayıcıda
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
Giriş yap; {liste.length} seçimin hesabında saklansın, her
|
||||||
|
cihazdan aç{" "}
|
||||||
|
<CihazCifti className="ml-0.5 align-[-3px] text-slate-400" />
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
size="sm"
|
||||||
|
className="cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href={girisUrl}
|
||||||
|
onClick={() =>
|
||||||
|
olay("giris_cta_tiklandi", { kaynak: "liste_kaydet_banner" })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Giriş yap, kaydet
|
||||||
|
<ArrowRight className="size-4" aria-hidden />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Kapat"
|
||||||
|
onClick={() => {
|
||||||
|
setKapali(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>
|
||||||
|
</div>
|
||||||
|
</ViewportPortal>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
src/components/manuel-liste/kayitli-liste-actions.ts
Normal file
91
src/components/manuel-liste/kayitli-liste-actions.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
// Manuel liste + sihirbaz profilinin sunucu kaydı (İP-C). Upsert deseni
|
||||||
|
// reports ile aynıdır (bkz. sonuc/actions.ts): userId unique, tek satır.
|
||||||
|
//
|
||||||
|
// Senkron kuralları (PRD C1):
|
||||||
|
// - Giriş anında listeSenkronla: çakışmada CLIENT kazanır (lokal sıra başa,
|
||||||
|
// sunucudaki eksik id'ler sona), 24 sınırı korunur; idempotent — aynı
|
||||||
|
// girdiyle tekrar çağrı aynı sonucu üretir, çift kayıt imkânsız.
|
||||||
|
// - Girişliyken listeyiKaydet: localStorage tek kaynaktır, sunucu kaydının
|
||||||
|
// üzerine yazılır (son yazan cihaz kazanır).
|
||||||
|
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { getSession } from "@/lib/session";
|
||||||
|
import { appDb, schema } from "@/lib/appdb";
|
||||||
|
import { tercihProfiliDogrula, type TercihProfili } from "@/lib/sihirbaz";
|
||||||
|
import {
|
||||||
|
MANUEL_LISTE_MAX,
|
||||||
|
dogrula,
|
||||||
|
type ManuelTercih,
|
||||||
|
} from "@/components/manuel-liste/dogrula";
|
||||||
|
|
||||||
|
export type ListeSenkronSonucu =
|
||||||
|
| { ok: true; items: ManuelTercih[]; profil: TercihProfili | null }
|
||||||
|
| { ok: false };
|
||||||
|
|
||||||
|
async function kaydiYaz(
|
||||||
|
userId: string,
|
||||||
|
items: ManuelTercih[],
|
||||||
|
profil: TercihProfili | null,
|
||||||
|
): Promise<void> {
|
||||||
|
const now = new Date();
|
||||||
|
await appDb
|
||||||
|
.insert(schema.savedLists)
|
||||||
|
.values({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
userId,
|
||||||
|
items,
|
||||||
|
profil,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: schema.savedLists.userId,
|
||||||
|
set: { items, profil, updatedAt: now },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Giriş anı merge'i: lokal + sunucu birleşir, kanonik sonuç döner. */
|
||||||
|
export async function listeSenkronla(girdi: {
|
||||||
|
items: unknown;
|
||||||
|
profil: unknown;
|
||||||
|
}): Promise<ListeSenkronSonucu> {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session) return { ok: false };
|
||||||
|
const userId = session.user.id;
|
||||||
|
|
||||||
|
const clientItems = dogrula(girdi.items);
|
||||||
|
const clientProfil = tercihProfiliDogrula(girdi.profil);
|
||||||
|
|
||||||
|
const mevcut = await appDb.query.savedLists.findFirst({
|
||||||
|
where: eq(schema.savedLists.userId, userId),
|
||||||
|
});
|
||||||
|
const sunucuItems = dogrula(mevcut?.items);
|
||||||
|
const sunucuProfil = tercihProfiliDogrula(mevcut?.profil);
|
||||||
|
|
||||||
|
const clientIdler = new Set(clientItems.map((t) => t.id));
|
||||||
|
const items = [
|
||||||
|
...clientItems,
|
||||||
|
...sunucuItems.filter((t) => !clientIdler.has(t.id)),
|
||||||
|
].slice(0, MANUEL_LISTE_MAX);
|
||||||
|
const profil = clientProfil ?? sunucuProfil;
|
||||||
|
|
||||||
|
await kaydiYaz(userId, items, profil);
|
||||||
|
return { ok: true, items, profil };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Girişliyken süregelen senkron: lokal durum sunucunun üzerine yazılır. */
|
||||||
|
export async function listeyiKaydet(girdi: {
|
||||||
|
items: unknown;
|
||||||
|
profil: unknown;
|
||||||
|
}): Promise<{ ok: boolean }> {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session) return { ok: false };
|
||||||
|
await kaydiYaz(
|
||||||
|
session.user.id,
|
||||||
|
dogrula(girdi.items),
|
||||||
|
tercihProfiliDogrula(girdi.profil),
|
||||||
|
);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
@@ -45,7 +45,11 @@ import {
|
|||||||
} from "motion/react";
|
} from "motion/react";
|
||||||
import { RISK_ETIKET, type RiskSeviyesi } from "@/lib/risk";
|
import { RISK_ETIKET, type RiskSeviyesi } from "@/lib/risk";
|
||||||
import { bolumBazAdi, turkishSlugify } from "@/lib/slug";
|
import { bolumBazAdi, turkishSlugify } from "@/lib/slug";
|
||||||
|
import { olay } from "@/lib/analitik";
|
||||||
|
import { CihazCifti, IsletimSistemiLogosu } from "./cihaz-ikonlari";
|
||||||
import { sonucaGitIste } from "./profil-kapisi-store";
|
import { sonucaGitIste } from "./profil-kapisi-store";
|
||||||
|
import { useOturumDurumu } from "./oturum-sinyali";
|
||||||
|
import { useGirisUrl } from "./giris-url";
|
||||||
import {
|
import {
|
||||||
MANUEL_LISTE_MAX,
|
MANUEL_LISTE_MAX,
|
||||||
cekmeceAc,
|
cekmeceAc,
|
||||||
@@ -81,6 +85,8 @@ export function ListeCekmecesi() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const acik = useCekmeceAcik();
|
const acik = useCekmeceAcik();
|
||||||
const liste = useManuelListe();
|
const liste = useManuelListe();
|
||||||
|
const girisli = useOturumDurumu();
|
||||||
|
const girisUrl = useGirisUrl();
|
||||||
// Giriş animasyonu bitmeden satır layout projeksiyonu kapalı — transform'lu
|
// Giriş animasyonu bitmeden satır layout projeksiyonu kapalı — transform'lu
|
||||||
// drawer içinde erken ölçüm satırları kaydırıyordu
|
// drawer içinde erken ölçüm satırları kaydırıyordu
|
||||||
const [oturdu, setOturdu] = useState(false);
|
const [oturdu, setOturdu] = useState(false);
|
||||||
@@ -216,7 +222,7 @@ export function ListeCekmecesi() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={programaEkleGit}
|
onClick={programaEkleGit}
|
||||||
className={`flex h-12 w-full cursor-pointer items-center justify-center rounded-xl border border-dashed border-slate-300 text-slate-400 transition-colors duration-200 hover:border-primary/50 hover:bg-primary/5 hover:text-primary active:scale-[0.98] ${
|
className={`flex h-12 w-full cursor-pointer items-center justify-center rounded-full border border-dashed border-slate-300 text-slate-400 transition-colors duration-200 hover:border-primary/50 hover:bg-primary/5 hover:text-primary active:scale-[0.98] ${
|
||||||
liste.length > 0 ? "mt-2" : "mt-1"
|
liste.length > 0 ? "mt-2" : "mt-1"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -227,6 +233,32 @@ export function ListeCekmecesi() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DrawerFooter className="border-t border-slate-100 pt-4 pb-[max(1rem,env(safe-area-inset-bottom))]">
|
<DrawerFooter className="border-t border-slate-100 pt-4 pb-[max(1rem,env(safe-area-inset-bottom))]">
|
||||||
|
{/* C2 kalıcı satır: girişsiz kullanıcıya listenin uçucu olduğu
|
||||||
|
hatırlatılır; giriş doğal "kaydet" gerekçesi olarak sunulur. */}
|
||||||
|
{girisli === false ? (
|
||||||
|
<div className="mb-3 flex items-center justify-between gap-3 rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-left">
|
||||||
|
<p className="min-w-0 text-xs leading-relaxed text-slate-600">
|
||||||
|
{/* O anki işletim sisteminin logosu — "bu tarayıcıda"yı somutlaştırır */}
|
||||||
|
<IsletimSistemiLogosu className="mr-1.5 inline size-3.5 align-[-3px] text-slate-700" />
|
||||||
|
<span className="font-semibold text-slate-800">
|
||||||
|
Listen yalnızca bu tarayıcıda.
|
||||||
|
</span>{" "}
|
||||||
|
Giriş yap; her cihazdan eriş{" "}
|
||||||
|
<CihazCifti className="align-[-3px] text-slate-400" />
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href={girisUrl}
|
||||||
|
onClick={() => {
|
||||||
|
cekmeceKapat();
|
||||||
|
olay("giris_cta_tiklandi", { kaynak: "liste_cekmecesi" });
|
||||||
|
}}
|
||||||
|
className="inline-flex shrink-0 items-center gap-1 text-xs font-semibold text-primary transition-colors duration-200 hover:text-primary/80"
|
||||||
|
>
|
||||||
|
Giriş yap, kaydet
|
||||||
|
<ArrowRight className="size-3.5" aria-hidden />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{/* Ana sayfadaki final CTA panelinin kompakt hâli: koyu primary
|
{/* Ana sayfadaki final CTA panelinin kompakt hâli: koyu primary
|
||||||
zemin + kenarlara doğru yoğunlaşan piksel dokusu (PixelField) */}
|
zemin + kenarlara doğru yoğunlaşan piksel dokusu (PixelField) */}
|
||||||
<div className="relative isolate flex items-center justify-between gap-4 overflow-hidden rounded-2xl bg-primary px-5 py-5 text-left text-white">
|
<div className="relative isolate flex items-center justify-between gap-4 overflow-hidden rounded-2xl bg-primary px-5 py-5 text-left text-white">
|
||||||
@@ -247,7 +279,7 @@ export function ListeCekmecesi() {
|
|||||||
<Button
|
<Button
|
||||||
asChild
|
asChild
|
||||||
size="lg"
|
size="lg"
|
||||||
className="h-11 shrink-0 cursor-pointer bg-orange-500 px-6 text-white transition-colors duration-200 hover:bg-orange-600"
|
className="shrink-0 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||||
>
|
>
|
||||||
<Link href="/listem" onClick={cekmeceKapat}>
|
<Link href="/listem" onClick={cekmeceKapat}>
|
||||||
Listemde aç
|
Listemde aç
|
||||||
|
|||||||
100
src/components/manuel-liste/liste-senkron.tsx
Normal file
100
src/components/manuel-liste/liste-senkron.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// Manuel liste + sihirbaz profilinin sunucu senkronu (İP-C). UserNav'dan
|
||||||
|
// (sunucu bileşeni) userId prop'uyla monte edilir — RybbitIdentify ile aynı
|
||||||
|
// kanonik desen. Hiçbir şey render etmez.
|
||||||
|
//
|
||||||
|
// - Mount: oturum sinyali yayılır (çekmece/banner girişsizliği buradan öğrenir).
|
||||||
|
// - Girişte: oturum başına 1 kez merge (çakışmada lokal kazanır); dönen
|
||||||
|
// kanonik liste lokale birleşir, profil lokalde yoksa tercihProfiliYaz ile
|
||||||
|
// iner (PROFIL_DEGISTI_EVENT otomatik yayılır, profil kapısı tazelenir).
|
||||||
|
// - Sonrasında: liste/profil değişimleri ~2 sn debounce ile sunucuya yazılır.
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import {
|
||||||
|
PROFIL_DEGISTI_EVENT,
|
||||||
|
tercihProfiliOku,
|
||||||
|
tercihProfiliYaz,
|
||||||
|
} from "@/lib/sihirbaz";
|
||||||
|
import { listeOku, sunucuylaBirlestir, useManuelListe } from "./store";
|
||||||
|
import { oturumDurumuBildir } from "./oturum-sinyali";
|
||||||
|
import { listeSenkronla, listeyiKaydet } from "./kayitli-liste-actions";
|
||||||
|
|
||||||
|
const DEBOUNCE_MS = 2000;
|
||||||
|
|
||||||
|
export function ListeSenkron({ userId }: { userId: string | null }) {
|
||||||
|
const liste = useManuelListe();
|
||||||
|
const mergeTamam = useRef(false);
|
||||||
|
const sonGonderilen = useRef<string | null>(null);
|
||||||
|
const zamanlayici = useRef<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
oturumDurumuBildir(Boolean(userId));
|
||||||
|
}, [userId]);
|
||||||
|
|
||||||
|
// Giriş anı merge'i — sekme oturumu başına bir kez (idempotent olsa da
|
||||||
|
// her gezinmede sunucuya gitmesin diye sessionStorage ile tekilleştirilir).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!userId) return;
|
||||||
|
const anahtar = `kolaytercih.liste-senkron.${userId}`;
|
||||||
|
try {
|
||||||
|
if (sessionStorage.getItem(anahtar)) {
|
||||||
|
mergeTamam.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
let iptal = false;
|
||||||
|
void (async () => {
|
||||||
|
const sonuc = await listeSenkronla({
|
||||||
|
items: listeOku(),
|
||||||
|
profil: tercihProfiliOku(),
|
||||||
|
});
|
||||||
|
if (iptal || !sonuc.ok) return;
|
||||||
|
sunucuylaBirlestir(sonuc.items);
|
||||||
|
if (sonuc.profil && !tercihProfiliOku()) {
|
||||||
|
tercihProfiliYaz(sonuc.profil);
|
||||||
|
}
|
||||||
|
// Merge sonrası anlık durumu "gönderildi" say — debounce aynı veriyi
|
||||||
|
// hemen geri yazmasın.
|
||||||
|
sonGonderilen.current = JSON.stringify({
|
||||||
|
items: listeOku(),
|
||||||
|
profil: tercihProfiliOku(),
|
||||||
|
});
|
||||||
|
mergeTamam.current = true;
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(anahtar, "1");
|
||||||
|
} catch {}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
iptal = true;
|
||||||
|
};
|
||||||
|
}, [userId]);
|
||||||
|
|
||||||
|
// Girişliyken süregelen senkron: liste (store aboneliği) ve profil
|
||||||
|
// (PROFIL_DEGISTI_EVENT) değişimlerini debounce ile sunucuya it.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!userId) return;
|
||||||
|
|
||||||
|
const planla = () => {
|
||||||
|
if (zamanlayici.current != null) window.clearTimeout(zamanlayici.current);
|
||||||
|
zamanlayici.current = window.setTimeout(() => {
|
||||||
|
if (!mergeTamam.current) return;
|
||||||
|
const items = listeOku();
|
||||||
|
const profil = tercihProfiliOku();
|
||||||
|
const imza = JSON.stringify({ items, profil });
|
||||||
|
if (imza === sonGonderilen.current) return;
|
||||||
|
sonGonderilen.current = imza;
|
||||||
|
void listeyiKaydet({ items, profil });
|
||||||
|
}, DEBOUNCE_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
planla();
|
||||||
|
window.addEventListener(PROFIL_DEGISTI_EVENT, planla);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener(PROFIL_DEGISTI_EVENT, planla);
|
||||||
|
if (zamanlayici.current != null) window.clearTimeout(zamanlayici.current);
|
||||||
|
};
|
||||||
|
}, [userId, liste]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
34
src/components/manuel-liste/oturum-sinyali.ts
Normal file
34
src/components/manuel-liste/oturum-sinyali.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// Oturum durumunun client tarafındaki hafif sinyali. Kaynak: UserNav (sunucu
|
||||||
|
// bileşeni) → ListeSenkron mount'ta bildirir. Çekmece ve kaydet banner'ı
|
||||||
|
// authClient round-trip'i atmadan "girişsiz mi?" bilgisine buradan erişir
|
||||||
|
// (layout'a useSession koymak landing'i statiklikten çıkarırdı —
|
||||||
|
// rybbit-identify.tsx'teki gerekçe).
|
||||||
|
|
||||||
|
import { useSyncExternalStore } from "react";
|
||||||
|
|
||||||
|
let girisli: boolean | null = null;
|
||||||
|
const dinleyiciler = new Set<() => void>();
|
||||||
|
|
||||||
|
export function oturumDurumuBildir(deger: boolean): void {
|
||||||
|
if (girisli === deger) return;
|
||||||
|
girisli = deger;
|
||||||
|
for (const cb of dinleyiciler) cb();
|
||||||
|
}
|
||||||
|
|
||||||
|
function abone(cb: () => void) {
|
||||||
|
dinleyiciler.add(cb);
|
||||||
|
return () => {
|
||||||
|
dinleyiciler.delete(cb);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** null = henüz bilinmiyor (sinyal gelmedi); banner bu durumda gösterilmez. */
|
||||||
|
export function useOturumDurumu(): boolean | null {
|
||||||
|
return useSyncExternalStore(
|
||||||
|
abone,
|
||||||
|
() => girisli,
|
||||||
|
() => null,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -39,12 +39,17 @@ export type BekleyenProgram = {
|
|||||||
adayTur?: string;
|
adayTur?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Kapı tamamlanınca nereye gideceği (sıra yazıldıktan sonra href üretilir). */
|
/** Kapı tamamlanınca nereye gideceği (sıra yazıldıktan sonra href üretilir).
|
||||||
export type BekleyenHedef = "sonuc";
|
* "sonuc": ücretsiz tabloya götürür. "sihirbaz": Yapay Zeka niyetli CTA'lardan
|
||||||
|
* gelindi — /sonuc'ta üretim akışı devam etsin (girişliyse otomatik üretim,
|
||||||
|
* girişsizse giriş CTA paneli; hero-form ile aynı desen). */
|
||||||
|
export type BekleyenHedef = "sonuc" | "sihirbaz";
|
||||||
|
|
||||||
type KapıDurumu = {
|
type KapıDurumu = {
|
||||||
acik: boolean;
|
acik: boolean;
|
||||||
duzenle: boolean;
|
duzenle: boolean;
|
||||||
|
/** Tanıtım (giriş) adımını atla, doğrudan sıralama adımıyla aç. */
|
||||||
|
dogrudanSira: boolean;
|
||||||
bekleyen: BekleyenProgram | null;
|
bekleyen: BekleyenProgram | null;
|
||||||
bekleyenHedef: BekleyenHedef | null;
|
bekleyenHedef: BekleyenHedef | null;
|
||||||
profil: TercihProfili | null;
|
profil: TercihProfili | null;
|
||||||
@@ -54,6 +59,7 @@ type KapıDurumu = {
|
|||||||
let durum: KapıDurumu = {
|
let durum: KapıDurumu = {
|
||||||
acik: false,
|
acik: false,
|
||||||
duzenle: false,
|
duzenle: false,
|
||||||
|
dogrudanSira: false,
|
||||||
bekleyen: null,
|
bekleyen: null,
|
||||||
bekleyenHedef: null,
|
bekleyenHedef: null,
|
||||||
profil: null,
|
profil: null,
|
||||||
@@ -104,6 +110,7 @@ function abone(cb: () => void) {
|
|||||||
const SSR_DURUM: KapıDurumu = {
|
const SSR_DURUM: KapıDurumu = {
|
||||||
acik: false,
|
acik: false,
|
||||||
duzenle: false,
|
duzenle: false,
|
||||||
|
dogrudanSira: false,
|
||||||
bekleyen: null,
|
bekleyen: null,
|
||||||
bekleyenHedef: null,
|
bekleyenHedef: null,
|
||||||
profil: null,
|
profil: null,
|
||||||
@@ -130,6 +137,7 @@ export function profilKapisiniKapat() {
|
|||||||
...durum,
|
...durum,
|
||||||
acik: false,
|
acik: false,
|
||||||
duzenle: false,
|
duzenle: false,
|
||||||
|
dogrudanSira: false,
|
||||||
bekleyen: null,
|
bekleyen: null,
|
||||||
bekleyenHedef: null,
|
bekleyenHedef: null,
|
||||||
};
|
};
|
||||||
@@ -142,6 +150,7 @@ export function profilDuzenlemeyiAc() {
|
|||||||
...durum,
|
...durum,
|
||||||
acik: true,
|
acik: true,
|
||||||
duzenle: true,
|
duzenle: true,
|
||||||
|
dogrudanSira: false,
|
||||||
bekleyen: null,
|
bekleyen: null,
|
||||||
bekleyenHedef: null,
|
bekleyenHedef: null,
|
||||||
};
|
};
|
||||||
@@ -151,13 +160,18 @@ export function profilDuzenlemeyiAc() {
|
|||||||
/**
|
/**
|
||||||
* Profil yokken kapıyı açar; tamamlanınca `hedef`'e yönlendirilir.
|
* Profil yokken kapıyı açar; tamamlanınca `hedef`'e yönlendirilir.
|
||||||
* Navbar, çekmece +, /sonuc boş hali ve listem gibi yerler bunu kullanır.
|
* Navbar, çekmece +, /sonuc boş hali ve listem gibi yerler bunu kullanır.
|
||||||
|
* `dogrudanSira` tanıtım adımını atlar — kapı sıralama adımıyla açılır.
|
||||||
*/
|
*/
|
||||||
export function profilToplamaAc(hedef: BekleyenHedef | null = null) {
|
export function profilToplamaAc(
|
||||||
|
hedef: BekleyenHedef | null = null,
|
||||||
|
dogrudanSira = false,
|
||||||
|
) {
|
||||||
if (!durum.profilYuklendi) yukle();
|
if (!durum.profilYuklendi) yukle();
|
||||||
durum = {
|
durum = {
|
||||||
...durum,
|
...durum,
|
||||||
acik: true,
|
acik: true,
|
||||||
duzenle: false,
|
duzenle: false,
|
||||||
|
dogrudanSira,
|
||||||
bekleyen: null,
|
bekleyen: null,
|
||||||
bekleyenHedef: hedef,
|
bekleyenHedef: hedef,
|
||||||
};
|
};
|
||||||
@@ -167,8 +181,9 @@ export function profilToplamaAc(hedef: BekleyenHedef | null = null) {
|
|||||||
/**
|
/**
|
||||||
* Sonuç sayfasına gitmek isteyen her yer: profil varsa href,
|
* Sonuç sayfasına gitmek isteyen her yer: profil varsa href,
|
||||||
* yoksa sıralama/tercih kapısını açar (tamamlanınca /sonuc'a gider).
|
* yoksa sıralama/tercih kapısını açar (tamamlanınca /sonuc'a gider).
|
||||||
|
* `dogrudanSira`, kapı açılırsa tanıtım adımını atlatır (navbar CTA'sı).
|
||||||
*/
|
*/
|
||||||
export function sonucaGitIste(): {
|
export function sonucaGitIste(secenekler?: { dogrudanSira?: boolean }): {
|
||||||
href: string | null;
|
href: string | null;
|
||||||
kapiAcildi: boolean;
|
kapiAcildi: boolean;
|
||||||
} {
|
} {
|
||||||
@@ -177,7 +192,7 @@ export function sonucaGitIste(): {
|
|||||||
if (profil) {
|
if (profil) {
|
||||||
return { href: sonucHref(profil), kapiAcildi: false };
|
return { href: sonucHref(profil), kapiAcildi: false };
|
||||||
}
|
}
|
||||||
profilToplamaAc("sonuc");
|
profilToplamaAc("sonuc", secenekler?.dogrudanSira ?? false);
|
||||||
return { href: null, kapiAcildi: true };
|
return { href: null, kapiAcildi: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,6 +218,7 @@ export function programEkleIste(opts: {
|
|||||||
...durum,
|
...durum,
|
||||||
acik: true,
|
acik: true,
|
||||||
duzenle: false,
|
duzenle: false,
|
||||||
|
dogrudanSira: false,
|
||||||
bekleyen: { program, kaynak, adaySira, adayTur },
|
bekleyen: { program, kaynak, adaySira, adayTur },
|
||||||
bekleyenHedef: null,
|
bekleyenHedef: null,
|
||||||
};
|
};
|
||||||
@@ -227,8 +243,13 @@ export function programEkleIste(opts: {
|
|||||||
return { eklendi: true, zatenVar: false, doldu: false, kapiAcildi: false };
|
return { eklendi: true, zatenVar: false, doldu: false, kapiAcildi: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Profil yazıldıktan sonra; bekleyen hedef varsa yönlendirme href'i döner. */
|
/** Profil yazıldıktan sonra; bekleyen hedef varsa yönlendirme href'i döner.
|
||||||
export function profilTamamlandi(profil: TercihProfili): string | null {
|
* `girisli`, "sihirbaz" hedefinde üretimin otomatik başlayıp başlamayacağını
|
||||||
|
* belirler (kapı bileşeni oturumu facet isteğiyle paralel çözer). */
|
||||||
|
export function profilTamamlandi(
|
||||||
|
profil: TercihProfili,
|
||||||
|
girisli = false,
|
||||||
|
): string | null {
|
||||||
const onceki = durum.profil;
|
const onceki = durum.profil;
|
||||||
const duzenle = durum.duzenle;
|
const duzenle = durum.duzenle;
|
||||||
riskleriYenile(profil);
|
riskleriYenile(profil);
|
||||||
@@ -241,12 +262,19 @@ export function profilTamamlandi(profil: TercihProfili): string | null {
|
|||||||
...durum,
|
...durum,
|
||||||
acik: false,
|
acik: false,
|
||||||
duzenle: false,
|
duzenle: false,
|
||||||
|
dogrudanSira: false,
|
||||||
bekleyen: null,
|
bekleyen: null,
|
||||||
bekleyenHedef: null,
|
bekleyenHedef: null,
|
||||||
profil,
|
profil,
|
||||||
profilYuklendi: true,
|
profilYuklendi: true,
|
||||||
};
|
};
|
||||||
yayinla();
|
yayinla();
|
||||||
|
if (hedef === "sihirbaz") {
|
||||||
|
// Yapay Zeka niyetli giriş: kapıda sihirbazı zaten doldurdu, /sonuc'ta
|
||||||
|
// akış kesilmesin — girişliyse üretim otomatik başlar (?sihirbaz=1),
|
||||||
|
// girişsizse ücretsiz tablo + giriş CTA paneli (?hazir=1) karşılar.
|
||||||
|
return sonucHref(profil, girisli ? { sihirbaz: "1" } : { hazir: "1" });
|
||||||
|
}
|
||||||
if (hedef === "sonuc") return sonucHref(profil);
|
if (hedef === "sonuc") return sonucHref(profil);
|
||||||
// Düzenlemede sıra/tür değişince /sonuc?sira= URL'si yenilenmezse sunucudaki
|
// Düzenlemede sıra/tür değişince /sonuc?sira= URL'si yenilenmezse sunucudaki
|
||||||
// hayal/dengeli/garanti tablosu eski sıralamada kalır (profil localStorage'da
|
// hayal/dengeli/garanti tablosu eski sıralamada kalır (profil localStorage'da
|
||||||
|
|||||||
@@ -7,31 +7,24 @@
|
|||||||
|
|
||||||
import { useSyncExternalStore } from "react";
|
import { useSyncExternalStore } from "react";
|
||||||
import type { Program } from "@/lib/db";
|
import type { Program } from "@/lib/db";
|
||||||
import { riskHesapla, type RiskSeviyesi } from "@/lib/risk";
|
import { riskHesapla } from "@/lib/risk";
|
||||||
import {
|
import {
|
||||||
tercihProfiliOku,
|
tercihProfiliOku,
|
||||||
type TercihProfili,
|
type TercihProfili,
|
||||||
} from "@/lib/sihirbaz";
|
} from "@/lib/sihirbaz";
|
||||||
|
import {
|
||||||
|
MANUEL_LISTE_MAX,
|
||||||
|
dogrula,
|
||||||
|
type ManuelKaynak,
|
||||||
|
type ManuelTercih,
|
||||||
|
} from "./dogrula";
|
||||||
|
|
||||||
|
// Doğrulama katmanı sunucu action'larıyla paylaşılır (bkz. dogrula.ts);
|
||||||
|
// mevcut import yolları kırılmasın diye buradan re-export edilir.
|
||||||
|
export { MANUEL_LISTE_MAX, type ManuelKaynak, type ManuelTercih };
|
||||||
|
|
||||||
export const MANUEL_LISTE_MAX = 24;
|
|
||||||
const STORAGE_KEY = "kolaytercih.manuel-liste.v1";
|
const STORAGE_KEY = "kolaytercih.manuel-liste.v1";
|
||||||
|
|
||||||
export type ManuelKaynak = "sonuc" | "bolum" | "universite";
|
|
||||||
|
|
||||||
export type ManuelTercih = {
|
|
||||||
/** yokatlas program id'si */
|
|
||||||
id: string;
|
|
||||||
isim: string;
|
|
||||||
universite: string;
|
|
||||||
il: string | null;
|
|
||||||
unitur: string | null;
|
|
||||||
tur: string | null;
|
|
||||||
efektifSira: number | null;
|
|
||||||
/** Ekleme / profil anındaki aday sıralamasına göre risk */
|
|
||||||
risk: RiskSeviyesi | null;
|
|
||||||
kaynak?: ManuelKaynak;
|
|
||||||
};
|
|
||||||
|
|
||||||
// SSR snapshot'ı: her çağrıda aynı referans dönmeli, yoksa React döngüye girer
|
// SSR snapshot'ı: her çağrıda aynı referans dönmeli, yoksa React döngüye girer
|
||||||
const BOS_LISTE: ManuelTercih[] = [];
|
const BOS_LISTE: ManuelTercih[] = [];
|
||||||
|
|
||||||
@@ -51,45 +44,6 @@ function kaydet() {
|
|||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
function dogrula(ham: unknown): ManuelTercih[] {
|
|
||||||
if (!Array.isArray(ham)) return [];
|
|
||||||
const sonuc: ManuelTercih[] = [];
|
|
||||||
for (const t of ham) {
|
|
||||||
if (typeof t !== "object" || t === null) continue;
|
|
||||||
const kayit = t as Record<string, unknown>;
|
|
||||||
if (typeof kayit.id !== "string" || typeof kayit.isim !== "string") continue;
|
|
||||||
const risk: RiskSeviyesi | null =
|
|
||||||
kayit.risk === "guvenli" ||
|
|
||||||
kayit.risk === "az-riskli" ||
|
|
||||||
kayit.risk === "riskli"
|
|
||||||
? kayit.risk
|
|
||||||
: null;
|
|
||||||
const kaynak: ManuelKaynak | undefined =
|
|
||||||
kayit.kaynak === "sonuc" ||
|
|
||||||
kayit.kaynak === "bolum" ||
|
|
||||||
kayit.kaynak === "universite"
|
|
||||||
? kayit.kaynak
|
|
||||||
: undefined;
|
|
||||||
sonuc.push({
|
|
||||||
id: kayit.id,
|
|
||||||
isim: kayit.isim,
|
|
||||||
universite: typeof kayit.universite === "string" ? kayit.universite : "",
|
|
||||||
il: typeof kayit.il === "string" ? kayit.il : null,
|
|
||||||
unitur: typeof kayit.unitur === "string" ? kayit.unitur : null,
|
|
||||||
tur: typeof kayit.tur === "string" ? kayit.tur : null,
|
|
||||||
efektifSira:
|
|
||||||
typeof kayit.efektifSira === "number" &&
|
|
||||||
Number.isFinite(kayit.efektifSira)
|
|
||||||
? kayit.efektifSira
|
|
||||||
: null,
|
|
||||||
risk,
|
|
||||||
kaynak,
|
|
||||||
});
|
|
||||||
if (sonuc.length >= MANUEL_LISTE_MAX) break;
|
|
||||||
}
|
|
||||||
return sonuc;
|
|
||||||
}
|
|
||||||
|
|
||||||
function yukle() {
|
function yukle() {
|
||||||
if (yuklendi || typeof window === "undefined") return;
|
if (yuklendi || typeof window === "undefined") return;
|
||||||
yuklendi = true;
|
yuklendi = true;
|
||||||
@@ -139,6 +93,28 @@ export function listedeMi(id: string): boolean {
|
|||||||
return liste.some((t) => t.id === id);
|
return liste.some((t) => t.id === id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Senkron snapshot — giriş anı merge'inde sunucuya gönderilecek liste. */
|
||||||
|
export function listeOku(): ManuelTercih[] {
|
||||||
|
yukle();
|
||||||
|
return liste;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sunucudan dönen kanonik listeyi lokale birleştirir: lokal sıra korunur,
|
||||||
|
* lokalde olmayan sunucu kayıtları sona eklenir, 24 sınırı aşılmaz.
|
||||||
|
* (Çakışmada localStorage kazanır — PRD C1 kuralı.)
|
||||||
|
*/
|
||||||
|
export function sunucuylaBirlestir(sunucuItems: ManuelTercih[]): ManuelTercih[] {
|
||||||
|
yukle();
|
||||||
|
const mevcut = new Set(liste.map((t) => t.id));
|
||||||
|
const ekler = sunucuItems.filter((t) => !mevcut.has(t.id));
|
||||||
|
if (ekler.length === 0) return liste;
|
||||||
|
liste = [...liste, ...ekler].slice(0, MANUEL_LISTE_MAX);
|
||||||
|
kaydet();
|
||||||
|
yayinla();
|
||||||
|
return liste;
|
||||||
|
}
|
||||||
|
|
||||||
export function efektifSiraAl(
|
export function efektifSiraAl(
|
||||||
p: Pick<Program, "sira2025" | "sira2024">,
|
p: Pick<Program, "sira2025" | "sira2024">,
|
||||||
): number | null {
|
): number | null {
|
||||||
|
|||||||
@@ -33,10 +33,12 @@ import {
|
|||||||
type SihirbazSecimleri,
|
type SihirbazSecimleri,
|
||||||
type TercihProfili,
|
type TercihProfili,
|
||||||
} from "@/lib/sihirbaz";
|
} from "@/lib/sihirbaz";
|
||||||
|
import { authClient } from "@/lib/auth-client";
|
||||||
import {
|
import {
|
||||||
profilKapisiniKapat,
|
profilKapisiniKapat,
|
||||||
profilTamamlandi,
|
profilTamamlandi,
|
||||||
useProfilKapisi,
|
useProfilKapisi,
|
||||||
|
type BekleyenHedef,
|
||||||
type BekleyenProgram,
|
type BekleyenProgram,
|
||||||
} from "./profil-kapisi-store";
|
} from "./profil-kapisi-store";
|
||||||
|
|
||||||
@@ -53,7 +55,8 @@ type Adim = "giris" | "sira" | "sihirbaz";
|
|||||||
const ADIM_SIRASI: Record<Adim, number> = { giris: 0, sira: 1, sihirbaz: 2 };
|
const ADIM_SIRASI: Record<Adim, number> = { giris: 0, sira: 1, sihirbaz: 2 };
|
||||||
|
|
||||||
export function TercihProfiliKapisi() {
|
export function TercihProfiliKapisi() {
|
||||||
const { acik, duzenle, bekleyen, profil } = useProfilKapisi();
|
const { acik, duzenle, dogrudanSira, bekleyen, bekleyenHedef, profil } =
|
||||||
|
useProfilKapisi();
|
||||||
const oturumAnahtari = acik
|
const oturumAnahtari = acik
|
||||||
? `${duzenle ? "d" : "n"}-${bekleyen?.program.id ?? "yok"}-${profil?.sira ?? 0}-${profil?.tur ?? ""}`
|
? `${duzenle ? "d" : "n"}-${bekleyen?.program.id ?? "yok"}-${profil?.sira ?? 0}-${profil?.tur ?? ""}`
|
||||||
: "kapali";
|
: "kapali";
|
||||||
@@ -80,7 +83,9 @@ export function TercihProfiliKapisi() {
|
|||||||
<KapıIcerik
|
<KapıIcerik
|
||||||
key={oturumAnahtari}
|
key={oturumAnahtari}
|
||||||
duzenle={duzenle}
|
duzenle={duzenle}
|
||||||
|
dogrudanSira={dogrudanSira}
|
||||||
bekleyen={bekleyen}
|
bekleyen={bekleyen}
|
||||||
|
hedef={bekleyenHedef}
|
||||||
profil={profil}
|
profil={profil}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -91,19 +96,25 @@ export function TercihProfiliKapisi() {
|
|||||||
|
|
||||||
function KapıIcerik({
|
function KapıIcerik({
|
||||||
duzenle,
|
duzenle,
|
||||||
|
dogrudanSira,
|
||||||
bekleyen,
|
bekleyen,
|
||||||
|
hedef,
|
||||||
profil,
|
profil,
|
||||||
}: {
|
}: {
|
||||||
duzenle: boolean;
|
duzenle: boolean;
|
||||||
|
dogrudanSira: boolean;
|
||||||
bekleyen: BekleyenProgram | null;
|
bekleyen: BekleyenProgram | null;
|
||||||
|
hedef: BekleyenHedef | null;
|
||||||
profil: TercihProfili | null;
|
profil: TercihProfili | null;
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const bilinenSira = bekleyen?.adaySira ?? profil?.sira ?? null;
|
const bilinenSira = bekleyen?.adaySira ?? profil?.sira ?? null;
|
||||||
const bilinenTur = bekleyen?.adayTur ?? profil?.tur ?? "say";
|
const bilinenTur = bekleyen?.adayTur ?? profil?.tur ?? "say";
|
||||||
|
|
||||||
// Düzenlemede doğrudan sıralama adımı — yeni sıra + tercihler aynı oturumda
|
// Düzenlemede ve navbar "Sıralamanı gir" girişinde doğrudan sıralama adımı
|
||||||
const [adim, setAdim] = useState<Adim>(duzenle ? "sira" : "giris");
|
const [adim, setAdim] = useState<Adim>(
|
||||||
|
duzenle || dogrudanSira ? "sira" : "giris",
|
||||||
|
);
|
||||||
// Adım geçişleri sihirbazın kendi içindeki TransitionPanel kaymasıyla oynar
|
// Adım geçişleri sihirbazın kendi içindeki TransitionPanel kaymasıyla oynar
|
||||||
const [yon, setYon] = useState(1);
|
const [yon, setYon] = useState(1);
|
||||||
const [gecisBasladi, setGecisBasladi] = useState(false);
|
const [gecisBasladi, setGecisBasladi] = useState(false);
|
||||||
@@ -117,6 +128,9 @@ function KapıIcerik({
|
|||||||
const [facetler, setFacetler] = useState<SihirbazFacetleri | null>(null);
|
const [facetler, setFacetler] = useState<SihirbazFacetleri | null>(null);
|
||||||
const [yukleniyor, setYukleniyor] = useState(false);
|
const [yukleniyor, setYukleniyor] = useState(false);
|
||||||
const [hata, setHata] = useState<string | null>(null);
|
const [hata, setHata] = useState<string | null>(null);
|
||||||
|
// "sihirbaz" hedefinde yönlendirme ve son buton etiketi oturuma göre
|
||||||
|
// değişir; oturum facet isteğiyle paralel çözülür (hero-form deseni).
|
||||||
|
const [girisli, setGirisli] = useState(false);
|
||||||
|
|
||||||
function siralamaDegisti(ham: string) {
|
function siralamaDegisti(ham: string) {
|
||||||
setHata(null);
|
setHata(null);
|
||||||
@@ -134,11 +148,13 @@ function KapıIcerik({
|
|||||||
setYukleniyor(true);
|
setYukleniyor(true);
|
||||||
setHata(null);
|
setHata(null);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(
|
const [res, oturum] = await Promise.all([
|
||||||
`/api/facetler?sira=${siraDeger}&tur=${turDeger}`,
|
fetch(`/api/facetler?sira=${siraDeger}&tur=${turDeger}`),
|
||||||
);
|
authClient.getSession().catch(() => null),
|
||||||
|
]);
|
||||||
if (!res.ok) throw new Error();
|
if (!res.ok) throw new Error();
|
||||||
const veri = (await res.json()) as { facetler: SihirbazFacetleri };
|
const veri = (await res.json()) as { facetler: SihirbazFacetleri };
|
||||||
|
setGirisli(Boolean(oturum?.data?.user));
|
||||||
setSira(siraDeger);
|
setSira(siraDeger);
|
||||||
setTur(turDeger);
|
setTur(turDeger);
|
||||||
setFacetler(veri.facetler);
|
setFacetler(veri.facetler);
|
||||||
@@ -179,8 +195,10 @@ function KapıIcerik({
|
|||||||
const yeniProfil: TercihProfili = { sira, tur, secimler };
|
const yeniProfil: TercihProfili = { sira, tur, secimler };
|
||||||
tercihProfiliYaz(yeniProfil);
|
tercihProfiliYaz(yeniProfil);
|
||||||
const bekleyenVar = Boolean(bekleyen);
|
const bekleyenVar = Boolean(bekleyen);
|
||||||
const yonlendir = profilTamamlandi(yeniProfil);
|
const yonlendir = profilTamamlandi(yeniProfil, girisli);
|
||||||
if (!duzenle && !bekleyenVar) {
|
// "sihirbaz" hedefinde akış /sonuc'ta devam eder (üretim ya da giriş
|
||||||
|
// paneli); "kaydedildi" toast'ı akış bitti izlenimi verirdi.
|
||||||
|
if (!duzenle && !bekleyenVar && hedef !== "sihirbaz") {
|
||||||
toast.success("Tercihlerin kaydedildi.");
|
toast.success("Tercihlerin kaydedildi.");
|
||||||
}
|
}
|
||||||
// Düzenlemede replace: aynı /sonuc sayfasında sira/tur değişince geçmiş
|
// Düzenlemede replace: aynı /sonuc sayfasında sira/tur değişince geçmiş
|
||||||
@@ -363,11 +381,13 @@ function KapıIcerik({
|
|||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
duzenle ? profilKapisiniKapat() : adimaGec("giris")
|
// Doğrudan sıralama adımıyla açıldıysa tanıtım adımı hiç
|
||||||
|
// görülmedi; "Geri" yerine kapıyı kapatmak beklenen davranış.
|
||||||
|
duzenle || dogrudanSira ? profilKapisiniKapat() : adimaGec("giris")
|
||||||
}
|
}
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
>
|
>
|
||||||
{duzenle ? "Vazgeç" : "Geri"}
|
{duzenle || dogrudanSira ? "Vazgeç" : "Geri"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -399,7 +419,11 @@ function KapıIcerik({
|
|||||||
? "Tercihlerimi güncelle"
|
? "Tercihlerimi güncelle"
|
||||||
: bekleyen
|
: bekleyen
|
||||||
? "Kaydet ve programı ekle"
|
? "Kaydet ve programı ekle"
|
||||||
: "Tercihlerimi kaydet"
|
: hedef === "sihirbaz"
|
||||||
|
? girisli
|
||||||
|
? "Yapay Zeka listemi kur (3 kredi)"
|
||||||
|
: "Ücretsiz tablonu gör"
|
||||||
|
: "Tercihlerimi kaydet"
|
||||||
}
|
}
|
||||||
onTamamla={secimleriTamamla}
|
onTamamla={secimleriTamamla}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,12 +1,31 @@
|
|||||||
import { TrendingDown, TrendingUp } from "lucide-react";
|
import { TrendingDown, TrendingUp } from "lucide-react";
|
||||||
import type { Program } from "@/lib/db";
|
import type { Program } from "@/lib/db";
|
||||||
|
|
||||||
export function programEtkinSira(program: Program): number | null {
|
/** Taban sıra sütunlarının yapısal alt kümesi: hem DB programı hem de
|
||||||
|
* rapordaki kaydedilmiş sıra geçmişi (bkz. raporSiraSerisi) bu tipe uyar. */
|
||||||
|
export type SiraSerisi = Pick<
|
||||||
|
Program,
|
||||||
|
"sira2021" | "sira2022" | "sira2023" | "sira2024" | "sira2025"
|
||||||
|
>;
|
||||||
|
|
||||||
|
/** Rapordaki [2021..2025] dizisini sütunlu seriye çevirir. */
|
||||||
|
export function raporSiraSerisi(gecmis?: (number | null)[]): SiraSerisi {
|
||||||
|
const g = gecmis ?? [];
|
||||||
|
return {
|
||||||
|
sira2021: g[0] ?? null,
|
||||||
|
sira2022: g[1] ?? null,
|
||||||
|
sira2023: g[2] ?? null,
|
||||||
|
sira2024: g[3] ?? null,
|
||||||
|
sira2025: g[4] ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function programEtkinSira(program: SiraSerisi): number | null {
|
||||||
return program.sira2025 ?? program.sira2024;
|
return program.sira2025 ?? program.sira2024;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 2021–2025 taban sıralamasını sonuç listesindeki kompakt çizgiyle gösterir. */
|
/** 2021–2025 taban sıralamasını sonuç listesindeki kompakt çizgiyle gösterir. */
|
||||||
export function ProgramSiraGecmisi({ program }: { program: Program }) {
|
export function ProgramSiraGecmisi({ program }: { program: SiraSerisi }) {
|
||||||
const yillar = [
|
const yillar = [
|
||||||
[2021, program.sira2021],
|
[2021, program.sira2021],
|
||||||
[2022, program.sira2022],
|
[2022, program.sira2022],
|
||||||
@@ -61,7 +80,7 @@ export function ProgramSiraGecmisi({ program }: { program: Program }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Son iki yılın tabanı %10'dan fazla değiştiğinde yönü işaretler. */
|
/** Son iki yılın tabanı %10'dan fazla değiştiğinde yönü işaretler. */
|
||||||
export function ProgramTabanTrendi({ program }: { program: Program }) {
|
export function ProgramTabanTrendi({ program }: { program: SiraSerisi }) {
|
||||||
if (program.sira2025 == null || program.sira2024 == null) return null;
|
if (program.sira2025 == null || program.sira2024 == null) return null;
|
||||||
|
|
||||||
const oran = (program.sira2024 - program.sira2025) / program.sira2024;
|
const oran = (program.sira2024 - program.sira2025) / program.sira2024;
|
||||||
|
|||||||
221
src/components/program-netleri.tsx
Normal file
221
src/components/program-netleri.tsx
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// Program satırındaki "netler" butonu: YÖK Atlas'ın "yerleşen son kişinin
|
||||||
|
// netleri" verisini (data/yokatlas.db → netler tablosu, /api/netler) yıl
|
||||||
|
// sütunlu kompakt bir modalde gösterir. Kadın-erkek dağılımı bilinçli olarak
|
||||||
|
// yok: YÖK Atlas 2026 SPA'sına geçerken cinsiyet panelini yayından kaldırdı
|
||||||
|
// (bkz. scripts/detay.ts).
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { ListChecks } from "lucide-react";
|
||||||
|
import type { ProgramNetSatiri } from "@/lib/db";
|
||||||
|
import { olay } from "@/lib/analitik";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
|
||||||
|
// Ders sırası ÖSYM oturum sırasıyla; max = testteki soru sayısı.
|
||||||
|
const DERSLER: {
|
||||||
|
alan: keyof Omit<ProgramNetSatiri, "yil" | "puanTuru" | "tabanPuan" | "obp">;
|
||||||
|
etiket: string;
|
||||||
|
max: number;
|
||||||
|
}[] = [
|
||||||
|
{ alan: "tytTrkNet", etiket: "TYT Türkçe", max: 40 },
|
||||||
|
{ alan: "tytSosNet", etiket: "TYT Sosyal Bilimler", max: 20 },
|
||||||
|
{ alan: "tytMatNet", etiket: "TYT Matematik", max: 40 },
|
||||||
|
{ alan: "tytFenNet", etiket: "TYT Fen Bilimleri", max: 20 },
|
||||||
|
{ alan: "aytMatNet", etiket: "AYT Matematik", max: 40 },
|
||||||
|
{ alan: "aytFizNet", etiket: "AYT Fizik", max: 14 },
|
||||||
|
{ alan: "aytKimNet", etiket: "AYT Kimya", max: 13 },
|
||||||
|
{ alan: "aytBioNet", etiket: "AYT Biyoloji", max: 13 },
|
||||||
|
{ alan: "aytTdeNet", etiket: "AYT Türk Dili ve Edebiyatı", max: 24 },
|
||||||
|
{ alan: "aytTrh1Net", etiket: "AYT Tarih-1", max: 10 },
|
||||||
|
{ alan: "aytCog1Net", etiket: "AYT Coğrafya-1", max: 6 },
|
||||||
|
{ alan: "aytTrh2Net", etiket: "AYT Tarih-2", max: 11 },
|
||||||
|
{ alan: "aytCog2Net", etiket: "AYT Coğrafya-2", max: 11 },
|
||||||
|
{ alan: "aytDinNet", etiket: "AYT Din Kültürü", max: 6 },
|
||||||
|
{ alan: "aytFelNet", etiket: "AYT Felsefe Grubu", max: 12 },
|
||||||
|
{ alan: "ydtYdilNet", etiket: "YDT Yabancı Dil", max: 80 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const net = (n: number) =>
|
||||||
|
n.toLocaleString("tr-TR", { maximumFractionDigits: 2 });
|
||||||
|
|
||||||
|
type Durum = "bekliyor" | "yukleniyor" | "hazir" | "hata";
|
||||||
|
|
||||||
|
export function ProgramNetleriButonu({
|
||||||
|
programId,
|
||||||
|
isim,
|
||||||
|
universite,
|
||||||
|
className = "",
|
||||||
|
}: {
|
||||||
|
programId: string;
|
||||||
|
isim: string;
|
||||||
|
universite?: string;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const [durum, setDurum] = useState<Durum>("bekliyor");
|
||||||
|
const [netler, setNetler] = useState<ProgramNetSatiri[]>([]);
|
||||||
|
|
||||||
|
async function yukle() {
|
||||||
|
setDurum("yukleniyor");
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/netler?id=${programId}`);
|
||||||
|
if (!res.ok) throw new Error(String(res.status));
|
||||||
|
const veri = (await res.json()) as { netler: ProgramNetSatiri[] };
|
||||||
|
setNetler(veri.netler);
|
||||||
|
setDurum("hazir");
|
||||||
|
} catch {
|
||||||
|
setDurum("hata");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function acildi(acik: boolean) {
|
||||||
|
if (!acik) return;
|
||||||
|
olay("netler_goruntulendi");
|
||||||
|
if (durum === "bekliyor" || durum === "hata") yukle();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog onOpenChange={acildi}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Son yerleşenin netleri"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className={`inline-flex size-11 min-h-11 min-w-11 cursor-pointer items-center justify-center rounded-full border border-slate-200 bg-white text-slate-500 transition-[background-color,border-color,color,transform] duration-150 hover:border-primary hover:bg-primary/5 hover:text-primary active:scale-[0.92] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${className}`}
|
||||||
|
>
|
||||||
|
<ListChecks className="size-4" aria-hidden />
|
||||||
|
<span className="sr-only">{isim} — son yerleşenin netleri</span>
|
||||||
|
</button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent
|
||||||
|
className="sm:max-w-md"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="pr-10">Son yerleşenin netleri</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{isim}
|
||||||
|
{universite ? ` · ${universite}` : ""} — bölüme en düşük puanla
|
||||||
|
yerleşen adayın sınav netleri (YÖK Atlas).
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<NetlerIcerik durum={durum} netler={netler} />
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NetlerIcerik({
|
||||||
|
durum,
|
||||||
|
netler,
|
||||||
|
}: {
|
||||||
|
durum: Durum;
|
||||||
|
netler: ProgramNetSatiri[];
|
||||||
|
}) {
|
||||||
|
if (durum === "yukleniyor" || durum === "bekliyor") {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2" aria-hidden>
|
||||||
|
{[0, 1, 2, 3, 4].map((i) => (
|
||||||
|
<div key={i} className="h-6 animate-pulse rounded-md bg-slate-100" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (durum === "hata") {
|
||||||
|
return (
|
||||||
|
<p className="py-4 text-center text-sm text-slate-500">
|
||||||
|
Netler yüklenemedi. Modalı kapatıp yeniden açmayı dene.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (netler.length === 0) {
|
||||||
|
return (
|
||||||
|
<p className="py-4 text-center text-sm text-slate-500">
|
||||||
|
Bu program için YÖK Atlas'ta net verisi yayınlanmamış.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dersler = DERSLER.filter((d) =>
|
||||||
|
netler.some((y) => y[d.alan] != null),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="-mx-1 overflow-x-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="h-9">Ders</TableHead>
|
||||||
|
{netler.map((y) => (
|
||||||
|
<TableHead key={y.yil} className="h-9 text-right tabular-nums">
|
||||||
|
{y.yil}
|
||||||
|
</TableHead>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{dersler.map((d) => (
|
||||||
|
<TableRow key={d.alan}>
|
||||||
|
<TableCell className="py-2 text-xs text-slate-600">
|
||||||
|
{d.etiket}
|
||||||
|
<span className="text-slate-400"> / {d.max}</span>
|
||||||
|
</TableCell>
|
||||||
|
{netler.map((y) => {
|
||||||
|
const deger = y[d.alan];
|
||||||
|
return (
|
||||||
|
<TableCell
|
||||||
|
key={y.yil}
|
||||||
|
className="py-2 text-right text-xs tabular-nums"
|
||||||
|
>
|
||||||
|
{deger != null ? net(deger) : "—"}
|
||||||
|
</TableCell>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
<TableRow>
|
||||||
|
<TableCell className="py-2 text-xs font-medium text-slate-600">
|
||||||
|
OBP
|
||||||
|
</TableCell>
|
||||||
|
{netler.map((y) => (
|
||||||
|
<TableCell
|
||||||
|
key={y.yil}
|
||||||
|
className="py-2 text-right text-xs tabular-nums text-slate-500"
|
||||||
|
>
|
||||||
|
{y.obp != null ? net(y.obp) : "—"}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell className="py-2 text-xs font-semibold text-slate-700">
|
||||||
|
Yerleşme puanı
|
||||||
|
</TableCell>
|
||||||
|
{netler.map((y) => (
|
||||||
|
<TableCell
|
||||||
|
key={y.yil}
|
||||||
|
className="py-2 text-right text-xs font-semibold tabular-nums text-slate-700"
|
||||||
|
>
|
||||||
|
{y.tabanPuan != null ? net(y.tabanPuan) : "—"}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { DilimKey, Program, PuanTuruKey, RankResults } from "@/lib/db";
|
import type { DilimKey, Program, PuanTuruKey, RankResults } from "@/lib/db";
|
||||||
import { riskHesapla, type RiskSeviyesi } from "@/lib/risk";
|
import { riskHesapla, type RiskSeviyesi } from "@/lib/risk";
|
||||||
import { bolumBazAdi, turkishSlugify } from "@/lib/slug";
|
import { bolumSayfaSlug, uniSayfaSlug } from "@/lib/slug";
|
||||||
import {
|
import {
|
||||||
ProgramSiraGecmisi,
|
ProgramSiraGecmisi,
|
||||||
ProgramTabanTrendi,
|
ProgramTabanTrendi,
|
||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { ProgramEkleButonu } from "@/components/manuel-liste/program-ekle-butonu";
|
import { ProgramEkleButonu } from "@/components/manuel-liste/program-ekle-butonu";
|
||||||
|
import { ProgramNetleriButonu } from "@/components/program-netleri";
|
||||||
import { UniLogo } from "@/components/uni-logo";
|
import { UniLogo } from "@/components/uni-logo";
|
||||||
|
|
||||||
const DILIMLER: {
|
const DILIMLER: {
|
||||||
@@ -183,8 +184,8 @@ export function ProgramTablosu({
|
|||||||
Kontenjan
|
Kontenjan
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="text-right">Taban sıra</TableHead>
|
<TableHead className="text-right">Taban sıra</TableHead>
|
||||||
<TableHead className="w-16 text-right">
|
<TableHead className="w-24 text-right">
|
||||||
<span className="sr-only">Listeye ekle</span>
|
<span className="sr-only">Netler ve listeye ekle</span>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
@@ -226,22 +227,6 @@ export function ProgramTablosu({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// /universite/[slug] sayfa slug'ı: harita.ts'teki uniAdiNormalize ile aynı
|
|
||||||
// kural (sondaki "(İL)" eki atılır) — client bundle'a harita verisi çekmemek
|
|
||||||
// için burada tekrarlanır. Katalog haritası DB'deki tüm universite
|
|
||||||
// değerlerinden kurulduğu için bu slug her zaman var olan bir sayfaya çıkar.
|
|
||||||
function uniSayfaSlug(ad: string): string {
|
|
||||||
return turkishSlugify(ad.replace(/\s*\([^)]*\)\s*$/, "").trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
// /bolum/[slug] sayfa slug'ı: katalog.ts'teki bolumSlugFromIsim ile aynı kural
|
|
||||||
// (bolumBazAdi + turkishSlugify). Bölüm haritası DB'deki tüm isim
|
|
||||||
// değerlerinden kurulduğu için bu slug da her zaman var olan bir sayfaya çıkar
|
|
||||||
// (bkz. liste-cekmecesi'ndeki bolumHref).
|
|
||||||
function bolumSayfaSlug(isim: string): string {
|
|
||||||
return turkishSlugify(bolumBazAdi(isim));
|
|
||||||
}
|
|
||||||
|
|
||||||
function ProgramSatiri({
|
function ProgramSatiri({
|
||||||
program: p,
|
program: p,
|
||||||
adaySira,
|
adaySira,
|
||||||
@@ -336,13 +321,21 @@ function ProgramSatiri({
|
|||||||
</span>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="pr-4 text-right">
|
<TableCell className="pr-4 text-right">
|
||||||
<ProgramEkleButonu
|
<span className="inline-flex items-center gap-1.5">
|
||||||
program={p}
|
<ProgramNetleriButonu
|
||||||
kaynak="sonuc"
|
programId={p.id}
|
||||||
adaySira={adaySira}
|
isim={p.isim}
|
||||||
adayTur={tur}
|
universite={p.universite}
|
||||||
className="size-8 min-h-8 min-w-8"
|
className="size-8 min-h-8 min-w-8"
|
||||||
/>
|
/>
|
||||||
|
<ProgramEkleButonu
|
||||||
|
program={p}
|
||||||
|
kaynak="sonuc"
|
||||||
|
adaySira={adaySira}
|
||||||
|
adayTur={tur}
|
||||||
|
className="size-8 min-h-8 min-w-8"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,22 +1,32 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
// 24'lük listenin gövdesi. Program adı, 2024/2025 tabanları ve ham trend
|
// 24'lük listenin gövdesi. Görsel dil sonuç sayfasındaki ham program
|
||||||
// herkese görünür. Yapay Zeka gerekçesi ile kişisel risk, kilitliBaslangic sonrasında
|
// tablosuyla birebir aynıdır (bkz. program-tablosu.tsx): aynı beyaz kart,
|
||||||
// ücretli katman olarak ayrılır; ücretli metin sunucuda zaten maskelidir.
|
// aynı satır düzeni, aynı "Son 5 yıl" / "Taban sıra" sütunları. Buradaki fark
|
||||||
|
// içerik: satır sırası tercih numarası, risk sütunu Yapay Zeka'dan gelir ve
|
||||||
|
// satır açıldığında gerekçe/risk notu görünür. Program adı ve tabanlar herkese
|
||||||
|
// açık; Yapay Zeka katmanı kilitliBaslangic sonrasında ücretlidir (metin
|
||||||
|
// sunucuda zaten maskelidir).
|
||||||
|
|
||||||
import { Fragment, useEffect, useRef, useState } from "react";
|
import { Fragment, useEffect, useRef, useState } from "react";
|
||||||
import {
|
import Link from "next/link";
|
||||||
ChevronDown,
|
import { ChevronDown, Lock, MapPin, Rocket, Scale, ShieldCheck } from "lucide-react";
|
||||||
Lock,
|
|
||||||
MapPin,
|
|
||||||
Minus,
|
|
||||||
Rocket,
|
|
||||||
Scale,
|
|
||||||
ShieldCheck,
|
|
||||||
TrendingDown,
|
|
||||||
TrendingUp,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { UniLogo } from "@/components/uni-logo";
|
import { UniLogo } from "@/components/uni-logo";
|
||||||
|
import {
|
||||||
|
ProgramSiraGecmisi,
|
||||||
|
ProgramTabanTrendi,
|
||||||
|
programEtkinSira,
|
||||||
|
raporSiraSerisi,
|
||||||
|
} from "@/components/program-liste-verileri";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { bolumSayfaSlug, uniSayfaSlug } from "@/lib/slug";
|
||||||
import type { RaporSonuc } from "@/lib/ai/rapor";
|
import type { RaporSonuc } from "@/lib/ai/rapor";
|
||||||
import {
|
import {
|
||||||
RISK_ETIKET,
|
RISK_ETIKET,
|
||||||
@@ -31,105 +41,66 @@ export const DILIM_ETIKET: Record<string, string> = {
|
|||||||
garanti: "Garanti",
|
garanti: "Garanti",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Risk renk dili: yeşil = güvenli, sarı = az riskli, kırmızı = riskli
|
// Risk renk dili: yeşil = güvenli, sarı = az riskli, kırmızı = riskli.
|
||||||
const RISK_STIL: Record<
|
// Nokta hiçbir yerde tek başına durmaz; yanındaki etiket dar ekranda sr-only
|
||||||
RiskSeviyesi,
|
// olarak kalır (renk tek başına anlam taşımaz).
|
||||||
{ nokta: string; kenar: string; icon: typeof Rocket }
|
const RISK_STIL: Record<RiskSeviyesi, { nokta: string; icon: typeof Rocket }> = {
|
||||||
> = {
|
riskli: { nokta: "bg-red-500", icon: Rocket },
|
||||||
riskli: { nokta: "bg-red-500", kenar: "border-l-red-500", icon: Rocket },
|
"az-riskli": { nokta: "bg-amber-500", icon: Scale },
|
||||||
"az-riskli": {
|
guvenli: { nokta: "bg-emerald-500", icon: ShieldCheck },
|
||||||
nokta: "bg-amber-500",
|
|
||||||
kenar: "border-l-amber-500",
|
|
||||||
icon: Scale,
|
|
||||||
},
|
|
||||||
guvenli: {
|
|
||||||
nokta: "bg-emerald-500",
|
|
||||||
kenar: "border-l-emerald-500",
|
|
||||||
icon: ShieldCheck,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const YILLAR = [2021, 2022, 2023, 2024, 2025];
|
// Sütun sayısı: # · Program · Son 5 yıl · Risk · Taban sıra · detay oku
|
||||||
|
const SUTUN_SAYISI = 6;
|
||||||
type Trend = "zorlasiyor" | "kolaylasiyor" | "stabil";
|
|
||||||
|
|
||||||
/** Deterministik trend: ilk ve son bilinen taban sıralaması kıyaslanır.
|
|
||||||
* Sıralama sayısı küçüldüyse bölüm zorlaşıyor demektir. */
|
|
||||||
function trendYonu(gecmis?: (number | null)[]): Trend | null {
|
|
||||||
const dolu = (gecmis ?? []).filter((s): s is number => s != null);
|
|
||||||
if (dolu.length < 2) return null;
|
|
||||||
const oran = dolu[dolu.length - 1] / dolu[0];
|
|
||||||
if (oran < 0.88) return "zorlasiyor";
|
|
||||||
if (oran > 1.14) return "kolaylasiyor";
|
|
||||||
return "stabil";
|
|
||||||
}
|
|
||||||
|
|
||||||
const TREND_GORUNUM: Record<
|
|
||||||
Trend,
|
|
||||||
{ icon: typeof Minus; etiket: string; renk: string }
|
|
||||||
> = {
|
|
||||||
zorlasiyor: {
|
|
||||||
icon: TrendingUp,
|
|
||||||
etiket: "Taban zorlaşıyor",
|
|
||||||
renk: "text-red-500",
|
|
||||||
},
|
|
||||||
kolaylasiyor: {
|
|
||||||
icon: TrendingDown,
|
|
||||||
etiket: "Taban rahatlıyor",
|
|
||||||
renk: "text-emerald-600",
|
|
||||||
},
|
|
||||||
stabil: { icon: Minus, etiket: "Taban stabil", renk: "text-slate-400" },
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Son 5 yılın taban sıralaması mini çizgisi (detay panelinde). */
|
|
||||||
function TrendCizgisi({ gecmis }: { gecmis: (number | null)[] }) {
|
|
||||||
const noktalar = YILLAR.map((yil, i) => ({ yil, sira: gecmis[i] })).filter(
|
|
||||||
(n): n is { yil: number; sira: number } => n.sira != null,
|
|
||||||
);
|
|
||||||
if (noktalar.length < 2) return null;
|
|
||||||
|
|
||||||
const W = 150;
|
|
||||||
const H = 40;
|
|
||||||
const min = Math.min(...noktalar.map((n) => n.sira));
|
|
||||||
const max = Math.max(...noktalar.map((n) => n.sira));
|
|
||||||
const aralik = Math.max(1, max - min);
|
|
||||||
// Küçük sıralama = daha zor bölüm = çizgide yukarıda
|
|
||||||
const nokta = (i: number, sira: number) => ({
|
|
||||||
x: 6 + (i / (noktalar.length - 1)) * (W - 12),
|
|
||||||
y: 8 + ((sira - min) / aralik) * (H - 16),
|
|
||||||
});
|
|
||||||
const path = noktalar
|
|
||||||
.map((n, i) => {
|
|
||||||
const { x, y } = nokta(i, n.sira);
|
|
||||||
return `${i === 0 ? "M" : "L"} ${x.toFixed(1)} ${y.toFixed(1)}`;
|
|
||||||
})
|
|
||||||
.join(" ");
|
|
||||||
const son = nokta(noktalar.length - 1, noktalar[noktalar.length - 1].sira);
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Açık satırın detay bloğu: dilim/risk başlığı + gerekçe ve risk/trend
|
||||||
|
* kartları. /sonuc'taki girişsiz tadımlık satırı da (tadimlik-satiri.tsx)
|
||||||
|
* aynı bileşeni kullanır — "demo değil ürünün kendisi" hissi kopyayla değil
|
||||||
|
* tek kaynakla sağlanır.
|
||||||
|
*/
|
||||||
|
export function RaporSatirDetayi({
|
||||||
|
dilim,
|
||||||
|
risk,
|
||||||
|
unitur,
|
||||||
|
gerekce,
|
||||||
|
riskNotu,
|
||||||
|
trendOzeti,
|
||||||
|
}: {
|
||||||
|
dilim: string;
|
||||||
|
risk: RiskSeviyesi;
|
||||||
|
unitur?: string | null;
|
||||||
|
gerekce: string;
|
||||||
|
riskNotu: string;
|
||||||
|
trendOzeti: string;
|
||||||
|
}) {
|
||||||
|
const stil = RISK_STIL[risk];
|
||||||
return (
|
return (
|
||||||
<div className="mt-2 flex items-center gap-2">
|
<>
|
||||||
<svg
|
<div className="mb-2 flex flex-wrap items-center gap-2 text-xs text-slate-500">
|
||||||
viewBox={`0 0 ${W} ${H}`}
|
<stil.icon className="size-3.5" aria-hidden />
|
||||||
className="h-10 w-37.5"
|
<span className="font-medium">
|
||||||
role="img"
|
{DILIM_ETIKET[dilim]} · {RISK_ETIKET[risk]}
|
||||||
aria-label={`Taban sıralaması ${noktalar[0].yil}: ${noktalar[0].sira.toLocaleString("tr-TR")}, ${noktalar[noktalar.length - 1].yil}: ${noktalar[noktalar.length - 1].sira.toLocaleString("tr-TR")}`}
|
</span>
|
||||||
>
|
{unitur ? <span>· {unitur}</span> : null}
|
||||||
<path
|
</div>
|
||||||
d={path}
|
<dl className="grid gap-2 text-sm sm:grid-cols-2">
|
||||||
fill="none"
|
<div className="rounded-lg border border-slate-200 bg-white p-3">
|
||||||
stroke="oklch(0.623 0.188 259.8)"
|
<dt className="text-xs font-semibold text-slate-500">
|
||||||
strokeWidth={2}
|
Neden listede?
|
||||||
strokeLinecap="round"
|
</dt>
|
||||||
/>
|
<dd className="mt-1 text-slate-700">{gerekce}</dd>
|
||||||
<circle cx={son.x} cy={son.y} r={3} fill="oklch(0.623 0.188 259.8)" />
|
</div>
|
||||||
</svg>
|
<div className="rounded-lg border border-slate-200 bg-white p-3">
|
||||||
<span className="text-xs text-slate-500">
|
<dt className="text-xs font-semibold text-slate-500">
|
||||||
{noktalar[0].yil}: ~{noktalar[0].sira.toLocaleString("tr-TR")}
|
Risk ve Yapay Zeka yorumu
|
||||||
<br />
|
</dt>
|
||||||
{noktalar[noktalar.length - 1].yil}: ~
|
<dd className="mt-1 text-slate-700">
|
||||||
{noktalar[noktalar.length - 1].sira.toLocaleString("tr-TR")}
|
{riskNotu} {trendOzeti}
|
||||||
</span>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
|
</dl>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,7 +122,7 @@ export function RaporListesi({
|
|||||||
onHaritadaGor?: (il: string | null, sira: number) => void;
|
onHaritadaGor?: (il: string | null, sira: number) => void;
|
||||||
}) {
|
}) {
|
||||||
const [acik, setAcik] = useState<Set<number>>(new Set());
|
const [acik, setAcik] = useState<Set<number>>(new Set());
|
||||||
const satirRefs = useRef(new Map<number, HTMLLIElement>());
|
const satirRefs = useRef(new Map<number, HTMLTableRowElement>());
|
||||||
|
|
||||||
// Haritadan gelen seçim: satırı aç ve görünür alana kaydır
|
// Haritadan gelen seçim: satırı aç ve görünür alana kaydır
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -174,15 +145,6 @@ export function RaporListesi({
|
|||||||
|
|
||||||
const kilitIdx = kilitliBaslangic ?? rapor.tercihler.length;
|
const kilitIdx = kilitliBaslangic ?? rapor.tercihler.length;
|
||||||
|
|
||||||
// Dilim dağılımı: liste hayal diliminden başladığı için ilk ekran hep
|
|
||||||
// kırmızı görünür; özet, dengenin sağlıklı olduğunu tek bakışta gösterir.
|
|
||||||
const dilimSayilari = rapor.tercihler.reduce(
|
|
||||||
(t, x) => {
|
|
||||||
t[x.dilim] = (t[x.dilim] ?? 0) + 1;
|
|
||||||
return t;
|
|
||||||
},
|
|
||||||
{} as Record<string, number>,
|
|
||||||
);
|
|
||||||
const iller = new Set(
|
const iller = new Set(
|
||||||
rapor.tercihler
|
rapor.tercihler
|
||||||
.map((t) => rapor.programlar[t.programId]?.il)
|
.map((t) => rapor.programlar[t.programId]?.il)
|
||||||
@@ -190,252 +152,241 @@ export function RaporListesi({
|
|||||||
);
|
);
|
||||||
const tekIl = iller.size === 1 ? [...iller][0] : null;
|
const tekIl = iller.size === 1 ? [...iller][0] : null;
|
||||||
|
|
||||||
const satir = (
|
const satir = (t: RaporSonuc["tercihler"][number], index: number) => {
|
||||||
t: RaporSonuc["tercihler"][number],
|
|
||||||
index: number,
|
|
||||||
) => {
|
|
||||||
const p = rapor.programlar[t.programId];
|
const p = rapor.programlar[t.programId];
|
||||||
const analizKilitli = index >= kilitIdx;
|
const analizKilitli = index >= kilitIdx;
|
||||||
const risk =
|
const risk =
|
||||||
(adaySira != null ? riskHesapla(p?.efektifSira, adaySira) : null) ??
|
(adaySira != null ? riskHesapla(p?.efektifSira, adaySira) : null) ??
|
||||||
dilimdenRisk(t.dilim);
|
dilimdenRisk(t.dilim);
|
||||||
const stil = RISK_STIL[risk];
|
const stil = RISK_STIL[risk];
|
||||||
const trend = trendYonu(p?.siraGecmisi);
|
const seri = raporSiraSerisi(p?.siraGecmisi);
|
||||||
|
const taban = p?.efektifSira ?? programEtkinSira(seri);
|
||||||
const acikMi = acik.has(t.sira);
|
const acikMi = acik.has(t.sira);
|
||||||
const sira2024 = p?.siraGecmisi?.[3] ?? null;
|
const devlet = p?.unitur === "DEVLET";
|
||||||
const sira2025 = p?.siraGecmisi?.[4] ?? null;
|
|
||||||
// Son iki yıldan biri eksikse satır başındaki trend rozeti çelişkili
|
|
||||||
// okunur ("2025: —" ama "stabil") — rozet ancak iki yıl da doluysa çıkar;
|
|
||||||
// uzun vadeli trend detaydaki 2021–2025 çizgisinde durur.
|
|
||||||
const trendG =
|
|
||||||
trend && sira2024 != null && sira2025 != null
|
|
||||||
? TREND_GORUNUM[trend]
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li
|
<Fragment key={t.sira}>
|
||||||
key={t.sira}
|
<TableRow
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
if (el) satirRefs.current.set(t.sira, el);
|
if (el) satirRefs.current.set(t.sira, el);
|
||||||
else satirRefs.current.delete(t.sira);
|
else satirRefs.current.delete(t.sira);
|
||||||
}}
|
}}
|
||||||
className={`overflow-hidden rounded-xl border bg-white transition-[border-color,box-shadow] duration-200 hover:border-slate-300 hover:shadow-xs ${
|
|
||||||
analizKilitli ? "border-slate-200" : `border-l-4 ${stil.kenar}`
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => tiklandi(t.sira)}
|
onClick={() => tiklandi(t.sira)}
|
||||||
aria-expanded={acikMi}
|
className="cursor-pointer"
|
||||||
className="grid min-h-16 w-full cursor-pointer grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-x-3 px-3 py-2.5 text-left xl:grid-cols-[auto_minmax(220px,1fr)_92px_92px_112px_82px_auto] xl:px-4"
|
|
||||||
>
|
>
|
||||||
<span className="flex size-7 shrink-0 items-center justify-center rounded-full bg-slate-100 font-heading text-xs font-bold">
|
<TableCell className="pl-4 pr-0">
|
||||||
{t.sira}
|
<span className="flex size-7 items-center justify-center rounded-full bg-slate-100 font-heading text-xs font-bold">
|
||||||
</span>
|
{t.sira}
|
||||||
<span className="flex min-w-0 items-center gap-2.5">
|
|
||||||
{p?.universite ? (
|
|
||||||
<UniLogo ad={p.universite} boy="sm" className="hidden sm:flex" />
|
|
||||||
) : null}
|
|
||||||
<span className="min-w-0 flex-1">
|
|
||||||
<span className="block truncate text-sm font-semibold">
|
|
||||||
{p?.isim ?? t.programId}
|
|
||||||
</span>
|
</span>
|
||||||
<span className="block truncate text-xs text-slate-500">
|
</TableCell>
|
||||||
{p?.universite}
|
<TableCell className="max-w-0 w-full">
|
||||||
{p?.il ? ` · ${p.il.toLocaleLowerCase("tr-TR")}` : ""}
|
<span className="flex min-w-0 items-center gap-2.5">
|
||||||
</span>
|
{p?.universite ? <UniLogo ad={p.universite} boy="sm" /> : null}
|
||||||
<span className="mt-1 flex items-center gap-3 text-[11px] tabular-nums text-slate-500 xl:hidden">
|
<span className="min-w-0 flex-1">
|
||||||
<span>2024: {sira2024?.toLocaleString("tr-TR") ?? "—"}</span>
|
{p ? (
|
||||||
<span>2025: {sira2025?.toLocaleString("tr-TR") ?? "—"}</span>
|
<Link
|
||||||
{trendG ? <span className={trendG.renk}>{trendG.etiket}</span> : null}
|
href={`/bolum/${bolumSayfaSlug(p.isim)}`}
|
||||||
</span>
|
className="block truncate text-sm font-semibold hover:text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
</span>
|
title={`${p.isim} bölüm sayfası`}
|
||||||
</span>
|
onClick={(e) => e.stopPropagation()}
|
||||||
<span className="hidden text-right text-xs tabular-nums text-slate-600 xl:block">
|
>
|
||||||
{sira2024?.toLocaleString("tr-TR") ?? "—"}
|
{p.isim}
|
||||||
</span>
|
</Link>
|
||||||
<span className="hidden text-right text-xs font-semibold tabular-nums text-slate-900 xl:block">
|
) : (
|
||||||
{sira2025?.toLocaleString("tr-TR") ?? "—"}
|
<span className="block truncate text-sm font-semibold">
|
||||||
</span>
|
{t.programId}
|
||||||
<span className="hidden items-center gap-1.5 text-xs xl:flex">
|
</span>
|
||||||
{trendG ? (
|
)}
|
||||||
<>
|
<span className="mt-0.5 flex items-center gap-1.5 text-xs text-slate-500">
|
||||||
<trendG.icon className={`size-4 shrink-0 ${trendG.renk}`} aria-hidden />
|
{p?.unitur ? (
|
||||||
<span className={trendG.renk}>
|
<span
|
||||||
{trend === "zorlasiyor"
|
className={`shrink-0 rounded px-1 py-px text-[10px] font-medium leading-4 ${
|
||||||
? "Zorlaşıyor"
|
devlet
|
||||||
: trend === "kolaylasiyor"
|
? "bg-slate-100 text-slate-600"
|
||||||
? "Rahatlıyor"
|
: "bg-violet-50 text-violet-600"
|
||||||
: "Stabil"}
|
}`}
|
||||||
|
>
|
||||||
|
{devlet ? "Devlet" : "Vakıf"}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<span className="truncate">
|
||||||
|
{p?.universite ? (
|
||||||
|
<Link
|
||||||
|
href={`/universite/${uniSayfaSlug(p.universite)}`}
|
||||||
|
className="hover:text-primary hover:underline"
|
||||||
|
title={`${p.universite} taban puanları sayfası`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{p.universite}
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
{p?.il ? ` · ${p.il.toLocaleLowerCase("tr-TR")}` : ""}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</>
|
</span>
|
||||||
) : (
|
</span>
|
||||||
<span className="text-slate-400">Veri eksik</span>
|
</TableCell>
|
||||||
)}
|
<TableCell className="hidden text-right text-xs md:table-cell">
|
||||||
</span>
|
<ProgramSiraGecmisi program={seri} />
|
||||||
<span className="hidden items-center gap-1.5 text-xs font-medium xl:flex">
|
</TableCell>
|
||||||
|
<TableCell className="text-xs font-medium">
|
||||||
{analizKilitli ? (
|
{analizKilitli ? (
|
||||||
<>
|
<span className="inline-flex items-center gap-1.5 text-slate-500">
|
||||||
<Lock className="size-3.5 text-slate-400" aria-hidden />
|
<Lock className="size-3.5 shrink-0 text-slate-400" aria-hidden />
|
||||||
<span className="text-slate-500">Pakette</span>
|
<span className="sr-only sm:not-sr-only">Pakette</span>
|
||||||
</>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<span className="inline-flex items-center gap-1.5">
|
||||||
<span className={`size-2.5 rounded-full ${stil.nokta}`} aria-hidden />
|
<span
|
||||||
<span>{RISK_ETIKET[risk]}</span>
|
className={`size-2.5 shrink-0 rounded-full ${stil.nokta}`}
|
||||||
</>
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<span className="sr-only sm:not-sr-only">
|
||||||
|
{RISK_ETIKET[risk]}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</TableCell>
|
||||||
<ChevronDown
|
<TableCell className="text-right text-xs tabular-nums text-slate-500">
|
||||||
className={`size-4 shrink-0 text-slate-400 transition-transform duration-200 ${acikMi ? "rotate-180" : ""}`}
|
<span className="inline-flex items-center gap-1">
|
||||||
aria-hidden
|
<ProgramTabanTrendi program={seri} />
|
||||||
/>
|
{taban != null ? `~${taban.toLocaleString("tr-TR")}.` : "—"}
|
||||||
</button>
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="pr-4 text-right">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-expanded={acikMi}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
tiklandi(t.sira);
|
||||||
|
}}
|
||||||
|
className="inline-flex size-8 min-h-8 min-w-8 cursor-pointer items-center justify-center rounded-full border border-slate-200 bg-white text-slate-500 transition-[background-color,border-color,color,transform] duration-150 hover:border-primary hover:bg-primary/5 hover:text-primary active:scale-[0.92] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
<ChevronDown
|
||||||
|
className={`size-4 transition-transform duration-200 ${acikMi ? "rotate-180" : ""}`}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<span className="sr-only">
|
||||||
|
{p?.isim ?? t.programId} — Yapay Zeka değerlendirmesi
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
|
||||||
{acikMi ? (
|
{acikMi ? (
|
||||||
<div className="border-t border-slate-100 bg-slate-50/50 px-4 py-4">
|
<TableRow className="hover:bg-transparent">
|
||||||
<div className="mb-2 flex flex-wrap items-center gap-2 text-xs text-slate-500">
|
<TableCell
|
||||||
|
colSpan={SUTUN_SAYISI}
|
||||||
|
className="whitespace-normal bg-slate-50/60 px-4 py-4"
|
||||||
|
>
|
||||||
{analizKilitli ? (
|
{analizKilitli ? (
|
||||||
<>
|
<>
|
||||||
<Lock className="size-3.5" aria-hidden />
|
<div className="mb-2 flex flex-wrap items-center gap-2 text-xs text-slate-500">
|
||||||
<span className="font-medium">Kişisel analiz pakette</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<stil.icon className="size-3.5" aria-hidden />
|
|
||||||
<span className="font-medium">
|
|
||||||
{DILIM_ETIKET[t.dilim]} · {RISK_ETIKET[risk]}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{p?.unitur ? <span>· {p.unitur}</span> : null}
|
|
||||||
</div>
|
|
||||||
<dl className="grid gap-2 text-sm sm:grid-cols-3">
|
|
||||||
<div className="rounded-lg border border-slate-200 bg-white p-3">
|
|
||||||
<dt className="text-xs font-semibold text-slate-500">
|
|
||||||
2021–2025 taban trendi
|
|
||||||
</dt>
|
|
||||||
<dd className="mt-1 text-slate-700">
|
|
||||||
{p?.siraGecmisi ? (
|
|
||||||
<TrendCizgisi gecmis={p.siraGecmisi} />
|
|
||||||
) : (
|
|
||||||
"Yeterli veri yok."
|
|
||||||
)}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
{analizKilitli ? (
|
|
||||||
<div className="flex flex-col justify-center rounded-lg border border-dashed border-slate-300 bg-white p-3 sm:col-span-2">
|
|
||||||
<dt className="flex items-center gap-1.5 text-xs font-semibold text-slate-700">
|
|
||||||
<Lock className="size-3.5" aria-hidden />
|
<Lock className="size-3.5" aria-hidden />
|
||||||
Yapay Zeka karar desteği
|
<span className="font-medium">Kişisel analiz pakette</span>
|
||||||
</dt>
|
{p?.unitur ? <span>· {p.unitur}</span> : null}
|
||||||
<dd className="mt-1 text-sm text-slate-600">
|
|
||||||
Bu bölümün neden listende olduğunu ve sıralamana özel yerleşme
|
|
||||||
riskini paketle gör.
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="rounded-lg border border-slate-200 bg-white p-3">
|
|
||||||
<dt className="text-xs font-semibold text-slate-500">
|
|
||||||
Neden listede?
|
|
||||||
</dt>
|
|
||||||
<dd className="mt-1 text-slate-700">{t.gerekce}</dd>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg border border-slate-200 bg-white p-3">
|
<div className="rounded-lg border border-dashed border-slate-300 bg-white p-3">
|
||||||
<dt className="text-xs font-semibold text-slate-500">
|
<p className="flex items-center gap-1.5 text-xs font-semibold text-slate-700">
|
||||||
Risk ve Yapay Zeka yorumu
|
<Lock className="size-3.5" aria-hidden />
|
||||||
</dt>
|
Yapay Zeka karar desteği
|
||||||
<dd className="mt-1 text-slate-700">
|
</p>
|
||||||
{t.riskNotu} {t.trendOzeti}
|
<p className="mt-1 text-sm text-slate-600">
|
||||||
</dd>
|
Bu bölümün neden listende olduğunu ve sıralamana özel
|
||||||
|
yerleşme riskini paketle gör.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
) : (
|
||||||
|
<RaporSatirDetayi
|
||||||
|
dilim={t.dilim}
|
||||||
|
risk={risk}
|
||||||
|
unitur={p?.unitur}
|
||||||
|
gerekce={t.gerekce}
|
||||||
|
riskNotu={t.riskNotu}
|
||||||
|
trendOzeti={t.trendOzeti}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</dl>
|
{onHaritadaGor && p?.il ? (
|
||||||
{onHaritadaGor && p?.il ? (
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
onClick={() => onHaritadaGor(p.il, t.sira)}
|
||||||
onClick={() => onHaritadaGor(p.il, t.sira)}
|
className="mt-3 inline-flex cursor-pointer items-center gap-1.5 text-xs font-medium text-primary transition-colors duration-200 hover:text-primary/80"
|
||||||
className="mt-3 inline-flex cursor-pointer items-center gap-1.5 text-xs font-medium text-primary transition-colors duration-200 hover:text-primary/80"
|
>
|
||||||
>
|
<MapPin className="size-3.5" aria-hidden />
|
||||||
<MapPin className="size-3.5" aria-hidden />
|
Haritada gör
|
||||||
Haritada gör
|
</button>
|
||||||
</button>
|
) : null}
|
||||||
) : null}
|
</TableCell>
|
||||||
</div>
|
</TableRow>
|
||||||
) : null}
|
) : null}
|
||||||
</li>
|
</Fragment>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex flex-wrap items-end justify-between gap-2">
|
{kilitIdx < rapor.tercihler.length ? (
|
||||||
<div>
|
<p className="text-right text-xs font-medium text-emerald-700">
|
||||||
<h2 className="font-heading text-lg font-bold">24 tercihin ve taban verileri</h2>
|
Programlar ve ham veriler ücretsiz
|
||||||
<p className="mt-1 text-xs text-slate-500">
|
|
||||||
Satıra dokunarak 2021–2025 trendini inceleyebilirsin.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{kilitIdx < rapor.tercihler.length ? (
|
|
||||||
<p className="text-xs font-medium text-emerald-700">
|
|
||||||
Programlar ve ham veriler ücretsiz
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs font-medium">
|
|
||||||
{(
|
|
||||||
[
|
|
||||||
["hayal", "bg-red-500"],
|
|
||||||
["dengeli", "bg-amber-500"],
|
|
||||||
["garanti", "bg-emerald-500"],
|
|
||||||
] as const
|
|
||||||
).map(([dilim, nokta]) => (
|
|
||||||
<span
|
|
||||||
key={dilim}
|
|
||||||
className="inline-flex items-center gap-1.5 rounded-full border border-slate-200 bg-white px-2.5 py-1 text-slate-700"
|
|
||||||
>
|
|
||||||
<span className={`size-2 rounded-full ${nokta}`} aria-hidden />
|
|
||||||
{dilimSayilari[dilim] ?? 0} {DILIM_ETIKET[dilim].toLocaleLowerCase("tr-TR")}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
<span className="text-slate-400">
|
|
||||||
Liste hayal diliminden başlar; kırmızı satırlar plana dahildir.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{tekIl ? (
|
|
||||||
<p className="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900">
|
|
||||||
Listenin 24 tercihi de tek şehirde (
|
|
||||||
{tekIl.toLocaleLowerCase("tr-TR")}). Boşta kalma riskini azaltmak
|
|
||||||
için birkaç farklı şehir eklemeyi düşünebilirsin.
|
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="mt-4 hidden grid-cols-[auto_minmax(220px,1fr)_92px_92px_112px_82px_auto] gap-x-3 px-4 text-[11px] font-semibold uppercase tracking-wide text-slate-500 xl:grid">
|
{rapor.kapsam?.uyarlandi ? (
|
||||||
<span className="w-7">#</span>
|
<p className="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900">
|
||||||
<span>Program</span>
|
Tercihlerine göre {rapor.kapsam.toplam} uygun program bulundu; listen{" "}
|
||||||
<span className="text-right">2024 taban</span>
|
{rapor.kapsam.dagilim.hayal} hayal · {rapor.kapsam.dagilim.dengeli}{" "}
|
||||||
<span className="text-right">2025 taban</span>
|
dengeli · {rapor.kapsam.dagilim.garanti} garanti olarak kuruldu. Daha
|
||||||
<span>Ham trend</span>
|
fazla seçenek için alan veya il filtreni genişletebilirsin.
|
||||||
<span>Yapay Zeka risk</span>
|
</p>
|
||||||
<span className="w-4" />
|
) : null}
|
||||||
</div>
|
|
||||||
|
|
||||||
<ol className="mt-2 flex flex-col gap-2">
|
{tekIl ? (
|
||||||
{rapor.tercihler.map((t, index) => (
|
<p className="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900">
|
||||||
<Fragment key={t.sira}>
|
Listenin {rapor.tercihler.length} tercihi de tek şehirde (
|
||||||
{index === kilitIdx && kilitOverlay ? (
|
{tekIl.toLocaleLowerCase("tr-TR")}). Boşta kalma riskini azaltmak için
|
||||||
<li className="my-2 list-none rounded-2xl border border-orange-200 bg-orange-50/70 p-5">
|
birkaç farklı şehir eklemeyi düşünebilirsin.
|
||||||
{kilitOverlay}
|
</p>
|
||||||
</li>
|
) : null}
|
||||||
) : null}
|
|
||||||
{satir(t, index)}
|
<div className="mt-3 overflow-hidden rounded-2xl border border-slate-200 bg-white">
|
||||||
</Fragment>
|
<Table>
|
||||||
))}
|
<TableHeader>
|
||||||
</ol>
|
<TableRow>
|
||||||
|
<TableHead className="w-8 pl-4 pr-0">#</TableHead>
|
||||||
|
<TableHead>Program</TableHead>
|
||||||
|
<TableHead className="hidden text-right md:table-cell">
|
||||||
|
Son 5 yıl
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>
|
||||||
|
<span className="hidden sm:inline">Yapay Zeka </span>Risk
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="text-right">Taban sıra</TableHead>
|
||||||
|
<TableHead className="w-12 pr-4 text-right">
|
||||||
|
<span className="sr-only">Yapay Zeka değerlendirmesi</span>
|
||||||
|
</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{rapor.tercihler.map((t, index) => (
|
||||||
|
<Fragment key={t.sira}>
|
||||||
|
{index === kilitIdx && kilitOverlay ? (
|
||||||
|
<TableRow className="hover:bg-transparent">
|
||||||
|
<TableCell
|
||||||
|
colSpan={SUTUN_SAYISI}
|
||||||
|
className="whitespace-normal border-y border-orange-200 bg-orange-50/70 p-5"
|
||||||
|
>
|
||||||
|
{kilitOverlay}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : null}
|
||||||
|
{satir(t, index)}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ export function Sayfalama({
|
|||||||
{sayfa > 1
|
{sayfa > 1
|
||||||
? gecis(
|
? gecis(
|
||||||
sayfa - 1,
|
sayfa - 1,
|
||||||
"inline-flex h-9 items-center gap-1 rounded-lg border border-slate-200 bg-white px-3 text-sm text-slate-700 hover:border-primary/40 hover:text-primary",
|
"inline-flex h-9 items-center gap-1 rounded-full border border-slate-200 bg-white px-3.5 text-sm text-slate-700 hover:border-primary/40 hover:text-primary",
|
||||||
<>
|
<>
|
||||||
<ChevronLeft className="size-4" aria-hidden />
|
<ChevronLeft className="size-4" aria-hidden />
|
||||||
Önceki
|
Önceki
|
||||||
@@ -107,8 +107,8 @@ export function Sayfalama({
|
|||||||
gecis(
|
gecis(
|
||||||
n,
|
n,
|
||||||
n === sayfa
|
n === sayfa
|
||||||
? "inline-flex h-9 min-w-9 items-center justify-center rounded-lg bg-primary px-2 text-sm font-semibold text-primary-foreground"
|
? "inline-flex h-9 min-w-9 items-center justify-center rounded-full bg-primary px-2 text-sm font-semibold text-primary-foreground"
|
||||||
: "inline-flex h-9 min-w-9 items-center justify-center rounded-lg border border-slate-200 bg-white px-2 text-sm text-slate-700 hover:border-primary/40 hover:text-primary",
|
: "inline-flex h-9 min-w-9 items-center justify-center rounded-full border border-slate-200 bg-white px-2 text-sm text-slate-700 hover:border-primary/40 hover:text-primary",
|
||||||
n,
|
n,
|
||||||
{ aktif: n === sayfa },
|
{ aktif: n === sayfa },
|
||||||
)
|
)
|
||||||
@@ -117,7 +117,7 @@ export function Sayfalama({
|
|||||||
{sayfa < toplamSayfa
|
{sayfa < toplamSayfa
|
||||||
? gecis(
|
? gecis(
|
||||||
sayfa + 1,
|
sayfa + 1,
|
||||||
"inline-flex h-9 items-center gap-1 rounded-lg border border-slate-200 bg-white px-3 text-sm text-slate-700 hover:border-primary/40 hover:text-primary",
|
"inline-flex h-9 items-center gap-1 rounded-full border border-slate-200 bg-white px-3.5 text-sm text-slate-700 hover:border-primary/40 hover:text-primary",
|
||||||
<>
|
<>
|
||||||
Sonraki
|
Sonraki
|
||||||
<ChevronRight className="size-4" aria-hidden />
|
<ChevronRight className="size-4" aria-hidden />
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { getAllBolumler } from "@/lib/katalog";
|
import { getAllBolumler } from "@/lib/katalog";
|
||||||
|
|
||||||
@@ -155,6 +156,15 @@ export function SiteFooter() {
|
|||||||
İletişim
|
İletişim
|
||||||
</Link>
|
</Link>
|
||||||
</nav>
|
</nav>
|
||||||
|
{/* iyzico işyeri sözleşmesi gereği: "iyzico ile Öde" + kart logoları
|
||||||
|
sitede görünür olmalı. Resmî logo bandı (iyzico logo paketi). */}
|
||||||
|
<Image
|
||||||
|
src="/odeme/iyzico-logo-band.svg"
|
||||||
|
alt="iyzico ile Öde — Mastercard, Visa, American Express ve Troy ile güvenli ödeme"
|
||||||
|
width={429}
|
||||||
|
height={32}
|
||||||
|
className="mx-auto mt-6 h-6 w-auto"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Slot } from "radix-ui"
|
|||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"group/button inline-flex shrink-0 items-center justify-center rounded-full border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
@@ -22,16 +22,14 @@ const buttonVariants = cva(
|
|||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default:
|
default:
|
||||||
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
"h-9 gap-1.5 px-3.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
|
||||||
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
xs: "h-6 gap-1 px-2.5 text-xs has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*='size-'])]:size-3",
|
||||||
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
sm: "h-7 gap-1 px-3 text-[0.8rem] has-data-[icon=inline-end]:pr-2.5 has-data-[icon=inline-start]:pl-2.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||||
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
lg: "h-12 gap-2 px-7 text-base has-data-[icon=inline-end]:pr-6 has-data-[icon=inline-start]:pl-6",
|
||||||
icon: "size-8",
|
icon: "size-9",
|
||||||
"icon-xs":
|
"icon-xs": "size-6 [&_svg:not([class*='size-'])]:size-3",
|
||||||
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
"icon-sm": "size-7",
|
||||||
"icon-sm":
|
"icon-lg": "size-12",
|
||||||
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
|
||||||
"icon-lg": "size-9",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ function PromptSuggestion({
|
|||||||
<Button
|
<Button
|
||||||
variant={variant || "outline"}
|
variant={variant || "outline"}
|
||||||
size={size || "lg"}
|
size={size || "lg"}
|
||||||
className={cn("rounded-full", className)}
|
className={cn(className)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
@@ -42,7 +42,7 @@ function PromptSuggestion({
|
|||||||
variant={variant || "ghost"}
|
variant={variant || "ghost"}
|
||||||
size={size || "sm"}
|
size={size || "sm"}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full cursor-pointer justify-start rounded-xl py-2",
|
"w-full cursor-pointer justify-start py-2",
|
||||||
"hover:bg-accent",
|
"hover:bg-accent",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
@@ -63,7 +63,7 @@ function PromptSuggestion({
|
|||||||
variant={variant || "ghost"}
|
variant={variant || "ghost"}
|
||||||
size={size || "sm"}
|
size={size || "sm"}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full cursor-pointer justify-start gap-0 rounded-xl py-2",
|
"w-full cursor-pointer justify-start gap-0 py-2",
|
||||||
"hover:bg-accent",
|
"hover:bg-accent",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
programEtkinSira,
|
programEtkinSira,
|
||||||
} from "@/components/program-liste-verileri";
|
} from "@/components/program-liste-verileri";
|
||||||
import { ProgramEkleButonu } from "@/components/manuel-liste/program-ekle-butonu";
|
import { ProgramEkleButonu } from "@/components/manuel-liste/program-ekle-butonu";
|
||||||
|
import { ProgramNetleriButonu } from "@/components/program-netleri";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -56,8 +57,8 @@ export function UniversiteProgramTablosu({
|
|||||||
Kontenjan
|
Kontenjan
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="text-right">Taban sıra</TableHead>
|
<TableHead className="text-right">Taban sıra</TableHead>
|
||||||
<TableHead className="w-14 text-right">
|
<TableHead className="w-24 text-right">
|
||||||
<span className="sr-only">Listeye ekle</span>
|
<span className="sr-only">Netler ve listeye ekle</span>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
@@ -108,11 +109,18 @@ export function UniversiteProgramTablosu({
|
|||||||
</span>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="pr-3 text-right">
|
<TableCell className="pr-3 text-right">
|
||||||
<ProgramEkleButonu
|
<span className="inline-flex items-center gap-1.5">
|
||||||
program={p}
|
<ProgramNetleriButonu
|
||||||
kaynak="universite"
|
programId={p.id}
|
||||||
className="size-8 min-h-8 min-w-8"
|
isim={p.isim.trim()}
|
||||||
/>
|
className="size-8 min-h-8 min-w-8"
|
||||||
|
/>
|
||||||
|
<ProgramEkleButonu
|
||||||
|
program={p}
|
||||||
|
kaynak="universite"
|
||||||
|
className="size-8 min-h-8 min-w-8"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
);
|
);
|
||||||
|
|||||||
54
src/components/use-gorunum-olayi.ts
Normal file
54
src/components/use-gorunum-olayi.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, type RefObject } from "react";
|
||||||
|
import { olay, type OlayAdi, type Ozellikler } from "@/lib/analitik";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bir elemanın viewport'a ilk girişinde analitik olayı atan ref hook'u.
|
||||||
|
* Gösterim olayları (hazir_panel_goruntulendi, kilit_goruntulendi vb.)
|
||||||
|
* oturum başına en fazla 1 kez sayılmalı; tekilleştirme sessionStorage
|
||||||
|
* anahtarıyla yapılır. IntersectionObserver yoksa olay hiç atılmaz —
|
||||||
|
* "görüntülendi" ölçümünde görünürlüğü doğrulayamadığımız cihazı saymak
|
||||||
|
* veriyi şişirir.
|
||||||
|
*/
|
||||||
|
export function useGorunumOlayi<T extends HTMLElement = HTMLElement>(
|
||||||
|
ad: OlayAdi,
|
||||||
|
ozellikler?: Ozellikler,
|
||||||
|
/** Aynı olayın farklı yüzeyleri için tekilleştirme eki ("satir", "kart"). */
|
||||||
|
anahtarEki?: string,
|
||||||
|
): RefObject<T | null> {
|
||||||
|
const ref = useRef<T>(null);
|
||||||
|
// Özellikler her render sonrası tazelenir; observer effect'i yalnızca
|
||||||
|
// ad/anahtar değişiminde kurulur (deps'e obje koymamak için ref).
|
||||||
|
const ozelliklerRef = useRef(ozellikler);
|
||||||
|
useEffect(() => {
|
||||||
|
ozelliklerRef.current = ozellikler;
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el || typeof IntersectionObserver === "undefined") return;
|
||||||
|
|
||||||
|
const anahtar = `kolaytercih.olay.${ad}${anahtarEki ? `.${anahtarEki}` : ""}`;
|
||||||
|
try {
|
||||||
|
if (window.sessionStorage.getItem(anahtar)) return;
|
||||||
|
} catch {
|
||||||
|
// Storage engelliyse tekilleştirmeden vazgeç, olay yine atılabilir
|
||||||
|
}
|
||||||
|
|
||||||
|
const io = new IntersectionObserver((entries) => {
|
||||||
|
if (!entries.some((e) => e.isIntersecting)) return;
|
||||||
|
io.disconnect();
|
||||||
|
try {
|
||||||
|
window.sessionStorage.setItem(anahtar, "1");
|
||||||
|
} catch {
|
||||||
|
// yut
|
||||||
|
}
|
||||||
|
olay(ad, ozelliklerRef.current);
|
||||||
|
});
|
||||||
|
io.observe(el);
|
||||||
|
return () => io.disconnect();
|
||||||
|
}, [ad, anahtarEki]);
|
||||||
|
|
||||||
|
return ref;
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { Coins } from "lucide-react";
|
|||||||
import { getCurrentUser } from "@/lib/session";
|
import { getCurrentUser } from "@/lib/session";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ListemButonu } from "@/components/manuel-liste/listem-butonu";
|
import { ListemButonu } from "@/components/manuel-liste/listem-butonu";
|
||||||
|
import { ListeSenkron } from "@/components/manuel-liste/liste-senkron";
|
||||||
import { RybbitIdentify } from "@/components/rybbit-identify";
|
import { RybbitIdentify } from "@/components/rybbit-identify";
|
||||||
import { SignOutButton } from "./sign-out-button";
|
import { SignOutButton } from "./sign-out-button";
|
||||||
|
|
||||||
@@ -29,6 +30,7 @@ export async function UserNav() {
|
|||||||
return (
|
return (
|
||||||
<div className="order-2 flex shrink-0 items-center gap-1.5 sm:gap-2">
|
<div className="order-2 flex shrink-0 items-center gap-1.5 sm:gap-2">
|
||||||
<RybbitIdentify kullaniciId={null} />
|
<RybbitIdentify kullaniciId={null} />
|
||||||
|
<ListeSenkron userId={null} />
|
||||||
<ListemButonu />
|
<ListemButonu />
|
||||||
<Button
|
<Button
|
||||||
asChild
|
asChild
|
||||||
@@ -43,6 +45,7 @@ export async function UserNav() {
|
|||||||
return (
|
return (
|
||||||
<div className="order-2 flex shrink-0 items-center gap-1.5 sm:gap-2">
|
<div className="order-2 flex shrink-0 items-center gap-1.5 sm:gap-2">
|
||||||
<RybbitIdentify kullaniciId={u.id} eposta={u.email} isim={u.name} />
|
<RybbitIdentify kullaniciId={u.id} eposta={u.email} isim={u.name} />
|
||||||
|
<ListeSenkron userId={u.id} />
|
||||||
<ListemButonu />
|
<ListemButonu />
|
||||||
<Link
|
<Link
|
||||||
href="/paket"
|
href="/paket"
|
||||||
|
|||||||
13
src/instrumentation.ts
Normal file
13
src/instrumentation.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* Sunucu açılışında bir kez çalışır (Next instrumentation konvansiyonu).
|
||||||
|
* Kredi-bitti hatırlatma taramasını başlatır — tek, sürekli çalışan
|
||||||
|
* container'da harici cron yerine in-process zamanlayıcı yeterli.
|
||||||
|
*/
|
||||||
|
export async function register() {
|
||||||
|
if (process.env.NEXT_RUNTIME !== "nodejs") return;
|
||||||
|
if (process.env.NEXT_PHASE === "phase-production-build") return;
|
||||||
|
// Şema, konteyner açılışında scripts/db-goc.mjs'in uyguladığı drizzle
|
||||||
|
// migration'larıyla güncellenir (bkz. docker-entrypoint.sh); burada DDL koşmaz.
|
||||||
|
const { krediHatirlatmaBaslat } = await import("./lib/kredi-hatirlatma");
|
||||||
|
krediHatirlatmaBaslat();
|
||||||
|
}
|
||||||
@@ -1,43 +1,40 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { zodOutputFormat } from "@anthropic-ai/sdk/helpers/zod";
|
import { getOpenRouter, raporModeli, sohbetModeli } from "./client";
|
||||||
import { ThinkingLevel } from "@google/genai";
|
|
||||||
import {
|
|
||||||
getAnthropic,
|
|
||||||
getGemini,
|
|
||||||
raporModeli,
|
|
||||||
saglayici,
|
|
||||||
sohbetModeli,
|
|
||||||
} from "./client";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sağlayıcıdan bağımsız AI çağrı katmanı. Anthropic ve Gemini'nin SDK'ları
|
* AI çağrı katmanı: tüm istekler OpenRouter'ın OpenAI-uyumlu API'sine gider
|
||||||
* yapılandırılmış çıktı ve stream için farklı şekiller kullanıyor; rapor ve
|
* (tek kapı — kullanıcı kararı, 7 Ağu 2026). Rapor ve sohbet kodu SDK/istek
|
||||||
* sohbet kodu bu farkı görmesin diye tek arayüz burada toplanıyor.
|
* ayrıntısını görmesin diye tek arayüz burada toplanıyor.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gemini JSON Schema'nın yalnızca bir alt kümesini destekliyor; zod'un ürettiği
|
* zod şemasını JSON Schema'ya çevirir: z.toJSONSchema'nın eklediği $schema
|
||||||
* $schema ve additionalProperties alanları isteği reddettiriyor, ayıklanıyor.
|
* alanı ayıklanır, her object düğümüne additionalProperties: false eklenir —
|
||||||
|
* OpenAI-uyumlu strict json_schema doğrulayıcıları bunu şart koşuyor.
|
||||||
*/
|
*/
|
||||||
function geminiSemasi(sema: z.ZodType): unknown {
|
function jsonSemasi(sema: z.ZodType): Record<string, unknown> {
|
||||||
const temizle = (dugum: unknown): unknown => {
|
const donustur = (dugum: unknown): unknown => {
|
||||||
if (Array.isArray(dugum)) return dugum.map(temizle);
|
if (Array.isArray(dugum)) return dugum.map(donustur);
|
||||||
if (dugum && typeof dugum === "object") {
|
if (dugum && typeof dugum === "object") {
|
||||||
const cikti: Record<string, unknown> = {};
|
const cikti: Record<string, unknown> = {};
|
||||||
for (const [k, v] of Object.entries(dugum as Record<string, unknown>)) {
|
for (const [k, v] of Object.entries(dugum as Record<string, unknown>)) {
|
||||||
if (k === "$schema" || k === "additionalProperties") continue;
|
if (k === "$schema") continue;
|
||||||
cikti[k] = temizle(v);
|
cikti[k] = donustur(v);
|
||||||
|
}
|
||||||
|
if (cikti.type === "object" && cikti.additionalProperties === undefined) {
|
||||||
|
cikti.additionalProperties = false;
|
||||||
}
|
}
|
||||||
return cikti;
|
return cikti;
|
||||||
}
|
}
|
||||||
return dugum;
|
return dugum;
|
||||||
};
|
};
|
||||||
return temizle(z.toJSONSchema(sema));
|
return donustur(z.toJSONSchema(sema)) as Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gemini ücretsiz katmanı yoğunlukta 503/429 döndürüyor; bu geçici hatalarda
|
* OpenRouter yoğunlukta/limitte 429, üst sağlayıcı arızasında 5xx dönebiliyor;
|
||||||
* artan bekleme ile yeniden dene. Kalıcı hatalar (400, 403) olduğu gibi fırlar.
|
* bu geçici hatalarda artan bekleme ile yeniden dene. Kalıcı hatalar
|
||||||
|
* (400, 401, 403) olduğu gibi fırlar.
|
||||||
*/
|
*/
|
||||||
const GECICI_KODLAR = new Set([429, 500, 502, 503, 504]);
|
const GECICI_KODLAR = new Set([429, 500, 502, 503, 504]);
|
||||||
|
|
||||||
@@ -65,9 +62,10 @@ async function geciciHatadaTekrarla<T>(
|
|||||||
* Tek yapılandırılmış üretim çağrısının üst süre sınırı. Süre, çağıranın
|
* Tek yapılandırılmış üretim çağrısının üst süre sınırı. Süre, çağıranın
|
||||||
* (server action) platform limitinden önce dolmalı ki hata iade kodunun
|
* (server action) platform limitinden önce dolmalı ki hata iade kodunun
|
||||||
* çalışabildiği pencerede fırlasın — fonksiyon dışarıdan öldürülürse
|
* çalışabildiği pencerede fırlasın — fonksiyon dışarıdan öldürülürse
|
||||||
* harcanan kredi iade edilemiyor.
|
* harcanan kredi iade edilemiyor. Flash sınıfı model tipik ~10-20 sn'de
|
||||||
|
* biter; 60 sn üst sağlayıcı yavaşlamalarına pay bırakır.
|
||||||
*/
|
*/
|
||||||
const URETIM_ZAMAN_ASIMI_MS = 50_000;
|
const URETIM_ZAMAN_ASIMI_MS = 60_000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Şemaya uyan tek seferlik çıktı üretir. Şemaya uymayan yanıtta null döner
|
* Şemaya uyan tek seferlik çıktı üretir. Şemaya uymayan yanıtta null döner
|
||||||
@@ -77,97 +75,94 @@ export async function yapilandirilmisUret<T extends z.ZodType>(opts: {
|
|||||||
system: string;
|
system: string;
|
||||||
kullanici: string;
|
kullanici: string;
|
||||||
sema: T;
|
sema: T;
|
||||||
|
// Sağlayıcıya giden json_schema bu şemadan üretilir (verilmezse sema).
|
||||||
|
// Lokal doğrulama HER ZAMAN sema ile yapılır: tel şeması sıkı uzunluk
|
||||||
|
// kısıtları taşıyabilir (grammar zorlaması), sağlayıcı bu kısıtları yok
|
||||||
|
// sayarsa çıktı yine gevşek şemayla kurtarılır. Not: strict json_schema'da
|
||||||
|
// minItems/maxItems bazı sağlayıcılarda 400 döndürebilir; mevcut sağlayıcı
|
||||||
|
// (deepseek) kabul ediyor — sağlayıcı değişiminde geri çekilme yolu
|
||||||
|
// wireSema'dan uzunluk kısıtlarını kaldırmaktır.
|
||||||
|
wireSema?: z.ZodType;
|
||||||
maxTokens: number;
|
maxTokens: number;
|
||||||
}): Promise<z.infer<T> | null> {
|
}): Promise<z.infer<T> | null> {
|
||||||
// Tek sinyal tüm iç denemeleri kapsar: süre dolunca bekleyen retry'lar da
|
// Tek sinyal tüm iç denemeleri kapsar: süre dolunca bekleyen retry'lar da
|
||||||
// anında düşer, toplam süre sınırın üstüne çıkamaz.
|
// anında düşer, toplam süre sınırın üstüne çıkamaz.
|
||||||
const sinyal = AbortSignal.timeout(URETIM_ZAMAN_ASIMI_MS);
|
const sinyal = AbortSignal.timeout(URETIM_ZAMAN_ASIMI_MS);
|
||||||
if (saglayici() === "gemini") {
|
const yanit = await geciciHatadaTekrarla(() =>
|
||||||
const yanit = await geciciHatadaTekrarla(() =>
|
getOpenRouter().chat.completions.create(
|
||||||
getGemini().models.generateContent({
|
{
|
||||||
model: raporModeli(),
|
model: raporModeli(),
|
||||||
contents: opts.kullanici,
|
max_tokens: opts.maxTokens,
|
||||||
config: {
|
// Görev küçük (havuzdan seçim + tek cümlelik gerekce) ve şemaya
|
||||||
abortSignal: sinyal,
|
// uymayan çıktıyı doğrulama döngüsü zaten yakalıyor; hibrit
|
||||||
systemInstruction: opts.system,
|
// modellerde düşünme bütçe/süre yemesin.
|
||||||
maxOutputTokens: opts.maxTokens,
|
reasoning_effort: "none",
|
||||||
responseMimeType: "application/json",
|
messages: [
|
||||||
responseJsonSchema: geminiSemasi(opts.sema),
|
{ role: "system", content: opts.system },
|
||||||
thinkingConfig: { thinkingLevel: ThinkingLevel.HIGH },
|
{ role: "user", content: opts.kullanici },
|
||||||
|
],
|
||||||
|
response_format: {
|
||||||
|
type: "json_schema",
|
||||||
|
json_schema: {
|
||||||
|
name: "yanit",
|
||||||
|
strict: true,
|
||||||
|
schema: jsonSemasi(opts.wireSema ?? opts.sema),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}),
|
},
|
||||||
);
|
{ signal: sinyal },
|
||||||
const metin = yanit.text;
|
),
|
||||||
if (!metin) return null;
|
|
||||||
try {
|
|
||||||
const parsed = opts.sema.safeParse(JSON.parse(metin));
|
|
||||||
return parsed.success ? parsed.data : null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const yanit = await getAnthropic().messages.parse(
|
|
||||||
{
|
|
||||||
model: raporModeli(),
|
|
||||||
max_tokens: opts.maxTokens,
|
|
||||||
thinking: { type: "adaptive" },
|
|
||||||
system: opts.system,
|
|
||||||
messages: [{ role: "user", content: opts.kullanici }],
|
|
||||||
output_config: { format: zodOutputFormat(opts.sema) },
|
|
||||||
},
|
|
||||||
{ signal: sinyal },
|
|
||||||
);
|
);
|
||||||
return yanit.parsed_output ?? null;
|
// OpenRouter üst sağlayıcı arızasında HTTP 200 gövdesinde {"error": ...}
|
||||||
|
// dönebiliyor; choices o durumda yok — TypeError yerine null (retry yolu).
|
||||||
|
if (!Array.isArray(yanit.choices) || yanit.choices.length === 0) return null;
|
||||||
|
const metin = yanit.choices[0]?.message?.content;
|
||||||
|
if (!metin) return null;
|
||||||
|
try {
|
||||||
|
const parsed = opts.sema.safeParse(JSON.parse(metin));
|
||||||
|
return parsed.success ? parsed.data : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SohbetMesaji = { role: "user" | "assistant"; content: string };
|
export type SohbetMesaji = { role: "user" | "assistant"; content: string };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sohbet yanıtını parça parça üretir. Sağlayıcıya bakmaksızın düz metin
|
* Sohbet yanıtını parça parça üretir; düz metin parçaları yield eder, çağıran
|
||||||
* parçaları yield eder; çağıran taraf hem stream'ler hem biriktirip kaydeder.
|
* taraf hem stream'ler hem biriktirip kaydeder. Akış ortasında zaman aşımı
|
||||||
|
* olursa hata fırlar (route'taki kısmi kaydet + iade yolu devreye girer).
|
||||||
*/
|
*/
|
||||||
export async function* sohbetAkisi(opts: {
|
export async function* sohbetAkisi(opts: {
|
||||||
system: string;
|
system: string;
|
||||||
mesajlar: SohbetMesaji[];
|
mesajlar: SohbetMesaji[];
|
||||||
maxTokens: number;
|
maxTokens: number;
|
||||||
}): AsyncGenerator<string> {
|
}): AsyncGenerator<string> {
|
||||||
if (saglayici() === "gemini") {
|
// Asılı kalan üst sağlayıcıya karşı sigorta
|
||||||
const akis = await geciciHatadaTekrarla(() =>
|
const sinyal = AbortSignal.timeout(120_000);
|
||||||
getGemini().models.generateContentStream({
|
const akis = await geciciHatadaTekrarla(() =>
|
||||||
|
getOpenRouter().chat.completions.create(
|
||||||
|
{
|
||||||
model: sohbetModeli(),
|
model: sohbetModeli(),
|
||||||
contents: opts.mesajlar.map((m) => ({
|
max_tokens: opts.maxTokens,
|
||||||
// Gemini "assistant" yerine "model" rolünü kullanır
|
// Sohbet 1024 token'a sığmalı; düşünme hem bütçeyi hem adayı bekletiyor
|
||||||
role: m.role === "assistant" ? "model" : "user",
|
reasoning_effort: "none",
|
||||||
parts: [{ text: m.content }],
|
stream: true,
|
||||||
})),
|
messages: [
|
||||||
config: {
|
{ role: "system", content: opts.system },
|
||||||
systemInstruction: opts.system,
|
...opts.mesajlar,
|
||||||
maxOutputTokens: opts.maxTokens,
|
],
|
||||||
// Sohbet kısa ve hızlı olmalı; uzun düşünme adayı bekletiyor
|
},
|
||||||
thinkingConfig: { thinkingLevel: ThinkingLevel.LOW },
|
{ signal: sinyal },
|
||||||
},
|
),
|
||||||
}),
|
);
|
||||||
);
|
for await (const parca of akis) {
|
||||||
for await (const parca of akis) {
|
// Yalnızca delta.content akar; varsa delta.reasoning kullanıcıya sızmaz
|
||||||
const metin = parca.text;
|
const metin = parca.choices?.[0]?.delta?.content;
|
||||||
if (metin) yield metin;
|
if (metin) yield metin;
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const akis = getAnthropic().messages.stream({
|
|
||||||
model: sohbetModeli(),
|
|
||||||
max_tokens: opts.maxTokens,
|
|
||||||
system: opts.system,
|
|
||||||
messages: opts.mesajlar,
|
|
||||||
});
|
|
||||||
for await (const olay of akis) {
|
|
||||||
if (
|
|
||||||
olay.type === "content_block_delta" &&
|
|
||||||
olay.delta.type === "text_delta"
|
|
||||||
) {
|
|
||||||
yield olay.delta.text;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
// openai SDK akış ortasındaki abort'u yutup iteratörü NORMAL bitiriyor
|
||||||
|
// (core/streaming: isAbortError -> return); kesik cevabın tam cevap
|
||||||
|
// sanılmaması ve iade yolunun çalışması için zaman aşımını elle fırlat.
|
||||||
|
if (sinyal.aborted) throw sinyal.reason;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,64 +1,52 @@
|
|||||||
import Anthropic from "@anthropic-ai/sdk";
|
import OpenAI from "openai";
|
||||||
import { GoogleGenAI } from "@google/genai";
|
|
||||||
|
|
||||||
export type Saglayici = "anthropic" | "gemini";
|
// Tüm AI akışı OpenRouter üzerinden tek kapıdan gider (kullanıcı kararı,
|
||||||
|
// 7 Ağu 2026 — önceki Anthropic/Gemini/lokal sağlayıcı seçimi kaldırıldı).
|
||||||
/** AI_SAGLAYICI env'i ile seçilir; tanımsızsa hangi anahtar varsa o kullanılır. */
|
// Model .env'den değiştirilebilir; rapor ve sohbet istenirse ayrışabilir.
|
||||||
export function saglayici(): Saglayici {
|
const VARSAYILAN_MODEL = "deepseek/deepseek-v4-flash";
|
||||||
const secim = process.env.AI_SAGLAYICI?.trim().toLowerCase();
|
|
||||||
if (secim === "gemini" || secim === "anthropic") return secim;
|
|
||||||
return process.env.GEMINI_API_KEY ? "gemini" : "anthropic";
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Seçili sağlayıcının anahtarı tanımlı mı (mock moduna düşülecek mi)? */
|
|
||||||
export function anahtarVar(): boolean {
|
|
||||||
return saglayici() === "gemini"
|
|
||||||
? Boolean(process.env.GEMINI_API_KEY)
|
|
||||||
: Boolean(process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Model seçimi (bilinçli):
|
|
||||||
// Anthropic — rapor tek seferlik ve yüksek değerli -> Opus 4.8;
|
|
||||||
// sohbet kredi başına marj hassas -> Haiku 4.5 (kullanıcı kararı, 22 Tem 2026).
|
|
||||||
// Gemini — ücretsiz katmanda yalnızca Flash ailesi açık (Pro'da kota 0),
|
|
||||||
// bu yüzden rapor ve sohbet aynı modeli kullanır; ayrım thinking seviyesinde.
|
|
||||||
const MODELLER: Record<Saglayici, { rapor: string; sohbet: string }> = {
|
|
||||||
anthropic: { rapor: "claude-opus-4-8", sohbet: "claude-haiku-4-5" },
|
|
||||||
gemini: { rapor: "gemini-3.6-flash", sohbet: "gemini-3.6-flash" },
|
|
||||||
};
|
|
||||||
|
|
||||||
export function raporModeli(): string {
|
export function raporModeli(): string {
|
||||||
return MODELLER[saglayici()].rapor;
|
return (
|
||||||
|
process.env.OPENROUTER_RAPOR_MODEL?.trim() ||
|
||||||
|
process.env.OPENROUTER_MODEL?.trim() ||
|
||||||
|
VARSAYILAN_MODEL
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sohbetModeli(): string {
|
export function sohbetModeli(): string {
|
||||||
return MODELLER[saglayici()].sohbet;
|
return (
|
||||||
|
process.env.OPENROUTER_SOHBET_MODEL?.trim() ||
|
||||||
|
process.env.OPENROUTER_MODEL?.trim() ||
|
||||||
|
VARSAYILAN_MODEL
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const globalForAi = globalThis as unknown as {
|
/** API anahtarı tanımlı mı (mock moduna düşülecek mi)? */
|
||||||
__anthropic?: Anthropic;
|
export function anahtarVar(): boolean {
|
||||||
__gemini?: GoogleGenAI;
|
return Boolean(process.env.OPENROUTER_API_KEY);
|
||||||
};
|
|
||||||
|
|
||||||
export function getAnthropic(): Anthropic {
|
|
||||||
if (!process.env.ANTHROPIC_API_KEY && !process.env.ANTHROPIC_AUTH_TOKEN) {
|
|
||||||
throw new Error("AI_KEY_MISSING");
|
|
||||||
}
|
|
||||||
if (!globalForAi.__anthropic) {
|
|
||||||
globalForAi.__anthropic = new Anthropic();
|
|
||||||
}
|
|
||||||
return globalForAi.__anthropic;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getGemini(): GoogleGenAI {
|
const globalForAi = globalThis as unknown as { __openrouter?: OpenAI };
|
||||||
const apiKey = process.env.GEMINI_API_KEY;
|
|
||||||
|
export function getOpenRouter(): OpenAI {
|
||||||
|
const apiKey = process.env.OPENROUTER_API_KEY;
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
throw new Error("AI_KEY_MISSING");
|
throw new Error("AI_KEY_MISSING");
|
||||||
}
|
}
|
||||||
if (!globalForAi.__gemini) {
|
if (!globalForAi.__openrouter) {
|
||||||
globalForAi.__gemini = new GoogleGenAI({ apiKey });
|
globalForAi.__openrouter = new OpenAI({
|
||||||
|
baseURL: "https://openrouter.ai/api/v1",
|
||||||
|
apiKey,
|
||||||
|
// Tek retry otoritesi cagri.ts'teki geciciHatadaTekrarla
|
||||||
|
maxRetries: 0,
|
||||||
|
// OpenRouter panelindeki kullanım dökümünde uygulama atfı (opsiyonel)
|
||||||
|
defaultHeaders: {
|
||||||
|
"HTTP-Referer": "https://kolaytercih.com",
|
||||||
|
"X-Title": "KolayTercih",
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return globalForAi.__gemini;
|
return globalForAi.__openrouter;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SORUMLULUK_REDDI =
|
export const SORUMLULUK_REDDI =
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import {
|
|||||||
havuzOlustur,
|
havuzOlustur,
|
||||||
havuzuKompaktJson,
|
havuzuKompaktJson,
|
||||||
turLabel,
|
turLabel,
|
||||||
|
dilimHedefiHesapla,
|
||||||
|
IDEAL_HEDEF,
|
||||||
|
type DilimHedef,
|
||||||
|
type Dilim,
|
||||||
type RaporParams,
|
type RaporParams,
|
||||||
type AdayProgram,
|
type AdayProgram,
|
||||||
} from "../rapor-havuzu";
|
} from "../rapor-havuzu";
|
||||||
@@ -26,9 +30,57 @@ const RaporSchema = z.object({
|
|||||||
uyarilar: z.array(z.string()).min(2).max(3),
|
uyarilar: z.array(z.string()).min(2).max(3),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Yapay zekâdan istenen küçük şema (kullanıcı kararı, 6 Ağu 2026): riskNotu
|
||||||
|
// ve trendOzeti gerçek sıra geçmişinden deterministik üretilir; dilim iskeleti
|
||||||
|
// ve tercih sırası da koddan gelir. Dilim başına SABİT uzunluklu ayrı listeler
|
||||||
|
// istenir ki dağılım şema düzeyinde (grammar/JSON-schema ile) zorlansın —
|
||||||
|
// reasoning kapalıyken model serbest dağılımı tutturamıyor (ölçüldü: 13 hayal
|
||||||
|
// / 0 garanti dönmüştü). Uzunluklar havuza uyarlanmış hedeften gelir (havuzda
|
||||||
|
// dilim başına yeterli program olmayabiliyor; sabit 5/13/6 o durumda aritmetik
|
||||||
|
// olarak imkânsızdı ve üretim her seferinde patlıyordu). AI yalnızca seçer +
|
||||||
|
// gerekce yazar.
|
||||||
|
const AiTercihSchema = z.object({
|
||||||
|
programId: z.string(),
|
||||||
|
gerekce: z.string(),
|
||||||
|
});
|
||||||
|
/** Sağlayıcıya giden tel şeması: dilim uzunlukları hedefe sabitlenir. */
|
||||||
|
function aiSemaWire(hedef: DilimHedef) {
|
||||||
|
return z.object({
|
||||||
|
hayal: z.array(AiTercihSchema).length(hedef.hayal),
|
||||||
|
dengeli: z.array(AiTercihSchema).length(hedef.dengeli),
|
||||||
|
garanti: z.array(AiTercihSchema).length(hedef.garanti),
|
||||||
|
genelDegerlendirme: z.string(),
|
||||||
|
uyarilar: z.array(z.string()).min(2).max(3),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Lokal parse şeması: uzunluk kısıtsız. Sağlayıcı tel şemasındaki kısıtları
|
||||||
|
// yok sayarsa çıktı reddedilmez; normalize + kurtarma katmanı düzeltir.
|
||||||
|
const AiRaporGevsek = z.object({
|
||||||
|
hayal: z.array(AiTercihSchema),
|
||||||
|
dengeli: z.array(AiTercihSchema),
|
||||||
|
garanti: z.array(AiTercihSchema),
|
||||||
|
genelDegerlendirme: z.string(),
|
||||||
|
uyarilar: z.array(z.string()),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listenin havuza uyarlanma bilgisi. uyarlandi=true ise dağılım ideal
|
||||||
|
* 5/13/6'dan sapmıştır (havuz yetersizdi) ve UI dürüst bir not gösterir.
|
||||||
|
* Eski kayıtlı raporlarda yok → opsiyonel.
|
||||||
|
*/
|
||||||
|
export type RaporKapsam = {
|
||||||
|
toplam: number;
|
||||||
|
dagilim: Record<Dilim, number>;
|
||||||
|
uyarlandi: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
// uyarilar şemada zorunlu ama eski kayıtlı raporlarda yok → tipte opsiyonel
|
// uyarilar şemada zorunlu ama eski kayıtlı raporlarda yok → tipte opsiyonel
|
||||||
export type RaporSonuc = Omit<z.infer<typeof RaporSchema>, "uyarilar"> & {
|
export type RaporSonuc = Omit<z.infer<typeof RaporSchema>, "uyarilar"> & {
|
||||||
uyarilar?: string[];
|
uyarilar?: string[];
|
||||||
|
kapsam?: RaporKapsam;
|
||||||
|
// Dev mock üretimi işareti: gerçek AI aktifken bayat mock kayıtları
|
||||||
|
// ayıklamak için (bkz. src/lib/rapor-kaydi.ts)
|
||||||
|
devMock?: boolean;
|
||||||
// Render için havuzdan zenginleştirilmiş program bilgisi
|
// Render için havuzdan zenginleştirilmiş program bilgisi
|
||||||
// efektifSira: COALESCE(sira2025, sira2024) — eski kayıtlı raporlarda yok
|
// efektifSira: COALESCE(sira2025, sira2024) — eski kayıtlı raporlarda yok
|
||||||
// siraGecmisi: [2021..2025] taban sıralamaları — deterministik trend oku
|
// siraGecmisi: [2021..2025] taban sıralamaları — deterministik trend oku
|
||||||
@@ -45,31 +97,126 @@ export type RaporSonuc = Omit<z.infer<typeof RaporSchema>, "uyarilar"> & {
|
|||||||
>;
|
>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const SISTEM = `Sen KolayTercih'in kıdemli YKS tercih danışmanısın. Görevin: sana verilen
|
function sistemMetni(hedef: DilimHedef): string {
|
||||||
GERÇEK YÖK Atlas aday havuzundan, adayın sıralamasına göre 24 tercihlik dengeli
|
const adet = (d: Dilim) =>
|
||||||
bir liste kurmak.
|
hedef[d] === 0 ? "BOŞ liste [] döndür" : `TAM ${hedef[d]} program`;
|
||||||
|
const toplam = hedef.hayal + hedef.dengeli + hedef.garanti;
|
||||||
|
return `Sen KolayTercih'in kıdemli YKS tercih danışmanısın. Görevin: sana verilen
|
||||||
|
GERÇEK YÖK Atlas aday havuzundan adaya en uygun programları seçip üç listeyi
|
||||||
|
doldurmak: hayal (${adet("hayal")}), dengeli (${adet("dengeli")}), garanti (${adet("garanti")}).
|
||||||
|
|
||||||
KURALLAR (kesin):
|
KURALLAR (kesin):
|
||||||
- SADECE havuzdaki programId'leri kullan. Havuzda olmayan program ASLA önerme.
|
- SADECE havuzdaki programId'leri kullan; havuzda olmayan program ASLA önerme.
|
||||||
- Tam 24 tercih üret; sira alanı 1'den 24'e sıralı olsun.
|
Aynı programId'yi birden fazla kez kullanma.
|
||||||
- Liste yapısı: en üstte 4-6 hayal, ortada 12-14 dengeli, sonda 5-7 garanti.
|
- Havuzdaki her programın "dilim" alanı var: hayal listesine yalnızca dilim
|
||||||
Tercih sırası MUTLAKA iyi taban sıralamasından kötüye doğru gitmeli
|
değeri "hayal" olan programları koy; dengeli ve garanti için de aynısı.
|
||||||
(küçük taban sıralaması = daha zor bölüm üstte).
|
- Seçerken adayın ilgi alanlarını ve illerini, kontenjan/yerleşen hareketini
|
||||||
- METİN DİYETİ: aday 24 satır okuyacak; her alan TEK kısa cümle, dolgu söz yok.
|
ve bölüm çeşitliliğini gözet.
|
||||||
- gerekce: bu programın bu aday için neden mantıklı olduğu (TEK cümle,
|
- METİN DİYETİ: aday ${toplam} satır okuyacak; gerekce TEK kısa cümle (en fazla ~15
|
||||||
en fazla ~15 kelime, Türkçe).
|
kelime, Türkçe): bu programın bu aday için neden mantıklı olduğu. Dolgu söz
|
||||||
- riskNotu: yerleşme ihtimali değerlendirmesi (TEK kısa cümle). ASLA "garanti",
|
yok; ASLA "garanti", "kesin", "yüzde yüz" deme.
|
||||||
"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").
|
|
||||||
- Kontenjan artışı/azalışı ve yerleşen sayısını değerlendirmene kat.
|
|
||||||
- Adayın notlarındaki şehir/bölüm tercihlerine saygı göster ama dengeyi bozma.
|
- Adayın notlarındaki şehir/bölüm tercihlerine saygı göster ama dengeyi bozma.
|
||||||
- genelDegerlendirme: listenin genel stratejisi, EN FAZLA 2 cümle. Uyarıları
|
- genelDegerlendirme: listenin genel stratejisi, EN FAZLA 2 cümle. Uyarıları
|
||||||
buraya yazma.
|
buraya yazma.
|
||||||
- uyarilar: adaya 2-3 pratik, eyleme dönük uyarı; her biri tek kısa cümle.`;
|
- uyarilar: adaya 2-3 pratik, eyleme dönük uyarı; her biri tek kısa cümle.
|
||||||
|
(Tercih sırası, risk notu ve trend özeti koddan hesaplanır; sen yazmazsın.)`;
|
||||||
|
}
|
||||||
|
|
||||||
export class RaporUretimHatasi extends Error {}
|
export class RaporUretimHatasi extends Error {}
|
||||||
|
|
||||||
|
// Liste kurmak için gereken mutlak taban. Havuz bunun altındaysa hata ekranı
|
||||||
|
// meşrudur; üstündeyse "ne olursa olsun liste kurulur" (kullanıcı kararı,
|
||||||
|
// 7 Ağu 2026) — eksik dilimler uyarlanır ve UI'da dürüstçe belirtilir.
|
||||||
|
// Not: ACIK_SATIR=3 maskesiyle tam 3 satırlık liste fiilen açık iner;
|
||||||
|
// kabul edilen kenar durumu.
|
||||||
|
const MIN_LISTE = 3;
|
||||||
|
|
||||||
|
const DILIMLER: Dilim[] = ["hayal", "dengeli", "garanti"];
|
||||||
|
|
||||||
|
function kapsamUret(hedef: DilimHedef): RaporKapsam {
|
||||||
|
return {
|
||||||
|
toplam: hedef.hayal + hedef.dengeli + hedef.garanti,
|
||||||
|
dagilim: { ...hedef },
|
||||||
|
uyarlandi: DILIMLER.some((d) => hedef[d] !== IDEAL_HEDEF[d]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type AiSecim = z.infer<typeof AiTercihSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI çıktısını havuza göre normalize eder: havuzda olmayan id'ler ve
|
||||||
|
* tekrarlar atılır, her program havuzdaki GERÇEK dilimine yeniden atanır
|
||||||
|
* (modelin yanlış listeye koyması artık ret sebebi değil, düzeltilir).
|
||||||
|
*/
|
||||||
|
function normalizeEt(
|
||||||
|
parsed: z.infer<typeof AiRaporGevsek>,
|
||||||
|
havuzMap: Map<string, AdayProgram>,
|
||||||
|
): Record<Dilim, AiSecim[]> {
|
||||||
|
const gruplar: Record<Dilim, AiSecim[]> = {
|
||||||
|
hayal: [],
|
||||||
|
dengeli: [],
|
||||||
|
garanti: [],
|
||||||
|
};
|
||||||
|
const gorulen = new Set<string>();
|
||||||
|
for (const t of [...parsed.hayal, ...parsed.dengeli, ...parsed.garanti]) {
|
||||||
|
const p = havuzMap.get(t.programId);
|
||||||
|
if (!p || gorulen.has(t.programId)) continue;
|
||||||
|
gorulen.add(t.programId);
|
||||||
|
gruplar[p.dilim].push(t);
|
||||||
|
}
|
||||||
|
return gruplar;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Deterministik satır metinleri: risk ve trend gerçek YÖK Atlas sıra
|
||||||
|
// geçmişinden hesaplanır; model bu alanlarda jenerik dolgu yazıyordu, kod
|
||||||
|
// kesin rakam verir ("45B düzeyinden 38B düzeyine indi").
|
||||||
|
|
||||||
|
/** 45000 -> "45B", 1_200_000 -> "1,2M" (şemadaki öğrenci dili). */
|
||||||
|
function siraKisa(n: number): string {
|
||||||
|
if (n >= 1_000_000) {
|
||||||
|
return `${(n / 1_000_000).toLocaleString("tr-TR", { maximumFractionDigits: 1 })}M`;
|
||||||
|
}
|
||||||
|
if (n >= 1000) return `${Math.round(n / 1000)}B`;
|
||||||
|
return String(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
function trendOzetiUret(p: AdayProgram): string {
|
||||||
|
const ham: Array<[number, number | null | undefined]> = [
|
||||||
|
[2021, p.sira2021],
|
||||||
|
[2022, p.sira2022],
|
||||||
|
[2023, p.sira2023],
|
||||||
|
[2024, p.sira2024],
|
||||||
|
[2025, p.sira2025],
|
||||||
|
];
|
||||||
|
const seri = ham.filter((cift): cift is [number, number] => cift[1] != null);
|
||||||
|
if (seri.length < 2) {
|
||||||
|
return "Karşılaştırma için yeterli geçmiş yıl verisi yok; trendi temkinli oku.";
|
||||||
|
}
|
||||||
|
const [ilkYil, ilkSira] = seri[0];
|
||||||
|
const [sonYil, sonSira] = seri[seri.length - 1];
|
||||||
|
const oran = (sonSira - ilkSira) / ilkSira;
|
||||||
|
if (oran <= -0.1) {
|
||||||
|
return `${ilkYil}-${sonYil} arasında taban ${siraKisa(ilkSira)} düzeyinden ${siraKisa(sonSira)} düzeyine indi; bölüm zorlaşıyor.`;
|
||||||
|
}
|
||||||
|
if (oran >= 0.1) {
|
||||||
|
return `${ilkYil}-${sonYil} arasında taban ${siraKisa(ilkSira)} düzeyinden ${siraKisa(sonSira)} düzeyine çıktı; yerleşmek görece kolaylaşıyor.`;
|
||||||
|
}
|
||||||
|
return `${ilkYil}-${sonYil} arasında taban sıralaması ${siraKisa(sonSira)} düzeyinde stabil seyretti.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function riskNotuUret(p: AdayProgram, adaySira: number): string {
|
||||||
|
switch (riskHesapla(p.sira2025 ?? p.sira2024, adaySira)) {
|
||||||
|
case "guvenli":
|
||||||
|
return "Taban sıralaması senin sıranın belirgin altında; yüksek ihtimalle yerleşirsin.";
|
||||||
|
case "az-riskli":
|
||||||
|
return "Taban sıralaması sırana yakın; orta ihtimalle yerleşilebilir.";
|
||||||
|
case "riskli":
|
||||||
|
return "Taban sıralaması senin sıranın üstünde kapandı; yerleşme ihtimali düşük.";
|
||||||
|
default:
|
||||||
|
return "Güncel taban sıralaması verisi yok; ihtimal öngörülemez, temkinli yaklaş.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Havuz programını render için gereken bilgiye indirger. */
|
/** Havuz programını render için gereken bilgiye indirger. */
|
||||||
function programBilgisi(p: AdayProgram): RaporSonuc["programlar"][string] {
|
function programBilgisi(p: AdayProgram): RaporSonuc["programlar"][string] {
|
||||||
const gecmis = [p.sira2021, p.sira2022, p.sira2023, p.sira2024, p.sira2025];
|
const gecmis = [p.sira2021, p.sira2022, p.sira2023, p.sira2024, p.sira2025];
|
||||||
@@ -96,6 +243,9 @@ function mockRapor(
|
|||||||
params: RaporParams,
|
params: RaporParams,
|
||||||
revizyonNotu?: string,
|
revizyonNotu?: string,
|
||||||
): RaporSonuc {
|
): RaporSonuc {
|
||||||
|
// Prod ile aynı hedef hesabı: mock, uyarlanmış dağılımı da birebir taklit eder
|
||||||
|
const hedef = dilimHedefiHesapla(havuz);
|
||||||
|
const toplamHedef = hedef.hayal + hedef.dengeli + hedef.garanti;
|
||||||
const efektif = (p: AdayProgram) => p.sira2025 ?? p.sira2024 ?? 0;
|
const efektif = (p: AdayProgram) => p.sira2025 ?? p.sira2024 ?? 0;
|
||||||
const dilimden = (dilim: AdayProgram["dilim"], adet: number) =>
|
const dilimden = (dilim: AdayProgram["dilim"], adet: number) =>
|
||||||
havuz
|
havuz
|
||||||
@@ -103,34 +253,24 @@ function mockRapor(
|
|||||||
.sort((a, b) => efektif(a) - efektif(b))
|
.sort((a, b) => efektif(a) - efektif(b))
|
||||||
.slice(0, adet);
|
.slice(0, adet);
|
||||||
|
|
||||||
// 5 hayal + 13 dengeli + 6 garanti; eksik dilim varsa havuzdan tamamla
|
|
||||||
const secilen = [
|
const secilen = [
|
||||||
...dilimden("hayal", 5),
|
...dilimden("hayal", hedef.hayal),
|
||||||
...dilimden("dengeli", 13),
|
...dilimden("dengeli", hedef.dengeli),
|
||||||
...dilimden("garanti", 6),
|
...dilimden("garanti", hedef.garanti),
|
||||||
];
|
];
|
||||||
for (const p of havuz) {
|
|
||||||
if (secilen.length >= 24) break;
|
|
||||||
if (!secilen.includes(p)) secilen.push(p);
|
|
||||||
}
|
|
||||||
|
|
||||||
const tercihler = secilen.slice(0, 24).map((p, i) => ({
|
const tercihler = secilen.map((p, i) => ({
|
||||||
programId: p.id,
|
programId: p.id,
|
||||||
sira: i + 1,
|
sira: i + 1,
|
||||||
dilim: p.dilim,
|
dilim: p.dilim,
|
||||||
gerekce: `(Dev mock) ${p.isim}, seçtiğin ilgi alanlarıyla ve ${params.sira.toLocaleString("tr-TR")}. sıralamanla uyumlu bir aday.`,
|
gerekce: `(Dev mock) ${p.isim}, seçtiğin ilgi alanlarıyla ve ${params.sira.toLocaleString("tr-TR")}. sıralamanla uyumlu bir aday.`,
|
||||||
riskNotu:
|
// Risk ve trend mock'ta da gerçek veriden: canlı davranışla birebir aynı
|
||||||
p.dilim === "hayal"
|
riskNotu: riskNotuUret(p, params.sira),
|
||||||
? "(Dev mock) Taban sıralaması senin üstünde kapandı; düşük ihtimal ama listenin başında yeri var."
|
trendOzeti: trendOzetiUret(p),
|
||||||
: p.dilim === "dengeli"
|
|
||||||
? "(Dev mock) Taban sıralaması senin civarında; orta-yüksek ihtimalle yerleşilebilir."
|
|
||||||
: "(Dev mock) Taban sıralaması senin belirgin altında; yüksek ihtimalle yerleşirsin.",
|
|
||||||
trendOzeti:
|
|
||||||
"(Dev mock) Son 4 yılda taban sıralaması görece stabil seyretti.",
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const programlar: RaporSonuc["programlar"] = {};
|
const programlar: RaporSonuc["programlar"] = {};
|
||||||
for (const p of secilen.slice(0, 24)) {
|
for (const p of secilen) {
|
||||||
programlar[p.id] = programBilgisi(p);
|
programlar[p.id] = programBilgisi(p);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,14 +278,16 @@ function mockRapor(
|
|||||||
tercihler,
|
tercihler,
|
||||||
genelDegerlendirme: `(Dev mock — Yapay Zeka anahtarı tanımlı değil, bu rapor gerçek Yapay Zeka çıktısı değildir.) ${params.sira.toLocaleString(
|
genelDegerlendirme: `(Dev mock — Yapay Zeka anahtarı tanımlı değil, bu rapor gerçek Yapay Zeka çıktısı değildir.) ${params.sira.toLocaleString(
|
||||||
"tr-TR",
|
"tr-TR",
|
||||||
)}. sıra için hayal/dengeli/garanti dağılımlı 24 tercihlik örnek liste kuruldu.${
|
)}. sıra için hayal/dengeli/garanti dağılımlı ${toplamHedef} tercihlik örnek liste kuruldu.${
|
||||||
revizyonNotu ? ` Revizyon isteğin not alındı: "${revizyonNotu}".` : ""
|
revizyonNotu ? ` Revizyon isteğin not alındı: "${revizyonNotu}".` : ""
|
||||||
}`,
|
}`,
|
||||||
uyarilar: [
|
uyarilar: [
|
||||||
"(Dev mock) Tercih dönemi kapanmadan listeyi ÖSYM sistemine kendin gir.",
|
"(Dev mock) Tercih dönemi kapanmadan listeyi ÖSYM sistemine kendin gir.",
|
||||||
"(Dev mock) Garanti dilimindeki son 3 tercihi asla silme.",
|
"(Dev mock) Garanti dilimindeki son tercihleri asla silme.",
|
||||||
],
|
],
|
||||||
programlar,
|
programlar,
|
||||||
|
kapsam: kapsamUret(hedef),
|
||||||
|
devMock: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,12 +297,18 @@ export async function raporUret(
|
|||||||
oncekiListeOzeti?: string,
|
oncekiListeOzeti?: string,
|
||||||
): Promise<RaporSonuc> {
|
): Promise<RaporSonuc> {
|
||||||
const { havuz, gevsetildi } = havuzOlustur(params);
|
const { havuz, gevsetildi } = havuzOlustur(params);
|
||||||
if (havuz.length < 24) {
|
if (havuz.length < MIN_LISTE) {
|
||||||
throw new RaporUretimHatasi(
|
throw new RaporUretimHatasi(
|
||||||
"Bu sıralama ve filtrelerle yeterli program bulunamadı. Daha geniş kategori veya il seçmeyi dene.",
|
"Bu sıralama ve filtrelerle yeterli program bulunamadı. Daha geniş kategori veya il seçmeyi dene.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dilim hedefleri havuzun gerçek dağılımına uyarlanır: sabit 5/13/6 bazı
|
||||||
|
// sıralamalarda (ör. çok iyi derece → dengeli dilim boş) imkânsızdı ve
|
||||||
|
// üretim her seferinde doğrulamada patlıyordu.
|
||||||
|
const hedef = dilimHedefiHesapla(havuz);
|
||||||
|
const toplamHedef = hedef.hayal + hedef.dengeli + hedef.garanti;
|
||||||
|
|
||||||
const secimVar =
|
const secimVar =
|
||||||
(params.kategoriler?.length ?? 0) > 0 ||
|
(params.kategoriler?.length ?? 0) > 0 ||
|
||||||
(params.iller?.length ?? 0) > 0 ||
|
(params.iller?.length ?? 0) > 0 ||
|
||||||
@@ -170,7 +318,7 @@ export async function raporUret(
|
|||||||
const gevsetmeNotu: string[] = [];
|
const gevsetmeNotu: string[] = [];
|
||||||
if (gevsetildi.includes("il")) {
|
if (gevsetildi.includes("il")) {
|
||||||
gevsetmeNotu.push(
|
gevsetmeNotu.push(
|
||||||
"Adayın seçtiği illerde yeterli program yoktu; havuza başka illerden de program eklendi. Seçili illere öncelik ver ama listeyi 24'e tamamlamak için diğerlerini de kullan.",
|
`Adayın seçtiği illerde yeterli program yoktu; havuza başka illerden de program eklendi. Seçili illere öncelik ver ama listeyi ${toplamHedef}'e tamamlamak için diğerlerini de kullan.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (gevsetildi.includes("kategori")) {
|
if (gevsetildi.includes("kategori")) {
|
||||||
@@ -205,54 +353,133 @@ export async function raporUret(
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join("\n\n");
|
.join("\n\n");
|
||||||
|
|
||||||
const gecerliIdler = new Set(havuz.map((p) => p.id));
|
const havuzMap = new Map(havuz.map((p) => [p.id, p]));
|
||||||
|
const efektif = (id: string) => {
|
||||||
|
const p = havuzMap.get(id)!;
|
||||||
|
return p.sira2025 ?? p.sira2024 ?? Number.MAX_SAFE_INTEGER;
|
||||||
|
};
|
||||||
|
|
||||||
let sonuc: z.infer<typeof RaporSchema> | null = null;
|
let secim: Record<Dilim, AiSecim[]> | null = null;
|
||||||
|
let sonSecim: Record<Dilim, AiSecim[]> | null = null;
|
||||||
|
let genel = "";
|
||||||
|
let uyarilar: string[] = [];
|
||||||
let sonHata = "";
|
let sonHata = "";
|
||||||
for (let deneme = 0; deneme < 2; deneme++) {
|
for (let deneme = 0; deneme < 2; deneme++) {
|
||||||
const parsed = await yapilandirilmisUret({
|
const parsed = await yapilandirilmisUret({
|
||||||
system: SISTEM,
|
system: sistemMetni(hedef),
|
||||||
kullanici:
|
kullanici:
|
||||||
deneme === 0
|
deneme === 0
|
||||||
? kullaniciMesaji
|
? kullaniciMesaji
|
||||||
: `${kullaniciMesaji}\n\nÖNCEKİ DENEMENDE HATA VARDI: ${sonHata} Kurallara tam uy.`,
|
: `${kullaniciMesaji}\n\nÖNCEKİ DENEMENDE HATA VARDI: ${sonHata} Kurallara tam uy.`,
|
||||||
sema: RaporSchema,
|
sema: AiRaporGevsek,
|
||||||
maxTokens: 16000,
|
wireSema: aiSemaWire(hedef),
|
||||||
|
// Küçük şema (24 satır × gerekce + genel + uyarılar) ~1-1.5k token;
|
||||||
|
// reasoning kapalıyken 4000 rahat bir tavan, kaçak uzun üretimi de keser.
|
||||||
|
maxTokens: 4000,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!parsed) {
|
if (!parsed) {
|
||||||
sonHata = "Çıktı şemaya uymadı.";
|
sonHata = `Çıktı şemaya uymadı (hayal ${hedef.hayal}, dengeli ${hedef.dengeli}, garanti ${hedef.garanti} olmalı).`;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Halüsinasyon koruması: her programId havuzda olmalı + tam 24 öğe
|
// Halüsinasyon koruması normalize içinde: havuz-dışı id ve tekrarlar
|
||||||
const bilinmeyen = parsed.tercihler.filter(
|
// atılır, dilimler havuzdan yeniden atanır. Ret yalnızca sayı tutmayınca.
|
||||||
(t) => !gecerliIdler.has(t.programId),
|
const norm = normalizeEt(parsed, havuzMap);
|
||||||
);
|
sonSecim = norm;
|
||||||
if (parsed.tercihler.length !== 24 || bilinmeyen.length > 0) {
|
genel = parsed.genelDegerlendirme;
|
||||||
sonHata =
|
uyarilar = parsed.uyarilar.slice(0, 3);
|
||||||
parsed.tercihler.length !== 24
|
if (DILIMLER.every((d) => norm[d].length === hedef[d])) {
|
||||||
? `Liste ${parsed.tercihler.length} öğe içeriyor, tam 24 olmalı.`
|
secim = norm;
|
||||||
: `Havuzda olmayan programId kullanıldı: ${bilinmeyen
|
break;
|
||||||
.map((t) => t.programId)
|
|
||||||
.slice(0, 3)
|
|
||||||
.join(", ")}.`;
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
sonuc = parsed;
|
sonHata = `Dilim sayıları tutmadı (beklenen: hayal ${hedef.hayal}, dengeli ${hedef.dengeli}, garanti ${hedef.garanti}; havuz-dışı ve tekrar eden id'ler sayılmaz). Havuzdaki "dilim" alanına ve adetlere birebir uy.`;
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!sonuc) {
|
if (!secim) {
|
||||||
throw new RaporUretimHatasi(
|
// AI hiç kullanılabilir program seçemedi (parse iki kez null ya da tüm
|
||||||
"Rapor üretilemedi (doğrulama iki denemede de başarısız). Tekrar dener misin? Revizyon hakkın kullanılmadı.",
|
// id'ler havuz-dışı): gerçek üretim hatası, hata ekranı meşru.
|
||||||
|
if (!sonSecim) {
|
||||||
|
throw new RaporUretimHatasi(
|
||||||
|
"Rapor üretilemedi (doğrulama iki denemede de başarısız). Tekrar dener misin? Revizyon hakkın kullanılmadı.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const son = sonSecim;
|
||||||
|
const eldeki = son.hayal.length + son.dengeli.length + son.garanti.length;
|
||||||
|
if (eldeki === 0) {
|
||||||
|
throw new RaporUretimHatasi(
|
||||||
|
"Rapor üretilemedi (doğrulama iki denemede de başarısız). Tekrar dener misin? Revizyon hakkın kullanılmadı.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Kurtarma ("ne olursa olsun liste kurulur"): fazla dilim en iyi taban
|
||||||
|
// sıralamalılar kalacak şekilde kırpılır, eksik dilim havuzun seçilmemiş
|
||||||
|
// programlarından deterministik tamamlanır. hedef[d] ≤ havuzdaki sayı
|
||||||
|
// olduğu için tamamlama her zaman yeterli aday bulur.
|
||||||
|
console.warn(
|
||||||
|
`[rapor] kurtarma: AI ${eldeki} kullanılabilir program döndü, hedef ${toplamHedef} (hayal ${son.hayal.length}/${hedef.hayal}, dengeli ${son.dengeli.length}/${hedef.dengeli}, garanti ${son.garanti.length}/${hedef.garanti})`,
|
||||||
);
|
);
|
||||||
|
const kullanilan = new Set(
|
||||||
|
DILIMLER.flatMap((d) => son[d].map((t) => t.programId)),
|
||||||
|
);
|
||||||
|
const kurtarilan: Record<Dilim, AiSecim[]> = {
|
||||||
|
hayal: [],
|
||||||
|
dengeli: [],
|
||||||
|
garanti: [],
|
||||||
|
};
|
||||||
|
for (const d of DILIMLER) {
|
||||||
|
const liste = [...son[d]]
|
||||||
|
.sort((a, b) => efektif(a.programId) - efektif(b.programId))
|
||||||
|
.slice(0, hedef[d]);
|
||||||
|
if (liste.length < hedef[d]) {
|
||||||
|
const adaylar = havuz
|
||||||
|
.filter((p) => p.dilim === d && !kullanilan.has(p.id))
|
||||||
|
.sort((a, b) => efektif(a.id) - efektif(b.id));
|
||||||
|
for (const p of adaylar) {
|
||||||
|
if (liste.length >= hedef[d]) break;
|
||||||
|
kullanilan.add(p.id);
|
||||||
|
liste.push({
|
||||||
|
programId: p.id,
|
||||||
|
gerekce:
|
||||||
|
"Sıralamana ve seçimlerine uygun olduğu için listene eklendi.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
kurtarilan[d] = liste;
|
||||||
|
}
|
||||||
|
secim = kurtarilan;
|
||||||
}
|
}
|
||||||
|
|
||||||
const programlar: RaporSonuc["programlar"] = {};
|
const programlar: RaporSonuc["programlar"] = {};
|
||||||
for (const p of havuz) {
|
for (const p of havuz) {
|
||||||
programlar[p.id] = programBilgisi(p);
|
programlar[p.id] = programBilgisi(p);
|
||||||
}
|
}
|
||||||
return { ...sonuc, programlar };
|
// Sıra ve satır metinleri deterministik: dilim içi iyi taban sıralamasından
|
||||||
|
// kötüye dizilir (hayal -> dengeli -> garanti zaten bu yönde akar), sira
|
||||||
|
// koddan atanır; riskNotu + trendOzeti gerçek veriden üretilir.
|
||||||
|
const nihai = secim;
|
||||||
|
const tercihler = DILIMLER.flatMap((dilim) =>
|
||||||
|
[...nihai[dilim]]
|
||||||
|
.sort((a, b) => efektif(a.programId) - efektif(b.programId))
|
||||||
|
.map((t) => ({ dilim, ...t })),
|
||||||
|
).map((t, i) => {
|
||||||
|
const p = havuzMap.get(t.programId)!;
|
||||||
|
return {
|
||||||
|
programId: t.programId,
|
||||||
|
sira: i + 1,
|
||||||
|
dilim: t.dilim,
|
||||||
|
gerekce: t.gerekce,
|
||||||
|
riskNotu: riskNotuUret(p, params.sira),
|
||||||
|
trendOzeti: trendOzetiUret(p),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
tercihler,
|
||||||
|
genelDegerlendirme:
|
||||||
|
genel.trim() ||
|
||||||
|
`Listen ${toplamHedef} tercihten oluşuyor; hayal, dengeli ve garanti dilimleri sıralamana göre dengelendi.`,
|
||||||
|
uyarilar: uyarilar.length > 0 ? uyarilar : undefined,
|
||||||
|
programlar,
|
||||||
|
kapsam: kapsamUret(hedef),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listeOzetiCikar(rapor: RaporSonuc, adaySira?: number): string {
|
export function listeOzetiCikar(rapor: RaporSonuc, adaySira?: number): string {
|
||||||
|
|||||||
@@ -19,11 +19,18 @@ export type OlayAdi =
|
|||||||
| "liste_olusturuldu"
|
| "liste_olusturuldu"
|
||||||
| "liste_revize"
|
| "liste_revize"
|
||||||
| "program_eklendi"
|
| "program_eklendi"
|
||||||
|
| "netler_goruntulendi"
|
||||||
| "danisman_sorusu"
|
| "danisman_sorusu"
|
||||||
| "katalog_arama"
|
| "katalog_arama"
|
||||||
| "odeme_baslatildi";
|
| "odeme_baslatildi"
|
||||||
|
| "hazir_panel_goruntulendi"
|
||||||
|
| "giris_cta_tiklandi"
|
||||||
|
| "kilit_goruntulendi"
|
||||||
|
| "kredi_bitti"
|
||||||
|
| "tadimlik_goruntulendi"
|
||||||
|
| "kaydet_banner_goruntulendi";
|
||||||
|
|
||||||
type Ozellikler = Record<string, string | number>;
|
export type Ozellikler = Record<string, string | number>;
|
||||||
|
|
||||||
interface RybbitApi {
|
interface RybbitApi {
|
||||||
event: (ad: string, ozellikler?: Ozellikler) => void;
|
event: (ad: string, ozellikler?: Ozellikler) => void;
|
||||||
|
|||||||
@@ -18,6 +18,13 @@ export const user = sqliteTable("user", {
|
|||||||
image: text("image"),
|
image: text("image"),
|
||||||
creditBalance: integer("credit_balance").notNull().default(0),
|
creditBalance: integer("credit_balance").notNull().default(0),
|
||||||
hasPaket: integer("has_paket", { mode: "boolean" }).notNull().default(false),
|
hasPaket: integer("has_paket", { mode: "boolean" }).notNull().default(false),
|
||||||
|
// Kredi-bitti hatırlatma durum makinesi (kuruldu → gönderildi):
|
||||||
|
// ilk INSUFFICIENT anı; kredi yüklenince sıfırlanır (hatırlatma iptal).
|
||||||
|
krediBittiAt: integer("kredi_bitti_at", { mode: "timestamp" }),
|
||||||
|
// Hatırlatma e-postası bir kez gönderilir, bu alan hiç sıfırlanmaz.
|
||||||
|
krediHatirlatmaGonderildiAt: integer("kredi_hatirlatma_gonderildi_at", {
|
||||||
|
mode: "timestamp",
|
||||||
|
}),
|
||||||
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
|
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
|
||||||
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
|
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
|
||||||
});
|
});
|
||||||
@@ -145,3 +152,48 @@ export const reports = sqliteTable("reports", {
|
|||||||
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
|
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
|
||||||
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
|
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Anonim /sonuc tadımlığı: batch üretilmiş tek tercih satırları.
|
||||||
|
// Anahtar: sıra kovası × puan türü × kategori ("genel" = kategorisiz fallback).
|
||||||
|
// Anonim istek LLM'e asla gitmez; bu tablo salt-okunur servis edilir.
|
||||||
|
// NOT: Yeni tablo/kolon eklerken `pnpm db:generate` ile migration üretilmeli;
|
||||||
|
// prod şeması açılışta yalnızca drizzle/ altındaki migration'larla güncellenir.
|
||||||
|
export const tadimlikHavuzu = sqliteTable(
|
||||||
|
"tadimlik_havuzu",
|
||||||
|
{
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
kovaSlug: text("kova_slug").notNull(),
|
||||||
|
tur: text("tur", { enum: ["say", "ea", "soz", "dil", "tyt"] }).notNull(),
|
||||||
|
kategoriSlug: text("kategori_slug").notNull(),
|
||||||
|
programId: text("program_id").notNull(),
|
||||||
|
dilim: text("dilim", { enum: ["hayal", "dengeli", "garanti"] }).notNull(),
|
||||||
|
gerekce: text("gerekce").notNull(),
|
||||||
|
riskNotu: text("risk_notu").notNull(),
|
||||||
|
trendOzeti: text("trend_ozeti").notNull(),
|
||||||
|
// Üretim anındaki program snapshot'ı (isim, universite, il, unitur,
|
||||||
|
// efektifSira, siraGecmisi) — render yokatlas sorgusu gerektirmesin diye.
|
||||||
|
program: text("program", { mode: "json" }).notNull(),
|
||||||
|
// Üretimde kullanılan temsili aday sırası (kovanın orta noktası).
|
||||||
|
ornekSira: integer("ornek_sira").notNull(),
|
||||||
|
uretimAt: integer("uretim_at", { mode: "timestamp" }).notNull(),
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
uniqueIndex("tadimlik_anahtar").on(t.kovaSlug, t.tur, t.kategoriSlug),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Manuel liste + sihirbaz profili — girişte localStorage'dan merge edilir
|
||||||
|
// (çakışmada client kazanır, 24 sınırı korunur). reports ile aynı upsert deseni.
|
||||||
|
export const savedLists = sqliteTable("saved_lists", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
userId: text("user_id")
|
||||||
|
.notNull()
|
||||||
|
.unique()
|
||||||
|
.references(() => user.id),
|
||||||
|
// ManuelTercih[] (≤24, sıra korunur)
|
||||||
|
items: text("items", { mode: "json" }).notNull(),
|
||||||
|
// TercihProfili | null
|
||||||
|
profil: text("profil", { mode: "json" }),
|
||||||
|
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
|
||||||
|
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
|
||||||
|
});
|
||||||
|
|||||||
@@ -4,37 +4,39 @@ import { magicLink } from "better-auth/plugins";
|
|||||||
import { appDb, schema } from "./appdb";
|
import { appDb, schema } from "./appdb";
|
||||||
import { grantCredits, DENEME_KREDISI } from "./credits";
|
import { grantCredits, DENEME_KREDISI } from "./credits";
|
||||||
import { magicLinkEpostasi } from "./eposta";
|
import { magicLinkEpostasi } from "./eposta";
|
||||||
|
import { epostaGonder } from "./eposta-gonder";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify URL'ini maile konacak onay sayfası URL'ine çevirir. Doğrulama linki
|
||||||
|
* GET'lendiği anda token'ı tüketen tek kullanımlık bir uçtur; mail güvenlik
|
||||||
|
* tarayıcıları (SafeLinks vb.) ve tarayıcı ön-yüklemeleri linki kullanıcıdan
|
||||||
|
* önce "ziyaret edip" token'ı yakıyordu. Maildeki link bu yüzden token'ı
|
||||||
|
* tüketmeyen /giris/dogrula sayfasına gider; verify ancak oradaki butonla
|
||||||
|
* tetiklenir.
|
||||||
|
*/
|
||||||
|
function onaySayfasiUrl(verifyUrl: string): string {
|
||||||
|
const kaynak = new URL(verifyUrl);
|
||||||
|
const hedef = new URL("/giris/dogrula", kaynak.origin);
|
||||||
|
hedef.search = kaynak.search;
|
||||||
|
return hedef.toString();
|
||||||
|
}
|
||||||
|
|
||||||
async function sendMagicLinkEmail(email: string, url: string) {
|
async function sendMagicLinkEmail(email: string, url: string) {
|
||||||
if (process.env.NODE_ENV !== "production") {
|
if (process.env.NODE_ENV !== "production") {
|
||||||
// Dev paneli "anında giriş" için son üretilen linki yakalar (bkz.
|
// Dev paneli "anında giriş" için son üretilen linki yakalar (bkz.
|
||||||
// src/lib/dev/actions.ts) — prod'da asla çalışmaz.
|
// src/lib/dev/actions.ts) — panel onay adımını atlayıp doğrudan verify
|
||||||
|
// URL'ine gittiği için ham link saklanır. Prod'da asla çalışmaz.
|
||||||
(globalThis as { __sonMagicLink?: string }).__sonMagicLink = url;
|
(globalThis as { __sonMagicLink?: string }).__sonMagicLink = url;
|
||||||
}
|
}
|
||||||
|
const mailLinki = onaySayfasiUrl(url);
|
||||||
if (!process.env.RESEND_API_KEY) {
|
if (!process.env.RESEND_API_KEY) {
|
||||||
// Dev fallback: Resend anahtarı yoksa linki terminale yaz
|
// Dev fallback: Resend anahtarı yoksa linki terminale yaz
|
||||||
console.log(`\n[giris] Magic link for ${email}:\n${url}\n`);
|
console.log(`\n[giris] Magic link for ${email}:\n${mailLinki}\n`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { Resend } = await import("resend");
|
// Gönderim hatası better-auth'a fırlatılır — aksi halde kullanıcı
|
||||||
const resend = new Resend(process.env.RESEND_API_KEY);
|
// "gönderildi" ekranını görür ama mail hiç çıkmaz.
|
||||||
const { subject, html, text } = magicLinkEpostasi(url);
|
await epostaGonder(email, magicLinkEpostasi(mailLinki));
|
||||||
const { error } = await resend.emails.send({
|
|
||||||
from: process.env.EMAIL_FROM ?? "KolayTercih <onboarding@resend.dev>",
|
|
||||||
to: email,
|
|
||||||
subject,
|
|
||||||
html,
|
|
||||||
// Düz metin alternatifi spam puanını düşürür ve HTML'i engelleyen
|
|
||||||
// istemcilerde bağlantının okunabilir kalmasını sağlar.
|
|
||||||
text,
|
|
||||||
});
|
|
||||||
// Resend SDK hata fırlatmaz, { data, error } döndürür. Kontrol etmezsek
|
|
||||||
// doğrulanmamış domain/kota hatasında kullanıcı "gönderildi" ekranını
|
|
||||||
// görür ama mail hiç çıkmaz — fırlatarak better-auth'a hata döndürüyoruz.
|
|
||||||
if (error) {
|
|
||||||
console.error(`[giris] Magic link gönderilemedi (${email}):`, error);
|
|
||||||
throw new Error(`Resend: ${error.name} — ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const auth = betterAuth({
|
export const auth = betterAuth({
|
||||||
@@ -69,6 +71,10 @@ export const auth = betterAuth({
|
|||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
magicLink({
|
magicLink({
|
||||||
|
// Varsayılan 5 dk, mail gecikmesi + onay adımı için 15 dk'ya çıkarıldı.
|
||||||
|
// Süreyi değiştirirken e-posta metnini (src/lib/eposta.ts) ve giriş
|
||||||
|
// ekranındaki "gönderildi" mesajını (giris-form.tsx) da güncelle.
|
||||||
|
expiresIn: 900,
|
||||||
sendMagicLink: async ({ email, url }) => {
|
sendMagicLink: async ({ email, url }) => {
|
||||||
await sendMagicLinkEmail(email, url);
|
await sendMagicLinkEmail(email, url);
|
||||||
},
|
},
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user