Compare commits
1 Commits
d93cf742ff
...
feat/kolay
| Author | SHA1 | Date | |
|---|---|---|---|
| bc77178ece |
@@ -1,11 +1,11 @@
|
|||||||
---
|
---
|
||||||
name: ui-ux-pro-max
|
name: ui-ux-pro-max
|
||||||
description: "UI/UX design intelligence. 50 styles, 21 palettes, 50 font pairings, 20 charts, 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
|
# 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
|
## When to Apply
|
||||||
|
|
||||||
@@ -128,7 +128,7 @@ Extract key information from user request:
|
|||||||
- **Product type**: SaaS, e-commerce, portfolio, dashboard, landing page, etc.
|
- **Product type**: SaaS, e-commerce, portfolio, dashboard, landing page, etc.
|
||||||
- **Style keywords**: minimal, playful, professional, elegant, dark mode, etc.
|
- **Style keywords**: minimal, playful, professional, elegant, dark mode, etc.
|
||||||
- **Industry**: healthcare, fintech, gaming, education, etc.
|
- **Industry**: healthcare, fintech, gaming, education, etc.
|
||||||
- **Stack**: Next.js / React / shadcn (default: `nextjs`)
|
- **Stack**: React, Vue, Next.js, or default to `html-tailwind`
|
||||||
|
|
||||||
### Step 2: Generate Design System (REQUIRED)
|
### Step 2: Generate Design System (REQUIRED)
|
||||||
|
|
||||||
@@ -167,15 +167,15 @@ python3 .claude/skills/ui-ux-pro-max/scripts/search.py "<keyword>" --domain <dom
|
|||||||
| Alternative fonts | `typography` | `--domain typography "elegant luxury"` |
|
| Alternative fonts | `typography` | `--domain typography "elegant luxury"` |
|
||||||
| Landing structure | `landing` | `--domain landing "hero social-proof"` |
|
| Landing structure | `landing` | `--domain landing "hero social-proof"` |
|
||||||
|
|
||||||
### Step 4: Stack Guidelines (Default: 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
|
```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 |
|
| Stack | Focus |
|
||||||
|-------|-------|
|
|-------|-------|
|
||||||
| `nextjs` | SSR, routing, images, API routes (DEFAULT) |
|
| `html-tailwind` | Tailwind utilities, responsive, a11y (DEFAULT) |
|
||||||
| `react` | State, hooks, performance, patterns |
|
| `react` | State, hooks, performance, patterns |
|
||||||
|
| `nextjs` | SSR, routing, images, API routes |
|
||||||
|
| `vue` | Composition API, Pinia, Vue Router |
|
||||||
|
| `svelte` | Runes, stores, SvelteKit |
|
||||||
|
| `swiftui` | Views, State, Navigation, Animation |
|
||||||
|
| `react-native` | Components, Navigation, Lists |
|
||||||
|
| `flutter` | Widgets, State, Layout, Theming |
|
||||||
| `shadcn` | shadcn/ui components, theming, forms, patterns |
|
| `shadcn` | shadcn/ui components, theming, forms, patterns |
|
||||||
| `html-tailwind` | Tailwind utilities, responsive, a11y |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -215,7 +220,7 @@ Available stacks: `nextjs`, `react`, `shadcn`, `html-tailwind`
|
|||||||
- Product type: Beauty/Spa service
|
- Product type: Beauty/Spa service
|
||||||
- Style keywords: elegant, professional, soft
|
- Style keywords: elegant, professional, soft
|
||||||
- Industry: Beauty/Wellness
|
- Industry: Beauty/Wellness
|
||||||
- Stack: nextjs (default)
|
- Stack: html-tailwind (default)
|
||||||
|
|
||||||
### Step 2: Generate Design System (REQUIRED)
|
### 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
|
### Step 4: Stack Guidelines
|
||||||
|
|
||||||
```bash
|
```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.
|
**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"},
|
"html-tailwind": {"file": "stacks/html-tailwind.csv"},
|
||||||
"react": {"file": "stacks/react.csv"},
|
"react": {"file": "stacks/react.csv"},
|
||||||
"nextjs": {"file": "stacks/nextjs.csv"},
|
"nextjs": {"file": "stacks/nextjs.csv"},
|
||||||
|
"vue": {"file": "stacks/vue.csv"},
|
||||||
|
"nuxtjs": {"file": "stacks/nuxtjs.csv"},
|
||||||
|
"nuxt-ui": {"file": "stacks/nuxt-ui.csv"},
|
||||||
|
"svelte": {"file": "stacks/svelte.csv"},
|
||||||
|
"swiftui": {"file": "stacks/swiftui.csv"},
|
||||||
|
"react-native": {"file": "stacks/react-native.csv"},
|
||||||
|
"flutter": {"file": "stacks/flutter.csv"},
|
||||||
"shadcn": {"file": "stacks/shadcn.csv"}
|
"shadcn": {"file": "stacks/shadcn.csv"}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ Usage: python search.py "<query>" [--domain <domain>] [--stack <stack>] [--max-r
|
|||||||
python search.py "<query>" --design-system [-p "Project Name"]
|
python search.py "<query>" --design-system [-p "Project Name"]
|
||||||
|
|
||||||
Domains: style, prompt, color, chart, landing, product, ux, typography
|
Domains: style, prompt, color, chart, landing, product, ux, typography
|
||||||
Stacks: html-tailwind, react, nextjs, shadcn
|
Stacks: html-tailwind, react, nextjs
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -44,7 +44,7 @@ if __name__ == "__main__":
|
|||||||
parser = argparse.ArgumentParser(description="UI Pro Max Search")
|
parser = argparse.ArgumentParser(description="UI Pro Max Search")
|
||||||
parser.add_argument("query", help="Search query")
|
parser.add_argument("query", help="Search query")
|
||||||
parser.add_argument("--domain", "-d", choices=list(CSV_CONFIG.keys()), help="Search domain")
|
parser.add_argument("--domain", "-d", choices=list(CSV_CONFIG.keys()), help="Search domain")
|
||||||
parser.add_argument("--stack", "-s", choices=AVAILABLE_STACKS, help="Stack-specific search (html-tailwind, react, nextjs, 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("--max-results", "-n", type=int, default=MAX_RESULTS, help="Max results (default: 3)")
|
||||||
parser.add_argument("--json", action="store_true", help="Output as JSON")
|
parser.add_argument("--json", action="store_true", help="Output as JSON")
|
||||||
# Design system generation
|
# Design system generation
|
||||||
|
|||||||
@@ -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 is NOT the Next.js you know
|
||||||
|
|
||||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
||||||
|
|
||||||
# Push öncesi build zorunlu
|
|
||||||
|
|
||||||
Push emri geldiğinde önce lokalde `pnpm build` başarıyla geçmeli; geçmeden asla push etme. Kritik detay: çalışma ağacı kirliyken build, commit'lenmemiş dosyaları da gördüğü için CI'da kırılacak bir commit'i yakalayamayabilir. Bu yüzden build'i **push edilecek commit'in kendisi** üzerinde doğrula (ör. geçici `git worktree add <dir> HEAD` + `node_modules` symlink'i ile). Ayrıca kısmi commit atarken değişikliğin bağımlılık kapanışını kontrol et — bir dosyanın kullandığı prop/export başka bir commit'lenmemiş dosyadan geliyorsa onları da aynı commit'e dahil et.
|
|
||||||
<!-- END:nextjs-agent-rules -->
|
<!-- END:nextjs-agent-rules -->
|
||||||
|
|||||||
55
Dockerfile
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"]
|
|
||||||
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";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: "standalone",
|
|
||||||
serverExternalPackages: ["better-sqlite3", "iyzipay", "@libsql/client"],
|
serverExternalPackages: ["better-sqlite3", "iyzipay", "@libsql/client"],
|
||||||
async redirects() {
|
async redirects() {
|
||||||
// Eski akış /sonuc modalına taşındı. /rapor/yazdir korunur (tam eşleşme).
|
// Eski akış /sonuc modalına taşındı. /rapor/yazdir korunur (tam eşleşme).
|
||||||
return [
|
return [
|
||||||
|
{ source: "/sohbet", destination: "/sonuc", permanent: false },
|
||||||
{ source: "/rapor", destination: "/sonuc", permanent: false },
|
{ source: "/rapor", destination: "/sonuc", permanent: false },
|
||||||
{ source: "/sohbet", destination: "/listem", permanent: false },
|
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -21,23 +21,16 @@
|
|||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
"iyzipay": "^2.0.69",
|
"iyzipay": "^2.0.69",
|
||||||
"lucide-react": "^1.25.0",
|
"lucide-react": "^1.25.0",
|
||||||
"marked": "^18.0.7",
|
|
||||||
"motion": "^12.42.2",
|
|
||||||
"next": "16.2.10",
|
"next": "16.2.10",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"radix-ui": "^1.6.3",
|
"radix-ui": "^1.6.3",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
"react-markdown": "^10.1.0",
|
|
||||||
"remark-breaks": "^4.0.0",
|
|
||||||
"remark-gfm": "^4.0.1",
|
|
||||||
"resend": "^6.18.0",
|
"resend": "^6.18.0",
|
||||||
"shiki": "^4.3.1",
|
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"turkey-map-react": "^2.0.5",
|
"turkey-map-react": "^2.0.5",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"use-stick-to-bottom": "^1.1.6",
|
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
1109
pnpm-lock.yaml
generated
1109
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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 type { RaporParams } from "@/lib/rapor-havuzu";
|
||||||
import { secimOzeti } from "@/lib/sihirbaz";
|
import { secimOzeti } from "@/lib/sihirbaz";
|
||||||
import { PUAN_TURLERI } from "@/lib/db";
|
import { PUAN_TURLERI } from "@/lib/db";
|
||||||
import { ACIK_SATIR } from "@/lib/rapor-maske";
|
|
||||||
|
|
||||||
const MAX_GECMIS = 12;
|
const MAX_GECMIS = 12;
|
||||||
const MAX_MESAJ_UZUNLUK = 2000;
|
const MAX_MESAJ_UZUNLUK = 2000;
|
||||||
|
|
||||||
function sistemPromptu(opts: {
|
function sistemPromptu(opts: { params: RaporParams; listeOzeti: string }): string {
|
||||||
params: RaporParams;
|
|
||||||
listeOzeti: string;
|
|
||||||
kilitli?: boolean;
|
|
||||||
}): string {
|
|
||||||
const { params } = opts;
|
const { params } = opts;
|
||||||
const secimVar =
|
const secimVar =
|
||||||
(params.kategoriler?.length ?? 0) > 0 ||
|
(params.kategoriler?.length ?? 0) > 0 ||
|
||||||
@@ -34,10 +29,6 @@ iş imkânları, tercih sırası) net ve dürüst yanıtlamak.
|
|||||||
KURALLAR:
|
KURALLAR:
|
||||||
- Cevaplarını adayın 24'lük listesi ve seçimleri bağlamında ver; listedeki
|
- Cevaplarını adayın 24'lük listesi ve seçimleri bağlamında ver; listedeki
|
||||||
programlara referans verebilirsin.
|
programlara referans verebilirsin.
|
||||||
- Listedeki her satırda dilim ve risk etiketi var: Güvenli (yeşil) = taban
|
|
||||||
sıralaması adayın belirgin altında, Az riskli (sarı) = adayın sıralaması
|
|
||||||
civarında, Riskli (kırmızı) = geçen yıl adayın sıralamasının üzerinde
|
|
||||||
kapandı. Risk sorularında bu etiketleri ve taban sıralarını kullan.
|
|
||||||
- ASLA yerleşme garantisi verme; "yüksek/orta/düşük ihtimal" dili kullan.
|
- ASLA yerleşme garantisi verme; "yüksek/orta/düşük ihtimal" dili kullan.
|
||||||
- Bilmediğin güncel veriyi uydurma; emin değilsen YÖK Atlas'a bakmasını söyle.
|
- Bilmediğin güncel veriyi uydurma; emin değilsen YÖK Atlas'a bakmasını söyle.
|
||||||
- Taban sıralamalarının her yıl değiştiğini gerektiğinde hatırlat.
|
- Taban sıralamalarının her yıl değiştiğini gerektiğinde hatırlat.
|
||||||
@@ -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]}.`,
|
`ADAY BİLGİSİ: başarı sıralaması ${params.sira.toLocaleString("tr-TR")}, puan türü ${PUAN_TURLERI[params.tur]}.`,
|
||||||
`ADAYIN 24'LÜK LİSTESİ:\n${opts.listeOzeti}`,
|
`ADAYIN 24'LÜK LİSTESİ:\n${opts.listeOzeti}`,
|
||||||
];
|
];
|
||||||
if (opts.kilitli) {
|
|
||||||
parcalar.push(
|
|
||||||
`ÖNEMLİ: Adayın paketi yok; listenin yalnızca ilk ${ACIK_SATIR} tercihi açık.
|
|
||||||
Sana da yalnızca bu satırlar verildi — kilitli tercihlerin içeriğini BİLMİYORSUN,
|
|
||||||
asla tahmin edip söyleme. Kilitli satırlar sorulursa sempatik biçimde tam listenin
|
|
||||||
Tercih Dönemi Paketi'yle (299 TL, /paket) açıldığını söyle; açık satırlar ve genel
|
|
||||||
tercih soruları üzerinden yardımcı olmaya devam et.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (secimVar) {
|
if (secimVar) {
|
||||||
parcalar.push(
|
parcalar.push(
|
||||||
`ADAYIN SİHİRBAZ SEÇİMLERİ:\n${secimOzeti({
|
`ADAYIN SİHİRBAZ SEÇİMLERİ:\n${secimOzeti({
|
||||||
@@ -158,67 +140,11 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
const kullaniciMesajId = harcama.messageId!;
|
const kullaniciMesajId = harcama.messageId!;
|
||||||
|
|
||||||
// Paketsiz kullanıcının prompt'una yalnızca açık satırlar girer — aksi
|
|
||||||
// halde danışman kilitli listeyi sözle sızdırabilir.
|
|
||||||
const kullanici = await appDb.query.user.findFirst({
|
|
||||||
where: eq(schema.user.id, session.user.id),
|
|
||||||
columns: { hasPaket: true },
|
|
||||||
});
|
|
||||||
const kilitli = !kullanici?.hasPaket;
|
|
||||||
const tamOzet = listeOzetiCikar(
|
|
||||||
rapor.result as RaporSonuc,
|
|
||||||
(rapor.params as RaporParams).sira,
|
|
||||||
);
|
|
||||||
const listeOzeti = kilitli
|
|
||||||
? [
|
|
||||||
...tamOzet.split("\n").slice(0, ACIK_SATIR),
|
|
||||||
`… kalan ${24 - ACIK_SATIR} tercih kilitli.`,
|
|
||||||
].join("\n")
|
|
||||||
: tamOzet;
|
|
||||||
|
|
||||||
const system = sistemPromptu({
|
const system = sistemPromptu({
|
||||||
params: rapor.params as RaporParams,
|
params: rapor.params as RaporParams,
|
||||||
listeOzeti,
|
listeOzeti: listeOzetiCikar(rapor.result as RaporSonuc),
|
||||||
kilitli,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Dev'de API key yoksa danışman mock cevap stream'ler (prod'da asla)
|
|
||||||
if (
|
|
||||||
process.env.NODE_ENV !== "production" &&
|
|
||||||
!process.env.ANTHROPIC_API_KEY &&
|
|
||||||
!process.env.ANTHROPIC_AUTH_TOKEN
|
|
||||||
) {
|
|
||||||
const mockEncoder = new TextEncoder();
|
|
||||||
const mockUserId = session.user.id;
|
|
||||||
const cevap =
|
|
||||||
`(Dev mock — ANTHROPIC_API_KEY tanımlı değil.) Sorunu aldım: "${mesaj.slice(0, 120)}". ` +
|
|
||||||
`Gerçek danışman burada listen ve seçimlerin bağlamında yanıt verecek. ` +
|
|
||||||
`${SORUMLULUK_REDDI}`;
|
|
||||||
const mockReadable = new ReadableStream({
|
|
||||||
async start(controller) {
|
|
||||||
for (const parca of cevap.split(/(?<= )/)) {
|
|
||||||
controller.enqueue(mockEncoder.encode(parca));
|
|
||||||
await new Promise((r) => setTimeout(r, 25));
|
|
||||||
}
|
|
||||||
await appDb.insert(schema.chatMessages).values({
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
userId: mockUserId,
|
|
||||||
role: "assistant",
|
|
||||||
content: cevap,
|
|
||||||
createdAt: new Date(),
|
|
||||||
});
|
|
||||||
controller.close();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return new Response(mockReadable, {
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "text/plain; charset=utf-8",
|
|
||||||
"X-Content-Type-Options": "nosniff",
|
|
||||||
"Cache-Control": "no-store",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let client;
|
let client;
|
||||||
try {
|
try {
|
||||||
client = getAnthropic();
|
client = getAnthropic();
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,6 @@ import type { Metadata } from "next";
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getSession } from "@/lib/session";
|
import { getSession } from "@/lib/session";
|
||||||
import { GirisForm } from "./giris-form";
|
import { GirisForm } from "./giris-form";
|
||||||
import { GirisBaslik } from "./giris-baslik";
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Giriş — KolayTercih",
|
title: "Giriş — KolayTercih",
|
||||||
@@ -20,28 +19,14 @@ export default async function GirisPage({
|
|||||||
const session = await getSession();
|
const session = await getSession();
|
||||||
if (session) redirect(callbackURL);
|
if (session) redirect(callbackURL);
|
||||||
|
|
||||||
// Sihirbaz funnel'ı: seçimler yapıldı, listeye giriş kaldı
|
|
||||||
const funnel = callbackURL.startsWith("/sonuc");
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col bg-slate-50 text-slate-900">
|
<div className="flex min-h-screen flex-col bg-slate-50 text-slate-900">
|
||||||
<main className="mx-auto flex w-full max-w-md flex-1 flex-col items-stretch justify-center px-4 py-16">
|
<main className="mx-auto flex w-full max-w-md flex-1 flex-col items-stretch justify-center px-4 py-16">
|
||||||
<div className="rounded-2xl border border-slate-200 bg-white p-8 shadow-sm">
|
<div className="rounded-2xl border border-slate-200 bg-white p-8 shadow-sm">
|
||||||
<GirisBaslik funnel={funnel} />
|
<h1 className="font-heading text-2xl font-bold">Giriş yap</h1>
|
||||||
<p className="mt-2 text-sm text-slate-600">
|
<p className="mt-2 text-sm text-slate-600">
|
||||||
{funnel ? (
|
Kayıt olduğunda <span className="font-semibold">5 deneme kredisi</span>{" "}
|
||||||
<>
|
hesabına tanımlanır — AI danışmana ücretsiz soru sorabilirsin.
|
||||||
Seçimlerin hazır — giriş yap,{" "}
|
|
||||||
<span className="font-semibold">5 deneme kredinle</span> listen
|
|
||||||
hemen üretilsin.
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
Kayıt olduğunda{" "}
|
|
||||||
<span className="font-semibold">5 deneme kredisi</span> hesabına
|
|
||||||
tanımlanır — AI danışmana ücretsiz soru sorabilirsin.
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</p>
|
</p>
|
||||||
<GirisForm callbackURL={callbackURL} />
|
<GirisForm callbackURL={callbackURL} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -117,33 +117,6 @@
|
|||||||
--sidebar-ring: oklch(0.556 0 0);
|
--sidebar-ring: oklch(0.556 0 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* TypingAnimation imleci (animate-blink-cursor utility'si) */
|
|
||||||
@theme {
|
|
||||||
--animate-blink-cursor: blink-cursor 1s step-end infinite;
|
|
||||||
|
|
||||||
@keyframes blink-cursor {
|
|
||||||
50% {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Ödeme dönüşünde kilidi açılan listenin blur çözülme animasyonu */
|
|
||||||
@keyframes kt-unblur {
|
|
||||||
from {
|
|
||||||
filter: blur(12px);
|
|
||||||
opacity: 0.4;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
filter: blur(0);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.kt-unblur {
|
|
||||||
animation: kt-unblur 0.9s ease-out both;
|
|
||||||
}
|
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import type { Metadata } from "next";
|
|||||||
import { Outfit, Work_Sans, Geist_Mono } from "next/font/google";
|
import { Outfit, Work_Sans, Geist_Mono } from "next/font/google";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
import { SiteHeader } from "@/components/site-header";
|
import { SiteHeader } from "@/components/site-header";
|
||||||
import { ListeCekmecesi } from "@/components/manuel-liste/liste-cekmecesi";
|
|
||||||
import { HeaderGate } from "@/components/header-gate";
|
import { HeaderGate } from "@/components/header-gate";
|
||||||
import { ProgressiveBlur } from "@/components/ui/skiper-ui/skiper41";
|
import { ProgressiveBlur } from "@/components/ui/skiper-ui/skiper41";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
@@ -23,7 +22,7 @@ const geistMono = Geist_Mono({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "KolayTercih — Tercih Danışmanı",
|
title: "KolayTercih — Cebindeki Tercih Danışmanı",
|
||||||
description:
|
description:
|
||||||
"YKS sıralamana göre gerçek YÖK Atlas verisiyle dengeli 24 tercihlik liste. İnsan danışmanın onda bir fiyatına, yapay zekâ destekli tercih danışmanlığı.",
|
"YKS sıralamana göre gerçek YÖK Atlas verisiyle dengeli 24 tercihlik liste. İnsan danışmanın onda bir fiyatına, yapay zekâ destekli tercih danışmanlığı.",
|
||||||
};
|
};
|
||||||
@@ -60,8 +59,6 @@ export default function RootLayout({
|
|||||||
<SiteHeader />
|
<SiteHeader />
|
||||||
</HeaderGate>
|
</HeaderGate>
|
||||||
{children}
|
{children}
|
||||||
{/* Manuel 24'lük liste çekmecesi + "+" uçuş katmanı (navbar'dan açılır) */}
|
|
||||||
<ListeCekmecesi />
|
|
||||||
<Toaster position="top-center" />
|
<Toaster position="top-center" />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,238 +0,0 @@
|
|||||||
import type { Metadata } from "next";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import {
|
|
||||||
ArrowRight,
|
|
||||||
Bot,
|
|
||||||
Check,
|
|
||||||
Database,
|
|
||||||
FileText,
|
|
||||||
Lock,
|
|
||||||
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 } 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";
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: "Listem — KolayTercih",
|
|
||||||
};
|
|
||||||
|
|
||||||
const PAKET_FIYATI = (URUNLER.paket.amountKurus / 100).toLocaleString("tr-TR", {
|
|
||||||
maximumFractionDigits: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
export default async function ListemPage() {
|
|
||||||
await verifySession("/listem");
|
|
||||||
const user = await getCurrentUser();
|
|
||||||
if (!user) redirect("/giris?callback=/listem");
|
|
||||||
|
|
||||||
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">
|
|
||||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<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]} · 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={`/sonuc?sira=${params.sira}&tur=${params.tur}&sihirbaz=1`}
|
|
||||||
>
|
|
||||||
<PencilLine className="size-4" aria-hidden />
|
|
||||||
Revize et
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
asChild
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="cursor-pointer"
|
|
||||||
>
|
|
||||||
<Link href="/rapor/yazdir" target="_blank">
|
|
||||||
<FileText className="size-4" aria-hidden />
|
|
||||||
PDF
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-8 grid items-start gap-8 lg:grid-cols-[minmax(0,1fr)_340px]">
|
|
||||||
<section className="min-w-0 rounded-2xl border border-slate-200 bg-slate-50/60 p-3 sm:p-5">
|
|
||||||
<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">
|
|
||||||
Ham veri burada bitmiyor — aşağıdaki tüm satırlar açık
|
|
||||||
</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 ve AI yorumunu açar.
|
|
||||||
</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ç
|
|
||||||
<ArrowRight data-icon="inline-end" aria-hidden />
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div className="lg:sticky lg:top-28">
|
|
||||||
{user.hasPaket ? (
|
|
||||||
<SohbetClient baslangicKredi={user.creditBalance} />
|
|
||||||
) : (
|
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -38,22 +38,6 @@ export default async function OdemeSonucPage({
|
|||||||
|
|
||||||
const basarili = durum === "paid";
|
const basarili = durum === "paid";
|
||||||
|
|
||||||
// Rapor zaten üretildiyse ödül bir tık uzakta: kullanıcıyı doğrudan
|
|
||||||
// sonuç 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: { params: true },
|
|
||||||
})
|
|
||||||
: null;
|
|
||||||
const raporParams = rapor?.params as
|
|
||||||
| { sira?: number; tur?: string }
|
|
||||||
| undefined;
|
|
||||||
const listeHref =
|
|
||||||
raporParams?.sira && raporParams?.tur
|
|
||||||
? `/sonuc?sira=${raporParams.sira}&tur=${raporParams.tur}&acildi=1`
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col bg-slate-50 text-slate-900">
|
<div className="flex min-h-screen flex-col bg-slate-50 text-slate-900">
|
||||||
<main className="mx-auto flex w-full max-w-md flex-1 flex-col justify-center px-4 py-16 text-center">
|
<main className="mx-auto flex w-full max-w-md flex-1 flex-col justify-center px-4 py-16 text-center">
|
||||||
@@ -80,17 +64,13 @@ export default async function OdemeSonucPage({
|
|||||||
asChild
|
asChild
|
||||||
className="h-11 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
|
className="h-11 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||||
>
|
>
|
||||||
<Link href={listeHref ?? "/"}>
|
<Link href="/">
|
||||||
<Sparkles className="size-4" aria-hidden />
|
<Sparkles className="size-4" aria-hidden />
|
||||||
{listeHref
|
24'lük listeni oluştur
|
||||||
? "Listeni aç — kilidi kaldırdık"
|
|
||||||
: "24'lük listeni oluştur"}
|
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<p className="text-center text-xs text-slate-500">
|
<p className="text-center text-xs text-slate-500">
|
||||||
{listeHref
|
Sıralamanı girip sonuç sayfasındaki sihirbazla listeni kur.
|
||||||
? "24 tercihin tamamı, gerekçeler, riskler ve PDF artık açık."
|
|
||||||
: "Sıralamanı girip sonuç sayfasındaki sihirbazla listeni kur."}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -151,11 +151,8 @@ export default function Home() {
|
|||||||
<main className="flex-1">
|
<main className="flex-1">
|
||||||
{/* Hero */}
|
{/* Hero */}
|
||||||
<section className="mx-auto flex max-w-6xl flex-col items-center px-4 pb-20 pt-16 text-center sm:pt-24">
|
<section className="mx-auto flex max-w-6xl flex-col items-center px-4 pb-20 pt-16 text-center sm:pt-24">
|
||||||
<Badge
|
<Badge variant="secondary" className="mb-6 bg-primary/10 text-primary">
|
||||||
variant="secondary"
|
2026 YKS tercih dönemi açık
|
||||||
className="mb-6 bg-primary/10 text-primary"
|
|
||||||
>
|
|
||||||
2026 YKS tercih dönemi açıkk
|
|
||||||
</Badge>
|
</Badge>
|
||||||
<h1 className="max-w-3xl font-heading text-4xl font-bold tracking-tight sm:text-6xl">
|
<h1 className="max-w-3xl font-heading text-4xl font-bold tracking-tight sm:text-6xl">
|
||||||
Dört yıllık geleceğini{" "}
|
Dört yıllık geleceğini{" "}
|
||||||
@@ -166,10 +163,7 @@ export default function Home() {
|
|||||||
danışmanın. İnsan danışmanın binlerce lira aldığı işi — dengeli
|
danışmanın. İnsan danışmanın binlerce lira aldığı işi — dengeli
|
||||||
liste, risk analizi, soru-cevap — onda bir fiyatına yapar.
|
liste, risk analizi, soru-cevap — onda bir fiyatına yapar.
|
||||||
</p>
|
</p>
|
||||||
<div
|
<div id="hero-form" className="mt-10 flex w-full scroll-mt-28 justify-center">
|
||||||
id="hero-form"
|
|
||||||
className="mt-10 flex w-full scroll-mt-28 justify-center"
|
|
||||||
>
|
|
||||||
<HeroForm />
|
<HeroForm />
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-4 text-sm text-slate-500">
|
<p className="mt-4 text-sm text-slate-500">
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ export const metadata: Metadata = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const DILIM_LABEL: Record<string, string> = {
|
const DILIM_LABEL: Record<string, string> = {
|
||||||
hayal: "Hayal (Riskli)",
|
hayal: "Hayal",
|
||||||
dengeli: "Dengeli (Az riskli)",
|
dengeli: "Dengeli",
|
||||||
garanti: "Garanti (Güvenli)",
|
garanti: "Garanti",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function YazdirPage() {
|
export default async function YazdirPage() {
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import {
|
|||||||
} from "@/lib/ai/rapor";
|
} from "@/lib/ai/rapor";
|
||||||
import type { RaporParams } from "@/lib/rapor-havuzu";
|
import type { RaporParams } from "@/lib/rapor-havuzu";
|
||||||
import { sihirbazDogrula, type SihirbazSecimleri } from "@/lib/sihirbaz";
|
import { sihirbazDogrula, type SihirbazSecimleri } from "@/lib/sihirbaz";
|
||||||
import { raporMaskele } from "@/lib/rapor-maske";
|
|
||||||
|
|
||||||
const MAX_REVIZYON = 2;
|
const MAX_REVIZYON = 2;
|
||||||
|
|
||||||
@@ -25,7 +24,6 @@ export type ListeSonuc =
|
|||||||
params: RaporParams;
|
params: RaporParams;
|
||||||
revisionCount: number;
|
revisionCount: number;
|
||||||
kredi: number;
|
kredi: number;
|
||||||
hasPaket: boolean;
|
|
||||||
}
|
}
|
||||||
| { ok: false; error: string; code: "AUTH" | "PAKET" | "KREDI" | "HATA" };
|
| { ok: false; error: string; code: "AUTH" | "PAKET" | "KREDI" | "HATA" };
|
||||||
|
|
||||||
@@ -73,8 +71,9 @@ export async function listeOlustur(input: {
|
|||||||
if (!user) {
|
if (!user) {
|
||||||
return { ok: false, code: "AUTH", error: "Giriş yapmalısın." };
|
return { ok: false, code: "AUTH", error: "Giriş yapmalısın." };
|
||||||
}
|
}
|
||||||
// Paket şartı yok: üretim kredi bazlı (yeni kullanıcının 5 deneme kredisi
|
if (!user.hasPaket) {
|
||||||
// burada yanmaya başlar). Paketsize dönen rapor maskelenir.
|
return { ok: false, code: "PAKET", error: "Paket gerekli." };
|
||||||
|
}
|
||||||
|
|
||||||
if (!siraTurGecerli(input.sira, input.tur)) {
|
if (!siraTurGecerli(input.sira, input.tur)) {
|
||||||
return { ok: false, code: "HATA", error: "Geçerli bir sıralama gerekli." };
|
return { ok: false, code: "HATA", error: "Geçerli bir sıralama gerekli." };
|
||||||
@@ -113,14 +112,12 @@ export async function listeOlustur(input: {
|
|||||||
where: eq(schema.reports.userId, user.id),
|
where: eq(schema.reports.userId, user.id),
|
||||||
});
|
});
|
||||||
if (mevcut?.result) {
|
if (mevcut?.result) {
|
||||||
const rapor = mevcut.result as RaporSonuc;
|
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
rapor: user.hasPaket ? rapor : raporMaskele(rapor),
|
rapor: mevcut.result as RaporSonuc,
|
||||||
params: mevcut.params as RaporParams,
|
params: mevcut.params as RaporParams,
|
||||||
revisionCount: mevcut.revisionCount,
|
revisionCount: mevcut.revisionCount,
|
||||||
kredi: user.creditBalance,
|
kredi: user.creditBalance,
|
||||||
hasPaket: user.hasPaket,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return { ok: false, code: "HATA", error: "İstek tekrarlandı." };
|
return { ok: false, code: "HATA", error: "İstek tekrarlandı." };
|
||||||
@@ -159,11 +156,10 @@ export async function listeOlustur(input: {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
rapor: user.hasPaket ? sonuc : raporMaskele(sonuc),
|
rapor: sonuc,
|
||||||
params,
|
params,
|
||||||
revisionCount: 0,
|
revisionCount: 0,
|
||||||
kredi: user.creditBalance - RAPOR_KREDI,
|
kredi: user.creditBalance - RAPOR_KREDI,
|
||||||
hasPaket: user.hasPaket,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,11 +221,7 @@ export async function listeRevize(input: {
|
|||||||
|
|
||||||
let sonuc: RaporSonuc;
|
let sonuc: RaporSonuc;
|
||||||
try {
|
try {
|
||||||
sonuc = await raporUret(
|
sonuc = await raporUret(params, feedback.slice(0, 1000), listeOzetiCikar(onceki));
|
||||||
params,
|
|
||||||
feedback.slice(0, 1000),
|
|
||||||
listeOzetiCikar(onceki, params.sira),
|
|
||||||
);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await grantCredits({
|
await grantCredits({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
@@ -256,6 +248,5 @@ export async function listeRevize(input: {
|
|||||||
params,
|
params,
|
||||||
revisionCount: rapor.revisionCount + 1,
|
revisionCount: rapor.revisionCount + 1,
|
||||||
kredi: user.creditBalance - RAPOR_KREDI,
|
kredi: user.creditBalance - RAPOR_KREDI,
|
||||||
hasPaket: true,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,27 +1,47 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import type { Metadata } from "next";
|
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 { eq } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
PUAN_TURLERI,
|
PUAN_TURLERI,
|
||||||
rankWindowFacets,
|
|
||||||
searchByRank,
|
searchByRank,
|
||||||
|
rankWindowFacets,
|
||||||
|
type Program,
|
||||||
type PuanTuruKey,
|
type PuanTuruKey,
|
||||||
} from "@/lib/db";
|
} from "@/lib/db";
|
||||||
import { getSession, getCurrentUser } from "@/lib/session";
|
import { getSession, getCurrentUser } from "@/lib/session";
|
||||||
import { appDb, schema } from "@/lib/appdb";
|
import { appDb, schema } from "@/lib/appdb";
|
||||||
import type { RaporSonuc } from "@/lib/ai/rapor";
|
import type { RaporSonuc } from "@/lib/ai/rapor";
|
||||||
import type { RaporParams } from "@/lib/rapor-havuzu";
|
import type { RaporParams } from "@/lib/rapor-havuzu";
|
||||||
import { raporMaskele } from "@/lib/rapor-maske";
|
|
||||||
import { SihirbazBolumu } from "./sihirbaz-cagri-karti";
|
import { SihirbazBolumu } from "./sihirbaz-cagri-karti";
|
||||||
import { FunnelBanner } from "./funnel-banner";
|
import type {
|
||||||
import type { MevcutRapor } from "./liste-paneli";
|
MevcutRapor,
|
||||||
|
OnizlemeSatir,
|
||||||
|
} from "./sihirbaz-modal";
|
||||||
|
import { BolgeHaritasi, type IlOzeti } from "@/components/bolge-haritasi";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ProgramTablosu } from "@/components/program-tablosu";
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Tercih Listeni Kur — KolayTercih",
|
title: "Sıralamana Uygun Programlar — KolayTercih",
|
||||||
};
|
};
|
||||||
|
|
||||||
const TUR_LABELS: Record<PuanTuruKey, string> = {
|
const TUR_LABELS: Record<PuanTuruKey, string> = {
|
||||||
@@ -32,14 +52,107 @@ const TUR_LABELS: Record<PuanTuruKey, string> = {
|
|||||||
tyt: "TYT (Önlisans)",
|
tyt: "TYT (Önlisans)",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function TrendIcon({ p }: { p: Program }) {
|
||||||
|
if (p.sira2025 == null || p.sira2024 == null) {
|
||||||
|
return <Minus className="size-4 text-slate-400" aria-label="Veri yok" />;
|
||||||
|
}
|
||||||
|
const degisim = (p.sira2024 - p.sira2025) / p.sira2024;
|
||||||
|
// taban sıralaması küçülüyorsa bölüm zorlaşıyor demektir
|
||||||
|
if (degisim > 0.05) {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1 text-orange-600">
|
||||||
|
<TrendingUp className="size-4" aria-hidden />
|
||||||
|
<span className="text-xs">zorlaşıyor</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (degisim < -0.05) {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1 text-emerald-600">
|
||||||
|
<TrendingDown className="size-4" aria-hidden />
|
||||||
|
<span className="text-xs">rahatlıyor</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1 text-slate-500">
|
||||||
|
<Minus className="size-4" aria-hidden />
|
||||||
|
<span className="text-xs">stabil</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmt(n: number | null): string {
|
||||||
|
return n == null ? "—" : n.toLocaleString("tr-TR");
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProgramTable({ programs }: { programs: Program[] }) {
|
||||||
|
if (programs.length === 0) {
|
||||||
|
return (
|
||||||
|
<p className="rounded-xl border border-dashed border-slate-300 p-6 text-center text-sm text-slate-500">
|
||||||
|
Bu dilimde uygun program bulunamadı.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto rounded-2xl border border-slate-200 bg-white">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="bg-slate-50">
|
||||||
|
<TableHead>Program</TableHead>
|
||||||
|
<TableHead>Üniversite</TableHead>
|
||||||
|
<TableHead className="text-right">2025 taban sırası</TableHead>
|
||||||
|
<TableHead className="text-right">2024</TableHead>
|
||||||
|
<TableHead>Trend</TableHead>
|
||||||
|
<TableHead className="text-right">Kontenjan</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{programs.map((p) => (
|
||||||
|
<TableRow key={p.id}>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
{p.isim}
|
||||||
|
{p.fakulte ? (
|
||||||
|
<span className="block text-xs text-slate-500">
|
||||||
|
{p.fakulte}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{p.universite}
|
||||||
|
<span className="block text-xs text-slate-500">
|
||||||
|
{p.il}
|
||||||
|
{p.unitur ? ` · ${p.unitur.toLocaleLowerCase("tr-TR")}` : ""}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-medium tabular-nums">
|
||||||
|
{fmt(p.sira2025)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right tabular-nums text-slate-500">
|
||||||
|
{fmt(p.sira2024)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<TrendIcon p={p} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right tabular-nums">
|
||||||
|
{fmt(p.kontenjan2025)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default async function SonucPage({
|
export default async function SonucPage({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
searchParams: Promise<{
|
searchParams: Promise<{
|
||||||
sira?: string;
|
sira?: string;
|
||||||
tur?: string;
|
tur?: string;
|
||||||
|
il?: string;
|
||||||
sihirbaz?: string;
|
sihirbaz?: string;
|
||||||
acildi?: string;
|
|
||||||
}>;
|
}>;
|
||||||
}) {
|
}) {
|
||||||
const params = await searchParams;
|
const params = await searchParams;
|
||||||
@@ -48,6 +161,7 @@ export default async function SonucPage({
|
|||||||
params.tur && params.tur in PUAN_TURLERI
|
params.tur && params.tur in PUAN_TURLERI
|
||||||
? (params.tur as PuanTuruKey)
|
? (params.tur as PuanTuruKey)
|
||||||
: "say";
|
: "say";
|
||||||
|
const seciliIl = params.il?.trim() || null;
|
||||||
|
|
||||||
if (!Number.isFinite(sira) || sira < 1 || sira > 4_000_000) {
|
if (!Number.isFinite(sira) || sira < 1 || sira > 4_000_000) {
|
||||||
return (
|
return (
|
||||||
@@ -56,7 +170,7 @@ export default async function SonucPage({
|
|||||||
Geçerli bir sıralama gerekli
|
Geçerli bir sıralama gerekli
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-3 text-slate-600">
|
<p className="mt-3 text-slate-600">
|
||||||
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>
|
</p>
|
||||||
<Button asChild className="mt-6 cursor-pointer">
|
<Button asChild className="mt-6 cursor-pointer">
|
||||||
<Link href="/">
|
<Link href="/">
|
||||||
@@ -78,27 +192,84 @@ export default async function SonucPage({
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
const facetler = rankWindowFacets(sira, turKey);
|
const facetler = rankWindowFacets(sira, turKey);
|
||||||
// AI'sız ham liste: yalnızca sıralamayla ulaşılabilen programlar (dilimli)
|
|
||||||
const hamSonuclar = searchByRank(sira, turKey, { limitPerBucket: 20 });
|
|
||||||
// Paketsize giden rapor sunucuda maskelenir: blur yalnızca CSS olduğundan
|
|
||||||
// kilitli satırların gerçek içeriği client'a hiç inmez.
|
|
||||||
const hasPaket = Boolean(user?.hasPaket);
|
|
||||||
const mevcutRapor: MevcutRapor | null = raporSatiri?.result
|
const mevcutRapor: MevcutRapor | null = raporSatiri?.result
|
||||||
? {
|
? {
|
||||||
rapor: hasPaket
|
rapor: raporSatiri.result as RaporSonuc,
|
||||||
? (raporSatiri.result as RaporSonuc)
|
|
||||||
: raporMaskele(raporSatiri.result as RaporSonuc),
|
|
||||||
params: raporSatiri.params as RaporParams,
|
params: raporSatiri.params as RaporParams,
|
||||||
revisionCount: raporSatiri.revisionCount,
|
revisionCount: raporSatiri.revisionCount,
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
|
// Paketsiz kullanıcıya blur önizlemede gösterilecek gerçek dengeli satırlar
|
||||||
|
const onizlemeSatirlari: OnizlemeSatir[] = searchByRank(sira, turKey, {
|
||||||
|
limitPerBucket: 5,
|
||||||
|
})
|
||||||
|
.dengeli.slice(0, 5)
|
||||||
|
.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
isim: p.isim,
|
||||||
|
universite: p.universite,
|
||||||
|
il: p.il,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const results = searchByRank(sira, turKey, {
|
||||||
|
il: seciliIl ?? undefined,
|
||||||
|
});
|
||||||
|
// Harita her zaman filtresiz dağılımı gösterir ki il değiştirilebilsin
|
||||||
|
const haritaSonuclari = seciliIl ? searchByRank(sira, turKey) : results;
|
||||||
|
|
||||||
|
// Harita için il bazında özet (81 il haritada; KKTC/yurtdışı ayrıca listelenir)
|
||||||
|
const iller: Record<string, IlOzeti> = {};
|
||||||
|
const dilimler = [
|
||||||
|
["hayal", haritaSonuclari.hayal],
|
||||||
|
["dengeli", haritaSonuclari.dengeli],
|
||||||
|
["garanti", haritaSonuclari.garanti],
|
||||||
|
] as const;
|
||||||
|
for (const [dilim, programs] of dilimler) {
|
||||||
|
for (const p of programs) {
|
||||||
|
if (!p.il) continue;
|
||||||
|
iller[p.il] ??= { hayal: 0, dengeli: 0, garanti: 0 };
|
||||||
|
iller[p.il][dilim]++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sections = [
|
||||||
|
{
|
||||||
|
key: "hayal",
|
||||||
|
icon: Rocket,
|
||||||
|
title: "Hayal",
|
||||||
|
tone: "text-orange-600",
|
||||||
|
description:
|
||||||
|
"Geçen yıl tabanı senin sıralamanın üzerinde kapanan programlar. Şansın düşük ama listenin başında birkaç tane bulunmalı.",
|
||||||
|
programs: results.hayal,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "dengeli",
|
||||||
|
icon: Scale,
|
||||||
|
title: "Dengeli",
|
||||||
|
tone: "text-primary",
|
||||||
|
description:
|
||||||
|
"Tabanı sıralamanın hemen civarında. Listenin bel kemiği bu dilimden kurulur.",
|
||||||
|
programs: results.dengeli,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "garanti",
|
||||||
|
icon: ShieldCheck,
|
||||||
|
title: "Garanti",
|
||||||
|
tone: "text-emerald-600",
|
||||||
|
description:
|
||||||
|
"Tabanı sıralamanın belirgin altında. Boşta kalmamak için listenin sonunda mutlaka yer almalı.",
|
||||||
|
programs: results.garanti,
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-50 text-slate-900">
|
<div className="min-h-screen bg-slate-50 text-slate-900">
|
||||||
<main 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">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<Badge variant="secondary" className="bg-primary/10 text-primary">
|
<Badge variant="secondary" className="bg-primary/10 text-primary">
|
||||||
{sira.toLocaleString("tr-TR")}. sıra · {TUR_LABELS[turKey]}
|
Ücretsiz ön izleme
|
||||||
</Badge>
|
</Badge>
|
||||||
<Button
|
<Button
|
||||||
asChild
|
asChild
|
||||||
@@ -113,38 +284,110 @@ export default async function SonucPage({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="mt-4 font-heading text-3xl font-bold sm:text-4xl">
|
<h1 className="mt-4 font-heading text-3xl font-bold sm:text-4xl">
|
||||||
{sira.toLocaleString("tr-TR")}. sıradaki bir aday için tercih planı
|
{sira.toLocaleString("tr-TR")}. sıradaki bir aday için görünüm
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-2 max-w-2xl text-slate-600">
|
<p className="mt-2 max-w-2xl text-slate-600">
|
||||||
Birkaç adımda ilgi alanlarını ve şehir tercihini seç; yapay zekâ
|
2025 YÖK Atlas taban sıralamalarına göre üç dilime ayrılmış öneriler.
|
||||||
gerçek YÖK Atlas verisiyle 24 tercihlik dengeli listeni kursun. Her
|
Taban sıralamaları her yıl değişir; bu liste garanti değil, başlangıç
|
||||||
satır yerleşme riskine göre renklenir:{" "}
|
noktasıdır.
|
||||||
<span className="font-semibold text-emerald-600">yeşil güvenli</span>,{" "}
|
|
||||||
<span className="font-semibold text-amber-600">sarı az riskli</span>,{" "}
|
|
||||||
<span className="font-semibold text-red-600">kırmızı riskli</span>.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Satın alım funnel'ı: değişen cümlelerle adayı sihirbaza (rapor
|
{/* Puan türü seçimi */}
|
||||||
varsa /listem paywall'ına) iten banner — paketli kullanıcıya
|
<nav className="mt-6 flex flex-wrap gap-2" aria-label="Puan türü">
|
||||||
satacak bir şey kalmadığından gösterilmez */}
|
{(Object.keys(PUAN_TURLERI) as PuanTuruKey[]).map((key) => (
|
||||||
{!hasPaket ? <FunnelBanner hedefListem={Boolean(mevcutRapor)} /> : null}
|
<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
|
{seciliIl ? (
|
||||||
gövdesi; puan türü seçici de listenin başında yaşar */}
|
<div className="mt-4 flex flex-wrap items-center gap-3 rounded-xl border border-primary/30 bg-primary/5 px-4 py-3">
|
||||||
<ProgramTablosu sonuclar={hamSonuclar} adaySira={sira} tur={turKey} />
|
<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
|
{/* Bölge haritası */}
|
||||||
sayfanın en altında yaşar */}
|
<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
|
<SihirbazBolumu
|
||||||
sira={sira}
|
sira={sira}
|
||||||
tur={turKey}
|
tur={turKey}
|
||||||
facetler={facetler}
|
facetler={facetler}
|
||||||
girisliMi={Boolean(session)}
|
girisliMi={Boolean(session)}
|
||||||
hasPaket={hasPaket}
|
hasPaket={Boolean(user?.hasPaket)}
|
||||||
kredi={user?.creditBalance ?? 0}
|
kredi={user?.creditBalance ?? 0}
|
||||||
mevcutRapor={mevcutRapor}
|
mevcutRapor={mevcutRapor}
|
||||||
|
onizlemeSatirlari={onizlemeSatirlari}
|
||||||
otomatikAc={params.sihirbaz === "1"}
|
otomatikAc={params.sihirbaz === "1"}
|
||||||
acilis={params.acildi === "1"}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<p className="mt-10 text-center text-xs leading-relaxed text-slate-500">
|
<p className="mt-10 text-center text-xs leading-relaxed text-slate-500">
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
|
import { useEffect, useState, useSyncExternalStore } from "react";
|
||||||
import Link from "next/link";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { Sparkles, X } from "lucide-react";
|
import { Sparkles, X } from "lucide-react";
|
||||||
import { animate } from "motion";
|
|
||||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import type { SihirbazFacetleri } from "@/lib/db";
|
import type { SihirbazFacetleri } from "@/lib/db";
|
||||||
import {
|
import {
|
||||||
@@ -14,10 +9,11 @@ import {
|
|||||||
sihirbazDogrula,
|
sihirbazDogrula,
|
||||||
type SihirbazSecimleri,
|
type SihirbazSecimleri,
|
||||||
} from "@/lib/sihirbaz";
|
} from "@/lib/sihirbaz";
|
||||||
import { SihirbazModal } from "./sihirbaz-modal";
|
import {
|
||||||
import { SIHIRBAZ_AC_EVENT } from "./funnel-banner";
|
SihirbazModal,
|
||||||
import { ListePaneli, type MevcutRapor, type PanelDurum } from "./liste-paneli";
|
type MevcutRapor,
|
||||||
import { listeOlustur, listeRevize } from "./actions";
|
type OnizlemeSatir,
|
||||||
|
} from "./sihirbaz-modal";
|
||||||
|
|
||||||
const KAPATMA_KEY = "kolaytercih.sihirbaz-karti-kapali";
|
const KAPATMA_KEY = "kolaytercih.sihirbaz-karti-kapali";
|
||||||
|
|
||||||
@@ -36,12 +32,11 @@ type BekleyenSecim = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sonuç sayfasında sihirbaz akışını yöneten client orkestratör:
|
* Sonuç sayfasında sihirbaz akışını yöneten client wrapper:
|
||||||
* - Sayfa ortasındaki büyük CTA + sabit alt çağrı kartı (rapor yokken)
|
* - Sayfa ortasındaki büyük CTA (satır içi)
|
||||||
* - Adımları toplayan modal
|
* - Sabit alt çağrı kartı
|
||||||
* - Üretim/önizleme/liste paneli (sayfa gövdesinde)
|
* - Modal
|
||||||
* Akış: adımlar → (girişsizse /giris) → kredi ile üretim → paketsizse
|
* hepsi tek `acik` state'ini paylaşır.
|
||||||
* progressive-blur önizleme, paketliyse tam liste.
|
|
||||||
*/
|
*/
|
||||||
export function SihirbazBolumu({
|
export function SihirbazBolumu({
|
||||||
sira,
|
sira,
|
||||||
@@ -49,10 +44,10 @@ export function SihirbazBolumu({
|
|||||||
facetler,
|
facetler,
|
||||||
girisliMi,
|
girisliMi,
|
||||||
hasPaket,
|
hasPaket,
|
||||||
kredi: baslangicKredi,
|
kredi,
|
||||||
mevcutRapor,
|
mevcutRapor,
|
||||||
|
onizlemeSatirlari,
|
||||||
otomatikAc,
|
otomatikAc,
|
||||||
acilis,
|
|
||||||
}: {
|
}: {
|
||||||
sira: number;
|
sira: number;
|
||||||
tur: string;
|
tur: string;
|
||||||
@@ -61,36 +56,18 @@ export function SihirbazBolumu({
|
|||||||
hasPaket: boolean;
|
hasPaket: boolean;
|
||||||
kredi: number;
|
kredi: number;
|
||||||
mevcutRapor: MevcutRapor | null;
|
mevcutRapor: MevcutRapor | null;
|
||||||
|
onizlemeSatirlari: OnizlemeSatir[];
|
||||||
/** ?sihirbaz=1 ile gelindiyse modalı otomatik aç */
|
/** ?sihirbaz=1 ile gelindiyse modalı otomatik aç */
|
||||||
otomatikAc?: boolean;
|
otomatikAc?: boolean;
|
||||||
/** ?acildi=1 (ödeme dönüşü): blur çözülme animasyonuyla aç */
|
|
||||||
acilis?: boolean;
|
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
|
||||||
const monteEdildi = useMonteEdildi();
|
const monteEdildi = useMonteEdildi();
|
||||||
const hareketiAzalt = useReducedMotion();
|
|
||||||
|
|
||||||
const [elleAcik, setElleAcik] = useState(false);
|
const [elleAcik, setElleAcik] = useState(false);
|
||||||
const [elleKapandi, setElleKapandi] = useState(false);
|
const [elleKapandi, setElleKapandi] = useState(false);
|
||||||
const [kartGizle, setKartGizle] = useState(false);
|
const [kartGizle, setKartGizle] = useState(false);
|
||||||
const [bekleyen, setBekleyen] = useState<BekleyenSecim | null>(null);
|
const [bekleyen, setBekleyen] = useState<BekleyenSecim | null>(null);
|
||||||
|
|
||||||
const [rapor, setRapor] = useState<MevcutRapor | null>(mevcutRapor);
|
|
||||||
const [kredi, setKredi] = useState(baslangicKredi);
|
|
||||||
const [paketli, setPaketli] = useState(hasPaket);
|
|
||||||
const [durum, setDurum] = useState<PanelDurum>("bos");
|
|
||||||
|
|
||||||
// Adımlarda yapılan son seçimler (hata sonrası modala dönüşte prefill)
|
|
||||||
const [sonSecimler, setSonSecimler] = useState<SihirbazSecimleri | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Aynı üretim için sabit requestId (retry çifte harcama yapmaz)
|
|
||||||
const requestIdRef = useRef<string | null>(null);
|
|
||||||
const otomatikBasladi = useRef(false);
|
|
||||||
const bolumRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
// Mount sonrası storage'dan bekleyen seçimleri + kalıcı kart kapatmayı oku
|
// Mount sonrası storage'dan bekleyen seçimleri + kalıcı kart kapatmayı oku
|
||||||
|
// (dış store'dan başlangıç senkronizasyonu — bir kez).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
const kaliciKapali = Boolean(sessionStorage.getItem(KAPATMA_KEY));
|
const kaliciKapali = Boolean(sessionStorage.getItem(KAPATMA_KEY));
|
||||||
@@ -116,158 +93,11 @@ export function SihirbazBolumu({
|
|||||||
} catch {}
|
} catch {}
|
||||||
}, [sira, tur]);
|
}, [sira, tur]);
|
||||||
|
|
||||||
// Üstteki funnel banner'ının CTA'sı sihirbaz modalını bu olayla açar
|
// Giriş/ödeme dönüşünde otomatik üretim: eşleşen bekleyen seçim + paket
|
||||||
useEffect(() => {
|
const otomatikUret = Boolean(bekleyen?.eslesir && girisliMi && hasPaket);
|
||||||
const ac = () => {
|
// Açık durumu türetilmiş: elle veya otomatik tetik, kullanıcı kapatmadıkça
|
||||||
setElleAcik(true);
|
const acik = !elleKapandi && (elleAcik || Boolean(otomatikAc) || otomatikUret);
|
||||||
setElleKapandi(false);
|
const baslangicSecimler = bekleyen?.eslesir ? bekleyen.secimler : null;
|
||||||
};
|
|
||||||
window.addEventListener(SIHIRBAZ_AC_EVENT, ac);
|
|
||||||
return () => window.removeEventListener(SIHIRBAZ_AC_EVENT, ac);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Bölüm ekran dışındaysa (kullanıcı sihirbazı üstteki banner'dan ya da
|
|
||||||
* sabit alt karttan açmış olabilir) modal hâlâ perdeyi kaplarken kaydırırız;
|
|
||||||
* böylece Motion ile giren hedef panel görünür alanda kalır.
|
|
||||||
*/
|
|
||||||
function bolumuGorunureAl() {
|
|
||||||
const el = bolumRef.current;
|
|
||||||
if (!el) return;
|
|
||||||
const kutu = el.getBoundingClientRect();
|
|
||||||
if (kutu.top >= 0 && kutu.bottom <= window.innerHeight) return;
|
|
||||||
el.scrollIntoView({ block: "center", behavior: "instant" });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function modalCikisiniOynat() {
|
|
||||||
if (hareketiAzalt) return;
|
|
||||||
|
|
||||||
const modal = document.querySelector<HTMLElement>("[data-sihirbaz-modal]");
|
|
||||||
if (!modal) return;
|
|
||||||
|
|
||||||
const mevcutTransform = getComputedStyle(modal).transform;
|
|
||||||
const hedefTransform =
|
|
||||||
mevcutTransform === "none"
|
|
||||||
? "translate3d(0, 48px, 0)"
|
|
||||||
: new DOMMatrixReadOnly(mevcutTransform).translate(0, 48).toString();
|
|
||||||
const perde = document.querySelector<HTMLElement>(
|
|
||||||
'[data-slot="dialog-overlay"]',
|
|
||||||
);
|
|
||||||
|
|
||||||
const modalAnimasyonu = animate(
|
|
||||||
modal,
|
|
||||||
{
|
|
||||||
opacity: 0,
|
|
||||||
transform: hedefTransform,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
duration: 0.24,
|
|
||||||
ease: [0.4, 0, 0.7, 0.2],
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (perde) {
|
|
||||||
await Promise.all([
|
|
||||||
modalAnimasyonu,
|
|
||||||
animate(perde, { opacity: 0 }, { duration: 0.2, ease: "easeOut" }),
|
|
||||||
]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await modalAnimasyonu;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function uret(secimler: SihirbazSecimleri) {
|
|
||||||
if (!requestIdRef.current) requestIdRef.current = crypto.randomUUID();
|
|
||||||
bolumuGorunureAl();
|
|
||||||
await modalCikisiniOynat();
|
|
||||||
setElleAcik(false);
|
|
||||||
setDurum("uretiliyor");
|
|
||||||
|
|
||||||
try {
|
|
||||||
const sonuc = await listeOlustur({
|
|
||||||
sira,
|
|
||||||
tur,
|
|
||||||
secimler,
|
|
||||||
requestId: requestIdRef.current,
|
|
||||||
});
|
|
||||||
if (sonuc.ok) {
|
|
||||||
requestIdRef.current = null;
|
|
||||||
setRapor({
|
|
||||||
rapor: sonuc.rapor,
|
|
||||||
params: sonuc.params,
|
|
||||||
revisionCount: sonuc.revisionCount,
|
|
||||||
});
|
|
||||||
setKredi(sonuc.kredi);
|
|
||||||
setPaketli(sonuc.hasPaket);
|
|
||||||
try {
|
|
||||||
localStorage.removeItem(SIHIRBAZ_STORAGE_KEY);
|
|
||||||
} catch {}
|
|
||||||
// AI çıktısı bu sayfada gösterilmez: üretim animasyonu açık kalır,
|
|
||||||
// liste (paketsizse paywall'lı hâliyle) /listem'de karşılar.
|
|
||||||
router.push("/listem");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Hata: aynı requestId'yi koru ki tekrar denemede çifte harcama olmasın
|
|
||||||
if (sonuc.code === "KREDI" || sonuc.code === "PAKET") {
|
|
||||||
setDurum("kredi-yetersiz");
|
|
||||||
} else if (sonuc.code === "AUTH") {
|
|
||||||
secimleriKaydetVeGirise(secimler);
|
|
||||||
} else {
|
|
||||||
toast.error(sonuc.error);
|
|
||||||
modalaDon();
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
toast.error("Beklenmeyen bir hata oluştu, tekrar dener misin?");
|
|
||||||
modalaDon();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Hata sonrası panelden modala dönüş. */
|
|
||||||
function modalaDon() {
|
|
||||||
setDurum("bos");
|
|
||||||
setElleAcik(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
function secimleriKaydetVeGirise(secimler: SihirbazSecimleri) {
|
|
||||||
try {
|
|
||||||
localStorage.setItem(
|
|
||||||
SIHIRBAZ_STORAGE_KEY,
|
|
||||||
JSON.stringify({ sira, tur, secimler }),
|
|
||||||
);
|
|
||||||
} catch {}
|
|
||||||
const geri = `/sonuc?sira=${sira}&tur=${tur}&sihirbaz=1`;
|
|
||||||
router.push(`/giris?callback=${encodeURIComponent(geri)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ilerle(secimler: SihirbazSecimleri) {
|
|
||||||
setSonSecimler(secimler);
|
|
||||||
if (!girisliMi) {
|
|
||||||
await modalCikisiniOynat();
|
|
||||||
secimleriKaydetVeGirise(secimler);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await uret(secimler);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Giriş dönüşü: storage'daki eşleşen seçimlerle otomatik üretime başla
|
|
||||||
// (bir kez, modal açmadan — üretim sayfa gövdesinde oynar).
|
|
||||||
const otomatikUret = Boolean(bekleyen?.eslesir && girisliMi && !rapor);
|
|
||||||
useEffect(() => {
|
|
||||||
if (otomatikUret && bekleyen && !otomatikBasladi.current) {
|
|
||||||
otomatikBasladi.current = true;
|
|
||||||
setSonSecimler(bekleyen.secimler);
|
|
||||||
void uret(bekleyen.secimler);
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [otomatikUret]);
|
|
||||||
|
|
||||||
// ?sihirbaz=1: otomatik üretim devreye girmediyse (seçim kaybolduysa/
|
|
||||||
// eşleşmiyorsa) modalı aç ki kullanıcı adımları tamamlayabilsin.
|
|
||||||
const acik =
|
|
||||||
!elleKapandi &&
|
|
||||||
(elleAcik ||
|
|
||||||
Boolean(otomatikAc && !otomatikUret && !rapor && durum === "bos"));
|
|
||||||
|
|
||||||
function acikDegisti(v: boolean) {
|
function acikDegisti(v: boolean) {
|
||||||
if (v) {
|
if (v) {
|
||||||
@@ -279,123 +109,43 @@ export function SihirbazBolumu({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const panelde = Boolean(rapor) || durum !== "bos";
|
// Üretim başladıysa storage'ı temizle (modal artık state'te tutuyor)
|
||||||
const panelAnahtari =
|
useEffect(() => {
|
||||||
durum !== "bos" ? durum : rapor ? "rapor-hazir" : "sihirbaz-cta";
|
if (acik && otomatikUret) {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(SIHIRBAZ_STORAGE_KEY);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}, [acik, otomatikUret]);
|
||||||
|
|
||||||
|
const raporVar = Boolean(mevcutRapor);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* AI çıktısı (rapor listesi/haritası) bu sayfada render edilmez;
|
{/* Sayfa ortasındaki büyük CTA */}
|
||||||
panel yalnızca üretim ve kredi-yetersiz gibi geçici funnel
|
<section className="mt-16 rounded-2xl bg-primary p-8 text-center text-white sm:p-12">
|
||||||
durumlarını gösterir, listenin kendisi /listem'de yaşar. */}
|
<h2 className="font-heading text-2xl font-bold sm:text-3xl">
|
||||||
<div ref={bolumRef} className="relative">
|
{raporVar
|
||||||
<AnimatePresence mode="popLayout" initial={false}>
|
? "24 tercihlik listen hazır"
|
||||||
<motion.div
|
: "Bu listeyi 24 tercihlik plana çevirelim mi?"}
|
||||||
key={panelAnahtari}
|
</h2>
|
||||||
initial={
|
<p className="mx-auto mt-3 max-w-xl text-white/90">
|
||||||
hareketiAzalt
|
{raporVar
|
||||||
? { opacity: 0 }
|
? "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."}
|
||||||
opacity: 0,
|
</p>
|
||||||
transform: "translate3d(0, 8px, 0)",
|
<Button
|
||||||
}
|
size="lg"
|
||||||
}
|
onClick={() => acikDegisti(true)}
|
||||||
animate={{
|
className="mt-6 h-12 cursor-pointer bg-orange-500 px-8 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||||
opacity: 1,
|
>
|
||||||
transform: "translate3d(0, 0, 0)",
|
<Sparkles className="size-4" aria-hidden />
|
||||||
transition: hareketiAzalt
|
{raporVar ? "Listemi görüntüle" : "Listemi oluştur"}
|
||||||
? { duration: 0.12, ease: "easeOut" }
|
</Button>
|
||||||
: {
|
</section>
|
||||||
duration: 0.28,
|
|
||||||
ease: [0.23, 1, 0.32, 1],
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
exit={
|
|
||||||
hareketiAzalt
|
|
||||||
? {
|
|
||||||
opacity: 0,
|
|
||||||
transition: { duration: 0.08, ease: "easeOut" },
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
opacity: 0,
|
|
||||||
transform: "translate3d(0, -4px, 0)",
|
|
||||||
transition: {
|
|
||||||
duration: 0.14,
|
|
||||||
ease: [0.4, 0, 0.7, 0.2],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{durum !== "bos" ? (
|
|
||||||
<ListePaneli
|
|
||||||
durum={durum}
|
|
||||||
rapor={null}
|
|
||||||
hasPaket={paketli}
|
|
||||||
kredi={kredi}
|
|
||||||
sira={sira}
|
|
||||||
acilis={acilis}
|
|
||||||
onRevize={async (feedback) => {
|
|
||||||
const sonuc = await listeRevize({
|
|
||||||
feedback,
|
|
||||||
requestId: crypto.randomUUID(),
|
|
||||||
});
|
|
||||||
if (sonuc.ok) {
|
|
||||||
setRapor({
|
|
||||||
rapor: sonuc.rapor,
|
|
||||||
params: sonuc.params,
|
|
||||||
revisionCount: sonuc.revisionCount,
|
|
||||||
});
|
|
||||||
setKredi(sonuc.kredi);
|
|
||||||
toast.success("Listen yeniden kuruldu.");
|
|
||||||
} else {
|
|
||||||
toast.error(sonuc.error);
|
|
||||||
}
|
|
||||||
return sonuc.ok;
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : rapor ? (
|
|
||||||
<section className="mt-16 rounded-2xl border border-slate-200 bg-white p-8 text-center sm:p-12">
|
|
||||||
<h2 className="font-heading text-2xl font-bold sm:text-3xl">
|
|
||||||
24 tercihlik listen hazır
|
|
||||||
</h2>
|
|
||||||
<p className="mx-auto mt-3 max-w-xl text-slate-600">
|
|
||||||
Yapay zekânın kurduğu risk analizli listen Listem sayfasında
|
|
||||||
seni bekliyor.
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
asChild
|
|
||||||
size="lg"
|
|
||||||
className="mt-6 h-12 cursor-pointer bg-orange-500 px-8 text-white transition-colors duration-200 hover:bg-orange-600"
|
|
||||||
>
|
|
||||||
<Link href="/listem">Listeni gör</Link>
|
|
||||||
</Button>
|
|
||||||
</section>
|
|
||||||
) : (
|
|
||||||
<section className="mt-16 rounded-2xl bg-primary p-8 text-center text-white sm:p-12">
|
|
||||||
<h2 className="font-heading text-2xl font-bold sm:text-3xl">
|
|
||||||
Bu listeyi 24 tercihlik plana çevirelim mi?
|
|
||||||
</h2>
|
|
||||||
<p className="mx-auto mt-3 max-w-xl text-white/90">
|
|
||||||
Birkaç adımda ilgi alanların ve şehir tercihine göre
|
|
||||||
gerekçeli, dengeli bir tercih listesi kuralım — her satır için
|
|
||||||
yerleşme riski analiziyle.
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
size="lg"
|
|
||||||
onClick={() => acikDegisti(true)}
|
|
||||||
className="mt-6 h-12 cursor-pointer bg-orange-500 px-8 text-white transition-colors duration-200 hover:bg-orange-600"
|
|
||||||
>
|
|
||||||
<Sparkles className="size-4" aria-hidden />
|
|
||||||
Listemi oluştur
|
|
||||||
</Button>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
</motion.div>
|
|
||||||
</AnimatePresence>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Sabit alt çağrı kartı — liste/panel yokken ve kart kapatılmadıysa */}
|
{/* Sabit alt çağrı kartı — rapor yoksa ve kart kapatılmadıysa */}
|
||||||
{monteEdildi && !panelde && !kartGizle && !acik ? (
|
{monteEdildi && !raporVar && !kartGizle && !acik ? (
|
||||||
<div className="pointer-events-none fixed inset-x-0 bottom-4 z-40 px-4">
|
<div className="pointer-events-none fixed inset-x-0 bottom-4 z-40 px-4">
|
||||||
<div className="pointer-events-auto mx-auto flex max-w-xl items-center gap-4 rounded-2xl border border-slate-200 bg-white p-4 shadow-xl">
|
<div className="pointer-events-auto mx-auto flex max-w-xl items-center gap-4 rounded-2xl border border-slate-200 bg-white p-4 shadow-xl">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
@@ -430,20 +180,20 @@ export function SihirbazBolumu({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Üretim başladığında modalı kaldır; yeni panelin geçişini Motion
|
{monteEdildi ? (
|
||||||
üstlenir ve odağı panel içeriğine taşır. */}
|
|
||||||
{monteEdildi && durum === "bos" ? (
|
|
||||||
<SihirbazModal
|
<SihirbazModal
|
||||||
acik={acik}
|
acik={acik}
|
||||||
onAcikDegisti={acikDegisti}
|
onAcikDegisti={acikDegisti}
|
||||||
|
sira={sira}
|
||||||
|
tur={tur}
|
||||||
facetler={facetler}
|
facetler={facetler}
|
||||||
baslangicSecimler={
|
girisliMi={girisliMi}
|
||||||
sonSecimler ?? (bekleyen?.eslesir ? bekleyen.secimler : null)
|
hasPaket={hasPaket}
|
||||||
}
|
kredi={kredi}
|
||||||
sonButonEtiketi={
|
mevcutRapor={mevcutRapor}
|
||||||
girisliMi ? "Listemi oluştur (3 kredi)" : "Listemi gör"
|
onizlemeSatirlari={onizlemeSatirlari}
|
||||||
}
|
otomatikUret={otomatikUret}
|
||||||
onTamamla={ilerle}
|
baslangicSecimler={baslangicSecimler}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,52 +1,636 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
ArrowRight,
|
||||||
|
FileText,
|
||||||
|
Loader2,
|
||||||
|
Lock,
|
||||||
|
RefreshCcw,
|
||||||
|
Sparkles,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { 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 { 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({
|
export function SihirbazModal({
|
||||||
acik,
|
acik,
|
||||||
onAcikDegisti,
|
onAcikDegisti,
|
||||||
|
sira,
|
||||||
|
tur,
|
||||||
facetler,
|
facetler,
|
||||||
|
girisliMi,
|
||||||
|
hasPaket,
|
||||||
|
kredi: baslangicKredi,
|
||||||
|
mevcutRapor,
|
||||||
|
onizlemeSatirlari,
|
||||||
|
otomatikUret,
|
||||||
baslangicSecimler,
|
baslangicSecimler,
|
||||||
sonButonEtiketi,
|
|
||||||
onTamamla,
|
|
||||||
}: {
|
}: {
|
||||||
acik: boolean;
|
acik: boolean;
|
||||||
onAcikDegisti: (acik: boolean) => void;
|
onAcikDegisti: (acik: boolean) => void;
|
||||||
|
sira: number;
|
||||||
|
tur: string;
|
||||||
facetler: SihirbazFacetleri;
|
facetler: SihirbazFacetleri;
|
||||||
|
girisliMi: boolean;
|
||||||
|
hasPaket: boolean;
|
||||||
|
kredi: number;
|
||||||
|
mevcutRapor: MevcutRapor | null;
|
||||||
|
onizlemeSatirlari: OnizlemeSatir[];
|
||||||
|
/** Giriş dönüşü: storage'daki seçimlerle otomatik üretime başla */
|
||||||
|
otomatikUret?: boolean;
|
||||||
baslangicSecimler?: SihirbazSecimleri | null;
|
baslangicSecimler?: SihirbazSecimleri | null;
|
||||||
sonButonEtiketi: string;
|
|
||||||
onTamamla: (secimler: SihirbazSecimleri) => void;
|
|
||||||
}) {
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const [faz, setFaz] = useState<Faz>(mevcutRapor ? "liste" : "adimlar");
|
||||||
|
const [adim, setAdim] = useState(0);
|
||||||
|
const [kredi, setKredi] = useState(baslangicKredi);
|
||||||
|
const [rapor, setRapor] = useState<MevcutRapor | null>(mevcutRapor);
|
||||||
|
const [uretimMesajIdx, setUretimMesajIdx] = useState(0);
|
||||||
|
|
||||||
|
// Seçimler
|
||||||
|
const [kategoriler, setKategoriler] = useState<string[]>(
|
||||||
|
baslangicSecimler?.kategoriler ?? [],
|
||||||
|
);
|
||||||
|
const [iller, setIller] = useState<string[]>(baslangicSecimler?.iller ?? []);
|
||||||
|
const [universiteTipi, setUniversiteTipi] = useState<UniversiteTipi>(
|
||||||
|
baslangicSecimler?.universiteTipi ?? "farketmez",
|
||||||
|
);
|
||||||
|
const [oncelikler, setOncelikler] = useState<string[]>(
|
||||||
|
baslangicSecimler?.oncelikler ?? [],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Aynı üretim için sabit requestId (retry çifte harcama yapmaz)
|
||||||
|
const requestIdRef = useRef<string | null>(null);
|
||||||
|
const otomatikBasladi = useRef(false);
|
||||||
|
|
||||||
|
function secimleriTopla(): SihirbazSecimleri {
|
||||||
|
return { kategoriler, iller, universiteTipi, oncelikler };
|
||||||
|
}
|
||||||
|
|
||||||
|
function listeDegistir(
|
||||||
|
liste: string[],
|
||||||
|
setListe: (v: string[]) => void,
|
||||||
|
deger: string,
|
||||||
|
maks?: number,
|
||||||
|
) {
|
||||||
|
if (liste.includes(deger)) {
|
||||||
|
setListe(liste.filter((x) => x !== deger));
|
||||||
|
} else {
|
||||||
|
if (maks && liste.length >= maks) {
|
||||||
|
toast.info(`En fazla ${maks} seçebilirsin.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setListe([...liste, deger]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uret(secimler: SihirbazSecimleri) {
|
||||||
|
if (!requestIdRef.current) requestIdRef.current = crypto.randomUUID();
|
||||||
|
setFaz("uretiliyor");
|
||||||
|
setUretimMesajIdx(0);
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setUretimMesajIdx((i) => Math.min(i + 1, URETIM_MESAJLARI.length - 1));
|
||||||
|
}, 7000);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sonuc = await listeOlustur({
|
||||||
|
sira,
|
||||||
|
tur,
|
||||||
|
secimler,
|
||||||
|
requestId: requestIdRef.current,
|
||||||
|
});
|
||||||
|
if (sonuc.ok) {
|
||||||
|
requestIdRef.current = null;
|
||||||
|
setRapor({
|
||||||
|
rapor: sonuc.rapor,
|
||||||
|
params: sonuc.params,
|
||||||
|
revisionCount: sonuc.revisionCount,
|
||||||
|
});
|
||||||
|
setKredi(sonuc.kredi);
|
||||||
|
setFaz("liste");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Hata: aynı requestId'yi koru ki tekrar denemede çifte harcama olmasın
|
||||||
|
if (sonuc.code === "KREDI") {
|
||||||
|
toast.error(sonuc.error);
|
||||||
|
setFaz("adimlar");
|
||||||
|
} else if (sonuc.code === "PAKET") {
|
||||||
|
setFaz("onizleme");
|
||||||
|
} else if (sonuc.code === "AUTH") {
|
||||||
|
secimleriKaydetVeGirise(secimler);
|
||||||
|
} else {
|
||||||
|
toast.error(sonuc.error);
|
||||||
|
setFaz("adimlar");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error("Beklenmeyen bir hata oluştu, tekrar dener misin?");
|
||||||
|
setFaz("adimlar");
|
||||||
|
} finally {
|
||||||
|
clearInterval(interval);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function secimleriKaydetVeGirise(secimler: SihirbazSecimleri) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(
|
||||||
|
SIHIRBAZ_STORAGE_KEY,
|
||||||
|
JSON.stringify({ sira, tur, secimler }),
|
||||||
|
);
|
||||||
|
} catch {}
|
||||||
|
const geri = `/sonuc?sira=${sira}&tur=${tur}&sihirbaz=1`;
|
||||||
|
router.push(`/giris?callback=${encodeURIComponent(geri)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Giriş dönüşü: storage'daki seçimlerle otomatik üretime başla (bir kez)
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
otomatikUret &&
|
||||||
|
baslangicSecimler &&
|
||||||
|
hasPaket &&
|
||||||
|
girisliMi &&
|
||||||
|
!otomatikBasladi.current &&
|
||||||
|
!mevcutRapor
|
||||||
|
) {
|
||||||
|
otomatikBasladi.current = true;
|
||||||
|
void uret(baslangicSecimler);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Paketsiz kullanıcı önizlemeye geçerken seçimleri sakla ki ödeme dönüşünde
|
||||||
|
// (aynı sıralamayla) sihirbaz kaldığı yerden devam edebilsin.
|
||||||
|
function secimleriSakla(secimler: SihirbazSecimleri) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(
|
||||||
|
SIHIRBAZ_STORAGE_KEY,
|
||||||
|
JSON.stringify({ sira, tur, secimler }),
|
||||||
|
);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ilerle() {
|
||||||
|
const secimler = secimleriTopla();
|
||||||
|
if (!girisliMi) {
|
||||||
|
secimleriKaydetVeGirise(secimler);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!hasPaket) {
|
||||||
|
secimleriSakla(secimler);
|
||||||
|
setFaz("onizleme");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void uret(secimler);
|
||||||
|
}
|
||||||
|
|
||||||
|
const adimGecerli = adim === 0 ? kategoriler.length > 0 : true;
|
||||||
|
|
||||||
|
// ---- Faz gövdeleri ----
|
||||||
|
|
||||||
|
const adimlarGovde = (
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{[0, 1, 2].map((i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className={
|
||||||
|
i === adim
|
||||||
|
? "h-1.5 w-6 rounded-full bg-primary transition-all"
|
||||||
|
: "h-1.5 w-1.5 rounded-full bg-slate-200 transition-all"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<span className="ml-2 text-xs font-medium text-slate-400">
|
||||||
|
Adım {adim + 1}/3
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{adim === 0 ? (
|
||||||
|
<div>
|
||||||
|
<h3 className="font-heading text-lg font-bold">
|
||||||
|
Hangi alanlara ilgi duyuyorsun?
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-sm text-slate-500">
|
||||||
|
Sıralamanla ulaşabileceğin alanlar. En az bir tane seç.
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
|
{facetler.kategoriler.map((k) => (
|
||||||
|
<Cip
|
||||||
|
key={k.ad}
|
||||||
|
secili={kategoriler.includes(k.ad)}
|
||||||
|
onClick={() =>
|
||||||
|
listeDegistir(kategoriler, setKategoriler, k.ad)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{k.ad}
|
||||||
|
<span className="ml-1.5 text-xs opacity-70">{k.adet}</span>
|
||||||
|
</Cip>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : adim === 1 ? (
|
||||||
|
<div>
|
||||||
|
<h3 className="font-heading text-lg font-bold">
|
||||||
|
Nerede okumak istersin?
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-sm text-slate-500">
|
||||||
|
İstersen boş bırak (fark etmez). En fazla 5 il.
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
|
{facetler.iller.map((i) => (
|
||||||
|
<Cip
|
||||||
|
key={i.il}
|
||||||
|
secili={iller.includes(i.il)}
|
||||||
|
onClick={() => listeDegistir(iller, setIller, i.il, 5)}
|
||||||
|
>
|
||||||
|
{i.il}
|
||||||
|
<span className="ml-1.5 text-xs opacity-70">{i.adet}</span>
|
||||||
|
</Cip>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-heading text-lg font-bold">
|
||||||
|
Üniversite tipi & öncelikler
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-sm text-slate-500">
|
||||||
|
Bunlar listenin dengesini belirler.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 text-xs font-semibold tracking-wide text-slate-400 uppercase">
|
||||||
|
Üniversite tipi
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{facetler.uniturler.map((u) => (
|
||||||
|
<Cip
|
||||||
|
key={u.grup}
|
||||||
|
secili={universiteTipi === u.grup}
|
||||||
|
onClick={() =>
|
||||||
|
setUniversiteTipi(
|
||||||
|
universiteTipi === u.grup ? "farketmez" : u.grup,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{UNIVERSITE_TIPI_ETIKET[u.grup]}
|
||||||
|
<span className="ml-1.5 text-xs opacity-70">{u.adet}</span>
|
||||||
|
</Cip>
|
||||||
|
))}
|
||||||
|
<Cip
|
||||||
|
secili={universiteTipi === "farketmez"}
|
||||||
|
onClick={() => setUniversiteTipi("farketmez")}
|
||||||
|
>
|
||||||
|
Fark etmez
|
||||||
|
</Cip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 text-xs font-semibold tracking-wide text-slate-400 uppercase">
|
||||||
|
Senin için önemli olan
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{ONCELIKLER.map((o) => (
|
||||||
|
<Cip
|
||||||
|
key={o}
|
||||||
|
secili={oncelikler.includes(o)}
|
||||||
|
onClick={() => listeDegistir(oncelikler, setOncelikler, o)}
|
||||||
|
>
|
||||||
|
{o}
|
||||||
|
</Cip>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between border-t border-slate-100 pt-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setAdim((a) => a - 1)}
|
||||||
|
disabled={adim === 0}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="size-4" aria-hidden />
|
||||||
|
Geri
|
||||||
|
</Button>
|
||||||
|
{adim < 2 ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setAdim((a) => a + 1)}
|
||||||
|
disabled={!adimGecerli}
|
||||||
|
className="cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||||
|
>
|
||||||
|
Devam
|
||||||
|
<ArrowRight className="size-4" aria-hidden />
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={ilerle}
|
||||||
|
disabled={kategoriler.length === 0}
|
||||||
|
className="cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||||
|
>
|
||||||
|
<Sparkles className="size-4" aria-hidden />
|
||||||
|
{hasPaket ? "24'lük listemi oluştur (3 kredi)" : "Listemi gör"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const onizlemeGovde = (
|
||||||
|
<div>
|
||||||
|
<h3 className="font-heading text-xl font-bold">
|
||||||
|
24 tercihlik listen hazır olmak üzere
|
||||||
|
</h3>
|
||||||
|
<p className="mt-2 text-sm text-slate-600">
|
||||||
|
Aşağıda ilk birkaç satırın ön izlemesi var. Tam listeyi, gerekçeleri, risk
|
||||||
|
analizini ve PDF çıktıyı görmek için paketi aktive et.
|
||||||
|
</p>
|
||||||
|
<div className="mt-6 overflow-hidden rounded-2xl border border-slate-200">
|
||||||
|
{onizlemeSatirlari.length > 0 ? (
|
||||||
|
<ul className="divide-y divide-slate-100">
|
||||||
|
{onizlemeSatirlari.map((p, i) => (
|
||||||
|
<li key={p.id} className="flex items-center gap-4 px-5 py-3">
|
||||||
|
<span className="w-6 text-right font-heading font-bold text-slate-400">
|
||||||
|
{i + 1}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate font-medium">{p.isim}</p>
|
||||||
|
<p className="truncate text-sm text-slate-500">
|
||||||
|
{p.universite} · {p.il ?? "—"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant="secondary" className="bg-blue-100 text-blue-700">
|
||||||
|
Dengeli
|
||||||
|
</Badge>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
<div className="relative">
|
||||||
|
<ul className="divide-y divide-slate-100 blur-sm select-none" aria-hidden>
|
||||||
|
{Array.from({ length: 7 }).map((_, i) => (
|
||||||
|
<li key={i} className="flex items-center gap-4 px-5 py-3">
|
||||||
|
<span className="w-6 text-right font-heading font-bold text-slate-300">
|
||||||
|
{onizlemeSatirlari.length + i + 1}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 space-y-1.5">
|
||||||
|
<div className="h-3.5 w-2/3 rounded bg-slate-200" />
|
||||||
|
<div className="h-3 w-1/2 rounded bg-slate-100" />
|
||||||
|
</div>
|
||||||
|
<div className="h-5 w-16 rounded-full bg-slate-100" />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-gradient-to-b from-white/30 via-white/80 to-white">
|
||||||
|
<Lock className="size-8 text-slate-400" aria-hidden />
|
||||||
|
<p className="max-w-sm text-center text-sm font-medium text-slate-700">
|
||||||
|
+19 tercih, her satır için gerekçe ve risk analizi, PDF çıktı ve AI
|
||||||
|
soru hakkı seni bekliyor.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
size="lg"
|
||||||
|
className="h-12 cursor-pointer bg-orange-500 px-8 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||||
|
>
|
||||||
|
<Link href="/paket">299 TL — Paketi aktive et</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setFaz("adimlar")}
|
||||||
|
className="mt-4 cursor-pointer"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="size-4" aria-hidden />
|
||||||
|
Seçimlere dön
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const uretiliyorGovde = (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-4 py-16 text-center">
|
||||||
|
<Loader2 className="size-10 animate-spin text-primary" aria-hidden />
|
||||||
|
<p className="font-heading text-lg font-bold">Listen hazırlanıyor</p>
|
||||||
|
<p className="max-w-sm text-sm text-slate-500">
|
||||||
|
{URETIM_MESAJLARI[uretimMesajIdx]}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
Bu işlem yarım dakika kadar sürebilir, sayfayı kapatma.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const listeGovde = rapor ? (
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-heading text-xl font-bold">24 Tercihlik Listen</h3>
|
||||||
|
<p className="mt-1 text-sm text-slate-500">
|
||||||
|
{sira.toLocaleString("tr-TR")}. sıra · {kredi} kredin kaldı
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button asChild variant="outline" className="cursor-pointer">
|
||||||
|
<Link href="/rapor/yazdir" target="_blank">
|
||||||
|
<FileText className="size-4" aria-hidden />
|
||||||
|
PDF indir
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5">
|
||||||
|
<RaporListesi rapor={rapor.rapor} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<RevizyonKutusu
|
||||||
|
kalanHak={MAX_REVIZYON - rapor.revisionCount}
|
||||||
|
onRevize={async (feedback) => {
|
||||||
|
const sonuc = await listeRevize({
|
||||||
|
feedback,
|
||||||
|
requestId: crypto.randomUUID(),
|
||||||
|
});
|
||||||
|
if (sonuc.ok) {
|
||||||
|
setRapor({
|
||||||
|
rapor: sonuc.rapor,
|
||||||
|
params: sonuc.params,
|
||||||
|
revisionCount: sonuc.revisionCount,
|
||||||
|
});
|
||||||
|
setKredi(sonuc.kredi);
|
||||||
|
toast.success("Listen yeniden kuruldu.");
|
||||||
|
} else {
|
||||||
|
toast.error(sonuc.error);
|
||||||
|
}
|
||||||
|
return sonuc.ok;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<SoruKutusu kredi={kredi} onKrediDegisti={setKredi} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={acik} onOpenChange={onAcikDegisti}>
|
<Dialog open={acik} onOpenChange={onAcikDegisti}>
|
||||||
<DialogContent
|
<DialogContent className="max-h-[92dvh] gap-0 overflow-y-auto p-6 sm:max-w-3xl">
|
||||||
data-sihirbaz-modal
|
|
||||||
className="max-h-[92dvh] gap-0 overflow-y-auto p-6 sm:max-w-3xl"
|
|
||||||
>
|
|
||||||
<DialogTitle className="sr-only">Tercih listesi sihirbazı</DialogTitle>
|
<DialogTitle className="sr-only">Tercih listesi sihirbazı</DialogTitle>
|
||||||
<DialogDescription className="sr-only">
|
<DialogDescription className="sr-only">
|
||||||
Sıralamana uygun 24 tercihlik listeni oluştur.
|
Sıralamana uygun 24 tercihlik listeni oluştur.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
<SihirbazAdimlar
|
{faz === "adimlar"
|
||||||
facetler={facetler}
|
? adimlarGovde
|
||||||
baslangicSecimler={baslangicSecimler}
|
: faz === "onizleme"
|
||||||
sonButonEtiketi={sonButonEtiketi}
|
? onizlemeGovde
|
||||||
onTamamla={onTamamla}
|
: faz === "uretiliyor"
|
||||||
/>
|
? uretiliyorGovde
|
||||||
|
: listeGovde}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function RevizyonKutusu({
|
||||||
|
kalanHak,
|
||||||
|
onRevize,
|
||||||
|
}: {
|
||||||
|
kalanHak: number;
|
||||||
|
onRevize: (feedback: string) => Promise<boolean>;
|
||||||
|
}) {
|
||||||
|
const [feedback, setFeedback] = useState("");
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
|
||||||
|
if (kalanHak <= 0) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-slate-200 bg-white p-5">
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
Revizyon hakların doldu. Aşağıdan listen hakkında soru sorabilirsin.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-slate-200 bg-white p-5">
|
||||||
|
<h3 className="font-heading text-base font-bold">
|
||||||
|
Listede değişiklik mi istiyorsun?
|
||||||
|
</h3>
|
||||||
|
<p className="mb-3 mt-1 text-sm text-slate-600">
|
||||||
|
Ne değişsin istediğini yaz; liste aynı gerçek veriyle yeniden kurulsun (3
|
||||||
|
kredi).
|
||||||
|
</p>
|
||||||
|
<textarea
|
||||||
|
value={feedback}
|
||||||
|
onChange={(e) => setFeedback(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
maxLength={1000}
|
||||||
|
placeholder="ör. İlk 5 tercihte daha fazla İstanbul olsun, garanti dilimini genişlet…"
|
||||||
|
disabled={pending}
|
||||||
|
className="w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||||
|
/>
|
||||||
|
<div className="mt-3 flex items-center gap-3">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={pending || feedback.trim().length < 5}
|
||||||
|
onClick={async () => {
|
||||||
|
setPending(true);
|
||||||
|
const ok = await onRevize(feedback.trim());
|
||||||
|
setPending(false);
|
||||||
|
if (ok) setFeedback("");
|
||||||
|
}}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
{pending ? (
|
||||||
|
<Loader2 className="size-4 animate-spin" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<RefreshCcw className="size-4" aria-hidden />
|
||||||
|
)}
|
||||||
|
{pending ? "Yeniden kuruluyor…" : "Revize et (3 kredi)"}
|
||||||
|
</Button>
|
||||||
|
<span className="text-xs text-slate-500">
|
||||||
|
Kalan hak: <span className="font-semibold">{kalanHak}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,68 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
// motion-primitives TextLoop — https://motion-primitives.com/docs/text-loop
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import {
|
|
||||||
AnimatePresence,
|
|
||||||
motion,
|
|
||||||
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);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const intervalMs = interval * 1000;
|
|
||||||
const timer = setInterval(() => {
|
|
||||||
setCurrentIndex((current) => {
|
|
||||||
const next = (current + 1) % items.length;
|
|
||||||
onIndexChange?.(next);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, intervalMs);
|
|
||||||
return () => clearInterval(timer);
|
|
||||||
}, [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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -2,115 +2,49 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { ArrowRight, Loader2 } from "lucide-react";
|
import { ArrowRight } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { SihirbazAdimlar } from "@/components/sihirbaz-adimlar";
|
|
||||||
import type { SihirbazFacetleri } from "@/lib/db";
|
|
||||||
import { SIHIRBAZ_STORAGE_KEY, type SihirbazSecimleri } from "@/lib/sihirbaz";
|
|
||||||
|
|
||||||
const TUR: string = "say";
|
|
||||||
|
|
||||||
export function HeroForm() {
|
export function HeroForm() {
|
||||||
const [siralama, setSiralama] = useState("");
|
const [siralama, setSiralama] = useState("");
|
||||||
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 router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const value = Number(siralama.replace(/\./g, ""));
|
const value = Number(siralama.replace(/\./g, ""));
|
||||||
if (!value || value < 1 || value > 4_000_000) {
|
if (!value || value < 1 || value > 4_000_000) {
|
||||||
toast.error("Geçerli bir başarı sıralaması gir (ör. 85.000).");
|
toast.error("Geçerli bir başarı sıralaması gir (ör. 85.000).");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setYukleniyor(true);
|
router.push(`/sonuc?sira=${value}`);
|
||||||
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 {
|
|
||||||
toast.error("Bir şeyler ters gitti, tekrar dener misin?");
|
|
||||||
} finally {
|
|
||||||
setYukleniyor(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Seçimler tamam: sakla ve giriş kapısına yönlendir. Girişli kullanıcıyı
|
|
||||||
// /giris zaten callback'e (sonuç + otomatik üretim) sektirir; girişsiz
|
|
||||||
// kullanıcı listeyi görmeden önce funnel'lı giriş ekranıyla karşılaşır.
|
|
||||||
function secimleriTamamla(secimler: SihirbazSecimleri) {
|
|
||||||
if (!sira) return;
|
|
||||||
try {
|
|
||||||
localStorage.setItem(
|
|
||||||
SIHIRBAZ_STORAGE_KEY,
|
|
||||||
JSON.stringify({ sira, tur: TUR, secimler }),
|
|
||||||
);
|
|
||||||
} catch {}
|
|
||||||
setModalAcik(false);
|
|
||||||
const geri = `/sonuc?sira=${sira}&tur=${TUR}&sihirbaz=1`;
|
|
||||||
router.push(`/giris?callback=${encodeURIComponent(geri)}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<form
|
||||||
<form
|
onSubmit={handleSubmit}
|
||||||
onSubmit={handleSubmit}
|
className="flex w-full max-w-md flex-col gap-3 sm:flex-row"
|
||||||
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"
|
||||||
>
|
>
|
||||||
<label htmlFor="siralama" className="sr-only">
|
Listemi oluştur
|
||||||
YKS başarı sıralaması
|
<ArrowRight className="size-4" aria-hidden />
|
||||||
</label>
|
</Button>
|
||||||
<Input
|
</form>
|
||||||
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"
|
|
||||||
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>
|
|
||||||
|
|
||||||
<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}
|
|
||||||
facetler={facetler}
|
|
||||||
sonButonEtiketi="Tercih planıma geç"
|
|
||||||
onTamamla={secimleriTamamla}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,101 +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}
|
|
||||||
>
|
|
||||||
<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 Listem 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 />
|
|
||||||
Listem
|
|
||||||
<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">
|
|
||||||
Listen henüz boş
|
|
||||||
</p>
|
|
||||||
<p className="text-xs leading-relaxed text-slate-500">
|
|
||||||
Sonuç sayfasındaki tablodan <span aria-hidden>+</span> ile
|
|
||||||
program ekle; 24 tercihlik listen 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,45 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
// Navbar'daki "Listem" butonu: manuel listenin sayacını taşır, tıklanınca
|
|
||||||
// çekmeceyi açar/kapatır. Sayaç değişince rozet spring ile "pulse" yapar.
|
|
||||||
|
|
||||||
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();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={cekmeceDegistir}
|
|
||||||
aria-expanded={acik}
|
|
||||||
aria-label={`Listem — ${liste.length}/${MANUEL_LISTE_MAX} tercih`}
|
|
||||||
title="24'lük tercih listen"
|
|
||||||
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">Listem</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,450 +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 kendi 24'lük listene 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 listende"
|
|
||||||
: doldu
|
|
||||||
? `Liste dolu (${MANUEL_LISTE_MAX}/${MANUEL_LISTE_MAX})`
|
|
||||||
: "Listeme 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} listende` : `${p.isim} listeye ekle`}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,383 +1,82 @@
|
|||||||
"use client";
|
import { Rocket, Scale, ShieldCheck } from "lucide-react";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
// 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 type { RaporSonuc } from "@/lib/ai/rapor";
|
import type { RaporSonuc } from "@/lib/ai/rapor";
|
||||||
import {
|
|
||||||
RISK_ETIKET,
|
|
||||||
dilimdenRisk,
|
|
||||||
riskHesapla,
|
|
||||||
type RiskSeviyesi,
|
|
||||||
} from "@/lib/risk";
|
|
||||||
|
|
||||||
export const DILIM_ETIKET: Record<string, string> = {
|
export const DILIM_STIL: Record<
|
||||||
hayal: "Hayal",
|
string,
|
||||||
dengeli: "Dengeli",
|
{ label: string; cls: string; icon: typeof Rocket }
|
||||||
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 }
|
|
||||||
> = {
|
> = {
|
||||||
riskli: { nokta: "bg-red-500", kenar: "border-l-red-500", icon: Rocket },
|
hayal: { label: "Hayal", cls: "bg-orange-100 text-orange-700", icon: Rocket },
|
||||||
"az-riskli": {
|
dengeli: { label: "Dengeli", cls: "bg-blue-100 text-blue-700", icon: Scale },
|
||||||
nokta: "bg-amber-500",
|
garanti: {
|
||||||
kenar: "border-l-amber-500",
|
label: "Garanti",
|
||||||
icon: Scale,
|
cls: "bg-emerald-100 text-emerald-700",
|
||||||
},
|
|
||||||
guvenli: {
|
|
||||||
nokta: "bg-emerald-500",
|
|
||||||
kenar: "border-l-emerald-500",
|
|
||||||
icon: ShieldCheck,
|
icon: ShieldCheck,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const YILLAR = [2021, 2022, 2023, 2024, 2025];
|
/**
|
||||||
|
* 24'lük listenin gövdesi (genel değerlendirme + tercih kartları).
|
||||||
type Trend = "zorlasiyor" | "kolaylasiyor" | "stabil";
|
* Hem sonuç modalında hem PDF/yazdır sayfasında kullanılır.
|
||||||
|
*/
|
||||||
/** Deterministik trend: ilk ve son bilinen taban sıralaması kıyaslanır.
|
export function RaporListesi({ rapor }: { rapor: RaporSonuc }) {
|
||||||
* 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;
|
|
||||||
|
|
||||||
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 trendG = trend ? TREND_GORUNUM[trend] : null;
|
|
||||||
const acikMi = acik.has(t.sira);
|
|
||||||
const sira2024 = p?.siraGecmisi?.[3] ?? null;
|
|
||||||
const sira2025 = p?.siraGecmisi?.[4] ?? 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 yok</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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex flex-wrap items-end justify-between gap-2">
|
<div className="rounded-2xl border border-slate-200 bg-white p-6">
|
||||||
<div>
|
<h2 className="font-heading text-lg font-bold">Genel değerlendirme</h2>
|
||||||
<h2 className="font-heading text-lg font-bold">24 tercihin ve taban verileri</h2>
|
<p className="mt-2 whitespace-pre-line text-sm leading-relaxed text-slate-700">
|
||||||
<p className="mt-1 text-xs text-slate-500">
|
{rapor.genelDegerlendirme}
|
||||||
Satıra dokunarak 2021–2025 trendini inceleyebilirsin.
|
</p>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{kilitIdx < rapor.tercihler.length ? (
|
|
||||||
<p className="text-xs font-medium text-emerald-700">
|
|
||||||
Programlar ve ham veriler ücretsiz
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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">
|
<ol className="mt-6 space-y-3">
|
||||||
<span className="w-7">#</span>
|
{rapor.tercihler.map((t) => {
|
||||||
<span>Program</span>
|
const p = rapor.programlar[t.programId];
|
||||||
<span className="text-right">2024 taban</span>
|
const stil = DILIM_STIL[t.dilim];
|
||||||
<span className="text-right">2025 taban</span>
|
return (
|
||||||
<span>Ham trend</span>
|
<li
|
||||||
<span>AI risk</span>
|
key={t.sira}
|
||||||
<span className="w-4" />
|
className="rounded-2xl border border-slate-200 bg-white p-5"
|
||||||
</div>
|
>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<ol className="mt-2 flex flex-col gap-2">
|
<span className="flex size-8 items-center justify-center rounded-full bg-slate-100 font-heading text-sm font-bold">
|
||||||
{rapor.tercihler.map((t, index) => (
|
{t.sira}
|
||||||
<Fragment key={t.sira}>
|
</span>
|
||||||
{index === kilitIdx && kilitOverlay ? (
|
<div className="min-w-0 flex-1">
|
||||||
<li className="my-2 list-none rounded-2xl border border-orange-200 bg-orange-50/70 p-5">
|
<p className="font-semibold">{p?.isim ?? t.programId}</p>
|
||||||
{kilitOverlay}
|
<p className="text-sm text-slate-500">
|
||||||
</li>
|
{p?.universite} · {p?.il ?? "—"}
|
||||||
) : null}
|
{p?.unitur ? ` · ${p.unitur}` : ""}
|
||||||
{satir(t, index)}
|
</p>
|
||||||
</Fragment>
|
</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>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,282 +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 { toast } from "sonner";
|
|
||||||
import { cities } from "turkey-map-react/lib/data";
|
|
||||||
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 [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;
|
|
||||||
|
|
||||||
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-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">
|
|
||||||
Haritadan dokunarak seç (en fazla 5 il). İstersen boş bırak, fark
|
|
||||||
etmez.
|
|
||||||
</p>
|
|
||||||
<div className="mt-4">
|
|
||||||
<IlSecimHaritasi
|
|
||||||
iller={facetler.iller.filter((i) => HARITA_ILLERI.has(i.il))}
|
|
||||||
secili={iller}
|
|
||||||
onToggle={(il) => listeDegistir(iller, setIller, il, 5)}
|
|
||||||
/>
|
|
||||||
</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}
|
|
||||||
{facetler.iller.some((i) => !HARITA_ILLERI.has(i.il)) ? (
|
|
||||||
<div className="mt-3">
|
|
||||||
<p className="mb-2 text-xs font-semibold tracking-wide text-slate-400 uppercase">
|
|
||||||
Harita dışı (KKTC vb.)
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{facetler.iller
|
|
||||||
.filter((i) => !HARITA_ILLERI.has(i.il))
|
|
||||||
.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>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</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={() =>
|
|
||||||
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,236 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import {
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
type ComponentType,
|
|
||||||
type RefAttributes,
|
|
||||||
type RefObject,
|
|
||||||
} from "react"
|
|
||||||
import {
|
|
||||||
motion,
|
|
||||||
useInView,
|
|
||||||
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")
|
|
||||||
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]
|
|
||||||
)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setDisplayedText("")
|
|
||||||
setCurrentWordIndex(0)
|
|
||||||
setCurrentCharIndex(0)
|
|
||||||
setPhase("typing")
|
|
||||||
}, [animationSourceKey])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let timeout: ReturnType<typeof setTimeout> | null = null
|
|
||||||
|
|
||||||
if (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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
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}
|
|
||||||
>
|
|
||||||
{displayedText}
|
|
||||||
{shouldShowCursor && (
|
|
||||||
<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 { Coins } from "lucide-react";
|
||||||
import { getCurrentUser } from "@/lib/session";
|
import { getCurrentUser } from "@/lib/session";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ListemButonu } from "@/components/manuel-liste/listem-butonu";
|
|
||||||
import { SignOutButton } from "./sign-out-button";
|
import { SignOutButton } from "./sign-out-button";
|
||||||
|
|
||||||
export async function UserNav() {
|
export async function UserNav() {
|
||||||
const u = await getCurrentUser();
|
const u = await getCurrentUser();
|
||||||
|
|
||||||
// Manuel liste localStorage'ta yaşadığı için Listem girişsiz de çalışır
|
|
||||||
if (!u) {
|
if (!u) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<Button
|
||||||
<ListemButonu />
|
asChild
|
||||||
<Button
|
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"
|
||||||
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>
|
||||||
<Link href="/giris">Giriş yap</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<ListemButonu />
|
|
||||||
<Link
|
<Link
|
||||||
href="/paket"
|
href="/paket"
|
||||||
className="inline-flex h-12 items-center gap-2 whitespace-nowrap rounded-full border border-slate-200 bg-white px-5 text-sm font-semibold text-slate-700 transition-colors duration-200 hover:border-slate-300 hover:bg-slate-50"
|
className="inline-flex h-12 items-center gap-2 whitespace-nowrap rounded-full border border-slate-200 bg-white px-5 text-sm font-semibold text-slate-700 transition-colors duration-200 hover:border-slate-300 hover:bg-slate-50"
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
type AdayProgram,
|
type AdayProgram,
|
||||||
} from "../rapor-havuzu";
|
} from "../rapor-havuzu";
|
||||||
import { secimOzeti } from "../sihirbaz";
|
import { secimOzeti } from "../sihirbaz";
|
||||||
import { RISK_ETIKET, dilimdenRisk, riskHesapla } from "../risk";
|
|
||||||
|
|
||||||
const TercihSchema = z.object({
|
const TercihSchema = z.object({
|
||||||
programId: z.string(),
|
programId: z.string(),
|
||||||
@@ -23,25 +22,13 @@ const TercihSchema = z.object({
|
|||||||
const RaporSchema = z.object({
|
const RaporSchema = z.object({
|
||||||
tercihler: z.array(TercihSchema),
|
tercihler: z.array(TercihSchema),
|
||||||
genelDegerlendirme: z.string(),
|
genelDegerlendirme: z.string(),
|
||||||
uyarilar: z.array(z.string()).min(2).max(3),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// uyarilar şemada zorunlu ama eski kayıtlı raporlarda yok → tipte opsiyonel
|
export type RaporSonuc = z.infer<typeof RaporSchema> & {
|
||||||
export type RaporSonuc = Omit<z.infer<typeof RaporSchema>, "uyarilar"> & {
|
|
||||||
uyarilar?: string[];
|
|
||||||
// Render için havuzdan zenginleştirilmiş program bilgisi
|
// Render için havuzdan zenginleştirilmiş program bilgisi
|
||||||
// efektifSira: COALESCE(sira2025, sira2024) — eski kayıtlı raporlarda yok
|
|
||||||
// siraGecmisi: [2021..2025] taban sıralamaları — deterministik trend oku
|
|
||||||
programlar: Record<
|
programlar: Record<
|
||||||
string,
|
string,
|
||||||
{
|
{ isim: string; universite: string; il: string | null; unitur: string | null }
|
||||||
isim: string;
|
|
||||||
universite: string;
|
|
||||||
il: string | null;
|
|
||||||
unitur: string | null;
|
|
||||||
efektifSira?: number | null;
|
|
||||||
siraGecmisi?: (number | null)[];
|
|
||||||
}
|
|
||||||
>;
|
>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -55,104 +42,17 @@ KURALLAR (kesin):
|
|||||||
- Liste yapısı: en üstte 4-6 hayal, ortada 12-14 dengeli, sonda 5-7 garanti.
|
- Liste yapısı: en üstte 4-6 hayal, ortada 12-14 dengeli, sonda 5-7 garanti.
|
||||||
Tercih sırası MUTLAKA iyi taban sıralamasından kötüye doğru gitmeli
|
Tercih sırası MUTLAKA iyi taban sıralamasından kötüye doğru gitmeli
|
||||||
(küçük taban sıralaması = daha zor bölüm üstte).
|
(küçük taban sıralaması = daha zor bölüm üstte).
|
||||||
- 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 (1-2 cümle, Türkçe).
|
||||||
- gerekce: bu programın bu aday için neden mantıklı olduğu (TEK cümle,
|
- riskNotu: yerleşme ihtimali değerlendirmesi. ASLA "garanti", "kesin",
|
||||||
en fazla ~15 kelime, Türkçe).
|
"yüzde yüz" deme; "yüksek/orta/düşük ihtimal" dili kullan.
|
||||||
- riskNotu: yerleşme ihtimali değerlendirmesi (TEK kısa cümle). ASLA "garanti",
|
- trendOzeti: son 4 yılın taban sıralaması hareketi (ör. "2022'den beri taban
|
||||||
"kesin", "yüzde yüz" deme; "yüksek/orta/düşük ihtimal" dili kullan.
|
sıralaması 45B'den 38B'ye indi, bölüm zorlaşıyor").
|
||||||
- trendOzeti: son 4 yılın taban sıralaması hareketi, TEK cümle (ör. "2022'den
|
|
||||||
beri taban 45B'den 38B'ye indi, bölüm zorlaşıyor").
|
|
||||||
- Kontenjan artışı/azalışı ve yerleşen sayısını değerlendirmene kat.
|
- Kontenjan artışı/azalışı ve yerleşen sayısını değerlendirmene kat.
|
||||||
- Adayın notlarındaki şehir/bölüm tercihlerine saygı göster ama dengeyi bozma.
|
- Adayın notlarındaki şehir/bölüm tercihlerine saygı göster ama dengeyi bozma.
|
||||||
- genelDegerlendirme: listenin genel stratejisi, EN FAZLA 2 cümle. Uyarıları
|
- genelDegerlendirme: listenin genel stratejisi + adaya 2-3 pratik uyarı (Türkçe).`;
|
||||||
buraya yazma.
|
|
||||||
- uyarilar: adaya 2-3 pratik, eyleme dönük uyarı; her biri tek kısa cümle.`;
|
|
||||||
|
|
||||||
export class RaporUretimHatasi extends Error {}
|
export class RaporUretimHatasi extends Error {}
|
||||||
|
|
||||||
/** Havuz programını render için gereken bilgiye indirger. */
|
|
||||||
function programBilgisi(p: AdayProgram): RaporSonuc["programlar"][string] {
|
|
||||||
const gecmis = [p.sira2021, p.sira2022, p.sira2023, p.sira2024, p.sira2025];
|
|
||||||
return {
|
|
||||||
isim: p.isim,
|
|
||||||
universite: p.universite,
|
|
||||||
il: p.il,
|
|
||||||
unitur: p.unitur,
|
|
||||||
efektifSira: p.sira2025 ?? p.sira2024,
|
|
||||||
siraGecmisi: gecmis.some((s) => s != null) ? gecmis : undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Dev mock: API key yokken (yalnızca development) gerçek havuzdan sahte
|
|
||||||
// ama tutarlı bir rapor üret. Akış (kredi, blur, risk renkleri, revizyon)
|
|
||||||
// gerçek AI olmadan uçtan uca test edilebilsin diye. Prod'da asla çalışmaz.
|
|
||||||
|
|
||||||
function mockAktif(): boolean {
|
|
||||||
return (
|
|
||||||
process.env.NODE_ENV !== "production" &&
|
|
||||||
!process.env.ANTHROPIC_API_KEY &&
|
|
||||||
!process.env.ANTHROPIC_AUTH_TOKEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function mockRapor(
|
|
||||||
havuz: AdayProgram[],
|
|
||||||
params: RaporParams,
|
|
||||||
revizyonNotu?: string,
|
|
||||||
): RaporSonuc {
|
|
||||||
const efektif = (p: AdayProgram) => p.sira2025 ?? p.sira2024 ?? 0;
|
|
||||||
const dilimden = (dilim: AdayProgram["dilim"], adet: number) =>
|
|
||||||
havuz
|
|
||||||
.filter((p) => p.dilim === dilim)
|
|
||||||
.sort((a, b) => efektif(a) - efektif(b))
|
|
||||||
.slice(0, adet);
|
|
||||||
|
|
||||||
// 5 hayal + 13 dengeli + 6 garanti; eksik dilim varsa havuzdan tamamla
|
|
||||||
const secilen = [
|
|
||||||
...dilimden("hayal", 5),
|
|
||||||
...dilimden("dengeli", 13),
|
|
||||||
...dilimden("garanti", 6),
|
|
||||||
];
|
|
||||||
for (const p of havuz) {
|
|
||||||
if (secilen.length >= 24) break;
|
|
||||||
if (!secilen.includes(p)) secilen.push(p);
|
|
||||||
}
|
|
||||||
|
|
||||||
const tercihler = secilen.slice(0, 24).map((p, i) => ({
|
|
||||||
programId: p.id,
|
|
||||||
sira: i + 1,
|
|
||||||
dilim: p.dilim,
|
|
||||||
gerekce: `(Dev mock) ${p.isim}, seçtiğin ilgi alanlarıyla ve ${params.sira.toLocaleString("tr-TR")}. sıralamanla uyumlu bir aday.`,
|
|
||||||
riskNotu:
|
|
||||||
p.dilim === "hayal"
|
|
||||||
? "(Dev mock) Taban sıralaması senin üstünde kapandı; düşük ihtimal ama listenin başında yeri var."
|
|
||||||
: p.dilim === "dengeli"
|
|
||||||
? "(Dev mock) Taban sıralaması senin civarında; orta-yüksek ihtimalle yerleşilebilir."
|
|
||||||
: "(Dev mock) Taban sıralaması senin belirgin altında; yüksek ihtimalle yerleşirsin.",
|
|
||||||
trendOzeti:
|
|
||||||
"(Dev mock) Son 4 yılda taban sıralaması görece stabil seyretti.",
|
|
||||||
}));
|
|
||||||
|
|
||||||
const programlar: RaporSonuc["programlar"] = {};
|
|
||||||
for (const p of secilen.slice(0, 24)) {
|
|
||||||
programlar[p.id] = programBilgisi(p);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
tercihler,
|
|
||||||
genelDegerlendirme: `(Dev mock — ANTHROPIC_API_KEY tanımlı değil, bu rapor gerçek AI çıktısı değildir.) ${params.sira.toLocaleString(
|
|
||||||
"tr-TR",
|
|
||||||
)}. sıra için hayal/dengeli/garanti dağılımlı 24 tercihlik örnek liste kuruldu.${
|
|
||||||
revizyonNotu ? ` Revizyon isteğin not alındı: "${revizyonNotu}".` : ""
|
|
||||||
}`,
|
|
||||||
uyarilar: [
|
|
||||||
"(Dev mock) Tercih dönemi kapanmadan listeyi ÖSYM sistemine kendin gir.",
|
|
||||||
"(Dev mock) Garanti dilimindeki son 3 tercihi asla silme.",
|
|
||||||
],
|
|
||||||
programlar,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function raporUret(
|
export async function raporUret(
|
||||||
params: RaporParams,
|
params: RaporParams,
|
||||||
revizyonNotu?: string,
|
revizyonNotu?: string,
|
||||||
@@ -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 client = getAnthropic();
|
||||||
const havuzJson = havuzuKompaktJson(havuz);
|
const havuzJson = havuzuKompaktJson(havuz);
|
||||||
const kullaniciMesaji = [
|
const kullaniciMesaji = [
|
||||||
@@ -263,24 +157,22 @@ export async function raporUret(
|
|||||||
|
|
||||||
const programlar: RaporSonuc["programlar"] = {};
|
const programlar: RaporSonuc["programlar"] = {};
|
||||||
for (const p of havuz) {
|
for (const p of havuz) {
|
||||||
programlar[p.id] = programBilgisi(p);
|
programlar[p.id] = {
|
||||||
|
isim: p.isim,
|
||||||
|
universite: p.universite,
|
||||||
|
il: p.il,
|
||||||
|
unitur: p.unitur,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return { ...sonuc, programlar };
|
return { ...sonuc, programlar };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listeOzetiCikar(rapor: RaporSonuc, adaySira?: number): string {
|
export function listeOzetiCikar(rapor: RaporSonuc): string {
|
||||||
return rapor.tercihler
|
return rapor.tercihler
|
||||||
.map((t) => {
|
.map(
|
||||||
const p = rapor.programlar[t.programId];
|
(t) =>
|
||||||
const risk =
|
`${t.sira}. [${t.dilim}] ${rapor.programlar[t.programId]?.isim ?? "?"} — ${rapor.programlar[t.programId]?.universite ?? "?"}`,
|
||||||
(adaySira != null ? riskHesapla(p?.efektifSira, adaySira) : null) ??
|
)
|
||||||
dilimdenRisk(t.dilim);
|
|
||||||
const taban =
|
|
||||||
p?.efektifSira != null
|
|
||||||
? ` (taban sırası ~${p.efektifSira.toLocaleString("tr-TR")})`
|
|
||||||
: "";
|
|
||||||
return `${t.sira}. [${t.dilim} | ${RISK_ETIKET[risk]}] ${p?.isim ?? "?"} — ${p?.universite ?? "?"}${taban}`;
|
|
||||||
})
|
|
||||||
.join("\n");
|
.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,43 +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),
|
|
||||||
uyarilar: rapor.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