init commit
This commit is contained in:
171
README.md
171
README.md
@@ -1,2 +1,171 @@
|
||||
# Next.js
|
||||
# Professional Resume Builder
|
||||
|
||||
**Created by David Valera Melendez** | david@valera-melendez.de | Made in Germany 🇩🇪
|
||||
|
||||
A modern, professional resume builder application built with Next.js, TypeScript, and Tailwind CSS. Designed specifically for creating impressive resumes for job applications.
|
||||
|
||||
## 🚀 Features
|
||||
|
||||
- **Professional Design**: Clean, modern interface optimized for professional use
|
||||
- **Real-time Preview**: See your resume as you build it
|
||||
- **Multiple Sections**: Personal info, experience, education, skills, languages, certifications, and projects
|
||||
- **Responsive Design**: Works perfectly on desktop and mobile devices
|
||||
- **Export Options**: Generate PDF versions of your resume (coming soon)
|
||||
- **TypeScript**: Fully typed for better development experience
|
||||
- **Tailwind CSS**: Modern styling with custom professional theme
|
||||
|
||||
## 🛠️ Tech Stack
|
||||
|
||||
- **Framework**: Next.js 14+ with App Router
|
||||
- **Language**: TypeScript
|
||||
- **Styling**: Tailwind CSS
|
||||
- **UI Components**: Custom React components
|
||||
- **Icons**: Emoji-based icons for better compatibility
|
||||
- **Export**: HTML to PDF conversion (planned)
|
||||
|
||||
## 📦 Installation
|
||||
|
||||
1. **Prerequisites**: Node.js 18+ and npm 9+
|
||||
|
||||
2. **Install dependencies**:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. **Run development server**:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
4. **Open in browser**:
|
||||
Navigate to [http://localhost:3000](http://localhost:3000)
|
||||
|
||||
## 🚀 Available Scripts
|
||||
|
||||
- `npm run dev` - Start development server
|
||||
- `npm run build` - Build for production
|
||||
- `npm run start` - Start production server
|
||||
- `npm run lint` - Run ESLint
|
||||
- `npm run type-check` - Run TypeScript compiler check
|
||||
- `npm run format` - Format code with Prettier
|
||||
- `npm run format:check` - Check code formatting
|
||||
|
||||
## 📁 Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/ # Next.js App Router
|
||||
│ ├── globals.css # Global styles
|
||||
│ ├── layout.tsx # Root layout
|
||||
│ └── page.tsx # Home page
|
||||
├── components/ # React components
|
||||
│ ├── layout/ # Layout components
|
||||
│ │ └── Header.tsx # App header
|
||||
│ └── resume/ # Resume-specific components
|
||||
│ ├── ResumeBuilder.tsx # Main builder interface
|
||||
│ ├── ResumePreview.tsx # Live preview
|
||||
│ └── forms/ # Form components
|
||||
│ ├── PersonalInfoForm.tsx
|
||||
│ ├── ExperienceForm.tsx
|
||||
│ ├── EducationForm.tsx
|
||||
│ └── SkillsForm.tsx
|
||||
├── types/ # TypeScript type definitions
|
||||
│ └── resume.ts # Resume data types
|
||||
└── utils/ # Utility functions (future)
|
||||
```
|
||||
|
||||
## 🎨 Design System
|
||||
|
||||
### Colors
|
||||
|
||||
- **Primary**: Blue theme (#0ea5e9 variations) - Professional and trustworthy
|
||||
- **Secondary**: Gray theme - Clean and readable text
|
||||
- **Accent**: Purple theme - Highlights and interactive elements
|
||||
- **Semantic**: Success, warning, and error colors
|
||||
|
||||
### Typography
|
||||
|
||||
- **Primary Font**: Inter (Google Fonts)
|
||||
- **Responsive Sizes**: Configured for optimal readability
|
||||
- **Professional Styling**: Clean, modern typography
|
||||
|
||||
### Components
|
||||
|
||||
- **Cards**: Elevated surfaces for content sections
|
||||
- **Buttons**: Multiple variants (primary, secondary, outline, ghost)
|
||||
- **Forms**: Consistent input styling with validation states
|
||||
- **Navigation**: Clean, intuitive section navigation
|
||||
|
||||
## 🎯 Usage
|
||||
|
||||
1. **Personal Information**: Start by filling in your basic contact details and professional summary
|
||||
2. **Work Experience**: Add your employment history with achievements and responsibilities
|
||||
3. **Education**: Include your academic background and qualifications
|
||||
4. **Skills**: Categorize your technical and soft skills with proficiency levels
|
||||
5. **Preview**: Use the live preview to see how your resume looks
|
||||
6. **Export**: Download your finished resume as PDF (feature in development)
|
||||
|
||||
## 🔧 Development Guidelines
|
||||
|
||||
### Code Standards
|
||||
|
||||
- All components use TypeScript with strict typing
|
||||
- Follow the established component structure
|
||||
- Use Tailwind CSS classes for styling
|
||||
- Include proper error handling
|
||||
- Maintain accessibility standards
|
||||
|
||||
### Component Development
|
||||
|
||||
- Use 'use client' for interactive components
|
||||
- Follow PascalCase naming convention
|
||||
- Include comprehensive prop interfaces
|
||||
- Add meaningful comments and documentation
|
||||
|
||||
### Professional Quality
|
||||
|
||||
- Code is production-ready and employer-review friendly
|
||||
- Comprehensive TypeScript coverage
|
||||
- Responsive design implementation
|
||||
- Clean, maintainable architecture
|
||||
|
||||
## 📱 Browser Support
|
||||
|
||||
- Chrome (recommended)
|
||||
- Firefox
|
||||
- Safari
|
||||
- Edge
|
||||
|
||||
## 🚀 Deployment
|
||||
|
||||
The application is configured for static export and can be deployed on:
|
||||
|
||||
- Vercel (recommended for Next.js)
|
||||
- Netlify
|
||||
- GitHub Pages
|
||||
- Any static hosting service
|
||||
|
||||
Build command: `npm run build`
|
||||
Output directory: `out/`
|
||||
|
||||
## 👨💻 Author
|
||||
|
||||
**David Valera Melendez**
|
||||
|
||||
- Email: david@valera-melendez.de
|
||||
- Location: Germany 🇩🇪
|
||||
- Created: August 7, 2025
|
||||
|
||||
## 📄 License
|
||||
|
||||
MIT License - Feel free to use this project for your professional resume needs.
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
This project is designed as a professional showcase. If you'd like to contribute or suggest improvements, please reach out via email.
|
||||
|
||||
---
|
||||
|
||||
**Professional Resume Builder** - Helping professionals create outstanding resumes | Made in Germany 🇩🇪
|
||||
|
||||
5
next-env.d.ts
vendored
Normal file
5
next-env.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
52
next.config.js
Normal file
52
next.config.js
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Next.js Configuration
|
||||
* Professional Resume Builder
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-07
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
/* config options here */
|
||||
experimental: {
|
||||
typedRoutes: true,
|
||||
},
|
||||
images: {
|
||||
domains: ['localhost'],
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: '**',
|
||||
},
|
||||
],
|
||||
},
|
||||
// Optimization settings
|
||||
swcMinify: true,
|
||||
|
||||
// Custom headers for security
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: '/(.*)',
|
||||
headers: [
|
||||
{
|
||||
key: 'X-Frame-Options',
|
||||
value: 'DENY',
|
||||
},
|
||||
{
|
||||
key: 'X-Content-Type-Options',
|
||||
value: 'nosniff',
|
||||
},
|
||||
{
|
||||
key: 'Referrer-Policy',
|
||||
value: 'strict-origin-when-cross-origin',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
7153
package-lock.json
generated
Normal file
7153
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
75
package.json
Normal file
75
package.json
Normal file
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"name": "david-valera-resume-builder",
|
||||
"version": "1.0.0",
|
||||
"description": "Professional Resume Builder - Created by David Valera Melendez",
|
||||
"author": {
|
||||
"name": "David Valera Melendez",
|
||||
"email": "david@valera-melendez.de",
|
||||
"url": "https://valera-melendez.de"
|
||||
},
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"type-check": "tsc --noEmit",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^2.1.2",
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"axios": "^1.11.0",
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^11.3.8",
|
||||
"html2canvas": "^1.4.1",
|
||||
"jose": "^6.0.12",
|
||||
"js-cookie": "^3.0.5",
|
||||
"jspdf": "^2.5.1",
|
||||
"lucide-react": "^0.416.0",
|
||||
"next": "14.2.5",
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
"react-hook-form": "^7.62.0",
|
||||
"tailwind-merge": "^2.4.0",
|
||||
"zod": "^4.0.15",
|
||||
"zustand": "^5.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
||||
"@typescript-eslint/parser": "^7.18.0",
|
||||
"eslint": "^8",
|
||||
"eslint-config-next": "14.2.5",
|
||||
"postcss": "^8",
|
||||
"prettier": "^3.3.3",
|
||||
"prettier-plugin-tailwindcss": "^0.6.5",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5"
|
||||
},
|
||||
"keywords": [
|
||||
"resume",
|
||||
"cv",
|
||||
"builder",
|
||||
"nextjs",
|
||||
"typescript",
|
||||
"tailwind",
|
||||
"professional",
|
||||
"david-valera-melendez"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/davidvalera/resume-builder.git"
|
||||
},
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.17.0",
|
||||
"npm": ">=9.6.0"
|
||||
}
|
||||
}
|
||||
15
postcss.config.js
Normal file
15
postcss.config.js
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* PostCSS Configuration
|
||||
* Professional Resume Builder
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-07
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
0
public/favicon.svg
Normal file
0
public/favicon.svg
Normal file
155
src/app/access-denied/page.tsx
Normal file
155
src/app/access-denied/page.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Access Denied Page Component
|
||||
* Professional Next.js Resume Builder - Enterprise Auth System
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-08
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Shield, ArrowLeft, Home, Lock } from 'lucide-react';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
|
||||
/**
|
||||
* Access Denied Component
|
||||
*
|
||||
* Displays when a user attempts to access a resource they don't have permission for.
|
||||
* Provides navigation options and clear messaging about the access restriction.
|
||||
*/
|
||||
export default function AccessDeniedPage() {
|
||||
const router = useRouter();
|
||||
const { user, logout } = useAuthStore();
|
||||
|
||||
/**
|
||||
* Handle logout
|
||||
*/
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await logout();
|
||||
router.push('/auth/login');
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
// Redirect anyway
|
||||
router.push('/auth/login');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Navigate back to dashboard
|
||||
*/
|
||||
const handleBackToDashboard = () => {
|
||||
router.push('/dashboard');
|
||||
};
|
||||
|
||||
/**
|
||||
* Navigate to previous page
|
||||
*/
|
||||
const handleGoBack = () => {
|
||||
if (window.history.length > 1) {
|
||||
router.back();
|
||||
} else {
|
||||
router.push('/dashboard');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-red-50 to-orange-50 flex items-center justify-center px-4">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
{/* Header */}
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 bg-red-600 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<Lock className="h-8 w-8 text-white" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">
|
||||
Zugriff verweigert
|
||||
</h1>
|
||||
<p className="mt-2 text-gray-600">
|
||||
Sie haben keine Berechtigung für diese Seite
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Access Information */}
|
||||
<div className="bg-white rounded-xl shadow-lg p-8">
|
||||
<div className="space-y-6">
|
||||
{/* Error Message */}
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<div className="flex items-start">
|
||||
<Shield className="h-5 w-5 text-red-600 mt-0.5 mr-3 flex-shrink-0" />
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-red-800">
|
||||
Unzureichende Berechtigungen
|
||||
</h3>
|
||||
<p className="text-sm text-red-700 mt-1">
|
||||
Sie benötigen höhere Berechtigungen, um auf diese Ressource
|
||||
zuzugreifen. Wenden Sie sich an Ihren Administrator, wenn Sie
|
||||
glauben, dass Sie Zugriff haben sollten.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* User Information */}
|
||||
{user && (
|
||||
<div className="border border-gray-200 rounded-lg p-4">
|
||||
<h4 className="text-sm font-medium text-gray-900 mb-2">
|
||||
Aktuelle Sitzung
|
||||
</h4>
|
||||
<div className="space-y-1 text-sm text-gray-600">
|
||||
<p>
|
||||
<span className="font-medium">Benutzer:</span> {user.firstName} {user.lastName}
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-medium">E-Mail:</span> {user.email}
|
||||
</p>
|
||||
{user.roles && user.roles.length > 0 && (
|
||||
<p>
|
||||
<span className="font-medium">Rollen:</span> {user.roles.join(', ')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={handleGoBack}
|
||||
className="w-full flex items-center justify-center px-4 py-3 border border-gray-300 rounded-lg shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Zurück zur vorherigen Seite
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleBackToDashboard}
|
||||
className="w-full flex items-center justify-center px-4 py-3 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
<Home className="h-4 w-4 mr-2" />
|
||||
Zum Dashboard
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full flex items-center justify-center px-4 py-3 border border-red-300 rounded-lg shadow-sm text-sm font-medium text-red-700 bg-white hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
|
||||
>
|
||||
Mit anderem Konto anmelden
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Help Information */}
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-gray-500">
|
||||
Benötigen Sie Hilfe? Kontaktieren Sie Ihren Systemadministrator oder
|
||||
wenden Sie sich an den Support.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
18
src/app/auth-showcase/page.tsx
Normal file
18
src/app/auth-showcase/page.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Login Demo Page - Showcase Beautiful Login Design
|
||||
* Professional Resume Builder - Login Interface Demo
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-07
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
|
||||
import LoginPage from '../login/page';
|
||||
|
||||
/**
|
||||
* Demo page to showcase the beautiful login form
|
||||
* This can be accessed at /login-demo or similar route
|
||||
*/
|
||||
export default function LoginDemoPage() {
|
||||
return <LoginPage />;
|
||||
}
|
||||
236
src/app/auth/device-verification/page.tsx
Normal file
236
src/app/auth/device-verification/page.tsx
Normal file
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* Device Verification Page Component
|
||||
* Professional Next.js Resume Builder - Enterprise Auth System
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-08
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Shield, Smartphone, Mail, AlertTriangle, CheckCircle } from 'lucide-react';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
|
||||
/**
|
||||
* Device Verification Component
|
||||
*
|
||||
* Handles device verification when a user logs in from an unrecognized device.
|
||||
* Includes verification code input and resend functionality.
|
||||
*/
|
||||
export default function DeviceVerificationPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { verifyDevice, resendDeviceVerification, isLoading, error } = useAuthStore();
|
||||
|
||||
const [verificationCode, setVerificationCode] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [countdown, setCountdown] = useState(0);
|
||||
const [showSuccess, setShowSuccess] = useState(false);
|
||||
|
||||
const sessionId = searchParams.get('session') || '';
|
||||
const email = searchParams.get('email') || '';
|
||||
|
||||
// Countdown timer for resend
|
||||
useEffect(() => {
|
||||
if (countdown > 0) {
|
||||
const timer = setTimeout(() => setCountdown(countdown - 1), 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [countdown]);
|
||||
|
||||
// Redirect if no session ID
|
||||
useEffect(() => {
|
||||
if (!sessionId) {
|
||||
router.push('/auth/login');
|
||||
}
|
||||
}, [sessionId, router]);
|
||||
|
||||
/**
|
||||
* Handle verification code submission
|
||||
*/
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!verificationCode.trim() || verificationCode.length < 6) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
await verifyDevice({
|
||||
sessionToken: sessionId,
|
||||
verificationCode: verificationCode.trim(),
|
||||
});
|
||||
|
||||
// If we reach here, verification was successful
|
||||
setShowSuccess(true);
|
||||
setTimeout(() => {
|
||||
router.push('/dashboard');
|
||||
}, 2000);
|
||||
} catch (error) {
|
||||
console.error('Device verification error:', error);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle resend verification code
|
||||
*/
|
||||
const handleResendCode = async () => {
|
||||
if (countdown > 0) return;
|
||||
|
||||
try {
|
||||
await resendDeviceVerification(sessionId);
|
||||
setCountdown(60); // 60 seconds countdown
|
||||
setVerificationCode(''); // Clear current code
|
||||
} catch (error) {
|
||||
console.error('Resend verification error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle input change
|
||||
*/
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value.replace(/\D/g, '').slice(0, 6);
|
||||
setVerificationCode(value);
|
||||
};
|
||||
|
||||
if (showSuccess) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center px-4">
|
||||
<div className="max-w-md w-full bg-white rounded-xl shadow-lg p-8 text-center">
|
||||
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<CheckCircle className="h-8 w-8 text-green-600" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
Gerät verifiziert!
|
||||
</h2>
|
||||
<p className="text-gray-600">
|
||||
Ihr Gerät wurde erfolgreich verifiziert. Sie werden weitergeleitet...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center px-4">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
{/* Header */}
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 bg-blue-600 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<Shield className="h-8 w-8 text-white" />
|
||||
</div>
|
||||
<h2 className="text-3xl font-bold text-gray-900">
|
||||
Gerät verifizieren
|
||||
</h2>
|
||||
<p className="mt-2 text-gray-600">
|
||||
Aus Sicherheitsgründen müssen wir Ihr Gerät verifizieren
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Security Notice */}
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
|
||||
<div className="flex items-start">
|
||||
<AlertTriangle className="h-5 w-5 text-amber-600 mt-0.5 mr-3 flex-shrink-0" />
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-amber-800">
|
||||
Unbekanntes Gerät erkannt
|
||||
</h3>
|
||||
<p className="text-sm text-amber-700 mt-1">
|
||||
Wir haben eine Anmeldung von einem neuen Gerät festgestellt.
|
||||
Bitte verifizieren Sie Ihre Identität mit dem Code, den wir an
|
||||
Ihre E-Mail-Adresse gesendet haben.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Verification Form */}
|
||||
<div className="bg-white rounded-xl shadow-lg p-8">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Email Display */}
|
||||
<div className="flex items-center justify-center space-x-2 text-sm text-gray-600">
|
||||
<Mail className="h-4 w-4" />
|
||||
<span>Code gesendet an: {email}</span>
|
||||
</div>
|
||||
|
||||
{/* Verification Code Input */}
|
||||
<div>
|
||||
<label htmlFor="verificationCode" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Verifizierungscode
|
||||
</label>
|
||||
<input
|
||||
id="verificationCode"
|
||||
type="text"
|
||||
value={verificationCode}
|
||||
onChange={handleInputChange}
|
||||
placeholder="6-stelliger Code"
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg text-center text-2xl font-mono tracking-widest focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
maxLength={6}
|
||||
autoComplete="one-time-code"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-3">
|
||||
<p className="text-sm text-red-600">{error.message}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting || verificationCode.length < 6}
|
||||
className="w-full flex items-center justify-center px-4 py-3 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||
Verifiziere...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Smartphone className="h-4 w-4 mr-2" />
|
||||
Gerät verifizieren
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Resend Code */}
|
||||
<div className="text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleResendCode}
|
||||
disabled={countdown > 0}
|
||||
className="text-sm text-blue-600 hover:text-blue-500 disabled:text-gray-400 disabled:cursor-not-allowed"
|
||||
>
|
||||
{countdown > 0 ? (
|
||||
`Code erneut senden in ${countdown}s`
|
||||
) : (
|
||||
'Code erneut senden'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Help Text */}
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-gray-500">
|
||||
Haben Sie keinen Code erhalten? Überprüfen Sie Ihren Spam-Ordner oder
|
||||
fordern Sie einen neuen Code an.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
219
src/app/auth/forgot-password/page.tsx
Normal file
219
src/app/auth/forgot-password/page.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* Forgot Password Page Component
|
||||
* Professional Next.js Resume Builder - Enterprise Auth System
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-08
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Mail, ArrowLeft, Send, CheckCircle } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Forgot password form validation schema
|
||||
*/
|
||||
const forgotPasswordSchema = z.object({
|
||||
email: z
|
||||
.string()
|
||||
.min(1, 'E-Mail-Adresse ist erforderlich')
|
||||
.email('Ungültige E-Mail-Adresse'),
|
||||
});
|
||||
|
||||
type ForgotPasswordForm = z.infer<typeof forgotPasswordSchema>;
|
||||
|
||||
/**
|
||||
* Forgot Password Component
|
||||
*
|
||||
* Allows users to request a password reset by entering their email address.
|
||||
* Provides feedback and navigation back to login.
|
||||
*/
|
||||
export default function ForgotPasswordPage() {
|
||||
const router = useRouter();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isSuccess, setIsSuccess] = useState(false);
|
||||
const [submittedEmail, setSubmittedEmail] = useState('');
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<ForgotPasswordForm>({
|
||||
resolver: zodResolver(forgotPasswordSchema),
|
||||
});
|
||||
|
||||
/**
|
||||
* Handle form submission
|
||||
*/
|
||||
const onSubmit = async (data: ForgotPasswordForm) => {
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
// TODO: Implement actual password reset API call
|
||||
// const response = await passwordResetRequest(data.email);
|
||||
|
||||
// Simulate API call
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
|
||||
setSubmittedEmail(data.email);
|
||||
setIsSuccess(true);
|
||||
} catch (error) {
|
||||
console.error('Password reset error:', error);
|
||||
// Handle error appropriately
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Navigate back to login
|
||||
*/
|
||||
const handleBackToLogin = () => {
|
||||
router.push('/auth/login');
|
||||
};
|
||||
|
||||
if (isSuccess) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center px-4">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
{/* Success Header */}
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 bg-green-600 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<CheckCircle className="h-8 w-8 text-white" />
|
||||
</div>
|
||||
<h2 className="text-3xl font-bold text-gray-900">
|
||||
E-Mail gesendet
|
||||
</h2>
|
||||
<p className="mt-2 text-gray-600">
|
||||
Überprüfen Sie Ihr E-Mail-Postfach
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Success Message */}
|
||||
<div className="bg-white rounded-xl shadow-lg p-8">
|
||||
<div className="space-y-6">
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<div className="flex items-start">
|
||||
<Mail className="h-5 w-5 text-green-600 mt-0.5 mr-3 flex-shrink-0" />
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-green-800">
|
||||
Passwort-Reset angefordert
|
||||
</h3>
|
||||
<p className="text-sm text-green-700 mt-1">
|
||||
Wir haben eine E-Mail mit Anweisungen zum Zurücksetzen
|
||||
Ihres Passworts an <strong>{submittedEmail}</strong> gesendet.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-gray-600 space-y-2">
|
||||
<p>
|
||||
<strong>Nächste Schritte:</strong>
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-1 ml-4">
|
||||
<li>Überprüfen Sie Ihr E-Mail-Postfach</li>
|
||||
<li>Klicken Sie auf den Link in der E-Mail</li>
|
||||
<li>Erstellen Sie ein neues Passwort</li>
|
||||
<li>Melden Sie sich mit dem neuen Passwort an</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleBackToLogin}
|
||||
className="w-full flex items-center justify-center px-4 py-3 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
Zur Anmeldung zurückkehren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Help Text */}
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-gray-500">
|
||||
Haben Sie keine E-Mail erhalten? Überprüfen Sie Ihren Spam-Ordner
|
||||
oder versuchen Sie es in wenigen Minuten erneut.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center px-4">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
{/* Header */}
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 bg-blue-600 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<Mail className="h-8 w-8 text-white" />
|
||||
</div>
|
||||
<h2 className="text-3xl font-bold text-gray-900">
|
||||
Passwort vergessen?
|
||||
</h2>
|
||||
<p className="mt-2 text-gray-600">
|
||||
Geben Sie Ihre E-Mail-Adresse ein, um ein neues Passwort zu erhalten
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Reset Form */}
|
||||
<div className="bg-white rounded-xl shadow-lg p-8">
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||
{/* Email Field */}
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
E-Mail-Adresse
|
||||
</label>
|
||||
<input
|
||||
{...register('email')}
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="ihre.email@beispiel.de"
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.email.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full flex items-center justify-center px-4 py-3 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||
Sende E-Mail...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="h-4 w-4 mr-2" />
|
||||
Passwort zurücksetzen
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Back to Login */}
|
||||
<div className="text-center">
|
||||
<button
|
||||
onClick={handleBackToLogin}
|
||||
className="inline-flex items-center text-sm text-blue-600 hover:text-blue-500"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Zurück zur Anmeldung
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
276
src/app/auth/login/page.tsx
Normal file
276
src/app/auth/login/page.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* Login Page Component
|
||||
* Professional Next.js Resume Builder - Enterprise Auth System
|
||||
*
|
||||
<h2 className="text-3xl font-bold text-gray-900">
|
||||
Willkommen zurück
|
||||
</h2>
|
||||
<p className="mt-2 text-gray-600">
|
||||
Melden Sie sich in Ihrem Konto an, um fortzufahren
|
||||
</p>hor David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-08
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import Link from 'next/link';
|
||||
import { Eye, EyeOff, Lock, Mail, AlertCircle, CheckCircle } from 'lucide-react';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
import { useGuestGuard } from '@/hooks/auth';
|
||||
import { loginSchema } from '@/lib/validation';
|
||||
import { LoginFormData } from '@/types/auth';
|
||||
|
||||
/**
|
||||
* Professional Login Page Component
|
||||
*/
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { login, isLoading, error, clearError } = useAuthStore();
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Protect route - redirect if already authenticated
|
||||
useGuestGuard();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isValid },
|
||||
watch,
|
||||
} = useForm<LoginFormData>({
|
||||
resolver: zodResolver(loginSchema),
|
||||
mode: 'onChange',
|
||||
defaultValues: {
|
||||
email: '',
|
||||
password: '',
|
||||
rememberMe: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Clear errors when component mounts or form changes
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
const timer = setTimeout(() => clearError(), 5000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [error, clearError]);
|
||||
|
||||
const onSubmit = async (data: LoginFormData) => {
|
||||
setIsSubmitting(true);
|
||||
clearError();
|
||||
|
||||
try {
|
||||
await login({
|
||||
email: data.email,
|
||||
password: data.password,
|
||||
rememberMe: data.rememberMe,
|
||||
});
|
||||
|
||||
// If login successful and no 2FA required, redirect
|
||||
const returnUrl = searchParams.get('returnUrl');
|
||||
if (returnUrl && returnUrl.startsWith('/') && !returnUrl.startsWith('//')) {
|
||||
router.push(returnUrl as any);
|
||||
} else {
|
||||
router.push('/dashboard');
|
||||
}
|
||||
} catch (err) {
|
||||
// Error is handled by the store
|
||||
console.error('Login error:', err);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formEmail = watch('email');
|
||||
const formPassword = watch('password');
|
||||
const canSubmit = isValid && formEmail && formPassword && !isSubmitting && !isLoading;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-white to-purple-50 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
{/* Header */}
|
||||
<div className="text-center">
|
||||
<div className="mx-auto h-12 w-12 bg-blue-600 rounded-lg flex items-center justify-center">
|
||||
<Lock className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
<h2 className="mt-6 text-3xl font-bold text-gray-900">
|
||||
Willkommen zurück
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
Melden Sie sich in Ihrem Konto an, um fortzufahren
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Login Form */}
|
||||
<div className="bg-white shadow-xl rounded-2xl px-8 py-8 space-y-6">
|
||||
{/* Error Alert */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<div className="flex items-center">
|
||||
<AlertCircle className="h-5 w-5 text-red-500 mr-3" />
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-red-800">
|
||||
Anmeldung fehlgeschlagen
|
||||
</h3>
|
||||
<p className="text-sm text-red-700 mt-1">
|
||||
{error.message}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||
{/* Email Field */}
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
E-Mail-Adresse
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Mail className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
{...register('email')}
|
||||
type="email"
|
||||
id="email"
|
||||
className={`
|
||||
block w-full pl-10 pr-3 py-3 border rounded-lg shadow-sm
|
||||
placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent
|
||||
${errors.email ? 'border-red-300 bg-red-50' : 'border-gray-300'}
|
||||
`}
|
||||
placeholder="Geben Sie Ihre E-Mail ein"
|
||||
autoComplete="email"
|
||||
/>
|
||||
</div>
|
||||
{errors.email && (
|
||||
<p className="mt-2 text-sm text-red-600">{errors.email.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Password Field */}
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Passwort
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Lock className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
{...register('password')}
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
id="password"
|
||||
className={`
|
||||
block w-full pl-10 pr-10 py-3 border rounded-lg shadow-sm
|
||||
placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent
|
||||
${errors.password ? 'border-red-300 bg-red-50' : 'border-gray-300'}
|
||||
`}
|
||||
placeholder="Geben Sie Ihr Passwort ein"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-5 w-5 text-gray-400 hover:text-gray-600" />
|
||||
) : (
|
||||
<Eye className="h-5 w-5 text-gray-400 hover:text-gray-600" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{errors.password && (
|
||||
<p className="mt-2 text-sm text-red-600">{errors.password.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Remember Me and Forgot Password */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
{...register('rememberMe')}
|
||||
id="rememberMe"
|
||||
type="checkbox"
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label htmlFor="rememberMe" className="ml-2 block text-sm text-gray-700">
|
||||
Angemeldet bleiben
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href="/auth/forgot-password"
|
||||
className="text-sm text-blue-600 hover:text-blue-500 font-medium"
|
||||
>
|
||||
Passwort vergessen?
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSubmit}
|
||||
className={`
|
||||
w-full flex justify-center items-center py-3 px-4 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white
|
||||
focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500
|
||||
${canSubmit
|
||||
? 'bg-blue-600 hover:bg-blue-700 transform hover:scale-[1.02] transition-all duration-200'
|
||||
: 'bg-gray-400 cursor-not-allowed'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{(isSubmitting || isLoading) ? (
|
||||
<>
|
||||
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
Anmelden...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle className="h-5 w-5 mr-2" />
|
||||
Anmelden
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-gray-300" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-2 bg-white text-gray-500">Neu auf unserer Plattform?</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Register Link */}
|
||||
<div className="text-center">
|
||||
<Link
|
||||
href="/auth/register"
|
||||
className="text-blue-600 hover:text-blue-500 font-medium text-sm"
|
||||
>
|
||||
Neues Konto erstellen
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="text-center text-xs text-gray-500">
|
||||
<p>Professioneller Lebenslauf-Builder</p>
|
||||
<p className="mt-1">Created by David Valera Melendez</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
435
src/app/auth/register/page.tsx
Normal file
435
src/app/auth/register/page.tsx
Normal file
@@ -0,0 +1,435 @@
|
||||
/**
|
||||
* Register Page Component
|
||||
* Professional Next.js Resume Builder - Enterprise Auth System
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-08
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
Eye, EyeOff, Lock, Mail, User, AlertCircle, CheckCircle,
|
||||
Check, X, Shield
|
||||
} from 'lucide-react';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
import { useGuestGuard } from '@/hooks/auth';
|
||||
import { registerSchema, validatePasswordStrength } from '@/lib/validation';
|
||||
import { RegisterFormData } from '@/types/auth';
|
||||
|
||||
/**
|
||||
* Professional Register Page Component
|
||||
*/
|
||||
export default function RegisterPage() {
|
||||
const router = useRouter();
|
||||
const { register: registerUser, isLoading, error, clearError } = useAuthStore();
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [passwordStrength, setPasswordStrength] = useState<{
|
||||
score: number;
|
||||
feedback: string[];
|
||||
isValid: boolean;
|
||||
}>({ score: 0, feedback: [], isValid: false });
|
||||
|
||||
// Protect route - redirect if already authenticated
|
||||
useGuestGuard();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isValid },
|
||||
watch,
|
||||
} = useForm<RegisterFormData>({
|
||||
resolver: zodResolver(registerSchema),
|
||||
mode: 'onChange',
|
||||
defaultValues: {
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
acceptTerms: false,
|
||||
},
|
||||
});
|
||||
|
||||
const watchedPassword = watch('password');
|
||||
|
||||
// Update password strength when password changes
|
||||
useEffect(() => {
|
||||
if (watchedPassword) {
|
||||
const strength = validatePasswordStrength(watchedPassword);
|
||||
setPasswordStrength(strength);
|
||||
}
|
||||
}, [watchedPassword]);
|
||||
|
||||
// Clear errors when component mounts or form changes
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
const timer = setTimeout(() => clearError(), 5000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [error, clearError]);
|
||||
|
||||
const onSubmit = async (data: RegisterFormData) => {
|
||||
setIsSubmitting(true);
|
||||
clearError();
|
||||
|
||||
try {
|
||||
await registerUser({
|
||||
firstName: data.firstName,
|
||||
lastName: data.lastName,
|
||||
email: data.email,
|
||||
password: data.password,
|
||||
confirmPassword: data.confirmPassword,
|
||||
acceptTerms: data.acceptTerms,
|
||||
});
|
||||
|
||||
// If registration successful, redirect to dashboard
|
||||
router.push('/dashboard');
|
||||
} catch (err) {
|
||||
// Error is handled by the store
|
||||
console.error('Registration error:', err);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getPasswordStrengthColor = (score: number): string => {
|
||||
if (score >= 6) return 'text-green-600';
|
||||
if (score >= 4) return 'text-yellow-600';
|
||||
return 'text-red-600';
|
||||
};
|
||||
|
||||
const getPasswordStrengthText = (score: number): string => {
|
||||
if (score >= 6) return 'Stark';
|
||||
if (score >= 4) return 'Mittel';
|
||||
if (score >= 2) return 'Schwach';
|
||||
return 'Sehr schwach';
|
||||
};
|
||||
|
||||
const formFirstName = watch('firstName');
|
||||
const formLastName = watch('lastName');
|
||||
const formEmail = watch('email');
|
||||
const formPassword = watch('password');
|
||||
const formConfirmPassword = watch('confirmPassword');
|
||||
const formAcceptTerms = watch('acceptTerms');
|
||||
|
||||
const canSubmit = isValid &&
|
||||
formFirstName && formLastName && formEmail &&
|
||||
formPassword && formConfirmPassword && formAcceptTerms &&
|
||||
!isSubmitting && !isLoading;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-green-50 via-white to-blue-50 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
{/* Header */}
|
||||
<div className="text-center">
|
||||
<div className="mx-auto h-12 w-12 bg-green-600 rounded-lg flex items-center justify-center">
|
||||
<User className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
<h2 className="mt-6 text-3xl font-bold text-gray-900">
|
||||
Konto erstellen
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
Treten Sie uns bei, um professionelle Lebensläufe zu erstellen
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Registration Form */}
|
||||
<div className="bg-white shadow-xl rounded-2xl px-8 py-8 space-y-6">
|
||||
{/* Error Alert */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<div className="flex items-center">
|
||||
<AlertCircle className="h-5 w-5 text-red-500 mr-3" />
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-red-800">
|
||||
Registrierung fehlgeschlagen
|
||||
</h3>
|
||||
<p className="text-sm text-red-700 mt-1">
|
||||
{error.message}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||
{/* Name Fields */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* First Name */}
|
||||
<div>
|
||||
<label htmlFor="firstName" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Vorname
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<User className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
{...register('firstName')}
|
||||
type="text"
|
||||
id="firstName"
|
||||
className={`
|
||||
block w-full pl-10 pr-3 py-3 border rounded-lg shadow-sm
|
||||
placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent
|
||||
${errors.firstName ? 'border-red-300 bg-red-50' : 'border-gray-300'}
|
||||
`}
|
||||
placeholder="Vorname"
|
||||
autoComplete="given-name"
|
||||
/>
|
||||
</div>
|
||||
{errors.firstName && (
|
||||
<p className="mt-2 text-sm text-red-600">{errors.firstName.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Last Name */}
|
||||
<div>
|
||||
<label htmlFor="lastName" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Nachname
|
||||
</label>
|
||||
<input
|
||||
{...register('lastName')}
|
||||
type="text"
|
||||
id="lastName"
|
||||
className={`
|
||||
block w-full px-3 py-3 border rounded-lg shadow-sm
|
||||
placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent
|
||||
${errors.lastName ? 'border-red-300 bg-red-50' : 'border-gray-300'}
|
||||
`}
|
||||
placeholder="Nachname"
|
||||
autoComplete="family-name"
|
||||
/>
|
||||
{errors.lastName && (
|
||||
<p className="mt-2 text-sm text-red-600">{errors.lastName.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Email Field */}
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
E-Mail-Adresse
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Mail className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
{...register('email')}
|
||||
type="email"
|
||||
id="email"
|
||||
className={`
|
||||
block w-full pl-10 pr-3 py-3 border rounded-lg shadow-sm
|
||||
placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent
|
||||
${errors.email ? 'border-red-300 bg-red-50' : 'border-gray-300'}
|
||||
`}
|
||||
placeholder="Geben Sie Ihre E-Mail ein"
|
||||
autoComplete="email"
|
||||
/>
|
||||
</div>
|
||||
{errors.email && (
|
||||
<p className="mt-2 text-sm text-red-600">{errors.email.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Password Field */}
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Passwort
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Lock className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
{...register('password')}
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
id="password"
|
||||
className={`
|
||||
block w-full pl-10 pr-10 py-3 border rounded-lg shadow-sm
|
||||
placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent
|
||||
${errors.password ? 'border-red-300 bg-red-50' : 'border-gray-300'}
|
||||
`}
|
||||
placeholder="Erstellen Sie ein starkes Passwort"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-5 w-5 text-gray-400 hover:text-gray-600" />
|
||||
) : (
|
||||
<Eye className="h-5 w-5 text-gray-400 hover:text-gray-600" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Password Strength Indicator */}
|
||||
{watchedPassword && (
|
||||
<div className="mt-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-gray-600">Passwortstärke:</span>
|
||||
<span className={`text-sm font-medium ${getPasswordStrengthColor(passwordStrength.score)}`}>
|
||||
{getPasswordStrengthText(passwordStrength.score)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className={`h-2 rounded-full transition-all duration-300 ${
|
||||
passwordStrength.score >= 6 ? 'bg-green-500' :
|
||||
passwordStrength.score >= 4 ? 'bg-yellow-500' :
|
||||
passwordStrength.score >= 2 ? 'bg-orange-500' : 'bg-red-500'
|
||||
}`}
|
||||
style={{ width: `${(passwordStrength.score / 8) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
{passwordStrength.feedback.length > 0 && (
|
||||
<div className="mt-2 text-xs text-gray-600">
|
||||
{passwordStrength.feedback.map((item, index) => (
|
||||
<div key={index} className="flex items-center">
|
||||
<X className="h-3 w-3 text-red-500 mr-1" />
|
||||
{item}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{errors.password && (
|
||||
<p className="mt-2 text-sm text-red-600">{errors.password.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Confirm Password Field */}
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Passwort bestätigen
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Shield className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
{...register('confirmPassword')}
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
id="confirmPassword"
|
||||
className={`
|
||||
block w-full pl-10 pr-10 py-3 border rounded-lg shadow-sm
|
||||
placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent
|
||||
${errors.confirmPassword ? 'border-red-300 bg-red-50' : 'border-gray-300'}
|
||||
`}
|
||||
placeholder="Bestätigen Sie Ihr Passwort"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
>
|
||||
{showConfirmPassword ? (
|
||||
<EyeOff className="h-5 w-5 text-gray-400 hover:text-gray-600" />
|
||||
) : (
|
||||
<Eye className="h-5 w-5 text-gray-400 hover:text-gray-600" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{errors.confirmPassword && (
|
||||
<p className="mt-2 text-sm text-red-600">{errors.confirmPassword.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Terms Acceptance */}
|
||||
<div className="flex items-start">
|
||||
<input
|
||||
{...register('acceptTerms')}
|
||||
id="acceptTerms"
|
||||
type="checkbox"
|
||||
className="h-4 w-4 text-green-600 focus:ring-green-500 border-gray-300 rounded mt-0.5"
|
||||
/>
|
||||
<label htmlFor="acceptTerms" className="ml-3 block text-sm text-gray-700">
|
||||
Ich stimme den{' '}
|
||||
<Link href={"/terms" as any} className="text-green-600 hover:text-green-500 font-medium">
|
||||
Nutzungsbedingungen
|
||||
</Link>{' '}
|
||||
und der{' '}
|
||||
<Link href={"/privacy" as any} className="text-green-600 hover:text-green-500 font-medium">
|
||||
Datenschutzerklärung
|
||||
</Link> zu
|
||||
</label>
|
||||
</div>
|
||||
{errors.acceptTerms && (
|
||||
<p className="text-sm text-red-600">{errors.acceptTerms.message}</p>
|
||||
)}
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSubmit}
|
||||
className={`
|
||||
w-full flex justify-center items-center py-3 px-4 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white
|
||||
focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500
|
||||
${canSubmit
|
||||
? 'bg-green-600 hover:bg-green-700 transform hover:scale-[1.02] transition-all duration-200'
|
||||
: 'bg-gray-400 cursor-not-allowed'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{(isSubmitting || isLoading) ? (
|
||||
<>
|
||||
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
Konto wird erstellt...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle className="h-5 w-5 mr-2" />
|
||||
Konto erstellen
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-gray-300" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-2 bg-white text-gray-500">Haben Sie bereits ein Konto?</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Login Link */}
|
||||
<div className="text-center">
|
||||
<Link
|
||||
href="/auth/login"
|
||||
className="text-green-600 hover:text-green-500 font-medium text-sm"
|
||||
>
|
||||
In Ihr Konto anmelden
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="text-center text-xs text-gray-500">
|
||||
<p>Professioneller Lebenslauf-Builder</p>
|
||||
<p className="mt-1">Erstellt von David Valera Melendez</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
359
src/app/auth/two-factor/page.tsx
Normal file
359
src/app/auth/two-factor/page.tsx
Normal file
@@ -0,0 +1,359 @@
|
||||
/**
|
||||
* Two-Factor Authentication Component
|
||||
* Professional Next.js Resume Builder - Enterprise Auth System
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-08
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Shield, AlertCircle, Clock, RefreshCw, CheckCircle, ArrowLeft } from 'lucide-react';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
import { twoFactorSchema } from '@/lib/validation';
|
||||
import { TwoFactorFormData } from '@/types/auth';
|
||||
|
||||
/**
|
||||
* Professional Two-Factor Authentication Component
|
||||
*/
|
||||
export default function TwoFactorAuthPage() {
|
||||
const router = useRouter();
|
||||
const {
|
||||
verifyTwoFactor,
|
||||
twoFactorData,
|
||||
isLoading,
|
||||
error,
|
||||
clearError,
|
||||
clearTwoFactor,
|
||||
} = useAuthStore();
|
||||
|
||||
const [countdown, setCountdown] = useState(0);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [canResend, setCanResend] = useState(false);
|
||||
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
setValue,
|
||||
watch,
|
||||
reset,
|
||||
} = useForm<TwoFactorFormData>({
|
||||
resolver: zodResolver(twoFactorSchema),
|
||||
mode: 'onChange',
|
||||
});
|
||||
|
||||
// Redirect if no 2FA data
|
||||
useEffect(() => {
|
||||
if (!twoFactorData) {
|
||||
router.push('/auth/login');
|
||||
}
|
||||
}, [twoFactorData, router]);
|
||||
|
||||
// Initialize countdown timer
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setCountdown(prev => {
|
||||
if (prev <= 1) {
|
||||
setCanResend(true);
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
// Set initial countdown (5 minutes)
|
||||
setCountdown(300);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
// Clear errors on form change
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
const timer = setTimeout(() => clearError(), 5000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [error, clearError]);
|
||||
|
||||
// Handle individual input changes for the 6-digit code
|
||||
const handleCodeInputChange = (index: number, value: string) => {
|
||||
const newValue = value.replace(/\D/g, ''); // Only digits
|
||||
|
||||
if (newValue.length <= 1) {
|
||||
// Update the specific position
|
||||
const currentCode = watch('verificationCode') || '';
|
||||
const codeArray = currentCode.split('');
|
||||
codeArray[index] = newValue;
|
||||
const newCode = codeArray.join('').slice(0, 6);
|
||||
|
||||
setValue('verificationCode', newCode);
|
||||
|
||||
// Auto-focus next input
|
||||
if (newValue && index < 5) {
|
||||
inputRefs.current[index + 1]?.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Handle backspace
|
||||
const handleKeyDown = (index: number, e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Backspace') {
|
||||
const currentCode = watch('verificationCode') || '';
|
||||
const codeArray = currentCode.split('');
|
||||
|
||||
if (!codeArray[index] && index > 0) {
|
||||
// If current is empty, go to previous and clear it
|
||||
inputRefs.current[index - 1]?.focus();
|
||||
codeArray[index - 1] = '';
|
||||
} else {
|
||||
// Clear current
|
||||
codeArray[index] = '';
|
||||
}
|
||||
|
||||
const newCode = codeArray.join('');
|
||||
setValue('verificationCode', newCode);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle paste
|
||||
const handlePaste = (e: React.ClipboardEvent) => {
|
||||
e.preventDefault();
|
||||
const pastedData = e.clipboardData.getData('text').replace(/\D/g, '').slice(0, 6);
|
||||
setValue('verificationCode', pastedData);
|
||||
|
||||
// Focus appropriate input
|
||||
const focusIndex = Math.min(pastedData.length, 5);
|
||||
inputRefs.current[focusIndex]?.focus();
|
||||
};
|
||||
|
||||
const onSubmit = async (data: TwoFactorFormData) => {
|
||||
if (!twoFactorData) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
clearError();
|
||||
|
||||
try {
|
||||
await verifyTwoFactor({
|
||||
code: data.verificationCode,
|
||||
sessionToken: twoFactorData.sessionToken || '',
|
||||
email: twoFactorData.userEmail,
|
||||
});
|
||||
|
||||
// Success - redirect to dashboard
|
||||
router.push('/dashboard');
|
||||
} catch (err) {
|
||||
console.error('2FA verification error:', err);
|
||||
reset(); // Clear the form on error
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResendCode = async () => {
|
||||
// TODO: Implement resend logic
|
||||
setCanResend(false);
|
||||
setCountdown(300); // Reset countdown
|
||||
clearError();
|
||||
};
|
||||
|
||||
const handleGoBack = () => {
|
||||
clearTwoFactor();
|
||||
router.push('/auth/login');
|
||||
};
|
||||
|
||||
const formatTime = (seconds: number): string => {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const verificationCode = watch('verificationCode') || '';
|
||||
const canSubmit = verificationCode.length === 6 && !isSubmitting && !isLoading;
|
||||
|
||||
if (!twoFactorData) {
|
||||
return null; // Will redirect
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-purple-50 via-white to-blue-50 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
{/* Header */}
|
||||
<div className="text-center">
|
||||
<div className="mx-auto h-12 w-12 bg-purple-600 rounded-lg flex items-center justify-center">
|
||||
<Shield className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
<h2 className="mt-6 text-3xl font-bold text-gray-900">
|
||||
Zwei-Faktor-Authentifizierung
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
Wir haben einen Bestätigungscode an Ihr Gerät gesendet
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* User Info */}
|
||||
<div className="bg-blue-50 rounded-lg p-4">
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-medium text-gray-700">
|
||||
Zugriff verifizieren für
|
||||
</p>
|
||||
<p className="text-lg font-semibold text-blue-900">
|
||||
{twoFactorData.userName}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
{twoFactorData.userEmail}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2FA Form */}
|
||||
<div className="bg-white shadow-xl rounded-2xl px-8 py-8 space-y-6">
|
||||
{/* Error Alert */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<div className="flex items-center">
|
||||
<AlertCircle className="h-5 w-5 text-red-500 mr-3" />
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-red-800">
|
||||
Verifizierung fehlgeschlagen
|
||||
</h3>
|
||||
<p className="text-sm text-red-700 mt-1">
|
||||
{error.message}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||
{/* Verification Code Input */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-4">
|
||||
Geben Sie den 6-stelligen Bestätigungscode ein
|
||||
</label>
|
||||
|
||||
{/* Individual digit inputs */}
|
||||
<div className="flex justify-center space-x-3 mb-4">
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<input
|
||||
key={index}
|
||||
ref={(el) => {
|
||||
inputRefs.current[index] = el;
|
||||
}}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
maxLength={1}
|
||||
value={verificationCode[index] || ''}
|
||||
onChange={(e) => handleCodeInputChange(index, e.target.value)}
|
||||
onKeyDown={(e) => handleKeyDown(index, e)}
|
||||
onPaste={handlePaste}
|
||||
className={`
|
||||
w-12 h-12 text-center text-lg font-semibold border-2 rounded-lg
|
||||
focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent
|
||||
${errors.verificationCode ? 'border-red-300 bg-red-50' : 'border-gray-300'}
|
||||
${verificationCode[index] ? 'border-purple-500 bg-purple-50' : ''}
|
||||
`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Hidden input for form validation */}
|
||||
<input
|
||||
{...register('verificationCode')}
|
||||
type="hidden"
|
||||
value={verificationCode}
|
||||
/>
|
||||
|
||||
{errors.verificationCode && (
|
||||
<p className="text-sm text-red-600 text-center">
|
||||
{errors.verificationCode.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Timer and Resend */}
|
||||
<div className="text-center">
|
||||
{countdown > 0 ? (
|
||||
<div className="flex items-center justify-center text-sm text-gray-600">
|
||||
<Clock className="h-4 w-4 mr-2" />
|
||||
Code läuft ab in {formatTime(countdown)}
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleResendCode}
|
||||
disabled={!canResend}
|
||||
className="flex items-center justify-center text-sm text-purple-600 hover:text-purple-500 font-medium mx-auto"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Code erneut senden
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSubmit}
|
||||
className={`
|
||||
w-full flex justify-center items-center py-3 px-4 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white
|
||||
focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500
|
||||
${canSubmit
|
||||
? 'bg-purple-600 hover:bg-purple-700 transform hover:scale-[1.02] transition-all duration-200'
|
||||
: 'bg-gray-400 cursor-not-allowed'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{(isSubmitting || isLoading) ? (
|
||||
<>
|
||||
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
Verifiziere...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle className="h-5 w-5 mr-2" />
|
||||
Code verifizieren
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Back to Login */}
|
||||
<div className="text-center border-t pt-6">
|
||||
<button
|
||||
onClick={handleGoBack}
|
||||
className="flex items-center justify-center text-sm text-gray-600 hover:text-gray-500 mx-auto"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back to login
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Security Notice */}
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-gray-600">
|
||||
🔒 Dieser zusätzliche Sicherheitsschritt hilft, Ihr Konto zu schützen
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="text-center text-xs text-gray-500">
|
||||
<p>Professioneller Lebenslauf-Builder</p>
|
||||
<p className="mt-1">Erstellt von David Valera Melendez</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
318
src/app/dashboard/page.tsx
Normal file
318
src/app/dashboard/page.tsx
Normal file
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* Dashboard Page - Resume Management Dashboard with Enterprise Authentication
|
||||
* Resume Builder Application
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-07
|
||||
* @updated 2025-08-08 - Added enterprise authentication
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { ModernCard } from '@/components/ui/ModernCard';
|
||||
import ResumePreview from '@/components/resume/ResumePreview';
|
||||
import { sampleResumeData } from '@/data/sampleResumeData';
|
||||
import { UserDropdown } from '@/components/ui/UserDropdown';
|
||||
import { useAuthGuard } from '@/hooks/auth';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
import {
|
||||
Plus,
|
||||
FileText,
|
||||
Edit,
|
||||
Download,
|
||||
Copy,
|
||||
Trash2,
|
||||
Calendar,
|
||||
Clock,
|
||||
Shield,
|
||||
User,
|
||||
LogOut,
|
||||
} from 'lucide-react';
|
||||
|
||||
/**
|
||||
* Dashboard Component - Main Resume Management Interface with Authentication
|
||||
*
|
||||
* Professional dashboard for managing resumes with modern UI patterns and enterprise-grade authentication.
|
||||
* Features resume overview, creation, management capabilities, and secure authentication.
|
||||
*
|
||||
* Key Features:
|
||||
* - Enterprise authentication with route protection
|
||||
* - User account management
|
||||
* - Resume creation and management
|
||||
* - Small resume previews with action buttons
|
||||
* - Responsive design
|
||||
* - German localization
|
||||
* - Security status display
|
||||
*
|
||||
* @returns Dashboard page with resume management and authentication
|
||||
*/
|
||||
export default function Dashboard() {
|
||||
const { user, logout } = useAuthStore();
|
||||
|
||||
// Protect this route - require authentication
|
||||
const { isAuthenticated } = useAuthGuard();
|
||||
|
||||
// Loading state while authentication is being verified
|
||||
if (!isAuthenticated || !user) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-white flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">Lade Dashboard...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
/**
|
||||
* Handles logout
|
||||
*/
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await logout();
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Sample resumes data - in real app, this would come from API/database
|
||||
*/
|
||||
const resumes = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'Senior Frontend Developer',
|
||||
lastModified: '2025-01-07',
|
||||
createdAt: '2025-01-01',
|
||||
data: sampleResumeData,
|
||||
},
|
||||
// Add more sample resumes if needed
|
||||
];
|
||||
|
||||
/**
|
||||
* Handles creating a new resume
|
||||
*/
|
||||
const handleCreateResume = () => {
|
||||
// Navigate to personal info form to start creating
|
||||
window.location.href = '/personal-info';
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles resume actions
|
||||
*/
|
||||
const handleEdit = (resumeId: string) => {
|
||||
window.location.href = '/personal-info';
|
||||
};
|
||||
|
||||
const handleDownload = (resumeId: string) => {
|
||||
console.log('Download resume:', resumeId);
|
||||
};
|
||||
|
||||
const handleDuplicate = (resumeId: string) => {
|
||||
console.log('Duplicate resume:', resumeId);
|
||||
};
|
||||
|
||||
const handleDelete = (resumeId: string) => {
|
||||
console.log('Delete resume:', resumeId);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-white">
|
||||
{/* Header */}
|
||||
<header className="border-b border-gray-200 bg-white shadow-sm">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex h-16 items-center justify-between">
|
||||
{/* Logo and User Info */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-primary-600 to-primary-700 shadow-sm">
|
||||
<FileText className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-primary-700">
|
||||
{user.firstName} {user.lastName}
|
||||
</h1>
|
||||
<p className="text-xs text-gray-500">Resume Builder</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Authentication Status and User Dropdown */}
|
||||
<div className="flex items-center space-x-4">
|
||||
{/* Authentication Status */}
|
||||
<div className="hidden md:flex items-center space-x-2 px-3 py-1 bg-green-50 border border-green-200 rounded-lg">
|
||||
<Shield className="h-4 w-4 text-green-600" />
|
||||
<span className="text-xs text-green-700 font-medium">Authenticated</span>
|
||||
</div>
|
||||
|
||||
{/* User Info */}
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-8 h-8 bg-primary-600 rounded-full flex items-center justify-center">
|
||||
<User className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
<div className="hidden lg:block">
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{user.firstName} {user.lastName}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">{user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Logout Button */}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center px-3 py-2 rounded-md text-sm font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<LogOut className="h-4 w-4 mr-2" />
|
||||
<span className="hidden sm:inline">Abmelden</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>{' '}
|
||||
{/* Main Content */}
|
||||
<main className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
{/* Welcome Section with Create Button */}
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">
|
||||
Willkommen zurück, {user.firstName}!
|
||||
</h1>
|
||||
<p className="mt-2 text-gray-600">
|
||||
Verwalten Sie Ihre Lebensläufe und erstellen Sie neue
|
||||
professionelle Bewerbungsunterlagen
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCreateResume}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-primary-600 px-6 py-3 text-sm font-medium text-white transition-colors hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2"
|
||||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
Neuen Lebenslauf erstellen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Security Status Card */}
|
||||
<div className="mb-6 bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div className="flex items-center">
|
||||
<Shield className="h-5 w-5 text-blue-600 mr-3" />
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-blue-900">
|
||||
Sicherheitsstatus
|
||||
</h4>
|
||||
<p className="text-sm text-blue-700 mt-1">
|
||||
Sie sind sicher mit Enterprise-Sicherheit authentifiziert.
|
||||
{user.roles && user.roles.length > 0 && (
|
||||
<span className="ml-2">
|
||||
Rollen: {user.roles.join(', ')}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Resumes Grid */}
|
||||
<div className="grid gap-6 sm:grid-cols-1 lg:grid-cols-2 xl:grid-cols-3">
|
||||
{resumes.map(resume => (
|
||||
<ModernCard key={resume.id} variant="elevated" padding="md">
|
||||
<div className="flex gap-4">
|
||||
{/* Small Resume Preview - Left Side */}
|
||||
<div className="flex-shrink-0">
|
||||
<div className="flex h-52 w-28 items-center justify-center overflow-hidden rounded-lg border border-gray-200 bg-white shadow-sm">
|
||||
<div className="origin-center scale-[0.18] transform">
|
||||
<div style={{ width: '650px', height: '920px' }}>
|
||||
<ResumePreview resumeData={resume.data} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Resume Info and Actions - Right Side */}
|
||||
<div className="flex flex-1 flex-col justify-between">
|
||||
{/* Title and Info */}
|
||||
<div>
|
||||
<h3 className="mb-2 text-lg font-semibold text-gray-900">
|
||||
{resume.title}
|
||||
</h3>
|
||||
<div className="mb-4 space-y-1 text-xs text-gray-500">
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
Bearbeitet:{' '}
|
||||
{new Date(resume.lastModified).toLocaleDateString(
|
||||
'de-DE'
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="h-3 w-3" />
|
||||
Erstellt:{' '}
|
||||
{new Date(resume.createdAt).toLocaleDateString('de-DE')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleEdit(resume.id)}
|
||||
className="flex flex-1 items-center justify-center gap-1 rounded-lg border border-gray-300 bg-white px-3 py-2 text-xs font-medium text-gray-700 transition-colors hover:bg-gray-50"
|
||||
>
|
||||
<Edit className="h-3 w-3" />
|
||||
Bearbeiten
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDownload(resume.id)}
|
||||
className="flex flex-1 items-center justify-center gap-1 rounded-lg border border-gray-300 bg-white px-3 py-2 text-xs font-medium text-gray-700 transition-colors hover:bg-gray-50"
|
||||
>
|
||||
<Download className="h-3 w-3" />
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleDuplicate(resume.id)}
|
||||
className="flex flex-1 items-center justify-center gap-1 rounded-lg border border-gray-300 bg-white px-3 py-2 text-xs font-medium text-gray-700 transition-colors hover:bg-gray-50"
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
Duplizieren
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(resume.id)}
|
||||
className="flex flex-1 items-center justify-center gap-1 rounded-lg border border-red-300 bg-white px-3 py-2 text-xs font-medium text-red-600 transition-colors hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
Löschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModernCard>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Empty State (if no resumes) */}
|
||||
{resumes.length === 0 && (
|
||||
<div className="mt-12 text-center">
|
||||
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-gray-100">
|
||||
<FileText className="h-8 w-8 text-gray-400" />
|
||||
</div>
|
||||
<h3 className="mt-4 text-lg font-medium text-gray-900">
|
||||
Keine Lebensläufe vorhanden
|
||||
</h3>
|
||||
<p className="mt-2 text-gray-500">
|
||||
Erstellen Sie Ihren ersten professionellen Lebenslauf
|
||||
</p>
|
||||
<button
|
||||
onClick={handleCreateResume}
|
||||
className="mt-6 inline-flex items-center gap-2 rounded-lg bg-primary-600 px-6 py-3 text-sm font-medium text-white transition-colors hover:bg-primary-700"
|
||||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
Ersten Lebenslauf erstellen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
192
src/app/globals.css
Normal file
192
src/app/globals.css
Normal file
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* Global Styles
|
||||
* Professional Resume Builder
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-07
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-secondary-50 text-secondary-900 antialiased;
|
||||
font-feature-settings: 'cv03', 'cv04', 'cv11';
|
||||
}
|
||||
|
||||
* {
|
||||
@apply border-secondary-200;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
@apply font-semibold tracking-tight;
|
||||
}
|
||||
|
||||
h1 {
|
||||
@apply text-4xl lg:text-5xl;
|
||||
}
|
||||
|
||||
h2 {
|
||||
@apply text-3xl lg:text-4xl;
|
||||
}
|
||||
|
||||
h3 {
|
||||
@apply text-2xl lg:text-3xl;
|
||||
}
|
||||
|
||||
h4 {
|
||||
@apply text-xl lg:text-2xl;
|
||||
}
|
||||
|
||||
h5 {
|
||||
@apply text-lg lg:text-xl;
|
||||
}
|
||||
|
||||
h6 {
|
||||
@apply text-base lg:text-lg;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* David Valera Melendez - Professional Button Styles */
|
||||
.btn {
|
||||
@apply inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply btn bg-primary-600 text-white hover:bg-primary-700 focus:ring-primary-500;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply btn bg-secondary-600 text-white hover:bg-secondary-700 focus:ring-secondary-500;
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
@apply btn border border-secondary-300 bg-white text-secondary-900 hover:bg-secondary-50 focus:ring-secondary-500;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
@apply btn text-secondary-700 hover:bg-secondary-100 focus:ring-secondary-500;
|
||||
}
|
||||
|
||||
/* Professional Card Styles */
|
||||
.card {
|
||||
@apply rounded-xl bg-white p-6 shadow-soft;
|
||||
}
|
||||
|
||||
.card-elevated {
|
||||
@apply card shadow-medium;
|
||||
}
|
||||
|
||||
/* Form Input Styles */
|
||||
.input {
|
||||
@apply w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm placeholder:text-secondary-400 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20;
|
||||
}
|
||||
|
||||
.textarea {
|
||||
@apply input resize-none;
|
||||
}
|
||||
|
||||
.label {
|
||||
@apply block text-sm font-medium text-secondary-700;
|
||||
}
|
||||
|
||||
/* Professional Layout Components */
|
||||
.section {
|
||||
@apply py-12 lg:py-16;
|
||||
}
|
||||
|
||||
.container-custom {
|
||||
@apply mx-auto max-w-7xl px-4 sm:px-6 lg:px-8;
|
||||
}
|
||||
|
||||
/* David Valera Melendez - Professional Resume Styles */
|
||||
.resume-section {
|
||||
@apply space-y-6;
|
||||
}
|
||||
|
||||
.resume-header {
|
||||
@apply border-b border-secondary-200 pb-6;
|
||||
}
|
||||
|
||||
.resume-content {
|
||||
@apply space-y-8;
|
||||
}
|
||||
|
||||
.resume-item {
|
||||
@apply relative;
|
||||
}
|
||||
|
||||
.resume-item::before {
|
||||
@apply absolute -left-4 top-2 h-2 w-2 rounded-full bg-primary-600 content-[''];
|
||||
}
|
||||
|
||||
.resume-timeline {
|
||||
@apply relative border-l border-secondary-200 pl-6;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.text-balance {
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.animation-delay-200 {
|
||||
animation-delay: 200ms;
|
||||
}
|
||||
|
||||
.animation-delay-400 {
|
||||
animation-delay: 400ms;
|
||||
}
|
||||
|
||||
.animation-delay-600 {
|
||||
animation-delay: 600ms;
|
||||
}
|
||||
|
||||
/* Professional Gradient Backgrounds */
|
||||
.gradient-primary {
|
||||
background: linear-gradient(135deg, theme('colors.primary.600'), theme('colors.primary.700'));
|
||||
}
|
||||
|
||||
.gradient-secondary {
|
||||
background: linear-gradient(135deg, theme('colors.secondary.600'), theme('colors.secondary.700'));
|
||||
}
|
||||
|
||||
.gradient-accent {
|
||||
background: linear-gradient(135deg, theme('colors.accent.600'), theme('colors.accent.700'));
|
||||
}
|
||||
}
|
||||
|
||||
/* Print Styles for Resume Export */
|
||||
@media print {
|
||||
.no-print {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.print-break-before {
|
||||
page-break-before: always;
|
||||
}
|
||||
|
||||
.print-break-after {
|
||||
page-break-after: always;
|
||||
}
|
||||
|
||||
.print-break-inside-avoid {
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-white text-black;
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply shadow-none;
|
||||
}
|
||||
}
|
||||
87
src/app/layout.tsx
Normal file
87
src/app/layout.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Root Layout Component
|
||||
* Professional Resume Builder
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-07
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
|
||||
import type { Metadata } from 'next';
|
||||
import { Inter } from 'next/font/google';
|
||||
import './globals.css';
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'David Valera Melendez - Professional Resume Builder',
|
||||
description:
|
||||
'Professional Resume Builder created by David Valera Melendez - Made in Germany',
|
||||
keywords: [
|
||||
'resume',
|
||||
'cv',
|
||||
'curriculum vitae',
|
||||
'professional',
|
||||
'builder',
|
||||
'David Valera Melendez',
|
||||
'Germany',
|
||||
'job application',
|
||||
],
|
||||
authors: [
|
||||
{
|
||||
name: 'David Valera Melendez',
|
||||
url: 'https://valera-melendez.de',
|
||||
},
|
||||
],
|
||||
creator: 'David Valera Melendez',
|
||||
publisher: 'David Valera Melendez',
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
googleBot: {
|
||||
index: true,
|
||||
follow: true,
|
||||
},
|
||||
},
|
||||
openGraph: {
|
||||
type: 'website',
|
||||
locale: 'en_US',
|
||||
url: 'https://resume.valera-melendez.de',
|
||||
title: 'David Valera Melendez - Professional Resume Builder',
|
||||
description:
|
||||
'Professional Resume Builder created by David Valera Melendez - Made in Germany',
|
||||
siteName: 'David Valera Resume Builder',
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: 'David Valera Melendez - Professional Resume Builder',
|
||||
description:
|
||||
'Professional Resume Builder created by David Valera Melendez - Made in Germany',
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en" className="scroll-smooth">
|
||||
<head>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#0ea5e9" />
|
||||
</head>
|
||||
<body className={`${inter.className} antialiased`}>
|
||||
<div className="from-grayscale-50 via-primary-25 to-grayscale-100 relative min-h-screen bg-gradient-to-br">
|
||||
{/* Professional background pattern */}
|
||||
<div className="absolute inset-0 opacity-30">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_1px_1px,rgba(14,165,233,0.15)_1px,transparent_0)] bg-[length:24px_24px]" />
|
||||
</div>
|
||||
{/* Content overlay */}
|
||||
<div className="relative z-10">{children}</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
111
src/app/login/page.tsx
Normal file
111
src/app/login/page.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Login Page - Professional Authentication
|
||||
* Professional Resume Builder - Enhanced with modern authentication standards
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-07
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import LoginForm from '@/components/auth/LoginForm';
|
||||
|
||||
/**
|
||||
* Login credentials interface
|
||||
*/
|
||||
interface LoginCredentials {
|
||||
email: string;
|
||||
password: string;
|
||||
rememberMe: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* LoginPage Component - Professional Authentication Page
|
||||
*
|
||||
* Complete login page with David Valera Melendez branding and German localization.
|
||||
* Integrates with the professional resume builder application with modern
|
||||
* authentication patterns and user experience.
|
||||
*
|
||||
* Key Features:
|
||||
* - Professional German branding
|
||||
* - David Valera Melendez logo and identity
|
||||
* - Modern authentication flow
|
||||
* - Responsive design
|
||||
* - Integration with Next.js routing
|
||||
* - Professional color scheme
|
||||
*
|
||||
* @returns Complete login page with authentication functionality
|
||||
*/
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
|
||||
/**
|
||||
* Handles successful login
|
||||
*
|
||||
* @param credentials - User login credentials
|
||||
*/
|
||||
const handleLogin = async (credentials: LoginCredentials) => {
|
||||
try {
|
||||
// Here you would typically make an API call to authenticate
|
||||
console.log('Login attempted with:', credentials);
|
||||
|
||||
// For now, simulate successful login and redirect
|
||||
// In a real application, you would:
|
||||
// 1. Send credentials to your authentication API
|
||||
// 2. Store the authentication token
|
||||
// 3. Update user context/state
|
||||
// 4. Redirect to the main application
|
||||
|
||||
// Simulate API delay
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
// Redirect to main application
|
||||
router.push('/');
|
||||
} catch (error) {
|
||||
console.error('Login failed:', error);
|
||||
// Handle login error (show error message, etc.)
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles forgot password action
|
||||
*/
|
||||
const handleForgotPassword = () => {
|
||||
// Here you would typically navigate to a forgot password page
|
||||
// or show a forgot password modal
|
||||
console.log('Forgot password clicked');
|
||||
|
||||
// For now, you could redirect to a forgot password page
|
||||
// router.push('/auth/forgot-password');
|
||||
|
||||
// Or show an alert (temporary solution)
|
||||
alert('Funktion "Passwort vergessen" wird in Kürze verfügbar sein.');
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles sign up action
|
||||
*/
|
||||
const handleSignUp = () => {
|
||||
// Here you would typically navigate to a registration page
|
||||
console.log('Sign up clicked');
|
||||
|
||||
// For now, you could redirect to a sign up page
|
||||
// router.push('/auth/signup');
|
||||
|
||||
// Or show an alert (temporary solution)
|
||||
alert('Registrierung wird in Kürze verfügbar sein.');
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<LoginForm
|
||||
onLogin={handleLogin}
|
||||
onForgotPassword={handleForgotPassword}
|
||||
onSignUp={handleSignUp}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
352
src/app/page.tsx
Normal file
352
src/app/page.tsx
Normal file
@@ -0,0 +1,352 @@
|
||||
/**
|
||||
* Home Page Component
|
||||
* Professional Resume Builder
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-07
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import HorizontalStepper, {
|
||||
Step,
|
||||
} from '@/components/navigation/HorizontalStepper';
|
||||
import StepContentRenderer from '@/components/resume/StepContentRenderer';
|
||||
import ResumePreview from '@/components/resume/ResumePreview';
|
||||
import { ResumeData } from '@/types/resume';
|
||||
|
||||
// Import organized data from centralized data folder
|
||||
import { emptyResumeTemplate } from '@/data/resumeTemplates';
|
||||
import { stepDescriptions } from '@/data/uiConstants';
|
||||
|
||||
/**
|
||||
* Initial resume data using centralized empty template
|
||||
*/
|
||||
const initialResumeData: ResumeData = emptyResumeTemplate;
|
||||
|
||||
/**
|
||||
* Step configuration for the resume builder process
|
||||
* Defines the sequence and metadata for each step in the resume creation workflow
|
||||
*/
|
||||
const resumeSteps: Step[] = [
|
||||
{
|
||||
id: 'contact',
|
||||
title: 'Contact',
|
||||
description: stepDescriptions.contact,
|
||||
icon: '👤',
|
||||
completed: false,
|
||||
},
|
||||
{
|
||||
id: 'experience',
|
||||
title: 'Experience',
|
||||
description: stepDescriptions.experience,
|
||||
icon: '💼',
|
||||
completed: false,
|
||||
},
|
||||
{
|
||||
id: 'education',
|
||||
title: 'Education',
|
||||
description: stepDescriptions.education,
|
||||
icon: '🎓',
|
||||
completed: false,
|
||||
},
|
||||
{
|
||||
id: 'skills',
|
||||
title: 'Skills',
|
||||
description: stepDescriptions.skills,
|
||||
icon: '⚡',
|
||||
completed: false,
|
||||
},
|
||||
{
|
||||
id: 'about',
|
||||
title: 'About',
|
||||
description: stepDescriptions.about,
|
||||
icon: '📝',
|
||||
completed: false,
|
||||
},
|
||||
{
|
||||
id: 'finish',
|
||||
title: 'Finish',
|
||||
description: '',
|
||||
icon: '✨',
|
||||
completed: false,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* HomePage Component - Main Resume Builder Application
|
||||
*
|
||||
* Professional resume builder with split-screen layout featuring a step-by-step form
|
||||
* interface and real-time preview. Implements modern CV format with comprehensive
|
||||
* data validation and progress tracking.
|
||||
*
|
||||
* Features:
|
||||
* - Multi-step form wizard with progress tracking
|
||||
* - Real-time resume preview with professional styling
|
||||
* - Modern CV format compliance
|
||||
* - Auto-completion validation
|
||||
* - Responsive design with mobile support
|
||||
* - PDF export capability
|
||||
*
|
||||
* @returns Complete resume builder application interface
|
||||
*/
|
||||
export default function HomePage() {
|
||||
const [resumeData, setResumeData] = useState<ResumeData>(initialResumeData);
|
||||
const [currentStep, setCurrentStep] = useState('contact');
|
||||
const [steps, setSteps] = useState(resumeSteps);
|
||||
|
||||
/**
|
||||
* Updates resume data with partial data changes
|
||||
* Merges new data with existing resume data while maintaining immutability
|
||||
*
|
||||
* @param updatedData - Partial resume data to merge with current data
|
||||
*/
|
||||
const handleDataUpdate = (updatedData: Partial<ResumeData>) => {
|
||||
setResumeData(prev => ({ ...prev, ...updatedData }));
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles step navigation when user clicks on a specific step
|
||||
*
|
||||
* @param stepId - The ID of the step to navigate to
|
||||
*/
|
||||
const handleStepChange = (stepId: string) => {
|
||||
setCurrentStep(stepId);
|
||||
};
|
||||
|
||||
/**
|
||||
* Navigates to the next step in the resume builder process
|
||||
* Marks current step as completed and advances to next step
|
||||
*/
|
||||
const handleNext = () => {
|
||||
const currentIndex = steps.findIndex(step => step.id === currentStep);
|
||||
if (currentIndex < steps.length - 1) {
|
||||
// Mark current step as completed
|
||||
setSteps(prev =>
|
||||
prev.map(step =>
|
||||
step.id === currentStep ? { ...step, completed: true } : step
|
||||
)
|
||||
);
|
||||
setCurrentStep(steps[currentIndex + 1].id);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Navigates to the previous step in the resume builder process
|
||||
*/
|
||||
const handlePrevious = () => {
|
||||
const currentIndex = steps.findIndex(step => step.id === currentStep);
|
||||
if (currentIndex > 0) {
|
||||
setCurrentStep(steps[currentIndex - 1].id);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Determines if the current step is the first step
|
||||
*/
|
||||
const isFirstStep = steps.findIndex(step => step.id === currentStep) === 0;
|
||||
|
||||
/**
|
||||
* Determines if the current step is the last step
|
||||
*/
|
||||
const isLastStep =
|
||||
steps.findIndex(step => step.id === currentStep) === steps.length - 1;
|
||||
|
||||
/**
|
||||
* Auto-completion effect - Updates step completion status based on resume data
|
||||
* Monitors resume data changes and automatically marks steps as completed
|
||||
* when required data is present
|
||||
*/
|
||||
useEffect(() => {
|
||||
setSteps(prev =>
|
||||
prev.map(step => {
|
||||
switch (step.id) {
|
||||
case 'contact':
|
||||
return {
|
||||
...step,
|
||||
completed: !!(
|
||||
resumeData.personalInfo.firstName &&
|
||||
resumeData.personalInfo.lastName &&
|
||||
resumeData.personalInfo.email
|
||||
),
|
||||
};
|
||||
case 'experience':
|
||||
return { ...step, completed: resumeData.experience.length > 0 };
|
||||
case 'education':
|
||||
return { ...step, completed: resumeData.education.length > 0 };
|
||||
case 'skills':
|
||||
return { ...step, completed: resumeData.skills.length > 0 };
|
||||
case 'about':
|
||||
return {
|
||||
...step,
|
||||
completed: !!(
|
||||
resumeData.personalInfo.summary &&
|
||||
resumeData.personalInfo.summary.length > 10
|
||||
),
|
||||
};
|
||||
default:
|
||||
return step;
|
||||
}
|
||||
})
|
||||
);
|
||||
}, [resumeData]);
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-gradient-to-br from-grayscale-100 via-primary-50 to-grayscale-50">
|
||||
{/* Main Content Area - Enhanced Professional Layout */}
|
||||
<div className="flex h-screen">
|
||||
{/* Left Panel - Entire Panel Scrolls Together */}
|
||||
<div className="relative z-10 w-1/2 overflow-y-auto border-r border-grayscale-200 bg-gradient-to-b from-white to-grayscale-25 shadow-2xl">
|
||||
{/* Horizontal Stepper Header - Scrolls with content */}
|
||||
<div className="border-b border-grayscale-100 bg-gradient-to-r from-white via-grayscale-25 to-white shadow-soft">
|
||||
<HorizontalStepper
|
||||
steps={steps}
|
||||
currentStep={currentStep}
|
||||
onStepChange={handleStepChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Step Content - No separate scrolling */}
|
||||
<div className="bg-gradient-to-b from-transparent to-grayscale-25/30">
|
||||
<StepContentRenderer
|
||||
currentStep={currentStep}
|
||||
resumeData={resumeData}
|
||||
onDataUpdate={handleDataUpdate}
|
||||
onNext={handleNext}
|
||||
onPrevious={handlePrevious}
|
||||
isFirstStep={isFirstStep}
|
||||
isLastStep={isLastStep}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Panel - Independent Scrolling Resume Preview */}
|
||||
<div className="relative flex w-1/2 flex-col bg-gradient-to-br from-grayscale-200 via-grayscale-100 to-primary-50">
|
||||
{/* Background Pattern for Professional Look */}
|
||||
<div className="absolute inset-0 opacity-20">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_2px_2px,rgba(100,116,139,0.15)_1px,transparent_0)] bg-[length:32px_32px]" />
|
||||
</div>
|
||||
|
||||
{/* Right Panel - Independent Scrollable Content */}
|
||||
<div className="relative z-10 flex-1 overflow-y-auto">
|
||||
<div className="p-4">
|
||||
{/* Preview Container - More Compact */}
|
||||
<div className="relative flex min-h-screen items-start justify-center p-4">
|
||||
<div className="" style={{ minHeight: '800px' }}>
|
||||
<ResumePreview resumeData={resumeData} />
|
||||
</div>
|
||||
|
||||
{/* Floating Action Buttons - Smaller */}
|
||||
<div className="absolute right-2 top-2 flex items-center gap-1">
|
||||
<button className="group flex h-7 w-7 items-center justify-center rounded-full border border-border-primary bg-background-primary text-text-tertiary shadow-medium transition-all duration-200 hover:text-primary-600 hover:shadow-large">
|
||||
<svg
|
||||
className="h-3.5 w-3.5 transition-transform duration-200 group-hover:scale-110"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button className="group flex h-10 w-10 items-center justify-center rounded-full border border-border-primary bg-background-primary text-text-tertiary shadow-medium transition-all duration-200 hover:text-primary-600 hover:shadow-large">
|
||||
<svg
|
||||
className="h-5 w-5 transition-transform duration-200 group-hover:scale-110"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Download Section - Enhanced */}
|
||||
<div className="mt-6 space-y-4 text-center">
|
||||
<button className="group inline-flex min-w-40 transform items-center justify-center rounded-xl bg-gradient-to-r from-primary-500 to-primary-600 px-6 py-2.5 text-sm font-semibold text-white shadow-large transition-all duration-300 hover:-translate-y-0.5 hover:from-primary-600 hover:to-primary-700 hover:shadow-xl">
|
||||
<svg
|
||||
className="mr-2 h-4 w-4 transition-transform duration-200 group-hover:scale-110"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
Download PDF
|
||||
</button>
|
||||
|
||||
<div className="flex items-center justify-center gap-4 text-sm text-text-tertiary">
|
||||
<span className="flex items-center gap-1">
|
||||
<svg
|
||||
className="h-4 w-4 text-success-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
ATS-Friendly
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<svg
|
||||
className="h-4 w-4 text-success-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
Professional Format
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<svg
|
||||
className="h-4 w-4 text-success-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
Made in Germany 🇩🇪
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
170
src/app/preview/page.tsx
Normal file
170
src/app/preview/page.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Preview Page - Resume Preview
|
||||
* Resume Builder Application
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-07
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import Layout from '@/components/layout/Layout';
|
||||
import ResumePreview from '@/components/resume/ResumePreview';
|
||||
import { ModernCard } from '@/components/ui/ModernCard';
|
||||
import { Eye, Download, Edit, Share } from 'lucide-react';
|
||||
import { sampleResumeData } from '@/data/sampleResumeData';
|
||||
|
||||
/**
|
||||
* Preview Component - Resume Preview Page
|
||||
*
|
||||
* Page for viewing the complete resume preview with export options.
|
||||
*
|
||||
* Key Features:
|
||||
* - Full resume preview
|
||||
* - Export to PDF functionality
|
||||
* - Edit shortcuts
|
||||
* - Responsive design
|
||||
*
|
||||
* @returns Preview page with resume display
|
||||
*/
|
||||
export default function Preview() {
|
||||
/**
|
||||
* Handles PDF export
|
||||
*/
|
||||
const handleExport = () => {
|
||||
// Implement PDF export logic
|
||||
console.log('Export to PDF');
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles edit action
|
||||
*/
|
||||
const handleEdit = () => {
|
||||
// Navigate to edit page
|
||||
window.location.href = '/personal-info';
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="space-y-6">
|
||||
{/* Page Header */}
|
||||
<div className="border-b border-gray-200 pb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-gradient-to-br from-primary-100 to-primary-200 shadow-sm">
|
||||
<Eye className="h-6 w-6 text-primary-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
Lebenslauf Vorschau
|
||||
</h1>
|
||||
<p className="text-gray-600">
|
||||
Vorschau Ihres professionellen Lebenslaufs
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleEdit}
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
<span>Bearbeiten</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExport}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-primary-700"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
<span>PDF Export</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview Content */}
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
{/* Resume Preview */}
|
||||
<div className="lg:col-span-2">
|
||||
<ModernCard variant="elevated" padding="lg">
|
||||
<div className="flex justify-center">
|
||||
<ResumePreview resumeData={sampleResumeData} />
|
||||
</div>
|
||||
</ModernCard>
|
||||
</div>
|
||||
|
||||
{/* Actions Sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* Export Options */}
|
||||
<ModernCard variant="elevated" padding="md">
|
||||
<h3 className="mb-4 text-lg font-semibold text-gray-900">
|
||||
Export-Optionen
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={handleExport}
|
||||
className="flex w-full items-center gap-3 rounded-lg border border-gray-200 p-3 text-left transition-colors hover:bg-gray-50"
|
||||
>
|
||||
<Download className="h-5 w-5 text-primary-600" />
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">
|
||||
PDF herunterladen
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
Hochauflösender PDF-Export
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
<button className="flex w-full items-center gap-3 rounded-lg border border-gray-200 p-3 text-left transition-colors hover:bg-gray-50">
|
||||
<Share className="h-5 w-5 text-primary-600" />
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">Link teilen</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
Öffentlichen Link erstellen
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</ModernCard>
|
||||
|
||||
{/* Tips */}
|
||||
<ModernCard variant="elevated" padding="md">
|
||||
<h3 className="mb-4 text-lg font-semibold text-gray-900">
|
||||
Tipps für bessere Ergebnisse
|
||||
</h3>
|
||||
<div className="space-y-3 text-sm text-gray-600">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="mt-2 h-2 w-2 flex-shrink-0 rounded-full bg-primary-600"></div>
|
||||
<p>
|
||||
Verwenden Sie konkrete Zahlen und Erfolge in Ihren
|
||||
Beschreibungen
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="mt-2 h-2 w-2 flex-shrink-0 rounded-full bg-primary-600"></div>
|
||||
<p>
|
||||
Halten Sie Beschreibungen präzise und relevant für die
|
||||
Zielposition
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="mt-2 h-2 w-2 flex-shrink-0 rounded-full bg-primary-600"></div>
|
||||
<p>Überprüfen Sie Rechtschreibung und Grammatik sorgfältig</p>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="mt-2 h-2 w-2 flex-shrink-0 rounded-full bg-primary-600"></div>
|
||||
<p>
|
||||
Aktualisieren Sie regelmäßig Ihre Fähigkeiten und
|
||||
Erfahrungen
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</ModernCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
234
src/app/settings/page.tsx
Normal file
234
src/app/settings/page.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* Settings Page Component
|
||||
* User Account Settings and Profile Management
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-07
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Save, ArrowLeft, User } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ModernCard } from '@/components/ui/ModernCard';
|
||||
import { ModernInput } from '@/components/ui/ModernInput';
|
||||
import { UserDropdown } from '@/components/ui/UserDropdown';
|
||||
|
||||
/**
|
||||
* Settings Component - User Profile Management
|
||||
*
|
||||
* Professional settings page for managing user account information
|
||||
* with German address format and comprehensive form validation.
|
||||
*
|
||||
* Features:
|
||||
* - Personal information management
|
||||
* - German address format
|
||||
* - Form validation
|
||||
* - Professional styling
|
||||
* - Responsive design
|
||||
*
|
||||
* @returns Settings page with user profile management
|
||||
*/
|
||||
export default function Settings() {
|
||||
const router = useRouter();
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// User profile state
|
||||
const [profile, setProfile] = useState({
|
||||
firstName: 'David',
|
||||
lastName: 'Valera Melendez',
|
||||
email: 'david@valera-melendez.de',
|
||||
phone: '+49 123 456 789',
|
||||
street: 'Musterstraße 123',
|
||||
zipCode: '10115',
|
||||
city: 'Berlin',
|
||||
country: 'Deutschland',
|
||||
});
|
||||
|
||||
/**
|
||||
* Handles form input changes
|
||||
*/
|
||||
const handleInputChange = (
|
||||
field: string,
|
||||
event: React.ChangeEvent<HTMLInputElement>
|
||||
) => {
|
||||
setProfile(prev => ({
|
||||
...prev,
|
||||
[field]: event.target.value,
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles form submission
|
||||
*/
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
|
||||
// Simulate API call
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
// Here you would typically save to your backend
|
||||
console.log('Saving profile:', profile);
|
||||
|
||||
setIsSaving(false);
|
||||
|
||||
// Show success message (you can implement a toast notification)
|
||||
alert('Profil erfolgreich gespeichert!');
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles navigation back to dashboard
|
||||
*/
|
||||
const handleBackToDashboard = () => {
|
||||
router.push('/dashboard');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-white">
|
||||
{/* Header */}
|
||||
<header className="border-b border-gray-200 bg-white shadow-sm">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex h-16 items-center justify-between">
|
||||
{/* Back Button - Left Side */}
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={handleBackToDashboard}
|
||||
className="flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-100"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Zurück zum Dashboard
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* User Info and Logo - Right Side */}
|
||||
<UserDropdown currentPage="settings" showLogo={true} />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="mx-auto max-w-4xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
{/* Page Title */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900">
|
||||
Konto-Einstellungen
|
||||
</h1>
|
||||
<p className="mt-2 text-gray-600">
|
||||
Verwalten Sie Ihre persönlichen Informationen und Kontoeinstellungen
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Settings Form */}
|
||||
<div className="grid gap-8 lg:grid-cols-1">
|
||||
<ModernCard variant="elevated" padding="lg">
|
||||
<div className="mb-6">
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-primary-100 to-primary-200">
|
||||
<User className="h-5 w-5 text-primary-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900">
|
||||
Persönliche Informationen
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500">
|
||||
Aktualisieren Sie Ihre persönlichen Daten
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Name Fields */}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<ModernInput
|
||||
label="Vorname"
|
||||
value={profile.firstName}
|
||||
onChange={e => handleInputChange('firstName', e)}
|
||||
placeholder="Ihr Vorname"
|
||||
required
|
||||
/>
|
||||
<ModernInput
|
||||
label="Nachname"
|
||||
value={profile.lastName}
|
||||
onChange={e => handleInputChange('lastName', e)}
|
||||
placeholder="Ihr Nachname"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Contact Information */}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<ModernInput
|
||||
label="E-Mail-Adresse"
|
||||
type="email"
|
||||
value={profile.email}
|
||||
onChange={e => handleInputChange('email', e)}
|
||||
placeholder="ihre.email@domain.de"
|
||||
required
|
||||
/>
|
||||
<ModernInput
|
||||
label="Telefonnummer"
|
||||
type="tel"
|
||||
value={profile.phone}
|
||||
onChange={e => handleInputChange('phone', e)}
|
||||
placeholder="+49 123 456 789"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Address Fields */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium text-gray-900">Adresse</h3>
|
||||
|
||||
<ModernInput
|
||||
label="Straße und Hausnummer"
|
||||
value={profile.street}
|
||||
onChange={e => handleInputChange('street', e)}
|
||||
placeholder="Musterstraße 123"
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<ModernInput
|
||||
label="Postleitzahl"
|
||||
value={profile.zipCode}
|
||||
onChange={e => handleInputChange('zipCode', e)}
|
||||
placeholder="12345"
|
||||
maxLength={5}
|
||||
/>
|
||||
<div className="sm:col-span-2">
|
||||
<ModernInput
|
||||
label="Stadt"
|
||||
value={profile.city}
|
||||
onChange={e => handleInputChange('city', e)}
|
||||
placeholder="Berlin"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ModernInput
|
||||
label="Land"
|
||||
value={profile.country}
|
||||
onChange={e => handleInputChange('country', e)}
|
||||
placeholder="Deutschland"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="flex justify-end pt-6">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-primary-600 px-6 py-3 text-sm font-medium text-white transition-colors hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
{isSaving ? 'Wird gespeichert...' : 'Änderungen speichern'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</ModernCard>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
317
src/components/auth/LoginForm.tsx
Normal file
317
src/components/auth/LoginForm.tsx
Normal file
@@ -0,0 +1,317 @@
|
||||
/**
|
||||
* Login Form Component - Professional Authentication
|
||||
* Professional Resume Builder - Enhanced with modern authentication standards
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-07
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { ModernCard } from '@/components/ui/ModernCard';
|
||||
import { ModernInput } from '@/components/ui/ModernInput';
|
||||
import {
|
||||
Mail,
|
||||
Lock,
|
||||
Eye,
|
||||
EyeOff,
|
||||
User,
|
||||
ArrowRight,
|
||||
Shield,
|
||||
Check,
|
||||
} from 'lucide-react';
|
||||
|
||||
/**
|
||||
* Props interface for LoginForm component
|
||||
*/
|
||||
interface LoginFormProps {
|
||||
onLogin?: (credentials: LoginCredentials) => void;
|
||||
onForgotPassword?: () => void;
|
||||
onSignUp?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Login credentials interface
|
||||
*/
|
||||
interface LoginCredentials {
|
||||
email: string;
|
||||
password: string;
|
||||
rememberMe: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* LoginForm Component - Professional Authentication Interface
|
||||
*
|
||||
* Beautiful and modern login form component with German localization
|
||||
* and professional branding. Features secure authentication with
|
||||
* modern UX patterns and accessibility standards.
|
||||
*
|
||||
* Key Features:
|
||||
* - German localization for professional German market
|
||||
* - David Valera Melendez branding and logo
|
||||
* - Modern authentication UI with validation
|
||||
* - Password visibility toggle
|
||||
* - Remember me functionality
|
||||
* - Professional color scheme matching CV design
|
||||
* - Responsive design for all devices
|
||||
*
|
||||
* @param props - Component properties
|
||||
* @param props.onLogin - Callback function for login submission
|
||||
* @param props.onForgotPassword - Callback for forgotten password
|
||||
* @param props.onSignUp - Callback for new user registration
|
||||
* @returns Professional login form with German localization
|
||||
*/
|
||||
export default function LoginForm({
|
||||
onLogin,
|
||||
onForgotPassword,
|
||||
onSignUp,
|
||||
}: LoginFormProps) {
|
||||
/**
|
||||
* Form state management
|
||||
*/
|
||||
const [credentials, setCredentials] = useState<LoginCredentials>({
|
||||
email: '',
|
||||
password: '',
|
||||
rememberMe: false,
|
||||
});
|
||||
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [errors, setErrors] = useState<Partial<LoginCredentials>>({});
|
||||
|
||||
/**
|
||||
* Handles input field changes and updates the credentials state
|
||||
*
|
||||
* @param field - The field name to update
|
||||
* @param value - The new value for the field
|
||||
*/
|
||||
const handleChange = (
|
||||
field: keyof LoginCredentials,
|
||||
value: string | boolean
|
||||
) => {
|
||||
setCredentials(prev => ({ ...prev, [field]: value }));
|
||||
// Clear error when user starts typing
|
||||
if (errors[field as keyof typeof errors]) {
|
||||
setErrors(prev => ({ ...prev, [field]: undefined }));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates form inputs
|
||||
*
|
||||
* @returns Boolean indicating if form is valid
|
||||
*/
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: Partial<LoginCredentials> = {};
|
||||
|
||||
if (!credentials.email) {
|
||||
newErrors.email = 'E-Mail-Adresse ist erforderlich';
|
||||
} else if (!/\S+@\S+\.\S+/.test(credentials.email)) {
|
||||
newErrors.email = 'Bitte geben Sie eine gültige E-Mail-Adresse ein';
|
||||
}
|
||||
|
||||
if (!credentials.password) {
|
||||
newErrors.password = 'Passwort ist erforderlich';
|
||||
} else if (credentials.password.length < 6) {
|
||||
newErrors.password = 'Passwort muss mindestens 6 Zeichen lang sein';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles form submission
|
||||
*
|
||||
* @param event - Form submission event
|
||||
*/
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (!validateForm()) return;
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// Simulate API call delay
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
if (onLogin) {
|
||||
onLogin(credentials);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Login failed:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggles password visibility
|
||||
*/
|
||||
const togglePasswordVisibility = () => {
|
||||
setShowPassword(prev => !prev);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-primary-50 via-white to-primary-100 px-4 py-8">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Brand Header */}
|
||||
<div className="mb-8 text-center">
|
||||
<div className="mb-4 flex justify-center"></div>
|
||||
<h1 className="mb-2 text-2xl font-bold tracking-wide text-primary-700">
|
||||
David Valera Melendez
|
||||
</h1>
|
||||
<p className="text-sm font-medium text-gray-600">
|
||||
Professional Resume Builder
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Login Form */}
|
||||
<ModernCard variant="elevated" padding="lg">
|
||||
<div className="mb-6">
|
||||
<div className="mb-4 flex items-center justify-center">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-gradient-to-br from-primary-100 to-primary-200 shadow-sm">
|
||||
<Lock className="h-6 w-6 text-primary-600" />
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="mb-2 text-center text-xl font-semibold text-text-primary">
|
||||
Willkommen zurück
|
||||
</h2>
|
||||
<p className="text-center text-sm text-text-secondary">
|
||||
Melden Sie sich in Ihrem Konto an, um fortzufahren
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Email Input */}
|
||||
<div>
|
||||
<ModernInput
|
||||
label="E-Mail-Adresse"
|
||||
type="email"
|
||||
value={credentials.email}
|
||||
onChange={e => handleChange('email', e.target.value)}
|
||||
placeholder="ihre.email@beispiel.de"
|
||||
leftIcon={<Mail className="h-4 w-4" />}
|
||||
variant="filled"
|
||||
required
|
||||
error={errors.email}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Password Input */}
|
||||
<div>
|
||||
<div className="relative">
|
||||
<ModernInput
|
||||
label="Passwort"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={credentials.password}
|
||||
onChange={e => handleChange('password', e.target.value)}
|
||||
placeholder="Ihr sicheres Passwort"
|
||||
leftIcon={<Lock className="h-4 w-4" />}
|
||||
variant="filled"
|
||||
required
|
||||
error={errors.password}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={togglePasswordVisibility}
|
||||
className="absolute right-3 top-8 rounded p-1 text-gray-400 hover:text-gray-600 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2"
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Remember Me & Forgot Password */}
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="flex cursor-pointer items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={credentials.rememberMe}
|
||||
onChange={e => handleChange('rememberMe', e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500 focus:ring-offset-2"
|
||||
/>
|
||||
<span className="text-sm text-text-secondary">
|
||||
Angemeldet bleiben
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onForgotPassword}
|
||||
className="text-sm font-medium text-primary-600 hover:text-primary-700 focus:underline focus:outline-none"
|
||||
>
|
||||
Passwort vergessen?
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className={`group relative flex w-full items-center justify-center gap-2 rounded-lg px-4 py-3 text-sm font-semibold text-white shadow-sm transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 ${
|
||||
isLoading
|
||||
? 'cursor-not-allowed bg-gray-400'
|
||||
: 'bg-primary-600 hover:bg-primary-700 hover:shadow-md active:scale-[0.98]'
|
||||
} `}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
||||
<span>Anmeldung läuft...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>Anmelden</span>
|
||||
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Sign Up Link */}
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-sm text-text-secondary">
|
||||
Noch kein Konto?{' '}
|
||||
<button
|
||||
onClick={onSignUp}
|
||||
className="font-medium text-primary-600 hover:text-primary-700 focus:underline focus:outline-none"
|
||||
>
|
||||
Jetzt registrieren
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Security Features */}
|
||||
<div className="mt-6 border-t border-gray-200 pt-4">
|
||||
<div className="flex items-center justify-center gap-4 text-xs text-gray-500">
|
||||
<div className="flex items-center gap-1">
|
||||
<Shield className="h-3 w-3" />
|
||||
<span>SSL-verschlüsselt</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Check className="h-3 w-3" />
|
||||
<span>DSGVO-konform</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModernCard>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-8 text-center text-xs text-gray-500">
|
||||
<p>© 2025 David Valera Melendez. Alle Rechte vorbehalten.</p>
|
||||
<p className="mt-1">
|
||||
Professional Resume Builder - Made in Germany 🇩🇪
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
29
src/components/index.ts
Normal file
29
src/components/index.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Components Barrel Export
|
||||
* Professional Resume Builder - Component Library Entry Point
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-07
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
|
||||
// UI Components (Reusable Design System)
|
||||
export * from './ui';
|
||||
|
||||
// Layout Components
|
||||
export { default as Header } from './layout/Header';
|
||||
|
||||
// Navigation Components
|
||||
export { default as HorizontalStepper } from './navigation/HorizontalStepper';
|
||||
export { default as StepperNavigation } from './navigation/StepperNavigation';
|
||||
|
||||
// Resume Components
|
||||
export { default as ResumeBuilder } from './resume/ResumeBuilder';
|
||||
export { default as ResumePreview } from './resume/ResumePreview';
|
||||
export { default as StepContentRenderer } from './resume/StepContentRenderer';
|
||||
|
||||
// Form Components
|
||||
export { default as PersonalInfoForm } from './resume/forms/PersonalInfoForm';
|
||||
export { default as ExperienceForm } from './resume/forms/ExperienceForm';
|
||||
export { default as EducationForm } from './resume/forms/EducationForm';
|
||||
export { default as SkillsForm } from './resume/forms/SkillsForm';
|
||||
111
src/components/layout/Header.tsx
Normal file
111
src/components/layout/Header.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Header Component
|
||||
* Professional Resume Builder
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-07
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { ResumeData } from '@/types/resume';
|
||||
|
||||
interface HeaderProps {
|
||||
activeView: 'builder' | 'preview';
|
||||
onViewChange: (view: 'builder' | 'preview') => void;
|
||||
resumeData: ResumeData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Header Component - Professional navigation and branding
|
||||
*
|
||||
* Provides the main application header with logo, view toggle controls,
|
||||
* and PDF export functionality. Responsive design with mobile-optimized UI.
|
||||
*
|
||||
* @param props - Component properties
|
||||
* @param props.activeView - Currently active view mode ('builder' or 'preview')
|
||||
* @param props.onViewChange - Callback to handle view mode changes
|
||||
* @param props.resumeData - Complete resume data for export functionality
|
||||
* @returns Professional header component with navigation controls
|
||||
*/
|
||||
export default function Header({
|
||||
activeView,
|
||||
onViewChange,
|
||||
resumeData,
|
||||
}: HeaderProps) {
|
||||
/**
|
||||
* Handles PDF export functionality
|
||||
* Initiates the PDF generation process with current resume data
|
||||
*/
|
||||
const handleExportPDF = () => {
|
||||
// PDF export implementation will be added here
|
||||
const exportData = {
|
||||
timestamp: new Date().toISOString(),
|
||||
resumeData,
|
||||
format: 'PDF',
|
||||
version: '1.0',
|
||||
};
|
||||
// Future: Implement PDF export service integration
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="border-b border-secondary-200 bg-white shadow-soft">
|
||||
<div className="container-custom">
|
||||
<div className="flex items-center justify-between py-4">
|
||||
{/* Logo & Branding */}
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="bg-gradient-primary flex h-10 w-10 items-center justify-center rounded-lg">
|
||||
<span className="text-lg font-bold text-white">DV</span>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-secondary-900">
|
||||
Resume Builder
|
||||
</h1>
|
||||
<p className="text-xs text-secondary-600">
|
||||
by David Valera Melendez
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* View Toggle (Mobile) */}
|
||||
<div className="flex items-center space-x-2 lg:hidden">
|
||||
<button
|
||||
onClick={() => onViewChange('builder')}
|
||||
className={`rounded-md px-3 py-1 text-sm font-medium transition-colors ${
|
||||
activeView === 'builder'
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'bg-secondary-100 text-secondary-700 hover:bg-secondary-200'
|
||||
}`}
|
||||
>
|
||||
Builder
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onViewChange('preview')}
|
||||
className={`rounded-md px-3 py-1 text-sm font-medium transition-colors ${
|
||||
activeView === 'preview'
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'bg-secondary-100 text-secondary-700 hover:bg-secondary-200'
|
||||
}`}
|
||||
>
|
||||
Preview
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="hidden items-center space-x-4 lg:flex">
|
||||
<button onClick={handleExportPDF} className="btn-primary">
|
||||
Export PDF
|
||||
</button>
|
||||
<div className="text-right">
|
||||
<p className="text-sm text-secondary-600">Made in Germany 🇩🇪</p>
|
||||
<p className="text-xs text-secondary-500">
|
||||
david@valera-melendez.de
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
56
src/components/layout/Layout.tsx
Normal file
56
src/components/layout/Layout.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Main Layout Component - Application Layout
|
||||
* Resume Builder Application
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-07
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import Navigation from './Navigation';
|
||||
|
||||
/**
|
||||
* Layout props interface
|
||||
*/
|
||||
interface LayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Layout Component - Main Application Layout
|
||||
*
|
||||
* Standard layout component that provides consistent structure across the application.
|
||||
* Includes navigation and main content area with responsive design.
|
||||
*
|
||||
* Key Features:
|
||||
* - Responsive design for all screen sizes
|
||||
* - Integrated navigation system
|
||||
* - Clean content area
|
||||
* - Standard layout patterns
|
||||
*
|
||||
* @param props - Component properties
|
||||
* @param props.children - Page content to be rendered
|
||||
* @returns Main application layout with navigation
|
||||
*/
|
||||
export default function Layout({ children }: LayoutProps) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Navigation */}
|
||||
<Navigation />
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="lg:pl-72">
|
||||
<div className="min-h-screen">
|
||||
<main className="py-6">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
376
src/components/layout/Navigation.tsx
Normal file
376
src/components/layout/Navigation.tsx
Normal file
@@ -0,0 +1,376 @@
|
||||
/**
|
||||
* Navigation Component - Main App Navigation
|
||||
* Resume Builder Application
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-07
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import {
|
||||
User,
|
||||
FileText,
|
||||
Settings,
|
||||
LogOut,
|
||||
Menu,
|
||||
X,
|
||||
Home,
|
||||
Briefcase,
|
||||
GraduationCap,
|
||||
Award,
|
||||
Languages,
|
||||
Eye,
|
||||
Download,
|
||||
Shield,
|
||||
} from 'lucide-react';
|
||||
|
||||
/**
|
||||
* Navigation item interface
|
||||
*/
|
||||
interface NavItem {
|
||||
href: string;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
description?: string;
|
||||
badge?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigation Component - Main Application Navigation
|
||||
*
|
||||
* Standard navigation component with German localization and responsive design.
|
||||
* Features clean routing structure and user-friendly interface.
|
||||
*
|
||||
* Key Features:
|
||||
* - German localization for European market
|
||||
* - Responsive mobile navigation
|
||||
* - Active route highlighting
|
||||
* - Clean user interface
|
||||
* - Standard navigation patterns
|
||||
*
|
||||
* @returns Main navigation component with routing
|
||||
*/
|
||||
export default function Navigation() {
|
||||
const pathname = usePathname();
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
|
||||
/**
|
||||
* Main navigation items
|
||||
*/
|
||||
const navItems: NavItem[] = [
|
||||
{
|
||||
href: '/',
|
||||
label: 'Übersicht',
|
||||
icon: <Home className="h-5 w-5" />,
|
||||
description: 'Übersicht und Start',
|
||||
},
|
||||
{
|
||||
href: '/personal-info',
|
||||
label: 'Persönliche Daten',
|
||||
icon: <User className="h-5 w-5" />,
|
||||
description: 'Name, Kontakt, Adresse',
|
||||
},
|
||||
{
|
||||
href: '/experience',
|
||||
label: 'Berufserfahrung',
|
||||
icon: <Briefcase className="h-5 w-5" />,
|
||||
description: 'Arbeitsstellen und Erfolge',
|
||||
},
|
||||
{
|
||||
href: '/education',
|
||||
label: 'Bildung',
|
||||
icon: <GraduationCap className="h-5 w-5" />,
|
||||
description: 'Ausbildung und Studium',
|
||||
},
|
||||
{
|
||||
href: '/skills',
|
||||
label: 'Fähigkeiten',
|
||||
icon: <Award className="h-5 w-5" />,
|
||||
description: 'Technische und Soft Skills',
|
||||
},
|
||||
{
|
||||
href: '/languages',
|
||||
label: 'Sprachen',
|
||||
icon: <Languages className="h-5 w-5" />,
|
||||
description: 'Sprachkenntnisse',
|
||||
},
|
||||
{
|
||||
href: '/preview',
|
||||
label: 'Vorschau',
|
||||
icon: <Eye className="h-5 w-5" />,
|
||||
description: 'Lebenslauf anzeigen',
|
||||
badge: 'Live',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Secondary navigation items
|
||||
*/
|
||||
const secondaryItems: NavItem[] = [
|
||||
{
|
||||
href: '/export',
|
||||
label: 'Export PDF',
|
||||
icon: <Download className="h-5 w-5" />,
|
||||
description: 'Lebenslauf herunterladen',
|
||||
},
|
||||
{
|
||||
href: '/settings',
|
||||
label: 'Einstellungen',
|
||||
icon: <Settings className="h-5 w-5" />,
|
||||
description: 'App-Konfiguration',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Checks if a route is currently active
|
||||
*/
|
||||
const isActive = (href: string): boolean => {
|
||||
if (href === '/') {
|
||||
return pathname === '/';
|
||||
}
|
||||
return pathname.startsWith(href);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles mobile menu toggle
|
||||
*/
|
||||
const toggleMobileMenu = () => {
|
||||
setIsMobileMenuOpen(!isMobileMenuOpen);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles logout action
|
||||
*/
|
||||
const handleLogout = () => {
|
||||
// Implement logout logic
|
||||
console.log('Logout clicked');
|
||||
window.location.href = '/login';
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Desktop Navigation Sidebar */}
|
||||
<div className="hidden lg:fixed lg:inset-y-0 lg:flex lg:w-72 lg:flex-col">
|
||||
<div className="flex flex-grow flex-col border-r border-gray-200 bg-white shadow-xl">
|
||||
{/* Brand Header */}
|
||||
<div className="flex items-center border-b border-gray-200 px-6 py-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-primary-600 to-primary-700 shadow-sm">
|
||||
<FileText className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-primary-700">
|
||||
David Valera Melendez
|
||||
</h1>
|
||||
<p className="text-xs text-gray-500">Resume Builder</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation Menu */}
|
||||
<nav className="flex-1 space-y-1 overflow-y-auto px-4 py-6">
|
||||
{navItems.map(item => {
|
||||
const active = isActive(item.href);
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href as any}
|
||||
className={`group flex items-center rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-200 ${
|
||||
active
|
||||
? 'border border-primary-200 bg-primary-50 text-primary-700'
|
||||
: 'text-gray-700 hover:bg-gray-50 hover:text-primary-600'
|
||||
} `}
|
||||
>
|
||||
<div
|
||||
className={`mr-3 flex h-6 w-6 items-center justify-center transition-colors ${active ? 'text-primary-600' : 'text-gray-400 group-hover:text-primary-500'} `}
|
||||
>
|
||||
{item.icon}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span>{item.label}</span>
|
||||
{item.badge && (
|
||||
<span className="ml-2 rounded-full bg-primary-100 px-2 py-0.5 text-xs font-semibold text-primary-700">
|
||||
{item.badge}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{item.description && (
|
||||
<p className="mt-0.5 text-xs text-gray-500">
|
||||
{item.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Divider */}
|
||||
<div className="my-4 border-t border-gray-200"></div>
|
||||
|
||||
{/* Secondary Items */}
|
||||
{secondaryItems.map(item => {
|
||||
const active = isActive(item.href);
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href as any}
|
||||
className={`group flex items-center rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-200 ${
|
||||
active
|
||||
? 'border border-primary-200 bg-primary-50 text-primary-700'
|
||||
: 'text-gray-700 hover:bg-gray-50 hover:text-primary-600'
|
||||
} `}
|
||||
>
|
||||
<div
|
||||
className={`mr-3 flex h-6 w-6 items-center justify-center transition-colors ${active ? 'text-primary-600' : 'text-gray-400 group-hover:text-primary-500'} `}
|
||||
>
|
||||
{item.icon}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<span>{item.label}</span>
|
||||
{item.description && (
|
||||
<p className="mt-0.5 text-xs text-gray-500">
|
||||
{item.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* User Profile & Logout */}
|
||||
<div className="flex-shrink-0 border-t border-gray-200 p-4">
|
||||
<div className="mb-3 flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-gradient-to-br from-primary-100 to-primary-200">
|
||||
<User className="h-4 w-4 text-primary-600" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-gray-900">
|
||||
David Valera
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
david@valera-melendez.de
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 hover:text-red-600"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
<span>Abmelden</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex-shrink-0 border-t border-gray-200 px-4 py-3">
|
||||
<div className="flex items-center justify-center gap-2 text-xs text-gray-500">
|
||||
<Shield className="h-3 w-3" />
|
||||
<span>Made in Germany 🇩🇪</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Navigation */}
|
||||
<div className="lg:hidden">
|
||||
{/* Mobile Header */}
|
||||
<div className="border-b border-gray-200 bg-white px-4 py-3 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-primary-600 to-primary-700 shadow-sm">
|
||||
<FileText className="h-4 w-4 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-sm font-bold text-primary-700">
|
||||
David Valera Melendez
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={toggleMobileMenu}
|
||||
className="rounded-lg p-2 transition-colors hover:bg-gray-100"
|
||||
>
|
||||
{isMobileMenuOpen ? (
|
||||
<X className="h-6 w-6 text-gray-600" />
|
||||
) : (
|
||||
<Menu className="h-6 w-6 text-gray-600" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu Overlay */}
|
||||
{isMobileMenuOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-black bg-opacity-50"
|
||||
onClick={toggleMobileMenu}
|
||||
>
|
||||
<div
|
||||
className="fixed inset-y-0 left-0 w-72 bg-white shadow-xl"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="p-4">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-primary-600 to-primary-700 shadow-sm">
|
||||
<FileText className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-primary-700">
|
||||
David Valera Melendez
|
||||
</h1>
|
||||
<p className="text-xs text-gray-500">Resume Builder</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={toggleMobileMenu}
|
||||
className="rounded-lg p-2 transition-colors hover:bg-gray-100"
|
||||
>
|
||||
<X className="h-6 w-6 text-gray-600" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav className="space-y-1">
|
||||
{navItems.map(item => {
|
||||
const active = isActive(item.href);
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href as any}
|
||||
onClick={toggleMobileMenu}
|
||||
className={`group flex items-center rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-200 ${
|
||||
active
|
||||
? 'border border-primary-200 bg-primary-50 text-primary-700'
|
||||
: 'text-gray-700 hover:bg-gray-50 hover:text-primary-600'
|
||||
} `}
|
||||
>
|
||||
<div
|
||||
className={`mr-3 flex h-6 w-6 items-center justify-center transition-colors ${active ? 'text-primary-600' : 'text-gray-400 group-hover:text-primary-500'} `}
|
||||
>
|
||||
{item.icon}
|
||||
</div>
|
||||
<span>{item.label}</span>
|
||||
{item.badge && (
|
||||
<span className="ml-auto rounded-full bg-primary-100 px-2 py-0.5 text-xs font-semibold text-primary-700">
|
||||
{item.badge}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
171
src/components/navigation/HorizontalStepper.tsx
Normal file
171
src/components/navigation/HorizontalStepper.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Horizontal Stepper Header Component
|
||||
* Professional Resume Builder
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-07
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Step configuration interface for stepper component
|
||||
*/
|
||||
export interface Step {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
completed: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props interface for HorizontalStepper component
|
||||
*/
|
||||
interface HorizontalStepperProps {
|
||||
steps: Step[];
|
||||
currentStep: string;
|
||||
onStepChange: (stepId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* HorizontalStepper Component - Professional multi-step navigation
|
||||
*
|
||||
* Displays a horizontal progress stepper with visual indicators for completion status.
|
||||
* Features responsive design with mobile-optimized layout and accessibility support.
|
||||
*
|
||||
* @param props - Component properties
|
||||
* @param props.steps - Array of step configurations with completion status
|
||||
* @param props.currentStep - ID of the currently active step
|
||||
* @param props.onStepChange - Callback function when user clicks on a step
|
||||
* @returns Horizontal stepper navigation component with progress tracking
|
||||
*/
|
||||
export default function HorizontalStepper({
|
||||
steps,
|
||||
currentStep,
|
||||
onStepChange,
|
||||
}: HorizontalStepperProps) {
|
||||
/**
|
||||
* Calculates the current step index for progress display
|
||||
* @returns The zero-based index of the current step
|
||||
*/
|
||||
const currentStepIndex = steps.findIndex(step => step.id === currentStep);
|
||||
|
||||
return (
|
||||
<header className="bg-white">
|
||||
<div className="px-4 py-4">
|
||||
{/* Header Title - Compact */}
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-secondary-900">
|
||||
Resume Builder
|
||||
</h1>
|
||||
<p className="text-xs text-secondary-600">Made in Germany 🇩🇪</p>
|
||||
</div>
|
||||
<div className="rounded bg-secondary-50 px-2 py-1 text-xs text-secondary-500">
|
||||
Step {currentStepIndex + 1} of {steps.length}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Horizontal Progress Stepper */}
|
||||
<div className="relative">
|
||||
{/* Progress Line */}
|
||||
<div className="absolute left-0 right-0 top-4 h-0.5 bg-secondary-200">
|
||||
<div
|
||||
className="h-full bg-primary-500 transition-all duration-500"
|
||||
style={{
|
||||
width: `${(currentStepIndex / (steps.length - 1)) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Steps */}
|
||||
<div className="relative flex justify-between">
|
||||
{steps.map((step, index) => {
|
||||
const isActive = step.id === currentStep;
|
||||
const isCompleted = step.completed;
|
||||
const isPast = index < currentStepIndex;
|
||||
const isFuture = index > currentStepIndex && !isCompleted;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={step.id}
|
||||
onClick={() => onStepChange(step.id)}
|
||||
disabled={isFuture}
|
||||
className={`flex min-w-0 flex-col items-center transition-all duration-300 ${
|
||||
isFuture
|
||||
? 'cursor-not-allowed'
|
||||
: 'hover:scale-105 hover:transform'
|
||||
}`}
|
||||
>
|
||||
{/* Step Circle - Ultra Compact */}
|
||||
<div
|
||||
className={`mb-1 flex h-8 w-8 items-center justify-center rounded-full text-xs font-semibold transition-all duration-300 ${
|
||||
isCompleted
|
||||
? 'bg-primary-500 text-white shadow-md'
|
||||
: isActive
|
||||
? 'border-2 border-primary-500 bg-primary-100 text-primary-700 shadow-sm'
|
||||
: isFuture
|
||||
? 'border border-secondary-200 bg-secondary-100 text-secondary-400'
|
||||
: 'border border-primary-300 bg-primary-200 text-primary-600'
|
||||
}`}
|
||||
>
|
||||
{isCompleted ? (
|
||||
<span className="text-xs font-bold text-white">✓</span>
|
||||
) : (
|
||||
<span className="text-sm">{step.icon}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Step Label - Title Only */}
|
||||
<div className="text-center">
|
||||
<div
|
||||
className={`text-xs font-medium transition-colors ${
|
||||
isActive
|
||||
? 'text-primary-700'
|
||||
: isCompleted
|
||||
? 'text-secondary-900'
|
||||
: isFuture
|
||||
? 'text-secondary-400'
|
||||
: 'text-secondary-600'
|
||||
}`}
|
||||
>
|
||||
{step.title}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active Pulse Effect */}
|
||||
{isActive && (
|
||||
<div className="absolute -left-1 -top-1 h-14 w-14 animate-ping rounded-full bg-primary-200 opacity-20" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Overall Progress Bar */}
|
||||
<div className="mt-6">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-sm text-secondary-600">Overall Progress</span>
|
||||
<span className="text-sm font-semibold text-secondary-900">
|
||||
{Math.round(
|
||||
(steps.filter(s => s.completed).length / steps.length) * 100
|
||||
)}
|
||||
%
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 w-full rounded-full bg-secondary-200">
|
||||
<div
|
||||
className="h-2 rounded-full bg-gradient-to-r from-primary-500 to-primary-600 transition-all duration-500"
|
||||
style={{
|
||||
width: `${(steps.filter(s => s.completed).length / steps.length) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
377
src/components/navigation/Navigation.tsx
Normal file
377
src/components/navigation/Navigation.tsx
Normal file
@@ -0,0 +1,377 @@
|
||||
/**
|
||||
* Navigation Component - Main App Navigation
|
||||
* Resume Builder Application
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-07
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import {
|
||||
User,
|
||||
FileText,
|
||||
Settings,
|
||||
LogOut,
|
||||
Menu,
|
||||
X,
|
||||
Home,
|
||||
Briefcase,
|
||||
GraduationCap,
|
||||
Award,
|
||||
Languages,
|
||||
Eye,
|
||||
Download,
|
||||
Shield,
|
||||
} from 'lucide-react';
|
||||
|
||||
/**
|
||||
* Navigation item interface
|
||||
*/
|
||||
interface NavItem {
|
||||
href: string;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
description?: string;
|
||||
badge?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigation Component - Main Application Navigation
|
||||
*
|
||||
* Standard navigation component with German localization and responsive design.
|
||||
* Features clean routing structure and user-friendly interface.
|
||||
*
|
||||
* Key Features:
|
||||
* - German localization for European market
|
||||
* - Responsive mobile navigation
|
||||
* - Active route highlighting
|
||||
* - Clean user interface
|
||||
* - Standard navigation patterns
|
||||
*
|
||||
* @returns Main navigation component with routing
|
||||
*/
|
||||
export default function Navigation() {
|
||||
const pathname = usePathname();
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
|
||||
/**
|
||||
* Main navigation items
|
||||
*/
|
||||
const navItems: NavItem[] = [
|
||||
{
|
||||
href: '/',
|
||||
label: 'Dashboard',
|
||||
icon: <Home className="h-5 w-5" />,
|
||||
description: 'Übersicht und Start',
|
||||
},
|
||||
{
|
||||
href: '/personal-info',
|
||||
label: 'Persönliche Daten',
|
||||
icon: <User className="h-5 w-5" />,
|
||||
description: 'Name, Kontakt, Adresse',
|
||||
},
|
||||
{
|
||||
href: '/experience',
|
||||
label: 'Berufserfahrung',
|
||||
icon: <Briefcase className="h-5 w-5" />,
|
||||
description: 'Arbeitsstellen und Erfolge',
|
||||
},
|
||||
{
|
||||
href: '/education',
|
||||
label: 'Bildung',
|
||||
icon: <GraduationCap className="h-5 w-5" />,
|
||||
description: 'Ausbildung und Studium',
|
||||
},
|
||||
{
|
||||
href: '/skills',
|
||||
label: 'Fähigkeiten',
|
||||
icon: <Award className="h-5 w-5" />,
|
||||
description: 'Technische und Soft Skills',
|
||||
},
|
||||
{
|
||||
href: '/languages',
|
||||
label: 'Sprachen',
|
||||
icon: <Languages className="h-5 w-5" />,
|
||||
description: 'Sprachkenntnisse',
|
||||
},
|
||||
{
|
||||
href: '/preview',
|
||||
label: 'Vorschau',
|
||||
icon: <Eye className="h-5 w-5" />,
|
||||
description: 'Lebenslauf anzeigen',
|
||||
badge: 'Live',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Secondary navigation items
|
||||
*/
|
||||
const secondaryItems: NavItem[] = [
|
||||
{
|
||||
href: '/export',
|
||||
label: 'Export PDF',
|
||||
icon: <Download className="h-5 w-5" />,
|
||||
description: 'Lebenslauf herunterladen',
|
||||
},
|
||||
{
|
||||
href: '/settings',
|
||||
label: 'Einstellungen',
|
||||
icon: <Settings className="h-5 w-5" />,
|
||||
description: 'App-Konfiguration',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Checks if a route is currently active
|
||||
*/
|
||||
const isActive = (href: string): boolean => {
|
||||
if (href === '/') {
|
||||
return pathname === '/';
|
||||
}
|
||||
return pathname.startsWith(href);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles mobile menu toggle
|
||||
*/
|
||||
const toggleMobileMenu = () => {
|
||||
setIsMobileMenuOpen(!isMobileMenuOpen);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles logout action
|
||||
*/
|
||||
const handleLogout = () => {
|
||||
// Here you would implement logout logic
|
||||
console.log('Logout clicked');
|
||||
// For now, redirect to login
|
||||
window.location.href = '/login';
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Desktop Navigation Sidebar */}
|
||||
<div className="hidden lg:fixed lg:inset-y-0 lg:flex lg:w-72 lg:flex-col">
|
||||
<div className="flex flex-grow flex-col border-r border-gray-200 bg-white shadow-xl">
|
||||
{/* Brand Header */}
|
||||
<div className="flex items-center border-b border-gray-200 px-6 py-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-primary-600 to-primary-700 shadow-sm">
|
||||
<FileText className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-primary-700">
|
||||
David Valera Melendez
|
||||
</h1>
|
||||
<p className="text-xs text-gray-500">Resume Builder</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation Menu */}
|
||||
<nav className="flex-1 space-y-1 overflow-y-auto px-4 py-6">
|
||||
{navItems.map(item => {
|
||||
const active = isActive(item.href);
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href as any}
|
||||
className={`group flex items-center rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-200 ${
|
||||
active
|
||||
? 'border border-primary-200 bg-primary-50 text-primary-700'
|
||||
: 'text-gray-700 hover:bg-gray-50 hover:text-primary-600'
|
||||
} `}
|
||||
>
|
||||
<div
|
||||
className={`mr-3 flex h-6 w-6 items-center justify-center transition-colors ${active ? 'text-primary-600' : 'text-gray-400 group-hover:text-primary-500'} `}
|
||||
>
|
||||
{item.icon}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span>{item.label}</span>
|
||||
{item.badge && (
|
||||
<span className="ml-2 rounded-full bg-primary-100 px-2 py-0.5 text-xs font-semibold text-primary-700">
|
||||
{item.badge}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{item.description && (
|
||||
<p className="mt-0.5 text-xs text-gray-500">
|
||||
{item.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Divider */}
|
||||
<div className="my-4 border-t border-gray-200"></div>
|
||||
|
||||
{/* Secondary Items */}
|
||||
{secondaryItems.map(item => {
|
||||
const active = isActive(item.href);
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href as any}
|
||||
className={`group flex items-center rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-200 ${
|
||||
active
|
||||
? 'border border-primary-200 bg-primary-50 text-primary-700'
|
||||
: 'text-gray-700 hover:bg-gray-50 hover:text-primary-600'
|
||||
} `}
|
||||
>
|
||||
<div
|
||||
className={`mr-3 flex h-6 w-6 items-center justify-center transition-colors ${active ? 'text-primary-600' : 'text-gray-400 group-hover:text-primary-500'} `}
|
||||
>
|
||||
{item.icon}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<span>{item.label}</span>
|
||||
{item.description && (
|
||||
<p className="mt-0.5 text-xs text-gray-500">
|
||||
{item.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* User Profile & Logout */}
|
||||
<div className="flex-shrink-0 border-t border-gray-200 p-4">
|
||||
<div className="mb-3 flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-gradient-to-br from-primary-100 to-primary-200">
|
||||
<User className="h-4 w-4 text-primary-600" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-gray-900">
|
||||
David Valera
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
david@valera-melendez.de
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 hover:text-red-600"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
<span>Abmelden</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex-shrink-0 border-t border-gray-200 px-4 py-3">
|
||||
<div className="flex items-center justify-center gap-2 text-xs text-gray-500">
|
||||
<Shield className="h-3 w-3" />
|
||||
<span>Made in Germany 🇩🇪</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Navigation */}
|
||||
<div className="lg:hidden">
|
||||
{/* Mobile Header */}
|
||||
<div className="border-b border-gray-200 bg-white px-4 py-3 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-primary-600 to-primary-700 shadow-sm">
|
||||
<FileText className="h-4 w-4 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-sm font-bold text-primary-700">
|
||||
David Valera Melendez
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={toggleMobileMenu}
|
||||
className="rounded-lg p-2 transition-colors hover:bg-gray-100"
|
||||
>
|
||||
{isMobileMenuOpen ? (
|
||||
<X className="h-6 w-6 text-gray-600" />
|
||||
) : (
|
||||
<Menu className="h-6 w-6 text-gray-600" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu Overlay */}
|
||||
{isMobileMenuOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-black bg-opacity-50"
|
||||
onClick={toggleMobileMenu}
|
||||
>
|
||||
<div
|
||||
className="fixed inset-y-0 left-0 w-72 bg-white shadow-xl"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="p-4">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-primary-600 to-primary-700 shadow-sm">
|
||||
<FileText className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-primary-700">
|
||||
David Valera Melendez
|
||||
</h1>
|
||||
<p className="text-xs text-gray-500">Resume Builder</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={toggleMobileMenu}
|
||||
className="rounded-lg p-2 transition-colors hover:bg-gray-100"
|
||||
>
|
||||
<X className="h-6 w-6 text-gray-600" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav className="space-y-1">
|
||||
{navItems.map(item => {
|
||||
const active = isActive(item.href);
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href as any}
|
||||
onClick={toggleMobileMenu}
|
||||
className={`group flex items-center rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-200 ${
|
||||
active
|
||||
? 'border border-primary-200 bg-primary-50 text-primary-700'
|
||||
: 'text-gray-700 hover:bg-gray-50 hover:text-primary-600'
|
||||
} `}
|
||||
>
|
||||
<div
|
||||
className={`mr-3 flex h-6 w-6 items-center justify-center transition-colors ${active ? 'text-primary-600' : 'text-gray-400 group-hover:text-primary-500'} `}
|
||||
>
|
||||
{item.icon}
|
||||
</div>
|
||||
<span>{item.label}</span>
|
||||
{item.badge && (
|
||||
<span className="ml-auto rounded-full bg-primary-100 px-2 py-0.5 text-xs font-semibold text-primary-700">
|
||||
{item.badge}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
147
src/components/navigation/StepNavigation.tsx
Normal file
147
src/components/navigation/StepNavigation.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Step Navigation Component
|
||||
* Professional Resume Builder - Enhanced with resume-example.com design patterns
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-07
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { ModernButton } from '@/components/ui/ModernButton';
|
||||
|
||||
interface StepNavigationProps {
|
||||
onPrevious?: () => void;
|
||||
onNext?: () => void;
|
||||
onSave?: () => void;
|
||||
isFirstStep?: boolean;
|
||||
isLastStep?: boolean;
|
||||
canProceed?: boolean;
|
||||
isLoading?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const StepNavigation: React.FC<StepNavigationProps> = ({
|
||||
onPrevious,
|
||||
onNext,
|
||||
onSave,
|
||||
isFirstStep = false,
|
||||
isLastStep = false,
|
||||
canProceed = true,
|
||||
isLoading = false,
|
||||
className = '',
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={`border-border-primary flex items-center justify-between border-t pt-6 ${className}`}
|
||||
>
|
||||
<div>
|
||||
{!isFirstStep && onPrevious && (
|
||||
<ModernButton
|
||||
variant="outline"
|
||||
size="lg"
|
||||
onClick={onPrevious}
|
||||
disabled={isLoading}
|
||||
className="min-w-32"
|
||||
>
|
||||
<svg
|
||||
className="mr-2 h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
Previous
|
||||
</ModernButton>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{onSave && (
|
||||
<ModernButton
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
onClick={onSave}
|
||||
disabled={isLoading}
|
||||
className="min-w-24"
|
||||
>
|
||||
<svg
|
||||
className="mr-2 h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3-3m0 0l-3 3m3-3v12"
|
||||
/>
|
||||
</svg>
|
||||
Save
|
||||
</ModernButton>
|
||||
)}
|
||||
|
||||
{isLastStep
|
||||
? onNext && (
|
||||
<ModernButton
|
||||
variant="primary"
|
||||
size="lg"
|
||||
onClick={onNext}
|
||||
disabled={!canProceed || isLoading}
|
||||
loading={isLoading}
|
||||
className="min-w-32"
|
||||
>
|
||||
<svg
|
||||
className="mr-2 h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
Complete
|
||||
</ModernButton>
|
||||
)
|
||||
: onNext && (
|
||||
<ModernButton
|
||||
variant="primary"
|
||||
size="lg"
|
||||
onClick={onNext}
|
||||
disabled={!canProceed || isLoading}
|
||||
loading={isLoading}
|
||||
className="min-w-32"
|
||||
>
|
||||
Continue
|
||||
<svg
|
||||
className="ml-2 h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</ModernButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
168
src/components/navigation/StepperNavigation.tsx
Normal file
168
src/components/navigation/StepperNavigation.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* Stepper Navigation Component
|
||||
* Professional Resume Builder
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-07
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
// Using Unicode checkmark instead of external icon
|
||||
|
||||
export interface Step {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
completed: boolean;
|
||||
}
|
||||
|
||||
interface StepperNavigationProps {
|
||||
steps: Step[];
|
||||
currentStep: string;
|
||||
onStepChange: (stepId: string) => void;
|
||||
}
|
||||
|
||||
export default function StepperNavigation({
|
||||
steps,
|
||||
currentStep,
|
||||
onStepChange,
|
||||
}: StepperNavigationProps) {
|
||||
const currentStepIndex = steps.findIndex(step => step.id === currentStep);
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-md p-6">
|
||||
<div className="mb-8">
|
||||
<h1 className="mb-2 text-3xl font-bold text-secondary-900">
|
||||
Lebenslauf-Builder
|
||||
</h1>
|
||||
<p className="text-secondary-600">
|
||||
Erstellen Sie Ihren professionellen Lebenslauf Schritt für Schritt
|
||||
</p>
|
||||
<div className="mt-4 text-sm text-secondary-500">
|
||||
Schritt {currentStepIndex + 1} von {steps.length}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{steps.map((step, index) => {
|
||||
const isActive = step.id === currentStep;
|
||||
const isCompleted = step.completed;
|
||||
const isPast = index < currentStepIndex;
|
||||
const isFuture = index > currentStepIndex;
|
||||
|
||||
return (
|
||||
<div key={step.id} className="relative">
|
||||
{/* Connection Line */}
|
||||
{index < steps.length - 1 && (
|
||||
<div
|
||||
className={`absolute left-6 top-12 h-16 w-0.5 ${
|
||||
isPast || isCompleted
|
||||
? 'bg-primary-500'
|
||||
: 'bg-secondary-200'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Step Item */}
|
||||
<button
|
||||
onClick={() => onStepChange(step.id)}
|
||||
className={`flex w-full items-start space-x-4 rounded-lg p-3 text-left transition-all duration-300 hover:bg-secondary-50 ${
|
||||
isActive ? 'border border-primary-200 bg-primary-50' : ''
|
||||
}`}
|
||||
disabled={isFuture && !isCompleted}
|
||||
>
|
||||
{/* Step Circle */}
|
||||
<div
|
||||
className={`flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full text-sm font-semibold transition-all duration-300 ${
|
||||
isCompleted
|
||||
? 'bg-primary-500 text-white'
|
||||
: isActive
|
||||
? 'border-2 border-primary-500 bg-primary-100 text-primary-700'
|
||||
: isFuture
|
||||
? 'border border-secondary-200 bg-secondary-100 text-secondary-400'
|
||||
: 'bg-secondary-200 text-secondary-600'
|
||||
}`}
|
||||
>
|
||||
{isCompleted ? (
|
||||
<span className="text-white">✓</span>
|
||||
) : (
|
||||
<span className="text-xl">{step.icon}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Step Content */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3
|
||||
className={`mb-1 text-base font-semibold transition-colors ${
|
||||
isActive
|
||||
? 'text-primary-700'
|
||||
: isCompleted
|
||||
? 'text-secondary-900'
|
||||
: isFuture
|
||||
? 'text-secondary-400'
|
||||
: 'text-secondary-700'
|
||||
}`}
|
||||
>
|
||||
{step.title}
|
||||
</h3>
|
||||
<p
|
||||
className={`text-sm leading-relaxed ${
|
||||
isActive
|
||||
? 'text-primary-600'
|
||||
: isCompleted
|
||||
? 'text-secondary-600'
|
||||
: isFuture
|
||||
? 'text-secondary-400'
|
||||
: 'text-secondary-500'
|
||||
}`}
|
||||
>
|
||||
{step.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Active Indicator */}
|
||||
{isActive && (
|
||||
<div className="h-2 w-2 flex-shrink-0 animate-pulse rounded-full bg-primary-500" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="mt-8">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-sm text-secondary-600">Overall Progress</span>
|
||||
<span className="text-sm font-semibold text-secondary-900">
|
||||
{Math.round(
|
||||
(steps.filter(s => s.completed).length / steps.length) * 100
|
||||
)}
|
||||
%
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 w-full rounded-full bg-secondary-200">
|
||||
<div
|
||||
className="h-2 rounded-full bg-gradient-to-r from-primary-500 to-primary-600 transition-all duration-500"
|
||||
style={{
|
||||
width: `${(steps.filter(s => s.completed).length / steps.length) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer Info */}
|
||||
<div className="mt-8 border-t border-secondary-200 pt-6">
|
||||
<div className="text-center">
|
||||
<p className="mb-1 text-xs text-secondary-500">
|
||||
Created by David Valera Melendez
|
||||
</p>
|
||||
<p className="text-xs text-secondary-400">Made in Germany 🇩🇪</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
119
src/components/resume/ResumeBuilder.tsx
Normal file
119
src/components/resume/ResumeBuilder.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Resume Builder Component
|
||||
* Professional Resume Builder
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-07
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { ResumeData } from '@/types/resume';
|
||||
import PersonalInfoForm from './forms/PersonalInfoForm';
|
||||
import ExperienceForm from './forms/ExperienceForm';
|
||||
import EducationForm from './forms/EducationForm';
|
||||
import SkillsForm from './forms/SkillsForm';
|
||||
|
||||
interface ResumeBuilderProps {
|
||||
resumeData: ResumeData;
|
||||
onDataUpdate: (data: Partial<ResumeData>) => void;
|
||||
}
|
||||
|
||||
type Section = 'personal' | 'experience' | 'education' | 'skills' | 'languages' | 'certifications' | 'projects';
|
||||
|
||||
export default function ResumeBuilder({ resumeData, onDataUpdate }: ResumeBuilderProps) {
|
||||
const [activeSection, setActiveSection] = useState<Section>('personal');
|
||||
|
||||
const sections = [
|
||||
{ id: 'personal' as Section, name: 'Persönliche Daten', icon: '👤' },
|
||||
{ id: 'experience' as Section, name: 'Berufserfahrung', icon: '💼' },
|
||||
{ id: 'education' as Section, name: 'Ausbildung', icon: '🎓' },
|
||||
{ id: 'skills' as Section, name: 'Fähigkeiten', icon: '⚡' },
|
||||
{ id: 'languages' as Section, name: 'Sprachen', icon: '🌍' },
|
||||
{ id: 'certifications' as Section, name: 'Zertifikate', icon: '🏆' },
|
||||
{ id: 'projects' as Section, name: 'Projekte', icon: '🚀' },
|
||||
];
|
||||
|
||||
const renderSectionContent = () => {
|
||||
switch (activeSection) {
|
||||
case 'personal':
|
||||
return (
|
||||
<PersonalInfoForm
|
||||
data={resumeData.personalInfo}
|
||||
onChange={(personalInfo) => onDataUpdate({ personalInfo })}
|
||||
/>
|
||||
);
|
||||
case 'experience':
|
||||
return (
|
||||
<ExperienceForm
|
||||
data={resumeData.experience}
|
||||
onChange={(experience) => onDataUpdate({ experience })}
|
||||
/>
|
||||
);
|
||||
case 'education':
|
||||
return (
|
||||
<EducationForm
|
||||
data={resumeData.education}
|
||||
onChange={(education) => onDataUpdate({ education })}
|
||||
/>
|
||||
);
|
||||
case 'skills':
|
||||
return (
|
||||
<SkillsForm
|
||||
data={resumeData.skills}
|
||||
onChange={(skills) => onDataUpdate({ skills })}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<div className="text-4xl mb-4">🚧</div>
|
||||
<h3 className="text-lg font-semibold text-secondary-700 mb-2">
|
||||
Coming Soon
|
||||
</h3>
|
||||
<p className="text-secondary-500">
|
||||
This section is under development by David Valera Melendez
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Section Navigation */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-2">
|
||||
{sections.map((section) => (
|
||||
<button
|
||||
key={section.id}
|
||||
onClick={() => setActiveSection(section.id)}
|
||||
className={`p-3 rounded-lg text-left transition-all duration-200 ${
|
||||
activeSection === section.id
|
||||
? 'bg-primary-100 border-2 border-primary-300 text-primary-800'
|
||||
: 'bg-secondary-50 border border-secondary-200 text-secondary-700 hover:bg-secondary-100'
|
||||
}`}
|
||||
>
|
||||
<div className="text-lg mb-1">{section.icon}</div>
|
||||
<div className="text-sm font-medium">{section.name}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Active Section Content */}
|
||||
<div className="bg-secondary-50 rounded-lg p-6">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-xl font-bold text-secondary-900 mb-1">
|
||||
{sections.find(s => s.id === activeSection)?.name}
|
||||
</h3>
|
||||
<p className="text-sm text-secondary-600">
|
||||
Fill in your information below
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{renderSectionContent()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
263
src/components/resume/ResumePreview.tsx
Normal file
263
src/components/resume/ResumePreview.tsx
Normal file
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* Resume Preview Component
|
||||
* Professional Resume Builder
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-07
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { ResumeData } from '@/types/resume';
|
||||
|
||||
/**
|
||||
* Props interface for ResumePreview component
|
||||
*/
|
||||
interface ResumePreviewProps {
|
||||
resumeData: ResumeData;
|
||||
}
|
||||
|
||||
/**
|
||||
* ResumePreview Component - Professional CV Preview with Print Support
|
||||
*
|
||||
* Renders a complete, professional resume preview with modern European format.
|
||||
* Features optimized typography, layout, and print-friendly styling for PDF export.
|
||||
*
|
||||
* Key Features:
|
||||
* - Professional typography with proper hierarchy
|
||||
* - Modern European CV layout
|
||||
* - Print-optimized styling
|
||||
* - Responsive design for various screen sizes
|
||||
* - ATS-friendly formatting
|
||||
* - Dynamic content rendering based on available data
|
||||
*
|
||||
* @param props - Component properties
|
||||
* @param props.resumeData - Complete resume data object to render
|
||||
* @returns Professional resume preview component ready for display and print
|
||||
*/
|
||||
export default function ResumePreview({ resumeData }: ResumePreviewProps) {
|
||||
/**
|
||||
* Destructures resume data for cleaner component code
|
||||
*/
|
||||
const {
|
||||
personalInfo,
|
||||
experience,
|
||||
education,
|
||||
skills,
|
||||
languages,
|
||||
certifications,
|
||||
projects,
|
||||
} = resumeData;
|
||||
|
||||
return (
|
||||
<div className="origin-center scale-90 transform">
|
||||
{/* Two Column Layout with Fixed Width but Flexible Height */}
|
||||
<div className="flex min-h-[600px] w-[650px] bg-white shadow-lg">
|
||||
{/* Left Column - Dark Sidebar (1/3 width) */}
|
||||
<div className="flex w-1/3 flex-col bg-gray-900 p-6 text-white">
|
||||
{/* Profile Image Placeholder */}
|
||||
<div className="mb-6 flex justify-center">
|
||||
<div className="flex h-32 w-32 items-center justify-center rounded-lg border-2 border-gray-600 bg-gray-800 text-xs text-gray-400">
|
||||
Profile Image
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Technical Skills */}
|
||||
{skills.length > 0 && (
|
||||
<div className="mb-8">
|
||||
<h2 className="mb-4 border-b border-gray-600 pb-2 text-sm font-bold uppercase tracking-wide text-white">
|
||||
Technische Fähigkeiten
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
{Array.from(new Set(skills.map(skill => skill.category))).map(
|
||||
category => (
|
||||
<div key={category}>
|
||||
<h3 className="mb-2 break-words text-xs font-semibold text-gray-300">
|
||||
{category}
|
||||
</h3>
|
||||
<p className="break-words text-xs leading-relaxed text-gray-400">
|
||||
{skills
|
||||
.filter(skill => skill.category === category)
|
||||
.map(skill => skill.name)
|
||||
.join(', ')}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Languages */}
|
||||
{languages && languages.length > 0 && (
|
||||
<div className="mb-8">
|
||||
<h2 className="mb-4 border-b border-gray-600 pb-2 text-sm font-bold uppercase tracking-wide text-white">
|
||||
Sprachen
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{languages.map((lang, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span className="mr-2 flex-1 break-words text-xs font-medium text-white">
|
||||
{lang.name}
|
||||
</span>
|
||||
<span className="flex-shrink-0 rounded bg-gray-800 px-2 py-1 text-xs text-blue-400">
|
||||
{lang.proficiency}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Personal Information */}
|
||||
<div className="mb-8">
|
||||
<h2 className="mb-4 border-b border-gray-600 pb-2 text-sm font-bold uppercase tracking-wide text-white">
|
||||
Persönliche Daten
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{personalInfo.dateOfBirth && (
|
||||
<div className="text-xs">
|
||||
<div className="mb-1 text-gray-300">Geburtsdatum</div>
|
||||
<div className="font-medium text-white">
|
||||
{personalInfo.dateOfBirth}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{personalInfo.nationality && (
|
||||
<div className="text-xs">
|
||||
<div className="mb-1 text-gray-300">Nationalität</div>
|
||||
<div className="font-medium text-white">
|
||||
{personalInfo.nationality}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{personalInfo.maritalStatus && (
|
||||
<div className="text-xs">
|
||||
<div className="mb-1 text-gray-300">Familienstand</div>
|
||||
<div className="font-medium text-white">
|
||||
{personalInfo.maritalStatus}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column - Main Content (2/3 width) */}
|
||||
<div className="flex w-2/3 flex-col bg-white p-6">
|
||||
{/* Header Section */}
|
||||
<div className="mb-6 border-b-2 border-primary-600 pb-4">
|
||||
<h1 className="mb-2 break-words text-xl font-bold uppercase leading-tight tracking-wide text-primary-600">
|
||||
{personalInfo.firstName} {personalInfo.lastName}
|
||||
</h1>
|
||||
{personalInfo.jobTitle && (
|
||||
<p className="mb-4 break-words text-sm font-medium text-gray-600">
|
||||
{personalInfo.jobTitle}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-1 text-xs text-gray-500">
|
||||
{personalInfo.email && (
|
||||
<div className="flex items-center gap-1 break-words">
|
||||
<span className="flex-shrink-0">📧</span>{' '}
|
||||
<span className="break-all">{personalInfo.email}</span>
|
||||
</div>
|
||||
)}
|
||||
{personalInfo.phone && (
|
||||
<div className="flex items-center gap-1 break-words">
|
||||
<span className="flex-shrink-0">📱</span>{' '}
|
||||
<span>{personalInfo.phone}</span>
|
||||
</div>
|
||||
)}
|
||||
{personalInfo.location && (
|
||||
<div className="flex items-center gap-1 break-words">
|
||||
<span className="flex-shrink-0">🏠</span>{' '}
|
||||
<span>{personalInfo.location}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Experience Section */}
|
||||
<div className="mb-8">
|
||||
<h2 className="mb-4 border-b border-gray-300 pb-2 text-sm font-bold uppercase tracking-wide text-primary-600">
|
||||
Berufserfahrung
|
||||
</h2>
|
||||
{experience && experience.length > 0 && (
|
||||
<div className="space-y-6">
|
||||
{experience.map((exp, index) => (
|
||||
<div key={index} className="resume-item">
|
||||
<div className="mb-2 flex items-start justify-between">
|
||||
<div className="mr-4 flex-1">
|
||||
<h3 className="break-words text-xs font-semibold text-gray-900">
|
||||
{exp.position}
|
||||
</h3>
|
||||
<p className="break-words text-xs font-medium text-primary-600">
|
||||
{exp.company}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-shrink-0 text-right text-xs text-gray-500">
|
||||
{exp.startDate} - {exp.endDate || 'Heute'}
|
||||
</div>
|
||||
</div>
|
||||
{exp.description && (
|
||||
<p className="mb-2 break-words text-xs text-gray-600">
|
||||
{exp.description}
|
||||
</p>
|
||||
)}
|
||||
{exp.achievements && exp.achievements.length > 0 && (
|
||||
<ul className="ml-4 list-outside list-disc space-y-1 text-xs text-gray-500">
|
||||
{exp.achievements.map((achievement, i) => (
|
||||
<li key={i} className="break-words">
|
||||
{achievement}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Education Section */}
|
||||
<div className="mb-8">
|
||||
<h2 className="mb-4 border-b border-gray-300 pb-2 text-sm font-bold uppercase tracking-wide text-primary-600">
|
||||
Bildung
|
||||
</h2>
|
||||
{education && education.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{education.map((edu, index) => (
|
||||
<div key={index} className="resume-item">
|
||||
<div className="mb-2">
|
||||
<h3 className="break-words text-xs font-semibold text-gray-900">
|
||||
{edu.degree}
|
||||
</h3>
|
||||
<p className="break-words text-xs font-medium text-primary-600">
|
||||
{edu.institution}
|
||||
</p>
|
||||
{(edu.startDate || edu.endDate) && (
|
||||
<p className="text-xs text-gray-500">
|
||||
{edu.startDate} - {edu.endDate}
|
||||
</p>
|
||||
)}
|
||||
{edu.description && (
|
||||
<p className="mt-1 break-words text-xs text-gray-500">
|
||||
{edu.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
279
src/components/resume/StepContentRenderer.tsx
Normal file
279
src/components/resume/StepContentRenderer.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* Step Content Renderer Component
|
||||
* Professional Resume Builder
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-07
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import PersonalInfoForm from '@/components/resume/forms/PersonalInfoForm';
|
||||
import ExperienceForm from '@/components/resume/forms/ExperienceForm';
|
||||
import EducationForm from '@/components/resume/forms/EducationForm';
|
||||
import SkillsForm from '@/components/resume/forms/SkillsForm';
|
||||
import { ResumeData } from '@/types/resume';
|
||||
|
||||
/**
|
||||
* Props interface for StepContentRenderer component
|
||||
*/
|
||||
interface StepContentRendererProps {
|
||||
currentStep: string;
|
||||
resumeData: ResumeData;
|
||||
onDataUpdate: (data: Partial<ResumeData>) => void;
|
||||
onNext: () => void;
|
||||
onPrevious: () => void;
|
||||
isFirstStep: boolean;
|
||||
isLastStep: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* StepContentRenderer Component - Dynamic form renderer for resume steps
|
||||
*
|
||||
* Renders the appropriate form component based on the current step in the resume builder.
|
||||
* Handles data flow between step forms and provides navigation controls with validation.
|
||||
*
|
||||
* @param props - Component properties
|
||||
* @param props.currentStep - ID of the currently active step
|
||||
* @param props.resumeData - Complete resume data object
|
||||
* @param props.onDataUpdate - Callback to update resume data
|
||||
* @param props.onNext - Callback to navigate to next step
|
||||
* @param props.onPrevious - Callback to navigate to previous step
|
||||
* @param props.isFirstStep - Boolean indicating if current step is the first
|
||||
* @param props.isLastStep - Boolean indicating if current step is the last
|
||||
* @returns Dynamic form component with navigation controls
|
||||
*/
|
||||
export default function StepContentRenderer({
|
||||
currentStep,
|
||||
resumeData,
|
||||
onDataUpdate,
|
||||
onNext,
|
||||
onPrevious,
|
||||
isFirstStep,
|
||||
isLastStep,
|
||||
}: StepContentRendererProps) {
|
||||
/**
|
||||
* Renders the appropriate form component based on current step
|
||||
* @returns JSX element for the current step's form
|
||||
*/
|
||||
const renderStepContent = () => {
|
||||
switch (currentStep) {
|
||||
case 'contact':
|
||||
return (
|
||||
<PersonalInfoForm
|
||||
data={resumeData.personalInfo}
|
||||
onChange={personalInfo => onDataUpdate({ personalInfo })}
|
||||
/>
|
||||
);
|
||||
case 'experience':
|
||||
return (
|
||||
<ExperienceForm
|
||||
data={resumeData.experience}
|
||||
onChange={experience => onDataUpdate({ experience })}
|
||||
/>
|
||||
);
|
||||
case 'education':
|
||||
return (
|
||||
<EducationForm
|
||||
data={resumeData.education}
|
||||
onChange={education => onDataUpdate({ education })}
|
||||
/>
|
||||
);
|
||||
case 'skills':
|
||||
return (
|
||||
<SkillsForm
|
||||
data={resumeData.skills}
|
||||
onChange={skills => onDataUpdate({ skills })}
|
||||
/>
|
||||
);
|
||||
case 'about':
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="card p-6">
|
||||
<h2 className="mb-4 text-xl font-semibold text-secondary-900">
|
||||
Professional Summary
|
||||
</h2>
|
||||
<textarea
|
||||
className="input min-h-[120px]"
|
||||
placeholder="Write a brief professional summary about yourself..."
|
||||
value={resumeData.personalInfo.summary || ''}
|
||||
onChange={e =>
|
||||
onDataUpdate({
|
||||
personalInfo: {
|
||||
...resumeData.personalInfo,
|
||||
summary: e.target.value,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="card p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold text-secondary-900">
|
||||
Additional Information
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="label">Languages</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
placeholder="English (Native), Spanish (C1), German (B2)"
|
||||
value={resumeData.languages
|
||||
.map(lang => `${lang.name} (${lang.proficiency})`)
|
||||
.join(', ')}
|
||||
onChange={e => {
|
||||
const languageStrings = e.target.value
|
||||
.split(',')
|
||||
.map(s => s.trim());
|
||||
const languages = languageStrings
|
||||
.filter(s => s)
|
||||
.map((langStr, index) => {
|
||||
const match = langStr.match(/^(.+)\s*\((.+)\)$/);
|
||||
return {
|
||||
id: Date.now().toString() + index,
|
||||
name: match ? match[1].trim() : langStr,
|
||||
proficiency: (match ? match[2].trim() : 'B2') as
|
||||
| 'A1'
|
||||
| 'A2'
|
||||
| 'B1'
|
||||
| 'B2'
|
||||
| 'C1'
|
||||
| 'C2'
|
||||
| 'Native',
|
||||
};
|
||||
});
|
||||
onDataUpdate({ languages });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Certifications</label>
|
||||
<textarea
|
||||
className="input min-h-[80px]"
|
||||
placeholder="List your certifications, one per line"
|
||||
value={resumeData.certifications
|
||||
.map(cert => cert.name)
|
||||
.join('\n')}
|
||||
onChange={e => {
|
||||
const certLines = e.target.value
|
||||
.split('\n')
|
||||
.filter(line => line.trim());
|
||||
const certifications = certLines.map((name, index) => ({
|
||||
id: Date.now().toString() + index,
|
||||
name: name.trim(),
|
||||
issuer: '',
|
||||
issueDate: new Date().toISOString().split('T')[0],
|
||||
}));
|
||||
onDataUpdate({ certifications });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case 'finish':
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="card p-6 text-center">
|
||||
<div className="mb-4 text-6xl">🎉</div>
|
||||
<h2 className="mb-2 text-2xl font-bold text-secondary-900">
|
||||
Congratulations!
|
||||
</h2>
|
||||
<p className="mb-6 text-secondary-600">
|
||||
Your professional resume is ready! Review it on the right panel
|
||||
and download when you're satisfied.
|
||||
</p>
|
||||
|
||||
<div className="mb-6 rounded-lg border border-primary-200 bg-primary-50 p-4">
|
||||
<h3 className="mb-2 font-semibold text-primary-900">
|
||||
Resume Summary
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-secondary-600">
|
||||
Experience entries:
|
||||
</span>
|
||||
<span className="ml-2 font-semibold">
|
||||
{resumeData.experience.length}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-secondary-600">
|
||||
Education entries:
|
||||
</span>
|
||||
<span className="ml-2 font-semibold">
|
||||
{resumeData.education.length}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-secondary-600">Skills:</span>
|
||||
<span className="ml-2 font-semibold">
|
||||
{resumeData.skills.length}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-secondary-600">Languages:</span>
|
||||
<span className="ml-2 font-semibold">
|
||||
{resumeData.languages.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button className="btn-primary px-8 py-3 text-lg">
|
||||
Download PDF Resume
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return <div>Step not found</div>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-col">
|
||||
{/* Step Content - No separate scrolling */}
|
||||
<div className="px-4 py-2">{renderStepContent()}</div>
|
||||
|
||||
{/* Navigation Buttons - Compact Footer */}
|
||||
<div className="mt-auto border-t border-secondary-200 bg-white px-4 py-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={onPrevious}
|
||||
disabled={isFirstStep}
|
||||
className={`rounded-lg px-3 py-1.5 text-sm font-medium transition-colors ${
|
||||
isFirstStep
|
||||
? 'cursor-not-allowed bg-secondary-100 text-secondary-400'
|
||||
: 'bg-secondary-200 text-secondary-700 hover:bg-secondary-300'
|
||||
}`}
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-secondary-500">
|
||||
Press Enter to continue
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onNext}
|
||||
disabled={isLastStep}
|
||||
className={`rounded-lg px-3 py-1.5 text-sm font-medium transition-colors ${
|
||||
isLastStep
|
||||
? 'cursor-not-allowed bg-secondary-100 text-secondary-400'
|
||||
: 'bg-primary-500 text-white hover:bg-primary-600'
|
||||
}`}
|
||||
>
|
||||
{isLastStep ? 'Finished' : 'Next'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
224
src/components/resume/forms/EducationForm.tsx
Normal file
224
src/components/resume/forms/EducationForm.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* Education Form Component
|
||||
* Professional Resume Builder
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-07
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Education } from '@/types/resume';
|
||||
|
||||
/**
|
||||
* Props interface for EducationForm component
|
||||
*/
|
||||
interface EducationFormProps {
|
||||
data: Education[];
|
||||
onChange: (data: Education[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* EducationForm Component - Professional Education History Management
|
||||
*
|
||||
* Comprehensive form component for managing educational background entries in modern CV format.
|
||||
* Features academic degree tracking, institution details, and professional educational formatting.
|
||||
*
|
||||
* Key Features:
|
||||
* - Add, edit, and delete education entries
|
||||
* - Academic system compliance
|
||||
* - Degree, institution, and location tracking
|
||||
* - Academic achievement and GPA recording
|
||||
* - Date validation and chronological ordering
|
||||
* - Professional education formatting
|
||||
*
|
||||
* @param props - Component properties
|
||||
* @param props.data - Array of education entries
|
||||
* @param props.onChange - Callback function to handle education data changes
|
||||
* @returns Professional education history management form
|
||||
*/
|
||||
export default function EducationForm({ data, onChange }: EducationFormProps) {
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
|
||||
/**
|
||||
* Creates a new education entry and sets it for editing
|
||||
*/
|
||||
const handleAdd = () => {
|
||||
const newEducation: Education = {
|
||||
id: Date.now().toString(),
|
||||
degree: '',
|
||||
institution: '',
|
||||
location: '',
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
gpa: '',
|
||||
description: '',
|
||||
};
|
||||
onChange([...data, newEducation]);
|
||||
setEditingId(newEducation.id);
|
||||
};
|
||||
|
||||
const handleUpdate = (id: string, updates: Partial<Education>) => {
|
||||
onChange(data.map(edu => (edu.id === id ? { ...edu, ...updates } : edu)));
|
||||
};
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
onChange(data.filter(edu => edu.id !== id));
|
||||
if (editingId === id) setEditingId(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-secondary-900">Ausbildung</h3>
|
||||
<button onClick={handleAdd} className="btn-primary text-sm">
|
||||
+ Ausbildung hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{data.length === 0 ? (
|
||||
<div className="py-8 text-center text-secondary-500">
|
||||
<div className="mb-2 text-4xl">🎓</div>
|
||||
<p>Noch keine Ausbildung hinzugefügt</p>
|
||||
<p className="text-sm">Klicken Sie auf "Ausbildung hinzufügen", um zu beginnen</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{data.map(education => (
|
||||
<div key={education.id} className="card p-4">
|
||||
<div className="mb-4 flex items-start justify-between">
|
||||
<h4 className="font-semibold text-secondary-900">
|
||||
{education.degree || 'Neuer Abschluss'}
|
||||
{education.institution && ` an ${education.institution}`}
|
||||
</h4>
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() =>
|
||||
setEditingId(
|
||||
editingId === education.id ? null : education.id
|
||||
)
|
||||
}
|
||||
className="text-sm text-primary-600 hover:text-primary-700"
|
||||
>
|
||||
{editingId === education.id ? 'Fertig' : 'Bearbeiten'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(education.id)}
|
||||
className="text-sm text-red-600 hover:text-red-700"
|
||||
>
|
||||
Löschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editingId === education.id && (
|
||||
<div className="space-y-4 border-t pt-4">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="label">Abschluss/Studiengang *</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
value={education.degree}
|
||||
onChange={e =>
|
||||
handleUpdate(education.id, { degree: e.target.value })
|
||||
}
|
||||
placeholder="Bachelor of Science in Informatik"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Institution *</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
value={education.institution}
|
||||
onChange={e =>
|
||||
handleUpdate(education.id, {
|
||||
institution: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="Universitätsname"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||
<div>
|
||||
<label className="label">Standort</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
value={education.location}
|
||||
onChange={e =>
|
||||
handleUpdate(education.id, {
|
||||
location: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="Stadt, Land"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Startdatum</label>
|
||||
<input
|
||||
type="month"
|
||||
className="input"
|
||||
value={education.startDate}
|
||||
onChange={e =>
|
||||
handleUpdate(education.id, {
|
||||
startDate: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Enddatum</label>
|
||||
<input
|
||||
type="month"
|
||||
className="input"
|
||||
value={education.endDate || ''}
|
||||
onChange={e =>
|
||||
handleUpdate(education.id, {
|
||||
endDate: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Notendurchschnitt (Optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
value={education.gpa || ''}
|
||||
onChange={e =>
|
||||
handleUpdate(education.id, { gpa: e.target.value })
|
||||
}
|
||||
placeholder="1,5 oder 3.8/4.0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Beschreibung (Optional)</label>
|
||||
<textarea
|
||||
className="textarea"
|
||||
rows={3}
|
||||
value={education.description || ''}
|
||||
onChange={e =>
|
||||
handleUpdate(education.id, {
|
||||
description: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="Relevante Kurse, Auszeichnungen, Erfolge..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
325
src/components/resume/forms/ExperienceForm.tsx
Normal file
325
src/components/resume/forms/ExperienceForm.tsx
Normal file
@@ -0,0 +1,325 @@
|
||||
/**
|
||||
* Experience Form Component
|
||||
* Professional Resume Builder
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-07
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Experience } from '@/types/resume';
|
||||
|
||||
/**
|
||||
* Props interface for ExperienceForm component
|
||||
*/
|
||||
interface ExperienceFormProps {
|
||||
data: Experience[];
|
||||
onChange: (data: Experience[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* ExperienceForm Component - Professional Work Experience Management
|
||||
*
|
||||
* Comprehensive form component for managing work experience entries in modern CV format.
|
||||
* Features CRUD operations, validation, and professional formatting with achievements tracking.
|
||||
*
|
||||
* Key Features:
|
||||
* - Add, edit, and delete work experience entries
|
||||
* - Professional CV format compliance
|
||||
* - Achievement and responsibility tracking
|
||||
* - Date validation and formatting
|
||||
* - Real-time updates and state management
|
||||
* - Responsive form layout
|
||||
*
|
||||
* @param props - Component properties
|
||||
* @param props.data - Array of work experience entries
|
||||
* @param props.onChange - Callback function to handle experience data changes
|
||||
* @returns Professional work experience management form
|
||||
*/
|
||||
export default function ExperienceForm({
|
||||
data,
|
||||
onChange,
|
||||
}: ExperienceFormProps) {
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
|
||||
/**
|
||||
* Creates a new work experience entry and sets it for editing
|
||||
*/
|
||||
const handleAdd = () => {
|
||||
const newExperience: Experience = {
|
||||
id: Date.now().toString(),
|
||||
position: '',
|
||||
company: '',
|
||||
location: '',
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
isCurrentPosition: false,
|
||||
description: '',
|
||||
achievements: [],
|
||||
};
|
||||
onChange([...data, newExperience]);
|
||||
setEditingId(newExperience.id);
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates an existing experience entry with partial data
|
||||
* @param id - The ID of the experience entry to update
|
||||
* @param updates - Partial experience object with updates
|
||||
*/
|
||||
const handleUpdate = (id: string, updates: Partial<Experience>) => {
|
||||
onChange(data.map(exp => (exp.id === id ? { ...exp, ...updates } : exp)));
|
||||
};
|
||||
|
||||
/**
|
||||
* Removes an experience entry from the data array
|
||||
* @param id - The ID of the experience entry to delete
|
||||
*/
|
||||
const handleDelete = (id: string) => {
|
||||
onChange(data.filter(exp => exp.id !== id));
|
||||
if (editingId === id) setEditingId(null);
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds a new empty achievement to an experience entry
|
||||
* @param id - The ID of the experience entry to add an achievement to
|
||||
*/
|
||||
const handleAddAchievement = (id: string) => {
|
||||
const experience = data.find(exp => exp.id === id);
|
||||
if (experience) {
|
||||
handleUpdate(id, {
|
||||
achievements: [...experience.achievements, ''],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateAchievement = (
|
||||
id: string,
|
||||
index: number,
|
||||
value: string
|
||||
) => {
|
||||
const experience = data.find(exp => exp.id === id);
|
||||
if (experience) {
|
||||
const achievements = [...experience.achievements];
|
||||
achievements[index] = value;
|
||||
handleUpdate(id, { achievements });
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveAchievement = (id: string, index: number) => {
|
||||
const experience = data.find(exp => exp.id === id);
|
||||
if (experience) {
|
||||
const achievements = experience.achievements.filter(
|
||||
(_, i) => i !== index
|
||||
);
|
||||
handleUpdate(id, { achievements });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-secondary-900">
|
||||
Berufserfahrung
|
||||
</h3>
|
||||
<button onClick={handleAdd} className="btn-primary text-sm">
|
||||
+ Erfahrung hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{data.length === 0 ? (
|
||||
<div className="py-8 text-center text-secondary-500">
|
||||
<div className="mb-2 text-4xl">💼</div>
|
||||
<p>Noch keine Berufserfahrung hinzugefügt</p>
|
||||
<p className="text-sm">Klicken Sie auf "Erfahrung hinzufügen", um zu beginnen</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{data.map(experience => (
|
||||
<div key={experience.id} className="card p-4">
|
||||
<div className="mb-4 flex items-start justify-between">
|
||||
<h4 className="font-semibold text-secondary-900">
|
||||
{experience.position || 'Neue Position'}
|
||||
{experience.company && ` bei ${experience.company}`}
|
||||
</h4>
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() =>
|
||||
setEditingId(
|
||||
editingId === experience.id ? null : experience.id
|
||||
)
|
||||
}
|
||||
className="text-sm text-primary-600 hover:text-primary-700"
|
||||
>
|
||||
{editingId === experience.id ? 'Fertig' : 'Bearbeiten'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(experience.id)}
|
||||
className="text-sm text-red-600 hover:text-red-700"
|
||||
>
|
||||
Löschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editingId === experience.id && (
|
||||
<div className="space-y-4 border-t pt-4">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="label">Position *</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
value={experience.position}
|
||||
onChange={e =>
|
||||
handleUpdate(experience.id, {
|
||||
position: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="Software-Entwickler"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Unternehmen *</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
value={experience.company}
|
||||
onChange={e =>
|
||||
handleUpdate(experience.id, {
|
||||
company: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="Firmenname"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<div>
|
||||
<label className="label">Standort</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
value={experience.location}
|
||||
onChange={e =>
|
||||
handleUpdate(experience.id, {
|
||||
location: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="Stadt, Land"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Startdatum</label>
|
||||
<input
|
||||
type="month"
|
||||
className="input"
|
||||
value={experience.startDate}
|
||||
onChange={e =>
|
||||
handleUpdate(experience.id, {
|
||||
startDate: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Enddatum</label>
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="month"
|
||||
className="input"
|
||||
value={experience.endDate || ''}
|
||||
onChange={e =>
|
||||
handleUpdate(experience.id, {
|
||||
endDate: e.target.value,
|
||||
})
|
||||
}
|
||||
disabled={experience.isCurrentPosition}
|
||||
/>
|
||||
<label className="flex items-center text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mr-2"
|
||||
checked={experience.isCurrentPosition}
|
||||
onChange={e =>
|
||||
handleUpdate(experience.id, {
|
||||
isCurrentPosition: e.target.checked,
|
||||
endDate: e.target.checked
|
||||
? null
|
||||
: experience.endDate,
|
||||
})
|
||||
}
|
||||
/>
|
||||
Aktuelle Position
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Tätigkeitsbeschreibung</label>
|
||||
<textarea
|
||||
className="textarea"
|
||||
rows={3}
|
||||
value={experience.description}
|
||||
onChange={e =>
|
||||
handleUpdate(experience.id, {
|
||||
description: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="Kurze Beschreibung Ihrer Rolle und Verantwortlichkeiten..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<label className="label">Wichtige Erfolge</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleAddAchievement(experience.id)}
|
||||
className="text-sm text-primary-600 hover:text-primary-700"
|
||||
>
|
||||
+ Erfolg hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{experience.achievements.map((achievement, index) => (
|
||||
<div key={index} className="flex space-x-2">
|
||||
<input
|
||||
type="text"
|
||||
className="input flex-1"
|
||||
value={achievement}
|
||||
onChange={e =>
|
||||
handleUpdateAchievement(
|
||||
experience.id,
|
||||
index,
|
||||
e.target.value
|
||||
)
|
||||
}
|
||||
placeholder="Beschreiben Sie einen wichtigen Erfolg..."
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleRemoveAchievement(experience.id, index)
|
||||
}
|
||||
className="px-2 text-red-600 hover:text-red-700"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
463
src/components/resume/forms/PersonalInfoForm.tsx
Normal file
463
src/components/resume/forms/PersonalInfoForm.tsx
Normal file
@@ -0,0 +1,463 @@
|
||||
/**
|
||||
* Personal Info Form Component - Professional CV Format
|
||||
* Professional Resume Builder - Enhanced with modern CV standards
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-07
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { PersonalInfo } from '@/types/resume';
|
||||
import { ModernCard } from '@/components/ui/ModernCard';
|
||||
import { ModernInput } from '@/components/ui/ModernInput';
|
||||
import {
|
||||
User,
|
||||
Mail,
|
||||
Phone,
|
||||
MapPin,
|
||||
Globe,
|
||||
Linkedin,
|
||||
Github,
|
||||
Upload,
|
||||
Calendar,
|
||||
Flag,
|
||||
Heart,
|
||||
Shield,
|
||||
Briefcase,
|
||||
Camera,
|
||||
} from 'lucide-react';
|
||||
|
||||
// Import organized data from centralized data folder
|
||||
import {
|
||||
nationalityOptions,
|
||||
maritalStatusOptions,
|
||||
visaStatusOptions,
|
||||
} from '@/data/formOptions';
|
||||
import { placeholders } from '@/data/uiConstants';
|
||||
|
||||
/**
|
||||
* Props interface for PersonalInfoForm component
|
||||
*/
|
||||
interface PersonalInfoFormProps {
|
||||
data: PersonalInfo;
|
||||
onChange: (data: PersonalInfo) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* PersonalInfoForm Component - Professional Personal Information Form
|
||||
*
|
||||
* Comprehensive form component for collecting personal information according to
|
||||
* modern CV standards. Features profile image upload, European address format,
|
||||
* and professional personal details with validation.
|
||||
*
|
||||
* Key Features:
|
||||
* - Profile image upload with drag-and-drop support
|
||||
* - European address format (Street, ZIP, City, Country)
|
||||
* - Personal details (nationality, birth date, marital status, visa status)
|
||||
* - Professional links (website, LinkedIn, GitHub)
|
||||
* - Real-time validation and error handling
|
||||
*
|
||||
* @param props - Component properties
|
||||
* @param props.data - Current personal information data
|
||||
* @param props.onChange - Callback function to handle data changes
|
||||
* @returns Professional personal information form with modern CV format
|
||||
*/
|
||||
export default function PersonalInfoForm({
|
||||
data,
|
||||
onChange,
|
||||
}: PersonalInfoFormProps) {
|
||||
/**
|
||||
* Handles input field changes and updates the data state
|
||||
*
|
||||
* @param field - The field name to update
|
||||
* @param value - The new value for the field
|
||||
*/
|
||||
const handleChange = (field: keyof PersonalInfo, value: string) => {
|
||||
onChange({ ...data, [field]: value });
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles profile image file selection
|
||||
* Converts selected file to base64 string for storage
|
||||
*
|
||||
* @param event - File input change event
|
||||
*/
|
||||
const handleImageChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = e => {
|
||||
const result = e.target?.result as string;
|
||||
handleChange('profileImage', result);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Job Title and Profile Image Section */}
|
||||
<ModernCard variant="elevated" padding="sm">
|
||||
<div className="mb-3 flex items-center gap-3 border-b border-grayscale-200 pb-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-primary-100 to-primary-200 shadow-sm">
|
||||
<Briefcase className="h-4 w-4 text-primary-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-text-primary">
|
||||
Berufliche Informationen
|
||||
</h2>
|
||||
<p className="text-xs text-text-secondary">
|
||||
Berufsbezeichnung und Profilbild
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{/* Job Title */}
|
||||
<div className="md:col-span-2">
|
||||
<ModernInput
|
||||
label="Berufsbezeichnung"
|
||||
type="text"
|
||||
value={data.jobTitle || ''}
|
||||
onChange={e => handleChange('jobTitle', e.target.value)}
|
||||
placeholder={placeholders.jobTitle}
|
||||
leftIcon={<Briefcase className="h-3 w-3" />}
|
||||
variant="filled"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Profile Image Upload */}
|
||||
<div className="md:col-span-2">
|
||||
<label className="mb-1 block text-xs font-medium text-text-primary">
|
||||
Profilbild
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
{data.profileImage && (
|
||||
<div className="h-10 w-10 overflow-hidden rounded-full bg-grayscale-100">
|
||||
<img
|
||||
src={data.profileImage}
|
||||
alt="Profile"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<label className="hover:bg-primary-25 flex w-full cursor-pointer items-center justify-center rounded-lg border-2 border-dashed border-grayscale-300 px-2 py-2 transition-colors hover:border-primary-500">
|
||||
<div className="flex items-center gap-1 text-xs text-grayscale-600">
|
||||
<Upload className="h-3 w-3" />
|
||||
<span>Profilbild hochladen</span>
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleImageChange}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModernCard>
|
||||
|
||||
{/* Personal Information Section */}
|
||||
<ModernCard variant="elevated" padding="sm">
|
||||
<div className="mb-3 flex items-center gap-3 border-b border-grayscale-200 pb-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-primary-100 to-primary-200 shadow-sm">
|
||||
<User className="h-4 w-4 text-primary-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-text-primary">
|
||||
Persönliche Informationen
|
||||
</h2>
|
||||
<p className="text-xs text-text-secondary">
|
||||
Ihr Name und grundlegende Details
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
{/* Basic Information */}
|
||||
<ModernInput
|
||||
label="Vorname"
|
||||
type="text"
|
||||
value={data.firstName}
|
||||
onChange={e => handleChange('firstName', e.target.value)}
|
||||
placeholder={placeholders.firstName}
|
||||
leftIcon={<User className="h-3 w-3" />}
|
||||
variant="filled"
|
||||
required
|
||||
/>
|
||||
|
||||
<ModernInput
|
||||
label="Nachname"
|
||||
type="text"
|
||||
value={data.lastName}
|
||||
onChange={e => handleChange('lastName', e.target.value)}
|
||||
placeholder={placeholders.lastName}
|
||||
leftIcon={<User className="h-3 w-3" />}
|
||||
variant="filled"
|
||||
required
|
||||
/>
|
||||
|
||||
{/* Contact Information */}
|
||||
<ModernInput
|
||||
label="E-Mail"
|
||||
type="email"
|
||||
value={data.email}
|
||||
onChange={e => handleChange('email', e.target.value)}
|
||||
placeholder={placeholders.email}
|
||||
leftIcon={<Mail className="h-3 w-3" />}
|
||||
variant="filled"
|
||||
required
|
||||
/>
|
||||
|
||||
<ModernInput
|
||||
label="Telefon"
|
||||
type="tel"
|
||||
value={data.phone}
|
||||
onChange={e => handleChange('phone', e.target.value)}
|
||||
placeholder={placeholders.phone}
|
||||
leftIcon={<Phone className="h-3 w-3" />}
|
||||
variant="filled"
|
||||
/>
|
||||
|
||||
{/* Address Section */}
|
||||
<div className="mt-3 border-t border-grayscale-200 pt-3 md:col-span-2">
|
||||
<div className="mb-3 flex items-center gap-3 border-b border-grayscale-200 pb-2">
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-lg bg-gradient-to-br from-primary-100 to-primary-200 shadow-sm">
|
||||
<MapPin className="h-3 w-3 text-primary-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-text-primary">
|
||||
Adresse
|
||||
</h3>
|
||||
<p className="text-xs text-text-secondary">
|
||||
Ihre Standortdaten
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
{/* Street and House Number on same line */}
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
<div className="col-span-3">
|
||||
<ModernInput
|
||||
label="Straße"
|
||||
type="text"
|
||||
value={data.street || ''}
|
||||
onChange={e => handleChange('street', e.target.value)}
|
||||
placeholder="Musterstraße"
|
||||
leftIcon={<MapPin className="h-3 w-3" />}
|
||||
variant="filled"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="col-span-1">
|
||||
<ModernInput
|
||||
label="Hausnummer"
|
||||
type="text"
|
||||
value={data.houseNumber || ''}
|
||||
onChange={e => handleChange('houseNumber', e.target.value)}
|
||||
placeholder="123"
|
||||
variant="filled"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ZIP Code and City on same line */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<ModernInput
|
||||
label="PLZ"
|
||||
type="text"
|
||||
value={data.zipCode || ''}
|
||||
onChange={e => handleChange('zipCode', e.target.value)}
|
||||
placeholder="12345"
|
||||
leftIcon={<MapPin className="h-3 w-3" />}
|
||||
variant="filled"
|
||||
/>
|
||||
|
||||
<ModernInput
|
||||
label="Stadt"
|
||||
type="text"
|
||||
value={data.city}
|
||||
onChange={e => handleChange('city', e.target.value)}
|
||||
placeholder="Berlin"
|
||||
leftIcon={<MapPin className="h-3 w-3" />}
|
||||
variant="filled"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Country on separate line */}
|
||||
<ModernInput
|
||||
label="Land"
|
||||
type="text"
|
||||
value={data.country || ''}
|
||||
onChange={e => handleChange('country', e.target.value)}
|
||||
placeholder="Deutschland"
|
||||
leftIcon={<Flag className="h-3 w-3" />}
|
||||
variant="filled"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Online Presence Section */}
|
||||
<div className="mt-3 border-t border-grayscale-200 pt-3 md:col-span-2">
|
||||
<div className="mb-3 flex items-center gap-3 border-b border-grayscale-200 pb-2">
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-lg bg-gradient-to-br from-primary-100 to-primary-200 shadow-sm">
|
||||
<Globe className="h-3 w-3 text-primary-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-text-primary">
|
||||
Online-Präsenz
|
||||
</h3>
|
||||
<p className="text-xs text-text-secondary">
|
||||
Ihre professionellen Links
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<ModernInput
|
||||
label="Website"
|
||||
type="url"
|
||||
value={data.website || ''}
|
||||
onChange={e => handleChange('website', e.target.value)}
|
||||
placeholder="https://ihre-website.com"
|
||||
leftIcon={<Globe className="h-3 w-3" />}
|
||||
variant="filled"
|
||||
/>
|
||||
|
||||
<ModernInput
|
||||
label="LinkedIn"
|
||||
type="text"
|
||||
value={data.linkedin || ''}
|
||||
onChange={e => handleChange('linkedin', e.target.value)}
|
||||
placeholder="linkedin.com/in/ihr-profil"
|
||||
leftIcon={<Linkedin className="h-3 w-3" />}
|
||||
variant="filled"
|
||||
/>
|
||||
|
||||
<ModernInput
|
||||
label="GitHub"
|
||||
type="text"
|
||||
value={data.github || ''}
|
||||
onChange={e => handleChange('github', e.target.value)}
|
||||
placeholder="github.com/yourusername"
|
||||
leftIcon={<Github className="h-3 w-3" />}
|
||||
variant="filled"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Personal Details */}
|
||||
<div className="mt-3 border-t border-grayscale-200 pt-3 md:col-span-2">
|
||||
<div className="mb-3 flex items-center gap-3 border-b border-grayscale-200 pb-2">
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-lg bg-gradient-to-br from-primary-100 to-primary-200 shadow-sm">
|
||||
<Shield className="h-3 w-3 text-primary-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-text-primary">
|
||||
Personal Details
|
||||
</h3>
|
||||
<p className="text-xs text-text-secondary">
|
||||
Additional personal information
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
{/* Nationality */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold tracking-wide text-text-primary">
|
||||
Nationality
|
||||
</label>
|
||||
<div className="mt-2">
|
||||
<select
|
||||
value={data.nationality || ''}
|
||||
onChange={e => handleChange('nationality', e.target.value)}
|
||||
className="w-full rounded-lg border border-grayscale-300 px-2 py-2 text-xs transition-colors focus:border-primary-500 focus:ring-1 focus:ring-primary-100"
|
||||
>
|
||||
<option value="">Select nationality</option>
|
||||
{nationalityOptions.map(option => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Date of Birth */}
|
||||
<ModernInput
|
||||
label="Date of Birth"
|
||||
type="date"
|
||||
value={data.dateOfBirth || ''}
|
||||
onChange={e => handleChange('dateOfBirth', e.target.value)}
|
||||
leftIcon={<Calendar className="h-3 w-3" />}
|
||||
variant="filled"
|
||||
/>
|
||||
|
||||
{/* Place of Birth */}
|
||||
<ModernInput
|
||||
label="Place of Birth"
|
||||
type="text"
|
||||
value={data.placeOfBirth || ''}
|
||||
onChange={e => handleChange('placeOfBirth', e.target.value)}
|
||||
placeholder="Berlin, Germany"
|
||||
leftIcon={<MapPin className="h-3 w-3" />}
|
||||
variant="filled"
|
||||
/>
|
||||
|
||||
{/* Marital Status */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold tracking-wide text-text-primary">
|
||||
Marital Status
|
||||
</label>
|
||||
<div className="mt-2">
|
||||
<select
|
||||
value={data.maritalStatus || ''}
|
||||
onChange={e =>
|
||||
handleChange('maritalStatus', e.target.value)
|
||||
}
|
||||
className="w-full rounded-lg border border-grayscale-300 px-2 py-2 text-xs transition-colors focus:border-primary-500 focus:ring-1 focus:ring-primary-100"
|
||||
>
|
||||
<option value="">Select marital status</option>
|
||||
{maritalStatusOptions.map(option => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Visa Status */}
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-semibold tracking-wide text-text-primary">
|
||||
Visa Status
|
||||
</label>
|
||||
<div className="mt-2">
|
||||
<select
|
||||
value={data.visaStatus || ''}
|
||||
onChange={e => handleChange('visaStatus', e.target.value)}
|
||||
className="w-full rounded-lg border border-grayscale-300 px-2 py-2 text-xs transition-colors focus:border-primary-500 focus:ring-1 focus:ring-primary-100"
|
||||
>
|
||||
<option value="">Select visa status</option>
|
||||
{visaStatusOptions.map(option => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModernCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
250
src/components/resume/forms/SkillsForm.tsx
Normal file
250
src/components/resume/forms/SkillsForm.tsx
Normal file
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* Skills Form Component
|
||||
* Professional Resume Builder
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-07
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Skill } from '@/types/resume';
|
||||
|
||||
// Import organized data from centralized data folder
|
||||
import { skillCategories, proficiencyLevels } from '@/data/formOptions';
|
||||
import {
|
||||
placeholders,
|
||||
emptyStateMessages,
|
||||
buttonTexts,
|
||||
} from '@/data/uiConstants';
|
||||
|
||||
/**
|
||||
* Props interface for SkillsForm component
|
||||
*/
|
||||
interface SkillsFormProps {
|
||||
data: Skill[];
|
||||
onChange: (data: Skill[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* SkillsForm Component - Professional Skills Management
|
||||
*
|
||||
* Comprehensive form component for managing technical and soft skills with proficiency levels.
|
||||
* Features skill categorization, level assessment, and professional organization for modern CVs.
|
||||
*
|
||||
* Key Features:
|
||||
* - Add, edit, and delete skills with proficiency levels
|
||||
* - Skill categorization (Technical, Soft Skills, Languages, etc.)
|
||||
* - Visual proficiency indicators
|
||||
* - Professional CV format compliance
|
||||
* - Real-time skill management
|
||||
* - Category-based organization
|
||||
*
|
||||
* @param props - Component properties
|
||||
* @param props.data - Array of skill entries
|
||||
* @param props.onChange - Callback function to handle skills data changes
|
||||
* @returns Professional skills management form with categorization
|
||||
*/
|
||||
export default function SkillsForm({ data, onChange }: SkillsFormProps) {
|
||||
/**
|
||||
* State for managing new skill input form
|
||||
*/
|
||||
const [newSkill, setNewSkill] = useState<{
|
||||
name: string;
|
||||
category: string;
|
||||
level: Skill['level'];
|
||||
}>({
|
||||
name: '',
|
||||
category: '',
|
||||
level: 'Intermediate',
|
||||
});
|
||||
|
||||
// Use proficiency levels from centralized data (map to Skill interface format)
|
||||
const levels: Skill['level'][] = [
|
||||
'Beginner',
|
||||
'Intermediate',
|
||||
'Advanced',
|
||||
'Expert',
|
||||
];
|
||||
|
||||
const handleAdd = () => {
|
||||
if (newSkill.name.trim() && newSkill.category.trim()) {
|
||||
const skill: Skill = {
|
||||
id: Date.now().toString(),
|
||||
name: newSkill.name.trim(),
|
||||
category: newSkill.category.trim(),
|
||||
level: newSkill.level,
|
||||
};
|
||||
onChange([...data, skill]);
|
||||
setNewSkill({ name: '', category: '', level: 'Intermediate' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
onChange(data.filter(skill => skill.id !== id));
|
||||
};
|
||||
|
||||
const handleUpdate = (id: string, updates: Partial<Skill>) => {
|
||||
onChange(
|
||||
data.map(skill => (skill.id === id ? { ...skill, ...updates } : skill))
|
||||
);
|
||||
};
|
||||
|
||||
const groupedSkills = data.reduce(
|
||||
(acc, skill) => {
|
||||
if (!acc[skill.category]) {
|
||||
acc[skill.category] = [];
|
||||
}
|
||||
acc[skill.category].push(skill);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, Skill[]>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Add New Skill */}
|
||||
<div className="card border border-primary-200 bg-primary-50 p-4">
|
||||
<h3 className="mb-4 text-lg font-semibold text-secondary-900">
|
||||
Neue Fähigkeit hinzufügen
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||
<div>
|
||||
<label className="label">Fähigkeitsname *</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
value={newSkill.name}
|
||||
onChange={e => setNewSkill({ ...newSkill, name: e.target.value })}
|
||||
placeholder={placeholders.skillName}
|
||||
onKeyPress={e => e.key === 'Enter' && handleAdd()}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Kategorie *</label>
|
||||
<select
|
||||
className="input"
|
||||
value={newSkill.category}
|
||||
onChange={e =>
|
||||
setNewSkill({ ...newSkill, category: e.target.value })
|
||||
}
|
||||
>
|
||||
<option value="">Kategorie auswählen</option>
|
||||
{skillCategories.map(category => (
|
||||
<option key={category} value={category}>
|
||||
{category}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Kenntnisstand</label>
|
||||
<select
|
||||
className="input"
|
||||
value={newSkill.level}
|
||||
onChange={e =>
|
||||
setNewSkill({
|
||||
...newSkill,
|
||||
level: e.target.value as Skill['level'],
|
||||
})
|
||||
}
|
||||
>
|
||||
{levels.map(level => (
|
||||
<option key={level} value={level}>
|
||||
{level}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
className="btn-primary w-full"
|
||||
disabled={!newSkill.name.trim() || !newSkill.category.trim()}
|
||||
>
|
||||
Fähigkeit hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Skills Display */}
|
||||
{Object.keys(groupedSkills).length === 0 ? (
|
||||
<div className="py-8 text-center text-secondary-500">
|
||||
<div className="mb-2 text-4xl">⚡</div>
|
||||
<p>Noch keine Fähigkeiten hinzugefügt</p>
|
||||
<p className="text-sm">Fügen Sie Ihre erste Fähigkeit oben hinzu</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{Object.entries(groupedSkills).map(([category, skills]) => (
|
||||
<div key={category} className="card p-4">
|
||||
<h4 className="mb-3 text-lg font-semibold text-secondary-900">
|
||||
{category}
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||
{skills.map(skill => (
|
||||
<div
|
||||
key={skill.id}
|
||||
className="flex items-center justify-between rounded-lg bg-secondary-50 p-3"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-secondary-900">
|
||||
{skill.name}
|
||||
</div>
|
||||
<div className="text-sm text-secondary-600">
|
||||
{skill.level}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<select
|
||||
className="rounded border border-secondary-300 px-2 py-1 text-xs"
|
||||
value={skill.level}
|
||||
onChange={e =>
|
||||
handleUpdate(skill.id, {
|
||||
level: e.target.value as Skill['level'],
|
||||
})
|
||||
}
|
||||
>
|
||||
{levels.map(level => (
|
||||
<option key={level} value={level}>
|
||||
{level}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={() => handleDelete(skill.id)}
|
||||
className="p-1 text-sm text-red-600 hover:text-red-700"
|
||||
title="Fähigkeit löschen"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-lg border border-secondary-200 bg-secondary-50 p-4">
|
||||
<div className="flex items-start space-x-3">
|
||||
<div className="text-secondary-600">💡</div>
|
||||
<div>
|
||||
<h4 className="mb-1 text-sm font-semibold text-secondary-900">
|
||||
Empfehlung für Fähigkeiten
|
||||
</h4>
|
||||
<p className="text-sm text-secondary-700">
|
||||
Konzentrieren Sie sich auf Fähigkeiten, die für Ihre Zielposition relevant sind.
|
||||
Fügen Sie sowohl technische als auch soziale Kompetenzen hinzu. Seien Sie ehrlich
|
||||
bezüglich Ihrer Kenntnisse - Arbeitgeber schätzen Genauigkeit.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
120
src/components/ui/ModernAvatar.tsx
Normal file
120
src/components/ui/ModernAvatar.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Modern Avatar Component
|
||||
* Professional Resume Builder - Reusable UI Component
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-07
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { User } from 'lucide-react';
|
||||
import { cn } from '@/utils';
|
||||
|
||||
export interface ModernAvatarProps {
|
||||
src?: string;
|
||||
alt?: string;
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl';
|
||||
fallback?: string;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
status?: 'online' | 'offline' | 'away' | 'busy';
|
||||
showStatus?: boolean;
|
||||
}
|
||||
|
||||
export const ModernAvatar: React.FC<ModernAvatarProps> = ({
|
||||
src,
|
||||
alt = 'Avatar',
|
||||
size = 'md',
|
||||
fallback,
|
||||
className = '',
|
||||
onClick,
|
||||
status,
|
||||
showStatus = false,
|
||||
}) => {
|
||||
const [imageError, setImageError] = useState(false);
|
||||
|
||||
const sizeClasses = {
|
||||
xs: 'w-6 h-6',
|
||||
sm: 'w-8 h-8',
|
||||
md: 'w-10 h-10',
|
||||
lg: 'w-12 h-12',
|
||||
xl: 'w-16 h-16',
|
||||
'2xl': 'w-20 h-20',
|
||||
};
|
||||
|
||||
const iconSizeClasses = {
|
||||
xs: 'w-3 h-3',
|
||||
sm: 'w-4 h-4',
|
||||
md: 'w-5 h-5',
|
||||
lg: 'w-6 h-6',
|
||||
xl: 'w-8 h-8',
|
||||
'2xl': 'w-10 h-10',
|
||||
};
|
||||
|
||||
const statusClasses = {
|
||||
online: 'bg-success-500 border-white',
|
||||
offline: 'bg-grayscale-400 border-white',
|
||||
away: 'bg-warning-500 border-white',
|
||||
busy: 'bg-error-500 border-white',
|
||||
};
|
||||
|
||||
const statusSizeClasses = {
|
||||
xs: 'w-2 h-2 border',
|
||||
sm: 'w-2.5 h-2.5 border',
|
||||
md: 'w-3 h-3 border-2',
|
||||
lg: 'w-3.5 h-3.5 border-2',
|
||||
xl: 'w-4 h-4 border-2',
|
||||
'2xl': 'w-5 h-5 border-2',
|
||||
};
|
||||
|
||||
const baseClasses = cn(
|
||||
'relative inline-flex items-center justify-center rounded-full bg-gradient-to-br from-grayscale-100 to-grayscale-200 overflow-hidden transition-all duration-200',
|
||||
sizeClasses[size],
|
||||
onClick && 'cursor-pointer hover:shadow-medium hover:scale-105',
|
||||
className
|
||||
);
|
||||
|
||||
const renderContent = () => {
|
||||
if (src && !imageError) {
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className="h-full w-full object-cover"
|
||||
onError={() => setImageError(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (fallback) {
|
||||
return (
|
||||
<span className="text-grayscale-600 text-sm font-medium">
|
||||
{fallback}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return <User className={cn('text-grayscale-500', iconSizeClasses[size])} />;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={baseClasses} onClick={onClick}>
|
||||
{renderContent()}
|
||||
|
||||
{/* Status indicator */}
|
||||
{showStatus && status && (
|
||||
<span
|
||||
className={cn(
|
||||
'absolute bottom-0 right-0 rounded-full',
|
||||
statusClasses[status],
|
||||
statusSizeClasses[size]
|
||||
)}
|
||||
aria-label={`Status: ${status}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
71
src/components/ui/ModernBadge.tsx
Normal file
71
src/components/ui/ModernBadge.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Modern Badge Component
|
||||
* Professional Resume Builder - Reusable UI Component
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-07
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { cn } from '@/utils';
|
||||
|
||||
export interface ModernBadgeProps {
|
||||
children: React.ReactNode;
|
||||
variant?: 'default' | 'primary' | 'success' | 'warning' | 'error' | 'info';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
rounded?: 'sm' | 'md' | 'lg' | 'full';
|
||||
className?: string;
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const ModernBadge: React.FC<ModernBadgeProps> = ({
|
||||
children,
|
||||
variant = 'default',
|
||||
size = 'md',
|
||||
rounded = 'md',
|
||||
className = '',
|
||||
icon,
|
||||
}) => {
|
||||
const baseClasses =
|
||||
'inline-flex items-center gap-1.5 font-medium transition-all duration-200';
|
||||
|
||||
const variantClasses = {
|
||||
default: 'bg-grayscale-100 text-grayscale-800 border border-grayscale-200',
|
||||
primary: 'bg-primary-100 text-primary-800 border border-primary-200',
|
||||
success: 'bg-success-100 text-success-800 border border-success-200',
|
||||
warning: 'bg-warning-100 text-warning-800 border border-warning-200',
|
||||
error: 'bg-error-100 text-error-800 border border-error-200',
|
||||
info: 'bg-blue-100 text-blue-800 border border-blue-200',
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'px-2 py-1 text-xs',
|
||||
md: 'px-3 py-1.5 text-sm',
|
||||
lg: 'px-4 py-2 text-base',
|
||||
};
|
||||
|
||||
const roundedClasses = {
|
||||
sm: 'rounded-sm',
|
||||
md: 'rounded-md',
|
||||
lg: 'rounded-lg',
|
||||
full: 'rounded-full',
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
baseClasses,
|
||||
variantClasses[variant],
|
||||
sizeClasses[size],
|
||||
roundedClasses[rounded],
|
||||
className
|
||||
)}
|
||||
>
|
||||
{icon && <span className="flex-shrink-0">{icon}</span>}
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
69
src/components/ui/ModernButton.tsx
Normal file
69
src/components/ui/ModernButton.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Modern Button Component
|
||||
* Professional Resume Builder - Enhanced with resume-example.com design patterns
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-07
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
export interface ModernButtonProps {
|
||||
children: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
variant?: 'primary' | 'secondary' | 'outline' | 'ghost';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
className?: string;
|
||||
type?: 'button' | 'submit' | 'reset';
|
||||
}
|
||||
|
||||
export const ModernButton: React.FC<ModernButtonProps> = ({
|
||||
children,
|
||||
onClick,
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
disabled = false,
|
||||
loading = false,
|
||||
className = '',
|
||||
type = 'button',
|
||||
}) => {
|
||||
const baseClasses =
|
||||
'inline-flex items-center justify-center font-semibold transition-all duration-200 ease-out focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed relative overflow-hidden';
|
||||
|
||||
const variantClasses = {
|
||||
primary:
|
||||
'bg-gradient-to-r from-primary-500 to-primary-600 text-white hover:from-primary-600 hover:to-primary-700 focus:ring-primary-500 shadow-medium hover:shadow-large active:shadow-soft',
|
||||
secondary:
|
||||
'bg-grayscale-100 text-text-primary hover:bg-grayscale-200 focus:ring-grayscale-500 border border-border-primary shadow-soft hover:shadow-medium',
|
||||
outline:
|
||||
'border-2 border-primary-500 text-primary-600 hover:bg-primary-50 focus:ring-primary-500 shadow-soft hover:shadow-medium',
|
||||
ghost: 'text-primary-600 hover:bg-primary-50 focus:ring-primary-500',
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'px-3 py-1.5 text-sm rounded-lg',
|
||||
md: 'px-4 py-2.5 text-sm rounded-xl',
|
||||
lg: 'px-6 py-3 text-base rounded-xl',
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
onClick={onClick}
|
||||
disabled={disabled || loading}
|
||||
className={` ${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${className} `}
|
||||
>
|
||||
{loading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
</div>
|
||||
)}
|
||||
<span className={loading ? 'opacity-0' : 'opacity-100'}>{children}</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
68
src/components/ui/ModernCard.tsx
Normal file
68
src/components/ui/ModernCard.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Modern Card Component
|
||||
* Professional Resume Builder - Enhanced with resume-example.com design patterns
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-07
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
export interface ModernCardProps {
|
||||
children: React.ReactNode;
|
||||
variant?: 'default' | 'elevated' | 'outlined' | 'filled';
|
||||
padding?: 'none' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
hoverable?: boolean;
|
||||
}
|
||||
|
||||
export const ModernCard: React.FC<ModernCardProps> = ({
|
||||
children,
|
||||
variant = 'default',
|
||||
padding = 'md',
|
||||
className = '',
|
||||
onClick,
|
||||
hoverable = false,
|
||||
}) => {
|
||||
const baseClasses = 'rounded-2xl transition-all duration-300 ease-out';
|
||||
|
||||
const variantClasses = {
|
||||
default:
|
||||
'bg-gradient-to-br from-white to-grayscale-50 border border-grayscale-200 shadow-medium backdrop-blur-sm',
|
||||
elevated:
|
||||
'bg-gradient-to-br from-white via-white to-grayscale-25 shadow-xl border border-grayscale-100',
|
||||
outlined:
|
||||
'bg-gradient-to-br from-grayscale-25 to-white border-2 border-primary-200 shadow-soft',
|
||||
filled:
|
||||
'bg-gradient-to-br from-grayscale-100 to-grayscale-50 border border-grayscale-200 shadow-soft',
|
||||
};
|
||||
|
||||
const paddingClasses = {
|
||||
none: '',
|
||||
sm: 'p-4',
|
||||
md: 'p-6',
|
||||
lg: 'p-8',
|
||||
xl: 'p-10',
|
||||
};
|
||||
|
||||
const hoverClasses = hoverable
|
||||
? 'hover:shadow-2xl hover:scale-[1.02] hover:bg-gradient-to-br hover:from-white hover:to-primary-25 cursor-pointer transform-gpu'
|
||||
: '';
|
||||
|
||||
const Component = onClick ? 'button' : 'div';
|
||||
|
||||
return (
|
||||
<Component
|
||||
onClick={onClick}
|
||||
className={`${baseClasses} ${variantClasses[variant]} ${paddingClasses[padding]} ${hoverClasses} ${className} relative overflow-hidden`}
|
||||
>
|
||||
{/* Subtle overlay for depth */}
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-br from-white/20 to-transparent" />
|
||||
<div className="relative z-10">{children}</div>
|
||||
</Component>
|
||||
);
|
||||
};
|
||||
353
src/components/ui/ModernFormList.tsx
Normal file
353
src/components/ui/ModernFormList.tsx
Normal file
@@ -0,0 +1,353 @@
|
||||
/**
|
||||
* Modern Form Builder Component (Reusable)
|
||||
* Professional UI Component Library
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-07
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { ModernCard } from './ModernCard';
|
||||
import { ModernInput } from './ModernInput';
|
||||
import { ModernButton } from './ModernButton';
|
||||
import { cn } from '@/utils';
|
||||
import { Plus, Trash2, Edit3 } from 'lucide-react';
|
||||
|
||||
export interface FormFieldConfig {
|
||||
key: string;
|
||||
label: string;
|
||||
type?: 'text' | 'email' | 'tel' | 'url' | 'date' | 'textarea' | 'select';
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
options?: { value: string; label: string }[]; // For select fields
|
||||
icon?: React.ReactNode;
|
||||
validation?: (value: string) => string | undefined;
|
||||
width?: 'full' | 'half' | 'third';
|
||||
}
|
||||
|
||||
export interface ListItemData {
|
||||
id: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface ModernFormListProps<T extends ListItemData> {
|
||||
title: string;
|
||||
description?: string;
|
||||
icon?: React.ReactNode;
|
||||
data: T[];
|
||||
fields: FormFieldConfig[];
|
||||
onAdd: () => void;
|
||||
onEdit: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onSave: (item: T) => void;
|
||||
onCancel: () => void;
|
||||
editingId?: string | null;
|
||||
editingItem?: T | null;
|
||||
emptyStateText?: string;
|
||||
addButtonText?: string;
|
||||
className?: string;
|
||||
renderCustomField?: (
|
||||
field: FormFieldConfig,
|
||||
value: any,
|
||||
onChange: (value: any) => void
|
||||
) => React.ReactNode;
|
||||
renderListItem?: (
|
||||
item: T,
|
||||
onEdit: () => void,
|
||||
onDelete: () => void
|
||||
) => React.ReactNode;
|
||||
}
|
||||
|
||||
export function ModernFormList<T extends ListItemData>({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
data,
|
||||
fields,
|
||||
onAdd,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onSave,
|
||||
onCancel,
|
||||
editingId = null,
|
||||
editingItem = null,
|
||||
emptyStateText = 'Noch keine Einträge hinzugefügt',
|
||||
addButtonText = 'Element hinzufügen',
|
||||
className = '',
|
||||
renderCustomField,
|
||||
renderListItem,
|
||||
}: ModernFormListProps<T>) {
|
||||
const [formData, setFormData] = React.useState<Partial<T>>(editingItem || {});
|
||||
const [errors, setErrors] = React.useState<Record<string, string>>({});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (editingItem) {
|
||||
setFormData(editingItem);
|
||||
} else {
|
||||
setFormData({});
|
||||
}
|
||||
setErrors({});
|
||||
}, [editingItem, editingId]);
|
||||
|
||||
const handleFieldChange = (key: string, value: any) => {
|
||||
setFormData(prev => ({ ...prev, [key]: value }));
|
||||
|
||||
// Clear error when user starts typing
|
||||
if (errors[key]) {
|
||||
setErrors(prev => ({ ...prev, [key]: '' }));
|
||||
}
|
||||
};
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
fields.forEach(field => {
|
||||
const value = formData[field.key];
|
||||
|
||||
if (field.required && (!value || String(value).trim() === '')) {
|
||||
newErrors[field.key] = `${field.label} is required`;
|
||||
} else if (value && field.validation) {
|
||||
const validationError = field.validation(String(value));
|
||||
if (validationError) {
|
||||
newErrors[field.key] = validationError;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (validateForm()) {
|
||||
onSave(formData as T);
|
||||
}
|
||||
};
|
||||
|
||||
const getFieldWidth = (width?: string) => {
|
||||
switch (width) {
|
||||
case 'half':
|
||||
return 'md:col-span-6';
|
||||
case 'third':
|
||||
return 'md:col-span-4';
|
||||
default:
|
||||
return 'md:col-span-12';
|
||||
}
|
||||
};
|
||||
|
||||
const renderField = (field: FormFieldConfig) => {
|
||||
const value = formData[field.key] || '';
|
||||
|
||||
if (renderCustomField) {
|
||||
const customField = renderCustomField(field, value, newValue =>
|
||||
handleFieldChange(field.key, newValue)
|
||||
);
|
||||
if (customField) return customField;
|
||||
}
|
||||
|
||||
switch (field.type) {
|
||||
case 'textarea':
|
||||
return (
|
||||
<div
|
||||
key={field.key}
|
||||
className={cn('col-span-12', getFieldWidth(field.width))}
|
||||
>
|
||||
<label className="text-text-primary mb-2 block text-sm font-medium">
|
||||
{field.label}
|
||||
{field.required && <span className="text-error-500 ml-1">*</span>}
|
||||
</label>
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={e => handleFieldChange(field.key, e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
rows={3}
|
||||
className={cn(
|
||||
'w-full rounded-lg border px-4 py-3 transition-colors focus:border-primary-500 focus:ring-2 focus:ring-primary-100',
|
||||
errors[field.key] ? 'border-error-500' : 'border-grayscale-300'
|
||||
)}
|
||||
/>
|
||||
{errors[field.key] && (
|
||||
<p className="text-error-600 mt-1 text-sm">{errors[field.key]}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'select':
|
||||
return (
|
||||
<div
|
||||
key={field.key}
|
||||
className={cn('col-span-12', getFieldWidth(field.width))}
|
||||
>
|
||||
<label className="text-text-primary mb-2 block text-sm font-medium">
|
||||
{field.label}
|
||||
{field.required && <span className="text-error-500 ml-1">*</span>}
|
||||
</label>
|
||||
<select
|
||||
value={value}
|
||||
onChange={e => handleFieldChange(field.key, e.target.value)}
|
||||
className={cn(
|
||||
'w-full rounded-lg border px-4 py-3 transition-colors focus:border-primary-500 focus:ring-2 focus:ring-primary-100',
|
||||
errors[field.key] ? 'border-error-500' : 'border-grayscale-300'
|
||||
)}
|
||||
>
|
||||
<option value="">Select {field.label}</option>
|
||||
{field.options?.map(option => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{errors[field.key] && (
|
||||
<p className="text-error-600 mt-1 text-sm">{errors[field.key]}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<div
|
||||
key={field.key}
|
||||
className={cn('col-span-12', getFieldWidth(field.width))}
|
||||
>
|
||||
<ModernInput
|
||||
label={field.label}
|
||||
type={field.type || 'text'}
|
||||
value={value}
|
||||
onChange={e => handleFieldChange(field.key, e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
leftIcon={field.icon}
|
||||
required={field.required}
|
||||
error={errors[field.key]}
|
||||
variant="filled"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const defaultListItemRenderer = (
|
||||
item: T,
|
||||
onEditItem: () => void,
|
||||
onDeleteItem: () => void
|
||||
) => (
|
||||
<div className="bg-grayscale-50 flex items-start justify-between rounded-lg p-4">
|
||||
<div className="flex-1">
|
||||
<h4 className="text-text-primary font-medium">
|
||||
{item[fields[0]?.key] || 'Untitled'}
|
||||
</h4>
|
||||
<div className="text-text-secondary mt-1 space-y-1 text-sm">
|
||||
{fields.slice(1, 3).map(
|
||||
field =>
|
||||
item[field.key] && (
|
||||
<p key={field.key}>
|
||||
<span className="font-medium">{field.label}:</span>{' '}
|
||||
{item[field.key]}
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-4 flex items-center gap-2">
|
||||
<button
|
||||
onClick={onEditItem}
|
||||
className="text-grayscale-600 rounded-lg p-2 transition-colors hover:bg-primary-50 hover:text-primary-600"
|
||||
>
|
||||
<Edit3 className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onDeleteItem}
|
||||
className="text-grayscale-600 hover:text-error-600 hover:bg-error-50 rounded-lg p-2 transition-colors"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-4', className)}>
|
||||
{/* Header */}
|
||||
<ModernCard variant="elevated" padding="md">
|
||||
<div className="border-grayscale-200 mb-4 flex items-center justify-between border-b pb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
{icon && (
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-primary-100 to-primary-200 shadow-sm">
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h2 className="text-text-primary text-lg font-semibold">
|
||||
{title}
|
||||
</h2>
|
||||
{description && (
|
||||
<p className="text-text-secondary mt-0.5 text-sm">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ModernButton
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onAdd}
|
||||
disabled={!!editingId}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{addButtonText}
|
||||
</ModernButton>
|
||||
</div>
|
||||
|
||||
{/* Form for editing/adding */}
|
||||
{editingId && (
|
||||
<div className="bg-primary-25 mb-6 rounded-lg border border-primary-200 p-4">
|
||||
<h3 className="text-text-primary mb-4 text-lg font-medium">
|
||||
{editingItem ? 'Element bearbeiten' : 'Neues Element hinzufügen'}
|
||||
</h3>
|
||||
<div className="mb-4 grid grid-cols-12 gap-4">
|
||||
{fields.map(renderField)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<ModernButton variant="primary" size="sm" onClick={handleSave}>
|
||||
Speichern
|
||||
</ModernButton>
|
||||
<ModernButton variant="ghost" size="sm" onClick={onCancel}>
|
||||
Abbrechen
|
||||
</ModernButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* List of items */}
|
||||
<div className="space-y-3">
|
||||
{data.length === 0 ? (
|
||||
<div className="text-text-tertiary py-8 text-center">
|
||||
<div className="bg-grayscale-100 mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full">
|
||||
{icon || <Plus className="h-6 w-6" />}
|
||||
</div>
|
||||
<p>{emptyStateText}</p>
|
||||
</div>
|
||||
) : (
|
||||
data.map(item => (
|
||||
<div key={item.id}>
|
||||
{renderListItem
|
||||
? renderListItem(
|
||||
item,
|
||||
() => onEdit(item.id),
|
||||
() => onDelete(item.id)
|
||||
)
|
||||
: defaultListItemRenderer(
|
||||
item,
|
||||
() => onEdit(item.id),
|
||||
() => onDelete(item.id)
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</ModernCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
120
src/components/ui/ModernInput.tsx
Normal file
120
src/components/ui/ModernInput.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Modern Form Input Component
|
||||
* Professional Resume Builder - Enhanced with resume-example.com design patterns
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-07
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { forwardRef } from 'react';
|
||||
|
||||
export interface ModernInputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
helperText?: string;
|
||||
leftIcon?: React.ReactNode;
|
||||
rightIcon?: React.ReactNode;
|
||||
variant?: 'default' | 'filled' | 'outlined';
|
||||
}
|
||||
|
||||
export const ModernInput = forwardRef<HTMLInputElement, ModernInputProps>(
|
||||
(
|
||||
{
|
||||
label,
|
||||
error,
|
||||
helperText,
|
||||
leftIcon,
|
||||
rightIcon,
|
||||
variant = 'default',
|
||||
className = '',
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const baseInputClasses = `
|
||||
w-full px-2.5 py-3 text-xs transition-all duration-200 ease-out
|
||||
placeholder-text-quaternary focus:outline-none disabled:opacity-50
|
||||
disabled:cursor-not-allowed
|
||||
`;
|
||||
|
||||
const variantClasses = {
|
||||
default: `
|
||||
border border-grayscale-300 rounded-lg bg-gradient-to-br from-white to-grayscale-25
|
||||
focus:border-primary-500 focus:ring-1 focus:ring-primary-100 focus:shadow-focus
|
||||
hover:border-grayscale-400 hover:shadow-soft
|
||||
${error ? 'border-error-500 focus:border-error-500 focus:ring-error-100 focus:shadow-focus-error' : ''}
|
||||
`,
|
||||
filled: `
|
||||
border-0 rounded-lg bg-gradient-to-br from-grayscale-100 to-grayscale-50 shadow-inner
|
||||
focus:bg-gradient-to-br focus:from-white focus:to-primary-25 focus:ring-1 focus:ring-primary-100 focus:shadow-focus
|
||||
hover:bg-gradient-to-br hover:from-grayscale-50 hover:to-white hover:shadow-soft
|
||||
${error ? 'bg-gradient-to-br from-error-50 to-error-25 focus:from-error-50 focus:to-error-25 focus:ring-error-100 focus:shadow-focus-error' : ''}
|
||||
`,
|
||||
outlined: `
|
||||
border-2 border-grayscale-300 rounded-lg bg-transparent backdrop-blur-sm
|
||||
focus:border-primary-500 focus:bg-gradient-to-br focus:from-white/50 focus:to-primary-25/50 focus:shadow-focus
|
||||
hover:border-grayscale-400 hover:bg-gradient-to-br hover:from-white/30 hover:to-grayscale-25/30
|
||||
${error ? 'border-error-500 focus:border-error-500 focus:from-error-50/50 focus:to-error-25/50 focus:shadow-focus-error' : ''}
|
||||
`,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{label && (
|
||||
<label className="block text-sm font-semibold tracking-wide text-text-primary">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div className="relative">
|
||||
{leftIcon && (
|
||||
<div className="absolute left-2 top-1/2 -translate-y-1/2 transform text-text-tertiary">
|
||||
{leftIcon}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input
|
||||
ref={ref}
|
||||
className={` ${baseInputClasses} ${variantClasses[variant]} ${leftIcon ? 'pl-7' : ''} ${rightIcon ? 'pr-7' : ''} ${className} `}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
{rightIcon && (
|
||||
<div className="absolute right-2 top-1/2 -translate-y-1/2 transform text-text-tertiary">
|
||||
{rightIcon}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="flex animate-slide-up items-center gap-1 text-sm font-medium text-error-500">
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{helperText && !error && (
|
||||
<p className="text-sm text-text-tertiary">{helperText}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
ModernInput.displayName = 'ModernInput';
|
||||
299
src/components/ui/ModernNavigation.tsx
Normal file
299
src/components/ui/ModernNavigation.tsx
Normal file
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* Modern Navigation Component (Reusable)
|
||||
* Professional UI Component Library
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-07
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { cn } from '@/utils';
|
||||
import { ChevronRight, ChevronDown } from 'lucide-react';
|
||||
|
||||
export interface NavigationItem {
|
||||
id: string;
|
||||
label: string;
|
||||
href?: string;
|
||||
icon?: React.ReactNode;
|
||||
badge?: string | number;
|
||||
isActive?: boolean;
|
||||
isCompleted?: boolean;
|
||||
isDisabled?: boolean;
|
||||
children?: NavigationItem[];
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export interface ModernNavigationProps {
|
||||
items: NavigationItem[];
|
||||
variant?: 'vertical' | 'horizontal' | 'breadcrumb';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
showIcons?: boolean;
|
||||
showBadges?: boolean;
|
||||
allowCollapse?: boolean;
|
||||
onItemClick?: (item: NavigationItem) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ModernNavigation({
|
||||
items,
|
||||
variant = 'vertical',
|
||||
size = 'md',
|
||||
showIcons = true,
|
||||
showBadges = true,
|
||||
allowCollapse = false,
|
||||
onItemClick,
|
||||
className = '',
|
||||
}: ModernNavigationProps) {
|
||||
const [collapsedItems, setCollapsedItems] = React.useState<Set<string>>(
|
||||
new Set()
|
||||
);
|
||||
|
||||
const toggleCollapse = (itemId: string) => {
|
||||
if (!allowCollapse) return;
|
||||
|
||||
setCollapsedItems(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(itemId)) {
|
||||
newSet.delete(itemId);
|
||||
} else {
|
||||
newSet.add(itemId);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
const handleItemClick = (item: NavigationItem, e?: React.MouseEvent) => {
|
||||
if (item.isDisabled) return;
|
||||
|
||||
if (item.children && allowCollapse) {
|
||||
e?.preventDefault();
|
||||
toggleCollapse(item.id);
|
||||
}
|
||||
|
||||
if (item.onClick) {
|
||||
item.onClick();
|
||||
}
|
||||
|
||||
if (onItemClick) {
|
||||
onItemClick(item);
|
||||
}
|
||||
};
|
||||
|
||||
const getSizeClasses = () => {
|
||||
switch (size) {
|
||||
case 'sm':
|
||||
return 'text-sm px-2 py-1.5';
|
||||
case 'lg':
|
||||
return 'text-lg px-4 py-3';
|
||||
default:
|
||||
return 'text-base px-3 py-2';
|
||||
}
|
||||
};
|
||||
|
||||
const renderBadge = (badge: string | number) => (
|
||||
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full bg-primary-500 text-xs font-medium text-white">
|
||||
{badge}
|
||||
</span>
|
||||
);
|
||||
|
||||
const renderIcon = (
|
||||
icon: React.ReactNode,
|
||||
isCompleted?: boolean,
|
||||
isActive?: boolean
|
||||
) => {
|
||||
if (!showIcons && !isCompleted) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-5 w-5 items-center justify-center',
|
||||
isCompleted && 'text-success-600',
|
||||
isActive && !isCompleted && 'text-primary-600'
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderVerticalItem = (item: NavigationItem, level = 0) => {
|
||||
const isCollapsed = collapsedItems.has(item.id);
|
||||
const hasChildren = item.children && item.children.length > 0;
|
||||
|
||||
return (
|
||||
<div key={item.id}>
|
||||
<div
|
||||
className={cn(
|
||||
'flex cursor-pointer items-center justify-between rounded-lg transition-all duration-200',
|
||||
getSizeClasses(),
|
||||
item.isActive &&
|
||||
'bg-primary-50 font-medium text-primary-700 shadow-sm',
|
||||
item.isCompleted && !item.isActive && 'text-success-600',
|
||||
item.isDisabled && 'cursor-not-allowed opacity-50',
|
||||
!item.isActive && !item.isDisabled && 'hover:bg-grayscale-50',
|
||||
level > 0 && 'border-grayscale-200 ml-6 border-l-2 pl-4'
|
||||
)}
|
||||
onClick={e => handleItemClick(item, e)}
|
||||
style={{ paddingLeft: level > 0 ? `${level * 1.5}rem` : undefined }}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{renderIcon(item.icon, item.isCompleted, item.isActive)}
|
||||
<span className="truncate">{item.label}</span>
|
||||
{showBadges && item.badge && renderBadge(item.badge)}
|
||||
</div>
|
||||
|
||||
{hasChildren && allowCollapse && (
|
||||
<div className="flex items-center">
|
||||
{isCollapsed ? (
|
||||
<ChevronRight className="text-grayscale-500 h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown className="text-grayscale-500 h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasChildren && (!allowCollapse || !isCollapsed) && (
|
||||
<div className="mt-1 space-y-1">
|
||||
{item.children!.map(child => renderVerticalItem(child, level + 1))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderHorizontalItem = (item: NavigationItem, isLast: boolean) => (
|
||||
<div key={item.id} className="flex items-center">
|
||||
<div
|
||||
className={cn(
|
||||
'flex cursor-pointer items-center gap-2 rounded-lg transition-all duration-200',
|
||||
getSizeClasses(),
|
||||
item.isActive && 'bg-primary-50 font-medium text-primary-700',
|
||||
item.isCompleted && !item.isActive && 'text-success-600',
|
||||
item.isDisabled && 'cursor-not-allowed opacity-50',
|
||||
!item.isActive && !item.isDisabled && 'hover:bg-grayscale-50'
|
||||
)}
|
||||
onClick={() => handleItemClick(item)}
|
||||
>
|
||||
{renderIcon(item.icon, item.isCompleted, item.isActive)}
|
||||
<span className="truncate">{item.label}</span>
|
||||
{showBadges && item.badge && renderBadge(item.badge)}
|
||||
</div>
|
||||
|
||||
{!isLast && (
|
||||
<ChevronRight className="text-grayscale-400 mx-2 h-4 w-4 flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderBreadcrumbItem = (item: NavigationItem, isLast: boolean) => (
|
||||
<div key={item.id} className="flex items-center">
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2 transition-colors duration-200',
|
||||
!isLast && 'cursor-pointer hover:text-primary-600',
|
||||
isLast && 'text-text-primary font-medium',
|
||||
!isLast && 'text-text-secondary',
|
||||
item.isDisabled && 'cursor-not-allowed opacity-50'
|
||||
)}
|
||||
onClick={() => !isLast && handleItemClick(item)}
|
||||
>
|
||||
{renderIcon(item.icon, item.isCompleted, item.isActive)}
|
||||
<span className="truncate">{item.label}</span>
|
||||
</div>
|
||||
|
||||
{!isLast && (
|
||||
<ChevronRight className="text-grayscale-400 mx-2 h-4 w-4 flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const containerClasses = cn(
|
||||
'modern-navigation',
|
||||
variant === 'vertical' && 'space-y-1',
|
||||
variant === 'horizontal' && 'flex items-center flex-wrap',
|
||||
variant === 'breadcrumb' && 'flex items-center flex-wrap text-sm',
|
||||
className
|
||||
);
|
||||
|
||||
return (
|
||||
<nav className={containerClasses} role="navigation">
|
||||
{variant === 'vertical' && items.map(item => renderVerticalItem(item))}
|
||||
|
||||
{variant === 'horizontal' &&
|
||||
items.map((item, index) =>
|
||||
renderHorizontalItem(item, index === items.length - 1)
|
||||
)}
|
||||
|
||||
{variant === 'breadcrumb' &&
|
||||
items.map((item, index) =>
|
||||
renderBreadcrumbItem(item, index === items.length - 1)
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
// Export additional components for specific use cases
|
||||
export function ModernSidebar({
|
||||
items,
|
||||
title,
|
||||
className = '',
|
||||
...props
|
||||
}: Omit<ModernNavigationProps, 'variant'> & { title?: string }) {
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
'bg-background-primary border-grayscale-200 w-64 border-r p-4',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{title && (
|
||||
<h2 className="text-text-primary mb-4 text-lg font-semibold">
|
||||
{title}
|
||||
</h2>
|
||||
)}
|
||||
<ModernNavigation
|
||||
{...props}
|
||||
items={items}
|
||||
variant="vertical"
|
||||
allowCollapse={true}
|
||||
/>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
export function ModernBreadcrumbs({
|
||||
items,
|
||||
className = '',
|
||||
...props
|
||||
}: Omit<ModernNavigationProps, 'variant'>) {
|
||||
return (
|
||||
<ModernNavigation
|
||||
{...props}
|
||||
items={items}
|
||||
variant="breadcrumb"
|
||||
showBadges={false}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ModernTabNavigation({
|
||||
items,
|
||||
className = '',
|
||||
...props
|
||||
}: Omit<ModernNavigationProps, 'variant'>) {
|
||||
return (
|
||||
<div className={cn('border-grayscale-200 border-b', className)}>
|
||||
<ModernNavigation
|
||||
{...props}
|
||||
items={items}
|
||||
variant="horizontal"
|
||||
size="md"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
175
src/components/ui/ModernPageHeader.tsx
Normal file
175
src/components/ui/ModernPageHeader.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Modern Page Header Component (Reusable)
|
||||
* Professional UI Component Library
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-07
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { cn } from '@/utils';
|
||||
|
||||
export interface HeaderAction {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
onClick: () => void;
|
||||
variant?: 'primary' | 'secondary' | 'ghost';
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface ModernPageHeaderProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
badge?: {
|
||||
text: string;
|
||||
variant?: 'default' | 'primary' | 'success' | 'warning' | 'error';
|
||||
};
|
||||
logo?: {
|
||||
src?: string;
|
||||
alt?: string;
|
||||
fallback?: React.ReactNode;
|
||||
};
|
||||
actions?: HeaderAction[];
|
||||
breadcrumbs?: {
|
||||
label: string;
|
||||
href?: string;
|
||||
}[];
|
||||
variant?: 'default' | 'compact' | 'elevated';
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const ModernPageHeader: React.FC<ModernPageHeaderProps> = ({
|
||||
title,
|
||||
subtitle,
|
||||
badge,
|
||||
logo,
|
||||
actions = [],
|
||||
breadcrumbs = [],
|
||||
variant = 'default',
|
||||
className = '',
|
||||
children,
|
||||
}) => {
|
||||
const variantClasses = {
|
||||
default: 'bg-white border-b border-grayscale-200 shadow-soft',
|
||||
compact: 'bg-white border-b border-grayscale-200',
|
||||
elevated: 'bg-white border-b border-grayscale-200 shadow-medium',
|
||||
};
|
||||
|
||||
const badgeVariants = {
|
||||
default: 'bg-grayscale-100 text-grayscale-800 border-grayscale-200',
|
||||
primary: 'bg-primary-100 text-primary-800 border-primary-200',
|
||||
success: 'bg-success-100 text-success-800 border-success-200',
|
||||
warning: 'bg-warning-100 text-warning-800 border-warning-200',
|
||||
error: 'bg-error-100 text-error-800 border-error-200',
|
||||
};
|
||||
|
||||
const buttonVariants = {
|
||||
primary: 'bg-primary-500 text-white hover:bg-primary-600 shadow-medium',
|
||||
secondary:
|
||||
'bg-grayscale-100 text-grayscale-800 hover:bg-grayscale-200 border border-grayscale-300',
|
||||
ghost: 'text-grayscale-600 hover:bg-grayscale-100 hover:text-grayscale-900',
|
||||
};
|
||||
|
||||
return (
|
||||
<header className={cn(variantClasses[variant], 'px-6 py-4', className)}>
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Left section - Logo, Title, Subtitle */}
|
||||
<div className="flex items-center gap-4">
|
||||
{logo && (
|
||||
<div className="flex-shrink-0">
|
||||
{logo.src ? (
|
||||
<img
|
||||
src={logo.src}
|
||||
alt={logo.alt || 'Logo'}
|
||||
className="h-10 w-10 rounded-lg object-cover"
|
||||
/>
|
||||
) : logo.fallback ? (
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-primary-500 to-primary-600 font-bold text-white">
|
||||
{logo.fallback}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
{/* Breadcrumbs */}
|
||||
{breadcrumbs.length > 0 && (
|
||||
<nav className="text-grayscale-600 mb-1 flex items-center text-sm">
|
||||
{breadcrumbs.map((crumb, index) => (
|
||||
<React.Fragment key={index}>
|
||||
{crumb.href ? (
|
||||
<a
|
||||
href={crumb.href}
|
||||
className="transition-colors hover:text-primary-600"
|
||||
>
|
||||
{crumb.label}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-grayscale-900 font-medium">
|
||||
{crumb.label}
|
||||
</span>
|
||||
)}
|
||||
{index < breadcrumbs.length - 1 && (
|
||||
<span className="mx-2">/</span>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
|
||||
{/* Title and Badge */}
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-grayscale-900 text-xl font-bold">{title}</h1>
|
||||
{badge && (
|
||||
<span
|
||||
className={cn(
|
||||
'rounded-md border px-2 py-1 text-xs font-medium',
|
||||
badgeVariants[badge.variant || 'default']
|
||||
)}
|
||||
>
|
||||
{badge.text}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Subtitle */}
|
||||
{subtitle && (
|
||||
<p className="text-grayscale-600 mt-1 text-sm">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right section - Actions */}
|
||||
<div className="flex items-center gap-3">
|
||||
{actions.map(action => (
|
||||
<button
|
||||
key={action.id}
|
||||
onClick={action.onClick}
|
||||
disabled={action.disabled}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-all duration-200',
|
||||
buttonVariants[action.variant || 'secondary'],
|
||||
action.disabled && 'cursor-not-allowed opacity-50'
|
||||
)}
|
||||
>
|
||||
{action.icon}
|
||||
{action.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom children content */}
|
||||
{children && (
|
||||
<div className="border-grayscale-200 mt-4 border-t pt-4">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
};
|
||||
76
src/components/ui/ModernSpinner.tsx
Normal file
76
src/components/ui/ModernSpinner.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Modern Loading Spinner Component
|
||||
* Professional Resume Builder - Reusable UI Component
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-07
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { cn } from '@/utils';
|
||||
|
||||
export interface ModernSpinnerProps {
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
variant?: 'primary' | 'secondary' | 'white';
|
||||
className?: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export const ModernSpinner: React.FC<ModernSpinnerProps> = ({
|
||||
size = 'md',
|
||||
variant = 'primary',
|
||||
className = '',
|
||||
label = 'Loading...',
|
||||
}) => {
|
||||
const sizeClasses = {
|
||||
xs: 'w-4 h-4',
|
||||
sm: 'w-5 h-5',
|
||||
md: 'w-6 h-6',
|
||||
lg: 'w-8 h-8',
|
||||
xl: 'w-10 h-10',
|
||||
};
|
||||
|
||||
const variantClasses = {
|
||||
primary: 'text-primary-600',
|
||||
secondary: 'text-grayscale-600',
|
||||
white: 'text-white',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="inline-flex items-center justify-center"
|
||||
role="status"
|
||||
aria-label={label}
|
||||
>
|
||||
<svg
|
||||
className={cn(
|
||||
'animate-spin',
|
||||
sizeClasses[size],
|
||||
variantClasses[variant],
|
||||
className
|
||||
)}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="m4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
<span className="sr-only">{label}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
220
src/components/ui/ModernStepper.tsx
Normal file
220
src/components/ui/ModernStepper.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* Modern Stepper Component (Reusable)
|
||||
* Professional UI Component Library
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-07
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { cn } from '@/utils';
|
||||
import { Check } from 'lucide-react';
|
||||
|
||||
export interface StepItem {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
icon?: React.ReactNode;
|
||||
completed: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface ModernStepperProps {
|
||||
steps: StepItem[];
|
||||
currentStep: string;
|
||||
onStepChange?: (stepId: string) => void;
|
||||
variant?: 'horizontal' | 'vertical';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
showProgress?: boolean;
|
||||
clickable?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const ModernStepper: React.FC<ModernStepperProps> = ({
|
||||
steps,
|
||||
currentStep,
|
||||
onStepChange,
|
||||
variant = 'horizontal',
|
||||
size = 'md',
|
||||
showProgress = true,
|
||||
clickable = true,
|
||||
className = '',
|
||||
}) => {
|
||||
const currentStepIndex = steps.findIndex(step => step.id === currentStep);
|
||||
const completedSteps = steps.filter(step => step.completed).length;
|
||||
const progressPercentage = (completedSteps / steps.length) * 100;
|
||||
|
||||
const sizeClasses = {
|
||||
sm: {
|
||||
step: 'w-6 h-6 text-xs',
|
||||
title: 'text-sm',
|
||||
description: 'text-xs',
|
||||
gap: 'gap-2',
|
||||
},
|
||||
md: {
|
||||
step: 'w-8 h-8 text-sm',
|
||||
title: 'text-base',
|
||||
description: 'text-sm',
|
||||
gap: 'gap-3',
|
||||
},
|
||||
lg: {
|
||||
step: 'w-10 h-10 text-base',
|
||||
title: 'text-lg',
|
||||
description: 'text-base',
|
||||
gap: 'gap-4',
|
||||
},
|
||||
};
|
||||
|
||||
const handleStepClick = (stepId: string, disabled?: boolean) => {
|
||||
if (clickable && !disabled && onStepChange) {
|
||||
onStepChange(stepId);
|
||||
}
|
||||
};
|
||||
|
||||
const renderStepIndicator = (step: StepItem, index: number) => {
|
||||
const isActive = step.id === currentStep;
|
||||
const isCompleted = step.completed;
|
||||
const isPast = index < currentStepIndex;
|
||||
const isFuture = index > currentStepIndex;
|
||||
|
||||
const stepClasses = cn(
|
||||
'flex items-center justify-center rounded-full border-2 transition-all duration-200 font-medium',
|
||||
sizeClasses[size].step,
|
||||
{
|
||||
// Completed state
|
||||
'bg-success-500 border-success-500 text-white': isCompleted,
|
||||
// Active state
|
||||
'bg-primary-500 border-primary-500 text-white':
|
||||
isActive && !isCompleted,
|
||||
// Past state
|
||||
'bg-grayscale-100 border-grayscale-300 text-grayscale-600':
|
||||
isPast && !isCompleted,
|
||||
// Future state
|
||||
'bg-white border-grayscale-300 text-grayscale-500': isFuture,
|
||||
// Disabled
|
||||
'opacity-50 cursor-not-allowed': step.disabled,
|
||||
// Clickable
|
||||
'cursor-pointer hover:scale-105': clickable && !step.disabled,
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={stepClasses}
|
||||
onClick={() => handleStepClick(step.id, step.disabled)}
|
||||
>
|
||||
{isCompleted ? (
|
||||
<Check className="h-4 w-4" />
|
||||
) : step.icon ? (
|
||||
step.icon
|
||||
) : (
|
||||
index + 1
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderHorizontalStepper = () => (
|
||||
<div className={cn('w-full', className)}>
|
||||
{showProgress && (
|
||||
<div className="mb-4">
|
||||
<div className="text-grayscale-600 mb-1 flex justify-between text-sm">
|
||||
<span>Progress</span>
|
||||
<span>{Math.round(progressPercentage)}%</span>
|
||||
</div>
|
||||
<div className="bg-grayscale-200 h-2 w-full rounded-full">
|
||||
<div
|
||||
className="h-2 rounded-full bg-gradient-to-r from-primary-500 to-primary-600 transition-all duration-500 ease-out"
|
||||
style={{ width: `${progressPercentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
{steps.map((step, index) => (
|
||||
<React.Fragment key={step.id}>
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col items-center',
|
||||
sizeClasses[size].gap
|
||||
)}
|
||||
>
|
||||
{renderStepIndicator(step, index)}
|
||||
<div className="text-center">
|
||||
<div
|
||||
className={cn(
|
||||
'text-grayscale-900 font-medium',
|
||||
sizeClasses[size].title
|
||||
)}
|
||||
>
|
||||
{step.title}
|
||||
</div>
|
||||
{step.description && (
|
||||
<div
|
||||
className={cn(
|
||||
'text-grayscale-600 mt-1',
|
||||
sizeClasses[size].description
|
||||
)}
|
||||
>
|
||||
{step.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{index < steps.length - 1 && (
|
||||
<div className="mx-4 flex-1">
|
||||
<div
|
||||
className={cn(
|
||||
'h-0.5 transition-colors duration-300',
|
||||
index < currentStepIndex || steps[index].completed
|
||||
? 'bg-primary-500'
|
||||
: 'bg-grayscale-300'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderVerticalStepper = () => (
|
||||
<div className={cn('space-y-4', className)}>
|
||||
{steps.map((step, index) => (
|
||||
<div key={step.id} className="flex items-start gap-4">
|
||||
{renderStepIndicator(step, index)}
|
||||
<div className="flex-1">
|
||||
<div
|
||||
className={cn(
|
||||
'text-grayscale-900 font-medium',
|
||||
sizeClasses[size].title
|
||||
)}
|
||||
>
|
||||
{step.title}
|
||||
</div>
|
||||
{step.description && (
|
||||
<div
|
||||
className={cn(
|
||||
'text-grayscale-600 mt-1',
|
||||
sizeClasses[size].description
|
||||
)}
|
||||
>
|
||||
{step.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
return variant === 'horizontal'
|
||||
? renderHorizontalStepper()
|
||||
: renderVerticalStepper();
|
||||
};
|
||||
252
src/components/ui/README.md
Normal file
252
src/components/ui/README.md
Normal file
@@ -0,0 +1,252 @@
|
||||
# 🎨 UI Component Library Documentation
|
||||
|
||||
**Professional Resume Builder - Reusable Components**
|
||||
**Author:** David Valera Melendez (david@valera-melendez.de)
|
||||
**Created:** 2025-08-07
|
||||
**Location:** Made in Germany 🇩🇪
|
||||
|
||||
---
|
||||
|
||||
## 📚 Component Library Overview
|
||||
|
||||
This is a professional, enterprise-ready component library built with **TypeScript**, **React**, and **Tailwind CSS**. Each component is designed for maximum reusability, type safety, and developer experience.
|
||||
|
||||
## 🧩 Available Components
|
||||
|
||||
### 1. **ModernCard**
|
||||
|
||||
Professional card component with multiple variants and padding options.
|
||||
|
||||
```tsx
|
||||
import { ModernCard } from '@/components/ui';
|
||||
|
||||
<ModernCard variant="elevated" padding="lg">
|
||||
<p>Your content here</p>
|
||||
</ModernCard>;
|
||||
```
|
||||
|
||||
**Props:**
|
||||
|
||||
- `variant`: 'default' | 'elevated' | 'outlined' | 'filled'
|
||||
- `padding`: 'none' | 'sm' | 'md' | 'lg' | 'xl'
|
||||
- `hoverable`: boolean
|
||||
- `onClick`: () => void
|
||||
|
||||
### 2. **ModernInput**
|
||||
|
||||
Enhanced form input with icons, validation, and multiple variants.
|
||||
|
||||
```tsx
|
||||
import { ModernInput } from '@/components/ui';
|
||||
import { Mail } from 'lucide-react';
|
||||
|
||||
<ModernInput
|
||||
label="Email Address"
|
||||
type="email"
|
||||
placeholder="your.email@example.com"
|
||||
leftIcon={<Mail className="h-4 w-4" />}
|
||||
variant="filled"
|
||||
required
|
||||
/>;
|
||||
```
|
||||
|
||||
**Props:**
|
||||
|
||||
- `variant`: 'default' | 'filled' | 'outlined'
|
||||
- `leftIcon`: React.ReactNode
|
||||
- `rightIcon`: React.ReactNode
|
||||
- `error`: string
|
||||
- `helperText`: string
|
||||
|
||||
### 3. **ModernButton**
|
||||
|
||||
Professional button component with loading states and multiple variants.
|
||||
|
||||
```tsx
|
||||
import { ModernButton } from '@/components/ui';
|
||||
|
||||
<ModernButton
|
||||
variant="primary"
|
||||
size="lg"
|
||||
loading={isLoading}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
Submit Application
|
||||
</ModernButton>;
|
||||
```
|
||||
|
||||
**Props:**
|
||||
|
||||
- `variant`: 'primary' | 'secondary' | 'outline' | 'ghost'
|
||||
- `size`: 'sm' | 'md' | 'lg'
|
||||
- `loading`: boolean
|
||||
- `disabled`: boolean
|
||||
|
||||
### 4. **ModernBadge**
|
||||
|
||||
Versatile badge component for status indicators and tags.
|
||||
|
||||
```tsx
|
||||
import { ModernBadge } from '@/components/ui';
|
||||
import { CheckCircle } from 'lucide-react';
|
||||
|
||||
<ModernBadge variant="success" icon={<CheckCircle className="h-3 w-3" />}>
|
||||
Verified
|
||||
</ModernBadge>;
|
||||
```
|
||||
|
||||
**Props:**
|
||||
|
||||
- `variant`: 'default' | 'primary' | 'success' | 'warning' | 'error' | 'info'
|
||||
- `size`: 'sm' | 'md' | 'lg'
|
||||
- `rounded`: 'sm' | 'md' | 'lg' | 'full'
|
||||
- `icon`: React.ReactNode
|
||||
|
||||
### 5. **ModernAvatar**
|
||||
|
||||
Professional avatar component with fallbacks and status indicators.
|
||||
|
||||
```tsx
|
||||
import { ModernAvatar } from '@/components/ui';
|
||||
|
||||
<ModernAvatar
|
||||
src="/profile.jpg"
|
||||
size="lg"
|
||||
fallback="DV"
|
||||
status="online"
|
||||
showStatus={true}
|
||||
/>;
|
||||
```
|
||||
|
||||
**Props:**
|
||||
|
||||
- `size`: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'
|
||||
- `status`: 'online' | 'offline' | 'away' | 'busy'
|
||||
- `showStatus`: boolean
|
||||
- `fallback`: string
|
||||
|
||||
### 6. **ModernSpinner**
|
||||
|
||||
Loading spinner with multiple sizes and variants.
|
||||
|
||||
```tsx
|
||||
import { ModernSpinner } from '@/components/ui';
|
||||
|
||||
<ModernSpinner size="lg" variant="primary" />;
|
||||
```
|
||||
|
||||
**Props:**
|
||||
|
||||
- `size`: 'xs' | 'sm' | 'md' | 'lg' | 'xl'
|
||||
- `variant`: 'primary' | 'secondary' | 'white'
|
||||
- `label`: string (for accessibility)
|
||||
|
||||
## 🎨 Design System
|
||||
|
||||
### Design Tokens
|
||||
|
||||
Centralized design system with consistent spacing, colors, and styling.
|
||||
|
||||
```tsx
|
||||
import { DESIGN_TOKENS, getToken } from '@/components/ui';
|
||||
|
||||
// Access design tokens
|
||||
const spacing = getToken.spacing('lg'); // 24px
|
||||
const shadow = getToken.shadow('medium');
|
||||
const radius = getToken.radius('xl');
|
||||
```
|
||||
|
||||
### Theme Colors
|
||||
|
||||
- **Primary:** Blue (#0ea5e9) - Main brand color
|
||||
- **Success:** Green (#22c55e) - Success states
|
||||
- **Warning:** Orange (#f59e0b) - Warning states
|
||||
- **Error:** Red (#ef4444) - Error states
|
||||
- **Accent:** Purple (#a855f7) - Accent elements
|
||||
|
||||
## 🏗️ Architecture Benefits
|
||||
|
||||
### ✅ **Enterprise-Ready Features**
|
||||
|
||||
- **TypeScript First:** Full type safety and IntelliSense
|
||||
- **Consistent API:** All components follow the same patterns
|
||||
- **Accessibility:** WCAG 2.1 compliant with ARIA labels
|
||||
- **Performance:** Optimized with React best practices
|
||||
- **Responsive:** Mobile-first design approach
|
||||
|
||||
### ✅ **Developer Experience**
|
||||
|
||||
- **Barrel Exports:** Clean imports from single entry point
|
||||
- **Prop Forwarding:** Extends native HTML props
|
||||
- **Design Tokens:** Centralized styling system
|
||||
- **Documentation:** Comprehensive type definitions
|
||||
|
||||
### ✅ **Scalability**
|
||||
|
||||
- **Variant System:** Easy to extend with new styles
|
||||
- **Composition:** Components work together seamlessly
|
||||
- **Tree Shaking:** Only import what you use
|
||||
- **Theme Support:** Easy to customize colors and spacing
|
||||
|
||||
## 📦 Usage Examples
|
||||
|
||||
### Import Components
|
||||
|
||||
```tsx
|
||||
// Single component import
|
||||
import { ModernCard } from '@/components/ui/ModernCard';
|
||||
|
||||
// Multiple components (recommended)
|
||||
import { ModernCard, ModernButton, ModernInput } from '@/components/ui';
|
||||
|
||||
// Import with types
|
||||
import { ModernCard, type ModernCardProps } from '@/components/ui';
|
||||
```
|
||||
|
||||
### Form Example
|
||||
|
||||
```tsx
|
||||
import { ModernCard, ModernInput, ModernButton } from '@/components/ui';
|
||||
|
||||
export function ContactForm() {
|
||||
return (
|
||||
<ModernCard variant="elevated" padding="lg">
|
||||
<div className="space-y-4">
|
||||
<ModernInput
|
||||
label="Full Name"
|
||||
placeholder="Enter your name"
|
||||
variant="filled"
|
||||
required
|
||||
/>
|
||||
<ModernInput
|
||||
label="Email"
|
||||
type="email"
|
||||
placeholder="your.email@example.com"
|
||||
variant="filled"
|
||||
required
|
||||
/>
|
||||
<ModernButton variant="primary" size="lg" className="w-full">
|
||||
Send Message
|
||||
</ModernButton>
|
||||
</div>
|
||||
</ModernCard>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## 🚀 Professional Standards
|
||||
|
||||
This component library demonstrates:
|
||||
|
||||
- **Clean Code:** Well-structured, readable, and maintainable
|
||||
- **Best Practices:** Following React and TypeScript conventions
|
||||
- **Performance:** Optimized for production use
|
||||
- **Scalability:** Easy to extend and customize
|
||||
- **Documentation:** Clear examples and API references
|
||||
- **Testing Ready:** Components designed for easy unit testing
|
||||
|
||||
---
|
||||
|
||||
**Built by David Valera Melendez - Professional Frontend Developer**
|
||||
**Portfolio:** https://valera-melendez.de
|
||||
**Email:** david@valera-melendez.de
|
||||
152
src/components/ui/UserDropdown.tsx
Normal file
152
src/components/ui/UserDropdown.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* User Dropdown Component
|
||||
* Reusable header dropdown with user information and navigation
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-07
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { User, FileText, ChevronDown, Settings, LogOut } from 'lucide-react';
|
||||
|
||||
interface UserDropdownProps {
|
||||
/** Current page identifier to highlight in dropdown */
|
||||
currentPage?: 'dashboard' | 'settings';
|
||||
/** Whether to show the logo icon */
|
||||
showLogo?: boolean;
|
||||
/** Custom CSS classes */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* UserDropdown Component - Reusable header dropdown
|
||||
*
|
||||
* Professional user dropdown component with user information display
|
||||
* and navigation options. Can be customized for different pages.
|
||||
*
|
||||
* Features:
|
||||
* - User information display (name + email)
|
||||
* - Navigation menu with current page highlighting
|
||||
* - Settings and logout functionality
|
||||
* - Optional logo display
|
||||
* - Responsive design
|
||||
*
|
||||
* @param currentPage - Current page to highlight in dropdown
|
||||
* @param showLogo - Whether to display the logo icon
|
||||
* @param className - Additional CSS classes
|
||||
* @returns Reusable user dropdown component
|
||||
*/
|
||||
export function UserDropdown({
|
||||
currentPage,
|
||||
showLogo = false,
|
||||
className = '',
|
||||
}: UserDropdownProps) {
|
||||
const router = useRouter();
|
||||
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
|
||||
|
||||
/**
|
||||
* Handles navigation to settings page
|
||||
*/
|
||||
const handleAccountSettings = () => {
|
||||
setIsUserMenuOpen(false);
|
||||
router.push('/settings');
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles logout functionality
|
||||
*/
|
||||
const handleLogout = () => {
|
||||
setIsUserMenuOpen(false);
|
||||
// Here you would typically clear user session/tokens
|
||||
console.log('Logging out...');
|
||||
// Redirect to login or home page
|
||||
router.push('/');
|
||||
};
|
||||
|
||||
/**
|
||||
* Closes dropdown when clicking outside
|
||||
*/
|
||||
const handleDropdownToggle = () => {
|
||||
setIsUserMenuOpen(!isUserMenuOpen);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-3 ${className}`}>
|
||||
{/* Optional Logo */}
|
||||
{showLogo && (
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-primary-600 to-primary-700 shadow-sm">
|
||||
<FileText className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* User Info Display */}
|
||||
<div className="text-right">
|
||||
<h1 className="text-lg font-bold text-primary-700">
|
||||
David Valera Melendez
|
||||
</h1>
|
||||
<p className="text-xs text-gray-500">david@valera-melendez.de</p>
|
||||
</div>
|
||||
|
||||
{/* User Dropdown */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={handleDropdownToggle}
|
||||
className="flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-100"
|
||||
aria-label="User menu"
|
||||
aria-expanded={isUserMenuOpen}
|
||||
>
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-gradient-to-br from-primary-100 to-primary-200">
|
||||
<User className="h-4 w-4 text-primary-600" />
|
||||
</div>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{/* Dropdown Menu */}
|
||||
{isUserMenuOpen && (
|
||||
<>
|
||||
{/* Backdrop for mobile */}
|
||||
<div
|
||||
className="fixed inset-0 z-40 lg:hidden"
|
||||
onClick={() => setIsUserMenuOpen(false)}
|
||||
/>
|
||||
|
||||
<div className="absolute right-0 top-full z-50 mt-2 w-56 rounded-lg border border-gray-200 bg-white shadow-lg">
|
||||
<div className="py-1">
|
||||
{/* Settings Option */}
|
||||
<button
|
||||
onClick={handleAccountSettings}
|
||||
className={`flex w-full items-center gap-2 px-4 py-2 text-left text-sm transition-colors ${
|
||||
currentPage === 'settings'
|
||||
? 'bg-primary-50 text-primary-600'
|
||||
: 'text-gray-700 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
Konto-Einstellungen
|
||||
</button>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="mx-1 my-1 border-t border-gray-100" />
|
||||
|
||||
{/* Logout Option */}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex w-full items-center gap-2 px-4 py-2 text-left text-sm text-red-600 transition-colors hover:bg-red-50"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Abmelden
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default UserDropdown;
|
||||
129
src/components/ui/design-tokens.ts
Normal file
129
src/components/ui/design-tokens.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Design System Tokens
|
||||
* Professional Resume Builder - Design System Constants
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-07
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
|
||||
// Design Tokens for consistent styling across components
|
||||
export const DESIGN_TOKENS = {
|
||||
// Spacing Scale (Tailwind-based)
|
||||
spacing: {
|
||||
none: '0',
|
||||
xs: '0.5rem', // 8px
|
||||
sm: '0.75rem', // 12px
|
||||
md: '1rem', // 16px
|
||||
lg: '1.5rem', // 24px
|
||||
xl: '2rem', // 32px
|
||||
'2xl': '3rem', // 48px
|
||||
'3xl': '4rem', // 64px
|
||||
},
|
||||
|
||||
// Border Radius
|
||||
radius: {
|
||||
none: '0',
|
||||
sm: '0.375rem', // 6px
|
||||
md: '0.5rem', // 8px
|
||||
lg: '0.75rem', // 12px
|
||||
xl: '1rem', // 16px
|
||||
'2xl': '1.5rem', // 24px
|
||||
full: '9999px',
|
||||
},
|
||||
|
||||
// Shadow Scale
|
||||
shadows: {
|
||||
none: 'none',
|
||||
soft: '0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)',
|
||||
medium: '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)',
|
||||
large: '0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)',
|
||||
xl: '0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1)',
|
||||
},
|
||||
|
||||
// Animation Durations
|
||||
animation: {
|
||||
fast: '150ms',
|
||||
normal: '300ms',
|
||||
slow: '500ms',
|
||||
},
|
||||
|
||||
// Breakpoints (for responsive design)
|
||||
breakpoints: {
|
||||
sm: '640px',
|
||||
md: '768px',
|
||||
lg: '1024px',
|
||||
xl: '1280px',
|
||||
'2xl': '1536px',
|
||||
},
|
||||
|
||||
// Z-Index Scale
|
||||
zIndex: {
|
||||
hide: -1,
|
||||
auto: 'auto',
|
||||
base: 0,
|
||||
docked: 10,
|
||||
dropdown: 1000,
|
||||
sticky: 1100,
|
||||
banner: 1200,
|
||||
overlay: 1300,
|
||||
modal: 1400,
|
||||
popover: 1500,
|
||||
skipLink: 1600,
|
||||
toast: 1700,
|
||||
tooltip: 1800,
|
||||
},
|
||||
|
||||
// Component Variants
|
||||
variants: {
|
||||
card: ['default', 'elevated', 'outlined', 'filled'],
|
||||
button: ['primary', 'secondary', 'outline', 'ghost'],
|
||||
input: ['default', 'filled', 'outlined'],
|
||||
alert: ['info', 'success', 'warning', 'error'],
|
||||
},
|
||||
|
||||
// Professional Color Palette Reference
|
||||
colors: {
|
||||
primary: 'rgb(14 165 233)', // Blue
|
||||
success: 'rgb(34 197 94)', // Green
|
||||
warning: 'rgb(245 158 11)', // Orange
|
||||
error: 'rgb(239 68 68)', // Red
|
||||
accent: 'rgb(168 85 247)', // Purple
|
||||
},
|
||||
} as const;
|
||||
|
||||
// Type definitions for design tokens
|
||||
export type SpacingKey = keyof typeof DESIGN_TOKENS.spacing;
|
||||
export type RadiusKey = keyof typeof DESIGN_TOKENS.radius;
|
||||
export type ShadowKey = keyof typeof DESIGN_TOKENS.shadows;
|
||||
export type BreakpointKey = keyof typeof DESIGN_TOKENS.breakpoints;
|
||||
|
||||
// Utility function to get design token values
|
||||
export const getToken = {
|
||||
spacing: (key: SpacingKey) => DESIGN_TOKENS.spacing[key],
|
||||
radius: (key: RadiusKey) => DESIGN_TOKENS.radius[key],
|
||||
shadow: (key: ShadowKey) => DESIGN_TOKENS.shadows[key],
|
||||
breakpoint: (key: BreakpointKey) => DESIGN_TOKENS.breakpoints[key],
|
||||
};
|
||||
|
||||
// Component size presets
|
||||
export const SIZE_PRESETS = {
|
||||
button: {
|
||||
sm: 'px-3 py-1.5 text-sm',
|
||||
md: 'px-4 py-2 text-base',
|
||||
lg: 'px-6 py-3 text-lg',
|
||||
},
|
||||
input: {
|
||||
sm: 'px-3 py-2 text-sm',
|
||||
md: 'px-4 py-3 text-base',
|
||||
lg: 'px-5 py-4 text-lg',
|
||||
},
|
||||
card: {
|
||||
sm: 'p-4',
|
||||
md: 'p-6',
|
||||
lg: 'p-8',
|
||||
xl: 'p-10',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export default DESIGN_TOKENS;
|
||||
52
src/components/ui/index.ts
Normal file
52
src/components/ui/index.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* UI Components Barrel Export
|
||||
* Professional Resume Builder - Reusable Component Library
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-07
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
|
||||
// Core UI Components
|
||||
export { ModernCard } from './ModernCard';
|
||||
export { ModernInput } from './ModernInput';
|
||||
export { ModernButton } from './ModernButton';
|
||||
export { ModernBadge } from './ModernBadge';
|
||||
export { ModernAvatar } from './ModernAvatar';
|
||||
export { ModernSpinner } from './ModernSpinner';
|
||||
export { ModernStepper } from './ModernStepper';
|
||||
export { ModernPageHeader } from './ModernPageHeader';
|
||||
export { ModernFormList } from './ModernFormList';
|
||||
export {
|
||||
ModernNavigation,
|
||||
ModernSidebar,
|
||||
ModernBreadcrumbs,
|
||||
ModernTabNavigation,
|
||||
} from './ModernNavigation';
|
||||
|
||||
// Design System
|
||||
export { DESIGN_TOKENS, getToken, SIZE_PRESETS } from './design-tokens';
|
||||
|
||||
// Type exports for better developer experience
|
||||
export type { ModernCardProps } from './ModernCard';
|
||||
export type { ModernInputProps } from './ModernInput';
|
||||
export type { ModernButtonProps } from './ModernButton';
|
||||
export type { ModernBadgeProps } from './ModernBadge';
|
||||
export type { ModernAvatarProps } from './ModernAvatar';
|
||||
export type { ModernSpinnerProps } from './ModernSpinner';
|
||||
export type { ModernStepperProps, StepItem } from './ModernStepper';
|
||||
export type { ModernPageHeaderProps, HeaderAction } from './ModernPageHeader';
|
||||
export type {
|
||||
ModernFormListProps,
|
||||
FormFieldConfig,
|
||||
ListItemData,
|
||||
} from './ModernFormList';
|
||||
export type { ModernNavigationProps, NavigationItem } from './ModernNavigation';
|
||||
|
||||
// Design token types
|
||||
export type {
|
||||
SpacingKey,
|
||||
RadiusKey,
|
||||
ShadowKey,
|
||||
BreakpointKey,
|
||||
} from './design-tokens';
|
||||
287
src/data/formFields.ts
Normal file
287
src/data/formFields.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* Form Field Configurations
|
||||
* Professional Resume Builder
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-07
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
|
||||
import { SelectOption } from './formOptions';
|
||||
|
||||
/**
|
||||
* Form field configuration interface
|
||||
*/
|
||||
export interface FormField {
|
||||
key: string;
|
||||
label: string;
|
||||
type: 'text' | 'email' | 'tel' | 'url' | 'date' | 'textarea' | 'select';
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
width?: 'full' | 'half' | 'third' | 'quarter';
|
||||
options?: SelectOption[];
|
||||
icon?: React.ReactNode;
|
||||
validation?: {
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
pattern?: RegExp;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Experience form field configuration
|
||||
* Professional work experience entry fields
|
||||
*/
|
||||
export const experienceFormFields: FormField[] = [
|
||||
{
|
||||
key: 'position',
|
||||
label: 'Job Title',
|
||||
type: 'text',
|
||||
placeholder: 'e.g., Senior Frontend Developer',
|
||||
required: true,
|
||||
width: 'full',
|
||||
},
|
||||
{
|
||||
key: 'company',
|
||||
label: 'Company Name',
|
||||
type: 'text',
|
||||
placeholder: 'e.g., Tech Innovation GmbH',
|
||||
required: true,
|
||||
width: 'half',
|
||||
},
|
||||
{
|
||||
key: 'location',
|
||||
label: 'Location',
|
||||
type: 'text',
|
||||
placeholder: 'e.g., Berlin, Germany',
|
||||
required: true,
|
||||
width: 'half',
|
||||
},
|
||||
{
|
||||
key: 'startDate',
|
||||
label: 'Start Date',
|
||||
type: 'date',
|
||||
required: true,
|
||||
width: 'half',
|
||||
},
|
||||
{
|
||||
key: 'endDate',
|
||||
label: 'End Date',
|
||||
type: 'date',
|
||||
required: false,
|
||||
width: 'half',
|
||||
},
|
||||
{
|
||||
key: 'description',
|
||||
label: 'Job Description',
|
||||
type: 'textarea',
|
||||
placeholder: 'Describe your main responsibilities and role...',
|
||||
required: false,
|
||||
width: 'full',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Education form field configuration
|
||||
* Academic history entry fields
|
||||
*/
|
||||
export const educationFormFields: FormField[] = [
|
||||
{
|
||||
key: 'degree',
|
||||
label: 'Degree/Program',
|
||||
type: 'text',
|
||||
placeholder: 'e.g., Bachelor of Science in Computer Science',
|
||||
required: true,
|
||||
width: 'full',
|
||||
},
|
||||
{
|
||||
key: 'institution',
|
||||
label: 'Institution',
|
||||
type: 'text',
|
||||
placeholder: 'e.g., Technical University of Berlin',
|
||||
required: true,
|
||||
width: 'half',
|
||||
},
|
||||
{
|
||||
key: 'location',
|
||||
label: 'Location',
|
||||
type: 'text',
|
||||
placeholder: 'e.g., Berlin, Germany',
|
||||
required: true,
|
||||
width: 'half',
|
||||
},
|
||||
{
|
||||
key: 'startDate',
|
||||
label: 'Start Date',
|
||||
type: 'date',
|
||||
required: true,
|
||||
width: 'half',
|
||||
},
|
||||
{
|
||||
key: 'endDate',
|
||||
label: 'End Date',
|
||||
type: 'date',
|
||||
required: false,
|
||||
width: 'half',
|
||||
},
|
||||
{
|
||||
key: 'gpa',
|
||||
label: 'Grade/GPA',
|
||||
type: 'text',
|
||||
placeholder: 'e.g., 1.5 (German system) or 3.8/4.0',
|
||||
required: false,
|
||||
width: 'half',
|
||||
},
|
||||
{
|
||||
key: 'description',
|
||||
label: 'Description',
|
||||
type: 'textarea',
|
||||
placeholder: 'Relevant coursework, achievements, thesis topic...',
|
||||
required: false,
|
||||
width: 'full',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Skills form field configuration
|
||||
* Technical and soft skills entry
|
||||
*/
|
||||
export const skillsFormFields: FormField[] = [
|
||||
{
|
||||
key: 'name',
|
||||
label: 'Skill Name',
|
||||
type: 'text',
|
||||
placeholder: 'e.g., React.js, Leadership, German',
|
||||
required: true,
|
||||
width: 'half',
|
||||
},
|
||||
{
|
||||
key: 'category',
|
||||
label: 'Category',
|
||||
type: 'select',
|
||||
required: true,
|
||||
width: 'quarter',
|
||||
},
|
||||
{
|
||||
key: 'level',
|
||||
label: 'Proficiency Level',
|
||||
type: 'select',
|
||||
required: true,
|
||||
width: 'quarter',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Project form field configuration
|
||||
* Personal and professional projects
|
||||
*/
|
||||
export const projectFormFields: FormField[] = [
|
||||
{
|
||||
key: 'name',
|
||||
label: 'Project Name',
|
||||
type: 'text',
|
||||
placeholder: 'e.g., E-commerce Platform',
|
||||
required: true,
|
||||
width: 'full',
|
||||
},
|
||||
{
|
||||
key: 'role',
|
||||
label: 'Your Role',
|
||||
type: 'text',
|
||||
placeholder: 'e.g., Lead Developer, Team Member',
|
||||
required: true,
|
||||
width: 'half',
|
||||
},
|
||||
{
|
||||
key: 'technologies',
|
||||
label: 'Technologies Used',
|
||||
type: 'text',
|
||||
placeholder: 'e.g., React, Node.js, PostgreSQL',
|
||||
required: true,
|
||||
width: 'half',
|
||||
},
|
||||
{
|
||||
key: 'startDate',
|
||||
label: 'Start Date',
|
||||
type: 'date',
|
||||
required: false,
|
||||
width: 'half',
|
||||
},
|
||||
{
|
||||
key: 'endDate',
|
||||
label: 'End Date',
|
||||
type: 'date',
|
||||
required: false,
|
||||
width: 'half',
|
||||
},
|
||||
{
|
||||
key: 'url',
|
||||
label: 'Project URL',
|
||||
type: 'url',
|
||||
placeholder: 'https://github.com/username/project',
|
||||
required: false,
|
||||
width: 'full',
|
||||
},
|
||||
{
|
||||
key: 'description',
|
||||
label: 'Project Description',
|
||||
type: 'textarea',
|
||||
placeholder:
|
||||
'Describe the project goals, your contributions, and key achievements...',
|
||||
required: false,
|
||||
width: 'full',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Certification form field configuration
|
||||
* Professional certifications and licenses
|
||||
*/
|
||||
export const certificationFormFields: FormField[] = [
|
||||
{
|
||||
key: 'name',
|
||||
label: 'Certification Name',
|
||||
type: 'text',
|
||||
placeholder: 'e.g., AWS Certified Solutions Architect',
|
||||
required: true,
|
||||
width: 'full',
|
||||
},
|
||||
{
|
||||
key: 'issuer',
|
||||
label: 'Issuing Organization',
|
||||
type: 'text',
|
||||
placeholder: 'e.g., Amazon Web Services',
|
||||
required: true,
|
||||
width: 'half',
|
||||
},
|
||||
{
|
||||
key: 'issueDate',
|
||||
label: 'Issue Date',
|
||||
type: 'date',
|
||||
required: true,
|
||||
width: 'half',
|
||||
},
|
||||
{
|
||||
key: 'expiryDate',
|
||||
label: 'Expiry Date',
|
||||
type: 'date',
|
||||
required: false,
|
||||
width: 'half',
|
||||
},
|
||||
{
|
||||
key: 'credentialUrl',
|
||||
label: 'Credential URL',
|
||||
type: 'url',
|
||||
placeholder: 'https://credentials.example.com/cert-id',
|
||||
required: false,
|
||||
width: 'half',
|
||||
},
|
||||
{
|
||||
key: 'description',
|
||||
label: 'Description',
|
||||
type: 'textarea',
|
||||
placeholder: 'Brief description of the certification and skills gained...',
|
||||
required: false,
|
||||
width: 'full',
|
||||
},
|
||||
];
|
||||
168
src/data/formOptions.ts
Normal file
168
src/data/formOptions.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* Form Options and Configuration Data
|
||||
* Professional Resume Builder
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-07
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
|
||||
/**
|
||||
* Common option interface for select fields
|
||||
*/
|
||||
export interface SelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nationality options for European CV format
|
||||
* Ordered by relevance for German job market
|
||||
*/
|
||||
export const nationalityOptions: SelectOption[] = [
|
||||
{ value: 'German', label: 'German' },
|
||||
{ value: 'EU Citizen', label: 'EU Citizen' },
|
||||
{ value: 'Austrian', label: 'Austrian' },
|
||||
{ value: 'Swiss', label: 'Swiss' },
|
||||
{ value: 'French', label: 'French' },
|
||||
{ value: 'Italian', label: 'Italian' },
|
||||
{ value: 'Spanish', label: 'Spanish' },
|
||||
{ value: 'Dutch', label: 'Dutch' },
|
||||
{ value: 'Belgian', label: 'Belgian' },
|
||||
{ value: 'Polish', label: 'Polish' },
|
||||
{ value: 'Czech', label: 'Czech' },
|
||||
{ value: 'Hungarian', label: 'Hungarian' },
|
||||
{ value: 'Romanian', label: 'Romanian' },
|
||||
{ value: 'Bulgarian', label: 'Bulgarian' },
|
||||
{ value: 'Croatian', label: 'Croatian' },
|
||||
{ value: 'Non-EU', label: 'Non-EU' },
|
||||
{ value: 'American', label: 'American' },
|
||||
{ value: 'Canadian', label: 'Canadian' },
|
||||
{ value: 'British', label: 'British' },
|
||||
{ value: 'Australian', label: 'Australian' },
|
||||
{ value: 'Other', label: 'Other' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Marital status options according to European CV standards
|
||||
*/
|
||||
export const maritalStatusOptions: SelectOption[] = [
|
||||
{ value: 'Single', label: 'Single' },
|
||||
{ value: 'Married', label: 'Married' },
|
||||
{ value: 'Divorced', label: 'Divorced' },
|
||||
{ value: 'Widowed', label: 'Widowed' },
|
||||
{ value: 'Civil Partnership', label: 'Civil Partnership' },
|
||||
{ value: 'Other', label: 'Other' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Visa status options for German/European employment
|
||||
*/
|
||||
export const visaStatusOptions: SelectOption[] = [
|
||||
{ value: 'German Citizen', label: 'German Citizen' },
|
||||
{ value: 'EU Citizen', label: 'EU Citizen' },
|
||||
{ value: 'Blue Card', label: 'EU Blue Card' },
|
||||
{ value: 'Work Permit', label: 'Work Permit' },
|
||||
{ value: 'Student Visa', label: 'Student Visa (Working Rights)' },
|
||||
{ value: 'Visa Required', label: 'Visa Required' },
|
||||
{ value: 'Other', label: 'Other' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Skill categories for professional development
|
||||
* Organized by modern tech industry standards
|
||||
*/
|
||||
export const skillCategories: string[] = [
|
||||
'Programming Languages',
|
||||
'Frontend Development',
|
||||
'Backend Development',
|
||||
'Full Stack Development',
|
||||
'Mobile Development',
|
||||
'DevOps & Cloud',
|
||||
'Database Technologies',
|
||||
'Frameworks & Libraries',
|
||||
'Tools & Technologies',
|
||||
'Design & UI/UX',
|
||||
'Data Science & Analytics',
|
||||
'Machine Learning & AI',
|
||||
'Quality Assurance',
|
||||
'Project Management',
|
||||
'Soft Skills',
|
||||
'Communication',
|
||||
'Languages',
|
||||
'Certifications',
|
||||
'Other',
|
||||
];
|
||||
|
||||
/**
|
||||
* Proficiency levels with European framework alignment
|
||||
*/
|
||||
export const proficiencyLevels: SelectOption[] = [
|
||||
{ value: 'Beginner', label: 'Beginner (A1-A2)' },
|
||||
{ value: 'Intermediate', label: 'Intermediate (B1-B2)' },
|
||||
{ value: 'Advanced', label: 'Advanced (C1)' },
|
||||
{ value: 'Expert', label: 'Expert/Native (C2)' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Language proficiency levels following CEFR standards
|
||||
*/
|
||||
export const languageProficiencyLevels: SelectOption[] = [
|
||||
{ value: 'A1', label: 'A1 - Beginner' },
|
||||
{ value: 'A2', label: 'A2 - Elementary' },
|
||||
{ value: 'B1', label: 'B1 - Intermediate' },
|
||||
{ value: 'B2', label: 'B2 - Upper Intermediate' },
|
||||
{ value: 'C1', label: 'C1 - Advanced' },
|
||||
{ value: 'C2', label: 'C2 - Proficient' },
|
||||
{ value: 'Native', label: 'Native Speaker' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Common European countries for location/experience fields
|
||||
*/
|
||||
export const europeanCountries: SelectOption[] = [
|
||||
{ value: 'Germany', label: 'Germany' },
|
||||
{ value: 'Austria', label: 'Austria' },
|
||||
{ value: 'Switzerland', label: 'Switzerland' },
|
||||
{ value: 'Netherlands', label: 'Netherlands' },
|
||||
{ value: 'Belgium', label: 'Belgium' },
|
||||
{ value: 'France', label: 'France' },
|
||||
{ value: 'Italy', label: 'Italy' },
|
||||
{ value: 'Spain', label: 'Spain' },
|
||||
{ value: 'Portugal', label: 'Portugal' },
|
||||
{ value: 'United Kingdom', label: 'United Kingdom' },
|
||||
{ value: 'Ireland', label: 'Ireland' },
|
||||
{ value: 'Denmark', label: 'Denmark' },
|
||||
{ value: 'Sweden', label: 'Sweden' },
|
||||
{ value: 'Norway', label: 'Norway' },
|
||||
{ value: 'Finland', label: 'Finland' },
|
||||
{ value: 'Poland', label: 'Poland' },
|
||||
{ value: 'Czech Republic', label: 'Czech Republic' },
|
||||
{ value: 'Hungary', label: 'Hungary' },
|
||||
{ value: 'Romania', label: 'Romania' },
|
||||
{ value: 'Bulgaria', label: 'Bulgaria' },
|
||||
{ value: 'Croatia', label: 'Croatia' },
|
||||
{ value: 'Other', label: 'Other' },
|
||||
];
|
||||
|
||||
/**
|
||||
* German major cities for location fields
|
||||
*/
|
||||
export const germanCities: SelectOption[] = [
|
||||
{ value: 'Berlin', label: 'Berlin' },
|
||||
{ value: 'Munich', label: 'Munich (München)' },
|
||||
{ value: 'Hamburg', label: 'Hamburg' },
|
||||
{ value: 'Frankfurt am Main', label: 'Frankfurt am Main' },
|
||||
{ value: 'Cologne', label: 'Cologne (Köln)' },
|
||||
{ value: 'Stuttgart', label: 'Stuttgart' },
|
||||
{ value: 'Düsseldorf', label: 'Düsseldorf' },
|
||||
{ value: 'Leipzig', label: 'Leipzig' },
|
||||
{ value: 'Dortmund', label: 'Dortmund' },
|
||||
{ value: 'Essen', label: 'Essen' },
|
||||
{ value: 'Dresden', label: 'Dresden' },
|
||||
{ value: 'Bremen', label: 'Bremen' },
|
||||
{ value: 'Hannover', label: 'Hannover' },
|
||||
{ value: 'Nuremberg', label: 'Nuremberg (Nürnberg)' },
|
||||
{ value: 'Duisburg', label: 'Duisburg' },
|
||||
{ value: 'Other', label: 'Other' },
|
||||
];
|
||||
23
src/data/index.ts
Normal file
23
src/data/index.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Data Module Exports
|
||||
* Professional Resume Builder - Centralized Data Management
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-07
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
|
||||
// Form Options and Configuration
|
||||
export * from './formOptions';
|
||||
|
||||
// Form Field Configurations
|
||||
export * from './formFields';
|
||||
|
||||
// UI Text Constants and Messages
|
||||
export * from './uiConstants';
|
||||
|
||||
// Resume Data Templates
|
||||
export * from './resumeTemplates';
|
||||
|
||||
// Legacy support - keep existing sample data available
|
||||
export * from './sampleResumeData';
|
||||
480
src/data/resumeTemplates.ts
Normal file
480
src/data/resumeTemplates.ts
Normal file
@@ -0,0 +1,480 @@
|
||||
/**
|
||||
* Resume Data Templates
|
||||
* Professional Resume Builder
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-07
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
|
||||
import { ResumeData } from '@/types/resume';
|
||||
|
||||
/**
|
||||
* Empty resume data template with proper structure
|
||||
* Provides a clean starting point for new resumes
|
||||
*/
|
||||
export const emptyResumeTemplate: ResumeData = {
|
||||
personalInfo: {
|
||||
// Basic Information
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
jobTitle: '',
|
||||
|
||||
// Contact Information
|
||||
email: '',
|
||||
phone: '',
|
||||
street: '',
|
||||
zipCode: '',
|
||||
city: '',
|
||||
country: '',
|
||||
location: '', // Keep for backward compatibility
|
||||
|
||||
// Professional Links
|
||||
website: '',
|
||||
linkedin: '',
|
||||
github: '',
|
||||
|
||||
// Personal Details (Modern CV style)
|
||||
nationality: '',
|
||||
dateOfBirth: '',
|
||||
placeOfBirth: '',
|
||||
maritalStatus: undefined,
|
||||
visaStatus: undefined,
|
||||
|
||||
// Professional Summary
|
||||
summary: '',
|
||||
},
|
||||
experience: [],
|
||||
education: [],
|
||||
skills: [],
|
||||
languages: [],
|
||||
certifications: [],
|
||||
projects: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* Demo resume template with realistic German tech worker data
|
||||
* Perfect for demonstrations and testing
|
||||
*/
|
||||
export const demoResumeTemplate: ResumeData = {
|
||||
personalInfo: {
|
||||
firstName: 'Max',
|
||||
lastName: 'Müller',
|
||||
jobTitle: 'Senior Full Stack Developer',
|
||||
email: 'max.mueller@example.de',
|
||||
phone: '+49 30 12345678',
|
||||
street: 'Alexanderstraße',
|
||||
zipCode: '10178',
|
||||
city: 'Berlin',
|
||||
country: 'Germany',
|
||||
location: 'Berlin, Germany',
|
||||
website: 'https://maxmueller.dev',
|
||||
linkedin: 'linkedin.com/in/maxmueller-dev',
|
||||
github: 'github.com/maxmueller',
|
||||
nationality: 'German',
|
||||
dateOfBirth: '1990-03-15',
|
||||
maritalStatus: 'Single',
|
||||
visaStatus: 'German Citizen',
|
||||
summary:
|
||||
'Experienced Full Stack Developer with 6+ years in modern web technologies. Specialized in React, Node.js, and cloud architectures. Passionate about clean code, user experience, and team collaboration in agile environments.',
|
||||
},
|
||||
experience: [
|
||||
{
|
||||
id: '1',
|
||||
position: 'Senior Full Stack Developer',
|
||||
company: 'TechStart Berlin GmbH',
|
||||
location: 'Berlin, Germany',
|
||||
startDate: '2022-03',
|
||||
endDate: '',
|
||||
isCurrentPosition: true,
|
||||
description:
|
||||
'Lead development of scalable web applications using modern JavaScript stack. Mentor junior developers and collaborate with product teams on technical solutions.',
|
||||
achievements: [
|
||||
'Architected and built a microservices platform serving 100,000+ daily active users',
|
||||
'Reduced application loading time by 60% through code optimization and caching strategies',
|
||||
'Led migration from monolithic to microservices architecture',
|
||||
'Mentored 3 junior developers and established code review processes',
|
||||
'Implemented CI/CD pipelines reducing deployment time from hours to minutes',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
position: 'Full Stack Developer',
|
||||
company: 'Digital Solutions AG',
|
||||
location: 'Munich, Germany',
|
||||
startDate: '2019-08',
|
||||
endDate: '2022-02',
|
||||
isCurrentPosition: false,
|
||||
description:
|
||||
'Developed and maintained various client projects using React, Node.js, and PostgreSQL. Collaborated with design and product teams to deliver high-quality solutions.',
|
||||
achievements: [
|
||||
'Delivered 15+ successful client projects on time and within budget',
|
||||
'Improved team productivity by implementing automated testing workflows',
|
||||
'Built responsive web applications for automotive and fintech industries',
|
||||
'Integrated third-party APIs and payment systems (Stripe, PayPal)',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
position: 'Junior Frontend Developer',
|
||||
company: 'WebCraft Studio',
|
||||
location: 'Hamburg, Germany',
|
||||
startDate: '2018-06',
|
||||
endDate: '2019-07',
|
||||
isCurrentPosition: false,
|
||||
description:
|
||||
'Started career focusing on frontend development with React and modern CSS. Worked on e-commerce and corporate websites.',
|
||||
achievements: [
|
||||
'Contributed to 10+ successful website launches',
|
||||
'Learned modern frontend technologies and best practices',
|
||||
'Collaborated with senior developers on complex user interfaces',
|
||||
],
|
||||
},
|
||||
],
|
||||
education: [
|
||||
{
|
||||
id: '1',
|
||||
degree: 'Bachelor of Science in Computer Science',
|
||||
institution: 'Technical University of Berlin',
|
||||
location: 'Berlin, Germany',
|
||||
startDate: '2014',
|
||||
endDate: '2018',
|
||||
gpa: '1.8',
|
||||
description:
|
||||
'Focus on software engineering, web technologies, and database systems. Thesis: "Performance Optimization in Single Page Applications"',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
degree: 'Abitur (German High School Diploma)',
|
||||
institution: 'Gymnasium Berlin-Mitte',
|
||||
location: 'Berlin, Germany',
|
||||
startDate: '2012',
|
||||
endDate: '2014',
|
||||
gpa: '1.5',
|
||||
description: 'Mathematics and Computer Science focus',
|
||||
},
|
||||
],
|
||||
skills: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'JavaScript',
|
||||
category: 'Programming Languages',
|
||||
level: 'Expert',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'TypeScript',
|
||||
category: 'Programming Languages',
|
||||
level: 'Advanced',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Python',
|
||||
category: 'Programming Languages',
|
||||
level: 'Intermediate',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: 'React.js',
|
||||
category: 'Frontend Development',
|
||||
level: 'Expert',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
name: 'Next.js',
|
||||
category: 'Frontend Development',
|
||||
level: 'Advanced',
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
name: 'Vue.js',
|
||||
category: 'Frontend Development',
|
||||
level: 'Intermediate',
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
name: 'Node.js',
|
||||
category: 'Backend Development',
|
||||
level: 'Advanced',
|
||||
},
|
||||
{
|
||||
id: '8',
|
||||
name: 'Express.js',
|
||||
category: 'Backend Development',
|
||||
level: 'Advanced',
|
||||
},
|
||||
{
|
||||
id: '9',
|
||||
name: 'PostgreSQL',
|
||||
category: 'Database Technologies',
|
||||
level: 'Advanced',
|
||||
},
|
||||
{
|
||||
id: '10',
|
||||
name: 'MongoDB',
|
||||
category: 'Database Technologies',
|
||||
level: 'Intermediate',
|
||||
},
|
||||
{
|
||||
id: '11',
|
||||
name: 'AWS',
|
||||
category: 'DevOps & Cloud',
|
||||
level: 'Intermediate',
|
||||
},
|
||||
{
|
||||
id: '12',
|
||||
name: 'Docker',
|
||||
category: 'DevOps & Cloud',
|
||||
level: 'Intermediate',
|
||||
},
|
||||
{
|
||||
id: '13',
|
||||
name: 'Git',
|
||||
category: 'Tools & Technologies',
|
||||
level: 'Advanced',
|
||||
},
|
||||
{
|
||||
id: '14',
|
||||
name: 'Tailwind CSS',
|
||||
category: 'Frontend Development',
|
||||
level: 'Advanced',
|
||||
},
|
||||
{
|
||||
id: '15',
|
||||
name: 'Team Leadership',
|
||||
category: 'Soft Skills',
|
||||
level: 'Advanced',
|
||||
},
|
||||
{
|
||||
id: '16',
|
||||
name: 'Agile/Scrum',
|
||||
category: 'Project Management',
|
||||
level: 'Advanced',
|
||||
},
|
||||
],
|
||||
languages: [
|
||||
{ id: '1', name: 'German', proficiency: 'Native' },
|
||||
{ id: '2', name: 'English', proficiency: 'C1' },
|
||||
{ id: '3', name: 'Spanish', proficiency: 'B1' },
|
||||
],
|
||||
certifications: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'AWS Certified Solutions Architect - Associate',
|
||||
issuer: 'Amazon Web Services',
|
||||
issueDate: '2023-05-15',
|
||||
expirationDate: '2026-05-15',
|
||||
credentialId: 'AWS-SAA-XXXXX',
|
||||
url: 'https://aws.amazon.com/verification/XXXXX',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Professional Scrum Master I (PSM I)',
|
||||
issuer: 'Scrum.org',
|
||||
issueDate: '2022-11-20',
|
||||
credentialId: 'PSM-I-XXXXX',
|
||||
url: 'https://scrum.org/certificates/XXXXX',
|
||||
},
|
||||
],
|
||||
projects: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'E-Commerce Platform',
|
||||
description:
|
||||
'Full-stack e-commerce solution with inventory management, payment processing, and admin dashboard. Built with modern tech stack and deployed on AWS.',
|
||||
technologies: ['React', 'Node.js', 'PostgreSQL', 'Stripe API'],
|
||||
startDate: '2023-01',
|
||||
endDate: '2023-08',
|
||||
url: 'https://github.com/maxmueller/ecommerce-platform',
|
||||
achievements: [],
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Task Management App',
|
||||
description:
|
||||
'Collaborative task management application with real-time updates, team collaboration features, and responsive design.',
|
||||
technologies: ['Next.js', 'TypeScript', 'Supabase'],
|
||||
startDate: '2022-06',
|
||||
endDate: '2022-10',
|
||||
url: 'https://taskmanager.maxmueller.dev',
|
||||
achievements: [],
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Open Source Component Library',
|
||||
description:
|
||||
'Reusable React component library with comprehensive documentation and automated testing. Used by 500+ developers.',
|
||||
technologies: ['React', 'TypeScript', 'Storybook', 'npm'],
|
||||
startDate: '2021-09',
|
||||
endDate: null,
|
||||
url: 'https://github.com/maxmueller/ui-components',
|
||||
achievements: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a personalized resume template based on role
|
||||
*/
|
||||
export const createRoleBasedTemplate = (
|
||||
role: 'frontend' | 'backend' | 'fullstack' | 'mobile' | 'devops'
|
||||
): ResumeData => {
|
||||
const baseTemplate = { ...emptyResumeTemplate };
|
||||
|
||||
const roleSkills = {
|
||||
frontend: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'React.js',
|
||||
category: 'Frontend Development',
|
||||
level: 'Advanced',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'TypeScript',
|
||||
category: 'Programming Languages',
|
||||
level: 'Advanced',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'CSS/SASS',
|
||||
category: 'Frontend Development',
|
||||
level: 'Advanced',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: 'JavaScript',
|
||||
category: 'Programming Languages',
|
||||
level: 'Expert',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
name: 'HTML5',
|
||||
category: 'Frontend Development',
|
||||
level: 'Expert',
|
||||
},
|
||||
],
|
||||
backend: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Node.js',
|
||||
category: 'Backend Development',
|
||||
level: 'Advanced',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Python',
|
||||
category: 'Programming Languages',
|
||||
level: 'Advanced',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'PostgreSQL',
|
||||
category: 'Database Technologies',
|
||||
level: 'Advanced',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: 'REST APIs',
|
||||
category: 'Backend Development',
|
||||
level: 'Advanced',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
name: 'Docker',
|
||||
category: 'DevOps & Cloud',
|
||||
level: 'Intermediate',
|
||||
},
|
||||
],
|
||||
fullstack: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'JavaScript',
|
||||
category: 'Programming Languages',
|
||||
level: 'Expert',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'React.js',
|
||||
category: 'Frontend Development',
|
||||
level: 'Advanced',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Node.js',
|
||||
category: 'Backend Development',
|
||||
level: 'Advanced',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: 'PostgreSQL',
|
||||
category: 'Database Technologies',
|
||||
level: 'Advanced',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
name: 'TypeScript',
|
||||
category: 'Programming Languages',
|
||||
level: 'Advanced',
|
||||
},
|
||||
],
|
||||
mobile: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'React Native',
|
||||
category: 'Mobile Development',
|
||||
level: 'Advanced',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Swift',
|
||||
category: 'Programming Languages',
|
||||
level: 'Intermediate',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Kotlin',
|
||||
category: 'Programming Languages',
|
||||
level: 'Intermediate',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: 'JavaScript',
|
||||
category: 'Programming Languages',
|
||||
level: 'Advanced',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
name: 'Mobile UI/UX',
|
||||
category: 'Design & UI/UX',
|
||||
level: 'Advanced',
|
||||
},
|
||||
],
|
||||
devops: [
|
||||
{ id: '1', name: 'AWS', category: 'DevOps & Cloud', level: 'Advanced' },
|
||||
{
|
||||
id: '2',
|
||||
name: 'Docker',
|
||||
category: 'DevOps & Cloud',
|
||||
level: 'Advanced',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Kubernetes',
|
||||
category: 'DevOps & Cloud',
|
||||
level: 'Intermediate',
|
||||
},
|
||||
{ id: '4', name: 'CI/CD', category: 'DevOps & Cloud', level: 'Advanced' },
|
||||
{
|
||||
id: '5',
|
||||
name: 'Linux',
|
||||
category: 'Tools & Technologies',
|
||||
level: 'Advanced',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
baseTemplate.skills = roleSkills[role] as any[];
|
||||
|
||||
return baseTemplate;
|
||||
};
|
||||
139
src/data/sampleResumeData.ts
Normal file
139
src/data/sampleResumeData.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Sample Resume Data Service
|
||||
* Resume Builder Application
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-07
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
|
||||
import { ResumeData } from '@/types/resume';
|
||||
|
||||
/**
|
||||
* Sample resume data for preview and testing purposes
|
||||
*
|
||||
* This data serves as a template and demonstration of the resume structure.
|
||||
* It can be used for:
|
||||
* - Preview functionality
|
||||
* - Testing components
|
||||
* - Default template for new resumes
|
||||
*/
|
||||
export const sampleResumeData: ResumeData = {
|
||||
personalInfo: {
|
||||
firstName: 'David',
|
||||
lastName: 'Valera Melendez',
|
||||
jobTitle: 'Senior Frontend Developer',
|
||||
email: 'david@valera-melendez.de',
|
||||
phone: '+49 123 456 7890',
|
||||
city: 'Berlin',
|
||||
country: 'Deutschland',
|
||||
location: 'Berlin, Deutschland',
|
||||
dateOfBirth: '1990-01-01',
|
||||
nationality: 'German',
|
||||
maritalStatus: 'Single' as any,
|
||||
summary:
|
||||
'Experienced Frontend Developer specializing in React and TypeScript',
|
||||
},
|
||||
experience: [
|
||||
{
|
||||
id: '1',
|
||||
position: 'Senior Frontend Developer',
|
||||
company: 'Tech Company GmbH',
|
||||
location: 'Berlin, Germany',
|
||||
startDate: '2022-01',
|
||||
endDate: '',
|
||||
isCurrentPosition: true,
|
||||
description:
|
||||
'Entwicklung moderner Webanwendungen mit React und TypeScript',
|
||||
achievements: [
|
||||
'Leitung eines 5-köpfigen Entwicklerteams',
|
||||
'Implementierung einer neuen Architektur mit 40% besserer Performance',
|
||||
'Einführung von Best Practices und Code Reviews',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
position: 'Frontend Developer',
|
||||
company: 'Digital Agency Berlin',
|
||||
location: 'Berlin, Germany',
|
||||
startDate: '2020-06',
|
||||
endDate: '2021-12',
|
||||
isCurrentPosition: false,
|
||||
description: 'Entwicklung responsiver Websites und Webanwendungen',
|
||||
achievements: [
|
||||
'Entwicklung von 15+ erfolgreichen Kundenprojekten',
|
||||
'Optimierung der Ladezeiten um durchschnittlich 60%',
|
||||
],
|
||||
},
|
||||
],
|
||||
education: [
|
||||
{
|
||||
id: '1',
|
||||
degree: 'Bachelor of Science in Computer Science',
|
||||
institution: 'Technische Universität Berlin',
|
||||
location: 'Berlin, Germany',
|
||||
startDate: '2016',
|
||||
endDate: '2020',
|
||||
description: 'Schwerpunkt: Softwareentwicklung und Web-Technologies',
|
||||
},
|
||||
],
|
||||
skills: [
|
||||
{ id: '1', name: 'React', category: 'Frontend', level: 'Advanced' },
|
||||
{ id: '2', name: 'TypeScript', category: 'Frontend', level: 'Advanced' },
|
||||
{ id: '3', name: 'Next.js', category: 'Frontend', level: 'Advanced' },
|
||||
{ id: '4', name: 'Tailwind CSS', category: 'Frontend', level: 'Advanced' },
|
||||
{ id: '5', name: 'Node.js', category: 'Backend', level: 'Intermediate' },
|
||||
{
|
||||
id: '6',
|
||||
name: 'PostgreSQL',
|
||||
category: 'Database',
|
||||
level: 'Intermediate',
|
||||
},
|
||||
],
|
||||
languages: [
|
||||
{ id: '1', name: 'Deutsch', proficiency: 'Native' },
|
||||
{ id: '2', name: 'English', proficiency: 'C2' },
|
||||
{ id: '3', name: 'Español', proficiency: 'A2' },
|
||||
],
|
||||
certifications: [],
|
||||
projects: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* Get sample resume data
|
||||
*
|
||||
* @returns Sample resume data object
|
||||
*/
|
||||
export const getSampleResumeData = (): ResumeData => {
|
||||
return sampleResumeData;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create empty resume data template
|
||||
*
|
||||
* @returns Empty resume data structure
|
||||
*/
|
||||
export const createEmptyResumeData = (): ResumeData => {
|
||||
return {
|
||||
personalInfo: {
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
jobTitle: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
city: '',
|
||||
country: '',
|
||||
location: '',
|
||||
dateOfBirth: '',
|
||||
nationality: '',
|
||||
maritalStatus: 'Single' as any,
|
||||
summary: '',
|
||||
},
|
||||
experience: [],
|
||||
education: [],
|
||||
skills: [],
|
||||
languages: [],
|
||||
certifications: [],
|
||||
projects: [],
|
||||
};
|
||||
};
|
||||
225
src/data/uiConstants.ts
Normal file
225
src/data/uiConstants.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* UI Text Constants and Messages
|
||||
* Professional Resume Builder
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-07
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
|
||||
/**
|
||||
* Placeholder text constants for form fields
|
||||
* Consistent placeholder text across the application
|
||||
*/
|
||||
export const placeholders = {
|
||||
// Personal Information
|
||||
firstName: 'Enter your first name',
|
||||
lastName: 'Enter your last name',
|
||||
jobTitle: 'e.g., Senior Frontend Developer',
|
||||
email: 'your.email@example.com',
|
||||
phone: '+49 123 456 7890',
|
||||
|
||||
// Address
|
||||
street: 'Musterstraße',
|
||||
houseNumber: '123',
|
||||
zipCode: '12345',
|
||||
city: 'Berlin',
|
||||
country: 'Deutschland',
|
||||
|
||||
// Professional Links
|
||||
website: 'https://your-website.com',
|
||||
linkedin: 'linkedin.com/in/yourprofile',
|
||||
github: 'github.com/yourusername',
|
||||
|
||||
// Location
|
||||
location: 'Berlin, Germany',
|
||||
|
||||
// Professional Summary
|
||||
summary: 'Write a brief professional summary about yourself...',
|
||||
|
||||
// Experience
|
||||
company: 'Tech Innovation GmbH',
|
||||
position: 'Senior Frontend Developer',
|
||||
workLocation: 'Berlin, Germany',
|
||||
jobDescription: 'Describe your main responsibilities and achievements...',
|
||||
|
||||
// Education
|
||||
degree: 'Bachelor of Science in Computer Science',
|
||||
institution: 'Technical University of Berlin',
|
||||
educationLocation: 'Berlin, Germany',
|
||||
gpa: '1.5 (German system) or 3.8/4.0',
|
||||
academicDescription: 'Relevant coursework, achievements, thesis topic...',
|
||||
|
||||
// Skills
|
||||
skillName: 'React.js',
|
||||
|
||||
// Languages
|
||||
languageInput: 'English (Native), Spanish (C1), German (B2)',
|
||||
|
||||
// Certifications
|
||||
certificationInput: 'List your certifications, one per line',
|
||||
|
||||
// Projects
|
||||
projectName: 'E-commerce Platform',
|
||||
projectRole: 'Lead Developer',
|
||||
projectTechnologies: 'React, Node.js, PostgreSQL',
|
||||
projectUrl: 'https://github.com/username/project',
|
||||
projectDescription:
|
||||
'Describe the project goals, your contributions, and key achievements...',
|
||||
|
||||
// Generic
|
||||
description: 'Enter description...',
|
||||
achievements: 'List your key achievements...',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Empty state messages for different sections
|
||||
*/
|
||||
export const emptyStateMessages = {
|
||||
experience: {
|
||||
title: 'No work experience added yet',
|
||||
description:
|
||||
'Click "Add Experience" to get started with your professional history',
|
||||
icon: '💼',
|
||||
},
|
||||
education: {
|
||||
title: 'No education added yet',
|
||||
description: 'Click "Add Education" to include your academic background',
|
||||
icon: '🎓',
|
||||
},
|
||||
skills: {
|
||||
title: 'No skills added yet',
|
||||
description:
|
||||
'Add your technical and soft skills to showcase your expertise',
|
||||
icon: '⚡',
|
||||
},
|
||||
languages: {
|
||||
title: 'No languages added yet',
|
||||
description: 'Add your language skills with proficiency levels',
|
||||
icon: '🌍',
|
||||
},
|
||||
certifications: {
|
||||
title: 'No certifications added yet',
|
||||
description: 'Add your professional certifications and licenses',
|
||||
icon: '🏆',
|
||||
},
|
||||
projects: {
|
||||
title: 'No projects added yet',
|
||||
description: 'Showcase your personal and professional projects',
|
||||
icon: '🚀',
|
||||
},
|
||||
achievements: {
|
||||
title: 'No achievements added yet',
|
||||
description: 'Add key accomplishments and achievements',
|
||||
icon: '🎯',
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Form section titles and descriptions
|
||||
*/
|
||||
export const sectionTitles = {
|
||||
personalInfo: {
|
||||
title: 'Personal Information',
|
||||
description: 'Your name and basic details',
|
||||
},
|
||||
professionalInfo: {
|
||||
title: 'Professional Information',
|
||||
description: 'Job title and profile image',
|
||||
},
|
||||
contactInfo: {
|
||||
title: 'Contact Information',
|
||||
description: 'Email, phone, and address',
|
||||
},
|
||||
addressInfo: {
|
||||
title: 'Address',
|
||||
description: 'Your current location',
|
||||
},
|
||||
onlinePresence: {
|
||||
title: 'Online Presence',
|
||||
description: 'Your professional links',
|
||||
},
|
||||
personalDetails: {
|
||||
title: 'Personal Details',
|
||||
description: 'Additional personal information',
|
||||
},
|
||||
experience: {
|
||||
title: 'Work Experience',
|
||||
description: 'Your professional work history',
|
||||
},
|
||||
education: {
|
||||
title: 'Education',
|
||||
description: 'Your academic background',
|
||||
},
|
||||
skills: {
|
||||
title: 'Skills',
|
||||
description: 'Technical and soft skills',
|
||||
},
|
||||
languages: {
|
||||
title: 'Languages',
|
||||
description: 'Language skills with proficiency levels',
|
||||
},
|
||||
certifications: {
|
||||
title: 'Certifications',
|
||||
description: 'Professional certifications and licenses',
|
||||
},
|
||||
projects: {
|
||||
title: 'Projects',
|
||||
description: 'Personal and professional projects',
|
||||
},
|
||||
summary: {
|
||||
title: 'Professional Summary',
|
||||
description: 'Brief overview of your career and goals',
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Button text constants
|
||||
*/
|
||||
export const buttonTexts = {
|
||||
add: 'Add',
|
||||
addExperience: 'Add Experience',
|
||||
addEducation: 'Add Education',
|
||||
addSkill: 'Add Skill',
|
||||
addLanguage: 'Add Language',
|
||||
addCertification: 'Add Certification',
|
||||
addProject: 'Add Project',
|
||||
addAchievement: 'Add Achievement',
|
||||
edit: 'Edit',
|
||||
save: 'Save',
|
||||
cancel: 'Cancel',
|
||||
delete: 'Delete',
|
||||
done: 'Done',
|
||||
upload: 'Upload',
|
||||
uploadImage: 'Upload Profile Image',
|
||||
remove: 'Remove',
|
||||
preview: 'Preview',
|
||||
export: 'Export PDF',
|
||||
back: 'Back',
|
||||
next: 'Next',
|
||||
finish: 'Finish',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Validation messages
|
||||
*/
|
||||
export const validationMessages = {
|
||||
required: 'This field is required',
|
||||
emailInvalid: 'Please enter a valid email address',
|
||||
phoneInvalid: 'Please enter a valid phone number',
|
||||
urlInvalid: 'Please enter a valid URL',
|
||||
dateInvalid: 'Please enter a valid date',
|
||||
minLength: (min: number) => `Must be at least ${min} characters`,
|
||||
maxLength: (max: number) => `Must be no more than ${max} characters`,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Step descriptions for the resume builder process
|
||||
*/
|
||||
export const stepDescriptions = {
|
||||
contact: 'Add your personal and contact information',
|
||||
experience: 'Document your professional work history',
|
||||
education: 'Include your educational background',
|
||||
skills: 'Showcase your technical and soft skills',
|
||||
about: 'Write a professional summary and add additional details',
|
||||
} as const;
|
||||
314
src/hooks/auth.ts
Normal file
314
src/hooks/auth.ts
Normal file
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* Route Protection Hooks
|
||||
* Professional Next.js Resume Builder - Enterprise Auth System
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-08
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
import { useRouter, usePathname } from 'next/navigation';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
import { UserRole } from '@/types/auth';
|
||||
|
||||
/**
|
||||
* Hook to protect routes that require authentication
|
||||
*/
|
||||
export function useAuthGuard(options?: {
|
||||
requiredRoles?: UserRole[];
|
||||
redirectTo?: string;
|
||||
checkOnMount?: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { isAuthenticated, user, checkAuth } = useAuthStore();
|
||||
|
||||
const {
|
||||
requiredRoles = [],
|
||||
redirectTo = '/auth/login',
|
||||
checkOnMount = true,
|
||||
} = options || {};
|
||||
|
||||
useEffect(() => {
|
||||
if (checkOnMount) {
|
||||
checkAuth();
|
||||
}
|
||||
}, [checkAuth, checkOnMount]);
|
||||
|
||||
useEffect(() => {
|
||||
// Skip check during initial load
|
||||
if (checkOnMount && !isAuthenticated && !user) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check authentication
|
||||
if (!isAuthenticated) {
|
||||
const loginUrl = new URL(redirectTo, window.location.origin);
|
||||
loginUrl.searchParams.set('returnUrl', pathname);
|
||||
router.push(loginUrl.toString() as any);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check role requirements
|
||||
if (requiredRoles.length > 0 && user) {
|
||||
const hasRequiredRole = requiredRoles.some(role =>
|
||||
user.roles.includes(role)
|
||||
);
|
||||
|
||||
if (!hasRequiredRole) {
|
||||
router.push('/access-denied');
|
||||
return;
|
||||
}
|
||||
}
|
||||
}, [isAuthenticated, user, router, pathname, requiredRoles, redirectTo]);
|
||||
|
||||
return {
|
||||
isAuthenticated,
|
||||
user,
|
||||
hasRequiredRole: (role: UserRole) => user?.roles.includes(role) || false,
|
||||
hasAnyRole: (roles: UserRole[]) => roles.some(role => user?.roles.includes(role)) || false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to protect routes that should only be accessible to guests (non-authenticated users)
|
||||
*/
|
||||
export function useGuestGuard(redirectTo: string = '/dashboard') {
|
||||
const router = useRouter();
|
||||
const { isAuthenticated, checkAuth } = useAuthStore();
|
||||
|
||||
useEffect(() => {
|
||||
checkAuth();
|
||||
}, [checkAuth]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
router.push(redirectTo as any);
|
||||
}
|
||||
}, [isAuthenticated, router, redirectTo]);
|
||||
|
||||
return { isAuthenticated };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to check if user has specific permissions
|
||||
*/
|
||||
export function usePermissions() {
|
||||
const { user } = useAuthStore();
|
||||
|
||||
const hasPermission = (permission: string): boolean => {
|
||||
return user?.permissions?.includes(permission) || false;
|
||||
};
|
||||
|
||||
const hasRole = (role: UserRole): boolean => {
|
||||
return user?.roles?.includes(role) || false;
|
||||
};
|
||||
|
||||
const hasAnyRole = (roles: UserRole[]): boolean => {
|
||||
return roles.some(role => hasRole(role));
|
||||
};
|
||||
|
||||
const hasAllRoles = (roles: UserRole[]): boolean => {
|
||||
return roles.every(role => hasRole(role));
|
||||
};
|
||||
|
||||
const isAdmin = (): boolean => {
|
||||
return hasRole(UserRole.ADMIN);
|
||||
};
|
||||
|
||||
const isModerator = (): boolean => {
|
||||
return hasRole(UserRole.MODERATOR);
|
||||
};
|
||||
|
||||
const canAccess = (resource: string, action: string): boolean => {
|
||||
const permission = `${resource}:${action}`;
|
||||
return hasPermission(permission) || isAdmin();
|
||||
};
|
||||
|
||||
return {
|
||||
user,
|
||||
hasPermission,
|
||||
hasRole,
|
||||
hasAnyRole,
|
||||
hasAllRoles,
|
||||
isAdmin,
|
||||
isModerator,
|
||||
canAccess,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for handling authentication redirects
|
||||
*/
|
||||
export function useAuthRedirect() {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
const redirectToLogin = (returnUrl?: string) => {
|
||||
const loginUrl = new URL('/auth/login', window.location.origin);
|
||||
loginUrl.searchParams.set('returnUrl', returnUrl || pathname);
|
||||
router.push(loginUrl.toString() as any);
|
||||
};
|
||||
|
||||
const redirectToDashboard = () => {
|
||||
router.push('/dashboard');
|
||||
};
|
||||
|
||||
const redirectToReturnUrl = (defaultUrl: string = '/dashboard') => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const returnUrl = urlParams.get('returnUrl');
|
||||
|
||||
if (returnUrl && returnUrl.startsWith('/') && !returnUrl.startsWith('//')) {
|
||||
router.push(returnUrl as any);
|
||||
} else {
|
||||
router.push(defaultUrl as any);
|
||||
}
|
||||
};
|
||||
|
||||
const redirectToAccessDenied = () => {
|
||||
router.push('/access-denied');
|
||||
};
|
||||
|
||||
return {
|
||||
redirectToLogin,
|
||||
redirectToDashboard,
|
||||
redirectToReturnUrl,
|
||||
redirectToAccessDenied,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to check authentication status and handle loading states
|
||||
*/
|
||||
export function useAuthStatus() {
|
||||
const {
|
||||
isAuthenticated,
|
||||
isLoading,
|
||||
user,
|
||||
error,
|
||||
checkAuth,
|
||||
clearError
|
||||
} = useAuthStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated && !isLoading) {
|
||||
checkAuth();
|
||||
}
|
||||
}, [isAuthenticated, isLoading, checkAuth]);
|
||||
|
||||
return {
|
||||
isAuthenticated,
|
||||
isLoading,
|
||||
user,
|
||||
error,
|
||||
clearError,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for checking specific route permissions
|
||||
*/
|
||||
export function useRoutePermissions(routePath: string) {
|
||||
const { user } = useAuthStore();
|
||||
|
||||
// Define route permissions
|
||||
const routePermissions: Record<string, {
|
||||
requiredRoles?: UserRole[];
|
||||
requiredPermissions?: string[];
|
||||
}> = {
|
||||
'/admin': { requiredRoles: [UserRole.ADMIN] },
|
||||
'/admin/users': { requiredRoles: [UserRole.ADMIN] },
|
||||
'/admin/analytics': { requiredRoles: [UserRole.ADMIN] },
|
||||
'/moderation': { requiredRoles: [UserRole.ADMIN, UserRole.MODERATOR] },
|
||||
'/dashboard': { requiredRoles: [UserRole.USER, UserRole.ADMIN, UserRole.MODERATOR] },
|
||||
'/profile': { requiredRoles: [UserRole.USER, UserRole.ADMIN, UserRole.MODERATOR] },
|
||||
'/resume-builder': { requiredRoles: [UserRole.USER, UserRole.ADMIN, UserRole.MODERATOR] },
|
||||
};
|
||||
|
||||
const permissions = routePermissions[routePath];
|
||||
|
||||
if (!permissions) {
|
||||
return { canAccess: true, missingPermissions: [] };
|
||||
}
|
||||
|
||||
const missingPermissions: string[] = [];
|
||||
let canAccess = true;
|
||||
|
||||
// Check required roles
|
||||
if (permissions.requiredRoles && user) {
|
||||
const hasRequiredRole = permissions.requiredRoles.some(role =>
|
||||
user.roles.includes(role)
|
||||
);
|
||||
|
||||
if (!hasRequiredRole) {
|
||||
canAccess = false;
|
||||
missingPermissions.push(`Required roles: ${permissions.requiredRoles.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check required permissions
|
||||
if (permissions.requiredPermissions && user) {
|
||||
const missingPerms = permissions.requiredPermissions.filter(permission =>
|
||||
!user.permissions?.includes(permission)
|
||||
);
|
||||
|
||||
if (missingPerms.length > 0) {
|
||||
canAccess = false;
|
||||
missingPermissions.push(`Required permissions: ${missingPerms.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
canAccess,
|
||||
missingPermissions,
|
||||
requiredRoles: permissions.requiredRoles || [],
|
||||
requiredPermissions: permissions.requiredPermissions || [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Higher-order component for route protection
|
||||
*/
|
||||
export function withAuthGuard<T extends object>(
|
||||
Component: React.ComponentType<T>,
|
||||
options?: {
|
||||
requiredRoles?: UserRole[];
|
||||
redirectTo?: string;
|
||||
loading?: React.ComponentType;
|
||||
}
|
||||
) {
|
||||
return function AuthGuardedComponent(props: T) {
|
||||
const { isAuthenticated, user } = useAuthGuard(options);
|
||||
|
||||
if (!isAuthenticated || !user) {
|
||||
if (options?.loading) {
|
||||
const LoadingComponent = options.loading;
|
||||
return React.createElement(LoadingComponent);
|
||||
}
|
||||
return React.createElement('div', null, 'Loading...');
|
||||
}
|
||||
|
||||
return React.createElement(Component, props);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Higher-order component for guest-only routes
|
||||
*/
|
||||
export function withGuestGuard<T extends object>(
|
||||
Component: React.ComponentType<T>,
|
||||
redirectTo?: string
|
||||
) {
|
||||
return function GuestGuardedComponent(props: T) {
|
||||
const { isAuthenticated } = useGuestGuard(redirectTo);
|
||||
|
||||
if (isAuthenticated) {
|
||||
return React.createElement('div', null, 'Redirecting...');
|
||||
}
|
||||
|
||||
return React.createElement(Component, props);
|
||||
};
|
||||
}
|
||||
110
src/lib/api-endpoints.ts
Normal file
110
src/lib/api-endpoints.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* API Endpoints Configuration
|
||||
* Professional Next.js Resume Builder - Enterprise Auth System
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-08
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
|
||||
/**
|
||||
* Environment configuration
|
||||
*/
|
||||
const config = {
|
||||
API_BASE_URL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080',
|
||||
AUTH_BASE_URL: process.env.NEXT_PUBLIC_AUTH_API_URL || 'http://localhost:8080/api/auth',
|
||||
WS_BASE_URL: process.env.NEXT_PUBLIC_WS_URL || 'ws://localhost:8080',
|
||||
APP_NAME: 'Professional Resume Builder',
|
||||
APP_VERSION: '1.0.0',
|
||||
REQUEST_TIMEOUT: 30000,
|
||||
MAX_RETRY_ATTEMPTS: 3,
|
||||
TOKEN_REFRESH_THRESHOLD: 5 * 60 * 1000, // 5 minutes
|
||||
};
|
||||
|
||||
/**
|
||||
* Centralized API endpoint management for professional organization
|
||||
*/
|
||||
export class ApiEndpoints {
|
||||
// Base URLs
|
||||
private static readonly API_BASE = config.API_BASE_URL;
|
||||
private static readonly AUTH_BASE = config.AUTH_BASE_URL;
|
||||
|
||||
// Authentication Endpoints - following exact Java backend structure
|
||||
static readonly AUTH = {
|
||||
// Core authentication
|
||||
LOGIN: `${ApiEndpoints.AUTH_BASE}/login`,
|
||||
REGISTER: `${ApiEndpoints.AUTH_BASE}/register`,
|
||||
LOGOUT: `${ApiEndpoints.AUTH_BASE}/logout`,
|
||||
LOGOUT_ALL: `${ApiEndpoints.AUTH_BASE}/logout-all`,
|
||||
REFRESH_TOKEN: `${ApiEndpoints.AUTH_BASE}/refresh`,
|
||||
|
||||
// Device verification
|
||||
VERIFY_DEVICE: `${ApiEndpoints.AUTH_BASE}/verify-device`,
|
||||
RESEND_DEVICE_VERIFICATION: `${ApiEndpoints.AUTH_BASE}/resend-device-verification`,
|
||||
|
||||
// Two-Factor Authentication - matching Java backend endpoints
|
||||
TWO_FACTOR: {
|
||||
VERIFY: `${ApiEndpoints.AUTH_BASE}/verify-2fa`,
|
||||
RESEND: `${ApiEndpoints.AUTH_BASE}/resend-2fa`,
|
||||
},
|
||||
|
||||
// Password Management
|
||||
PASSWORD_RESET: {
|
||||
REQUEST: `${ApiEndpoints.AUTH_BASE}/password-reset`,
|
||||
CONFIRM: `${ApiEndpoints.AUTH_BASE}/password-reset/confirm`
|
||||
},
|
||||
|
||||
// Profile Management
|
||||
PROFILE: `${ApiEndpoints.AUTH_BASE}/profile`,
|
||||
UPDATE_PROFILE: `${ApiEndpoints.AUTH_BASE}/profile`,
|
||||
|
||||
// Session Management
|
||||
SESSIONS: `${ApiEndpoints.AUTH_BASE}/sessions`,
|
||||
REVOKE_SESSION: `${ApiEndpoints.AUTH_BASE}/sessions/revoke`,
|
||||
};
|
||||
|
||||
// Application Endpoints
|
||||
static readonly APP = {
|
||||
HEALTH: `${ApiEndpoints.API_BASE}/health`,
|
||||
VERSION: `${ApiEndpoints.API_BASE}/version`,
|
||||
};
|
||||
|
||||
// User Management Endpoints
|
||||
static readonly USERS = {
|
||||
BASE: `${ApiEndpoints.API_BASE}/users`,
|
||||
PROFILE: `${ApiEndpoints.API_BASE}/users/profile`,
|
||||
PREFERENCES: `${ApiEndpoints.API_BASE}/users/preferences`,
|
||||
};
|
||||
|
||||
// Resume Builder Endpoints (for future use)
|
||||
static readonly RESUMES = {
|
||||
BASE: `${ApiEndpoints.API_BASE}/resumes`,
|
||||
TEMPLATES: `${ApiEndpoints.API_BASE}/templates`,
|
||||
EXPORT: `${ApiEndpoints.API_BASE}/resumes/export`,
|
||||
};
|
||||
|
||||
/**
|
||||
* Build URL with query parameters
|
||||
*/
|
||||
static buildUrl(endpoint: string, params?: Record<string, any>): string {
|
||||
if (!params) return endpoint;
|
||||
|
||||
const url = new URL(endpoint);
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null) {
|
||||
url.searchParams.append(key, String(value));
|
||||
}
|
||||
});
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configuration values
|
||||
*/
|
||||
static get config() {
|
||||
return { ...config };
|
||||
}
|
||||
}
|
||||
|
||||
export default ApiEndpoints;
|
||||
471
src/lib/auth-store.ts
Normal file
471
src/lib/auth-store.ts
Normal file
@@ -0,0 +1,471 @@
|
||||
/**
|
||||
* Authentication Store
|
||||
* Professional Next.js Resume Builder - Enterprise Auth System
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-08
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import { devtools } from 'zustand/middleware';
|
||||
import {
|
||||
AuthState,
|
||||
AuthActions,
|
||||
AuthUser,
|
||||
LoginCredentials,
|
||||
RegisterData,
|
||||
TwoFactorVerificationRequest,
|
||||
DeviceVerificationRequest,
|
||||
AuthResponse,
|
||||
AuthError,
|
||||
TwoFactorPageData,
|
||||
DeviceVerificationResponse,
|
||||
} from '@/types/auth';
|
||||
import { ApiEndpoints } from '@/lib/api-endpoints';
|
||||
import { HttpClientService } from '@/lib/http-client';
|
||||
import { EncryptionService } from '@/lib/encryption';
|
||||
import { DeviceFingerprintService } from '@/lib/device-fingerprint';
|
||||
|
||||
/**
|
||||
* Combined auth store state and actions
|
||||
*/
|
||||
interface AuthStore extends AuthState, AuthActions {
|
||||
// Helper methods
|
||||
handleSuccessfulAuth: (response: AuthResponse, rememberMe?: boolean) => void;
|
||||
storeTokens: (tokens: { accessToken: string; refreshToken?: string }, rememberMe?: boolean) => void;
|
||||
getAccessToken: () => string | null;
|
||||
getRefreshToken: () => string | null;
|
||||
clearAuthState: () => void;
|
||||
isTokenValid: (token: string) => boolean;
|
||||
transformError: (error: any) => AuthError;
|
||||
resendDeviceVerification: (sessionId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enterprise-grade authentication store with Zustand
|
||||
* Handles authentication state, token management, and security features
|
||||
*/
|
||||
export const useAuthStore = create<AuthStore>()(
|
||||
devtools(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
// Initial state
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
requiresTwoFactor: false,
|
||||
requiresDeviceVerification: false,
|
||||
twoFactorData: null,
|
||||
deviceVerificationData: null,
|
||||
error: null,
|
||||
|
||||
// Actions
|
||||
login: async (credentials: LoginCredentials) => {
|
||||
const httpClient = HttpClientService.getInstance();
|
||||
const encryptionService = EncryptionService.getInstance();
|
||||
const deviceService = DeviceFingerprintService.getInstance();
|
||||
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
try {
|
||||
// Generate device fingerprint
|
||||
const deviceFingerprint = deviceService.generateFingerprint();
|
||||
|
||||
// Create secure encrypted payload
|
||||
const securePayload = encryptionService.createSecurePayloadSync(credentials.password);
|
||||
|
||||
// Prepare login payload with encrypted password and device fingerprint
|
||||
const loginPayload = {
|
||||
email: credentials.email,
|
||||
fingerprint: deviceFingerprint,
|
||||
...securePayload
|
||||
};
|
||||
|
||||
const response = await httpClient.post<AuthResponse>(
|
||||
ApiEndpoints.AUTH.LOGIN,
|
||||
loginPayload,
|
||||
{ includeFingerprint: true }
|
||||
);
|
||||
|
||||
const authResponse = response.data;
|
||||
|
||||
if (authResponse.requiresTwoFactor) {
|
||||
// 2FA is required
|
||||
set({
|
||||
requiresTwoFactor: true,
|
||||
twoFactorData: {
|
||||
userId: authResponse.user.id,
|
||||
userEmail: authResponse.user.email,
|
||||
userName: `${authResponse.user.firstName} ${authResponse.user.lastName}`,
|
||||
reason: authResponse.reason,
|
||||
riskScore: authResponse.riskScore,
|
||||
tempToken: authResponse.tempToken,
|
||||
sessionToken: authResponse.sessionToken,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
isLoading: false,
|
||||
});
|
||||
} else if (authResponse.requiresDeviceVerification) {
|
||||
// Device verification is required
|
||||
set({
|
||||
requiresDeviceVerification: true,
|
||||
deviceVerificationData: {
|
||||
deviceId: '',
|
||||
isKnownDevice: false,
|
||||
isTrustedDevice: false,
|
||||
requiresTwoFactor: false,
|
||||
device: {
|
||||
name: deviceService.generateDeviceName(),
|
||||
platform: deviceFingerprint.platform,
|
||||
registeredAt: new Date(),
|
||||
},
|
||||
user: {
|
||||
firstName: authResponse.user.firstName,
|
||||
lastName: authResponse.user.lastName,
|
||||
email: authResponse.user.email,
|
||||
fullName: `${authResponse.user.firstName} ${authResponse.user.lastName}`,
|
||||
},
|
||||
},
|
||||
twoFactorData: {
|
||||
userId: authResponse.user.id,
|
||||
userEmail: authResponse.user.email,
|
||||
userName: `${authResponse.user.firstName} ${authResponse.user.lastName}`,
|
||||
sessionToken: authResponse.sessionToken,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
isLoading: false,
|
||||
});
|
||||
} else {
|
||||
// Login successful
|
||||
get().handleSuccessfulAuth(authResponse, credentials.rememberMe);
|
||||
}
|
||||
} catch (error) {
|
||||
set({
|
||||
error: get().transformError(error),
|
||||
isLoading: false,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
register: async (data: RegisterData) => {
|
||||
const httpClient = HttpClientService.getInstance();
|
||||
const encryptionService = EncryptionService.getInstance();
|
||||
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
try {
|
||||
// Create secure encrypted payloads
|
||||
const passwordPayload = encryptionService.createSecurePayloadSync(data.password);
|
||||
const confirmPasswordPayload = encryptionService.createSecurePayloadSync(data.confirmPassword);
|
||||
|
||||
const registerRequest = {
|
||||
firstName: data.firstName,
|
||||
lastName: data.lastName,
|
||||
email: data.email,
|
||||
encryptedPassword: passwordPayload.encryptedPassword,
|
||||
passwordEncryptionMeta: passwordPayload.encryptionMeta,
|
||||
encryptedConfirmPassword: confirmPasswordPayload.encryptedPassword,
|
||||
confirmPasswordEncryptionMeta: confirmPasswordPayload.encryptionMeta,
|
||||
acceptTerms: data.acceptTerms,
|
||||
};
|
||||
|
||||
const response = await httpClient.post<AuthResponse>(
|
||||
ApiEndpoints.AUTH.REGISTER,
|
||||
registerRequest
|
||||
);
|
||||
|
||||
get().handleSuccessfulAuth(response.data, false);
|
||||
} catch (error) {
|
||||
set({
|
||||
error: get().transformError(error),
|
||||
isLoading: false,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
logout: async () => {
|
||||
const httpClient = HttpClientService.getInstance();
|
||||
|
||||
try {
|
||||
const refreshToken = get().getRefreshToken();
|
||||
|
||||
if (refreshToken) {
|
||||
await httpClient.post(ApiEndpoints.AUTH.LOGOUT, { refreshToken });
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Logout request failed:', error);
|
||||
} finally {
|
||||
get().clearAuthState();
|
||||
httpClient.removeAuthToken();
|
||||
|
||||
// Redirect to login page
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = '/auth/login';
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
verifyTwoFactor: async (data: TwoFactorVerificationRequest) => {
|
||||
const httpClient = HttpClientService.getInstance();
|
||||
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
try {
|
||||
const response = await httpClient.post<AuthResponse>(
|
||||
ApiEndpoints.AUTH.TWO_FACTOR.VERIFY,
|
||||
data
|
||||
);
|
||||
|
||||
get().handleSuccessfulAuth(response.data, false);
|
||||
set({
|
||||
requiresTwoFactor: false,
|
||||
twoFactorData: null,
|
||||
});
|
||||
} catch (error) {
|
||||
set({
|
||||
error: get().transformError(error),
|
||||
isLoading: false,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
verifyDevice: async (data: DeviceVerificationRequest) => {
|
||||
const httpClient = HttpClientService.getInstance();
|
||||
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
try {
|
||||
const response = await httpClient.post<AuthResponse>(
|
||||
ApiEndpoints.AUTH.VERIFY_DEVICE,
|
||||
data
|
||||
);
|
||||
|
||||
get().handleSuccessfulAuth(response.data, false);
|
||||
set({
|
||||
requiresDeviceVerification: false,
|
||||
deviceVerificationData: null,
|
||||
});
|
||||
} catch (error) {
|
||||
set({
|
||||
error: get().transformError(error),
|
||||
isLoading: false,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
refreshToken: async () => {
|
||||
const httpClient = HttpClientService.getInstance();
|
||||
const refreshToken = get().getRefreshToken();
|
||||
|
||||
if (!refreshToken) {
|
||||
throw new Error('No refresh token available');
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await httpClient.post(
|
||||
ApiEndpoints.AUTH.REFRESH_TOKEN,
|
||||
{ refreshToken },
|
||||
{ skipAuth: true } as any
|
||||
);
|
||||
|
||||
const { accessToken, refreshToken: newRefreshToken } = response.data;
|
||||
get().storeTokens({ accessToken, refreshToken: newRefreshToken });
|
||||
httpClient.setAuthToken(accessToken);
|
||||
} catch (error) {
|
||||
get().clearAuthState();
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
checkAuth: async () => {
|
||||
const accessToken = get().getAccessToken();
|
||||
|
||||
if (!accessToken || !get().isTokenValid(accessToken)) {
|
||||
get().clearAuthState();
|
||||
return;
|
||||
}
|
||||
|
||||
const httpClient = HttpClientService.getInstance();
|
||||
httpClient.setAuthToken(accessToken);
|
||||
|
||||
try {
|
||||
// Verify token by fetching user profile
|
||||
const response = await httpClient.get<AuthUser>(ApiEndpoints.AUTH.PROFILE);
|
||||
|
||||
set({
|
||||
user: response.data,
|
||||
isAuthenticated: true,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('Auth check failed:', error);
|
||||
get().clearAuthState();
|
||||
}
|
||||
},
|
||||
|
||||
clearError: () => {
|
||||
set({ error: null });
|
||||
},
|
||||
|
||||
clearTwoFactor: () => {
|
||||
set({
|
||||
requiresTwoFactor: false,
|
||||
twoFactorData: null,
|
||||
});
|
||||
},
|
||||
|
||||
clearDeviceVerification: () => {
|
||||
set({
|
||||
requiresDeviceVerification: false,
|
||||
deviceVerificationData: null,
|
||||
});
|
||||
},
|
||||
|
||||
// Helper methods (not part of the public API)
|
||||
handleSuccessfulAuth: (response: AuthResponse, rememberMe: boolean = false) => {
|
||||
if (!response.accessToken) {
|
||||
throw new Error('Invalid authentication response: missing access token');
|
||||
}
|
||||
|
||||
const tokens = {
|
||||
accessToken: response.accessToken,
|
||||
refreshToken: response.user?.id ? 'dummy-refresh-token' : undefined, // Adapt based on your backend
|
||||
};
|
||||
|
||||
get().storeTokens(tokens, rememberMe);
|
||||
|
||||
const httpClient = HttpClientService.getInstance();
|
||||
httpClient.setAuthToken(tokens.accessToken);
|
||||
|
||||
set({
|
||||
user: response.user,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
requiresTwoFactor: false,
|
||||
requiresDeviceVerification: false,
|
||||
twoFactorData: null,
|
||||
deviceVerificationData: null,
|
||||
error: null,
|
||||
});
|
||||
},
|
||||
|
||||
storeTokens: (tokens: { accessToken: string; refreshToken?: string }, rememberMe: boolean = false) => {
|
||||
const storage = rememberMe ? localStorage : sessionStorage;
|
||||
|
||||
storage.setItem('auth_access_token', tokens.accessToken);
|
||||
if (tokens.refreshToken) {
|
||||
storage.setItem('auth_refresh_token', tokens.refreshToken);
|
||||
}
|
||||
|
||||
if (rememberMe) {
|
||||
localStorage.setItem('auth_remember_me', 'true');
|
||||
}
|
||||
},
|
||||
|
||||
getAccessToken: (): string | null => {
|
||||
if (typeof window === 'undefined') return null;
|
||||
|
||||
return localStorage.getItem('auth_access_token') ||
|
||||
sessionStorage.getItem('auth_access_token');
|
||||
},
|
||||
|
||||
getRefreshToken: (): string | null => {
|
||||
if (typeof window === 'undefined') return null;
|
||||
|
||||
return localStorage.getItem('auth_refresh_token') ||
|
||||
sessionStorage.getItem('auth_refresh_token');
|
||||
},
|
||||
|
||||
clearAuthState: () => {
|
||||
// Clear from both storages
|
||||
if (typeof window !== 'undefined') {
|
||||
[localStorage, sessionStorage].forEach(storage => {
|
||||
storage.removeItem('auth_access_token');
|
||||
storage.removeItem('auth_refresh_token');
|
||||
storage.removeItem('auth_user_data');
|
||||
});
|
||||
localStorage.removeItem('auth_remember_me');
|
||||
}
|
||||
|
||||
set({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
requiresTwoFactor: false,
|
||||
requiresDeviceVerification: false,
|
||||
twoFactorData: null,
|
||||
deviceVerificationData: null,
|
||||
error: null,
|
||||
});
|
||||
},
|
||||
|
||||
isTokenValid: (token: string): boolean => {
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||
const currentTime = Math.floor(Date.now() / 1000);
|
||||
return payload.exp > currentTime;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
transformError: (error: any): AuthError => {
|
||||
if (error?.code && error?.message) {
|
||||
return error as AuthError;
|
||||
}
|
||||
|
||||
return {
|
||||
code: error?.response?.status ? `HTTP_${error.response.status}` : 'CLIENT_ERROR',
|
||||
message: error?.response?.data?.message || error?.message || 'An authentication error occurred',
|
||||
timestamp: new Date(),
|
||||
details: error?.response?.data,
|
||||
};
|
||||
},
|
||||
|
||||
resendDeviceVerification: async (sessionId: string): Promise<void> => {
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
try {
|
||||
const httpClient = HttpClientService.getInstance();
|
||||
await httpClient.post(ApiEndpoints.AUTH.RESEND_DEVICE_VERIFICATION, {
|
||||
sessionId,
|
||||
});
|
||||
} catch (error) {
|
||||
const authError = get().transformError(error);
|
||||
set({ error: authError });
|
||||
throw authError;
|
||||
} finally {
|
||||
set({ isLoading: false });
|
||||
}
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'auth-storage',
|
||||
storage: createJSONStorage(() =>
|
||||
typeof window !== 'undefined' ? localStorage : {
|
||||
getItem: () => null,
|
||||
setItem: () => {},
|
||||
removeItem: () => {},
|
||||
}
|
||||
),
|
||||
partialize: (state) => ({
|
||||
user: state.user,
|
||||
isAuthenticated: state.isAuthenticated,
|
||||
}),
|
||||
}
|
||||
),
|
||||
{
|
||||
name: 'auth-store',
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Initialize auth state on app load
|
||||
if (typeof window !== 'undefined') {
|
||||
useAuthStore.getState().checkAuth();
|
||||
}
|
||||
0
src/lib/constants.ts
Normal file
0
src/lib/constants.ts
Normal file
304
src/lib/device-fingerprint.ts
Normal file
304
src/lib/device-fingerprint.ts
Normal file
@@ -0,0 +1,304 @@
|
||||
/**
|
||||
* Device Fingerprinting Service
|
||||
* Professional Next.js Resume Builder - Enterprise Auth System
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-08
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
|
||||
import { DeviceFingerprint } from '@/types/auth';
|
||||
|
||||
/**
|
||||
* Device fingerprinting service for enhanced security
|
||||
* Generates unique device fingerprints for device verification
|
||||
*/
|
||||
export class DeviceFingerprintService {
|
||||
private static instance: DeviceFingerprintService;
|
||||
private fingerprint: DeviceFingerprint | null = null;
|
||||
|
||||
public static getInstance(): DeviceFingerprintService {
|
||||
if (!DeviceFingerprintService.instance) {
|
||||
DeviceFingerprintService.instance = new DeviceFingerprintService();
|
||||
}
|
||||
return DeviceFingerprintService.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate comprehensive device fingerprint
|
||||
*/
|
||||
public generateFingerprint(): DeviceFingerprint {
|
||||
if (typeof window === 'undefined') {
|
||||
// Server-side rendering fallback
|
||||
return this.getServerSideFingerprint();
|
||||
}
|
||||
|
||||
const fingerprint: DeviceFingerprint = {
|
||||
userAgent: navigator.userAgent,
|
||||
acceptLanguage: navigator.language || 'en-US',
|
||||
screenResolution: `${screen.width}x${screen.height}`,
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
platform: navigator.platform,
|
||||
cookieEnabled: navigator.cookieEnabled,
|
||||
doNotTrack: navigator.doNotTrack === '1',
|
||||
colorDepth: screen.colorDepth || 24,
|
||||
hardwareConcurrency: navigator.hardwareConcurrency || 1,
|
||||
deviceMemory: (navigator as any).deviceMemory || 0,
|
||||
touchSupport: 'ontouchstart' in window || navigator.maxTouchPoints > 0,
|
||||
};
|
||||
|
||||
// Add advanced fingerprinting if available
|
||||
fingerprint.fontsHash = this.generateFontsHash();
|
||||
fingerprint.canvasFingerprint = this.generateCanvasFingerprint();
|
||||
fingerprint.webglFingerprint = this.generateWebGLFingerprint();
|
||||
|
||||
this.fingerprint = fingerprint;
|
||||
return fingerprint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached fingerprint or generate new one
|
||||
*/
|
||||
public getFingerprint(): DeviceFingerprint {
|
||||
if (!this.fingerprint) {
|
||||
return this.generateFingerprint();
|
||||
}
|
||||
return this.fingerprint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate fonts fingerprint hash
|
||||
*/
|
||||
private generateFontsHash(): string {
|
||||
if (typeof window === 'undefined') return '';
|
||||
|
||||
const testFonts = [
|
||||
'Arial', 'Helvetica', 'Times New Roman', 'Courier New',
|
||||
'Verdana', 'Georgia', 'Palatino', 'Garamond',
|
||||
'Bookman', 'Comic Sans MS', 'Trebuchet MS', 'Arial Black',
|
||||
'Impact', 'Lucida Sans Unicode', 'Tahoma', 'Lucida Console',
|
||||
'Monaco', 'Bradley Hand ITC', 'Brush Script MT', 'Luminari',
|
||||
'Chalkduster'
|
||||
];
|
||||
|
||||
const availableFonts: string[] = [];
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d');
|
||||
|
||||
if (!context) return '';
|
||||
|
||||
canvas.width = 200;
|
||||
canvas.height = 50;
|
||||
|
||||
testFonts.forEach(font => {
|
||||
context.font = `12px ${font}, monospace`;
|
||||
context.fillText('Test font detection', 10, 25);
|
||||
const imageData = context.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const fontData = Array.from(imageData.data).join(',');
|
||||
|
||||
// Simple hash of the font rendering
|
||||
let hash = 0;
|
||||
for (let i = 0; i < fontData.length; i++) {
|
||||
const char = fontData.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash) + char;
|
||||
hash = hash & hash; // Convert to 32-bit integer
|
||||
}
|
||||
|
||||
if (hash !== 0) {
|
||||
availableFonts.push(font);
|
||||
}
|
||||
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
});
|
||||
|
||||
return this.simpleHash(availableFonts.join(','));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate canvas fingerprint
|
||||
*/
|
||||
private generateCanvasFingerprint(): string {
|
||||
if (typeof window === 'undefined') return '';
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d');
|
||||
|
||||
if (!context) return '';
|
||||
|
||||
canvas.width = 280;
|
||||
canvas.height = 60;
|
||||
|
||||
// Draw text with various styles
|
||||
context.textBaseline = 'top';
|
||||
context.font = '14px Arial';
|
||||
context.fillStyle = '#f60';
|
||||
context.fillRect(125, 1, 62, 20);
|
||||
context.fillStyle = '#069';
|
||||
context.fillText('Device fingerprint test 🔒', 2, 15);
|
||||
context.fillStyle = 'rgba(102, 204, 0, 0.7)';
|
||||
context.fillText('Professional security', 4, 45);
|
||||
|
||||
// Draw shapes
|
||||
context.globalCompositeOperation = 'multiply';
|
||||
context.fillStyle = 'rgb(255,0,255)';
|
||||
context.beginPath();
|
||||
context.arc(50, 50, 50, 0, Math.PI * 2, true);
|
||||
context.closePath();
|
||||
context.fill();
|
||||
context.fillStyle = 'rgb(0,255,255)';
|
||||
context.beginPath();
|
||||
context.arc(100, 50, 50, 0, Math.PI * 2, true);
|
||||
context.closePath();
|
||||
context.fill();
|
||||
context.fillStyle = 'rgb(255,255,0)';
|
||||
context.beginPath();
|
||||
context.arc(75, 100, 50, 0, Math.PI * 2, true);
|
||||
context.closePath();
|
||||
context.fill();
|
||||
|
||||
return canvas.toDataURL();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate WebGL fingerprint
|
||||
*/
|
||||
private generateWebGLFingerprint(): string {
|
||||
if (typeof window === 'undefined') return '';
|
||||
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl') as WebGLRenderingContext;
|
||||
|
||||
if (!gl) return '';
|
||||
|
||||
const debugInfo = gl.getExtension('WEBGL_debug_renderer_info');
|
||||
const vendor = debugInfo ? gl.getParameter(debugInfo.UNMASKED_VENDOR_WEBGL) : '';
|
||||
const renderer = debugInfo ? gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL) : '';
|
||||
|
||||
const webglInfo = {
|
||||
vendor,
|
||||
renderer,
|
||||
version: gl.getParameter(gl.VERSION),
|
||||
shadingLanguageVersion: gl.getParameter(gl.SHADING_LANGUAGE_VERSION),
|
||||
maxVertexAttribs: gl.getParameter(gl.MAX_VERTEX_ATTRIBS),
|
||||
maxVertexUniformVectors: gl.getParameter(gl.MAX_VERTEX_UNIFORM_VECTORS),
|
||||
maxVaryingVectors: gl.getParameter(gl.MAX_VARYING_VECTORS),
|
||||
maxFragmentUniformVectors: gl.getParameter(gl.MAX_FRAGMENT_UNIFORM_VECTORS),
|
||||
extensions: gl.getSupportedExtensions()?.join(',') || ''
|
||||
};
|
||||
|
||||
return this.simpleHash(JSON.stringify(webglInfo));
|
||||
} catch (e) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple hash function for fingerprint data
|
||||
*/
|
||||
private simpleHash(str: string): string {
|
||||
let hash = 0;
|
||||
if (str.length === 0) return hash.toString();
|
||||
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const char = str.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash) + char;
|
||||
hash = hash & hash; // Convert to 32-bit integer
|
||||
}
|
||||
|
||||
return Math.abs(hash).toString(16);
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side rendering fallback fingerprint
|
||||
*/
|
||||
private getServerSideFingerprint(): DeviceFingerprint {
|
||||
return {
|
||||
userAgent: 'SSR-UserAgent',
|
||||
acceptLanguage: 'en-US',
|
||||
screenResolution: '1920x1080',
|
||||
timezone: 'UTC',
|
||||
platform: 'SSR-Platform',
|
||||
cookieEnabled: true,
|
||||
doNotTrack: false,
|
||||
colorDepth: 24,
|
||||
hardwareConcurrency: 1,
|
||||
deviceMemory: 0,
|
||||
touchSupport: false,
|
||||
fontsHash: '',
|
||||
canvasFingerprint: '',
|
||||
webglFingerprint: ''
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate device name based on fingerprint
|
||||
*/
|
||||
public generateDeviceName(fingerprint?: DeviceFingerprint): string {
|
||||
const fp = fingerprint || this.getFingerprint();
|
||||
|
||||
// Extract browser name
|
||||
const userAgent = fp.userAgent.toLowerCase();
|
||||
let browser = 'Unknown Browser';
|
||||
|
||||
if (userAgent.includes('chrome')) browser = 'Chrome';
|
||||
else if (userAgent.includes('firefox')) browser = 'Firefox';
|
||||
else if (userAgent.includes('safari')) browser = 'Safari';
|
||||
else if (userAgent.includes('edge')) browser = 'Edge';
|
||||
else if (userAgent.includes('opera')) browser = 'Opera';
|
||||
|
||||
// Extract OS
|
||||
let os = 'Unknown OS';
|
||||
if (userAgent.includes('windows')) os = 'Windows';
|
||||
else if (userAgent.includes('mac')) os = 'macOS';
|
||||
else if (userAgent.includes('linux')) os = 'Linux';
|
||||
else if (userAgent.includes('android')) os = 'Android';
|
||||
else if (userAgent.includes('ios')) os = 'iOS';
|
||||
|
||||
// Extract device type
|
||||
const isMobile = userAgent.includes('mobile') || fp.touchSupport;
|
||||
const deviceType = isMobile ? 'Mobile' : 'Desktop';
|
||||
|
||||
return `${browser} on ${os} ${deviceType}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if fingerprint matches stored fingerprint
|
||||
*/
|
||||
public compareFingerprinst(fp1: DeviceFingerprint, fp2: DeviceFingerprint): number {
|
||||
const weights = {
|
||||
userAgent: 0.3,
|
||||
screenResolution: 0.2,
|
||||
timezone: 0.1,
|
||||
platform: 0.15,
|
||||
canvasFingerprint: 0.15,
|
||||
webglFingerprint: 0.1
|
||||
};
|
||||
|
||||
let score = 0;
|
||||
let totalWeight = 0;
|
||||
|
||||
Object.entries(weights).forEach(([key, weight]) => {
|
||||
const key1 = fp1[key as keyof DeviceFingerprint];
|
||||
const key2 = fp2[key as keyof DeviceFingerprint];
|
||||
|
||||
if (key1 && key2) {
|
||||
totalWeight += weight;
|
||||
if (key1 === key2) {
|
||||
score += weight;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return totalWeight > 0 ? score / totalWeight : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cached fingerprint
|
||||
*/
|
||||
public clearFingerprint(): void {
|
||||
this.fingerprint = null;
|
||||
}
|
||||
}
|
||||
|
||||
export default DeviceFingerprintService;
|
||||
281
src/lib/encryption.ts
Normal file
281
src/lib/encryption.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* Encryption Service
|
||||
* Professional Next.js Resume Builder - Enterprise Auth System
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-08
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
|
||||
/**
|
||||
* Encryption metadata interface
|
||||
*/
|
||||
interface EncryptionMeta {
|
||||
algorithm: string;
|
||||
keyLength: number;
|
||||
timestamp: string;
|
||||
nonce: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Secure payload interface
|
||||
*/
|
||||
interface SecurePayload {
|
||||
encryptedPassword: string;
|
||||
encryptionMeta: EncryptionMeta;
|
||||
}
|
||||
|
||||
/**
|
||||
* Professional encryption service for secure data transmission
|
||||
* Implements client-side encryption for sensitive data like passwords
|
||||
*/
|
||||
export class EncryptionService {
|
||||
private static instance: EncryptionService;
|
||||
private readonly algorithm = 'AES-GCM';
|
||||
private readonly keyLength = 256;
|
||||
|
||||
public static getInstance(): EncryptionService {
|
||||
if (!EncryptionService.instance) {
|
||||
EncryptionService.instance = new EncryptionService();
|
||||
}
|
||||
return EncryptionService.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create secure payload with encrypted password
|
||||
* For enterprise-grade security in password transmission
|
||||
*/
|
||||
public async createSecurePayload(password: string): Promise<SecurePayload> {
|
||||
try {
|
||||
// Generate a random key for this encryption
|
||||
const key = await this.generateKey();
|
||||
|
||||
// Generate a random nonce
|
||||
const nonce = crypto.getRandomValues(new Uint8Array(12));
|
||||
|
||||
// Encrypt the password
|
||||
const encryptedData = await this.encrypt(password, key, nonce);
|
||||
|
||||
// Create metadata
|
||||
const encryptionMeta: EncryptionMeta = {
|
||||
algorithm: this.algorithm,
|
||||
keyLength: this.keyLength,
|
||||
timestamp: new Date().toISOString(),
|
||||
nonce: this.arrayBufferToBase64(nonce.buffer)
|
||||
};
|
||||
|
||||
return {
|
||||
encryptedPassword: this.arrayBufferToBase64(encryptedData),
|
||||
encryptionMeta
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Encryption failed:', error);
|
||||
// Fallback: return base64 encoded password for basic obfuscation
|
||||
return this.createFallbackPayload(password);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create secure payload (synchronous version for compatibility)
|
||||
*/
|
||||
public createSecurePayloadSync(password: string): SecurePayload {
|
||||
// For compatibility with existing code, use simple base64 encoding
|
||||
// In production, this should be replaced with proper encryption
|
||||
return this.createFallbackPayload(password);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate encryption key
|
||||
*/
|
||||
private async generateKey(): Promise<CryptoKey> {
|
||||
return await crypto.subtle.generateKey(
|
||||
{
|
||||
name: this.algorithm,
|
||||
length: this.keyLength,
|
||||
},
|
||||
true,
|
||||
['encrypt', 'decrypt']
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt data using AES-GCM
|
||||
*/
|
||||
private async encrypt(data: string, key: CryptoKey, nonce: Uint8Array): Promise<ArrayBuffer> {
|
||||
const encoder = new TextEncoder();
|
||||
const dataBuffer = encoder.encode(data);
|
||||
|
||||
return await crypto.subtle.encrypt(
|
||||
{
|
||||
name: this.algorithm,
|
||||
iv: nonce as BufferSource,
|
||||
},
|
||||
key,
|
||||
dataBuffer
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert ArrayBuffer to base64 string
|
||||
*/
|
||||
private arrayBufferToBase64(buffer: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.byteLength; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert base64 string to ArrayBuffer
|
||||
*/
|
||||
private base64ToArrayBuffer(base64: string): ArrayBuffer {
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes.buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create fallback payload when encryption is not available
|
||||
*/
|
||||
private createFallbackPayload(password: string): SecurePayload {
|
||||
// Simple base64 encoding as fallback
|
||||
const encoded = btoa(password);
|
||||
|
||||
const encryptionMeta: EncryptionMeta = {
|
||||
algorithm: 'BASE64',
|
||||
keyLength: 0,
|
||||
timestamp: new Date().toISOString(),
|
||||
nonce: ''
|
||||
};
|
||||
|
||||
return {
|
||||
encryptedPassword: encoded,
|
||||
encryptionMeta
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash password using SHA-256 for client-side verification
|
||||
*/
|
||||
public async hashPassword(password: string, salt?: string): Promise<string> {
|
||||
try {
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(password + (salt || ''));
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
} catch (error) {
|
||||
console.error('Hashing failed:', error);
|
||||
// Fallback to simple encoding
|
||||
return btoa(password);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate secure random string
|
||||
*/
|
||||
public generateRandomString(length: number = 32): string {
|
||||
if (typeof window === 'undefined') {
|
||||
// Server-side fallback
|
||||
return Math.random().toString(36).substring(2, length + 2);
|
||||
}
|
||||
|
||||
try {
|
||||
const array = new Uint8Array(length);
|
||||
crypto.getRandomValues(array);
|
||||
return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('');
|
||||
} catch (error) {
|
||||
// Fallback to Math.random
|
||||
return Math.random().toString(36).substring(2, length + 2);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate password strength
|
||||
*/
|
||||
public validatePasswordStrength(password: string): {
|
||||
score: number;
|
||||
feedback: string[];
|
||||
isValid: boolean;
|
||||
} {
|
||||
const feedback: string[] = [];
|
||||
let score = 0;
|
||||
|
||||
// Length check
|
||||
if (password.length >= 8) score += 1;
|
||||
else feedback.push('Password must be at least 8 characters long');
|
||||
|
||||
if (password.length >= 12) score += 1;
|
||||
|
||||
// Character variety checks
|
||||
if (/[a-z]/.test(password)) score += 1;
|
||||
else feedback.push('Add lowercase letters');
|
||||
|
||||
if (/[A-Z]/.test(password)) score += 1;
|
||||
else feedback.push('Add uppercase letters');
|
||||
|
||||
if (/[0-9]/.test(password)) score += 1;
|
||||
else feedback.push('Add numbers');
|
||||
|
||||
if (/[^a-zA-Z0-9]/.test(password)) score += 1;
|
||||
else feedback.push('Add special characters');
|
||||
|
||||
// Common patterns
|
||||
if (!/(.)\1{2,}/.test(password)) score += 1;
|
||||
else feedback.push('Avoid repeating characters');
|
||||
|
||||
if (!/^.*(123|abc|qwe|password|admin).*$/i.test(password)) score += 1;
|
||||
else feedback.push('Avoid common patterns');
|
||||
|
||||
const isValid = score >= 6 && password.length >= 8;
|
||||
|
||||
return {
|
||||
score: Math.min(score, 8),
|
||||
feedback,
|
||||
isValid
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt sensitive data for storage
|
||||
*/
|
||||
public encryptForStorage(data: string, key: string): string {
|
||||
try {
|
||||
// Simple XOR encryption for local storage
|
||||
let result = '';
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
result += String.fromCharCode(
|
||||
data.charCodeAt(i) ^ key.charCodeAt(i % key.length)
|
||||
);
|
||||
}
|
||||
return btoa(result);
|
||||
} catch (error) {
|
||||
return btoa(data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt data from storage
|
||||
*/
|
||||
public decryptFromStorage(encryptedData: string, key: string): string {
|
||||
try {
|
||||
const data = atob(encryptedData);
|
||||
let result = '';
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
result += String.fromCharCode(
|
||||
data.charCodeAt(i) ^ key.charCodeAt(i % key.length)
|
||||
);
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default EncryptionService;
|
||||
446
src/lib/http-client.ts
Normal file
446
src/lib/http-client.ts
Normal file
@@ -0,0 +1,446 @@
|
||||
/**
|
||||
* HTTP Client Service
|
||||
* Professional Next.js Resume Builder - Enterprise Auth System
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-08
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
|
||||
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse, AxiosError } from 'axios';
|
||||
import { ApiEndpoints } from './api-endpoints';
|
||||
import { DeviceFingerprintService } from './device-fingerprint';
|
||||
|
||||
/**
|
||||
* HTTP response interface
|
||||
*/
|
||||
interface HttpResponse<T = any> {
|
||||
data: T;
|
||||
status: number;
|
||||
statusText: string;
|
||||
headers: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP error interface
|
||||
*/
|
||||
interface HttpError {
|
||||
code: string;
|
||||
message: string;
|
||||
status?: number;
|
||||
details?: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request configuration interface
|
||||
*/
|
||||
interface RequestConfig extends AxiosRequestConfig {
|
||||
skipAuth?: boolean;
|
||||
skipRetry?: boolean;
|
||||
includeFingerprint?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Professional HTTP client service with enterprise features
|
||||
* Handles authentication, device fingerprinting, retry logic, and error handling
|
||||
*/
|
||||
export class HttpClientService {
|
||||
private static instance: HttpClientService;
|
||||
private axiosInstance: AxiosInstance;
|
||||
private deviceFingerprintService: DeviceFingerprintService;
|
||||
private isRefreshing = false;
|
||||
private failedQueue: Array<{
|
||||
resolve: (value?: any) => void;
|
||||
reject: (error?: any) => void;
|
||||
}> = [];
|
||||
|
||||
private constructor() {
|
||||
this.deviceFingerprintService = DeviceFingerprintService.getInstance();
|
||||
this.axiosInstance = this.createAxiosInstance();
|
||||
this.setupInterceptors();
|
||||
}
|
||||
|
||||
public static getInstance(): HttpClientService {
|
||||
if (!HttpClientService.instance) {
|
||||
HttpClientService.instance = new HttpClientService();
|
||||
}
|
||||
return HttpClientService.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create axios instance with default configuration
|
||||
*/
|
||||
private createAxiosInstance(): AxiosInstance {
|
||||
const config = ApiEndpoints.config;
|
||||
|
||||
return axios.create({
|
||||
baseURL: config.API_BASE_URL,
|
||||
timeout: config.REQUEST_TIMEOUT,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-Client-Version': config.APP_VERSION,
|
||||
'X-Client-Name': config.APP_NAME,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup request and response interceptors
|
||||
*/
|
||||
private setupInterceptors(): void {
|
||||
// Request interceptor
|
||||
this.axiosInstance.interceptors.request.use(
|
||||
(config) => {
|
||||
// Add authentication token
|
||||
if (!(config as any).skipAuth) {
|
||||
const token = this.getAccessToken();
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Add device fingerprint headers
|
||||
if ((config as any).includeFingerprint !== false) {
|
||||
this.addDeviceFingerprintHeaders(config);
|
||||
}
|
||||
|
||||
// Add request timestamp
|
||||
config.headers['X-Request-Time'] = new Date().toISOString();
|
||||
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Response interceptor
|
||||
this.axiosInstance.interceptors.response.use(
|
||||
(response) => {
|
||||
return response;
|
||||
},
|
||||
async (error) => {
|
||||
const originalRequest = error.config;
|
||||
|
||||
// Handle 401 errors with token refresh
|
||||
if (error.response?.status === 401 && !originalRequest._retry && !(originalRequest as any).skipAuth) {
|
||||
if (this.isRefreshing) {
|
||||
// If already refreshing, queue the request
|
||||
return new Promise((resolve, reject) => {
|
||||
this.failedQueue.push({ resolve, reject });
|
||||
}).then(() => {
|
||||
return this.axiosInstance(originalRequest);
|
||||
}).catch((err) => {
|
||||
return Promise.reject(err);
|
||||
});
|
||||
}
|
||||
|
||||
originalRequest._retry = true;
|
||||
this.isRefreshing = true;
|
||||
|
||||
try {
|
||||
await this.refreshToken();
|
||||
this.processQueue(null);
|
||||
return this.axiosInstance(originalRequest);
|
||||
} catch (refreshError) {
|
||||
this.processQueue(refreshError);
|
||||
this.handleAuthenticationFailure();
|
||||
return Promise.reject(refreshError);
|
||||
} finally {
|
||||
this.isRefreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(this.transformError(error));
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add device fingerprint headers to request
|
||||
*/
|
||||
private addDeviceFingerprintHeaders(config: AxiosRequestConfig): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const fingerprint = this.deviceFingerprintService.getFingerprint();
|
||||
|
||||
config.headers = {
|
||||
...config.headers,
|
||||
'User-Agent': fingerprint.userAgent,
|
||||
'Accept-Language': fingerprint.acceptLanguage,
|
||||
'Timezone': fingerprint.timezone,
|
||||
'Screen-Resolution': fingerprint.screenResolution,
|
||||
'Color-Depth': fingerprint.colorDepth?.toString() || '24',
|
||||
'Hardware-Concurrency': fingerprint.hardwareConcurrency?.toString() || '1',
|
||||
'Device-Memory': fingerprint.deviceMemory?.toString() || '0',
|
||||
'Platform': fingerprint.platform,
|
||||
'Cookie-Enabled': fingerprint.cookieEnabled.toString(),
|
||||
'DNT': fingerprint.doNotTrack ? '1' : '0',
|
||||
'Webdriver': 'false',
|
||||
'Device-Fingerprint': JSON.stringify(fingerprint),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Process queued requests after token refresh
|
||||
*/
|
||||
private processQueue(error: any): void {
|
||||
this.failedQueue.forEach(({ resolve, reject }) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
|
||||
this.failedQueue = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle authentication failure
|
||||
*/
|
||||
private handleAuthenticationFailure(): void {
|
||||
// Clear tokens and redirect to login
|
||||
this.clearTokens();
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = '/auth/login';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform axios error to our error format
|
||||
*/
|
||||
private transformError(error: AxiosError): HttpError {
|
||||
if (error.response) {
|
||||
// Server responded with error status
|
||||
return {
|
||||
code: (error.response.data as any)?.code || `HTTP_${error.response.status}`,
|
||||
message: (error.response.data as any)?.message || error.message,
|
||||
status: error.response.status,
|
||||
details: error.response.data,
|
||||
};
|
||||
} else if (error.request) {
|
||||
// Request was made but no response
|
||||
return {
|
||||
code: 'NETWORK_ERROR',
|
||||
message: 'Network error - please check your connection',
|
||||
details: error.request,
|
||||
};
|
||||
} else {
|
||||
// Something else happened
|
||||
return {
|
||||
code: 'CLIENT_ERROR',
|
||||
message: error.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET request
|
||||
*/
|
||||
public async get<T = any>(url: string, config?: RequestConfig): Promise<HttpResponse<T>> {
|
||||
const response = await this.axiosInstance.get<T>(url, config);
|
||||
return this.transformResponse(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST request
|
||||
*/
|
||||
public async post<T = any>(url: string, data?: any, config?: RequestConfig): Promise<HttpResponse<T>> {
|
||||
const response = await this.axiosInstance.post<T>(url, data, config);
|
||||
return this.transformResponse(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT request
|
||||
*/
|
||||
public async put<T = any>(url: string, data?: any, config?: RequestConfig): Promise<HttpResponse<T>> {
|
||||
const response = await this.axiosInstance.put<T>(url, data, config);
|
||||
return this.transformResponse(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH request
|
||||
*/
|
||||
public async patch<T = any>(url: string, data?: any, config?: RequestConfig): Promise<HttpResponse<T>> {
|
||||
const response = await this.axiosInstance.patch<T>(url, data, config);
|
||||
return this.transformResponse(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE request
|
||||
*/
|
||||
public async delete<T = any>(url: string, config?: RequestConfig): Promise<HttpResponse<T>> {
|
||||
const response = await this.axiosInstance.delete<T>(url, config);
|
||||
return this.transformResponse(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform axios response to our response format
|
||||
*/
|
||||
private transformResponse<T>(response: AxiosResponse<T>): HttpResponse<T> {
|
||||
return {
|
||||
data: response.data,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: response.headers as Record<string, string>,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get access token from storage
|
||||
*/
|
||||
private getAccessToken(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
|
||||
return localStorage.getItem('auth_access_token') ||
|
||||
sessionStorage.getItem('auth_access_token');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get refresh token from storage
|
||||
*/
|
||||
private getRefreshToken(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
|
||||
return localStorage.getItem('auth_refresh_token') ||
|
||||
sessionStorage.getItem('auth_refresh_token');
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear tokens from storage
|
||||
*/
|
||||
private clearTokens(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
localStorage.removeItem('auth_access_token');
|
||||
localStorage.removeItem('auth_refresh_token');
|
||||
sessionStorage.removeItem('auth_access_token');
|
||||
sessionStorage.removeItem('auth_refresh_token');
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh authentication token
|
||||
*/
|
||||
private async refreshToken(): Promise<void> {
|
||||
const refreshToken = this.getRefreshToken();
|
||||
|
||||
if (!refreshToken) {
|
||||
throw new Error('No refresh token available');
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.axiosInstance.post(
|
||||
ApiEndpoints.AUTH.REFRESH_TOKEN,
|
||||
{ refreshToken },
|
||||
{ ...{}, skipAuth: true } as any
|
||||
);
|
||||
|
||||
const { accessToken, refreshToken: newRefreshToken } = response.data;
|
||||
|
||||
// Store new tokens
|
||||
const storage = localStorage.getItem('auth_remember_me') ? localStorage : sessionStorage;
|
||||
storage.setItem('auth_access_token', accessToken);
|
||||
|
||||
if (newRefreshToken) {
|
||||
storage.setItem('auth_refresh_token', newRefreshToken);
|
||||
}
|
||||
} catch (error) {
|
||||
this.clearTokens();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update authentication token
|
||||
*/
|
||||
public setAuthToken(token: string): void {
|
||||
this.axiosInstance.defaults.headers.common['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove authentication token
|
||||
*/
|
||||
public removeAuthToken(): void {
|
||||
delete this.axiosInstance.defaults.headers.common['Authorization'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get axios instance for advanced usage
|
||||
*/
|
||||
public getAxiosInstance(): AxiosInstance {
|
||||
return this.axiosInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if request should be retried
|
||||
*/
|
||||
private shouldRetry(error: AxiosError, retryCount: number): boolean {
|
||||
const maxRetries = 3;
|
||||
|
||||
if (retryCount >= maxRetries) return false;
|
||||
if ((error.config as any)?.skipRetry) return false;
|
||||
if (error.response?.status && error.response.status < 500) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download file with progress tracking
|
||||
*/
|
||||
public async downloadFile(
|
||||
url: string,
|
||||
filename?: string,
|
||||
onProgress?: (progress: number) => void
|
||||
): Promise<void> {
|
||||
const response = await this.axiosInstance.get(url, {
|
||||
responseType: 'blob',
|
||||
onDownloadProgress: (progressEvent) => {
|
||||
if (onProgress && progressEvent.total) {
|
||||
const progress = (progressEvent.loaded / progressEvent.total) * 100;
|
||||
onProgress(progress);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Create download link
|
||||
const blob = new Blob([response.data]);
|
||||
const downloadUrl = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = downloadUrl;
|
||||
link.download = filename || 'download';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(downloadUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload file with progress tracking
|
||||
*/
|
||||
public async uploadFile(
|
||||
url: string,
|
||||
file: File,
|
||||
onProgress?: (progress: number) => void
|
||||
): Promise<HttpResponse> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await this.axiosInstance.post(url, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
onUploadProgress: (progressEvent) => {
|
||||
if (onProgress && progressEvent.total) {
|
||||
const progress = (progressEvent.loaded / progressEvent.total) * 100;
|
||||
onProgress(progress);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return this.transformResponse(response);
|
||||
}
|
||||
}
|
||||
|
||||
export default HttpClientService;
|
||||
0
src/lib/utils.ts
Normal file
0
src/lib/utils.ts
Normal file
282
src/lib/validation.ts
Normal file
282
src/lib/validation.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* Form Validation Schemas
|
||||
* Professional Next.js Resume Builder - Enterprise Auth System
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-08
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Login form validation schema
|
||||
*/
|
||||
export const loginSchema = z.object({
|
||||
email: z
|
||||
.string()
|
||||
.min(1, 'E-Mail ist erforderlich')
|
||||
.email('Bitte geben Sie eine gültige E-Mail-Adresse ein')
|
||||
.max(255, 'E-Mail muss weniger als 255 Zeichen haben'),
|
||||
|
||||
password: z
|
||||
.string()
|
||||
.min(1, 'Passwort ist erforderlich')
|
||||
.min(6, 'Passwort muss mindestens 6 Zeichen lang sein'),
|
||||
|
||||
rememberMe: z.boolean().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Registration form validation schema
|
||||
*/
|
||||
export const registerSchema = z.object({
|
||||
firstName: z
|
||||
.string()
|
||||
.min(1, 'Vorname ist erforderlich')
|
||||
.min(2, 'Vorname muss mindestens 2 Zeichen haben')
|
||||
.max(50, 'Vorname muss weniger als 50 Zeichen haben')
|
||||
.regex(/^[a-zA-ZÀ-ÿ\s'-]+$/, 'Vorname darf nur Buchstaben, Leerzeichen, Bindestriche und Apostrophe enthalten'),
|
||||
|
||||
lastName: z
|
||||
.string()
|
||||
.min(1, 'Nachname ist erforderlich')
|
||||
.min(2, 'Nachname muss mindestens 2 Zeichen haben')
|
||||
.max(50, 'Nachname muss weniger als 50 Zeichen haben')
|
||||
.regex(/^[a-zA-ZÀ-ÿ\s'-]+$/, 'Nachname darf nur Buchstaben, Leerzeichen, Bindestriche und Apostrophe enthalten'),
|
||||
|
||||
email: z
|
||||
.string()
|
||||
.min(1, 'E-Mail ist erforderlich')
|
||||
.email('Bitte geben Sie eine gültige E-Mail-Adresse ein')
|
||||
.max(255, 'E-Mail muss weniger als 255 Zeichen haben'),
|
||||
|
||||
password: z
|
||||
.string()
|
||||
.min(1, 'Passwort ist erforderlich')
|
||||
.min(8, 'Passwort muss mindestens 8 Zeichen lang sein')
|
||||
.max(128, 'Passwort muss weniger als 128 Zeichen haben')
|
||||
.regex(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/,
|
||||
'Passwort muss mindestens einen Großbuchstaben, einen Kleinbuchstaben, eine Zahl und ein Sonderzeichen enthalten'),
|
||||
|
||||
confirmPassword: z
|
||||
.string()
|
||||
.min(1, 'Bitte bestätigen Sie Ihr Passwort'),
|
||||
|
||||
acceptTerms: z
|
||||
.boolean()
|
||||
.refine(val => val === true, 'Sie müssen die Nutzungsbedingungen akzeptieren'),
|
||||
}).refine((data) => data.password === data.confirmPassword, {
|
||||
message: 'Passwörter stimmen nicht überein',
|
||||
path: ['confirmPassword'],
|
||||
});
|
||||
|
||||
/**
|
||||
* Two-factor authentication form validation schema
|
||||
*/
|
||||
export const twoFactorSchema = z.object({
|
||||
verificationCode: z
|
||||
.string()
|
||||
.min(1, 'Bestätigungscode ist erforderlich')
|
||||
.length(6, 'Bestätigungscode muss genau 6 Ziffern haben')
|
||||
.regex(/^\d{6}$/, 'Bestätigungscode darf nur Zahlen enthalten'),
|
||||
});
|
||||
|
||||
/**
|
||||
* Device verification form validation schema
|
||||
*/
|
||||
export const deviceVerificationSchema = z.object({
|
||||
verificationCode: z
|
||||
.string()
|
||||
.min(1, 'Bestätigungscode ist erforderlich')
|
||||
.length(6, 'Bestätigungscode muss genau 6 Ziffern haben')
|
||||
.regex(/^\d{6}$/, 'Bestätigungscode darf nur Zahlen enthalten'),
|
||||
|
||||
deviceName: z
|
||||
.string()
|
||||
.max(100, 'Device name must be less than 100 characters')
|
||||
.optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Password reset request form validation schema
|
||||
*/
|
||||
export const passwordResetRequestSchema = z.object({
|
||||
email: z
|
||||
.string()
|
||||
.min(1, 'Email is required')
|
||||
.email('Please enter a valid email address')
|
||||
.max(255, 'Email must be less than 255 characters'),
|
||||
});
|
||||
|
||||
/**
|
||||
* Password reset confirmation form validation schema
|
||||
*/
|
||||
export const passwordResetConfirmationSchema = z.object({
|
||||
token: z
|
||||
.string()
|
||||
.min(1, 'Reset token is required'),
|
||||
|
||||
newPassword: z
|
||||
.string()
|
||||
.min(1, 'New password is required')
|
||||
.min(8, 'Password must be at least 8 characters long')
|
||||
.max(128, 'Password must be less than 128 characters')
|
||||
.regex(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/,
|
||||
'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character'),
|
||||
|
||||
confirmPassword: z
|
||||
.string()
|
||||
.min(1, 'Please confirm your new password'),
|
||||
}).refine((data) => data.newPassword === data.confirmPassword, {
|
||||
message: 'Passwords do not match',
|
||||
path: ['confirmPassword'],
|
||||
});
|
||||
|
||||
/**
|
||||
* Profile update form validation schema
|
||||
*/
|
||||
export const profileUpdateSchema = z.object({
|
||||
firstName: z
|
||||
.string()
|
||||
.min(1, 'First name is required')
|
||||
.min(2, 'First name must be at least 2 characters')
|
||||
.max(50, 'First name must be less than 50 characters')
|
||||
.regex(/^[a-zA-ZÀ-ÿ\s'-]+$/, 'First name can only contain letters, spaces, hyphens, and apostrophes'),
|
||||
|
||||
lastName: z
|
||||
.string()
|
||||
.min(1, 'Last name is required')
|
||||
.min(2, 'Last name must be at least 2 characters')
|
||||
.max(50, 'Last name must be less than 50 characters')
|
||||
.regex(/^[a-zA-ZÀ-ÿ\s'-]+$/, 'Last name can only contain letters, spaces, hyphens, and apostrophes'),
|
||||
|
||||
email: z
|
||||
.string()
|
||||
.min(1, 'Email is required')
|
||||
.email('Please enter a valid email address')
|
||||
.max(255, 'Email must be less than 255 characters'),
|
||||
|
||||
profileImage: z
|
||||
.string()
|
||||
.url('Profile image must be a valid URL')
|
||||
.optional()
|
||||
.or(z.literal('')),
|
||||
});
|
||||
|
||||
/**
|
||||
* Change password form validation schema
|
||||
*/
|
||||
export const changePasswordSchema = z.object({
|
||||
currentPassword: z
|
||||
.string()
|
||||
.min(1, 'Current password is required'),
|
||||
|
||||
newPassword: z
|
||||
.string()
|
||||
.min(1, 'New password is required')
|
||||
.min(8, 'Password must be at least 8 characters long')
|
||||
.max(128, 'Password must be less than 128 characters')
|
||||
.regex(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/,
|
||||
'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character'),
|
||||
|
||||
confirmPassword: z
|
||||
.string()
|
||||
.min(1, 'Please confirm your new password'),
|
||||
}).refine((data) => data.newPassword === data.confirmPassword, {
|
||||
message: 'Passwords do not match',
|
||||
path: ['confirmPassword'],
|
||||
}).refine((data) => data.currentPassword !== data.newPassword, {
|
||||
message: 'New password must be different from current password',
|
||||
path: ['newPassword'],
|
||||
});
|
||||
|
||||
/**
|
||||
* Email validation helper
|
||||
*/
|
||||
export const validateEmail = (email: string): boolean => {
|
||||
const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
|
||||
return emailRegex.test(email);
|
||||
};
|
||||
|
||||
/**
|
||||
* Password strength validation helper
|
||||
*/
|
||||
export const validatePasswordStrength = (password: string): {
|
||||
score: number;
|
||||
feedback: string[];
|
||||
isValid: boolean;
|
||||
} => {
|
||||
const feedback: string[] = [];
|
||||
let score = 0;
|
||||
|
||||
// Length checks
|
||||
if (password.length >= 8) score += 1;
|
||||
else feedback.push('Password must be at least 8 characters long');
|
||||
|
||||
if (password.length >= 12) score += 1;
|
||||
|
||||
// Character variety checks
|
||||
if (/[a-z]/.test(password)) score += 1;
|
||||
else feedback.push('Add lowercase letters');
|
||||
|
||||
if (/[A-Z]/.test(password)) score += 1;
|
||||
else feedback.push('Add uppercase letters');
|
||||
|
||||
if (/[0-9]/.test(password)) score += 1;
|
||||
else feedback.push('Add numbers');
|
||||
|
||||
if (/[^a-zA-Z0-9]/.test(password)) score += 1;
|
||||
else feedback.push('Add special characters');
|
||||
|
||||
// Pattern checks
|
||||
if (!/(.)\1{2,}/.test(password)) score += 1;
|
||||
else feedback.push('Avoid repeating characters');
|
||||
|
||||
if (!/^.*(123|abc|qwe|password|admin).*$/i.test(password)) score += 1;
|
||||
else feedback.push('Avoid common patterns');
|
||||
|
||||
const isValid = score >= 6 && password.length >= 8;
|
||||
|
||||
return {
|
||||
score: Math.min(score, 8),
|
||||
feedback,
|
||||
isValid
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Sanitize string input
|
||||
*/
|
||||
export const sanitizeString = (input: string): string => {
|
||||
return input
|
||||
.trim()
|
||||
.replace(/[<>\"']/g, '') // Remove potentially dangerous characters
|
||||
.substring(0, 1000); // Limit length
|
||||
};
|
||||
|
||||
/**
|
||||
* Validation error formatter
|
||||
*/
|
||||
export const formatValidationErrors = (errors: z.ZodError): Record<string, string> => {
|
||||
const formattedErrors: Record<string, string> = {};
|
||||
|
||||
errors.issues.forEach((error) => {
|
||||
const path = error.path.join('.');
|
||||
formattedErrors[path] = error.message;
|
||||
});
|
||||
|
||||
return formattedErrors;
|
||||
};
|
||||
|
||||
// Export all schemas as a group for convenience
|
||||
export const authSchemas = {
|
||||
login: loginSchema,
|
||||
register: registerSchema,
|
||||
twoFactor: twoFactorSchema,
|
||||
deviceVerification: deviceVerificationSchema,
|
||||
passwordResetRequest: passwordResetRequestSchema,
|
||||
passwordResetConfirmation: passwordResetConfirmationSchema,
|
||||
profileUpdate: profileUpdateSchema,
|
||||
changePassword: changePasswordSchema,
|
||||
};
|
||||
292
src/middleware.ts
Normal file
292
src/middleware.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* Route Protection Middleware
|
||||
* Professional Next.js Resume Builder - Enterprise Auth System
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-08
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { jwtVerify } from 'jose';
|
||||
import { UserRole } from '@/types/auth';
|
||||
|
||||
/**
|
||||
* Route configuration interface
|
||||
*/
|
||||
interface RouteConfig {
|
||||
requiresAuth?: boolean;
|
||||
allowedRoles?: UserRole[];
|
||||
redirectTo?: string;
|
||||
isPublic?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT payload interface for middleware
|
||||
*/
|
||||
interface JWTPayload {
|
||||
sub: string;
|
||||
email: string;
|
||||
roles?: string[];
|
||||
iat: number;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Route protection configuration
|
||||
*/
|
||||
const routeConfigs: Record<string, RouteConfig> = {
|
||||
// Public routes (no authentication required)
|
||||
'/': { isPublic: true },
|
||||
'/auth/login': { isPublic: true },
|
||||
'/auth/register': { isPublic: true },
|
||||
'/auth/forgot-password': { isPublic: true },
|
||||
'/auth/reset-password': { isPublic: true },
|
||||
'/auth/verify-email': { isPublic: true },
|
||||
'/about': { isPublic: true },
|
||||
'/pricing': { isPublic: true },
|
||||
'/contact': { isPublic: true },
|
||||
'/terms': { isPublic: true },
|
||||
'/privacy': { isPublic: true },
|
||||
|
||||
// Authentication flow routes
|
||||
'/auth/two-factor': { requiresAuth: false }, // Special case - temp auth state
|
||||
'/auth/device-verification': { requiresAuth: false }, // Special case - temp auth state
|
||||
|
||||
// Protected routes (authentication required)
|
||||
'/dashboard': { requiresAuth: true },
|
||||
'/profile': { requiresAuth: true },
|
||||
'/settings': { requiresAuth: true },
|
||||
'/resume-builder': { requiresAuth: true },
|
||||
'/templates': { requiresAuth: true },
|
||||
'/resumes': { requiresAuth: true },
|
||||
|
||||
// Admin routes (admin role required)
|
||||
'/admin': { requiresAuth: true, allowedRoles: [UserRole.ADMIN] },
|
||||
'/admin/users': { requiresAuth: true, allowedRoles: [UserRole.ADMIN] },
|
||||
'/admin/analytics': { requiresAuth: true, allowedRoles: [UserRole.ADMIN] },
|
||||
|
||||
// Moderator routes
|
||||
'/moderation': { requiresAuth: true, allowedRoles: [UserRole.ADMIN, UserRole.MODERATOR] },
|
||||
};
|
||||
|
||||
/**
|
||||
* Get route configuration for a path
|
||||
*/
|
||||
function getRouteConfig(pathname: string): RouteConfig {
|
||||
// Exact match first
|
||||
if (routeConfigs[pathname]) {
|
||||
return routeConfigs[pathname];
|
||||
}
|
||||
|
||||
// Pattern matching for dynamic routes
|
||||
for (const [pattern, config] of Object.entries(routeConfigs)) {
|
||||
if (pattern.includes('[') || pattern.includes('*')) {
|
||||
// Handle dynamic routes and wildcards
|
||||
const regex = new RegExp(
|
||||
'^' + pattern.replace(/\[.*?\]/g, '[^/]+').replace(/\*/g, '.*') + '$'
|
||||
);
|
||||
if (regex.test(pathname)) {
|
||||
return config;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default: require authentication for all other routes
|
||||
return { requiresAuth: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify JWT token
|
||||
*/
|
||||
async function verifyJWTToken(token: string): Promise<JWTPayload | null> {
|
||||
try {
|
||||
const secret = new TextEncoder().encode(
|
||||
process.env.JWT_SECRET || 'your-secret-key'
|
||||
);
|
||||
|
||||
const { payload } = await jwtVerify(token, secret);
|
||||
|
||||
// Validate required fields exist
|
||||
if (payload.sub && payload.email) {
|
||||
return payload as unknown as JWTPayload;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('JWT verification failed:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has required role
|
||||
*/
|
||||
function hasRequiredRole(userRoles: string[], allowedRoles: UserRole[]): boolean {
|
||||
if (!allowedRoles || allowedRoles.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return allowedRoles.some(role => userRoles.includes(role));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get authentication token from request
|
||||
*/
|
||||
function getAuthToken(request: NextRequest): string | null {
|
||||
// Check Authorization header first
|
||||
const authHeader = request.headers.get('authorization');
|
||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||
return authHeader.substring(7);
|
||||
}
|
||||
|
||||
// Check cookies
|
||||
const tokenCookie = request.cookies.get('auth_access_token');
|
||||
if (tokenCookie) {
|
||||
return tokenCookie.value;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main middleware function
|
||||
*/
|
||||
export async function middleware(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl;
|
||||
|
||||
// Skip middleware for static files and API routes
|
||||
if (
|
||||
pathname.startsWith('/_next/') ||
|
||||
pathname.startsWith('/api/') ||
|
||||
pathname.startsWith('/static/') ||
|
||||
pathname.includes('.') // Files with extensions
|
||||
) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
const config = getRouteConfig(pathname);
|
||||
|
||||
// Allow public routes
|
||||
if (config.isPublic) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// Get authentication token
|
||||
const token = getAuthToken(request);
|
||||
|
||||
// If route requires authentication but no token present
|
||||
if (config.requiresAuth && !token) {
|
||||
const loginUrl = new URL('/auth/login', request.url);
|
||||
loginUrl.searchParams.set('returnUrl', pathname);
|
||||
|
||||
return NextResponse.redirect(loginUrl);
|
||||
}
|
||||
|
||||
// If token is present, verify it
|
||||
if (token) {
|
||||
const payload = await verifyJWTToken(token);
|
||||
|
||||
if (!payload) {
|
||||
// Invalid token - redirect to login
|
||||
const loginUrl = new URL('/auth/login', request.url);
|
||||
loginUrl.searchParams.set('returnUrl', pathname);
|
||||
|
||||
// Clear invalid token
|
||||
const response = NextResponse.redirect(loginUrl);
|
||||
response.cookies.delete('auth_access_token');
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
// Check role-based access
|
||||
if (config.allowedRoles && !hasRequiredRole(payload.roles || [], config.allowedRoles)) {
|
||||
// User doesn't have required role
|
||||
return NextResponse.redirect(new URL('/access-denied', request.url));
|
||||
}
|
||||
|
||||
// If user is authenticated and trying to access auth pages, redirect to dashboard
|
||||
if (pathname.startsWith('/auth/') && !pathname.includes('two-factor') && !pathname.includes('device-verification')) {
|
||||
return NextResponse.redirect(new URL('/dashboard', request.url));
|
||||
}
|
||||
}
|
||||
|
||||
// Continue with the request
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware configuration
|
||||
*/
|
||||
export const config = {
|
||||
matcher: [
|
||||
/*
|
||||
* Match all request paths except for the ones starting with:
|
||||
* - api (API routes)
|
||||
* - _next/static (static files)
|
||||
* - _next/image (image optimization files)
|
||||
* - favicon.ico (favicon file)
|
||||
* - public files (images, etc.)
|
||||
*/
|
||||
'/((?!api|_next/static|_next/image|favicon.ico|public).*)',
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper function to protect API routes
|
||||
*/
|
||||
export async function protectAPIRoute(request: NextRequest, allowedRoles?: UserRole[]) {
|
||||
const token = getAuthToken(request);
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Authentication required' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const payload = await verifyJWTToken(token);
|
||||
|
||||
if (!payload) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid or expired token' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
if (allowedRoles && !hasRequiredRole(payload.roles || [], allowedRoles)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Insufficient permissions' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
return { user: payload };
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to check if user is authenticated
|
||||
*/
|
||||
export async function isAuthenticated(request: NextRequest): Promise<boolean> {
|
||||
const token = getAuthToken(request);
|
||||
|
||||
if (!token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const payload = await verifyJWTToken(token);
|
||||
return !!payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to get current user from request
|
||||
*/
|
||||
export async function getCurrentUser(request: NextRequest): Promise<JWTPayload | null> {
|
||||
const token = getAuthToken(request);
|
||||
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await verifyJWTToken(token);
|
||||
}
|
||||
342
src/types/auth.ts
Normal file
342
src/types/auth.ts
Normal file
@@ -0,0 +1,342 @@
|
||||
/**
|
||||
* Authentication Types
|
||||
* Professional Next.js Resume Builder - Enterprise Auth System
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-08
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
|
||||
/**
|
||||
* User authentication credentials interface
|
||||
*/
|
||||
export interface LoginCredentials {
|
||||
email: string;
|
||||
password: string;
|
||||
rememberMe?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* User registration data interface
|
||||
*/
|
||||
export interface RegisterData {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
password: string;
|
||||
confirmPassword: string;
|
||||
acceptTerms: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT token response interface
|
||||
*/
|
||||
export interface AuthTokens {
|
||||
accessToken: string;
|
||||
refreshToken?: string;
|
||||
expiresIn?: number;
|
||||
tokenType?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticated user profile interface
|
||||
*/
|
||||
export interface AuthUser {
|
||||
id: number;
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
roles: string[];
|
||||
permissions?: string[];
|
||||
emailVerified?: boolean;
|
||||
profileImage?: string;
|
||||
lastLoginAt?: Date;
|
||||
createdAt?: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* User role enumeration
|
||||
*/
|
||||
export enum UserRole {
|
||||
ADMIN = 'admin',
|
||||
USER = 'user',
|
||||
MODERATOR = 'moderator',
|
||||
GUEST = 'guest'
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication response interface
|
||||
*/
|
||||
export interface AuthResponse {
|
||||
message?: string;
|
||||
accessToken?: string;
|
||||
user: AuthUser;
|
||||
requiresTwoFactor: boolean;
|
||||
reason?: string;
|
||||
riskScore?: number;
|
||||
tempToken?: string | null;
|
||||
deviceInfo?: {
|
||||
isTrusted: boolean;
|
||||
deviceName?: string;
|
||||
lastUsed?: Date;
|
||||
};
|
||||
requiresDeviceVerification?: boolean;
|
||||
sessionToken?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Password reset request interface
|
||||
*/
|
||||
export interface PasswordResetRequest {
|
||||
email: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Password reset confirmation interface
|
||||
*/
|
||||
export interface PasswordResetConfirmation {
|
||||
token: string;
|
||||
newPassword: string;
|
||||
confirmPassword: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication error interface
|
||||
*/
|
||||
export interface AuthError {
|
||||
code: string;
|
||||
message: string;
|
||||
details?: any;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT token payload interface
|
||||
*/
|
||||
export interface JwtPayload {
|
||||
sub: number; // User ID
|
||||
email: string;
|
||||
roles?: string[];
|
||||
iat: number; // Issued at
|
||||
exp: number; // Expires at
|
||||
iss?: string; // Issuer
|
||||
}
|
||||
|
||||
/**
|
||||
* Session information interface
|
||||
*/
|
||||
export interface SessionInfo {
|
||||
sessionId: string;
|
||||
userId: string;
|
||||
deviceInfo: string;
|
||||
ipAddress: string;
|
||||
userAgent: string;
|
||||
createdAt: Date;
|
||||
expiresAt: Date;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Two-factor authentication setup interface
|
||||
*/
|
||||
export interface TwoFactorSetup {
|
||||
secret: string;
|
||||
qrCode: string;
|
||||
backupCodes: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Two-factor authentication verification interface
|
||||
*/
|
||||
export interface TwoFactorVerification {
|
||||
token: string;
|
||||
backupCode?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Device fingerprint interface
|
||||
*/
|
||||
export interface DeviceFingerprint {
|
||||
userAgent: string;
|
||||
acceptLanguage: string;
|
||||
screenResolution: string;
|
||||
timezone: string;
|
||||
platform: string;
|
||||
cookieEnabled: boolean;
|
||||
doNotTrack: boolean;
|
||||
fontsHash?: string;
|
||||
canvasFingerprint?: string;
|
||||
webglFingerprint?: string;
|
||||
colorDepth?: number;
|
||||
hardwareConcurrency?: number;
|
||||
deviceMemory?: number;
|
||||
touchSupport?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Device verification request interface
|
||||
*/
|
||||
export interface DeviceVerificationRequest {
|
||||
sessionToken: string;
|
||||
verificationCode: string;
|
||||
deviceName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Device verification response interface
|
||||
*/
|
||||
export interface DeviceVerificationResponse {
|
||||
deviceId: string;
|
||||
isKnownDevice: boolean;
|
||||
isTrustedDevice: boolean;
|
||||
requiresTwoFactor: boolean;
|
||||
device: {
|
||||
name: string;
|
||||
platform: string;
|
||||
registeredAt: Date;
|
||||
lastUsedAt?: Date;
|
||||
};
|
||||
user: {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
fullName: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Two-factor authentication initiation request interface
|
||||
*/
|
||||
export interface TwoFactorInitiationRequest {
|
||||
userId: number;
|
||||
method?: 'sms' | 'email' | 'totp';
|
||||
}
|
||||
|
||||
/**
|
||||
* Two-factor authentication initiation response interface
|
||||
*/
|
||||
export interface TwoFactorInitiationResponse {
|
||||
verificationId: string;
|
||||
method: string;
|
||||
message: string;
|
||||
expiresAt: Date;
|
||||
attemptsRemaining: number;
|
||||
durationMinutes: number;
|
||||
mode: string;
|
||||
verificationCode?: string; // Only in demo mode
|
||||
userInfo?: {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
fullName: string;
|
||||
};
|
||||
environmentInfo: {
|
||||
environment: string;
|
||||
demoModeEnabled: boolean;
|
||||
timestamp: string;
|
||||
securityLevel: string;
|
||||
purpose: string;
|
||||
};
|
||||
securityInfo: {
|
||||
rateLimitingEnabled: boolean;
|
||||
auditLoggingEnabled: boolean;
|
||||
sessionId: string;
|
||||
ipAddress: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Two-factor authentication verification request interface
|
||||
*/
|
||||
export interface TwoFactorVerificationRequest {
|
||||
code: string;
|
||||
sessionToken: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Two-factor authentication page data interface
|
||||
*/
|
||||
export interface TwoFactorPageData {
|
||||
userId: number;
|
||||
userEmail: string;
|
||||
userName: string;
|
||||
reason?: string;
|
||||
riskScore?: number;
|
||||
flowType?: 'device_registration' | 'code_required';
|
||||
deviceInfo?: {
|
||||
isTrusted: boolean;
|
||||
deviceName?: string;
|
||||
lastUsed?: Date;
|
||||
};
|
||||
tempToken?: string | null;
|
||||
sessionToken?: string;
|
||||
timestamp?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auth store state interface
|
||||
*/
|
||||
export interface AuthState {
|
||||
user: AuthUser | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
requiresTwoFactor: boolean;
|
||||
requiresDeviceVerification: boolean;
|
||||
twoFactorData: TwoFactorPageData | null;
|
||||
deviceVerificationData: DeviceVerificationResponse | null;
|
||||
error: AuthError | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auth store actions interface
|
||||
*/
|
||||
export interface AuthActions {
|
||||
login: (credentials: LoginCredentials) => Promise<void>;
|
||||
register: (data: RegisterData) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
verifyTwoFactor: (data: TwoFactorVerificationRequest) => Promise<void>;
|
||||
verifyDevice: (data: DeviceVerificationRequest) => Promise<void>;
|
||||
refreshToken: () => Promise<void>;
|
||||
checkAuth: () => Promise<void>;
|
||||
clearError: () => void;
|
||||
clearTwoFactor: () => void;
|
||||
clearDeviceVerification: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Route protection types
|
||||
*/
|
||||
export interface RouteGuardConfig {
|
||||
requiresAuth?: boolean;
|
||||
requiredRoles?: UserRole[];
|
||||
requiredPermissions?: string[];
|
||||
redirectTo?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Form validation schemas
|
||||
*/
|
||||
export interface LoginFormData {
|
||||
email: string;
|
||||
password: string;
|
||||
rememberMe?: boolean;
|
||||
}
|
||||
|
||||
export interface RegisterFormData {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
password: string;
|
||||
confirmPassword: string;
|
||||
acceptTerms: boolean;
|
||||
}
|
||||
|
||||
export interface TwoFactorFormData {
|
||||
verificationCode: string;
|
||||
}
|
||||
|
||||
export interface DeviceVerificationFormData {
|
||||
verificationCode: string;
|
||||
deviceName?: string;
|
||||
}
|
||||
145
src/types/resume.ts
Normal file
145
src/types/resume.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Resume Data Types
|
||||
* Professional Resume Builder
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-07
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
|
||||
export interface PersonalInfo {
|
||||
// Basic Identity
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
jobTitle?: string;
|
||||
profileImage?: string;
|
||||
|
||||
// Contact Information
|
||||
email: string;
|
||||
phone: string;
|
||||
street?: string;
|
||||
houseNumber?: string;
|
||||
zipCode?: string;
|
||||
city: string;
|
||||
country?: string;
|
||||
location: string; // Keep for backward compatibility
|
||||
|
||||
// Personal Details (German CV style)
|
||||
nationality?: string;
|
||||
dateOfBirth?: string;
|
||||
placeOfBirth?: string;
|
||||
maritalStatus?: 'Single' | 'Married' | 'Divorced' | 'Widowed' | 'Other';
|
||||
visaStatus?:
|
||||
| 'German Citizen'
|
||||
| 'EU Citizen'
|
||||
| 'Work Permit'
|
||||
| 'Visa Required'
|
||||
| 'Other';
|
||||
|
||||
// Online Presence
|
||||
website?: string;
|
||||
linkedin?: string;
|
||||
github?: string;
|
||||
|
||||
// Professional Summary
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export interface Experience {
|
||||
id: string;
|
||||
position: string;
|
||||
company: string;
|
||||
location: string;
|
||||
startDate: string;
|
||||
endDate: string | null;
|
||||
isCurrentPosition: boolean;
|
||||
description: string;
|
||||
achievements: string[];
|
||||
}
|
||||
|
||||
export interface Education {
|
||||
id: string;
|
||||
degree: string;
|
||||
institution: string;
|
||||
location: string;
|
||||
startDate: string;
|
||||
endDate: string | null;
|
||||
gpa?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface Skill {
|
||||
id: string;
|
||||
name: string;
|
||||
category: string;
|
||||
level: 'Beginner' | 'Intermediate' | 'Advanced' | 'Expert';
|
||||
}
|
||||
|
||||
export interface Language {
|
||||
id: string;
|
||||
name: string;
|
||||
proficiency: 'A1' | 'A2' | 'B1' | 'B2' | 'C1' | 'C2' | 'Native';
|
||||
}
|
||||
|
||||
export interface Certification {
|
||||
id: string;
|
||||
name: string;
|
||||
issuer: string;
|
||||
issueDate: string;
|
||||
expirationDate?: string | null;
|
||||
credentialId?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
technologies: string[];
|
||||
startDate: string;
|
||||
endDate: string | null;
|
||||
url?: string;
|
||||
github?: string;
|
||||
achievements: string[];
|
||||
}
|
||||
|
||||
export interface ResumeData {
|
||||
personalInfo: PersonalInfo;
|
||||
experience: Experience[];
|
||||
education: Education[];
|
||||
skills: Skill[];
|
||||
languages: Language[];
|
||||
certifications: Certification[];
|
||||
projects: Project[];
|
||||
}
|
||||
|
||||
// David Valera Melendez - Professional Theme Types
|
||||
export interface ResumeTheme {
|
||||
id: string;
|
||||
name: string;
|
||||
colors: {
|
||||
primary: string;
|
||||
secondary: string;
|
||||
accent: string;
|
||||
text: string;
|
||||
background: string;
|
||||
};
|
||||
fonts: {
|
||||
primary: string;
|
||||
secondary: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ExportOptions {
|
||||
format: 'pdf' | 'docx' | 'txt' | 'json';
|
||||
theme: string;
|
||||
includeSections: {
|
||||
personalInfo: boolean;
|
||||
experience: boolean;
|
||||
education: boolean;
|
||||
skills: boolean;
|
||||
languages: boolean;
|
||||
certifications: boolean;
|
||||
projects: boolean;
|
||||
};
|
||||
}
|
||||
84
src/utils/index.ts
Normal file
84
src/utils/index.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Utility Functions
|
||||
* Professional Resume Builder
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-07
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
|
||||
import { type ClassValue, clsx } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
/**
|
||||
* Utility function to merge Tailwind CSS classes
|
||||
* Created by David Valera Melendez
|
||||
*/
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date for display in resume
|
||||
* Created by David Valera Melendez
|
||||
*/
|
||||
export function formatDate(dateString: string | null): string {
|
||||
if (!dateString) return 'Present';
|
||||
|
||||
try {
|
||||
const date = new Date(dateString + '-01'); // Add day for month input
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
});
|
||||
} catch {
|
||||
return dateString;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique ID for form elements
|
||||
* Created by David Valera Melendez
|
||||
*/
|
||||
export function generateId(): string {
|
||||
return Date.now().toString(36) + Math.random().toString(36).substr(2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate email address
|
||||
* Created by David Valera Melendez
|
||||
*/
|
||||
export function isValidEmail(email: string): boolean {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(email);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate URL
|
||||
* Created by David Valera Melendez
|
||||
*/
|
||||
export function isValidUrl(url: string): boolean {
|
||||
try {
|
||||
new URL(url);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate years of experience from date range
|
||||
* Created by David Valera Melendez
|
||||
*/
|
||||
export function calculateYearsOfExperience(
|
||||
startDate: string,
|
||||
endDate: string | null
|
||||
): number {
|
||||
const start = new Date(startDate + '-01');
|
||||
const end = endDate ? new Date(endDate + '-01') : new Date();
|
||||
|
||||
const diffTime = Math.abs(end.getTime() - start.getTime());
|
||||
const diffYears = diffTime / (1000 * 60 * 60 * 24 * 365.25);
|
||||
|
||||
return Math.round(diffYears * 10) / 10; // Round to 1 decimal place
|
||||
}
|
||||
283
tailwind.config.ts
Normal file
283
tailwind.config.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* Tailwind CSS Configuration
|
||||
* Professional Resume Builder
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-07
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
|
||||
import type { Config } from 'tailwindcss';
|
||||
|
||||
const config: Config = {
|
||||
content: [
|
||||
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
// David Valera Melendez Professional Brand Colors
|
||||
// Enhanced with resume-example.com design patterns
|
||||
primary: {
|
||||
50: '#f0f9ff',
|
||||
100: '#e0f2fe',
|
||||
200: '#bae6fd',
|
||||
300: '#7dd3fc',
|
||||
400: '#38bdf8',
|
||||
500: '#0ea5e9',
|
||||
600: '#0284c7',
|
||||
700: '#0369a1',
|
||||
800: '#075985',
|
||||
900: '#0c4a6e',
|
||||
950: '#082f49',
|
||||
},
|
||||
secondary: {
|
||||
50: '#f8fafc',
|
||||
100: '#f1f5f9',
|
||||
200: '#e2e8f0',
|
||||
300: '#cbd5e1',
|
||||
400: '#94a3b8',
|
||||
500: '#64748b',
|
||||
600: '#475569',
|
||||
700: '#334155',
|
||||
800: '#1e293b',
|
||||
900: '#0f172a',
|
||||
950: '#020617',
|
||||
},
|
||||
// Enhanced grayscale system inspired by resume-example.com
|
||||
grayscale: {
|
||||
25: '#fcfcfd',
|
||||
50: '#f9fafb',
|
||||
100: '#f2f4f7',
|
||||
200: '#eaecf0',
|
||||
300: '#d0d5dd',
|
||||
400: '#98a2b3',
|
||||
500: '#667085',
|
||||
600: '#475467',
|
||||
700: '#344054',
|
||||
800: '#182230',
|
||||
900: '#101828',
|
||||
950: '#0c111d',
|
||||
},
|
||||
accent: {
|
||||
50: '#fef7ff',
|
||||
100: '#feeeff',
|
||||
200: '#fcddff',
|
||||
300: '#f9bbff',
|
||||
400: '#f589ff',
|
||||
500: '#ee56ff',
|
||||
600: '#d633e7',
|
||||
700: '#b322c3',
|
||||
800: '#921b9f',
|
||||
900: '#771a82',
|
||||
950: '#50045a',
|
||||
},
|
||||
// Enhanced semantic colors
|
||||
success: {
|
||||
50: '#ecfdf5',
|
||||
100: '#d1fae5',
|
||||
200: '#a7f3d0',
|
||||
300: '#6ee7b7',
|
||||
400: '#34d399',
|
||||
500: '#10b981',
|
||||
600: '#059669',
|
||||
700: '#047857',
|
||||
800: '#065f46',
|
||||
900: '#064e3b',
|
||||
},
|
||||
warning: {
|
||||
50: '#fffbeb',
|
||||
100: '#fef3c7',
|
||||
200: '#fde68a',
|
||||
300: '#fcd34d',
|
||||
400: '#fbbf24',
|
||||
500: '#f59e0b',
|
||||
600: '#d97706',
|
||||
700: '#b45309',
|
||||
800: '#92400e',
|
||||
900: '#78350f',
|
||||
},
|
||||
error: {
|
||||
50: '#fef2f2',
|
||||
100: '#fee2e2',
|
||||
200: '#fecaca',
|
||||
300: '#fca5a5',
|
||||
400: '#f87171',
|
||||
500: '#ef4444',
|
||||
600: '#dc2626',
|
||||
700: '#b91c1c',
|
||||
800: '#991b1b',
|
||||
900: '#7f1d1d',
|
||||
},
|
||||
// Professional background colors
|
||||
background: {
|
||||
primary: '#ffffff',
|
||||
secondary: '#f8fafc',
|
||||
tertiary: '#f1f5f9',
|
||||
inverse: '#0f172a',
|
||||
},
|
||||
// Text color system
|
||||
text: {
|
||||
primary: '#0f172a',
|
||||
secondary: '#334155',
|
||||
tertiary: '#64748b',
|
||||
quaternary: '#94a3b8',
|
||||
inverse: '#ffffff',
|
||||
link: '#0ea5e9',
|
||||
'link-hover': '#0284c7',
|
||||
},
|
||||
// Border color system
|
||||
border: {
|
||||
primary: '#e2e8f0',
|
||||
secondary: '#cbd5e1',
|
||||
tertiary: '#94a3b8',
|
||||
focus: '#0ea5e9',
|
||||
error: '#ef4444',
|
||||
success: '#10b981',
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
sans: [
|
||||
'Inter',
|
||||
'system-ui',
|
||||
'-apple-system',
|
||||
'BlinkMacSystemFont',
|
||||
'Segoe UI',
|
||||
'Roboto',
|
||||
'Helvetica Neue',
|
||||
'Arial',
|
||||
'sans-serif',
|
||||
],
|
||||
serif: ['Georgia', 'Times New Roman', 'serif'],
|
||||
mono: [
|
||||
'JetBrains Mono',
|
||||
'Monaco',
|
||||
'Consolas',
|
||||
'Liberation Mono',
|
||||
'Courier New',
|
||||
'monospace',
|
||||
],
|
||||
display: ['Inter', 'system-ui', 'sans-serif'],
|
||||
},
|
||||
fontSize: {
|
||||
'2xs': [
|
||||
'0.625rem',
|
||||
{ lineHeight: '0.875rem', letterSpacing: '0.025em' },
|
||||
],
|
||||
xs: ['0.75rem', { lineHeight: '1rem', letterSpacing: '0.025em' }],
|
||||
sm: ['0.875rem', { lineHeight: '1.25rem', letterSpacing: '0.0125em' }],
|
||||
base: ['1rem', { lineHeight: '1.5rem', letterSpacing: '0' }],
|
||||
lg: ['1.125rem', { lineHeight: '1.75rem', letterSpacing: '-0.0125em' }],
|
||||
xl: ['1.25rem', { lineHeight: '1.75rem', letterSpacing: '-0.025em' }],
|
||||
'2xl': ['1.5rem', { lineHeight: '2rem', letterSpacing: '-0.025em' }],
|
||||
'3xl': [
|
||||
'1.875rem',
|
||||
{ lineHeight: '2.25rem', letterSpacing: '-0.0375em' },
|
||||
],
|
||||
'4xl': ['2.25rem', { lineHeight: '2.5rem', letterSpacing: '-0.05em' }],
|
||||
'5xl': ['3rem', { lineHeight: '1', letterSpacing: '-0.05em' }],
|
||||
'6xl': ['3.75rem', { lineHeight: '1', letterSpacing: '-0.0625em' }],
|
||||
'7xl': ['4.5rem', { lineHeight: '1', letterSpacing: '-0.0625em' }],
|
||||
'8xl': ['6rem', { lineHeight: '1', letterSpacing: '-0.075em' }],
|
||||
'9xl': ['8rem', { lineHeight: '1', letterSpacing: '-0.075em' }],
|
||||
},
|
||||
spacing: {
|
||||
'18': '4.5rem',
|
||||
'88': '22rem',
|
||||
'128': '32rem',
|
||||
'144': '36rem',
|
||||
'160': '40rem',
|
||||
'176': '44rem',
|
||||
'192': '48rem',
|
||||
},
|
||||
animation: {
|
||||
'fade-in': 'fadeIn 0.3s ease-in-out',
|
||||
'fade-out': 'fadeOut 0.3s ease-in-out',
|
||||
'slide-up': 'slideUp 0.3s ease-out',
|
||||
'slide-down': 'slideDown 0.3s ease-out',
|
||||
'slide-in-right': 'slideInRight 0.3s ease-out',
|
||||
'slide-in-left': 'slideInLeft 0.3s ease-out',
|
||||
'bounce-soft': 'bounceSoft 0.6s ease-in-out',
|
||||
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
||||
'scale-in': 'scaleIn 0.2s ease-out',
|
||||
'scale-out': 'scaleOut 0.2s ease-out',
|
||||
},
|
||||
keyframes: {
|
||||
fadeIn: {
|
||||
'0%': { opacity: '0' },
|
||||
'100%': { opacity: '1' },
|
||||
},
|
||||
fadeOut: {
|
||||
'0%': { opacity: '1' },
|
||||
'100%': { opacity: '0' },
|
||||
},
|
||||
slideUp: {
|
||||
'0%': { transform: 'translateY(10px)', opacity: '0' },
|
||||
'100%': { transform: 'translateY(0)', opacity: '1' },
|
||||
},
|
||||
slideDown: {
|
||||
'0%': { transform: 'translateY(-10px)', opacity: '0' },
|
||||
'100%': { transform: 'translateY(0)', opacity: '1' },
|
||||
},
|
||||
slideInRight: {
|
||||
'0%': { transform: 'translateX(10px)', opacity: '0' },
|
||||
'100%': { transform: 'translateX(0)', opacity: '1' },
|
||||
},
|
||||
slideInLeft: {
|
||||
'0%': { transform: 'translateX(-10px)', opacity: '0' },
|
||||
'100%': { transform: 'translateX(0)', opacity: '1' },
|
||||
},
|
||||
bounceSoft: {
|
||||
'0%, 100%': { transform: 'scale(1)' },
|
||||
'50%': { transform: 'scale(1.02)' },
|
||||
},
|
||||
scaleIn: {
|
||||
'0%': { transform: 'scale(0.95)', opacity: '0' },
|
||||
'100%': { transform: 'scale(1)', opacity: '1' },
|
||||
},
|
||||
scaleOut: {
|
||||
'0%': { transform: 'scale(1)', opacity: '1' },
|
||||
'100%': { transform: 'scale(0.95)', opacity: '0' },
|
||||
},
|
||||
},
|
||||
boxShadow: {
|
||||
soft: '0 1px 3px 0 rgba(0, 0, 0, 0.05), 0 1px 2px 0 rgba(0, 0, 0, 0.06)',
|
||||
medium:
|
||||
'0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03)',
|
||||
large:
|
||||
'0 10px 15px -3px rgba(0, 0, 0, 0.05), 0 4px 6px -2px rgba(0, 0, 0, 0.03)',
|
||||
xl: '0 20px 25px -5px rgba(0, 0, 0, 0.05), 0 10px 10px -5px rgba(0, 0, 0, 0.02)',
|
||||
'2xl': '0 25px 50px -12px rgba(0, 0, 0, 0.15)',
|
||||
inner: 'inset 0 2px 4px 0 rgba(0, 0, 0, 0.03)',
|
||||
focus: '0 0 0 3px rgba(14, 165, 233, 0.1)',
|
||||
'focus-error': '0 0 0 3px rgba(239, 68, 68, 0.1)',
|
||||
'focus-success': '0 0 0 3px rgba(16, 185, 129, 0.1)',
|
||||
},
|
||||
borderRadius: {
|
||||
'4xl': '2rem',
|
||||
'5xl': '2.5rem',
|
||||
'6xl': '3rem',
|
||||
},
|
||||
backdropBlur: {
|
||||
xs: '2px',
|
||||
},
|
||||
backgroundImage: {
|
||||
'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
|
||||
'gradient-conic':
|
||||
'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))',
|
||||
},
|
||||
zIndex: {
|
||||
'60': '60',
|
||||
'70': '70',
|
||||
'80': '80',
|
||||
'90': '90',
|
||||
'100': '100',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
|
||||
export default config;
|
||||
33
tsconfig.json
Normal file
33
tsconfig.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["dom", "dom.iterable", "es6"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"@/components/*": ["./src/components/*"],
|
||||
"@/lib/*": ["./src/lib/*"],
|
||||
"@/types/*": ["./src/types/*"],
|
||||
"@/hooks/*": ["./src/hooks/*"],
|
||||
"@/utils/*": ["./src/utils/*"],
|
||||
"@/styles/*": ["./src/styles/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user