25 Commits

Author SHA1 Message Date
bilalgursen
40a1c8ef8e Dev paneli Apple hareket diliyle yenilendi; animasyon ve UX düzeltmeleri
All checks were successful
Deploy / deploy (push) Successful in 9m29s
- Dev paneli: DEV çipinden layoutId morph ile büyüyüp küçülür (kritik sönümlü
  spring), yarı saydam malzeme (backdrop-blur + saturate), basışta anında
  scale geri bildirimi, Escape ile kapanış, reduced-motion/transparency desteği
- TypingAnimation ve TextLoop: prefers-reduced-motion'da sonsuz döngü durur,
  ilk metin sabit gösterilir
- kt-unblur: reduced-motion'da blur çözülmesi yerine kısa yumuşak belirme
- Sihirbaz: reduced-motion'da adım geçişinde height/x anında oturur;
  adım noktalarında transition-all yerine kapsamlı geçiş özellikleri
- Sihirbaz il adımındaki arama kutusu kaldırıldı; tüm iller program sayısına
  göre sıralı çip olarak listelenir
- /giris: spinner yerine kartın iskeletini gösteren route-level Skeleton

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 15:26:40 +03:00
bilalgursen
7288c926d0 UX/CRO denetimi uygulandı: değer-önce funnel, güven sayfaları, dev paneli
Kritik (K1-K6) sonrası kalan tüm maddeler:
- Değer önce: girişsiz kullanıcı sihirbaz bitince /giris yerine /sonuc'taki
  ücretsiz tabloya gider (?hazir=1); AI CTA'sı "5 deneme kredinle listeni
  kur — giriş yap" paneli + sabit alt karttan verilir, girişte otomatik üretim
- Ö1-Ö10: paywall kopyası, aranabilir il seçimi, "Seçtiklerim" adlandırması,
  gizlilik/koşullar/iletişim sayfaları, danışman özeti + uyarılar, revizyon
  kutusu, dilim özeti + tek şehir uyarısı, eksik veri rozetleri, magic link
  yeniden gönder, mobil sohbet kısayolu
- G1-G5: binlik ayraçlı input + inline hata, adım göstergesi, route-level
  loading/error/not-found, dürüst konumlandırma kartı, dürüst buton etiketleri
- Seçtiklerim butonu liste boşken gizli; ilk eklemede belirir
- Dev süperadmin paneli (yalnızca development): hızlı giriş, kredi/paket/rapor
  senaryoları, sahte ödeme dönüşü

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 15:10:57 +03:00
bilalgursen
d93cf742ff Update UI components and database schema
All checks were successful
Deploy / deploy (push) Successful in 2m38s
- Updated the `page.tsx` component to enhance user experience with new UI elements and improved layout.
- Modified the `funnel-banner.tsx` to include dynamic text and improved styling for better engagement.
- Adjusted the `liste-paneli.tsx` to provide clearer information on user credits and package details.
- Enhanced the `rapor-listesi.tsx` to display additional data points for better insights.
- Updated the database schema in `app.db` to reflect recent changes in the application logic.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 05:17:17 +03:00
bilalgursen
209d203fe8 Update Next.js configuration and package dependencies
All checks were successful
Deploy / deploy (push) Successful in 9m29s
- Modified redirects in next.config.ts to change the destination for "/sohbet" to "/listem".
- Added new dependencies in package.json: marked, motion, react-markdown, remark-breaks, remark-gfm, shiki, and use-stick-to-bottom.
- Updated pnpm-lock.yaml to reflect the new package versions and dependencies.
- Removed obsolete stack files for Flutter, Nuxt, Nuxt.js, React Native, and Svelte from the UI/UX Pro Max skill data.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 04:26:52 +03:00
bilalgursen
1d0328d8eb AGENTS.md: push öncesi commit'lenmiş hal üzerinde lokal build kuralı
All checks were successful
Deploy / deploy (push) Successful in 1m57s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 02:35:41 +03:00
bilalgursen
d5235310d5 Risk hesaplama katmanı: rapor listesi satırları adayın sırasına göre renklenir
sihirbaz-modal'ın kullandığı RaporListesi adaySira prop'u bu dosyalara
bağlıydı; önceki commit'te eksik kalınca CI build'i kırıldı.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 02:35:20 +03:00
bilalgursen
2c02e3d7e9 Sihirbaz akışı ana sayfaya taşındı: sıralama girilince modal orada açılır
Some checks failed
Deploy / deploy (push) Failing after 1m30s
- Ortak SihirbazAdimlar bileşeni (3 adımlık seçim UI'ı) çıkarıldı;
  hem hero modalı hem /sonuc modalı kullanıyor
- /api/facetler: sıraya göre kategori/il/üniversite tipi facet'leri
- HeroForm: "Listemi oluştur" artık modalı açar; seçimler tamamlanınca
  localStorage'a (SIHIRBAZ_STORAGE_KEY) yazılıp /sonuc'a yönlendirilir
- /sonuc: modal yalnızca giriş dönüşünde (?sihirbaz=1) otomatik açılır;
  kayıtlı seçimler prefill olarak kullanılır

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 02:30:44 +03:00
bilalgursen
fc146b0e18 Docker: seed veritabanları + entrypoint (yokatlas.db imaja gömülür, açılışta volume'e kopyalanır)
All checks were successful
Deploy / deploy (push) Successful in 1m52s
- .dockerignore: data/ içindeki sqlite dosyalarına build context izni
- runner imajına data/ -> /app/seed kopyalanır
- entrypoint: yokatlas.db her açılışta tazelenir, app.db sadece ilk kurulumda tohumlanır

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 01:51:36 +03:00
bilalgursen
92adfad464 CI: .env.production'ı tekil secret'lardan oluştur
All checks were successful
Deploy / deploy (push) Successful in 1m44s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 01:46:57 +03:00
bilalgursen
669ff9b43a CI tetikle: ENV_PRODUCTION secret sonrası yeniden deploy
All checks were successful
Deploy / deploy (push) Successful in 11s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 01:44:23 +03:00
bilalgursen
486f99c897 compose: traefik ağına bağlan, kolaytercih.com router label'ları ekle
All checks were successful
Deploy / deploy (push) Successful in 12s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 01:30:21 +03:00
bilalgursen
6d8699005c Dockerfile: build sırasında data/ dizinini oluştur (libsql SQLITE_CANTOPEN düzeltmesi)
All checks were successful
Deploy / deploy (push) Successful in 3m33s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 01:18:49 +03:00
bilalgursen
43457df3a9 Dockerfile: better-sqlite3 derlemesi için python3/make/g++ ekle
Some checks failed
Deploy / deploy (push) Failing after 6m12s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 01:08:03 +03:00
bilalgursen
312c06abbc Dockerfile: pnpm sürümünü 10.8.1'e sabitle (pnpm 11 Node 22 istiyor)
Some checks failed
Deploy / deploy (push) Failing after 50s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 01:06:35 +03:00
bilalgursen
0aaf705be2 CI: docker CLI içeren job imajı kullan (catthehacker/ubuntu:act-latest)
Some checks failed
Deploy / deploy (push) Failing after 25s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 01:05:06 +03:00
bilalgursen
fb44349bf8 Gitea CI'yı sıfırdan yaz: tek job ile compose build & deploy
Some checks failed
Deploy / deploy (push) Failing after 10s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 01:04:13 +03:00
d5e23c0ce9 .gitea/workflows/ci.yml Sil
All checks were successful
Test / test (push) Successful in 2s
2026-07-25 18:49:21 +03:00
Muhammed Emin Akalan
2ce0d417dc Docker & Gitea CI ekle
Some checks failed
Test / test (push) Successful in 2s
CI / lint-and-build (push) Failing after 1m16s
CI / docker (push) Has been skipped
- Multi-stage Dockerfile (node:20-alpine, pnpm, standalone)
- docker-compose.yml (SQLite volume dahil)
- .dockerignore
- Gitea CI: lint + build + Docker imaj push
- next.config.ts: output standalone eklendi
2026-07-24 04:08:22 +03:00
fef13ce7b8 .gitea/workflows/ci.yaml Güncelle
All checks were successful
Test / test (push) Successful in 2s
2026-07-24 04:00:07 +03:00
17caaab5fb .gitea/workflows/ci.yaml Güncelle
Some checks failed
Test / test (push) Has been cancelled
2026-07-24 03:58:20 +03:00
f815d1e861 .gitea/workflows/ci.yaml Güncelle
Some checks failed
Test Runner / test-job (push) Has been cancelled
2026-07-24 03:52:38 +03:00
bilalgursen
1105415284 fix: correct typo in YKS preference period message
Some checks failed
CI / build (push) Has been cancelled
2026-07-24 03:33:17 +03:00
Muhammed Emin Akalan
03107841ac fix: CI workflow — pnpm yerine bun + gerekli env vars
Some checks failed
CI / build (push) Has been cancelled
2026-07-24 03:29:13 +03:00
5ce68c7932 src/app/layout.tsx Güncelle
Some checks failed
CI / build (push) Has been cancelled
2026-07-24 03:26:12 +03:00
bilalgursen
307e778d62 feat: add CI workflow configuration for automated builds and linting
Some checks failed
CI / build (push) Has been cancelled
2026-07-24 02:56:04 +03:00
89 changed files with 9321 additions and 1943 deletions

View File

@@ -1,11 +1,11 @@
--- ---
name: ui-ux-pro-max name: ui-ux-pro-max
description: "UI/UX design intelligence. 50 styles, 21 palettes, 50 font pairings, 20 charts, 9 stacks (React, Next.js, Vue, Svelte, SwiftUI, React Native, Flutter, Tailwind, shadcn/ui). Actions: plan, build, create, design, implement, review, fix, improve, optimize, enhance, refactor, check UI/UX code. Projects: website, landing page, dashboard, admin panel, e-commerce, SaaS, portfolio, blog, mobile app, .html, .tsx, .vue, .svelte. Elements: button, modal, navbar, sidebar, card, table, form, chart. Styles: glassmorphism, claymorphism, minimalism, brutalism, neumorphism, bento grid, dark mode, responsive, skeuomorphism, flat design. Topics: color palette, accessibility, animation, layout, typography, font pairing, spacing, hover, shadow, gradient. Integrations: shadcn/ui MCP for component search and examples." description: "UI/UX design intelligence. 50 styles, 21 palettes, 50 font pairings, 20 charts, 4 stacks (Next.js, React, Tailwind, shadcn/ui). Actions: plan, build, create, design, implement, review, fix, improve, optimize, enhance, refactor, check UI/UX code. Projects: website, landing page, dashboard, admin panel, e-commerce, SaaS, portfolio, blog, .html, .tsx. Elements: button, modal, navbar, sidebar, card, table, form, chart. Styles: glassmorphism, claymorphism, minimalism, brutalism, neumorphism, bento grid, dark mode, responsive, skeuomorphism, flat design. Topics: color palette, accessibility, animation, layout, typography, font pairing, spacing, hover, shadow, gradient. Integrations: shadcn/ui MCP for component search and examples."
--- ---
# UI/UX Pro Max - Design Intelligence # UI/UX Pro Max - Design Intelligence
Comprehensive design guide for web and mobile applications. Contains 50+ styles, 97 color palettes, 57 font pairings, 99 UX guidelines, and 25 chart types across 9 technology stacks. Searchable database with priority-based recommendations. Comprehensive design guide for this Next.js + React + Tailwind + shadcn/ui codebase. Contains 50+ styles, 97 color palettes, 57 font pairings, 99 UX guidelines, and 25 chart types across 4 technology stacks. Searchable database with priority-based recommendations.
## When to Apply ## When to Apply
@@ -128,7 +128,7 @@ Extract key information from user request:
- **Product type**: SaaS, e-commerce, portfolio, dashboard, landing page, etc. - **Product type**: SaaS, e-commerce, portfolio, dashboard, landing page, etc.
- **Style keywords**: minimal, playful, professional, elegant, dark mode, etc. - **Style keywords**: minimal, playful, professional, elegant, dark mode, etc.
- **Industry**: healthcare, fintech, gaming, education, etc. - **Industry**: healthcare, fintech, gaming, education, etc.
- **Stack**: React, Vue, Next.js, or default to `html-tailwind` - **Stack**: Next.js / React / shadcn (default: `nextjs`)
### Step 2: Generate Design System (REQUIRED) ### Step 2: Generate Design System (REQUIRED)
@@ -167,15 +167,15 @@ python3 .claude/skills/ui-ux-pro-max/scripts/search.py "<keyword>" --domain <dom
| Alternative fonts | `typography` | `--domain typography "elegant luxury"` | | Alternative fonts | `typography` | `--domain typography "elegant luxury"` |
| Landing structure | `landing` | `--domain landing "hero social-proof"` | | Landing structure | `landing` | `--domain landing "hero social-proof"` |
### Step 4: Stack Guidelines (Default: html-tailwind) ### Step 4: Stack Guidelines (Default: nextjs)
Get implementation-specific best practices. If user doesn't specify a stack, **default to `html-tailwind`**. Get implementation-specific best practices. If user doesn't specify a stack, **default to `nextjs`**. For component patterns use `shadcn`; for Tailwind utilities use `html-tailwind`.
```bash ```bash
python3 .claude/skills/ui-ux-pro-max/scripts/search.py "<keyword>" --stack html-tailwind python3 .claude/skills/ui-ux-pro-max/scripts/search.py "<keyword>" --stack nextjs
``` ```
Available stacks: `html-tailwind`, `react`, `nextjs`, `vue`, `svelte`, `swiftui`, `react-native`, `flutter`, `shadcn` Available stacks: `nextjs`, `react`, `shadcn`, `html-tailwind`
--- ---
@@ -200,15 +200,10 @@ Available stacks: `html-tailwind`, `react`, `nextjs`, `vue`, `svelte`, `swiftui`
| Stack | Focus | | Stack | Focus |
|-------|-------| |-------|-------|
| `html-tailwind` | Tailwind utilities, responsive, a11y (DEFAULT) | | `nextjs` | SSR, routing, images, API routes (DEFAULT) |
| `react` | State, hooks, performance, patterns | | `react` | State, hooks, performance, patterns |
| `nextjs` | SSR, routing, images, API routes |
| `vue` | Composition API, Pinia, Vue Router |
| `svelte` | Runes, stores, SvelteKit |
| `swiftui` | Views, State, Navigation, Animation |
| `react-native` | Components, Navigation, Lists |
| `flutter` | Widgets, State, Layout, Theming |
| `shadcn` | shadcn/ui components, theming, forms, patterns | | `shadcn` | shadcn/ui components, theming, forms, patterns |
| `html-tailwind` | Tailwind utilities, responsive, a11y |
--- ---
@@ -220,7 +215,7 @@ Available stacks: `html-tailwind`, `react`, `nextjs`, `vue`, `svelte`, `swiftui`
- Product type: Beauty/Spa service - Product type: Beauty/Spa service
- Style keywords: elegant, professional, soft - Style keywords: elegant, professional, soft
- Industry: Beauty/Wellness - Industry: Beauty/Wellness
- Stack: html-tailwind (default) - Stack: nextjs (default)
### Step 2: Generate Design System (REQUIRED) ### Step 2: Generate Design System (REQUIRED)
@@ -243,7 +238,7 @@ python3 .claude/skills/ui-ux-pro-max/scripts/search.py "elegant luxury serif" --
### Step 4: Stack Guidelines ### Step 4: Stack Guidelines
```bash ```bash
python3 .claude/skills/ui-ux-pro-max/scripts/search.py "layout responsive form" --stack html-tailwind python3 .claude/skills/ui-ux-pro-max/scripts/search.py "layout responsive form" --stack nextjs
``` ```
**Then:** Synthesize design system + detailed searches and implement the design. **Then:** Synthesize design system + detailed searches and implement the design.

View File

@@ -1,53 +0,0 @@
No,Category,Guideline,Description,Do,Don't,Code Good,Code Bad,Severity,Docs URL
1,Widgets,Use StatelessWidget when possible,Immutable widgets are simpler,StatelessWidget for static UI,StatefulWidget for everything,class MyWidget extends StatelessWidget,class MyWidget extends StatefulWidget (static),Medium,https://api.flutter.dev/flutter/widgets/StatelessWidget-class.html
2,Widgets,Keep widgets small,Single responsibility principle,Extract widgets into smaller pieces,Large build methods,Column(children: [Header() Content()]),500+ line build method,Medium,
3,Widgets,Use const constructors,Compile-time constants for performance,const MyWidget() when possible,Non-const for static widgets,const Text('Hello'),Text('Hello') for literals,High,https://dart.dev/guides/language/language-tour#constant-constructors
4,Widgets,Prefer composition over inheritance,Combine widgets using children,Compose widgets,Extend widget classes,Container(child: MyContent()),class MyContainer extends Container,Medium,
5,State,Use setState correctly,Minimal state in StatefulWidget,setState for UI state changes,setState for business logic,setState(() { _counter++; }),Complex logic in setState,Medium,https://api.flutter.dev/flutter/widgets/State/setState.html
6,State,Avoid setState in build,Never call setState during build,setState in callbacks only,setState in build method,onPressed: () => setState(() {}),build() { setState(); },High,
7,State,Use state management for complex apps,Provider Riverpod BLoC,State management for shared state,setState for global state,Provider.of<MyState>(context),Global setState calls,Medium,
8,State,Prefer Riverpod or Provider,Recommended state solutions,Riverpod for new projects,InheritedWidget manually,ref.watch(myProvider),Custom InheritedWidget,Medium,https://riverpod.dev/
9,State,Dispose resources,Clean up controllers and subscriptions,dispose() for cleanup,Memory leaks from subscriptions,@override void dispose() { controller.dispose(); },No dispose implementation,High,
10,Layout,Use Column and Row,Basic layout widgets,Column Row for linear layouts,Stack for simple layouts,"Column(children: [Text(), Button()])",Stack for vertical list,Medium,https://api.flutter.dev/flutter/widgets/Column-class.html
11,Layout,Use Expanded and Flexible,Control flex behavior,Expanded to fill space,Fixed sizes in flex containers,Expanded(child: Container()),Container(width: 200) in Row,Medium,
12,Layout,Use SizedBox for spacing,Consistent spacing,SizedBox for gaps,Container for spacing only,SizedBox(height: 16),Container(height: 16),Low,
13,Layout,Use LayoutBuilder for responsive,Respond to constraints,LayoutBuilder for adaptive layouts,Fixed sizes for responsive,LayoutBuilder(builder: (context constraints) {}),Container(width: 375),Medium,https://api.flutter.dev/flutter/widgets/LayoutBuilder-class.html
14,Layout,Avoid deep nesting,Keep widget tree shallow,Extract deeply nested widgets,10+ levels of nesting,Extract widget to method or class,Column(Row(Column(Row(...)))),Medium,
15,Lists,Use ListView.builder,Lazy list building,ListView.builder for long lists,ListView with children for large lists,"ListView.builder(itemCount: 100, itemBuilder: ...)",ListView(children: items.map(...).toList()),High,https://api.flutter.dev/flutter/widgets/ListView-class.html
16,Lists,Provide itemExtent when known,Skip measurement,itemExtent for fixed height items,No itemExtent for uniform lists,ListView.builder(itemExtent: 50),ListView.builder without itemExtent,Medium,
17,Lists,Use keys for stateful items,Preserve widget state,Key for stateful list items,No key for dynamic lists,ListTile(key: ValueKey(item.id)),ListTile without key,High,
18,Lists,Use SliverList for custom scroll,Custom scroll effects,CustomScrollView with Slivers,Nested ListViews,CustomScrollView(slivers: [SliverList()]),ListView inside ListView,Medium,https://api.flutter.dev/flutter/widgets/SliverList-class.html
19,Navigation,Use Navigator 2.0 or GoRouter,Declarative routing,go_router for navigation,Navigator.push for complex apps,GoRouter(routes: [...]),Navigator.push everywhere,Medium,https://pub.dev/packages/go_router
20,Navigation,Use named routes,Organized navigation,Named routes for clarity,Anonymous routes,Navigator.pushNamed(context '/home'),Navigator.push(context MaterialPageRoute()),Low,
21,Navigation,Handle back button (PopScope),Android back behavior and predictive back (Android 14+),Use PopScope widget (WillPopScope is deprecated),Use WillPopScope,"PopScope(canPop: false, onPopInvoked: (didPop) => ...)",WillPopScope(onWillPop: ...),High,https://api.flutter.dev/flutter/widgets/PopScope-class.html
22,Navigation,Pass typed arguments,Type-safe route arguments,Typed route arguments,Dynamic arguments,MyRoute(id: '123'),arguments: {'id': '123'},Medium,
23,Async,Use FutureBuilder,Async UI building,FutureBuilder for async data,setState for async,FutureBuilder(future: fetchData()),fetchData().then((d) => setState()),Medium,https://api.flutter.dev/flutter/widgets/FutureBuilder-class.html
24,Async,Use StreamBuilder,Stream UI building,StreamBuilder for streams,Manual stream subscription,StreamBuilder(stream: myStream),stream.listen in initState,Medium,https://api.flutter.dev/flutter/widgets/StreamBuilder-class.html
25,Async,Handle loading and error states,Complete async UI states,ConnectionState checks,Only success state,if (snapshot.connectionState == ConnectionState.waiting),No loading indicator,High,
26,Async,Cancel subscriptions,Clean up stream subscriptions,Cancel in dispose,Memory leaks,subscription.cancel() in dispose,No subscription cleanup,High,
27,Theming,Use ThemeData,Consistent theming,ThemeData for app theme,Hardcoded colors,Theme.of(context).primaryColor,Color(0xFF123456) everywhere,Medium,https://api.flutter.dev/flutter/material/ThemeData-class.html
28,Theming,Use ColorScheme,Material 3 color system,ColorScheme for colors,Individual color properties,colorScheme: ColorScheme.fromSeed(),primaryColor: Colors.blue,Medium,
29,Theming,Access theme via context,Dynamic theme access,Theme.of(context),Static theme reference,Theme.of(context).textTheme.bodyLarge,TextStyle(fontSize: 16),Medium,
30,Theming,Support dark mode,Respect system theme,darkTheme in MaterialApp,Light theme only,"MaterialApp(theme: light, darkTheme: dark)",MaterialApp(theme: light),Medium,
31,Animation,Use implicit animations,Simple animations,AnimatedContainer AnimatedOpacity,Explicit for simple transitions,AnimatedContainer(duration: Duration()),AnimationController for fade,Low,https://api.flutter.dev/flutter/widgets/AnimatedContainer-class.html
32,Animation,Use AnimationController for complex,Fine-grained control,AnimationController with Ticker,Implicit for complex sequences,AnimationController(vsync: this),AnimatedContainer for staggered,Medium,
33,Animation,Dispose AnimationControllers,Clean up animation resources,dispose() for controllers,Memory leaks,controller.dispose() in dispose,No controller disposal,High,
34,Animation,Use Hero for transitions,Shared element transitions,Hero for navigation animations,Manual shared element,Hero(tag: 'image' child: Image()),Custom shared element animation,Low,https://api.flutter.dev/flutter/widgets/Hero-class.html
35,Forms,Use Form widget,Form validation,Form with GlobalKey,Individual validation,Form(key: _formKey child: ...),TextField without Form,Medium,https://api.flutter.dev/flutter/widgets/Form-class.html
36,Forms,Use TextEditingController,Control text input,Controller for text fields,onChanged for all text,final controller = TextEditingController(),onChanged: (v) => setState(),Medium,
37,Forms,Validate on submit,Form validation flow,_formKey.currentState!.validate(),Skip validation,if (_formKey.currentState!.validate()),Submit without validation,High,
38,Forms,Dispose controllers,Clean up text controllers,dispose() for controllers,Memory leaks,controller.dispose() in dispose,No controller disposal,High,
39,Performance,Use const widgets,Reduce rebuilds,const for static widgets,No const for literals,const Icon(Icons.add),Icon(Icons.add),High,
40,Performance,Avoid rebuilding entire tree,Minimal rebuild scope,Isolate changing widgets,setState on parent,Consumer only around changing widget,setState on root widget,High,
41,Performance,Use RepaintBoundary,Isolate repaints,RepaintBoundary for animations,Full screen repaints,RepaintBoundary(child: AnimatedWidget()),Animation without boundary,Medium,https://api.flutter.dev/flutter/widgets/RepaintBoundary-class.html
42,Performance,Profile with DevTools,Measure before optimizing,Flutter DevTools profiling,Guess at performance,DevTools performance tab,Optimize without measuring,Medium,https://docs.flutter.dev/tools/devtools
43,Accessibility,Use Semantics widget,Screen reader support,Semantics for accessibility,Missing accessibility info,Semantics(label: 'Submit button'),GestureDetector without semantics,High,https://api.flutter.dev/flutter/widgets/Semantics-class.html
44,Accessibility,Support large fonts,MediaQuery text scaling,MediaQuery.textScaleFactor,Fixed font sizes,style: Theme.of(context).textTheme,TextStyle(fontSize: 14),High,
45,Accessibility,Test with screen readers,TalkBack and VoiceOver,Test accessibility regularly,Skip accessibility testing,Regular TalkBack testing,No screen reader testing,High,
46,Testing,Use widget tests,Test widget behavior,WidgetTester for UI tests,Unit tests only,testWidgets('...' (tester) async {}),Only test() for UI,Medium,https://docs.flutter.dev/testing
47,Testing,Use integration tests,Full app testing,integration_test package,Manual testing only,IntegrationTestWidgetsFlutterBinding,Manual E2E testing,Medium,
48,Testing,Mock dependencies,Isolate tests,Mockito or mocktail,Real dependencies in tests,when(mock.method()).thenReturn(),Real API calls in tests,Medium,
49,Platform,Use Platform checks,Platform-specific code,Platform.isIOS Platform.isAndroid,Same code for all platforms,if (Platform.isIOS) {},Hardcoded iOS behavior,Medium,
50,Platform,Use kIsWeb for web,Web platform detection,kIsWeb for web checks,Platform for web,if (kIsWeb) {},Platform.isWeb (doesn't exist),Medium,
51,Packages,Use pub.dev packages,Community packages,Popular maintained packages,Custom implementations,cached_network_image,Custom image cache,Medium,https://pub.dev/
52,Packages,Check package quality,Quality before adding,Pub points and popularity,Any package without review,100+ pub points,Unmaintained packages,Medium,
1 No Category Guideline Description Do Don't Code Good Code Bad Severity Docs URL
2 1 Widgets Use StatelessWidget when possible Immutable widgets are simpler StatelessWidget for static UI StatefulWidget for everything class MyWidget extends StatelessWidget class MyWidget extends StatefulWidget (static) Medium https://api.flutter.dev/flutter/widgets/StatelessWidget-class.html
3 2 Widgets Keep widgets small Single responsibility principle Extract widgets into smaller pieces Large build methods Column(children: [Header() Content()]) 500+ line build method Medium
4 3 Widgets Use const constructors Compile-time constants for performance const MyWidget() when possible Non-const for static widgets const Text('Hello') Text('Hello') for literals High https://dart.dev/guides/language/language-tour#constant-constructors
5 4 Widgets Prefer composition over inheritance Combine widgets using children Compose widgets Extend widget classes Container(child: MyContent()) class MyContainer extends Container Medium
6 5 State Use setState correctly Minimal state in StatefulWidget setState for UI state changes setState for business logic setState(() { _counter++; }) Complex logic in setState Medium https://api.flutter.dev/flutter/widgets/State/setState.html
7 6 State Avoid setState in build Never call setState during build setState in callbacks only setState in build method onPressed: () => setState(() {}) build() { setState(); } High
8 7 State Use state management for complex apps Provider Riverpod BLoC State management for shared state setState for global state Provider.of<MyState>(context) Global setState calls Medium
9 8 State Prefer Riverpod or Provider Recommended state solutions Riverpod for new projects InheritedWidget manually ref.watch(myProvider) Custom InheritedWidget Medium https://riverpod.dev/
10 9 State Dispose resources Clean up controllers and subscriptions dispose() for cleanup Memory leaks from subscriptions @override void dispose() { controller.dispose(); } No dispose implementation High
11 10 Layout Use Column and Row Basic layout widgets Column Row for linear layouts Stack for simple layouts Column(children: [Text(), Button()]) Stack for vertical list Medium https://api.flutter.dev/flutter/widgets/Column-class.html
12 11 Layout Use Expanded and Flexible Control flex behavior Expanded to fill space Fixed sizes in flex containers Expanded(child: Container()) Container(width: 200) in Row Medium
13 12 Layout Use SizedBox for spacing Consistent spacing SizedBox for gaps Container for spacing only SizedBox(height: 16) Container(height: 16) Low
14 13 Layout Use LayoutBuilder for responsive Respond to constraints LayoutBuilder for adaptive layouts Fixed sizes for responsive LayoutBuilder(builder: (context constraints) {}) Container(width: 375) Medium https://api.flutter.dev/flutter/widgets/LayoutBuilder-class.html
15 14 Layout Avoid deep nesting Keep widget tree shallow Extract deeply nested widgets 10+ levels of nesting Extract widget to method or class Column(Row(Column(Row(...)))) Medium
16 15 Lists Use ListView.builder Lazy list building ListView.builder for long lists ListView with children for large lists ListView.builder(itemCount: 100, itemBuilder: ...) ListView(children: items.map(...).toList()) High https://api.flutter.dev/flutter/widgets/ListView-class.html
17 16 Lists Provide itemExtent when known Skip measurement itemExtent for fixed height items No itemExtent for uniform lists ListView.builder(itemExtent: 50) ListView.builder without itemExtent Medium
18 17 Lists Use keys for stateful items Preserve widget state Key for stateful list items No key for dynamic lists ListTile(key: ValueKey(item.id)) ListTile without key High
19 18 Lists Use SliverList for custom scroll Custom scroll effects CustomScrollView with Slivers Nested ListViews CustomScrollView(slivers: [SliverList()]) ListView inside ListView Medium https://api.flutter.dev/flutter/widgets/SliverList-class.html
20 19 Navigation Use Navigator 2.0 or GoRouter Declarative routing go_router for navigation Navigator.push for complex apps GoRouter(routes: [...]) Navigator.push everywhere Medium https://pub.dev/packages/go_router
21 20 Navigation Use named routes Organized navigation Named routes for clarity Anonymous routes Navigator.pushNamed(context '/home') Navigator.push(context MaterialPageRoute()) Low
22 21 Navigation Handle back button (PopScope) Android back behavior and predictive back (Android 14+) Use PopScope widget (WillPopScope is deprecated) Use WillPopScope PopScope(canPop: false, onPopInvoked: (didPop) => ...) WillPopScope(onWillPop: ...) High https://api.flutter.dev/flutter/widgets/PopScope-class.html
23 22 Navigation Pass typed arguments Type-safe route arguments Typed route arguments Dynamic arguments MyRoute(id: '123') arguments: {'id': '123'} Medium
24 23 Async Use FutureBuilder Async UI building FutureBuilder for async data setState for async FutureBuilder(future: fetchData()) fetchData().then((d) => setState()) Medium https://api.flutter.dev/flutter/widgets/FutureBuilder-class.html
25 24 Async Use StreamBuilder Stream UI building StreamBuilder for streams Manual stream subscription StreamBuilder(stream: myStream) stream.listen in initState Medium https://api.flutter.dev/flutter/widgets/StreamBuilder-class.html
26 25 Async Handle loading and error states Complete async UI states ConnectionState checks Only success state if (snapshot.connectionState == ConnectionState.waiting) No loading indicator High
27 26 Async Cancel subscriptions Clean up stream subscriptions Cancel in dispose Memory leaks subscription.cancel() in dispose No subscription cleanup High
28 27 Theming Use ThemeData Consistent theming ThemeData for app theme Hardcoded colors Theme.of(context).primaryColor Color(0xFF123456) everywhere Medium https://api.flutter.dev/flutter/material/ThemeData-class.html
29 28 Theming Use ColorScheme Material 3 color system ColorScheme for colors Individual color properties colorScheme: ColorScheme.fromSeed() primaryColor: Colors.blue Medium
30 29 Theming Access theme via context Dynamic theme access Theme.of(context) Static theme reference Theme.of(context).textTheme.bodyLarge TextStyle(fontSize: 16) Medium
31 30 Theming Support dark mode Respect system theme darkTheme in MaterialApp Light theme only MaterialApp(theme: light, darkTheme: dark) MaterialApp(theme: light) Medium
32 31 Animation Use implicit animations Simple animations AnimatedContainer AnimatedOpacity Explicit for simple transitions AnimatedContainer(duration: Duration()) AnimationController for fade Low https://api.flutter.dev/flutter/widgets/AnimatedContainer-class.html
33 32 Animation Use AnimationController for complex Fine-grained control AnimationController with Ticker Implicit for complex sequences AnimationController(vsync: this) AnimatedContainer for staggered Medium
34 33 Animation Dispose AnimationControllers Clean up animation resources dispose() for controllers Memory leaks controller.dispose() in dispose No controller disposal High
35 34 Animation Use Hero for transitions Shared element transitions Hero for navigation animations Manual shared element Hero(tag: 'image' child: Image()) Custom shared element animation Low https://api.flutter.dev/flutter/widgets/Hero-class.html
36 35 Forms Use Form widget Form validation Form with GlobalKey Individual validation Form(key: _formKey child: ...) TextField without Form Medium https://api.flutter.dev/flutter/widgets/Form-class.html
37 36 Forms Use TextEditingController Control text input Controller for text fields onChanged for all text final controller = TextEditingController() onChanged: (v) => setState() Medium
38 37 Forms Validate on submit Form validation flow _formKey.currentState!.validate() Skip validation if (_formKey.currentState!.validate()) Submit without validation High
39 38 Forms Dispose controllers Clean up text controllers dispose() for controllers Memory leaks controller.dispose() in dispose No controller disposal High
40 39 Performance Use const widgets Reduce rebuilds const for static widgets No const for literals const Icon(Icons.add) Icon(Icons.add) High
41 40 Performance Avoid rebuilding entire tree Minimal rebuild scope Isolate changing widgets setState on parent Consumer only around changing widget setState on root widget High
42 41 Performance Use RepaintBoundary Isolate repaints RepaintBoundary for animations Full screen repaints RepaintBoundary(child: AnimatedWidget()) Animation without boundary Medium https://api.flutter.dev/flutter/widgets/RepaintBoundary-class.html
43 42 Performance Profile with DevTools Measure before optimizing Flutter DevTools profiling Guess at performance DevTools performance tab Optimize without measuring Medium https://docs.flutter.dev/tools/devtools
44 43 Accessibility Use Semantics widget Screen reader support Semantics for accessibility Missing accessibility info Semantics(label: 'Submit button') GestureDetector without semantics High https://api.flutter.dev/flutter/widgets/Semantics-class.html
45 44 Accessibility Support large fonts MediaQuery text scaling MediaQuery.textScaleFactor Fixed font sizes style: Theme.of(context).textTheme TextStyle(fontSize: 14) High
46 45 Accessibility Test with screen readers TalkBack and VoiceOver Test accessibility regularly Skip accessibility testing Regular TalkBack testing No screen reader testing High
47 46 Testing Use widget tests Test widget behavior WidgetTester for UI tests Unit tests only testWidgets('...' (tester) async {}) Only test() for UI Medium https://docs.flutter.dev/testing
48 47 Testing Use integration tests Full app testing integration_test package Manual testing only IntegrationTestWidgetsFlutterBinding Manual E2E testing Medium
49 48 Testing Mock dependencies Isolate tests Mockito or mocktail Real dependencies in tests when(mock.method()).thenReturn() Real API calls in tests Medium
50 49 Platform Use Platform checks Platform-specific code Platform.isIOS Platform.isAndroid Same code for all platforms if (Platform.isIOS) {} Hardcoded iOS behavior Medium
51 50 Platform Use kIsWeb for web Web platform detection kIsWeb for web checks Platform for web if (kIsWeb) {} Platform.isWeb (doesn't exist) Medium
52 51 Packages Use pub.dev packages Community packages Popular maintained packages Custom implementations cached_network_image Custom image cache Medium https://pub.dev/
53 52 Packages Check package quality Quality before adding Pub points and popularity Any package without review 100+ pub points Unmaintained packages Medium

View File

@@ -1,51 +0,0 @@
No,Category,Guideline,Description,Do,Don't,Code Good,Code Bad,Severity,Docs URL
1,Installation,Add Nuxt UI module,Install and configure Nuxt UI in your Nuxt project,pnpm add @nuxt/ui and add to modules,Manual component imports,"modules: ['@nuxt/ui']","import { UButton } from '@nuxt/ui'",High,https://ui.nuxt.com/docs/getting-started/installation/nuxt
2,Installation,Import Tailwind and Nuxt UI CSS,Required CSS imports in main.css file,@import tailwindcss and @import @nuxt/ui,Skip CSS imports,"@import ""tailwindcss""; @import ""@nuxt/ui"";",No CSS imports,High,https://ui.nuxt.com/docs/getting-started/installation/nuxt
3,Installation,Wrap app with UApp component,UApp provides global configs for Toast Tooltip and overlays,<UApp> wrapper in app.vue,Skip UApp wrapper,<UApp><NuxtPage/></UApp>,<NuxtPage/> without wrapper,High,https://ui.nuxt.com/docs/components/app
4,Components,Use U prefix for components,All Nuxt UI components use U prefix by default,UButton UInput UModal,Button Input Modal,<UButton>Click</UButton>,<Button>Click</Button>,Medium,https://ui.nuxt.com/docs/getting-started/installation/nuxt
5,Components,Use semantic color props,Use semantic colors like primary secondary error,color="primary" color="error",Hardcoded colors,"<UButton color=""primary"">","<UButton class=""bg-green-500"">",Medium,https://ui.nuxt.com/docs/getting-started/theme/design-system
6,Components,Use variant prop for styling,Nuxt UI provides solid outline soft subtle ghost link variants,variant="soft" variant="outline",Custom button classes,"<UButton variant=""soft"">","<UButton class=""border bg-transparent"">",Medium,https://ui.nuxt.com/docs/components/button
7,Components,Use size prop consistently,Components support xs sm md lg xl sizes,size="sm" size="lg",Arbitrary sizing classes,"<UButton size=""lg"">","<UButton class=""text-xl px-6"">",Low,https://ui.nuxt.com/docs/components/button
8,Icons,Use icon prop with Iconify format,Nuxt UI supports Iconify icons via icon prop,icon="lucide:home" icon="heroicons:user",i-lucide-home format,"<UButton icon=""lucide:home"">","<UButton icon=""i-lucide-home"">",Medium,https://ui.nuxt.com/docs/getting-started/integrations/icons/nuxt
9,Icons,Use leadingIcon and trailingIcon,Position icons with dedicated props for clarity,leadingIcon="lucide:plus" trailingIcon="lucide:arrow-right",Manual icon positioning,"<UButton leadingIcon=""lucide:plus"">","<UButton><Icon name=""lucide:plus""/>Add</UButton>",Low,https://ui.nuxt.com/docs/components/button
10,Theming,Configure colors in app.config.ts,Runtime color configuration without restart,ui.colors.primary in app.config.ts,Hardcoded colors in components,"defineAppConfig({ ui: { colors: { primary: 'blue' } } })","<UButton class=""bg-blue-500"">",High,https://ui.nuxt.com/docs/getting-started/theme/design-system
11,Theming,Use @theme directive for custom colors,Define design tokens in CSS with Tailwind @theme,@theme { --color-brand-500: #xxx },Inline color definitions,@theme { --color-brand-500: #ef4444; },:style="{ color: '#ef4444' }",Medium,https://ui.nuxt.com/docs/getting-started/theme/design-system
12,Theming,Extend semantic colors in nuxt.config,Register new colors like tertiary in theme.colors,theme.colors array in ui config,Use undefined colors,"ui: { theme: { colors: ['primary', 'tertiary'] } }","<UButton color=""tertiary""> without config",Medium,https://ui.nuxt.com/docs/getting-started/theme/design-system
13,Forms,Use UForm with schema validation,UForm supports Zod Yup Joi Valibot schemas,:schema prop with validation schema,Manual form validation,"<UForm :schema=""schema"" :state=""state"">",Manual @blur validation,High,https://ui.nuxt.com/docs/components/form
14,Forms,Use UFormField for field wrapper,Provides label error message and validation display,UFormField with name prop,Manual error handling,"<UFormField name=""email"" label=""Email"">",<div><label>Email</label><UInput/><span>error</span></div>,Medium,https://ui.nuxt.com/docs/components/form-field
15,Forms,Handle form submit with @submit,UForm emits submit event with validated data,@submit handler on UForm,@click on submit button,"<UForm @submit=""onSubmit"">","<UButton @click=""onSubmit"">",Medium,https://ui.nuxt.com/docs/components/form
16,Forms,Use validateOn prop for validation timing,Control when validation triggers (blur change input),validateOn="['blur']" for performance,Always validate on input,"<UForm :validateOn=""['blur', 'change']"">","<UForm> (validates on every keystroke)",Low,https://ui.nuxt.com/docs/components/form
17,Overlays,Use v-model:open for overlay control,Modal Slideover Drawer use v-model:open,v-model:open for controlled state,Manual show/hide logic,"<UModal v-model:open=""isOpen"">",<UModal v-if="isOpen">,Medium,https://ui.nuxt.com/docs/components/modal
18,Overlays,Use useOverlay composable for programmatic overlays,Open overlays programmatically without template refs,useOverlay().open(MyModal),Template ref and manual control,"const overlay = useOverlay(); overlay.open(MyModal, { props })","const modal = ref(); modal.value.open()",Medium,https://ui.nuxt.com/docs/components/modal
19,Overlays,Use title and description props,Built-in header support for overlays,title="Confirm" description="Are you sure?",Manual header content,"<UModal title=""Confirm"" description=""Are you sure?"">","<UModal><template #header><h2>Confirm</h2></template>",Low,https://ui.nuxt.com/docs/components/modal
20,Dashboard,Use UDashboardSidebar for navigation,Provides collapsible resizable sidebar with mobile support,UDashboardSidebar with header default footer slots,Custom sidebar implementation,<UDashboardSidebar><template #header>...</template></UDashboardSidebar>,<aside class="w-64 border-r">,Medium,https://ui.nuxt.com/docs/components/dashboard-sidebar
21,Dashboard,Use UDashboardGroup for layout,Wraps dashboard components with sidebar state management,UDashboardGroup > UDashboardSidebar + UDashboardPanel,Manual layout flex containers,<UDashboardGroup><UDashboardSidebar/><UDashboardPanel/></UDashboardGroup>,"<div class=""flex""><aside/><main/></div>",Medium,https://ui.nuxt.com/docs/components/dashboard-group
22,Dashboard,Use UDashboardNavbar for top navigation,Responsive navbar with mobile menu support,UDashboardNavbar in dashboard layout,Custom navbar implementation,<UDashboardNavbar :links="navLinks"/>,<nav class="border-b">,Low,https://ui.nuxt.com/docs/components/dashboard-navbar
23,Tables,Use UTable with data and columns props,Powered by TanStack Table with built-in features,:data and :columns props,Manual table markup,"<UTable :data=""users"" :columns=""columns""/>","<table><tr v-for=""user in users"">",High,https://ui.nuxt.com/docs/components/table
24,Tables,Define columns with accessorKey,Column definitions use accessorKey for data binding,accessorKey: 'email' in column def,String column names only,"{ accessorKey: 'email', header: 'Email' }","['name', 'email']",Medium,https://ui.nuxt.com/docs/components/table
25,Tables,Use cell slot for custom rendering,Customize cell content with scoped slots,#cell-columnName slot,Override entire table,<template #cell-status="{ row }">,Manual column render function,Medium,https://ui.nuxt.com/docs/components/table
26,Tables,Enable sorting with sortable column option,Add sortable: true to column definition,sortable: true in column,Manual sort implementation,"{ accessorKey: 'name', sortable: true }",@click="sortBy('name')",Low,https://ui.nuxt.com/docs/components/table
27,Navigation,Use UNavigationMenu for nav links,Horizontal or vertical navigation with dropdown support,UNavigationMenu with items array,Manual nav with v-for,"<UNavigationMenu :items=""navItems""/>","<nav><a v-for=""item in items"">",Medium,https://ui.nuxt.com/docs/components/navigation-menu
28,Navigation,Use UBreadcrumb for page hierarchy,Automatic breadcrumb with NuxtLink support,:items array with label and to,Manual breadcrumb links,"<UBreadcrumb :items=""breadcrumbs""/>","<nav><span v-for=""crumb in crumbs"">",Low,https://ui.nuxt.com/docs/components/breadcrumb
29,Navigation,Use UTabs for tabbed content,Tab navigation with content panels,UTabs with items containing slot content,Manual tab state,"<UTabs :items=""tabs""/>","<div><button @click=""tab=1"">",Medium,https://ui.nuxt.com/docs/components/tabs
30,Feedback,Use useToast for notifications,Composable for toast notifications,useToast().add({ title description }),Alert components for toasts,"const toast = useToast(); toast.add({ title: 'Saved' })",<UAlert v-if="showSuccess">,High,https://ui.nuxt.com/docs/components/toast
31,Feedback,Use UAlert for inline messages,Static alert messages with icon and actions,UAlert with title description color,Toast for static messages,"<UAlert title=""Warning"" color=""warning""/>",useToast for inline alerts,Medium,https://ui.nuxt.com/docs/components/alert
32,Feedback,Use USkeleton for loading states,Placeholder content during data loading,USkeleton with appropriate size,Spinner for content loading,<USkeleton class="h-4 w-32"/>,<UIcon name="lucide:loader" class="animate-spin"/>,Low,https://ui.nuxt.com/docs/components/skeleton
33,Color Mode,Use UColorModeButton for theme toggle,Built-in light/dark mode toggle button,UColorModeButton component,Manual color mode logic,<UColorModeButton/>,"<button @click=""toggleColorMode"">",Low,https://ui.nuxt.com/docs/components/color-mode-button
34,Color Mode,Use UColorModeSelect for theme picker,Dropdown to select system light or dark mode,UColorModeSelect component,Custom select for theme,<UColorModeSelect/>,"<USelect v-model=""colorMode"" :items=""modes""/>",Low,https://ui.nuxt.com/docs/components/color-mode-select
35,Customization,Use ui prop for component styling,Override component styles via ui prop,ui prop with slot class overrides,Global CSS overrides,"<UButton :ui=""{ base: 'rounded-full' }""/>",<UButton class="!rounded-full"/>,Medium,https://ui.nuxt.com/docs/getting-started/theme/components
36,Customization,Configure default variants in nuxt.config,Set default color and size for all components,theme.defaultVariants in ui config,Repeat props on every component,"ui: { theme: { defaultVariants: { color: 'neutral' } } }","<UButton color=""neutral""> everywhere",Medium,https://ui.nuxt.com/docs/getting-started/installation/nuxt
37,Customization,Use app.config.ts for theme overrides,Runtime theme customization,defineAppConfig with ui key,nuxt.config for runtime values,"defineAppConfig({ ui: { button: { defaultVariants: { size: 'sm' } } } })","nuxt.config ui.button.size: 'sm'",Medium,https://ui.nuxt.com/docs/getting-started/theme/components
38,Performance,Enable component detection,Tree-shake unused component CSS,experimental.componentDetection: true,Include all component CSS,"ui: { experimental: { componentDetection: true } }","ui: {} (includes all CSS)",Low,https://ui.nuxt.com/docs/getting-started/installation/nuxt
39,Performance,Use UTable virtualize for large data,Enable virtualization for 1000+ rows,:virtualize prop on UTable,Render all rows,"<UTable :data=""largeData"" virtualize/>","<UTable :data=""largeData""/>",Medium,https://ui.nuxt.com/docs/components/table
40,Accessibility,Use semantic component props,Components have built-in ARIA support,Use title description label props,Skip accessibility props,"<UModal title=""Settings"">","<UModal><h2>Settings</h2>",Medium,https://ui.nuxt.com/docs/components/modal
41,Accessibility,Use UFormField for form accessibility,Automatic label-input association,UFormField wraps inputs,Manual id and for attributes,"<UFormField label=""Email""><UInput/></UFormField>","<label for=""email"">Email</label><UInput id=""email""/>",High,https://ui.nuxt.com/docs/components/form-field
42,Content,Use UContentToc for table of contents,Automatic TOC with active heading highlight,UContentToc with :links,Manual TOC implementation,"<UContentToc :links=""toc""/>","<nav><a v-for=""heading in headings"">",Low,https://ui.nuxt.com/docs/components/content-toc
43,Content,Use UContentSearch for docs search,Command palette for documentation search,UContentSearch with Nuxt Content,Custom search implementation,<UContentSearch/>,<UCommandPalette :groups="searchResults"/>,Low,https://ui.nuxt.com/docs/components/content-search
44,AI/Chat,Use UChatMessages for chat UI,Designed for Vercel AI SDK integration,UChatMessages with messages array,Custom chat message list,"<UChatMessages :messages=""messages""/>","<div v-for=""msg in messages"">",Medium,https://ui.nuxt.com/docs/components/chat-messages
45,AI/Chat,Use UChatPrompt for input,Enhanced textarea for AI prompts,UChatPrompt with v-model,Basic textarea,<UChatPrompt v-model="prompt"/>,<UTextarea v-model="prompt"/>,Medium,https://ui.nuxt.com/docs/components/chat-prompt
46,Editor,Use UEditor for rich text,TipTap-based editor with toolbar support,UEditor with v-model:content,Custom TipTap setup,"<UEditor v-model:content=""content""/>",Manual TipTap initialization,Medium,https://ui.nuxt.com/docs/components/editor
47,Links,Use to prop for navigation,UButton and ULink support NuxtLink to prop,to="/dashboard" for internal links,href for internal navigation,"<UButton to=""/dashboard"">","<UButton href=""/dashboard"">",Medium,https://ui.nuxt.com/docs/components/button
48,Links,Use external prop for outside links,Explicitly mark external links,target="_blank" with external URLs,Forget rel="noopener","<UButton to=""https://example.com"" target=""_blank"">","<UButton href=""https://..."">",Low,https://ui.nuxt.com/docs/components/link
49,Loading,Use loadingAuto on buttons,Automatic loading state from @click promise,loadingAuto prop on UButton,Manual loading state,"<UButton loadingAuto @click=""async () => await save()"">","<UButton :loading=""isLoading"" @click=""save"">",Low,https://ui.nuxt.com/docs/components/button
50,Loading,Use UForm loadingAuto,Auto-disable form during submit,loadingAuto on UForm (default true),Manual form disabled state,"<UForm @submit=""handleSubmit"">","<UForm :disabled=""isSubmitting"">",Low,https://ui.nuxt.com/docs/components/form
Can't render this file because it contains an unexpected character in line 6 and column 94.

View File

@@ -1,59 +0,0 @@
No,Category,Guideline,Description,Do,Don't,Code Good,Code Bad,Severity,Docs URL
1,Routing,Use file-based routing,Create routes by adding files in pages directory,pages/ directory with index.vue,Manual route configuration,pages/dashboard/index.vue,Custom router setup,Medium,https://nuxt.com/docs/getting-started/routing
2,Routing,Use dynamic route parameters,Create dynamic routes with bracket syntax,[id].vue for dynamic params,Hardcoded routes for dynamic content,pages/posts/[id].vue,pages/posts/post1.vue,Medium,https://nuxt.com/docs/getting-started/routing
3,Routing,Use catch-all routes,Handle multiple path segments with [...slug],[...slug].vue for catch-all,Multiple nested dynamic routes,pages/[...slug].vue,pages/[a]/[b]/[c].vue,Low,https://nuxt.com/docs/getting-started/routing
4,Routing,Define page metadata with definePageMeta,Set page-level configuration and middleware,definePageMeta for layout middleware title,Manual route meta configuration,"definePageMeta({ layout: 'admin', middleware: 'auth' })",router.beforeEach for page config,High,https://nuxt.com/docs/api/utils/define-page-meta
5,Routing,Use validate for route params,Validate dynamic route parameters before rendering,validate function in definePageMeta,Manual validation in setup,"definePageMeta({ validate: (route) => /^\d+$/.test(route.params.id) })",if (!valid) navigateTo('/404'),Medium,https://nuxt.com/docs/api/utils/define-page-meta
6,Rendering,Use SSR by default,Server-side rendering is enabled by default,Keep ssr: true (default),Disable SSR unnecessarily,ssr: true (default),ssr: false for all pages,High,https://nuxt.com/docs/guide/concepts/rendering
7,Rendering,Use .client suffix for client-only components,Mark components to render only on client,ComponentName.client.vue suffix,v-if with process.client check,Comments.client.vue,<div v-if="process.client"><Comments/></div>,Medium,https://nuxt.com/docs/guide/directory-structure/components
8,Rendering,Use .server suffix for server-only components,Mark components to render only on server,ComponentName.server.vue suffix,Manual server check,HeavyMarkdown.server.vue,v-if="process.server",Low,https://nuxt.com/docs/guide/directory-structure/components
9,DataFetching,Use useFetch for simple data fetching,Wrapper around useAsyncData for URL fetching,useFetch for API calls,$fetch in onMounted,"const { data } = await useFetch('/api/posts')","onMounted(async () => { data.value = await $fetch('/api/posts') })",High,https://nuxt.com/docs/api/composables/use-fetch
10,DataFetching,Use useAsyncData for complex fetching,Fine-grained control over async data,useAsyncData for CMS or custom fetching,useFetch for non-URL data sources,"const { data } = await useAsyncData('posts', () => cms.getPosts())","const { data } = await useFetch(() => cms.getPosts())",Medium,https://nuxt.com/docs/api/composables/use-async-data
11,DataFetching,Use $fetch for non-reactive requests,$fetch for event handlers and non-component code,$fetch in event handlers or server routes,useFetch in click handlers,"async function submit() { await $fetch('/api/submit', { method: 'POST' }) }","async function submit() { await useFetch('/api/submit') }",High,https://nuxt.com/docs/api/utils/dollarfetch
12,DataFetching,Use lazy option for non-blocking fetch,Defer data fetching for better initial load,lazy: true for below-fold content,Blocking fetch for non-critical data,"useFetch('/api/comments', { lazy: true })",await useFetch('/api/comments') for footer,Medium,https://nuxt.com/docs/api/composables/use-fetch
13,DataFetching,Use server option to control fetch location,Choose where data is fetched,server: false for client-only data,Server fetch for user-specific client data,"useFetch('/api/user-preferences', { server: false })",useFetch for localStorage-dependent data,Medium,https://nuxt.com/docs/api/composables/use-fetch
14,DataFetching,Use pick to reduce payload size,Select only needed fields from response,pick option for large responses,Fetching entire objects when few fields needed,"useFetch('/api/user', { pick: ['id', 'name'] })",useFetch('/api/user') then destructure,Low,https://nuxt.com/docs/api/composables/use-fetch
15,DataFetching,Use transform for data manipulation,Transform data before storing in state,transform option for data shaping,Manual transformation after fetch,"useFetch('/api/posts', { transform: (posts) => posts.map(p => p.title) })",const titles = data.value.map(p => p.title),Low,https://nuxt.com/docs/api/composables/use-fetch
16,DataFetching,Handle loading and error states,Always handle pending and error states,Check status pending error refs,Ignoring loading states,"<div v-if=""status === 'pending'"">Loading...</div>",No loading indicator,High,https://nuxt.com/docs/getting-started/data-fetching
17,Lifecycle,Avoid side effects in script setup root,Move side effects to lifecycle hooks,Side effects in onMounted,setInterval in root script setup,"onMounted(() => { interval = setInterval(...) })","<script setup>setInterval(...)</script>",High,https://nuxt.com/docs/guide/concepts/nuxt-lifecycle
18,Lifecycle,Use onMounted for DOM access,Access DOM only after component is mounted,onMounted for DOM manipulation,Direct DOM access in setup,"onMounted(() => { document.getElementById('el') })","<script setup>document.getElementById('el')</script>",High,https://nuxt.com/docs/api/composables/on-mounted
19,Lifecycle,Use nextTick for post-render access,Wait for DOM updates before accessing elements,await nextTick() after state changes,Immediate DOM access after state change,"count.value++; await nextTick(); el.value.focus()","count.value++; el.value.focus()",Medium,https://nuxt.com/docs/api/utils/next-tick
20,Lifecycle,Use onPrehydrate for pre-hydration logic,Run code before Nuxt hydrates the page,onPrehydrate for client setup,onMounted for hydration-critical code,"onPrehydrate(() => { console.log(window) })",onMounted for pre-hydration needs,Low,https://nuxt.com/docs/api/composables/on-prehydrate
21,Server,Use server/api for API routes,Create API endpoints in server/api directory,server/api/users.ts for /api/users,Manual Express setup,server/api/hello.ts -> /api/hello,app.get('/api/hello'),High,https://nuxt.com/docs/guide/directory-structure/server
22,Server,Use defineEventHandler for handlers,Define server route handlers,defineEventHandler for all handlers,export default function,"export default defineEventHandler((event) => { return { hello: 'world' } })","export default function(req, res) {}",High,https://nuxt.com/docs/guide/directory-structure/server
23,Server,Use server/routes for non-api routes,Routes without /api prefix,server/routes for custom paths,server/api for non-api routes,server/routes/sitemap.xml.ts,server/api/sitemap.xml.ts,Medium,https://nuxt.com/docs/guide/directory-structure/server
24,Server,Use getQuery and readBody for input,Access query params and request body,getQuery(event) readBody(event),Direct event access,"const { id } = getQuery(event)",event.node.req.query,Medium,https://nuxt.com/docs/guide/directory-structure/server
25,Server,Validate server input,Always validate input in server handlers,Zod or similar for validation,Trust client input,"const body = await readBody(event); schema.parse(body)",const body = await readBody(event),High,https://nuxt.com/docs/guide/directory-structure/server
26,State,Use useState for shared reactive state,SSR-friendly shared state across components,useState for cross-component state,ref for shared state,"const count = useState('count', () => 0)",const count = ref(0) in composable,High,https://nuxt.com/docs/api/composables/use-state
27,State,Use unique keys for useState,Prevent state conflicts with unique keys,Descriptive unique keys for each state,Generic or duplicate keys,"useState('user-preferences', () => ({}))",useState('data') in multiple places,Medium,https://nuxt.com/docs/api/composables/use-state
28,State,Use Pinia for complex state,Pinia for advanced state management,@pinia/nuxt for complex apps,Custom state management,useMainStore() with Pinia,Custom reactive store implementation,Medium,https://nuxt.com/docs/getting-started/state-management
29,State,Use callOnce for one-time async operations,Ensure async operations run only once,callOnce for store initialization,Direct await in component,"await callOnce(store.fetch)",await store.fetch() on every render,Medium,https://nuxt.com/docs/api/utils/call-once
30,SEO,Use useSeoMeta for SEO tags,Type-safe SEO meta tag management,useSeoMeta for meta tags,useHead for simple meta,"useSeoMeta({ title: 'Home', ogTitle: 'Home', description: '...' })","useHead({ meta: [{ name: 'description', content: '...' }] })",High,https://nuxt.com/docs/api/composables/use-seo-meta
31,SEO,Use reactive values in useSeoMeta,Dynamic SEO tags with refs or getters,Computed getters for dynamic values,Static values for dynamic content,"useSeoMeta({ title: () => post.value.title })","useSeoMeta({ title: post.value.title })",Medium,https://nuxt.com/docs/api/composables/use-seo-meta
32,SEO,Use useHead for non-meta head elements,Scripts styles links in head,useHead for scripts and links,useSeoMeta for scripts,"useHead({ script: [{ src: '/analytics.js' }] })","useSeoMeta({ script: '...' })",Medium,https://nuxt.com/docs/api/composables/use-head
33,SEO,Include OpenGraph tags,Add OG tags for social sharing,ogTitle ogDescription ogImage,Missing social preview,"useSeoMeta({ ogImage: '/og.png', twitterCard: 'summary_large_image' })",No OG configuration,Medium,https://nuxt.com/docs/api/composables/use-seo-meta
34,Middleware,Use defineNuxtRouteMiddleware,Define route middleware properly,defineNuxtRouteMiddleware wrapper,export default function,"export default defineNuxtRouteMiddleware((to, from) => {})","export default function(to, from) {}",High,https://nuxt.com/docs/guide/directory-structure/middleware
35,Middleware,Use navigateTo for redirects,Redirect in middleware with navigateTo,return navigateTo('/login'),router.push in middleware,"if (!auth) return navigateTo('/login')","if (!auth) router.push('/login')",High,https://nuxt.com/docs/api/utils/navigate-to
36,Middleware,Reference middleware in definePageMeta,Apply middleware to specific pages,middleware array in definePageMeta,Global middleware for page-specific,definePageMeta({ middleware: ['auth'] }),Global auth check for one page,Medium,https://nuxt.com/docs/guide/directory-structure/middleware
37,Middleware,Use .global suffix for global middleware,Apply middleware to all routes,auth.global.ts for app-wide auth,Manual middleware on every page,middleware/auth.global.ts,middleware: ['auth'] on every page,Medium,https://nuxt.com/docs/guide/directory-structure/middleware
38,ErrorHandling,Use createError for errors,Create errors with proper status codes,createError with statusCode,throw new Error,"throw createError({ statusCode: 404, statusMessage: 'Not Found' })",throw new Error('Not Found'),High,https://nuxt.com/docs/api/utils/create-error
39,ErrorHandling,Use NuxtErrorBoundary for local errors,Handle errors within component subtree,NuxtErrorBoundary for component errors,Global error page for local errors,"<NuxtErrorBoundary @error=""log""><template #error=""{ error }"">",error.vue for component errors,Medium,https://nuxt.com/docs/getting-started/error-handling
40,ErrorHandling,Use clearError to recover from errors,Clear error state and optionally redirect,clearError({ redirect: '/' }),Manual error state reset,clearError({ redirect: '/home' }),error.value = null,Medium,https://nuxt.com/docs/api/utils/clear-error
41,ErrorHandling,Use short statusMessage,Keep statusMessage brief for security,Short generic messages,Detailed error info in statusMessage,"createError({ statusCode: 400, statusMessage: 'Bad Request' })","createError({ statusMessage: 'Invalid user ID: 123' })",High,https://nuxt.com/docs/getting-started/error-handling
42,Link,Use NuxtLink for internal navigation,Client-side navigation with prefetching,<NuxtLink to> for internal links,<a href> for internal links,<NuxtLink to="/about">About</NuxtLink>,<a href="/about">About</a>,High,https://nuxt.com/docs/api/components/nuxt-link
43,Link,Configure prefetch behavior,Control when prefetching occurs,prefetchOn for interaction-based,Default prefetch for low-priority,"<NuxtLink prefetch-on=""interaction"">",Always default prefetch,Low,https://nuxt.com/docs/api/components/nuxt-link
44,Link,Use useRouter for programmatic navigation,Navigate programmatically,useRouter().push() for navigation,Direct window.location,"const router = useRouter(); router.push('/dashboard')",window.location.href = '/dashboard',Medium,https://nuxt.com/docs/api/composables/use-router
45,Link,Use navigateTo in composables,Navigate outside components,navigateTo() in middleware or plugins,useRouter in non-component code,return navigateTo('/login'),router.push in middleware,Medium,https://nuxt.com/docs/api/utils/navigate-to
46,AutoImports,Leverage auto-imports,Use auto-imported composables directly,Direct use of ref computed useFetch,Manual imports for Nuxt composables,"const count = ref(0)","import { ref } from 'vue'; const count = ref(0)",Medium,https://nuxt.com/docs/guide/concepts/auto-imports
47,AutoImports,Use #imports for explicit imports,Explicit imports when needed,#imports for clarity or disabled auto-imports,"import from 'vue' when auto-import enabled","import { ref } from '#imports'","import { ref } from 'vue'",Low,https://nuxt.com/docs/guide/concepts/auto-imports
48,AutoImports,Configure third-party auto-imports,Add external package auto-imports,imports.presets in nuxt.config,Manual imports everywhere,"imports: { presets: [{ from: 'vue-i18n', imports: ['useI18n'] }] }",import { useI18n } everywhere,Low,https://nuxt.com/docs/guide/concepts/auto-imports
49,Plugins,Use defineNuxtPlugin,Define plugins properly,defineNuxtPlugin wrapper,export default function,"export default defineNuxtPlugin((nuxtApp) => {})","export default function(ctx) {}",High,https://nuxt.com/docs/guide/directory-structure/plugins
50,Plugins,Use provide for injection,Provide helpers across app,return { provide: {} } for type safety,nuxtApp.provide without types,"return { provide: { hello: (name) => `Hello ${name}!` } }","nuxtApp.provide('hello', fn)",Medium,https://nuxt.com/docs/guide/directory-structure/plugins
51,Plugins,Use .client or .server suffix,Control plugin execution environment,plugin.client.ts for client-only,if (process.client) checks,analytics.client.ts,"if (process.client) { // analytics }",Medium,https://nuxt.com/docs/guide/directory-structure/plugins
52,Environment,Use runtimeConfig for env vars,Access environment variables safely,runtimeConfig in nuxt.config,process.env directly,"runtimeConfig: { apiSecret: '', public: { apiBase: '' } }",process.env.API_SECRET in components,High,https://nuxt.com/docs/guide/going-further/runtime-config
53,Environment,Use NUXT_ prefix for env override,Override config with environment variables,NUXT_API_SECRET NUXT_PUBLIC_API_BASE,Custom env var names,NUXT_PUBLIC_API_BASE=https://api.example.com,API_BASE=https://api.example.com,High,https://nuxt.com/docs/guide/going-further/runtime-config
54,Environment,Access public config with useRuntimeConfig,Get public config in components,useRuntimeConfig().public,Direct process.env access,const config = useRuntimeConfig(); config.public.apiBase,process.env.NUXT_PUBLIC_API_BASE,High,https://nuxt.com/docs/api/composables/use-runtime-config
55,Environment,Keep secrets in private config,Server-only secrets in runtimeConfig root,runtimeConfig.apiSecret (server only),Secrets in public config,runtimeConfig: { dbPassword: '' },runtimeConfig: { public: { dbPassword: '' } },High,https://nuxt.com/docs/guide/going-further/runtime-config
56,Performance,Use Lazy prefix for code splitting,Lazy load components with Lazy prefix,<LazyComponent> for below-fold,Eager load all components,<LazyMountainsList v-if="show"/>,<MountainsList/> for hidden content,Medium,https://nuxt.com/docs/guide/directory-structure/components
57,Performance,Use useLazyFetch for non-blocking data,Alias for useFetch with lazy: true,useLazyFetch for secondary data,useFetch for all requests,"const { data } = useLazyFetch('/api/comments')",await useFetch for comments section,Medium,https://nuxt.com/docs/api/composables/use-lazy-fetch
58,Performance,Use lazy hydration for interactivity,Delay component hydration until needed,LazyComponent with hydration strategy,Immediate hydration for all,<LazyModal hydrate-on-visible/>,<Modal/> in footer,Low,https://nuxt.com/docs/guide/going-further/experimental-features
Can't render this file because it contains an unexpected character in line 8 and column 193.

View File

@@ -1,52 +0,0 @@
No,Category,Guideline,Description,Do,Don't,Code Good,Code Bad,Severity,Docs URL
1,Components,Use functional components,Hooks-based components are standard,Functional components with hooks,Class components,const App = () => { },class App extends Component,Medium,https://reactnative.dev/docs/intro-react
2,Components,Keep components small,Single responsibility principle,Split into smaller components,Large monolithic components,<Header /><Content /><Footer />,500+ line component,Medium,
3,Components,Use TypeScript,Type safety for props and state,TypeScript for new projects,JavaScript without types,const Button: FC<Props> = () => { },const Button = (props) => { },Medium,
4,Components,Colocate component files,Keep related files together,Component folder with styles,Flat structure,components/Button/index.tsx styles.ts,components/Button.tsx styles/button.ts,Low,
5,Styling,Use StyleSheet.create,Optimized style objects,StyleSheet for all styles,Inline style objects,StyleSheet.create({ container: {} }),style={{ margin: 10 }},High,https://reactnative.dev/docs/stylesheet
6,Styling,Avoid inline styles,Prevent object recreation,Styles in StyleSheet,Inline style objects in render,style={styles.container},"style={{ margin: 10, padding: 5 }}",Medium,
7,Styling,Use flexbox for layout,React Native uses flexbox,flexDirection alignItems justifyContent,Absolute positioning everywhere,flexDirection: 'row',position: 'absolute' everywhere,Medium,https://reactnative.dev/docs/flexbox
8,Styling,Handle platform differences,Platform-specific styles,Platform.select or .ios/.android files,Same styles for both platforms,"Platform.select({ ios: {}, android: {} })",Hardcoded iOS values,Medium,https://reactnative.dev/docs/platform-specific-code
9,Styling,Use responsive dimensions,Scale for different screens,Dimensions or useWindowDimensions,Fixed pixel values,useWindowDimensions(),width: 375,Medium,
10,Navigation,Use React Navigation,Standard navigation library,React Navigation for routing,Manual navigation management,createStackNavigator(),Custom navigation state,Medium,https://reactnavigation.org/
11,Navigation,Type navigation params,Type-safe navigation,Typed navigation props,Untyped navigation,"navigation.navigate<RootStackParamList>('Home', { id })","navigation.navigate('Home', { id })",Medium,
12,Navigation,Use deep linking,Support URL-based navigation,Configure linking prop,No deep link support,linking: { prefixes: [] },No linking configuration,Medium,https://reactnavigation.org/docs/deep-linking/
13,Navigation,Handle back button,Android back button handling,useFocusEffect with BackHandler,Ignore back button,BackHandler.addEventListener,No back handler,High,
14,State,Use useState for local state,Simple component state,useState for UI state,Class component state,"const [count, setCount] = useState(0)",this.state = { count: 0 },Medium,
15,State,Use useReducer for complex state,Complex state logic,useReducer for related state,Multiple useState for related values,useReducer(reducer initialState),5+ useState calls,Medium,
16,State,Use context sparingly,Context for global state,Context for theme auth locale,Context for frequently changing data,ThemeContext for app theme,Context for list item data,Medium,
17,State,Consider Zustand or Redux,External state management,Zustand for simple Redux for complex,useState for global state,create((set) => ({ })),Prop drilling global state,Medium,
18,Lists,Use FlatList for long lists,Virtualized list rendering,FlatList for 50+ items,ScrollView with map,<FlatList data={items} />,<ScrollView>{items.map()}</ScrollView>,High,https://reactnative.dev/docs/flatlist
19,Lists,Provide keyExtractor,Unique keys for list items,keyExtractor with stable ID,Index as key,keyExtractor={(item) => item.id},"keyExtractor={(_, index) => index}",High,
20,Lists,Optimize renderItem,Memoize list item components,React.memo for list items,Inline render function,renderItem={({ item }) => <MemoizedItem item={item} />},renderItem={({ item }) => <View>...</View>},High,
21,Lists,Use getItemLayout for fixed height,Skip measurement for performance,getItemLayout when height known,Dynamic measurement for fixed items,"getItemLayout={(_, index) => ({ length: 50, offset: 50 * index, index })}",No getItemLayout for fixed height,Medium,
22,Lists,Implement windowSize,Control render window,Smaller windowSize for memory,Default windowSize for large lists,windowSize={5},windowSize={21} for huge lists,Medium,
23,Performance,Use React.memo,Prevent unnecessary re-renders,memo for pure components,No memoization,export default memo(MyComponent),export default MyComponent,Medium,
24,Performance,Use useCallback for handlers,Stable function references,useCallback for props,New function on every render,"useCallback(() => {}, [deps])",() => handlePress(),Medium,
25,Performance,Use useMemo for expensive ops,Cache expensive calculations,useMemo for heavy computations,Recalculate every render,"useMemo(() => expensive(), [deps])",const result = expensive(),Medium,
26,Performance,Avoid anonymous functions in JSX,Prevent re-renders,Named handlers or useCallback,Inline arrow functions,onPress={handlePress},onPress={() => doSomething()},Medium,
27,Performance,Use Hermes engine,Improved startup and memory,Enable Hermes in build,JavaScriptCore for new projects,hermes_enabled: true,hermes_enabled: false,Medium,https://reactnative.dev/docs/hermes
28,Images,Use expo-image,Modern performant image component for React Native,"Use expo-image for caching, blurring, and performance",Use default Image for heavy lists or unmaintained libraries,<Image source={url} cachePolicy='memory-disk' /> (expo-image),<FastImage source={url} />,Medium,https://docs.expo.dev/versions/latest/sdk/image/
29,Images,Specify image dimensions,Prevent layout shifts,width and height for remote images,No dimensions for network images,<Image style={{ width: 100 height: 100 }} />,<Image source={{ uri }} /> no size,High,
30,Images,Use resizeMode,Control image scaling,resizeMode cover contain,Stretch images,"resizeMode=""cover""",No resizeMode,Low,
31,Forms,Use controlled inputs,State-controlled form fields,value + onChangeText,Uncontrolled inputs,<TextInput value={text} onChangeText={setText} />,<TextInput defaultValue={text} />,Medium,
32,Forms,Handle keyboard,Manage keyboard visibility,KeyboardAvoidingView,Content hidden by keyboard,"<KeyboardAvoidingView behavior=""padding"">",No keyboard handling,High,https://reactnative.dev/docs/keyboardavoidingview
33,Forms,Use proper keyboard types,Appropriate keyboard for input,keyboardType for input type,Default keyboard for all,"keyboardType=""email-address""","keyboardType=""default"" for email",Low,
34,Touch,Use Pressable,Modern touch handling,Pressable for touch interactions,TouchableOpacity for new code,<Pressable onPress={} />,<TouchableOpacity onPress={} />,Low,https://reactnative.dev/docs/pressable
35,Touch,Provide touch feedback,Visual feedback on press,Ripple or opacity change,No feedback on press,android_ripple={{ color: 'gray' }},No press feedback,Medium,
36,Touch,Set hitSlop for small targets,Increase touch area,hitSlop for icons and small buttons,Tiny touch targets,hitSlop={{ top: 10 bottom: 10 }},44x44 with no hitSlop,Medium,
37,Animation,Use Reanimated,High-performance animations,react-native-reanimated,Animated API for complex,useSharedValue useAnimatedStyle,Animated.timing for gesture,Medium,https://docs.swmansion.com/react-native-reanimated/
38,Animation,Run on UI thread,worklets for smooth animation,Run animations on UI thread,JS thread animations,runOnUI(() => {}),Animated on JS thread,High,
39,Animation,Use gesture handler,Native gesture recognition,react-native-gesture-handler,JS-based gesture handling,<GestureDetector>,<View onTouchMove={} />,Medium,https://docs.swmansion.com/react-native-gesture-handler/
40,Async,Handle loading states,Show loading indicators,ActivityIndicator during load,Empty screen during load,{isLoading ? <ActivityIndicator /> : <Content />},No loading state,Medium,
41,Async,Handle errors gracefully,Error boundaries and fallbacks,Error UI for failed requests,Crash on error,{error ? <ErrorView /> : <Content />},No error handling,High,
42,Async,Cancel async operations,Cleanup on unmount,AbortController or cleanup,Memory leaks from async,useEffect cleanup,No cleanup for subscriptions,High,
43,Accessibility,Add accessibility labels,Describe UI elements,accessibilityLabel for all interactive,Missing labels,"accessibilityLabel=""Submit form""",<Pressable> without label,High,https://reactnative.dev/docs/accessibility
44,Accessibility,Use accessibility roles,Semantic meaning,accessibilityRole for elements,Wrong roles,"accessibilityRole=""button""",No role for button,Medium,
45,Accessibility,Support screen readers,Test with TalkBack/VoiceOver,Test with screen readers,Skip accessibility testing,Regular TalkBack testing,No screen reader testing,High,
46,Testing,Use React Native Testing Library,Component testing,render and fireEvent,Enzyme or manual testing,render(<Component />),shallow(<Component />),Medium,https://callstack.github.io/react-native-testing-library/
47,Testing,Test on real devices,Real device behavior,Test on iOS and Android devices,Simulator only,Device testing in CI,Simulator only testing,High,
48,Testing,Use Detox for E2E,End-to-end testing,Detox for critical flows,Manual E2E testing,detox test,Manual testing only,Medium,https://wix.github.io/Detox/
49,Native,Use native modules carefully,Bridge has overhead,Batch native calls,Frequent bridge crossing,Batch updates,Call native on every keystroke,High,
50,Native,Use Expo when possible,Simplified development,Expo for standard features,Bare RN for simple apps,expo install package,react-native link package,Low,https://docs.expo.dev/
51,Native,Handle permissions,Request permissions properly,Check and request permissions,Assume permissions granted,PermissionsAndroid.request(),Access without permission check,High,https://reactnative.dev/docs/permissionsandroid
1 No Category Guideline Description Do Don't Code Good Code Bad Severity Docs URL
2 1 Components Use functional components Hooks-based components are standard Functional components with hooks Class components const App = () => { } class App extends Component Medium https://reactnative.dev/docs/intro-react
3 2 Components Keep components small Single responsibility principle Split into smaller components Large monolithic components <Header /><Content /><Footer /> 500+ line component Medium
4 3 Components Use TypeScript Type safety for props and state TypeScript for new projects JavaScript without types const Button: FC<Props> = () => { } const Button = (props) => { } Medium
5 4 Components Colocate component files Keep related files together Component folder with styles Flat structure components/Button/index.tsx styles.ts components/Button.tsx styles/button.ts Low
6 5 Styling Use StyleSheet.create Optimized style objects StyleSheet for all styles Inline style objects StyleSheet.create({ container: {} }) style={{ margin: 10 }} High https://reactnative.dev/docs/stylesheet
7 6 Styling Avoid inline styles Prevent object recreation Styles in StyleSheet Inline style objects in render style={styles.container} style={{ margin: 10, padding: 5 }} Medium
8 7 Styling Use flexbox for layout React Native uses flexbox flexDirection alignItems justifyContent Absolute positioning everywhere flexDirection: 'row' position: 'absolute' everywhere Medium https://reactnative.dev/docs/flexbox
9 8 Styling Handle platform differences Platform-specific styles Platform.select or .ios/.android files Same styles for both platforms Platform.select({ ios: {}, android: {} }) Hardcoded iOS values Medium https://reactnative.dev/docs/platform-specific-code
10 9 Styling Use responsive dimensions Scale for different screens Dimensions or useWindowDimensions Fixed pixel values useWindowDimensions() width: 375 Medium
11 10 Navigation Use React Navigation Standard navigation library React Navigation for routing Manual navigation management createStackNavigator() Custom navigation state Medium https://reactnavigation.org/
12 11 Navigation Type navigation params Type-safe navigation Typed navigation props Untyped navigation navigation.navigate<RootStackParamList>('Home', { id }) navigation.navigate('Home', { id }) Medium
13 12 Navigation Use deep linking Support URL-based navigation Configure linking prop No deep link support linking: { prefixes: [] } No linking configuration Medium https://reactnavigation.org/docs/deep-linking/
14 13 Navigation Handle back button Android back button handling useFocusEffect with BackHandler Ignore back button BackHandler.addEventListener No back handler High
15 14 State Use useState for local state Simple component state useState for UI state Class component state const [count, setCount] = useState(0) this.state = { count: 0 } Medium
16 15 State Use useReducer for complex state Complex state logic useReducer for related state Multiple useState for related values useReducer(reducer initialState) 5+ useState calls Medium
17 16 State Use context sparingly Context for global state Context for theme auth locale Context for frequently changing data ThemeContext for app theme Context for list item data Medium
18 17 State Consider Zustand or Redux External state management Zustand for simple Redux for complex useState for global state create((set) => ({ })) Prop drilling global state Medium
19 18 Lists Use FlatList for long lists Virtualized list rendering FlatList for 50+ items ScrollView with map <FlatList data={items} /> <ScrollView>{items.map()}</ScrollView> High https://reactnative.dev/docs/flatlist
20 19 Lists Provide keyExtractor Unique keys for list items keyExtractor with stable ID Index as key keyExtractor={(item) => item.id} keyExtractor={(_, index) => index} High
21 20 Lists Optimize renderItem Memoize list item components React.memo for list items Inline render function renderItem={({ item }) => <MemoizedItem item={item} />} renderItem={({ item }) => <View>...</View>} High
22 21 Lists Use getItemLayout for fixed height Skip measurement for performance getItemLayout when height known Dynamic measurement for fixed items getItemLayout={(_, index) => ({ length: 50, offset: 50 * index, index })} No getItemLayout for fixed height Medium
23 22 Lists Implement windowSize Control render window Smaller windowSize for memory Default windowSize for large lists windowSize={5} windowSize={21} for huge lists Medium
24 23 Performance Use React.memo Prevent unnecessary re-renders memo for pure components No memoization export default memo(MyComponent) export default MyComponent Medium
25 24 Performance Use useCallback for handlers Stable function references useCallback for props New function on every render useCallback(() => {}, [deps]) () => handlePress() Medium
26 25 Performance Use useMemo for expensive ops Cache expensive calculations useMemo for heavy computations Recalculate every render useMemo(() => expensive(), [deps]) const result = expensive() Medium
27 26 Performance Avoid anonymous functions in JSX Prevent re-renders Named handlers or useCallback Inline arrow functions onPress={handlePress} onPress={() => doSomething()} Medium
28 27 Performance Use Hermes engine Improved startup and memory Enable Hermes in build JavaScriptCore for new projects hermes_enabled: true hermes_enabled: false Medium https://reactnative.dev/docs/hermes
29 28 Images Use expo-image Modern performant image component for React Native Use expo-image for caching, blurring, and performance Use default Image for heavy lists or unmaintained libraries <Image source={url} cachePolicy='memory-disk' /> (expo-image) <FastImage source={url} /> Medium https://docs.expo.dev/versions/latest/sdk/image/
30 29 Images Specify image dimensions Prevent layout shifts width and height for remote images No dimensions for network images <Image style={{ width: 100 height: 100 }} /> <Image source={{ uri }} /> no size High
31 30 Images Use resizeMode Control image scaling resizeMode cover contain Stretch images resizeMode="cover" No resizeMode Low
32 31 Forms Use controlled inputs State-controlled form fields value + onChangeText Uncontrolled inputs <TextInput value={text} onChangeText={setText} /> <TextInput defaultValue={text} /> Medium
33 32 Forms Handle keyboard Manage keyboard visibility KeyboardAvoidingView Content hidden by keyboard <KeyboardAvoidingView behavior="padding"> No keyboard handling High https://reactnative.dev/docs/keyboardavoidingview
34 33 Forms Use proper keyboard types Appropriate keyboard for input keyboardType for input type Default keyboard for all keyboardType="email-address" keyboardType="default" for email Low
35 34 Touch Use Pressable Modern touch handling Pressable for touch interactions TouchableOpacity for new code <Pressable onPress={} /> <TouchableOpacity onPress={} /> Low https://reactnative.dev/docs/pressable
36 35 Touch Provide touch feedback Visual feedback on press Ripple or opacity change No feedback on press android_ripple={{ color: 'gray' }} No press feedback Medium
37 36 Touch Set hitSlop for small targets Increase touch area hitSlop for icons and small buttons Tiny touch targets hitSlop={{ top: 10 bottom: 10 }} 44x44 with no hitSlop Medium
38 37 Animation Use Reanimated High-performance animations react-native-reanimated Animated API for complex useSharedValue useAnimatedStyle Animated.timing for gesture Medium https://docs.swmansion.com/react-native-reanimated/
39 38 Animation Run on UI thread worklets for smooth animation Run animations on UI thread JS thread animations runOnUI(() => {}) Animated on JS thread High
40 39 Animation Use gesture handler Native gesture recognition react-native-gesture-handler JS-based gesture handling <GestureDetector> <View onTouchMove={} /> Medium https://docs.swmansion.com/react-native-gesture-handler/
41 40 Async Handle loading states Show loading indicators ActivityIndicator during load Empty screen during load {isLoading ? <ActivityIndicator /> : <Content />} No loading state Medium
42 41 Async Handle errors gracefully Error boundaries and fallbacks Error UI for failed requests Crash on error {error ? <ErrorView /> : <Content />} No error handling High
43 42 Async Cancel async operations Cleanup on unmount AbortController or cleanup Memory leaks from async useEffect cleanup No cleanup for subscriptions High
44 43 Accessibility Add accessibility labels Describe UI elements accessibilityLabel for all interactive Missing labels accessibilityLabel="Submit form" <Pressable> without label High https://reactnative.dev/docs/accessibility
45 44 Accessibility Use accessibility roles Semantic meaning accessibilityRole for elements Wrong roles accessibilityRole="button" No role for button Medium
46 45 Accessibility Support screen readers Test with TalkBack/VoiceOver Test with screen readers Skip accessibility testing Regular TalkBack testing No screen reader testing High
47 46 Testing Use React Native Testing Library Component testing render and fireEvent Enzyme or manual testing render(<Component />) shallow(<Component />) Medium https://callstack.github.io/react-native-testing-library/
48 47 Testing Test on real devices Real device behavior Test on iOS and Android devices Simulator only Device testing in CI Simulator only testing High
49 48 Testing Use Detox for E2E End-to-end testing Detox for critical flows Manual E2E testing detox test Manual testing only Medium https://wix.github.io/Detox/
50 49 Native Use native modules carefully Bridge has overhead Batch native calls Frequent bridge crossing Batch updates Call native on every keystroke High
51 50 Native Use Expo when possible Simplified development Expo for standard features Bare RN for simple apps expo install package react-native link package Low https://docs.expo.dev/
52 51 Native Handle permissions Request permissions properly Check and request permissions Assume permissions granted PermissionsAndroid.request() Access without permission check High https://reactnative.dev/docs/permissionsandroid

View File

@@ -1,54 +0,0 @@
No,Category,Guideline,Description,Do,Don't,Code Good,Code Bad,Severity,Docs URL
1,Reactivity,Use $: for reactive statements,Automatic dependency tracking,$: for derived values,Manual recalculation,$: doubled = count * 2,let doubled; count && (doubled = count * 2),Medium,https://svelte.dev/docs/svelte-components#script-3-$-marks-a-statement-as-reactive
2,Reactivity,Trigger reactivity with assignment,Svelte tracks assignments not mutations,Reassign arrays/objects to trigger update,Mutate without reassignment,"items = [...items, newItem]",items.push(newItem),High,https://svelte.dev/docs/svelte-components#script-2-assignments-are-reactive
3,Reactivity,Use $state in Svelte 5,Runes for explicit reactivity,let count = $state(0),Implicit reactivity in Svelte 5,let count = $state(0),let count = 0 (Svelte 5),Medium,https://svelte.dev/blog/runes
4,Reactivity,Use $derived for computed values,$derived replaces $: in Svelte 5,let doubled = $derived(count * 2),$: in Svelte 5,let doubled = $derived(count * 2),$: doubled = count * 2 (Svelte 5),Medium,
5,Reactivity,Use $effect for side effects,$effect replaces $: side effects,Use $effect for subscriptions,$: for side effects in Svelte 5,$effect(() => console.log(count)),$: console.log(count) (Svelte 5),Medium,
6,Props,Export let for props,Declare props with export let,export let propName,Props without export,export let count = 0,let count = 0,High,https://svelte.dev/docs/svelte-components#script-1-export-creates-a-component-prop
7,Props,Use $props in Svelte 5,$props rune for prop access,let { name } = $props(),export let in Svelte 5,"let { name, age = 0 } = $props()",export let name; export let age = 0,Medium,
8,Props,Provide default values,Default props with assignment,export let count = 0,Required props without defaults,export let count = 0,export let count,Low,
9,Props,Use spread props,Pass through unknown props,{...$$restProps} on elements,Manual prop forwarding,<button {...$$restProps}>,<button class={$$props.class}>,Low,https://svelte.dev/docs/basic-markup#attributes-and-props
10,Bindings,Use bind: for two-way binding,Simplified input handling,bind:value for inputs,on:input with manual update,<input bind:value={name}>,<input value={name} on:input={e => name = e.target.value}>,Low,https://svelte.dev/docs/element-directives#bind-property
11,Bindings,Bind to DOM elements,Reference DOM nodes,bind:this for element reference,querySelector in onMount,<div bind:this={el}>,onMount(() => el = document.querySelector()),Medium,
12,Bindings,Use bind:group for radios/checkboxes,Simplified group handling,bind:group for radio/checkbox groups,Manual checked handling,"<input type=""radio"" bind:group={selected}>","<input type=""radio"" checked={selected === value}>",Low,
13,Events,Use on: for event handlers,Event directive syntax,on:click={handler},addEventListener in onMount,<button on:click={handleClick}>,onMount(() => btn.addEventListener()),Medium,https://svelte.dev/docs/element-directives#on-eventname
14,Events,Forward events with on:event,Pass events to parent,on:click without handler,createEventDispatcher for DOM events,<button on:click>,"dispatch('click', event)",Low,
15,Events,Use createEventDispatcher,Custom component events,dispatch for custom events,on:event for custom events,"dispatch('save', { data })",on:save without dispatch,Medium,https://svelte.dev/docs/svelte#createeventdispatcher
16,Lifecycle,Use onMount for initialization,Run code after component mounts,onMount for setup and data fetching,Code in script body for side effects,onMount(() => fetchData()),fetchData() in script body,High,https://svelte.dev/docs/svelte#onmount
17,Lifecycle,Return cleanup from onMount,Automatic cleanup on destroy,Return function from onMount,Separate onDestroy for paired cleanup,onMount(() => { sub(); return unsub }),onMount(sub); onDestroy(unsub),Medium,
18,Lifecycle,Use onDestroy sparingly,Only when onMount cleanup not possible,onDestroy for non-mount cleanup,onDestroy for mount-related cleanup,onDestroy for store unsubscribe,onDestroy(() => clearInterval(id)),Low,
19,Lifecycle,Avoid beforeUpdate/afterUpdate,Usually not needed,Reactive statements instead,beforeUpdate for derived state,$: if (x) doSomething(),beforeUpdate(() => doSomething()),Low,
20,Stores,Use writable for mutable state,Basic reactive store,writable for shared mutable state,Local variables for shared state,const count = writable(0),let count = 0 in module,Medium,https://svelte.dev/docs/svelte-store#writable
21,Stores,Use readable for read-only state,External data sources,readable for derived/external data,writable for read-only data,"readable(0, set => interval(set))",writable(0) for timer,Low,https://svelte.dev/docs/svelte-store#readable
22,Stores,Use derived for computed stores,Combine or transform stores,derived for computed values,Manual subscription for derived,"derived(count, $c => $c * 2)",count.subscribe(c => doubled = c * 2),Medium,https://svelte.dev/docs/svelte-store#derived
23,Stores,Use $ prefix for auto-subscription,Automatic subscribe/unsubscribe,$storeName in components,Manual subscription,{$count},count.subscribe(c => value = c),High,
24,Stores,Clean up custom subscriptions,Unsubscribe when component destroys,Return unsubscribe from onMount,Leave subscriptions open,onMount(() => store.subscribe(fn)),store.subscribe(fn) in script,High,
25,Slots,Use slots for composition,Content projection,<slot> for flexible content,Props for all content,<slot>Default</slot>,"<Component content=""text""/>",Medium,https://svelte.dev/docs/special-elements#slot
26,Slots,Name slots for multiple areas,Multiple content areas,"<slot name=""header"">",Single slot for complex layouts,"<slot name=""header""><slot name=""footer"">",<slot> with complex conditionals,Low,
27,Slots,Check slot content with $$slots,Conditional slot rendering,$$slots.name for conditional rendering,Always render slot wrapper,"{#if $$slots.footer}<slot name=""footer""/>{/if}","<div><slot name=""footer""/></div>",Low,
28,Styling,Use scoped styles by default,Styles scoped to component,<style> for component styles,Global styles for component,:global() only when needed,<style> all global,Medium,https://svelte.dev/docs/svelte-components#style
29,Styling,Use :global() sparingly,Escape scoping when needed,:global for third-party styling,Global for all styles,:global(.external-lib),<style> without scoping,Medium,
30,Styling,Use CSS variables for theming,Dynamic styling,CSS custom properties,Inline styles for themes,"style=""--color: {color}""","style=""color: {color}""",Low,
31,Transitions,Use built-in transitions,Svelte transition directives,transition:fade for simple effects,Manual CSS transitions,<div transition:fade>,<div class:fade={visible}>,Low,https://svelte.dev/docs/element-directives#transition-fn
32,Transitions,Use in: and out: separately,Different enter/exit animations,in:fly out:fade for asymmetric,Same transition for both,<div in:fly out:fade>,<div transition:fly>,Low,
33,Transitions,Add local modifier,Prevent ancestor trigger,transition:fade|local,Global transitions for lists,<div transition:slide|local>,<div transition:slide>,Medium,
34,Actions,Use actions for DOM behavior,Reusable DOM logic,use:action for DOM enhancements,onMount for each usage,<div use:clickOutside>,onMount(() => setupClickOutside(el)),Medium,https://svelte.dev/docs/element-directives#use-action
35,Actions,Return update and destroy,Lifecycle methods for actions,"Return { update, destroy }",Only initial setup,"return { update(params) {}, destroy() {} }",return destroy only,Medium,
36,Actions,Pass parameters to actions,Configure action behavior,use:action={params},Hardcoded action behavior,<div use:tooltip={options}>,<div use:tooltip>,Low,
37,Logic,Use {#if} for conditionals,Template conditionals,{#if} {:else if} {:else},Ternary in expressions,{#if cond}...{:else}...{/if},{cond ? a : b} for complex,Low,https://svelte.dev/docs/logic-blocks#if
38,Logic,Use {#each} for lists,List rendering,{#each} with key,Map in expression,{#each items as item (item.id)},{items.map(i => `<div>${i}</div>`)},Medium,
39,Logic,Always use keys in {#each},Proper list reconciliation,(item.id) for unique key,Index as key or no key,{#each items as item (item.id)},"{#each items as item, i (i)}",High,
40,Logic,Use {#await} for promises,Handle async states,{#await} for loading/error states,Manual promise handling,{#await promise}...{:then}...{:catch},{#if loading}...{#if error},Medium,https://svelte.dev/docs/logic-blocks#await
41,SvelteKit,Use +page.svelte for routes,File-based routing,+page.svelte for route components,Custom routing setup,routes/about/+page.svelte,routes/About.svelte,Medium,https://kit.svelte.dev/docs/routing
42,SvelteKit,Use +page.js for data loading,Load data before render,load function in +page.js,onMount for data fetching,export function load() {},onMount(() => fetchData()),High,https://kit.svelte.dev/docs/load
43,SvelteKit,Use +page.server.js for server-only,Server-side data loading,+page.server.js for sensitive data,+page.js for API keys,+page.server.js with DB access,+page.js with DB access,High,
44,SvelteKit,Use form actions,Server-side form handling,+page.server.js actions,API routes for forms,export const actions = { default },fetch('/api/submit'),Medium,https://kit.svelte.dev/docs/form-actions
45,SvelteKit,Use $app/stores for app state,$page $navigating $updated,$page for current page data,Manual URL parsing,import { page } from '$app/stores',window.location.pathname,Medium,https://kit.svelte.dev/docs/modules#$app-stores
46,Performance,Use {#key} for forced re-render,Reset component state,{#key id} for fresh instance,Manual destroy/create,{#key item.id}<Component/>{/key},on:change={() => component = null},Low,https://svelte.dev/docs/logic-blocks#key
47,Performance,Avoid unnecessary reactivity,Not everything needs $:,$: only for side effects,$: for simple assignments,$: if (x) console.log(x),$: y = x (when y = x works),Low,
48,Performance,Use immutable compiler option,Skip equality checks,immutable: true for large lists,Default for all components,<svelte:options immutable/>,Default without immutable,Low,
49,TypeScript,"Use lang=""ts"" in script",TypeScript support,"<script lang=""ts"">",JavaScript for typed projects,"<script lang=""ts"">",<script> with JSDoc,Medium,https://svelte.dev/docs/typescript
50,TypeScript,Type props with interface,Explicit prop types,interface $$Props for types,Untyped props,interface $$Props { name: string },export let name,Medium,
51,TypeScript,Type events with createEventDispatcher,Type-safe events,createEventDispatcher<Events>(),Untyped dispatch,createEventDispatcher<{ save: Data }>(),createEventDispatcher(),Medium,
52,Accessibility,Use semantic elements,Proper HTML in templates,button nav main appropriately,div for everything,<button on:click>,<div on:click>,High,
53,Accessibility,Add aria to dynamic content,Accessible state changes,aria-live for updates,Silent dynamic updates,"<div aria-live=""polite"">{message}</div>",<div>{message}</div>,Medium,
1 No Category Guideline Description Do Don't Code Good Code Bad Severity Docs URL
2 1 Reactivity Use $: for reactive statements Automatic dependency tracking $: for derived values Manual recalculation $: doubled = count * 2 let doubled; count && (doubled = count * 2) Medium https://svelte.dev/docs/svelte-components#script-3-$-marks-a-statement-as-reactive
3 2 Reactivity Trigger reactivity with assignment Svelte tracks assignments not mutations Reassign arrays/objects to trigger update Mutate without reassignment items = [...items, newItem] items.push(newItem) High https://svelte.dev/docs/svelte-components#script-2-assignments-are-reactive
4 3 Reactivity Use $state in Svelte 5 Runes for explicit reactivity let count = $state(0) Implicit reactivity in Svelte 5 let count = $state(0) let count = 0 (Svelte 5) Medium https://svelte.dev/blog/runes
5 4 Reactivity Use $derived for computed values $derived replaces $: in Svelte 5 let doubled = $derived(count * 2) $: in Svelte 5 let doubled = $derived(count * 2) $: doubled = count * 2 (Svelte 5) Medium
6 5 Reactivity Use $effect for side effects $effect replaces $: side effects Use $effect for subscriptions $: for side effects in Svelte 5 $effect(() => console.log(count)) $: console.log(count) (Svelte 5) Medium
7 6 Props Export let for props Declare props with export let export let propName Props without export export let count = 0 let count = 0 High https://svelte.dev/docs/svelte-components#script-1-export-creates-a-component-prop
8 7 Props Use $props in Svelte 5 $props rune for prop access let { name } = $props() export let in Svelte 5 let { name, age = 0 } = $props() export let name; export let age = 0 Medium
9 8 Props Provide default values Default props with assignment export let count = 0 Required props without defaults export let count = 0 export let count Low
10 9 Props Use spread props Pass through unknown props {...$$restProps} on elements Manual prop forwarding <button {...$$restProps}> <button class={$$props.class}> Low https://svelte.dev/docs/basic-markup#attributes-and-props
11 10 Bindings Use bind: for two-way binding Simplified input handling bind:value for inputs on:input with manual update <input bind:value={name}> <input value={name} on:input={e => name = e.target.value}> Low https://svelte.dev/docs/element-directives#bind-property
12 11 Bindings Bind to DOM elements Reference DOM nodes bind:this for element reference querySelector in onMount <div bind:this={el}> onMount(() => el = document.querySelector()) Medium
13 12 Bindings Use bind:group for radios/checkboxes Simplified group handling bind:group for radio/checkbox groups Manual checked handling <input type="radio" bind:group={selected}> <input type="radio" checked={selected === value}> Low
14 13 Events Use on: for event handlers Event directive syntax on:click={handler} addEventListener in onMount <button on:click={handleClick}> onMount(() => btn.addEventListener()) Medium https://svelte.dev/docs/element-directives#on-eventname
15 14 Events Forward events with on:event Pass events to parent on:click without handler createEventDispatcher for DOM events <button on:click> dispatch('click', event) Low
16 15 Events Use createEventDispatcher Custom component events dispatch for custom events on:event for custom events dispatch('save', { data }) on:save without dispatch Medium https://svelte.dev/docs/svelte#createeventdispatcher
17 16 Lifecycle Use onMount for initialization Run code after component mounts onMount for setup and data fetching Code in script body for side effects onMount(() => fetchData()) fetchData() in script body High https://svelte.dev/docs/svelte#onmount
18 17 Lifecycle Return cleanup from onMount Automatic cleanup on destroy Return function from onMount Separate onDestroy for paired cleanup onMount(() => { sub(); return unsub }) onMount(sub); onDestroy(unsub) Medium
19 18 Lifecycle Use onDestroy sparingly Only when onMount cleanup not possible onDestroy for non-mount cleanup onDestroy for mount-related cleanup onDestroy for store unsubscribe onDestroy(() => clearInterval(id)) Low
20 19 Lifecycle Avoid beforeUpdate/afterUpdate Usually not needed Reactive statements instead beforeUpdate for derived state $: if (x) doSomething() beforeUpdate(() => doSomething()) Low
21 20 Stores Use writable for mutable state Basic reactive store writable for shared mutable state Local variables for shared state const count = writable(0) let count = 0 in module Medium https://svelte.dev/docs/svelte-store#writable
22 21 Stores Use readable for read-only state External data sources readable for derived/external data writable for read-only data readable(0, set => interval(set)) writable(0) for timer Low https://svelte.dev/docs/svelte-store#readable
23 22 Stores Use derived for computed stores Combine or transform stores derived for computed values Manual subscription for derived derived(count, $c => $c * 2) count.subscribe(c => doubled = c * 2) Medium https://svelte.dev/docs/svelte-store#derived
24 23 Stores Use $ prefix for auto-subscription Automatic subscribe/unsubscribe $storeName in components Manual subscription {$count} count.subscribe(c => value = c) High
25 24 Stores Clean up custom subscriptions Unsubscribe when component destroys Return unsubscribe from onMount Leave subscriptions open onMount(() => store.subscribe(fn)) store.subscribe(fn) in script High
26 25 Slots Use slots for composition Content projection <slot> for flexible content Props for all content <slot>Default</slot> <Component content="text"/> Medium https://svelte.dev/docs/special-elements#slot
27 26 Slots Name slots for multiple areas Multiple content areas <slot name="header"> Single slot for complex layouts <slot name="header"><slot name="footer"> <slot> with complex conditionals Low
28 27 Slots Check slot content with $$slots Conditional slot rendering $$slots.name for conditional rendering Always render slot wrapper {#if $$slots.footer}<slot name="footer"/>{/if} <div><slot name="footer"/></div> Low
29 28 Styling Use scoped styles by default Styles scoped to component <style> for component styles Global styles for component :global() only when needed <style> all global Medium https://svelte.dev/docs/svelte-components#style
30 29 Styling Use :global() sparingly Escape scoping when needed :global for third-party styling Global for all styles :global(.external-lib) <style> without scoping Medium
31 30 Styling Use CSS variables for theming Dynamic styling CSS custom properties Inline styles for themes style="--color: {color}" style="color: {color}" Low
32 31 Transitions Use built-in transitions Svelte transition directives transition:fade for simple effects Manual CSS transitions <div transition:fade> <div class:fade={visible}> Low https://svelte.dev/docs/element-directives#transition-fn
33 32 Transitions Use in: and out: separately Different enter/exit animations in:fly out:fade for asymmetric Same transition for both <div in:fly out:fade> <div transition:fly> Low
34 33 Transitions Add local modifier Prevent ancestor trigger transition:fade|local Global transitions for lists <div transition:slide|local> <div transition:slide> Medium
35 34 Actions Use actions for DOM behavior Reusable DOM logic use:action for DOM enhancements onMount for each usage <div use:clickOutside> onMount(() => setupClickOutside(el)) Medium https://svelte.dev/docs/element-directives#use-action
36 35 Actions Return update and destroy Lifecycle methods for actions Return { update, destroy } Only initial setup return { update(params) {}, destroy() {} } return destroy only Medium
37 36 Actions Pass parameters to actions Configure action behavior use:action={params} Hardcoded action behavior <div use:tooltip={options}> <div use:tooltip> Low
38 37 Logic Use {#if} for conditionals Template conditionals {#if} {:else if} {:else} Ternary in expressions {#if cond}...{:else}...{/if} {cond ? a : b} for complex Low https://svelte.dev/docs/logic-blocks#if
39 38 Logic Use {#each} for lists List rendering {#each} with key Map in expression {#each items as item (item.id)} {items.map(i => `<div>${i}</div>`)} Medium
40 39 Logic Always use keys in {#each} Proper list reconciliation (item.id) for unique key Index as key or no key {#each items as item (item.id)} {#each items as item, i (i)} High
41 40 Logic Use {#await} for promises Handle async states {#await} for loading/error states Manual promise handling {#await promise}...{:then}...{:catch} {#if loading}...{#if error} Medium https://svelte.dev/docs/logic-blocks#await
42 41 SvelteKit Use +page.svelte for routes File-based routing +page.svelte for route components Custom routing setup routes/about/+page.svelte routes/About.svelte Medium https://kit.svelte.dev/docs/routing
43 42 SvelteKit Use +page.js for data loading Load data before render load function in +page.js onMount for data fetching export function load() {} onMount(() => fetchData()) High https://kit.svelte.dev/docs/load
44 43 SvelteKit Use +page.server.js for server-only Server-side data loading +page.server.js for sensitive data +page.js for API keys +page.server.js with DB access +page.js with DB access High
45 44 SvelteKit Use form actions Server-side form handling +page.server.js actions API routes for forms export const actions = { default } fetch('/api/submit') Medium https://kit.svelte.dev/docs/form-actions
46 45 SvelteKit Use $app/stores for app state $page $navigating $updated $page for current page data Manual URL parsing import { page } from '$app/stores' window.location.pathname Medium https://kit.svelte.dev/docs/modules#$app-stores
47 46 Performance Use {#key} for forced re-render Reset component state {#key id} for fresh instance Manual destroy/create {#key item.id}<Component/>{/key} on:change={() => component = null} Low https://svelte.dev/docs/logic-blocks#key
48 47 Performance Avoid unnecessary reactivity Not everything needs $: $: only for side effects $: for simple assignments $: if (x) console.log(x) $: y = x (when y = x works) Low
49 48 Performance Use immutable compiler option Skip equality checks immutable: true for large lists Default for all components <svelte:options immutable/> Default without immutable Low
50 49 TypeScript Use lang="ts" in script TypeScript support <script lang="ts"> JavaScript for typed projects <script lang="ts"> <script> with JSDoc Medium https://svelte.dev/docs/typescript
51 50 TypeScript Type props with interface Explicit prop types interface $$Props for types Untyped props interface $$Props { name: string } export let name Medium
52 51 TypeScript Type events with createEventDispatcher Type-safe events createEventDispatcher<Events>() Untyped dispatch createEventDispatcher<{ save: Data }>() createEventDispatcher() Medium
53 52 Accessibility Use semantic elements Proper HTML in templates button nav main appropriately div for everything <button on:click> <div on:click> High
54 53 Accessibility Add aria to dynamic content Accessible state changes aria-live for updates Silent dynamic updates <div aria-live="polite">{message}</div> <div>{message}</div> Medium

View File

@@ -1,51 +0,0 @@
No,Category,Guideline,Description,Do,Don't,Code Good,Code Bad,Severity,Docs URL
1,Views,Use struct for views,SwiftUI views are value types,struct MyView: View,class MyView: View,struct ContentView: View { var body: some View },class ContentView: View,High,https://developer.apple.com/documentation/swiftui/view
2,Views,Keep views small and focused,Single responsibility for each view,Extract subviews for complex layouts,Large monolithic views,Extract HeaderView FooterView,500+ line View struct,Medium,
3,Views,Use body computed property,body returns the view hierarchy,var body: some View { },func body() -> some View,"var body: some View { Text(""Hello"") }",func body() -> Text,High,
4,Views,Prefer composition over inheritance,Compose views using ViewBuilder,Combine smaller views,Inheritance hierarchies,VStack { Header() Content() },class SpecialView extends BaseView,Medium,
5,State,Use @State for local state,Simple value types owned by view,@State for view-local primitives,@State for shared data,@State private var count = 0,@State var sharedData: Model,High,https://developer.apple.com/documentation/swiftui/state
6,State,Use @Binding for two-way data,Pass mutable state to child views,@Binding for child input,@State in child for parent data,@Binding var isOn: Bool,$isOn to pass binding,Medium,https://developer.apple.com/documentation/swiftui/binding
7,State,Use @StateObject for reference types,ObservableObject owned by view,@StateObject for view-created objects,@ObservedObject for owned objects,@StateObject private var vm = ViewModel(),@ObservedObject var vm = ViewModel(),High,https://developer.apple.com/documentation/swiftui/stateobject
8,State,Use @ObservedObject for injected objects,Reference types passed from parent,@ObservedObject for injected dependencies,@StateObject for injected objects,@ObservedObject var vm: ViewModel,@StateObject var vm: ViewModel (injected),High,https://developer.apple.com/documentation/swiftui/observedobject
9,State,Use @EnvironmentObject for shared state,App-wide state injection,@EnvironmentObject for global state,Prop drilling through views,@EnvironmentObject var settings: Settings,Pass settings through 5 views,Medium,https://developer.apple.com/documentation/swiftui/environmentobject
10,State,Use @Published in ObservableObject,Automatically publish property changes,@Published for observed properties,Manual objectWillChange calls,@Published var items: [Item] = [],var items: [Item] { didSet { objectWillChange.send() } },Medium,
11,Observable,Use @Observable macro (iOS 17+),Modern observation without Combine,@Observable class for view models,ObservableObject for new projects,@Observable class ViewModel { },class ViewModel: ObservableObject,Medium,https://developer.apple.com/documentation/observation
12,Observable,Use @Bindable for @Observable,Create bindings from @Observable,@Bindable var vm for bindings,@Binding with @Observable,@Bindable var viewModel,$viewModel.name with @Observable,Medium,
13,Layout,Use VStack HStack ZStack,Standard stack-based layouts,Stacks for linear arrangements,GeometryReader for simple layouts,VStack { Text() Image() },GeometryReader for vertical list,Medium,https://developer.apple.com/documentation/swiftui/vstack
14,Layout,Use LazyVStack LazyHStack for lists,Lazy loading for performance,Lazy stacks for long lists,Regular stacks for 100+ items,LazyVStack { ForEach(items) },VStack { ForEach(largeArray) },High,https://developer.apple.com/documentation/swiftui/lazyvstack
15,Layout,Use GeometryReader sparingly,Only when needed for sizing,GeometryReader for responsive layouts,GeometryReader everywhere,GeometryReader for aspect ratio,GeometryReader wrapping everything,Medium,
16,Layout,Use spacing and padding consistently,Consistent spacing throughout app,Design system spacing values,Magic numbers for spacing,.padding(16) or .padding(),".padding(13), .padding(17)",Low,
17,Layout,Use frame modifiers correctly,Set explicit sizes when needed,.frame(maxWidth: .infinity),Fixed sizes for responsive content,.frame(maxWidth: .infinity),.frame(width: 375),Medium,
18,Modifiers,Order modifiers correctly,Modifier order affects rendering,Background before padding for full coverage,Wrong modifier order,.padding().background(Color.red),.background(Color.red).padding(),High,
19,Modifiers,Create custom ViewModifiers,Reusable modifier combinations,ViewModifier for repeated styling,Duplicate modifier chains,struct CardStyle: ViewModifier,.shadow().cornerRadius() everywhere,Medium,https://developer.apple.com/documentation/swiftui/viewmodifier
20,Modifiers,Use conditional modifiers carefully,Avoid changing view identity,if-else with same view type,Conditional that changes view identity,Text(title).foregroundColor(isActive ? .blue : .gray),if isActive { Text().bold() } else { Text() },Medium,
21,Navigation,Use NavigationStack (iOS 16+),Modern navigation with type-safe paths,NavigationStack with navigationDestination,NavigationView for new projects,NavigationStack { },NavigationView { } (deprecated),Medium,https://developer.apple.com/documentation/swiftui/navigationstack
22,Navigation,Use navigationDestination,Type-safe navigation destinations,.navigationDestination(for:),NavigationLink(destination:),.navigationDestination(for: Item.self),NavigationLink(destination: DetailView()),Medium,
23,Navigation,Use @Environment for dismiss,Programmatic navigation dismissal,@Environment(\.dismiss) var dismiss,presentationMode (deprecated),@Environment(\.dismiss) var dismiss,@Environment(\.presentationMode),Low,
24,Lists,Use List for scrollable content,Built-in scrolling and styling,List for standard scrollable content,ScrollView + VStack for simple lists,List { ForEach(items) { } },ScrollView { VStack { ForEach } },Low,https://developer.apple.com/documentation/swiftui/list
25,Lists,Provide stable identifiers,Use Identifiable or explicit id,Identifiable protocol or id parameter,Index as identifier,ForEach(items) where Item: Identifiable,"ForEach(items.indices, id: \.self)",High,
26,Lists,Use onDelete and onMove,Standard list editing,onDelete for swipe to delete,Custom delete implementation,.onDelete(perform: delete),.onTapGesture for delete,Low,
27,Forms,Use Form for settings,Grouped input controls,Form for settings screens,Manual grouping for forms,Form { Section { Toggle() } },VStack { Toggle() },Low,https://developer.apple.com/documentation/swiftui/form
28,Forms,Use @FocusState for keyboard,Manage keyboard focus,@FocusState for text field focus,Manual first responder handling,@FocusState private var isFocused: Bool,UIKit first responder,Medium,https://developer.apple.com/documentation/swiftui/focusstate
29,Forms,Validate input properly,Show validation feedback,Real-time validation feedback,Submit without validation,TextField with validation state,TextField without error handling,Medium,
30,Async,Use .task for async work,Automatic cancellation on view disappear,.task for view lifecycle async,onAppear with Task,.task { await loadData() },onAppear { Task { await loadData() } },Medium,https://developer.apple.com/documentation/swiftui/view/task(priority:_:)
31,Async,Handle loading states,Show progress during async operations,ProgressView during loading,Empty view during load,if isLoading { ProgressView() },No loading indicator,Medium,
32,Async,Use @MainActor for UI updates,Ensure UI updates on main thread,@MainActor on view models,Manual DispatchQueue.main,@MainActor class ViewModel,DispatchQueue.main.async,Medium,
33,Animation,Use withAnimation,Animate state changes,withAnimation for state transitions,No animation for state changes,withAnimation { isExpanded.toggle() },isExpanded.toggle(),Low,https://developer.apple.com/documentation/swiftui/withanimation(_:_:)
34,Animation,Use .animation modifier,Apply animations to views,.animation(.spring()) on view,Manual animation timing,.animation(.easeInOut),CABasicAnimation equivalent,Low,
35,Animation,Respect reduced motion,Check accessibility settings,Check accessibilityReduceMotion,Ignore motion preferences,@Environment(\.accessibilityReduceMotion),Always animate regardless,High,
36,Preview,Use #Preview macro (Xcode 15+),Modern preview syntax,#Preview for view previews,PreviewProvider protocol,#Preview { ContentView() },struct ContentView_Previews: PreviewProvider,Low,
37,Preview,Create multiple previews,Test different states and devices,Multiple previews for states,Single preview only,"#Preview(""Light"") { } #Preview(""Dark"") { }",Single preview configuration,Low,
38,Preview,Use preview data,Dedicated preview mock data,Static preview data,Production data in previews,Item.preview for preview,Fetch real data in preview,Low,
39,Performance,Avoid expensive body computations,Body should be fast to compute,Precompute in view model,Heavy computation in body,vm.computedValue in body,Complex calculation in body,High,
40,Performance,Use Equatable views,Skip unnecessary view updates,Equatable for complex views,Default equality for all views,struct MyView: View Equatable,No Equatable conformance,Medium,
41,Performance,Profile with Instruments,Measure before optimizing,Use SwiftUI Instruments,Guess at performance issues,Profile with Instruments,Optimize without measuring,Medium,
42,Accessibility,Add accessibility labels,Describe UI elements,.accessibilityLabel for context,Missing labels,".accessibilityLabel(""Close button"")",Button without label,High,https://developer.apple.com/documentation/swiftui/view/accessibilitylabel(_:)-1d7jv
43,Accessibility,Support Dynamic Type,Respect text size preferences,Scalable fonts and layouts,Fixed font sizes,.font(.body) with Dynamic Type,.font(.system(size: 16)),High,
44,Accessibility,Use semantic views,Proper accessibility traits,Correct accessibilityTraits,Wrong semantic meaning,Button for actions Image for display,Image that acts like button,Medium,
45,Testing,Use ViewInspector for testing,Third-party view testing,ViewInspector for unit tests,UI tests only,ViewInspector assertions,Only XCUITest,Medium,
46,Testing,Test view models,Unit test business logic,XCTest for view model,Skip view model testing,Test ViewModel methods,No unit tests,Medium,
47,Testing,Use preview as visual test,Previews catch visual regressions,Multiple preview configurations,No visual verification,Preview different states,Single preview only,Low,
48,Architecture,Use MVVM pattern,Separate view and logic,ViewModel for business logic,Logic in View,ObservableObject ViewModel,@State for complex logic,Medium,
49,Architecture,Keep views dumb,Views display view model state,View reads from ViewModel,Business logic in View,view.items from vm.items,Complex filtering in View,Medium,
50,Architecture,Use dependency injection,Inject dependencies for testing,Initialize with dependencies,Hard-coded dependencies,init(service: ServiceProtocol),let service = RealService(),Medium,
1 No Category Guideline Description Do Don't Code Good Code Bad Severity Docs URL
2 1 Views Use struct for views SwiftUI views are value types struct MyView: View class MyView: View struct ContentView: View { var body: some View } class ContentView: View High https://developer.apple.com/documentation/swiftui/view
3 2 Views Keep views small and focused Single responsibility for each view Extract subviews for complex layouts Large monolithic views Extract HeaderView FooterView 500+ line View struct Medium
4 3 Views Use body computed property body returns the view hierarchy var body: some View { } func body() -> some View var body: some View { Text("Hello") } func body() -> Text High
5 4 Views Prefer composition over inheritance Compose views using ViewBuilder Combine smaller views Inheritance hierarchies VStack { Header() Content() } class SpecialView extends BaseView Medium
6 5 State Use @State for local state Simple value types owned by view @State for view-local primitives @State for shared data @State private var count = 0 @State var sharedData: Model High https://developer.apple.com/documentation/swiftui/state
7 6 State Use @Binding for two-way data Pass mutable state to child views @Binding for child input @State in child for parent data @Binding var isOn: Bool $isOn to pass binding Medium https://developer.apple.com/documentation/swiftui/binding
8 7 State Use @StateObject for reference types ObservableObject owned by view @StateObject for view-created objects @ObservedObject for owned objects @StateObject private var vm = ViewModel() @ObservedObject var vm = ViewModel() High https://developer.apple.com/documentation/swiftui/stateobject
9 8 State Use @ObservedObject for injected objects Reference types passed from parent @ObservedObject for injected dependencies @StateObject for injected objects @ObservedObject var vm: ViewModel @StateObject var vm: ViewModel (injected) High https://developer.apple.com/documentation/swiftui/observedobject
10 9 State Use @EnvironmentObject for shared state App-wide state injection @EnvironmentObject for global state Prop drilling through views @EnvironmentObject var settings: Settings Pass settings through 5 views Medium https://developer.apple.com/documentation/swiftui/environmentobject
11 10 State Use @Published in ObservableObject Automatically publish property changes @Published for observed properties Manual objectWillChange calls @Published var items: [Item] = [] var items: [Item] { didSet { objectWillChange.send() } } Medium
12 11 Observable Use @Observable macro (iOS 17+) Modern observation without Combine @Observable class for view models ObservableObject for new projects @Observable class ViewModel { } class ViewModel: ObservableObject Medium https://developer.apple.com/documentation/observation
13 12 Observable Use @Bindable for @Observable Create bindings from @Observable @Bindable var vm for bindings @Binding with @Observable @Bindable var viewModel $viewModel.name with @Observable Medium
14 13 Layout Use VStack HStack ZStack Standard stack-based layouts Stacks for linear arrangements GeometryReader for simple layouts VStack { Text() Image() } GeometryReader for vertical list Medium https://developer.apple.com/documentation/swiftui/vstack
15 14 Layout Use LazyVStack LazyHStack for lists Lazy loading for performance Lazy stacks for long lists Regular stacks for 100+ items LazyVStack { ForEach(items) } VStack { ForEach(largeArray) } High https://developer.apple.com/documentation/swiftui/lazyvstack
16 15 Layout Use GeometryReader sparingly Only when needed for sizing GeometryReader for responsive layouts GeometryReader everywhere GeometryReader for aspect ratio GeometryReader wrapping everything Medium
17 16 Layout Use spacing and padding consistently Consistent spacing throughout app Design system spacing values Magic numbers for spacing .padding(16) or .padding() .padding(13), .padding(17) Low
18 17 Layout Use frame modifiers correctly Set explicit sizes when needed .frame(maxWidth: .infinity) Fixed sizes for responsive content .frame(maxWidth: .infinity) .frame(width: 375) Medium
19 18 Modifiers Order modifiers correctly Modifier order affects rendering Background before padding for full coverage Wrong modifier order .padding().background(Color.red) .background(Color.red).padding() High
20 19 Modifiers Create custom ViewModifiers Reusable modifier combinations ViewModifier for repeated styling Duplicate modifier chains struct CardStyle: ViewModifier .shadow().cornerRadius() everywhere Medium https://developer.apple.com/documentation/swiftui/viewmodifier
21 20 Modifiers Use conditional modifiers carefully Avoid changing view identity if-else with same view type Conditional that changes view identity Text(title).foregroundColor(isActive ? .blue : .gray) if isActive { Text().bold() } else { Text() } Medium
22 21 Navigation Use NavigationStack (iOS 16+) Modern navigation with type-safe paths NavigationStack with navigationDestination NavigationView for new projects NavigationStack { } NavigationView { } (deprecated) Medium https://developer.apple.com/documentation/swiftui/navigationstack
23 22 Navigation Use navigationDestination Type-safe navigation destinations .navigationDestination(for:) NavigationLink(destination:) .navigationDestination(for: Item.self) NavigationLink(destination: DetailView()) Medium
24 23 Navigation Use @Environment for dismiss Programmatic navigation dismissal @Environment(\.dismiss) var dismiss presentationMode (deprecated) @Environment(\.dismiss) var dismiss @Environment(\.presentationMode) Low
25 24 Lists Use List for scrollable content Built-in scrolling and styling List for standard scrollable content ScrollView + VStack for simple lists List { ForEach(items) { } } ScrollView { VStack { ForEach } } Low https://developer.apple.com/documentation/swiftui/list
26 25 Lists Provide stable identifiers Use Identifiable or explicit id Identifiable protocol or id parameter Index as identifier ForEach(items) where Item: Identifiable ForEach(items.indices, id: \.self) High
27 26 Lists Use onDelete and onMove Standard list editing onDelete for swipe to delete Custom delete implementation .onDelete(perform: delete) .onTapGesture for delete Low
28 27 Forms Use Form for settings Grouped input controls Form for settings screens Manual grouping for forms Form { Section { Toggle() } } VStack { Toggle() } Low https://developer.apple.com/documentation/swiftui/form
29 28 Forms Use @FocusState for keyboard Manage keyboard focus @FocusState for text field focus Manual first responder handling @FocusState private var isFocused: Bool UIKit first responder Medium https://developer.apple.com/documentation/swiftui/focusstate
30 29 Forms Validate input properly Show validation feedback Real-time validation feedback Submit without validation TextField with validation state TextField without error handling Medium
31 30 Async Use .task for async work Automatic cancellation on view disappear .task for view lifecycle async onAppear with Task .task { await loadData() } onAppear { Task { await loadData() } } Medium https://developer.apple.com/documentation/swiftui/view/task(priority:_:)
32 31 Async Handle loading states Show progress during async operations ProgressView during loading Empty view during load if isLoading { ProgressView() } No loading indicator Medium
33 32 Async Use @MainActor for UI updates Ensure UI updates on main thread @MainActor on view models Manual DispatchQueue.main @MainActor class ViewModel DispatchQueue.main.async Medium
34 33 Animation Use withAnimation Animate state changes withAnimation for state transitions No animation for state changes withAnimation { isExpanded.toggle() } isExpanded.toggle() Low https://developer.apple.com/documentation/swiftui/withanimation(_:_:)
35 34 Animation Use .animation modifier Apply animations to views .animation(.spring()) on view Manual animation timing .animation(.easeInOut) CABasicAnimation equivalent Low
36 35 Animation Respect reduced motion Check accessibility settings Check accessibilityReduceMotion Ignore motion preferences @Environment(\.accessibilityReduceMotion) Always animate regardless High
37 36 Preview Use #Preview macro (Xcode 15+) Modern preview syntax #Preview for view previews PreviewProvider protocol #Preview { ContentView() } struct ContentView_Previews: PreviewProvider Low
38 37 Preview Create multiple previews Test different states and devices Multiple previews for states Single preview only #Preview("Light") { } #Preview("Dark") { } Single preview configuration Low
39 38 Preview Use preview data Dedicated preview mock data Static preview data Production data in previews Item.preview for preview Fetch real data in preview Low
40 39 Performance Avoid expensive body computations Body should be fast to compute Precompute in view model Heavy computation in body vm.computedValue in body Complex calculation in body High
41 40 Performance Use Equatable views Skip unnecessary view updates Equatable for complex views Default equality for all views struct MyView: View Equatable No Equatable conformance Medium
42 41 Performance Profile with Instruments Measure before optimizing Use SwiftUI Instruments Guess at performance issues Profile with Instruments Optimize without measuring Medium
43 42 Accessibility Add accessibility labels Describe UI elements .accessibilityLabel for context Missing labels .accessibilityLabel("Close button") Button without label High https://developer.apple.com/documentation/swiftui/view/accessibilitylabel(_:)-1d7jv
44 43 Accessibility Support Dynamic Type Respect text size preferences Scalable fonts and layouts Fixed font sizes .font(.body) with Dynamic Type .font(.system(size: 16)) High
45 44 Accessibility Use semantic views Proper accessibility traits Correct accessibilityTraits Wrong semantic meaning Button for actions Image for display Image that acts like button Medium
46 45 Testing Use ViewInspector for testing Third-party view testing ViewInspector for unit tests UI tests only ViewInspector assertions Only XCUITest Medium
47 46 Testing Test view models Unit test business logic XCTest for view model Skip view model testing Test ViewModel methods No unit tests Medium
48 47 Testing Use preview as visual test Previews catch visual regressions Multiple preview configurations No visual verification Preview different states Single preview only Low
49 48 Architecture Use MVVM pattern Separate view and logic ViewModel for business logic Logic in View ObservableObject ViewModel @State for complex logic Medium
50 49 Architecture Keep views dumb Views display view model state View reads from ViewModel Business logic in View view.items from vm.items Complex filtering in View Medium
51 50 Architecture Use dependency injection Inject dependencies for testing Initialize with dependencies Hard-coded dependencies init(service: ServiceProtocol) let service = RealService() Medium

View File

@@ -1,50 +0,0 @@
No,Category,Guideline,Description,Do,Don't,Code Good,Code Bad,Severity,Docs URL
1,Composition,Use Composition API for new projects,Composition API offers better TypeScript support and logic reuse,<script setup> for components,Options API for new projects,<script setup>,export default { data() },Medium,https://vuejs.org/guide/extras/composition-api-faq.html
2,Composition,Use script setup syntax,Cleaner syntax with automatic exports,<script setup> with defineProps,setup() function manually,<script setup>,<script> setup() { return {} },Low,https://vuejs.org/api/sfc-script-setup.html
3,Reactivity,Use ref for primitives,ref() for primitive values that need reactivity,ref() for strings numbers booleans,reactive() for primitives,const count = ref(0),const count = reactive(0),Medium,https://vuejs.org/guide/essentials/reactivity-fundamentals.html
4,Reactivity,Use reactive for objects,reactive() for complex objects and arrays,reactive() for objects with multiple properties,ref() for complex objects,const state = reactive({ user: null }),const state = ref({ user: null }),Medium,
5,Reactivity,Access ref values with .value,Remember .value in script unwrap in template,Use .value in script,Forget .value in script,count.value++,count++ (in script),High,
6,Reactivity,Use computed for derived state,Computed properties cache and update automatically,computed() for derived values,Methods for derived values,const doubled = computed(() => count.value * 2),const doubled = () => count.value * 2,Medium,https://vuejs.org/guide/essentials/computed.html
7,Reactivity,Use shallowRef for large objects,Avoid deep reactivity for performance,shallowRef for large data structures,ref for large nested objects,const bigData = shallowRef(largeObject),const bigData = ref(largeObject),Medium,https://vuejs.org/api/reactivity-advanced.html#shallowref
8,Watchers,Use watchEffect for simple cases,Auto-tracks dependencies,watchEffect for simple reactive effects,watch with explicit deps when not needed,watchEffect(() => console.log(count.value)),"watch(count, (val) => console.log(val))",Low,https://vuejs.org/guide/essentials/watchers.html
9,Watchers,Use watch for specific sources,Explicit control over what to watch,watch with specific refs,watchEffect for complex conditional logic,"watch(userId, fetchUser)",watchEffect with conditionals,Medium,
10,Watchers,Clean up side effects,Return cleanup function in watchers,Return cleanup in watchEffect,Leave subscriptions open,watchEffect((onCleanup) => { onCleanup(unsub) }),watchEffect without cleanup,High,
11,Props,Define props with defineProps,Type-safe prop definitions,defineProps with TypeScript,Props without types,defineProps<{ msg: string }>(),defineProps(['msg']),Medium,https://vuejs.org/guide/typescript/composition-api.html#typing-component-props
12,Props,Use withDefaults for default values,Provide defaults for optional props,withDefaults with defineProps,Defaults in destructuring,"withDefaults(defineProps<Props>(), { count: 0 })",const { count = 0 } = defineProps(),Medium,
13,Props,Avoid mutating props,Props should be read-only,Emit events to parent for changes,Direct prop mutation,"emit('update:modelValue', newVal)",props.modelValue = newVal,High,
14,Emits,Define emits with defineEmits,Type-safe event emissions,defineEmits with types,Emit without definition,defineEmits<{ change: [id: number] }>(),"emit('change', id) without define",Medium,https://vuejs.org/guide/typescript/composition-api.html#typing-component-emits
15,Emits,Use v-model for two-way binding,Simplified parent-child data flow,v-model with modelValue prop,:value + @input manually,"<Child v-model=""value""/>","<Child :value=""value"" @input=""value = $event""/>",Low,https://vuejs.org/guide/components/v-model.html
16,Lifecycle,Use onMounted for DOM access,DOM is ready in onMounted,onMounted for DOM operations,Access DOM in setup directly,onMounted(() => el.value.focus()),el.value.focus() in setup,High,https://vuejs.org/api/composition-api-lifecycle.html
17,Lifecycle,Clean up in onUnmounted,Remove listeners and subscriptions,onUnmounted for cleanup,Leave listeners attached,onUnmounted(() => window.removeEventListener()),No cleanup on unmount,High,
18,Lifecycle,Avoid onBeforeMount for data,Use onMounted or setup for data fetching,Fetch in onMounted or setup,Fetch in onBeforeMount,onMounted(async () => await fetchData()),onBeforeMount(async () => await fetchData()),Low,
19,Components,Use single-file components,Keep template script style together,.vue files for components,Separate template/script files,Component.vue with all parts,Component.js + Component.html,Low,
20,Components,Use PascalCase for components,Consistent component naming,PascalCase in imports and templates,kebab-case in script,<MyComponent/>,<my-component/>,Low,https://vuejs.org/style-guide/rules-strongly-recommended.html
21,Components,Prefer composition over mixins,Composables replace mixins,Composables for shared logic,Mixins for code reuse,const { data } = useApi(),mixins: [apiMixin],Medium,
22,Composables,Name composables with use prefix,Convention for composable functions,useFetch useAuth useForm,getData or fetchApi,export function useFetch(),export function fetchData(),Medium,https://vuejs.org/guide/reusability/composables.html
23,Composables,Return refs from composables,Maintain reactivity when destructuring,Return ref values,Return reactive objects that lose reactivity,return { data: ref(null) },return reactive({ data: null }),Medium,
24,Composables,Accept ref or value params,Use toValue for flexible inputs,toValue() or unref() for params,Only accept ref or only value,const val = toValue(maybeRef),const val = maybeRef.value,Low,https://vuejs.org/api/reactivity-utilities.html#tovalue
25,Templates,Use v-bind shorthand,Cleaner template syntax,:prop instead of v-bind:prop,Full v-bind syntax,"<div :class=""cls"">","<div v-bind:class=""cls"">",Low,
26,Templates,Use v-on shorthand,Cleaner event binding,@event instead of v-on:event,Full v-on syntax,"<button @click=""handler"">","<button v-on:click=""handler"">",Low,
27,Templates,Avoid v-if with v-for,v-if has higher priority causes issues,Wrap in template or computed filter,v-if on same element as v-for,<template v-for><div v-if>,<div v-for v-if>,High,https://vuejs.org/style-guide/rules-essential.html#avoid-v-if-with-v-for
28,Templates,Use key with v-for,Proper list rendering and updates,Unique key for each item,Index as key for dynamic lists,"v-for=""item in items"" :key=""item.id""","v-for=""(item, i) in items"" :key=""i""",High,
29,State,Use Pinia for global state,Official state management for Vue 3,Pinia stores for shared state,Vuex for new projects,const store = useCounterStore(),Vuex with mutations,Medium,https://pinia.vuejs.org/
30,State,Define stores with defineStore,Composition API style stores,Setup stores with defineStore,Options stores for complex state,"defineStore('counter', () => {})","defineStore('counter', { state })",Low,
31,State,Use storeToRefs for destructuring,Maintain reactivity when destructuring,storeToRefs(store),Direct destructuring,const { count } = storeToRefs(store),const { count } = store,High,https://pinia.vuejs.org/core-concepts/#destructuring-from-a-store
32,Routing,Use useRouter and useRoute,Composition API router access,useRouter() useRoute() in setup,this.$router this.$route,const router = useRouter(),this.$router.push(),Medium,https://router.vuejs.org/guide/advanced/composition-api.html
33,Routing,Lazy load route components,Code splitting for routes,() => import() for components,Static imports for all routes,component: () => import('./Page.vue'),component: Page,Medium,https://router.vuejs.org/guide/advanced/lazy-loading.html
34,Routing,Use navigation guards,Protect routes and handle redirects,beforeEach for auth checks,Check auth in each component,router.beforeEach((to) => {}),Check auth in onMounted,Medium,
35,Performance,Use v-once for static content,Skip re-renders for static elements,v-once on never-changing content,v-once on dynamic content,<div v-once>{{ staticText }}</div>,<div v-once>{{ dynamicText }}</div>,Low,https://vuejs.org/api/built-in-directives.html#v-once
36,Performance,Use v-memo for expensive lists,Memoize list items,v-memo with dependency array,Re-render entire list always,"<div v-for v-memo=""[item.id]"">",<div v-for> without memo,Medium,https://vuejs.org/api/built-in-directives.html#v-memo
37,Performance,Use shallowReactive for flat objects,Avoid deep reactivity overhead,shallowReactive for flat state,reactive for simple objects,shallowReactive({ count: 0 }),reactive({ count: 0 }),Low,
38,Performance,Use defineAsyncComponent,Lazy load heavy components,defineAsyncComponent for modals dialogs,Import all components eagerly,defineAsyncComponent(() => import()),import HeavyComponent from,Medium,https://vuejs.org/guide/components/async.html
39,TypeScript,Use generic components,Type-safe reusable components,Generic with defineComponent,Any types in components,"<script setup lang=""ts"" generic=""T"">",<script setup> without types,Medium,https://vuejs.org/guide/typescript/composition-api.html
40,TypeScript,Type template refs,Proper typing for DOM refs,ref<HTMLInputElement>(null),ref(null) without type,const input = ref<HTMLInputElement>(null),const input = ref(null),Medium,
41,TypeScript,Use PropType for complex props,Type complex prop types,PropType<User> for object props,Object without type,type: Object as PropType<User>,type: Object,Medium,
42,Testing,Use Vue Test Utils,Official testing library,mount shallowMount for components,Manual DOM testing,import { mount } from '@vue/test-utils',document.createElement,Medium,https://test-utils.vuejs.org/
43,Testing,Test component behavior,Focus on inputs and outputs,Test props emit and rendered output,Test internal implementation,expect(wrapper.text()).toContain(),expect(wrapper.vm.internalState),Medium,
44,Forms,Use v-model modifiers,Built-in input handling,.lazy .number .trim modifiers,Manual input parsing,"<input v-model.number=""age"">","<input v-model=""age""> then parse",Low,https://vuejs.org/guide/essentials/forms.html#modifiers
45,Forms,Use VeeValidate or FormKit,Form validation libraries,VeeValidate for complex forms,Manual validation logic,useField useForm from vee-validate,Custom validation in each input,Medium,
46,Accessibility,Use semantic elements,Proper HTML elements in templates,button nav main for purpose,div for everything,<button @click>,<div @click>,High,
47,Accessibility,Bind aria attributes dynamically,Keep ARIA in sync with state,":aria-expanded=""isOpen""",Static ARIA values,":aria-expanded=""menuOpen""","aria-expanded=""true""",Medium,
48,SSR,Use Nuxt for SSR,Full-featured SSR framework,Nuxt 3 for SSR apps,Manual SSR setup,npx nuxi init my-app,Custom SSR configuration,Medium,https://nuxt.com/
49,SSR,Handle hydration mismatches,Client/server content must match,ClientOnly for browser-only content,Different content server/client,<ClientOnly><BrowserWidget/></ClientOnly>,<div>{{ Date.now() }}</div>,High,
1 No Category Guideline Description Do Don't Code Good Code Bad Severity Docs URL
2 1 Composition Use Composition API for new projects Composition API offers better TypeScript support and logic reuse <script setup> for components Options API for new projects <script setup> export default { data() } Medium https://vuejs.org/guide/extras/composition-api-faq.html
3 2 Composition Use script setup syntax Cleaner syntax with automatic exports <script setup> with defineProps setup() function manually <script setup> <script> setup() { return {} } Low https://vuejs.org/api/sfc-script-setup.html
4 3 Reactivity Use ref for primitives ref() for primitive values that need reactivity ref() for strings numbers booleans reactive() for primitives const count = ref(0) const count = reactive(0) Medium https://vuejs.org/guide/essentials/reactivity-fundamentals.html
5 4 Reactivity Use reactive for objects reactive() for complex objects and arrays reactive() for objects with multiple properties ref() for complex objects const state = reactive({ user: null }) const state = ref({ user: null }) Medium
6 5 Reactivity Access ref values with .value Remember .value in script unwrap in template Use .value in script Forget .value in script count.value++ count++ (in script) High
7 6 Reactivity Use computed for derived state Computed properties cache and update automatically computed() for derived values Methods for derived values const doubled = computed(() => count.value * 2) const doubled = () => count.value * 2 Medium https://vuejs.org/guide/essentials/computed.html
8 7 Reactivity Use shallowRef for large objects Avoid deep reactivity for performance shallowRef for large data structures ref for large nested objects const bigData = shallowRef(largeObject) const bigData = ref(largeObject) Medium https://vuejs.org/api/reactivity-advanced.html#shallowref
9 8 Watchers Use watchEffect for simple cases Auto-tracks dependencies watchEffect for simple reactive effects watch with explicit deps when not needed watchEffect(() => console.log(count.value)) watch(count, (val) => console.log(val)) Low https://vuejs.org/guide/essentials/watchers.html
10 9 Watchers Use watch for specific sources Explicit control over what to watch watch with specific refs watchEffect for complex conditional logic watch(userId, fetchUser) watchEffect with conditionals Medium
11 10 Watchers Clean up side effects Return cleanup function in watchers Return cleanup in watchEffect Leave subscriptions open watchEffect((onCleanup) => { onCleanup(unsub) }) watchEffect without cleanup High
12 11 Props Define props with defineProps Type-safe prop definitions defineProps with TypeScript Props without types defineProps<{ msg: string }>() defineProps(['msg']) Medium https://vuejs.org/guide/typescript/composition-api.html#typing-component-props
13 12 Props Use withDefaults for default values Provide defaults for optional props withDefaults with defineProps Defaults in destructuring withDefaults(defineProps<Props>(), { count: 0 }) const { count = 0 } = defineProps() Medium
14 13 Props Avoid mutating props Props should be read-only Emit events to parent for changes Direct prop mutation emit('update:modelValue', newVal) props.modelValue = newVal High
15 14 Emits Define emits with defineEmits Type-safe event emissions defineEmits with types Emit without definition defineEmits<{ change: [id: number] }>() emit('change', id) without define Medium https://vuejs.org/guide/typescript/composition-api.html#typing-component-emits
16 15 Emits Use v-model for two-way binding Simplified parent-child data flow v-model with modelValue prop :value + @input manually <Child v-model="value"/> <Child :value="value" @input="value = $event"/> Low https://vuejs.org/guide/components/v-model.html
17 16 Lifecycle Use onMounted for DOM access DOM is ready in onMounted onMounted for DOM operations Access DOM in setup directly onMounted(() => el.value.focus()) el.value.focus() in setup High https://vuejs.org/api/composition-api-lifecycle.html
18 17 Lifecycle Clean up in onUnmounted Remove listeners and subscriptions onUnmounted for cleanup Leave listeners attached onUnmounted(() => window.removeEventListener()) No cleanup on unmount High
19 18 Lifecycle Avoid onBeforeMount for data Use onMounted or setup for data fetching Fetch in onMounted or setup Fetch in onBeforeMount onMounted(async () => await fetchData()) onBeforeMount(async () => await fetchData()) Low
20 19 Components Use single-file components Keep template script style together .vue files for components Separate template/script files Component.vue with all parts Component.js + Component.html Low
21 20 Components Use PascalCase for components Consistent component naming PascalCase in imports and templates kebab-case in script <MyComponent/> <my-component/> Low https://vuejs.org/style-guide/rules-strongly-recommended.html
22 21 Components Prefer composition over mixins Composables replace mixins Composables for shared logic Mixins for code reuse const { data } = useApi() mixins: [apiMixin] Medium
23 22 Composables Name composables with use prefix Convention for composable functions useFetch useAuth useForm getData or fetchApi export function useFetch() export function fetchData() Medium https://vuejs.org/guide/reusability/composables.html
24 23 Composables Return refs from composables Maintain reactivity when destructuring Return ref values Return reactive objects that lose reactivity return { data: ref(null) } return reactive({ data: null }) Medium
25 24 Composables Accept ref or value params Use toValue for flexible inputs toValue() or unref() for params Only accept ref or only value const val = toValue(maybeRef) const val = maybeRef.value Low https://vuejs.org/api/reactivity-utilities.html#tovalue
26 25 Templates Use v-bind shorthand Cleaner template syntax :prop instead of v-bind:prop Full v-bind syntax <div :class="cls"> <div v-bind:class="cls"> Low
27 26 Templates Use v-on shorthand Cleaner event binding @event instead of v-on:event Full v-on syntax <button @click="handler"> <button v-on:click="handler"> Low
28 27 Templates Avoid v-if with v-for v-if has higher priority causes issues Wrap in template or computed filter v-if on same element as v-for <template v-for><div v-if> <div v-for v-if> High https://vuejs.org/style-guide/rules-essential.html#avoid-v-if-with-v-for
29 28 Templates Use key with v-for Proper list rendering and updates Unique key for each item Index as key for dynamic lists v-for="item in items" :key="item.id" v-for="(item, i) in items" :key="i" High
30 29 State Use Pinia for global state Official state management for Vue 3 Pinia stores for shared state Vuex for new projects const store = useCounterStore() Vuex with mutations Medium https://pinia.vuejs.org/
31 30 State Define stores with defineStore Composition API style stores Setup stores with defineStore Options stores for complex state defineStore('counter', () => {}) defineStore('counter', { state }) Low
32 31 State Use storeToRefs for destructuring Maintain reactivity when destructuring storeToRefs(store) Direct destructuring const { count } = storeToRefs(store) const { count } = store High https://pinia.vuejs.org/core-concepts/#destructuring-from-a-store
33 32 Routing Use useRouter and useRoute Composition API router access useRouter() useRoute() in setup this.$router this.$route const router = useRouter() this.$router.push() Medium https://router.vuejs.org/guide/advanced/composition-api.html
34 33 Routing Lazy load route components Code splitting for routes () => import() for components Static imports for all routes component: () => import('./Page.vue') component: Page Medium https://router.vuejs.org/guide/advanced/lazy-loading.html
35 34 Routing Use navigation guards Protect routes and handle redirects beforeEach for auth checks Check auth in each component router.beforeEach((to) => {}) Check auth in onMounted Medium
36 35 Performance Use v-once for static content Skip re-renders for static elements v-once on never-changing content v-once on dynamic content <div v-once>{{ staticText }}</div> <div v-once>{{ dynamicText }}</div> Low https://vuejs.org/api/built-in-directives.html#v-once
37 36 Performance Use v-memo for expensive lists Memoize list items v-memo with dependency array Re-render entire list always <div v-for v-memo="[item.id]"> <div v-for> without memo Medium https://vuejs.org/api/built-in-directives.html#v-memo
38 37 Performance Use shallowReactive for flat objects Avoid deep reactivity overhead shallowReactive for flat state reactive for simple objects shallowReactive({ count: 0 }) reactive({ count: 0 }) Low
39 38 Performance Use defineAsyncComponent Lazy load heavy components defineAsyncComponent for modals dialogs Import all components eagerly defineAsyncComponent(() => import()) import HeavyComponent from Medium https://vuejs.org/guide/components/async.html
40 39 TypeScript Use generic components Type-safe reusable components Generic with defineComponent Any types in components <script setup lang="ts" generic="T"> <script setup> without types Medium https://vuejs.org/guide/typescript/composition-api.html
41 40 TypeScript Type template refs Proper typing for DOM refs ref<HTMLInputElement>(null) ref(null) without type const input = ref<HTMLInputElement>(null) const input = ref(null) Medium
42 41 TypeScript Use PropType for complex props Type complex prop types PropType<User> for object props Object without type type: Object as PropType<User> type: Object Medium
43 42 Testing Use Vue Test Utils Official testing library mount shallowMount for components Manual DOM testing import { mount } from '@vue/test-utils' document.createElement Medium https://test-utils.vuejs.org/
44 43 Testing Test component behavior Focus on inputs and outputs Test props emit and rendered output Test internal implementation expect(wrapper.text()).toContain() expect(wrapper.vm.internalState) Medium
45 44 Forms Use v-model modifiers Built-in input handling .lazy .number .trim modifiers Manual input parsing <input v-model.number="age"> <input v-model="age"> then parse Low https://vuejs.org/guide/essentials/forms.html#modifiers
46 45 Forms Use VeeValidate or FormKit Form validation libraries VeeValidate for complex forms Manual validation logic useField useForm from vee-validate Custom validation in each input Medium
47 46 Accessibility Use semantic elements Proper HTML elements in templates button nav main for purpose div for everything <button @click> <div @click> High
48 47 Accessibility Bind aria attributes dynamically Keep ARIA in sync with state :aria-expanded="isOpen" Static ARIA values :aria-expanded="menuOpen" aria-expanded="true" Medium
49 48 SSR Use Nuxt for SSR Full-featured SSR framework Nuxt 3 for SSR apps Manual SSR setup npx nuxi init my-app Custom SSR configuration Medium https://nuxt.com/
50 49 SSR Handle hydration mismatches Client/server content must match ClientOnly for browser-only content Different content server/client <ClientOnly><BrowserWidget/></ClientOnly> <div>{{ Date.now() }}</div> High

View File

@@ -76,13 +76,6 @@ STACK_CONFIG = {
"html-tailwind": {"file": "stacks/html-tailwind.csv"}, "html-tailwind": {"file": "stacks/html-tailwind.csv"},
"react": {"file": "stacks/react.csv"}, "react": {"file": "stacks/react.csv"},
"nextjs": {"file": "stacks/nextjs.csv"}, "nextjs": {"file": "stacks/nextjs.csv"},
"vue": {"file": "stacks/vue.csv"},
"nuxtjs": {"file": "stacks/nuxtjs.csv"},
"nuxt-ui": {"file": "stacks/nuxt-ui.csv"},
"svelte": {"file": "stacks/svelte.csv"},
"swiftui": {"file": "stacks/swiftui.csv"},
"react-native": {"file": "stacks/react-native.csv"},
"flutter": {"file": "stacks/flutter.csv"},
"shadcn": {"file": "stacks/shadcn.csv"} "shadcn": {"file": "stacks/shadcn.csv"}
} }

View File

@@ -6,7 +6,7 @@ Usage: python search.py "<query>" [--domain <domain>] [--stack <stack>] [--max-r
python search.py "<query>" --design-system [-p "Project Name"] python search.py "<query>" --design-system [-p "Project Name"]
Domains: style, prompt, color, chart, landing, product, ux, typography Domains: style, prompt, color, chart, landing, product, ux, typography
Stacks: html-tailwind, react, nextjs Stacks: html-tailwind, react, nextjs, shadcn
""" """
import argparse import argparse
@@ -44,7 +44,7 @@ if __name__ == "__main__":
parser = argparse.ArgumentParser(description="UI Pro Max Search") parser = argparse.ArgumentParser(description="UI Pro Max Search")
parser.add_argument("query", help="Search query") parser.add_argument("query", help="Search query")
parser.add_argument("--domain", "-d", choices=list(CSV_CONFIG.keys()), help="Search domain") parser.add_argument("--domain", "-d", choices=list(CSV_CONFIG.keys()), help="Search domain")
parser.add_argument("--stack", "-s", choices=AVAILABLE_STACKS, help="Stack-specific search (html-tailwind, react, nextjs)") parser.add_argument("--stack", "-s", choices=AVAILABLE_STACKS, help="Stack-specific search (html-tailwind, react, nextjs, shadcn)")
parser.add_argument("--max-results", "-n", type=int, default=MAX_RESULTS, help="Max results (default: 3)") parser.add_argument("--max-results", "-n", type=int, default=MAX_RESULTS, help="Max results (default: 3)")
parser.add_argument("--json", action="store_true", help="Output as JSON") parser.add_argument("--json", action="store_true", help="Output as JSON")
# Design system generation # Design system generation

15
.dockerignore Normal file
View File

@@ -0,0 +1,15 @@
node_modules
.next
.git
.agents
.claude
.mcp.json
*.md
.env*
!.env.example
drizzle/
data/
!data/app.db
!data/yokatlas.db
!data/yokatlas.db-wal
!data/yokatlas.db-shm

35
.gitea/workflows/ci.yaml Normal file
View File

@@ -0,0 +1,35 @@
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: Kaynak kodu çek
uses: actions/checkout@v4
- name: .env.production oluştur
run: |
cat > .env.production <<'EOF'
BETTER_AUTH_SECRET=${{ secrets.BETTER_AUTH_SECRET }}
BETTER_AUTH_URL=${{ secrets.BETTER_AUTH_URL }}
NEXT_PUBLIC_APP_URL=${{ secrets.NEXT_PUBLIC_APP_URL }}
GOOGLE_CLIENT_ID=${{ secrets.GOOGLE_CLIENT_ID }}
GOOGLE_CLIENT_SECRET=${{ secrets.GOOGLE_CLIENT_SECRET }}
RESEND_API_KEY=${{ secrets.RESEND_API_KEY }}
EMAIL_FROM=${{ secrets.EMAIL_FROM }}
IYZICO_API_KEY=${{ secrets.IYZICO_API_KEY }}
IYZICO_SECRET_KEY=${{ secrets.IYZICO_SECRET_KEY }}
IYZICO_BASE_URL=${{ secrets.IYZICO_BASE_URL }}
ANTHROPIC_API_KEY=${{ secrets.ANTHROPIC_API_KEY }}
EOF
- name: Build & deploy
run: |
docker compose -p kolaytercih up -d --build --remove-orphans
docker image prune -f

View File

@@ -2,4 +2,8 @@
# This is NOT the Next.js you know # This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices. This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
# Push öncesi build zorunlu
Push emri geldiğinde önce lokalde `pnpm build` başarıyla geçmeli; geçmeden asla push etme. Kritik detay: çalışma ağacı kirliyken build, commit'lenmemiş dosyaları da gördüğü için CI'da kırılacak bir commit'i yakalayamayabilir. Bu yüzden build'i **push edilecek commit'in kendisi** üzerinde doğrula (ör. geçici `git worktree add <dir> HEAD` + `node_modules` symlink'i ile). Ayrıca kısmi commit atarken değişikliğin bağımlılık kapanışını kontrol et — bir dosyanın kullandığı prop/export başka bir commit'lenmemiş dosyadan geliyorsa onları da aynı commit'e dahil et.
<!-- END:nextjs-agent-rules --> <!-- END:nextjs-agent-rules -->

55
Dockerfile Normal file
View File

@@ -0,0 +1,55 @@
FROM node:20-alpine AS base
# pnpm kurulumu
RUN corepack enable && corepack prepare pnpm@10.8.1 --activate
# ── Bağımlılık katmanı ──
FROM base AS deps
WORKDIR /app
# better-sqlite3 Alpine'de kaynaktan derleniyor (musl için prebuilt yok)
RUN apk add --no-cache python3 make g++
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
# ── Derleme katmanı ──
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
# data/ .dockerignore'da; libsql build sırasında file:./data/app.db açabilsin diye boş dizin gerekli
RUN mkdir -p data && pnpm build
# ── Çalıştırma katmanı ──
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
# Bağımlılıklar
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
# Seed veritabanları: entrypoint açılışta bunları /app/data volume'üne kopyalar
COPY data/ ./seed/
COPY scripts/docker-entrypoint.sh ./docker-entrypoint.sh
# SQLite veri dizini
RUN chmod +x /app/docker-entrypoint.sh && \
mkdir -p /app/data && \
chown -R nextjs:nodejs /app/data /app/seed
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["/app/docker-entrypoint.sh"]

101
UX-DUZELTMELER.md Normal file
View File

@@ -0,0 +1,101 @@
# UX/CRO Kritik Düzeltmeler — Uygulama Takibi
UX denetiminden çıkan 🔴 kritik bulguların uygulama listesi.
Kaynak: 26 Temmuz 2026 tarihli UX/CRO denetimi.
## K1 — Ödeme sonrası teslimat
- [x] `/odeme/sonuc` başarı CTA'sı doğrudan `/listem`'e gitsin (ölü `/sonuc?acildi=1` yerine)
(sahte "paid" siparişle canlı doğrulandı: CTA → `/listem?acildi=1`)
- [x] `/listem` ödeme dönüşünde tek seferlik "kilit açıldı" deneyimi (banner + unblur)
- [x] Ücretli `/listem` başlık kopyası düzelsin (rozet "Paketin aktif", alt metin
"24 tercihinin tamamını gerekçe, risk analizi ve trendleriyle…")
## K2 — Buton dürüstlüğü (giriş duvarı sürprizi)
- [x] Hero sihirbazı son butonu girişsiz kullanıcıda girişi açıkça söylesin
("Giriş yap ve listeni gör"; girişliyse "Listemi oluştur (3 kredi)")
- [x] Hero akışı oturumu açık kullanıcıyı `/giris`'ten sektirmeyip doğrudan `/sonuc`'a götürsün
- [x] `/sonuc` sihirbazında girişsiz son buton "Listemi gör" yerine dürüst etiket taşısın
- [x] Değer önce: hero sihirbazı biten girişsiz kullanıcı `/giris`'e değil doğrudan
`/sonuc`'taki ücretsiz tabloya gitsin; AI CTA'sı oradan "5 deneme kredinle
AI listeni kur — giriş yap" olarak verilsin (panel + sabit alt kart);
girişten dönüşte kayıtlı seçimlerle otomatik üretim başlasın
## K3 — Fiyat şeffaflığı
- [x] Landing hero mikro-kopyasına paket fiyatı (299 TL) eklensin
- [x] FAQ "Fiyatlandırma nasıl çalışıyor?" cevabına rakamlar yazılsın
## K4 — Puan türü seçici
- [x] Hero formuna SAY/EA/SÖZ/DİL/TYT segment kontrolü (varsayılan SAY)
## K5 — Ücretsiz kullanıcıya AI sohbeti
- [x] `/listem`'de paketi olmayan kullanıcıya kalan kredileriyle sohbet açılsın
(fiyat kartının altında; mesaj gönderimi + kredi düşümü canlı doğrulandı)
- [x] Kredi bitince composer yerinde paket CTA'sı görünsün (0 krediyle canlı doğrulandı)
## K6 — Ödeme/AI hata mesajları
- [x] iyzico/Anthropic yapılandırma hataları kullanıcıya ham sızmasın (log'a detay, kullanıcıya sade mesaj)
(iyzico yolu canlı doğrulandı: "Ödeme şu anda başlatılamıyor; kartından çekim yapılmadı…")
- [x] `/paket` sayfası `?hata=token|siparis` parametresini okuyup banner göstersin
## Bonus
- [x] Hero rozetindeki "açıkk" yazım hatası düzeltilsin
---
# 🟠 Önemli
- [x] Ö1: `/listem` paywall ara kartı kopyası netleşsin (kilit ikonu + "aşağıdaki tüm
satırlar açık" çelişkisi giderilsin; CTA fiyatı söylesin)
(yeni kopya: "İlk 3 tercihte AI analizini gördün… Analizleri aç — 299 TL")
- [x] Ö2: İl seçimi mobilde aranabilir liste ile yapılabilsin; harita mobilde geri
çekilsin; harita illerine erişilebilir isim (aria-label) eklensin
(arama + çip birincil; harita ≥sm'de görsel destek; path'lerde aria-label/aria-pressed)
- [x] Ö3: "Listem" isim çakışması giderilsin (manuel liste "Seçtiklerim" olsun,
AI raporu /listem "Listem" kalsın)
- [x] Ö3b: Navbar'daki "Seçtiklerim" butonu liste boşken görünmesin — tabloya
ulaşmamış ziyaretçiye "0/24" gürültü; ilk program eklenince belirir,
liste boşalınca kaybolur (canlı doğrulandı)
- [x] Ö4: Güven sayfaları eklensin (gizlilik/KVKK, kullanım & iade koşulları,
iletişim) ve footer + giriş + paket sayfalarından bağlansın
(NOT: metinler MVP taslağı — yayın öncesi hukuki gözden geçirme gerekir)
- [x] Ö5: AI'ın ürettiği genel değerlendirme ve uyarılar /listem'de görünsün
(paketliye tam, paketsize kilitli önizleme — "Danışman özeti" kartı)
- [x] Ö6: Paketlinin gerçek revizyon akışı /listem'de çalışsın (geri bildirim
kutusu; başlıktaki "Revize et" oraya götürsün)
(`revizyon-kutusu.tsx``listeRevize` aksiyonu; kalan hak sayacıyla)
- [x] Ö7: Liste tepesine dilim özeti (hayal/dengeli/garanti dağılımı) ve tek
şehir uyarısı eklensin
- [x] Ö8: 2024/2025 verisi eksik satırda çelişkili trend rozeti gösterilmesin
(rozet iki yıl da doluysa çıkar; yoksa "Veri eksik")
- [x] Ö9: Magic link "gönderildi" ekranına yeniden gönder + e-postayı düzelt eklensin
(30 sn bekleme sayaçlı yeniden gönder + "E-postayı düzelt")
- [x] Ö10: Mobilde sohbete hızlı erişim (yüzen "Danışmana sor" kısayolu)
# 🛠 Dev araçları
- [x] Dev süperadmin paneli: yalnızca geliştirme ortamında sol altta "DEV"
butonu — anında giriş (magic link atlanır), kredi/paket/rapor senaryoları,
sahte "paid" siparişle ödeme başarı ekranı, tüm sayfalara hızlı bağlantılar
(`src/components/dev/dev-panel.tsx` + `src/lib/dev/actions.ts`;
layout NODE_ENV kontrolüyle monte eder, aksiyonlar sunucuda ayrıca korunur)
# 🟢 Güzel olur
- [x] G1: Sıralama girişinde canlı binlik ayracı + toast yerine input altı hata
- [x] G2: Sihirbaz adım göstergesi tamamlanan adımı işaretlesin; 1. adımda
"Geri" görünmesin
- [x] G3: Route-level loading / error / not-found boundary'leri eklensin
(kök loading/error/not-found + /sonuc ve /listem'e Skeleton'lı loading)
- [x] G4: Landing istatistik kartlarının yanında "yerleşme garantisi vermeyen
dürüst araç" konumlandırması cesur işlensin (koyu kart: "Yerleşme
garantisi vermiyoruz. Çünkü kimse veremez.")
- [x] G5: `sonButonEtiketi` şeffaflığı hero varyantına taşınsın (girişli:
"Listemi oluştur (3 kredi)"; girişsiz artık ücretsiz tabloya gittiği
için "Ücretsiz tablonu gör")

Binary file not shown.

27
docker-compose.yml Normal file
View File

@@ -0,0 +1,27 @@
services:
app:
build: .
container_name: kolaytercih
restart: unless-stopped
volumes:
- app-data:/app/data
env_file:
- .env.production
environment:
- APP_DB_URL=file:/app/data/app.db
networks:
- traefik
labels:
- traefik.enable=true
- traefik.docker.network=traefik
- traefik.http.routers.kolaytercih.rule=Host(`kolaytercih.com`)
- traefik.http.routers.kolaytercih.entrypoints=websecure
- traefik.http.routers.kolaytercih.tls.certresolver=letsencrypt
- traefik.http.services.kolaytercih.loadbalancer.server.port=3000
networks:
traefik:
external: true
volumes:
app-data:

View File

@@ -1,12 +1,13 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: "standalone",
serverExternalPackages: ["better-sqlite3", "iyzipay", "@libsql/client"], serverExternalPackages: ["better-sqlite3", "iyzipay", "@libsql/client"],
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 [
{ source: "/sohbet", destination: "/sonuc", permanent: false },
{ source: "/rapor", destination: "/sonuc", permanent: false }, { source: "/rapor", destination: "/sonuc", permanent: false },
{ source: "/sohbet", destination: "/listem", permanent: false },
]; ];
}, },
}; };

View File

@@ -21,16 +21,24 @@
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.45.2",
"iyzipay": "^2.0.69", "iyzipay": "^2.0.69",
"lucide-react": "^1.25.0", "lucide-react": "^1.25.0",
"marked": "^18.0.7",
"motion": "^12.42.2",
"next": "16.2.10", "next": "16.2.10",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"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",
"react-markdown": "^10.1.0",
"react-use-measure": "^2.1.7",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"resend": "^6.18.0", "resend": "^6.18.0",
"shiki": "^4.3.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",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"use-stick-to-bottom": "^1.1.6",
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {

1127
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

28
public/yok-logo.svg Normal file
View File

@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: visioncortex VTracer 0.6.12 -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="871" height="420" viewBox="75 82 871 420">
<path d="M0,0 L46,0 L54,8 L54,10 L56,10 L56,12 L58,12 L58,14 L60,14 L65,19 L76,30 L76,32 L78,32 L83,37 L96,50 L96,52 L100,54 L118,72 L118,74 L122,76 L158,112 L158,114 L160,114 L160,116 L164,118 L168,118 L178,108 L180,108 L182,104 L184,104 L186,100 L188,100 L188,98 L196,91 L217,70 L219,70 L220,67 L222,67 L222,65 L230,58 L252,36 L254,36 L254,34 L262,27 L287,2 L289,1 L334,1 L332,5 L314,23 L306,30 L286,50 L284,50 L282,54 L270,65 L247,88 L239,95 L218,116 L216,116 L214,120 L199,134 L190,143 L188,143 L186,147 L171,161 L150,182 L148,182 L148,184 L140,191 L111,220 L103,227 L82,248 L74,255 L46,283 L38,290 L14,314 L12,314 L12,316 L4,323 L-3,330 L-50,330 L-44,323 L-25,304 L-23,304 L-23,302 L-15,295 L2,278 L4,278 L4,276 L12,269 L30,251 L38,244 L58,224 L60,224 L60,222 L62,222 L62,220 L70,213 L81,202 L75,195 L61,181 L58,179 L58,177 L54,175 L22,143 L20,142 L20,140 L18,140 L18,138 L14,136 L-16,106 L-24,99 L-56,67 L-64,60 L-93,31 L-95,30 L-95,28 L-99,26 L-121,4 L-121,2 L-78,1 L-67,10 L-32,45 L-28,48 L-28,50 L-24,52 L8,84 L16,91 L46,121 L54,128 L85,159 L93,166 L102,175 L107,173 L115,165 L123,158 L132,150 L134,150 L134,148 L140,143 L138,139 L82,83 L80,82 L80,80 L78,80 L78,78 L76,78 L76,76 L74,76 L74,74 L70,72 L70,70 L68,70 L68,68 L66,68 L66,66 L62,64 L0,2 Z " fill="#e22126" transform="translate(196,82)"/>
<path d="M0,0 L46,0 L44,4 L14,34 L12,34 L12,36 L10,36 L10,38 L2,45 L-39,86 L-44,91 L-54,100 L-86,132 L-88,132 L-88,134 L-90,134 L-90,136 L-92,136 L-85,144 L-71,158 L-68,160 L-68,162 L-64,164 L-33,195 L-25,202 L5,232 L13,239 L37,263 L39,264 L39,266 L43,268 L70,295 L72,296 L72,298 L76,300 L104,328 L104,330 L61,330 L46,315 L44,314 L44,312 L40,310 L40,308 L36,306 L20,290 L12,283 L-8,263 L-10,262 L-10,260 L-14,258 L-35,237 L-43,230 L-62,211 L-64,210 L-64,208 L-66,208 L-66,206 L-68,206 L-68,204 L-72,202 L-92,182 L-100,175 L-114,161 L-119,163 L-134,178 L-142,185 L-153,196 L-147,203 L-134,216 L-127,224 L-111,240 L-111,242 L-109,242 L-102,250 L-80,272 L-73,280 L-56,297 L-49,305 L-32,322 L-26,330 L-68,330 L-78,320 L-86,313 L-118,281 L-126,274 L-155,245 L-163,238 L-179,222 L-184,224 L-184,226 L-192,233 L-213,254 L-221,261 L-246,286 L-254,293 L-281,320 L-289,327 L-292,330 L-338,330 L-332,323 L-305,296 L-297,289 L-270,262 L-268,262 L-268,260 L-266,260 L-266,258 L-258,251 L-229,222 L-221,215 L-206,200 L-204,200 L-202,196 L-194,188 L-192,188 L-192,186 L-184,179 L-157,152 L-149,145 L-121,117 L-113,110 L-83,80 L-75,73 L-50,48 L-48,48 L-48,46 L-40,39 L-9,8 L-7,8 L-7,6 Z " fill="#e22126" transform="translate(840,82)"/>
<path d="M0,0 L48,0 L48,2 L46,2 L46,4 L38,11 L14,35 L6,42 L-20,68 L-28,75 L-53,100 L-61,107 L-84,130 L-86,130 L-88,134 L-92,136 L-92,138 L-100,145 L-120,165 L-128,172 L-158,202 L-166,209 L-187,230 L-189,230 L-191,234 L-201,243 L-222,264 L-230,271 L-259,300 L-267,307 L-288,328 L-332,328 L-332,326 L-324,319 L-281,276 L-276,271 L-266,262 L-216,212 L-208,205 L-166,163 L-164,163 L-162,159 L-157,155 L-152,150 L-104,102 L-102,102 L-101,99 L-93,92 L-49,48 L-47,48 L-45,44 L-29,29 Z " fill="#e22126" transform="translate(598,84)"/>
<path d="M0,0 L47,0 L46,4 L36,13 L11,38 L9,38 L7,42 L5,42 L5,44 L-3,51 L-27,75 L-35,82 L-59,106 L-61,106 L-61,108 L-69,115 L-91,137 L-93,137 L-94,140 L-102,147 L-123,168 L-125,168 L-127,172 L-129,172 L-129,174 L-137,181 L-160,204 L-168,211 L-191,234 L-193,234 L-195,238 L-199,240 L-199,242 L-207,249 L-232,274 L-240,281 L-263,304 L-271,311 L-288,328 L-332,328 L-326,321 L-291,286 L-289,286 L-289,284 L-287,284 L-287,282 L-283,278 L-281,278 L-281,276 L-273,269 L-232,228 L-230,228 L-229,225 L-221,218 L-177,174 L-175,174 L-173,170 L-171,170 L-171,168 L-169,168 L-169,166 L-167,166 L-167,164 L-165,164 L-165,162 L-163,162 L-163,160 L-155,153 L-113,111 L-105,104 L-100,99 L-57,56 L-55,56 L-55,54 L-50,50 L-49,48 L-47,48 L-47,46 L-39,39 Z " fill="#e22126" transform="translate(721,84)"/>
<path d="M0,0 L5,0 L7,3 L14,3 L15,0 L20,1 L18,6 L13,9 L4,8 L0,4 Z " fill="#e22126" transform="translate(405,444)"/>
<path d="M0,0 L13,1 L18,3 L17,8 L14,9 L7,7 L-2,8 L-5,11 L-5,16 L0,19 L14,23 L19,27 L21,32 L20,39 L15,45 L7,48 L-1,48 L-12,44 L-14,40 L-12,38 L-6,39 L1,41 L9,40 L12,38 L13,33 L10,29 L-5,25 L-10,22 L-13,16 L-12,8 L-8,3 Z " fill="#e22126" transform="translate(227,454)"/>
<path d="M0,0 L6,0 L14,15 L16,20 L18,17 L27,0 L33,0 L34,3 L21,27 L20,46 L14,46 L13,45 L12,28 L1,6 L-1,1 Z " fill="#e22126" transform="translate(76,455)"/>
<path d="M0,0 L5,0 L7,3 L7,31 L9,37 L16,40 L23,39 L27,34 L28,1 L29,0 L34,0 L35,1 L35,36 L31,42 L24,46 L22,47 L13,47 L4,43 L0,37 L-1,34 L-1,1 Z " fill="#e22126" transform="translate(121,455)"/>
<path d="M0,0 L5,0 L6,1 L7,22 L20,7 L26,0 L33,0 L32,4 L22,15 L20,19 L31,37 L35,43 L35,46 L28,46 L22,37 L15,26 L11,28 L7,33 L6,45 L5,46 L-1,46 L-1,1 Z " fill="#e22126" transform="translate(170,455)"/>
<path d="M0,0 L31,0 L32,5 L31,6 L7,7 L6,20 L27,20 L28,21 L28,26 L7,27 L6,40 L31,40 L32,45 L31,46 L0,46 L-1,45 L-1,2 Z " fill="#e22126" transform="translate(260,455)"/>
<path d="M0,0 L5,0 L5,23 L10,18 L19,8 L26,0 L32,0 L32,3 L23,14 L20,17 L21,21 L33,41 L35,46 L27,46 L15,26 L12,26 L6,33 L5,46 L-1,46 L-1,1 Z " fill="#e22126" transform="translate(305,455)"/>
<path d="M0,0 L11,0 L18,3 L23,9 L24,13 L24,34 L20,42 L10,47 L1,47 L-7,43 L-11,38 L-12,35 L-12,11 L-8,4 Z M1,6 L-4,10 L-5,12 L-5,34 L-1,39 L7,41 L14,38 L17,34 L17,13 L14,8 L10,6 Z " fill="#e22126" transform="translate(361,455)"/>
<path d="M0,0 L12,0 L19,3 L17,8 L11,7 L2,7 L-3,10 L-5,14 L-5,32 L-2,38 L6,40 L13,38 L14,28 L8,28 L7,27 L7,22 L21,22 L21,41 L16,45 L10,47 L1,47 L-7,43 L-12,37 L-12,10 L-8,4 Z " fill="#e22126" transform="translate(410,455)"/>
<path d="M0,0 L22,0 L29,3 L33,10 L33,18 L29,24 L26,26 L27,31 L33,44 L33,46 L26,46 L18,30 L18,28 L6,28 L6,44 L5,46 L-1,46 L-1,1 Z M6,6 L6,22 L19,22 L25,19 L26,17 L26,11 L23,7 L20,6 Z " fill="#e22126" transform="translate(445,455)"/>
<path d="M0,0 L30,0 L31,1 L31,6 L6,6 L6,20 L27,20 L28,23 L27,26 L6,26 L6,40 L30,40 L31,41 L31,46 L-1,46 L-1,1 Z " fill="#e22126" transform="translate(491,455)"/>
<path d="M0,0 L33,0 L34,5 L33,6 L20,6 L20,46 L13,46 L12,7 L-1,6 L-1,1 Z " fill="#e22126" transform="translate(532,455)"/>
<path d="M0,0 L5,0 L6,6 L6,44 L5,46 L-1,46 L-1,1 Z " fill="#e22126" transform="translate(579,455)"/>
<path d="M0,0 L6,1 L20,28 L21,29 L35,2 L37,0 L42,1 L43,2 L43,45 L42,46 L36,46 L35,18 L24,39 L18,39 L8,21 L6,17 L5,46 L-1,46 L-1,1 Z " fill="#e22126" transform="translate(601,455)"/>
<path d="M0,0 L5,0 L6,1 L7,22 L14,15 L23,4 L27,0 L33,0 L33,3 L21,17 L22,22 L36,44 L36,46 L28,46 L15,25 L10,30 L7,33 L6,46 L0,46 L-1,33 Z " fill="#e22126" transform="translate(680,455)"/>
<path d="M0,0 L5,0 L6,1 L7,35 L8,38 L14,40 L22,39 L26,35 L27,33 L28,1 L29,0 L34,0 L35,2 L35,33 L32,41 L26,45 L21,47 L12,47 L4,43 L0,39 L-1,36 L-1,1 Z " fill="#e22126" transform="translate(726,455)"/>
<path d="M0,0 L22,0 L29,3 L33,9 L33,18 L30,23 L26,28 L33,43 L33,46 L26,46 L18,29 L6,28 L6,46 L0,46 L-1,45 L-1,1 Z M7,6 L6,7 L6,21 L7,22 L20,22 L25,19 L26,18 L26,10 L21,6 Z " fill="#e22126" transform="translate(774,455)"/>
<path d="M0,0 L5,0 L6,1 L7,35 L11,39 L18,40 L25,37 L27,31 L28,0 L34,0 L34,37 L28,44 L21,47 L12,47 L4,43 L-1,37 L-2,33 L-2,2 Z " fill="#e22126" transform="translate(820,455)"/>
<path d="M0,0 L5,0 L6,4 L6,40 L30,40 L31,41 L31,46 L-1,46 L-1,1 Z " fill="#e22126" transform="translate(869,455)"/>
<path d="M0,0 L5,0 L6,1 L7,35 L11,39 L19,40 L25,37 L27,32 L28,1 L29,0 L34,0 L35,1 L35,34 L31,42 L24,46 L21,47 L12,47 L4,43 L0,38 L-1,36 L-1,1 Z " fill="#e22126" transform="translate(911,455)"/>
</svg>

After

Width:  |  Height:  |  Size: 7.5 KiB

View File

@@ -0,0 +1,17 @@
#!/bin/sh
set -e
# yokatlas.db salt-okunur referans verisi: her deploy'da imajdaki güncel kopya volume'e yazılır
cp -f /app/seed/yokatlas.db /app/data/
for ek in wal shm; do
if [ -f "/app/seed/yokatlas.db-$ek" ]; then
cp -f "/app/seed/yokatlas.db-$ek" /app/data/
fi
done
# app.db kullanıcı verisi: sadece volume'de yoksa (ilk kurulum) şema tohumu kopyalanır
if [ ! -s /app/data/app.db ] && [ -f /app/seed/app.db ]; then
cp /app/seed/app.db /app/data/
fi
exec node server.js

View File

@@ -0,0 +1,22 @@
import { NextResponse, type NextRequest } from "next/server";
import { PUAN_TURLERI, rankWindowFacets, type PuanTuruKey } from "@/lib/db";
// Ana sayfadaki sihirbaz modalı için: sıralamaya uygun kategori/il/üniversite
// tipi facet'leri. /sonuc bu veriyi server component'te doğrudan alır; burada
// client'tan sıra girildikten sonra çekilir.
export function GET(request: NextRequest) {
const { searchParams } = request.nextUrl;
const sira = Number.parseInt(searchParams.get("sira") ?? "", 10);
const turParam = searchParams.get("tur") ?? "say";
const tur: PuanTuruKey =
turParam in PUAN_TURLERI ? (turParam as PuanTuruKey) : "say";
if (!Number.isFinite(sira) || sira < 1 || sira > 4_000_000) {
return NextResponse.json(
{ error: "Geçerli bir sıralama gerekli." },
{ status: 400 },
);
}
return NextResponse.json({ facetler: rankWindowFacets(sira, tur) });
}

View File

@@ -8,11 +8,16 @@ import { listeOzetiCikar, type RaporSonuc } from "@/lib/ai/rapor";
import type { RaporParams } from "@/lib/rapor-havuzu"; import type { RaporParams } from "@/lib/rapor-havuzu";
import { secimOzeti } from "@/lib/sihirbaz"; import { secimOzeti } from "@/lib/sihirbaz";
import { PUAN_TURLERI } from "@/lib/db"; import { PUAN_TURLERI } from "@/lib/db";
import { ACIK_SATIR } from "@/lib/rapor-maske";
const MAX_GECMIS = 12; const MAX_GECMIS = 12;
const MAX_MESAJ_UZUNLUK = 2000; const MAX_MESAJ_UZUNLUK = 2000;
function sistemPromptu(opts: { params: RaporParams; listeOzeti: string }): string { function sistemPromptu(opts: {
params: RaporParams;
listeOzeti: string;
kilitli?: boolean;
}): string {
const { params } = opts; const { params } = opts;
const secimVar = const secimVar =
(params.kategoriler?.length ?? 0) > 0 || (params.kategoriler?.length ?? 0) > 0 ||
@@ -29,6 +34,10 @@ iş imkânları, tercih sırası) net ve dürüst yanıtlamak.
KURALLAR: KURALLAR:
- Cevaplarını adayın 24'lük listesi ve seçimleri bağlamında ver; listedeki - Cevaplarını adayın 24'lük listesi ve seçimleri bağlamında ver; listedeki
programlara referans verebilirsin. programlara referans verebilirsin.
- Listedeki her satırda dilim ve risk etiketi var: Güvenli (yeşil) = taban
sıralaması adayın belirgin altında, Az riskli (sarı) = adayın sıralaması
civarında, Riskli (kırmızı) = geçen yıl adayın sıralamasının üzerinde
kapandı. Risk sorularında bu etiketleri ve taban sıralarını kullan.
- ASLA yerleşme garantisi verme; "yüksek/orta/düşük ihtimal" dili kullan. - ASLA yerleşme garantisi verme; "yüksek/orta/düşük ihtimal" dili kullan.
- Bilmediğin güncel veriyi uydurma; emin değilsen YÖK Atlas'a bakmasını söyle. - Bilmediğin güncel veriyi uydurma; emin değilsen YÖK Atlas'a bakmasını söyle.
- Taban sıralamalarının her yıl değiştiğini gerektiğinde hatırlat. - Taban sıralamalarının her yıl değiştiğini gerektiğinde hatırlat.
@@ -39,6 +48,15 @@ KURALLAR:
`ADAY BİLGİSİ: başarı sıralaması ${params.sira.toLocaleString("tr-TR")}, puan türü ${PUAN_TURLERI[params.tur]}.`, `ADAY BİLGİSİ: başarı sıralaması ${params.sira.toLocaleString("tr-TR")}, puan türü ${PUAN_TURLERI[params.tur]}.`,
`ADAYIN 24'LÜK LİSTESİ:\n${opts.listeOzeti}`, `ADAYIN 24'LÜK LİSTESİ:\n${opts.listeOzeti}`,
]; ];
if (opts.kilitli) {
parcalar.push(
`ÖNEMLİ: Adayın paketi yok; listenin yalnızca ilk ${ACIK_SATIR} tercihi açık.
Sana da yalnızca bu satırlar verildi — kilitli tercihlerin içeriğini BİLMİYORSUN,
asla tahmin edip söyleme. Kilitli satırlar sorulursa sempatik biçimde tam listenin
Tercih Dönemi Paketi'yle (299 TL, /paket) açıldığını söyle; açık satırlar ve genel
tercih soruları üzerinden yardımcı olmaya devam et.`,
);
}
if (secimVar) { if (secimVar) {
parcalar.push( parcalar.push(
`ADAYIN SİHİRBAZ SEÇİMLERİ:\n${secimOzeti({ `ADAYIN SİHİRBAZ SEÇİMLERİ:\n${secimOzeti({
@@ -140,11 +158,67 @@ export async function POST(request: NextRequest) {
} }
const kullaniciMesajId = harcama.messageId!; const kullaniciMesajId = harcama.messageId!;
// Paketsiz kullanıcının prompt'una yalnızca açık satırlar girer — aksi
// halde danışman kilitli listeyi sözle sızdırabilir.
const kullanici = await appDb.query.user.findFirst({
where: eq(schema.user.id, session.user.id),
columns: { hasPaket: true },
});
const kilitli = !kullanici?.hasPaket;
const tamOzet = listeOzetiCikar(
rapor.result as RaporSonuc,
(rapor.params as RaporParams).sira,
);
const listeOzeti = kilitli
? [
...tamOzet.split("\n").slice(0, ACIK_SATIR),
`… kalan ${24 - ACIK_SATIR} tercih kilitli.`,
].join("\n")
: tamOzet;
const system = sistemPromptu({ const system = sistemPromptu({
params: rapor.params as RaporParams, params: rapor.params as RaporParams,
listeOzeti: listeOzetiCikar(rapor.result as RaporSonuc), listeOzeti,
kilitli,
}); });
// Dev'de API key yoksa danışman mock cevap stream'ler (prod'da asla)
if (
process.env.NODE_ENV !== "production" &&
!process.env.ANTHROPIC_API_KEY &&
!process.env.ANTHROPIC_AUTH_TOKEN
) {
const mockEncoder = new TextEncoder();
const mockUserId = session.user.id;
const cevap =
`(Dev mock — ANTHROPIC_API_KEY tanımlı değil.) Sorunu aldım: "${mesaj.slice(0, 120)}". ` +
`Gerçek danışman burada listen ve seçimlerin bağlamında yanıt verecek. ` +
`${SORUMLULUK_REDDI}`;
const mockReadable = new ReadableStream({
async start(controller) {
for (const parca of cevap.split(/(?<= )/)) {
controller.enqueue(mockEncoder.encode(parca));
await new Promise((r) => setTimeout(r, 25));
}
await appDb.insert(schema.chatMessages).values({
id: crypto.randomUUID(),
userId: mockUserId,
role: "assistant",
content: cevap,
createdAt: new Date(),
});
controller.close();
},
});
return new Response(mockReadable, {
headers: {
"Content-Type": "text/plain; charset=utf-8",
"X-Content-Type-Options": "nosniff",
"Cache-Control": "no-store",
},
});
}
let client; let client;
try { try {
client = getAnthropic(); client = getAnthropic();
@@ -155,8 +229,12 @@ export async function POST(request: NextRequest) {
reason: "refund", reason: "refund",
refId: kullaniciMesajId, refId: kullaniciMesajId,
}); });
console.error("[soru] AI istemcisi kurulamadı (anahtar eksik olabilir)");
return Response.json( return Response.json(
{ error: "AI altyapısı yapılandırılmadı." }, {
error:
"Danışman şu anda yanıt veremiyor; kredin iade edildi. Birazdan tekrar dener misin?",
},
{ status: 503 }, { status: 503 },
); );
} }

45
src/app/error.tsx Normal file
View File

@@ -0,0 +1,45 @@
"use client";
// Kök hata sınırı: beklenmedik sunucu/istemci hatalarında Next'in çıplak
// hata ekranı yerine markalı, kurtarılabilir bir ekran gösterir.
import { useEffect } from "react";
import Link from "next/link";
import { TriangleAlert } from "lucide-react";
import { Button } from "@/components/ui/button";
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error("[hata-siniri]", error);
}, [error]);
return (
<main className="mx-auto flex min-h-[60vh] w-full max-w-md flex-col items-center justify-center px-4 py-16 text-center">
<TriangleAlert className="size-10 text-amber-500" aria-hidden />
<h1 className="mt-4 font-heading text-2xl font-bold">
Bir şeyler ters gitti
</h1>
<p className="mt-2 text-sm leading-6 text-slate-600">
Beklenmedik bir hata oluştu. Tekrar dener misin? Sorun devam ederse
birkaç dakika sonra yeniden gel verilerin güvende.
</p>
<div className="mt-6 flex gap-3">
<Button
onClick={reset}
className="cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
>
Tekrar dene
</Button>
<Button asChild variant="outline" className="cursor-pointer">
<Link href="/">Ana sayfaya dön</Link>
</Button>
</div>
</main>
);
}

View File

@@ -0,0 +1,61 @@
"use client";
import { TextLoop } from "@/components/core/text-loop";
// Sonuç sayfasından gelen funnel'da dönen sempatik mesajlar.
// Kısa tutulmalı — başlıkta tek satır, taşma yok.
const FUNNEL_MESAJLARI = [
"1 adım kaldı tercihine",
"Listen seni bekliyor 👀",
"24 tercih, hepsi sana göre",
"5 kredin hazır, ya sen?",
];
/**
* Giriş sayfası başlığı. Kullanıcı sihirbazdan (callback /sonuc'a dönüyorsa)
* geldiyse TextLoop'la funnel mesajları döner; diğer girişlerde sabit başlık.
*/
export function GirisBaslik({ funnel }: { funnel: boolean }) {
if (!funnel) {
return <h1 className="font-heading text-2xl font-bold">Giriş yap</h1>;
}
return (
<h1 className="font-heading text-2xl font-bold">
<TextLoop
className="overflow-y-clip"
interval={2.5}
transition={{
type: "spring",
stiffness: 900,
damping: 80,
mass: 10,
}}
variants={{
initial: {
y: 20,
rotateX: 90,
opacity: 0,
filter: "blur(4px)",
},
animate: {
y: 0,
rotateX: 0,
opacity: 1,
filter: "blur(0px)",
},
exit: {
y: -20,
rotateX: -90,
opacity: 0,
filter: "blur(4px)",
},
}}
>
{FUNNEL_MESAJLARI.map((mesaj) => (
<span key={mesaj}>{mesaj}</span>
))}
</TextLoop>
</h1>
);
}

View File

@@ -34,6 +34,21 @@ export function GirisForm({ callbackURL }: { callbackURL: string }) {
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [busy, setBusy] = useState<"google" | "email" | null>(null); const [busy, setBusy] = useState<"google" | "email" | null>(null);
const [sent, setSent] = useState(false); const [sent, setSent] = useState(false);
// Yeniden gönderim arası bekleme (sn) — istemeden spam'lenmesin
const [bekleme, setBekleme] = useState(0);
function geriSayimBaslat() {
setBekleme(30);
const sayac = setInterval(() => {
setBekleme((s) => {
if (s <= 1) {
clearInterval(sayac);
return 0;
}
return s - 1;
});
}, 1000);
}
async function googleIleGiris() { async function googleIleGiris() {
setBusy("google"); setBusy("google");
@@ -47,8 +62,8 @@ export function GirisForm({ callbackURL }: { callbackURL: string }) {
} }
} }
async function linkGonder(e: React.FormEvent) { async function linkGonder(e?: React.FormEvent) {
e.preventDefault(); e?.preventDefault();
if (!/^\S+@\S+\.\S+$/.test(email)) { if (!/^\S+@\S+\.\S+$/.test(email)) {
toast.error("Geçerli bir e-posta adresi gir."); toast.error("Geçerli bir e-posta adresi gir.");
return; return;
@@ -64,17 +79,41 @@ export function GirisForm({ callbackURL }: { callbackURL: string }) {
return; return;
} }
setSent(true); setSent(true);
geriSayimBaslat();
} }
if (sent) { if (sent) {
return ( return (
<div className="mt-6 rounded-xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800"> <div className="mt-6 space-y-3">
<p className="font-semibold">Bağlantı gönderildi 📬</p> <div className="rounded-xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800">
<p className="mt-1"> <p className="font-semibold">Bağlantı gönderildi 📬</p>
<span className="font-medium">{email}</span> adresine bir giriş <p className="mt-1">
bağlantısı gönderdik. Gelen kutunu (ve spam klasörünü) kontrol et <span className="font-medium">{email}</span> adresine bir giriş
bağlantı 5 dakika geçerli. bağlantısı gönderdik. Gelen kutunu (ve spam klasörünü) kontrol et
</p> bağlantı 5 dakika geçerli.
</p>
</div>
<div className="flex items-center justify-center gap-4 text-sm">
<button
type="button"
onClick={() => linkGonder()}
disabled={busy !== null || bekleme > 0}
className="cursor-pointer font-medium text-primary underline-offset-2 hover:underline disabled:cursor-default disabled:text-slate-400 disabled:no-underline"
>
{busy === "email"
? "Gönderiliyor…"
: bekleme > 0
? `Yeniden gönder (${bekleme})`
: "Yeniden gönder"}
</button>
<button
type="button"
onClick={() => setSent(false)}
className="cursor-pointer text-slate-500 underline-offset-2 hover:underline"
>
E-postayı düzelt
</button>
</div>
</div> </div>
); );
} }

39
src/app/giris/loading.tsx Normal file
View File

@@ -0,0 +1,39 @@
import { Skeleton } from "@/components/ui/skeleton";
// /giris açılırken spinner yerine sayfanın kendi iskeleti: kart, başlık,
// Google butonu, ayraç, e-posta alanı ve gönder butonu aynı yerde belirir —
// içerik gelince zıplama olmaz.
export default function GirisLoading() {
return (
<div className="flex min-h-screen flex-col bg-slate-50">
<main className="mx-auto flex w-full max-w-md flex-1 flex-col items-stretch justify-center px-4 py-16">
<div className="rounded-2xl border border-slate-200 bg-white p-8 shadow-sm">
{/* Başlık + alt metin */}
<Skeleton className="h-8 w-40" />
<Skeleton className="mt-3 h-4 w-full" />
<Skeleton className="mt-1.5 h-4 w-2/3" />
{/* Google ile devam et */}
<Skeleton className="mt-6 h-11 w-full rounded-lg" />
{/* "veya" ayracı */}
<div className="mt-4 flex items-center gap-3">
<Skeleton className="h-px flex-1" />
<Skeleton className="h-3 w-8" />
<Skeleton className="h-px flex-1" />
</div>
{/* E-posta alanı + gönder butonu */}
<Skeleton className="mt-4 h-11 w-full rounded-lg" />
<Skeleton className="mt-3 h-11 w-full rounded-lg" />
</div>
{/* Kart altındaki yasal metin */}
<div className="mt-6 flex flex-col items-center gap-1.5">
<Skeleton className="h-3 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</div>
</main>
</div>
);
}

View File

@@ -1,7 +1,9 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getSession } from "@/lib/session"; import { getSession } from "@/lib/session";
import { GirisForm } from "./giris-form"; import { GirisForm } from "./giris-form";
import { GirisBaslik } from "./giris-baslik";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Giriş — KolayTercih", title: "Giriş — KolayTercih",
@@ -19,20 +21,41 @@ export default async function GirisPage({
const session = await getSession(); const session = await getSession();
if (session) redirect(callbackURL); if (session) redirect(callbackURL);
// Sihirbaz funnel'ı: seçimler yapıldı, listeye giriş kaldı
const funnel = callbackURL.startsWith("/sonuc");
return ( return (
<div className="flex min-h-screen flex-col bg-slate-50 text-slate-900"> <div className="flex min-h-screen flex-col bg-slate-50 text-slate-900">
<main className="mx-auto flex w-full max-w-md flex-1 flex-col items-stretch justify-center px-4 py-16"> <main className="mx-auto flex w-full max-w-md flex-1 flex-col items-stretch justify-center px-4 py-16">
<div className="rounded-2xl border border-slate-200 bg-white p-8 shadow-sm"> <div className="rounded-2xl border border-slate-200 bg-white p-8 shadow-sm">
<h1 className="font-heading text-2xl font-bold">Giriş yap</h1> <GirisBaslik funnel={funnel} />
<p className="mt-2 text-sm text-slate-600"> <p className="mt-2 text-sm text-slate-600">
Kayıt olduğunda <span className="font-semibold">5 deneme kredisi</span>{" "} {funnel ? (
hesabına tanımlanır AI danışmana ücretsiz soru sorabilirsin. <>
Seçimlerin hazır giriş yap,{" "}
<span className="font-semibold">5 deneme kredinle</span> listen
hemen üretilsin.
</>
) : (
<>
Kayıt olduğunda{" "}
<span className="font-semibold">5 deneme kredisi</span> hesabına
tanımlanır AI danışmana ücretsiz soru sorabilirsin.
</>
)}
</p> </p>
<GirisForm callbackURL={callbackURL} /> <GirisForm callbackURL={callbackURL} />
</div> </div>
<p className="mt-6 text-center text-xs leading-relaxed text-slate-500"> <p className="mt-6 text-center text-xs leading-relaxed text-slate-500">
Giriş yaparak verilerinin tercih önerileri üretmek amacıyla Giriş yaparak verilerinin tercih önerileri üretmek amacıyla{" "}
işlenmesini kabul etmiş olursun. KolayTercih yerleşme garantisi vermez. <Link href="/gizlilik" className="underline hover:text-slate-700">
gizlilik metnindeki
</Link>{" "}
gibi işlenmesini ve{" "}
<Link href="/kosullar" className="underline hover:text-slate-700">
kullanım koşullarını
</Link>{" "}
kabul etmiş olursun. KolayTercih yerleşme garantisi vermez.
</p> </p>
</main> </main>
</div> </div>

66
src/app/gizlilik/page.tsx Normal file
View File

@@ -0,0 +1,66 @@
import type { Metadata } from "next";
import Link from "next/link";
export const metadata: Metadata = {
title: "Gizlilik ve KVKK — KolayTercih",
};
// NOT: MVP taslağıdır; yayına almadan önce hukuki gözden geçirme gerekir.
export default function GizlilikPage() {
return (
<main className="mx-auto w-full max-w-2xl px-4 py-16">
<h1 className="font-heading text-3xl font-bold">
Gizlilik ve Kişisel Verilerin Korunması
</h1>
<p className="mt-2 text-sm text-slate-500">Son güncelleme: 26 Temmuz 2026</p>
<div className="mt-8 space-y-6 text-sm leading-7 text-slate-700">
<section>
<h2 className="font-heading text-lg font-bold text-slate-900">
Hangi verileri işliyoruz?
</h2>
<p className="mt-2">
KolayTercih; e-posta adresini (hesap oluşturma ve giriş için), YKS
başarı sıralamanı ve sihirbazda yaptığın seçimleri (tercih listeni
üretmek için), danışman sohbetine yazdığın mesajları (sorularını
yanıtlamak için) ve ödeme yaptığında sipariş kayıtlarını işler.
Kart bilgilerin bize hiç ulaşmaz; ödeme iyzico altyapısında
gerçekleşir.
</p>
</section>
<section>
<h2 className="font-heading text-lg font-bold text-slate-900">
Ne için kullanıyoruz?
</h2>
<p className="mt-2">
Verilerin yalnızca tercih önerileri üretmek, hesabını ve kredi
bakiyeni yönetmek ve sana hizmetle ilgili e-posta (giriş bağlantısı,
ödeme bilgilendirmesi) göndermek için kullanılır. Verilerini
üçüncü taraflara satmayız, reklam amacıyla paylaşmayız.
</p>
</section>
<section>
<h2 className="font-heading text-lg font-bold text-slate-900">
Saklama ve silme
</h2>
<p className="mt-2">
Hesabını sildirmek veya verilerinin bir kopyasını istemek için{" "}
<Link href="/iletisim" className="text-primary underline">
iletişim sayfasındaki
</Link>{" "}
adresten bize yazabilirsin; talepler 30 gün içinde sonuçlandırılır.
</p>
</section>
<section>
<h2 className="font-heading text-lg font-bold text-slate-900">
Çerezler
</h2>
<p className="mt-2">
Yalnızca oturumunu ık tutmak için zorunlu çerezler kullanılır;
reklam veya izleme çerezi kullanılmaz.
</p>
</section>
</div>
</main>
);
}

View File

@@ -117,6 +117,50 @@
--sidebar-ring: oklch(0.556 0 0); --sidebar-ring: oklch(0.556 0 0);
} }
/* TypingAnimation imleci (animate-blink-cursor utility'si) */
@theme {
--animate-blink-cursor: blink-cursor 1s step-end infinite;
@keyframes blink-cursor {
50% {
opacity: 0;
}
}
}
/* Ödeme dönüşünde kilidi açılan listenin blur çözülme animasyonu */
@keyframes kt-unblur {
from {
filter: blur(12px);
opacity: 0.4;
}
to {
filter: blur(0);
opacity: 1;
}
}
.kt-unblur {
animation: kt-unblur 0.9s ease-out both;
}
/* Hareket azaltılmışsa blur çözülmesi yerine kısa, yumuşak bir belirme:
kavramayı sağlayan geri bildirim kalır, gösterişli efekt düşer. */
@media (prefers-reduced-motion: reduce) {
.kt-unblur {
animation: kt-unblur-sade 0.25s ease-out both;
}
}
@keyframes kt-unblur-sade {
from {
opacity: 0.6;
}
to {
opacity: 1;
}
}
@layer base { @layer base {
* { * {
@apply border-border outline-ring/50; @apply border-border outline-ring/50;

30
src/app/iletisim/page.tsx Normal file
View File

@@ -0,0 +1,30 @@
import type { Metadata } from "next";
import { Mail } from "lucide-react";
export const metadata: Metadata = {
title: "İletişim — KolayTercih",
};
export default function IletisimPage() {
return (
<main className="mx-auto w-full max-w-2xl px-4 py-16">
<h1 className="font-heading text-3xl font-bold">İletişim</h1>
<p className="mt-4 text-sm leading-7 text-slate-700">
Ödeme, iade, hesap silme ya da başka bir konuda yardıma mı ihtiyacın
var? Tercih döneminde mesajlara aynı gün dönmeye çalışıyoruz.
</p>
<a
href="mailto:destek@kolaytercih.com"
className="mt-6 inline-flex items-center gap-2 rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm font-semibold text-slate-900 shadow-xs transition-colors hover:border-slate-300 hover:bg-slate-50"
>
<Mail className="size-4 text-primary" aria-hidden />
destek@kolaytercih.com
</a>
<p className="mt-6 text-xs text-slate-500">
Ödemenle ilgili yazıyorsan e-postana sipariş tarihini ve kullandığın
hesap adresini eklersen daha hızlı yardımcı olabiliriz. Kart bilgini
asla e-postayla paylaşma.
</p>
</main>
);
}

74
src/app/kosullar/page.tsx Normal file
View File

@@ -0,0 +1,74 @@
import type { Metadata } from "next";
import Link from "next/link";
export const metadata: Metadata = {
title: "Kullanım ve Satış Koşulları — KolayTercih",
};
// NOT: MVP taslağıdır; yayına almadan önce hukuki gözden geçirme gerekir.
export default function KosullarPage() {
return (
<main className="mx-auto w-full max-w-2xl px-4 py-16">
<h1 className="font-heading text-3xl font-bold">
Kullanım ve Satış Koşulları
</h1>
<p className="mt-2 text-sm text-slate-500">Son güncelleme: 26 Temmuz 2026</p>
<div className="mt-8 space-y-6 text-sm leading-7 text-slate-700">
<section>
<h2 className="font-heading text-lg font-bold text-slate-900">
Hizmetin kapsamı
</h2>
<p className="mt-2">
KolayTercih, resmî YÖK Atlas verilerine dayanan bir karar destek
aracıdır. Öneriler bilgilendirme amaçlıdır; KolayTercih yerleşme
garantisi vermez ve ÖSYM ya da YÖK ile bağlantılı değildir. Tercih
listenin son hali ve başvuru sorumluluğu sana aittir.
</p>
</section>
<section>
<h2 className="font-heading text-lg font-bold text-slate-900">
Ücretlendirme
</h2>
<p className="mt-2">
Temel program arama ücretsizdir. Tercih Dönemi Paketi tek seferlik
bir satın almadır; abonelik değildir, otomatik yenilenmez. Paket
içeriği ve güncel fiyat{" "}
<Link href="/paket" className="text-primary underline">
paket sayfasında
</Link>{" "}
yazar.
</p>
</section>
<section id="iade" className="scroll-mt-24">
<h2 className="font-heading text-lg font-bold text-slate-900">
İade ve cayma
</h2>
<p className="mt-2">
Paket, satın alır almaz ifasına başlanan dijital bir içeriktir.
Buna rağmen kişisel listen henüz üretilmemişse (paket hiç
kullanılmamışsa) satın almadan itibaren 14 gün içinde koşulsuz
iade talep edebilirsin. Teknik bir sorun nedeniyle hizmeti
alamadıysan, kullanımdan bağımsız olarak ücretin iade edilir.
İade talepleri için{" "}
<Link href="/iletisim" className="text-primary underline">
bize yaz
</Link>
; talepler 14 gün içinde sonuçlandırılır ve iade, ödeme yaptığın
karta yapılır.
</p>
</section>
<section>
<h2 className="font-heading text-lg font-bold text-slate-900">
Hesap ve adil kullanım
</h2>
<p className="mt-2">
Krediler hesabına tanımlıdır, kişiseldir ve üçüncü kişilere
devredilemez. Hizmeti kötüye kullanan (otomatik istek, içerik
kazıma vb.) hesaplar kapatılabilir.
</p>
</section>
</div>
</main>
);
}

View File

@@ -2,8 +2,10 @@ import type { Metadata } from "next";
import { Outfit, Work_Sans, Geist_Mono } from "next/font/google"; import { Outfit, Work_Sans, Geist_Mono } from "next/font/google";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
import { SiteHeader } from "@/components/site-header"; import { SiteHeader } from "@/components/site-header";
import { ListeCekmecesi } from "@/components/manuel-liste/liste-cekmecesi";
import { HeaderGate } from "@/components/header-gate"; import { HeaderGate } from "@/components/header-gate";
import { ProgressiveBlur } from "@/components/ui/skiper-ui/skiper41"; import { ProgressiveBlur } from "@/components/ui/skiper-ui/skiper41";
import { DevPanel } from "@/components/dev/dev-panel";
import "./globals.css"; import "./globals.css";
const outfit = Outfit({ const outfit = Outfit({
@@ -22,7 +24,7 @@ const geistMono = Geist_Mono({
}); });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "KolayTercih — Cebindeki Tercih Danışmanı", title: "KolayTercih — Tercih Danışmanı",
description: description:
"YKS sıralamana göre gerçek YÖK Atlas verisiyle dengeli 24 tercihlik liste. İnsan danışmanın onda bir fiyatına, yapay zekâ destekli tercih danışmanlığı.", "YKS sıralamana göre gerçek YÖK Atlas verisiyle dengeli 24 tercihlik liste. İnsan danışmanın onda bir fiyatına, yapay zekâ destekli tercih danışmanlığı.",
}; };
@@ -59,7 +61,11 @@ export default function RootLayout({
<SiteHeader /> <SiteHeader />
</HeaderGate> </HeaderGate>
{children} {children}
{/* Manuel 24'lük liste çekmecesi + "+" uçuş katmanı (navbar'dan açılır) */}
<ListeCekmecesi />
<Toaster position="top-center" /> <Toaster position="top-center" />
{/* Dev süperadmin paneli — prod build'de hiç render edilmez */}
{process.env.NODE_ENV !== "production" ? <DevPanel /> : null}
</body> </body>
</html> </html>
); );

View File

@@ -0,0 +1,19 @@
import { Skeleton } from "@/components/ui/skeleton";
export default function ListemLoading() {
return (
<main className="mx-auto w-full max-w-7xl px-4 py-10">
<Skeleton className="h-6 w-40 rounded-full" />
<Skeleton className="mt-4 h-9 w-3/4 max-w-xl" />
<Skeleton className="mt-3 h-5 w-2/3 max-w-lg" />
<div className="mt-8 grid items-start gap-8 lg:grid-cols-[minmax(0,1fr)_340px]">
<div className="flex flex-col gap-2">
{Array.from({ length: 10 }).map((_, i) => (
<Skeleton key={i} className="h-16 w-full rounded-xl" />
))}
</div>
<Skeleton className="h-[480px] w-full rounded-2xl" />
</div>
</main>
);
}

334
src/app/listem/page.tsx Normal file
View File

@@ -0,0 +1,334 @@
import type { Metadata } from "next";
import Link from "next/link";
import { eq } from "drizzle-orm";
import { redirect } from "next/navigation";
import {
AlertTriangle,
ArrowRight,
Bot,
Check,
Database,
FileText,
Lock,
MessageCircleQuestion,
PencilLine,
ShieldCheck,
Sparkles,
} from "lucide-react";
import { verifySession, getCurrentUser } from "@/lib/session";
import { appDb, schema } from "@/lib/appdb";
import { PUAN_TURLERI } from "@/lib/db";
import { URUNLER, MAX_REVIZYON } from "@/lib/credits";
import type { RaporSonuc } from "@/lib/ai/rapor";
import type { RaporParams } from "@/lib/rapor-havuzu";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { RaporListesi } from "@/components/rapor-listesi";
import { raporMaskele, ACIK_SATIR } from "@/lib/rapor-maske";
import { SohbetClient } from "./sohbet-client";
import { RevizyonKutusu } from "./revizyon-kutusu";
export const metadata: Metadata = {
title: "Listem — KolayTercih",
};
const PAKET_FIYATI = (URUNLER.paket.amountKurus / 100).toLocaleString("tr-TR", {
maximumFractionDigits: 0,
});
export default async function ListemPage({
searchParams,
}: {
searchParams: Promise<{ acildi?: string }>;
}) {
await verifySession("/listem");
const user = await getCurrentUser();
if (!user) redirect("/giris?callback=/listem");
// ?acildi=1: ödeme dönüşü — kilidin kalktığı tek seferlik açılış deneyimi
const { acildi } = await searchParams;
const kilitAcilisi = Boolean(user.hasPaket && acildi === "1");
const satir = await appDb.query.reports.findFirst({
where: eq(schema.reports.userId, user.id),
});
// Liste yoksa önce sihirbazdan oluşturulmalı
if (!satir?.result) {
return (
<main className="mx-auto flex max-w-2xl flex-col items-center px-4 py-24 text-center">
<Sparkles className="size-10 text-primary" aria-hidden />
<h1 className="mt-4 font-heading text-2xl font-bold">
Henüz bir listen yok
</h1>
<p className="mt-3 text-slate-600">
Sıralamanı girip birkaç adımda 24 tercihlik risk analizli listeni kur;
sonra burada görüntüleyip AI danışmanla üzerinde konuşabilirsin.
</p>
<Button
asChild
size="lg"
className="mt-6 h-12 cursor-pointer bg-orange-500 px-8 text-white hover:bg-orange-600"
>
<Link href="/">Sıralamanı gir, listeni kur</Link>
</Button>
</main>
);
}
// Paketsize giden rapor burada da maskelenir (bkz. rapor-maske)
const rapor = user.hasPaket
? (satir.result as RaporSonuc)
: raporMaskele(satir.result as RaporSonuc);
const params = satir.params as RaporParams;
return (
<main className="mx-auto w-full max-w-7xl px-4 py-8 sm:py-10">
{kilitAcilisi ? (
<div
role="status"
className="mb-6 flex items-center gap-3 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-medium text-emerald-800"
>
<Sparkles className="size-4 shrink-0" aria-hidden />
Paketin aktif 24 tercihinin tamamı, gerekçeler, riskler ve PDF
artık ık. 🎉
</div>
) : null}
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
{user.hasPaket ? (
<Badge variant="secondary" className="mb-3">
<ShieldCheck aria-hidden />
Paketin aktif
</Badge>
) : (
<Badge variant="secondary" className="mb-3">
<Database aria-hidden />
YÖK Atlas verileri ücretsiz
</Badge>
)}
<h1 className="max-w-2xl font-heading text-2xl font-bold tracking-tight sm:text-3xl">
{user.hasPaket
? "Tercih karar merkezin"
: "Veriyi ücretsiz incele. Kararı AI ile netleştir."}
</h1>
<p className="mt-2 max-w-2xl text-sm leading-6 text-slate-600">
{params.sira.toLocaleString("tr-TR")}. sıra ·{" "}
{PUAN_TURLERI[params.tur]} ·{" "}
{user.hasPaket
? "24 tercihinin tamamını gerekçe, risk analizi ve trendleriyle aşağıda inceleyebilirsin."
: "24 programın 20242025 taban sıralamalarını ve ham trendini aşağıda ücretsiz inceleyebilirsin."}
</p>
</div>
{user.hasPaket ? (
<div className="flex gap-2">
<Button
asChild
variant="outline"
size="sm"
className="cursor-pointer"
>
<Link href="#revizyon">
<PencilLine className="size-4" aria-hidden />
Revize et
</Link>
</Button>
<Button
asChild
variant="outline"
size="sm"
className="cursor-pointer"
>
<Link href="/rapor/yazdir" target="_blank">
<FileText className="size-4" aria-hidden />
PDF
</Link>
</Button>
</div>
) : null}
</div>
{/* AI'ın genel değerlendirmesi ve uyarıları: paketliye tam,
paketsize sunucuda kırpılmış önizleme + kilit notu */}
{rapor.genelDegerlendirme ? (
<section className="mt-6 rounded-2xl border border-slate-200 bg-white p-5">
<h2 className="flex items-center gap-2 font-heading text-base font-bold">
<Bot className="size-4 text-primary" aria-hidden />
Danışman özeti
</h2>
<p className="mt-2 text-sm leading-6 text-slate-700">
{rapor.genelDegerlendirme}
</p>
{user.hasPaket && rapor.uyarilar?.length ? (
<ul className="mt-3 flex flex-col gap-2">
{rapor.uyarilar.map((uyari) => (
<li
key={uyari}
className="flex items-start gap-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900"
>
<AlertTriangle
className="mt-0.5 size-4 shrink-0"
aria-hidden
/>
{uyari}
</li>
))}
</ul>
) : null}
{!user.hasPaket ? (
<p className="mt-3 flex items-center gap-1.5 text-xs text-slate-500">
<Lock className="size-3.5" aria-hidden />
Değerlendirmenin tamamı ve sıralamana özel uyarılar pakette.
</p>
) : null}
</section>
) : null}
<div className="mt-8 grid items-start gap-8 lg:grid-cols-[minmax(0,1fr)_340px]">
<div className="flex min-w-0 flex-col gap-6">
<section
className={`min-w-0 rounded-2xl border border-slate-200 bg-slate-50/60 p-3 sm:p-5${
kilitAcilisi ? " kt-unblur" : ""
}`}
>
<RaporListesi
rapor={rapor}
adaySira={params.sira}
kilitliBaslangic={user.hasPaket ? undefined : ACIK_SATIR}
kilitOverlay={
<div className="flex flex-col items-start gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex gap-3">
<span className="flex size-10 shrink-0 items-center justify-center rounded-full bg-orange-100 text-orange-700">
<Lock className="size-4" aria-hidden />
</span>
<div>
<p className="font-heading font-bold text-slate-900">
İlk {ACIK_SATIR} tercihte AI analizini gördün
</p>
<p className="mt-1 max-w-xl text-sm text-slate-600">
Kalan {24 - ACIK_SATIR} tercihin kişisel risk, gerekçe ve
AI yorumu paketle ı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 {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} />
) : (
<div className="flex flex-col gap-6">
<section className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm">
<div className="border-b border-slate-100 p-6">
<div className="flex items-center gap-2 text-primary">
<Sparkles className="size-4" aria-hidden />
<span className="text-xs font-bold uppercase tracking-wide">
Tercih Dönemi Paketi
</span>
</div>
<h2 className="mt-3 font-heading text-xl font-bold tracking-tight">
Tablo ücretsiz.
<br />
Karar desteği pakette.
</h2>
<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 ıklar.
</p>
</div>
<div className="p-6">
<ul className="flex flex-col gap-3 text-sm">
{[
"24 tercihin tamamında kişisel risk analizi",
"Her program için kısa, gerekçeli AI yorumu",
"2 liste revizyonu ve paylaşılabilir PDF",
`${URUNLER.paket.credits} AI danışman sorusu`,
].map((ozellik) => (
<li key={ozellik} className="flex items-start gap-2.5">
<Check
className="mt-0.5 size-4 shrink-0 text-emerald-600"
aria-hidden
/>
<span>{ozellik}</span>
</li>
))}
</ul>
<div className="mt-6 flex items-end justify-between">
<div>
<p className="text-xs text-slate-500">Tek seferlik</p>
<p className="font-heading text-3xl font-bold">
{PAKET_FIYATI} TL
</p>
</div>
<Badge variant="outline">Abonelik yok</Badge>
</div>
<Button
asChild
size="lg"
className="mt-5 h-12 w-full cursor-pointer bg-orange-500 text-white transition-[background-color,transform] duration-200 active:scale-[0.97] hover:bg-orange-600"
>
<Link href="/paket">
Kişisel analizi
<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>
{/* Deneme kredileri sohbette geçerli: AI'ı satın almadan önce
tattırır (kalan kredi biterse composer paket CTA'sına döner). */}
<SohbetClient
baslangicKredi={user.creditBalance}
className="h-[480px]"
/>
</div>
)}
</div>
</div>
{/* Mobilde sohbet sağ rayla birlikte en alta düşer; yüzen kısayol
kullanıcıyı 24 satırı kaydırmadan danışmana ulaştırır. */}
<a
href="#sohbet"
className="fixed bottom-5 right-4 z-40 inline-flex h-11 items-center gap-2 rounded-full bg-primary px-4 text-sm font-semibold text-white shadow-lg transition-transform duration-200 active:scale-[0.97] lg:hidden print:hidden"
>
<MessageCircleQuestion className="size-4" aria-hidden />
Danışmana sor
</a>
</main>
);
}

View File

@@ -0,0 +1,96 @@
"use client";
// Paketlinin geri bildirime dayalı liste revizyonu (/listem üzerinde).
// listeRevize sunucu aksiyonunu çağırır; başarıda sayfayı yeniler ki
// sunucuda güncellenen rapor ve kredi bakiyesi ekrana yansısın.
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Loader2, RefreshCcw } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { listeRevize } from "@/app/sonuc/actions";
export function RevizyonKutusu({ kalanHak }: { kalanHak: number }) {
const [feedback, setFeedback] = useState("");
const [pending, setPending] = useState(false);
const router = useRouter();
if (kalanHak <= 0) {
return (
<section
id="revizyon"
className="scroll-mt-24 rounded-2xl border border-slate-200 bg-white p-5"
>
<p className="text-sm text-slate-500">
Revizyon hakların doldu. Danışman sohbetinden listen hakkında soru
sormaya devam edebilirsin.
</p>
</section>
);
}
async function revizeEt() {
setPending(true);
try {
const sonuc = await listeRevize({
feedback: feedback.trim(),
requestId: crypto.randomUUID(),
});
if (sonuc.ok) {
toast.success("Listen yeniden kuruldu.");
setFeedback("");
router.refresh();
} else {
toast.error(sonuc.error);
}
} catch {
toast.error("Beklenmeyen bir hata oluştu, tekrar dener misin?");
} finally {
setPending(false);
}
}
return (
<section
id="revizyon"
className="scroll-mt-24 rounded-2xl border border-slate-200 bg-white p-5"
>
<h2 className="font-heading text-base font-bold">
Listede değişiklik mi istiyorsun?
</h2>
<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={revizeEt}
className="cursor-pointer"
>
{pending ? (
<Loader2 className="size-4 animate-spin" aria-hidden />
) : (
<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>
</section>
);
}

View File

@@ -0,0 +1,260 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { ArrowUp, Coins, Sparkles } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
ChatContainerContent,
ChatContainerRoot,
ChatContainerScrollAnchor,
} from "@/components/ui/chat-container";
import { Message, MessageContent } from "@/components/ui/message";
import {
PromptInput,
PromptInputAction,
PromptInputActions,
PromptInputTextarea,
} from "@/components/ui/prompt-input";
import { PromptSuggestion } from "@/components/ui/prompt-suggestion";
import { ScrollButton } from "@/components/ui/scroll-button";
import { DotsLoader } from "@/components/ui/loader";
interface Mesaj {
id: string;
role: "user" | "assistant";
content: string;
}
const ORNEK_SORULAR = [
"Listemdeki kırmızı (riskli) tercihler ne kadar riskli?",
"İlk 5 tercihimin taban sıralaması trendi nasıl?",
"Güvenli dilimden bir tercihi daha üste alsam mantıklı mı?",
"X bölümü ile Y bölümü arasında kalırsam neye bakmalıyım?",
];
/**
* Listem sayfasındaki danışman sohbeti. /api/soru ile konuşur:
* GET geçmiş + kredi, POST 1 kredi düşerek cevabı stream eder.
*/
export function SohbetClient({
baslangicKredi,
className,
}: {
baslangicKredi: number;
/** Boyutlandırma override'ı (ör. alt popup'ta h-full) */
className?: string;
}) {
const [mesajlar, setMesajlar] = useState<Mesaj[]>([]);
const [girdi, setGirdi] = useState("");
const [bekliyor, setBekliyor] = useState(false);
const [yuklendi, setYuklendi] = useState(false);
const [kredi, setKredi] = useState(baslangicKredi);
useEffect(() => {
fetch("/api/soru")
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
if (data?.mesajlar) setMesajlar(data.mesajlar);
if (typeof data?.kredi === "number") setKredi(data.kredi);
})
.catch(() => {})
.finally(() => setYuklendi(true));
}, []);
async function gonder(metin: string) {
const mesaj = metin.trim();
if (!mesaj || bekliyor) return;
if (kredi < 1) {
toast.error("Kredin bitti — devam etmek için kredi yükle.");
return;
}
setGirdi("");
setBekliyor(true);
const clientMessageId = crypto.randomUUID();
const asistanId = crypto.randomUUID();
setMesajlar((m) => [
...m,
{ id: clientMessageId, role: "user", content: mesaj },
{ id: asistanId, role: "assistant", content: "" },
]);
try {
const res = await fetch("/api/soru", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: mesaj, clientMessageId }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setMesajlar((m) =>
m.filter((x) => x.id !== asistanId && x.id !== clientMessageId),
);
if (res.status === 402) {
toast.error("Kredin bitti — kredi yükleyerek devam edebilirsin.");
setKredi(0);
} else {
toast.error(data.error ?? "Bir sorun oluştu, tekrar dener misin?");
}
return;
}
setKredi((k) => k - 1);
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let birikmis = "";
for (;;) {
const { done, value } = await reader.read();
if (done) break;
birikmis += decoder.decode(value, { stream: true });
const anlik = birikmis;
setMesajlar((m) =>
m.map((x) => (x.id === asistanId ? { ...x, content: anlik } : x)),
);
}
} catch {
toast.error("Bağlantı koptu. Cevabı sayfayı yenileyerek görebilirsin.");
} finally {
setBekliyor(false);
}
}
const krediYok = kredi < 1;
return (
<section
aria-label="Danışman sohbeti"
className={cn(
"flex flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white",
className ?? "h-[calc(100dvh-9rem)] min-h-[480px]",
)}
>
<div className="flex items-center justify-between border-b border-slate-100 px-4 py-3">
<h2 className="font-heading text-base font-bold">Danışmanla konuş</h2>
<Link
href="/paket"
className="inline-flex items-center gap-1.5 rounded-full border border-amber-200 bg-amber-50 px-2.5 py-1 text-xs font-semibold text-amber-700 transition-colors hover:bg-amber-100"
title="Kredilerin — yüklemek için tıkla"
>
<Coins className="size-3.5" aria-hidden />
{kredi} kredi
</Link>
</div>
<div className="relative flex-1 overflow-hidden">
<ChatContainerRoot className="h-full">
<ChatContainerContent className="gap-4 p-4">
{mesajlar.length === 0 ? (
<div className="flex flex-col items-center gap-4 py-10 text-center">
<Sparkles className="size-7 text-primary" aria-hidden />
<div>
<p className="font-heading font-bold">
Listen hakkında her şeyi sorabilirsin
</p>
<p className="mx-auto mt-1 max-w-md text-sm text-slate-600">
Cevaplar senin 24&apos;lük listen, seçimlerin ve gerçek
YÖK Atlas verisi bağlamında üretilir. Her mesaj 1 kredi.
</p>
</div>
{yuklendi ? (
<div className="flex max-w-lg flex-wrap justify-center gap-2">
{ORNEK_SORULAR.map((s) => (
<PromptSuggestion
key={s}
size="sm"
className="cursor-pointer text-xs"
disabled={krediYok}
onClick={() => gonder(s)}
>
{s}
</PromptSuggestion>
))}
</div>
) : (
<DotsLoader />
)}
</div>
) : (
mesajlar.map((m) =>
m.role === "user" ? (
<Message key={m.id} className="justify-end">
<MessageContent className="max-w-[85%] bg-primary text-white">
{m.content}
</MessageContent>
</Message>
) : (
<Message key={m.id} className="justify-start">
{m.content ? (
<MessageContent
markdown
className="max-w-[85%] bg-slate-100 text-slate-800 prose-sm"
>
{m.content}
</MessageContent>
) : (
<div className="rounded-lg bg-slate-100 px-4 py-3">
<DotsLoader size="sm" />
</div>
)}
</Message>
),
)
)}
<ChatContainerScrollAnchor />
</ChatContainerContent>
<div className="absolute bottom-4 right-4">
<ScrollButton className="cursor-pointer bg-white shadow-md" />
</div>
</ChatContainerRoot>
</div>
<div className="border-t border-slate-100 p-3">
{krediYok ? (
<div className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3">
<p className="text-sm font-medium text-amber-800">
Kredin bitti sohbete devam etmek için kredi yükle.
</p>
<Button
asChild
size="sm"
className="cursor-pointer bg-orange-500 text-white hover:bg-orange-600"
>
<Link href="/paket">Kredi yükle</Link>
</Button>
</div>
) : (
<PromptInput
value={girdi}
onValueChange={setGirdi}
onSubmit={() => gonder(girdi)}
isLoading={bekliyor}
disabled={bekliyor}
maxHeight={160}
className="rounded-2xl border-slate-200"
>
<PromptInputTextarea
placeholder="Sorunu yaz… (1 kredi)"
maxLength={2000}
/>
<PromptInputActions className="justify-end pt-1">
<PromptInputAction tooltip="Gönder (Enter)">
<Button
size="icon"
onClick={() => gonder(girdi)}
disabled={bekliyor || !girdi.trim()}
className="size-9 cursor-pointer rounded-full bg-orange-500 text-white hover:bg-orange-600"
aria-label="Gönder"
>
{bekliyor ? <DotsLoader size="sm" /> : <ArrowUp className="size-4" aria-hidden />}
</Button>
</PromptInputAction>
</PromptInputActions>
</PromptInput>
)}
</div>
</section>
);
}

13
src/app/loading.tsx Normal file
View File

@@ -0,0 +1,13 @@
import { Loader2 } from "lucide-react";
export default function Loading() {
return (
<main
aria-live="polite"
className="flex min-h-[60vh] flex-col items-center justify-center gap-3"
>
<Loader2 className="size-8 animate-spin text-primary" aria-hidden />
<p className="text-sm text-slate-500">Yükleniyor</p>
</main>
);
}

24
src/app/not-found.tsx Normal file
View File

@@ -0,0 +1,24 @@
import Link from "next/link";
import { Compass } from "lucide-react";
import { Button } from "@/components/ui/button";
export default function NotFound() {
return (
<main className="mx-auto flex min-h-[60vh] w-full max-w-md flex-col items-center justify-center px-4 py-16 text-center">
<Compass className="size-10 text-primary" aria-hidden />
<h1 className="mt-4 font-heading text-2xl font-bold">
Böyle bir sayfa yok
</h1>
<p className="mt-2 text-sm leading-6 text-slate-600">
Aradığın sayfa taşınmış ya da hiç var olmamış olabilir. Sıralamanı
girip listeni kurmaya ana sayfadan devam edebilirsin.
</p>
<Button
asChild
className="mt-6 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
>
<Link href="/">Ana sayfaya dön</Link>
</Button>
</main>
);
}

View File

@@ -38,6 +38,16 @@ export default async function OdemeSonucPage({
const basarili = durum === "paid"; const basarili = durum === "paid";
// Rapor zaten üretildiyse ödül bir tık uzakta: kullanıcıyı doğrudan
// rapor sayfasına döndür ki blur çözülme animasyonuyla tam listeyi görsün.
const rapor = basarili
? await appDb.query.reports.findFirst({
where: eq(schema.reports.userId, session.user.id),
columns: { id: true },
})
: null;
const listeHref = rapor ? "/listem?acildi=1" : null;
return ( return (
<div className="flex min-h-screen flex-col bg-slate-50 text-slate-900"> <div className="flex min-h-screen flex-col bg-slate-50 text-slate-900">
<main className="mx-auto flex w-full max-w-md flex-1 flex-col justify-center px-4 py-16 text-center"> <main className="mx-auto flex w-full max-w-md flex-1 flex-col justify-center px-4 py-16 text-center">
@@ -64,13 +74,17 @@ export default async function OdemeSonucPage({
asChild asChild
className="h-11 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600" className="h-11 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
> >
<Link href="/"> <Link href={listeHref ?? "/"}>
<Sparkles className="size-4" aria-hidden /> <Sparkles className="size-4" aria-hidden />
24&apos;lük listeni oluştur {listeHref
? "Listeni aç — kilidi kaldırdık"
: "24'lük listeni oluştur"}
</Link> </Link>
</Button> </Button>
<p className="text-center text-xs text-slate-500"> <p className="text-center text-xs text-slate-500">
Sıralamanı girip sonuç sayfasındaki sihirbazla listeni kur. {listeHref
? "24 tercihin tamamı, gerekçeler, riskler ve PDF artık açık."
: "Sıralamanı girip sonuç sayfasındaki sihirbazla listeni kur."}
</p> </p>
</div> </div>
</div> </div>
@@ -81,9 +95,18 @@ export default async function OdemeSonucPage({
Ödeme tamamlanamadı Ödeme tamamlanamadı
</h1> </h1>
<p className="mt-2 text-sm text-slate-600"> <p className="mt-2 text-sm text-slate-600">
{durum === "pending" {durum === "pending" ? (
? "Ödemen hâlâ işleniyor. Birkaç saniye sonra bu sayfayı yenile — kartından çekim yapıldıysa krediler mutlaka tanımlanır." "Ödemen hâlâ işleniyor. Birkaç saniye sonra bu sayfayı yenile — kartından çekim yapıldıysa krediler mutlaka tanımlanır."
: "Çekim yapılmadı ya da işlem iptal edildi. Kartından para çekildiğini düşünüyorsan bizimle iletişime geç."} ) : (
<>
Çekim yapılmadı ya da işlem iptal edildi. Kartından para
çekildiğini düşünüyorsan{" "}
<Link href="/iletisim" className="underline">
bizimle iletişime geç
</Link>
.
</>
)}
</p> </p>
<Button asChild variant="outline" className="mt-6 h-11 cursor-pointer"> <Button asChild variant="outline" className="mt-6 h-11 cursor-pointer">
<Link href="/paket">Tekrar dene</Link> <Link href="/paket">Tekrar dene</Link>

View File

@@ -9,7 +9,10 @@ import {
Wallet, Wallet,
X, X,
} from "lucide-react"; } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { HeroForm } from "@/components/hero-form"; import { HeroForm } from "@/components/hero-form";
import { URUNLER } from "@/lib/credits";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@@ -33,6 +36,13 @@ import {
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
const PAKET_FIYATI = (URUNLER.paket.amountKurus / 100).toLocaleString("tr-TR", {
maximumFractionDigits: 0,
});
const TOPUP_FIYATI = (URUNLER.topup.amountKurus / 100).toLocaleString("tr-TR", {
maximumFractionDigits: 0,
});
const problems = [ const problems = [
{ {
icon: ListChecks, icon: ListChecks,
@@ -96,8 +106,7 @@ const faqs = [
}, },
{ {
question: "Fiyatlandırma nasıl çalışıyor?", question: "Fiyatlandırma nasıl çalışıyor?",
answer: answer: `Abonelik yok. Tercih dönemi boyunca geçerli tek seferlik ${PAKET_FIYATI} TL'lik paket alırsın: yapay zekâ destekli kişisel 24'lük liste, risk analizi, liste revizyonları ve ${URUNLER.paket.credits} soru hakkı dahil. Soru hakların biterse ${TOPUP_FIYATI} TL'ye +${URUNLER.topup.credits} soru ekleyebilirsin. Temel program arama ise herkes için ücretsiz.`,
"Abonelik yok. Tercih dönemi boyunca geçerli tek seferlik paket alırsın: yapay zekâ destekli kişisel 24'lük liste, risk analizi, liste revizyonları ve listen hakkında soru hakkı dahil. Temel program arama ise herkes için ücretsiz.",
}, },
]; ];
@@ -151,7 +160,10 @@ export default function Home() {
<main className="flex-1"> <main className="flex-1">
{/* Hero */} {/* Hero */}
<section className="mx-auto flex max-w-6xl flex-col items-center px-4 pb-20 pt-16 text-center sm:pt-24"> <section className="mx-auto flex max-w-6xl flex-col items-center px-4 pb-20 pt-16 text-center sm:pt-24">
<Badge variant="secondary" className="mb-6 bg-primary/10 text-primary"> <Badge
variant="secondary"
className="mb-6 bg-primary/10 text-primary"
>
2026 YKS tercih dönemi ık 2026 YKS tercih dönemi ık
</Badge> </Badge>
<h1 className="max-w-3xl font-heading text-4xl font-bold tracking-tight sm:text-6xl"> <h1 className="max-w-3xl font-heading text-4xl font-bold tracking-tight sm:text-6xl">
@@ -163,31 +175,32 @@ export default function Home() {
danışmanın. İnsan danışmanın binlerce lira aldığı işi dengeli danışmanın. İnsan danışmanın binlerce lira aldığı işi dengeli
liste, risk analizi, soru-cevap onda bir fiyatına yapar. liste, risk analizi, soru-cevap onda bir fiyatına yapar.
</p> </p>
<div id="hero-form" className="mt-10 flex w-full scroll-mt-28 justify-center"> <div
id="hero-form"
className="mt-10 flex w-full scroll-mt-28 justify-center"
>
<HeroForm /> <HeroForm />
</div> </div>
<p className="mt-4 text-sm text-slate-500"> <p className="mt-4 text-sm text-slate-500">
Temel arama her zaman ücretsiz · Kredi kartı gerekmez Temel arama her zaman ücretsiz · Kredi kartı gerekmez · Tam analiz
paketi tek seferlik {PAKET_FIYATI} TL
</p> </p>
<div className="mt-3 inline-flex items-center gap-2.5 rounded-lg border border-slate-200 bg-white px-3 py-2 text-left text-xs text-slate-700 shadow-xs">
{/* Sosyal kanıt: henüz kullanıcı yok — dürüstçe veri istatistikleri */} <Image
<dl className="mt-16 grid w-full max-w-3xl grid-cols-1 gap-4 sm:grid-cols-3"> src="/yok-logo.svg"
{[ alt="Yükseköğretim Kurulu"
{ value: "23.000+", label: "Taranan lisans programı" }, width={68}
{ value: "4 yıl", label: "Taban sıralaması trend verisi" }, height={33}
{ value: "%100", label: "Resmî YÖK Atlas kaynağı" }, className="h-7 w-auto shrink-0"
].map((stat) => ( />
<div <span className="h-5 w-px shrink-0 bg-slate-200" aria-hidden />
key={stat.label} <span>
className="rounded-2xl border border-slate-200 bg-white p-6" <strong className="font-semibold text-slate-900">
> YÖK Atlas&apos;ın gerçek verileriyle
<dt className="text-sm text-slate-500">{stat.label}</dt> </strong>{" "}
<dd className="mt-1 font-heading text-3xl font-bold text-primary"> güvenilir sonuçlar
{stat.value} </span>
</dd> </div>
</div>
))}
</dl>
</section> </section>
{/* Problem */} {/* Problem */}
@@ -241,6 +254,43 @@ export default function Home() {
</div> </div>
))} ))}
</div> </div>
{/* Veriye dayalı güven + garanti vaadine karşı dürüst konumlandırma */}
<div className="mx-auto mt-12 w-full max-w-3xl overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm">
<dl className="grid grid-cols-1 divide-y divide-slate-200 sm:grid-cols-3 sm:divide-x sm:divide-y-0">
{[
{ value: "23.000+", label: "Taranan lisans programı" },
{ value: "4 yıl", label: "Taban sıralaması trend verisi" },
{ value: "%100", label: "Resmî YÖK Atlas kaynağı" },
].map((stat) => (
<div key={stat.label} className="p-6 text-center">
<dt className="text-sm text-slate-500">{stat.label}</dt>
<dd className="mt-1 font-heading text-3xl font-bold text-primary">
{stat.value}
</dd>
</div>
))}
</dl>
<div className="flex gap-4 border-t border-slate-200 bg-primary/5 p-6 text-left sm:items-center sm:p-7">
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-primary text-white shadow-sm">
<ShieldCheck className="size-5" aria-hidden />
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-primary">
Dürüstlük notu
</p>
<p className="mt-1 font-heading text-lg font-bold text-slate-900 sm:text-xl">
Yerleşme garantisi vermiyoruz çünkü kimse veremez.
</p>
<p className="mt-1.5 text-sm leading-6 text-slate-600">
Taban sıralamaları her yıl değişir. Biz sonuç vaadi satmak
yerine riski satır satır gösterir, daha güvenli bir liste
kurmana yardımcı oluruz.
</p>
</div>
</div>
</div>
</div> </div>
</section> </section>
@@ -335,7 +385,7 @@ export default function Home() {
</section> </section>
</main> </main>
<footer className="border-t border-slate-200 bg-white py-10"> <footer className="border-t border-slate-200 bg-white pb-32 pt-10">
<div className="mx-auto max-w-6xl px-4 text-center text-sm leading-relaxed text-slate-500"> <div className="mx-auto max-w-6xl px-4 text-center text-sm leading-relaxed text-slate-500">
<p> <p>
© 2026 KolayTercih. Veriler resmî YÖK Atlas kaynağından derlenir; © 2026 KolayTercih. Veriler resmî YÖK Atlas kaynağından derlenir;
@@ -345,6 +395,20 @@ export default function Home() {
KolayTercih bir karar destek aracıdır, yerleşme garantisi vermez. KolayTercih bir karar destek aracıdır, yerleşme garantisi vermez.
Tercih listenizin son hali ve başvuru sorumluluğu size aittir. Tercih listenizin son hali ve başvuru sorumluluğu size aittir.
</p> </p>
<nav
aria-label="Yasal sayfalar"
className="mt-4 flex items-center justify-center gap-4"
>
<Link href="/gizlilik" className="hover:text-slate-700 hover:underline">
Gizlilik & KVKK
</Link>
<Link href="/kosullar" className="hover:text-slate-700 hover:underline">
Kullanım & iade koşulları
</Link>
<Link href="/iletisim" className="hover:text-slate-700 hover:underline">
İletişim
</Link>
</nav>
</div> </div>
</footer> </footer>
</div> </div>

View File

@@ -50,9 +50,11 @@ export async function baslatOdeme(
}); });
} catch (err) { } catch (err) {
if (err instanceof Error && err.message === "IYZICO_KEYS_MISSING") { if (err instanceof Error && err.message === "IYZICO_KEYS_MISSING") {
// Yapılandırma detayı log'a; kullanıcıya altyapı sızdırmayan mesaj
console.error("[odeme] iyzico anahtarları eksik — ödeme başlatılamadı");
return { return {
error: error:
"Ödeme altyapısı henüz yapılandırılmadı (iyzico anahtarları eksik).", "Ödeme şu anda başlatılamıyor; kartından çekim yapılmadı. Birazdan tekrar dener misin?",
}; };
} }
throw err; throw err;

View File

@@ -40,12 +40,29 @@ const OZELLIKLER = [
}, },
] as const; ] as const;
export default async function PaketPage() { export default async function PaketPage({
searchParams,
}: {
searchParams: Promise<{ hata?: string }>;
}) {
const u = await getCurrentUser(); const u = await getCurrentUser();
const { hata } = await searchParams;
// Ödeme callback'i doğrulama hatasında buraya ?hata=token|siparis ile döner
const odemeHatasi = hata === "token" || hata === "siparis";
return ( return (
<div className="flex min-h-screen flex-col bg-slate-50 text-slate-900"> <div className="flex min-h-screen flex-col bg-slate-50 text-slate-900">
<main className="mx-auto w-full max-w-3xl flex-1 px-4 py-16"> <main className="mx-auto w-full max-w-3xl flex-1 px-4 py-16">
{odemeHatasi ? (
<div
role="alert"
className="mb-8 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800"
>
Ödemen doğrulanamadı. Kartından çekim yapıldıysa kredilerin birkaç
dakika içinde otomatik tanımlanır; tanımlanmazsa aşağıdan tekrar
deneyebilirsin çekim yapılmadıysa hiçbir ücret ödemedin.
</div>
) : null}
<div className="text-center"> <div className="text-center">
<Badge variant="secondary" className="bg-primary/10 text-primary"> <Badge variant="secondary" className="bg-primary/10 text-primary">
Tek seferlik ödeme · Abonelik yok Tek seferlik ödeme · Abonelik yok
@@ -111,7 +128,13 @@ export default async function PaketPage() {
)} )}
</div> </div>
<p className="mt-4 text-center text-xs text-slate-500"> <p className="mt-4 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
href="/kosullar#iade"
className="underline hover:text-slate-700"
>
İade koşulları
</Link>
</p> </p>
</section> </section>
@@ -149,7 +172,11 @@ export default async function PaketPage() {
<p className="mt-10 text-center text-xs leading-relaxed text-slate-500"> <p className="mt-10 text-center text-xs leading-relaxed text-slate-500">
KolayTercih bir karar destek aracıdır; öneriler resmî YÖK Atlas verisine KolayTercih bir karar destek aracıdır; öneriler resmî YÖK Atlas verisine
dayanır ancak yerleşme garantisi verilmez. Tercih listesinin nihai dayanır ancak yerleşme garantisi verilmez. Tercih listesinin nihai
sorumluluğu adaya aittir. sorumluluğu adaya aittir. Sorun mu var?{" "}
<Link href="/iletisim" className="underline hover:text-slate-700">
Bize yaz
</Link>
.
</p> </p>
</main> </main>
</div> </div>

View File

@@ -13,9 +13,9 @@ export const metadata: Metadata = {
}; };
const DILIM_LABEL: Record<string, string> = { const DILIM_LABEL: Record<string, string> = {
hayal: "Hayal", hayal: "Hayal (Riskli)",
dengeli: "Dengeli", dengeli: "Dengeli (Az riskli)",
garanti: "Garanti", garanti: "Garanti (Güvenli)",
}; };
export default async function YazdirPage() { export default async function YazdirPage() {

View File

@@ -4,7 +4,12 @@ import { 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 { PUAN_TURLERI, type PuanTuruKey } from "@/lib/db"; import { PUAN_TURLERI, type PuanTuruKey } from "@/lib/db";
import { spendCredits, grantCredits, RAPOR_KREDI } from "@/lib/credits"; import {
spendCredits,
grantCredits,
RAPOR_KREDI,
MAX_REVIZYON,
} from "@/lib/credits";
import { import {
raporUret, raporUret,
listeOzetiCikar, listeOzetiCikar,
@@ -13,8 +18,8 @@ import {
} from "@/lib/ai/rapor"; } from "@/lib/ai/rapor";
import type { RaporParams } from "@/lib/rapor-havuzu"; import type { RaporParams } from "@/lib/rapor-havuzu";
import { sihirbazDogrula, type SihirbazSecimleri } from "@/lib/sihirbaz"; import { sihirbazDogrula, type SihirbazSecimleri } from "@/lib/sihirbaz";
import { raporMaskele } from "@/lib/rapor-maske";
const MAX_REVIZYON = 2;
// Modal yerinde render ettiği için action redirect etmez; sonucu döner. // Modal yerinde render ettiği için action redirect etmez; sonucu döner.
export type ListeSonuc = export type ListeSonuc =
@@ -24,6 +29,7 @@ export type ListeSonuc =
params: RaporParams; params: RaporParams;
revisionCount: number; revisionCount: number;
kredi: number; kredi: number;
hasPaket: boolean;
} }
| { ok: false; error: string; code: "AUTH" | "PAKET" | "KREDI" | "HATA" }; | { ok: false; error: string; code: "AUTH" | "PAKET" | "KREDI" | "HATA" };
@@ -36,7 +42,9 @@ 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;
if (err instanceof Error && err.message === "AI_KEY_MISSING") { if (err instanceof Error && err.message === "AI_KEY_MISSING") {
return "AI altyapısı henüz yapılandırılmadı (ANTHROPIC_API_KEY eksik)."; // Yapılandırma detayı log'a; kullanıcıya altyapı sızdırmayan mesaj
console.error("[liste] ANTHROPIC_API_KEY eksik — üretim başlatılamadı");
return "Liste üretimi şu anda başlatılamıyor. Birazdan tekrar dener misin? Harcanan kredin otomatik iade edilir.";
} }
console.error("[liste] üretim hatası:", err); console.error("[liste] üretim hatası:", err);
return "Liste üretilirken bir sorun oluştu. Birazdan tekrar dener misin?"; return "Liste üretilirken bir sorun oluştu. Birazdan tekrar dener misin?";
@@ -71,9 +79,8 @@ export async function listeOlustur(input: {
if (!user) { if (!user) {
return { ok: false, code: "AUTH", error: "Giriş yapmalısın." }; return { ok: false, code: "AUTH", error: "Giriş yapmalısın." };
} }
if (!user.hasPaket) { // Paket şartı yok: üretim kredi bazlı (yeni kullanıcının 5 deneme kredisi
return { ok: false, code: "PAKET", error: "Paket gerekli." }; // burada yanmaya başlar). Paketsize dönen rapor maskelenir.
}
if (!siraTurGecerli(input.sira, input.tur)) { if (!siraTurGecerli(input.sira, input.tur)) {
return { ok: false, code: "HATA", error: "Geçerli bir sıralama gerekli." }; return { ok: false, code: "HATA", error: "Geçerli bir sıralama gerekli." };
@@ -112,12 +119,14 @@ export async function listeOlustur(input: {
where: eq(schema.reports.userId, user.id), where: eq(schema.reports.userId, user.id),
}); });
if (mevcut?.result) { if (mevcut?.result) {
const rapor = mevcut.result as RaporSonuc;
return { return {
ok: true, ok: true,
rapor: mevcut.result as RaporSonuc, rapor: user.hasPaket ? rapor : raporMaskele(rapor),
params: mevcut.params as RaporParams, params: mevcut.params as RaporParams,
revisionCount: mevcut.revisionCount, revisionCount: mevcut.revisionCount,
kredi: user.creditBalance, kredi: user.creditBalance,
hasPaket: user.hasPaket,
}; };
} }
return { ok: false, code: "HATA", error: "İstek tekrarlandı." }; return { ok: false, code: "HATA", error: "İstek tekrarlandı." };
@@ -156,10 +165,11 @@ export async function listeOlustur(input: {
return { return {
ok: true, ok: true,
rapor: sonuc, rapor: user.hasPaket ? sonuc : raporMaskele(sonuc),
params, params,
revisionCount: 0, revisionCount: 0,
kredi: user.creditBalance - RAPOR_KREDI, kredi: user.creditBalance - RAPOR_KREDI,
hasPaket: user.hasPaket,
}; };
} }
@@ -221,7 +231,11 @@ export async function listeRevize(input: {
let sonuc: RaporSonuc; let sonuc: RaporSonuc;
try { try {
sonuc = await raporUret(params, feedback.slice(0, 1000), listeOzetiCikar(onceki)); sonuc = await raporUret(
params,
feedback.slice(0, 1000),
listeOzetiCikar(onceki, params.sira),
);
} catch (err) { } catch (err) {
await grantCredits({ await grantCredits({
userId: user.id, userId: user.id,
@@ -248,5 +262,6 @@ export async function listeRevize(input: {
params, params,
revisionCount: rapor.revisionCount + 1, revisionCount: rapor.revisionCount + 1,
kredi: user.creditBalance - RAPOR_KREDI, kredi: user.creditBalance - RAPOR_KREDI,
hasPaket: true,
}; };
} }

View File

@@ -0,0 +1,140 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import {
ArrowRight,
ChartNoAxesCombined,
Database,
ListChecks,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { TypingAnimation } from "@/components/ui/typing-animation";
/** Banner CTA'sının sihirbaz modalını açmak için yayınladığı olay. */
export const SIHIRBAZ_AC_EVENT = "kolaytercih:sihirbaz-ac";
const FUNNEL_METINLERI = [
"Sıralamana en uygun listeyi birlikte kuralım.",
"Geleceğin 24 satırlık bir plana sığar.",
"Gerçek YÖK Atlas verisiyle güvenle seç.",
"Her tercihinin bir nedeni olsun.",
"Riskleri biz hesaplayalım, kararı sen ver.",
"Sana uygun bölümler bir adım uzağında.",
"Dengeli bir liste, rahat bir tercih dönemi.",
"Tercihlerini şansa bırakma.",
"Doğru strateji, doğru yerleşme.",
"Hayalindeki bölüm için doğru plan.",
"Verilerle konuşan bir tercih listesi.",
"Üç adımda sana özel 24 tercih.",
"Tercih dönemini stressiz geçir.",
"Geçen yılın verisi, senin avantajın.",
"Güvenli ve iddialı tercihleri dengele.",
"Sıralaman ne söylüyor? Birlikte bakalım.",
"Her satırı senin için hesaplıyoruz.",
"Akıllı tercih, içi rahat bir bekleyiş.",
"Yerleşme ihtimalini görerek seç.",
"Sana özel liste birkaç dakikada hazır.",
"İlgi alanların ve şehrin, tek listede.",
"Boş kalan tercih, kaçan fırsattır.",
"Uzman işi bir liste, senin onayınla.",
"Kararını veriye dayandır.",
"Doğru tercih, doğru bilgiyle başlar.",
"Listeni bugün kur, için rahat olsun.",
"Yerleşme riskin renklerle önünde.",
"En önemli 24 kararını birlikte verelim.",
"Senin sıralaman, sana özel plan.",
"İyi bir liste, iyi bir başlangıçtır.",
];
/** Fisher-Yates ile kopya üzerinde karıştırır; her ziyarette farklı sıra. */
function karistir<T>(dizi: readonly T[]): T[] {
const kopya = [...dizi];
for (let i = kopya.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[kopya[i], kopya[j]] = [kopya[j], kopya[i]];
}
return kopya;
}
const KANITLAR = [
{ ikon: ListChecks, etiket: "24 tercih" },
{ ikon: ChartNoAxesCombined, etiket: "Risk analizi" },
{ ikon: Database, etiket: "YÖK Atlas verisi" },
];
/**
* Puan türü badge'lerinin hemen altında yaşayan satın alım funnel banner'ı.
* Rastgele sırayla yazılıp silinen samimi ve profesyonel funnel
* metinleriyle adayı sihirbaza; rapor zaten varsa kilitli listenin
* durduğu /listem paywall'ına iter.
* Tasarım: güven veren lacivert yüzey, veri kanıtları ve turuncu CTA.
*/
export function FunnelBanner({ hedefListem }: { hedefListem: boolean }) {
// İlk render'da bir kez karıştırılır; SSR boş metinle başladığı için
// hydration uyuşmazlığı yaratmaz.
const [metinler] = useState(() => karistir(FUNNEL_METINLERI));
const ctaSinif =
"h-12 w-full shrink-0 cursor-pointer rounded-xl bg-orange-500 px-5 font-semibold text-white shadow-[0_8px_24px_rgba(249,115,22,0.28)] transition-[background-color,transform,box-shadow] duration-200 hover:bg-orange-400 hover:shadow-[0_10px_28px_rgba(249,115,22,0.36)] active:scale-[0.98] focus-visible:ring-white/70 sm:w-auto";
return (
<section
aria-label="Tercih listeni kur"
className="relative mt-6 overflow-hidden rounded-3xl border border-blue-400/20 bg-[#0b1f3a] px-5 py-6 text-white shadow-[0_20px_50px_-28px_rgba(15,71,150,0.65)] sm:px-7 sm:py-7"
>
<div
aria-hidden
className="pointer-events-none absolute -right-16 -top-24 size-64 rounded-full bg-blue-500/25 blur-3xl"
/>
<div
aria-hidden
className="pointer-events-none absolute -bottom-20 left-1/3 size-48 rounded-full bg-cyan-400/10 blur-3xl"
/>
<div className="relative grid items-center gap-6 sm:grid-cols-[minmax(0,1fr)_auto] sm:gap-8">
<div className="min-w-0">
<TypingAnimation
as="h2"
words={metinler}
loop
typeSpeed={38}
deleteSpeed={18}
pauseDelay={2600}
className="min-h-[2.5em] max-w-2xl font-heading text-2xl font-bold leading-tight tracking-[-0.02em] sm:min-h-[1.25em] sm:text-3xl"
/>
<div className="mt-4 flex flex-wrap gap-x-4 gap-y-2">
{KANITLAR.map(({ ikon: Ikon, etiket }) => (
<span
key={etiket}
className="inline-flex items-center gap-1.5 text-xs font-medium text-slate-200"
>
<Ikon className="size-3.5 text-cyan-300" aria-hidden />
{etiket}
</span>
))}
</div>
</div>
{hedefListem ? (
<Button asChild size="lg" className={ctaSinif}>
<Link href="/listem">
Listeni
<ArrowRight className="size-4" aria-hidden />
</Link>
</Button>
) : (
<Button
size="lg"
onClick={() => window.dispatchEvent(new Event(SIHIRBAZ_AC_EVENT))}
className={ctaSinif}
>
Listemi oluştur
<ArrowRight className="size-4" aria-hidden />
</Button>
)}
</div>
</section>
);
}

View File

@@ -0,0 +1,386 @@
"use client";
import { useMemo, useEffect, useRef, useState } from "react";
import Link from "next/link";
import { FileText, Loader2, Lock, RefreshCcw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { SohbetPopup } from "./sohbet-popup";
import { RaporListesi } from "@/components/rapor-listesi";
import { TercihHaritasi, type HaritaPin } from "@/components/tercih-haritasi";
import type { RaporSonuc } from "@/lib/ai/rapor";
import type { RaporParams } from "@/lib/rapor-havuzu";
import { ACIK_SATIR } from "@/lib/rapor-maske";
import { ilKonum, uniAdiNormalize, uniKonum } from "@/lib/harita";
import { dilimdenRisk, riskHesapla, type RiskSeviyesi } from "@/lib/risk";
const MAX_REVIZYON = 2;
export type MevcutRapor = {
rapor: RaporSonuc;
params: RaporParams;
revisionCount: number;
};
/** Panelin liste dışındaki geçici durumları (liste, rapor varlığından türer). */
export type PanelDurum = "bos" | "uretiliyor" | "kredi-yetersiz";
const RISK_AGIRLIK: Record<RiskSeviyesi, number> = {
guvenli: 0,
"az-riskli": 1,
riskli: 2,
};
/**
* Rapordan harita pinleri türetir. Açık satırlar üniversite bazında gruplanır
* (kampüs konumunda, adıyla); kilitli satırlar il başına tek adsız "?" pinine
* toplanır — maskeli veride üniversite adı zaten client'a inmediği için
* harita paywall'ı delemez. Haritada karşılığı olmayan tercihler sayılır.
*/
function pinleriTuret(
rapor: RaporSonuc,
adaySira: number,
kilitliBaslangic: number,
): { pinler: HaritaPin[]; haritaDisi: number } {
const gruplar = new Map<string, HaritaPin>();
let haritaDisi = 0;
rapor.tercihler.forEach((t, i) => {
const p = rapor.programlar[t.programId];
if (!p) return;
const risk = riskHesapla(p.efektifSira, adaySira) ?? dilimdenRisk(t.dilim);
const kilitli = i >= kilitliBaslangic;
if (kilitli) {
const konum = ilKonum(p.il);
if (!konum) {
haritaDisi += 1;
return;
}
const key = `kilit|${p.il}`;
const mevcut = gruplar.get(key);
if (mevcut) {
mevcut.tercihSiralari.push(t.sira);
if (RISK_AGIRLIK[risk] > RISK_AGIRLIK[mevcut.risk]) mevcut.risk = risk;
} else {
// Açık pinlerle üst üste binmesin diye il merkezinin hafif altına
gruplar.set(key, {
id: key,
universite: "",
il: p.il,
x: konum.x,
y: konum.y + 9,
tercihSiralari: [t.sira],
risk,
kilitli: true,
});
}
return;
}
const konum = uniKonum(p.universite, p.il);
if (!konum) {
haritaDisi += 1;
return;
}
const key = `${uniAdiNormalize(p.universite)}|${p.il ?? ""}`;
const mevcut = gruplar.get(key);
if (mevcut) {
mevcut.tercihSiralari.push(t.sira);
if (RISK_AGIRLIK[risk] > RISK_AGIRLIK[mevcut.risk]) mevcut.risk = risk;
} else {
gruplar.set(key, {
id: key,
universite: p.universite,
il: p.il,
x: konum.x,
y: konum.y,
tercihSiralari: [t.sira],
risk,
});
}
});
return { pinler: [...gruplar.values()], haritaDisi };
}
const URETIM_MESAJLARI = [
"Sıralamana uygun programlar taranıyor…",
"Gerçek YÖK Atlas verisiyle dengeli liste kuruluyor…",
"Her tercih için risk ve trend analizi yazılıyor…",
"Son rötuşlar yapılıyor, neredeyse hazır…",
];
/**
* Sonuç sayfası gövdesindeki liste paneli: üretim animasyonu, paketli tam
* liste veya paketsiz progressive-blur önizleme burada render edilir.
* (Sihirbaz modalı yalnızca adımları toplar; sonuç artık sayfanın kendisinde.)
*/
export function ListePaneli({
durum,
rapor,
hasPaket,
kredi,
sira,
acilis,
onRevize,
}: {
durum: PanelDurum;
rapor: MevcutRapor | null;
hasPaket: boolean;
kredi: number;
sira: number;
/** Ödeme dönüşü: blur çözülme animasyonuyla aç */
acilis?: boolean;
onRevize: (feedback: string) => Promise<boolean>;
}) {
const kilitli = !hasPaket;
// Harita ⇄ liste köprüsü: pinden satıra, satırdan haritaya
const [seciliIl, setSeciliIl] = useState<string | null>(null);
const [acikSira, setAcikSira] = useState<number | null>(null);
const haritaRef = useRef<HTMLDivElement>(null);
const pinVerisi = useMemo(
() =>
rapor
? pinleriTuret(
rapor.rapor,
rapor.params.sira,
kilitli ? ACIK_SATIR : rapor.rapor.tercihler.length,
)
: null,
[rapor, kilitli],
);
if (durum === "uretiliyor") return <UretimGovdesi />;
if (durum === "kredi-yetersiz") {
return (
<section className="mt-16 rounded-2xl border border-slate-200 bg-white p-8 text-center sm:p-12">
<Lock className="mx-auto size-8 text-slate-400" aria-hidden />
<h2 className="mt-4 font-heading text-xl font-bold">
Kredin bu liste için yetmiyor
</h2>
<p className="mx-auto mt-2 max-w-md text-sm text-slate-600">
Liste üretimi 3 kredi. Tercih Dönemi Paketi&apos;yle 60 kredi, tam
liste, PDF ve revizyon hakları birlikte gelir.
</p>
<Button
asChild
size="lg"
className="mt-6 h-12 cursor-pointer bg-orange-500 px-8 text-white transition-colors duration-200 hover:bg-orange-600"
>
<Link href="/paket">299 TL Paketi aktive et</Link>
</Button>
</section>
);
}
if (!rapor) return null;
return (
<section
className={`mt-16 ${acilis && !kilitli ? "kt-unblur" : ""}`}
aria-label="Tercih listen"
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 className="font-heading text-2xl font-bold">
24 Tercihlik Listen
</h2>
<p className="mt-1 text-sm text-slate-500">
{sira.toLocaleString("tr-TR")}. sıra ·{" "}
{kilitli
? `tüm taban verileri açık · ${ACIK_SATIR} AI analiz örneği`
: `${kredi} kredin kaldı`}
</p>
</div>
{!kilitli ? (
<Button asChild variant="outline" className="cursor-pointer">
<Link href="/rapor/yazdir" target="_blank">
<FileText className="size-4" aria-hidden />
PDF indir
</Link>
</Button>
) : null}
</div>
{/* Harita: 24 tercihin coğrafyası — ile dokun, üniversiteleri gör */}
<div ref={haritaRef} className="mt-5 scroll-mt-24">
<TercihHaritasi
pinler={pinVerisi?.pinler ?? []}
seciliIl={seciliIl}
onSeciliIlDegisti={setSeciliIl}
onTercihSec={setAcikSira}
/>
{pinVerisi && pinVerisi.haritaDisi > 0 ? (
<p className="mt-1 px-1 text-xs text-slate-500">
{pinVerisi.haritaDisi} tercih harita dışında (KKTC vb.)
</p>
) : null}
</div>
<div className="mt-5">
<RaporListesi
rapor={rapor.rapor}
adaySira={rapor.params.sira}
acikSira={acikSira}
onHaritadaGor={(il) => {
if (!il) return;
setSeciliIl(il);
haritaRef.current?.scrollIntoView({
behavior: "smooth",
block: "start",
});
}}
kilitliBaslangic={kilitli ? ACIK_SATIR : undefined}
kilitOverlay={
<div className="flex flex-col items-start gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex gap-3">
<Lock
className="mt-1 size-5 shrink-0 text-orange-700"
aria-hidden
/>
<div>
<p className="font-heading font-bold text-slate-900">
Tüm program ve taban verileri ücretsiz
</p>
<p className="mt-1 max-w-xl text-sm text-slate-600">
Paket; kalan {24 - ACIK_SATIR} tercih için kişisel risk,
gerekçe, PDF, 2 revizyon ve 60 AI danışman sorusunu açar.
</p>
{kredi > 0 ? (
<p className="mt-1 text-xs text-slate-500">
Kalan {kredi} deneme kredin hesabında duruyor.
</p>
) : null}
</div>
</div>
<Button
asChild
size="lg"
className="h-12 w-full shrink-0 cursor-pointer bg-orange-500 px-8 text-white transition-colors duration-200 hover:bg-orange-600 sm:w-auto"
>
<Link href="/paket">299 TL Analizleri </Link>
</Button>
</div>
}
/>
</div>
{!kilitli ? (
<div className="mt-6">
<RevizyonKutusu
kalanHak={MAX_REVIZYON - rapor.revisionCount}
onRevize={onRevize}
/>
</div>
) : null}
{/* AI danışman: sağ altta yüzen buton, alttan popup olarak açılır */}
<SohbetPopup kredi={kredi} />
</section>
);
}
function UretimGovdesi() {
const [mesajIdx, setMesajIdx] = useState(0);
const govdeRef = useRef<HTMLElement>(null);
useEffect(() => {
const interval = setInterval(() => {
setMesajIdx((i) => Math.min(i + 1, URETIM_MESAJLARI.length - 1));
}, 7000);
return () => clearInterval(interval);
}, []);
// Modal geçiş anında unmount edildiğinden Radix odağı tetikleyiciye geri
// veremiyor; odağı panele alıp klavye ve ekran okuyucu akışını sürdürüyoruz.
useEffect(() => {
govdeRef.current?.focus({ preventScroll: true });
}, []);
return (
<section
ref={govdeRef}
tabIndex={-1}
aria-live="polite"
className="mt-16 flex flex-col items-center justify-center gap-4 rounded-2xl border border-slate-200 bg-white py-16 text-center outline-none"
>
<Loader2 className="size-10 animate-spin text-primary" aria-hidden />
<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>
</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"
>
{pending ? (
<Loader2 className="size-4 animate-spin" aria-hidden />
) : (
<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>
);
}

19
src/app/sonuc/loading.tsx Normal file
View File

@@ -0,0 +1,19 @@
import { Skeleton } from "@/components/ui/skeleton";
// /sonuc senkron SQLite sorgularıyla açılır; yavaş bağlantıda beyaz ekran
// yerine sayfanın iskeletini göster.
export default function SonucLoading() {
return (
<main className="mx-auto w-full max-w-6xl px-4 py-10">
<Skeleton className="h-6 w-40 rounded-full" />
<Skeleton className="mt-4 h-9 w-3/4 max-w-xl" />
<Skeleton className="mt-3 h-5 w-2/3 max-w-lg" />
<Skeleton className="mt-8 h-36 w-full rounded-2xl" />
<div className="mt-8 flex flex-col gap-2">
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full rounded-xl" />
))}
</div>
</main>
);
}

View File

@@ -1,47 +1,27 @@
import Link from "next/link"; import Link from "next/link";
import type { Metadata } from "next"; import type { Metadata } from "next";
import { import { ArrowLeft } from "lucide-react";
ArrowLeft,
MapPin,
Minus,
Rocket,
Scale,
ShieldCheck,
TrendingDown,
TrendingUp,
X,
} from "lucide-react";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { import {
PUAN_TURLERI, PUAN_TURLERI,
searchByRank,
rankWindowFacets, rankWindowFacets,
type Program, searchByRank,
type PuanTuruKey, type PuanTuruKey,
} from "@/lib/db"; } from "@/lib/db";
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 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 { raporMaskele } from "@/lib/rapor-maske";
import { SihirbazBolumu } from "./sihirbaz-cagri-karti"; import { SihirbazBolumu } from "./sihirbaz-cagri-karti";
import type { import { FunnelBanner } from "./funnel-banner";
MevcutRapor, import type { MevcutRapor } from "./liste-paneli";
OnizlemeSatir,
} from "./sihirbaz-modal";
import { BolgeHaritasi, type IlOzeti } from "@/components/bolge-haritasi";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import { ProgramTablosu } from "@/components/program-tablosu";
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Sıralamana Uygun Programlar — KolayTercih", title: "Tercih Listeni Kur — KolayTercih",
}; };
const TUR_LABELS: Record<PuanTuruKey, string> = { const TUR_LABELS: Record<PuanTuruKey, string> = {
@@ -52,107 +32,15 @@ const TUR_LABELS: Record<PuanTuruKey, string> = {
tyt: "TYT (Önlisans)", tyt: "TYT (Önlisans)",
}; };
function TrendIcon({ p }: { p: Program }) {
if (p.sira2025 == null || p.sira2024 == null) {
return <Minus className="size-4 text-slate-400" aria-label="Veri yok" />;
}
const degisim = (p.sira2024 - p.sira2025) / p.sira2024;
// taban sıralaması küçülüyorsa bölüm zorlaşıyor demektir
if (degisim > 0.05) {
return (
<span className="inline-flex items-center gap-1 text-orange-600">
<TrendingUp className="size-4" aria-hidden />
<span className="text-xs">zorlaşıyor</span>
</span>
);
}
if (degisim < -0.05) {
return (
<span className="inline-flex items-center gap-1 text-emerald-600">
<TrendingDown className="size-4" aria-hidden />
<span className="text-xs">rahatlıyor</span>
</span>
);
}
return (
<span className="inline-flex items-center gap-1 text-slate-500">
<Minus className="size-4" aria-hidden />
<span className="text-xs">stabil</span>
</span>
);
}
function fmt(n: number | null): string {
return n == null ? "—" : n.toLocaleString("tr-TR");
}
function ProgramTable({ programs }: { programs: Program[] }) {
if (programs.length === 0) {
return (
<p className="rounded-xl border border-dashed border-slate-300 p-6 text-center text-sm text-slate-500">
Bu dilimde uygun program bulunamadı.
</p>
);
}
return (
<div className="overflow-x-auto rounded-2xl border border-slate-200 bg-white">
<Table>
<TableHeader>
<TableRow className="bg-slate-50">
<TableHead>Program</TableHead>
<TableHead>Üniversite</TableHead>
<TableHead className="text-right">2025 taban sırası</TableHead>
<TableHead className="text-right">2024</TableHead>
<TableHead>Trend</TableHead>
<TableHead className="text-right">Kontenjan</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{programs.map((p) => (
<TableRow key={p.id}>
<TableCell className="font-medium">
{p.isim}
{p.fakulte ? (
<span className="block text-xs text-slate-500">
{p.fakulte}
</span>
) : null}
</TableCell>
<TableCell>
{p.universite}
<span className="block text-xs text-slate-500">
{p.il}
{p.unitur ? ` · ${p.unitur.toLocaleLowerCase("tr-TR")}` : ""}
</span>
</TableCell>
<TableCell className="text-right font-medium tabular-nums">
{fmt(p.sira2025)}
</TableCell>
<TableCell className="text-right tabular-nums text-slate-500">
{fmt(p.sira2024)}
</TableCell>
<TableCell>
<TrendIcon p={p} />
</TableCell>
<TableCell className="text-right tabular-nums">
{fmt(p.kontenjan2025)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
export default async function SonucPage({ export default async function SonucPage({
searchParams, searchParams,
}: { }: {
searchParams: Promise<{ searchParams: Promise<{
sira?: string; sira?: string;
tur?: string; tur?: string;
il?: string;
sihirbaz?: string; sihirbaz?: string;
acildi?: string;
hazir?: string;
}>; }>;
}) { }) {
const params = await searchParams; const params = await searchParams;
@@ -161,7 +49,6 @@ export default async function SonucPage({
params.tur && params.tur in PUAN_TURLERI params.tur && params.tur in PUAN_TURLERI
? (params.tur as PuanTuruKey) ? (params.tur as PuanTuruKey)
: "say"; : "say";
const seciliIl = params.il?.trim() || null;
if (!Number.isFinite(sira) || sira < 1 || sira > 4_000_000) { if (!Number.isFinite(sira) || sira < 1 || sira > 4_000_000) {
return ( return (
@@ -170,7 +57,7 @@ export default async function SonucPage({
Geçerli bir sıralama gerekli Geçerli bir sıralama gerekli
</h1> </h1>
<p className="mt-3 text-slate-600"> <p className="mt-3 text-slate-600">
Sonuçları görebilmek için ana sayfadan YKS başarı sıralamanı gir. Listeni kurabilmek için ana sayfadan YKS başarı sıralamanı gir.
</p> </p>
<Button asChild className="mt-6 cursor-pointer"> <Button asChild className="mt-6 cursor-pointer">
<Link href="/"> <Link href="/">
@@ -192,84 +79,27 @@ export default async function SonucPage({
: null; : null;
const facetler = rankWindowFacets(sira, turKey); const facetler = rankWindowFacets(sira, turKey);
// AI'sız ham liste: yalnızca sıralamayla ulaşılabilen programlar (dilimli)
const hamSonuclar = searchByRank(sira, turKey, { limitPerBucket: 20 });
// Paketsize giden rapor sunucuda maskelenir: blur yalnızca CSS olduğundan
// kilitli satırların gerçek içeriği client'a hiç inmez.
const hasPaket = Boolean(user?.hasPaket);
const mevcutRapor: MevcutRapor | null = raporSatiri?.result const mevcutRapor: MevcutRapor | null = raporSatiri?.result
? { ? {
rapor: raporSatiri.result as RaporSonuc, rapor: hasPaket
? (raporSatiri.result as RaporSonuc)
: raporMaskele(raporSatiri.result as RaporSonuc),
params: raporSatiri.params as RaporParams, params: raporSatiri.params as RaporParams,
revisionCount: raporSatiri.revisionCount, revisionCount: raporSatiri.revisionCount,
} }
: null; : null;
// Paketsiz kullanıcıya blur önizlemede gösterilecek gerçek dengeli satırlar
const onizlemeSatirlari: OnizlemeSatir[] = searchByRank(sira, turKey, {
limitPerBucket: 5,
})
.dengeli.slice(0, 5)
.map((p) => ({
id: p.id,
isim: p.isim,
universite: p.universite,
il: p.il,
}));
const results = searchByRank(sira, turKey, {
il: seciliIl ?? undefined,
});
// Harita her zaman filtresiz dağılımı gösterir ki il değiştirilebilsin
const haritaSonuclari = seciliIl ? searchByRank(sira, turKey) : results;
// Harita için il bazında özet (81 il haritada; KKTC/yurtdışı ayrıca listelenir)
const iller: Record<string, IlOzeti> = {};
const dilimler = [
["hayal", haritaSonuclari.hayal],
["dengeli", haritaSonuclari.dengeli],
["garanti", haritaSonuclari.garanti],
] as const;
for (const [dilim, programs] of dilimler) {
for (const p of programs) {
if (!p.il) continue;
iller[p.il] ??= { hayal: 0, dengeli: 0, garanti: 0 };
iller[p.il][dilim]++;
}
}
const sections = [
{
key: "hayal",
icon: Rocket,
title: "Hayal",
tone: "text-orange-600",
description:
"Geçen yıl tabanı senin sıralamanın üzerinde kapanan programlar. Şansın düşük ama listenin başında birkaç tane bulunmalı.",
programs: results.hayal,
},
{
key: "dengeli",
icon: Scale,
title: "Dengeli",
tone: "text-primary",
description:
"Tabanı sıralamanın hemen civarında. Listenin bel kemiği bu dilimden kurulur.",
programs: results.dengeli,
},
{
key: "garanti",
icon: ShieldCheck,
title: "Garanti",
tone: "text-emerald-600",
description:
"Tabanı sıralamanın belirgin altında. Boşta kalmamak için listenin sonunda mutlaka yer almalı.",
programs: results.garanti,
},
] as const;
return ( return (
<div className="min-h-screen bg-slate-50 text-slate-900"> <div className="min-h-screen bg-slate-50 text-slate-900">
<main <main className="mx-auto max-w-4xl px-4 py-12">
className={`mx-auto max-w-6xl px-4 py-12 ${mevcutRapor ? "" : "pb-56"}`}
>
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<Badge variant="secondary" className="bg-primary/10 text-primary"> <Badge variant="secondary" className="bg-primary/10 text-primary">
Ücretsiz ön izleme {sira.toLocaleString("tr-TR")}. sıra · {TUR_LABELS[turKey]}
</Badge> </Badge>
<Button <Button
asChild asChild
@@ -284,110 +114,39 @@ export default async function SonucPage({
</Button> </Button>
</div> </div>
<h1 className="mt-4 font-heading text-3xl font-bold sm:text-4xl"> <h1 className="mt-4 font-heading text-3xl font-bold sm:text-4xl">
{sira.toLocaleString("tr-TR")}. sıradaki bir aday için görünüm {sira.toLocaleString("tr-TR")}. sıradaki bir aday için tercih planı
</h1> </h1>
<p className="mt-2 max-w-2xl text-slate-600"> <p className="mt-2 max-w-2xl text-slate-600">
2025 YÖK Atlas taban sıralamalarına göre üç dilime ayrılmış öneriler. Birkaç adımda ilgi alanlarını ve şehir tercihini seç; yapay zekâ
Taban sıralamaları her yıl değişir; bu liste garanti değil, başlangıç gerçek YÖK Atlas verisiyle 24 tercihlik dengeli listeni kursun. Her
noktasıdır. satır yerleşme riskine göre renklenir:{" "}
<span className="font-semibold text-emerald-600">yeşil güvenli</span>,{" "}
<span className="font-semibold text-amber-600">sarı az riskli</span>,{" "}
<span className="font-semibold text-red-600">kırmızı riskli</span>.
</p> </p>
{/* Puan türü seçimi */} {/* Satın alım funnel'ı: değişen cümlelerle adayı sihirbaza (rapor
<nav className="mt-6 flex flex-wrap gap-2" aria-label="Puan türü"> varsa /listem paywall'ına) iten banner — paketli kullanıcıya
{(Object.keys(PUAN_TURLERI) as PuanTuruKey[]).map((key) => ( satacak bir şey kalmadığından gösterilmez */}
<Button {!hasPaket ? <FunnelBanner hedefListem={Boolean(mevcutRapor)} /> : null}
key={key}
asChild
size="sm"
variant={key === turKey ? "default" : "outline"}
className="cursor-pointer"
>
<Link
href={`/sonuc?sira=${sira}&tur=${key}${
seciliIl ? `&il=${encodeURIComponent(seciliIl)}` : ""
}`}
>
{TUR_LABELS[key]}
</Link>
</Button>
))}
</nav>
{seciliIl ? ( {/* AI'dan bağımsız ham tablo + manuel liste haritası — sayfanın ana
<div className="mt-4 flex flex-wrap items-center gap-3 rounded-xl border border-primary/30 bg-primary/5 px-4 py-3"> gövdesi; puan türü seçici de listenin başında yaşar */}
<MapPin className="size-4 text-primary" aria-hidden /> <ProgramTablosu sonuclar={hamSonuclar} adaySira={sira} tur={turKey} />
<p className="text-sm font-medium">
Yalnızca <span className="font-bold">{seciliIl}</span>{" "}
programları gösteriliyor
</p>
<Button
asChild
size="sm"
variant="outline"
className="cursor-pointer"
>
<Link href={`/sonuc?sira=${sira}&tur=${turKey}`}>
<X className="size-3.5" aria-hidden />
Filtreyi kaldır
</Link>
</Button>
</div>
) : null}
{/* Bölge haritası */} {/* Sihirbaz akışı (CTA + modal + AI listesi) — blurlu AI paneli
<section aria-labelledby="baslik-harita" className="mt-10"> sayfanın en altında yaşar */}
<h2 id="baslik-harita" className="font-heading text-2xl font-bold">
Nerede okuyacaksın?
</h2>
<p className="mt-1 text-sm text-slate-600">
Aşağıdaki önerilerin illere dağılımı. Koyu renk, o ilde daha fazla
öneri olduğunu gösterir. Bir ile tıklayarak listeyi o ile
daraltabilir, tekrar tıklayarak filtreyi kaldırabilirsin.
</p>
<div className="mt-4 rounded-2xl border border-slate-200 bg-white p-4 sm:p-6">
<BolgeHaritasi
iller={iller}
sira={sira}
turKey={turKey}
seciliIl={seciliIl}
/>
</div>
</section>
<div className="mt-10 space-y-12">
{sections.map((s) => (
<section key={s.key} aria-labelledby={`baslik-${s.key}`}>
<div className="mb-4 flex items-start gap-3">
<s.icon className={`mt-1 size-6 ${s.tone}`} aria-hidden />
<div>
<h2
id={`baslik-${s.key}`}
className="font-heading text-2xl font-bold"
>
{s.title}
<span className="ml-2 text-base font-normal text-slate-500">
{s.programs.length} program
</span>
</h2>
<p className="text-sm text-slate-600">{s.description}</p>
</div>
</div>
<ProgramTable programs={s.programs} />
</section>
))}
</div>
{/* Ücretli katman — sihirbaz akışı (CTA + alt kart + modal) */}
<SihirbazBolumu <SihirbazBolumu
sira={sira} sira={sira}
tur={turKey} tur={turKey}
facetler={facetler} facetler={facetler}
girisliMi={Boolean(session)} girisliMi={Boolean(session)}
hasPaket={Boolean(user?.hasPaket)} hasPaket={hasPaket}
kredi={user?.creditBalance ?? 0} kredi={user?.creditBalance ?? 0}
mevcutRapor={mevcutRapor} mevcutRapor={mevcutRapor}
onizlemeSatirlari={onizlemeSatirlari}
otomatikAc={params.sihirbaz === "1"} otomatikAc={params.sihirbaz === "1"}
acilis={params.acildi === "1"}
secimlerHazir={params.hazir === "1"}
/> />
<p className="mt-10 text-center text-xs leading-relaxed text-slate-500"> <p className="mt-10 text-center text-xs leading-relaxed text-slate-500">

View File

@@ -1,7 +1,12 @@
"use client"; "use client";
import { useEffect, useState, useSyncExternalStore } from "react"; import { useEffect, useRef, useState, useSyncExternalStore } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Sparkles, X } from "lucide-react"; import { Sparkles, X } from "lucide-react";
import { animate } from "motion";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import type { SihirbazFacetleri } from "@/lib/db"; import type { SihirbazFacetleri } from "@/lib/db";
import { import {
@@ -9,11 +14,10 @@ import {
sihirbazDogrula, sihirbazDogrula,
type SihirbazSecimleri, type SihirbazSecimleri,
} from "@/lib/sihirbaz"; } from "@/lib/sihirbaz";
import { import { SihirbazModal } from "./sihirbaz-modal";
SihirbazModal, import { SIHIRBAZ_AC_EVENT } from "./funnel-banner";
type MevcutRapor, import { ListePaneli, type MevcutRapor, type PanelDurum } from "./liste-paneli";
type OnizlemeSatir, import { listeOlustur, listeRevize } from "./actions";
} from "./sihirbaz-modal";
const KAPATMA_KEY = "kolaytercih.sihirbaz-karti-kapali"; const KAPATMA_KEY = "kolaytercih.sihirbaz-karti-kapali";
@@ -32,11 +36,16 @@ type BekleyenSecim = {
}; };
/** /**
* Sonuç sayfasında sihirbaz akışını yöneten client wrapper: * Sonuç sayfasında sihirbaz akışını yöneten client orkestratör:
* - Sayfa ortasındaki büyük CTA (satır içi) * - Sayfa ortasındaki büyük CTA + sabit alt çağrı kartı (rapor yokken)
* - Sabit alt çağrı kartı * - Adımları toplayan modal
* - Modal * - Üretim/önizleme/liste paneli (sayfa gövdesinde)
* hepsi tek `acik` state'ini paylaşır. * Akış: adımlar → kredi ile üretim → paketsizse progressive-blur önizleme,
* paketliyse tam liste. Girişsiz kullanıcı hero'dan seçimleri tamamlanmış
* gelirse önce ücretsiz tabloyu görür; giriş CTA'sı "hazır seçimlerin
* kaydedilmesi" olarak panelde sunulur, girişten dönüşte üretim otomatik
* başlar. Modal içinden ilerleyen girişsiz kullanıcı ise butonun söylediği
* gibi doğrudan girişe gider.
*/ */
export function SihirbazBolumu({ export function SihirbazBolumu({
sira, sira,
@@ -44,10 +53,11 @@ export function SihirbazBolumu({
facetler, facetler,
girisliMi, girisliMi,
hasPaket, hasPaket,
kredi, kredi: baslangicKredi,
mevcutRapor, mevcutRapor,
onizlemeSatirlari,
otomatikAc, otomatikAc,
acilis,
secimlerHazir,
}: { }: {
sira: number; sira: number;
tur: string; tur: string;
@@ -56,18 +66,40 @@ export function SihirbazBolumu({
hasPaket: boolean; hasPaket: boolean;
kredi: number; kredi: number;
mevcutRapor: MevcutRapor | null; mevcutRapor: MevcutRapor | null;
onizlemeSatirlari: OnizlemeSatir[];
/** ?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,
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. */
secimlerHazir?: boolean;
}) { }) {
const router = useRouter();
const monteEdildi = useMonteEdildi(); const monteEdildi = useMonteEdildi();
const hareketiAzalt = useReducedMotion();
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 [kartGizle, setKartGizle] = useState(false);
const [bekleyen, setBekleyen] = useState<BekleyenSecim | null>(null); const [bekleyen, setBekleyen] = useState<BekleyenSecim | null>(null);
const [rapor, setRapor] = useState<MevcutRapor | null>(mevcutRapor);
const [kredi, setKredi] = useState(baslangicKredi);
const [paketli, setPaketli] = useState(hasPaket);
const [durum, setDurum] = useState<PanelDurum>("bos");
// Adımlarda yapılan son seçimler (hata sonrası modala dönüşte prefill)
const [sonSecimler, setSonSecimler] = useState<SihirbazSecimleri | null>(
null,
);
// Aynı üretim için sabit requestId (retry çifte harcama yapmaz)
const requestIdRef = useRef<string | null>(null);
const otomatikBasladi = useRef(false);
const bolumRef = useRef<HTMLDivElement>(null);
// Mount sonrası storage'dan bekleyen seçimleri + kalıcı kart kapatmayı oku // Mount sonrası storage'dan bekleyen seçimleri + kalıcı kart kapatmayı oku
// (dış store'dan başlangıç senkronizasyonu — bir kez).
useEffect(() => { useEffect(() => {
try { try {
const kaliciKapali = Boolean(sessionStorage.getItem(KAPATMA_KEY)); const kaliciKapali = Boolean(sessionStorage.getItem(KAPATMA_KEY));
@@ -93,11 +125,158 @@ export function SihirbazBolumu({
} catch {} } catch {}
}, [sira, tur]); }, [sira, tur]);
// Giriş/ödeme dönüşünde otomatik üretim: eşleşen bekleyen seçim + paket // Üstteki funnel banner'ının CTA'sı sihirbaz modalını bu olayla açar
const otomatikUret = Boolean(bekleyen?.eslesir && girisliMi && hasPaket); useEffect(() => {
// Açık durumu türetilmiş: elle veya otomatik tetik, kullanıcı kapatmadıkça const ac = () => {
const acik = !elleKapandi && (elleAcik || Boolean(otomatikAc) || otomatikUret); setElleAcik(true);
const baslangicSecimler = bekleyen?.eslesir ? bekleyen.secimler : null; setElleKapandi(false);
};
window.addEventListener(SIHIRBAZ_AC_EVENT, ac);
return () => window.removeEventListener(SIHIRBAZ_AC_EVENT, ac);
}, []);
/**
* Bölüm ekran dışındaysa (kullanıcı sihirbazı üstteki banner'dan ya da
* sabit alt karttan açmış olabilir) modal hâlâ perdeyi kaplarken kaydırırız;
* böylece Motion ile giren hedef panel görünür alanda kalır.
*/
function bolumuGorunureAl() {
const el = bolumRef.current;
if (!el) return;
const kutu = el.getBoundingClientRect();
if (kutu.top >= 0 && kutu.bottom <= window.innerHeight) return;
el.scrollIntoView({ block: "center", behavior: "instant" });
}
async function modalCikisiniOynat() {
if (hareketiAzalt) return;
const modal = document.querySelector<HTMLElement>("[data-sihirbaz-modal]");
if (!modal) return;
const mevcutTransform = getComputedStyle(modal).transform;
const hedefTransform =
mevcutTransform === "none"
? "translate3d(0, 48px, 0)"
: new DOMMatrixReadOnly(mevcutTransform).translate(0, 48).toString();
const perde = document.querySelector<HTMLElement>(
'[data-slot="dialog-overlay"]',
);
const modalAnimasyonu = animate(
modal,
{
opacity: 0,
transform: hedefTransform,
},
{
duration: 0.24,
ease: [0.4, 0, 0.7, 0.2],
},
);
if (perde) {
await Promise.all([
modalAnimasyonu,
animate(perde, { opacity: 0 }, { duration: 0.2, ease: "easeOut" }),
]);
return;
}
await modalAnimasyonu;
}
async function uret(secimler: SihirbazSecimleri) {
if (!requestIdRef.current) requestIdRef.current = crypto.randomUUID();
bolumuGorunureAl();
await modalCikisiniOynat();
setElleAcik(false);
setDurum("uretiliyor");
try {
const sonuc = await listeOlustur({
sira,
tur,
secimler,
requestId: requestIdRef.current,
});
if (sonuc.ok) {
requestIdRef.current = null;
setRapor({
rapor: sonuc.rapor,
params: sonuc.params,
revisionCount: sonuc.revisionCount,
});
setKredi(sonuc.kredi);
setPaketli(sonuc.hasPaket);
try {
localStorage.removeItem(SIHIRBAZ_STORAGE_KEY);
} catch {}
// AI çıktısı bu sayfada gösterilmez: üretim animasyonu açık kalır,
// liste (paketsizse paywall'lı hâliyle) /listem'de karşılar.
router.push("/listem");
return;
}
// Hata: aynı requestId'yi koru ki tekrar denemede çifte harcama olmasın
if (sonuc.code === "KREDI" || sonuc.code === "PAKET") {
setDurum("kredi-yetersiz");
} else if (sonuc.code === "AUTH") {
secimleriKaydetVeGirise(secimler);
} else {
toast.error(sonuc.error);
modalaDon();
}
} catch {
toast.error("Beklenmeyen bir hata oluştu, tekrar dener misin?");
modalaDon();
}
}
/** Hata sonrası panelden modala dönüş. */
function modalaDon() {
setDurum("bos");
setElleAcik(true);
}
function secimleriKaydetVeGirise(secimler: SihirbazSecimleri) {
try {
localStorage.setItem(
SIHIRBAZ_STORAGE_KEY,
JSON.stringify({ sira, tur, secimler }),
);
} catch {}
const geri = `/sonuc?sira=${sira}&tur=${tur}&sihirbaz=1`;
router.push(`/giris?callback=${encodeURIComponent(geri)}`);
}
async function ilerle(secimler: SihirbazSecimleri) {
setSonSecimler(secimler);
if (!girisliMi) {
await modalCikisiniOynat();
secimleriKaydetVeGirise(secimler);
return;
}
await uret(secimler);
}
// Giriş dönüşü: storage'daki eşleşen seçimlerle otomatik üretime başla
// (bir kez, modal açmadan — üretim sayfa gövdesinde oynar).
const otomatikUret = Boolean(bekleyen?.eslesir && girisliMi && !rapor);
useEffect(() => {
if (otomatikUret && bekleyen && !otomatikBasladi.current) {
otomatikBasladi.current = true;
setSonSecimler(bekleyen.secimler);
void uret(bekleyen.secimler);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [otomatikUret]);
// ?sihirbaz=1: otomatik üretim devreye girmediyse (seçim kaybolduysa/
// eşleşmiyorsa) modalı aç ki kullanıcı adımları tamamlayabilsin.
const acik =
!elleKapandi &&
(elleAcik ||
Boolean(otomatikAc && !otomatikUret && !rapor && durum === "bos"));
function acikDegisti(v: boolean) { function acikDegisti(v: boolean) {
if (v) { if (v) {
@@ -109,60 +288,196 @@ export function SihirbazBolumu({
} }
} }
// Üretim başladıysa storage'ı temizle (modal artık state'te tutuyor) const panelde = Boolean(rapor) || durum !== "bos";
useEffect(() => {
if (acik && otomatikUret) {
try {
localStorage.removeItem(SIHIRBAZ_STORAGE_KEY);
} catch {}
}
}, [acik, otomatikUret]);
const raporVar = Boolean(mevcutRapor); // Değer önce: sihirbazı bitirmiş ama girişsiz kullanıcı ücretsiz tabloyu
// zaten görüyor; giriş burada bariyer değil, hazır seçimlerin (ödülün)
// kaydedilmesi olarak sunulur. Girişten dönüşte otomatik üretim başlar.
// Sinyal URL'den (?hazir=1) gelir; storage'a bakılsaydı panel anahtarı
// mount sonrası değişir ve AnimatePresence geçişi donardı.
const girisBekliyor = Boolean(!girisliMi && secimlerHazir && !rapor);
const girisUrl = `/giris?callback=${encodeURIComponent(
`/sonuc?sira=${sira}&tur=${tur}&sihirbaz=1`,
)}`;
const panelAnahtari =
durum !== "bos"
? durum
: rapor
? "rapor-hazir"
: girisBekliyor
? "giris-cta"
: "sihirbaz-cta";
return ( return (
<> <>
{/* Sayfa ortasındaki büyük CTA */} {/* AI çıktısı (rapor listesi/haritası) bu sayfada render edilmez;
<section className="mt-16 rounded-2xl bg-primary p-8 text-center text-white sm:p-12"> panel yalnızca üretim ve kredi-yetersiz gibi geçici funnel
<h2 className="font-heading text-2xl font-bold sm:text-3xl"> durumlarını gösterir, listenin kendisi /listem'de yaşar. */}
{raporVar <div ref={bolumRef} className="relative">
? "24 tercihlik listen hazır" <AnimatePresence mode="popLayout" initial={false}>
: "Bu listeyi 24 tercihlik plana çevirelim mi?"} <motion.div
</h2> key={panelAnahtari}
<p className="mx-auto mt-3 max-w-xl text-white/90"> initial={
{raporVar hareketiAzalt
? "Listeni görüntüle, revize et veya listen hakkında soru sor." ? { opacity: 0 }
: "Birkaç adımda ilgi alanların ve şehir tercihine göre gerekçeli, dengeli bir tercih listesi kuralım — her satır için yerleşme riski analiziyle."} : {
</p> opacity: 0,
<Button transform: "translate3d(0, 8px, 0)",
size="lg" }
onClick={() => acikDegisti(true)} }
className="mt-6 h-12 cursor-pointer bg-orange-500 px-8 text-white transition-colors duration-200 hover:bg-orange-600" animate={{
> opacity: 1,
<Sparkles className="size-4" aria-hidden /> transform: "translate3d(0, 0, 0)",
{raporVar ? "Listemi görüntüle" : "Listemi oluştur"} transition: hareketiAzalt
</Button> ? { duration: 0.12, ease: "easeOut" }
</section> : {
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" ? (
<ListePaneli
durum={durum}
rapor={null}
hasPaket={paketli}
kredi={kredi}
sira={sira}
acilis={acilis}
onRevize={async (feedback) => {
const sonuc = await listeRevize({
feedback,
requestId: crypto.randomUUID(),
});
if (sonuc.ok) {
setRapor({
rapor: sonuc.rapor,
params: sonuc.params,
revisionCount: sonuc.revisionCount,
});
setKredi(sonuc.kredi);
toast.success("Listen yeniden kuruldu.");
} else {
toast.error(sonuc.error);
}
return sonuc.ok;
}}
/>
) : rapor ? (
<section className="mt-16 rounded-2xl border border-slate-200 bg-white p-8 text-center sm:p-12">
<h2 className="font-heading text-2xl font-bold sm:text-3xl">
24 tercihlik listen hazır
</h2>
<p className="mx-auto mt-3 max-w-xl text-slate-600">
Yapay zekânın kurduğu risk analizli listen Listem sayfasında
seni bekliyor.
</p>
<Button
asChild
size="lg"
className="mt-6 h-12 cursor-pointer bg-orange-500 px-8 text-white transition-colors duration-200 hover:bg-orange-600"
>
<Link href="/listem">Listeni gör</Link>
</Button>
</section>
) : girisBekliyor ? (
<section className="mt-16 rounded-2xl bg-primary p-8 text-center text-white sm:p-12">
<h2 className="font-heading text-2xl font-bold sm:text-3xl">
Seçimlerin hazır AI listen bir adım uzakta
</h2>
<p className="mx-auto mt-3 max-w-xl text-white/90">
Yukarıdaki tablo her zaman ücretsiz. 5 deneme kredinle 24
tercihlik AI listeni de kurabilirsin: ücretsiz giriş yap,
seçimlerin kayıtlı listen otomatik kurulsun (üretim 3
kredi, kalan 2 kredinle danışmana soru sorarsın).
</p>
<Button
asChild
size="lg"
className="mt-6 h-12 cursor-pointer bg-orange-500 px-8 text-white transition-colors duration-200 hover:bg-orange-600"
>
<Link href={girisUrl}>
<Sparkles className="size-4" aria-hidden />5 deneme kredinle
AI listeni kur giriş yap
</Link>
</Button>
<p className="mt-3 text-xs text-white/70">
Kredi kartı gerekmez · Google ya da e-posta ile 30 saniyede
</p>
</section>
) : (
<section className="mt-16 rounded-2xl bg-primary p-8 text-center text-white sm:p-12">
<h2 className="font-heading text-2xl font-bold sm:text-3xl">
Bu listeyi 24 tercihlik plana çevirelim mi?
</h2>
<p className="mx-auto mt-3 max-w-xl text-white/90">
Birkaç adımda ilgi alanların ve şehir tercihine göre
gerekçeli, dengeli bir tercih listesi kuralım her satır için
yerleşme riski analiziyle.
</p>
<Button
size="lg"
onClick={() => acikDegisti(true)}
className="mt-6 h-12 cursor-pointer bg-orange-500 px-8 text-white transition-colors duration-200 hover:bg-orange-600"
>
<Sparkles className="size-4" aria-hidden />
Listemi oluştur
</Button>
</section>
)}
</motion.div>
</AnimatePresence>
</div>
{/* Sabit alt çağrı kartırapor yoksa ve kart kapatılmadıysa */} {/* Sabit alt çağrı kartıliste/panel yokken ve kart kapatılmadıysa */}
{monteEdildi && !raporVar && !kartGizle && !acik ? ( {monteEdildi && !panelde && !kartGizle && !acik ? (
<div className="pointer-events-none fixed inset-x-0 bottom-4 z-40 px-4"> <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="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"> <div className="flex-1">
<p className="font-heading text-sm font-bold text-slate-900"> <p className="font-heading text-sm font-bold text-slate-900">
3 adımda 24 tercihlik listen {girisBekliyor
? "Seçimlerin kayıtlı — AI listen hazır bekliyor"
: "3 adımda 24 tercihlik listen"}
</p> </p>
<p className="text-xs text-slate-500"> <p className="text-xs text-slate-500">
Sıralamana uygun bölümleri seç, yapay zekâ listeni kursun. {girisBekliyor
? "Ücretsiz giriş yap, 5 deneme kredinle listen otomatik kurulsun."
: "Sıralamana uygun bölümleri seç, yapay zekâ listeni kursun."}
</p> </p>
</div> </div>
<Button {girisBekliyor ? (
size="sm" <Button
onClick={() => acikDegisti(true)} asChild
className="cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600" size="sm"
> className="cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
Başla >
</Button> <Link href={girisUrl}>Giriş yap</Link>
</Button>
) : (
<Button
size="sm"
onClick={() => acikDegisti(true)}
className="cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
>
Başla
</Button>
)}
<button <button
type="button" type="button"
aria-label="Kapat" aria-label="Kapat"
@@ -180,20 +495,20 @@ export function SihirbazBolumu({
</div> </div>
) : null} ) : null}
{monteEdildi ? ( {/* Üretim başladığında modalı kaldır; yeni panelin geçişini Motion
üstlenir ve odağı panel içeriğine taşır. */}
{monteEdildi && durum === "bos" ? (
<SihirbazModal <SihirbazModal
acik={acik} acik={acik}
onAcikDegisti={acikDegisti} onAcikDegisti={acikDegisti}
sira={sira}
tur={tur}
facetler={facetler} facetler={facetler}
girisliMi={girisliMi} baslangicSecimler={
hasPaket={hasPaket} sonSecimler ?? (bekleyen?.eslesir ? bekleyen.secimler : null)
kredi={kredi} }
mevcutRapor={mevcutRapor} sonButonEtiketi={
onizlemeSatirlari={onizlemeSatirlari} girisliMi ? "Listemi oluştur (3 kredi)" : "Giriş yap ve listeni gör"
otomatikUret={otomatikUret} }
baslangicSecimler={baslangicSecimler} onTamamla={ilerle}
/> />
) : null} ) : null}
</> </>

View File

@@ -1,636 +1,52 @@
"use client"; "use client";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import {
ArrowLeft,
ArrowRight,
FileText,
Loader2,
Lock,
RefreshCcw,
Sparkles,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge"; import { SihirbazAdimlar } from "@/components/sihirbaz-adimlar";
import { RaporListesi } from "@/components/rapor-listesi";
import { SoruKutusu } from "./soru-kutusu";
import type { SihirbazFacetleri } from "@/lib/db"; import type { SihirbazFacetleri } from "@/lib/db";
import type { RaporSonuc } from "@/lib/ai/rapor"; import type { SihirbazSecimleri } from "@/lib/sihirbaz";
import type { RaporParams } from "@/lib/rapor-havuzu";
import {
ONCELIKLER,
SIHIRBAZ_STORAGE_KEY,
UNIVERSITE_TIPI_ETIKET,
type SihirbazSecimleri,
type UniversiteTipi,
} from "@/lib/sihirbaz";
import { listeOlustur, listeRevize } from "./actions";
const MAX_REVIZYON = 2;
export type OnizlemeSatir = {
id: string;
isim: string;
universite: string;
il: string | null;
};
export type MevcutRapor = {
rapor: RaporSonuc;
params: RaporParams;
revisionCount: number;
};
type Faz = "adimlar" | "onizleme" | "uretiliyor" | "liste";
function Cip({
secili,
onClick,
children,
}: {
secili: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={secili}
className={
secili
? "cursor-pointer rounded-full border border-primary bg-primary px-3.5 py-2 text-sm font-medium text-white transition-colors duration-200"
: "cursor-pointer rounded-full border border-slate-200 bg-white px-3.5 py-2 text-sm text-slate-700 transition-colors duration-200 hover:border-slate-300 hover:bg-slate-50"
}
>
{children}
</button>
);
}
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…",
];
/**
* 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
* sihirbaz-cagri-karti.tsx'teki SihirbazBolumu'ndadır.
*/
export function SihirbazModal({ export function SihirbazModal({
acik, acik,
onAcikDegisti, onAcikDegisti,
sira,
tur,
facetler, facetler,
girisliMi,
hasPaket,
kredi: baslangicKredi,
mevcutRapor,
onizlemeSatirlari,
otomatikUret,
baslangicSecimler, baslangicSecimler,
sonButonEtiketi,
onTamamla,
}: { }: {
acik: boolean; acik: boolean;
onAcikDegisti: (acik: boolean) => void; onAcikDegisti: (acik: boolean) => void;
sira: number;
tur: string;
facetler: SihirbazFacetleri; facetler: SihirbazFacetleri;
girisliMi: boolean;
hasPaket: boolean;
kredi: number;
mevcutRapor: MevcutRapor | null;
onizlemeSatirlari: OnizlemeSatir[];
/** Giriş dönüşü: storage'daki seçimlerle otomatik üretime başla */
otomatikUret?: boolean;
baslangicSecimler?: SihirbazSecimleri | null; baslangicSecimler?: SihirbazSecimleri | null;
sonButonEtiketi: string;
onTamamla: (secimler: SihirbazSecimleri) => void;
}) { }) {
const router = useRouter();
const [faz, setFaz] = useState<Faz>(mevcutRapor ? "liste" : "adimlar");
const [adim, setAdim] = useState(0);
const [kredi, setKredi] = useState(baslangicKredi);
const [rapor, setRapor] = useState<MevcutRapor | null>(mevcutRapor);
const [uretimMesajIdx, setUretimMesajIdx] = useState(0);
// Seçimler
const [kategoriler, setKategoriler] = useState<string[]>(
baslangicSecimler?.kategoriler ?? [],
);
const [iller, setIller] = useState<string[]>(baslangicSecimler?.iller ?? []);
const [universiteTipi, setUniversiteTipi] = useState<UniversiteTipi>(
baslangicSecimler?.universiteTipi ?? "farketmez",
);
const [oncelikler, setOncelikler] = useState<string[]>(
baslangicSecimler?.oncelikler ?? [],
);
// Aynı üretim için sabit requestId (retry çifte harcama yapmaz)
const requestIdRef = useRef<string | null>(null);
const otomatikBasladi = useRef(false);
function secimleriTopla(): SihirbazSecimleri {
return { kategoriler, iller, universiteTipi, oncelikler };
}
function listeDegistir(
liste: string[],
setListe: (v: string[]) => void,
deger: string,
maks?: number,
) {
if (liste.includes(deger)) {
setListe(liste.filter((x) => x !== deger));
} else {
if (maks && liste.length >= maks) {
toast.info(`En fazla ${maks} seçebilirsin.`);
return;
}
setListe([...liste, deger]);
}
}
async function uret(secimler: SihirbazSecimleri) {
if (!requestIdRef.current) requestIdRef.current = crypto.randomUUID();
setFaz("uretiliyor");
setUretimMesajIdx(0);
const interval = setInterval(() => {
setUretimMesajIdx((i) => Math.min(i + 1, URETIM_MESAJLARI.length - 1));
}, 7000);
try {
const sonuc = await listeOlustur({
sira,
tur,
secimler,
requestId: requestIdRef.current,
});
if (sonuc.ok) {
requestIdRef.current = null;
setRapor({
rapor: sonuc.rapor,
params: sonuc.params,
revisionCount: sonuc.revisionCount,
});
setKredi(sonuc.kredi);
setFaz("liste");
return;
}
// Hata: aynı requestId'yi koru ki tekrar denemede çifte harcama olmasın
if (sonuc.code === "KREDI") {
toast.error(sonuc.error);
setFaz("adimlar");
} else if (sonuc.code === "PAKET") {
setFaz("onizleme");
} else if (sonuc.code === "AUTH") {
secimleriKaydetVeGirise(secimler);
} else {
toast.error(sonuc.error);
setFaz("adimlar");
}
} catch {
toast.error("Beklenmeyen bir hata oluştu, tekrar dener misin?");
setFaz("adimlar");
} finally {
clearInterval(interval);
}
}
function secimleriKaydetVeGirise(secimler: SihirbazSecimleri) {
try {
localStorage.setItem(
SIHIRBAZ_STORAGE_KEY,
JSON.stringify({ sira, tur, secimler }),
);
} catch {}
const geri = `/sonuc?sira=${sira}&tur=${tur}&sihirbaz=1`;
router.push(`/giris?callback=${encodeURIComponent(geri)}`);
}
// Giriş dönüşü: storage'daki seçimlerle otomatik üretime başla (bir kez)
useEffect(() => {
if (
otomatikUret &&
baslangicSecimler &&
hasPaket &&
girisliMi &&
!otomatikBasladi.current &&
!mevcutRapor
) {
otomatikBasladi.current = true;
void uret(baslangicSecimler);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Paketsiz kullanıcı önizlemeye geçerken seçimleri sakla ki ödeme dönüşünde
// (aynı sıralamayla) sihirbaz kaldığı yerden devam edebilsin.
function secimleriSakla(secimler: SihirbazSecimleri) {
try {
localStorage.setItem(
SIHIRBAZ_STORAGE_KEY,
JSON.stringify({ sira, tur, secimler }),
);
} catch {}
}
function ilerle() {
const secimler = secimleriTopla();
if (!girisliMi) {
secimleriKaydetVeGirise(secimler);
return;
}
if (!hasPaket) {
secimleriSakla(secimler);
setFaz("onizleme");
return;
}
void uret(secimler);
}
const adimGecerli = adim === 0 ? kategoriler.length > 0 : true;
// ---- Faz gövdeleri ----
const adimlarGovde = (
<div className="flex flex-col gap-5">
<div className="flex items-center gap-1.5">
{[0, 1, 2].map((i) => (
<span
key={i}
className={
i === adim
? "h-1.5 w-6 rounded-full bg-primary transition-all"
: "h-1.5 w-1.5 rounded-full bg-slate-200 transition-all"
}
/>
))}
<span className="ml-2 text-xs font-medium text-slate-400">
Adım {adim + 1}/3
</span>
</div>
{adim === 0 ? (
<div>
<h3 className="font-heading text-lg font-bold">
Hangi alanlara ilgi duyuyorsun?
</h3>
<p className="mt-1 text-sm text-slate-500">
Sıralamanla ulaşabileceğin alanlar. En az bir tane seç.
</p>
<div className="mt-4 flex flex-wrap gap-2">
{facetler.kategoriler.map((k) => (
<Cip
key={k.ad}
secili={kategoriler.includes(k.ad)}
onClick={() =>
listeDegistir(kategoriler, setKategoriler, k.ad)
}
>
{k.ad}
<span className="ml-1.5 text-xs opacity-70">{k.adet}</span>
</Cip>
))}
</div>
</div>
) : adim === 1 ? (
<div>
<h3 className="font-heading text-lg font-bold">
Nerede okumak istersin?
</h3>
<p className="mt-1 text-sm text-slate-500">
İstersen boş bırak (fark etmez). En fazla 5 il.
</p>
<div className="mt-4 flex flex-wrap gap-2">
{facetler.iller.map((i) => (
<Cip
key={i.il}
secili={iller.includes(i.il)}
onClick={() => listeDegistir(iller, setIller, i.il, 5)}
>
{i.il}
<span className="ml-1.5 text-xs opacity-70">{i.adet}</span>
</Cip>
))}
</div>
</div>
) : (
<div className="space-y-5">
<div>
<h3 className="font-heading text-lg font-bold">
Üniversite tipi & öncelikler
</h3>
<p className="mt-1 text-sm text-slate-500">
Bunlar listenin dengesini belirler.
</p>
</div>
<div>
<p className="mb-2 text-xs font-semibold tracking-wide text-slate-400 uppercase">
Üniversite tipi
</p>
<div className="flex flex-wrap gap-2">
{facetler.uniturler.map((u) => (
<Cip
key={u.grup}
secili={universiteTipi === u.grup}
onClick={() =>
setUniversiteTipi(
universiteTipi === u.grup ? "farketmez" : u.grup,
)
}
>
{UNIVERSITE_TIPI_ETIKET[u.grup]}
<span className="ml-1.5 text-xs opacity-70">{u.adet}</span>
</Cip>
))}
<Cip
secili={universiteTipi === "farketmez"}
onClick={() => setUniversiteTipi("farketmez")}
>
Fark etmez
</Cip>
</div>
</div>
<div>
<p className="mb-2 text-xs font-semibold tracking-wide text-slate-400 uppercase">
Senin için önemli olan
</p>
<div className="flex flex-wrap gap-2">
{ONCELIKLER.map((o) => (
<Cip
key={o}
secili={oncelikler.includes(o)}
onClick={() => listeDegistir(oncelikler, setOncelikler, o)}
>
{o}
</Cip>
))}
</div>
</div>
</div>
)}
<div className="flex items-center justify-between border-t border-slate-100 pt-4">
<Button
type="button"
variant="ghost"
onClick={() => setAdim((a) => a - 1)}
disabled={adim === 0}
className="cursor-pointer"
>
<ArrowLeft className="size-4" aria-hidden />
Geri
</Button>
{adim < 2 ? (
<Button
type="button"
onClick={() => setAdim((a) => a + 1)}
disabled={!adimGecerli}
className="cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
>
Devam
<ArrowRight className="size-4" aria-hidden />
</Button>
) : (
<Button
type="button"
onClick={ilerle}
disabled={kategoriler.length === 0}
className="cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
>
<Sparkles className="size-4" aria-hidden />
{hasPaket ? "24'lük listemi oluştur (3 kredi)" : "Listemi gör"}
</Button>
)}
</div>
</div>
);
const onizlemeGovde = (
<div>
<h3 className="font-heading text-xl font-bold">
24 tercihlik listen hazır olmak üzere
</h3>
<p className="mt-2 text-sm text-slate-600">
Aşağıda ilk birkaç satırın ön izlemesi var. Tam listeyi, gerekçeleri, risk
analizini ve PDF çıktıyı görmek için paketi aktive et.
</p>
<div className="mt-6 overflow-hidden rounded-2xl border border-slate-200">
{onizlemeSatirlari.length > 0 ? (
<ul className="divide-y divide-slate-100">
{onizlemeSatirlari.map((p, i) => (
<li key={p.id} className="flex items-center gap-4 px-5 py-3">
<span className="w-6 text-right font-heading font-bold text-slate-400">
{i + 1}
</span>
<div className="min-w-0 flex-1">
<p className="truncate font-medium">{p.isim}</p>
<p className="truncate text-sm text-slate-500">
{p.universite} · {p.il ?? "—"}
</p>
</div>
<Badge variant="secondary" className="bg-blue-100 text-blue-700">
Dengeli
</Badge>
</li>
))}
</ul>
) : null}
<div className="relative">
<ul className="divide-y divide-slate-100 blur-sm select-none" aria-hidden>
{Array.from({ length: 7 }).map((_, i) => (
<li key={i} className="flex items-center gap-4 px-5 py-3">
<span className="w-6 text-right font-heading font-bold text-slate-300">
{onizlemeSatirlari.length + i + 1}
</span>
<div className="flex-1 space-y-1.5">
<div className="h-3.5 w-2/3 rounded bg-slate-200" />
<div className="h-3 w-1/2 rounded bg-slate-100" />
</div>
<div className="h-5 w-16 rounded-full bg-slate-100" />
</li>
))}
</ul>
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-gradient-to-b from-white/30 via-white/80 to-white">
<Lock className="size-8 text-slate-400" aria-hidden />
<p className="max-w-sm text-center text-sm font-medium text-slate-700">
+19 tercih, her satır için gerekçe ve risk analizi, PDF çıktı ve AI
soru hakkı seni bekliyor.
</p>
<Button
asChild
size="lg"
className="h-12 cursor-pointer bg-orange-500 px-8 text-white transition-colors duration-200 hover:bg-orange-600"
>
<Link href="/paket">299 TL Paketi aktive et</Link>
</Button>
</div>
</div>
</div>
<Button
type="button"
variant="ghost"
onClick={() => setFaz("adimlar")}
className="mt-4 cursor-pointer"
>
<ArrowLeft className="size-4" aria-hidden />
Seçimlere dön
</Button>
</div>
);
const uretiliyorGovde = (
<div className="flex flex-col items-center justify-center gap-4 py-16 text-center">
<Loader2 className="size-10 animate-spin text-primary" aria-hidden />
<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[uretimMesajIdx]}
</p>
<p className="text-xs text-slate-400">
Bu işlem yarım dakika kadar sürebilir, sayfayı kapatma.
</p>
</div>
);
const listeGovde = rapor ? (
<div>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 className="font-heading text-xl font-bold">24 Tercihlik Listen</h3>
<p className="mt-1 text-sm text-slate-500">
{sira.toLocaleString("tr-TR")}. sıra · {kredi} kredin kaldı
</p>
</div>
<Button asChild variant="outline" className="cursor-pointer">
<Link href="/rapor/yazdir" target="_blank">
<FileText className="size-4" aria-hidden />
PDF indir
</Link>
</Button>
</div>
<div className="mt-5">
<RaporListesi rapor={rapor.rapor} />
</div>
<div className="mt-6">
<RevizyonKutusu
kalanHak={MAX_REVIZYON - rapor.revisionCount}
onRevize={async (feedback) => {
const sonuc = await listeRevize({
feedback,
requestId: crypto.randomUUID(),
});
if (sonuc.ok) {
setRapor({
rapor: sonuc.rapor,
params: sonuc.params,
revisionCount: sonuc.revisionCount,
});
setKredi(sonuc.kredi);
toast.success("Listen yeniden kuruldu.");
} else {
toast.error(sonuc.error);
}
return sonuc.ok;
}}
/>
</div>
<div className="mt-6">
<SoruKutusu kredi={kredi} onKrediDegisti={setKredi} />
</div>
</div>
) : null;
return ( return (
<Dialog open={acik} onOpenChange={onAcikDegisti}> <Dialog open={acik} onOpenChange={onAcikDegisti}>
<DialogContent className="max-h-[92dvh] gap-0 overflow-y-auto p-6 sm:max-w-3xl"> <DialogContent
data-sihirbaz-modal
className="max-h-[92dvh] gap-0 overflow-y-auto p-6 sm:max-w-3xl"
>
<DialogTitle className="sr-only">Tercih listesi sihirbazı</DialogTitle> <DialogTitle className="sr-only">Tercih listesi sihirbazı</DialogTitle>
<DialogDescription className="sr-only"> <DialogDescription className="sr-only">
Sıralamana uygun 24 tercihlik listeni oluştur. Sıralamana uygun 24 tercihlik listeni oluştur.
</DialogDescription> </DialogDescription>
{faz === "adimlar" <SihirbazAdimlar
? adimlarGovde facetler={facetler}
: faz === "onizleme" baslangicSecimler={baslangicSecimler}
? onizlemeGovde sonButonEtiketi={sonButonEtiketi}
: faz === "uretiliyor" onTamamla={onTamamla}
? uretiliyorGovde />
: listeGovde}
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );
} }
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"
>
{pending ? (
<Loader2 className="size-4 animate-spin" aria-hidden />
) : (
<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>
);
}

View File

@@ -0,0 +1,73 @@
"use client";
// AI danışman sohbeti — sayfa altında yüzen buton + intercom tarzı popup.
// Motion dili: panel tetikleyicisinden doğar (transform-origin sağ-alt),
// spring ile açılır; çıkış girişten hızlı. Reduced motion'da cross-fade.
import { useEffect, useState } from "react";
import { MessageCircleMore, X } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { SohbetClient } from "@/app/listem/sohbet-client";
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 (
<>
<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>
</>
);
}

View File

@@ -1,222 +0,0 @@
"use client";
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import { Coins, Loader2, SendHorizonal, Sparkles } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
interface Mesaj {
id: string;
role: "user" | "assistant";
content: string;
}
const ORNEK_SORULAR = [
"Listemdeki ilk 5 tercih çok mu riskli?",
"Bu sıralamayla hangi bölüme kesin girerim?",
"İlk tercihimle ikinciyi neden bu sırada koydun?",
];
/**
* Liste oluşturulduktan sonra, o listeye bağlı bağlamsal soru-cevap kutusu.
* Serbest sohbet değil: her soru 1 kredi, cevap /api/soru'dan stream edilir.
*/
export function SoruKutusu({
kredi,
onKrediDegisti,
}: {
// Kredi tümüyle kontrollü: kaynak modaldaki state (tek doğruluk kaynağı)
kredi: number;
onKrediDegisti: (kredi: number) => void;
}) {
const [mesajlar, setMesajlar] = useState<Mesaj[]>([]);
const [girdi, setGirdi] = useState("");
const [bekliyor, setBekliyor] = useState(false);
const [yuklendi, setYuklendi] = useState(false);
const altRef = useRef<HTMLDivElement>(null);
// Geçmiş soru-cevapları ve güncel krediyi yükle
useEffect(() => {
fetch("/api/soru")
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
if (data?.mesajlar) setMesajlar(data.mesajlar);
if (typeof data?.kredi === "number") onKrediDegisti(data.kredi);
})
.catch(() => {})
.finally(() => setYuklendi(true));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (mesajlar.length > 0) {
altRef.current?.scrollIntoView({ behavior: "smooth" });
}
}, [mesajlar]);
function krediGuncelle(yeni: number) {
onKrediDegisti(yeni);
}
async function gonder(metin: string) {
const mesaj = metin.trim();
if (!mesaj || bekliyor) return;
if (kredi < 1) {
toast.error("Kredin bitti — devam etmek için kredi yükle.");
return;
}
setGirdi("");
setBekliyor(true);
const clientMessageId = crypto.randomUUID();
const asistanId = crypto.randomUUID();
setMesajlar((m) => [
...m,
{ id: clientMessageId, role: "user", content: mesaj },
{ id: asistanId, role: "assistant", content: "" },
]);
try {
const res = await fetch("/api/soru", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: mesaj, clientMessageId }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setMesajlar((m) =>
m.filter((x) => x.id !== asistanId && x.id !== clientMessageId),
);
if (res.status === 402) {
toast.error("Kredin bitti — kredi yükleyerek devam edebilirsin.");
krediGuncelle(0);
} else {
toast.error(data.error ?? "Bir sorun oluştu, tekrar dener misin?");
}
return;
}
krediGuncelle(kredi - 1);
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let birikmis = "";
for (;;) {
const { done, value } = await reader.read();
if (done) break;
birikmis += decoder.decode(value, { stream: true });
const anlik = birikmis;
setMesajlar((m) =>
m.map((x) => (x.id === asistanId ? { ...x, content: anlik } : x)),
);
}
} catch {
toast.error("Bağlantı koptu. Cevabı sayfayı yenileyerek görebilirsin.");
} finally {
setBekliyor(false);
}
}
return (
<div className="rounded-2xl border border-slate-200 bg-white">
<div className="flex items-center justify-between border-b border-slate-100 px-4 py-3">
<h3 className="font-heading text-base font-bold">Listen hakkında sor</h3>
<span className="inline-flex items-center gap-1.5 rounded-full border border-amber-200 bg-amber-50 px-2.5 py-1 text-xs font-semibold text-amber-700">
<Coins className="size-3.5" aria-hidden />
{kredi} kredi
</span>
</div>
<div className="max-h-72 space-y-3 overflow-y-auto p-4">
{mesajlar.length === 0 ? (
<div className="flex flex-col items-center gap-3 py-4 text-center">
<Sparkles className="size-6 text-primary" aria-hidden />
<p className="max-w-sm text-sm text-slate-600">
Bu listeye özel soru sorabilirsin. Her soru 1 kredi kullanır.
</p>
{yuklendi ? (
<div className="flex flex-wrap justify-center gap-2">
{ORNEK_SORULAR.map((s) => (
<button
key={s}
onClick={() => gonder(s)}
disabled={kredi < 1}
className="cursor-pointer rounded-full border border-slate-200 bg-slate-50 px-3 py-1.5 text-xs text-slate-700 transition-colors duration-200 hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-50"
>
{s}
</button>
))}
</div>
) : null}
</div>
) : (
mesajlar.map((m) => (
<div
key={m.id}
className={
m.role === "user" ? "flex justify-end" : "flex justify-start"
}
>
<div
className={
m.role === "user"
? "max-w-[85%] rounded-2xl rounded-br-sm bg-primary px-4 py-2.5 text-sm text-white"
: "max-w-[85%] whitespace-pre-line rounded-2xl rounded-bl-sm bg-slate-100 px-4 py-2.5 text-sm text-slate-800"
}
>
{m.content || (
<Loader2
className="size-4 animate-spin text-slate-400"
aria-hidden
/>
)}
</div>
</div>
))
)}
<div ref={altRef} />
</div>
<form
className="flex gap-2 border-t border-slate-100 p-3"
onSubmit={(e) => {
e.preventDefault();
gonder(girdi);
}}
>
<Input
value={girdi}
onChange={(e) => setGirdi(e.target.value)}
placeholder={
kredi < 1 ? "Kredin bitti" : "Sorunu yaz… (1 kredi)"
}
disabled={bekliyor || kredi < 1}
maxLength={2000}
className="h-11 flex-1"
/>
{kredi < 1 ? (
<Button
asChild
className="h-11 cursor-pointer bg-orange-500 px-4 text-white hover:bg-orange-600"
>
<Link href="/paket">Kredi yükle</Link>
</Button>
) : (
<Button
type="submit"
disabled={bekliyor || !girdi.trim()}
className="h-11 cursor-pointer bg-orange-500 px-4 text-white transition-colors duration-200 hover:bg-orange-600"
aria-label="Gönder"
>
{bekliyor ? (
<Loader2 className="size-4 animate-spin" aria-hidden />
) : (
<SendHorizonal className="size-4" aria-hidden />
)}
</Button>
)}
</form>
</div>
);
}

View File

@@ -1,182 +0,0 @@
"use client";
import { useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { cities } from "turkey-map-react/lib/data";
export type IlOzeti = {
hayal: number;
dengeli: number;
garanti: number;
};
type Props = {
/** DB'deki il adıyla (büyük harf Türkçe) program sayıları */
iller: Record<string, IlOzeti>;
sira: number;
turKey: string;
seciliIl: string | null;
};
// Harita adını DB formatına çevirir: "Hakkâri" → "HAKKARİ"
function normalizeIl(name: string): string {
return name
.toLocaleUpperCase("tr-TR")
.replace(/Â/g, "A")
.replace(/Î/g, "İ")
.replace(/Û/g, "U");
}
function toplam(o: IlOzeti): number {
return o.hayal + o.dengeli + o.garanti;
}
type Secim = {
name: string;
ozet: IlOzeti;
x: number;
y: number;
};
export function BolgeHaritasi({ iller, sira, turKey, seciliIl }: Props) {
const [secim, setSecim] = useState<Secim | null>(null);
const kapsayici = useRef<HTMLDivElement>(null);
const router = useRouter();
function filtrele(ilKey: string) {
const url =
ilKey === seciliIl
? `/sonuc?sira=${sira}&tur=${turKey}`
: `/sonuc?sira=${sira}&tur=${turKey}&il=${encodeURIComponent(ilKey)}`;
router.push(url, { scroll: false });
}
const maxToplam = Math.max(1, ...Object.values(iller).map((o) => toplam(o)));
// Haritada karşılığı olmayan yerler (KKTC, yurtdışı kampüsler)
const haritaIlleri = new Set(cities.map((c) => normalizeIl(c.name)));
const haritaDisi = Object.entries(iller).filter(
([il]) => !haritaIlleri.has(il)
);
function sec(el: SVGPathElement, name: string, ozet: IlOzeti) {
const kutu = kapsayici.current?.getBoundingClientRect();
const r = el.getBoundingClientRect();
if (!kutu) return;
setSecim({
name,
ozet,
x: r.x - kutu.x + r.width / 2,
y: r.y - kutu.y,
});
}
return (
<div ref={kapsayici} className="relative">
<svg
viewBox="0 0 1050 585"
className="h-auto w-full"
role="img"
aria-label="Önerilen programların illere dağılımı haritası"
onClick={(e) => {
if ((e.target as Element).tagName !== "path") setSecim(null);
}}
>
{/* Seçili il en son çizilir ki turuncu konturu komşu iller örtmesin */}
{[...cities]
.sort((a, b) =>
(normalizeIl(a.name) === seciliIl ? 1 : 0) -
(normalizeIl(b.name) === seciliIl ? 1 : 0)
)
.map((city) => {
const key = normalizeIl(city.name);
const ozet = iller[key];
const yogunluk = ozet ? toplam(ozet) / maxToplam : 0;
const secili = key === seciliIl;
return (
<path
key={city.id}
d={city.path}
className={
ozet
? "cursor-pointer transition-opacity duration-200 hover:opacity-80"
: "fill-slate-200 stroke-white"
}
style={
ozet
? {
fill: `oklch(0.623 0.188 259.8 / ${0.3 + 0.7 * yogunluk})`,
stroke: secili ? "oklch(0.705 0.213 47.6)" : "white",
strokeWidth: secili ? 2.5 : 1,
}
: undefined
}
strokeWidth={1}
onClick={
ozet ? () => filtrele(key) : () => setSecim(null)
}
onMouseEnter={
ozet
? (e) => sec(e.currentTarget, city.name, ozet)
: undefined
}
onMouseLeave={ozet ? () => setSecim(null) : undefined}
>
<title>
{ozet
? `${city.name}: ${toplam(ozet)} program — filtrelemek için tıkla`
: city.name}
</title>
</path>
);
})}
</svg>
{secim ? (
<div
data-testid="harita-tooltip"
className="pointer-events-none absolute z-10 -translate-x-1/2 -translate-y-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm shadow-md"
style={{ left: secim.x, top: secim.y - 6 }}
>
<p className="font-heading font-semibold">{secim.name}</p>
<p className="text-slate-600">
{toplam(secim.ozet)} program
<span className="block text-xs">
{secim.ozet.hayal} hayal · {secim.ozet.dengeli} dengeli ·{" "}
{secim.ozet.garanti} garanti
</span>
<span className="block text-xs font-medium text-primary">
{normalizeIl(secim.name) === seciliIl
? "Filtreyi kaldırmak için tıkla"
: "Filtrelemek için tıkla"}
</span>
</p>
</div>
) : null}
<div className="mt-2 flex items-center justify-end gap-2 text-xs text-slate-500">
<span>az</span>
<span
aria-hidden
className="h-2 w-24 rounded-full"
style={{
background:
"linear-gradient(to right, oklch(0.623 0.188 259.8 / 0.3), oklch(0.623 0.188 259.8))",
}}
/>
<span>çok program</span>
</div>
{haritaDisi.length > 0 ? (
<p className="mt-3 text-xs text-slate-500">
Harita dışı:{" "}
{haritaDisi
.map(
([il, ozet]) => `${il.toLocaleLowerCase("tr-TR")} (${toplam(ozet)})`
)
.join(", ")}
</p>
) : null}
</div>
);
}

View File

@@ -0,0 +1,73 @@
"use client";
// motion-primitives TextLoop — https://motion-primitives.com/docs/text-loop
import { cn } from "@/lib/utils";
import {
AnimatePresence,
motion,
useReducedMotion,
type Transition,
type Variants,
} from "motion/react";
import { Children, useEffect, useState } from "react";
export type TextLoopProps = {
children: React.ReactNode[];
className?: string;
interval?: number;
transition?: Transition;
variants?: Variants;
onIndexChange?: (index: number) => void;
};
const varsayilanVaryantlar: Variants = {
initial: { y: 20, opacity: 0 },
animate: { y: 0, opacity: 1 },
exit: { y: -20, opacity: 0 },
};
export function TextLoop({
children,
className,
interval = 2,
transition = { duration: 0.3 },
variants,
onIndexChange,
}: TextLoopProps) {
const [currentIndex, setCurrentIndex] = useState(0);
const items = Children.toArray(children);
// Sürekli dönen dekoratif metin: hareket azaltılsın diyen kullanıcıda
// döngü hiç başlamaz, ilk metin sabit kalır.
const azMotion = useReducedMotion();
useEffect(() => {
if (azMotion) return;
const intervalMs = interval * 1000;
const timer = setInterval(() => {
setCurrentIndex((current) => {
const next = (current + 1) % items.length;
onIndexChange?.(next);
return next;
});
}, intervalMs);
return () => clearInterval(timer);
}, [azMotion, items.length, interval, onIndexChange]);
return (
<div className={cn("relative inline-block whitespace-nowrap", className)}>
<AnimatePresence mode="popLayout" initial={false}>
<motion.div
key={currentIndex}
initial="initial"
animate="animate"
exit="exit"
transition={transition}
variants={variants ?? varsayilanVaryantlar}
>
{items[currentIndex]}
</motion.div>
</AnimatePresence>
</div>
);
}

View File

@@ -0,0 +1,45 @@
"use client";
import { Children, type ReactNode } from "react";
import {
AnimatePresence,
motion,
type Transition,
type Variants,
} from "motion/react";
type TransitionPanelProps = {
activeIndex: number;
children: ReactNode;
variants: Variants;
transition?: Transition;
custom?: number;
};
export function TransitionPanel({
activeIndex,
children,
variants,
transition,
custom,
}: TransitionPanelProps) {
const paneller = Children.toArray(children);
return (
<div className="relative overflow-hidden">
<AnimatePresence initial={false} custom={custom}>
<motion.div
key={activeIndex}
custom={custom}
variants={variants}
initial="enter"
animate="center"
exit="exit"
transition={transition}
>
{paneller[activeIndex]}
</motion.div>
</AnimatePresence>
</div>
);
}

View File

@@ -0,0 +1,344 @@
"use client";
// Dev süperadmin paneli: yalnızca geliştirme ortamında render edilir
// (layout.tsx NODE_ENV kontrolüyle monte eder; aksiyonlar da sunucuda
// ayrıca korunur). Amaç: satın alma / giriş / kredi durumlarını tek
// tıkla taklit edip uygulamanın tüm sayfalarını gerçek akışta gezmek.
//
// Hareket dili (Apple "Designing Fluid Interfaces"): panel DEV çipinden
// büyür ve yine ona küçülür (paylaşılan layoutId ile mekânsal bağ), yay
// kritik sönümlüdür (bounce 0) — açılış ortasında kapatılırsa mevcut
// değerden geri döner. Yüzey opak kutu değil, yarı saydam malzemedir.
import { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Loader2, Wrench, X } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { toast } from "sonner";
import { authClient } from "@/lib/auth-client";
import {
devAnindaGiris,
devAyarla,
devDurum,
devRaporSil,
devSahteOdeme,
type DevDurum,
} from "@/lib/dev/actions";
const SAYFALAR: { ad: string; href: string }[] = [
{ ad: "Ana sayfa", href: "/" },
{ ad: "Sonuç (85k · SAY)", href: "/sonuc?sira=85000&tur=say&sihirbaz=1" },
{ ad: "Sonuç (girişsiz hazır)", href: "/sonuc?sira=85000&tur=say&hazir=1" },
{ ad: "Listem", href: "/listem" },
{ ad: "Listem (kilit açılışı)", href: "/listem?acildi=1" },
{ ad: "Paket", href: "/paket" },
{ ad: "Paket (ödeme hatası)", href: "/paket?hata=token" },
{ ad: "PDF / yazdır", href: "/rapor/yazdir" },
{ ad: "Giriş", href: "/giris" },
{ ad: "Gizlilik", href: "/gizlilik" },
{ ad: "Koşullar", href: "/kosullar" },
{ ad: "İletişim", href: "/iletisim" },
{ ad: "404", href: "/boyle-bir-sayfa-yok" },
];
// Apple değerleri: damping 1.0 (bounce 0), response ~0.35 sn.
// Yay süreli değildir; hedef değişirse mevcut değer + hızdan devam eder.
const YAY = { type: "spring", bounce: 0, duration: 0.35 } as const;
// Basış anında tepki (pointer-down feedback) — bırakınca değil.
const BASIS =
"active:scale-[0.97] transition-[transform,background-color,border-color,opacity] duration-150 ease-out";
// Yarı saydam malzeme; şeffaflık azaltılsın diyen kullanıcıya opak yüzey.
const MALZEME =
"border border-white/60 bg-white/80 shadow-2xl shadow-slate-900/20 backdrop-blur-2xl backdrop-saturate-150 " +
"[@media(prefers-reduced-transparency:reduce)]:bg-white [@media(prefers-reduced-transparency:reduce)]:backdrop-blur-none";
export function DevPanel() {
const [acik, setAcik] = useState(false);
const [durum, setDurum] = useState<DevDurum | null>(null);
const [email, setEmail] = useState("test@kolaytercih.dev");
const [mesgul, setMesgul] = useState(false);
const router = useRouter();
const azMotion = useReducedMotion();
const yenile = useCallback(async () => {
try {
setDurum(await devDurum());
} catch {
setDurum(null);
}
}, []);
function paneliAc() {
setAcik(true);
void yenile();
}
// Yol bulma: panelden çıkış her an mümkün — Escape da kapatır.
useEffect(() => {
if (!acik) return;
const f = (e: KeyboardEvent) => {
if (e.key === "Escape") setAcik(false);
};
window.addEventListener("keydown", f);
return () => window.removeEventListener("keydown", f);
}, [acik]);
// Her aksiyonu tek kalıptan geçir: meşgul kilidi + durum tazeleme +
// server component'lerin (header'daki kredi rozeti dahil) yenilenmesi
async function calistir(fn: () => Promise<unknown>) {
setMesgul(true);
try {
await fn();
await yenile();
router.refresh();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Dev aksiyonu başarısız.");
} finally {
setMesgul(false);
}
}
async function anindaGiris() {
setMesgul(true);
try {
const sonuc = await devAnindaGiris(email);
if (!sonuc.ok) {
toast.error(sonuc.error);
return;
}
// Verify URL'i oturumu kurup callback'e yönlendirir
window.location.assign(sonuc.url);
} finally {
setMesgul(false);
}
}
async function odemeEkrani() {
setMesgul(true);
try {
const sonuc = await devSahteOdeme();
if (!sonuc.ok) {
toast.error(sonuc.error);
return;
}
router.push(`/odeme/sonuc?siparis=${sonuc.siparis}`);
} finally {
setMesgul(false);
}
}
const g = durum?.girisli ? durum : null;
// Azaltılmış hareket: morph yerine kısa çapraz solma (yaysız, kaymasız).
const paylasilan = azMotion ? {} : { layoutId: "dev-panel" };
const gecis = azMotion ? { duration: 0.15 } : YAY;
return (
<AnimatePresence initial={false}>
{!acik ? (
<motion.button
key="cip"
{...paylasilan}
type="button"
onClick={paneliAc}
title="Dev süperadmin paneli"
style={{ borderRadius: 18 }}
transition={gecis}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0, transition: { duration: 0.15, ease: "easeOut" } }}
className={`fixed bottom-20 left-4 z-50 inline-flex h-9 cursor-pointer items-center gap-1.5 px-3 text-xs font-bold tracking-wide text-white ${MALZEME} !bg-slate-900/85 print:hidden ${BASIS} hover:!bg-slate-800/85`}
>
<Wrench className="size-3.5 text-violet-300" aria-hidden />
DEV
</motion.button>
) : (
<motion.div
key="panel"
{...paylasilan}
role="dialog"
aria-label="Dev süperadmin paneli"
style={{ borderRadius: 24, transformOrigin: "bottom left" }}
transition={gecis}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0, transition: { duration: 0.15, ease: "easeOut" } }}
className={`fixed bottom-20 left-4 z-50 flex max-h-[72vh] w-80 flex-col overflow-hidden ${MALZEME} print:hidden`}
>
{/* İçerik morph sırasında bozulmasın: yüzey oturduktan hemen
sonra belirir (malzeme önce, içerik sonra). */}
<motion.div
initial={azMotion ? false : { opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.2, delay: azMotion ? 0 : 0.12 }}
className="flex min-h-0 flex-col"
>
{/* Başlık: opak şerit değil, aynı malzemenin üstünde ince
bir kenar çizgisiyle ayrışan katman. */}
<div className="flex items-center justify-between border-b border-slate-900/5 px-4 py-3">
<p className="flex items-center gap-2 font-heading text-sm font-bold tracking-[-0.01em] text-slate-900">
<span className="flex size-6 items-center justify-center rounded-lg bg-violet-600 text-white shadow-sm">
<Wrench className="size-3.5" aria-hidden />
</span>
Dev süperadmin
{mesgul ? (
<Loader2
className="size-3.5 animate-spin text-slate-400"
aria-hidden
/>
) : null}
</p>
<button
type="button"
onClick={() => setAcik(false)}
className={`cursor-pointer rounded-full p-1.5 text-slate-500 hover:bg-slate-900/5 hover:text-slate-700 ${BASIS}`}
aria-label="Kapat"
>
<X className="size-4" aria-hidden />
</button>
</div>
<div className="flex flex-col gap-4 overflow-y-auto p-4 text-sm">
{/* Oturum */}
<section>
<p className="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
Oturum
</p>
{g ? (
<div className="mt-2 rounded-xl border border-slate-900/5 bg-white/60 px-3 py-2 text-xs">
<p className="font-semibold text-slate-800">{g.email}</p>
<p className="mt-0.5 tabular-nums text-slate-500">
{g.kredi} kredi · {g.hasPaket ? "paketli" : "paketsiz"} ·{" "}
{g.raporVar
? `rapor var (${g.sira?.toLocaleString("tr-TR")} · ${g.tur})`
: "rapor yok"}
</p>
<button
type="button"
disabled={mesgul}
onClick={() =>
calistir(async () => {
await authClient.signOut();
})
}
className="mt-2 cursor-pointer font-medium text-violet-700 underline-offset-2 hover:underline"
>
Çıkış yap
</button>
</div>
) : (
<p className="mt-2 text-xs text-slate-500">
Girişsiz geziyorsun.
</p>
)}
<div className="mt-2 flex gap-1.5">
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="deneme@ornek.dev"
className="h-9 min-w-0 flex-1 rounded-lg border border-slate-900/10 bg-white/70 px-2.5 text-xs outline-none transition-colors duration-150 focus-visible:border-violet-400"
/>
<button
type="button"
disabled={mesgul}
onClick={anindaGiris}
className={`h-9 shrink-0 cursor-pointer rounded-lg bg-violet-600 px-3 text-xs font-semibold text-white hover:bg-violet-700 disabled:opacity-50 ${BASIS}`}
>
Anında gir
</button>
</div>
<p className="mt-1 text-[10px] text-slate-400">
Yeni e-posta = 5 kredili yeni kullanıcı (magic link atlanır).
</p>
</section>
{/* Senaryolar */}
<section>
<p className="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
Senaryo
</p>
<div className="mt-2 grid grid-cols-2 gap-1.5">
{(
[
{
ad: "Yeni kullanıcı",
aciklama: "5 kredi · paketsiz · raporsuz",
fn: async () => {
await devAyarla({ kredi: 5, hasPaket: false });
await devRaporSil();
},
},
{
ad: "Paketli",
aciklama: "60 kredi · paket aktif",
fn: () => devAyarla({ kredi: 60, hasPaket: true }),
},
{
ad: "Kredisi bitmiş",
aciklama: "0 kredi",
fn: () => devAyarla({ kredi: 0 }),
},
{
ad: "Raporu sil",
aciklama: "boş durumlar",
fn: () => devRaporSil(),
},
] as const
).map((s) => (
<button
key={s.ad}
type="button"
disabled={mesgul || !g}
onClick={() => calistir(s.fn)}
className={`cursor-pointer rounded-xl border border-slate-900/10 bg-white/60 px-2.5 py-2 text-left text-xs hover:border-violet-300 hover:bg-violet-50/80 disabled:cursor-default disabled:opacity-40 ${BASIS}`}
>
<span className="block font-semibold text-slate-800">
{s.ad}
</span>
<span className="text-[10px] tabular-nums text-slate-500">
{s.aciklama}
</span>
</button>
))}
</div>
<button
type="button"
disabled={mesgul || !g}
onClick={odemeEkrani}
className={`mt-1.5 w-full cursor-pointer rounded-xl border border-emerald-600/15 bg-emerald-50/80 px-2.5 py-2 text-left text-xs font-semibold text-emerald-800 hover:bg-emerald-100/80 disabled:cursor-default disabled:opacity-40 ${BASIS}`}
>
Ödeme başarı ekranı 🎉
<span className="block text-[10px] font-normal text-emerald-700">
sahte &quot;paid&quot; sipariş oluşturup /odeme/sonuc&apos;a
gider
</span>
</button>
</section>
{/* Sayfalar */}
<section>
<p className="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
Sayfalar
</p>
<div className="mt-2 flex flex-wrap gap-1.5">
{SAYFALAR.map((s) => (
<a
key={s.href}
href={s.href}
className={`cursor-pointer rounded-full border border-slate-900/10 bg-white/60 px-2.5 py-1 text-[11px] text-slate-700 hover:border-violet-300 hover:bg-violet-50/80 ${BASIS}`}
>
{s.ad}
</a>
))}
</div>
</section>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}

View File

@@ -2,49 +2,176 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { ArrowRight } from "lucide-react"; import { ArrowRight, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
} from "@/components/ui/dialog";
import { SihirbazAdimlar } from "@/components/sihirbaz-adimlar";
import { authClient } from "@/lib/auth-client";
import type { SihirbazFacetleri } from "@/lib/db";
import { SIHIRBAZ_STORAGE_KEY, type SihirbazSecimleri } from "@/lib/sihirbaz";
// lib/db sunucu tarafı; etiketler client bundle'a sızmasın diye burada.
const PUAN_TURLERI_SECENEK = [
{ deger: "say", etiket: "Sayısal" },
{ deger: "ea", etiket: "Eşit Ağırlık" },
{ deger: "soz", etiket: "Sözel" },
{ deger: "dil", etiket: "Dil" },
{ deger: "tyt", etiket: "TYT" },
] as const;
export function HeroForm() { export function HeroForm() {
const [siralama, setSiralama] = useState(""); const [siralama, setSiralama] = useState("");
const [tur, setTur] = useState<string>("say");
const [sira, setSira] = useState<number | null>(null);
const [facetler, setFacetler] = useState<SihirbazFacetleri | null>(null);
const [yukleniyor, setYukleniyor] = useState(false);
const [modalAcik, setModalAcik] = useState(false);
const [hata, setHata] = useState<string | null>(null);
const router = useRouter(); const router = useRouter();
const { data: oturum } = authClient.useSession();
const girisli = Boolean(oturum?.user);
function handleSubmit(e: React.FormEvent) { // Yazarken binlik ayracıyla biçimle (85000 → 85.000)
function siralamaDegisti(ham: string) {
setHata(null);
const rakamlar = ham.replace(/\D/g, "").slice(0, 7);
setSiralama(
rakamlar ? Number(rakamlar).toLocaleString("tr-TR") : "",
);
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
const value = Number(siralama.replace(/\./g, "")); const value = Number(siralama.replace(/\./g, ""));
if (!value || value < 1 || value > 4_000_000) { if (!value || value < 1 || value > 4_000_000) {
toast.error("Geçerli bir başarı sıralaması gir (ör. 85.000)."); setHata("Geçerli bir başarı sıralaması gir (ör. 85.000).");
return; return;
} }
router.push(`/sonuc?sira=${value}`); setYukleniyor(true);
try {
const res = await fetch(`/api/facetler?sira=${value}&tur=${tur}`);
if (!res.ok) throw new Error();
const veri = (await res.json()) as { facetler: SihirbazFacetleri };
setSira(value);
setFacetler(veri.facetler);
setModalAcik(true);
} catch {
setHata("Bir şeyler ters gitti, tekrar dener misin?");
} finally {
setYukleniyor(false);
}
}
// Seçimler tamam: sakla ve herkes doğrudan /sonuc'a gitsin. Girişli
// kullanıcıda orada otomatik üretim başlar; girişsiz kullanıcı önce
// ücretsiz tabloyu görür, giriş CTA'sı ödülün kaydedilmesi olarak
// oradan gelir (değer önce — giriş duvarı sihirbazın hemen arkasında değil).
function secimleriTamamla(secimler: SihirbazSecimleri) {
if (!sira) return;
try {
localStorage.setItem(
SIHIRBAZ_STORAGE_KEY,
JSON.stringify({ sira, tur, secimler }),
);
} catch {}
setModalAcik(false);
router.push(
girisli
? `/sonuc?sira=${sira}&tur=${tur}&sihirbaz=1`
: `/sonuc?sira=${sira}&tur=${tur}&hazir=1`,
);
} }
return ( return (
<form <div className="flex w-full max-w-md flex-col items-center gap-3">
onSubmit={handleSubmit} <div
className="flex w-full max-w-md flex-col gap-3 sm:flex-row" role="radiogroup"
> aria-label="Puan türü"
<label htmlFor="siralama" className="sr-only"> className="flex flex-wrap justify-center gap-1.5"
YKS başarı sıralaması
</label>
<Input
id="siralama"
inputMode="numeric"
placeholder="YKS başarı sıralaman (ör. 85.000)"
value={siralama}
onChange={(e) => setSiralama(e.target.value)}
className="h-12 flex-1 bg-white text-base"
/>
<Button
type="submit"
size="lg"
className="h-12 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
> >
Listemi oluştur {PUAN_TURLERI_SECENEK.map((t) => (
<ArrowRight className="size-4" aria-hidden /> <button
</Button> key={t.deger}
</form> type="button"
role="radio"
aria-checked={tur === t.deger}
onClick={() => setTur(t.deger)}
className={
tur === t.deger
? "cursor-pointer rounded-full border border-primary bg-primary px-3 py-1.5 text-xs font-medium text-white transition-colors duration-200"
: "cursor-pointer rounded-full border border-slate-200 bg-white px-3 py-1.5 text-xs text-slate-600 transition-colors duration-200 hover:border-slate-300 hover:bg-slate-50"
}
>
{t.etiket}
</button>
))}
</div>
<form
onSubmit={handleSubmit}
className="flex w-full flex-col gap-3 sm:flex-row"
>
<label htmlFor="siralama" className="sr-only">
YKS başarı sıralaması
</label>
<Input
id="siralama"
inputMode="numeric"
placeholder="YKS başarı sıralaman (ör. 85.000)"
value={siralama}
onChange={(e) => siralamaDegisti(e.target.value)}
aria-invalid={hata ? true : undefined}
aria-describedby={hata ? "siralama-hata" : undefined}
className="h-12 flex-1 bg-white text-base"
/>
<Button
type="submit"
size="lg"
disabled={yukleniyor}
className="h-12 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
>
{yukleniyor ? (
<Loader2 className="size-4 animate-spin" aria-hidden />
) : null}
Listemi oluştur
{yukleniyor ? null : <ArrowRight className="size-4" aria-hidden />}
</Button>
</form>
{hata ? (
<p
id="siralama-hata"
role="alert"
className="self-start text-sm font-medium text-red-600"
>
{hata}
</p>
) : null}
<Dialog open={modalAcik} onOpenChange={setModalAcik}>
<DialogContent className="max-h-[92dvh] gap-0 overflow-y-auto p-6 text-left sm:max-w-3xl">
<DialogTitle className="sr-only">Tercih listesi sihirbazı</DialogTitle>
<DialogDescription className="sr-only">
Sıralamana uygun seçimlerini yap, tercih planına geç.
</DialogDescription>
{facetler ? (
<SihirbazAdimlar
key={`${sira}-${tur}`}
facetler={facetler}
sonButonEtiketi={
girisli ? "Listemi oluştur (3 kredi)" : "Ücretsiz tablonu gör"
}
onTamamla={secimleriTamamla}
/>
) : null}
</DialogContent>
</Dialog>
</div>
); );
} }

View File

@@ -0,0 +1,104 @@
"use client";
// Sihirbazın "Nerede okumak istersin?" adımı: çip listesi yerine dokunarak
// il seçilen Türkiye haritası. Mavi ton ildeki program sayısını, turuncu
// kontur seçimi gösterir. KKTC gibi harita dışı yerler çip olarak eklenir.
import { cities } from "turkey-map-react/lib/data";
import {
HARITA_GENISLIK,
HARITA_UST_KIRPMA,
HARITA_YUKSEKLIK,
normalizeIlAdi,
} from "@/lib/harita";
export function IlSecimHaritasi({
iller,
secili,
onToggle,
}: {
/** Bu sıralama penceresinde programı olan iller (DB biçimi ad + adet) */
iller: { il: string; adet: number }[];
secili: string[];
onToggle: (il: string) => void;
}) {
const adetler = new Map(iller.map((i) => [i.il, i.adet]));
const maxAdet = Math.max(1, ...iller.map((i) => i.adet));
const seciliSet = new Set(secili);
return (
<div>
<svg
viewBox={`0 ${HARITA_UST_KIRPMA} ${HARITA_GENISLIK} ${HARITA_YUKSEKLIK - HARITA_UST_KIRPMA}`}
className="h-auto w-full"
role="group"
aria-label="Okumak istediğin illeri haritadan seç"
>
{/* Seçili iller en son çizilir ki turuncu kontur komşularca örtülmesin */}
{[...cities]
.sort(
(a, b) =>
Number(seciliSet.has(normalizeIlAdi(a.name))) -
Number(seciliSet.has(normalizeIlAdi(b.name))),
)
.map((city) => {
const key = normalizeIlAdi(city.name);
const adet = adetler.get(key);
const seciliMi = seciliSet.has(key);
const yogunluk = adet ? adet / maxAdet : 0;
return (
<path
key={city.id}
d={city.path}
className={
adet
? "cursor-pointer transition-opacity duration-200 hover:opacity-75"
: undefined
}
fill={
seciliMi
? "oklch(0.705 0.213 47.6)" // orange-500
: adet
? `oklch(0.623 0.188 259.8 / ${0.25 + 0.65 * yogunluk})`
: "oklch(0.929 0.013 255.5)" // slate-200
}
stroke={seciliMi ? "oklch(0.553 0.195 38.4)" : "white"}
strokeWidth={seciliMi ? 2 : 1}
onClick={adet ? () => onToggle(key) : undefined}
role={adet ? "button" : undefined}
aria-pressed={adet ? seciliMi : undefined}
aria-label={
adet ? `${city.name}${adet} program` : undefined
}
>
<title>
{adet
? `${city.name}: ${adet} program — ${seciliMi ? "seçimi kaldır" : "seçmek için tıkla"}`
: `${city.name}: bu sıralamada program yok`}
</title>
</path>
);
})}
</svg>
<div className="mt-2 flex items-center justify-end gap-2 text-xs text-slate-500">
<span>az</span>
<span
aria-hidden
className="h-2 w-24 rounded-full"
style={{
background:
"linear-gradient(to right, oklch(0.623 0.188 259.8 / 0.25), oklch(0.623 0.188 259.8 / 0.9))",
}}
/>
<span>çok program</span>
<span className="ml-3 inline-flex items-center gap-1.5">
<span
aria-hidden
className="size-2.5 rounded-full bg-orange-500"
/>
seçili
</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,357 @@
"use client";
// Manuel 24'lük listenin alt çekmecesi (bottom sheet). Layout'ta bir kez
// monte edilir; navbar'daki Seçtiklerim butonu açar/kapatır.
//
// Motion dili (apple-design / emil-design-eng):
// - Sheet alttan spring ile yükselir; çıkış girişten hızlı, iOS drawer eğrisi.
// - Üstteki tutamak çubuğundan aşağı çekilince kapanır (offset veya hız eşiği).
// - Satır sıralama yalnız grip tutamağından başlar (dragControls); gövdeyi
// sağa kaydırmak silme jesti — eşik aşılırsa satır uçar, altından kırmızı
// sil bandı görünür.
// - Silme/temizlemede kart yüksekliği ve kalan satırlar layout projeksiyonuyla
// yumuşakça oturur ("lap diye" kısalma yok). Projeksiyon, sheet'in giriş
// spring'i BİTTİKTEN sonra açılır (oturdu state'i): transform'lu parent
// içinde ölçüm alan satırlar o offset'i kalıcı transform olarak taşıyordu.
// - prefers-reduced-motion: sheet cross-fade; silme için X butonu her
// durumda kalır.
import { useEffect, useState } from "react";
import Link from "next/link";
import {
GripVertical,
ListChecks,
Sparkles,
Trash2,
X,
} from "lucide-react";
import {
AnimatePresence,
motion,
Reorder,
useDragControls,
useMotionValue,
useReducedMotion,
useTransform,
type PanInfo,
} from "motion/react";
import { RISK_ETIKET, type RiskSeviyesi } from "@/lib/risk";
import {
MANUEL_LISTE_MAX,
cekmeceKapat,
cikar,
temizle,
useCekmeceAcik,
useManuelListe,
yenidenSirala,
type ManuelTercih,
} from "./store";
// Risk renk dili ürünün geri kalanıyla birebir aynı (bkz. rapor-listesi)
const RISK_STIL: Record<RiskSeviyesi, { nokta: string; kenar: string }> = {
guvenli: { nokta: "bg-emerald-500", kenar: "border-l-emerald-500" },
"az-riskli": { nokta: "bg-amber-500", kenar: "border-l-amber-500" },
riskli: { nokta: "bg-red-500", kenar: "border-l-red-500" },
};
export function ListeCekmecesi() {
const acik = useCekmeceAcik();
return <AnimatePresence>{acik ? <CekmeceSheet /> : null}</AnimatePresence>;
}
function CekmeceSheet() {
const liste = useManuelListe();
const azMotion = useReducedMotion();
const sheetKontrol = useDragControls();
// Giriş spring'i bitmeden satırların layout projeksiyonu kapalı kalır —
// aksi halde transform'lu sheet içinde alınan ölçüm satırları kaydırıyor
const [oturdu, setOturdu] = useState(false);
// Escape ile kapat — çekmece modal değil, akışı bölmeden yaşar
useEffect(() => {
const dinle = (e: KeyboardEvent) => {
if (e.key === "Escape") cekmeceKapat();
};
window.addEventListener("keydown", dinle);
return () => window.removeEventListener("keydown", dinle);
}, []);
const doluluk = Math.round((liste.length / MANUEL_LISTE_MAX) * 100);
const surukleyinceKapat = (_: unknown, info: PanInfo) => {
if (info.offset.y > 80 || info.velocity.y > 500) cekmeceKapat();
};
return (
<motion.aside
initial={azMotion ? { opacity: 0 } : { y: "calc(100% + 16px)" }}
animate={azMotion ? { opacity: 1 } : { y: 0 }}
exit={
azMotion
? { opacity: 0, transition: { duration: 0.15 } }
: {
y: "calc(100% + 16px)",
transition: {
duration: 0.25,
ease: [0.32, 0.72, 0, 1],
},
}
}
transition={{ type: "spring", duration: 0.4, bounce: 0.15 }}
onAnimationComplete={() => setOturdu(true)}
drag={azMotion ? false : "y"}
dragListener={false}
dragControls={sheetKontrol}
dragConstraints={{ top: 0, bottom: 0 }}
dragElastic={{ top: 0, bottom: 0.6 }}
onDragEnd={surukleyinceKapat}
aria-label="Manuel tercih listem"
className="fixed inset-x-0 bottom-0 z-[60] flex max-h-[85dvh] w-full justify-center p-3"
>
{/* layout: satır silinince/eklenince kart yüksekliği spring ile oturur.
Doğrudan çocuklar layout="position" taşır ki ölçekleme sırasında
içerik yamulmasın (scale-correction zinciri). */}
<motion.div
layout={oturdu}
transition={{ type: "spring", duration: 0.35, bounce: 0 }}
className="flex min-h-0 w-full max-w-lg flex-col rounded-2xl border border-slate-200 bg-white shadow-2xl"
>
{/* Tutamak: buradan aşağı çekilince sheet kapanır */}
<motion.div
layout="position"
onPointerDown={(e) => {
if (!azMotion) sheetKontrol.start(e);
}}
className="flex cursor-grab touch-none justify-center pb-1 pt-3 active:cursor-grabbing"
>
<div className="h-1 w-10 rounded-full bg-slate-300" aria-hidden />
</motion.div>
{/* Başlık + doluluk */}
<motion.div
layout="position"
className="border-b border-slate-100 px-5 pb-4 pt-1"
>
<div className="flex items-center justify-between gap-3">
<h2 className="flex items-center gap-2 font-heading text-lg font-bold">
<ListChecks className="size-5 text-primary" aria-hidden />
Seçtiklerim
<span className="font-heading text-sm font-bold tabular-nums text-slate-400">
{liste.length}/{MANUEL_LISTE_MAX}
</span>
</h2>
<div className="flex items-center gap-1">
{liste.length > 0 ? (
<button
type="button"
onClick={temizle}
title="Listeyi temizle"
className="cursor-pointer rounded-full p-2 text-slate-400 transition-colors duration-200 hover:bg-slate-100 hover:text-red-500"
>
<Trash2 className="size-4" aria-hidden />
<span className="sr-only">Listeyi temizle</span>
</button>
) : null}
<button
type="button"
onClick={cekmeceKapat}
className="cursor-pointer rounded-full p-2 text-slate-400 transition-colors duration-200 hover:bg-slate-100 hover:text-slate-600"
>
<X className="size-4" aria-hidden />
<span className="sr-only">Kapat</span>
</button>
</div>
</div>
<div
className="mt-3 h-1.5 overflow-hidden rounded-full bg-slate-100"
role="progressbar"
aria-valuenow={liste.length}
aria-valuemin={0}
aria-valuemax={MANUEL_LISTE_MAX}
aria-label="Liste doluluğu"
>
<motion.div
className="h-full rounded-full bg-primary"
animate={{ width: `${doluluk}%` }}
transition={{ type: "spring", duration: 0.5, bounce: 0 }}
/>
</div>
<p className="mt-2 text-xs text-slate-500">
Sıralamanla ulaşabildiğin programlardan kendi listen tutamaktan
sürükleyip sırala, sağa kaydırıp sil.
</p>
</motion.div>
{/* Gövde: sürüklenebilir satırlar */}
{liste.length === 0 ? (
<motion.div
layout="position"
className="flex flex-col items-center justify-center gap-2 px-8 py-12 text-center"
>
<ListChecks className="size-8 text-slate-300" aria-hidden />
<p className="font-heading text-sm font-bold text-slate-700">
Henüz program seçmedin
</p>
<p className="text-xs leading-relaxed text-slate-500">
Sonuç sayfasındaki tablodan <span aria-hidden></span> ile
program ekle; beğendiklerin burada birikir.
</p>
</motion.div>
) : (
<Reorder.Group
axis="y"
values={liste}
onReorder={yenidenSirala}
layout="position"
layoutScroll
as="ol"
className="min-h-0 flex-1 space-y-2 overflow-x-hidden overflow-y-auto px-4 py-4"
>
<AnimatePresence initial={false}>
{liste.map((t, i) => (
<CekmeceSatiri
key={t.id}
tercih={t}
sira={i + 1}
projeksiyonAcik={oturdu}
/>
))}
</AnimatePresence>
</Reorder.Group>
)}
{/* Alt bant: AI danışman köprüsü */}
<motion.div
layout="position"
className="border-t border-slate-100 px-5 py-4"
>
<Link
href="/listem"
onClick={cekmeceKapat}
className="inline-flex items-center gap-1.5 text-sm font-medium text-primary transition-colors duration-200 hover:text-primary/80"
>
<Sparkles className="size-4" aria-hidden />
AI listeni ve danışmanı
</Link>
</motion.div>
</motion.div>
</motion.aside>
);
}
/** Sağa kaydırıp silme eşiği (px) — bunu aşınca satır listeden çıkar */
const SIL_ESIK = 90;
function CekmeceSatiri({
tercih,
sira,
projeksiyonAcik,
}: {
tercih: ManuelTercih;
sira: number;
projeksiyonAcik: boolean;
}) {
const stil = tercih.risk ? RISK_STIL[tercih.risk] : null;
const siraKontrol = useDragControls();
// Kartın yatay konumu: sil bandının görünürlüğü buna bağlı
const x = useMotionValue(0);
const silGorunur = useTransform(x, [0, SIL_ESIK / 2], [0, 1]);
const kaydirinca = (_: unknown, info: PanInfo) => {
if (
info.offset.x > SIL_ESIK ||
(info.offset.x > 32 && info.velocity.x > 500)
) {
cikar(tercih.id);
}
};
return (
<Reorder.Item
value={tercih}
as="li"
layout={projeksiyonAcik ? "position" : undefined}
layoutId={projeksiyonAcik ? `manuel-tercih-${tercih.id}` : undefined}
dragListener={false}
dragControls={siraKontrol}
initial={{ opacity: 0, y: 8, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, x: 96, transition: { duration: 0.18 } }}
transition={{ type: "spring", duration: 0.4, bounce: 0 }}
whileDrag={{
scale: 1.03,
boxShadow: "0 12px 32px rgba(15, 23, 42, 0.16)",
zIndex: 10,
}}
className="relative select-none"
>
{/* Sil bandı: kart sağa kaydıkça altından görünür */}
<motion.div
style={{ opacity: silGorunur }}
className="absolute inset-0 flex items-center rounded-xl bg-red-500 pl-4"
aria-hidden
>
<Trash2 className="size-4 text-white" />
</motion.div>
{/* Kart gövdesi: sağa kaydırılabilir (sola kilitli) */}
<motion.div
drag="x"
style={{ x }}
dragConstraints={{ left: 0, right: 0 }}
dragElastic={{ left: 0, right: 0.7 }}
dragSnapToOrigin
onDragEnd={kaydirinca}
className={`relative flex items-center gap-2.5 rounded-xl border border-l-4 border-slate-200 bg-white px-3 py-2.5 ${
stil?.kenar ?? "border-l-slate-300"
}`}
>
<button
type="button"
onPointerDown={(e) => {
e.stopPropagation(); // yatay kaydırmayı tetiklemesin
siraKontrol.start(e);
}}
className="-m-1 shrink-0 cursor-grab touch-none rounded p-1 text-slate-300 active:cursor-grabbing"
>
<GripVertical className="size-4" aria-hidden />
<span className="sr-only">{tercih.isim} sırasını değiştir</span>
</button>
<span className="flex size-6 shrink-0 items-center justify-center rounded-full bg-slate-100 font-heading text-[11px] font-bold tabular-nums">
{sira}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-semibold">
{tercih.isim}
</span>
<span className="block truncate text-xs text-slate-500">
{tercih.universite}
{tercih.il ? ` · ${tercih.il.toLocaleLowerCase("tr-TR")}` : ""}
</span>
</span>
{tercih.efektifSira != null ? (
<span className="shrink-0 text-xs tabular-nums text-slate-500">
~{tercih.efektifSira.toLocaleString("tr-TR")}.
</span>
) : null}
{tercih.risk ? (
<span
className={`size-2.5 shrink-0 rounded-full ${RISK_STIL[tercih.risk].nokta}`}
aria-label={RISK_ETIKET[tercih.risk]}
/>
) : null}
<button
type="button"
onClick={() => cikar(tercih.id)}
onPointerDown={(e) => e.stopPropagation()} // kaydırmayı tetiklemesin
className="shrink-0 cursor-pointer rounded-full p-1.5 text-slate-300 transition-colors duration-200 hover:bg-slate-100 hover:text-red-500"
>
<X className="size-3.5" aria-hidden />
<span className="sr-only">{tercih.isim} listeden çıkar</span>
</button>
</motion.div>
</Reorder.Item>
);
}

View File

@@ -0,0 +1,50 @@
"use client";
// Navbar'daki "Seçtiklerim" butonu: elle kurulan aday listesinin sayacını
// taşır, tıklanınca çekmeceyi açar/kapatır. ("Listem" adı AI raporunun
// yaşadığı /listem sayfasına aittir — isim çakışmasın.)
import { ListChecks } from "lucide-react";
import { motion, useReducedMotion } from "motion/react";
import {
MANUEL_LISTE_MAX,
cekmeceDegistir,
useCekmeceAcik,
useManuelListe,
} from "./store";
export function ListemButonu() {
const liste = useManuelListe();
const acik = useCekmeceAcik();
const azMotion = useReducedMotion();
// Liste boşken buton gürültü: tabloya () ulaşmamış ziyaretçiye "0/24"
// bir şey ifade etmez. İlk program eklenince belirir, liste boşalınca gider.
if (liste.length === 0) return null;
return (
<button
type="button"
onClick={cekmeceDegistir}
aria-expanded={acik}
aria-label={`Seçtiklerim — ${liste.length}/${MANUEL_LISTE_MAX} program`}
title="Tablodan seçtiğin programlar"
className="relative inline-flex h-12 cursor-pointer items-center gap-2 whitespace-nowrap rounded-full border border-slate-200 bg-white px-5 text-sm font-semibold text-slate-700 transition-[color,background-color,border-color,transform] duration-200 hover:border-slate-300 hover:bg-slate-50 active:scale-[0.97]"
>
<ListChecks className="size-4 text-primary" aria-hidden />
<span className="hidden sm:inline">Seçtiklerim</span>
{liste.length > 0 ? (
// key=length: her eklemede rozet yeniden doğar ve spring ile oturur
<motion.span
key={liste.length}
initial={azMotion ? false : { scale: 0.6 }}
animate={{ scale: 1 }}
transition={{ type: "spring", duration: 0.4, bounce: 0.45 }}
className="flex size-5 items-center justify-center rounded-full bg-orange-500 font-heading text-[11px] font-bold tabular-nums text-white"
>
{liste.length}
</motion.span>
) : null}
</button>
);
}

View File

@@ -0,0 +1,76 @@
"use client";
// Manuel 24'lük listenin coğrafyası: tablodan "+" ile eklenen her programın
// üniversitesi gerçek kampüs konumunda risk renkli pinlenir. Liste store'una
// abonedir; ekleme/çıkarma/sürükleme anında haritaya yansır.
import { useMemo, useState } from "react";
import { TercihHaritasi, type HaritaPin } from "@/components/tercih-haritasi";
import { ilKonum, uniAdiNormalize, uniKonum } from "@/lib/harita";
import type { RiskSeviyesi } from "@/lib/risk";
import { useManuelListe } from "./store";
// Aynı üniversitede birden çok tercih varsa pinde kötü risk baskın olsun
const RISK_AGIRLIK: Record<RiskSeviyesi, number> = {
guvenli: 0,
"az-riskli": 1,
riskli: 2,
};
export function ManuelHarita() {
const liste = useManuelListe();
const [seciliIl, setSeciliIl] = useState<string | null>(null);
const { pinler, haritaDisi } = useMemo(() => {
const gruplar = new Map<string, HaritaPin>();
let haritaDisi = 0;
liste.forEach((t, i) => {
const konum = uniKonum(t.universite, t.il) ?? ilKonum(t.il);
if (!konum) {
haritaDisi += 1;
return;
}
const risk = t.risk ?? "az-riskli";
const key = `${uniAdiNormalize(t.universite)}|${t.il ?? ""}`;
const mevcut = gruplar.get(key);
if (mevcut) {
mevcut.tercihSiralari.push(i + 1);
if (RISK_AGIRLIK[risk] > RISK_AGIRLIK[mevcut.risk]) mevcut.risk = risk;
} else {
gruplar.set(key, {
id: key,
universite: t.universite,
il: t.il,
x: konum.x,
y: konum.y,
tercihSiralari: [i + 1],
risk,
});
}
});
return { pinler: [...gruplar.values()], haritaDisi };
}, [liste]);
return (
<div>
<TercihHaritasi
pinler={pinler}
seciliIl={seciliIl}
onSeciliIlDegisti={setSeciliIl}
/>
{haritaDisi > 0 ? (
<p className="mt-1 px-1 text-xs text-slate-500">
{haritaDisi} tercih harita dışında (KKTC vb.)
</p>
) : null}
{liste.length === 0 ? (
<p className="mt-2 text-center text-xs text-slate-500">
Tablodan <span aria-hidden></span> ile ekledikçe üniversiteler
haritada belirir.
</p>
) : null}
</div>
);
}

View File

@@ -0,0 +1,148 @@
"use client";
// Manuel 24'lük liste — AI'dan bağımsız, kullanıcının tablodan "+" ile kendi
// kurduğu tercih listesi. Kaynak tek: bu modüldeki harici store. Navbar rozeti,
// çekmece ve program tablosu aynı store'a abone olur; kalıcılık localStorage'ta
// (girişsiz kullanıcı da liste kurabilsin diye sunucuya yazmıyoruz).
import { useSyncExternalStore } from "react";
import type { RiskSeviyesi } from "@/lib/risk";
export const MANUEL_LISTE_MAX = 24;
const STORAGE_KEY = "kolaytercih.manuel-liste.v1";
export type ManuelTercih = {
/** yokatlas program id'si */
id: string;
isim: string;
universite: string;
il: string | null;
efektifSira: number | null;
/** Ekleme anındaki aday sıralamasına göre risk (renklendirme için) */
risk: RiskSeviyesi | null;
};
// SSR snapshot'ı: her çağrıda aynı referans dönmeli, yoksa React döngüye girer
const BOS_LISTE: ManuelTercih[] = [];
let liste: ManuelTercih[] = BOS_LISTE;
let cekmeceAcik = false;
let yuklendi = false;
const dinleyiciler = new Set<() => void>();
function yayinla() {
for (const cb of dinleyiciler) cb();
}
function kaydet() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(liste));
} catch {}
}
function dogrula(ham: unknown): ManuelTercih[] {
if (!Array.isArray(ham)) return [];
return ham
.filter(
(t): t is ManuelTercih =>
typeof t === "object" &&
t !== null &&
typeof (t as ManuelTercih).id === "string" &&
typeof (t as ManuelTercih).isim === "string",
)
.slice(0, MANUEL_LISTE_MAX);
}
function yukle() {
if (yuklendi || typeof window === "undefined") return;
yuklendi = true;
try {
const ham = localStorage.getItem(STORAGE_KEY);
if (ham) liste = dogrula(JSON.parse(ham));
} catch {}
// Başka sekmede yapılan değişiklikleri de yansıt
window.addEventListener("storage", (e) => {
if (e.key !== STORAGE_KEY) return;
try {
liste = e.newValue ? dogrula(JSON.parse(e.newValue)) : [];
yayinla();
} catch {}
});
}
function abone(cb: () => void) {
dinleyiciler.add(cb);
if (!yuklendi) {
// İlk client abonesi: storage'ı oku ve snapshot'ı tazele
yukle();
queueMicrotask(yayinla);
}
return () => {
dinleyiciler.delete(cb);
};
}
export function useManuelListe(): ManuelTercih[] {
return useSyncExternalStore(
abone,
() => liste,
() => BOS_LISTE,
);
}
export function useCekmeceAcik(): boolean {
return useSyncExternalStore(
abone,
() => cekmeceAcik,
() => false,
);
}
export function listedeMi(id: string): boolean {
return liste.some((t) => t.id === id);
}
/** Listeye ekler; doluysa veya zaten varsa false döner. */
export function ekle(tercih: ManuelTercih): boolean {
yukle();
if (liste.length >= MANUEL_LISTE_MAX || listedeMi(tercih.id)) return false;
liste = [...liste, tercih];
kaydet();
yayinla();
return true;
}
export function cikar(id: string) {
liste = liste.filter((t) => t.id !== id);
kaydet();
yayinla();
}
/** Sürükle-bırak sonrası yeni sıra (motion Reorder'dan gelir) */
export function yenidenSirala(yeni: ManuelTercih[]) {
liste = yeni;
kaydet();
yayinla();
}
export function temizle() {
liste = [];
kaydet();
yayinla();
}
export function cekmeceAc() {
cekmeceAcik = true;
yayinla();
}
export function cekmeceKapat() {
cekmeceAcik = false;
yayinla();
}
export function cekmeceDegistir() {
cekmeceAcik = !cekmeceAcik;
yayinla();
}

View File

@@ -0,0 +1,452 @@
"use client";
// Sonuç sayfasının AI'sız ham listesi: yalnızca başarı sıralamasıyla
// yokatlas'tan gelen programlar, dilim sekmeleri halinde. Her satırın "+"
// butonu programı manuel 24'lük listeye ekler (bkz. manuel-liste/store).
import { useState } from "react";
import Link from "next/link";
import { useLinkStatus } from "next/link";
import {
Check,
Plus,
Rocket,
Scale,
ShieldCheck,
TrendingDown,
TrendingUp,
} from "lucide-react";
import type { Program, PuanTuruKey, RankResults } from "@/lib/db";
import { riskHesapla, type RiskSeviyesi } from "@/lib/risk";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
MANUEL_LISTE_MAX,
ekle,
useManuelListe,
type ManuelTercih,
} from "@/components/manuel-liste/store";
import { ManuelHarita } from "@/components/manuel-liste/manuel-harita";
type DilimKey = keyof RankResults;
const DILIMLER: {
key: DilimKey;
etiket: string;
aciklama: string;
icon: typeof Rocket;
renk: string;
}[] = [
{
key: "hayal",
etiket: "Hayal",
aciklama: "Tabanı sıralamanın üstünde — şansını denediğin satırlar.",
icon: Rocket,
renk: "text-red-500",
},
{
key: "dengeli",
etiket: "Dengeli",
aciklama: "Tabanı sıralamana denk — listenin bel kemiği.",
icon: Scale,
renk: "text-amber-500",
},
{
key: "garanti",
etiket: "Garanti",
aciklama: "Tabanı belirgin şekilde altında — güvenli bölge.",
icon: ShieldCheck,
renk: "text-emerald-600",
},
];
const RISK_NOKTA: Record<RiskSeviyesi, string> = {
guvenli: "bg-emerald-500",
"az-riskli": "bg-amber-500",
riskli: "bg-red-500",
};
// Seçicideki kısa etiketler — sayfa başlığındaki uzun hallerinden bağımsız
const TUR_ETIKETLERI: Record<PuanTuruKey, string> = {
say: "Sayısal",
ea: "Eşit Ağırlık",
soz: "Sözel",
dil: "Dil",
tyt: "TYT",
};
// useLinkStatus Link'in altındaki bir bileşende çağrılmalı; sunucu
// gidiş-dönüşü sürerken tıklanan pill hafifçe nabız atar (layout shift yok).
function SeciciEtiket({ children }: { children: React.ReactNode }) {
const { pending } = useLinkStatus();
return (
<span
className={`transition-opacity duration-150 ${
pending ? "animate-pulse opacity-60" : ""
}`}
>
{children}
</span>
);
}
function PuanTuruSecici({
sira,
aktif,
}: {
sira: number;
aktif: PuanTuruKey;
}) {
return (
<nav
aria-label="Puan türü"
className="flex w-fit max-w-full flex-wrap gap-1 rounded-full border border-slate-200 bg-slate-100 p-1"
>
{(Object.keys(TUR_ETIKETLERI) as PuanTuruKey[]).map((key) => (
<Link
key={key}
href={`/sonuc?sira=${sira}&tur=${key}`}
scroll={false}
aria-current={key === aktif ? "page" : undefined}
className={`cursor-pointer rounded-full px-3 py-1.5 text-xs font-medium transition-[background-color,color,transform] duration-150 active:scale-[0.97] ${
key === aktif
? "bg-white text-slate-900 shadow-sm"
: "text-slate-500 hover:text-slate-900"
}`}
>
<SeciciEtiket>{TUR_ETIKETLERI[key]}</SeciciEtiket>
</Link>
))}
</nav>
);
}
function efektifSira(p: Program): number | null {
return p.sira2025 ?? p.sira2024;
}
function tercihYap(p: Program, adaySira: number): ManuelTercih {
const taban = efektifSira(p);
return {
id: p.id,
isim: p.isim,
universite: p.universite,
il: p.il,
efektifSira: taban,
risk: riskHesapla(taban, adaySira),
};
}
export function ProgramTablosu({
sonuclar,
adaySira,
tur,
}: {
sonuclar: RankResults;
adaySira: number;
tur: PuanTuruKey;
}) {
const liste = useManuelListe();
const [aktifDilim, setAktifDilim] = useState<DilimKey>("dengeli");
const listedekiler = new Set(liste.map((t) => t.id));
const doldu = liste.length >= MANUEL_LISTE_MAX;
function eklemeyiDene(p: Program) {
ekle(tercihYap(p, adaySira));
}
return (
<section className="mt-16" aria-label="Sıralamanla açılan programlar">
<div>
<h2 className="font-heading text-2xl font-bold">
Sıralamanla ılan programlar
</h2>
<p className="mt-1 max-w-2xl text-sm text-slate-500">
Yapay zekâsız, ham YÖK Atlas verisi: geçen yılın taban
sıralamalarına göre ulaşabildiğin bölümler.{" "}
<span className="font-medium text-slate-700">
ile beğendiklerini Seçtiklerim&apos;e at.
</span>
</p>
</div>
{/* Listendeki üniversitelerin konumu — ekledikçe canlı dolar */}
<div className="mt-5">
<ManuelHarita />
</div>
<Tabs
value={aktifDilim}
onValueChange={(v) => setAktifDilim(v as DilimKey)}
className="mt-5"
>
{/* Kontrol çubuğu: tabloyu süzen her şey tablonun hemen üstünde —
solda dilim sekmeleri (birincil), sağda puan türü (ikincil filtre).
Dar ekranda alt alta sarar. */}
<div className="flex flex-wrap items-center justify-between gap-x-6 gap-y-3">
<TabsList>
{DILIMLER.map((d) => (
<TabsTrigger key={d.key} value={d.key} className="cursor-pointer">
<d.icon className={`size-3.5 ${d.renk}`} aria-hidden />
{d.etiket}
<span className="text-xs tabular-nums text-slate-400">
{sonuclar[d.key].length}
</span>
</TabsTrigger>
))}
</TabsList>
<div className="flex items-center gap-2">
<span className="text-[11px] font-medium uppercase tracking-wide text-slate-400">
Puan türü
</span>
{/* scroll={false}: tür değişince sayfa konumu korunur */}
<PuanTuruSecici sira={adaySira} aktif={tur} />
</div>
</div>
{DILIMLER.map((d) => (
<TabsContent key={d.key} value={d.key}>
<p className="mt-2 text-xs text-slate-500">{d.aciklama}</p>
<div className="mt-3 overflow-hidden rounded-2xl border border-slate-200 bg-white">
{sonuclar[d.key].length === 0 ? (
<p className="px-5 py-8 text-center text-sm text-slate-500">
Bu dilimde filtrene uyan program bulunamadı.
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-8" />
<TableHead>Program</TableHead>
<TableHead className="hidden text-right md:table-cell">
Son 5 yıl
</TableHead>
<TableHead className="hidden text-right sm:table-cell">
Kontenjan
</TableHead>
<TableHead className="text-right">Taban sıra</TableHead>
<TableHead className="w-16 text-right">
<span className="sr-only">Listeye ekle</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sonuclar[d.key].map((p) => (
<ProgramSatiri
key={p.id}
program={p}
adaySira={adaySira}
listede={listedekiler.has(p.id)}
doldu={doldu}
onEkle={eklemeyiDene}
/>
))}
</TableBody>
</Table>
)}
</div>
</TabsContent>
))}
</Tabs>
</section>
);
}
// Tabanın 20212025 seyri: küçük sıra = daha rekabetçi olduğundan y ekseni
// ters çevrilir; çizgi yukarı gidiyorsa bölüm yıllar içinde zorlaşıyor demektir.
function SiraGecmisi({ p }: { p: Program }) {
const yillar = [
[2021, p.sira2021],
[2022, p.sira2022],
[2023, p.sira2023],
[2024, p.sira2024],
[2025, p.sira2025],
].filter((y): y is [number, number] => y[1] != null);
if (yillar.length < 2) return <span className="text-slate-400"></span>;
const siralar = yillar.map(([, s]) => s);
const min = Math.min(...siralar);
const max = Math.max(...siralar);
const W = 56;
const H = 18;
const PAD = 2;
const noktalar = yillar.map(([yil, s], i) => {
const x = PAD + (i / (yillar.length - 1)) * (W - PAD * 2);
// min sıra (en rekabetçi) üstte dursun
const y =
max === min
? H / 2
: PAD + ((s - min) / (max - min)) * (H - PAD * 2);
return { x, y, yil, s };
});
const son = noktalar[noktalar.length - 1];
const ozet = yillar
.map(([yil, s]) => `${yil}: ~${s.toLocaleString("tr-TR")}.`)
.join("\n");
return (
<svg
viewBox={`0 0 ${W} ${H}`}
className="inline-block h-[18px] w-14 text-slate-300"
role="img"
aria-label={`Taban sıra geçmişi. ${ozet.replaceAll("\n", " ")}`}
>
<title>{ozet}</title>
<polyline
points={noktalar.map((n) => `${n.x},${n.y}`).join(" ")}
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<circle cx={son.x} cy={son.y} r="2" className="fill-slate-500" />
</svg>
);
}
// Taban sıra iki yıl üst üste %10'dan fazla oynadıysa yönünü gösterir;
// küçük sıra = daha rekabetçi taban demektir.
function TabanTrendi({ p }: { p: Program }) {
if (p.sira2025 == null || p.sira2024 == null) return null;
const oran = (p.sira2024 - p.sira2025) / p.sira2024;
if (Math.abs(oran) < 0.1) return null;
const yukseliyor = oran > 0;
const gecis = `${p.sira2024.toLocaleString("tr-TR")}${p.sira2025.toLocaleString("tr-TR")}`;
return yukseliyor ? (
<TrendingUp
className="size-3 shrink-0 text-amber-500"
aria-label={`Taban yükseliyor: ${gecis}`}
role="img"
>
<title>{`Taban yükseliyor: ${gecis}`}</title>
</TrendingUp>
) : (
<TrendingDown
className="size-3 shrink-0 text-emerald-600"
aria-label={`Taban geriliyor: ${gecis}`}
role="img"
>
<title>{`Taban geriliyor: ${gecis}`}</title>
</TrendingDown>
);
}
function ProgramSatiri({
program: p,
adaySira,
listede,
doldu,
onEkle,
}: {
program: Program;
adaySira: number;
listede: boolean;
doldu: boolean;
onEkle: (p: Program) => void;
}) {
const taban = efektifSira(p);
const risk = riskHesapla(taban, adaySira);
const devlet = p.unitur === "DEVLET";
return (
<TableRow>
<TableCell className="pl-4 pr-0">
{risk ? (
<span
className={`block size-2.5 rounded-full ${RISK_NOKTA[risk]}`}
aria-hidden
/>
) : null}
</TableCell>
<TableCell className="max-w-0 w-full">
<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">
<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>
<span className="truncate">
{p.universite}
{p.il ? ` · ${p.il.toLocaleLowerCase("tr-TR")}` : ""}
</span>
{p.sure ? (
<span className="shrink-0 text-slate-400">· {p.sure} yıl</span>
) : null}
</span>
</TableCell>
<TableCell className="hidden text-right text-xs md:table-cell">
<SiraGecmisi p={p} />
</TableCell>
<TableCell
className="hidden text-right text-xs tabular-nums text-slate-500 sm:table-cell"
title={
p.kontenjan2025 != null && p.yerlesen2025 != null
? `${p.kontenjan2025} kontenjan, ${p.yerlesen2025} yerleşen`
: undefined
}
>
{p.kontenjan2025 != null ? (
<span className="inline-flex items-center gap-1.5">
{p.yerlesen2025 != null && p.yerlesen2025 < p.kontenjan2025 ? (
<span className="rounded bg-amber-50 px-1 py-px text-[10px] font-medium leading-4 text-amber-600">
boş kaldı
</span>
) : null}
{p.kontenjan2025.toLocaleString("tr-TR")} kişi
</span>
) : (
"—"
)}
</TableCell>
<TableCell className="text-right text-xs tabular-nums text-slate-500">
<span className="inline-flex items-center gap-1">
<TabanTrendi p={p} />
{taban != null ? `~${taban.toLocaleString("tr-TR")}.` : "—"}
</span>
</TableCell>
<TableCell className="pr-4 text-right">
<button
type="button"
disabled={listede || doldu}
onClick={() => onEkle(p)}
title={
listede
? "Zaten seçtiklerinde"
: doldu
? `Seçtiklerin dolu (${MANUEL_LISTE_MAX}/${MANUEL_LISTE_MAX})`
: "Seçtiklerime ekle"
}
className={`inline-flex size-8 cursor-pointer items-center justify-center rounded-full border transition-[background-color,border-color,color,transform] duration-150 active:scale-[0.92] disabled:cursor-default ${
listede
? "border-emerald-200 bg-emerald-50 text-emerald-600"
: "border-slate-200 bg-white text-slate-500 hover:border-primary hover:bg-primary/5 hover:text-primary disabled:opacity-40"
}`}
>
{listede ? (
<Check className="size-4" aria-hidden />
) : (
<Plus className="size-4" aria-hidden />
)}
<span className="sr-only">
{listede
? `${p.isim} seçtiklerinde`
: `${p.isim} seçtiklerine ekle`}
</span>
</button>
</TableCell>
</TableRow>
);
}

View File

@@ -1,82 +1,434 @@
import { Rocket, Scale, ShieldCheck } from "lucide-react"; "use client";
import { Badge } from "@/components/ui/badge";
import type { RaporSonuc } from "@/lib/ai/rapor";
export const DILIM_STIL: Record< // 24'lük listenin gövdesi. Program adı, 2024/2025 tabanları ve ham trend
string, // herkese görünür. AI gerekçesi ile kişisel risk, kilitliBaslangic sonrasında
{ label: string; cls: string; icon: typeof Rocket } // ücretli katman olarak ayrılır; ücretli metin sunucuda zaten maskelidir.
import { Fragment, useEffect, useRef, useState } from "react";
import {
ChevronDown,
Lock,
MapPin,
Minus,
Rocket,
Scale,
ShieldCheck,
TrendingDown,
TrendingUp,
} from "lucide-react";
import type { RaporSonuc } from "@/lib/ai/rapor";
import {
RISK_ETIKET,
dilimdenRisk,
riskHesapla,
type RiskSeviyesi,
} from "@/lib/risk";
export const DILIM_ETIKET: Record<string, string> = {
hayal: "Hayal",
dengeli: "Dengeli",
garanti: "Garanti",
};
// Risk renk dili: yeşil = güvenli, sarı = az riskli, kırmızı = riskli
const RISK_STIL: Record<
RiskSeviyesi,
{ nokta: string; kenar: string; icon: typeof Rocket }
> = { > = {
hayal: { label: "Hayal", cls: "bg-orange-100 text-orange-700", icon: Rocket }, riskli: { nokta: "bg-red-500", kenar: "border-l-red-500", icon: Rocket },
dengeli: { label: "Dengeli", cls: "bg-blue-100 text-blue-700", icon: Scale }, "az-riskli": {
garanti: { nokta: "bg-amber-500",
label: "Garanti", kenar: "border-l-amber-500",
cls: "bg-emerald-100 text-emerald-700", icon: Scale,
},
guvenli: {
nokta: "bg-emerald-500",
kenar: "border-l-emerald-500",
icon: ShieldCheck, icon: ShieldCheck,
}, },
}; };
/** const YILLAR = [2021, 2022, 2023, 2024, 2025];
* 24'lük listenin gövdesi (genel değerlendirme + tercih kartları).
* Hem sonuç modalında hem PDF/yazdır sayfasında kullanılır. type Trend = "zorlasiyor" | "kolaylasiyor" | "stabil";
*/
export function RaporListesi({ rapor }: { rapor: RaporSonuc }) { /** 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);
return (
<div className="mt-2 flex items-center gap-2">
<svg
viewBox={`0 0 ${W} ${H}`}
className="h-10 w-37.5"
role="img"
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")}`}
>
<path
d={path}
fill="none"
stroke="oklch(0.623 0.188 259.8)"
strokeWidth={2}
strokeLinecap="round"
/>
<circle cx={son.x} cy={son.y} r={3} fill="oklch(0.623 0.188 259.8)" />
</svg>
<span className="text-xs text-slate-500">
{noktalar[0].yil}: ~{noktalar[0].sira.toLocaleString("tr-TR")}
<br />
{noktalar[noktalar.length - 1].yil}: ~
{noktalar[noktalar.length - 1].sira.toLocaleString("tr-TR")}
</span>
</div>
);
}
export function RaporListesi({
rapor,
adaySira,
kilitliBaslangic,
kilitOverlay,
acikSira,
onHaritadaGor,
}: {
rapor: RaporSonuc;
adaySira?: number;
kilitliBaslangic?: number;
kilitOverlay?: React.ReactNode;
/** Dışarıdan (haritadan) açtırılan satır — değişince satır açılıp kaydırılır */
acikSira?: number | null;
/** Detaydaki "Haritada gör" — il DB biçiminde */
onHaritadaGor?: (il: string | null, sira: number) => void;
}) {
const [acik, setAcik] = useState<Set<number>>(new Set());
const satirRefs = useRef(new Map<number, HTMLLIElement>());
// Haritadan gelen seçim: satırı aç ve görünür alana kaydır
useEffect(() => {
if (acikSira == null) return;
// eslint-disable-next-line react-hooks/set-state-in-effect
setAcik((mevcut) => new Set(mevcut).add(acikSira));
satirRefs.current
.get(acikSira)
?.scrollIntoView({ behavior: "smooth", block: "center" });
}, [acikSira]);
function tiklandi(sira: number) {
setAcik((mevcut) => {
const yeni = new Set(mevcut);
if (yeni.has(sira)) yeni.delete(sira);
else yeni.add(sira);
return yeni;
});
}
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(
rapor.tercihler
.map((t) => rapor.programlar[t.programId]?.il)
.filter((il): il is string => Boolean(il)),
);
const tekIl = iller.size === 1 ? [...iller][0] : null;
const satir = (
t: RaporSonuc["tercihler"][number],
index: number,
) => {
const p = rapor.programlar[t.programId];
const analizKilitli = index >= kilitIdx;
const risk =
(adaySira != null ? riskHesapla(p?.efektifSira, adaySira) : null) ??
dilimdenRisk(t.dilim);
const stil = RISK_STIL[risk];
const trend = trendYonu(p?.siraGecmisi);
const acikMi = acik.has(t.sira);
const sira2024 = p?.siraGecmisi?.[3] ?? null;
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 20212025 çizgisinde durur.
const trendG =
trend && sira2024 != null && sira2025 != null
? TREND_GORUNUM[trend]
: null;
return (
<li
key={t.sira}
ref={(el) => {
if (el) satirRefs.current.set(t.sira, el);
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)}
aria-expanded={acikMi}
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">
{t.sira}
</span>
<span className="min-w-0">
<span className="block truncate text-sm font-semibold">
{p?.isim ?? t.programId}
</span>
<span className="block truncate text-xs text-slate-500">
{p?.universite}
{p?.il ? ` · ${p.il.toLocaleLowerCase("tr-TR")}` : ""}
</span>
<span className="mt-1 flex items-center gap-3 text-[11px] tabular-nums text-slate-500 xl:hidden">
<span>2024: {sira2024?.toLocaleString("tr-TR") ?? "—"}</span>
<span>2025: {sira2025?.toLocaleString("tr-TR") ?? "—"}</span>
{trendG ? <span className={trendG.renk}>{trendG.etiket}</span> : null}
</span>
</span>
<span className="hidden text-right text-xs tabular-nums text-slate-600 xl:block">
{sira2024?.toLocaleString("tr-TR") ?? "—"}
</span>
<span className="hidden text-right text-xs font-semibold tabular-nums text-slate-900 xl:block">
{sira2025?.toLocaleString("tr-TR") ?? "—"}
</span>
<span className="hidden items-center gap-1.5 text-xs xl:flex">
{trendG ? (
<>
<trendG.icon className={`size-4 shrink-0 ${trendG.renk}`} aria-hidden />
<span className={trendG.renk}>
{trend === "zorlasiyor"
? "Zorlaşıyor"
: trend === "kolaylasiyor"
? "Rahatlıyor"
: "Stabil"}
</span>
</>
) : (
<span className="text-slate-400">Veri eksik</span>
)}
</span>
<span className="hidden items-center gap-1.5 text-xs font-medium xl:flex">
{analizKilitli ? (
<>
<Lock className="size-3.5 text-slate-400" aria-hidden />
<span className="text-slate-500">Pakette</span>
</>
) : (
<>
<span className={`size-2.5 rounded-full ${stil.nokta}`} aria-hidden />
<span>{RISK_ETIKET[risk]}</span>
</>
)}
</span>
<ChevronDown
className={`size-4 shrink-0 text-slate-400 transition-transform duration-200 ${acikMi ? "rotate-180" : ""}`}
aria-hidden
/>
</button>
{acikMi ? (
<div className="border-t border-slate-100 bg-slate-50/50 px-4 py-4">
<div className="mb-2 flex flex-wrap items-center gap-2 text-xs text-slate-500">
{analizKilitli ? (
<>
<Lock className="size-3.5" aria-hidden />
<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">
20212025 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 />
AI karar desteği
</dt>
<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 className="rounded-lg border border-slate-200 bg-white p-3">
<dt className="text-xs font-semibold text-slate-500">
Risk ve AI yorumu
</dt>
<dd className="mt-1 text-slate-700">
{t.riskNotu} {t.trendOzeti}
</dd>
</div>
</>
)}
</dl>
{onHaritadaGor && p?.il ? (
<button
type="button"
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"
>
<MapPin className="size-3.5" aria-hidden />
Haritada gör
</button>
) : null}
</div>
) : null}
</li>
);
};
return ( return (
<div> <div>
<div className="rounded-2xl border border-slate-200 bg-white p-6"> <div className="flex flex-wrap items-end justify-between gap-2">
<h2 className="font-heading text-lg font-bold">Genel değerlendirme</h2> <div>
<p className="mt-2 whitespace-pre-line text-sm leading-relaxed text-slate-700"> <h2 className="font-heading text-lg font-bold">24 tercihin ve taban verileri</h2>
{rapor.genelDegerlendirme} <p className="mt-1 text-xs text-slate-500">
</p> Satıra dokunarak 20212025 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>
<ol className="mt-6 space-y-3"> <div className="mt-3 flex flex-wrap items-center gap-2 text-xs font-medium">
{rapor.tercihler.map((t) => { {(
const p = rapor.programlar[t.programId]; [
const stil = DILIM_STIL[t.dilim]; ["hayal", "bg-red-500"],
return ( ["dengeli", "bg-amber-500"],
<li ["garanti", "bg-emerald-500"],
key={t.sira} ] as const
className="rounded-2xl border border-slate-200 bg-white p-5" ).map(([dilim, nokta]) => (
> <span
<div className="flex flex-wrap items-center gap-3"> key={dilim}
<span className="flex size-8 items-center justify-center rounded-full bg-slate-100 font-heading text-sm font-bold"> className="inline-flex items-center gap-1.5 rounded-full border border-slate-200 bg-white px-2.5 py-1 text-slate-700"
{t.sira} >
</span> <span className={`size-2 rounded-full ${nokta}`} aria-hidden />
<div className="min-w-0 flex-1"> {dilimSayilari[dilim] ?? 0} {DILIM_ETIKET[dilim].toLocaleLowerCase("tr-TR")}
<p className="font-semibold">{p?.isim ?? t.programId}</p> </span>
<p className="text-sm text-slate-500"> ))}
{p?.universite} · {p?.il ?? "—"} <span className="text-slate-400">
{p?.unitur ? ` · ${p.unitur}` : ""} Liste hayal diliminden başlar; kırmızı satırlar plana dahildir.
</p> </span>
</div> </div>
<Badge variant="secondary" className={stil.cls}>
<stil.icon className="size-3" aria-hidden /> {tekIl ? (
{stil.label} <p className="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900">
</Badge> Listenin 24 tercihi de tek şehirde (
</div> {tekIl.toLocaleLowerCase("tr-TR")}). Boşta kalma riskini azaltmak
<dl className="mt-3 grid gap-2 text-sm sm:grid-cols-3"> için birkaç farklı şehir eklemeyi düşünebilirsin.
<div className="rounded-lg bg-slate-50 p-3"> </p>
<dt className="text-xs font-semibold text-slate-500"> ) : null}
Neden listede?
</dt> <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">
<dd className="mt-1 text-slate-700">{t.gerekce}</dd> <span className="w-7">#</span>
</div> <span>Program</span>
<div className="rounded-lg bg-slate-50 p-3"> <span className="text-right">2024 taban</span>
<dt className="text-xs font-semibold text-slate-500"> <span className="text-right">2025 taban</span>
Risk notu <span>Ham trend</span>
</dt> <span>AI risk</span>
<dd className="mt-1 text-slate-700">{t.riskNotu}</dd> <span className="w-4" />
</div> </div>
<div className="rounded-lg bg-slate-50 p-3">
<dt className="text-xs font-semibold text-slate-500"> <ol className="mt-2 flex flex-col gap-2">
4 yıllık trend {rapor.tercihler.map((t, index) => (
</dt> <Fragment key={t.sira}>
<dd className="mt-1 text-slate-700">{t.trendOzeti}</dd> {index === kilitIdx && kilitOverlay ? (
</div> <li className="my-2 list-none rounded-2xl border border-orange-200 bg-orange-50/70 p-5">
</dl> {kilitOverlay}
</li> </li>
); ) : null}
})} {satir(t, index)}
</Fragment>
))}
</ol> </ol>
</div> </div>
); );

View File

@@ -0,0 +1,338 @@
"use client";
// Sihirbazın 3 adımlık seçim gövdesi. Hem ana sayfadaki hero modalında hem de
// /sonuc'taki sihirbaz modalında kullanılır; seçim state'ini kendi tutar,
// tamamlanınca onTamamla(secimler) çağırır.
import { useState } from "react";
import { ArrowLeft, ArrowRight, Sparkles, X } from "lucide-react";
import { useReducedMotion } from "motion/react";
import useMeasure from "react-use-measure";
import { toast } from "sonner";
import { cities } from "turkey-map-react/lib/data";
import { TransitionPanel } from "@/components/core/transition-panel";
import { Button } from "@/components/ui/button";
import { IlSecimHaritasi } from "@/components/il-secim-haritasi";
import { normalizeIlAdi } from "@/lib/harita";
import type { SihirbazFacetleri } from "@/lib/db";
import {
ONCELIKLER,
UNIVERSITE_TIPI_ETIKET,
type SihirbazSecimleri,
type UniversiteTipi,
} from "@/lib/sihirbaz";
export function Cip({
secili,
onClick,
children,
}: {
secili: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={secili}
className={
secili
? "cursor-pointer rounded-full border border-primary bg-primary px-3.5 py-2 text-sm font-medium text-white transition-colors duration-200"
: "cursor-pointer rounded-full border border-slate-200 bg-white px-3.5 py-2 text-sm text-slate-700 transition-colors duration-200 hover:border-slate-300 hover:bg-slate-50"
}
>
{children}
</button>
);
}
// Haritada karşılığı olan iller (KKTC/yurtdışı kampüsler çip olarak kalır)
const HARITA_ILLERI = new Set(cities.map((c) => normalizeIlAdi(c.name)));
export function SihirbazAdimlar({
facetler,
baslangicSecimler,
sonButonEtiketi,
onTamamla,
}: {
facetler: SihirbazFacetleri;
baslangicSecimler?: SihirbazSecimleri | null;
sonButonEtiketi: string;
onTamamla: (secimler: SihirbazSecimleri) => void;
}) {
const [adim, setAdim] = useState(0);
const [yon, setYon] = useState(1);
const [gecisBasladi, setGecisBasladi] = useState(false);
const [panelRef, panelOlculeri] = useMeasure();
const hareketiAzalt = useReducedMotion();
const [kategoriler, setKategoriler] = useState<string[]>(
baslangicSecimler?.kategoriler ?? [],
);
const [iller, setIller] = useState<string[]>(baslangicSecimler?.iller ?? []);
const [universiteTipi, setUniversiteTipi] = useState<UniversiteTipi>(
baslangicSecimler?.universiteTipi ?? "farketmez",
);
const [oncelikler, setOncelikler] = useState<string[]>(
baslangicSecimler?.oncelikler ?? [],
);
function listeDegistir(
liste: string[],
setListe: (v: string[]) => void,
deger: string,
maks?: number,
) {
if (liste.includes(deger)) {
setListe(liste.filter((x) => x !== deger));
} else {
if (maks && liste.length >= maks) {
toast.info(`En fazla ${maks} seçebilirsin.`);
return;
}
setListe([...liste, deger]);
}
}
const adimGecerli = adim === 0 ? kategoriler.length > 0 : true;
function adimaGit(yeniAdim: number) {
setYon(yeniAdim > adim ? 1 : -1);
setGecisBasladi(true);
setAdim(yeniAdim);
}
const panelYuksekligi =
gecisBasladi && panelOlculeri.height > 0
? panelOlculeri.height
: "auto";
return (
<div className="flex flex-col gap-5">
<div className="flex items-center gap-1.5">
{[0, 1, 2].map((i) => (
<span
key={i}
className={
i === adim
? "h-1.5 w-6 rounded-full bg-primary transition-[width,background-color] duration-200 ease-out"
: i < adim
? "h-1.5 w-3 rounded-full bg-primary/50 transition-[width,background-color] duration-200 ease-out"
: "h-1.5 w-1.5 rounded-full bg-slate-200 transition-[width,background-color] duration-200 ease-out"
}
/>
))}
<span className="ml-2 text-xs font-medium text-slate-400">
Adım {adim + 1}/3
</span>
</div>
<TransitionPanel
activeIndex={adim}
custom={yon}
variants={{
enter: (gecisYonu: number) => ({
x: hareketiAzalt ? 0 : gecisYonu > 0 ? "100%" : "-100%",
opacity: 0,
height: panelYuksekligi,
position: "relative",
}),
center: {
zIndex: 1,
x: 0,
opacity: 1,
height: panelYuksekligi,
},
exit: (gecisYonu: number) => ({
zIndex: 0,
x: hareketiAzalt ? 0 : gecisYonu < 0 ? "100%" : "-100%",
opacity: 0,
position: "absolute",
top: 0,
left: 0,
width: "100%",
}),
}}
transition={
hareketiAzalt
? // Azaltılmış harekette yalnızca kısa çapraz solma; height/x
// varsayılan spring'e düşüp oynamasın diye açıkça sıfırlanır.
{
opacity: { duration: 0.12 },
x: { duration: 0 },
height: { duration: 0 },
}
: {
x: { type: "spring", stiffness: 300, damping: 30 },
height: { type: "spring", stiffness: 300, damping: 30 },
opacity: { duration: 0.2 },
}
}
>
<div ref={panelRef} className="px-px pb-2">
<h3 className="font-heading text-lg font-bold">
Hangi alanlara ilgi duyuyorsun?
</h3>
<p className="mt-1 text-sm text-slate-500">
Sıralamanla ulaşabileceğin alanlar. En az bir tane seç.
</p>
<div className="mt-4 flex flex-wrap gap-2">
{facetler.kategoriler.map((k) => (
<Cip
key={k.ad}
secili={kategoriler.includes(k.ad)}
onClick={() => listeDegistir(kategoriler, setKategoriler, k.ad)}
>
{k.ad}
<span className="ml-1.5 text-xs opacity-70">{k.adet}</span>
</Cip>
))}
</div>
</div>
<div ref={panelRef}>
<h3 className="font-heading text-lg font-bold">
Nerede okumak istersin?
</h3>
<p className="mt-1 text-sm text-slate-500">
En fazla 5 il seçebilirsin. İstersen boş bırak, fark etmez.
</p>
{/* Çipler birincil seçim yolu (haritadaki küçük iller mobilde
güvenilir dokunma hedefi değil); tüm iller program sayısına
göre sıralı listelenir. */}
<div className="mt-4 flex flex-wrap gap-2">
{[...facetler.iller]
.sort((a, b) => b.adet - a.adet)
.map((i) => (
<Cip
key={i.il}
secili={iller.includes(i.il)}
onClick={() => listeDegistir(iller, setIller, i.il, 5)}
>
{i.il.toLocaleLowerCase("tr-TR")}
<span className="ml-1.5 text-xs opacity-70">{i.adet}</span>
</Cip>
))}
</div>
{iller.length > 0 ? (
<div className="mt-3 flex flex-wrap items-center gap-2">
<span className="text-xs font-semibold text-slate-400">
Seçtiklerin:
</span>
{iller.map((il) => (
<button
key={il}
type="button"
onClick={() => listeDegistir(iller, setIller, il)}
className="inline-flex cursor-pointer items-center gap-1 rounded-full bg-orange-500 px-3 py-1.5 text-xs font-medium text-white transition-colors duration-200 hover:bg-orange-600"
>
{il.toLocaleLowerCase("tr-TR")}
<X className="size-3" aria-hidden />
</button>
))}
</div>
) : null}
{/* Harita geniş ekranda görsel destek olarak kalır */}
<div className="mt-4 hidden sm:block">
<IlSecimHaritasi
iller={facetler.iller.filter((i) => HARITA_ILLERI.has(i.il))}
secili={iller}
onToggle={(il) => listeDegistir(iller, setIller, il, 5)}
/>
</div>
</div>
<div ref={panelRef} className="space-y-5">
<div>
<h3 className="font-heading text-lg font-bold">
Üniversite tipi & öncelikler
</h3>
<p className="mt-1 text-sm text-slate-500">
Bunlar listenin dengesini belirler.
</p>
</div>
<div>
<p className="mb-2 text-xs font-semibold tracking-wide text-slate-400 uppercase">
Üniversite tipi
</p>
<div className="flex flex-wrap gap-2">
{facetler.uniturler.map((u) => (
<Cip
key={u.grup}
secili={universiteTipi === u.grup}
onClick={() =>
setUniversiteTipi(
universiteTipi === u.grup ? "farketmez" : u.grup,
)
}
>
{UNIVERSITE_TIPI_ETIKET[u.grup]}
<span className="ml-1.5 text-xs opacity-70">{u.adet}</span>
</Cip>
))}
<Cip
secili={universiteTipi === "farketmez"}
onClick={() => setUniversiteTipi("farketmez")}
>
Fark etmez
</Cip>
</div>
</div>
<div>
<p className="mb-2 text-xs font-semibold tracking-wide text-slate-400 uppercase">
Senin için önemli olan
</p>
<div className="flex flex-wrap gap-2">
{ONCELIKLER.map((o) => (
<Cip
key={o}
secili={oncelikler.includes(o)}
onClick={() => listeDegistir(oncelikler, setOncelikler, o)}
>
{o}
</Cip>
))}
</div>
</div>
</div>
</TransitionPanel>
<div className="flex items-center justify-between border-t border-slate-100 pt-4">
<Button
type="button"
variant="ghost"
onClick={() => adimaGit(adim - 1)}
disabled={adim === 0}
className={adim === 0 ? "invisible" : "cursor-pointer"}
>
<ArrowLeft className="size-4" aria-hidden />
Geri
</Button>
{adim < 2 ? (
<Button
type="button"
onClick={() => adimaGit(adim + 1)}
disabled={!adimGecerli}
className="cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
>
Devam
<ArrowRight className="size-4" aria-hidden />
</Button>
) : (
<Button
type="button"
onClick={() =>
onTamamla({ kategoriler, iller, universiteTipi, oncelikler })
}
disabled={kategoriler.length === 0}
className="cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
>
<Sparkles className="size-4" aria-hidden />
{sonButonEtiketi}
</Button>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,416 @@
"use client";
// Sonuç sayfasının kahraman öğesi: 24 tercihin Türkiye haritası üzerinde
// risk renkli pinlerle gösterimi. Ülke seviyesinde il başına tek toplu pin;
// ile tıklayınca viewBox o ilin sınırlarına animasyonla akar, il büyür ve
// üniversite pinleri gerçek kampüs konumlarında adlarıyla görünür.
// Kilitli (paketsiz) tercihler il merkezinde adsız "?" pini olarak çizilir —
// üniversite adı client'a hiç inmediği için paywall haritadan delinemez.
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { ArrowLeft, Lock } from "lucide-react";
import { cities } from "turkey-map-react/lib/data";
import { Button } from "@/components/ui/button";
import {
HARITA_GENISLIK,
HARITA_UST_KIRPMA,
HARITA_YUKSEKLIK,
normalizeIlAdi,
} from "@/lib/harita";
import type { RiskSeviyesi } from "@/lib/risk";
export type HaritaPin = {
/** Üniversite bazlı benzersiz anahtar (kilitlilerde sentetik) */
id: string;
/** Görünen ad — kilitli pinlerde boş bırakılır */
universite: string;
/** DB biçiminde il adı (büyük harf); harita dışıysa null */
il: string | null;
x: number;
y: number;
/** Bu üniversitedeki tercihlerin liste sıraları (1-24) */
tercihSiralari: number[];
risk: RiskSeviyesi;
kilitli?: boolean;
};
const RISK_RENK: Record<RiskSeviyesi, string> = {
guvenli: "oklch(0.696 0.17 162.5)", // emerald-500
"az-riskli": "oklch(0.769 0.188 70.1)", // amber-500
riskli: "oklch(0.637 0.237 25.3)", // red-500
};
const TAM_GORUNUM = {
x: 0,
y: HARITA_UST_KIRPMA,
w: HARITA_GENISLIK,
h: HARITA_YUKSEKLIK - HARITA_UST_KIRPMA,
};
type ViewBox = typeof TAM_GORUNUM;
/** "ORTA DOĞU TEKNİK ÜNİVERSİTESİ" → "Orta Doğu Teknik" */
function kisaUniAdi(ad: string): string {
const kisa = ad
.replace(/\s+(ÜNİVERSİTESİ|ENSTİTÜSÜ|MESLEK YÜKSEKOKULU)$/i, "")
.trim();
return kisa
.toLocaleLowerCase("tr-TR")
.split(" ")
.map((k) =>
k.length > 2 || k === "29"
? k.charAt(0).toLocaleUpperCase("tr-TR") + k.slice(1)
: k,
)
.join(" ");
}
/** İldeki pinlerin çoğunluk riski (eşitlikte güvenli taraf). */
function baskinRisk(pinler: HaritaPin[]): RiskSeviyesi {
const sayac: Record<RiskSeviyesi, number> = {
guvenli: 0,
"az-riskli": 0,
riskli: 0,
};
for (const p of pinler) sayac[p.risk] += p.tercihSiralari.length;
return (["guvenli", "az-riskli", "riskli"] as const).reduce((a, b) =>
sayac[b] > sayac[a] ? b : a,
);
}
/** Aynı noktaya düşen pinleri küçük bir halka üzerinde ayrıştırır. */
function cakismaAc(pinler: HaritaPin[]): HaritaPin[] {
const gruplar = new Map<string, HaritaPin[]>();
for (const p of pinler) {
const key = `${Math.round(p.x)}:${Math.round(p.y)}`;
const grup = gruplar.get(key);
if (grup) grup.push(p);
else gruplar.set(key, [p]);
}
const sonuc: HaritaPin[] = [];
for (const grup of gruplar.values()) {
if (grup.length === 1) {
sonuc.push(grup[0]);
continue;
}
grup.forEach((p, i) => {
const aci = (2 * Math.PI * i) / grup.length;
sonuc.push({ ...p, x: p.x + Math.cos(aci) * 5, y: p.y + Math.sin(aci) * 5 });
});
}
return sonuc;
}
export function TercihHaritasi({
pinler,
seciliIl,
onSeciliIlDegisti,
onTercihSec,
}: {
pinler: HaritaPin[];
/** Zoom'lanacak il (DB biçimi) — liste tarafından da kontrol edilebilir */
seciliIl: string | null;
onSeciliIlDegisti: (il: string | null) => void;
/** Alt şeritte bir tercihe dokunulunca (liste satırına in) */
onTercihSec?: (sira: number) => void;
}) {
const pathRefs = useRef(new Map<string, SVGPathElement>());
const [vb, setVb] = useState<ViewBox>(TAM_GORUNUM);
const vbRef = useRef<ViewBox>(TAM_GORUNUM);
const animRef = useRef(0);
const animateTo = useCallback((hedef: ViewBox) => {
cancelAnimationFrame(animRef.current);
const kaynak = { ...vbRef.current };
const t0 = performance.now();
const SURE = 380;
const ease = (t: number) => 1 - Math.pow(1 - t, 3);
const adim = (now: number) => {
const t = Math.min(1, (now - t0) / SURE);
const k = ease(t);
const sonraki = {
x: kaynak.x + (hedef.x - kaynak.x) * k,
y: kaynak.y + (hedef.y - kaynak.y) * k,
w: kaynak.w + (hedef.w - kaynak.w) * k,
h: kaynak.h + (hedef.h - kaynak.h) * k,
};
vbRef.current = sonraki;
setVb(sonraki);
if (t < 1) animRef.current = requestAnimationFrame(adim);
};
animRef.current = requestAnimationFrame(adim);
}, []);
useEffect(() => () => cancelAnimationFrame(animRef.current), []);
// seciliIl değişince ilgili ilin bbox'ına (veya tam görünüme) ak
useEffect(() => {
if (!seciliIl) {
animateTo(TAM_GORUNUM);
return;
}
const path = pathRefs.current.get(seciliIl);
if (!path) return;
const b = path.getBBox();
const pad = Math.max(b.width, b.height) * 0.22;
let x = b.x - pad;
let y = b.y - pad;
let w = b.width + pad * 2;
let h = b.height + pad * 2;
// viewBox oranını koru ki il yamulmadan büyüsün ve harita yüksekliği
// zoom'da değişmesin (tam görünümle aynı oran)
const oran = TAM_GORUNUM.w / TAM_GORUNUM.h;
if (w / h > oran) {
const yeniH = w / oran;
y -= (yeniH - h) / 2;
h = yeniH;
} else {
const yeniW = h * oran;
x -= (yeniW - w) / 2;
w = yeniW;
}
animateTo({ x, y, w, h });
}, [seciliIl, animateTo]);
const ilPinleri = useMemo(() => {
const gruplar = new Map<string, HaritaPin[]>();
for (const p of pinler) {
if (!p.il) continue;
const grup = gruplar.get(p.il);
if (grup) grup.push(p);
else gruplar.set(p.il, [p]);
}
return gruplar;
}, [pinler]);
// Ekran boyutu sabit kalsın diye SVG içi ölçüler zoom oranıyla çarpılır
const sf = vb.w / HARITA_GENISLIK;
const zoomda = Boolean(seciliIl);
const seciliPinler = seciliIl ? (ilPinleri.get(seciliIl) ?? []) : [];
const acikPinler = cakismaAc(seciliPinler);
return (
<div>
<div className="relative">
<svg
viewBox={`${vb.x} ${vb.y} ${vb.w} ${vb.h}`}
className="h-auto w-full"
role="img"
aria-label="Tercih listesinin Türkiye haritasındaki dağılımı"
>
{/* İl zeminleri */}
{cities.map((city) => {
const key = normalizeIlAdi(city.name);
const iceriyor = ilPinleri.has(key);
const secili = key === seciliIl;
return (
<path
key={city.id}
ref={(el) => {
if (el) pathRefs.current.set(key, el);
else pathRefs.current.delete(key);
}}
d={city.path}
className={
iceriyor
? "cursor-pointer transition-opacity duration-200 hover:opacity-80"
: undefined
}
fill={
secili
? "oklch(0.97 0.014 254.6)" // blue-50: seçili zemin
: iceriyor
? "oklch(0.882 0.059 254.1)" // blue-200
: "oklch(0.929 0.013 255.5)" // slate-200
}
stroke="white"
strokeWidth={secili ? 1.6 * sf : 1 * sf}
onClick={() => {
if (secili) onSeciliIlDegisti(null);
else if (iceriyor) onSeciliIlDegisti(key);
}}
>
<title>
{iceriyor
? `${city.name}: ${(ilPinleri.get(key) ?? []).reduce(
(a, p) => a + p.tercihSiralari.length,
0,
)} tercih — yakınlaşmak için tıkla`
: city.name}
</title>
</path>
);
})}
{/* Ülke seviyesi: il başına toplu pin */}
{!zoomda
? [...ilPinleri.entries()].map(([il, iller]) => {
const adet = iller.reduce(
(a, p) => a + p.tercihSiralari.length,
0,
);
const cx =
iller.reduce((a, p) => a + p.x, 0) / iller.length;
const cy =
iller.reduce((a, p) => a + p.y, 0) / iller.length;
const renk = RISK_RENK[baskinRisk(iller)];
return (
<g
key={il}
className="cursor-pointer"
onClick={() => onSeciliIlDegisti(il)}
role="button"
aria-label={`${il.toLocaleLowerCase("tr-TR")}: ${adet} tercih`}
>
<circle
cx={cx}
cy={cy}
r={11}
fill={renk}
stroke="white"
strokeWidth={2}
/>
<text
x={cx}
y={cy}
textAnchor="middle"
dominantBaseline="central"
fontSize={11}
fontWeight={700}
fill="white"
className="pointer-events-none select-none"
>
{adet}
</text>
</g>
);
})
: null}
{/* İl seviyesi: üniversite pinleri */}
{zoomda
? acikPinler.map((p) => {
const r = (p.kilitli ? 7 : 8) * sf;
const etiket = p.kilitli ? null : kisaUniAdi(p.universite);
return (
<g
key={p.id}
className={onTercihSec && !p.kilitli ? "cursor-pointer" : undefined}
onClick={
onTercihSec && !p.kilitli
? () => onTercihSec(p.tercihSiralari[0])
: undefined
}
role={p.kilitli ? undefined : "button"}
aria-label={
p.kilitli
? "Kilitli tercih"
: `${etiket}: ${p.tercihSiralari.length} tercih`
}
>
<circle
cx={p.x}
cy={p.y}
r={r}
fill={p.kilitli ? "oklch(0.704 0.04 256.8)" : RISK_RENK[p.risk]}
stroke="white"
strokeWidth={2 * sf}
strokeDasharray={p.kilitli ? `${3 * sf} ${2.5 * sf}` : undefined}
/>
<text
x={p.x}
y={p.y}
textAnchor="middle"
dominantBaseline="central"
fontSize={9 * sf}
fontWeight={700}
fill="white"
className="pointer-events-none select-none"
>
{p.kilitli ? "?" : p.tercihSiralari.length}
</text>
{etiket ? (
<text
x={p.x}
y={p.y - r - 4 * sf}
textAnchor="middle"
fontSize={10.5 * sf}
fontWeight={600}
fill="oklch(0.279 0.041 260.0)" // slate-800
stroke="white"
strokeWidth={3 * sf}
paintOrder="stroke"
className="pointer-events-none select-none"
>
{etiket}
</text>
) : null}
</g>
);
})
: null}
</svg>
{zoomda ? (
<Button
size="sm"
variant="outline"
onClick={() => onSeciliIlDegisti(null)}
className="absolute left-3 top-3 cursor-pointer rounded-full bg-white/90 backdrop-blur"
>
<ArrowLeft className="size-4" aria-hidden />
Türkiye
</Button>
) : null}
</div>
{/* Seçili ilin tercih şeridi (yalnızca zoom'dayken) */}
{zoomda && seciliPinler.length > 0 ? (
<div className="mt-2">
<p className="px-1 pb-1 text-xs font-semibold text-slate-500">
{seciliIl!.toLocaleLowerCase("tr-TR")} tercihlerin
</p>
<ul className="flex gap-2 overflow-x-auto pb-1">
{seciliPinler
.flatMap((p) =>
p.tercihSiralari.map((sira) => ({ pin: p, sira })),
)
.sort((a, b) => a.sira - b.sira)
.map(({ pin, sira }) => (
<li key={sira} className="shrink-0">
<button
type="button"
onClick={onTercihSec ? () => onTercihSec(sira) : undefined}
className="flex cursor-pointer items-center gap-2 rounded-full border border-slate-200 bg-white py-1 pl-1.5 pr-3 text-xs transition-colors duration-200 hover:border-slate-300 hover:bg-slate-50"
>
<span
className="flex size-5 items-center justify-center rounded-full font-heading text-[10px] font-bold text-white"
style={{ backgroundColor: RISK_RENK[pin.risk] }}
>
{sira}
</span>
{pin.kilitli ? (
<span className="inline-flex items-center gap-1 text-slate-400">
<Lock className="size-3" aria-hidden />
Kilitli tercih
</span>
) : (
<span className="max-w-40 truncate font-medium text-slate-700">
{kisaUniAdi(pin.universite)}
</span>
)}
</button>
</li>
))}
</ul>
</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,112 @@
"use client"
import * as React from "react"
import { Avatar as AvatarPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Avatar({
className,
size = "default",
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root> & {
size?: "default" | "sm" | "lg"
}) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
className={cn(
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
className
)}
{...props}
/>
)
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn(
"aspect-square size-full rounded-full object-cover",
className
)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
className
)}
{...props}
/>
)
}
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="avatar-badge"
className={cn(
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
className
)}
{...props}
/>
)
}
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group"
className={cn(
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
className
)}
{...props}
/>
)
}
function AvatarGroupCount({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group-count"
className={cn(
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
className
)}
{...props}
/>
)
}
export {
Avatar,
AvatarImage,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
AvatarBadge,
}

View File

@@ -0,0 +1,67 @@
"use client"
import { cn } from "@/lib/utils"
import { StickToBottom } from "use-stick-to-bottom"
export type ChatContainerRootProps = {
children: React.ReactNode
className?: string
} & React.HTMLAttributes<HTMLDivElement>
export type ChatContainerContentProps = {
children: React.ReactNode
className?: string
} & React.HTMLAttributes<HTMLDivElement>
export type ChatContainerScrollAnchorProps = {
className?: string
ref?: React.RefObject<HTMLDivElement>
} & React.HTMLAttributes<HTMLDivElement>
function ChatContainerRoot({
children,
className,
...props
}: ChatContainerRootProps) {
return (
<StickToBottom
className={cn("flex overflow-y-auto", className)}
resize="smooth"
initial="instant"
role="log"
{...props}
>
{children}
</StickToBottom>
)
}
function ChatContainerContent({
children,
className,
...props
}: ChatContainerContentProps) {
return (
<StickToBottom.Content
className={cn("flex w-full flex-col", className)}
{...props}
>
{children}
</StickToBottom.Content>
)
}
function ChatContainerScrollAnchor({
className,
...props
}: ChatContainerScrollAnchorProps) {
return (
<div
className={cn("h-px w-full shrink-0 scroll-mt-4", className)}
aria-hidden="true"
{...props}
/>
)
}
export { ChatContainerRoot, ChatContainerContent, ChatContainerScrollAnchor }

View File

@@ -0,0 +1,94 @@
"use client"
import { cn } from "@/lib/utils"
import React, { useEffect, useState } from "react"
import { codeToHtml } from "shiki"
export type CodeBlockProps = {
children?: React.ReactNode
className?: string
} & React.HTMLProps<HTMLDivElement>
function CodeBlock({ children, className, ...props }: CodeBlockProps) {
return (
<div
className={cn(
"not-prose flex w-full flex-col overflow-clip border",
"border-border bg-card text-card-foreground rounded-xl",
className
)}
{...props}
>
{children}
</div>
)
}
export type CodeBlockCodeProps = {
code: string
language?: string
theme?: string
className?: string
} & React.HTMLProps<HTMLDivElement>
function CodeBlockCode({
code,
language = "tsx",
theme = "github-light",
className,
...props
}: CodeBlockCodeProps) {
const [highlightedHtml, setHighlightedHtml] = useState<string | null>(null)
useEffect(() => {
async function highlight() {
if (!code) {
setHighlightedHtml("<pre><code></code></pre>")
return
}
const html = await codeToHtml(code, { lang: language, theme })
setHighlightedHtml(html)
}
highlight()
}, [code, language, theme])
const classNames = cn(
"w-full overflow-x-auto text-[13px] [&>pre]:px-4 [&>pre]:py-4",
className
)
// SSR fallback: render plain code if not hydrated yet
return highlightedHtml ? (
<div
className={classNames}
dangerouslySetInnerHTML={{ __html: highlightedHtml }}
{...props}
/>
) : (
<div className={classNames} {...props}>
<pre>
<code>{code}</code>
</pre>
</div>
)
}
export type CodeBlockGroupProps = React.HTMLAttributes<HTMLDivElement>
function CodeBlockGroup({
children,
className,
...props
}: CodeBlockGroupProps) {
return (
<div
className={cn("flex items-center justify-between", className)}
{...props}
>
{children}
</div>
)
}
export { CodeBlockGroup, CodeBlockCode, CodeBlock }

View File

@@ -0,0 +1,499 @@
"use client"
import { cn } from "@/lib/utils"
import React from "react"
export interface LoaderProps {
variant?:
| "circular"
| "classic"
| "pulse"
| "pulse-dot"
| "dots"
| "typing"
| "wave"
| "bars"
| "terminal"
| "text-blink"
| "text-shimmer"
| "loading-dots"
size?: "sm" | "md" | "lg"
text?: string
className?: string
}
export function CircularLoader({
className,
size = "md",
}: {
className?: string
size?: "sm" | "md" | "lg"
}) {
const sizeClasses = {
sm: "size-4",
md: "size-5",
lg: "size-6",
}
return (
<div
className={cn(
"border-primary animate-spin rounded-full border-2 border-t-transparent",
sizeClasses[size],
className
)}
>
<span className="sr-only">Loading</span>
</div>
)
}
export function ClassicLoader({
className,
size = "md",
}: {
className?: string
size?: "sm" | "md" | "lg"
}) {
const sizeClasses = {
sm: "size-4",
md: "size-5",
lg: "size-6",
}
const barSizes = {
sm: { height: "6px", width: "1.5px" },
md: { height: "8px", width: "2px" },
lg: { height: "10px", width: "2.5px" },
}
return (
<div className={cn("relative", sizeClasses[size], className)}>
<div className="absolute h-full w-full">
{[...Array(12)].map((_, i) => (
<div
key={i}
className="bg-primary absolute animate-[spinner-fade_1.2s_linear_infinite] rounded-full"
style={{
top: "0",
left: "50%",
marginLeft:
size === "sm" ? "-0.75px" : size === "lg" ? "-1.25px" : "-1px",
transformOrigin: `${size === "sm" ? "0.75px" : size === "lg" ? "1.25px" : "1px"} ${size === "sm" ? "10px" : size === "lg" ? "14px" : "12px"}`,
transform: `rotate(${i * 30}deg)`,
opacity: 0,
animationDelay: `${i * 0.1}s`,
height: barSizes[size].height,
width: barSizes[size].width,
}}
/>
))}
</div>
<span className="sr-only">Loading</span>
</div>
)
}
export function PulseLoader({
className,
size = "md",
}: {
className?: string
size?: "sm" | "md" | "lg"
}) {
const sizeClasses = {
sm: "size-4",
md: "size-5",
lg: "size-6",
}
return (
<div className={cn("relative", sizeClasses[size], className)}>
<div className="border-primary absolute inset-0 animate-[thin-pulse_1.5s_ease-in-out_infinite] rounded-full border-2" />
<span className="sr-only">Loading</span>
</div>
)
}
export function PulseDotLoader({
className,
size = "md",
}: {
className?: string
size?: "sm" | "md" | "lg"
}) {
const sizeClasses = {
sm: "size-1",
md: "size-2",
lg: "size-3",
}
return (
<div
className={cn(
"bg-primary animate-[pulse-dot_1.2s_ease-in-out_infinite] rounded-full",
sizeClasses[size],
className
)}
>
<span className="sr-only">Loading</span>
</div>
)
}
export function DotsLoader({
className,
size = "md",
}: {
className?: string
size?: "sm" | "md" | "lg"
}) {
const dotSizes = {
sm: "h-1.5 w-1.5",
md: "h-2 w-2",
lg: "h-2.5 w-2.5",
}
const containerSizes = {
sm: "h-4",
md: "h-5",
lg: "h-6",
}
return (
<div
className={cn(
"flex items-center space-x-1",
containerSizes[size],
className
)}
>
{[...Array(3)].map((_, i) => (
<div
key={i}
className={cn(
"bg-primary animate-[bounce-dots_1.4s_ease-in-out_infinite] rounded-full",
dotSizes[size]
)}
style={{
animationDelay: `${i * 160}ms`,
}}
/>
))}
<span className="sr-only">Loading</span>
</div>
)
}
export function TypingLoader({
className,
size = "md",
}: {
className?: string
size?: "sm" | "md" | "lg"
}) {
const dotSizes = {
sm: "h-1 w-1",
md: "h-1.5 w-1.5",
lg: "h-2 w-2",
}
const containerSizes = {
sm: "h-4",
md: "h-5",
lg: "h-6",
}
return (
<div
className={cn(
"flex items-center space-x-1",
containerSizes[size],
className
)}
>
{[...Array(3)].map((_, i) => (
<div
key={i}
className={cn(
"bg-primary animate-[typing_1s_infinite] rounded-full",
dotSizes[size]
)}
style={{
animationDelay: `${i * 250}ms`,
}}
/>
))}
<span className="sr-only">Loading</span>
</div>
)
}
export function WaveLoader({
className,
size = "md",
}: {
className?: string
size?: "sm" | "md" | "lg"
}) {
const barWidths = {
sm: "w-0.5",
md: "w-0.5",
lg: "w-1",
}
const containerSizes = {
sm: "h-4",
md: "h-5",
lg: "h-6",
}
const heights = {
sm: ["6px", "9px", "12px", "9px", "6px"],
md: ["8px", "12px", "16px", "12px", "8px"],
lg: ["10px", "15px", "20px", "15px", "10px"],
}
return (
<div
className={cn(
"flex items-center gap-0.5",
containerSizes[size],
className
)}
>
{[...Array(5)].map((_, i) => (
<div
key={i}
className={cn(
"bg-primary animate-[wave_1s_ease-in-out_infinite] rounded-full",
barWidths[size]
)}
style={{
animationDelay: `${i * 100}ms`,
height: heights[size][i],
}}
/>
))}
<span className="sr-only">Loading</span>
</div>
)
}
export function BarsLoader({
className,
size = "md",
}: {
className?: string
size?: "sm" | "md" | "lg"
}) {
const barWidths = {
sm: "w-1",
md: "w-1.5",
lg: "w-2",
}
const containerSizes = {
sm: "h-4 gap-1",
md: "h-5 gap-1.5",
lg: "h-6 gap-2",
}
return (
<div className={cn("flex", containerSizes[size], className)}>
{[...Array(3)].map((_, i) => (
<div
key={i}
className={cn(
"bg-primary h-full animate-[wave-bars_1.2s_ease-in-out_infinite]",
barWidths[size]
)}
style={{
animationDelay: `${i * 0.2}s`,
}}
/>
))}
<span className="sr-only">Loading</span>
</div>
)
}
export function TerminalLoader({
className,
size = "md",
}: {
className?: string
size?: "sm" | "md" | "lg"
}) {
const cursorSizes = {
sm: "h-3 w-1.5",
md: "h-4 w-2",
lg: "h-5 w-2.5",
}
const textSizes = {
sm: "text-xs",
md: "text-sm",
lg: "text-base",
}
const containerSizes = {
sm: "h-4",
md: "h-5",
lg: "h-6",
}
return (
<div
className={cn(
"flex items-center space-x-1",
containerSizes[size],
className
)}
>
<span className={cn("text-primary font-mono", textSizes[size])}>
{">"}
</span>
<div
className={cn(
"bg-primary animate-[blink_1s_step-end_infinite]",
cursorSizes[size]
)}
/>
<span className="sr-only">Loading</span>
</div>
)
}
export function TextBlinkLoader({
text = "Thinking",
className,
size = "md",
}: {
text?: string
className?: string
size?: "sm" | "md" | "lg"
}) {
const textSizes = {
sm: "text-xs",
md: "text-sm",
lg: "text-base",
}
return (
<div
className={cn(
"animate-[text-blink_2s_ease-in-out_infinite] font-medium",
textSizes[size],
className
)}
>
{text}
</div>
)
}
export function TextShimmerLoader({
text = "Thinking",
className,
size = "md",
}: {
text?: string
className?: string
size?: "sm" | "md" | "lg"
}) {
const textSizes = {
sm: "text-xs",
md: "text-sm",
lg: "text-base",
}
return (
<div
className={cn(
"bg-[linear-gradient(to_right,var(--muted-foreground)_40%,var(--foreground)_60%,var(--muted-foreground)_80%)]",
"bg-size-[200%_auto] bg-clip-text font-medium text-transparent",
"animate-[shimmer_4s_infinite_linear]",
textSizes[size],
className
)}
>
{text}
</div>
)
}
export function TextDotsLoader({
className,
text = "Thinking",
size = "md",
}: {
className?: string
text?: string
size?: "sm" | "md" | "lg"
}) {
const textSizes = {
sm: "text-xs",
md: "text-sm",
lg: "text-base",
}
return (
<div
className={cn("inline-flex items-center", className)}
>
<span className={cn("text-primary font-medium", textSizes[size])}>
{text}
</span>
<span className="inline-flex">
<span className="text-primary animate-[loading-dots_1.4s_infinite_0.2s]">
.
</span>
<span className="text-primary animate-[loading-dots_1.4s_infinite_0.4s]">
.
</span>
<span className="text-primary animate-[loading-dots_1.4s_infinite_0.6s]">
.
</span>
</span>
</div>
)
}
function Loader({
variant = "circular",
size = "md",
text,
className,
}: LoaderProps) {
switch (variant) {
case "circular":
return <CircularLoader size={size} className={className} />
case "classic":
return <ClassicLoader size={size} className={className} />
case "pulse":
return <PulseLoader size={size} className={className} />
case "pulse-dot":
return <PulseDotLoader size={size} className={className} />
case "dots":
return <DotsLoader size={size} className={className} />
case "typing":
return <TypingLoader size={size} className={className} />
case "wave":
return <WaveLoader size={size} className={className} />
case "bars":
return <BarsLoader size={size} className={className} />
case "terminal":
return <TerminalLoader size={size} className={className} />
case "text-blink":
return <TextBlinkLoader text={text} size={size} className={className} />
case "text-shimmer":
return <TextShimmerLoader text={text} size={size} className={className} />
case "loading-dots":
return <TextDotsLoader text={text} size={size} className={className} />
default:
return <CircularLoader size={size} className={className} />
}
}
export { Loader }

View File

@@ -0,0 +1,110 @@
import { cn } from "@/lib/utils"
import { marked } from "marked"
import { memo, useId, useMemo } from "react"
import ReactMarkdown, { Components } from "react-markdown"
import remarkBreaks from "remark-breaks"
import remarkGfm from "remark-gfm"
import { CodeBlock, CodeBlockCode } from "./code-block"
export type MarkdownProps = {
children: string
id?: string
className?: string
components?: Partial<Components>
}
function parseMarkdownIntoBlocks(markdown: string): string[] {
const tokens = marked.lexer(markdown)
return tokens.map((token) => token.raw)
}
function extractLanguage(className?: string): string {
if (!className) return "plaintext"
const match = className.match(/language-(\w+)/)
return match ? match[1] : "plaintext"
}
const INITIAL_COMPONENTS: Partial<Components> = {
code: function CodeComponent({ className, children, ...props }) {
const isInline =
!props.node?.position?.start.line ||
props.node?.position?.start.line === props.node?.position?.end.line
if (isInline) {
return (
<span
className={cn(
"bg-primary-foreground rounded-sm px-1 font-mono text-sm",
className
)}
{...props}
>
{children}
</span>
)
}
const language = extractLanguage(className)
return (
<CodeBlock className={className}>
<CodeBlockCode code={children as string} language={language} />
</CodeBlock>
)
},
pre: function PreComponent({ children }) {
return <>{children}</>
},
}
const MemoizedMarkdownBlock = memo(
function MarkdownBlock({
content,
components = INITIAL_COMPONENTS,
}: {
content: string
components?: Partial<Components>
}) {
return (
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkBreaks]}
components={components}
>
{content}
</ReactMarkdown>
)
},
function propsAreEqual(prevProps, nextProps) {
return prevProps.content === nextProps.content
}
)
MemoizedMarkdownBlock.displayName = "MemoizedMarkdownBlock"
function MarkdownComponent({
children,
id,
className,
components = INITIAL_COMPONENTS,
}: MarkdownProps) {
const generatedId = useId()
const blockId = id ?? generatedId
const blocks = useMemo(() => parseMarkdownIntoBlocks(children), [children])
return (
<div className={className}>
{blocks.map((block, index) => (
<MemoizedMarkdownBlock
key={`${blockId}-block-${index}`}
content={block}
components={components}
/>
))}
</div>
)
}
const Markdown = memo(MarkdownComponent)
Markdown.displayName = "Markdown"
export { Markdown }

View File

@@ -0,0 +1,120 @@
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"
import { Markdown } from "./markdown"
export type MessageProps = {
children: React.ReactNode
className?: string
} & React.HTMLProps<HTMLDivElement>
const Message = ({ children, className, ...props }: MessageProps) => (
<div className={cn("flex gap-3", className)} {...props}>
{children}
</div>
)
export type MessageAvatarProps = {
src: string
alt: string
fallback?: string
delayMs?: number
className?: string
}
const MessageAvatar = ({
src,
alt,
fallback,
delayMs,
className,
}: MessageAvatarProps) => {
return (
<Avatar className={cn("h-8 w-8 shrink-0", className)}>
<AvatarImage src={src} alt={alt} />
{fallback && (
<AvatarFallback delayMs={delayMs}>{fallback}</AvatarFallback>
)}
</Avatar>
)
}
export type MessageContentProps = {
children: React.ReactNode
markdown?: boolean
className?: string
} & React.ComponentProps<typeof Markdown> &
React.HTMLProps<HTMLDivElement>
const MessageContent = ({
children,
markdown = false,
className,
...props
}: MessageContentProps) => {
const classNames = cn(
"rounded-lg p-2 text-foreground bg-secondary prose break-words whitespace-normal",
className
)
return markdown ? (
<Markdown className={classNames} {...props}>
{children as string}
</Markdown>
) : (
<div className={classNames} {...props}>
{children}
</div>
)
}
export type MessageActionsProps = {
children: React.ReactNode
className?: string
} & React.HTMLProps<HTMLDivElement>
const MessageActions = ({
children,
className,
...props
}: MessageActionsProps) => (
<div
className={cn("text-muted-foreground flex items-center gap-2", className)}
{...props}
>
{children}
</div>
)
export type MessageActionProps = {
className?: string
tooltip: React.ReactNode
children: React.ReactNode
side?: "top" | "bottom" | "left" | "right"
} & React.ComponentProps<typeof Tooltip>
const MessageAction = ({
tooltip,
children,
className,
side = "top",
...props
}: MessageActionProps) => {
return (
<TooltipProvider>
<Tooltip {...props}>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent side={side} className={className}>
{tooltip}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
export { Message, MessageAvatar, MessageContent, MessageActions, MessageAction }

View File

@@ -0,0 +1,233 @@
"use client"
import { Textarea } from "@/components/ui/textarea"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"
import React, {
createContext,
useContext,
useLayoutEffect,
useRef,
useState,
} from "react"
type PromptInputContextType = {
isLoading: boolean
value: string
setValue: (value: string) => void
maxHeight: number | string
onSubmit?: () => void
disabled?: boolean
textareaRef: React.RefObject<HTMLTextAreaElement | null>
}
const PromptInputContext = createContext<PromptInputContextType>({
isLoading: false,
value: "",
setValue: () => {},
maxHeight: 240,
onSubmit: undefined,
disabled: false,
textareaRef: React.createRef<HTMLTextAreaElement>(),
})
function usePromptInput() {
return useContext(PromptInputContext)
}
export type PromptInputProps = {
isLoading?: boolean
value?: string
onValueChange?: (value: string) => void
maxHeight?: number | string
onSubmit?: () => void
children: React.ReactNode
className?: string
disabled?: boolean
} & React.ComponentProps<"div">
function PromptInput({
className,
isLoading = false,
maxHeight = 240,
value,
onValueChange,
onSubmit,
children,
disabled = false,
onClick,
...props
}: PromptInputProps) {
const [internalValue, setInternalValue] = useState(value || "")
const textareaRef = useRef<HTMLTextAreaElement>(null)
const handleChange = (newValue: string) => {
setInternalValue(newValue)
onValueChange?.(newValue)
}
const handleClick: React.MouseEventHandler<HTMLDivElement> = (e) => {
if (!disabled) textareaRef.current?.focus()
onClick?.(e)
}
return (
<TooltipProvider>
<PromptInputContext.Provider
value={{
isLoading,
value: value ?? internalValue,
setValue: onValueChange ?? handleChange,
maxHeight,
onSubmit,
disabled,
textareaRef,
}}
>
<div
onClick={handleClick}
className={cn(
"border-input bg-background cursor-text rounded-3xl border p-2 shadow-xs",
disabled && "cursor-not-allowed opacity-60",
className
)}
{...props}
>
{children}
</div>
</PromptInputContext.Provider>
</TooltipProvider>
)
}
export type PromptInputTextareaProps = {
disableAutosize?: boolean
} & React.ComponentProps<typeof Textarea>
function PromptInputTextarea({
className,
onKeyDown,
disableAutosize = false,
...props
}: PromptInputTextareaProps) {
const { value, setValue, maxHeight, onSubmit, disabled, textareaRef } =
usePromptInput()
const adjustHeight = (el: HTMLTextAreaElement | null) => {
if (!el || disableAutosize) return
el.style.height = "auto"
if (typeof maxHeight === "number") {
el.style.height = `${Math.min(el.scrollHeight, maxHeight)}px`
} else {
el.style.height = `min(${el.scrollHeight}px, ${maxHeight})`
}
}
const handleRef = (el: HTMLTextAreaElement | null) => {
textareaRef.current = el
adjustHeight(el)
}
useLayoutEffect(() => {
if (!textareaRef.current || disableAutosize) return
const el = textareaRef.current
el.style.height = "auto"
if (typeof maxHeight === "number") {
el.style.height = `${Math.min(el.scrollHeight, maxHeight)}px`
} else {
el.style.height = `min(${el.scrollHeight}px, ${maxHeight})`
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value, maxHeight, disableAutosize])
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
adjustHeight(e.target)
setValue(e.target.value)
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
onSubmit?.()
}
onKeyDown?.(e)
}
return (
<Textarea
ref={handleRef}
value={value}
onChange={handleChange}
onKeyDown={handleKeyDown}
className={cn(
"text-primary min-h-[44px] w-full resize-none border-none bg-transparent shadow-none outline-none focus-visible:ring-0 focus-visible:ring-offset-0",
className
)}
rows={1}
disabled={disabled}
{...props}
/>
)
}
export type PromptInputActionsProps = React.HTMLAttributes<HTMLDivElement>
function PromptInputActions({
children,
className,
...props
}: PromptInputActionsProps) {
return (
<div className={cn("flex items-center gap-2", className)} {...props}>
{children}
</div>
)
}
export type PromptInputActionProps = {
className?: string
tooltip: React.ReactNode
children: React.ReactNode
side?: "top" | "bottom" | "left" | "right"
} & React.ComponentProps<typeof Tooltip>
function PromptInputAction({
tooltip,
children,
className,
side = "top",
...props
}: PromptInputActionProps) {
const { disabled } = usePromptInput()
return (
<Tooltip {...props}>
<TooltipTrigger
asChild
disabled={disabled}
onClick={(event) => event.stopPropagation()}
>
{children}
</TooltipTrigger>
<TooltipContent side={side} className={className}>
{tooltip}
</TooltipContent>
</Tooltip>
)
}
export {
PromptInput,
PromptInputTextarea,
PromptInputActions,
PromptInputAction,
}

View File

@@ -0,0 +1,117 @@
"use client"
import { Button, buttonVariants } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import { VariantProps } from "class-variance-authority"
export type PromptSuggestionProps = {
children: React.ReactNode
variant?: VariantProps<typeof buttonVariants>["variant"]
size?: VariantProps<typeof buttonVariants>["size"]
className?: string
highlight?: string
} & React.ButtonHTMLAttributes<HTMLButtonElement>
function PromptSuggestion({
children,
variant,
size,
className,
highlight,
...props
}: PromptSuggestionProps) {
const isHighlightMode = highlight !== undefined && highlight.trim() !== ""
const content = typeof children === "string" ? children : ""
if (!isHighlightMode) {
return (
<Button
variant={variant || "outline"}
size={size || "lg"}
className={cn("rounded-full", className)}
{...props}
>
{children}
</Button>
)
}
if (!content) {
return (
<Button
variant={variant || "ghost"}
size={size || "sm"}
className={cn(
"w-full cursor-pointer justify-start rounded-xl py-2",
"hover:bg-accent",
className
)}
{...props}
>
{children}
</Button>
)
}
const trimmedHighlight = highlight.trim()
const contentLower = content.toLowerCase()
const highlightLower = trimmedHighlight.toLowerCase()
const shouldHighlight = contentLower.includes(highlightLower)
return (
<Button
variant={variant || "ghost"}
size={size || "sm"}
className={cn(
"w-full cursor-pointer justify-start gap-0 rounded-xl py-2",
"hover:bg-accent",
className
)}
{...props}
>
{shouldHighlight ? (
(() => {
const index = contentLower.indexOf(highlightLower)
if (index === -1)
return (
<span className="text-muted-foreground whitespace-pre-wrap">
{content}
</span>
)
const actualHighlightedText = content.substring(
index,
index + highlightLower.length
)
const before = content.substring(0, index)
const after = content.substring(index + actualHighlightedText.length)
return (
<>
{before && (
<span className="text-muted-foreground whitespace-pre-wrap">
{before}
</span>
)}
<span className="text-primary font-medium whitespace-pre-wrap">
{actualHighlightedText}
</span>
{after && (
<span className="text-muted-foreground whitespace-pre-wrap">
{after}
</span>
)}
</>
)
})()
) : (
<span className="text-muted-foreground whitespace-pre-wrap">
{content}
</span>
)}
</Button>
)
}
export { PromptSuggestion }

View File

@@ -0,0 +1,42 @@
"use client"
import { Button, buttonVariants } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import { type VariantProps } from "class-variance-authority"
import { ChevronDown } from "lucide-react"
import { useStickToBottomContext } from "use-stick-to-bottom"
export type ScrollButtonProps = {
className?: string
variant?: VariantProps<typeof buttonVariants>["variant"]
size?: VariantProps<typeof buttonVariants>["size"]
} & React.ButtonHTMLAttributes<HTMLButtonElement>
function ScrollButton({
className,
variant = "outline",
size = "sm",
...props
}: ScrollButtonProps) {
const { isAtBottom, scrollToBottom } = useStickToBottomContext()
return (
<Button
variant={variant}
size={size}
className={cn(
"h-10 w-10 rounded-full transition-all duration-150 ease-out",
!isAtBottom
? "translate-y-0 scale-100 opacity-100"
: "pointer-events-none translate-y-4 scale-95 opacity-0",
className
)}
onClick={() => scrollToBottom()}
{...props}
>
<ChevronDown className="h-5 w-5" />
</Button>
)
}
export { ScrollButton }

View File

@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }

View File

@@ -0,0 +1,57 @@
"use client"
import * as React from "react"
import { Tooltip as TooltipPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"z-50 inline-flex w-fit max-w-xs origin-(--radix-tooltip-content-transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }

View File

@@ -0,0 +1,243 @@
"use client"
import {
useEffect,
useMemo,
useRef,
useState,
type ComponentType,
type RefAttributes,
type RefObject,
} from "react"
import {
motion,
useInView,
useReducedMotion,
type DOMMotionComponents,
type HTMLMotionProps,
type MotionProps,
} from "motion/react"
import { cn } from "@/lib/utils"
const motionElements = {
article: motion.article,
div: motion.div,
h1: motion.h1,
h2: motion.h2,
h3: motion.h3,
h4: motion.h4,
h5: motion.h5,
h6: motion.h6,
li: motion.li,
p: motion.p,
section: motion.section,
span: motion.span,
} as const
type MotionElementType = Extract<
keyof DOMMotionComponents,
keyof typeof motionElements
>
type TypingAnimationMotionComponent = ComponentType<
Omit<HTMLMotionProps<"span">, "ref"> & RefAttributes<HTMLElement>
>
interface TypingAnimationProps extends Omit<MotionProps, "children"> {
children?: string
words?: string[]
className?: string
duration?: number
typeSpeed?: number
deleteSpeed?: number
delay?: number
pauseDelay?: number
loop?: boolean
as?: MotionElementType
startOnView?: boolean
showCursor?: boolean
blinkCursor?: boolean
cursorStyle?: "line" | "block" | "underscore"
}
export function TypingAnimation({
children,
words,
className,
duration = 100,
typeSpeed,
deleteSpeed,
delay = 0,
pauseDelay = 1000,
loop = false,
as: Component = "span",
startOnView = true,
showCursor = true,
blinkCursor = true,
cursorStyle = "line",
...props
}: TypingAnimationProps) {
const MotionComponent = motionElements[
Component
] as TypingAnimationMotionComponent
const [displayedText, setDisplayedText] = useState<string>("")
const [currentWordIndex, setCurrentWordIndex] = useState(0)
const [currentCharIndex, setCurrentCharIndex] = useState(0)
const [phase, setPhase] = useState<"typing" | "pause" | "deleting">("typing")
// Sürekli yazıp silen döngü, hareket azaltılsın diyen kullanıcı için
// rahatsız edici: ilk metin tam ve sabit gösterilir, imleç yanmaz.
const azMotion = useReducedMotion()
const elementRef = useRef<HTMLElement | null>(null)
const isInView = useInView(elementRef as RefObject<Element>, {
amount: 0.3,
once: true,
})
const wordsToAnimate = useMemo(
() => words ?? (children ? [children] : []),
[words, children]
)
const hasMultipleWords = wordsToAnimate.length > 1
const typingSpeed = typeSpeed ?? duration
const deletingSpeed = deleteSpeed ?? typingSpeed / 2
const shouldStart = startOnView ? isInView : true
const animationSourceKey = useMemo(
() => (words ? words.join("\u0000") : (children ?? "")),
[words, children]
)
// Kaynak metin değişince animasyon baştan başlar — bilinçli reset
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setDisplayedText("")
setCurrentWordIndex(0)
setCurrentCharIndex(0)
setPhase("typing")
}, [animationSourceKey])
useEffect(() => {
let timeout: ReturnType<typeof setTimeout> | null = null
if (!azMotion && shouldStart && wordsToAnimate.length > 0) {
const timeoutDelay =
delay > 0 && displayedText === ""
? delay
: phase === "typing"
? typingSpeed
: phase === "deleting"
? deletingSpeed
: pauseDelay
timeout = setTimeout(() => {
const currentWord = wordsToAnimate[currentWordIndex] || ""
const graphemes = Array.from(currentWord)
switch (phase) {
case "typing":
if (currentCharIndex < graphemes.length) {
setDisplayedText(
graphemes.slice(0, currentCharIndex + 1).join("")
)
setCurrentCharIndex(currentCharIndex + 1)
} else {
if (hasMultipleWords || loop) {
const isLastWord =
currentWordIndex === wordsToAnimate.length - 1
if (!isLastWord || loop) {
setPhase("pause")
}
}
}
break
case "pause":
setPhase("deleting")
break
case "deleting":
if (currentCharIndex > 0) {
setDisplayedText(
graphemes.slice(0, currentCharIndex - 1).join("")
)
setCurrentCharIndex(currentCharIndex - 1)
} else {
const nextIndex = (currentWordIndex + 1) % wordsToAnimate.length
setCurrentWordIndex(nextIndex)
setPhase("typing")
}
break
}
}, timeoutDelay)
}
return () => {
if (timeout !== null) {
clearTimeout(timeout)
}
}
}, [
azMotion,
shouldStart,
phase,
currentCharIndex,
currentWordIndex,
displayedText,
wordsToAnimate,
hasMultipleWords,
loop,
typingSpeed,
deletingSpeed,
pauseDelay,
delay,
])
const currentWordGraphemes = Array.from(
wordsToAnimate[currentWordIndex] || ""
)
const isComplete =
!loop &&
currentWordIndex === wordsToAnimate.length - 1 &&
currentCharIndex >= currentWordGraphemes.length &&
phase !== "deleting"
const shouldShowCursor =
showCursor &&
!isComplete &&
(hasMultipleWords || loop || currentCharIndex < currentWordGraphemes.length)
const getCursorChar = () => {
switch (cursorStyle) {
case "block":
return "▌"
case "underscore":
return "_"
case "line":
default:
return "|"
}
}
return (
<MotionComponent
ref={elementRef}
className={cn(
"leading-20 tracking-[-0.02em]",
Component === "span" && "inline-block",
className
)}
{...props}
>
{azMotion ? (wordsToAnimate[0] ?? "") : displayedText}
{shouldShowCursor && !azMotion && (
<span
className={cn("inline-block", blinkCursor && "animate-blink-cursor")}
>
{getCursorChar()}
</span>
)}
</MotionComponent>
)
}

View File

@@ -2,24 +2,30 @@ import Link from "next/link";
import { Coins } from "lucide-react"; import { Coins } from "lucide-react";
import { getCurrentUser } from "@/lib/session"; import { getCurrentUser } from "@/lib/session";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ListemButonu } from "@/components/manuel-liste/listem-butonu";
import { SignOutButton } from "./sign-out-button"; import { SignOutButton } from "./sign-out-button";
export async function UserNav() { export async function UserNav() {
const u = await getCurrentUser(); const u = await getCurrentUser();
// Manuel liste localStorage'ta yaşadığı için Listem girişsiz de çalışır
if (!u) { if (!u) {
return ( return (
<Button <div className="flex items-center gap-2">
asChild <ListemButonu />
className="h-12 cursor-pointer rounded-full bg-slate-900 px-7 text-base font-medium text-white transition-colors duration-200 hover:bg-slate-700" <Button
> asChild
<Link href="/giris">Giriş yap</Link> className="h-12 cursor-pointer rounded-full bg-slate-900 px-7 text-base font-medium text-white transition-colors duration-200 hover:bg-slate-700"
</Button> >
<Link href="/giris">Giriş yap</Link>
</Button>
</div>
); );
} }
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ListemButonu />
<Link <Link
href="/paket" href="/paket"
className="inline-flex h-12 items-center gap-2 whitespace-nowrap rounded-full border border-slate-200 bg-white px-5 text-sm font-semibold text-slate-700 transition-colors duration-200 hover:border-slate-300 hover:bg-slate-50" className="inline-flex h-12 items-center gap-2 whitespace-nowrap rounded-full border border-slate-200 bg-white px-5 text-sm font-semibold text-slate-700 transition-colors duration-200 hover:border-slate-300 hover:bg-slate-50"

View File

@@ -9,6 +9,7 @@ import {
type AdayProgram, type AdayProgram,
} from "../rapor-havuzu"; } from "../rapor-havuzu";
import { secimOzeti } from "../sihirbaz"; import { secimOzeti } from "../sihirbaz";
import { RISK_ETIKET, dilimdenRisk, riskHesapla } from "../risk";
const TercihSchema = z.object({ const TercihSchema = z.object({
programId: z.string(), programId: z.string(),
@@ -22,13 +23,25 @@ const TercihSchema = z.object({
const RaporSchema = z.object({ const RaporSchema = z.object({
tercihler: z.array(TercihSchema), tercihler: z.array(TercihSchema),
genelDegerlendirme: z.string(), genelDegerlendirme: z.string(),
uyarilar: z.array(z.string()).min(2).max(3),
}); });
export type RaporSonuc = z.infer<typeof RaporSchema> & { // uyarilar şemada zorunlu ama eski kayıtlı raporlarda yok → tipte opsiyonel
export type RaporSonuc = Omit<z.infer<typeof RaporSchema>, "uyarilar"> & {
uyarilar?: string[];
// 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
// siraGecmisi: [2021..2025] taban sıralamaları — deterministik trend oku
programlar: Record< programlar: Record<
string, string,
{ isim: string; universite: string; il: string | null; unitur: string | null } {
isim: string;
universite: string;
il: string | null;
unitur: string | null;
efektifSira?: number | null;
siraGecmisi?: (number | null)[];
}
>; >;
}; };
@@ -42,17 +55,104 @@ KURALLAR (kesin):
- Liste yapısı: en üstte 4-6 hayal, ortada 12-14 dengeli, sonda 5-7 garanti. - Liste yapısı: en üstte 4-6 hayal, ortada 12-14 dengeli, sonda 5-7 garanti.
Tercih sırası MUTLAKA iyi taban sıralamasından kötüye doğru gitmeli Tercih sırası MUTLAKA iyi taban sıralamasından kötüye doğru gitmeli
(küçük taban sıralaması = daha zor bölüm üstte). (küçük taban sıralaması = daha zor bölüm üstte).
- gerekce: bu programın bu aday için neden mantıklı olduğu (1-2 cümle, Türkçe). - METİN DİYETİ: aday 24 satır okuyacak; her alan TEK kısa cümle, dolgu söz yok.
- riskNotu: yerleşme ihtimali değerlendirmesi. ASLA "garanti", "kesin", - gerekce: bu programın bu aday için neden mantıklı olduğu (TEK cümle,
"yüzde yüz" deme; "yüksek/orta/düşük ihtimal" dili kullan. en fazla ~15 kelime, Türkçe).
- trendOzeti: son 4 yılın taban sıralaması hareketi (ör. "2022'den beri taban - riskNotu: yerleşme ihtimali değerlendirmesi (TEK kısa cümle). ASLA "garanti",
sıralaması 45B'den 38B'ye indi, bölüm zorlaşıyor"). "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. - 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 + adaya 2-3 pratik uyarı (Türkçe).`; - genelDegerlendirme: listenin genel stratejisi, EN FAZLA 2 cümle. Uyarıları
buraya yazma.
- uyarilar: adaya 2-3 pratik, eyleme dönük uyarı; her biri tek kısa cümle.`;
export class RaporUretimHatasi extends Error {} export class RaporUretimHatasi extends Error {}
/** Havuz programını render için gereken bilgiye indirger. */
function programBilgisi(p: AdayProgram): RaporSonuc["programlar"][string] {
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,
};
}
// --- Dev mock: API key yokken (yalnızca development) gerçek havuzdan sahte
// ama tutarlı bir rapor üret. Akış (kredi, blur, risk renkleri, revizyon)
// gerçek AI olmadan uçtan uca test edilebilsin diye. Prod'da asla çalışmaz.
function mockAktif(): boolean {
return (
process.env.NODE_ENV !== "production" &&
!process.env.ANTHROPIC_API_KEY &&
!process.env.ANTHROPIC_AUTH_TOKEN
);
}
function mockRapor(
havuz: AdayProgram[],
params: RaporParams,
revizyonNotu?: string,
): RaporSonuc {
const efektif = (p: AdayProgram) => p.sira2025 ?? p.sira2024 ?? 0;
const dilimden = (dilim: AdayProgram["dilim"], adet: number) =>
havuz
.filter((p) => p.dilim === dilim)
.sort((a, b) => efektif(a) - efektif(b))
.slice(0, adet);
// 5 hayal + 13 dengeli + 6 garanti; eksik dilim varsa havuzdan tamamla
const secilen = [
...dilimden("hayal", 5),
...dilimden("dengeli", 13),
...dilimden("garanti", 6),
];
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) => ({
programId: p.id,
sira: i + 1,
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.`,
riskNotu:
p.dilim === "hayal"
? "(Dev mock) Taban sıralaması senin üstünde kapandı; düşük ihtimal ama listenin başında yeri var."
: 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"] = {};
for (const p of secilen.slice(0, 24)) {
programlar[p.id] = programBilgisi(p);
}
return {
tercihler,
genelDegerlendirme: `(Dev mock — ANTHROPIC_API_KEY tanımlı değil, bu rapor gerçek AI çıktısı değildir.) ${params.sira.toLocaleString(
"tr-TR",
)}. sıra için hayal/dengeli/garanti dağılımlı 24 tercihlik örnek liste kuruldu.${
revizyonNotu ? ` Revizyon isteğin not alındı: "${revizyonNotu}".` : ""
}`,
uyarilar: [
"(Dev mock) Tercih dönemi kapanmadan listeyi ÖSYM sistemine kendin gir.",
"(Dev mock) Garanti dilimindeki son 3 tercihi asla silme.",
],
programlar,
};
}
export async function raporUret( export async function raporUret(
params: RaporParams, params: RaporParams,
revizyonNotu?: string, revizyonNotu?: string,
@@ -83,6 +183,12 @@ export async function raporUret(
); );
} }
// Dev'de API key yoksa gerçek AI yerine mock rapor (üretim gecikmesi taklidi)
if (mockAktif()) {
await new Promise((r) => setTimeout(r, 1500));
return mockRapor(havuz, params, revizyonNotu);
}
const client = getAnthropic(); const client = getAnthropic();
const havuzJson = havuzuKompaktJson(havuz); const havuzJson = havuzuKompaktJson(havuz);
const kullaniciMesaji = [ const kullaniciMesaji = [
@@ -157,22 +263,24 @@ export async function raporUret(
const programlar: RaporSonuc["programlar"] = {}; const programlar: RaporSonuc["programlar"] = {};
for (const p of havuz) { for (const p of havuz) {
programlar[p.id] = { programlar[p.id] = programBilgisi(p);
isim: p.isim,
universite: p.universite,
il: p.il,
unitur: p.unitur,
};
} }
return { ...sonuc, programlar }; return { ...sonuc, programlar };
} }
export function listeOzetiCikar(rapor: RaporSonuc): string { export function listeOzetiCikar(rapor: RaporSonuc, adaySira?: number): string {
return rapor.tercihler return rapor.tercihler
.map( .map((t) => {
(t) => const p = rapor.programlar[t.programId];
`${t.sira}. [${t.dilim}] ${rapor.programlar[t.programId]?.isim ?? "?"}${rapor.programlar[t.programId]?.universite ?? "?"}`, const risk =
) (adaySira != null ? riskHesapla(p?.efektifSira, adaySira) : null) ??
dilimdenRisk(t.dilim);
const taban =
p?.efektifSira != null
? ` (taban sırası ~${p.efektifSira.toLocaleString("tr-TR")})`
: "";
return `${t.sira}. [${t.dilim} | ${RISK_ETIKET[risk]}] ${p?.isim ?? "?"}${p?.universite ?? "?"}${taban}`;
})
.join("\n"); .join("\n");
} }

View File

@@ -5,6 +5,11 @@ import { appDb, schema } from "./appdb";
import { grantCredits, DENEME_KREDISI } from "./credits"; import { grantCredits, DENEME_KREDISI } from "./credits";
async function sendMagicLinkEmail(email: string, url: string) { async function sendMagicLinkEmail(email: string, url: string) {
if (process.env.NODE_ENV !== "production") {
// Dev paneli "anında giriş" için son üretilen linki yakalar (bkz.
// src/lib/dev/actions.ts) — prod'da asla çalışmaz.
(globalThis as { __sonMagicLink?: string }).__sonMagicLink = 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${url}\n`);

View File

@@ -13,6 +13,9 @@ export const DENEME_KREDISI = 5;
// Rapor üretimi/revizyonu başına düşen kredi // Rapor üretimi/revizyonu başına düşen kredi
export const RAPOR_KREDI = 3; export const RAPOR_KREDI = 3;
/** Paketle gelen geri bildirimli liste revizyonu hakkı */
export const MAX_REVIZYON = 2;
export type SpendResult = export type SpendResult =
| { ok: true } | { ok: true }
| { ok: false; error: "INSUFFICIENT" | "DUPLICATE" }; | { ok: false; error: "INSUFFICIENT" | "DUPLICATE" };

127
src/lib/dev/actions.ts Normal file
View File

@@ -0,0 +1,127 @@
"use server";
// Dev süperadmin aksiyonları: yalnızca geliştirme ortamında çalışır.
// Panelden (src/components/dev/dev-panel.tsx) çağrılır; kullanıcı durumunu
// (kredi, paket, rapor) kurcalayıp tüm sayfaları gerçek akışta gezmeyi sağlar.
import { eq } from "drizzle-orm";
import { headers } from "next/headers";
import { appDb, schema } from "@/lib/appdb";
import { auth } from "@/lib/auth";
import { getCurrentUser } from "@/lib/session";
import { URUNLER } from "@/lib/credits";
function devKontrol() {
if (process.env.NODE_ENV === "production") {
throw new Error("Dev paneli yalnızca geliştirme ortamında çalışır.");
}
}
export type DevDurum =
| { girisli: false }
| {
girisli: true;
email: string;
kredi: number;
hasPaket: boolean;
raporVar: boolean;
sira: number | null;
tur: string | null;
};
export async function devDurum(): Promise<DevDurum> {
devKontrol();
const user = await getCurrentUser();
if (!user) return { girisli: false };
const rapor = await appDb.query.reports.findFirst({
where: eq(schema.reports.userId, user.id),
columns: { params: true },
});
const params = rapor?.params as { sira?: number; tur?: string } | undefined;
return {
girisli: true,
email: user.email,
kredi: user.creditBalance,
hasPaket: user.hasPaket,
raporVar: Boolean(rapor),
sira: params?.sira ?? null,
tur: params?.tur ?? null,
};
}
/** Magic link üretir ve (dev'de auth.ts'in yakaladığı) URL'i döner —
* panel bu URL'e giderek anında oturum açar; yeni e-posta = yeni kullanıcı. */
export async function devAnindaGiris(
email: string,
): Promise<{ ok: true; url: string } | { ok: false; error: string }> {
devKontrol();
const temiz = email.trim().toLowerCase();
if (!/^\S+@\S+\.\S+$/.test(temiz)) {
return { ok: false, error: "Geçerli bir e-posta gir." };
}
const g = globalThis as { __sonMagicLink?: string };
g.__sonMagicLink = undefined;
await auth.api.signInMagicLink({
body: { email: temiz, callbackURL: "/" },
headers: await headers(),
});
if (!g.__sonMagicLink) {
return { ok: false, error: "Magic link üretilemedi (auth.ts yakalayamadı)." };
}
return { ok: true, url: g.__sonMagicLink };
}
export async function devAyarla(ayar: {
kredi?: number;
hasPaket?: boolean;
}): Promise<{ ok: boolean; error?: string }> {
devKontrol();
const user = await getCurrentUser();
if (!user) return { ok: false, error: "Önce giriş yap." };
await appDb
.update(schema.user)
.set({
...(typeof ayar.kredi === "number"
? { creditBalance: Math.max(0, Math.round(ayar.kredi)) }
: {}),
...(typeof ayar.hasPaket === "boolean"
? { hasPaket: ayar.hasPaket }
: {}),
})
.where(eq(schema.user.id, user.id));
return { ok: true };
}
/** Raporu ve sohbet geçmişini siler — boş durumları test etmek için. */
export async function devRaporSil(): Promise<{ ok: boolean; error?: string }> {
devKontrol();
const user = await getCurrentUser();
if (!user) return { ok: false, error: "Önce giriş yap." };
await appDb
.delete(schema.reports)
.where(eq(schema.reports.userId, user.id));
await appDb
.delete(schema.chatMessages)
.where(eq(schema.chatMessages.userId, user.id));
return { ok: true };
}
/** Ödenmiş sahte sipariş oluşturur — /odeme/sonuc başarı ekranı için. */
export async function devSahteOdeme(): Promise<
{ ok: true; siparis: string } | { ok: false; error: string }
> {
devKontrol();
const user = await getCurrentUser();
if (!user) return { ok: false, error: "Önce giriş yap." };
const id = `dev-${crypto.randomUUID()}`;
await appDb.insert(schema.orders).values({
id,
userId: user.id,
product: "paket",
amountKurus: URUNLER.paket.amountKurus,
credits: URUNLER.paket.credits,
status: "paid",
createdAt: new Date(),
});
return { ok: true, siparis: id };
}

61
src/lib/harita.ts Normal file
View File

@@ -0,0 +1,61 @@
// turkey-map-react SVG uzayı (viewBox 0 0 1050 585) ile coğrafi koordinatlar
// arasındaki dönüşüm ve üniversite konum çözümü.
//
// Afin katsayılar, 81 il path'inin bbox merkezleri ile il merkezlerinin
// (lat, lng) değerleri arasında en küçük kareler uyumuyla hesaplanıp
// sabitlendi; ölçek doğrulaması: ~0.6 px/km her iki eksende de tutarlı.
import { UNI_KOORDINAT, IL_MERKEZ, type LatLng } from "./uni-koordinatlar";
export const HARITA_GENISLIK = 1050;
export const HARITA_YUKSEKLIK = 585;
/** Ülke çizimi y≈144'te başlıyor; üstteki ölü bant her haritada kırpılır. */
export const HARITA_UST_KIRPMA = 138;
export function latLngToXy([lat, lng]: LatLng): { x: number; y: number } {
return {
x: 52.5215 * lng - 1332.1743,
y: -65.4185 * lat + 2917.4061,
};
}
/** Harita il adını DB formatına çevirir: "Hakkâri" → "HAKKARİ". */
export function normalizeIlAdi(name: string): string {
return name
.toLocaleUpperCase("tr-TR")
.replace(/Â/g, "A")
.replace(/Î/g, "İ")
.replace(/Û/g, "U");
}
/** DB'deki üniversite adının "(İL)" ekini ayıklar. */
export function uniAdiNormalize(ad: string): string {
return ad.replace(/\s*\([^)]*\)\s*$/, "").trim();
}
export type UniKonum = {
x: number;
y: number;
/** true: kampüs koordinatı; false: il merkezine düşen yaklaşık konum */
kesin: boolean;
};
/**
* Üniversitenin harita üzerindeki konumu. Sıra: "AD|İL" → "AD" → il merkezi.
* KKTC ve yurtdışı kampüsler için null (harita dışı).
*/
export function uniKonum(universite: string, il: string | null): UniKonum | null {
const ad = uniAdiNormalize(universite);
const koord =
(il ? UNI_KOORDINAT[`${ad}|${il}`] : undefined) ?? UNI_KOORDINAT[ad];
if (koord) return { ...latLngToXy(koord), kesin: true };
const merkez = il ? IL_MERKEZ[il] : undefined;
if (merkez) return { ...latLngToXy(merkez), kesin: false };
return null;
}
/** İl merkezinin harita konumu (kilitli/adsız pinler için). */
export function ilKonum(il: string | null): { x: number; y: number } | null {
const merkez = il ? IL_MERKEZ[il] : undefined;
return merkez ? latLngToXy(merkez) : null;
}

44
src/lib/rapor-maske.ts Normal file
View File

@@ -0,0 +1,44 @@
// Paketsiz kullanıcıya giden raporun sunucu tarafı maskesi.
// YÖK Atlas'ın ham program ve taban sıralaması verileri ücretsiz kalır.
// Yalnızca AI'ın ürettiği kişisel gerekçe/risk/trend yorumu maskelenir;
// böylece istemciye ücretli analiz metni hiç inmez.
import type { RaporSonuc } from "./ai/rapor";
export const ACIK_SATIR = 3;
const SAHTE_GEREKCE =
"Bu tercih için kişisel gerekçe paketle açılır.";
const SAHTE_RISK =
"Sıralamana özel yerleşme riski paketle açılır.";
const SAHTE_TREND =
"Ham trend verisi ücretsizdir; AI yorumu paketle açılır.";
/** genelDegerlendirme'nin yalnızca ilk 1-2 cümlesini bırakır. */
function degerlendirmeKirp(metin: string): string {
const cumleler = metin.match(/[^.!?]+[.!?]+/g);
if (!cumleler || cumleler.length <= 2) return metin;
return `${cumleler.slice(0, 2).join(" ").trim()}`;
}
export function raporMaskele(rapor: RaporSonuc): RaporSonuc {
const tercihler = rapor.tercihler.map((t, i) =>
i < ACIK_SATIR
? t
: {
...t,
gerekce: SAHTE_GEREKCE,
riskNotu: SAHTE_RISK,
trendOzeti: SAHTE_TREND,
},
);
return {
tercihler,
genelDegerlendirme: degerlendirmeKirp(rapor.genelDegerlendirme),
// Kişisel uyarılar ücretli katmandır; istemciye hiç inmez.
uyarilar: [],
// Program adı, üniversite ve 20212025 taban sıralamaları ham veridir.
programlar: rapor.programlar,
};
}

37
src/lib/risk.ts Normal file
View File

@@ -0,0 +1,37 @@
import type { Dilim } from "./rapor-havuzu";
/**
* Satır bazlı yerleşme riski. Renk dili ürünün her yerinde aynı:
* yeşil = güvenli, sarı = az riskli, kırmızı = riskli.
*/
export type RiskSeviyesi = "guvenli" | "az-riskli" | "riskli";
export const RISK_ETIKET: Record<RiskSeviyesi, string> = {
guvenli: "Güvenli",
"az-riskli": "Az riskli",
riskli: "Riskli",
};
/**
* Deterministik risk: programın efektif taban sıralaması (COALESCE 2025→2024)
* adayın sıralamasıyla kıyaslanır. Eşikler db.ts'teki dilim aralıklarıyla
* birebir aynı (dengeli: [sira, 1.4×sira], garanti: (1.4×sira, 3×sira]).
*/
export function riskHesapla(
efektifSira: number | null | undefined,
adaySira: number,
): RiskSeviyesi | null {
if (efektifSira == null || !Number.isFinite(adaySira) || adaySira < 1) {
return null;
}
if (efektifSira > Math.round(adaySira * 1.4)) return "guvenli";
if (efektifSira >= adaySira) return "az-riskli";
return "riskli";
}
/** Eski raporlar için (efektifSira kaydedilmemiş): dilimden risk türet. */
export function dilimdenRisk(dilim: Dilim | string): RiskSeviyesi {
if (dilim === "garanti") return "guvenli";
if (dilim === "dengeli") return "az-riskli";
return "riskli";
}

249
src/lib/uni-koordinatlar.ts Normal file
View File

@@ -0,0 +1,249 @@
// Üniversite kampüsü ve il merkezi koordinatları (lat, lng).
// Kaynak: kamuya açık kampüs konumları; hassasiyet il-içi pin göstermeye
// yeterli olacak düzeyde tutuldu (birkaç yüz metre sapma önemsiz).
//
// Anahtar biçimi: DB'deki üniversite adının "(İL)" eki ayıklanmış hali.
// Aynı ad birden çok ilde kampüs taşıyorsa (ör. Sağlık Bilimleri Üni.)
// "AD|İL" biçimindeki anahtar önceliklidir. Burada olmayan üniversiteler
// il merkezine (IL_MERKEZ) düşer; o da yoksa (KKTC, yurtdışı) harita dışıdır.
export type LatLng = readonly [number, number];
export const UNI_KOORDINAT: Record<string, LatLng> = {
// ── İstanbul ──────────────────────────────────────────────
"ACIBADEM MEHMET ALİ AYDINLAR ÜNİVERSİTESİ": [40.973, 29.129],
"ALTINBAŞ ÜNİVERSİTESİ": [41.058, 28.807],
"ATAŞEHİR ADIGÜZEL MESLEK YÜKSEKOKULU": [40.993, 29.123],
"BAHÇEŞEHİR ÜNİVERSİTESİ": [41.042, 29.009],
"BEYKOZ ÜNİVERSİTESİ": [41.093, 29.093],
"BEZM-İ ÂLEM VAKIF ÜNİVERSİTESİ": [41.017, 28.945],
"BOĞAZİÇİ ÜNİVERSİTESİ": [41.083, 29.051],
"BİRUNİ ÜNİVERSİTESİ": [40.989, 28.899],
"DEMİROĞLU BİLİM ÜNİVERSİTESİ": [41.06, 28.988],
"DOĞUŞ ÜNİVERSİTESİ": [41.018, 29.157],
"FATİH SULTAN MEHMET VAKIF ÜNİVERSİTESİ": [41.041, 28.945],
"FENERBAHÇE ÜNİVERSİTESİ": [40.983, 29.099],
"GALATASARAY ÜNİVERSİTESİ": [41.047, 29.026],
"HALİÇ ÜNİVERSİTESİ": [41.048, 28.945],
"IŞIK ÜNİVERSİTESİ": [41.109, 29.61],
"KADİR HAS ÜNİVERSİTESİ": [41.025, 28.958],
"KOÇ ÜNİVERSİTESİ": [41.204, 29.073],
"MALTEPE ÜNİVERSİTESİ": [40.943, 29.13],
"MARMARA ÜNİVERSİTESİ": [40.987, 29.053],
"MEF ÜNİVERSİTESİ": [41.107, 29.011],
"MİMAR SİNAN GÜZEL SANATLAR ÜNİVERSİTESİ": [41.031, 28.992],
"PİRİ REİS ÜNİVERSİTESİ": [40.811, 29.269],
"SABANCI ÜNİVERSİTESİ": [40.891, 29.379],
"SAĞLIK BİLİMLERİ ÜNİVERSİTESİ|İSTANBUL": [41.007, 29.016],
"TÜRK-ALMAN ÜNİVERSİTESİ": [41.148, 29.1],
"YEDİTEPE ÜNİVERSİTESİ": [40.973, 29.15],
"YILDIZ TEKNİK ÜNİVERSİTESİ": [41.021, 28.888],
"ÖZYEĞİN ÜNİVERSİTESİ": [41.031, 29.259],
"ÜSKÜDAR ÜNİVERSİTESİ": [41.022, 29.024],
"İBN HALDUN ÜNİVERSİTESİ": [41.107, 28.762],
"İSTANBUL 29 MAYIS ÜNİVERSİTESİ": [41.031, 29.098],
"İSTANBUL AREL ÜNİVERSİTESİ": [41.061, 28.532],
"İSTANBUL ATLAS ÜNİVERSİTESİ": [41.086, 28.978],
"İSTANBUL AYDIN ÜNİVERSİTESİ": [40.992, 28.797],
"İSTANBUL BEYKENT ÜNİVERSİTESİ": [41.111, 29.02],
"İSTANBUL BİLGİ ÜNİVERSİTESİ": [41.062, 28.947],
"İSTANBUL ESENYURT ÜNİVERSİTESİ": [41.034, 28.68],
"İSTANBUL GALATA ÜNİVERSİTESİ": [41.028, 28.974],
"İSTANBUL GEDİK ÜNİVERSİTESİ": [40.906, 29.23],
"İSTANBUL GELİŞİM ÜNİVERSİTESİ": [40.995, 28.708],
"İSTANBUL KENT ÜNİVERSİTESİ": [41.037, 28.972],
"İSTANBUL KÜLTÜR ÜNİVERSİTESİ": [40.991, 28.822],
"İSTANBUL MEDENİYET ÜNİVERSİTESİ": [40.999, 29.068],
"İSTANBUL MEDİPOL ÜNİVERSİTESİ": [41.089, 29.086],
"İSTANBUL NİŞANTAŞI ÜNİVERSİTESİ": [41.111, 29.023],
"İSTANBUL OKAN ÜNİVERSİTESİ": [40.94, 29.32],
"İSTANBUL RUMELİ ÜNİVERSİTESİ": [41.117, 28.204],
"İSTANBUL SABAHATTİN ZAİM ÜNİVERSİTESİ": [41.031, 28.783],
"İSTANBUL TEKNİK ÜNİVERSİTESİ": [41.105, 29.025],
"İSTANBUL TOPKAPI ÜNİVERSİTESİ": [41.021, 28.917],
"İSTANBUL TİCARET ÜNİVERSİTESİ": [41.048, 28.947],
"İSTANBUL YENİ YÜZYIL ÜNİVERSİTESİ": [41.004, 28.911],
"İSTANBUL ÜNİVERSİTESİ": [41.013, 28.964],
"İSTANBUL ÜNİVERSİTESİ-CERRAHPAŞA": [41.003, 28.725],
"İSTANBUL ŞİŞLİ MESLEK YÜKSEKOKULU": [41.06, 28.987],
"İSTİNYE ÜNİVERSİTESİ": [41.023, 28.925],
// ── Ankara ────────────────────────────────────────────────
"ANKARA HACI BAYRAM VELİ ÜNİVERSİTESİ": [39.938, 32.822],
"ANKARA MEDİPOL ÜNİVERSİTESİ": [39.943, 32.854],
"ANKARA SOSYAL BİLİMLER ÜNİVERSİTESİ": [39.943, 32.854],
"ANKARA YILDIRIM BEYAZIT ÜNİVERSİTESİ": [40.058, 32.947],
"ANKARA ÜNİVERSİTESİ": [39.938, 32.836],
"ATILIM ÜNİVERSİTESİ": [39.813, 32.729],
"BAŞKENT ÜNİVERSİTESİ": [39.888, 32.658],
"GAZİ ÜNİVERSİTESİ": [39.941, 32.822],
"HACETTEPE ÜNİVERSİTESİ": [39.867, 32.735],
"LOKMAN HEKİM ÜNİVERSİTESİ": [39.911, 32.799],
"ORTA DOĞU TEKNİK ÜNİVERSİTESİ": [39.891, 32.783],
"OSTİM TEKNİK ÜNİVERSİTESİ": [39.972, 32.758],
"SAĞLIK BİLİMLERİ ÜNİVERSİTESİ|ANKARA": [39.967, 32.887],
"TED ÜNİVERSİTESİ": [39.918, 32.867],
"TOBB EKONOMİ VE TEKNOLOJİ ÜNİVERSİTESİ": [39.919, 32.799],
"TÜRK HAVA KURUMU ÜNİVERSİTESİ": [39.949, 32.688],
"UFUK ÜNİVERSİTESİ": [39.892, 32.815],
"ÇANKAYA ÜNİVERSİTESİ": [39.818, 32.565],
"İHSAN DOĞRAMACI BİLKENT ÜNİVERSİTESİ": [39.868, 32.748],
// ── İzmir ─────────────────────────────────────────────────
"DOKUZ EYLÜL ÜNİVERSİTESİ": [38.368, 27.203],
"EGE ÜNİVERSİTESİ": [38.457, 27.23],
"YAŞAR ÜNİVERSİTESİ": [38.454, 27.2],
"İZMİR BAKIRÇAY ÜNİVERSİTESİ": [38.588, 27.06],
"İZMİR DEMOKRASİ ÜNİVERSİTESİ": [38.383, 27.096],
"İZMİR EKONOMİ ÜNİVERSİTESİ": [38.389, 27.049],
"İZMİR KATİP ÇELEBİ ÜNİVERSİTESİ": [38.483, 27.055],
"İZMİR KAVRAM MESLEK YÜKSEKOKULU": [38.415, 27.14],
"İZMİR TINAZTEPE ÜNİVERSİTESİ": [38.352, 27.185],
"İZMİR YÜKSEK TEKNOLOJİ ENSTİTÜSÜ": [38.319, 26.64],
// ── İlçe kampüsleri (il merkezinden belirgin uzakta) ──────
"ALANYA ALAADDİN KEYKUBAT ÜNİVERSİTESİ": [36.545, 31.996],
"ALANYA ÜNİVERSİTESİ": [36.552, 32.012],
"BANDIRMA ONYEDİ EYLÜL ÜNİVERSİTESİ": [40.352, 27.973],
"GEBZE TEKNİK ÜNİVERSİTESİ": [40.808, 29.359],
"TARSUS ÜNİVERSİTESİ": [36.919, 34.892],
"İSKENDERUN TEKNİK ÜNİVERSİTESİ": [36.577, 36.172],
"HATAY MUSTAFA KEMAL ÜNİVERSİTESİ": [36.32, 36.194],
"KAPADOKYA ÜNİVERSİTESİ": [38.446, 34.855],
// ── Diğer büyük kampüsler ─────────────────────────────────
"ABDULLAH GÜL ÜNİVERSİTESİ": [38.738, 35.472],
"ADANA ALPARSLAN TÜRKEŞ BİLİM VE TEKNOLOJİ ÜNİVERSİTESİ": [37.058, 35.442],
"AKDENİZ ÜNİVERSİTESİ": [36.897, 30.65],
"ANADOLU ÜNİVERSİTESİ": [39.79, 30.502],
"ANTALYA BELEK ÜNİVERSİTESİ": [36.897, 30.749],
"ANTALYA BİLİM ÜNİVERSİTESİ": [36.916, 30.795],
"ATATÜRK ÜNİVERSİTESİ": [39.902, 41.24],
"AYDIN ADNAN MENDERES ÜNİVERSİTESİ": [37.855, 27.851],
"BOLU ABANT İZZET BAYSAL ÜNİVERSİTESİ": [40.712, 31.523],
"BURSA TEKNİK ÜNİVERSİTESİ": [40.229, 29.009],
"BURSA ULUDAĞ ÜNİVERSİTESİ": [40.226, 28.87],
"ÇUKUROVA ÜNİVERSİTESİ": [37.058, 35.358],
"DİCLE ÜNİVERSİTESİ": [37.888, 40.276],
"ERCİYES ÜNİVERSİTESİ": [38.703, 35.526],
"ERZURUM TEKNİK ÜNİVERSİTESİ": [39.945, 41.181],
"ESKİŞEHİR OSMANGAZİ ÜNİVERSİTESİ": [39.751, 30.483],
"ESKİŞEHİR TEKNİK ÜNİVERSİTESİ": [39.814, 30.531],
"FIRAT ÜNİVERSİTESİ": [38.679, 39.199],
"GAZİANTEP ÜNİVERSİTESİ": [37.106, 37.362],
"GAZİANTEP İSLAM BİLİM VE TEKNOLOJİ ÜNİVERSİTESİ": [37.11, 37.42],
"HARRAN ÜNİVERSİTESİ": [37.008, 38.828],
"HASAN KALYONCU ÜNİVERSİTESİ": [37.14, 37.483],
"KAHRAMANMARAŞ SÜTÇÜ İMAM ÜNİVERSİTESİ": [37.629, 36.822],
"KARADENİZ TEKNİK ÜNİVERSİTESİ": [40.994, 39.766],
"KOCAELİ ÜNİVERSİTESİ": [40.822, 29.923],
"KONYA GIDA VE TARIM ÜNİVERSİTESİ": [37.939, 32.516],
"KONYA TEKNİK ÜNİVERSİTESİ": [38.026, 32.51],
"KTO KARATAY ÜNİVERSİTESİ": [37.884, 32.494],
"MANİSA CELÂL BAYAR ÜNİVERSİTESİ": [38.679, 27.311],
"MERSİN ÜNİVERSİTESİ": [36.786, 34.525],
"MUĞLA SITKI KOÇMAN ÜNİVERSİTESİ": [37.16, 28.372],
"NECMETTİN ERBAKAN ÜNİVERSİTESİ": [37.936, 32.504],
"NUH NACİ YAZGAN ÜNİVERSİTESİ": [38.756, 35.517],
"ONDOKUZ MAYIS ÜNİVERSİTESİ": [41.368, 36.2],
"PAMUKKALE ÜNİVERSİTESİ": [37.741, 29.101],
"SAKARYA ÜNİVERSİTESİ": [40.741, 30.331],
"SANKO ÜNİVERSİTESİ": [37.076, 37.331],
"SELÇUK ÜNİVERSİTESİ": [38.023, 32.512],
"SÜLEYMAN DEMİREL ÜNİVERSİTESİ": [37.83, 30.526],
"SİVAS CUMHURİYET ÜNİVERSİTESİ": [39.727, 37.05],
"TOROS ÜNİVERSİTESİ": [36.786, 34.583],
"TRABZON ÜNİVERSİTESİ": [40.999, 39.65],
"TRAKYA ÜNİVERSİTESİ": [41.66, 26.554],
"VAN YÜZÜNCÜ YIL ÜNİVERSİTESİ": [38.567, 43.276],
"ÇAĞ ÜNİVERSİTESİ": [36.925, 34.955],
"ÇANAKKALE ONSEKİZ MART ÜNİVERSİTESİ": [40.117, 26.42],
"İNÖNÜ ÜNİVERSİTESİ": [38.318, 38.435],
"ZONGULDAK BÜLENT ECEVİT ÜNİVERSİTESİ": [41.448, 31.767],
};
// İl merkezi koordinatları — DB'deki il adı biçimiyle (büyük harf, Türkçe).
// Koordinatı bilinmeyen üniversiteler bu noktaya düşer.
export const IL_MERKEZ: Record<string, LatLng> = {
ADANA: [37.0, 35.32],
ADIYAMAN: [37.76, 38.28],
AFYONKARAHİSAR: [38.76, 30.54],
AĞRI: [39.72, 43.05],
AKSARAY: [38.37, 34.03],
AMASYA: [40.65, 35.83],
ANKARA: [39.93, 32.86],
ANTALYA: [36.9, 30.7],
ARDAHAN: [41.11, 42.7],
ARTVİN: [41.18, 41.82],
AYDIN: [37.85, 27.85],
BALIKESİR: [39.65, 27.89],
BARTIN: [41.63, 32.34],
BATMAN: [37.88, 41.13],
BAYBURT: [40.26, 40.22],
BİLECİK: [40.15, 29.98],
BİNGÖL: [38.88, 40.5],
BİTLİS: [38.4, 42.11],
BOLU: [40.74, 31.61],
BURDUR: [37.72, 30.29],
BURSA: [40.18, 29.07],
ÇANAKKALE: [40.15, 26.41],
ÇANKIRI: [40.6, 33.62],
ÇORUM: [40.55, 34.95],
DENİZLİ: [37.77, 29.09],
DİYARBAKIR: [37.91, 40.24],
DÜZCE: [40.84, 31.16],
EDİRNE: [41.68, 26.56],
ELAZIĞ: [38.68, 39.22],
ERZİNCAN: [39.75, 39.49],
ERZURUM: [39.9, 41.27],
ESKİŞEHİR: [39.78, 30.52],
GAZİANTEP: [37.07, 37.38],
GİRESUN: [40.91, 38.39],
GÜMÜŞHANE: [40.46, 39.48],
HAKKARİ: [37.57, 43.74],
HATAY: [36.2, 36.16],
IĞDIR: [39.92, 44.04],
ISPARTA: [37.76, 30.56],
İSTANBUL: [41.01, 28.98],
İZMİR: [38.42, 27.14],
KAHRAMANMARAŞ: [37.58, 36.93],
KARABÜK: [41.2, 32.63],
KARAMAN: [37.18, 33.22],
KARS: [40.6, 43.1],
KASTAMONU: [41.38, 33.78],
KAYSERİ: [38.72, 35.49],
KIRIKKALE: [39.85, 33.51],
KIRKLARELİ: [41.73, 27.22],
KIRŞEHİR: [39.15, 34.16],
KİLİS: [36.72, 37.12],
KOCAELİ: [40.77, 29.94],
KONYA: [37.87, 32.48],
KÜTAHYA: [39.42, 29.98],
MALATYA: [38.35, 38.31],
MANİSA: [38.61, 27.43],
MARDİN: [37.31, 40.74],
MERSİN: [36.8, 34.63],
MUĞLA: [37.22, 28.36],
MUŞ: [38.75, 41.5],
NEVŞEHİR: [38.62, 34.71],
NİĞDE: [37.97, 34.68],
ORDU: [40.98, 37.88],
OSMANİYE: [37.07, 36.25],
RİZE: [41.02, 40.52],
SAKARYA: [40.77, 30.4],
SAMSUN: [41.29, 36.33],
SİİRT: [37.93, 41.94],
SİNOP: [42.03, 35.15],
SİVAS: [39.75, 37.02],
ŞANLIURFA: [37.16, 38.79],
ŞIRNAK: [37.52, 42.46],
TEKİRDAĞ: [40.98, 27.51],
TOKAT: [40.31, 36.55],
TRABZON: [41.0, 39.72],
TUNCELİ: [39.11, 39.55],
UŞAK: [38.68, 29.4],
VAN: [38.5, 43.38],
YALOVA: [40.65, 29.27],
YOZGAT: [39.82, 34.81],
ZONGULDAK: [41.45, 31.79],
};