Compare commits
1 Commits
40a1c8ef8e
...
feat/kolay
| Author | SHA1 | Date | |
|---|---|---|---|
| bc77178ece |
@@ -1,11 +1,11 @@
|
||||
---
|
||||
name: ui-ux-pro-max
|
||||
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."
|
||||
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."
|
||||
---
|
||||
|
||||
# UI/UX Pro Max - Design Intelligence
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## When to Apply
|
||||
|
||||
@@ -128,7 +128,7 @@ Extract key information from user request:
|
||||
- **Product type**: SaaS, e-commerce, portfolio, dashboard, landing page, etc.
|
||||
- **Style keywords**: minimal, playful, professional, elegant, dark mode, etc.
|
||||
- **Industry**: healthcare, fintech, gaming, education, etc.
|
||||
- **Stack**: Next.js / React / shadcn (default: `nextjs`)
|
||||
- **Stack**: React, Vue, Next.js, or default to `html-tailwind`
|
||||
|
||||
### 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"` |
|
||||
| Landing structure | `landing` | `--domain landing "hero social-proof"` |
|
||||
|
||||
### Step 4: Stack Guidelines (Default: nextjs)
|
||||
### Step 4: Stack Guidelines (Default: 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`.
|
||||
Get implementation-specific best practices. If user doesn't specify a stack, **default to `html-tailwind`**.
|
||||
|
||||
```bash
|
||||
python3 .claude/skills/ui-ux-pro-max/scripts/search.py "<keyword>" --stack nextjs
|
||||
python3 .claude/skills/ui-ux-pro-max/scripts/search.py "<keyword>" --stack html-tailwind
|
||||
```
|
||||
|
||||
Available stacks: `nextjs`, `react`, `shadcn`, `html-tailwind`
|
||||
Available stacks: `html-tailwind`, `react`, `nextjs`, `vue`, `svelte`, `swiftui`, `react-native`, `flutter`, `shadcn`
|
||||
|
||||
---
|
||||
|
||||
@@ -200,10 +200,15 @@ Available stacks: `nextjs`, `react`, `shadcn`, `html-tailwind`
|
||||
|
||||
| Stack | Focus |
|
||||
|-------|-------|
|
||||
| `nextjs` | SSR, routing, images, API routes (DEFAULT) |
|
||||
| `html-tailwind` | Tailwind utilities, responsive, a11y (DEFAULT) |
|
||||
| `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 |
|
||||
| `html-tailwind` | Tailwind utilities, responsive, a11y |
|
||||
|
||||
---
|
||||
|
||||
@@ -215,7 +220,7 @@ Available stacks: `nextjs`, `react`, `shadcn`, `html-tailwind`
|
||||
- Product type: Beauty/Spa service
|
||||
- Style keywords: elegant, professional, soft
|
||||
- Industry: Beauty/Wellness
|
||||
- Stack: nextjs (default)
|
||||
- Stack: html-tailwind (default)
|
||||
|
||||
### Step 2: Generate Design System (REQUIRED)
|
||||
|
||||
@@ -238,7 +243,7 @@ python3 .claude/skills/ui-ux-pro-max/scripts/search.py "elegant luxury serif" --
|
||||
### Step 4: Stack Guidelines
|
||||
|
||||
```bash
|
||||
python3 .claude/skills/ui-ux-pro-max/scripts/search.py "layout responsive form" --stack nextjs
|
||||
python3 .claude/skills/ui-ux-pro-max/scripts/search.py "layout responsive form" --stack html-tailwind
|
||||
```
|
||||
|
||||
**Then:** Synthesize design system + detailed searches and implement the design.
|
||||
|
||||
53
.claude/skills/ui-ux-pro-max/data/stacks/flutter.csv
Normal file
53
.claude/skills/ui-ux-pro-max/data/stacks/flutter.csv
Normal file
@@ -0,0 +1,53 @@
|
||||
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,
|
||||
|
51
.claude/skills/ui-ux-pro-max/data/stacks/nuxt-ui.csv
Normal file
51
.claude/skills/ui-ux-pro-max/data/stacks/nuxt-ui.csv
Normal file
@@ -0,0 +1,51 @@
|
||||
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.
|
59
.claude/skills/ui-ux-pro-max/data/stacks/nuxtjs.csv
Normal file
59
.claude/skills/ui-ux-pro-max/data/stacks/nuxtjs.csv
Normal file
@@ -0,0 +1,59 @@
|
||||
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.
|
52
.claude/skills/ui-ux-pro-max/data/stacks/react-native.csv
Normal file
52
.claude/skills/ui-ux-pro-max/data/stacks/react-native.csv
Normal file
@@ -0,0 +1,52 @@
|
||||
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
|
||||
|
54
.claude/skills/ui-ux-pro-max/data/stacks/svelte.csv
Normal file
54
.claude/skills/ui-ux-pro-max/data/stacks/svelte.csv
Normal file
@@ -0,0 +1,54 @@
|
||||
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,
|
||||
|
51
.claude/skills/ui-ux-pro-max/data/stacks/swiftui.csv
Normal file
51
.claude/skills/ui-ux-pro-max/data/stacks/swiftui.csv
Normal file
@@ -0,0 +1,51 @@
|
||||
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,
|
||||
|
50
.claude/skills/ui-ux-pro-max/data/stacks/vue.csv
Normal file
50
.claude/skills/ui-ux-pro-max/data/stacks/vue.csv
Normal file
@@ -0,0 +1,50 @@
|
||||
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,
|
||||
|
@@ -76,6 +76,13 @@ STACK_CONFIG = {
|
||||
"html-tailwind": {"file": "stacks/html-tailwind.csv"},
|
||||
"react": {"file": "stacks/react.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"}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ Usage: python search.py "<query>" [--domain <domain>] [--stack <stack>] [--max-r
|
||||
python search.py "<query>" --design-system [-p "Project Name"]
|
||||
|
||||
Domains: style, prompt, color, chart, landing, product, ux, typography
|
||||
Stacks: html-tailwind, react, nextjs, shadcn
|
||||
Stacks: html-tailwind, react, nextjs
|
||||
"""
|
||||
|
||||
import argparse
|
||||
@@ -44,7 +44,7 @@ if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="UI Pro Max Search")
|
||||
parser.add_argument("query", help="Search query")
|
||||
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, shadcn)")
|
||||
parser.add_argument("--stack", "-s", choices=AVAILABLE_STACKS, help="Stack-specific search (html-tailwind, react, nextjs)")
|
||||
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")
|
||||
# Design system generation
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
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
|
||||
@@ -1,35 +0,0 @@
|
||||
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
|
||||
@@ -2,8 +2,4 @@
|
||||
# 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.
|
||||
|
||||
# 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 -->
|
||||
|
||||
55
Dockerfile
55
Dockerfile
@@ -1,55 +0,0 @@
|
||||
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"]
|
||||
@@ -1,101 +0,0 @@
|
||||
# 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")
|
||||
BIN
data/app.db
BIN
data/app.db
Binary file not shown.
@@ -1,27 +0,0 @@
|
||||
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:
|
||||
@@ -1,13 +1,12 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
serverExternalPackages: ["better-sqlite3", "iyzipay", "@libsql/client"],
|
||||
async redirects() {
|
||||
// Eski akış /sonuc modalına taşındı. /rapor/yazdir korunur (tam eşleşme).
|
||||
return [
|
||||
{ source: "/sohbet", destination: "/sonuc", permanent: false },
|
||||
{ source: "/rapor", destination: "/sonuc", permanent: false },
|
||||
{ source: "/sohbet", destination: "/listem", permanent: false },
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
@@ -21,24 +21,16 @@
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"iyzipay": "^2.0.69",
|
||||
"lucide-react": "^1.25.0",
|
||||
"marked": "^18.0.7",
|
||||
"motion": "^12.42.2",
|
||||
"next": "16.2.10",
|
||||
"next-themes": "^0.4.6",
|
||||
"radix-ui": "^1.6.3",
|
||||
"react": "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",
|
||||
"shiki": "^4.3.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"turkey-map-react": "^2.0.5",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"use-stick-to-bottom": "^1.1.6",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
1127
pnpm-lock.yaml
generated
1127
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,28 +0,0 @@
|
||||
<?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>
|
||||
|
Before Width: | Height: | Size: 7.5 KiB |
@@ -1,17 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,22 +0,0 @@
|
||||
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) });
|
||||
}
|
||||
@@ -8,16 +8,11 @@ import { listeOzetiCikar, type RaporSonuc } from "@/lib/ai/rapor";
|
||||
import type { RaporParams } from "@/lib/rapor-havuzu";
|
||||
import { secimOzeti } from "@/lib/sihirbaz";
|
||||
import { PUAN_TURLERI } from "@/lib/db";
|
||||
import { ACIK_SATIR } from "@/lib/rapor-maske";
|
||||
|
||||
const MAX_GECMIS = 12;
|
||||
const MAX_MESAJ_UZUNLUK = 2000;
|
||||
|
||||
function sistemPromptu(opts: {
|
||||
params: RaporParams;
|
||||
listeOzeti: string;
|
||||
kilitli?: boolean;
|
||||
}): string {
|
||||
function sistemPromptu(opts: { params: RaporParams; listeOzeti: string }): string {
|
||||
const { params } = opts;
|
||||
const secimVar =
|
||||
(params.kategoriler?.length ?? 0) > 0 ||
|
||||
@@ -34,10 +29,6 @@ iş imkânları, tercih sırası) net ve dürüst yanıtlamak.
|
||||
KURALLAR:
|
||||
- Cevaplarını adayın 24'lük listesi ve seçimleri bağlamında ver; listedeki
|
||||
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.
|
||||
- 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.
|
||||
@@ -48,15 +39,6 @@ KURALLAR:
|
||||
`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}`,
|
||||
];
|
||||
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) {
|
||||
parcalar.push(
|
||||
`ADAYIN SİHİRBAZ SEÇİMLERİ:\n${secimOzeti({
|
||||
@@ -158,67 +140,11 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
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({
|
||||
params: rapor.params as RaporParams,
|
||||
listeOzeti,
|
||||
kilitli,
|
||||
listeOzeti: listeOzetiCikar(rapor.result as RaporSonuc),
|
||||
});
|
||||
|
||||
// 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;
|
||||
try {
|
||||
client = getAnthropic();
|
||||
@@ -229,12 +155,8 @@ export async function POST(request: NextRequest) {
|
||||
reason: "refund",
|
||||
refId: kullaniciMesajId,
|
||||
});
|
||||
console.error("[soru] AI istemcisi kurulamadı (anahtar eksik olabilir)");
|
||||
return Response.json(
|
||||
{
|
||||
error:
|
||||
"Danışman şu anda yanıt veremiyor; kredin iade edildi. Birazdan tekrar dener misin?",
|
||||
},
|
||||
{ error: "AI altyapısı yapılandırılmadı." },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -34,21 +34,6 @@ export function GirisForm({ callbackURL }: { callbackURL: string }) {
|
||||
const [email, setEmail] = useState("");
|
||||
const [busy, setBusy] = useState<"google" | "email" | null>(null);
|
||||
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() {
|
||||
setBusy("google");
|
||||
@@ -62,8 +47,8 @@ export function GirisForm({ callbackURL }: { callbackURL: string }) {
|
||||
}
|
||||
}
|
||||
|
||||
async function linkGonder(e?: React.FormEvent) {
|
||||
e?.preventDefault();
|
||||
async function linkGonder(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!/^\S+@\S+\.\S+$/.test(email)) {
|
||||
toast.error("Geçerli bir e-posta adresi gir.");
|
||||
return;
|
||||
@@ -79,41 +64,17 @@ export function GirisForm({ callbackURL }: { callbackURL: string }) {
|
||||
return;
|
||||
}
|
||||
setSent(true);
|
||||
geriSayimBaslat();
|
||||
}
|
||||
|
||||
if (sent) {
|
||||
return (
|
||||
<div className="mt-6 space-y-3">
|
||||
<div className="rounded-xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800">
|
||||
<p className="font-semibold">Bağlantı gönderildi 📬</p>
|
||||
<p className="mt-1">
|
||||
<span className="font-medium">{email}</span> adresine bir giriş
|
||||
bağlantısı gönderdik. Gelen kutunu (ve spam klasörünü) kontrol et —
|
||||
bağlantı 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 className="mt-6 rounded-xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800">
|
||||
<p className="font-semibold">Bağlantı gönderildi 📬</p>
|
||||
<p className="mt-1">
|
||||
<span className="font-medium">{email}</span> adresine bir giriş
|
||||
bağlantısı gönderdik. Gelen kutunu (ve spam klasörünü) kontrol et —
|
||||
bağlantı 5 dakika geçerli.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
// /giris açılırken spinner yerine sayfanın kendi iskeleti: kart, başlık,
|
||||
// Google butonu, ayraç, e-posta alanı ve gönder butonu aynı yerde belirir —
|
||||
// içerik gelince zıplama olmaz.
|
||||
export default function GirisLoading() {
|
||||
return (
|
||||
<div className="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>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getSession } from "@/lib/session";
|
||||
import { GirisForm } from "./giris-form";
|
||||
import { GirisBaslik } from "./giris-baslik";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Giriş — KolayTercih",
|
||||
@@ -21,41 +19,20 @@ export default async function GirisPage({
|
||||
const session = await getSession();
|
||||
if (session) redirect(callbackURL);
|
||||
|
||||
// Sihirbaz funnel'ı: seçimler yapıldı, listeye giriş kaldı
|
||||
const funnel = callbackURL.startsWith("/sonuc");
|
||||
|
||||
return (
|
||||
<div className="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">
|
||||
<div className="rounded-2xl border border-slate-200 bg-white p-8 shadow-sm">
|
||||
<GirisBaslik funnel={funnel} />
|
||||
<h1 className="font-heading text-2xl font-bold">Giriş yap</h1>
|
||||
<p className="mt-2 text-sm text-slate-600">
|
||||
{funnel ? (
|
||||
<>
|
||||
Seçimlerin hazır — giriş yap,{" "}
|
||||
<span className="font-semibold">5 deneme kredinle</span> listen
|
||||
hemen üretilsin.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Kayıt olduğunda{" "}
|
||||
<span className="font-semibold">5 deneme kredisi</span> hesabına
|
||||
tanımlanır — AI danışmana ücretsiz soru sorabilirsin.
|
||||
</>
|
||||
)}
|
||||
Kayıt olduğunda <span className="font-semibold">5 deneme kredisi</span>{" "}
|
||||
hesabına tanımlanır — AI danışmana ücretsiz soru sorabilirsin.
|
||||
</p>
|
||||
<GirisForm callbackURL={callbackURL} />
|
||||
</div>
|
||||
<p className="mt-6 text-center text-xs leading-relaxed text-slate-500">
|
||||
Giriş yaparak verilerinin tercih önerileri üretmek amacıyla{" "}
|
||||
<Link href="/gizlilik" className="underline hover:text-slate-700">
|
||||
gizlilik metnindeki
|
||||
</Link>{" "}
|
||||
gibi işlenmesini ve{" "}
|
||||
<Link href="/kosullar" className="underline hover:text-slate-700">
|
||||
kullanım koşullarını
|
||||
</Link>{" "}
|
||||
kabul etmiş olursun. KolayTercih yerleşme garantisi vermez.
|
||||
Giriş yaparak verilerinin tercih önerileri üretmek amacıyla
|
||||
işlenmesini kabul etmiş olursun. KolayTercih yerleşme garantisi vermez.
|
||||
</p>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
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 açık tutmak için zorunlu çerezler kullanılır;
|
||||
reklam veya izleme çerezi kullanılmaz.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -117,50 +117,6 @@
|
||||
--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 {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -2,10 +2,8 @@ import type { Metadata } from "next";
|
||||
import { Outfit, Work_Sans, Geist_Mono } from "next/font/google";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { SiteHeader } from "@/components/site-header";
|
||||
import { ListeCekmecesi } from "@/components/manuel-liste/liste-cekmecesi";
|
||||
import { HeaderGate } from "@/components/header-gate";
|
||||
import { ProgressiveBlur } from "@/components/ui/skiper-ui/skiper41";
|
||||
import { DevPanel } from "@/components/dev/dev-panel";
|
||||
import "./globals.css";
|
||||
|
||||
const outfit = Outfit({
|
||||
@@ -24,7 +22,7 @@ const geistMono = Geist_Mono({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "KolayTercih — Tercih Danışmanı",
|
||||
title: "KolayTercih — Cebindeki Tercih Danışmanı",
|
||||
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ığı.",
|
||||
};
|
||||
@@ -61,11 +59,7 @@ export default function RootLayout({
|
||||
<SiteHeader />
|
||||
</HeaderGate>
|
||||
{children}
|
||||
{/* Manuel 24'lük liste çekmecesi + "+" uçuş katmanı (navbar'dan açılır) */}
|
||||
<ListeCekmecesi />
|
||||
<Toaster position="top-center" />
|
||||
{/* Dev süperadmin paneli — prod build'de hiç render edilmez */}
|
||||
{process.env.NODE_ENV !== "production" ? <DevPanel /> : null}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export default function ListemLoading() {
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-7xl px-4 py-10">
|
||||
<Skeleton className="h-6 w-40 rounded-full" />
|
||||
<Skeleton className="mt-4 h-9 w-3/4 max-w-xl" />
|
||||
<Skeleton className="mt-3 h-5 w-2/3 max-w-lg" />
|
||||
<div className="mt-8 grid items-start gap-8 lg:grid-cols-[minmax(0,1fr)_340px]">
|
||||
<div className="flex flex-col gap-2">
|
||||
{Array.from({ length: 10 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-16 w-full rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
<Skeleton className="h-[480px] w-full rounded-2xl" />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,334 +0,0 @@
|
||||
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 açık. 🎉
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
{user.hasPaket ? (
|
||||
<Badge variant="secondary" className="mb-3">
|
||||
<ShieldCheck aria-hidden />
|
||||
Paketin aktif
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary" className="mb-3">
|
||||
<Database aria-hidden />
|
||||
YÖK Atlas verileri ücretsiz
|
||||
</Badge>
|
||||
)}
|
||||
<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 2024–2025 taban sıralamalarını ve ham trendini aşağıda ücretsiz inceleyebilirsin."}
|
||||
</p>
|
||||
</div>
|
||||
{user.hasPaket ? (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Link href="#revizyon">
|
||||
<PencilLine className="size-4" aria-hidden />
|
||||
Revize et
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Link href="/rapor/yazdir" target="_blank">
|
||||
<FileText className="size-4" aria-hidden />
|
||||
PDF
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* 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 açılır. Program adları ve taban verileri
|
||||
her zaman ücretsiz.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
asChild
|
||||
className="h-11 w-full shrink-0 cursor-pointer bg-orange-500 text-white transition-[background-color,transform] duration-200 active:scale-[0.97] hover:bg-orange-600 sm:w-auto"
|
||||
>
|
||||
<Link href="/paket">
|
||||
Analizleri aç — {PAKET_FIYATI} TL
|
||||
<ArrowRight data-icon="inline-end" aria-hidden />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{user.hasPaket ? (
|
||||
<RevizyonKutusu
|
||||
kalanHak={Math.max(0, MAX_REVIZYON - (satir.revisionCount ?? 0))}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div id="sohbet" className="scroll-mt-24 lg:sticky lg:top-28">
|
||||
{user.hasPaket ? (
|
||||
<SohbetClient baslangicKredi={user.creditBalance} />
|
||||
) : (
|
||||
<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 açıklar.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<ul className="flex flex-col gap-3 text-sm">
|
||||
{[
|
||||
"24 tercihin tamamında kişisel risk analizi",
|
||||
"Her program için kısa, gerekçeli AI yorumu",
|
||||
"2 liste revizyonu ve paylaşılabilir PDF",
|
||||
`${URUNLER.paket.credits} AI danışman sorusu`,
|
||||
].map((ozellik) => (
|
||||
<li key={ozellik} className="flex items-start gap-2.5">
|
||||
<Check
|
||||
className="mt-0.5 size-4 shrink-0 text-emerald-600"
|
||||
aria-hidden
|
||||
/>
|
||||
<span>{ozellik}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="mt-6 flex items-end justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-slate-500">Tek seferlik</p>
|
||||
<p className="font-heading text-3xl font-bold">
|
||||
{PAKET_FIYATI} TL
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline">Abonelik yok</Badge>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="mt-5 h-12 w-full cursor-pointer bg-orange-500 text-white transition-[background-color,transform] duration-200 active:scale-[0.97] hover:bg-orange-600"
|
||||
>
|
||||
<Link href="/paket">
|
||||
Kişisel analizi aç
|
||||
<ArrowRight data-icon="inline-end" aria-hidden />
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<div className="mt-4 flex items-center justify-center gap-4 text-[11px] text-slate-500">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<ShieldCheck className="size-3.5" aria-hidden />
|
||||
iyzico güvenli ödeme
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Bot className="size-3.5" aria-hidden />
|
||||
Gerçek YÖK verisi
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,260 +0,0 @@
|
||||
"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'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>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -38,16 +38,6 @@ export default async function OdemeSonucPage({
|
||||
|
||||
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 (
|
||||
<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">
|
||||
@@ -74,17 +64,13 @@ export default async function OdemeSonucPage({
|
||||
asChild
|
||||
className="h-11 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||
>
|
||||
<Link href={listeHref ?? "/"}>
|
||||
<Link href="/">
|
||||
<Sparkles className="size-4" aria-hidden />
|
||||
{listeHref
|
||||
? "Listeni aç — kilidi kaldırdık"
|
||||
: "24'lük listeni oluştur"}
|
||||
24'lük listeni oluştur
|
||||
</Link>
|
||||
</Button>
|
||||
<p className="text-center text-xs text-slate-500">
|
||||
{listeHref
|
||||
? "24 tercihin tamamı, gerekçeler, riskler ve PDF artık açık."
|
||||
: "Sıralamanı girip sonuç sayfasındaki sihirbazla listeni kur."}
|
||||
Sıralamanı girip sonuç sayfasındaki sihirbazla listeni kur.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -95,18 +81,9 @@ export default async function OdemeSonucPage({
|
||||
Ödeme tamamlanamadı
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-slate-600">
|
||||
{durum === "pending" ? (
|
||||
"Ödemen hâlâ işleniyor. Birkaç saniye sonra bu sayfayı yenile — kartından çekim yapıldıysa krediler mutlaka tanımlanır."
|
||||
) : (
|
||||
<>
|
||||
Çekim yapılmadı ya da işlem iptal edildi. Kartından para
|
||||
çekildiğini düşünüyorsan{" "}
|
||||
<Link href="/iletisim" className="underline">
|
||||
bizimle iletişime geç
|
||||
</Link>
|
||||
.
|
||||
</>
|
||||
)}
|
||||
{durum === "pending"
|
||||
? "Ödemen hâlâ işleniyor. Birkaç saniye sonra bu sayfayı yenile — kartından çekim yapıldıysa krediler mutlaka tanımlanır."
|
||||
: "Çekim yapılmadı ya da işlem iptal edildi. Kartından para çekildiğini düşünüyorsan bizimle iletişime geç."}
|
||||
</p>
|
||||
<Button asChild variant="outline" className="mt-6 h-11 cursor-pointer">
|
||||
<Link href="/paket">Tekrar dene</Link>
|
||||
|
||||
114
src/app/page.tsx
114
src/app/page.tsx
@@ -9,10 +9,7 @@ import {
|
||||
Wallet,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { HeroForm } from "@/components/hero-form";
|
||||
import { URUNLER } from "@/lib/credits";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -36,13 +33,6 @@ import {
|
||||
TableRow,
|
||||
} 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 = [
|
||||
{
|
||||
icon: ListChecks,
|
||||
@@ -106,7 +96,8 @@ const faqs = [
|
||||
},
|
||||
{
|
||||
question: "Fiyatlandırma nasıl çalışıyor?",
|
||||
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.`,
|
||||
answer:
|
||||
"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.",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -160,10 +151,7 @@ export default function Home() {
|
||||
<main className="flex-1">
|
||||
{/* Hero */}
|
||||
<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 açık
|
||||
</Badge>
|
||||
<h1 className="max-w-3xl font-heading text-4xl font-bold tracking-tight sm:text-6xl">
|
||||
@@ -175,32 +163,31 @@ export default function Home() {
|
||||
danışmanın. İnsan danışmanın binlerce lira aldığı işi — dengeli
|
||||
liste, risk analizi, soru-cevap — onda bir fiyatına yapar.
|
||||
</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 />
|
||||
</div>
|
||||
<p className="mt-4 text-sm text-slate-500">
|
||||
Temel arama her zaman ücretsiz · Kredi kartı gerekmez · Tam analiz
|
||||
paketi tek seferlik {PAKET_FIYATI} TL
|
||||
Temel arama her zaman ücretsiz · Kredi kartı gerekmez
|
||||
</p>
|
||||
<div className="mt-3 inline-flex items-center gap-2.5 rounded-lg border border-slate-200 bg-white px-3 py-2 text-left text-xs text-slate-700 shadow-xs">
|
||||
<Image
|
||||
src="/yok-logo.svg"
|
||||
alt="Yükseköğretim Kurulu"
|
||||
width={68}
|
||||
height={33}
|
||||
className="h-7 w-auto shrink-0"
|
||||
/>
|
||||
<span className="h-5 w-px shrink-0 bg-slate-200" aria-hidden />
|
||||
<span>
|
||||
<strong className="font-semibold text-slate-900">
|
||||
YÖK Atlas'ın gerçek verileriyle
|
||||
</strong>{" "}
|
||||
güvenilir sonuçlar
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Sosyal kanıt: henüz kullanıcı yok — dürüstçe veri istatistikleri */}
|
||||
<dl className="mt-16 grid w-full max-w-3xl grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
{[
|
||||
{ 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="rounded-2xl border border-slate-200 bg-white p-6"
|
||||
>
|
||||
<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>
|
||||
</section>
|
||||
|
||||
{/* Problem */}
|
||||
@@ -254,43 +241,6 @@ export default function Home() {
|
||||
</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>
|
||||
</section>
|
||||
|
||||
@@ -385,7 +335,7 @@ export default function Home() {
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer className="border-t border-slate-200 bg-white pb-32 pt-10">
|
||||
<footer className="border-t border-slate-200 bg-white py-10">
|
||||
<div className="mx-auto max-w-6xl px-4 text-center text-sm leading-relaxed text-slate-500">
|
||||
<p>
|
||||
© 2026 KolayTercih. Veriler resmî YÖK Atlas kaynağından derlenir;
|
||||
@@ -395,20 +345,6 @@ export default function Home() {
|
||||
KolayTercih bir karar destek aracıdır, yerleşme garantisi vermez.
|
||||
Tercih listenizin son hali ve başvuru sorumluluğu size aittir.
|
||||
</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>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
@@ -50,11 +50,9 @@ export async function baslatOdeme(
|
||||
});
|
||||
} catch (err) {
|
||||
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 {
|
||||
error:
|
||||
"Ödeme şu anda başlatılamıyor; kartından çekim yapılmadı. Birazdan tekrar dener misin?",
|
||||
"Ödeme altyapısı henüz yapılandırılmadı (iyzico anahtarları eksik).",
|
||||
};
|
||||
}
|
||||
throw err;
|
||||
|
||||
@@ -40,29 +40,12 @@ const OZELLIKLER = [
|
||||
},
|
||||
] as const;
|
||||
|
||||
export default async function PaketPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ hata?: string }>;
|
||||
}) {
|
||||
export default async function PaketPage() {
|
||||
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 (
|
||||
<div className="flex min-h-screen flex-col bg-slate-50 text-slate-900">
|
||||
<main className="mx-auto w-full max-w-3xl flex-1 px-4 py-16">
|
||||
{odemeHatasi ? (
|
||||
<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">
|
||||
<Badge variant="secondary" className="bg-primary/10 text-primary">
|
||||
Tek seferlik ödeme · Abonelik yok
|
||||
@@ -128,13 +111,7 @@ export default async function PaketPage({
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-4 text-center text-xs text-slate-500">
|
||||
iyzico güvenli ödeme · Kart bilgilerin bize hiç ulaşmaz ·{" "}
|
||||
<Link
|
||||
href="/kosullar#iade"
|
||||
className="underline hover:text-slate-700"
|
||||
>
|
||||
İade koşulları
|
||||
</Link>
|
||||
iyzico güvenli ödeme · Kart bilgilerin bize hiç ulaşmaz
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -172,11 +149,7 @@ export default async function PaketPage({
|
||||
<p className="mt-10 text-center text-xs leading-relaxed text-slate-500">
|
||||
KolayTercih bir karar destek aracıdır; öneriler resmî YÖK Atlas verisine
|
||||
dayanır ancak yerleşme garantisi verilmez. Tercih listesinin nihai
|
||||
sorumluluğu adaya aittir. Sorun mu var?{" "}
|
||||
<Link href="/iletisim" className="underline hover:text-slate-700">
|
||||
Bize yaz
|
||||
</Link>
|
||||
.
|
||||
sorumluluğu adaya aittir.
|
||||
</p>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -13,9 +13,9 @@ export const metadata: Metadata = {
|
||||
};
|
||||
|
||||
const DILIM_LABEL: Record<string, string> = {
|
||||
hayal: "Hayal (Riskli)",
|
||||
dengeli: "Dengeli (Az riskli)",
|
||||
garanti: "Garanti (Güvenli)",
|
||||
hayal: "Hayal",
|
||||
dengeli: "Dengeli",
|
||||
garanti: "Garanti",
|
||||
};
|
||||
|
||||
export default async function YazdirPage() {
|
||||
|
||||
@@ -4,12 +4,7 @@ import { eq, sql } from "drizzle-orm";
|
||||
import { getSession, getCurrentUser } from "@/lib/session";
|
||||
import { appDb, schema } from "@/lib/appdb";
|
||||
import { PUAN_TURLERI, type PuanTuruKey } from "@/lib/db";
|
||||
import {
|
||||
spendCredits,
|
||||
grantCredits,
|
||||
RAPOR_KREDI,
|
||||
MAX_REVIZYON,
|
||||
} from "@/lib/credits";
|
||||
import { spendCredits, grantCredits, RAPOR_KREDI } from "@/lib/credits";
|
||||
import {
|
||||
raporUret,
|
||||
listeOzetiCikar,
|
||||
@@ -18,8 +13,8 @@ import {
|
||||
} from "@/lib/ai/rapor";
|
||||
import type { RaporParams } from "@/lib/rapor-havuzu";
|
||||
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.
|
||||
export type ListeSonuc =
|
||||
@@ -29,7 +24,6 @@ export type ListeSonuc =
|
||||
params: RaporParams;
|
||||
revisionCount: number;
|
||||
kredi: number;
|
||||
hasPaket: boolean;
|
||||
}
|
||||
| { ok: false; error: string; code: "AUTH" | "PAKET" | "KREDI" | "HATA" };
|
||||
|
||||
@@ -42,9 +36,7 @@ function siraTurGecerli(sira: number, tur: string): tur is PuanTuruKey {
|
||||
function aiHataMesaji(err: unknown): string {
|
||||
if (err instanceof RaporUretimHatasi) return err.message;
|
||||
if (err instanceof Error && err.message === "AI_KEY_MISSING") {
|
||||
// Yapılandırma detayı log'a; kullanıcıya altyapı sızdırmayan mesaj
|
||||
console.error("[liste] ANTHROPIC_API_KEY eksik — üretim başlatılamadı");
|
||||
return "Liste üretimi şu anda başlatılamıyor. Birazdan tekrar dener misin? Harcanan kredin otomatik iade edilir.";
|
||||
return "AI altyapısı henüz yapılandırılmadı (ANTHROPIC_API_KEY eksik).";
|
||||
}
|
||||
console.error("[liste] üretim hatası:", err);
|
||||
return "Liste üretilirken bir sorun oluştu. Birazdan tekrar dener misin?";
|
||||
@@ -79,8 +71,9 @@ export async function listeOlustur(input: {
|
||||
if (!user) {
|
||||
return { ok: false, code: "AUTH", error: "Giriş yapmalısın." };
|
||||
}
|
||||
// Paket şartı yok: üretim kredi bazlı (yeni kullanıcının 5 deneme kredisi
|
||||
// burada yanmaya başlar). Paketsize dönen rapor maskelenir.
|
||||
if (!user.hasPaket) {
|
||||
return { ok: false, code: "PAKET", error: "Paket gerekli." };
|
||||
}
|
||||
|
||||
if (!siraTurGecerli(input.sira, input.tur)) {
|
||||
return { ok: false, code: "HATA", error: "Geçerli bir sıralama gerekli." };
|
||||
@@ -119,14 +112,12 @@ export async function listeOlustur(input: {
|
||||
where: eq(schema.reports.userId, user.id),
|
||||
});
|
||||
if (mevcut?.result) {
|
||||
const rapor = mevcut.result as RaporSonuc;
|
||||
return {
|
||||
ok: true,
|
||||
rapor: user.hasPaket ? rapor : raporMaskele(rapor),
|
||||
rapor: mevcut.result as RaporSonuc,
|
||||
params: mevcut.params as RaporParams,
|
||||
revisionCount: mevcut.revisionCount,
|
||||
kredi: user.creditBalance,
|
||||
hasPaket: user.hasPaket,
|
||||
};
|
||||
}
|
||||
return { ok: false, code: "HATA", error: "İstek tekrarlandı." };
|
||||
@@ -165,11 +156,10 @@ export async function listeOlustur(input: {
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
rapor: user.hasPaket ? sonuc : raporMaskele(sonuc),
|
||||
rapor: sonuc,
|
||||
params,
|
||||
revisionCount: 0,
|
||||
kredi: user.creditBalance - RAPOR_KREDI,
|
||||
hasPaket: user.hasPaket,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -231,11 +221,7 @@ export async function listeRevize(input: {
|
||||
|
||||
let sonuc: RaporSonuc;
|
||||
try {
|
||||
sonuc = await raporUret(
|
||||
params,
|
||||
feedback.slice(0, 1000),
|
||||
listeOzetiCikar(onceki, params.sira),
|
||||
);
|
||||
sonuc = await raporUret(params, feedback.slice(0, 1000), listeOzetiCikar(onceki));
|
||||
} catch (err) {
|
||||
await grantCredits({
|
||||
userId: user.id,
|
||||
@@ -262,6 +248,5 @@ export async function listeRevize(input: {
|
||||
params,
|
||||
revisionCount: rapor.revisionCount + 1,
|
||||
kredi: user.creditBalance - RAPOR_KREDI,
|
||||
hasPaket: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
"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 aç
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={() => window.dispatchEvent(new Event(SIHIRBAZ_AC_EVENT))}
|
||||
className={ctaSinif}
|
||||
>
|
||||
Listemi oluştur
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,386 +0,0 @@
|
||||
"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'yle 60 kredi, tam
|
||||
liste, PDF ve revizyon hakları birlikte gelir.
|
||||
</p>
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="mt-6 h-12 cursor-pointer bg-orange-500 px-8 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||
>
|
||||
<Link href="/paket">299 TL — Paketi aktive et</Link>
|
||||
</Button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (!rapor) return null;
|
||||
|
||||
return (
|
||||
<section
|
||||
className={`mt-16 ${acilis && !kilitli ? "kt-unblur" : ""}`}
|
||||
aria-label="Tercih listen"
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="font-heading text-2xl font-bold">
|
||||
24 Tercihlik Listen
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
{sira.toLocaleString("tr-TR")}. sıra ·{" "}
|
||||
{kilitli
|
||||
? `tüm taban verileri açık · ${ACIK_SATIR} AI analiz örneği`
|
||||
: `${kredi} kredin kaldı`}
|
||||
</p>
|
||||
</div>
|
||||
{!kilitli ? (
|
||||
<Button asChild variant="outline" className="cursor-pointer">
|
||||
<Link href="/rapor/yazdir" target="_blank">
|
||||
<FileText className="size-4" aria-hidden />
|
||||
PDF indir
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Harita: 24 tercihin coğrafyası — ile dokun, üniversiteleri gör */}
|
||||
<div ref={haritaRef} className="mt-5 scroll-mt-24">
|
||||
<TercihHaritasi
|
||||
pinler={pinVerisi?.pinler ?? []}
|
||||
seciliIl={seciliIl}
|
||||
onSeciliIlDegisti={setSeciliIl}
|
||||
onTercihSec={setAcikSira}
|
||||
/>
|
||||
{pinVerisi && pinVerisi.haritaDisi > 0 ? (
|
||||
<p className="mt-1 px-1 text-xs text-slate-500">
|
||||
{pinVerisi.haritaDisi} tercih harita dışında (KKTC vb.)
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<RaporListesi
|
||||
rapor={rapor.rapor}
|
||||
adaySira={rapor.params.sira}
|
||||
acikSira={acikSira}
|
||||
onHaritadaGor={(il) => {
|
||||
if (!il) return;
|
||||
setSeciliIl(il);
|
||||
haritaRef.current?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "start",
|
||||
});
|
||||
}}
|
||||
kilitliBaslangic={kilitli ? ACIK_SATIR : undefined}
|
||||
kilitOverlay={
|
||||
<div className="flex flex-col items-start gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex gap-3">
|
||||
<Lock
|
||||
className="mt-1 size-5 shrink-0 text-orange-700"
|
||||
aria-hidden
|
||||
/>
|
||||
<div>
|
||||
<p className="font-heading font-bold text-slate-900">
|
||||
Tüm program ve taban verileri ücretsiz
|
||||
</p>
|
||||
<p className="mt-1 max-w-xl text-sm text-slate-600">
|
||||
Paket; kalan {24 - ACIK_SATIR} tercih için kişisel risk,
|
||||
gerekçe, PDF, 2 revizyon ve 60 AI danışman sorusunu açar.
|
||||
</p>
|
||||
{kredi > 0 ? (
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
Kalan {kredi} deneme kredin hesabında duruyor.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="h-12 w-full shrink-0 cursor-pointer bg-orange-500 px-8 text-white transition-colors duration-200 hover:bg-orange-600 sm:w-auto"
|
||||
>
|
||||
<Link href="/paket">299 TL — Analizleri aç</Link>
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!kilitli ? (
|
||||
<div className="mt-6">
|
||||
<RevizyonKutusu
|
||||
kalanHak={MAX_REVIZYON - rapor.revisionCount}
|
||||
onRevize={onRevize}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* AI danışman: sağ altta yüzen buton, alttan popup olarak açılır */}
|
||||
<SohbetPopup kredi={kredi} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function UretimGovdesi() {
|
||||
const [mesajIdx, setMesajIdx] = useState(0);
|
||||
const govdeRef = useRef<HTMLElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setMesajIdx((i) => Math.min(i + 1, URETIM_MESAJLARI.length - 1));
|
||||
}, 7000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// Modal geçiş anında unmount edildiğinden Radix odağı tetikleyiciye geri
|
||||
// veremiyor; odağı panele alıp klavye ve ekran okuyucu akışını sürdürüyoruz.
|
||||
useEffect(() => {
|
||||
govdeRef.current?.focus({ preventScroll: true });
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={govdeRef}
|
||||
tabIndex={-1}
|
||||
aria-live="polite"
|
||||
className="mt-16 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>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
// /sonuc senkron SQLite sorgularıyla açılır; yavaş bağlantıda beyaz ekran
|
||||
// yerine sayfanın iskeletini göster.
|
||||
export default function SonucLoading() {
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-6xl px-4 py-10">
|
||||
<Skeleton className="h-6 w-40 rounded-full" />
|
||||
<Skeleton className="mt-4 h-9 w-3/4 max-w-xl" />
|
||||
<Skeleton className="mt-3 h-5 w-2/3 max-w-lg" />
|
||||
<Skeleton className="mt-8 h-36 w-full rounded-2xl" />
|
||||
<div className="mt-8 flex flex-col gap-2">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +1,47 @@
|
||||
import Link from "next/link";
|
||||
import type { Metadata } from "next";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
MapPin,
|
||||
Minus,
|
||||
Rocket,
|
||||
Scale,
|
||||
ShieldCheck,
|
||||
TrendingDown,
|
||||
TrendingUp,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { eq } from "drizzle-orm";
|
||||
import {
|
||||
PUAN_TURLERI,
|
||||
rankWindowFacets,
|
||||
searchByRank,
|
||||
rankWindowFacets,
|
||||
type Program,
|
||||
type PuanTuruKey,
|
||||
} from "@/lib/db";
|
||||
import { getSession, getCurrentUser } from "@/lib/session";
|
||||
import { appDb, schema } from "@/lib/appdb";
|
||||
import type { RaporSonuc } from "@/lib/ai/rapor";
|
||||
import type { RaporParams } from "@/lib/rapor-havuzu";
|
||||
import { raporMaskele } from "@/lib/rapor-maske";
|
||||
import { SihirbazBolumu } from "./sihirbaz-cagri-karti";
|
||||
import { FunnelBanner } from "./funnel-banner";
|
||||
import type { MevcutRapor } from "./liste-paneli";
|
||||
import type {
|
||||
MevcutRapor,
|
||||
OnizlemeSatir,
|
||||
} from "./sihirbaz-modal";
|
||||
import { BolgeHaritasi, type IlOzeti } from "@/components/bolge-haritasi";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ProgramTablosu } from "@/components/program-tablosu";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Tercih Listeni Kur — KolayTercih",
|
||||
title: "Sıralamana Uygun Programlar — KolayTercih",
|
||||
};
|
||||
|
||||
const TUR_LABELS: Record<PuanTuruKey, string> = {
|
||||
@@ -32,15 +52,107 @@ const TUR_LABELS: Record<PuanTuruKey, string> = {
|
||||
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({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{
|
||||
sira?: string;
|
||||
tur?: string;
|
||||
il?: string;
|
||||
sihirbaz?: string;
|
||||
acildi?: string;
|
||||
hazir?: string;
|
||||
}>;
|
||||
}) {
|
||||
const params = await searchParams;
|
||||
@@ -49,6 +161,7 @@ export default async function SonucPage({
|
||||
params.tur && params.tur in PUAN_TURLERI
|
||||
? (params.tur as PuanTuruKey)
|
||||
: "say";
|
||||
const seciliIl = params.il?.trim() || null;
|
||||
|
||||
if (!Number.isFinite(sira) || sira < 1 || sira > 4_000_000) {
|
||||
return (
|
||||
@@ -57,7 +170,7 @@ export default async function SonucPage({
|
||||
Geçerli bir sıralama gerekli
|
||||
</h1>
|
||||
<p className="mt-3 text-slate-600">
|
||||
Listeni kurabilmek için ana sayfadan YKS başarı sıralamanı gir.
|
||||
Sonuçları görebilmek için ana sayfadan YKS başarı sıralamanı gir.
|
||||
</p>
|
||||
<Button asChild className="mt-6 cursor-pointer">
|
||||
<Link href="/">
|
||||
@@ -79,27 +192,84 @@ export default async function SonucPage({
|
||||
: null;
|
||||
|
||||
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
|
||||
? {
|
||||
rapor: hasPaket
|
||||
? (raporSatiri.result as RaporSonuc)
|
||||
: raporMaskele(raporSatiri.result as RaporSonuc),
|
||||
rapor: raporSatiri.result as RaporSonuc,
|
||||
params: raporSatiri.params as RaporParams,
|
||||
revisionCount: raporSatiri.revisionCount,
|
||||
}
|
||||
: 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 (
|
||||
<div className="min-h-screen bg-slate-50 text-slate-900">
|
||||
<main className="mx-auto max-w-4xl px-4 py-12">
|
||||
<main
|
||||
className={`mx-auto max-w-6xl px-4 py-12 ${mevcutRapor ? "" : "pb-56"}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Badge variant="secondary" className="bg-primary/10 text-primary">
|
||||
{sira.toLocaleString("tr-TR")}. sıra · {TUR_LABELS[turKey]}
|
||||
Ücretsiz ön izleme
|
||||
</Badge>
|
||||
<Button
|
||||
asChild
|
||||
@@ -114,39 +284,110 @@ export default async function SonucPage({
|
||||
</Button>
|
||||
</div>
|
||||
<h1 className="mt-4 font-heading text-3xl font-bold sm:text-4xl">
|
||||
{sira.toLocaleString("tr-TR")}. sıradaki bir aday için tercih planı
|
||||
{sira.toLocaleString("tr-TR")}. sıradaki bir aday için görünüm
|
||||
</h1>
|
||||
<p className="mt-2 max-w-2xl text-slate-600">
|
||||
Birkaç adımda ilgi alanlarını ve şehir tercihini seç; yapay zekâ
|
||||
gerçek YÖK Atlas verisiyle 24 tercihlik dengeli listeni kursun. Her
|
||||
satır yerleşme riskine göre renklenir:{" "}
|
||||
<span className="font-semibold text-emerald-600">yeşil güvenli</span>,{" "}
|
||||
<span className="font-semibold text-amber-600">sarı az riskli</span>,{" "}
|
||||
<span className="font-semibold text-red-600">kırmızı riskli</span>.
|
||||
2025 YÖK Atlas taban sıralamalarına göre üç dilime ayrılmış öneriler.
|
||||
Taban sıralamaları her yıl değişir; bu liste garanti değil, başlangıç
|
||||
noktasıdır.
|
||||
</p>
|
||||
|
||||
{/* Satın alım funnel'ı: değişen cümlelerle adayı sihirbaza (rapor
|
||||
varsa /listem paywall'ına) iten banner — paketli kullanıcıya
|
||||
satacak bir şey kalmadığından gösterilmez */}
|
||||
{!hasPaket ? <FunnelBanner hedefListem={Boolean(mevcutRapor)} /> : null}
|
||||
{/* Puan türü seçimi */}
|
||||
<nav className="mt-6 flex flex-wrap gap-2" aria-label="Puan türü">
|
||||
{(Object.keys(PUAN_TURLERI) as PuanTuruKey[]).map((key) => (
|
||||
<Button
|
||||
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>
|
||||
|
||||
{/* AI'dan bağımsız ham tablo + manuel liste haritası — sayfanın ana
|
||||
gövdesi; puan türü seçici de listenin başında yaşar */}
|
||||
<ProgramTablosu sonuclar={hamSonuclar} adaySira={sira} tur={turKey} />
|
||||
{seciliIl ? (
|
||||
<div className="mt-4 flex flex-wrap items-center gap-3 rounded-xl border border-primary/30 bg-primary/5 px-4 py-3">
|
||||
<MapPin className="size-4 text-primary" aria-hidden />
|
||||
<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}
|
||||
|
||||
{/* Sihirbaz akışı (CTA + modal + AI listesi) — blurlu AI paneli
|
||||
sayfanın en altında yaşar */}
|
||||
{/* Bölge haritası */}
|
||||
<section aria-labelledby="baslik-harita" className="mt-10">
|
||||
<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
|
||||
sira={sira}
|
||||
tur={turKey}
|
||||
facetler={facetler}
|
||||
girisliMi={Boolean(session)}
|
||||
hasPaket={hasPaket}
|
||||
hasPaket={Boolean(user?.hasPaket)}
|
||||
kredi={user?.creditBalance ?? 0}
|
||||
mevcutRapor={mevcutRapor}
|
||||
onizlemeSatirlari={onizlemeSatirlari}
|
||||
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">
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState, useSyncExternalStore } from "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 type { SihirbazFacetleri } from "@/lib/db";
|
||||
import {
|
||||
@@ -14,10 +9,11 @@ import {
|
||||
sihirbazDogrula,
|
||||
type SihirbazSecimleri,
|
||||
} from "@/lib/sihirbaz";
|
||||
import { SihirbazModal } from "./sihirbaz-modal";
|
||||
import { SIHIRBAZ_AC_EVENT } from "./funnel-banner";
|
||||
import { ListePaneli, type MevcutRapor, type PanelDurum } from "./liste-paneli";
|
||||
import { listeOlustur, listeRevize } from "./actions";
|
||||
import {
|
||||
SihirbazModal,
|
||||
type MevcutRapor,
|
||||
type OnizlemeSatir,
|
||||
} from "./sihirbaz-modal";
|
||||
|
||||
const KAPATMA_KEY = "kolaytercih.sihirbaz-karti-kapali";
|
||||
|
||||
@@ -36,16 +32,11 @@ type BekleyenSecim = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Sonuç sayfasında sihirbaz akışını yöneten client orkestratör:
|
||||
* - Sayfa ortasındaki büyük CTA + sabit alt çağrı kartı (rapor yokken)
|
||||
* - Adımları toplayan modal
|
||||
* - Üretim/önizleme/liste paneli (sayfa gövdesinde)
|
||||
* Akış: adımlar → kredi ile üretim → paketsizse progressive-blur önizleme,
|
||||
* paketliyse tam liste. Girişsiz kullanıcı hero'dan seçimleri tamamlanmış
|
||||
* gelirse önce ücretsiz tabloyu görür; giriş CTA'sı "hazır seçimlerin
|
||||
* kaydedilmesi" olarak panelde sunulur, girişten dönüşte üretim otomatik
|
||||
* başlar. Modal içinden ilerleyen girişsiz kullanıcı ise butonun söylediği
|
||||
* gibi doğrudan girişe gider.
|
||||
* Sonuç sayfasında sihirbaz akışını yöneten client wrapper:
|
||||
* - Sayfa ortasındaki büyük CTA (satır içi)
|
||||
* - Sabit alt çağrı kartı
|
||||
* - Modal
|
||||
* hepsi tek `acik` state'ini paylaşır.
|
||||
*/
|
||||
export function SihirbazBolumu({
|
||||
sira,
|
||||
@@ -53,11 +44,10 @@ export function SihirbazBolumu({
|
||||
facetler,
|
||||
girisliMi,
|
||||
hasPaket,
|
||||
kredi: baslangicKredi,
|
||||
kredi,
|
||||
mevcutRapor,
|
||||
onizlemeSatirlari,
|
||||
otomatikAc,
|
||||
acilis,
|
||||
secimlerHazir,
|
||||
}: {
|
||||
sira: number;
|
||||
tur: string;
|
||||
@@ -66,40 +56,18 @@ export function SihirbazBolumu({
|
||||
hasPaket: boolean;
|
||||
kredi: number;
|
||||
mevcutRapor: MevcutRapor | null;
|
||||
onizlemeSatirlari: OnizlemeSatir[];
|
||||
/** ?sihirbaz=1 ile gelindiyse modalı otomatik aç */
|
||||
otomatikAc?: boolean;
|
||||
/** ?acildi=1 (ödeme dönüşü): blur çözülme animasyonuyla aç */
|
||||
acilis?: boolean;
|
||||
/** ?hazir=1 (hero sihirbazından girişsiz geliş): seçimler storage'da,
|
||||
panel giriş CTA'sına döner. URL'den gelir ki SSR'da doğru panel
|
||||
render edilsin — mount sonrası anahtar değişimi animasyonu donduruyor. */
|
||||
secimlerHazir?: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const monteEdildi = useMonteEdildi();
|
||||
const hareketiAzalt = useReducedMotion();
|
||||
|
||||
const [elleAcik, setElleAcik] = useState(false);
|
||||
const [elleKapandi, setElleKapandi] = useState(false);
|
||||
const [kartGizle, setKartGizle] = useState(false);
|
||||
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
|
||||
// (dış store'dan başlangıç senkronizasyonu — bir kez).
|
||||
useEffect(() => {
|
||||
try {
|
||||
const kaliciKapali = Boolean(sessionStorage.getItem(KAPATMA_KEY));
|
||||
@@ -125,158 +93,11 @@ export function SihirbazBolumu({
|
||||
} catch {}
|
||||
}, [sira, tur]);
|
||||
|
||||
// Üstteki funnel banner'ının CTA'sı sihirbaz modalını bu olayla açar
|
||||
useEffect(() => {
|
||||
const ac = () => {
|
||||
setElleAcik(true);
|
||||
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"));
|
||||
// Giriş/ödeme dönüşünde otomatik üretim: eşleşen bekleyen seçim + paket
|
||||
const otomatikUret = Boolean(bekleyen?.eslesir && girisliMi && hasPaket);
|
||||
// Açık durumu türetilmiş: elle veya otomatik tetik, kullanıcı kapatmadıkça
|
||||
const acik = !elleKapandi && (elleAcik || Boolean(otomatikAc) || otomatikUret);
|
||||
const baslangicSecimler = bekleyen?.eslesir ? bekleyen.secimler : null;
|
||||
|
||||
function acikDegisti(v: boolean) {
|
||||
if (v) {
|
||||
@@ -288,196 +109,60 @@ export function SihirbazBolumu({
|
||||
}
|
||||
}
|
||||
|
||||
const panelde = Boolean(rapor) || durum !== "bos";
|
||||
// Üretim başladıysa storage'ı temizle (modal artık state'te tutuyor)
|
||||
useEffect(() => {
|
||||
if (acik && otomatikUret) {
|
||||
try {
|
||||
localStorage.removeItem(SIHIRBAZ_STORAGE_KEY);
|
||||
} catch {}
|
||||
}
|
||||
}, [acik, otomatikUret]);
|
||||
|
||||
// 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";
|
||||
const raporVar = Boolean(mevcutRapor);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* AI çıktısı (rapor listesi/haritası) bu sayfada render edilmez;
|
||||
panel yalnızca üretim ve kredi-yetersiz gibi geçici funnel
|
||||
durumlarını gösterir, listenin kendisi /listem'de yaşar. */}
|
||||
<div ref={bolumRef} className="relative">
|
||||
<AnimatePresence mode="popLayout" initial={false}>
|
||||
<motion.div
|
||||
key={panelAnahtari}
|
||||
initial={
|
||||
hareketiAzalt
|
||||
? { opacity: 0 }
|
||||
: {
|
||||
opacity: 0,
|
||||
transform: "translate3d(0, 8px, 0)",
|
||||
}
|
||||
}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
transform: "translate3d(0, 0, 0)",
|
||||
transition: hareketiAzalt
|
||||
? { duration: 0.12, ease: "easeOut" }
|
||||
: {
|
||||
duration: 0.28,
|
||||
ease: [0.23, 1, 0.32, 1],
|
||||
},
|
||||
}}
|
||||
exit={
|
||||
hareketiAzalt
|
||||
? {
|
||||
opacity: 0,
|
||||
transition: { duration: 0.08, ease: "easeOut" },
|
||||
}
|
||||
: {
|
||||
opacity: 0,
|
||||
transform: "translate3d(0, -4px, 0)",
|
||||
transition: {
|
||||
duration: 0.14,
|
||||
ease: [0.4, 0, 0.7, 0.2],
|
||||
},
|
||||
}
|
||||
}
|
||||
>
|
||||
{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>
|
||||
{/* Sayfa ortasındaki büyük CTA */}
|
||||
<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">
|
||||
{raporVar
|
||||
? "24 tercihlik listen hazır"
|
||||
: "Bu listeyi 24 tercihlik plana çevirelim mi?"}
|
||||
</h2>
|
||||
<p className="mx-auto mt-3 max-w-xl text-white/90">
|
||||
{raporVar
|
||||
? "Listeni görüntüle, revize et veya listen hakkında soru sor."
|
||||
: "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 />
|
||||
{raporVar ? "Listemi görüntüle" : "Listemi oluştur"}
|
||||
</Button>
|
||||
</section>
|
||||
|
||||
{/* Sabit alt çağrı kartı — liste/panel yokken ve kart kapatılmadıysa */}
|
||||
{monteEdildi && !panelde && !kartGizle && !acik ? (
|
||||
{/* Sabit alt çağrı kartı — rapor yoksa ve kart kapatılmadıysa */}
|
||||
{monteEdildi && !raporVar && !kartGizle && !acik ? (
|
||||
<div className="pointer-events-none fixed inset-x-0 bottom-4 z-40 px-4">
|
||||
<div className="pointer-events-auto mx-auto flex max-w-xl items-center gap-4 rounded-2xl border border-slate-200 bg-white p-4 shadow-xl">
|
||||
<div className="flex-1">
|
||||
<p className="font-heading text-sm font-bold text-slate-900">
|
||||
{girisBekliyor
|
||||
? "Seçimlerin kayıtlı — AI listen hazır bekliyor"
|
||||
: "3 adımda 24 tercihlik listen"}
|
||||
3 adımda 24 tercihlik listen
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{girisBekliyor
|
||||
? "Ücretsiz giriş yap, 5 deneme kredinle listen otomatik kurulsun."
|
||||
: "Sıralamana uygun bölümleri seç, yapay zekâ listeni kursun."}
|
||||
Sıralamana uygun bölümleri seç, yapay zekâ listeni kursun.
|
||||
</p>
|
||||
</div>
|
||||
{girisBekliyor ? (
|
||||
<Button
|
||||
asChild
|
||||
size="sm"
|
||||
className="cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||
>
|
||||
<Link href={girisUrl}>Giriş yap</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => acikDegisti(true)}
|
||||
className="cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||
>
|
||||
Başla
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => acikDegisti(true)}
|
||||
className="cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||
>
|
||||
Başla
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Kapat"
|
||||
@@ -495,20 +180,20 @@ export function SihirbazBolumu({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Üretim başladığında modalı kaldır; yeni panelin geçişini Motion
|
||||
üstlenir ve odağı panel içeriğine taşır. */}
|
||||
{monteEdildi && durum === "bos" ? (
|
||||
{monteEdildi ? (
|
||||
<SihirbazModal
|
||||
acik={acik}
|
||||
onAcikDegisti={acikDegisti}
|
||||
sira={sira}
|
||||
tur={tur}
|
||||
facetler={facetler}
|
||||
baslangicSecimler={
|
||||
sonSecimler ?? (bekleyen?.eslesir ? bekleyen.secimler : null)
|
||||
}
|
||||
sonButonEtiketi={
|
||||
girisliMi ? "Listemi oluştur (3 kredi)" : "Giriş yap ve listeni gör"
|
||||
}
|
||||
onTamamla={ilerle}
|
||||
girisliMi={girisliMi}
|
||||
hasPaket={hasPaket}
|
||||
kredi={kredi}
|
||||
mevcutRapor={mevcutRapor}
|
||||
onizlemeSatirlari={onizlemeSatirlari}
|
||||
otomatikUret={otomatikUret}
|
||||
baslangicSecimler={baslangicSecimler}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
|
||||
@@ -1,52 +1,636 @@
|
||||
"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 {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { SihirbazAdimlar } from "@/components/sihirbaz-adimlar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { RaporListesi } from "@/components/rapor-listesi";
|
||||
import { SoruKutusu } from "./soru-kutusu";
|
||||
import type { SihirbazFacetleri } from "@/lib/db";
|
||||
import type { SihirbazSecimleri } from "@/lib/sihirbaz";
|
||||
import type { RaporSonuc } from "@/lib/ai/rapor";
|
||||
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({
|
||||
acik,
|
||||
onAcikDegisti,
|
||||
sira,
|
||||
tur,
|
||||
facetler,
|
||||
girisliMi,
|
||||
hasPaket,
|
||||
kredi: baslangicKredi,
|
||||
mevcutRapor,
|
||||
onizlemeSatirlari,
|
||||
otomatikUret,
|
||||
baslangicSecimler,
|
||||
sonButonEtiketi,
|
||||
onTamamla,
|
||||
}: {
|
||||
acik: boolean;
|
||||
onAcikDegisti: (acik: boolean) => void;
|
||||
sira: number;
|
||||
tur: string;
|
||||
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;
|
||||
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 (
|
||||
<Dialog open={acik} onOpenChange={onAcikDegisti}>
|
||||
<DialogContent
|
||||
data-sihirbaz-modal
|
||||
className="max-h-[92dvh] gap-0 overflow-y-auto p-6 sm:max-w-3xl"
|
||||
>
|
||||
<DialogContent className="max-h-[92dvh] gap-0 overflow-y-auto p-6 sm:max-w-3xl">
|
||||
<DialogTitle className="sr-only">Tercih listesi sihirbazı</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
Sıralamana uygun 24 tercihlik listeni oluştur.
|
||||
</DialogDescription>
|
||||
<SihirbazAdimlar
|
||||
facetler={facetler}
|
||||
baslangicSecimler={baslangicSecimler}
|
||||
sonButonEtiketi={sonButonEtiketi}
|
||||
onTamamla={onTamamla}
|
||||
/>
|
||||
{faz === "adimlar"
|
||||
? adimlarGovde
|
||||
: faz === "onizleme"
|
||||
? onizlemeGovde
|
||||
: faz === "uretiliyor"
|
||||
? uretiliyorGovde
|
||||
: listeGovde}
|
||||
</DialogContent>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
"use client";
|
||||
|
||||
// AI danışman sohbeti — sayfa altında yüzen buton + intercom tarzı popup.
|
||||
// Motion dili: panel tetikleyicisinden doğar (transform-origin sağ-alt),
|
||||
// spring ile açılır; çıkış girişten hızlı. Reduced motion'da cross-fade.
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
222
src/app/sonuc/soru-kutusu.tsx
Normal file
222
src/app/sonuc/soru-kutusu.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
182
src/components/bolge-haritasi.tsx
Normal file
182
src/components/bolge-haritasi.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,344 +0,0 @@
|
||||
"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 "paid" sipariş oluşturup /odeme/sonuc'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>
|
||||
);
|
||||
}
|
||||
@@ -2,176 +2,49 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ArrowRight, Loader2 } from "lucide-react";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
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() {
|
||||
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 { data: oturum } = authClient.useSession();
|
||||
const girisli = Boolean(oturum?.user);
|
||||
|
||||
// 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) {
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const value = Number(siralama.replace(/\./g, ""));
|
||||
if (!value || value < 1 || value > 4_000_000) {
|
||||
setHata("Geçerli bir başarı sıralaması gir (ör. 85.000).");
|
||||
toast.error("Geçerli bir başarı sıralaması gir (ör. 85.000).");
|
||||
return;
|
||||
}
|
||||
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`,
|
||||
);
|
||||
router.push(`/sonuc?sira=${value}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-md flex-col items-center gap-3">
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="Puan türü"
|
||||
className="flex flex-wrap justify-center gap-1.5"
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex w-full max-w-md 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) => 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"
|
||||
>
|
||||
{PUAN_TURLERI_SECENEK.map((t) => (
|
||||
<button
|
||||
key={t.deger}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={tur === t.deger}
|
||||
onClick={() => setTur(t.deger)}
|
||||
className={
|
||||
tur === t.deger
|
||||
? "cursor-pointer rounded-full border border-primary bg-primary px-3 py-1.5 text-xs font-medium text-white transition-colors duration-200"
|
||||
: "cursor-pointer rounded-full border border-slate-200 bg-white px-3 py-1.5 text-xs text-slate-600 transition-colors duration-200 hover:border-slate-300 hover:bg-slate-50"
|
||||
}
|
||||
>
|
||||
{t.etiket}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
Listemi oluştur
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,357 +0,0 @@
|
||||
"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ı aç
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
"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();
|
||||
}
|
||||
@@ -1,452 +0,0 @@
|
||||
"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 açı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'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 2021–2025 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>
|
||||
);
|
||||
}
|
||||
@@ -1,434 +1,82 @@
|
||||
"use client";
|
||||
|
||||
// 24'lük listenin gövdesi. Program adı, 2024/2025 tabanları ve ham trend
|
||||
// herkese görünür. AI gerekçesi ile kişisel risk, kilitliBaslangic sonrasında
|
||||
// ü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 { Rocket, Scale, ShieldCheck } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
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 }
|
||||
export const DILIM_STIL: Record<
|
||||
string,
|
||||
{ label: string; cls: string; icon: typeof Rocket }
|
||||
> = {
|
||||
riskli: { nokta: "bg-red-500", kenar: "border-l-red-500", icon: Rocket },
|
||||
"az-riskli": {
|
||||
nokta: "bg-amber-500",
|
||||
kenar: "border-l-amber-500",
|
||||
icon: Scale,
|
||||
},
|
||||
guvenli: {
|
||||
nokta: "bg-emerald-500",
|
||||
kenar: "border-l-emerald-500",
|
||||
hayal: { label: "Hayal", cls: "bg-orange-100 text-orange-700", icon: Rocket },
|
||||
dengeli: { label: "Dengeli", cls: "bg-blue-100 text-blue-700", icon: Scale },
|
||||
garanti: {
|
||||
label: "Garanti",
|
||||
cls: "bg-emerald-100 text-emerald-700",
|
||||
icon: ShieldCheck,
|
||||
},
|
||||
};
|
||||
|
||||
const YILLAR = [2021, 2022, 2023, 2024, 2025];
|
||||
|
||||
type Trend = "zorlasiyor" | "kolaylasiyor" | "stabil";
|
||||
|
||||
/** Deterministik trend: ilk ve son bilinen taban sıralaması kıyaslanır.
|
||||
* Sıralama sayısı küçüldüyse bölüm zorlaşıyor demektir. */
|
||||
function trendYonu(gecmis?: (number | null)[]): Trend | null {
|
||||
const dolu = (gecmis ?? []).filter((s): s is number => s != null);
|
||||
if (dolu.length < 2) return null;
|
||||
const oran = dolu[dolu.length - 1] / dolu[0];
|
||||
if (oran < 0.88) return "zorlasiyor";
|
||||
if (oran > 1.14) return "kolaylasiyor";
|
||||
return "stabil";
|
||||
}
|
||||
|
||||
const TREND_GORUNUM: Record<
|
||||
Trend,
|
||||
{ icon: typeof Minus; etiket: string; renk: string }
|
||||
> = {
|
||||
zorlasiyor: {
|
||||
icon: TrendingUp,
|
||||
etiket: "Taban zorlaşıyor",
|
||||
renk: "text-red-500",
|
||||
},
|
||||
kolaylasiyor: {
|
||||
icon: TrendingDown,
|
||||
etiket: "Taban rahatlıyor",
|
||||
renk: "text-emerald-600",
|
||||
},
|
||||
stabil: { icon: Minus, etiket: "Taban stabil", renk: "text-slate-400" },
|
||||
};
|
||||
|
||||
/** Son 5 yılın taban sıralaması mini çizgisi (detay panelinde). */
|
||||
function TrendCizgisi({ gecmis }: { gecmis: (number | null)[] }) {
|
||||
const noktalar = YILLAR.map((yil, i) => ({ yil, sira: gecmis[i] })).filter(
|
||||
(n): n is { yil: number; sira: number } => n.sira != null,
|
||||
);
|
||||
if (noktalar.length < 2) return null;
|
||||
|
||||
const W = 150;
|
||||
const H = 40;
|
||||
const min = Math.min(...noktalar.map((n) => n.sira));
|
||||
const max = Math.max(...noktalar.map((n) => n.sira));
|
||||
const aralik = Math.max(1, max - min);
|
||||
// Küçük sıralama = daha zor bölüm = çizgide yukarıda
|
||||
const nokta = (i: number, sira: number) => ({
|
||||
x: 6 + (i / (noktalar.length - 1)) * (W - 12),
|
||||
y: 8 + ((sira - min) / aralik) * (H - 16),
|
||||
});
|
||||
const path = noktalar
|
||||
.map((n, i) => {
|
||||
const { x, y } = nokta(i, n.sira);
|
||||
return `${i === 0 ? "M" : "L"} ${x.toFixed(1)} ${y.toFixed(1)}`;
|
||||
})
|
||||
.join(" ");
|
||||
const son = nokta(noktalar.length - 1, noktalar[noktalar.length - 1].sira);
|
||||
|
||||
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 2021–2025 ç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">
|
||||
2021–2025 taban trendi
|
||||
</dt>
|
||||
<dd className="mt-1 text-slate-700">
|
||||
{p?.siraGecmisi ? (
|
||||
<TrendCizgisi gecmis={p.siraGecmisi} />
|
||||
) : (
|
||||
"Yeterli veri yok."
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
{analizKilitli ? (
|
||||
<div className="flex flex-col justify-center rounded-lg border border-dashed border-slate-300 bg-white p-3 sm:col-span-2">
|
||||
<dt className="flex items-center gap-1.5 text-xs font-semibold text-slate-700">
|
||||
<Lock className="size-3.5" aria-hidden />
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 24'lük listenin gövdesi (genel değerlendirme + tercih kartları).
|
||||
* Hem sonuç modalında hem PDF/yazdır sayfasında kullanılır.
|
||||
*/
|
||||
export function RaporListesi({ rapor }: { rapor: RaporSonuc }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-wrap items-end justify-between gap-2">
|
||||
<div>
|
||||
<h2 className="font-heading text-lg font-bold">24 tercihin ve taban verileri</h2>
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
Satıra dokunarak 2021–2025 trendini inceleyebilirsin.
|
||||
</p>
|
||||
</div>
|
||||
{kilitIdx < rapor.tercihler.length ? (
|
||||
<p className="text-xs font-medium text-emerald-700">
|
||||
Programlar ve ham veriler ücretsiz
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs font-medium">
|
||||
{(
|
||||
[
|
||||
["hayal", "bg-red-500"],
|
||||
["dengeli", "bg-amber-500"],
|
||||
["garanti", "bg-emerald-500"],
|
||||
] as const
|
||||
).map(([dilim, nokta]) => (
|
||||
<span
|
||||
key={dilim}
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-slate-200 bg-white px-2.5 py-1 text-slate-700"
|
||||
>
|
||||
<span className={`size-2 rounded-full ${nokta}`} aria-hidden />
|
||||
{dilimSayilari[dilim] ?? 0} {DILIM_ETIKET[dilim].toLocaleLowerCase("tr-TR")}
|
||||
</span>
|
||||
))}
|
||||
<span className="text-slate-400">
|
||||
Liste hayal diliminden başlar; kırmızı satırlar plana dahildir.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{tekIl ? (
|
||||
<p className="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900">
|
||||
Listenin 24 tercihi de tek şehirde (
|
||||
{tekIl.toLocaleLowerCase("tr-TR")}). Boşta kalma riskini azaltmak
|
||||
için birkaç farklı şehir eklemeyi düşünebilirsin.
|
||||
<div className="rounded-2xl border border-slate-200 bg-white p-6">
|
||||
<h2 className="font-heading text-lg font-bold">Genel değerlendirme</h2>
|
||||
<p className="mt-2 whitespace-pre-line text-sm leading-relaxed text-slate-700">
|
||||
{rapor.genelDegerlendirme}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="mt-4 hidden grid-cols-[auto_minmax(220px,1fr)_92px_92px_112px_82px_auto] gap-x-3 px-4 text-[11px] font-semibold uppercase tracking-wide text-slate-500 xl:grid">
|
||||
<span className="w-7">#</span>
|
||||
<span>Program</span>
|
||||
<span className="text-right">2024 taban</span>
|
||||
<span className="text-right">2025 taban</span>
|
||||
<span>Ham trend</span>
|
||||
<span>AI risk</span>
|
||||
<span className="w-4" />
|
||||
</div>
|
||||
|
||||
<ol className="mt-2 flex flex-col gap-2">
|
||||
{rapor.tercihler.map((t, index) => (
|
||||
<Fragment key={t.sira}>
|
||||
{index === kilitIdx && kilitOverlay ? (
|
||||
<li className="my-2 list-none rounded-2xl border border-orange-200 bg-orange-50/70 p-5">
|
||||
{kilitOverlay}
|
||||
</li>
|
||||
) : null}
|
||||
{satir(t, index)}
|
||||
</Fragment>
|
||||
))}
|
||||
<ol className="mt-6 space-y-3">
|
||||
{rapor.tercihler.map((t) => {
|
||||
const p = rapor.programlar[t.programId];
|
||||
const stil = DILIM_STIL[t.dilim];
|
||||
return (
|
||||
<li
|
||||
key={t.sira}
|
||||
className="rounded-2xl border border-slate-200 bg-white p-5"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className="flex size-8 items-center justify-center rounded-full bg-slate-100 font-heading text-sm font-bold">
|
||||
{t.sira}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-semibold">{p?.isim ?? t.programId}</p>
|
||||
<p className="text-sm text-slate-500">
|
||||
{p?.universite} · {p?.il ?? "—"}
|
||||
{p?.unitur ? ` · ${p.unitur}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="secondary" className={stil.cls}>
|
||||
<stil.icon className="size-3" aria-hidden />
|
||||
{stil.label}
|
||||
</Badge>
|
||||
</div>
|
||||
<dl className="mt-3 grid gap-2 text-sm sm:grid-cols-3">
|
||||
<div className="rounded-lg bg-slate-50 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 bg-slate-50 p-3">
|
||||
<dt className="text-xs font-semibold text-slate-500">
|
||||
Risk notu
|
||||
</dt>
|
||||
<dd className="mt-1 text-slate-700">{t.riskNotu}</dd>
|
||||
</div>
|
||||
<div className="rounded-lg bg-slate-50 p-3">
|
||||
<dt className="text-xs font-semibold text-slate-500">
|
||||
4 yıllık trend
|
||||
</dt>
|
||||
<dd className="mt-1 text-slate-700">{t.trendOzeti}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,338 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,416 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
"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,
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
"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 }
|
||||
@@ -1,94 +0,0 @@
|
||||
"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 }
|
||||
@@ -1,499 +0,0 @@
|
||||
"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 }
|
||||
@@ -1,110 +0,0 @@
|
||||
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 }
|
||||
@@ -1,120 +0,0 @@
|
||||
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 }
|
||||
@@ -1,233 +0,0 @@
|
||||
"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,
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
"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 }
|
||||
@@ -1,42 +0,0 @@
|
||||
"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 }
|
||||
@@ -1,18 +0,0 @@
|
||||
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 }
|
||||
@@ -1,57 +0,0 @@
|
||||
"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 }
|
||||
@@ -1,243 +0,0 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
@@ -2,30 +2,24 @@ import Link from "next/link";
|
||||
import { Coins } from "lucide-react";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ListemButonu } from "@/components/manuel-liste/listem-butonu";
|
||||
import { SignOutButton } from "./sign-out-button";
|
||||
|
||||
export async function UserNav() {
|
||||
const u = await getCurrentUser();
|
||||
|
||||
// Manuel liste localStorage'ta yaşadığı için Listem girişsiz de çalışır
|
||||
if (!u) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<ListemButonu />
|
||||
<Button
|
||||
asChild
|
||||
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"
|
||||
>
|
||||
<Link href="/giris">Giriş yap</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
asChild
|
||||
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"
|
||||
>
|
||||
<Link href="/giris">Giriş yap</Link>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<ListemButonu />
|
||||
<Link
|
||||
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"
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
type AdayProgram,
|
||||
} from "../rapor-havuzu";
|
||||
import { secimOzeti } from "../sihirbaz";
|
||||
import { RISK_ETIKET, dilimdenRisk, riskHesapla } from "../risk";
|
||||
|
||||
const TercihSchema = z.object({
|
||||
programId: z.string(),
|
||||
@@ -23,25 +22,13 @@ const TercihSchema = z.object({
|
||||
const RaporSchema = z.object({
|
||||
tercihler: z.array(TercihSchema),
|
||||
genelDegerlendirme: z.string(),
|
||||
uyarilar: z.array(z.string()).min(2).max(3),
|
||||
});
|
||||
|
||||
// uyarilar şemada zorunlu ama eski kayıtlı raporlarda yok → tipte opsiyonel
|
||||
export type RaporSonuc = Omit<z.infer<typeof RaporSchema>, "uyarilar"> & {
|
||||
uyarilar?: string[];
|
||||
export type RaporSonuc = z.infer<typeof RaporSchema> & {
|
||||
// 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<
|
||||
string,
|
||||
{
|
||||
isim: string;
|
||||
universite: string;
|
||||
il: string | null;
|
||||
unitur: string | null;
|
||||
efektifSira?: number | null;
|
||||
siraGecmisi?: (number | null)[];
|
||||
}
|
||||
{ isim: string; universite: string; il: string | null; unitur: string | null }
|
||||
>;
|
||||
};
|
||||
|
||||
@@ -55,104 +42,17 @@ KURALLAR (kesin):
|
||||
- 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
|
||||
(küçük taban sıralaması = daha zor bölüm üstte).
|
||||
- METİN DİYETİ: aday 24 satır okuyacak; her alan TEK kısa cümle, dolgu söz yok.
|
||||
- gerekce: bu programın bu aday için neden mantıklı olduğu (TEK cümle,
|
||||
en fazla ~15 kelime, Türkçe).
|
||||
- riskNotu: yerleşme ihtimali değerlendirmesi (TEK kısa cümle). ASLA "garanti",
|
||||
"kesin", "yüzde yüz" deme; "yüksek/orta/düşük ihtimal" dili kullan.
|
||||
- trendOzeti: son 4 yılın taban sıralaması hareketi, TEK cümle (ör. "2022'den
|
||||
beri taban 45B'den 38B'ye indi, bölüm zorlaşıyor").
|
||||
- gerekce: bu programın bu aday için neden mantıklı olduğu (1-2 cümle, Türkçe).
|
||||
- riskNotu: yerleşme ihtimali değerlendirmesi. ASLA "garanti", "kesin",
|
||||
"yüzde yüz" deme; "yüksek/orta/düşük ihtimal" dili kullan.
|
||||
- trendOzeti: son 4 yılın taban sıralaması hareketi (ör. "2022'den beri taban
|
||||
sıralaması 45B'den 38B'ye indi, bölüm zorlaşıyor").
|
||||
- Kontenjan artışı/azalışı ve yerleşen sayısını değerlendirmene kat.
|
||||
- Adayın notlarındaki şehir/bölüm tercihlerine saygı göster ama dengeyi bozma.
|
||||
- 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.`;
|
||||
- genelDegerlendirme: listenin genel stratejisi + adaya 2-3 pratik uyarı (Türkçe).`;
|
||||
|
||||
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(
|
||||
params: RaporParams,
|
||||
revizyonNotu?: string,
|
||||
@@ -183,12 +83,6 @@ 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 havuzJson = havuzuKompaktJson(havuz);
|
||||
const kullaniciMesaji = [
|
||||
@@ -263,24 +157,22 @@ export async function raporUret(
|
||||
|
||||
const programlar: RaporSonuc["programlar"] = {};
|
||||
for (const p of havuz) {
|
||||
programlar[p.id] = programBilgisi(p);
|
||||
programlar[p.id] = {
|
||||
isim: p.isim,
|
||||
universite: p.universite,
|
||||
il: p.il,
|
||||
unitur: p.unitur,
|
||||
};
|
||||
}
|
||||
return { ...sonuc, programlar };
|
||||
}
|
||||
|
||||
export function listeOzetiCikar(rapor: RaporSonuc, adaySira?: number): string {
|
||||
export function listeOzetiCikar(rapor: RaporSonuc): string {
|
||||
return rapor.tercihler
|
||||
.map((t) => {
|
||||
const p = rapor.programlar[t.programId];
|
||||
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}`;
|
||||
})
|
||||
.map(
|
||||
(t) =>
|
||||
`${t.sira}. [${t.dilim}] ${rapor.programlar[t.programId]?.isim ?? "?"} — ${rapor.programlar[t.programId]?.universite ?? "?"}`,
|
||||
)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
|
||||
@@ -5,11 +5,6 @@ import { appDb, schema } from "./appdb";
|
||||
import { grantCredits, DENEME_KREDISI } from "./credits";
|
||||
|
||||
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) {
|
||||
// Dev fallback: Resend anahtarı yoksa linki terminale yaz
|
||||
console.log(`\n[giris] Magic link for ${email}:\n${url}\n`);
|
||||
|
||||
@@ -13,9 +13,6 @@ export const DENEME_KREDISI = 5;
|
||||
// Rapor üretimi/revizyonu başına düşen kredi
|
||||
export const RAPOR_KREDI = 3;
|
||||
|
||||
/** Paketle gelen geri bildirimli liste revizyonu hakkı */
|
||||
export const MAX_REVIZYON = 2;
|
||||
|
||||
export type SpendResult =
|
||||
| { ok: true }
|
||||
| { ok: false; error: "INSUFFICIENT" | "DUPLICATE" };
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
"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 };
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
// 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;
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
// 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 2021–2025 taban sıralamaları ham veridir.
|
||||
programlar: rapor.programlar,
|
||||
};
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
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";
|
||||
}
|
||||
@@ -1,249 +0,0 @@
|
||||
// Ü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],
|
||||
};
|
||||
Reference in New Issue
Block a user