From e25d53d054ba0765bce66b7c334b5511dd1c6d65 Mon Sep 17 00:00:00 2001 From: David Melendez Date: Wed, 14 Jan 2026 22:38:44 +0100 Subject: [PATCH] init commit --- README.md | 268 +++++++- app/Console/Kernel.php | 27 + app/DTOs/UserDTO.php | 51 ++ app/Exceptions/Handler.php | 30 + app/Http/Controllers/Api/AuthController.php | 117 ++++ app/Http/Controllers/Api/V1/ApiController.php | 55 ++ app/Http/Controllers/Auth/AuthController.php | 586 +++++++++++++++++ app/Http/Controllers/Controller.php | 25 + app/Http/Controllers/DashboardController.php | 154 +++++ app/Http/Controllers/HomeController.php | 99 +++ app/Http/Controllers/ProfileController.php | 169 +++++ .../Controllers/ResumeBuilderController.php | 356 ++++++++++ app/Http/Controllers/TemplateController.php | 87 +++ app/Http/Kernel.php | 76 +++ app/Http/Middleware/Authenticate.php | 17 + app/Http/Middleware/EncryptCookies.php | 17 + .../PreventRequestsDuringMaintenance.php | 17 + .../Middleware/RedirectIfAuthenticated.php | 30 + app/Http/Middleware/TrimStrings.php | 19 + app/Http/Middleware/TrustHosts.php | 20 + app/Http/Middleware/TrustProxies.php | 28 + app/Http/Middleware/ValidateSignature.php | 22 + app/Http/Middleware/VerifyCsrfToken.php | 17 + app/Http/Requests/Auth/LoginRequest.php | 110 ++++ app/Http/Requests/Auth/RegisterRequest.php | 209 ++++++ .../Requests/Auth/UpdateProfileRequest.php | 119 ++++ .../Requests/Resume/StoreResumeRequest.php | 336 ++++++++++ .../Requests/Resume/UpdateResumeRequest.php | 465 +++++++++++++ app/Interfaces/BaseRepositoryInterface.php | 156 +++++ app/Interfaces/ResumeRepositoryInterface.php | 206 ++++++ app/Interfaces/UserRepositoryInterface.php | 157 +++++ app/Models/Resume.php | 260 ++++++++ app/Models/User.php | 191 ++++++ app/Providers/AppServiceProvider.php | 24 + app/Providers/AuthServiceProvider.php | 26 + app/Providers/BroadcastServiceProvider.php | 19 + app/Providers/EventServiceProvider.php | 38 ++ app/Providers/RepositoryServiceProvider.php | 63 ++ app/Providers/RouteServiceProvider.php | 40 ++ app/Repositories/BaseRepository.php | 345 ++++++++++ app/Repositories/ResumeRepository.php | 522 +++++++++++++++ app/Repositories/UserRepository.php | 410 ++++++++++++ app/Services/AuthService.php | 447 +++++++++++++ app/Services/ProfileCompletionService.php | 127 ++++ app/Services/ResumeService.php | 380 +++++++++++ app/Services/SecurityService.php | 400 ++++++++++++ bootstrap/app.php | 64 ++ bootstrap/cache/.gitignore | 2 + config/app.php | 189 ++++++ config/auth.php | 115 ++++ config/broadcasting.php | 71 ++ config/cache.php | 111 ++++ config/cors.php | 34 + config/database.php | 151 +++++ config/filesystems.php | 76 +++ config/hashing.php | 54 ++ config/logging.php | 131 ++++ config/mail.php | 134 ++++ config/queue.php | 109 +++ config/sanctum.php | 83 +++ config/services.php | 34 + config/session.php | 214 ++++++ config/view.php | 36 + database/.gitignore | 1 + database/factories/ResumeFactory.php | 295 +++++++++ database/factories/UserFactory.php | 97 +++ .../2024_01_01_000000_create_users_table.php | 63 ++ ...001_create_password_reset_tokens_table.php | 30 + ..._01_01_000002_create_failed_jobs_table.php | 35 + ...2024_01_01_000004_create_resumes_table.php | 64 ++ ...01_01_000005_create_resume_views_table.php | 44 ++ ...1_01_000006_create_activity_logs_table.php | 40 ++ database/seeders/DatabaseSeeder.php | 20 + database/seeders/ResumeSeeder.php | 242 +++++++ database/seeders/UserSeeder.php | 66 ++ public/.htaccess | 21 + public/favicon.ico | 0 public/index.php | 55 ++ public/robots.txt | 2 + resources/css/app.css | 0 resources/js/app.js | 534 +++++++++++++++ resources/js/bootstrap.js | 32 + resources/sass/abstracts/_mixins.scss | 234 +++++++ resources/sass/abstracts/_variables.scss | 288 ++++++++ resources/sass/app.scss | 47 ++ resources/sass/base/_base.scss | 231 +++++++ resources/sass/components/_buttons.scss | 465 +++++++++++++ resources/sass/components/_cards.scss | 537 +++++++++++++++ resources/sass/components/_forms.scss | 585 +++++++++++++++++ resources/sass/layouts/_footer.scss | 484 ++++++++++++++ resources/sass/layouts/_header.scss | 478 ++++++++++++++ resources/sass/pages/_dashboard-old.scss | 570 ++++++++++++++++ resources/sass/pages/_dashboard.scss | 357 ++++++++++ resources/sass/pages/_login.scss | 494 ++++++++++++++ resources/sass/pages/_register-complete.scss | 188 ++++++ resources/sass/pages/_register.scss | 516 +++++++++++++++ resources/sass/pages/_resume-create.scss | 529 +++++++++++++++ resources/sass/pages/_resume-index.scss | 510 +++++++++++++++ resources/sass/utilities/_helpers.scss | 618 ++++++++++++++++++ resources/views/auth/login.blade.php | 194 ++++++ resources/views/auth/register.blade.php | 315 +++++++++ resources/views/dashboard/index.blade.php | 197 ++++++ resources/views/layouts/app.blade.php | 251 +++++++ resources/views/layouts/auth.blade.php | 205 ++++++ resources/views/profile/edit.blade.php | 220 +++++++ resources/views/profile/settings.blade.php | 421 ++++++++++++ resources/views/profile/show.blade.php | 233 +++++++ .../views/resume-builder/create.blade.php | 189 ++++++ .../views/resume-builder/index.blade.php | 327 +++++++++ resources/views/templates/index.blade.php | 277 ++++++++ resources/views/templates/preview.blade.php | 443 +++++++++++++ resources/views/templates/show.blade.php | 267 ++++++++ routes/api.php | 166 +++++ routes/channels.php | 18 + routes/console.php | 19 + routes/web.php | 170 +++++ storage/app/.gitignore | 3 + storage/app/public/.gitignore | 2 + storage/framework/.gitignore | 9 + storage/framework/cache/.gitignore | 3 + storage/framework/cache/data/.gitignore | 2 + storage/framework/sessions/.gitignore | 2 + storage/framework/testing/.gitignore | 2 + storage/framework/views/.gitignore | 2 + 124 files changed, 21653 insertions(+), 1 deletion(-) create mode 100644 app/Console/Kernel.php create mode 100644 app/DTOs/UserDTO.php create mode 100644 app/Exceptions/Handler.php create mode 100644 app/Http/Controllers/Api/AuthController.php create mode 100644 app/Http/Controllers/Api/V1/ApiController.php create mode 100644 app/Http/Controllers/Auth/AuthController.php create mode 100644 app/Http/Controllers/Controller.php create mode 100644 app/Http/Controllers/DashboardController.php create mode 100644 app/Http/Controllers/HomeController.php create mode 100644 app/Http/Controllers/ProfileController.php create mode 100644 app/Http/Controllers/ResumeBuilderController.php create mode 100644 app/Http/Controllers/TemplateController.php create mode 100644 app/Http/Kernel.php create mode 100644 app/Http/Middleware/Authenticate.php create mode 100644 app/Http/Middleware/EncryptCookies.php create mode 100644 app/Http/Middleware/PreventRequestsDuringMaintenance.php create mode 100644 app/Http/Middleware/RedirectIfAuthenticated.php create mode 100644 app/Http/Middleware/TrimStrings.php create mode 100644 app/Http/Middleware/TrustHosts.php create mode 100644 app/Http/Middleware/TrustProxies.php create mode 100644 app/Http/Middleware/ValidateSignature.php create mode 100644 app/Http/Middleware/VerifyCsrfToken.php create mode 100644 app/Http/Requests/Auth/LoginRequest.php create mode 100644 app/Http/Requests/Auth/RegisterRequest.php create mode 100644 app/Http/Requests/Auth/UpdateProfileRequest.php create mode 100644 app/Http/Requests/Resume/StoreResumeRequest.php create mode 100644 app/Http/Requests/Resume/UpdateResumeRequest.php create mode 100644 app/Interfaces/BaseRepositoryInterface.php create mode 100644 app/Interfaces/ResumeRepositoryInterface.php create mode 100644 app/Interfaces/UserRepositoryInterface.php create mode 100644 app/Models/Resume.php create mode 100644 app/Models/User.php create mode 100644 app/Providers/AppServiceProvider.php create mode 100644 app/Providers/AuthServiceProvider.php create mode 100644 app/Providers/BroadcastServiceProvider.php create mode 100644 app/Providers/EventServiceProvider.php create mode 100644 app/Providers/RepositoryServiceProvider.php create mode 100644 app/Providers/RouteServiceProvider.php create mode 100644 app/Repositories/BaseRepository.php create mode 100644 app/Repositories/ResumeRepository.php create mode 100644 app/Repositories/UserRepository.php create mode 100644 app/Services/AuthService.php create mode 100644 app/Services/ProfileCompletionService.php create mode 100644 app/Services/ResumeService.php create mode 100644 app/Services/SecurityService.php create mode 100644 bootstrap/app.php create mode 100644 bootstrap/cache/.gitignore create mode 100644 config/app.php create mode 100644 config/auth.php create mode 100644 config/broadcasting.php create mode 100644 config/cache.php create mode 100644 config/cors.php create mode 100644 config/database.php create mode 100644 config/filesystems.php create mode 100644 config/hashing.php create mode 100644 config/logging.php create mode 100644 config/mail.php create mode 100644 config/queue.php create mode 100644 config/sanctum.php create mode 100644 config/services.php create mode 100644 config/session.php create mode 100644 config/view.php create mode 100644 database/.gitignore create mode 100644 database/factories/ResumeFactory.php create mode 100644 database/factories/UserFactory.php create mode 100644 database/migrations/2024_01_01_000000_create_users_table.php create mode 100644 database/migrations/2024_01_01_000001_create_password_reset_tokens_table.php create mode 100644 database/migrations/2024_01_01_000002_create_failed_jobs_table.php create mode 100644 database/migrations/2024_01_01_000004_create_resumes_table.php create mode 100644 database/migrations/2024_01_01_000005_create_resume_views_table.php create mode 100644 database/migrations/2024_01_01_000006_create_activity_logs_table.php create mode 100644 database/seeders/DatabaseSeeder.php create mode 100644 database/seeders/ResumeSeeder.php create mode 100644 database/seeders/UserSeeder.php create mode 100644 public/.htaccess create mode 100644 public/favicon.ico create mode 100644 public/index.php create mode 100644 public/robots.txt create mode 100644 resources/css/app.css create mode 100644 resources/js/app.js create mode 100644 resources/js/bootstrap.js create mode 100644 resources/sass/abstracts/_mixins.scss create mode 100644 resources/sass/abstracts/_variables.scss create mode 100644 resources/sass/app.scss create mode 100644 resources/sass/base/_base.scss create mode 100644 resources/sass/components/_buttons.scss create mode 100644 resources/sass/components/_cards.scss create mode 100644 resources/sass/components/_forms.scss create mode 100644 resources/sass/layouts/_footer.scss create mode 100644 resources/sass/layouts/_header.scss create mode 100644 resources/sass/pages/_dashboard-old.scss create mode 100644 resources/sass/pages/_dashboard.scss create mode 100644 resources/sass/pages/_login.scss create mode 100644 resources/sass/pages/_register-complete.scss create mode 100644 resources/sass/pages/_register.scss create mode 100644 resources/sass/pages/_resume-create.scss create mode 100644 resources/sass/pages/_resume-index.scss create mode 100644 resources/sass/utilities/_helpers.scss create mode 100644 resources/views/auth/login.blade.php create mode 100644 resources/views/auth/register.blade.php create mode 100644 resources/views/dashboard/index.blade.php create mode 100644 resources/views/layouts/app.blade.php create mode 100644 resources/views/layouts/auth.blade.php create mode 100644 resources/views/profile/edit.blade.php create mode 100644 resources/views/profile/settings.blade.php create mode 100644 resources/views/profile/show.blade.php create mode 100644 resources/views/resume-builder/create.blade.php create mode 100644 resources/views/resume-builder/index.blade.php create mode 100644 resources/views/templates/index.blade.php create mode 100644 resources/views/templates/preview.blade.php create mode 100644 resources/views/templates/show.blade.php create mode 100644 routes/api.php create mode 100644 routes/channels.php create mode 100644 routes/console.php create mode 100644 routes/web.php create mode 100644 storage/app/.gitignore create mode 100644 storage/app/public/.gitignore create mode 100644 storage/framework/.gitignore create mode 100644 storage/framework/cache/.gitignore create mode 100644 storage/framework/cache/data/.gitignore create mode 100644 storage/framework/sessions/.gitignore create mode 100644 storage/framework/testing/.gitignore create mode 100644 storage/framework/views/.gitignore diff --git a/README.md b/README.md index ee0253e..f493549 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,268 @@ -# Laravel +/** + * Professional Resume Builder - Laravel Application + * Created by David Valera Melendez + * + * @author David Valera Melendez + * @created 2025-08-08 + * @location Made in Germany ๐Ÿ‡ฉ๐Ÿ‡ช + */ +# Professional Laravel Resume Builder + +**Created by David Valera Melendez** | david@valera-melendez.de | Made in Germany ๐Ÿ‡ฉ๐Ÿ‡ช + +A modern, professional resume builder application built with Laravel 10, Bootstrap 5, and Blade templates. This project follows enterprise-grade architecture patterns and design principles, inspired by the Angular Material UI version. + +## ๐Ÿš€ Features + +- **Professional Design**: Clean, modern interface with Bootstrap 5 +- **Enterprise Authentication**: Secure JWT-based authentication system +- **Real-time Validation**: Client-side and server-side form validation +- **Responsive Design**: Mobile-first responsive layout +- **Multi-step Forms**: Intuitive step-by-step resume building process +- **PDF Export**: Generate professional PDF resumes +- **Admin Dashboard**: Administrative interface for user management +- **Security Features**: CSRF protection, rate limiting, and secure sessions + +## ๐Ÿ› ๏ธ Tech Stack + +- **Framework**: Laravel 10.x +- **Frontend**: Bootstrap 5, Sass, Vite +- **Database**: MySQL/PostgreSQL +- **Authentication**: Laravel Sanctum +- **Templating**: Blade Templates +- **Icons**: Bootstrap Icons +- **PDF Generation**: Laravel DomPDF/Snappy +- **Asset Building**: Vite + +## ๐Ÿ“ฆ Installation + +1. **Prerequisites**: PHP 8.1+, Composer, Node.js 18+, MySQL/PostgreSQL + +2. **Clone and setup**: +```bash +# Navigate to project directory +cd Laravel + +# Install PHP dependencies +composer install + +# Install Node.js dependencies +npm install + +# Copy environment file +cp .env.example .env + +# Generate application key +php artisan key:generate + +# Configure database in .env file +# Run migrations +php artisan migrate + +# Seed database +php artisan db:seed + +# Build assets +npm run build + +# Start development server +php artisan serve +``` + +## ๐Ÿš€ Available Commands + +```bash +# Development +php artisan serve # Start development server +npm run dev # Start Vite development server +npm run build # Build assets for production + +# Database +php artisan migrate # Run migrations +php artisan db:seed # Seed database +php artisan migrate:fresh --seed # Fresh migration with seeding + +# Cache +php artisan config:cache # Cache configuration +php artisan route:cache # Cache routes +php artisan view:cache # Cache views + +# Testing +php artisan test # Run PHPUnit tests +``` + +## ๐Ÿ“ Project Structure + +``` +Laravel/ +โ”œโ”€โ”€ app/ +โ”‚ โ”œโ”€โ”€ Http/ +โ”‚ โ”‚ โ”œโ”€โ”€ Controllers/ # Application controllers +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ Auth/ # Authentication controllers +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ Dashboard/ # Dashboard controllers +โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ Api/ # API controllers +โ”‚ โ”‚ โ”œโ”€โ”€ Middleware/ # Custom middleware +โ”‚ โ”‚ โ”œโ”€โ”€ Requests/ # Form request validation +โ”‚ โ”‚ โ””โ”€โ”€ Resources/ # API resources +โ”‚ โ”œโ”€โ”€ Models/ # Eloquent models +โ”‚ โ”œโ”€โ”€ Services/ # Business logic services +โ”‚ โ””โ”€โ”€ Providers/ # Service providers +โ”œโ”€โ”€ database/ +โ”‚ โ”œโ”€โ”€ migrations/ # Database migrations +โ”‚ โ”œโ”€โ”€ seeders/ # Database seeders +โ”‚ โ””โ”€โ”€ factories/ # Model factories +โ”œโ”€โ”€ resources/ +โ”‚ โ”œโ”€โ”€ views/ # Blade templates +โ”‚ โ”‚ โ”œโ”€โ”€ layouts/ # Layout templates +โ”‚ โ”‚ โ”œโ”€โ”€ auth/ # Authentication views +โ”‚ โ”‚ โ”œโ”€โ”€ dashboard/ # Dashboard views +โ”‚ โ”‚ โ””โ”€โ”€ components/ # Reusable components +โ”‚ โ”œโ”€โ”€ js/ # JavaScript files +โ”‚ โ””โ”€โ”€ sass/ # Sass stylesheets +โ”œโ”€โ”€ routes/ +โ”‚ โ”œโ”€โ”€ web.php # Web routes +โ”‚ โ”œโ”€โ”€ api.php # API routes +โ”‚ โ””โ”€โ”€ auth.php # Authentication routes +โ”œโ”€โ”€ storage/ # File storage +โ”œโ”€โ”€ tests/ # Test files +โ”œโ”€โ”€ public/ # Public assets +โ””โ”€โ”€ config/ # Configuration files +``` + +## ๐ŸŽจ Design System + +### Bootstrap Theme + +- **Primary**: Professional blue (#0d6efd) - Bootstrap primary +- **Secondary**: Complementary colors for UI elements +- **Success/Warning/Danger**: Standard Bootstrap states +- **Typography**: System font stack with fallbacks + +### Component Architecture + +- **Bootstrap Components**: Utilizing Bootstrap 5's comprehensive component library +- **Custom Components**: Blade components for reusable UI elements +- **Responsive Layout**: CSS Grid and Flexbox with Bootstrap's grid system +- **Consistent Spacing**: Bootstrap spacing utilities + +## ๐ŸŽฏ Usage + +1. **Start the application**: `php artisan serve` +2. **Build assets**: `npm run dev` or `npm run build` +3. **Register/Login** to access the resume builder +4. **Fill in your information** using the multi-step form +5. **Preview your resume** in real-time +6. **Export to PDF** when ready + +### Resume Sections + +- **Personal Information**: Contact details and professional summary +- **Work Experience**: Job history with achievements +- **Education**: Academic background +- **Skills**: Technical and soft skills with proficiency levels +- **Languages**: Language skills with proficiency +- **Certifications**: Professional certifications +- **Projects**: Notable projects and achievements + +## ๐Ÿ”ง Development Guidelines + +### Code Standards + +- **PSR-4**: Following PSR-4 autoloading standard +- **Laravel Best Practices**: Following Laravel coding standards +- **Bootstrap Conventions**: Consistent with Bootstrap design patterns +- **Professional Quality**: Production-ready code with proper documentation +- **Author Attribution**: David Valera Melendez signature on all files + +### Architecture Principles + +- **MVC Pattern**: Model-View-Controller architecture +- **Service Layer**: Business logic in dedicated service classes +- **Repository Pattern**: Data access abstraction +- **Dependency Injection**: Laravel's service container +- **Form Validation**: Request validation with custom rules + +## ๐Ÿ”’ Security Features + +- **Authentication**: Laravel Sanctum for API authentication +- **Authorization**: Policy-based authorization +- **CSRF Protection**: Cross-site request forgery protection +- **Rate Limiting**: API and form submission rate limiting +- **Input Validation**: Comprehensive input validation and sanitization +- **Secure Sessions**: Encrypted session storage + +## ๐Ÿ“ฑ Browser Support + +- Chrome (recommended) +- Firefox +- Safari +- Edge +- Mobile browsers (iOS Safari, Chrome Mobile) + +## ๐Ÿš€ Deployment + +### Production Build +```bash +composer install --optimize-autoloader --no-dev +npm run build +php artisan config:cache +php artisan route:cache +php artisan view:cache +``` + +### Deploy to: +- DigitalOcean +- AWS EC2 +- Laravel Forge +- Heroku +- Any VPS with PHP support + +## ๐Ÿ“Š Performance + +### Optimization Features +- **Asset Bundling**: Vite for optimized asset compilation +- **Database Optimization**: Query optimization and indexing +- **Caching**: Redis/Memcached support for sessions and cache +- **CDN Ready**: Asset URLs configurable for CDN delivery + +## ๐Ÿงช Testing + +```bash +# Run all tests +php artisan test + +# Run specific test suite +php artisan test --testsuite=Feature +php artisan test --testsuite=Unit + +# Run with coverage +php artisan test --coverage +``` + +## ๐ŸŒ Internationalization + +- **Multi-language Support**: Laravel's localization system +- **German Localization**: Primary language support +- **English Support**: Secondary language +- **RTL Support**: Right-to-left language compatibility + +## ๐Ÿ‘จโ€๐Ÿ’ป Author + +**David Valera Melendez** + +- Email: david@valera-melendez.de +- Location: Germany ๐Ÿ‡ฉ๐Ÿ‡ช +- Created: August 8, 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 Laravel Resume Builder** - Helping professionals create outstanding resumes with modern web technologies | Made in Germany ๐Ÿ‡ฉ๐Ÿ‡ช diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php new file mode 100644 index 0000000..e6b9960 --- /dev/null +++ b/app/Console/Kernel.php @@ -0,0 +1,27 @@ +command('inspire')->hourly(); + } + + /** + * Register the commands for the application. + */ + protected function commands(): void + { + $this->load(__DIR__.'/Commands'); + + require base_path('routes/console.php'); + } +} diff --git a/app/DTOs/UserDTO.php b/app/DTOs/UserDTO.php new file mode 100644 index 0000000..18ef925 --- /dev/null +++ b/app/DTOs/UserDTO.php @@ -0,0 +1,51 @@ + + * @created 2025-08-08 + * @location Made in Germany ๐Ÿ‡ฉ๐Ÿ‡ช + */ +class UserDTO +{ + public function __construct( + public readonly string $first_name, + public readonly string $last_name, + public readonly string $email, + public readonly ?string $phone = null, + public readonly ?string $profession = null, + public readonly ?string $location = null, + public readonly ?string $status = 'active' + ) {} + + public function toArray(): array + { + return [ + 'first_name' => $this->first_name, + 'last_name' => $this->last_name, + 'email' => $this->email, + 'phone' => $this->phone, + 'profession' => $this->profession, + 'location' => $this->location, + 'status' => $this->status, + ]; + } + + public static function fromArray(array $data): self + { + return new self( + first_name: $data['first_name'], + last_name: $data['last_name'], + email: $data['email'], + phone: $data['phone'] ?? null, + profession: $data['profession'] ?? null, + location: $data['location'] ?? null, + status: $data['status'] ?? 'active' + ); + } +} diff --git a/app/Exceptions/Handler.php b/app/Exceptions/Handler.php new file mode 100644 index 0000000..56af264 --- /dev/null +++ b/app/Exceptions/Handler.php @@ -0,0 +1,30 @@ + + */ + protected $dontFlash = [ + 'current_password', + 'password', + 'password_confirmation', + ]; + + /** + * Register the exception handling callbacks for the application. + */ + public function register(): void + { + $this->reportable(function (Throwable $e) { + // + }); + } +} diff --git a/app/Http/Controllers/Api/AuthController.php b/app/Http/Controllers/Api/AuthController.php new file mode 100644 index 0000000..879c192 --- /dev/null +++ b/app/Http/Controllers/Api/AuthController.php @@ -0,0 +1,117 @@ + + * @created 2025-08-09 + * @location Made in Germany ๐Ÿ‡ฉ๐Ÿ‡ช + */ +class AuthController extends Controller +{ + /** + * Handle API login + */ + public function login(Request $request): JsonResponse + { + return response()->json([ + 'message' => 'API login not implemented yet', + 'status' => 'error' + ], 501); + } + + /** + * Handle API registration + */ + public function register(Request $request): JsonResponse + { + return response()->json([ + 'message' => 'API registration not implemented yet', + 'status' => 'error' + ], 501); + } + + /** + * Handle forgot password + */ + public function forgotPassword(Request $request): JsonResponse + { + return response()->json([ + 'message' => 'API forgot password not implemented yet', + 'status' => 'error' + ], 501); + } + + /** + * Handle password reset + */ + public function resetPassword(Request $request): JsonResponse + { + return response()->json([ + 'message' => 'API password reset not implemented yet', + 'status' => 'error' + ], 501); + } + + /** + * Refresh token + */ + public function refresh(Request $request): JsonResponse + { + return response()->json([ + 'message' => 'API token refresh not implemented yet', + 'status' => 'error' + ], 501); + } + + /** + * Handle logout + */ + public function logout(Request $request): JsonResponse + { + return response()->json([ + 'message' => 'API logout not implemented yet', + 'status' => 'error' + ], 501); + } + + /** + * Get current user + */ + public function me(Request $request): JsonResponse + { + return response()->json([ + 'message' => 'API user profile not implemented yet', + 'status' => 'error' + ], 501); + } + + /** + * Update user profile + */ + public function updateProfile(Request $request): JsonResponse + { + return response()->json([ + 'message' => 'API profile update not implemented yet', + 'status' => 'error' + ], 501); + } + + /** + * Update password + */ + public function updatePassword(Request $request): JsonResponse + { + return response()->json([ + 'message' => 'API password update not implemented yet', + 'status' => 'error' + ], 501); + } +} diff --git a/app/Http/Controllers/Api/V1/ApiController.php b/app/Http/Controllers/Api/V1/ApiController.php new file mode 100644 index 0000000..f1d6d13 --- /dev/null +++ b/app/Http/Controllers/Api/V1/ApiController.php @@ -0,0 +1,55 @@ + + * @created 2025-08-08 + * @location Made in Germany ๐Ÿ‡ฉ๐Ÿ‡ช + */ +abstract class ApiController extends Controller +{ + /** + * Success response + */ + protected function successResponse($data = null, string $message = 'Success', int $code = 200): JsonResponse + { + return response()->json([ + 'success' => true, + 'message' => $message, + 'data' => $data, + ], $code); + } + + /** + * Error response + */ + protected function errorResponse(string $message = 'Error', int $code = 400, $errors = null): JsonResponse + { + $response = [ + 'success' => false, + 'message' => $message, + ]; + + if ($errors) { + $response['errors'] = $errors; + } + + return response()->json($response, $code); + } + + /** + * Validation error response + */ + protected function validationErrorResponse($errors): JsonResponse + { + return $this->errorResponse('Validation failed', 422, $errors); + } +} diff --git a/app/Http/Controllers/Auth/AuthController.php b/app/Http/Controllers/Auth/AuthController.php new file mode 100644 index 0000000..910f3d4 --- /dev/null +++ b/app/Http/Controllers/Auth/AuthController.php @@ -0,0 +1,586 @@ + + * @created 2025-08-08 + * @location Made in Germany ๐Ÿ‡ฉ๐Ÿ‡ช + */ + +namespace App\Http\Controllers\Auth; + +use App\Http\Controllers\Controller; +use App\Http\Requests\Auth\LoginRequest; +use App\Http\Requests\Auth\RegisterRequest; +use App\Http\Requests\Auth\UpdateProfileRequest; +use App\Interfaces\UserRepositoryInterface; +use App\Services\AuthService; +use App\Services\ProfileCompletionService; +use Illuminate\Http\RedirectResponse; +use Illuminate\Http\Request; +use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Log; +use Illuminate\View\View; +use Illuminate\Http\JsonResponse; +use Illuminate\Validation\ValidationException; +use Exception; + +/** + * Authentication Controller + * Handles user authentication, registration, and session management with repository pattern + */ +class AuthController extends Controller +{ + /** + * User repository instance + */ + private UserRepositoryInterface $userRepository; + + /** + * AuthService instance + */ + private AuthService $authService; + + /** + * Profile completion service instance + */ + private ProfileCompletionService $profileCompletionService; + + /** + * Maximum login attempts before lockout + */ + private const MAX_LOGIN_ATTEMPTS = 5; + + /** + * Account lockout duration in minutes + */ + private const LOCKOUT_DURATION = 15; + + /** + * Create a new controller instance with dependency injection + */ + public function __construct( + UserRepositoryInterface $userRepository, + AuthService $authService, + ProfileCompletionService $profileCompletionService + ) { + $this->userRepository = $userRepository; + $this->authService = $authService; + $this->profileCompletionService = $profileCompletionService; + $this->middleware('guest')->except(['logout', 'profile', 'updateProfile', 'activity']); + $this->middleware('auth')->only(['logout', 'profile', 'updateProfile', 'activity']); + } + + /** + * Show the login form + */ + public function showLoginForm(): View + { + return view('auth.login', [ + 'title' => 'Sign In - Professional Resume Builder', + 'description' => 'Access your professional resume builder account' + ]); + } + + /** + * Handle a login request with enhanced maintainability + * + * @param LoginRequest $request + * @return RedirectResponse + */ + public function login(LoginRequest $request): RedirectResponse + { + try { + $credentials = $request->validated(); + $loginResult = $this->authService->attemptLogin($credentials); + + return $this->handleLoginResult($loginResult, $request); + + } catch (ValidationException $e) { + return $this->handleLoginValidationError($e, $request); + } catch (Exception $e) { + return $this->handleLoginSystemError($e, $request); + } + } + + /** + * Handle the result of a login attempt + * + * @param array $loginResult + * @param LoginRequest $request + * @return RedirectResponse + */ + private function handleLoginResult(array $loginResult, LoginRequest $request): RedirectResponse + { + if ($loginResult['success']) { + return $this->handleSuccessfulLogin($loginResult, $request); + } + + return $this->handleFailedLogin($loginResult, $request); + } + + /** + * Handle successful login response + * + * @param array $loginResult + * @param LoginRequest $request + * @return RedirectResponse + */ + private function handleSuccessfulLogin(array $loginResult, LoginRequest $request): RedirectResponse + { + $request->session()->regenerate(); + + Log::info('User logged in successfully', [ + 'user_id' => $loginResult['user']->id, + 'email' => $loginResult['user']->email, + 'ip_address' => $request->ip(), + 'user_agent' => $request->userAgent(), + 'login_method' => 'standard' + ]); + + $welcomeMessage = $this->getWelcomeMessage($loginResult['user']); + $redirectUrl = $this->getPostLoginRedirectUrl($loginResult['user']); + + return redirect()->intended($redirectUrl)->with('success', $welcomeMessage); + } + + /** + * Handle failed login response + * + * @param array $loginResult + * @param LoginRequest $request + * @return RedirectResponse + */ + private function handleFailedLogin(array $loginResult, LoginRequest $request): RedirectResponse + { + $errorMessage = $this->getLoginErrorMessage($loginResult['reason']); + + Log::warning('Login attempt failed', [ + 'email' => $request->input('email'), + 'reason' => $loginResult['reason'], + 'ip_address' => $request->ip(), + 'user_agent' => $request->userAgent() + ]); + + return back() + ->withErrors(['email' => $errorMessage]) + ->onlyInput('email'); + } + + /** + * Handle validation errors during login + * + * @param ValidationException $e + * @param LoginRequest $request + * @return RedirectResponse + */ + private function handleLoginValidationError(ValidationException $e, LoginRequest $request): RedirectResponse + { + Log::warning('Login validation error', [ + 'email' => $request->input('email'), + 'errors' => $e->errors(), + 'ip_address' => $request->ip() + ]); + + return back() + ->withErrors($e->errors()) + ->onlyInput('email'); + } + + /** + * Handle system errors during login + * + * @param Exception $e + * @param LoginRequest $request + * @return RedirectResponse + */ + private function handleLoginSystemError(Exception $e, LoginRequest $request): RedirectResponse + { + Log::error('Login system error', [ + 'error_message' => $e->getMessage(), + 'error_trace' => $e->getTraceAsString(), + 'email' => $request->input('email'), + 'ip_address' => $request->ip(), + 'timestamp' => now() + ]); + + return back() + ->withErrors(['email' => 'A system error occurred. Please try again or contact support.']) + ->onlyInput('email'); + } + + /** + * Get personalized welcome message + * + * @param User $user + * @return string + */ + private function getWelcomeMessage(User $user): string + { + $timeOfDay = $this->getTimeOfDay(); + $firstName = $user->first_name ?? 'there'; + + return "Good {$timeOfDay}, {$firstName}! Welcome back to your professional resume builder."; + } + + /** + * Get post-login redirect URL based on user profile and activity state + * + * Intelligently determines the most appropriate landing page based on: + * - Profile completion percentage (incomplete profiles โ†’ profile page) + * - Resume creation status (no resumes โ†’ dashboard for guidance) + * - Default โ†’ main dashboard + * + * @param User $user The authenticated user + * @return string The route name for redirection + */ + private function getPostLoginRedirectUrl(User $user): string + { + $profileCompletion = $this->profileCompletionService->calculateCompletion($user); + + if ($profileCompletion < 50) { + return route('profile'); + } + + $userStats = $this->userRepository->getUserStatistics($user->id); + if (($userStats['resumes_count'] ?? 0) === 0) { + return route('dashboard'); + } + + return route('dashboard'); + } + + /** + * Get appropriate error message based on failure reason + * + * @param string $reason + * @return string + */ + private function getLoginErrorMessage(string $reason): string + { + switch ($reason) { + case 'account_locked': + return 'Account is temporarily locked due to too many failed attempts. Please try again later.'; + case 'account_inactive': + return 'Your account has been deactivated. Please contact support.'; + case 'invalid_credentials': + return 'The provided credentials do not match our records.'; + case 'too_many_attempts': + return 'Too many failed attempts. Account has been locked for security.'; + case 'rate_limited': + return 'Too many login attempts. Please wait before trying again.'; + default: + return 'Login failed. Please check your credentials and try again.'; + } + } + + /** + * Get time of day greeting + * + * @return string + */ + private function getTimeOfDay(): string + { + $hour = now()->hour; + + if ($hour < 12) { + return 'morning'; + } elseif ($hour < 17) { + return 'afternoon'; + } else { + return 'evening'; + } + } + + /** + * Show the registration form + */ + public function showRegistrationForm(): View + { + return view('auth.register', [ + 'title' => 'Create Account - Professional Resume Builder', + 'description' => 'Join thousands of professionals creating outstanding resumes' + ]); + } + + /** + * Handle a registration request + */ + public function register(RegisterRequest $request): RedirectResponse + { + try { + $userData = $request->validated(); + + $user = $this->authService->createUser($userData); + + if ($user) { + Auth::login($user); + + // Log successful registration + logger()->info('User registered successfully', [ + 'user_id' => $user->id, + 'email' => $user->email, + 'ip' => $request->ip(), + 'user_agent' => $request->userAgent() + ]); + + return redirect()->route('dashboard') + ->with('success', 'Welcome! Your account has been created successfully.'); + } + + return back()->withErrors([ + 'email' => 'Unable to create account. Please try again.', + ])->withInput(); + + } catch (\Exception $e) { + logger()->error('Registration error', [ + 'error' => $e->getMessage(), + 'email' => $request->input('email'), + 'ip' => $request->ip() + ]); + + return back()->withErrors([ + 'email' => 'An error occurred during registration. Please try again.', + ])->withInput(); + } + } + + /** + * Handle logout request + */ + public function logout(Request $request): RedirectResponse + { + // Log the logout + logger()->info('User logged out', [ + 'user_id' => Auth::id(), + 'ip' => $request->ip() + ]); + + Auth::logout(); + + $request->session()->invalidate(); + $request->session()->regenerateToken(); + + return redirect()->route('login') + ->with('success', 'You have been successfully logged out.'); + } + + /** + * Show user profile with comprehensive analytics + */ + public function profile(): View + { + try { + $user = Auth::user(); + + // Get user statistics from repository + $userStats = $this->userRepository->getUserStatistics($user->id); + + // Get profile completion analysis from service + $profileCompletion = $this->profileCompletionService->calculateCompletion($user); + $profileAnalysis = $this->profileCompletionService->getDetailedAnalysis($user); + + // Prepare view data + $viewData = [ + 'title' => 'Profile Settings - Professional Resume Builder', + 'user' => $user, + 'userStats' => $userStats, + 'profileCompletion' => $profileCompletion, + 'profileAnalysis' => $profileAnalysis, + 'accountSecurity' => [ + 'two_factor_enabled' => $user->two_factor_secret !== null, + 'email_verified' => $user->email_verified_at !== null, + 'is_locked' => $this->userRepository->isAccountLocked($user->email) + ] + ]; + + // Log profile view for analytics + Log::info('User profile viewed', [ + 'user_id' => $user->id, + 'profile_completion' => $profileCompletion, + 'ip_address' => request()->ip(), + 'timestamp' => now() + ]); + + return view('auth.profile', $viewData); + + } catch (Exception $e) { + Log::error('Error loading user profile', [ + 'error_message' => $e->getMessage(), + 'user_id' => Auth::id(), + 'ip_address' => request()->ip(), + 'timestamp' => now() + ]); + + // Fallback with minimal data + return view('auth.profile', [ + 'title' => 'Profile Settings', + 'user' => Auth::user(), + 'userStats' => [], + 'profileCompletion' => 0, + 'error' => 'Unable to load profile data completely' + ]); + } + } + + /** + * Update user profile using professional form request validation + * + * Handles comprehensive profile updates with: + * - Repository pattern for data persistence + * - Structured logging for security audit trails + * - Real-time profile completion calculation + * - User-friendly completion status messaging + * + * @param UpdateProfileRequest $request Validated profile update data + * @return RedirectResponse Response with success message or error state + * @throws Exception When profile update fails due to system errors + */ + public function updateProfile(UpdateProfileRequest $request): RedirectResponse + { + try { + $user = Auth::user(); + $updateData = $request->validated(); + + $updatedUser = $this->userRepository->update($user->id, $updateData); + + Log::info('User profile updated successfully', [ + 'user_id' => $user->id, + 'updated_fields' => array_keys($updateData), + 'ip_address' => $request->ip(), + 'user_agent' => $request->userAgent(), + 'timestamp' => now() + ]); + + $completionPercentage = $this->profileCompletionService + ->calculateCompletion($updatedUser); + + $message = "Profile updated successfully! "; + if ($completionPercentage < 100) { + $message .= "Your profile is {$completionPercentage}% complete."; + } else { + $message .= "Your profile is now 100% complete!"; + } + + return back()->with('success', $message); + + } catch (Exception $e) { + Log::error('Profile update failed', [ + 'error_message' => $e->getMessage(), + 'error_trace' => $e->getTraceAsString(), + 'user_id' => Auth::id(), + 'request_data' => $request->except(['password', 'password_confirmation']), + 'ip_address' => $request->ip(), + 'timestamp' => now() + ]); + + return back() + ->withErrors(['error' => 'Unable to update profile. Please try again.']) + ->withInput(); + } + } + + /** + * Retrieve comprehensive user activity and analytics data + * + * Provides detailed activity analytics including: + * - User profile and authentication information + * - Login activity patterns and security metrics + * - Profile completion analysis with detailed breakdown + * - Account security status and recommendations + * - Usage statistics (resumes, templates, exports) + * + * @return JsonResponse Structured activity data with metadata + * @throws Exception When analytics data retrieval fails + */ + public function activity(): JsonResponse + { + try { + $user = Auth::user(); + + $userStats = $this->userRepository->getUserStatistics($user->id); + + $profileCompletion = $this->profileCompletionService + ->calculateCompletion($user); + + $profileAnalysis = $this->profileCompletionService + ->getDetailedAnalysis($user); + + $activityData = [ + 'user_info' => [ + 'id' => $user->id, + 'name' => $user->first_name . ' ' . $user->last_name, + 'email' => $user->email, + 'member_since' => $user->created_at->format('F Y'), + 'account_status' => $user->status ?? 'active' + ], + 'login_activity' => [ + 'last_login' => $user->last_login_at ? $user->last_login_at->format('Y-m-d H:i:s') : null, + 'last_login_ip' => $user->last_login_ip, + 'total_logins' => $userStats['total_logins'] ?? 0, + 'failed_attempts' => $user->login_attempts ?? 0 + ], + 'profile_metrics' => [ + 'completion_percentage' => $profileCompletion, + 'completed_fields' => $profileAnalysis['completed_fields'], + 'missing_fields' => $profileAnalysis['missing_fields'], + 'completion_score' => $profileAnalysis['weighted_score'] + ], + 'account_security' => [ + 'two_factor_enabled' => $user->two_factor_secret !== null, + 'email_verified' => $user->email_verified_at !== null, + 'password_updated' => $user->password_updated_at ? $user->password_updated_at->format('Y-m-d') : null, + 'is_locked' => $this->userRepository->isAccountLocked($user->email) + ], + 'activity_summary' => [ + 'resumes_created' => $userStats['resumes_count'] ?? 0, + 'templates_used' => $userStats['templates_used'] ?? 0, + 'exports_generated' => $userStats['exports_count'] ?? 0, + 'profile_views' => $userStats['profile_views'] ?? 0 + ] + ]; + + Log::info('User activity data requested', [ + 'user_id' => $user->id, + 'ip_address' => request()->ip(), + 'timestamp' => now() + ]); + + return response()->json([ + 'success' => true, + 'data' => $activityData, + 'meta' => [ + 'generated_at' => now()->toISOString(), + 'version' => '1.0' + ] + ]); + + } catch (Exception $e) { + Log::error('Failed to retrieve user activity data', [ + 'error_message' => $e->getMessage(), + 'user_id' => Auth::id(), + 'ip_address' => request()->ip(), + 'timestamp' => now() + ]); + + return response()->json([ + 'success' => false, + 'message' => 'Unable to retrieve activity data', + 'error' => app()->environment('local') ? $e->getMessage() : 'Internal server error' + ], 500); + } + } + + /** + * Show forgot password form + */ + public function showForgotPasswordForm(): View + { + return view('auth.forgot-password', [ + 'title' => 'Reset Password - Professional Resume Builder', + 'description' => 'Enter your email to receive password reset instructions' + ]); + } +} diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php new file mode 100644 index 0000000..fb87ee7 --- /dev/null +++ b/app/Http/Controllers/Controller.php @@ -0,0 +1,25 @@ + + * @created 2025-08-08 + * @location Made in Germany ๐Ÿ‡ฉ๐Ÿ‡ช + */ + +namespace App\Http\Controllers; + +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Foundation\Validation\ValidatesRequests; +use Illuminate\Routing\Controller as BaseController; + +/** + * Base Controller + * Parent class for all application controllers + */ +abstract class Controller extends BaseController +{ + use AuthorizesRequests, ValidatesRequests; +} diff --git a/app/Http/Controllers/DashboardController.php b/app/Http/Controllers/DashboardController.php new file mode 100644 index 0000000..417c8f2 --- /dev/null +++ b/app/Http/Controllers/DashboardController.php @@ -0,0 +1,154 @@ + + * @created 2025-08-08 + * @location Made in Germany ๐Ÿ‡ฉ๐Ÿ‡ช + */ + +namespace App\Http\Controllers; + +use App\Interfaces\UserRepositoryInterface; +use App\Interfaces\ResumeRepositoryInterface; +use App\Services\ResumeService; +use Illuminate\Http\Request; +use Illuminate\Support\Facades\Auth; +use Illuminate\View\View; + +/** + * Dashboard Controller + * Handles the main dashboard functionality with repository pattern + */ +class DashboardController extends Controller +{ + /** + * User repository instance + */ + protected UserRepositoryInterface $userRepository; + + /** + * Resume repository instance + */ + protected ResumeRepositoryInterface $resumeRepository; + + /** + * Resume service instance + */ + protected ResumeService $resumeService; + + /** + * Create a new controller instance with dependency injection + */ + public function __construct( + UserRepositoryInterface $userRepository, + ResumeRepositoryInterface $resumeRepository, + ResumeService $resumeService + ) { + $this->userRepository = $userRepository; + $this->resumeRepository = $resumeRepository; + $this->resumeService = $resumeService; + $this->middleware('auth'); + } + + /** + * Show the application dashboard + */ + public function index(): View + { + $user = Auth::user(); + + // Get user's resumes using repository + $resumes = $this->resumeRepository->getUserResumes($user->id); + + // Get resume statistics using repository + $resumeStats = $this->resumeRepository->getResumeStatistics($user->id); + + // Get recent resumes (last 5) + $recentResumes = $this->resumeRepository + ->orderBy('updated_at', 'desc') + ->limit(5) + ->getUserResumes($user->id); + + // Calculate profile completion + $profileCompletion = $this->calculateProfileCompletion($user); + + return view('dashboard.index', [ + 'title' => 'Dashboard - Professional Resume Builder', + 'user' => $user, + 'resumes' => $resumes, + 'resumeStats' => $resumeStats, + 'recentResumes' => $recentResumes, + 'profileCompletion' => $profileCompletion, + 'stats' => [ + 'total_resumes' => $resumeStats['total_resumes'], + 'published_resumes' => $resumeStats['published_resumes'], + 'draft_resumes' => $resumeStats['draft_resumes'], + 'completed_resumes' => $resumeStats['completed_resumes'], + 'avg_completion' => $resumeStats['avg_completion'], + 'total_views' => $resumeStats['total_views'], + 'total_downloads' => $resumeStats['total_downloads'], + 'profile_completion' => $profileCompletion + ] + ]); + } + + /** + * Calculate profile completion percentage + */ + private function calculateProfileCompletion($user): int + { + $requiredFields = [ + 'first_name', 'last_name', 'email', 'phone', + 'bio', 'website', 'linkedin' + ]; + + $completed = 0; + foreach ($requiredFields as $field) { + if (!empty($user->$field)) { + $completed++; + } + } + + return (int) (($completed / count($requiredFields)) * 100); + } + + /** + * Get dashboard analytics data + */ + public function analytics(): \Illuminate\Http\JsonResponse + { + $user = Auth::user(); + + $analytics = [ + 'user_stats' => $this->userRepository->getUserStatistics(), + 'resume_stats' => $this->resumeRepository->getResumeStatistics($user->id), + 'recent_activity' => $this->getRecentActivity($user->id), + ]; + + return response()->json($analytics); + } + + /** + * Get recent user activity + */ + private function getRecentActivity(int $userId): array + { + $recentResumes = $this->resumeRepository + ->orderBy('updated_at', 'desc') + ->limit(10) + ->getUserResumes($userId); + + return $recentResumes->map(function ($resume) { + return [ + 'id' => $resume->id, + 'title' => $resume->title, + 'action' => 'updated', + 'timestamp' => $resume->updated_at, + 'status' => $resume->status, + ]; + })->toArray(); + } +} diff --git a/app/Http/Controllers/HomeController.php b/app/Http/Controllers/HomeController.php new file mode 100644 index 0000000..d661999 --- /dev/null +++ b/app/Http/Controllers/HomeController.php @@ -0,0 +1,99 @@ +route('dashboard'); + } + + // If not authenticated, redirect to login + return redirect()->route('login'); + } + + /** + * Display the features page. + */ + public function features(): View + { + return view('home.features'); + } + + /** + * Display the pricing page. + */ + public function pricing(): View + { + return view('home.pricing'); + } + + /** + * Display the help page. + */ + public function help(): View + { + return view('home.help'); + } + + /** + * Display the contact page. + */ + public function contact(): View + { + return view('home.contact'); + } + + /** + * Handle contact form submission. + */ + public function submitContact(Request $request) + { + $request->validate([ + 'name' => 'required|string|max:255', + 'email' => 'required|email|max:255', + 'subject' => 'required|string|max:255', + 'message' => 'required|string', + ]); + + // Handle contact form logic here + // You can send email, save to database, etc. + + return redirect()->route('contact')->with('success', 'Your message has been sent successfully!'); + } + + /** + * Display the privacy policy page. + */ + public function privacy(): View + { + return view('home.privacy'); + } + + /** + * Display the terms of service page. + */ + public function terms(): View + { + return view('home.terms'); + } + + /** + * Display the support page. + */ + public function support(): View + { + return view('home.support'); + } +} diff --git a/app/Http/Controllers/ProfileController.php b/app/Http/Controllers/ProfileController.php new file mode 100644 index 0000000..c1592f8 --- /dev/null +++ b/app/Http/Controllers/ProfileController.php @@ -0,0 +1,169 @@ +validate([ + 'name' => 'required|string|max:255', + 'email' => 'required|email|max:255|unique:users,email,' . $user->id, + 'phone' => 'nullable|string|max:20', + 'bio' => 'nullable|string|max:1000', + ]); + + $user->update($request->only(['name', 'email', 'phone', 'bio'])); + + return redirect()->route('profile.show')->with('success', 'Profile updated successfully!'); + } + + /** + * Delete the user's account. + */ + public function destroy(Request $request): RedirectResponse + { + $request->validate([ + 'password' => 'required|current_password', + ]); + + $user = Auth::user(); + Auth::logout(); + + $user->delete(); + + $request->session()->invalidate(); + $request->session()->regenerateToken(); + + return redirect('/')->with('success', 'Your account has been deleted.'); + } + + /** + * Show the settings page. + */ + public function settings(): View + { + $user = Auth::user(); + return view('profile.settings', compact('user')); + } + + /** + * Update the user's password. + */ + public function updatePassword(Request $request): RedirectResponse + { + $request->validate([ + 'current_password' => 'required|current_password', + 'password' => ['required', 'confirmed', Password::defaults()], + ]); + + Auth::user()->update([ + 'password' => Hash::make($request->password), + ]); + + return redirect()->route('profile.settings')->with('success', 'Password updated successfully!'); + } + + /** + * Update user preferences. + */ + public function updatePreferences(Request $request): RedirectResponse + { + $request->validate([ + 'theme' => 'required|in:light,dark,auto', + 'notifications_email' => 'boolean', + 'notifications_browser' => 'boolean', + 'language' => 'required|string|max:5', + ]); + + $user = Auth::user(); + + // If your User model has a preferences JSON column + $preferences = $user->preferences ?? []; + $preferences['theme'] = $request->theme; + $preferences['notifications_email'] = $request->boolean('notifications_email'); + $preferences['notifications_browser'] = $request->boolean('notifications_browser'); + $preferences['language'] = $request->language; + + $user->update(['preferences' => $preferences]); + + return redirect()->route('profile.settings')->with('success', 'Preferences updated successfully!'); + } + + /** + * Update user avatar. + */ + public function updateAvatar(Request $request): RedirectResponse + { + $request->validate([ + 'avatar' => 'required|image|mimes:jpeg,png,jpg,gif|max:2048', + ]); + + $user = Auth::user(); + + // Delete old avatar if exists + if ($user->avatar && Storage::exists('public/' . $user->avatar)) { + Storage::delete('public/' . $user->avatar); + } + + // Store new avatar + $avatarPath = $request->file('avatar')->store('avatars', 'public'); + + $user->update(['avatar' => $avatarPath]); + + return redirect()->route('profile.settings')->with('success', 'Avatar updated successfully!'); + } + + /** + * Show profile completion status. + */ + public function completion(): View + { + $user = Auth::user(); + + // Calculate completion percentage + $fields = ['name', 'email', 'phone', 'bio', 'avatar']; + $completedFields = 0; + + foreach ($fields as $field) { + if (!empty($user->$field)) { + $completedFields++; + } + } + + $completionPercentage = round(($completedFields / count($fields)) * 100); + + return view('profile.completion', compact('user', 'completionPercentage')); + } +} diff --git a/app/Http/Controllers/ResumeBuilderController.php b/app/Http/Controllers/ResumeBuilderController.php new file mode 100644 index 0000000..ef55d1d --- /dev/null +++ b/app/Http/Controllers/ResumeBuilderController.php @@ -0,0 +1,356 @@ + + * @created 2025-08-08 + * @location Made in Germany ๐Ÿ‡ฉ๐Ÿ‡ช + */ + +namespace App\Http\Controllers; + +use App\Http\Requests\Resume\StoreResumeRequest; +use App\Http\Requests\Resume\UpdateResumeRequest; +use App\Models\Resume; +use App\Interfaces\ResumeRepositoryInterface; +use App\Interfaces\UserRepositoryInterface; +use App\Services\ResumeService; +use Illuminate\Http\RedirectResponse; +use Illuminate\Http\Request; +use Illuminate\Support\Facades\Auth; +use Illuminate\View\View; + +/** + * Resume Builder Controller + * Handles resume creation, editing, and management with repository pattern + */ +class ResumeBuilderController extends Controller +{ + /** + * Resume repository instance + */ + protected ResumeRepositoryInterface $resumeRepository; + + /** + * User repository instance + */ + protected UserRepositoryInterface $userRepository; + + /** + * ResumeService instance + */ + protected ResumeService $resumeService; + + /** + * Create a new controller instance with dependency injection + */ + public function __construct( + ResumeRepositoryInterface $resumeRepository, + UserRepositoryInterface $userRepository, + ResumeService $resumeService + ) { + $this->resumeRepository = $resumeRepository; + $this->userRepository = $userRepository; + $this->resumeService = $resumeService; + $this->middleware('auth'); + } + + /** + * Display a listing of user's resumes + */ + public function index(Request $request): View + { + $userId = Auth::id(); + + // Get paginated resumes using repository + $resumes = $this->resumeRepository->getPaginatedUserResumes($userId, 12); + + // Get resume statistics using repository + $resumeStats = $this->resumeRepository->getResumeStatistics($userId); + + return view('resume-builder.index', [ + 'title' => 'My Resumes - Resume Builder', + 'resumes' => $resumes, + 'resumeStats' => $resumeStats + ]); + } + + /** + * Show the form for creating a new resume + */ + public function create(): View + { + $userId = Auth::id(); + + // Check if user can create more resumes + if (!$this->resumeRepository->canUserCreateMoreResumes($userId, 10)) { + return redirect()->route('resume-builder.index') + ->with('error', 'You have reached the maximum number of resumes allowed.'); + } + + $templates = $this->resumeService->getAvailableTemplates(); + + return view('resume-builder.create', [ + 'title' => 'Create New Resume', + 'templates' => $templates + ]); + } + + /** + * Store a newly created resume + */ + public function store(StoreResumeRequest $request): RedirectResponse + { + try { + $userId = Auth::id(); + + // Check if user can create more resumes + if (!$this->resumeRepository->canUserCreateMoreResumes($userId, 10)) { + return back()->withErrors([ + 'title' => 'You have reached the maximum number of resumes allowed.' + ])->withInput(); + } + + $resumeData = $request->validated(); + $resumeData['user_id'] = $userId; + + // Create resume using repository + $resume = $this->resumeRepository->create($resumeData); + + // Update completion percentage + $this->resumeRepository->updateCompletionPercentage($resume->id); + + logger()->info('Resume created', [ + 'user_id' => $userId, + 'resume_id' => $resume->id, + 'title' => $resume->title + ]); + + return redirect()->route('resume-builder.edit', $resume) + ->with('success', 'Resume created successfully! Start building your professional CV.'); + + } catch (\Exception $e) { + logger()->error('Resume creation error', [ + 'error' => $e->getMessage(), + 'user_id' => Auth::id() + ]); + + return back()->withErrors([ + 'title' => 'Unable to create resume. Please try again.' + ])->withInput(); + } + } + + /** + * Show the form for editing the specified resume + */ + public function edit(Resume $resume): View + { + $this->authorize('update', $resume); + + return view('resume-builder.edit', [ + 'title' => 'Edit Resume - ' . $resume->title, + 'resume' => $resume, + 'templates' => $this->resumeService->getAvailableTemplates() + ]); + } + + /** + * Update the specified resume + */ + public function update(UpdateResumeRequest $request, Resume $resume): RedirectResponse + { + $this->authorize('update', $resume); + + try { + $resumeData = $request->validated(); + + // Update resume using repository + $this->resumeRepository->update($resume->id, $resumeData); + + // Update completion percentage + $this->resumeRepository->updateCompletionPercentage($resume->id); + + logger()->info('Resume updated', [ + 'user_id' => Auth::id(), + 'resume_id' => $resume->id + ]); + + return back()->with('success', 'Resume updated successfully!'); + + } catch (\Exception $e) { + logger()->error('Resume update error', [ + 'error' => $e->getMessage(), + 'user_id' => Auth::id(), + 'resume_id' => $resume->id + ]); + + return back()->withErrors([ + 'title' => 'Unable to update resume. Please try again.' + ])->withInput(); + } + } + + /** + * Display the specified resume preview + */ + public function preview(Resume $resume): View + { + $this->authorize('view', $resume); + + // Increment view count using repository + $this->resumeRepository->incrementViewCount($resume->id); + + return view('resume-builder.preview', [ + 'title' => 'Preview - ' . $resume->title, + 'resume' => $resume + ]); + } + + /** + * Remove the specified resume + */ + public function destroy(Resume $resume): RedirectResponse + { + $this->authorize('delete', $resume); + + try { + // Delete resume using repository + $this->resumeRepository->delete($resume->id); + + logger()->info('Resume deleted', [ + 'user_id' => Auth::id(), + 'resume_id' => $resume->id, + 'title' => $resume->title + ]); + + return redirect()->route('resume-builder.index') + ->with('success', 'Resume deleted successfully.'); + + } catch (\Exception $e) { + logger()->error('Resume deletion error', [ + 'error' => $e->getMessage(), + 'user_id' => Auth::id(), + 'resume_id' => $resume->id + ]); + + return back()->withErrors([ + 'error' => 'Unable to delete resume. Please try again.' + ]); + } + } + + /** + * Download resume as PDF + */ + public function downloadPdf(Resume $resume) + { + $this->authorize('view', $resume); + + try { + // Increment download count using repository + $this->resumeRepository->incrementDownloadCount($resume->id); + + return $this->resumeService->generatePdf($resume); + + } catch (\Exception $e) { + logger()->error('PDF generation error', [ + 'error' => $e->getMessage(), + 'user_id' => Auth::id(), + 'resume_id' => $resume->id + ]); + + return back()->withErrors([ + 'error' => 'Unable to generate PDF. Please try again.' + ]); + } + } + + /** + * Duplicate an existing resume + */ + public function duplicate(Resume $resume): RedirectResponse + { + $this->authorize('view', $resume); + + try { + $userId = Auth::id(); + + // Check if user can create more resumes + if (!$this->resumeRepository->canUserCreateMoreResumes($userId, 10)) { + return back()->withErrors([ + 'error' => 'You have reached the maximum number of resumes allowed.' + ]); + } + + $newTitle = $resume->title . ' (Copy)'; + $duplicatedResume = $this->resumeRepository->duplicateResume($resume->id, $userId, $newTitle); + + logger()->info('Resume duplicated', [ + 'user_id' => $userId, + 'original_resume_id' => $resume->id, + 'new_resume_id' => $duplicatedResume->id + ]); + + return redirect()->route('resume-builder.edit', $duplicatedResume) + ->with('success', 'Resume duplicated successfully!'); + + } catch (\Exception $e) { + logger()->error('Resume duplication error', [ + 'error' => $e->getMessage(), + 'user_id' => Auth::id(), + 'resume_id' => $resume->id + ]); + + return back()->withErrors([ + 'error' => 'Unable to duplicate resume. Please try again.' + ]); + } + } + + /** + * Search user's resumes + */ + public function search(Request $request): \Illuminate\Http\JsonResponse + { + $query = $request->input('query', ''); + $userId = Auth::id(); + + if (empty($query)) { + return response()->json([]); + } + + $resumes = $this->resumeRepository + ->getUserResumes($userId) + ->filter(function ($resume) use ($query) { + return stripos($resume->title, $query) !== false || + stripos($resume->description, $query) !== false; + }) + ->take(10) + ->values(); + + return response()->json($resumes); + } + + /** + * Get resume analytics + */ + public function analytics(Resume $resume): \Illuminate\Http\JsonResponse + { + $this->authorize('view', $resume); + + $analytics = [ + 'view_count' => $resume->view_count, + 'download_count' => $resume->download_count, + 'completion_percentage' => $resume->completion_percentage, + 'last_viewed_at' => $resume->last_viewed_at, + 'last_downloaded_at' => $resume->last_downloaded_at, + 'created_at' => $resume->created_at, + 'updated_at' => $resume->updated_at, + ]; + + return response()->json($analytics); + } +} diff --git a/app/Http/Controllers/TemplateController.php b/app/Http/Controllers/TemplateController.php new file mode 100644 index 0000000..f9c9073 --- /dev/null +++ b/app/Http/Controllers/TemplateController.php @@ -0,0 +1,87 @@ + 1, + 'name' => 'Professional', + 'description' => 'Clean and professional template', + 'preview_image' => '/images/templates/professional.jpg', + 'is_premium' => false, + ], + [ + 'id' => 2, + 'name' => 'Modern', + 'description' => 'Modern and stylish template', + 'preview_image' => '/images/templates/modern.jpg', + 'is_premium' => true, + ], + [ + 'id' => 3, + 'name' => 'Classic', + 'description' => 'Traditional classic template', + 'preview_image' => '/images/templates/classic.jpg', + 'is_premium' => false, + ], + ]; + + return view('templates.index', compact('templates')); + } + + /** + * Display the specified template. + */ + public function show(string $template): View + { + // Find template by ID or slug + // This is a placeholder - replace with actual template fetching logic + $templateData = [ + 'id' => 1, + 'name' => ucfirst($template), + 'description' => 'Template description', + 'preview_image' => "/images/templates/{$template}.jpg", + 'is_premium' => false, + ]; + + return view('templates.show', compact('templateData')); + } + + /** + * Preview the specified template. + */ + public function preview(string $template): View + { + // Generate preview for the template + // This would typically include sample data + $templateData = [ + 'id' => 1, + 'name' => ucfirst($template), + 'description' => 'Template description', + 'preview_image' => "/images/templates/{$template}.jpg", + 'is_premium' => false, + ]; + + $sampleData = [ + 'name' => 'John Doe', + 'title' => 'Software Developer', + 'email' => 'john.doe@example.com', + 'phone' => '+1 (555) 123-4567', + 'summary' => 'Experienced software developer with expertise in web technologies.', + ]; + + return view('templates.preview', compact('templateData', 'sampleData')); + } +} diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php new file mode 100644 index 0000000..20b0708 --- /dev/null +++ b/app/Http/Kernel.php @@ -0,0 +1,76 @@ + + * @created 2025-08-08 + * @location Made in Germany ๐Ÿ‡ฉ๐Ÿ‡ช + */ + +namespace App\Http; + +use Illuminate\Foundation\Http\Kernel as HttpKernel; + +class Kernel extends HttpKernel +{ + /** + * The application's global HTTP middleware stack. + * + * These middleware are run during every request to your application. + * + * @var array + */ + protected $middleware = [ + // \App\Http\Middleware\TrustHosts::class, + \App\Http\Middleware\TrustProxies::class, + \Illuminate\Http\Middleware\HandleCors::class, + \App\Http\Middleware\PreventRequestsDuringMaintenance::class, + \Illuminate\Foundation\Http\Middleware\ValidatePostSize::class, + \App\Http\Middleware\TrimStrings::class, + \Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class, + ]; + + /** + * The application's route middleware groups. + * + * @var array> + */ + protected $middlewareGroups = [ + 'web' => [ + \App\Http\Middleware\EncryptCookies::class, + \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class, + \Illuminate\Session\Middleware\StartSession::class, + \Illuminate\View\Middleware\ShareErrorsFromSession::class, + \App\Http\Middleware\VerifyCsrfToken::class, + \Illuminate\Routing\Middleware\SubstituteBindings::class, + ], + + 'api' => [ + // \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class, + \Illuminate\Routing\Middleware\ThrottleRequests::class.':api', + \Illuminate\Routing\Middleware\SubstituteBindings::class, + ], + ]; + + /** + * The application's middleware aliases. + * + * Aliases may be used instead of class names to conveniently assign middleware to routes and groups. + * + * @var array + */ + protected $middlewareAliases = [ + 'auth' => \App\Http\Middleware\Authenticate::class, + 'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class, + 'auth.session' => \Illuminate\Session\Middleware\AuthenticateSession::class, + 'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class, + 'can' => \Illuminate\Auth\Middleware\Authorize::class, + 'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class, + 'password.confirm' => \Illuminate\Auth\Middleware\RequirePassword::class, + 'signed' => \App\Http\Middleware\ValidateSignature::class, + 'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class, + 'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class, + ]; +} diff --git a/app/Http/Middleware/Authenticate.php b/app/Http/Middleware/Authenticate.php new file mode 100644 index 0000000..d4ef644 --- /dev/null +++ b/app/Http/Middleware/Authenticate.php @@ -0,0 +1,17 @@ +expectsJson() ? null : route('login'); + } +} diff --git a/app/Http/Middleware/EncryptCookies.php b/app/Http/Middleware/EncryptCookies.php new file mode 100644 index 0000000..867695b --- /dev/null +++ b/app/Http/Middleware/EncryptCookies.php @@ -0,0 +1,17 @@ + + */ + protected $except = [ + // + ]; +} diff --git a/app/Http/Middleware/PreventRequestsDuringMaintenance.php b/app/Http/Middleware/PreventRequestsDuringMaintenance.php new file mode 100644 index 0000000..74cbd9a --- /dev/null +++ b/app/Http/Middleware/PreventRequestsDuringMaintenance.php @@ -0,0 +1,17 @@ + + */ + protected $except = [ + // + ]; +} diff --git a/app/Http/Middleware/RedirectIfAuthenticated.php b/app/Http/Middleware/RedirectIfAuthenticated.php new file mode 100644 index 0000000..afc78c4 --- /dev/null +++ b/app/Http/Middleware/RedirectIfAuthenticated.php @@ -0,0 +1,30 @@ +check()) { + return redirect(RouteServiceProvider::HOME); + } + } + + return $next($request); + } +} diff --git a/app/Http/Middleware/TrimStrings.php b/app/Http/Middleware/TrimStrings.php new file mode 100644 index 0000000..88cadca --- /dev/null +++ b/app/Http/Middleware/TrimStrings.php @@ -0,0 +1,19 @@ + + */ + protected $except = [ + 'current_password', + 'password', + 'password_confirmation', + ]; +} diff --git a/app/Http/Middleware/TrustHosts.php b/app/Http/Middleware/TrustHosts.php new file mode 100644 index 0000000..c9c58bd --- /dev/null +++ b/app/Http/Middleware/TrustHosts.php @@ -0,0 +1,20 @@ + + */ + public function hosts(): array + { + return [ + $this->allSubdomainsOfApplicationUrl(), + ]; + } +} diff --git a/app/Http/Middleware/TrustProxies.php b/app/Http/Middleware/TrustProxies.php new file mode 100644 index 0000000..3391630 --- /dev/null +++ b/app/Http/Middleware/TrustProxies.php @@ -0,0 +1,28 @@ +|string|null + */ + protected $proxies; + + /** + * The headers that should be used to detect proxies. + * + * @var int + */ + protected $headers = + Request::HEADER_X_FORWARDED_FOR | + Request::HEADER_X_FORWARDED_HOST | + Request::HEADER_X_FORWARDED_PORT | + Request::HEADER_X_FORWARDED_PROTO | + Request::HEADER_X_FORWARDED_AWS_ELB; +} diff --git a/app/Http/Middleware/ValidateSignature.php b/app/Http/Middleware/ValidateSignature.php new file mode 100644 index 0000000..093bf64 --- /dev/null +++ b/app/Http/Middleware/ValidateSignature.php @@ -0,0 +1,22 @@ + + */ + protected $except = [ + // 'fbclid', + // 'utm_campaign', + // 'utm_content', + // 'utm_medium', + // 'utm_source', + // 'utm_term', + ]; +} diff --git a/app/Http/Middleware/VerifyCsrfToken.php b/app/Http/Middleware/VerifyCsrfToken.php new file mode 100644 index 0000000..9e86521 --- /dev/null +++ b/app/Http/Middleware/VerifyCsrfToken.php @@ -0,0 +1,17 @@ + + */ + protected $except = [ + // + ]; +} diff --git a/app/Http/Requests/Auth/LoginRequest.php b/app/Http/Requests/Auth/LoginRequest.php new file mode 100644 index 0000000..9530df3 --- /dev/null +++ b/app/Http/Requests/Auth/LoginRequest.php @@ -0,0 +1,110 @@ + + * @since February 2025 + */ +class LoginRequest extends FormRequest +{ + /** + * Determine if the user is authorized to make this request. + */ + public function authorize(): bool + { + return true; + } + + /** + * Get the validation rules that apply to the request. + */ + public function rules(): array + { + return [ + 'email' => [ + 'required', + 'string', + 'email:rfc,dns', + 'max:255', + 'exists:users,email' + ], + 'password' => [ + 'required', + 'string', + 'min:6', + 'max:255' + ], + 'remember' => [ + 'boolean' + ] + ]; + } + + /** + * Get custom error messages for validation rules. + */ + public function messages(): array + { + return [ + 'email.required' => 'Email address is required.', + 'email.email' => 'Please enter a valid email address.', + 'email.exists' => 'No account found with this email address.', + 'password.required' => 'Password is required.', + 'password.min' => 'Password must be at least 6 characters long.', + ]; + } + + /** + * Get custom attributes for validator errors. + */ + public function attributes(): array + { + return [ + 'email' => 'email address', + 'password' => 'password', + ]; + } + + /** + * Prepare the data for validation. + */ + protected function prepareForValidation(): void + { + $this->merge([ + 'email' => strtolower(trim($this->email)), + 'remember' => $this->boolean('remember'), + ]); + } + + /** + * Handle failed validation attempts for security monitoring + * + * Logs failed login validation attempts with security context + * including IP address, user agent, and validation errors. + * Essential for detecting potential security threats. + * + * @param \Illuminate\Contracts\Validation\Validator $validator + * @return void + * @throws \Illuminate\Validation\ValidationException + */ + protected function failedValidation($validator): void + { + logger()->warning('Login validation failed', [ + 'email' => $this->input('email'), + 'ip' => $this->ip(), + 'user_agent' => $this->userAgent(), + 'errors' => $validator->errors()->toArray(), + ]); + + parent::failedValidation($validator); + } +} diff --git a/app/Http/Requests/Auth/RegisterRequest.php b/app/Http/Requests/Auth/RegisterRequest.php new file mode 100644 index 0000000..0553ddb --- /dev/null +++ b/app/Http/Requests/Auth/RegisterRequest.php @@ -0,0 +1,209 @@ + + * @since February 2025 + */ +class RegisterRequest extends FormRequest +{ + /** + * Determine if the user is authorized to make this request. + */ + public function authorize(): bool + { + return true; + } + + /** + * Get the validation rules that apply to the request. + */ + public function rules(): array + { + return [ + 'first_name' => [ + 'required', + 'string', + 'min:2', + 'max:50', + 'regex:/^[a-zA-Zร€-รฟ\s\-\'\.]+$/', + ], + 'last_name' => [ + 'required', + 'string', + 'min:2', + 'max:50', + 'regex:/^[a-zA-Zร€-รฟ\s\-\'\.]+$/', + ], + 'email' => [ + 'required', + 'string', + 'email:rfc,dns', + 'max:255', + 'unique:users,email', + ], + 'password' => [ + 'required', + 'string', + 'confirmed', + Password::min(8) + ->letters() + ->mixedCase() + ->numbers() + ->symbols() + ->uncompromised(), + ], + 'password_confirmation' => [ + 'required', + 'string', + 'same:password', + ], + 'phone' => [ + 'nullable', + 'string', + 'min:10', + 'max:20', + 'regex:/^[\+]?[0-9\s\-\(\)]+$/', + ], + 'terms' => [ + 'required', + 'accepted', + ], + 'newsletter' => [ + 'boolean', + ], + 'marketing' => [ + 'boolean', + ], + ]; + } + + /** + * Get custom error messages for validation rules. + */ + public function messages(): array + { + return [ + 'first_name.required' => 'First name is required.', + 'first_name.min' => 'First name must be at least 2 characters.', + 'first_name.regex' => 'First name contains invalid characters.', + 'last_name.required' => 'Last name is required.', + 'last_name.min' => 'Last name must be at least 2 characters.', + 'last_name.regex' => 'Last name contains invalid characters.', + 'email.required' => 'Email address is required.', + 'email.email' => 'Please enter a valid email address.', + 'email.unique' => 'An account with this email address already exists.', + 'password.required' => 'Password is required.', + 'password.min' => 'Password must be at least 8 characters long.', + 'password.confirmed' => 'Password confirmation does not match.', + 'password_confirmation.required' => 'Password confirmation is required.', + 'password_confirmation.same' => 'Password confirmation must match the password.', + 'phone.regex' => 'Please enter a valid phone number.', + 'terms.required' => 'You must accept the terms and conditions.', + 'terms.accepted' => 'You must accept the terms and conditions.', + ]; + } + + /** + * Get custom attributes for validator errors. + */ + public function attributes(): array + { + return [ + 'first_name' => 'first name', + 'last_name' => 'last name', + 'email' => 'email address', + 'password' => 'password', + 'password_confirmation' => 'password confirmation', + 'phone' => 'phone number', + 'terms' => 'terms and conditions', + 'newsletter' => 'newsletter subscription', + 'marketing' => 'marketing communications', + ]; + } + + /** + * Prepare the data for validation. + */ + protected function prepareForValidation(): void + { + $this->merge([ + 'first_name' => ucfirst(strtolower(trim($this->first_name))), + 'last_name' => ucfirst(strtolower(trim($this->last_name))), + 'email' => strtolower(trim($this->email)), + 'phone' => $this->phone ? preg_replace('/[^\+0-9]/', '', $this->phone) : null, + 'newsletter' => $this->boolean('newsletter'), + 'marketing' => $this->boolean('marketing'), + ]); + } + + /** + * Configure advanced validation rules and security checks + * + * Implements additional validation logic beyond standard rules: + * - Name similarity validation + * - Suspicious pattern detection + * - Data integrity verification + * + * @param \Illuminate\Validation\Validator $validator + * @return void + */ + public function withValidator($validator): void + { + $validator->after(function ($validator) { + if ($this->first_name === $this->last_name) { + $validator->errors()->add( + 'last_name', + 'Last name should be different from first name.' + ); + } + + $suspiciousPatterns = ['test', 'admin', 'root', 'null', 'undefined']; + $fullName = strtolower($this->first_name . ' ' . $this->last_name); + + foreach ($suspiciousPatterns as $pattern) { + if (strpos($fullName, $pattern) !== false) { + $validator->errors()->add( + 'first_name', + 'Please enter your real name.' + ); + break; + } + } + }); + } + + /** + * Handle failed registration validation for security monitoring + * + * Logs failed registration attempts with comprehensive security context + * including user input, IP address, and validation errors. + * Critical for detecting registration abuse and attacks. + * + * @param \Illuminate\Contracts\Validation\Validator $validator + * @return void + * @throws \Illuminate\Validation\ValidationException + */ + protected function failedValidation($validator): void + { + logger()->warning('Registration validation failed', [ + 'email' => $this->input('email'), + 'first_name' => $this->input('first_name'), + 'last_name' => $this->input('last_name'), + 'ip' => $this->ip(), + 'user_agent' => $this->userAgent(), + 'errors' => $validator->errors()->toArray(), + ]); + + parent::failedValidation($validator); + } +} diff --git a/app/Http/Requests/Auth/UpdateProfileRequest.php b/app/Http/Requests/Auth/UpdateProfileRequest.php new file mode 100644 index 0000000..efa6f10 --- /dev/null +++ b/app/Http/Requests/Auth/UpdateProfileRequest.php @@ -0,0 +1,119 @@ + + * @since February 2025 + */ +class UpdateProfileRequest extends FormRequest +{ + /** + * Determine if the user is authorized to make this request. + */ + public function authorize(): bool + { + return Auth::check(); + } + + /** + * Get the validation rules that apply to the request. + */ + public function rules(): array + { + return [ + 'first_name' => [ + 'required', + 'string', + 'min:2', + 'max:50', + 'regex:/^[a-zA-Zร€-รฟ\s\-\'\.]+$/', + ], + 'last_name' => [ + 'required', + 'string', + 'min:2', + 'max:50', + 'regex:/^[a-zA-Zร€-รฟ\s\-\'\.]+$/', + ], + 'phone' => [ + 'nullable', + 'string', + 'min:10', + 'max:20', + 'regex:/^[\+]?[0-9\s\-\(\)]+$/', + ], + 'bio' => [ + 'nullable', + 'string', + 'max:1000', + ], + 'website' => [ + 'nullable', + 'url', + 'max:255', + 'regex:/^https?:\/\/.+$/', + ], + 'linkedin' => [ + 'nullable', + 'url', + 'max:255', + 'regex:/^https:\/\/(www\.)?linkedin\.com\/in\/[a-zA-Z0-9\-]+\/?$/', + ], + 'github' => [ + 'nullable', + 'url', + 'max:255', + 'regex:/^https:\/\/(www\.)?github\.com\/[a-zA-Z0-9\-]+\/?$/', + ], + 'twitter' => [ + 'nullable', + 'url', + 'max:255', + 'regex:/^https:\/\/(www\.)?twitter\.com\/[a-zA-Z0-9_]+\/?$/', + ], + ]; + } + + /** + * Get custom error messages for validation rules. + */ + public function messages(): array + { + return [ + 'first_name.required' => 'First name is required.', + 'first_name.regex' => 'First name contains invalid characters.', + 'last_name.required' => 'Last name is required.', + 'last_name.regex' => 'Last name contains invalid characters.', + 'phone.regex' => 'Please enter a valid phone number.', + 'website.regex' => 'Website URL must start with http:// or https://', + 'linkedin.regex' => 'Please enter a valid LinkedIn profile URL.', + 'github.regex' => 'Please enter a valid GitHub profile URL.', + 'twitter.regex' => 'Please enter a valid Twitter profile URL.', + ]; + } + + /** + * Prepare the data for validation. + */ + protected function prepareForValidation(): void + { + $this->merge([ + 'first_name' => ucfirst(strtolower(trim($this->first_name ?? ''))), + 'last_name' => ucfirst(strtolower(trim($this->last_name ?? ''))), + 'phone' => $this->phone ? preg_replace('/[^\+0-9]/', '', $this->phone) : null, + 'bio' => $this->bio ? trim($this->bio) : null, + 'website' => $this->website ? strtolower(trim($this->website)) : null, + 'linkedin' => $this->linkedin ? strtolower(trim($this->linkedin)) : null, + 'github' => $this->github ? strtolower(trim($this->github)) : null, + 'twitter' => $this->twitter ? strtolower(trim($this->twitter)) : null, + ]); + } +} diff --git a/app/Http/Requests/Resume/StoreResumeRequest.php b/app/Http/Requests/Resume/StoreResumeRequest.php new file mode 100644 index 0000000..2f82e09 --- /dev/null +++ b/app/Http/Requests/Resume/StoreResumeRequest.php @@ -0,0 +1,336 @@ + + * @since February 2025 + */ +class StoreResumeRequest extends FormRequest +{ + /** + * Determine if the user is authorized to make this request. + */ + public function authorize(): bool + { + return Auth::check(); + } + + /** + * Get the validation rules that apply to the request. + */ + public function rules(): array + { + return [ + 'title' => [ + 'required', + 'string', + 'min:3', + 'max:255', + 'regex:/^[a-zA-Z0-9\s\-\_\.\,\!\?]+$/', + ], + 'description' => [ + 'nullable', + 'string', + 'max:1000', + ], + 'template' => [ + 'required', + 'string', + 'in:professional,modern,executive,minimal,technical', + ], + 'content' => [ + 'nullable', + 'array', + ], + 'content.personal_info' => [ + 'nullable', + 'array', + ], + 'content.personal_info.full_name' => [ + 'nullable', + 'string', + 'max:255', + ], + 'content.personal_info.title' => [ + 'nullable', + 'string', + 'max:255', + ], + 'content.personal_info.email' => [ + 'nullable', + 'email', + 'max:255', + ], + 'content.personal_info.phone' => [ + 'nullable', + 'string', + 'max:20', + ], + 'content.personal_info.location' => [ + 'nullable', + 'string', + 'max:255', + ], + 'content.personal_info.website' => [ + 'nullable', + 'url', + 'max:255', + ], + 'content.personal_info.linkedin' => [ + 'nullable', + 'url', + 'max:255', + ], + 'content.personal_info.github' => [ + 'nullable', + 'url', + 'max:255', + ], + 'content.summary' => [ + 'nullable', + 'string', + 'max:2000', + ], + 'content.experience' => [ + 'nullable', + 'array', + 'max:20', + ], + 'content.experience.*.company' => [ + 'required_with:content.experience', + 'string', + 'max:255', + ], + 'content.experience.*.position' => [ + 'required_with:content.experience', + 'string', + 'max:255', + ], + 'content.experience.*.start_date' => [ + 'required_with:content.experience', + 'date', + 'before_or_equal:today', + ], + 'content.experience.*.end_date' => [ + 'nullable', + 'date', + 'after:content.experience.*.start_date', + 'before_or_equal:today', + ], + 'content.experience.*.current' => [ + 'boolean', + ], + 'content.experience.*.description' => [ + 'nullable', + 'string', + 'max:2000', + ], + 'content.education' => [ + 'nullable', + 'array', + 'max:10', + ], + 'content.education.*.institution' => [ + 'required_with:content.education', + 'string', + 'max:255', + ], + 'content.education.*.degree' => [ + 'required_with:content.education', + 'string', + 'max:255', + ], + 'content.education.*.field' => [ + 'nullable', + 'string', + 'max:255', + ], + 'content.education.*.start_date' => [ + 'required_with:content.education', + 'date', + 'before_or_equal:today', + ], + 'content.education.*.end_date' => [ + 'nullable', + 'date', + 'after:content.education.*.start_date', + 'before_or_equal:today', + ], + 'content.skills' => [ + 'nullable', + 'array', + ], + 'settings' => [ + 'nullable', + 'array', + ], + 'settings.color_scheme' => [ + 'nullable', + 'string', + 'in:blue,green,red,purple,orange,gray,black', + ], + 'settings.font_family' => [ + 'nullable', + 'string', + 'in:Arial,Times New Roman,Calibri,Helvetica,Georgia', + ], + 'settings.font_size' => [ + 'nullable', + 'integer', + 'min:8', + 'max:16', + ], + 'is_public' => [ + 'boolean', + ], + 'allow_comments' => [ + 'boolean', + ], + ]; + } + + /** + * Get custom error messages for validation rules. + */ + public function messages(): array + { + return [ + 'title.required' => 'Resume title is required.', + 'title.min' => 'Resume title must be at least 3 characters.', + 'title.max' => 'Resume title cannot exceed 255 characters.', + 'title.regex' => 'Resume title contains invalid characters.', + 'description.max' => 'Description cannot exceed 1000 characters.', + 'template.required' => 'Please select a resume template.', + 'template.in' => 'Selected template is not available.', + 'content.experience.max' => 'You cannot add more than 20 work experiences.', + 'content.education.max' => 'You cannot add more than 10 education entries.', + 'content.personal_info.email.email' => 'Please enter a valid email address.', + 'content.personal_info.website.url' => 'Please enter a valid website URL.', + 'content.personal_info.linkedin.url' => 'Please enter a valid LinkedIn URL.', + 'content.personal_info.github.url' => 'Please enter a valid GitHub URL.', + 'content.experience.*.company.required_with' => 'Company name is required for work experience.', + 'content.experience.*.position.required_with' => 'Position title is required for work experience.', + 'content.experience.*.start_date.required_with' => 'Start date is required for work experience.', + 'content.experience.*.start_date.before_or_equal' => 'Start date cannot be in the future.', + 'content.experience.*.end_date.after' => 'End date must be after start date.', + 'content.experience.*.end_date.before_or_equal' => 'End date cannot be in the future.', + 'content.education.*.institution.required_with' => 'Institution name is required for education.', + 'content.education.*.degree.required_with' => 'Degree is required for education.', + 'content.education.*.start_date.required_with' => 'Start date is required for education.', + 'settings.font_size.min' => 'Font size must be at least 8.', + 'settings.font_size.max' => 'Font size cannot exceed 16.', + ]; + } + + /** + * Get custom attributes for validator errors. + */ + public function attributes(): array + { + return [ + 'title' => 'resume title', + 'description' => 'resume description', + 'template' => 'resume template', + 'content.personal_info.full_name' => 'full name', + 'content.personal_info.title' => 'professional title', + 'content.personal_info.email' => 'email address', + 'content.personal_info.phone' => 'phone number', + 'content.personal_info.location' => 'location', + 'content.personal_info.website' => 'website', + 'content.personal_info.linkedin' => 'LinkedIn profile', + 'content.personal_info.github' => 'GitHub profile', + 'content.summary' => 'professional summary', + 'is_public' => 'public visibility', + 'allow_comments' => 'allow comments', + ]; + } + + /** + * Prepare the data for validation. + */ + protected function prepareForValidation(): void + { + $this->merge([ + 'title' => trim($this->title), + 'description' => $this->description ? trim($this->description) : null, + 'is_public' => $this->boolean('is_public'), + 'allow_comments' => $this->boolean('allow_comments'), + ]); + + if ($this->has('content.personal_info')) { + $personalInfo = $this->input('content.personal_info', []); + + if (isset($personalInfo['email'])) { + $personalInfo['email'] = strtolower(trim($personalInfo['email'])); + } + + if (isset($personalInfo['full_name'])) { + $personalInfo['full_name'] = trim($personalInfo['full_name']); + } + + $this->merge(['content' => array_merge($this->input('content', []), [ + 'personal_info' => $personalInfo + ])]); + } + } + + /** + * Configure the validator instance. + */ + public function withValidator($validator): void + { + $validator->after(function ($validator) { + $userId = Auth::id(); + $existingResume = \App\Models\Resume::where('user_id', $userId) + ->where('title', $this->title) + ->first(); + + if ($existingResume) { + $validator->errors()->add( + 'title', + 'You already have a resume with this title. Please choose a different title.' + ); + } + + if ($this->has('content.experience') && is_array($this->content['experience'])) { + foreach ($this->content['experience'] as $index => $experience) { + if (!empty($experience['start_date']) && !empty($experience['end_date'])) { + $startDate = Carbon::parse($experience['start_date']); + $endDate = Carbon::parse($experience['end_date']); + + if ($startDate->greaterThan($endDate)) { + $validator->errors()->add( + "content.experience.{$index}.end_date", + 'End date must be after start date.' + ); + } + } + } + } + }); + } + + /** + * Handle a failed validation attempt. + */ + protected function failedValidation($validator): void + { + logger()->warning('Resume creation validation failed', [ + 'user_id' => Auth::id(), + 'title' => $this->input('title'), + 'template' => $this->input('template'), + 'errors' => $validator->errors()->toArray(), + ]); + + parent::failedValidation($validator); + } +} diff --git a/app/Http/Requests/Resume/UpdateResumeRequest.php b/app/Http/Requests/Resume/UpdateResumeRequest.php new file mode 100644 index 0000000..606c910 --- /dev/null +++ b/app/Http/Requests/Resume/UpdateResumeRequest.php @@ -0,0 +1,465 @@ + + * @since February 2025 + */ +class UpdateResumeRequest extends FormRequest +{ + /** + * Determine if the user is authorized to make this request. + */ + public function authorize(): bool + { + $resume = $this->route('resume'); + return Auth::check() && $resume && $resume->user_id === Auth::id(); + } + + /** + * Get the validation rules that apply to the request. + */ + public function rules(): array + { + $resumeId = $this->route('resume')->id ?? null; + + return [ + 'title' => [ + 'sometimes', + 'required', + 'string', + 'min:3', + 'max:255', + 'regex:/^[a-zA-Z0-9\s\-\_\.\,\!\?]+$/', + ], + 'description' => [ + 'nullable', + 'string', + 'max:1000', + ], + 'template' => [ + 'sometimes', + 'required', + 'string', + 'in:professional,modern,executive,minimal,technical', + ], + 'status' => [ + 'sometimes', + 'string', + 'in:draft,published,archived', + ], + 'content' => [ + 'nullable', + 'array', + ], + 'content.personal_info' => [ + 'nullable', + 'array', + ], + 'content.personal_info.full_name' => [ + 'nullable', + 'string', + 'max:255', + ], + 'content.personal_info.title' => [ + 'nullable', + 'string', + 'max:255', + ], + 'content.personal_info.email' => [ + 'nullable', + 'email', + 'max:255', + ], + 'content.personal_info.phone' => [ + 'nullable', + 'string', + 'max:20', + 'regex:/^[\+]?[0-9\s\-\(\)]+$/', + ], + 'content.personal_info.location' => [ + 'nullable', + 'string', + 'max:255', + ], + 'content.personal_info.website' => [ + 'nullable', + 'url', + 'max:255', + ], + 'content.personal_info.linkedin' => [ + 'nullable', + 'url', + 'max:255', + 'regex:/^https:\/\/(www\.)?linkedin\.com\/in\/[a-zA-Z0-9\-]+\/?$/', + ], + 'content.personal_info.github' => [ + 'nullable', + 'url', + 'max:255', + 'regex:/^https:\/\/(www\.)?github\.com\/[a-zA-Z0-9\-]+\/?$/', + ], + 'content.summary' => [ + 'nullable', + 'string', + 'max:2000', + ], + 'content.experience' => [ + 'nullable', + 'array', + 'max:20', + ], + 'content.experience.*.company' => [ + 'required_with:content.experience', + 'string', + 'max:255', + ], + 'content.experience.*.position' => [ + 'required_with:content.experience', + 'string', + 'max:255', + ], + 'content.experience.*.location' => [ + 'nullable', + 'string', + 'max:255', + ], + 'content.experience.*.start_date' => [ + 'required_with:content.experience', + 'date', + 'before_or_equal:today', + ], + 'content.experience.*.end_date' => [ + 'nullable', + 'date', + 'after:content.experience.*.start_date', + 'before_or_equal:today', + ], + 'content.experience.*.current' => [ + 'boolean', + ], + 'content.experience.*.description' => [ + 'nullable', + 'string', + 'max:2000', + ], + 'content.experience.*.achievements' => [ + 'nullable', + 'array', + 'max:10', + ], + 'content.experience.*.achievements.*' => [ + 'string', + 'max:500', + ], + 'content.education' => [ + 'nullable', + 'array', + 'max:10', + ], + 'content.education.*.institution' => [ + 'required_with:content.education', + 'string', + 'max:255', + ], + 'content.education.*.degree' => [ + 'required_with:content.education', + 'string', + 'max:255', + ], + 'content.education.*.field' => [ + 'nullable', + 'string', + 'max:255', + ], + 'content.education.*.start_date' => [ + 'required_with:content.education', + 'date', + 'before_or_equal:today', + ], + 'content.education.*.end_date' => [ + 'nullable', + 'date', + 'after:content.education.*.start_date', + 'before_or_equal:today', + ], + 'content.education.*.gpa' => [ + 'nullable', + 'numeric', + 'min:0', + 'max:4', + ], + 'content.education.*.description' => [ + 'nullable', + 'string', + 'max:1000', + ], + 'content.skills' => [ + 'nullable', + 'array', + ], + 'content.projects' => [ + 'nullable', + 'array', + 'max:15', + ], + 'content.projects.*.name' => [ + 'required_with:content.projects', + 'string', + 'max:255', + ], + 'content.projects.*.description' => [ + 'nullable', + 'string', + 'max:1000', + ], + 'content.projects.*.url' => [ + 'nullable', + 'url', + 'max:255', + ], + 'content.projects.*.technologies' => [ + 'nullable', + 'array', + 'max:20', + ], + 'content.certifications' => [ + 'nullable', + 'array', + 'max:20', + ], + 'content.certifications.*.name' => [ + 'required_with:content.certifications', + 'string', + 'max:255', + ], + 'content.certifications.*.issuer' => [ + 'required_with:content.certifications', + 'string', + 'max:255', + ], + 'content.certifications.*.date' => [ + 'required_with:content.certifications', + 'date', + 'before_or_equal:today', + ], + 'content.certifications.*.url' => [ + 'nullable', + 'url', + 'max:255', + ], + 'content.languages' => [ + 'nullable', + 'array', + 'max:10', + ], + 'content.languages.*.language' => [ + 'required_with:content.languages', + 'string', + 'max:100', + ], + 'content.languages.*.level' => [ + 'required_with:content.languages', + 'string', + 'in:Native,Fluent,Advanced,Intermediate,Basic', + ], + 'settings' => [ + 'nullable', + 'array', + ], + 'settings.color_scheme' => [ + 'nullable', + 'string', + 'in:blue,green,red,purple,orange,gray,black', + ], + 'settings.font_family' => [ + 'nullable', + 'string', + 'in:Arial,Times New Roman,Calibri,Helvetica,Georgia', + ], + 'settings.font_size' => [ + 'nullable', + 'integer', + 'min:8', + 'max:16', + ], + 'settings.line_spacing' => [ + 'nullable', + 'numeric', + 'min:1.0', + 'max:2.0', + ], + 'settings.margin' => [ + 'nullable', + 'integer', + 'min:10', + 'max:30', + ], + 'settings.show_photo' => [ + 'boolean', + ], + 'is_public' => [ + 'boolean', + ], + 'allow_comments' => [ + 'boolean', + ], + 'password' => [ + 'nullable', + 'string', + 'min:6', + 'max:50', + ], + 'expires_at' => [ + 'nullable', + 'date', + 'after:today', + ], + ]; + } + + /** + * Get custom error messages for validation rules. + */ + public function messages(): array + { + return [ + 'title.required' => 'Resume title is required.', + 'title.min' => 'Resume title must be at least 3 characters.', + 'title.regex' => 'Resume title contains invalid characters.', + 'template.in' => 'Selected template is not available.', + 'status.in' => 'Invalid resume status.', + 'content.personal_info.email.email' => 'Please enter a valid email address.', + 'content.personal_info.phone.regex' => 'Please enter a valid phone number.', + 'content.personal_info.linkedin.regex' => 'Please enter a valid LinkedIn profile URL.', + 'content.personal_info.github.regex' => 'Please enter a valid GitHub profile URL.', + 'content.experience.max' => 'You cannot have more than 20 work experiences.', + 'content.education.max' => 'You cannot have more than 10 education entries.', + 'content.projects.max' => 'You cannot have more than 15 projects.', + 'content.certifications.max' => 'You cannot have more than 20 certifications.', + 'content.languages.max' => 'You cannot have more than 10 languages.', + 'content.experience.*.end_date.after' => 'End date must be after start date.', + 'content.education.*.gpa.max' => 'GPA cannot exceed 4.0.', + 'content.languages.*.level.in' => 'Invalid language proficiency level.', + 'settings.font_size.min' => 'Font size must be at least 8.', + 'settings.font_size.max' => 'Font size cannot exceed 16.', + 'settings.line_spacing.min' => 'Line spacing must be at least 1.0.', + 'settings.line_spacing.max' => 'Line spacing cannot exceed 2.0.', + 'expires_at.after' => 'Expiration date must be in the future.', + 'password.min' => 'Password must be at least 6 characters.', + ]; + } + + /** + * Prepare the data for validation. + */ + protected function prepareForValidation(): void + { + if ($this->has('title')) { + $this->merge(['title' => trim($this->title)]); + } + + if ($this->has('description')) { + $this->merge(['description' => $this->description ? trim($this->description) : null]); + } + + $this->merge([ + 'is_public' => $this->boolean('is_public'), + 'allow_comments' => $this->boolean('allow_comments'), + ]); + + + if ($this->has('content.personal_info')) { + $personalInfo = $this->input('content.personal_info', []); + + if (isset($personalInfo['email'])) { + $personalInfo['email'] = strtolower(trim($personalInfo['email'])); + } + + $this->merge(['content' => array_merge($this->input('content', []), [ + 'personal_info' => $personalInfo + ])]); + } + } + + /** + * Configure the validator instance. + */ + public function withValidator($validator): void + { + $validator->after(function ($validator) { + $resume = $this->route('resume'); + + if ($this->has('title')) { + $userId = Auth::id(); + $existingResume = \App\Models\Resume::where('user_id', $userId) + ->where('title', $this->title) + ->where('id', '!=', $resume->id) + ->first(); + + if ($existingResume) { + $validator->errors()->add( + 'title', + 'You already have a resume with this title. Please choose a different title.' + ); + } + } + + if ($this->has('content.experience') && is_array($this->content['experience'])) { + foreach ($this->content['experience'] as $index => $experience) { + if (!empty($experience['start_date']) && !empty($experience['end_date'])) { + $startDate = Carbon::parse($experience['start_date']); + $endDate = Carbon::parse($experience['end_date']); + + if ($startDate->greaterThan($endDate)) { + $validator->errors()->add( + "content.experience.{$index}.end_date", + 'End date must be after start date.' + ); + } + } + } + } + + if ($this->has('status') && $this->status === 'published') { + $content = $this->input('content', []); + $requiredSections = ['personal_info', 'summary']; + + foreach ($requiredSections as $section) { + if (empty($content[$section])) { + $validator->errors()->add( + 'status', + "Cannot publish resume: {$section} section is required." + ); + } + } + } + }); + } + + /** + * Handle a failed validation attempt. + */ + protected function failedValidation($validator): void + { + $resume = $this->route('resume'); + + logger()->warning('Resume update validation failed', [ + 'user_id' => Auth::id(), + 'resume_id' => $resume->id ?? null, + 'title' => $this->input('title'), + 'errors' => $validator->errors()->toArray(), + ]); + + parent::failedValidation($validator); + } +} diff --git a/app/Interfaces/BaseRepositoryInterface.php b/app/Interfaces/BaseRepositoryInterface.php new file mode 100644 index 0000000..ca970a8 --- /dev/null +++ b/app/Interfaces/BaseRepositoryInterface.php @@ -0,0 +1,156 @@ + + * @since February 2025 + */ +interface BaseRepositoryInterface +{ + /** + * Get all records + * + * @param array $columns + * @return Collection + */ + public function all(array $columns = ['*']): Collection; + + /** + * Get records with pagination + * + * @param int $perPage + * @param array $columns + * @return LengthAwarePaginator + */ + public function paginate(int $perPage = 15, array $columns = ['*']): LengthAwarePaginator; + + /** + * Find a record by ID + * + * @param int $id + * @param array $columns + * @return Model|null + */ + public function find(int $id, array $columns = ['*']): ?Model; + + /** + * Find a record by ID or throw an exception + * + * @param int $id + * @param array $columns + * @return Model + * @throws \Illuminate\Database\Eloquent\ModelNotFoundException + */ + public function findOrFail(int $id, array $columns = ['*']): Model; + + /** + * Find records by criteria + * + * @param array $criteria + * @param array $columns + * @return Collection + */ + public function findBy(array $criteria, array $columns = ['*']): Collection; + + /** + * Find a single record by criteria + * + * @param array $criteria + * @param array $columns + * @return Model|null + */ + public function findOneBy(array $criteria, array $columns = ['*']): ?Model; + + /** + * Create a new record + * + * @param array $data + * @return Model + */ + public function create(array $data): Model; + + /** + * Update an existing record + * + * @param int $id + * @param array $data + * @return bool + */ + public function update(int $id, array $data): bool; + + /** + * Update or create a record + * + * @param array $attributes + * @param array $values + * @return Model + */ + public function updateOrCreate(array $attributes, array $values = []): Model; + + /** + * Delete a record by ID + * + * @param int $id + * @return bool + */ + public function delete(int $id): bool; + + /** + * Delete records by criteria + * + * @param array $criteria + * @return int Number of deleted records + */ + public function deleteBy(array $criteria): int; + + /** + * Count records + * + * @param array $criteria + * @return int + */ + public function count(array $criteria = []): int; + + /** + * Check if record exists + * + * @param array $criteria + * @return bool + */ + public function exists(array $criteria): bool; + + /** + * Get records with relationships + * + * @param array $relations + * @return BaseRepositoryInterface + */ + public function with(array $relations): BaseRepositoryInterface; + + /** + * Order records + * + * @param string $column + * @param string $direction + * @return BaseRepositoryInterface + */ + public function orderBy(string $column, string $direction = 'asc'): BaseRepositoryInterface; + + /** + * Limit records + * + * @param int $limit + * @return BaseRepositoryInterface + */ + public function limit(int $limit): BaseRepositoryInterface; +} diff --git a/app/Interfaces/ResumeRepositoryInterface.php b/app/Interfaces/ResumeRepositoryInterface.php new file mode 100644 index 0000000..3945a75 --- /dev/null +++ b/app/Interfaces/ResumeRepositoryInterface.php @@ -0,0 +1,206 @@ + + * @since February 2025 + */ +interface ResumeRepositoryInterface extends BaseRepositoryInterface +{ + /** + * Get user's resumes + * + * @param int $userId + * @param array $columns + * @return Collection + */ + public function getUserResumes(int $userId, array $columns = ['*']): Collection; + + /** + * Get user's resumes with pagination + * + * @param int $userId + * @param int $perPage + * @return LengthAwarePaginator + */ + public function getPaginatedUserResumes(int $userId, int $perPage = 10): LengthAwarePaginator; + + /** + * Find resume by public URL + * + * @param string $publicUrl + * @return Resume|null + */ + public function findByPublicUrl(string $publicUrl): ?Resume; + + /** + * Get public resumes + * + * @param int $limit + * @return Collection + */ + public function getPublicResumes(int $limit = 20): Collection; + + /** + * Get resumes by template + * + * @param string $template + * @return Collection + */ + public function getResumesByTemplate(string $template): Collection; + + /** + * Get resumes by status + * + * @param string $status + * @return Collection + */ + public function getResumesByStatus(string $status): Collection; + + /** + * Get popular resumes (most viewed) + * + * @param int $limit + * @param int $days + * @return Collection + */ + public function getPopularResumes(int $limit = 10, int $days = 30): Collection; + + /** + * Get recent resumes + * + * @param int $limit + * @param int $days + * @return Collection + */ + public function getRecentResumes(int $limit = 10, int $days = 7): Collection; + + /** + * Search resumes + * + * @param string $query + * @param array $filters + * @param int $limit + * @return Collection + */ + public function searchResumes(string $query, array $filters = [], int $limit = 20): Collection; + + /** + * Get incomplete resumes + * + * @param int $threshold + * @return Collection + */ + public function getIncompleteResumes(int $threshold = 50): Collection; + + /** + * Duplicate resume for user + * + * @param int $resumeId + * @param int $userId + * @param string $newTitle + * @return Resume + */ + public function duplicateResume(int $resumeId, int $userId, string $newTitle): Resume; + + /** + * Update resume view count + * + * @param int $resumeId + * @return bool + */ + public function incrementViewCount(int $resumeId): bool; + + /** + * Update resume download count + * + * @param int $resumeId + * @return bool + */ + public function incrementDownloadCount(int $resumeId): bool; + + /** + * Get resume statistics + * + * @param int|null $userId + * @return array + */ + public function getResumeStatistics(?int $userId = null): array; + + /** + * Get resumes requiring PDF regeneration + * + * @return Collection + */ + public function getResumesNeedingPdfRegeneration(): Collection; + + /** + * Archive old resumes + * + * @param int $days + * @return int Number of archived resumes + */ + public function archiveOldResumes(int $days = 365): int; + + /** + * Get user's resume by title + * + * @param int $userId + * @param string $title + * @return Resume|null + */ + public function getUserResumeByTitle(int $userId, string $title): ?Resume; + + /** + * Check if user can create more resumes + * + * @param int $userId + * @param int $limit + * @return bool + */ + public function canUserCreateMoreResumes(int $userId, int $limit = 10): bool; + + /** + * Get featured resumes for showcase + * + * @param int $limit + * @return Collection + */ + public function getFeaturedResumes(int $limit = 6): Collection; + + /** + * Update resume completion percentage + * + * @param int $resumeId + * @return bool + */ + public function updateCompletionPercentage(int $resumeId): bool; + + /** + * Get resumes expiring soon + * + * @param int $days + * @return Collection + */ + public function getResumesExpiringSoon(int $days = 7): Collection; + + /** + * Bulk update resume status + * + * @param array $resumeIds + * @param string $status + * @return int Number of updated resumes + */ + public function bulkUpdateStatus(array $resumeIds, string $status): int; +} diff --git a/app/Interfaces/UserRepositoryInterface.php b/app/Interfaces/UserRepositoryInterface.php new file mode 100644 index 0000000..65691b4 --- /dev/null +++ b/app/Interfaces/UserRepositoryInterface.php @@ -0,0 +1,157 @@ + + * @since February 2025 + */ +interface UserRepositoryInterface extends BaseRepositoryInterface +{ + /** + * Find a user by email address + * + * @param string $email + * @return User|null + */ + public function findByEmail(string $email): ?User; + + /** + * Find users by status + * + * @param string $status + * @return Collection + */ + public function findByStatus(string $status): Collection; + + /** + * Get users who haven't logged in recently + * + * @param int $days + * @return Collection + */ + public function getInactiveUsers(int $days = 30): Collection; + + /** + * Get users with completed profiles + * + * @return Collection + */ + public function getUsersWithCompletedProfiles(): Collection; + + /** + * Get users with incomplete profiles + * + * @return Collection + */ + public function getUsersWithIncompleteProfiles(): Collection; + + /** + * Get users subscribed to newsletter + * + * @return Collection + */ + public function getNewsletterSubscribers(): Collection; + + /** + * Search users by name or email + * + * @param string $query + * @param int $limit + * @return Collection + */ + public function searchUsers(string $query, int $limit = 10): Collection; + + /** + * Get user statistics + * + * @return array + */ + public function getUserStatistics(): array; + + /** + * Update user's last login information + * + * @param int $userId + * @param string $ipAddress + * @return bool + */ + public function updateLastLogin(int $userId, string $ipAddress): bool; + + /** + * Increment user's login attempts + * + * @param string $email + * @return bool + */ + public function incrementLoginAttempts(string $email): bool; + + /** + * Reset user's login attempts + * + * @param string $email + * @return bool + */ + public function resetLoginAttempts(string $email): bool; + + /** + * Lock user account + * + * @param string $email + * @param int $minutes + * @return bool + */ + public function lockAccount(string $email, int $minutes = 15): bool; + + /** + * Check if account is locked + * + * @param string $email + * @return bool + */ + public function isAccountLocked(string $email): bool; + + /** + * Suspend user account + * + * @param int $userId + * @param string $reason + * @return bool + */ + public function suspendAccount(int $userId, string $reason): bool; + + /** + * Reactivate user account + * + * @param int $userId + * @return bool + */ + public function reactivateAccount(int $userId): bool; + + /** + * Get users registered in date range + * + * @param string $startDate + * @param string $endDate + * @return Collection + */ + public function getUsersRegisteredBetween(string $startDate, string $endDate): Collection; + + /** + * Get paginated users with filters + * + * @param array $filters + * @param int $perPage + * @return LengthAwarePaginator + */ + public function getPaginatedUsersWithFilters(array $filters = [], int $perPage = 15): LengthAwarePaginator; +} diff --git a/app/Models/Resume.php b/app/Models/Resume.php new file mode 100644 index 0000000..2f08ffc --- /dev/null +++ b/app/Models/Resume.php @@ -0,0 +1,260 @@ + + * @created 2025-08-08 + * @location Made in Germany ๐Ÿ‡ฉ๐Ÿ‡ช + */ + +namespace App\Models; + +use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\SoftDeletes; + +/** + * Resume Model + * Represents user resumes with all personal and professional information + */ +class Resume extends Model +{ + use HasFactory, SoftDeletes; + + /** + * The attributes that are mass assignable. + * + * @var array + */ + protected $fillable = [ + 'user_id', + 'title', + 'template_id', + 'slug', + 'personal_info', + 'professional_summary', + 'work_experiences', + 'education', + 'skills', + 'languages', + 'certifications', + 'projects', + 'references', + 'custom_sections', + 'settings', + 'is_active', + 'is_completed', + 'is_public', + 'public_url', + 'created_at', + 'updated_at' + ]; + + /** + * The attributes that should be cast. + * + * @var array + */ + protected $casts = [ + 'personal_info' => 'array', + 'work_experiences' => 'array', + 'education' => 'array', + 'skills' => 'array', + 'languages' => 'array', + 'certifications' => 'array', + 'projects' => 'array', + 'references' => 'array', + 'custom_sections' => 'array', + 'settings' => 'array', + 'is_active' => 'boolean', + 'is_completed' => 'boolean', + 'is_public' => 'boolean', + ]; + + /** + * Get the user that owns the resume. + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * Get the resume's public URL. + */ + public function getPublicUrlAttribute(): ?string + { + if ($this->is_public && $this->public_url) { + return route('public.resume', $this->public_url); + } + + return null; + } + + /** + * Get the resume's preview URL. + */ + public function getPreviewUrlAttribute(): string + { + return route('resume-builder.preview', $this); + } + + /** + * Get the resume's edit URL. + */ + public function getEditUrlAttribute(): string + { + return route('resume-builder.edit', $this); + } + + /** + * Get the resume's PDF download URL. + */ + public function getPdfUrlAttribute(): string + { + return route('resume-builder.download-pdf', $this); + } + + /** + * Calculate completion percentage of the resume. + */ + public function getCompletionPercentageAttribute(): int + { + $sections = [ + 'personal_info' => 25, + 'professional_summary' => 10, + 'work_experiences' => 30, + 'education' => 15, + 'skills' => 10, + 'languages' => 5, + 'certifications' => 5, + ]; + + $completed = 0; + foreach ($sections as $section => $weight) { + if ($this->isSectionCompleted($section)) { + $completed += $weight; + } + } + + return min(100, $completed); + } + + /** + * Check if a specific section is completed. + */ + public function isSectionCompleted(string $section): bool + { + $data = $this->$section; + + if (empty($data)) { + return false; + } + + switch ($section) { + case 'personal_info': + $required = ['first_name', 'last_name', 'email', 'phone']; + foreach ($required as $field) { + if (empty($data[$field] ?? null)) { + return false; + } + } + return true; + + case 'professional_summary': + return !empty($data) && strlen($data) >= 50; + + case 'work_experiences': + return is_array($data) && count($data) > 0 && !empty($data[0]['company'] ?? null); + + case 'education': + return is_array($data) && count($data) > 0 && !empty($data[0]['institution'] ?? null); + + case 'skills': + return is_array($data) && count($data) > 0; + + default: + return !empty($data); + } + } + + /** + * Generate a unique slug for the resume. + */ + public function generateSlug(): string + { + $baseSlug = str()->slug($this->title); + $slug = $baseSlug; + $counter = 1; + + while (static::where('slug', $slug)->where('id', '!=', $this->id)->exists()) { + $slug = $baseSlug . '-' . $counter; + $counter++; + } + + return $slug; + } + + /** + * Generate a unique public URL for the resume. + */ + public function generatePublicUrl(): string + { + return str()->random(32); + } + + /** + * Mark the resume as completed. + */ + public function markAsCompleted(): void + { + $this->update(['is_completed' => true]); + } + + /** + * Scope a query to only include active resumes. + */ + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + /** + * Scope a query to only include completed resumes. + */ + public function scopeCompleted($query) + { + return $query->where('is_completed', true); + } + + /** + * Scope a query to only include public resumes. + */ + public function scopePublic($query) + { + return $query->where('is_public', true); + } + + /** + * Boot the model. + */ + protected static function boot() + { + parent::boot(); + + static::creating(function ($resume) { + if (empty($resume->slug)) { + $resume->slug = $resume->generateSlug(); + } + }); + + static::updating(function ($resume) { + if ($resume->isDirty('title')) { + $resume->slug = $resume->generateSlug(); + } + }); + } +} diff --git a/app/Models/User.php b/app/Models/User.php new file mode 100644 index 0000000..624b1a5 --- /dev/null +++ b/app/Models/User.php @@ -0,0 +1,191 @@ + + * @created 2025-08-08 + * @location Made in Germany ๐Ÿ‡ฉ๐Ÿ‡ช + */ + +namespace App\Models; + +use Illuminate\Contracts\Auth\MustVerifyEmail; +use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Foundation\Auth\User as Authenticatable; +use Illuminate\Notifications\Notifiable; +use Laravel\Sanctum\HasApiTokens; + +/** + * User Model + * Represents application users with authentication and profile features + */ +class User extends Authenticatable implements MustVerifyEmail +{ + use HasApiTokens, HasFactory, Notifiable; + + /** + * The attributes that are mass assignable. + * + * @var array + */ + protected $fillable = [ + 'first_name', + 'last_name', + 'email', + 'password', + 'phone', + 'date_of_birth', + 'gender', + 'avatar', + 'bio', + 'website', + 'linkedin', + 'github', + 'twitter', + 'preferences', + 'newsletter_subscribed', + 'last_login_at', + 'last_login_ip', + 'status', + 'suspended_at', + 'suspension_reason', + 'login_attempts', + 'locked_until', + 'locale', + 'timezone', + 'two_factor_enabled', + 'two_factor_secret', + 'two_factor_recovery_codes', + 'profile_completed_at' + ]; + + /** + * The attributes that should be hidden for serialization. + * + * @var array + */ + protected $hidden = [ + 'password', + 'remember_token', + ]; + + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'email_verified_at' => 'datetime', + 'last_login_at' => 'datetime', + 'date_of_birth' => 'date', + 'suspended_at' => 'datetime', + 'locked_until' => 'datetime', + 'profile_completed_at' => 'datetime', + 'newsletter_subscribed' => 'boolean', + 'two_factor_enabled' => 'boolean', + 'preferences' => 'array', + 'two_factor_recovery_codes' => 'array', + 'password' => 'hashed', + ]; + } + + /** + * Get the user's full name. + */ + public function getFullNameAttribute(): string + { + return "{$this->first_name} {$this->last_name}"; + } + + /** + * Get the user's initials. + */ + public function getInitialsAttribute(): string + { + return strtoupper(substr($this->first_name, 0, 1) . substr($this->last_name, 0, 1)); + } + + /** + * Get the user's avatar URL. + */ + public function getAvatarUrlAttribute(): string + { + if ($this->avatar) { + return asset('storage/avatars/' . $this->avatar); + } + + return "https://ui-avatars.com/api/?name={$this->initials}&background=667eea&color=fff&size=200"; + } + + /** + * Check if user has completed their profile. + */ + public function hasCompletedProfile(): bool + { + $requiredFields = ['first_name', 'last_name', 'email', 'phone', 'city', 'country']; + + foreach ($requiredFields as $field) { + if (empty($this->$field)) { + return false; + } + } + + return true; + } + + /** + * Get the user's resumes. + */ + public function resumes(): HasMany + { + return $this->hasMany(Resume::class)->orderBy('updated_at', 'desc'); + } + + /** + * Get the user's active resumes. + */ + public function activeResumes(): HasMany + { + return $this->resumes()->where('is_active', true); + } + + /** + * Get the user's completed resumes. + */ + public function completedResumes(): HasMany + { + return $this->resumes()->where('is_completed', true); + } + + /** + * Update user's last login information. + */ + public function updateLastLogin(string $ipAddress): void + { + $this->update([ + 'last_login_at' => now(), + 'last_login_ip' => $ipAddress, + ]); + } + + /** + * Scope a query to only include active users. + */ + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + /** + * Scope a query to only include verified users. + */ + public function scopeVerified($query) + { + return $query->whereNotNull('email_verified_at'); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php new file mode 100644 index 0000000..452e6b6 --- /dev/null +++ b/app/Providers/AppServiceProvider.php @@ -0,0 +1,24 @@ + + */ + protected $policies = [ + // + ]; + + /** + * Register any authentication / authorization services. + */ + public function boot(): void + { + // + } +} diff --git a/app/Providers/BroadcastServiceProvider.php b/app/Providers/BroadcastServiceProvider.php new file mode 100644 index 0000000..2be04f5 --- /dev/null +++ b/app/Providers/BroadcastServiceProvider.php @@ -0,0 +1,19 @@ +> + */ + protected $listen = [ + Registered::class => [ + SendEmailVerificationNotification::class, + ], + ]; + + /** + * Register any events for your application. + */ + public function boot(): void + { + // + } + + /** + * Determine if events and listeners should be automatically discovered. + */ + public function shouldDiscoverEvents(): bool + { + return false; + } +} diff --git a/app/Providers/RepositoryServiceProvider.php b/app/Providers/RepositoryServiceProvider.php new file mode 100644 index 0000000..fc3f17d --- /dev/null +++ b/app/Providers/RepositoryServiceProvider.php @@ -0,0 +1,63 @@ + + * @since February 2025 + */ +class RepositoryServiceProvider extends ServiceProvider +{ + /** + * All of the container bindings that should be registered. + * + * @var array + */ + public array $bindings = [ + UserRepositoryInterface::class => UserRepository::class, + ResumeRepositoryInterface::class => ResumeRepository::class, + ]; + + /** + * Register services. + */ + public function register(): void + { + // Bind User Repository + $this->app->bind(UserRepositoryInterface::class, function ($app) { + return new UserRepository(new User()); + }); + + // Bind Resume Repository + $this->app->bind(ResumeRepositoryInterface::class, function ($app) { + return new ResumeRepository(new Resume()); + }); + } + + /** + * Bootstrap services. + */ + public function boot(): void + { + // + } +} diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php new file mode 100644 index 0000000..1cf5f15 --- /dev/null +++ b/app/Providers/RouteServiceProvider.php @@ -0,0 +1,40 @@ +by($request->user()?->id ?: $request->ip()); + }); + + $this->routes(function () { + Route::middleware('api') + ->prefix('api') + ->group(base_path('routes/api.php')); + + Route::middleware('web') + ->group(base_path('routes/web.php')); + }); + } +} diff --git a/app/Repositories/BaseRepository.php b/app/Repositories/BaseRepository.php new file mode 100644 index 0000000..a8f9a92 --- /dev/null +++ b/app/Repositories/BaseRepository.php @@ -0,0 +1,345 @@ + + * @since February 2025 + */ +abstract class BaseRepository implements BaseRepositoryInterface +{ + /** + * The model instance + * + * @var Model + */ + protected Model $model; + + /** + * The query builder instance + * + * @var Builder + */ + protected Builder $query; + + /** + * Create a new repository instance + * + * @param Model $model + */ + public function __construct(Model $model) + { + $this->model = $model; + $this->resetQuery(); + } + + /** + * Get all records + * + * @param array $columns + * @return Collection + */ + public function all(array $columns = ['*']): Collection + { + $result = $this->query->get($columns); + $this->resetQuery(); + + return $result; + } + + /** + * Get records with pagination + * + * @param int $perPage + * @param array $columns + * @return LengthAwarePaginator + */ + public function paginate(int $perPage = 15, array $columns = ['*']): LengthAwarePaginator + { + $result = $this->query->paginate($perPage, $columns); + $this->resetQuery(); + + return $result; + } + + /** + * Find a record by ID + * + * @param int $id + * @param array $columns + * @return Model|null + */ + public function find(int $id, array $columns = ['*']): ?Model + { + $result = $this->query->find($id, $columns); + $this->resetQuery(); + + return $result; + } + + /** + * Find a record by ID or throw an exception + * + * @param int $id + * @param array $columns + * @return Model + * @throws ModelNotFoundException + */ + public function findOrFail(int $id, array $columns = ['*']): Model + { + $result = $this->query->findOrFail($id, $columns); + $this->resetQuery(); + + return $result; + } + + /** + * Find records by criteria + * + * @param array $criteria + * @param array $columns + * @return Collection + */ + public function findBy(array $criteria, array $columns = ['*']): Collection + { + $this->applyCriteria($criteria); + $result = $this->query->get($columns); + $this->resetQuery(); + + return $result; + } + + /** + * Find a single record by criteria + * + * @param array $criteria + * @param array $columns + * @return Model|null + */ + public function findOneBy(array $criteria, array $columns = ['*']): ?Model + { + $this->applyCriteria($criteria); + $result = $this->query->first($columns); + $this->resetQuery(); + + return $result; + } + + /** + * Create a new record + * + * @param array $data + * @return Model + */ + public function create(array $data): Model + { + return $this->model->create($data); + } + + /** + * Update an existing record + * + * @param int $id + * @param array $data + * @return bool + */ + public function update(int $id, array $data): bool + { + $model = $this->findOrFail($id); + return $model->update($data); + } + + /** + * Update or create a record + * + * @param array $attributes + * @param array $values + * @return Model + */ + public function updateOrCreate(array $attributes, array $values = []): Model + { + return $this->model->updateOrCreate($attributes, $values); + } + + /** + * Delete a record by ID + * + * @param int $id + * @return bool + */ + public function delete(int $id): bool + { + $model = $this->findOrFail($id); + return $model->delete(); + } + + /** + * Delete records by criteria + * + * @param array $criteria + * @return int Number of deleted records + */ + public function deleteBy(array $criteria): int + { + $this->applyCriteria($criteria); + $result = $this->query->delete(); + $this->resetQuery(); + + return $result; + } + + /** + * Count records + * + * @param array $criteria + * @return int + */ + public function count(array $criteria = []): int + { + if (!empty($criteria)) { + $this->applyCriteria($criteria); + } + + $result = $this->query->count(); + $this->resetQuery(); + + return $result; + } + + /** + * Check if record exists + * + * @param array $criteria + * @return bool + */ + public function exists(array $criteria): bool + { + $this->applyCriteria($criteria); + $result = $this->query->exists(); + $this->resetQuery(); + + return $result; + } + + /** + * Get records with relationships + * + * @param array $relations + * @return BaseRepositoryInterface + */ + public function with(array $relations): BaseRepositoryInterface + { + $this->query = $this->query->with($relations); + return $this; + } + + /** + * Order records + * + * @param string $column + * @param string $direction + * @return BaseRepositoryInterface + */ + public function orderBy(string $column, string $direction = 'asc'): BaseRepositoryInterface + { + $this->query = $this->query->orderBy($column, $direction); + return $this; + } + + /** + * Limit records + * + * @param int $limit + * @return BaseRepositoryInterface + */ + public function limit(int $limit): BaseRepositoryInterface + { + $this->query = $this->query->limit($limit); + return $this; + } + + /** + * Apply criteria to the query + * + * @param array $criteria + * @return void + */ + protected function applyCriteria(array $criteria): void + { + foreach ($criteria as $field => $value) { + if (is_array($value)) { + $this->query = $this->query->whereIn($field, $value); + } elseif (is_null($value)) { + $this->query = $this->query->whereNull($field); + } else { + $this->query = $this->query->where($field, $value); + } + } + } + + /** + * Reset the query builder + * + * @return void + */ + protected function resetQuery(): void + { + $this->query = $this->model->newQuery(); + } + + /** + * Get the model instance + * + * @return Model + */ + public function getModel(): Model + { + return $this->model; + } + + /** + * Set the model instance + * + * @param Model $model + * @return void + */ + public function setModel(Model $model): void + { + $this->model = $model; + $this->resetQuery(); + } + + /** + * Execute a callback within a database transaction + * + * @param callable $callback + * @return mixed + * @throws \Throwable + */ + protected function transaction(callable $callback) + { + return $this->model->getConnection()->transaction($callback); + } + + /** + * Get fresh timestamp for the model + * + * @return \Carbon\Carbon + */ + protected function freshTimestamp(): \Carbon\Carbon + { + return $this->model->freshTimestamp(); + } +} diff --git a/app/Repositories/ResumeRepository.php b/app/Repositories/ResumeRepository.php new file mode 100644 index 0000000..0d164c6 --- /dev/null +++ b/app/Repositories/ResumeRepository.php @@ -0,0 +1,522 @@ + + * @since February 2025 + */ +class ResumeRepository extends BaseRepository implements ResumeRepositoryInterface +{ + /** + * Create a new ResumeRepository instance + * + * @param Resume $model + */ + public function __construct(Resume $model) + { + parent::__construct($model); + } + + /** + * Get user's resumes + * + * @param int $userId + * @param array $columns + * @return Collection + */ + public function getUserResumes(int $userId, array $columns = ['*']): Collection + { + return $this->orderBy('updated_at', 'desc') + ->findBy(['user_id' => $userId], $columns); + } + + /** + * Get user's resumes with pagination + * + * @param int $userId + * @param int $perPage + * @return LengthAwarePaginator + */ + public function getPaginatedUserResumes(int $userId, int $perPage = 10): LengthAwarePaginator + { + $result = $this->query + ->where('user_id', $userId) + ->orderBy('updated_at', 'desc') + ->paginate($perPage); + + $this->resetQuery(); + return $result; + } + + /** + * Find resume by public URL + * + * @param string $publicUrl + * @return Resume|null + */ + public function findByPublicUrl(string $publicUrl): ?Resume + { + return $this->findOneBy([ + 'public_url' => $publicUrl, + 'is_public' => true, + 'status' => 'published' + ]); + } + + /** + * Get public resumes + * + * @param int $limit + * @return Collection + */ + public function getPublicResumes(int $limit = 20): Collection + { + $result = $this->query + ->where('is_public', true) + ->where('status', 'published') + ->whereNull('expires_at') + ->orWhere('expires_at', '>', Carbon::now()) + ->orderBy('published_at', 'desc') + ->limit($limit) + ->with(['user:id,first_name,last_name']) + ->get(); + + $this->resetQuery(); + return $result; + } + + /** + * Get resumes by template + * + * @param string $template + * @return Collection + */ + public function getResumesByTemplate(string $template): Collection + { + return $this->orderBy('created_at', 'desc') + ->findBy(['template' => $template]); + } + + /** + * Get resumes by status + * + * @param string $status + * @return Collection + */ + public function getResumesByStatus(string $status): Collection + { + return $this->orderBy('updated_at', 'desc') + ->findBy(['status' => $status]); + } + + /** + * Get popular resumes (most viewed) + * + * @param int $limit + * @param int $days + * @return Collection + */ + public function getPopularResumes(int $limit = 10, int $days = 30): Collection + { + $cutoffDate = Carbon::now()->subDays($days); + + $result = $this->query + ->where('is_public', true) + ->where('status', 'published') + ->where('last_viewed_at', '>=', $cutoffDate) + ->orderBy('view_count', 'desc') + ->limit($limit) + ->with(['user:id,first_name,last_name']) + ->get(); + + $this->resetQuery(); + return $result; + } + + /** + * Get recent resumes + * + * @param int $limit + * @param int $days + * @return Collection + */ + public function getRecentResumes(int $limit = 10, int $days = 7): Collection + { + $cutoffDate = Carbon::now()->subDays($days); + + $result = $this->query + ->where('is_public', true) + ->where('status', 'published') + ->where('published_at', '>=', $cutoffDate) + ->orderBy('published_at', 'desc') + ->limit($limit) + ->with(['user:id,first_name,last_name']) + ->get(); + + $this->resetQuery(); + return $result; + } + + /** + * Search resumes + * + * @param string $query + * @param array $filters + * @param int $limit + * @return Collection + */ + public function searchResumes(string $query, array $filters = [], int $limit = 20): Collection + { + $searchTerm = '%' . $query . '%'; + + $queryBuilder = $this->query + ->where('is_public', true) + ->where('status', 'published') + ->where(function ($q) use ($searchTerm) { + $q->where('title', 'like', $searchTerm) + ->orWhere('description', 'like', $searchTerm) + ->orWhereJsonContains('content->summary', $searchTerm) + ->orWhereJsonLike('content->skills', $searchTerm); + }); + + // Apply filters + if (isset($filters['template']) && !empty($filters['template'])) { + $queryBuilder = $queryBuilder->where('template', $filters['template']); + } + + if (isset($filters['min_completion']) && is_numeric($filters['min_completion'])) { + $queryBuilder = $queryBuilder->where('completion_percentage', '>=', $filters['min_completion']); + } + + $result = $queryBuilder + ->orderBy('view_count', 'desc') + ->limit($limit) + ->with(['user:id,first_name,last_name']) + ->get(); + + $this->resetQuery(); + return $result; + } + + /** + * Get incomplete resumes + * + * @param int $threshold + * @return Collection + */ + public function getIncompleteResumes(int $threshold = 50): Collection + { + $result = $this->query + ->where('completion_percentage', '<', $threshold) + ->where('status', 'draft') + ->orderBy('updated_at', 'desc') + ->with(['user:id,first_name,last_name,email']) + ->get(); + + $this->resetQuery(); + return $result; + } + + /** + * Duplicate resume for user + * + * @param int $resumeId + * @param int $userId + * @param string $newTitle + * @return Resume + */ + public function duplicateResume(int $resumeId, int $userId, string $newTitle): Resume + { + return $this->transaction(function () use ($resumeId, $userId, $newTitle) { + $originalResume = $this->findOrFail($resumeId); + + $duplicateData = $originalResume->toArray(); + + // Remove unique fields and reset for new resume + unset($duplicateData['id'], $duplicateData['created_at'], $duplicateData['updated_at'], $duplicateData['deleted_at']); + + $duplicateData['user_id'] = $userId; + $duplicateData['title'] = $newTitle; + $duplicateData['status'] = 'draft'; + $duplicateData['is_public'] = false; + $duplicateData['public_url'] = null; + $duplicateData['published_at'] = null; + $duplicateData['view_count'] = 0; + $duplicateData['download_count'] = 0; + $duplicateData['last_viewed_at'] = null; + $duplicateData['last_downloaded_at'] = null; + $duplicateData['pdf_path'] = null; + $duplicateData['pdf_generated_at'] = null; + $duplicateData['pdf_version'] = 1; + + return $this->create($duplicateData); + }); + } + + /** + * Update resume view count + * + * @param int $resumeId + * @return bool + */ + public function incrementViewCount(int $resumeId): bool + { + $resume = $this->find($resumeId); + + if (!$resume) { + return false; + } + + return $resume->update([ + 'view_count' => $resume->view_count + 1, + 'last_viewed_at' => $this->freshTimestamp(), + ]); + } + + /** + * Update resume download count + * + * @param int $resumeId + * @return bool + */ + public function incrementDownloadCount(int $resumeId): bool + { + $resume = $this->find($resumeId); + + if (!$resume) { + return false; + } + + return $resume->update([ + 'download_count' => $resume->download_count + 1, + 'last_downloaded_at' => $this->freshTimestamp(), + ]); + } + + /** + * Get resume statistics + * + * @param int|null $userId + * @return array + */ + public function getResumeStatistics(?int $userId = null): array + { + $query = $this->query; + + if ($userId) { + $query = $query->where('user_id', $userId); + } + + $stats = [ + 'total_resumes' => $query->count(), + 'draft_resumes' => $query->where('status', 'draft')->count(), + 'published_resumes' => $query->where('status', 'published')->count(), + 'archived_resumes' => $query->where('status', 'archived')->count(), + 'public_resumes' => $query->where('is_public', true)->count(), + 'private_resumes' => $query->where('is_public', false)->count(), + ]; + + // Template statistics + $templateStats = $query->select('template', DB::raw('count(*) as count')) + ->groupBy('template') + ->pluck('count', 'template') + ->toArray(); + $stats['by_template'] = $templateStats; + + // Completion statistics + $stats['avg_completion'] = $query->avg('completion_percentage') ?? 0; + $stats['completed_resumes'] = $query->where('completion_percentage', '>=', 90)->count(); + + // View statistics + $stats['total_views'] = $query->sum('view_count'); + $stats['total_downloads'] = $query->sum('download_count'); + + // Recent activity (last 30 days) + $thirtyDaysAgo = Carbon::now()->subDays(30); + $stats['created_last_30_days'] = $query->where('created_at', '>=', $thirtyDaysAgo)->count(); + $stats['updated_last_30_days'] = $query->where('updated_at', '>=', $thirtyDaysAgo)->count(); + + $this->resetQuery(); + return $stats; + } + + /** + * Get resumes requiring PDF regeneration + * + * @return Collection + */ + public function getResumesNeedingPdfRegeneration(): Collection + { + $result = $this->query + ->where('status', 'published') + ->where(function ($q) { + $q->whereNull('pdf_generated_at') + ->orWhere('updated_at', '>', DB::raw('pdf_generated_at')); + }) + ->orderBy('updated_at', 'desc') + ->get(); + + $this->resetQuery(); + return $result; + } + + /** + * Archive old resumes + * + * @param int $days + * @return int Number of archived resumes + */ + public function archiveOldResumes(int $days = 365): int + { + $cutoffDate = Carbon::now()->subDays($days); + + $result = $this->query + ->where('status', 'draft') + ->where('updated_at', '<', $cutoffDate) + ->update(['status' => 'archived']); + + $this->resetQuery(); + return $result; + } + + /** + * Get user's resume by title + * + * @param int $userId + * @param string $title + * @return Resume|null + */ + public function getUserResumeByTitle(int $userId, string $title): ?Resume + { + return $this->findOneBy([ + 'user_id' => $userId, + 'title' => $title + ]); + } + + /** + * Check if user can create more resumes + * + * @param int $userId + * @param int $limit + * @return bool + */ + public function canUserCreateMoreResumes(int $userId, int $limit = 10): bool + { + $userResumeCount = $this->count(['user_id' => $userId]); + return $userResumeCount < $limit; + } + + /** + * Get featured resumes for showcase + * + * @param int $limit + * @return Collection + */ + public function getFeaturedResumes(int $limit = 6): Collection + { + $result = $this->query + ->where('is_public', true) + ->where('status', 'published') + ->where('completion_percentage', '>=', 90) + ->where('view_count', '>', 50) + ->orderBy('view_count', 'desc') + ->orderBy('download_count', 'desc') + ->limit($limit) + ->with(['user:id,first_name,last_name']) + ->get(); + + $this->resetQuery(); + return $result; + } + + /** + * Update resume completion percentage + * + * @param int $resumeId + * @return bool + */ + public function updateCompletionPercentage(int $resumeId): bool + { + $resume = $this->find($resumeId); + + if (!$resume) { + return false; + } + + $content = $resume->content ?? []; + $sections = ['personal_info', 'summary', 'experience', 'education', 'skills']; + $completedSections = 0; + $totalSections = count($sections); + + foreach ($sections as $section) { + if (isset($content[$section]) && !empty($content[$section])) { + $completedSections++; + } + } + + $percentage = $totalSections > 0 ? intval(($completedSections / $totalSections) * 100) : 0; + + return $resume->update([ + 'completion_percentage' => $percentage, + 'completion_sections' => array_fill_keys($sections, false) + ]); + } + + /** + * Get resumes expiring soon + * + * @param int $days + * @return Collection + */ + public function getResumesExpiringSoon(int $days = 7): Collection + { + $futureDate = Carbon::now()->addDays($days); + + $result = $this->query + ->where('is_public', true) + ->whereNotNull('expires_at') + ->whereBetween('expires_at', [Carbon::now(), $futureDate]) + ->orderBy('expires_at', 'asc') + ->with(['user:id,first_name,last_name,email']) + ->get(); + + $this->resetQuery(); + return $result; + } + + /** + * Bulk update resume status + * + * @param array $resumeIds + * @param string $status + * @return int Number of updated resumes + */ + public function bulkUpdateStatus(array $resumeIds, string $status): int + { + $result = $this->query + ->whereIn('id', $resumeIds) + ->update([ + 'status' => $status, + 'updated_at' => $this->freshTimestamp() + ]); + + $this->resetQuery(); + return $result; + } +} diff --git a/app/Repositories/UserRepository.php b/app/Repositories/UserRepository.php new file mode 100644 index 0000000..8a1beba --- /dev/null +++ b/app/Repositories/UserRepository.php @@ -0,0 +1,410 @@ + + * @since February 2025 + */ +class UserRepository extends BaseRepository implements UserRepositoryInterface +{ + /** + * Create a new UserRepository instance + * + * @param User $model + */ + public function __construct(User $model) + { + parent::__construct($model); + } + + /** + * Find a user by email address + * + * @param string $email + * @return User|null + */ + public function findByEmail(string $email): ?User + { + return $this->findOneBy(['email' => $email]); + } + + /** + * Find users by status + * + * @param string $status + * @return Collection + */ + public function findByStatus(string $status): Collection + { + return $this->findBy(['status' => $status]); + } + + /** + * Get users who haven't logged in recently + * + * @param int $days + * @return Collection + */ + public function getInactiveUsers(int $days = 30): Collection + { + $cutoffDate = Carbon::now()->subDays($days); + + $result = $this->query + ->where(function ($query) use ($cutoffDate) { + $query->where('last_login_at', '<', $cutoffDate) + ->orWhereNull('last_login_at'); + }) + ->where('status', 'active') + ->orderBy('last_login_at', 'asc') + ->get(); + + $this->resetQuery(); + return $result; + } + + /** + * Get users with completed profiles + * + * @return Collection + */ + public function getUsersWithCompletedProfiles(): Collection + { + $result = $this->query + ->whereNotNull('profile_completed_at') + ->where('status', 'active') + ->orderBy('profile_completed_at', 'desc') + ->get(); + + $this->resetQuery(); + return $result; + } + + /** + * Get users with incomplete profiles + * + * @return Collection + */ + public function getUsersWithIncompleteProfiles(): Collection + { + $result = $this->query + ->whereNull('profile_completed_at') + ->where('status', 'active') + ->orderBy('created_at', 'desc') + ->get(); + + $this->resetQuery(); + return $result; + } + + /** + * Get users subscribed to newsletter + * + * @return Collection + */ + public function getNewsletterSubscribers(): Collection + { + return $this->findBy([ + 'newsletter_subscribed' => true, + 'status' => 'active' + ]); + } + + /** + * Search users by name or email + * + * @param string $query + * @param int $limit + * @return Collection + */ + public function searchUsers(string $query, int $limit = 10): Collection + { + $searchTerm = '%' . $query . '%'; + + $result = $this->query + ->where(function ($q) use ($searchTerm) { + $q->where('first_name', 'like', $searchTerm) + ->orWhere('last_name', 'like', $searchTerm) + ->orWhere('email', 'like', $searchTerm) + ->orWhereRaw("CONCAT(first_name, ' ', last_name) LIKE ?", [$searchTerm]); + }) + ->where('status', 'active') + ->orderBy('last_login_at', 'desc') + ->limit($limit) + ->get(); + + $this->resetQuery(); + return $result; + } + + /** + * Retrieve comprehensive user statistics and analytics + * + * Aggregates key user metrics including: + * - Total users by status (active, inactive, suspended) + * - Email verification status counts + * - Registration trends (30-day window) + * - Profile completion metrics + * - Newsletter subscription data + * - Daily active user statistics + * + * Used for admin dashboards, reporting, and user behavior analysis. + * + * @return array Comprehensive statistics array with user metrics + * @throws Exception When database queries fail + */ + public function getUserStatistics(): array + { + $stats = [ + 'total_users' => $this->count(), + 'active_users' => $this->count(['status' => 'active']), + 'inactive_users' => $this->count(['status' => 'inactive']), + 'suspended_users' => $this->count(['status' => 'suspended']), + 'verified_users' => $this->query->whereNotNull('email_verified_at')->count(), + 'unverified_users' => $this->query->whereNull('email_verified_at')->count(), + ]; + + $thirtyDaysAgo = Carbon::now()->subDays(30); + $stats['new_users_30_days'] = $this->query + ->where('created_at', '>=', $thirtyDaysAgo) + ->count(); + + $stats['completed_profiles'] = $this->query + ->whereNotNull('profile_completed_at') + ->count(); + + $stats['newsletter_subscribers'] = $this->count(['newsletter_subscribed' => true]); + + $stats['active_today'] = $this->query + ->whereDate('last_login_at', Carbon::today()) + ->count(); + + $this->resetQuery(); + return $stats; + } + + /** + * Update user's last login metadata with security tracking + * + * Records successful login timestamp, IP address, and resets + * security-related fields including login attempts and account locks. + * Essential for authentication audit trails and security monitoring. + * + * @param int $userId The user ID to update + * @param string $ipAddress Client IP address for security tracking + * @return bool True if update succeeded, false otherwise + * @throws Exception When user record update fails + */ + public function updateLastLogin(int $userId, string $ipAddress): bool + { + return $this->update($userId, [ + 'last_login_at' => $this->freshTimestamp(), + 'last_login_ip' => $ipAddress, + 'login_attempts' => 0, + 'locked_until' => null, + ]); + } + + /** + * Increment failed login attempt counter for user account + * + * Increases the login_attempts field by 1 for security tracking. + * Used in conjunction with account locking mechanisms to prevent + * brute force attacks. Should be called after each failed login. + * + * @param string $email User email address to track attempts for + * @return bool True if increment succeeded, false if user not found + */ + public function incrementLoginAttempts(string $email): bool + { + $user = $this->findByEmail($email); + + if (!$user) { + return false; + } + + return $user->increment('login_attempts'); + } + + /** + * Reset user's login attempts + * + * @param string $email + * @return bool + */ + public function resetLoginAttempts(string $email): bool + { + $user = $this->findByEmail($email); + + if (!$user) { + return false; + } + + return $user->update([ + 'login_attempts' => 0, + 'locked_until' => null, + ]); + } + + /** + * Temporarily lock user account for security purposes + * + * Implements account lockout mechanism to prevent brute force attacks + * by setting locked_until timestamp. Account remains inaccessible + * until the lockout period expires or is manually cleared. + * + * @param string $email User email address to lock + * @param int $minutes Lockout duration in minutes (default: 15) + * @return bool True if lock applied successfully, false if user not found + */ + public function lockAccount(string $email, int $minutes = 15): bool + { + $user = $this->findByEmail($email); + + if (!$user) { + return false; + } + + return $user->update([ + 'locked_until' => Carbon::now()->addMinutes($minutes), + ]); + } + + /** + * Determine if user account is currently locked + * + * Validates account lockout status by checking if locked_until + * timestamp exists and is still in the future. Used before + * authentication attempts to enforce security policies. + * + * @param string $email User email address to check + * @return bool True if account is locked, false if accessible or user not found + */ + public function isAccountLocked(string $email): bool + { + $user = $this->findByEmail($email); + + if (!$user || !$user->locked_until) { + return false; + } + + return Carbon::now()->lessThan($user->locked_until); + } + + /** + * Suspend user account + * + * @param int $userId + * @param string $reason + * @return bool + */ + public function suspendAccount(int $userId, string $reason): bool + { + return $this->update($userId, [ + 'status' => 'suspended', + 'suspended_at' => $this->freshTimestamp(), + 'suspension_reason' => $reason, + ]); + } + + /** + * Reactivate user account + * + * @param int $userId + * @return bool + */ + public function reactivateAccount(int $userId): bool + { + return $this->update($userId, [ + 'status' => 'active', + 'suspended_at' => null, + 'suspension_reason' => null, + ]); + } + + /** + * Get users registered in date range + * + * @param string $startDate + * @param string $endDate + * @return Collection + */ + public function getUsersRegisteredBetween(string $startDate, string $endDate): Collection + { + $result = $this->query + ->whereBetween('created_at', [$startDate, $endDate]) + ->orderBy('created_at', 'desc') + ->get(); + + $this->resetQuery(); + return $result; + } + + /** + * Get paginated users with filters + * + * @param array $filters + * @param int $perPage + * @return LengthAwarePaginator + */ + public function getPaginatedUsersWithFilters(array $filters = [], int $perPage = 15): LengthAwarePaginator + { + $query = $this->query; + + // Apply filters + if (isset($filters['status']) && !empty($filters['status'])) { + $query = $query->where('status', $filters['status']); + } + + if (isset($filters['verified']) && $filters['verified'] !== '') { + if ($filters['verified']) { + $query = $query->whereNotNull('email_verified_at'); + } else { + $query = $query->whereNull('email_verified_at'); + } + } + + if (isset($filters['newsletter']) && $filters['newsletter'] !== '') { + $query = $query->where('newsletter_subscribed', (bool) $filters['newsletter']); + } + + if (isset($filters['search']) && !empty($filters['search'])) { + $searchTerm = '%' . $filters['search'] . '%'; + $query = $query->where(function ($q) use ($searchTerm) { + $q->where('first_name', 'like', $searchTerm) + ->orWhere('last_name', 'like', $searchTerm) + ->orWhere('email', 'like', $searchTerm); + }); + } + + if (isset($filters['date_from']) && !empty($filters['date_from'])) { + $query = $query->whereDate('created_at', '>=', $filters['date_from']); + } + + if (isset($filters['date_to']) && !empty($filters['date_to'])) { + $query = $query->whereDate('created_at', '<=', $filters['date_to']); + } + + // Default ordering + $sortBy = $filters['sort_by'] ?? 'created_at'; + $sortDirection = $filters['sort_direction'] ?? 'desc'; + $query = $query->orderBy($sortBy, $sortDirection); + + $result = $query->paginate($perPage); + $this->resetQuery(); + + return $result; + } +} diff --git a/app/Services/AuthService.php b/app/Services/AuthService.php new file mode 100644 index 0000000..c5c2b47 --- /dev/null +++ b/app/Services/AuthService.php @@ -0,0 +1,447 @@ + + * @created 2025-08-08 + * @location Made in Germany ๐Ÿ‡ฉ๐Ÿ‡ช + */ + +namespace App\Services; + +use App\Interfaces\UserRepositoryInterface; +use App\Models\User; +use Illuminate\Database\Eloquent\ModelNotFoundException; +use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\RateLimiter; +use Illuminate\Validation\ValidationException; +use Exception; + +/** + * Authentication Service + * Handles user authentication, registration, and security features with repository pattern + */ +class AuthService +{ + /** + * User repository instance + */ + private UserRepositoryInterface $userRepository; + + /** + * Maximum login attempts per minute + */ + protected const MAX_LOGIN_ATTEMPTS = 5; + + /** + * Login throttle key prefix + */ + protected const THROTTLE_KEY_PREFIX = 'login_attempts:'; + + /** + * Account lockout duration in minutes + */ + protected const LOCKOUT_DURATION = 15; + + /** + * Create a new service instance with dependency injection + */ + public function __construct(UserRepositoryInterface $userRepository) + { + $this->userRepository = $userRepository; + } + + /** + * Attempt user authentication with comprehensive security measures + * + * Implements enterprise-grade authentication with: + * - Rate limiting to prevent brute force attacks + * - Account lockout mechanisms for security + * - Detailed security event logging + * - Structured response format for consistent handling + * + * @param array $credentials User login credentials (email, password, remember) + * @return array Structured authentication result with success status and details + * @throws ValidationException When rate limiting is exceeded or validation fails + * @throws Exception When system errors occur during authentication + */ + public function attemptLogin(array $credentials): array + { + $email = $credentials['email']; + $password = $credentials['password']; + $remember = $credentials['remember'] ?? false; + + try { + $this->checkRateLimit($email); + + if ($this->userRepository->isAccountLocked($email)) { + Log::warning('Login attempt on locked account', [ + 'email' => $email, + 'ip_address' => request()->ip() + ]); + + return [ + 'success' => false, + 'reason' => 'account_locked', + 'message' => 'Account is temporarily locked' + ]; + } + + $user = $this->userRepository->findByEmail($email); + + if (!$user) { + $this->incrementRateLimit($email); + return [ + 'success' => false, + 'reason' => 'invalid_credentials', + 'message' => 'User not found' + ]; + } + + if (!$user->is_active) { + $this->incrementRateLimit($email); + + Log::warning('Login attempt on inactive account', [ + 'user_id' => $user->id, + 'email' => $email, + 'ip_address' => request()->ip() + ]); + + return [ + 'success' => false, + 'reason' => 'account_inactive', + 'message' => 'Account is deactivated' + ]; + } + + if (!Hash::check($password, $user->password)) { + $this->incrementRateLimit($email); + $this->userRepository->incrementLoginAttempts($email); + + $updatedUser = $this->userRepository->findByEmail($email); + if ($updatedUser && $updatedUser->login_attempts >= self::MAX_LOGIN_ATTEMPTS) { + $this->userRepository->lockAccount($email, self::LOCKOUT_DURATION); + + Log::warning('Account locked due to excessive failed attempts', [ + 'user_id' => $user->id, + 'email' => $email, + 'attempts' => $updatedUser->login_attempts + ]); + + return [ + 'success' => false, + 'reason' => 'too_many_attempts', + 'message' => 'Account locked due to failed attempts' + ]; + } + + return [ + 'success' => false, + 'reason' => 'invalid_credentials', + 'message' => 'Invalid password' + ]; + } + + Auth::login($user, $remember); + + $this->userRepository->updateLastLogin($user->id, request()->ip()); + $this->userRepository->resetLoginAttempts($email); + $this->clearRateLimit($email); + + Log::info('Successful user authentication', [ + 'user_id' => $user->id, + 'email' => $email, + 'ip_address' => request()->ip(), + 'user_agent' => request()->userAgent() + ]); + + return [ + 'success' => true, + 'user' => $user, + 'message' => 'Login successful' + ]; + + } catch (ValidationException $e) { + throw $e; + } catch (Exception $e) { + Log::error('Authentication service error', [ + 'error_message' => $e->getMessage(), + 'email' => $email, + 'ip_address' => request()->ip(), + 'trace' => $e->getTraceAsString() + ]); + + return [ + 'success' => false, + 'reason' => 'system_error', + 'message' => 'System error occurred' + ]; + } + } + + /** + * Create new user account with comprehensive security validation + * + * Implements secure user registration with: + * - Duplicate email detection using repository layer + * - Secure password hashing with bcrypt + * - Security-focused default settings + * - Comprehensive audit logging + * - Prepared data structure for repository storage + * + * @param array $userData User registration data (first_name, last_name, email, password) + * @return User The created user instance + * @throws ValidationException When email already exists or validation fails + * @throws Exception When user creation fails due to system errors + */ + public function createUser(array $userData): User + { + try { + if ($this->userRepository->findByEmail($userData['email'])) { + Log::warning('Registration attempt with existing email', [ + 'email' => $userData['email'], + 'ip_address' => request()->ip() + ]); + + throw ValidationException::withMessages([ + 'email' => 'An account with this email address already exists.', + ]); + } + + $preparedData = [ + 'first_name' => $userData['first_name'], + 'last_name' => $userData['last_name'], + 'email' => $userData['email'], + 'password' => Hash::make($userData['password']), + 'status' => 'active', + 'locale' => $userData['locale'] ?? 'en', + 'timezone' => $userData['timezone'] ?? 'UTC', + 'login_attempts' => 0, + 'locked_until' => null, + 'email_verified_at' => null, + 'last_login_at' => null, + 'last_login_ip' => null + ]; + + $user = $this->userRepository->create($preparedData); + + if ($user) { + Log::info('New user account created successfully', [ + 'user_id' => $user->id, + 'email' => $user->email, + 'ip_address' => request()->ip(), + 'user_agent' => request()->userAgent() + ]); + } + + return $user; + + } catch (ValidationException $e) { + throw $e; + } catch (Exception $e) { + Log::error('User creation failed', [ + 'error_message' => $e->getMessage(), + 'email' => $userData['email'] ?? 'unknown', + 'ip_address' => request()->ip(), + 'trace' => $e->getTraceAsString() + ]); + + throw new Exception('Failed to create user account'); + } + } + + /** + * Update user profile information + */ + public function updateProfile(User $user, array $profileData): User + { + unset($profileData['password'], $profileData['email'], $profileData['is_active']); + + $user->update($profileData); + + logger()->info('User profile updated', [ + 'user_id' => $user->id, + 'email' => $user->email, + 'ip' => request()->ip(), + ]); + + return $user; + } + + /** + * Change user password + */ + public function changePassword(User $user, string $currentPassword, string $newPassword): bool + { + if (!Hash::check($currentPassword, $user->password)) { + throw new \InvalidArgumentException('Current password is incorrect.'); + } + + $user->update(['password' => Hash::make($newPassword)]); + + logger()->info('User password changed', [ + 'user_id' => $user->id, + 'email' => $user->email + ]); + + return true; + } + + /** + * Deactivate user account + */ + public function deactivateAccount(User $user): bool + { + $user->update(['is_active' => false]); + + logger()->info('User account deactivated', [ + 'user_id' => $user->id, + 'email' => $user->email, + 'ip' => request()->ip(), + ]); + + return true; + } + + /** + * Activate user account + */ + public function activateAccount(User $user): bool + { + $user->update(['is_active' => true]); + + logger()->info('User account activated', [ + 'user_id' => $user->id, + 'email' => $user->email, + 'ip' => request()->ip(), + ]); + + return true; + } + + /** + * Check if user has exceeded login rate limit + */ + protected function checkRateLimit(string $email): void + { + $key = $this->getRateLimitKey($email); + + if (RateLimiter::tooManyAttempts($key, self::MAX_LOGIN_ATTEMPTS)) { + $seconds = RateLimiter::availableIn($key); + + throw ValidationException::withMessages([ + 'email' => "Too many login attempts. Please try again in {$seconds} seconds.", + ]); + } + } + + /** + * Increment rate limit counter + */ + protected function incrementRateLimit(string $email): void + { + $key = $this->getRateLimitKey($email); + RateLimiter::hit($key, 60); + } + + /** + * Clear rate limit counter + */ + protected function clearRateLimit(string $email): void + { + $key = $this->getRateLimitKey($email); + RateLimiter::clear($key); + } + + /** + * Get rate limit key for email + */ + protected function getRateLimitKey(string $email): string + { + return self::THROTTLE_KEY_PREFIX . $email . '|' . request()->ip(); + } + + /** + * Send password reset notification + */ + public function sendPasswordResetNotification(string $email): bool + { + $user = User::where('email', $email)->first(); + + if (!$user) { + // Don't reveal if email exists or not for security + return true; + } + + // Generate and send password reset token + // Implementation depends on your notification preferences + // $user->sendPasswordResetNotification($token); + + logger()->info('Password reset requested', [ + 'user_id' => $user->id, + 'email' => $user->email, + 'ip' => request()->ip(), + ]); + + return true; + } + + /** + * Verify email address + */ + public function verifyEmail(User $user): bool + { + if ($user->hasVerifiedEmail()) { + return true; + } + + $user->markEmailAsVerified(); + + logger()->info('Email verified', [ + 'user_id' => $user->id, + 'email' => $user->email, + 'ip' => request()->ip(), + ]); + + return true; + } + + /** + * Get user statistics + */ + public function getUserStats(User $user): array + { + return [ + 'account_created' => $user->created_at, + 'last_login' => $user->last_login_at, + 'profile_completion' => $this->calculateProfileCompletion($user), + 'total_resumes' => $user->resumes()->count(), + 'completed_resumes' => $user->completedResumes()->count(), + 'is_verified' => $user->hasVerifiedEmail(), + ]; + } + + /** + * Calculate profile completion percentage + */ + protected function calculateProfileCompletion(User $user): int + { + $fields = [ + 'first_name', 'last_name', 'email', 'phone', + 'address', 'city', 'country', 'profession' + ]; + + $completed = 0; + foreach ($fields as $field) { + if (!empty($user->$field)) { + $completed++; + } + } + + return (int) (($completed / count($fields)) * 100); + } +} diff --git a/app/Services/ProfileCompletionService.php b/app/Services/ProfileCompletionService.php new file mode 100644 index 0000000..a072333 --- /dev/null +++ b/app/Services/ProfileCompletionService.php @@ -0,0 +1,127 @@ + + * @since February 2025 + */ +class ProfileCompletionService +{ + /** + * Required fields for profile completion + */ + private const REQUIRED_FIELDS = [ + 'first_name' => 10, + 'last_name' => 10, + 'email' => 15, + 'phone' => 10, + 'bio' => 20, + 'website' => 10, + 'linkedin' => 15, + 'github' => 10, + ]; + + /** + * Calculate profile completion percentage + */ + public function calculateCompletion(User $user): int + { + $totalWeight = array_sum(self::REQUIRED_FIELDS); + $completedWeight = 0; + + foreach (self::REQUIRED_FIELDS as $field => $weight) { + if (!empty($user->$field)) { + $completedWeight += $weight; + } + } + + return (int) (($completedWeight / $totalWeight) * 100); + } + + /** + * Get missing profile fields + */ + public function getMissingFields(User $user): array + { + $missing = []; + + foreach (self::REQUIRED_FIELDS as $field => $weight) { + if (empty($user->$field)) { + $missing[] = [ + 'field' => $field, + 'label' => $this->getFieldLabel($field), + 'weight' => $weight, + ]; + } + } + + return $missing; + } + + /** + * Get user-friendly field labels + */ + private function getFieldLabel(string $field): string + { + $labels = [ + 'first_name' => 'First Name', + 'last_name' => 'Last Name', + 'email' => 'Email Address', + 'phone' => 'Phone Number', + 'bio' => 'Professional Bio', + 'website' => 'Website', + 'linkedin' => 'LinkedIn Profile', + 'github' => 'GitHub Profile', + ]; + + return $labels[$field] ?? ucfirst(str_replace('_', ' ', $field)); + } + + /** + * Check if profile is considered complete + */ + public function isProfileComplete(User $user): bool + { + return $this->calculateCompletion($user) >= 80; + } + + /** + * Get profile completion statistics + */ + public function getCompletionStats(User $user): array + { + $completion = $this->calculateCompletion($user); + $missingFields = $this->getMissingFields($user); + + return [ + 'completion_percentage' => $completion, + 'is_complete' => $this->isProfileComplete($user), + 'missing_fields' => $missingFields, + 'missing_count' => count($missingFields), + 'status' => $this->getCompletionStatus($completion), + ]; + } + + /** + * Get completion status message + */ + private function getCompletionStatus(int $completion): string + { + if ($completion >= 90) { + return 'excellent'; + } elseif ($completion >= 70) { + return 'good'; + } elseif ($completion >= 50) { + return 'fair'; + } else { + return 'incomplete'; + } + } +} diff --git a/app/Services/ResumeService.php b/app/Services/ResumeService.php new file mode 100644 index 0000000..df3863c --- /dev/null +++ b/app/Services/ResumeService.php @@ -0,0 +1,380 @@ + + * @created 2025-08-08 + * @location Made in Germany ๐Ÿ‡ฉ๐Ÿ‡ช + */ + +namespace App\Services; + +use App\Models\Resume; +use App\Models\User; +use Illuminate\Database\Eloquent\Collection; +use Illuminate\Http\Response; +use Barryvdh\DomPDF\Facade\Pdf; + +/** + * Resume Service + * Handles resume creation, management, and operations + */ +class ResumeService +{ + /** + * Available resume templates + */ + protected array $templates = [ + 'professional' => [ + 'id' => 'professional', + 'name' => 'Professional Classic', + 'description' => 'Clean and professional design perfect for corporate environments', + 'preview' => 'templates/professional-preview.jpg', + 'category' => 'Professional' + ], + 'modern' => [ + 'id' => 'modern', + 'name' => 'Modern Creative', + 'description' => 'Contemporary design with creative elements for modern companies', + 'preview' => 'templates/modern-preview.jpg', + 'category' => 'Creative' + ], + 'executive' => [ + 'id' => 'executive', + 'name' => 'Executive', + 'description' => 'Sophisticated layout for senior-level positions', + 'preview' => 'templates/executive-preview.jpg', + 'category' => 'Executive' + ], + 'minimal' => [ + 'id' => 'minimal', + 'name' => 'Minimal Clean', + 'description' => 'Simple and clean design focusing on content', + 'preview' => 'templates/minimal-preview.jpg', + 'category' => 'Minimal' + ], + 'technical' => [ + 'id' => 'technical', + 'name' => 'Technical', + 'description' => 'Optimized for technical and engineering roles', + 'preview' => 'templates/technical-preview.jpg', + 'category' => 'Technical' + ] + ]; + + /** + * Get all resumes for a specific user + */ + public function getUserResumes(int $userId): Collection + { + return Resume::where('user_id', $userId) + ->orderBy('updated_at', 'desc') + ->get(); + } + + /** + * Create a new resume + */ + public function createResume(array $resumeData): Resume + { + // Set default values + $resumeData['is_active'] = true; + $resumeData['is_completed'] = false; + $resumeData['is_public'] = false; + + // Initialize empty sections + $resumeData['personal_info'] = $resumeData['personal_info'] ?? []; + $resumeData['work_experiences'] = $resumeData['work_experiences'] ?? []; + $resumeData['education'] = $resumeData['education'] ?? []; + $resumeData['skills'] = $resumeData['skills'] ?? []; + $resumeData['languages'] = $resumeData['languages'] ?? []; + $resumeData['certifications'] = $resumeData['certifications'] ?? []; + $resumeData['projects'] = $resumeData['projects'] ?? []; + $resumeData['references'] = $resumeData['references'] ?? []; + $resumeData['custom_sections'] = $resumeData['custom_sections'] ?? []; + $resumeData['settings'] = $resumeData['settings'] ?? $this->getDefaultSettings(); + + $resume = Resume::create($resumeData); + + logger()->info('Resume created', [ + 'resume_id' => $resume->id, + 'user_id' => $resume->user_id, + 'title' => $resume->title, + ]); + + return $resume; + } + + /** + * Update an existing resume + */ + public function updateResume(Resume $resume, array $resumeData): Resume + { + // Update completion status based on content + if ($this->shouldMarkAsCompleted($resumeData)) { + $resumeData['is_completed'] = true; + } + + $resume->update($resumeData); + + logger()->info('Resume updated', [ + 'resume_id' => $resume->id, + 'user_id' => $resume->user_id, + 'completion' => $resume->completion_percentage, + ]); + + return $resume; + } + + /** + * Delete a resume + */ + public function deleteResume(Resume $resume): bool + { + $resumeId = $resume->id; + $userId = $resume->user_id; + + $deleted = $resume->delete(); + + if ($deleted) { + logger()->info('Resume deleted', [ + 'resume_id' => $resumeId, + 'user_id' => $userId, + ]); + } + + return $deleted; + } + + /** + * Duplicate a resume + */ + public function duplicateResume(Resume $resume, string $newTitle): Resume + { + $resumeData = $resume->toArray(); + + // Remove unique identifiers + unset($resumeData['id'], $resumeData['slug'], $resumeData['public_url']); + + // Set new title and reset status + $resumeData['title'] = $newTitle; + $resumeData['is_public'] = false; + $resumeData['created_at'] = now(); + $resumeData['updated_at'] = now(); + + $newResume = Resume::create($resumeData); + + logger()->info('Resume duplicated', [ + 'original_resume_id' => $resume->id, + 'new_resume_id' => $newResume->id, + 'user_id' => $resume->user_id, + ]); + + return $newResume; + } + + /** + * Generate PDF for a resume + */ + public function generatePdf(Resume $resume): Response + { + $template = $this->getTemplate($resume->template_id ?? 'professional'); + + $pdf = Pdf::loadView('pdf.resume.' . $template['id'], [ + 'resume' => $resume, + 'template' => $template + ]); + + $fileName = str()->slug($resume->title) . '-resume.pdf'; + + logger()->info('PDF generated', [ + 'resume_id' => $resume->id, + 'user_id' => $resume->user_id, + 'template' => $template['id'], + ]); + + return $pdf->download($fileName); + } + + /** + * Make resume public + */ + public function makePublic(Resume $resume): Resume + { + if (!$resume->public_url) { + $resume->public_url = $resume->generatePublicUrl(); + } + + $resume->update(['is_public' => true]); + + logger()->info('Resume made public', [ + 'resume_id' => $resume->id, + 'user_id' => $resume->user_id, + 'public_url' => $resume->public_url, + ]); + + return $resume; + } + + /** + * Make resume private + */ + public function makePrivate(Resume $resume): Resume + { + $resume->update(['is_public' => false]); + + logger()->info('Resume made private', [ + 'resume_id' => $resume->id, + 'user_id' => $resume->user_id, + ]); + + return $resume; + } + + /** + * Get available templates + */ + public function getAvailableTemplates(): array + { + return $this->templates; + } + + /** + * Get specific template + */ + public function getTemplate(string $templateId): array + { + return $this->templates[$templateId] ?? $this->templates['professional']; + } + + /** + * Get resume analytics + */ + public function getResumeAnalytics(Resume $resume): array + { + return [ + 'completion_percentage' => $resume->completion_percentage, + 'sections_completed' => $this->getCompletedSections($resume), + 'total_sections' => 7, + 'word_count' => $this->calculateWordCount($resume), + 'last_updated' => $resume->updated_at, + 'views' => 0, // Implement view tracking if needed + ]; + } + + /** + * Get completed sections count + */ + protected function getCompletedSections(Resume $resume): int + { + $sections = ['personal_info', 'professional_summary', 'work_experiences', + 'education', 'skills', 'languages', 'certifications']; + + $completed = 0; + foreach ($sections as $section) { + if ($resume->isSectionCompleted($section)) { + $completed++; + } + } + + return $completed; + } + + /** + * Calculate total word count in resume + */ + protected function calculateWordCount(Resume $resume): int + { + $wordCount = 0; + + // Professional summary + if ($resume->professional_summary) { + $wordCount += str_word_count(strip_tags($resume->professional_summary)); + } + + // Work experiences + if ($resume->work_experiences) { + foreach ($resume->work_experiences as $experience) { + $wordCount += str_word_count(strip_tags($experience['description'] ?? '')); + } + } + + // Education + if ($resume->education) { + foreach ($resume->education as $education) { + $wordCount += str_word_count(strip_tags($education['description'] ?? '')); + } + } + + // Projects + if ($resume->projects) { + foreach ($resume->projects as $project) { + $wordCount += str_word_count(strip_tags($project['description'] ?? '')); + } + } + + return $wordCount; + } + + /** + * Check if resume should be marked as completed + */ + protected function shouldMarkAsCompleted(array $resumeData): bool + { + $requiredSections = ['personal_info', 'work_experiences', 'education']; + + foreach ($requiredSections as $section) { + if (empty($resumeData[$section])) { + return false; + } + } + + return true; + } + + /** + * Get default resume settings + */ + protected function getDefaultSettings(): array + { + return [ + 'theme' => 'light', + 'font_family' => 'Roboto', + 'font_size' => 14, + 'color_scheme' => 'blue', + 'show_photo' => false, + 'show_references' => true, + 'show_certifications' => true, + 'show_projects' => true, + 'section_order' => [ + 'personal_info', + 'professional_summary', + 'work_experiences', + 'education', + 'skills', + 'languages', + 'certifications', + 'projects', + 'references' + ] + ]; + } + + /** + * Get resume statistics for user + */ + public function getUserResumeStats(int $userId): array + { + $resumes = $this->getUserResumes($userId); + + return [ + 'total_resumes' => $resumes->count(), + 'completed_resumes' => $resumes->where('is_completed', true)->count(), + 'public_resumes' => $resumes->where('is_public', true)->count(), + 'avg_completion' => $resumes->avg('completion_percentage') ?? 0, + 'last_updated' => $resumes->max('updated_at'), + ]; + } +} diff --git a/app/Services/SecurityService.php b/app/Services/SecurityService.php new file mode 100644 index 0000000..57efc11 --- /dev/null +++ b/app/Services/SecurityService.php @@ -0,0 +1,400 @@ + + * @created 2025-08-08 + * @location Made in Germany ๐Ÿ‡ฉ๐Ÿ‡ช + */ + +namespace App\Services; + +use App\Interfaces\UserRepositoryInterface; +use App\Models\User; +use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Facades\Cache; +use Carbon\Carbon; +use Exception; + +/** + * Security Service + * Handles security-related operations and monitoring + */ +class SecurityService +{ + /** + * User repository instance + */ + private UserRepositoryInterface $userRepository; + + /** + * Maximum failed attempts before account lockout + */ + private const MAX_FAILED_ATTEMPTS = 5; + + /** + * Account lockout duration in minutes + */ + private const LOCKOUT_DURATION = 15; + + /** + * Password reset token expiry in hours + */ + private const PASSWORD_RESET_EXPIRY = 24; + + /** + * Create a new service instance + */ + public function __construct(UserRepositoryInterface $userRepository) + { + $this->userRepository = $userRepository; + } + + /** + * Generate secure password reset token + * + * @param User $user + * @return string + */ + public function generatePasswordResetToken(User $user): string + { + $token = bin2hex(random_bytes(32)); + $expiry = now()->addHours(self::PASSWORD_RESET_EXPIRY); + + // Store token in cache with expiry + Cache::put( + "password_reset:{$user->id}:{$token}", + [ + 'user_id' => $user->id, + 'email' => $user->email, + 'expires_at' => $expiry, + 'created_at' => now() + ], + $expiry + ); + + Log::info('Password reset token generated', [ + 'user_id' => $user->id, + 'email' => $user->email, + 'expires_at' => $expiry, + 'ip_address' => request()->ip() + ]); + + return $token; + } + + /** + * Validate password reset token + * + * @param string $token + * @param int $userId + * @return bool + */ + public function validatePasswordResetToken(string $token, int $userId): bool + { + $cacheKey = "password_reset:{$userId}:{$token}"; + $tokenData = Cache::get($cacheKey); + + if (!$tokenData) { + Log::warning('Invalid password reset token used', [ + 'user_id' => $userId, + 'token' => substr($token, 0, 8) . '...', + 'ip_address' => request()->ip() + ]); + return false; + } + + if ($tokenData['user_id'] !== $userId) { + Log::warning('Password reset token user mismatch', [ + 'expected_user_id' => $userId, + 'token_user_id' => $tokenData['user_id'], + 'ip_address' => request()->ip() + ]); + return false; + } + + return true; + } + + /** + * Reset user password using token + * + * @param string $token + * @param int $userId + * @param string $newPassword + * @return bool + * @throws Exception + */ + public function resetPassword(string $token, int $userId, string $newPassword): bool + { + try { + if (!$this->validatePasswordResetToken($token, $userId)) { + throw new Exception('Invalid or expired password reset token'); + } + + $user = $this->userRepository->findById($userId); + if (!$user) { + throw new Exception('User not found'); + } + + // Update password + $hashedPassword = Hash::make($newPassword); + $updateData = [ + 'password' => $hashedPassword, + 'password_updated_at' => now(), + 'login_attempts' => 0, + 'account_locked_until' => null + ]; + + $success = $this->userRepository->update($userId, $updateData); + + if ($success) { + // Remove used token + $cacheKey = "password_reset:{$userId}:{$token}"; + Cache::forget($cacheKey); + + Log::info('Password reset completed successfully', [ + 'user_id' => $userId, + 'email' => $user->email, + 'ip_address' => request()->ip() + ]); + } + + return $success; + + } catch (Exception $e) { + Log::error('Password reset failed', [ + 'error_message' => $e->getMessage(), + 'user_id' => $userId, + 'ip_address' => request()->ip() + ]); + + throw $e; + } + } + + /** + * Check if user account is secure + * + * @param User $user + * @return array + */ + public function checkAccountSecurity(User $user): array + { + $securityChecks = [ + 'password_strength' => $this->checkPasswordStrength($user), + 'two_factor_enabled' => $user->two_factor_secret !== null, + 'email_verified' => $user->email_verified_at !== null, + 'recent_password_change' => $this->checkRecentPasswordChange($user), + 'suspicious_activity' => $this->checkSuspiciousActivity($user), + 'account_locked' => $this->userRepository->isAccountLocked($user->email) + ]; + + $securityScore = $this->calculateSecurityScore($securityChecks); + + return [ + 'security_score' => $securityScore, + 'checks' => $securityChecks, + 'recommendations' => $this->getSecurityRecommendations($securityChecks) + ]; + } + + /** + * Log security event + * + * @param string $event + * @param User $user + * @param array $details + */ + public function logSecurityEvent(string $event, User $user, array $details = []): void + { + $logData = array_merge([ + 'event' => $event, + 'user_id' => $user->id, + 'email' => $user->email, + 'ip_address' => request()->ip(), + 'user_agent' => request()->userAgent(), + 'timestamp' => now()->toISOString() + ], $details); + + Log::warning("Security Event: {$event}", $logData); + + // Store in security events table (implement if needed) + // SecurityEvent::create($logData); + } + + /** + * Generate two-factor authentication secret + * + * @param User $user + * @return string + */ + public function generateTwoFactorSecret(User $user): string + { + $secret = bin2hex(random_bytes(16)); + + $this->userRepository->update($user->id, [ + 'two_factor_secret' => $secret, + 'two_factor_recovery_codes' => $this->generateRecoveryCodes() + ]); + + Log::info('Two-factor authentication enabled', [ + 'user_id' => $user->id, + 'email' => $user->email, + 'ip_address' => request()->ip() + ]); + + return $secret; + } + + /** + * Verify two-factor authentication code + * + * @param User $user + * @param string $code + * @return bool + */ + public function verifyTwoFactorCode(User $user, string $code): bool + { + if (!$user->two_factor_secret) { + return false; + } + + // Implement TOTP verification logic here + // This is a simplified version + $isValid = $this->validateTOTP($user->two_factor_secret, $code); + + if ($isValid) { + Log::info('Two-factor authentication verified', [ + 'user_id' => $user->id, + 'email' => $user->email, + 'ip_address' => request()->ip() + ]); + } else { + Log::warning('Invalid two-factor authentication code', [ + 'user_id' => $user->id, + 'email' => $user->email, + 'ip_address' => request()->ip() + ]); + } + + return $isValid; + } + + /** + * Check password strength + * + * @param User $user + * @return bool + */ + private function checkPasswordStrength(User $user): bool + { + // This is a simplified check - implement proper password strength validation + return $user->password_updated_at && + $user->password_updated_at->gt(now()->subMonths(6)); + } + + /** + * Check if password was changed recently + * + * @param User $user + * @return bool + */ + private function checkRecentPasswordChange(User $user): bool + { + return $user->password_updated_at && + $user->password_updated_at->gt(now()->subMonths(3)); + } + + /** + * Check for suspicious activity + * + * @param User $user + * @return bool + */ + private function checkSuspiciousActivity(User $user): bool + { + // Check failed login attempts + return $user->login_attempts > 2; + } + + /** + * Calculate security score + * + * @param array $checks + * @return int + */ + private function calculateSecurityScore(array $checks): int + { + $score = 0; + $totalChecks = count($checks); + + foreach ($checks as $check) { + if ($check === true) { + $score++; + } + } + + return (int) (($score / $totalChecks) * 100); + } + + /** + * Get security recommendations + * + * @param array $checks + * @return array + */ + private function getSecurityRecommendations(array $checks): array + { + $recommendations = []; + + if (!$checks['two_factor_enabled']) { + $recommendations[] = 'Enable two-factor authentication for enhanced security'; + } + + if (!$checks['email_verified']) { + $recommendations[] = 'Verify your email address'; + } + + if (!$checks['recent_password_change']) { + $recommendations[] = 'Consider changing your password regularly'; + } + + if ($checks['suspicious_activity']) { + $recommendations[] = 'Review recent login activity for suspicious behavior'; + } + + return $recommendations; + } + + /** + * Generate recovery codes + * + * @return array + */ + private function generateRecoveryCodes(): array + { + $codes = []; + for ($i = 0; $i < 10; $i++) { + $codes[] = strtoupper(bin2hex(random_bytes(4))); + } + return $codes; + } + + /** + * Validate TOTP code (simplified implementation) + * + * @param string $secret + * @param string $code + * @return bool + */ + private function validateTOTP(string $secret, string $code): bool + { + // This is a placeholder - implement proper TOTP validation + // You would typically use a library like spomky-labs/otphp + return strlen($code) === 6 && is_numeric($code); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php new file mode 100644 index 0000000..e49abeb --- /dev/null +++ b/bootstrap/app.php @@ -0,0 +1,64 @@ + + * @created 2025-08-08 + * @location Made in Germany ๐Ÿ‡ฉ๐Ÿ‡ช + */ + +/* +|-------------------------------------------------------------------------- +| Create The Application +|-------------------------------------------------------------------------- +| +| The first thing we will do is create a new Laravel application instance +| which serves as the "glue" for all the components of Laravel, and is +| the IoC container for the system binding all of the various parts. +| +*/ + +$app = new Illuminate\Foundation\Application( + $_ENV['APP_BASE_PATH'] ?? dirname(__DIR__) +); + +/* +|-------------------------------------------------------------------------- +| Bind Important Interfaces +|-------------------------------------------------------------------------- +| +| Next, we need to bind some important interfaces into the container so +| we will be able to resolve them when needed. The kernels serve the +| incoming requests to this application from both the web and CLI. +| +*/ + +$app->singleton( + Illuminate\Contracts\Http\Kernel::class, + App\Http\Kernel::class +); + +$app->singleton( + Illuminate\Contracts\Console\Kernel::class, + App\Console\Kernel::class +); + +$app->singleton( + Illuminate\Contracts\Debug\ExceptionHandler::class, + App\Exceptions\Handler::class +); + +/* +|-------------------------------------------------------------------------- +| Return The Application +|-------------------------------------------------------------------------- +| +| This script returns the application instance. The instance is given to +| the calling script so we can separate the building of the instances +| from the actual running of the application and sending responses. +| +*/ + +return $app; diff --git a/bootstrap/cache/.gitignore b/bootstrap/cache/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/bootstrap/cache/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/config/app.php b/config/app.php new file mode 100644 index 0000000..ac864d1 --- /dev/null +++ b/config/app.php @@ -0,0 +1,189 @@ + env('APP_NAME', 'Laravel'), + + /* + |-------------------------------------------------------------------------- + | Application Environment + |-------------------------------------------------------------------------- + | + | This value determines the "environment" your application is currently + | running in. This may determine how you prefer to configure various + | services the application utilizes. Set this in your ".env" file. + | + */ + + 'env' => env('APP_ENV', 'production'), + + /* + |-------------------------------------------------------------------------- + | Application Debug Mode + |-------------------------------------------------------------------------- + | + | When your application is in debug mode, detailed error messages with + | stack traces will be shown on every error that occurs within your + | application. If disabled, a simple generic error page is shown. + | + */ + + 'debug' => (bool) env('APP_DEBUG', false), + + /* + |-------------------------------------------------------------------------- + | Application URL + |-------------------------------------------------------------------------- + | + | This URL is used by the console to properly generate URLs when using + | the Artisan command line tool. You should set this to the root of + | your application so that it is used when running Artisan tasks. + | + */ + + 'url' => env('APP_URL', 'http://localhost'), + + 'asset_url' => env('ASSET_URL'), + + /* + |-------------------------------------------------------------------------- + | Application Timezone + |-------------------------------------------------------------------------- + | + | Here you may specify the default timezone for your application, which + | will be used by the PHP date and date-time functions. We have gone + | ahead and set this to a sensible default for you out of the box. + | + */ + + 'timezone' => 'UTC', + + /* + |-------------------------------------------------------------------------- + | Application Locale Configuration + |-------------------------------------------------------------------------- + | + | The application locale determines the default locale that will be used + | by the translation service provider. You are free to set this value + | to any of the locales which will be supported by the application. + | + */ + + 'locale' => 'en', + + /* + |-------------------------------------------------------------------------- + | Application Fallback Locale + |-------------------------------------------------------------------------- + | + | The fallback locale determines the locale to use when the current one + | is not available. You may change the value to correspond to any of + | the language folders that are provided through your application. + | + */ + + 'fallback_locale' => 'en', + + /* + |-------------------------------------------------------------------------- + | Faker Locale + |-------------------------------------------------------------------------- + | + | This locale will be used by the Faker PHP library when generating fake + | data for your database seeds. For example, this will be used to get + | localized telephone numbers, street address information and more. + | + */ + + 'faker_locale' => 'en_US', + + /* + |-------------------------------------------------------------------------- + | Encryption Key + |-------------------------------------------------------------------------- + | + | This key is used by the Illuminate encrypter service and should be set + | to a random, 32 character string, otherwise these encrypted strings + | will not be safe. Please do this before deploying an application! + | + */ + + 'key' => env('APP_KEY'), + + 'cipher' => 'AES-256-CBC', + + /* + |-------------------------------------------------------------------------- + | Maintenance Mode Driver + |-------------------------------------------------------------------------- + | + | These configuration options determine the driver used to determine and + | manage Laravel's "maintenance mode" status. The "cache" driver will + | allow maintenance mode to be controlled across multiple machines. + | + | Supported drivers: "file", "cache" + | + */ + + 'maintenance' => [ + 'driver' => 'file', + // 'store' => 'redis', + ], + + /* + |-------------------------------------------------------------------------- + | Autoloaded Service Providers + |-------------------------------------------------------------------------- + | + | The service providers listed here will be automatically loaded on the + | request to your application. Feel free to add your own services to + | this array to grant expanded functionality to your applications. + | + */ + + 'providers' => ServiceProvider::defaultProviders()->merge([ + /* + * Package Service Providers... + */ + + /* + * Application Service Providers... + */ + App\Providers\AppServiceProvider::class, + App\Providers\AuthServiceProvider::class, + // App\Providers\BroadcastServiceProvider::class, + App\Providers\EventServiceProvider::class, + App\Providers\RouteServiceProvider::class, + App\Providers\RepositoryServiceProvider::class, + ])->toArray(), + + /* + |-------------------------------------------------------------------------- + | Class Aliases + |-------------------------------------------------------------------------- + | + | This array of class aliases will be registered when this application + | is started. However, feel free to register as many as you wish as + | the aliases are "lazy" loaded so they don't hinder performance. + | + */ + + 'aliases' => Facade::defaultAliases()->merge([ + // 'Example' => App\Facades\Example::class, + ])->toArray(), + +]; diff --git a/config/auth.php b/config/auth.php new file mode 100644 index 0000000..9548c15 --- /dev/null +++ b/config/auth.php @@ -0,0 +1,115 @@ + [ + 'guard' => 'web', + 'passwords' => 'users', + ], + + /* + |-------------------------------------------------------------------------- + | Authentication Guards + |-------------------------------------------------------------------------- + | + | Next, you may define every authentication guard for your application. + | Of course, a great default configuration has been defined for you + | here which uses session storage and the Eloquent user provider. + | + | All authentication drivers have a user provider. This defines how the + | users are actually retrieved out of your database or other storage + | mechanisms used by this application to persist your user's data. + | + | Supported: "session" + | + */ + + 'guards' => [ + 'web' => [ + 'driver' => 'session', + 'provider' => 'users', + ], + ], + + /* + |-------------------------------------------------------------------------- + | User Providers + |-------------------------------------------------------------------------- + | + | All authentication drivers have a user provider. This defines how the + | users are actually retrieved out of your database or other storage + | mechanisms used by this application to persist your user's data. + | + | If you have multiple user tables or models you may configure multiple + | sources which represent each model / table. These sources may then + | be assigned to any extra authentication guards you have defined. + | + | Supported: "database", "eloquent" + | + */ + + 'providers' => [ + 'users' => [ + 'driver' => 'eloquent', + 'model' => App\Models\User::class, + ], + + // 'users' => [ + // 'driver' => 'database', + // 'table' => 'users', + // ], + ], + + /* + |-------------------------------------------------------------------------- + | Resetting Passwords + |-------------------------------------------------------------------------- + | + | You may specify multiple password reset configurations if you have more + | than one user table or model in the application and you want to have + | separate password reset settings based on the specific user types. + | + | The expiry time is the number of minutes that each reset token will be + | considered valid. This security feature keeps tokens short-lived so + | they have less time to be guessed. You may change this as needed. + | + | The throttle setting is the number of seconds a user must wait before + | generating more password reset tokens. This prevents the user from + | quickly generating a very large amount of password reset tokens. + | + */ + + 'passwords' => [ + 'users' => [ + 'provider' => 'users', + 'table' => 'password_reset_tokens', + 'expire' => 60, + 'throttle' => 60, + ], + ], + + /* + |-------------------------------------------------------------------------- + | Password Confirmation Timeout + |-------------------------------------------------------------------------- + | + | Here you may define the amount of seconds before a password confirmation + | times out and the user is prompted to re-enter their password via the + | confirmation screen. By default, the timeout lasts for three hours. + | + */ + + 'password_timeout' => 10800, + +]; diff --git a/config/broadcasting.php b/config/broadcasting.php new file mode 100644 index 0000000..2410485 --- /dev/null +++ b/config/broadcasting.php @@ -0,0 +1,71 @@ + env('BROADCAST_DRIVER', 'null'), + + /* + |-------------------------------------------------------------------------- + | Broadcast Connections + |-------------------------------------------------------------------------- + | + | Here you may define all of the broadcast connections that will be used + | to broadcast events to other systems or over websockets. Samples of + | each available type of connection are provided inside this array. + | + */ + + 'connections' => [ + + 'pusher' => [ + 'driver' => 'pusher', + 'key' => env('PUSHER_APP_KEY'), + 'secret' => env('PUSHER_APP_SECRET'), + 'app_id' => env('PUSHER_APP_ID'), + 'options' => [ + 'cluster' => env('PUSHER_APP_CLUSTER'), + 'host' => env('PUSHER_HOST') ?: 'api-'.env('PUSHER_APP_CLUSTER', 'mt1').'.pusher.com', + 'port' => env('PUSHER_PORT', 443), + 'scheme' => env('PUSHER_SCHEME', 'https'), + 'encrypted' => true, + 'useTLS' => env('PUSHER_SCHEME', 'https') === 'https', + ], + 'client_options' => [ + // Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html + ], + ], + + 'ably' => [ + 'driver' => 'ably', + 'key' => env('ABLY_KEY'), + ], + + 'redis' => [ + 'driver' => 'redis', + 'connection' => 'default', + ], + + 'log' => [ + 'driver' => 'log', + ], + + 'null' => [ + 'driver' => 'null', + ], + + ], + +]; diff --git a/config/cache.php b/config/cache.php new file mode 100644 index 0000000..d4171e2 --- /dev/null +++ b/config/cache.php @@ -0,0 +1,111 @@ + env('CACHE_DRIVER', 'file'), + + /* + |-------------------------------------------------------------------------- + | Cache Stores + |-------------------------------------------------------------------------- + | + | Here you may define all of the cache "stores" for your application as + | well as their drivers. You may even define multiple stores for the + | same cache driver to group types of items stored in your caches. + | + | Supported drivers: "apc", "array", "database", "file", + | "memcached", "redis", "dynamodb", "octane", "null" + | + */ + + 'stores' => [ + + 'apc' => [ + 'driver' => 'apc', + ], + + 'array' => [ + 'driver' => 'array', + 'serialize' => false, + ], + + 'database' => [ + 'driver' => 'database', + 'table' => 'cache', + 'connection' => null, + 'lock_connection' => null, + ], + + 'file' => [ + 'driver' => 'file', + 'path' => storage_path('framework/cache/data'), + 'lock_path' => storage_path('framework/cache/data'), + ], + + 'memcached' => [ + 'driver' => 'memcached', + 'persistent_id' => env('MEMCACHED_PERSISTENT_ID'), + 'sasl' => [ + env('MEMCACHED_USERNAME'), + env('MEMCACHED_PASSWORD'), + ], + 'options' => [ + // Memcached::OPT_CONNECT_TIMEOUT => 2000, + ], + 'servers' => [ + [ + 'host' => env('MEMCACHED_HOST', '127.0.0.1'), + 'port' => env('MEMCACHED_PORT', 11211), + 'weight' => 100, + ], + ], + ], + + 'redis' => [ + 'driver' => 'redis', + 'connection' => 'cache', + 'lock_connection' => 'default', + ], + + 'dynamodb' => [ + 'driver' => 'dynamodb', + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), + 'table' => env('DYNAMODB_CACHE_TABLE', 'cache'), + 'endpoint' => env('DYNAMODB_ENDPOINT'), + ], + + 'octane' => [ + 'driver' => 'octane', + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Cache Key Prefix + |-------------------------------------------------------------------------- + | + | When utilizing the APC, database, memcached, Redis, or DynamoDB cache + | stores there might be other applications using the same cache. For + | that reason, you may prefix every cache key to avoid collisions. + | + */ + + 'prefix' => env('CACHE_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_').'_cache_'), + +]; diff --git a/config/cors.php b/config/cors.php new file mode 100644 index 0000000..8a39e6d --- /dev/null +++ b/config/cors.php @@ -0,0 +1,34 @@ + ['api/*', 'sanctum/csrf-cookie'], + + 'allowed_methods' => ['*'], + + 'allowed_origins' => ['*'], + + 'allowed_origins_patterns' => [], + + 'allowed_headers' => ['*'], + + 'exposed_headers' => [], + + 'max_age' => 0, + + 'supports_credentials' => false, + +]; diff --git a/config/database.php b/config/database.php new file mode 100644 index 0000000..137ad18 --- /dev/null +++ b/config/database.php @@ -0,0 +1,151 @@ + env('DB_CONNECTION', 'mysql'), + + /* + |-------------------------------------------------------------------------- + | Database Connections + |-------------------------------------------------------------------------- + | + | Here are each of the database connections setup for your application. + | Of course, examples of configuring each database platform that is + | supported by Laravel is shown below to make development simple. + | + | + | All database work in Laravel is done through the PHP PDO facilities + | so make sure you have the driver for your particular database of + | choice installed on your machine before you begin development. + | + */ + + 'connections' => [ + + 'sqlite' => [ + 'driver' => 'sqlite', + 'url' => env('DATABASE_URL'), + 'database' => env('DB_DATABASE', database_path('database.sqlite')), + 'prefix' => '', + 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true), + ], + + 'mysql' => [ + 'driver' => 'mysql', + 'url' => env('DATABASE_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '3306'), + 'database' => env('DB_DATABASE', 'forge'), + 'username' => env('DB_USERNAME', 'forge'), + 'password' => env('DB_PASSWORD', ''), + 'unix_socket' => env('DB_SOCKET', ''), + 'charset' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'prefix' => '', + 'prefix_indexes' => true, + 'strict' => true, + 'engine' => null, + 'options' => extension_loaded('pdo_mysql') ? array_filter([ + PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), + ]) : [], + ], + + 'pgsql' => [ + 'driver' => 'pgsql', + 'url' => env('DATABASE_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '5432'), + 'database' => env('DB_DATABASE', 'forge'), + 'username' => env('DB_USERNAME', 'forge'), + 'password' => env('DB_PASSWORD', ''), + 'charset' => 'utf8', + 'prefix' => '', + 'prefix_indexes' => true, + 'search_path' => 'public', + 'sslmode' => 'prefer', + ], + + 'sqlsrv' => [ + 'driver' => 'sqlsrv', + 'url' => env('DATABASE_URL'), + 'host' => env('DB_HOST', 'localhost'), + 'port' => env('DB_PORT', '1433'), + 'database' => env('DB_DATABASE', 'forge'), + 'username' => env('DB_USERNAME', 'forge'), + 'password' => env('DB_PASSWORD', ''), + 'charset' => 'utf8', + 'prefix' => '', + 'prefix_indexes' => true, + // 'encrypt' => env('DB_ENCRYPT', 'yes'), + // 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'), + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Migration Repository Table + |-------------------------------------------------------------------------- + | + | This table keeps track of all the migrations that have already run for + | your application. Using this information, we can determine which of + | the migrations on disk haven't actually been run in the database. + | + */ + + 'migrations' => 'migrations', + + /* + |-------------------------------------------------------------------------- + | Redis Databases + |-------------------------------------------------------------------------- + | + | Redis is an open source, fast, and advanced key-value store that also + | provides a richer body of commands than a typical key-value system + | such as APC or Memcached. Laravel makes it easy to dig right in. + | + */ + + 'redis' => [ + + 'client' => env('REDIS_CLIENT', 'phpredis'), + + 'options' => [ + 'cluster' => env('REDIS_CLUSTER', 'redis'), + 'prefix' => env('REDIS_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_').'_database_'), + ], + + 'default' => [ + 'url' => env('REDIS_URL'), + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'username' => env('REDIS_USERNAME'), + 'password' => env('REDIS_PASSWORD'), + 'port' => env('REDIS_PORT', '6379'), + 'database' => env('REDIS_DB', '0'), + ], + + 'cache' => [ + 'url' => env('REDIS_URL'), + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'username' => env('REDIS_USERNAME'), + 'password' => env('REDIS_PASSWORD'), + 'port' => env('REDIS_PORT', '6379'), + 'database' => env('REDIS_CACHE_DB', '1'), + ], + + ], + +]; diff --git a/config/filesystems.php b/config/filesystems.php new file mode 100644 index 0000000..e9d9dbd --- /dev/null +++ b/config/filesystems.php @@ -0,0 +1,76 @@ + env('FILESYSTEM_DISK', 'local'), + + /* + |-------------------------------------------------------------------------- + | Filesystem Disks + |-------------------------------------------------------------------------- + | + | Here you may configure as many filesystem "disks" as you wish, and you + | may even configure multiple disks of the same driver. Defaults have + | been set up for each driver as an example of the required values. + | + | Supported Drivers: "local", "ftp", "sftp", "s3" + | + */ + + 'disks' => [ + + 'local' => [ + 'driver' => 'local', + 'root' => storage_path('app'), + 'throw' => false, + ], + + 'public' => [ + 'driver' => 'local', + 'root' => storage_path('app/public'), + 'url' => env('APP_URL').'/storage', + 'visibility' => 'public', + 'throw' => false, + ], + + 's3' => [ + 'driver' => 's3', + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION'), + 'bucket' => env('AWS_BUCKET'), + 'url' => env('AWS_URL'), + 'endpoint' => env('AWS_ENDPOINT'), + 'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false), + 'throw' => false, + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Symbolic Links + |-------------------------------------------------------------------------- + | + | Here you may configure the symbolic links that will be created when the + | `storage:link` Artisan command is executed. The array keys should be + | the locations of the links and the values should be their targets. + | + */ + + 'links' => [ + public_path('storage') => storage_path('app/public'), + ], + +]; diff --git a/config/hashing.php b/config/hashing.php new file mode 100644 index 0000000..0e8a0bb --- /dev/null +++ b/config/hashing.php @@ -0,0 +1,54 @@ + 'bcrypt', + + /* + |-------------------------------------------------------------------------- + | Bcrypt Options + |-------------------------------------------------------------------------- + | + | Here you may specify the configuration options that should be used when + | passwords are hashed using the Bcrypt algorithm. This will allow you + | to control the amount of time it takes to hash the given password. + | + */ + + 'bcrypt' => [ + 'rounds' => env('BCRYPT_ROUNDS', 12), + 'verify' => true, + ], + + /* + |-------------------------------------------------------------------------- + | Argon Options + |-------------------------------------------------------------------------- + | + | Here you may specify the configuration options that should be used when + | passwords are hashed using the Argon algorithm. These will allow you + | to control the amount of time it takes to hash the given password. + | + */ + + 'argon' => [ + 'memory' => 65536, + 'threads' => 1, + 'time' => 4, + 'verify' => true, + ], + +]; diff --git a/config/logging.php b/config/logging.php new file mode 100644 index 0000000..c44d276 --- /dev/null +++ b/config/logging.php @@ -0,0 +1,131 @@ + env('LOG_CHANNEL', 'stack'), + + /* + |-------------------------------------------------------------------------- + | Deprecations Log Channel + |-------------------------------------------------------------------------- + | + | This option controls the log channel that should be used to log warnings + | regarding deprecated PHP and library features. This allows you to get + | your application ready for upcoming major versions of dependencies. + | + */ + + 'deprecations' => [ + 'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'), + 'trace' => false, + ], + + /* + |-------------------------------------------------------------------------- + | Log Channels + |-------------------------------------------------------------------------- + | + | Here you may configure the log channels for your application. Out of + | the box, Laravel uses the Monolog PHP logging library. This gives + | you a variety of powerful log handlers / formatters to utilize. + | + | Available Drivers: "single", "daily", "slack", "syslog", + | "errorlog", "monolog", + | "custom", "stack" + | + */ + + 'channels' => [ + 'stack' => [ + 'driver' => 'stack', + 'channels' => ['single'], + 'ignore_exceptions' => false, + ], + + 'single' => [ + 'driver' => 'single', + 'path' => storage_path('logs/laravel.log'), + 'level' => env('LOG_LEVEL', 'debug'), + 'replace_placeholders' => true, + ], + + 'daily' => [ + 'driver' => 'daily', + 'path' => storage_path('logs/laravel.log'), + 'level' => env('LOG_LEVEL', 'debug'), + 'days' => 14, + 'replace_placeholders' => true, + ], + + 'slack' => [ + 'driver' => 'slack', + 'url' => env('LOG_SLACK_WEBHOOK_URL'), + 'username' => 'Laravel Log', + 'emoji' => ':boom:', + 'level' => env('LOG_LEVEL', 'critical'), + 'replace_placeholders' => true, + ], + + 'papertrail' => [ + 'driver' => 'monolog', + 'level' => env('LOG_LEVEL', 'debug'), + 'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class), + 'handler_with' => [ + 'host' => env('PAPERTRAIL_URL'), + 'port' => env('PAPERTRAIL_PORT'), + 'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'), + ], + 'processors' => [PsrLogMessageProcessor::class], + ], + + 'stderr' => [ + 'driver' => 'monolog', + 'level' => env('LOG_LEVEL', 'debug'), + 'handler' => StreamHandler::class, + 'formatter' => env('LOG_STDERR_FORMATTER'), + 'with' => [ + 'stream' => 'php://stderr', + ], + 'processors' => [PsrLogMessageProcessor::class], + ], + + 'syslog' => [ + 'driver' => 'syslog', + 'level' => env('LOG_LEVEL', 'debug'), + 'facility' => LOG_USER, + 'replace_placeholders' => true, + ], + + 'errorlog' => [ + 'driver' => 'errorlog', + 'level' => env('LOG_LEVEL', 'debug'), + 'replace_placeholders' => true, + ], + + 'null' => [ + 'driver' => 'monolog', + 'handler' => NullHandler::class, + ], + + 'emergency' => [ + 'path' => storage_path('logs/laravel.log'), + ], + ], + +]; diff --git a/config/mail.php b/config/mail.php new file mode 100644 index 0000000..e894b2e --- /dev/null +++ b/config/mail.php @@ -0,0 +1,134 @@ + env('MAIL_MAILER', 'smtp'), + + /* + |-------------------------------------------------------------------------- + | Mailer Configurations + |-------------------------------------------------------------------------- + | + | Here you may configure all of the mailers used by your application plus + | their respective settings. Several examples have been configured for + | you and you are free to add your own as your application requires. + | + | Laravel supports a variety of mail "transport" drivers to be used while + | sending an e-mail. You will specify which one you are using for your + | mailers below. You are free to add additional mailers as required. + | + | Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2", + | "postmark", "log", "array", "failover", "roundrobin" + | + */ + + 'mailers' => [ + 'smtp' => [ + 'transport' => 'smtp', + 'url' => env('MAIL_URL'), + 'host' => env('MAIL_HOST', 'smtp.mailgun.org'), + 'port' => env('MAIL_PORT', 587), + 'encryption' => env('MAIL_ENCRYPTION', 'tls'), + 'username' => env('MAIL_USERNAME'), + 'password' => env('MAIL_PASSWORD'), + 'timeout' => null, + 'local_domain' => env('MAIL_EHLO_DOMAIN'), + ], + + 'ses' => [ + 'transport' => 'ses', + ], + + 'postmark' => [ + 'transport' => 'postmark', + // 'message_stream_id' => null, + // 'client' => [ + // 'timeout' => 5, + // ], + ], + + 'mailgun' => [ + 'transport' => 'mailgun', + // 'client' => [ + // 'timeout' => 5, + // ], + ], + + 'sendmail' => [ + 'transport' => 'sendmail', + 'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'), + ], + + 'log' => [ + 'transport' => 'log', + 'channel' => env('MAIL_LOG_CHANNEL'), + ], + + 'array' => [ + 'transport' => 'array', + ], + + 'failover' => [ + 'transport' => 'failover', + 'mailers' => [ + 'smtp', + 'log', + ], + ], + + 'roundrobin' => [ + 'transport' => 'roundrobin', + 'mailers' => [ + 'ses', + 'postmark', + ], + ], + ], + + /* + |-------------------------------------------------------------------------- + | Global "From" Address + |-------------------------------------------------------------------------- + | + | You may wish for all e-mails sent by your application to be sent from + | the same address. Here, you may specify a name and address that is + | used globally for all e-mails that are sent by your application. + | + */ + + 'from' => [ + 'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'), + 'name' => env('MAIL_FROM_NAME', 'Example'), + ], + + /* + |-------------------------------------------------------------------------- + | Markdown Mail Settings + |-------------------------------------------------------------------------- + | + | If you are using Markdown based email rendering, you may configure your + | theme and component paths here, allowing you to customize the design + | of the emails. Or, you may simply stick with the Laravel defaults! + | + */ + + 'markdown' => [ + 'theme' => 'default', + + 'paths' => [ + resource_path('views/vendor/mail'), + ], + ], + +]; diff --git a/config/queue.php b/config/queue.php new file mode 100644 index 0000000..01c6b05 --- /dev/null +++ b/config/queue.php @@ -0,0 +1,109 @@ + env('QUEUE_CONNECTION', 'sync'), + + /* + |-------------------------------------------------------------------------- + | Queue Connections + |-------------------------------------------------------------------------- + | + | Here you may configure the connection information for each server that + | is used by your application. A default configuration has been added + | for each back-end shipped with Laravel. You are free to add more. + | + | Drivers: "sync", "database", "beanstalkd", "sqs", "redis", "null" + | + */ + + 'connections' => [ + + 'sync' => [ + 'driver' => 'sync', + ], + + 'database' => [ + 'driver' => 'database', + 'table' => 'jobs', + 'queue' => 'default', + 'retry_after' => 90, + 'after_commit' => false, + ], + + 'beanstalkd' => [ + 'driver' => 'beanstalkd', + 'host' => 'localhost', + 'queue' => 'default', + 'retry_after' => 90, + 'block_for' => 0, + 'after_commit' => false, + ], + + 'sqs' => [ + 'driver' => 'sqs', + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'), + 'queue' => env('SQS_QUEUE', 'default'), + 'suffix' => env('SQS_SUFFIX'), + 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), + 'after_commit' => false, + ], + + 'redis' => [ + 'driver' => 'redis', + 'connection' => 'default', + 'queue' => env('REDIS_QUEUE', 'default'), + 'retry_after' => 90, + 'block_for' => null, + 'after_commit' => false, + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Job Batching + |-------------------------------------------------------------------------- + | + | The following options configure the database and table that store job + | batching information. These options can be updated to any database + | connection and table which has been defined by your application. + | + */ + + 'batching' => [ + 'database' => env('DB_CONNECTION', 'mysql'), + 'table' => 'job_batches', + ], + + /* + |-------------------------------------------------------------------------- + | Failed Queue Jobs + |-------------------------------------------------------------------------- + | + | These options configure the behavior of failed queue job logging so you + | can control which database and table are used to store the jobs that + | have failed. You may change them to any database / table you wish. + | + */ + + 'failed' => [ + 'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'), + 'database' => env('DB_CONNECTION', 'mysql'), + 'table' => 'failed_jobs', + ], + +]; diff --git a/config/sanctum.php b/config/sanctum.php new file mode 100644 index 0000000..35d75b3 --- /dev/null +++ b/config/sanctum.php @@ -0,0 +1,83 @@ + explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf( + '%s%s', + 'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1', + Sanctum::currentApplicationUrlWithPort() + ))), + + /* + |-------------------------------------------------------------------------- + | Sanctum Guards + |-------------------------------------------------------------------------- + | + | This array contains the authentication guards that will be checked when + | Sanctum is trying to authenticate a request. If none of these guards + | are able to authenticate the request, Sanctum will use the bearer + | token that's present on an incoming request for authentication. + | + */ + + 'guard' => ['web'], + + /* + |-------------------------------------------------------------------------- + | Expiration Minutes + |-------------------------------------------------------------------------- + | + | This value controls the number of minutes until an issued token will be + | considered expired. This will override any values set in the token's + | "expires_at" attribute, but first-party sessions are not affected. + | + */ + + 'expiration' => null, + + /* + |-------------------------------------------------------------------------- + | Token Prefix + |-------------------------------------------------------------------------- + | + | Sanctum can prefix new tokens in order to take advantage of numerous + | security scanning initiatives maintained by open source platforms + | that notify developers if they commit tokens into repositories. + | + | See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning + | + */ + + 'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''), + + /* + |-------------------------------------------------------------------------- + | Sanctum Middleware + |-------------------------------------------------------------------------- + | + | When authenticating your first-party SPA with Sanctum you may need to + | customize some of the middleware Sanctum uses while processing the + | request. You may change the middleware listed below as required. + | + */ + + 'middleware' => [ + 'authenticate_session' => Laravel\Sanctum\Http\Middleware\AuthenticateSession::class, + 'encrypt_cookies' => App\Http\Middleware\EncryptCookies::class, + 'verify_csrf_token' => App\Http\Middleware\VerifyCsrfToken::class, + ], + +]; diff --git a/config/services.php b/config/services.php new file mode 100644 index 0000000..0ace530 --- /dev/null +++ b/config/services.php @@ -0,0 +1,34 @@ + [ + 'domain' => env('MAILGUN_DOMAIN'), + 'secret' => env('MAILGUN_SECRET'), + 'endpoint' => env('MAILGUN_ENDPOINT', 'api.mailgun.net'), + 'scheme' => 'https', + ], + + 'postmark' => [ + 'token' => env('POSTMARK_TOKEN'), + ], + + 'ses' => [ + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), + ], + +]; diff --git a/config/session.php b/config/session.php new file mode 100644 index 0000000..e738cb3 --- /dev/null +++ b/config/session.php @@ -0,0 +1,214 @@ + env('SESSION_DRIVER', 'file'), + + /* + |-------------------------------------------------------------------------- + | Session Lifetime + |-------------------------------------------------------------------------- + | + | Here you may specify the number of minutes that you wish the session + | to be allowed to remain idle before it expires. If you want them + | to immediately expire on the browser closing, set that option. + | + */ + + 'lifetime' => env('SESSION_LIFETIME', 120), + + 'expire_on_close' => false, + + /* + |-------------------------------------------------------------------------- + | Session Encryption + |-------------------------------------------------------------------------- + | + | This option allows you to easily specify that all of your session data + | should be encrypted before it is stored. All encryption will be run + | automatically by Laravel and you can use the Session like normal. + | + */ + + 'encrypt' => false, + + /* + |-------------------------------------------------------------------------- + | Session File Location + |-------------------------------------------------------------------------- + | + | When using the native session driver, we need a location where session + | files may be stored. A default has been set for you but a different + | location may be specified. This is only needed for file sessions. + | + */ + + 'files' => storage_path('framework/sessions'), + + /* + |-------------------------------------------------------------------------- + | Session Database Connection + |-------------------------------------------------------------------------- + | + | When using the "database" or "redis" session drivers, you may specify a + | connection that should be used to manage these sessions. This should + | correspond to a connection in your database configuration options. + | + */ + + 'connection' => env('SESSION_CONNECTION'), + + /* + |-------------------------------------------------------------------------- + | Session Database Table + |-------------------------------------------------------------------------- + | + | When using the "database" session driver, you may specify the table we + | should use to manage the sessions. Of course, a sensible default is + | provided for you; however, you are free to change this as needed. + | + */ + + 'table' => 'sessions', + + /* + |-------------------------------------------------------------------------- + | Session Cache Store + |-------------------------------------------------------------------------- + | + | While using one of the framework's cache driven session backends you may + | list a cache store that should be used for these sessions. This value + | must match with one of the application's configured cache "stores". + | + | Affects: "apc", "dynamodb", "memcached", "redis" + | + */ + + 'store' => env('SESSION_STORE'), + + /* + |-------------------------------------------------------------------------- + | Session Sweeping Lottery + |-------------------------------------------------------------------------- + | + | Some session drivers must manually sweep their storage location to get + | rid of old sessions from storage. Here are the chances that it will + | happen on a given request. By default, the odds are 2 out of 100. + | + */ + + 'lottery' => [2, 100], + + /* + |-------------------------------------------------------------------------- + | Session Cookie Name + |-------------------------------------------------------------------------- + | + | Here you may change the name of the cookie used to identify a session + | instance by ID. The name specified here will get used every time a + | new session cookie is created by the framework for every driver. + | + */ + + 'cookie' => env( + 'SESSION_COOKIE', + Str::slug(env('APP_NAME', 'laravel'), '_').'_session' + ), + + /* + |-------------------------------------------------------------------------- + | Session Cookie Path + |-------------------------------------------------------------------------- + | + | The session cookie path determines the path for which the cookie will + | be regarded as available. Typically, this will be the root path of + | your application but you are free to change this when necessary. + | + */ + + 'path' => '/', + + /* + |-------------------------------------------------------------------------- + | Session Cookie Domain + |-------------------------------------------------------------------------- + | + | Here you may change the domain of the cookie used to identify a session + | in your application. This will determine which domains the cookie is + | available to in your application. A sensible default has been set. + | + */ + + 'domain' => env('SESSION_DOMAIN'), + + /* + |-------------------------------------------------------------------------- + | HTTPS Only Cookies + |-------------------------------------------------------------------------- + | + | By setting this option to true, session cookies will only be sent back + | to the server if the browser has a HTTPS connection. This will keep + | the cookie from being sent to you when it can't be done securely. + | + */ + + 'secure' => env('SESSION_SECURE_COOKIE'), + + /* + |-------------------------------------------------------------------------- + | HTTP Access Only + |-------------------------------------------------------------------------- + | + | Setting this value to true will prevent JavaScript from accessing the + | value of the cookie and the cookie will only be accessible through + | the HTTP protocol. You are free to modify this option if needed. + | + */ + + 'http_only' => true, + + /* + |-------------------------------------------------------------------------- + | Same-Site Cookies + |-------------------------------------------------------------------------- + | + | This option determines how your cookies behave when cross-site requests + | take place, and can be used to mitigate CSRF attacks. By default, we + | will set this value to "lax" since this is a secure default value. + | + | Supported: "lax", "strict", "none", null + | + */ + + 'same_site' => 'lax', + + /* + |-------------------------------------------------------------------------- + | Partitioned Cookies + |-------------------------------------------------------------------------- + | + | Setting this value to true will tie the cookie to the top-level site for + | a cross-site context. Partitioned cookies are accepted by the browser + | when flagged "secure" and the Same-Site attribute is set to "none". + | + */ + + 'partitioned' => false, + +]; diff --git a/config/view.php b/config/view.php new file mode 100644 index 0000000..22b8a18 --- /dev/null +++ b/config/view.php @@ -0,0 +1,36 @@ + [ + resource_path('views'), + ], + + /* + |-------------------------------------------------------------------------- + | Compiled View Path + |-------------------------------------------------------------------------- + | + | This option determines where all the compiled Blade templates will be + | stored for your application. Typically, this is within the storage + | directory. However, as usual, you are free to change this value. + | + */ + + 'compiled' => env( + 'VIEW_COMPILED_PATH', + realpath(storage_path('framework/views')) + ), + +]; diff --git a/database/.gitignore b/database/.gitignore new file mode 100644 index 0000000..9b19b93 --- /dev/null +++ b/database/.gitignore @@ -0,0 +1 @@ +*.sqlite* diff --git a/database/factories/ResumeFactory.php b/database/factories/ResumeFactory.php new file mode 100644 index 0000000..4ee271d --- /dev/null +++ b/database/factories/ResumeFactory.php @@ -0,0 +1,295 @@ + + */ +class ResumeFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $templates = ['professional', 'modern', 'executive', 'minimal', 'technical']; + $statuses = ['draft', 'published', 'archived']; + + return [ + 'user_id' => User::factory(), + 'title' => fake()->sentence(3), + 'description' => fake()->paragraph(), + 'template' => fake()->randomElement($templates), + 'content' => $this->generateSampleContent(), + 'settings' => $this->generateSettings(), + 'status' => fake()->randomElement($statuses), + 'is_public' => fake()->boolean(30), // 30% chance of being public + 'public_url' => fake()->optional(0.3)->slug(), + 'published_at' => fake()->optional(0.5)->dateTimeBetween('-6 months', 'now'), + 'view_count' => fake()->numberBetween(0, 1000), + 'download_count' => fake()->numberBetween(0, 100), + 'last_viewed_at' => fake()->optional(0.7)->dateTimeBetween('-1 month', 'now'), + 'last_downloaded_at' => fake()->optional(0.4)->dateTimeBetween('-1 month', 'now'), + 'completion_percentage' => fake()->numberBetween(25, 100), + 'completion_sections' => $this->generateCompletionSections(), + 'analytics' => [ + 'total_views' => fake()->numberBetween(0, 1000), + 'unique_visitors' => fake()->numberBetween(0, 500), + 'avg_time_on_page' => fake()->numberBetween(30, 300), + 'bounce_rate' => fake()->randomFloat(2, 0, 1), + ], + 'allow_comments' => fake()->boolean(20), + 'expires_at' => fake()->optional(0.2)->dateTimeBetween('now', '+1 year'), + ]; + } + + /** + * Generate sample resume content. + */ + private function generateSampleContent(): array + { + return [ + 'personal_info' => [ + 'full_name' => fake()->name(), + 'title' => fake()->jobTitle(), + 'email' => fake()->email(), + 'phone' => fake()->phoneNumber(), + 'location' => fake()->city() . ', ' . fake()->country(), + 'website' => fake()->optional()->url(), + 'linkedin' => fake()->optional()->url(), + 'github' => fake()->optional()->url(), + ], + 'summary' => fake()->paragraph(4), + 'experience' => $this->generateExperience(), + 'education' => $this->generateEducation(), + 'skills' => $this->generateSkills(), + 'projects' => fake()->optional(0.7)->passthrough($this->generateProjects()), + 'certifications' => fake()->optional(0.5)->passthrough($this->generateCertifications()), + 'languages' => fake()->optional(0.6)->passthrough($this->generateLanguages()), + ]; + } + + /** + * Generate work experience. + */ + private function generateExperience(): array + { + $experiences = []; + $count = fake()->numberBetween(1, 4); + + for ($i = 0; $i < $count; $i++) { + $startDate = fake()->dateTimeBetween('-10 years', '-1 year'); + $isCurrent = $i === 0 && fake()->boolean(40); + + $experiences[] = [ + 'company' => fake()->company(), + 'position' => fake()->jobTitle(), + 'location' => fake()->city() . ', ' . fake()->country(), + 'start_date' => $startDate->format('Y-m-d'), + 'end_date' => $isCurrent ? null : fake()->dateTimeBetween($startDate, 'now')->format('Y-m-d'), + 'current' => $isCurrent, + 'description' => fake()->paragraph(2), + 'achievements' => fake()->sentences(fake()->numberBetween(2, 5)), + ]; + } + + return $experiences; + } + + /** + * Generate education. + */ + private function generateEducation(): array + { + $education = []; + $count = fake()->numberBetween(1, 3); + + for ($i = 0; $i < $count; $i++) { + $startDate = fake()->dateTimeBetween('-15 years', '-2 years'); + $endDate = fake()->dateTimeBetween($startDate, '-1 year'); + + $education[] = [ + 'institution' => fake()->company() . ' University', + 'degree' => fake()->randomElement(['Bachelor of Science', 'Master of Science', 'Bachelor of Arts', 'Master of Arts']), + 'field' => fake()->randomElement(['Computer Science', 'Information Technology', 'Software Engineering', 'Business Administration']), + 'start_date' => $startDate->format('Y-m-d'), + 'end_date' => $endDate->format('Y-m-d'), + 'gpa' => fake()->optional(0.7)->randomFloat(1, 2.5, 4.0), + 'description' => fake()->optional(0.5)->sentence(), + ]; + } + + return $education; + } + + /** + * Generate skills. + */ + private function generateSkills(): array + { + $skillCategories = [ + 'Programming Languages' => ['PHP', 'JavaScript', 'Python', 'Java', 'TypeScript'], + 'Frontend' => ['React', 'Angular', 'Vue.js', 'HTML5', 'CSS3', 'Bootstrap'], + 'Backend' => ['Laravel', 'Node.js', 'Express', 'Django', 'Spring Boot'], + 'Databases' => ['MySQL', 'PostgreSQL', 'MongoDB', 'Redis'], + 'Tools & Technologies' => ['Git', 'Docker', 'AWS', 'Linux', 'Jenkins'], + ]; + + $skills = []; + $numCategories = fake()->numberBetween(2, 5); + $categories = fake()->randomElements(array_keys($skillCategories), $numCategories); + + foreach ($categories as $category) { + $skills[$category] = fake()->randomElements($skillCategories[$category], fake()->numberBetween(2, 4)); + } + + return $skills; + } + + /** + * Generate projects. + */ + private function generateProjects(): array + { + $projects = []; + $count = fake()->numberBetween(1, 4); + + for ($i = 0; $i < $count; $i++) { + $projects[] = [ + 'name' => fake()->catchPhrase(), + 'description' => fake()->paragraph(), + 'technologies' => fake()->randomElements(['Laravel', 'React', 'Vue.js', 'MySQL', 'Docker', 'AWS'], fake()->numberBetween(2, 4)), + 'url' => fake()->optional(0.6)->url(), + 'achievements' => fake()->sentences(fake()->numberBetween(2, 4)), + ]; + } + + return $projects; + } + + /** + * Generate certifications. + */ + private function generateCertifications(): array + { + $certifications = []; + $count = fake()->numberBetween(1, 3); + + for ($i = 0; $i < $count; $i++) { + $certifications[] = [ + 'name' => fake()->sentence(4), + 'issuer' => fake()->company(), + 'date' => fake()->dateTimeBetween('-5 years', 'now')->format('Y-m-d'), + 'credential_id' => fake()->optional(0.7)->bothify('??###-####'), + 'url' => fake()->optional(0.5)->url(), + ]; + } + + return $certifications; + } + + /** + * Generate languages. + */ + private function generateLanguages(): array + { + $languages = ['English', 'Spanish', 'French', 'German', 'Italian', 'Portuguese', 'Chinese', 'Japanese']; + $levels = ['Native', 'Fluent', 'Advanced', 'Intermediate', 'Basic']; + + $result = []; + $count = fake()->numberBetween(1, 4); + $selectedLanguages = fake()->randomElements($languages, $count); + + foreach ($selectedLanguages as $language) { + $result[] = [ + 'language' => $language, + 'level' => fake()->randomElement($levels), + ]; + } + + return $result; + } + + /** + * Generate settings. + */ + private function generateSettings(): array + { + return [ + 'color_scheme' => fake()->randomElement(['blue', 'green', 'red', 'purple', 'orange']), + 'font_family' => fake()->randomElement(['Arial', 'Times New Roman', 'Calibri', 'Helvetica']), + 'font_size' => fake()->numberBetween(9, 12), + 'line_spacing' => fake()->randomFloat(1, 1.0, 1.5), + 'margin' => fake()->numberBetween(15, 25), + 'show_photo' => fake()->boolean(30), + 'section_order' => ['summary', 'experience', 'education', 'skills', 'projects', 'certifications', 'languages'], + ]; + } + + /** + * Generate completion sections. + */ + private function generateCompletionSections(): array + { + $sections = ['personal_info', 'summary', 'experience', 'education', 'skills', 'projects', 'certifications', 'languages']; + $completed = []; + + foreach ($sections as $section) { + $completed[$section] = fake()->boolean(80); // 80% chance of being completed + } + + return $completed; + } + + /** + * Indicate that the resume is published. + */ + public function published() + { + return $this->state(function (array $attributes) { + return [ + 'status' => 'published', + 'published_at' => fake()->dateTimeBetween('-6 months', 'now'), + 'is_public' => true, + 'public_url' => fake()->slug(), + 'completion_percentage' => fake()->numberBetween(80, 100), + ]; + }); + } + + /** + * Indicate that the resume is a draft. + */ + public function draft() + { + return $this->state(function (array $attributes) { + return [ + 'status' => 'draft', + 'published_at' => null, + 'is_public' => false, + 'public_url' => null, + 'completion_percentage' => fake()->numberBetween(25, 75), + ]; + }); + } + + /** + * Indicate that the resume is popular. + */ + public function popular() + { + return $this->state(function (array $attributes) { + return [ + 'view_count' => fake()->numberBetween(500, 5000), + 'download_count' => fake()->numberBetween(50, 500), + 'is_public' => true, + 'status' => 'published', + ]; + }); + } +} diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php new file mode 100644 index 0000000..78a4b46 --- /dev/null +++ b/database/factories/UserFactory.php @@ -0,0 +1,97 @@ + + */ +class UserFactory extends Factory +{ + /** + * The current password being used by the factory. + */ + protected static ?string $password; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'first_name' => fake()->firstName(), + 'last_name' => fake()->lastName(), + 'email' => fake()->unique()->safeEmail(), + 'email_verified_at' => now(), + 'password' => static::$password ??= Hash::make('password'), + 'phone' => fake()->phoneNumber(), + 'date_of_birth' => fake()->date('Y-m-d', '2000-01-01'), + 'gender' => fake()->randomElement(['male', 'female', 'other', 'prefer_not_to_say']), + 'bio' => fake()->paragraph(3), + 'website' => fake()->url(), + 'linkedin' => 'https://linkedin.com/in/' . fake()->userName(), + 'github' => 'https://github.com/' . fake()->userName(), + 'twitter' => 'https://twitter.com/' . fake()->userName(), + 'preferences' => [ + 'theme' => fake()->randomElement(['professional', 'modern', 'minimal']), + 'language' => fake()->randomElement(['en', 'de', 'es', 'fr']), + 'notifications' => fake()->boolean(), + 'marketing_emails' => fake()->boolean(), + ], + 'newsletter_subscribed' => fake()->boolean(), + 'last_login_at' => fake()->optional()->dateTimeBetween('-1 month', 'now'), + 'last_login_ip' => fake()->ipv4(), + 'status' => fake()->randomElement(['active', 'inactive']), + 'locale' => fake()->randomElement(['en', 'de', 'es', 'fr']), + 'timezone' => fake()->timezone(), + 'profile_completed_at' => fake()->optional(0.8)->dateTimeBetween('-1 month', 'now'), + 'remember_token' => Str::random(10), + ]; + } + + /** + * Indicate that the model's email address should be unverified. + */ + public function unverified() + { + return $this->state(function (array $attributes) { + return [ + 'email_verified_at' => null, + ]; + }); + } + + /** + * Indicate that the user is suspended. + */ + public function suspended() + { + return $this->state(function (array $attributes) { + return [ + 'status' => 'suspended', + 'suspended_at' => now(), + 'suspension_reason' => fake()->sentence(), + ]; + }); + } + + /** + * Indicate that the user has incomplete profile. + */ + public function incompleteProfile() + { + return $this->state(function (array $attributes) { + return [ + 'profile_completed_at' => null, + 'bio' => null, + 'website' => null, + 'phone' => null, + ]; + }); + } +} diff --git a/database/migrations/2024_01_01_000000_create_users_table.php b/database/migrations/2024_01_01_000000_create_users_table.php new file mode 100644 index 0000000..6d35ce4 --- /dev/null +++ b/database/migrations/2024_01_01_000000_create_users_table.php @@ -0,0 +1,63 @@ +id(); + $table->string('first_name'); + $table->string('last_name'); + $table->string('email')->unique(); + $table->timestamp('email_verified_at')->nullable(); + $table->string('password'); + $table->string('phone')->nullable(); + $table->date('date_of_birth')->nullable(); + $table->enum('gender', ['male', 'female', 'other', 'prefer_not_to_say'])->nullable(); + $table->string('avatar')->nullable(); + $table->text('bio')->nullable(); + $table->string('website')->nullable(); + $table->string('linkedin')->nullable(); + $table->string('github')->nullable(); + $table->string('twitter')->nullable(); + $table->json('preferences')->nullable(); // User preferences as JSON + $table->boolean('newsletter_subscribed')->default(false); + $table->timestamp('last_login_at')->nullable(); + $table->string('last_login_ip')->nullable(); + $table->enum('status', ['active', 'inactive', 'suspended'])->default('active'); + $table->timestamp('suspended_at')->nullable(); + $table->string('suspension_reason')->nullable(); + $table->integer('login_attempts')->default(0); + $table->timestamp('locked_until')->nullable(); + $table->string('locale', 10)->default('en'); + $table->string('timezone', 50)->default('UTC'); + $table->boolean('two_factor_enabled')->default(false); + $table->string('two_factor_secret')->nullable(); + $table->json('two_factor_recovery_codes')->nullable(); + $table->timestamp('profile_completed_at')->nullable(); + $table->rememberToken(); + $table->timestamps(); + + // Indexes for performance + $table->index(['email', 'status']); + $table->index(['last_login_at']); + $table->index(['created_at']); + $table->index(['status']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('users'); + } +}; diff --git a/database/migrations/2024_01_01_000001_create_password_reset_tokens_table.php b/database/migrations/2024_01_01_000001_create_password_reset_tokens_table.php new file mode 100644 index 0000000..b2cab2f --- /dev/null +++ b/database/migrations/2024_01_01_000001_create_password_reset_tokens_table.php @@ -0,0 +1,30 @@ +string('email')->primary(); + $table->string('token'); + $table->timestamp('created_at')->nullable(); + + $table->index(['email', 'token']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('password_reset_tokens'); + } +}; diff --git a/database/migrations/2024_01_01_000002_create_failed_jobs_table.php b/database/migrations/2024_01_01_000002_create_failed_jobs_table.php new file mode 100644 index 0000000..6086a9b --- /dev/null +++ b/database/migrations/2024_01_01_000002_create_failed_jobs_table.php @@ -0,0 +1,35 @@ +id(); + $table->string('uuid')->unique(); + $table->text('connection'); + $table->string('queue', 191); // Changed from text to string with length + $table->longText('payload'); + $table->longText('exception'); + $table->timestamp('failed_at')->useCurrent(); + + $table->index(['failed_at']); + $table->index(['queue']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('failed_jobs'); + } +}; diff --git a/database/migrations/2024_01_01_000004_create_resumes_table.php b/database/migrations/2024_01_01_000004_create_resumes_table.php new file mode 100644 index 0000000..436bc2f --- /dev/null +++ b/database/migrations/2024_01_01_000004_create_resumes_table.php @@ -0,0 +1,64 @@ +id(); + $table->foreignId('user_id')->constrained()->onDelete('cascade'); + $table->string('title'); + $table->text('description')->nullable(); + $table->enum('template', ['professional', 'modern', 'executive', 'minimal', 'technical'])->default('professional'); + $table->json('content')->nullable(); // Resume content as JSON + $table->json('settings')->nullable(); // Template settings as JSON + $table->enum('status', ['draft', 'published', 'archived'])->default('draft'); + $table->boolean('is_public')->default(false); + $table->string('public_url')->nullable()->unique(); + $table->timestamp('published_at')->nullable(); + $table->integer('view_count')->default(0); + $table->integer('download_count')->default(0); + $table->timestamp('last_viewed_at')->nullable(); + $table->timestamp('last_downloaded_at')->nullable(); + $table->integer('completion_percentage')->default(0); + $table->json('completion_sections')->nullable(); // Which sections are complete + $table->string('pdf_path')->nullable(); // Path to generated PDF + $table->timestamp('pdf_generated_at')->nullable(); + $table->integer('pdf_version')->default(1); + $table->json('analytics')->nullable(); // Analytics data + $table->json('feedback')->nullable(); // User feedback/notes + $table->boolean('allow_comments')->default(false); + $table->string('password')->nullable(); // Password protection + $table->timestamp('expires_at')->nullable(); // Public link expiration + $table->json('share_settings')->nullable(); // Sharing permissions + $table->string('slug')->nullable(); // SEO-friendly URL + $table->json('seo_meta')->nullable(); // SEO metadata + $table->timestamps(); + $table->softDeletes(); // Soft delete for data recovery + + // Indexes for performance + $table->index(['user_id', 'status']); + $table->index(['is_public', 'published_at']); + $table->index(['template']); + $table->index(['created_at']); + $table->index(['view_count']); + $table->index(['slug']); + $table->index(['public_url']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('resumes'); + } +}; diff --git a/database/migrations/2024_01_01_000005_create_resume_views_table.php b/database/migrations/2024_01_01_000005_create_resume_views_table.php new file mode 100644 index 0000000..120f2ff --- /dev/null +++ b/database/migrations/2024_01_01_000005_create_resume_views_table.php @@ -0,0 +1,44 @@ +id(); + $table->foreignId('resume_id')->constrained()->onDelete('cascade'); + $table->string('ip_address'); + $table->string('user_agent')->nullable(); + $table->string('referrer')->nullable(); + $table->string('country')->nullable(); + $table->string('city')->nullable(); + $table->json('browser_info')->nullable(); // Browser details + $table->integer('duration')->nullable(); // Time spent viewing (seconds) + $table->boolean('is_download')->default(false); + $table->enum('format', ['web', 'pdf', 'docx'])->default('web'); + $table->timestamp('viewed_at'); + $table->timestamps(); + + // Indexes for analytics + $table->index(['resume_id', 'viewed_at']); + $table->index(['ip_address']); + $table->index(['country']); + $table->index(['is_download']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('resume_views'); + } +}; diff --git a/database/migrations/2024_01_01_000006_create_activity_logs_table.php b/database/migrations/2024_01_01_000006_create_activity_logs_table.php new file mode 100644 index 0000000..011183f --- /dev/null +++ b/database/migrations/2024_01_01_000006_create_activity_logs_table.php @@ -0,0 +1,40 @@ +id(); + $table->foreignId('user_id')->nullable()->constrained()->onDelete('set null'); + $table->string('event'); // login, logout, resume_created, etc. + $table->string('description'); + $table->json('properties')->nullable(); // Additional event data + $table->string('ip_address')->nullable(); + $table->string('user_agent')->nullable(); + $table->morphs('subject'); // Polymorphic relation to any model + $table->timestamps(); + + // Indexes for querying + $table->index(['user_id', 'created_at']); + $table->index(['event']); + // Note: morphs() already creates an index for subject_type and subject_id + $table->index(['created_at']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('activity_logs'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php new file mode 100644 index 0000000..7a40fb7 --- /dev/null +++ b/database/seeders/DatabaseSeeder.php @@ -0,0 +1,20 @@ +call([ + UserSeeder::class, + ResumeSeeder::class, + ]); + } +} diff --git a/database/seeders/ResumeSeeder.php b/database/seeders/ResumeSeeder.php new file mode 100644 index 0000000..a5307e1 --- /dev/null +++ b/database/seeders/ResumeSeeder.php @@ -0,0 +1,242 @@ +first(); + $demo = User::where('email', 'demo@example.com')->first(); + + if ($admin) { + // Create admin's professional resume + Resume::create([ + 'user_id' => $admin->id, + 'title' => 'Senior Full-Stack Developer Resume', + 'description' => 'Professional resume showcasing expertise in Laravel, Angular, and enterprise software development.', + 'template' => 'executive', + 'content' => [ + 'personal_info' => [ + 'full_name' => 'David Valera Melendez', + 'title' => 'Senior Full-Stack Developer', + 'email' => 'david@valera-melendez.de', + 'phone' => '+49 123 456 7890', + 'location' => 'Germany', + 'website' => 'https://valera-melendez.de', + 'linkedin' => 'https://linkedin.com/in/david-valera-melendez', + 'github' => 'https://github.com/davidvalera', + ], + 'summary' => 'Experienced Senior Full-Stack Developer with 8+ years of expertise in enterprise web applications. Specialized in Laravel, Angular, and modern development practices. Proven track record of delivering scalable solutions for international clients.', + 'experience' => [ + [ + 'company' => 'Tech Solutions GmbH', + 'position' => 'Senior Full-Stack Developer', + 'location' => 'Berlin, Germany', + 'start_date' => '2020-01-01', + 'end_date' => null, + 'current' => true, + 'description' => 'Lead development of enterprise web applications using Laravel, Angular, and microservices architecture. Mentor junior developers and establish best practices.', + 'achievements' => [ + 'Architected and developed 5+ enterprise applications serving 100K+ users', + 'Reduced application load time by 60% through optimization techniques', + 'Led team of 6 developers on critical client projects', + 'Implemented CI/CD pipelines reducing deployment time by 80%' + ] + ], + [ + 'company' => 'Digital Innovation Ltd', + 'position' => 'Full-Stack Developer', + 'location' => 'Hamburg, Germany', + 'start_date' => '2018-06-01', + 'end_date' => '2019-12-31', + 'current' => false, + 'description' => 'Developed responsive web applications and REST APIs for various clients in e-commerce and fintech sectors.', + 'achievements' => [ + 'Built e-commerce platform handling โ‚ฌ2M+ monthly transactions', + 'Developed fintech dashboard with real-time analytics', + 'Improved code quality by implementing automated testing' + ] + ] + ], + 'education' => [ + [ + 'institution' => 'Technical University of Berlin', + 'degree' => 'Master of Science in Computer Science', + 'field' => 'Software Engineering', + 'start_date' => '2014-09-01', + 'end_date' => '2016-07-31', + 'gpa' => '1.2', + 'description' => 'Specialized in software architecture, distributed systems, and web technologies.' + ], + [ + 'institution' => 'University of Applied Sciences', + 'degree' => 'Bachelor of Science in Information Technology', + 'field' => 'Web Development', + 'start_date' => '2011-09-01', + 'end_date' => '2014-07-31', + 'gpa' => '1.4', + 'description' => 'Foundation in programming, databases, and web development technologies.' + ] + ], + 'skills' => [ + 'Backend Development' => ['Laravel', 'PHP', 'Node.js', 'Python', 'REST APIs', 'GraphQL'], + 'Frontend Development' => ['Angular', 'TypeScript', 'JavaScript', 'HTML5', 'CSS3', 'Bootstrap', 'Sass'], + 'Databases' => ['MySQL', 'PostgreSQL', 'MongoDB', 'Redis'], + 'DevOps & Tools' => ['Docker', 'AWS', 'Git', 'CI/CD', 'Linux', 'Nginx'], + 'Methodologies' => ['Agile', 'Scrum', 'TDD', 'Clean Code', 'Design Patterns'] + ], + 'projects' => [ + [ + 'name' => 'Enterprise Resume Builder', + 'description' => 'Professional resume builder application with Laravel backend and Angular frontend.', + 'technologies' => ['Laravel', 'Angular', 'Bootstrap', 'MySQL'], + 'url' => 'https://resume.valera-melendez.de', + 'achievements' => [ + 'Built scalable architecture supporting 10K+ users', + 'Implemented real-time collaboration features', + 'Created PDF generation with custom templates' + ] + ], + [ + 'name' => 'E-commerce Platform', + 'description' => 'Full-featured e-commerce solution with inventory management and payment processing.', + 'technologies' => ['Laravel', 'Vue.js', 'PostgreSQL', 'Stripe'], + 'achievements' => [ + 'Processed โ‚ฌ5M+ in transactions', + 'Integrated multiple payment gateways', + 'Built admin dashboard with analytics' + ] + ] + ], + 'certifications' => [ + [ + 'name' => 'AWS Certified Solutions Architect', + 'issuer' => 'Amazon Web Services', + 'date' => '2023-03-15', + 'credential_id' => 'AWS-SA-12345', + 'url' => 'https://aws.amazon.com/certification/' + ], + [ + 'name' => 'Laravel Certified Developer', + 'issuer' => 'Laravel', + 'date' => '2022-08-20', + 'credential_id' => 'LRV-DEV-67890' + ] + ], + 'languages' => [ + ['language' => 'German', 'level' => 'Native'], + ['language' => 'English', 'level' => 'Fluent'], + ['language' => 'Spanish', 'level' => 'Intermediate'], + ] + ], + 'settings' => [ + 'color_scheme' => 'blue', + 'font_family' => 'Arial', + 'font_size' => 11, + 'line_spacing' => 1.2, + 'margin' => 20, + 'show_photo' => false, + 'section_order' => ['summary', 'experience', 'education', 'skills', 'projects', 'certifications', 'languages'] + ], + 'status' => 'published', + 'is_public' => true, + 'public_url' => 'david-valera-melendez-senior-developer', + 'published_at' => now(), + 'completion_percentage' => 100, + 'completion_sections' => [ + 'personal_info' => true, + 'summary' => true, + 'experience' => true, + 'education' => true, + 'skills' => true, + 'projects' => true, + 'certifications' => true, + 'languages' => true + ] + ]); + } + + if ($demo) { + // Create demo user's resume + Resume::create([ + 'user_id' => $demo->id, + 'title' => 'My Professional Resume', + 'description' => 'Demo resume for testing the application features.', + 'template' => 'modern', + 'content' => [ + 'personal_info' => [ + 'full_name' => 'Demo User', + 'title' => 'Software Developer', + 'email' => 'demo@example.com', + 'phone' => '+1 555 123 4567', + 'location' => 'New York, USA', + ], + 'summary' => 'Passionate software developer with experience in web development and modern frameworks.', + 'experience' => [ + [ + 'company' => 'Example Corp', + 'position' => 'Junior Developer', + 'location' => 'New York, USA', + 'start_date' => '2022-01-01', + 'end_date' => null, + 'current' => true, + 'description' => 'Developing web applications and learning new technologies.', + 'achievements' => [ + 'Built responsive web interfaces', + 'Collaborated with senior developers', + 'Participated in code reviews' + ] + ] + ], + 'education' => [ + [ + 'institution' => 'State University', + 'degree' => 'Bachelor of Science', + 'field' => 'Computer Science', + 'start_date' => '2018-09-01', + 'end_date' => '2022-05-31', + 'gpa' => '3.8' + ] + ], + 'skills' => [ + 'Programming' => ['JavaScript', 'Python', 'Java'], + 'Web Development' => ['HTML', 'CSS', 'React', 'Node.js'], + 'Tools' => ['Git', 'VS Code', 'Docker'] + ] + ], + 'settings' => [ + 'color_scheme' => 'green', + 'font_family' => 'Arial', + 'font_size' => 10, + 'section_order' => ['summary', 'experience', 'education', 'skills'] + ], + 'status' => 'draft', + 'completion_percentage' => 75, + 'completion_sections' => [ + 'personal_info' => true, + 'summary' => true, + 'experience' => true, + 'education' => true, + 'skills' => true + ] + ]); + } + + // Create additional test resumes + $users = User::all(); + foreach ($users as $user) { + if (!in_array($user->email, ['david@valera-melendez.de', 'demo@example.com'])) { + Resume::factory()->create(['user_id' => $user->id]); + } + } + } +} diff --git a/database/seeders/UserSeeder.php b/database/seeders/UserSeeder.php new file mode 100644 index 0000000..7d1bb31 --- /dev/null +++ b/database/seeders/UserSeeder.php @@ -0,0 +1,66 @@ + 'David', + 'last_name' => 'Valera Melendez', + 'email' => 'david@valera-melendez.de', + 'email_verified_at' => now(), + 'password' => Hash::make('password123'), + 'phone' => '+49 123 456 7890', + 'bio' => 'Senior Full-Stack Developer and Enterprise Software Architect specializing in Laravel, Angular, and modern web technologies.', + 'website' => 'https://valera-melendez.de', + 'linkedin' => 'https://linkedin.com/in/david-valera-melendez', + 'github' => 'https://github.com/davidvalera', + 'preferences' => [ + 'theme' => 'professional', + 'language' => 'en', + 'notifications' => true, + 'marketing_emails' => false, + ], + 'newsletter_subscribed' => false, + 'status' => 'active', + 'locale' => 'en', + 'timezone' => 'Europe/Berlin', + 'profile_completed_at' => now(), + ]); + + // Create demo user + User::create([ + 'first_name' => 'Demo', + 'last_name' => 'User', + 'email' => 'demo@example.com', + 'email_verified_at' => now(), + 'password' => Hash::make('demo123'), + 'bio' => 'Demo user for testing the resume builder application.', + 'preferences' => [ + 'theme' => 'modern', + 'language' => 'en', + 'notifications' => true, + 'marketing_emails' => true, + ], + 'newsletter_subscribed' => true, + 'status' => 'active', + 'locale' => 'en', + 'timezone' => 'UTC', + 'profile_completed_at' => now(), + ]); + + // Create additional test users + User::factory(10)->create(); + } +} diff --git a/public/.htaccess b/public/.htaccess new file mode 100644 index 0000000..3aec5e2 --- /dev/null +++ b/public/.htaccess @@ -0,0 +1,21 @@ + + + Options -MultiViews -Indexes + + + RewriteEngine On + + # Handle Authorization Header + RewriteCond %{HTTP:Authorization} . + RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] + + # Redirect Trailing Slashes If Not A Folder... + RewriteCond %{REQUEST_FILENAME} !-d + RewriteCond %{REQUEST_URI} (.+)/$ + RewriteRule ^ %1 [L,R=301] + + # Send Requests To Front Controller... + RewriteCond %{REQUEST_FILENAME} !-d + RewriteCond %{REQUEST_FILENAME} !-f + RewriteRule ^ index.php [L] + diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..e69de29 diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..1d69f3a --- /dev/null +++ b/public/index.php @@ -0,0 +1,55 @@ +make(Kernel::class); + +$response = $kernel->handle( + $request = Request::capture() +)->send(); + +$kernel->terminate($request, $response); diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..eb05362 --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: diff --git a/resources/css/app.css b/resources/css/app.css new file mode 100644 index 0000000..e69de29 diff --git a/resources/js/app.js b/resources/js/app.js new file mode 100644 index 0000000..30fca7f --- /dev/null +++ b/resources/js/app.js @@ -0,0 +1,534 @@ +/** + * Main JavaScript Application + * Professional Resume Builder - Laravel Application + * + * @author David Valera Melendez + * @created 2025-08-08 + * @location Made in Germany ๐Ÿ‡ฉ๐Ÿ‡ช + */ + +// Import Bootstrap JavaScript +import 'bootstrap'; + +// Import Axios for HTTP requests +import axios from 'axios'; + +// Configure Axios defaults +window.axios = axios; +window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'; + +// CSRF Token setup +const token = document.head.querySelector('meta[name="csrf-token"]'); +if (token) { + window.axios.defaults.headers.common['X-CSRF-TOKEN'] = token.content; +} else { + console.error('CSRF token not found: https://laravel.com/docs/csrf#csrf-x-csrf-token'); +} + +/** + * Application Class - Main Application Logic + */ +class ResumeBuilderApp { + constructor() { + this.init(); + } + + /** + * Initialize the application + */ + init() { + this.setupEventListeners(); + this.setupFormValidation(); + this.setupTooltips(); + this.setupAnimations(); + this.setupAccessibility(); + this.setupProgressBars(); + // Professional Resume Builder initialized - Made in Germany ๐Ÿ‡ฉ๐Ÿ‡ช + } + + /** + * Setup global event listeners + */ + setupEventListeners() { + document.addEventListener('DOMContentLoaded', () => { + this.handlePageLoad(); + }); + + // Handle form submissions with loading states + document.addEventListener('submit', (e) => { + if (e.target.tagName === 'FORM') { + this.handleFormSubmit(e.target); + } + }); + + // Handle dynamic content loading + document.addEventListener('click', (e) => { + if (e.target.matches('[data-action]')) { + this.handleDynamicAction(e); + } + }); + } + + /** + * Handle page load events + */ + handlePageLoad() { + // Animate elements on page load + this.animateOnLoad(); + + // Focus management + this.setupFocusManagement(); + + // Auto-dismiss alerts + this.setupAlertDismissal(); + } + + /** + * Setup form validation + */ + setupFormValidation() { + const forms = document.querySelectorAll('form[data-validate]'); + + forms.forEach(form => { + const inputs = form.querySelectorAll('input, select, textarea'); + + inputs.forEach(input => { + // Real-time validation + input.addEventListener('input', () => { + this.validateField(input); + }); + + input.addEventListener('blur', () => { + this.validateField(input); + }); + }); + + // Form submission validation + form.addEventListener('submit', (e) => { + if (!this.validateForm(form)) { + e.preventDefault(); + this.showValidationErrors(form); + } + }); + }); + } + + /** + * Validate individual field + */ + validateField(field) { + const isValid = field.checkValidity(); + const hasValue = field.value.trim().length > 0; + + field.classList.remove('is-valid', 'is-invalid'); + + if (hasValue) { + field.classList.add(isValid ? 'is-valid' : 'is-invalid'); + } + + // Custom validation rules + if (field.type === 'password' && field.name === 'password') { + this.validatePassword(field); + } + + if (field.type === 'password' && field.name === 'password_confirmation') { + this.validatePasswordConfirmation(field); + } + + return isValid; + } + + /** + * Validate password strength + */ + validatePassword(passwordField) { + const password = passwordField.value; + const minLength = 8; + const hasUpperCase = /[A-Z]/.test(password); + const hasLowerCase = /[a-z]/.test(password); + const hasNumbers = /\d/.test(password); + const hasSpecialChar = /[!@#$%^&*]/.test(password); + + const isStrong = password.length >= minLength && hasUpperCase && hasLowerCase && hasNumbers; + + passwordField.classList.remove('is-valid', 'is-invalid'); + if (password.length > 0) { + passwordField.classList.add(isStrong ? 'is-valid' : 'is-invalid'); + } + + return isStrong; + } + + /** + * Validate password confirmation + */ + validatePasswordConfirmation(confirmField) { + const password = document.querySelector('input[name="password"]')?.value || ''; + const confirmation = confirmField.value; + + const matches = password === confirmation && confirmation.length > 0; + + confirmField.classList.remove('is-valid', 'is-invalid'); + if (confirmation.length > 0) { + confirmField.classList.add(matches ? 'is-valid' : 'is-invalid'); + } + + return matches; + } + + /** + * Validate entire form + */ + validateForm(form) { + const inputs = form.querySelectorAll('input[required], select[required], textarea[required]'); + let isValid = true; + + inputs.forEach(input => { + if (!this.validateField(input)) { + isValid = false; + } + }); + + return isValid; + } + + /** + * Show validation errors + */ + showValidationErrors(form) { + const firstInvalidField = form.querySelector('.is-invalid'); + if (firstInvalidField) { + firstInvalidField.focus(); + firstInvalidField.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + } + + /** + * Handle form submission with loading states + */ + handleFormSubmit(form) { + const submitButton = form.querySelector('button[type="submit"]'); + if (submitButton) { + const originalContent = submitButton.innerHTML; + const loadingText = submitButton.dataset.loading || 'Processing...'; + + submitButton.innerHTML = `${loadingText}`; + submitButton.disabled = true; + + // Re-enable if form doesn't redirect (error case) + setTimeout(() => { + if (document.contains(submitButton)) { + submitButton.innerHTML = originalContent; + submitButton.disabled = false; + } + }, 5000); + } + } + + /** + * Setup tooltips + */ + setupTooltips() { + const tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]')); + tooltipTriggerList.map(function (tooltipTriggerEl) { + return new bootstrap.Tooltip(tooltipTriggerEl); + }); + } + + /** + * Setup animations + */ + setupAnimations() { + // Intersection Observer for scroll animations + if ('IntersectionObserver' in window) { + const observer = new IntersectionObserver((entries) => { + entries.forEach(entry => { + if (entry.isIntersecting) { + entry.target.classList.add('animate-fade-in-up'); + observer.unobserve(entry.target); + } + }); + }); + + document.querySelectorAll('[data-animate]').forEach(el => { + observer.observe(el); + }); + } + } + + /** + * Animate elements on page load + */ + animateOnLoad() { + // Stagger animation for cards + const cards = document.querySelectorAll('.card'); + cards.forEach((card, index) => { + setTimeout(() => { + card.style.opacity = '0'; + card.style.transform = 'translateY(20px)'; + card.style.transition = 'all 0.5s ease'; + + setTimeout(() => { + card.style.opacity = '1'; + card.style.transform = 'translateY(0)'; + }, 100 * index); + }, 0); + }); + } + + /** + * Setup progress bars with animation + */ + setupProgressBars() { + const progressBars = document.querySelectorAll('.progress-bar'); + + const animateProgress = (entries, observer) => { + entries.forEach(entry => { + if (entry.isIntersecting) { + const progressBar = entry.target; + const targetWidth = progressBar.style.width; + + progressBar.style.width = '0%'; + + setTimeout(() => { + progressBar.style.transition = 'width 1s ease-in-out'; + progressBar.style.width = targetWidth; + }, 200); + + observer.unobserve(progressBar); + } + }); + }; + + if ('IntersectionObserver' in window) { + const observer = new IntersectionObserver(animateProgress); + progressBars.forEach(bar => observer.observe(bar)); + } + } + + /** + * Setup accessibility features + */ + setupAccessibility() { + // Keyboard navigation for custom elements + document.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + if (e.target.matches('[role="button"]:not(button):not(input)')) { + e.preventDefault(); + e.target.click(); + } + } + }); + + // Focus management for modals + document.addEventListener('shown.bs.modal', (e) => { + const modal = e.target; + const focusableElement = modal.querySelector('input, button, select, textarea, [tabindex]:not([tabindex="-1"])'); + if (focusableElement) { + focusableElement.focus(); + } + }); + } + + /** + * Setup focus management + */ + setupFocusManagement() { + // Auto-focus first input in forms + const firstInput = document.querySelector('input:not([type="hidden"]):not([readonly])'); + if (firstInput && !firstInput.value) { + setTimeout(() => firstInput.focus(), 100); + } + + // Skip links for accessibility + const skipLinks = document.querySelectorAll('.skip-link'); + skipLinks.forEach(link => { + link.addEventListener('click', (e) => { + e.preventDefault(); + const target = document.querySelector(link.getAttribute('href')); + if (target) { + target.focus(); + target.scrollIntoView(); + } + }); + }); + } + + /** + * Setup alert auto-dismissal + */ + setupAlertDismissal() { + const alerts = document.querySelectorAll('.alert:not(.alert-permanent)'); + + alerts.forEach(alert => { + // Auto-dismiss success alerts + if (alert.classList.contains('alert-success')) { + setTimeout(() => { + this.dismissAlert(alert); + }, 5000); + } + + // Auto-dismiss info alerts + if (alert.classList.contains('alert-info')) { + setTimeout(() => { + this.dismissAlert(alert); + }, 8000); + } + }); + } + + /** + * Dismiss alert with animation + */ + dismissAlert(alert) { + alert.style.transition = 'opacity 0.5s ease, transform 0.5s ease'; + alert.style.opacity = '0'; + alert.style.transform = 'translateY(-20px)'; + + setTimeout(() => { + if (alert.parentNode) { + alert.parentNode.removeChild(alert); + } + }, 500); + } + + /** + * Handle dynamic actions + */ + handleDynamicAction(e) { + e.preventDefault(); + const action = e.target.dataset.action; + + switch (action) { + case 'confirm-delete': + this.handleConfirmDelete(e.target); + break; + case 'copy-link': + this.handleCopyLink(e.target); + break; + case 'toggle-visibility': + this.handleToggleVisibility(e.target); + break; + default: + console.warn('Unknown action:', action); + } + } + + /** + * Handle confirm delete action + */ + handleConfirmDelete(element) { + const message = element.dataset.message || 'Are you sure you want to delete this item?'; + const form = element.closest('form') || document.querySelector(element.dataset.form); + + if (confirm(message) && form) { + form.submit(); + } + } + + /** + * Handle copy link action + */ + async handleCopyLink(element) { + const url = element.dataset.url || window.location.href; + + try { + await navigator.clipboard.writeText(url); + this.showToast('Link copied to clipboard!', 'success'); + } catch (err) { + console.error('Failed to copy link:', err); + this.showToast('Failed to copy link', 'error'); + } + } + + /** + * Handle toggle visibility action + */ + handleToggleVisibility(element) { + const target = document.querySelector(element.dataset.target); + const type = element.type; + + if (target && type === 'password') { + const isPassword = target.type === 'password'; + target.type = isPassword ? 'text' : 'password'; + + const icon = element.querySelector('i'); + if (icon) { + icon.classList.toggle('bi-eye'); + icon.classList.toggle('bi-eye-slash'); + } + } + } + + /** + * Show toast notification + */ + showToast(message, type = 'info') { + const toastContainer = this.getOrCreateToastContainer(); + const toast = this.createToast(message, type); + + toastContainer.appendChild(toast); + + const bsToast = new bootstrap.Toast(toast); + bsToast.show(); + + toast.addEventListener('hidden.bs.toast', () => { + toast.remove(); + }); + } + + /** + * Get or create toast container + */ + getOrCreateToastContainer() { + let container = document.querySelector('.toast-container'); + + if (!container) { + container = document.createElement('div'); + container.className = 'toast-container position-fixed top-0 end-0 p-3'; + container.style.zIndex = '1055'; + document.body.appendChild(container); + } + + return container; + } + + /** + * Create toast element + */ + createToast(message, type) { + const toast = document.createElement('div'); + toast.className = 'toast'; + toast.setAttribute('role', 'alert'); + toast.setAttribute('aria-live', 'assertive'); + toast.setAttribute('aria-atomic', 'true'); + + const iconMap = { + success: 'bi-check-circle-fill text-success', + error: 'bi-exclamation-circle-fill text-danger', + warning: 'bi-exclamation-triangle-fill text-warning', + info: 'bi-info-circle-fill text-info' + }; + + const icon = iconMap[type] || iconMap.info; + + toast.innerHTML = ` +
+ + Resume Builder + +
+
+ ${message} +
+ `; + + return toast; + } +} + +// Initialize the application +const app = new ResumeBuilderApp(); + +// Export for global access +window.ResumeBuilderApp = app; diff --git a/resources/js/bootstrap.js b/resources/js/bootstrap.js new file mode 100644 index 0000000..846d350 --- /dev/null +++ b/resources/js/bootstrap.js @@ -0,0 +1,32 @@ +/** + * We'll load the axios HTTP library which allows us to easily issue requests + * to our Laravel back-end. This library automatically handles sending the + * CSRF token as a header based on the value of the "XSRF" token cookie. + */ + +import axios from 'axios'; +window.axios = axios; + +window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'; + +/** + * Echo exposes an expressive API for subscribing to channels and listening + * for events that are broadcast by Laravel. Echo and event broadcasting + * allows your team to easily build robust real-time web applications. + */ + +// import Echo from 'laravel-echo'; + +// import Pusher from 'pusher-js'; +// window.Pusher = Pusher; + +// window.Echo = new Echo({ +// broadcaster: 'pusher', +// key: import.meta.env.VITE_PUSHER_APP_KEY, +// cluster: import.meta.env.VITE_PUSHER_APP_CLUSTER ?? 'mt1', +// wsHost: import.meta.env.VITE_PUSHER_HOST ? import.meta.env.VITE_PUSHER_HOST : `ws-${import.meta.env.VITE_PUSHER_APP_CLUSTER}.pusher.com`, +// wsPort: import.meta.env.VITE_PUSHER_PORT ?? 80, +// wssPort: import.meta.env.VITE_PUSHER_PORT ?? 443, +// forceTLS: (import.meta.env.VITE_PUSHER_SCHEME ?? 'https') === 'https', +// enabledTransports: ['ws', 'wss'], +// }); diff --git a/resources/sass/abstracts/_mixins.scss b/resources/sass/abstracts/_mixins.scss new file mode 100644 index 0000000..7ad1c54 --- /dev/null +++ b/resources/sass/abstracts/_mixins.scss @@ -0,0 +1,234 @@ +/** + * Mixins - Enterprise SCSS Architecture + * Professional Resume Builder + * + * @author David Valera Melendez + * @created 2025-08-08 + * @location Made in Germany ๐Ÿ‡ฉ๐Ÿ‡ช + */ + +// ========================================================================== +// Media Query Mixins +// ========================================================================== +@mixin media-up($breakpoint) { + @media (min-width: $breakpoint) { + @content; + } +} + +@mixin media-down($breakpoint) { + @media (max-width: ($breakpoint - 1px)) { + @content; + } +} + +@mixin media-between($min, $max) { + @media (min-width: $min) and (max-width: ($max - 1px)) { + @content; + } +} + +// ========================================================================== +// Flexbox Mixins +// ========================================================================== +@mixin flex-center { + display: flex; + align-items: center; + justify-content: center; +} + +@mixin flex-between { + display: flex; + align-items: center; + justify-content: space-between; +} + +@mixin flex-column-center { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +// ========================================================================== +// Button Mixins +// ========================================================================== +@mixin button-base { + display: inline-flex; + align-items: center; + justify-content: center; + padding: $spacing-sm $spacing-base; + border: none; + border-radius: $radius-md; + font-weight: $font-weight-medium; + font-size: $font-size-base; + line-height: $line-height-tight; + text-decoration: none; + cursor: pointer; + transition: all $transition-base; + user-select: none; + + &:focus { + outline: none; + box-shadow: 0 0 0 3px rgba($primary-color, 0.1); + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } +} + +@mixin button-variant($bg-color, $text-color: $white, $hover-bg-color: null) { + @include button-base; + + background-color: $bg-color; + color: $text-color; + + @if $hover-bg-color { + &:hover:not(:disabled) { + background-color: $hover-bg-color; + transform: translateY(-1px); + box-shadow: $shadow-md; + } + } @else { + &:hover:not(:disabled) { + background-color: darken($bg-color, 8%); + transform: translateY(-1px); + box-shadow: $shadow-md; + } + } + + &:active { + transform: translateY(0); + } +} + +// ========================================================================== +// Card Mixins +// ========================================================================== +@mixin card-base { + background-color: $white; + border-radius: $radius-xl; + box-shadow: $shadow-sm; + overflow: hidden; + transition: all $transition-base; +} + +@mixin card-hover { + &:hover { + transform: translateY(-2px); + box-shadow: $shadow-lg; + } +} + +// ========================================================================== +// Form Mixins +// ========================================================================== +@mixin form-control-base { + display: block; + width: 100%; + padding: $spacing-sm $spacing-base; + font-size: $font-size-base; + font-weight: $font-weight-normal; + line-height: $line-height-normal; + color: $text-primary; + background-color: $white; + border: 2px solid $border-color; + border-radius: $radius-md; + transition: all $transition-base; + + &:focus { + outline: none; + border-color: $primary-color; + box-shadow: 0 0 0 0.25rem rgba($primary-color, 0.25); + } + + &::placeholder { + color: $text-muted; + opacity: 1; + } + + &:disabled { + background-color: $gray-100; + opacity: 1; + cursor: not-allowed; + } +} + +// ========================================================================== +// Animation Mixins +// ========================================================================== +@mixin fade-in($duration: $transition-base) { + animation: fadeIn $duration ease-out; +} + +@mixin slide-up($duration: $transition-base) { + animation: slideUp $duration ease-out; +} + +@mixin scale-in($duration: $transition-base) { + animation: scaleIn $duration ease-out; +} + +// ========================================================================== +// Utility Mixins +// ========================================================================== +@mixin sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +@mixin clearfix { + &::after { + content: ''; + display: table; + clear: both; + } +} + +@mixin text-truncate { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +@mixin aspect-ratio($width, $height) { + position: relative; + + &::before { + content: ''; + display: block; + padding-top: percentage($height / $width); + } + + > * { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + } +} + +// ========================================================================== +// Background Pattern Mixins +// ========================================================================== +@mixin grid-pattern($size: 50px, $color: rgba(255, 255, 255, 0.1)) { + background-image: + radial-gradient(circle at 25% 25%, $color 2px, transparent 2px), + radial-gradient(circle at 75% 75%, $color 1px, transparent 1px); + background-size: $size $size; +} + +@mixin dot-pattern($size: 20px, $color: rgba(0, 0, 0, 0.1)) { + background-image: radial-gradient(circle, $color 1px, transparent 1px); + background-size: $size $size; +} diff --git a/resources/sass/abstracts/_variables.scss b/resources/sass/abstracts/_variables.scss new file mode 100644 index 0000000..0016f36 --- /dev/null +++ b/resources/sass/abstracts/_variables.scss @@ -0,0 +1,288 @@ +/** + * Variables - Professional Bootstrap Design System for Laravel + * Professional Resume Builder + * + * @author David Valera Melendez + * @created 2025-08-08 + * @location Made in Germany ๐Ÿ‡ฉ๐Ÿ‡ช + * @description Professional color system and design tokens for Laravel with Bootstrap + */ + +/* ======================================== + CSS CUSTOM PROPERTIES (Professional Bootstrap Match) + ======================================== */ + +:root { + /* Primary Brand Colors - Professional Blue */ + --color-primary: #1976d2; + --color-primary-light: #42a5f5; + --color-primary-dark: #1565c0; + --color-primary-50: #e3f2fd; + --color-primary-100: #bbdefb; + --color-primary-200: #90caf9; + --color-primary-300: #64b5f6; + --color-primary-400: #42a5f5; + --color-primary-500: #1976d2; + --color-primary-600: #1976d2; + --color-primary-700: #1565c0; + --color-primary-800: #0d47a1; + --color-primary-900: #0d47a1; + + /* Accent Colors */ + --color-accent: #ff9800; + --color-accent-light: #ffb74d; + --color-accent-dark: #f57f17; + + /* Semantic Colors */ + --color-success: #4caf50; + --color-success-light: #81c784; + --color-success-dark: #388e3c; + + --color-warning: #ff9800; + --color-warning-light: #ffb74d; + --color-warning-dark: #f57f17; + + --color-error: #f44336; + --color-error-light: #ef5350; + --color-error-dark: #c62828; + --color-error-bg: rgba(244, 67, 54, 0.05); + + --color-info: #2196f3; + --color-info-light: #64b5f6; + --color-info-dark: #1976d2; + + /* Text Colors */ + --color-text-primary: #212121; + --color-text-secondary: #757575; + --color-text-disabled: #bdbdbd; + --color-text-hint: #9e9e9e; + --color-text-muted: #555555; + --color-text-dark: #444444; + --color-text-light: #999999; + --color-text-white: #ffffff; + --color-text-white-muted: rgba(255, 255, 255, 0.9); + + /* Background Colors */ + --color-background: #fafafa; + --color-background-paper: #ffffff; + --color-background-default: #f5f5f5; + --color-background-dialog: #ffffff; + + /* Surface Colors */ + --color-surface: #ffffff; + --color-surface-variant: #f5f5f5; + + /* Border Colors */ + --color-border: #e0e0e0; + --color-border-light: #f5f5f5; + --color-border-dark: #bdbdbd; + + /* Shadow Colors */ + --color-shadow-light: rgba(0, 0, 0, 0.1); + --color-shadow-medium: rgba(0, 0, 0, 0.15); + --color-shadow-dark: rgba(0, 0, 0, 0.25); + + /* Gradient Definitions */ + --gradient-primary: linear-gradient(135deg, #1976d2 0%, #42a5f5 100%); + --gradient-accent: linear-gradient(135deg, #ff9800 0%, #ffb74d 100%); + --gradient-success: linear-gradient(135deg, #4caf50 0%, #81c784 100%); + + /* Spacing System */ + --spacing-xs: 0.25rem; + --spacing-sm: 0.5rem; + --spacing-md: 1rem; + --spacing-lg: 1.5rem; + --spacing-xl: 2rem; + --spacing-xxl: 3rem; + + /* Border Radius System */ + --border-radius-sm: 4px; + --border-radius-md: 8px; + --border-radius-lg: 12px; + --border-radius-xl: 16px; + --border-radius-round: 50%; + + /* Professional Shadow System */ + --shadow-xs: 0 1px 2px 0 var(--color-shadow-light); + --shadow-sm: 0 1px 3px 0 var(--color-shadow-light), 0 1px 2px 0 var(--color-shadow-light); + --shadow-md: 0 4px 6px -1px var(--color-shadow-light), 0 2px 4px -1px var(--color-shadow-light); + --shadow-lg: 0 10px 15px -3px var(--color-shadow-light), 0 4px 6px -2px var(--color-shadow-light); + --shadow-xl: 0 20px 25px -5px var(--color-shadow-light), 0 10px 10px -5px var(--color-shadow-light); + --shadow-2xl: 0 25px 50px -12px var(--color-shadow-dark); + + /* Transitions */ + --transition-fast: 0.15s ease; + --transition-normal: 0.2s ease; + --transition-slow: 0.3s ease; +} + +/* ======================================== + SCSS VARIABLES (Build-time) + ======================================== */ + +// Primary Brand Colors +$primary-color: #1976d2; +$primary-light: #42a5f5; +$primary-dark: #1565c0; +$secondary-color: #757575; +$accent-color: #ff9800; + +// Semantic Colors +$success-color: #4caf50; +$warning-color: #ff9800; +$danger-color: #f44336; +$info-color: #2196f3; + +// Text Colors +$text-primary: #212121; +$text-secondary: #757575; +$text-muted: #9e9e9e; +$text-disabled: #bdbdbd; +$black: #000000; +$white: #ffffff; + +// Background Colors +$bg-primary: #ffffff; +$bg-secondary: #fafafa; +$bg-tertiary: #f5f5f5; + +// Border Colors +$border-color: #e0e0e0; +$border-light: #f5f5f5; +$border-dark: #bdbdbd; + +// Spacing System +$spacing-xs: 0.25rem; +$spacing-sm: 0.5rem; +$spacing-md: 1rem; +$spacing-base: 1rem; // Same as md for compatibility +$spacing-lg: 1.5rem; +$spacing-xl: 2rem; +$spacing-xxl: 3rem; + +// Typography Scale +$font-size-xs: 0.75rem; // 12px +$font-size-sm: 0.875rem; // 14px +$font-size-base: 1rem; // 16px +$font-size-lg: 1.125rem; // 18px +$font-size-xl: 1.25rem; // 20px +$font-size-xxl: 1.5rem; // 24px +$font-size-2xl: 1.5rem; // 24px +$font-size-xxxl: 2rem; // 32px +$font-size-3xl: 1.875rem; // 30px +$font-size-4xl: 2.25rem; // 36px +$font-size-5xl: 3rem; // 48px + +$font-weight-light: 300; +$font-weight-normal: 400; +$font-weight-medium: 500; +$font-weight-semibold: 600; +$font-weight-bold: 700; + +// Line Heights +$line-height-tight: 1.25; +$line-height-normal: 1.5; +$line-height-relaxed: 1.75; + +// Font Families (Material Design Compatible) +$font-family-primary: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; +$font-family-mono: Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + +// Line Heights +$line-height-tight: 1.2; +$line-height-normal: 1.5; +$line-height-relaxed: 1.6; + +// Border Radius System +$border-radius-sm: 4px; +$border-radius-md: 8px; +$border-radius-lg: 12px; +$border-radius-xl: 16px; +$border-radius-round: 50%; + +// Breakpoints (Bootstrap Compatible) +$breakpoint-xs: 576px; +$breakpoint-sm: 768px; +$breakpoint-md: 992px; +$breakpoint-lg: 1200px; +$breakpoint-xl: 1400px; + +/* ======================================== + PROFESSIONAL MIXINS + ======================================== */ + +// Material Design Card Mixin +@mixin material-card { + background-color: var(--color-surface); + border-radius: var(--border-radius-lg); + box-shadow: var(--shadow-md); + border: 1px solid var(--color-border-light); + padding: var(--spacing-lg); + transition: var(--transition-normal); + + &:hover { + box-shadow: var(--shadow-lg); + transform: translateY(-2px); + } +} + +// Material Design Button Mixin +@mixin material-button($color: var(--color-primary)) { + background-color: $color; + color: white; + border: none; + border-radius: var(--border-radius-md); + padding: var(--spacing-sm) var(--spacing-md); + font-weight: $font-weight-medium; + transition: var(--transition-normal); + cursor: pointer; + display: inline-flex; + align-items: center; + gap: var(--spacing-xs); + text-decoration: none; + + &:hover { + filter: brightness(110%); + transform: translateY(-1px); + box-shadow: var(--shadow-md); + color: white; + text-decoration: none; + } + + &:active { + transform: translateY(0); + } + + &:disabled { + background-color: var(--color-text-disabled); + cursor: not-allowed; + transform: none; + box-shadow: none; + } +} + +// Gradient Background Mixin +@mixin gradient-background($gradient: var(--gradient-primary)) { + background: $gradient; + color: var(--color-text-white); +} + +// Professional Input Mixin +@mixin material-input { + border: 1px solid var(--color-border); + border-radius: var(--border-radius-md); + padding: var(--spacing-sm) var(--spacing-md); + background-color: var(--color-surface); + transition: var(--transition-normal); + font-size: $font-size-base; + + &:focus { + border-color: var(--color-primary); + box-shadow: 0 0 0 2px rgba(25, 118, 210, 0.1); + outline: none; + } + + &::placeholder { + color: var(--color-text-hint); + } +} diff --git a/resources/sass/app.scss b/resources/sass/app.scss new file mode 100644 index 0000000..27b480e --- /dev/null +++ b/resources/sass/app.scss @@ -0,0 +1,47 @@ +๏ปฟ// ========================================================================== +// Enterprise SCSS Architecture - 7-1 Pattern +// Professional Resume Builder +// ========================================================================== + +// ========================================================================== +// 1. Abstracts +// ========================================================================== +@import 'abstracts/variables'; +@import 'abstracts/mixins'; + +// ========================================================================== +// 2. Vendors/Third-party +// ========================================================================== +@import '~bootstrap/scss/bootstrap'; + +// ========================================================================== +// 3. Base +// ========================================================================== +@import 'base/base'; + +// ========================================================================== +// 4. Layout +// ========================================================================== +@import 'layouts/header'; +@import 'layouts/footer'; + +// ========================================================================== +// 5. Components +// ========================================================================== +@import 'components/buttons'; +@import 'components/cards'; +@import 'components/forms'; + +// ========================================================================== +// 6. Pages +// ========================================================================== +@import 'pages/login'; +@import 'pages/register'; +@import 'pages/dashboard'; +@import 'pages/resume-index'; +@import 'pages/resume-create'; + +// ========================================================================== +// 7. Utilities +// ========================================================================== +@import 'utilities/helpers'; diff --git a/resources/sass/base/_base.scss b/resources/sass/base/_base.scss new file mode 100644 index 0000000..960261e --- /dev/null +++ b/resources/sass/base/_base.scss @@ -0,0 +1,231 @@ +/** + * Base Styles - Enterprise SCSS Architecture + * Professional Resume Builder + * + * @author David Valera Melendez + * @created 2025-08-08 + * @location Made in Germany ๐Ÿ‡ฉ๐Ÿ‡ช + */ + +// ========================================================================== +// CSS Reset & Base Styles +// ========================================================================== +*, +*::before, +*::after { + box-sizing: border-box; +} + +html { + font-family: $font-family-primary; + font-size: $font-size-base; + line-height: $line-height-normal; + -webkit-text-size-adjust: 100%; + -webkit-tap-highlight-color: rgba($black, 0); +} + +body { + margin: 0; + font-family: $font-family-primary; + font-size: $font-size-base; + font-weight: $font-weight-normal; + line-height: $line-height-normal; + color: $text-primary; + background-color: $bg-secondary; + -webkit-text-size-adjust: 100%; + -webkit-tap-highlight-color: rgba($black, 0); +} + +// ========================================================================== +// Typography Base +// ========================================================================== +h1, h2, h3, h4, h5, h6 { + margin-top: 0; + margin-bottom: $spacing-md; + font-weight: $font-weight-semibold; + line-height: $line-height-tight; + color: $text-primary; +} + +h1 { font-size: $font-size-4xl; } +h2 { font-size: $font-size-3xl; } +h3 { font-size: $font-size-2xl; } +h4 { font-size: $font-size-xl; } +h5 { font-size: $font-size-lg; } +h6 { font-size: $font-size-base; } + +p { + margin-top: 0; + margin-bottom: $spacing-base; + + &:last-child { + margin-bottom: 0; + } +} + +small, +.small { + font-size: $font-size-sm; + font-weight: $font-weight-normal; +} + +// ========================================================================== +// Links +// ========================================================================== +a { + color: $primary-color; + text-decoration: underline; + transition: color $transition-base; + + &:hover, + &:focus { + color: darken($primary-color, 15%); + } + + &:focus { + outline: thin dotted; + outline-offset: 2px; + } +} + +// ========================================================================== +// Lists +// ========================================================================== +ul, +ol { + margin-top: 0; + margin-bottom: $spacing-base; + padding-left: $spacing-xl; +} + +li { + margin-bottom: $spacing-xs; +} + +// ========================================================================== +// Forms Base +// ========================================================================== +button, +input, +optgroup, +select, +textarea { + margin: 0; + font-family: inherit; + font-size: inherit; + line-height: inherit; +} + +button:focus:not(:focus-visible) { + outline: 0; +} + +input, +button, +select, +optgroup, +textarea { + font-family: inherit; + font-size: inherit; + line-height: inherit; +} + +button, +input { + overflow: visible; +} + +button, +select { + text-transform: none; +} + +// ========================================================================== +// Images & Media +// ========================================================================== +img { + border-style: none; + vertical-align: middle; +} + +svg { + overflow: hidden; + vertical-align: middle; +} + +// ========================================================================== +// Tables +// ========================================================================== +table { + border-collapse: collapse; +} + +caption { + padding-top: $spacing-sm; + padding-bottom: $spacing-sm; + color: $text-muted; + text-align: left; + caption-side: bottom; +} + +th { + text-align: inherit; + text-align: -webkit-match-parent; +} + +// ========================================================================== +// Code +// ========================================================================== +pre, +code, +kbd, +samp { + font-family: $font-family-mono; + font-size: 1em; +} + +pre { + margin-top: 0; + margin-bottom: $spacing-base; + overflow: auto; + -ms-overflow-style: scrollbar; +} + +// ========================================================================== +// Accessibility +// ========================================================================== +.sr-only { + @include sr-only; +} + +.sr-only-focusable { + &:active, + &:focus { + position: static; + width: auto; + height: auto; + padding: 0; + margin: 0; + overflow: visible; + clip: auto; + white-space: normal; + border: 0; + } +} + +// ========================================================================== +// Focus States +// ========================================================================== +:focus { + outline: 2px solid $primary-color; + outline-offset: 2px; +} + +:focus:not(:focus-visible) { + outline: none; +} + +:focus-visible { + outline: 2px solid $primary-color; + outline-offset: 2px; +} diff --git a/resources/sass/components/_buttons.scss b/resources/sass/components/_buttons.scss new file mode 100644 index 0000000..c0d772d --- /dev/null +++ b/resources/sass/components/_buttons.scss @@ -0,0 +1,465 @@ +// ========================================================================== +// Button Components +// Professional Resume Builder - Button Styles +// ========================================================================== + +// Base button enhancements +.btn { + font-weight: 500; + letter-spacing: 0.025em; + border-radius: var(--border-radius-md); + transition: all 0.2s ease-in-out; + position: relative; + overflow: hidden; + + &:hover:not(:disabled) { + transform: translateY(-1px); + } + + &:active:not(:disabled) { + transform: translateY(0); + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } + + // Button loading state + &.btn-loading { + color: transparent !important; + + &::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 1rem; + height: 1rem; + border: 2px solid currentColor; + border-radius: 50%; + border-top-color: transparent; + animation: btn-spinner 0.8s linear infinite; + } + } +} + +// Primary button variants +.btn-primary { + background: var(--brand-gradient); + border: none; + color: var(--color-white); + box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3); + + &:hover:not(:disabled) { + background: var(--brand-gradient-hover); + box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4); + } + + &:focus { + box-shadow: 0 0 0 0.2rem rgba(102, 126, 234, 0.25); + } + + &:active { + background: var(--brand-primary-dark); + } +} + +.btn-primary-soft { + background: rgba(102, 126, 234, 0.1); + border: 1px solid rgba(102, 126, 234, 0.2); + color: var(--brand-primary); + + &:hover:not(:disabled) { + background: rgba(102, 126, 234, 0.15); + border-color: rgba(102, 126, 234, 0.3); + color: var(--brand-primary-dark); + } +} + +// Success button variants +.btn-success { + background: var(--brand-gradient-success); + border: none; + color: var(--color-white); + box-shadow: 0 4px 12px rgba(34, 197, 94, 0.3); + + &:hover:not(:disabled) { + background: var(--brand-gradient-success-hover); + box-shadow: 0 6px 20px rgba(34, 197, 94, 0.4); + } + + &:focus { + box-shadow: 0 0 0 0.2rem rgba(34, 197, 94, 0.25); + } +} + +.btn-success-soft { + background: rgba(34, 197, 94, 0.1); + border: 1px solid rgba(34, 197, 94, 0.2); + color: var(--brand-success); + + &:hover:not(:disabled) { + background: rgba(34, 197, 94, 0.15); + border-color: rgba(34, 197, 94, 0.3); + color: var(--brand-success-dark); + } +} + +// Warning button variants +.btn-warning { + background: var(--brand-gradient-warning); + border: none; + color: var(--color-white); + box-shadow: 0 4px 12px rgba(251, 191, 36, 0.3); + + &:hover:not(:disabled) { + background: var(--brand-gradient-warning-hover); + box-shadow: 0 6px 20px rgba(251, 191, 36, 0.4); + } + + &:focus { + box-shadow: 0 0 0 0.2rem rgba(251, 191, 36, 0.25); + } +} + +.btn-warning-soft { + background: rgba(251, 191, 36, 0.1); + border: 1px solid rgba(251, 191, 36, 0.2); + color: var(--brand-warning); + + &:hover:not(:disabled) { + background: rgba(251, 191, 36, 0.15); + border-color: rgba(251, 191, 36, 0.3); + color: var(--brand-warning-dark); + } +} + +// Danger button variants +.btn-danger { + background: var(--brand-gradient-danger); + border: none; + color: var(--color-white); + box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3); + + &:hover:not(:disabled) { + background: var(--brand-gradient-danger-hover); + box-shadow: 0 6px 20px rgba(239, 68, 68, 0.4); + } + + &:focus { + box-shadow: 0 0 0 0.2rem rgba(239, 68, 68, 0.25); + } +} + +.btn-danger-soft { + background: rgba(239, 68, 68, 0.1); + border: 1px solid rgba(239, 68, 68, 0.2); + color: var(--brand-danger); + + &:hover:not(:disabled) { + background: rgba(239, 68, 68, 0.15); + border-color: rgba(239, 68, 68, 0.3); + color: var(--brand-danger-dark); + } +} + +// Outline button enhancements +.btn-outline-primary { + border-color: var(--brand-primary); + color: var(--brand-primary); + + &:hover:not(:disabled) { + background: var(--brand-primary); + border-color: var(--brand-primary); + color: var(--color-white); + } +} + +.btn-outline-success { + border-color: var(--brand-success); + color: var(--brand-success); + + &:hover:not(:disabled) { + background: var(--brand-success); + border-color: var(--brand-success); + color: var(--color-white); + } +} + +.btn-outline-warning { + border-color: var(--brand-warning); + color: var(--brand-warning); + + &:hover:not(:disabled) { + background: var(--brand-warning); + border-color: var(--brand-warning); + color: var(--color-white); + } +} + +.btn-outline-danger { + border-color: var(--brand-danger); + color: var(--brand-danger); + + &:hover:not(:disabled) { + background: var(--brand-danger); + border-color: var(--brand-danger); + color: var(--color-white); + } +} + +// Button sizes +.btn-xs { + padding: 0.25rem 0.5rem; + font-size: 0.75rem; + border-radius: var(--border-radius-sm); +} + +.btn-xl { + padding: 1rem 2rem; + font-size: 1.125rem; + border-radius: var(--border-radius-lg); +} + +// Button with icons +.btn-icon { + display: inline-flex; + align-items: center; + + i { + margin-right: 0.5rem; + + &:last-child { + margin-right: 0; + margin-left: 0.5rem; + } + } + + &.btn-icon-only { + padding: 0.625rem; + + i { + margin: 0; + } + } +} + +// Floating action button +.btn-fab { + width: 3.5rem; + height: 3.5rem; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.25rem; + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.15); + position: fixed; + bottom: 2rem; + right: 2rem; + z-index: 1050; + + &:hover:not(:disabled) { + transform: translateY(-2px) scale(1.05); + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.2); + } + + i { + margin: 0; + } +} + +// Button groups +.btn-group { + .btn { + &:not(:first-child):not(:last-child) { + border-radius: 0; + } + + &:first-child { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } + + &:last-child { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + } + + &:hover:not(:disabled) { + transform: none; + z-index: 1; + } + } +} + +// Toggle buttons +.btn-toggle { + background: var(--color-white); + border: 2px solid var(--color-gray-200); + color: var(--color-gray-600); + + &:hover:not(:disabled) { + background: var(--color-gray-50); + border-color: var(--color-gray-300); + } + + &.active { + background: var(--brand-primary); + border-color: var(--brand-primary); + color: var(--color-white); + + &:hover:not(:disabled) { + background: var(--brand-primary-dark); + border-color: var(--brand-primary-dark); + } + } +} + +// Gradient buttons +.btn-gradient-purple { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + border: none; + color: var(--color-white); + box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3); + + &:hover:not(:disabled) { + background: linear-gradient(135deg, #5a6fd8 0%, #6a4190 100%); + box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4); + } +} + +.btn-gradient-orange { + background: linear-gradient(135deg, #ff9a56 0%, #ff6b95 100%); + border: none; + color: var(--color-white); + box-shadow: 0 4px 12px rgba(255, 154, 86, 0.3); + + &:hover:not(:disabled) { + background: linear-gradient(135deg, #ff8a46 0%, #ff5b85 100%); + box-shadow: 0 6px 20px rgba(255, 154, 86, 0.4); + } +} + +.btn-gradient-teal { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + border: none; + color: var(--color-white); + box-shadow: 0 4px 12px rgba(20, 184, 166, 0.3); + + &:hover:not(:disabled) { + background: linear-gradient(135deg, #0d9488 0%, #0f766e 100%); + box-shadow: 0 6px 20px rgba(20, 184, 166, 0.4); + } +} + +// Social buttons +.btn-social { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.75rem 1.5rem; + border-radius: var(--border-radius-md); + font-weight: 500; + text-decoration: none; + transition: all 0.2s ease; + + i { + margin-right: 0.75rem; + font-size: 1.125rem; + } + + &:hover { + transform: translateY(-1px); + text-decoration: none; + } + + &.btn-google { + background: #db4437; + color: var(--color-white); + + &:hover { + background: #c23321; + color: var(--color-white); + } + } + + &.btn-facebook { + background: #3b5998; + color: var(--color-white); + + &:hover { + background: #2d4373; + color: var(--color-white); + } + } + + &.btn-linkedin { + background: #0077b5; + color: var(--color-white); + + &:hover { + background: #005885; + color: var(--color-white); + } + } + + &.btn-github { + background: #333; + color: var(--color-white); + + &:hover { + background: #24292e; + color: var(--color-white); + } + } +} + +// Button animations +@keyframes btn-spinner { + 0% { + transform: translate(-50%, -50%) rotate(0deg); + } + 100% { + transform: translate(-50%, -50%) rotate(360deg); + } +} + +@keyframes btn-pulse { + 0% { + box-shadow: 0 0 0 0 rgba(102, 126, 234, 0.7); + } + 70% { + box-shadow: 0 0 0 10px rgba(102, 126, 234, 0); + } + 100% { + box-shadow: 0 0 0 0 rgba(102, 126, 234, 0); + } +} + +.btn-pulse { + animation: btn-pulse 2s infinite; +} + +// Responsive button adjustments +@include media-breakpoint-down(sm) { + .btn-fab { + width: 3rem; + height: 3rem; + font-size: 1.125rem; + bottom: 1.5rem; + right: 1.5rem; + } + + .btn-social { + padding: 0.625rem 1.25rem; + font-size: 0.875rem; + + i { + margin-right: 0.5rem; + font-size: 1rem; + } + } +} diff --git a/resources/sass/components/_cards.scss b/resources/sass/components/_cards.scss new file mode 100644 index 0000000..b3c993f --- /dev/null +++ b/resources/sass/components/_cards.scss @@ -0,0 +1,537 @@ +// ========================================================================== +// Card Components +// Professional Resume Builder - Card Styles +// ========================================================================== + +// Base card enhancements +.card { + border: none; + border-radius: var(--border-radius-lg); + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); + transition: all 0.3s ease-in-out; + background: var(--color-white); + overflow: hidden; + + &:hover { + transform: translateY(-2px); + box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); + } +} + +// Card header enhancements +.card-header { + background-color: transparent; + border-bottom: 1px solid var(--color-gray-100); + font-weight: 600; + color: var(--color-dark); + padding: var(--spacing-lg); + + &.bg-light { + background: linear-gradient(135deg, var(--color-gray-50) 0%, var(--color-white) 100%) !important; + } + + &.bg-primary { + background: var(--brand-gradient) !important; + color: var(--color-white); + border-bottom-color: rgba(255, 255, 255, 0.2); + } + + &.bg-success { + background: var(--brand-gradient-success) !important; + color: var(--color-white); + border-bottom-color: rgba(255, 255, 255, 0.2); + } +} + +// Card body enhancements +.card-body { + padding: var(--spacing-lg); +} + +// Card footer enhancements +.card-footer { + background-color: var(--color-gray-50); + border-top: 1px solid var(--color-gray-100); + padding: var(--spacing-lg); +} + +// Feature Cards +.feature-card { + background: var(--color-white); + border-radius: var(--border-radius-lg); + padding: var(--spacing-xl); + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); + transition: all 0.3s ease-in-out; + text-align: center; + height: 100%; + + &:hover { + transform: translateY(-4px); + box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); + } + + .feature-icon { + width: 4rem; + height: 4rem; + border-radius: var(--border-radius-full); + display: flex; + align-items: center; + justify-content: center; + margin: 0 auto var(--spacing-lg); + background: var(--brand-gradient); + color: var(--color-white); + font-size: 1.5rem; + box-shadow: 0 8px 16px rgba(102, 126, 234, 0.3); + + &.icon-success { + background: var(--brand-gradient-success); + box-shadow: 0 8px 16px rgba(34, 197, 94, 0.3); + } + + &.icon-warning { + background: var(--brand-gradient-warning); + box-shadow: 0 8px 16px rgba(251, 191, 36, 0.3); + } + + &.icon-info { + background: var(--brand-gradient-info); + box-shadow: 0 8px 16px rgba(59, 130, 246, 0.3); + } + } + + .feature-title { + font-size: 1.25rem; + font-weight: 600; + color: var(--color-dark); + margin-bottom: var(--spacing-sm); + } + + .feature-description { + color: var(--color-gray-600); + line-height: 1.6; + margin-bottom: 0; + } + + .feature-link { + color: var(--brand-primary); + font-weight: 500; + text-decoration: none; + margin-top: var(--spacing-md); + display: inline-flex; + align-items: center; + transition: color 0.2s ease; + + &:hover { + color: var(--brand-primary-dark); + text-decoration: underline; + } + + i { + margin-left: 0.25rem; + transition: transform 0.2s ease; + } + + &:hover i { + transform: translateX(2px); + } + } +} + +// Stats Cards +.stats-card { + background: var(--color-white); + border-radius: var(--border-radius-lg); + padding: var(--spacing-lg); + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); + text-align: center; + transition: all 0.3s ease; + height: 100%; + + &:hover { + transform: translateY(-2px); + box-shadow: 0 12px 20px -5px rgba(0, 0, 0, 0.15); + } + + .stats-number { + font-size: 2.5rem; + font-weight: 800; + color: var(--brand-primary); + margin-bottom: 0.5rem; + line-height: 1; + + &.text-success { + color: var(--brand-success); + } + + &.text-warning { + color: var(--brand-warning); + } + + &.text-danger { + color: var(--brand-danger); + } + } + + .stats-label { + color: var(--color-gray-600); + font-weight: 500; + text-transform: uppercase; + font-size: 0.875rem; + letter-spacing: 0.05em; + margin-bottom: 0; + } + + .stats-change { + margin-top: var(--spacing-sm); + font-size: 0.875rem; + font-weight: 500; + + &.positive { + color: var(--brand-success); + } + + &.negative { + color: var(--brand-danger); + } + + i { + margin-right: 0.25rem; + } + } +} + +// Profile Cards +.profile-card { + background: var(--color-white); + border-radius: var(--border-radius-lg); + padding: var(--spacing-xl); + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); + text-align: center; + transition: all 0.3s ease; + + &:hover { + transform: translateY(-2px); + box-shadow: 0 12px 20px -5px rgba(0, 0, 0, 0.15); + } + + .profile-avatar { + width: 5rem; + height: 5rem; + border-radius: 50%; + margin: 0 auto var(--spacing-md); + border: 4px solid var(--color-white); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + overflow: hidden; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } + + .avatar-initials { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + background: var(--brand-gradient); + color: var(--color-white); + font-weight: 600; + font-size: 1.5rem; + } + } + + .profile-name { + font-size: 1.25rem; + font-weight: 600; + color: var(--color-dark); + margin-bottom: 0.25rem; + } + + .profile-role { + color: var(--color-gray-600); + margin-bottom: var(--spacing-md); + } + + .profile-bio { + color: var(--color-gray-600); + font-size: 0.9rem; + line-height: 1.5; + margin-bottom: var(--spacing-lg); + } + + .profile-actions { + display: flex; + gap: 0.5rem; + justify-content: center; + } +} + +// Testimonial Cards +.testimonial-card { + background: var(--color-white); + border-radius: var(--border-radius-lg); + padding: var(--spacing-xl); + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); + position: relative; + + &::before { + content: '"'; + position: absolute; + top: -0.5rem; + left: var(--spacing-lg); + font-size: 4rem; + color: var(--brand-primary); + font-weight: 700; + line-height: 1; + opacity: 0.3; + } + + .testimonial-content { + font-style: italic; + color: var(--color-gray-700); + margin-bottom: var(--spacing-lg); + font-size: 1.1rem; + line-height: 1.6; + } + + .testimonial-author { + display: flex; + align-items: center; + gap: var(--spacing-md); + + .author-avatar { + width: 3rem; + height: 3rem; + border-radius: 50%; + overflow: hidden; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } + } + + .author-info { + .author-name { + font-weight: 600; + color: var(--color-dark); + margin-bottom: 0.125rem; + } + + .author-role { + font-size: 0.875rem; + color: var(--color-gray-600); + margin-bottom: 0; + } + } + } + + .testimonial-rating { + display: flex; + gap: 0.125rem; + margin-bottom: var(--spacing-md); + + i { + color: var(--brand-warning); + font-size: 0.875rem; + } + } +} + +// Pricing Cards +.pricing-card { + background: var(--color-white); + border-radius: var(--border-radius-lg); + padding: var(--spacing-xl); + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); + text-align: center; + position: relative; + transition: all 0.3s ease; + height: 100%; + + &:hover { + transform: translateY(-4px); + box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); + } + + &.featured { + border: 2px solid var(--brand-primary); + transform: scale(1.05); + + .pricing-badge { + position: absolute; + top: -0.5rem; + left: 50%; + transform: translateX(-50%); + background: var(--brand-gradient); + color: var(--color-white); + padding: 0.375rem 1rem; + border-radius: var(--border-radius-full); + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + } + } + + .pricing-title { + font-size: 1.5rem; + font-weight: 600; + color: var(--color-dark); + margin-bottom: var(--spacing-md); + } + + .pricing-price { + margin-bottom: var(--spacing-lg); + + .price-currency { + font-size: 1.25rem; + color: var(--color-gray-600); + vertical-align: top; + } + + .price-amount { + font-size: 3rem; + font-weight: 800; + color: var(--brand-primary); + line-height: 1; + } + + .price-period { + color: var(--color-gray-600); + font-size: 1rem; + } + } + + .pricing-features { + list-style: none; + padding: 0; + margin: 0 0 var(--spacing-xl); + + li { + padding: 0.5rem 0; + color: var(--color-gray-600); + display: flex; + align-items: center; + + i { + color: var(--brand-success); + margin-right: var(--spacing-sm); + font-size: 0.875rem; + } + } + } +} + +// Card variations +.card-elevated { + box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); +} + +.card-flat { + box-shadow: none; + border: 1px solid var(--color-gray-100); +} + +.card-bordered { + border: 2px solid var(--color-gray-100); + + &.border-primary { + border-color: var(--brand-primary); + } + + &.border-success { + border-color: var(--brand-success); + } + + &.border-warning { + border-color: var(--brand-warning); + } + + &.border-danger { + border-color: var(--brand-danger); + } +} + +// Responsive adjustments +@include media-breakpoint-down(md) { + .feature-card { + padding: var(--spacing-lg); + text-align: left; + + .feature-icon { + margin: 0 0 var(--spacing-md) 0; + width: 3rem; + height: 3rem; + font-size: 1.25rem; + } + } + + .stats-card { + .stats-number { + font-size: 2rem; + } + } + + .profile-card { + padding: var(--spacing-lg); + + .profile-avatar { + width: 4rem; + height: 4rem; + } + } + + .pricing-card { + padding: var(--spacing-lg); + + &.featured { + transform: none; + margin-bottom: var(--spacing-lg); + } + + .pricing-price { + .price-amount { + font-size: 2.5rem; + } + } + } + + .testimonial-card { + padding: var(--spacing-lg); + + .testimonial-content { + font-size: 1rem; + } + } +} + +// Card animations +@keyframes cardFadeIn { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.card-animate { + animation: cardFadeIn 0.6s ease-out; + + &:nth-child(2) { + animation-delay: 0.1s; + } + + &:nth-child(3) { + animation-delay: 0.2s; + } + + &:nth-child(4) { + animation-delay: 0.3s; + } +} diff --git a/resources/sass/components/_forms.scss b/resources/sass/components/_forms.scss new file mode 100644 index 0000000..263e8c8 --- /dev/null +++ b/resources/sass/components/_forms.scss @@ -0,0 +1,585 @@ +// ========================================================================== +// Form Components +// Professional Resume Builder - Form Styles +// ========================================================================== + +// Base form enhancements +.form-control, .form-select { + border: 2px solid var(--color-gray-200); + border-radius: var(--border-radius-md); + padding: 0.75rem 1rem; + font-size: 1rem; + transition: all 0.2s ease-in-out; + background-color: var(--color-white); + + &:focus { + border-color: var(--brand-primary); + box-shadow: 0 0 0 0.2rem rgba(102, 126, 234, 0.1); + background-color: var(--color-white); + } + + &:disabled { + background-color: var(--color-gray-50); + border-color: var(--color-gray-100); + opacity: 0.6; + } + + &.is-valid { + border-color: var(--brand-success); + box-shadow: 0 0 0 0.2rem rgba(34, 197, 94, 0.1); + } + + &.is-invalid { + border-color: var(--brand-danger); + box-shadow: 0 0 0 0.2rem rgba(239, 68, 68, 0.1); + } +} + +// Form labels +.form-label { + font-weight: 500; + color: var(--color-gray-700); + margin-bottom: 0.5rem; + font-size: 0.9rem; + + &.required { + &::after { + content: " *"; + color: var(--brand-danger); + font-weight: 600; + } + } +} + +// Floating labels +.form-floating { + position: relative; + + .form-control, .form-select { + height: 3.5rem; + padding: 1rem 0.75rem; + + &:focus, &:not(:placeholder-shown) { + padding-top: 1.625rem; + padding-bottom: 0.625rem; + } + } + + label { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + padding: 1rem 0.75rem; + overflow: hidden; + text-align: start; + text-overflow: ellipsis; + white-space: nowrap; + pointer-events: none; + border: 2px solid transparent; + transform-origin: 0 0; + transition: opacity 0.1s ease-in-out, transform 0.1s ease-in-out; + font-weight: 400; + color: var(--color-gray-500); + } + + .form-control:focus ~ label, + .form-control:not(:placeholder-shown) ~ label, + .form-select ~ label { + opacity: 0.65; + transform: scale(0.85) translateY(-0.5rem) translateX(0.15rem); + color: var(--brand-primary); + } + + .form-control.is-invalid:focus ~ label, + .form-control.is-invalid:not(:placeholder-shown) ~ label { + color: var(--brand-danger); + } + + .form-control.is-valid:focus ~ label, + .form-control.is-valid:not(:placeholder-shown) ~ label { + color: var(--brand-success); + } +} + +// Input groups +.input-group { + .form-control { + &:not(:first-child) { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + } + + &:not(:last-child) { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } + + &:focus { + z-index: 3; + } + } + + .input-group-text { + background-color: var(--color-gray-50); + border: 2px solid var(--color-gray-200); + color: var(--color-gray-600); + font-weight: 500; + padding: 0.75rem 1rem; + + i { + color: var(--color-gray-400); + } + } +} + +// Form check (checkboxes and radios) +.form-check { + margin-bottom: 0.5rem; + + .form-check-input { + width: 1.25rem; + height: 1.25rem; + margin-top: 0.125rem; + border: 2px solid var(--color-gray-300); + border-radius: var(--border-radius-sm); + transition: all 0.2s ease; + + &:checked { + background-color: var(--brand-primary); + border-color: var(--brand-primary); + } + + &:focus { + box-shadow: 0 0 0 0.2rem rgba(102, 126, 234, 0.1); + border-color: var(--brand-primary); + } + + &[type="radio"] { + border-radius: 50%; + } + } + + .form-check-label { + font-weight: 400; + color: var(--color-gray-700); + margin-left: 0.5rem; + cursor: pointer; + } + + &.form-switch { + .form-check-input { + width: 2.5rem; + height: 1.25rem; + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgba%280,0,0,.25%29'/%3e%3c/svg%3e"); + background-position: left center; + border-radius: var(--border-radius-full); + transition: all 0.2s ease; + + &:checked { + background-position: right center; + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgba%28255,255,255,1.0%29'/%3e%3c/svg%3e"); + } + } + } +} + +// Range inputs +.form-range { + height: 1.5rem; + padding: 0; + background-color: transparent; + appearance: none; + + &::-webkit-slider-track { + width: 100%; + height: 0.5rem; + color: transparent; + cursor: pointer; + background: var(--color-gray-200); + border-radius: var(--border-radius-full); + } + + &::-webkit-slider-thumb { + width: 1.5rem; + height: 1.5rem; + background: var(--brand-primary); + border: 2px solid var(--color-white); + border-radius: 50%; + cursor: pointer; + appearance: none; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2); + transition: all 0.2s ease; + + &:hover { + transform: scale(1.1); + } + } + + &::-moz-range-track { + width: 100%; + height: 0.5rem; + color: transparent; + cursor: pointer; + background: var(--color-gray-200); + border-radius: var(--border-radius-full); + border: none; + } + + &::-moz-range-thumb { + width: 1.5rem; + height: 1.5rem; + background: var(--brand-primary); + border: 2px solid var(--color-white); + border-radius: 50%; + cursor: pointer; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2); + } +} + +// File inputs +.form-control[type="file"] { + padding: 0.375rem 0.75rem; + + &::file-selector-button { + padding: 0.375rem 0.75rem; + margin: -0.375rem -0.75rem -0.375rem 0; + margin-inline-end: 0.75rem; + color: var(--color-gray-600); + background-color: var(--color-gray-100); + pointer-events: none; + border-color: inherit; + border-style: solid; + border-width: 0; + border-inline-end-width: 2px; + border-radius: 0; + transition: all 0.2s ease; + } + + &:hover:not(:disabled):not([readonly])::file-selector-button { + background-color: var(--color-gray-200); + } +} + +// Form validation +.was-validated .form-control:valid, +.form-control.is-valid { + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%2322c55e' d='m2.3 6.73.8.8 4.1-4.1-.8-.8L3.1 5.9l-1.6-1.6-.8.8z'/%3e%3c/svg%3e"); + background-repeat: no-repeat; + background-position: right calc(0.375em + 0.1875rem) center; + background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem); +} + +.was-validated .form-control:invalid, +.form-control.is-invalid { + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23ef4444'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath d='m5.8 4.6 2.4 2.4M8.2 4.6l-2.4 2.4'/%3e%3c/svg%3e"); + background-repeat: no-repeat; + background-position: right calc(0.375em + 0.1875rem) center; + background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem); +} + +// Feedback messages +.valid-feedback { + color: var(--brand-success); + font-size: 0.875rem; + font-weight: 500; + margin-top: 0.25rem; +} + +.invalid-feedback { + color: var(--brand-danger); + font-size: 0.875rem; + font-weight: 500; + margin-top: 0.25rem; +} + +// Custom form controls +.custom-file-upload { + display: inline-block; + padding: 0.75rem 1.5rem; + cursor: pointer; + background: var(--brand-gradient); + color: var(--color-white); + border-radius: var(--border-radius-md); + font-weight: 500; + transition: all 0.2s ease; + text-align: center; + + &:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3); + } + + input[type="file"] { + display: none; + } + + i { + margin-right: 0.5rem; + } +} + +// Multi-step form +.multi-step-form { + .step-indicator { + display: flex; + justify-content: space-between; + margin-bottom: var(--spacing-xl); + padding: 0; + list-style: none; + + .step { + flex: 1; + text-align: center; + position: relative; + + &:not(:last-child)::after { + content: ''; + position: absolute; + top: 1.5rem; + right: -50%; + width: 100%; + height: 2px; + background: var(--color-gray-200); + z-index: -1; + } + + &.active { + &:not(:last-child)::after { + background: var(--brand-primary); + } + + .step-number { + background: var(--brand-primary); + color: var(--color-white); + } + + .step-title { + color: var(--brand-primary); + font-weight: 600; + } + } + + &.completed { + .step-number { + background: var(--brand-success); + color: var(--color-white); + + &::before { + content: "โœ“"; + font-weight: bold; + } + } + + .step-title { + color: var(--brand-success); + } + } + } + + .step-number { + display: inline-flex; + align-items: center; + justify-content: center; + width: 3rem; + height: 3rem; + border-radius: 50%; + background: var(--color-gray-200); + color: var(--color-gray-600); + font-weight: 600; + margin-bottom: 0.5rem; + transition: all 0.2s ease; + } + + .step-title { + font-size: 0.875rem; + color: var(--color-gray-600); + margin: 0; + transition: all 0.2s ease; + } + } + + .step-content { + display: none; + + &.active { + display: block; + animation: fadeIn 0.3s ease-in-out; + } + } + + .step-actions { + display: flex; + justify-content: space-between; + margin-top: var(--spacing-xl); + padding-top: var(--spacing-lg); + border-top: 1px solid var(--color-gray-100); + + .btn { + min-width: 120px; + } + } +} + +// Form sections +.form-section { + background: var(--color-white); + border-radius: var(--border-radius-lg); + padding: var(--spacing-xl); + margin-bottom: var(--spacing-lg); + box-shadow: 0 2px 4px -1px rgba(0, 0, 0, 0.06); + + .section-title { + font-size: 1.25rem; + font-weight: 600; + color: var(--color-dark); + margin-bottom: var(--spacing-md); + padding-bottom: var(--spacing-sm); + border-bottom: 2px solid var(--color-gray-100); + + i { + color: var(--brand-primary); + margin-right: 0.5rem; + } + } + + .section-description { + color: var(--color-gray-600); + margin-bottom: var(--spacing-lg); + font-size: 0.9rem; + } +} + +// Responsive form adjustments +@include media-breakpoint-down(md) { + .form-floating { + .form-control, .form-select { + height: 3.25rem; + padding: 0.875rem 0.75rem; + } + } + + .multi-step-form { + .step-indicator { + .step-number { + width: 2.5rem; + height: 2.5rem; + font-size: 0.875rem; + } + + .step-title { + font-size: 0.75rem; + } + } + + .step-actions { + flex-direction: column; + gap: 0.75rem; + + .btn { + width: 100%; + order: 2; + + &.btn-outline-secondary { + order: 1; + } + } + } + } + + .form-section { + padding: var(--spacing-lg); + + .section-title { + font-size: 1.125rem; + } + } +} + +// Form animations +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes shake { + 0%, 100% { transform: translateX(0); } + 25% { transform: translateX(-5px); } + 75% { transform: translateX(5px); } +} + +.form-control.is-invalid, +.form-select.is-invalid { + animation: shake 0.5s ease-in-out; +} + +// Loading states +.form-loading { + position: relative; + pointer-events: none; + + &::after { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(255, 255, 255, 0.8); + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--border-radius-lg); + z-index: 10; + } +} + +// Form success state +.form-success { + text-align: center; + padding: var(--spacing-4xl) var(--spacing-lg); + + .success-icon { + width: 5rem; + height: 5rem; + border-radius: 50%; + background: var(--brand-gradient-success); + color: var(--color-white); + display: flex; + align-items: center; + justify-content: center; + margin: 0 auto var(--spacing-lg); + font-size: 2rem; + animation: successPulse 2s infinite; + } + + h3 { + color: var(--color-dark); + font-weight: 600; + margin-bottom: var(--spacing-md); + } + + p { + color: var(--color-gray-600); + margin-bottom: var(--spacing-xl); + } +} + +@keyframes successPulse { + 0% { + box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.7); + } + 70% { + box-shadow: 0 0 0 10px rgba(34, 197, 94, 0); + } + 100% { + box-shadow: 0 0 0 0 rgba(34, 197, 94, 0); + } +} diff --git a/resources/sass/layouts/_footer.scss b/resources/sass/layouts/_footer.scss new file mode 100644 index 0000000..9399f19 --- /dev/null +++ b/resources/sass/layouts/_footer.scss @@ -0,0 +1,484 @@ +// ========================================================================== +// Footer Layout Styles +// Professional Resume Builder - Footer +// ========================================================================== + +.main-footer { + background: linear-gradient(135deg, var(--color-gray-900) 0%, var(--color-gray-800) 100%); + color: var(--color-gray-300); + padding: var(--spacing-4xl) 0 var(--spacing-xl); + margin-top: auto; + + .footer-content { + .footer-brand { + margin-bottom: var(--spacing-xl); + + .brand-logo { + display: flex; + align-items: center; + margin-bottom: var(--spacing-md); + + .brand-icon { + width: 3rem; + height: 3rem; + border-radius: var(--border-radius-lg); + background: var(--brand-gradient); + color: var(--color-white); + display: flex; + align-items: center; + justify-content: center; + margin-right: var(--spacing-md); + font-size: 1.5rem; + box-shadow: 0 8px 16px rgba(102, 126, 234, 0.3); + } + + .brand-text { + font-size: 1.5rem; + font-weight: 700; + color: var(--color-white); + } + } + + .brand-description { + color: var(--color-gray-400); + line-height: 1.6; + max-width: 300px; + } + } + + .footer-links { + .footer-section { + margin-bottom: var(--spacing-xl); + + h6 { + color: var(--color-white); + font-weight: 600; + font-size: 1rem; + margin-bottom: var(--spacing-md); + text-transform: uppercase; + letter-spacing: 0.05em; + } + + ul { + list-style: none; + padding: 0; + margin: 0; + + li { + margin-bottom: 0.5rem; + + a { + color: var(--color-gray-400); + text-decoration: none; + font-size: 0.9rem; + transition: all 0.2s ease; + display: inline-flex; + align-items: center; + + &:hover { + color: var(--color-white); + transform: translateX(4px); + } + + i { + margin-right: 0.5rem; + width: 1rem; + color: var(--brand-primary); + } + } + } + } + } + } + + .footer-contact { + .contact-item { + display: flex; + align-items: center; + margin-bottom: var(--spacing-md); + + .contact-icon { + width: 2.5rem; + height: 2.5rem; + border-radius: var(--border-radius-md); + background: rgba(102, 126, 234, 0.1); + color: var(--brand-primary); + display: flex; + align-items: center; + justify-content: center; + margin-right: var(--spacing-md); + font-size: 1rem; + } + + .contact-info { + .contact-label { + font-size: 0.8rem; + color: var(--color-gray-500); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 0.125rem; + } + + .contact-value { + color: var(--color-gray-300); + font-size: 0.9rem; + margin: 0; + + a { + color: inherit; + text-decoration: none; + transition: color 0.2s ease; + + &:hover { + color: var(--color-white); + } + } + } + } + } + } + + .footer-social { + .social-links { + display: flex; + gap: var(--spacing-sm); + margin-bottom: var(--spacing-lg); + + a { + width: 2.75rem; + height: 2.75rem; + border-radius: var(--border-radius-md); + background: rgba(255, 255, 255, 0.1); + color: var(--color-gray-300); + display: flex; + align-items: center; + justify-content: center; + text-decoration: none; + font-size: 1.125rem; + transition: all 0.3s ease; + + &:hover { + background: var(--brand-primary); + color: var(--color-white); + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4); + } + + &.facebook:hover { + background: #3b5998; + } + + &.twitter:hover { + background: #1da1f2; + } + + &.linkedin:hover { + background: #0077b5; + } + + &.github:hover { + background: #333; + } + + &.instagram:hover { + background: linear-gradient(45deg, #f09433 0%,#e6683c 25%,#dc2743 50%,#cc2366 75%,#bc1888 100%); + } + } + } + + .newsletter { + .newsletter-title { + color: var(--color-white); + font-weight: 600; + margin-bottom: var(--spacing-sm); + font-size: 1rem; + } + + .newsletter-description { + color: var(--color-gray-400); + font-size: 0.875rem; + margin-bottom: var(--spacing-md); + } + + .newsletter-form { + display: flex; + gap: 0.5rem; + + .form-control { + flex: 1; + border: 1px solid rgba(255, 255, 255, 0.2); + background: rgba(255, 255, 255, 0.1); + color: var(--color-white); + border-radius: var(--border-radius-md); + padding: 0.75rem 1rem; + + &::placeholder { + color: rgba(255, 255, 255, 0.6); + } + + &:focus { + border-color: var(--brand-primary); + background: rgba(255, 255, 255, 0.15); + box-shadow: 0 0 0 0.2rem rgba(102, 126, 234, 0.25); + } + } + + .btn { + background: var(--brand-gradient); + border: none; + color: var(--color-white); + padding: 0.75rem 1.5rem; + border-radius: var(--border-radius-md); + font-weight: 500; + transition: all 0.2s ease; + + &:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4); + } + } + } + } + } + } + + .footer-bottom { + border-top: 1px solid rgba(255, 255, 255, 0.1); + padding-top: var(--spacing-lg); + margin-top: var(--spacing-xl); + + .footer-copyright { + color: var(--color-gray-500); + font-size: 0.875rem; + margin: 0; + + a { + color: var(--brand-primary); + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + } + + .footer-legal { + .legal-links { + display: flex; + gap: var(--spacing-lg); + list-style: none; + padding: 0; + margin: 0; + + a { + color: var(--color-gray-500); + text-decoration: none; + font-size: 0.875rem; + transition: color 0.2s ease; + + &:hover { + color: var(--color-white); + } + } + } + } + } +} + +// Minimal footer variant +.footer-minimal { + background: var(--color-white); + border-top: 1px solid var(--color-gray-100); + padding: var(--spacing-lg) 0; + + .footer-content { + display: flex; + justify-content: space-between; + align-items: center; + + .footer-text { + color: var(--color-gray-600); + font-size: 0.875rem; + margin: 0; + } + + .footer-links { + display: flex; + gap: var(--spacing-lg); + list-style: none; + padding: 0; + margin: 0; + + a { + color: var(--color-gray-600); + text-decoration: none; + font-size: 0.875rem; + transition: color 0.2s ease; + + &:hover { + color: var(--brand-primary); + } + } + } + } +} + +// Auth footer (for login/register pages) +.auth-footer { + background: transparent; + padding: var(--spacing-lg) 0; + text-align: center; + margin-top: auto; + + .footer-text { + color: var(--color-gray-600); + font-size: 0.875rem; + margin-bottom: var(--spacing-md); + } + + .footer-links { + display: flex; + justify-content: center; + gap: var(--spacing-lg); + list-style: none; + padding: 0; + margin: 0; + + a { + color: var(--brand-primary); + text-decoration: none; + font-size: 0.875rem; + font-weight: 500; + transition: color 0.2s ease; + + &:hover { + color: var(--brand-primary-dark); + text-decoration: underline; + } + } + } +} + +// Responsive design +@include media-breakpoint-down(lg) { + .main-footer { + .footer-content { + .footer-links { + .footer-section { + margin-bottom: var(--spacing-lg); + } + } + + .footer-social { + margin-top: var(--spacing-xl); + + .social-links { + justify-content: center; + } + + .newsletter-form { + flex-direction: column; + + .form-control { + margin-bottom: 0.5rem; + } + + .btn { + width: 100%; + } + } + } + } + + .footer-bottom { + text-align: center; + + .footer-legal { + margin-top: var(--spacing-md); + + .legal-links { + justify-content: center; + flex-wrap: wrap; + } + } + } + } + + .footer-minimal { + .footer-content { + flex-direction: column; + text-align: center; + gap: var(--spacing-md); + + .footer-links { + justify-content: center; + flex-wrap: wrap; + } + } + } +} + +@include media-breakpoint-down(md) { + .main-footer { + padding: var(--spacing-2xl) 0 var(--spacing-lg); + + .footer-content { + .footer-brand { + text-align: center; + + .brand-description { + margin: 0 auto; + } + } + + .footer-links { + text-align: center; + } + + .footer-contact { + text-align: center; + + .contact-item { + justify-content: center; + } + } + } + } + + .auth-footer { + .footer-links { + flex-direction: column; + gap: var(--spacing-sm); + } + } +} + +// Footer animations +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(30px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.footer-animate { + .footer-section { + animation: fadeInUp 0.6s ease-out; + + &:nth-child(2) { + animation-delay: 0.1s; + } + + &:nth-child(3) { + animation-delay: 0.2s; + } + + &:nth-child(4) { + animation-delay: 0.3s; + } + } +} diff --git a/resources/sass/layouts/_header.scss b/resources/sass/layouts/_header.scss new file mode 100644 index 0000000..0ea6ea8 --- /dev/null +++ b/resources/sass/layouts/_header.scss @@ -0,0 +1,478 @@ +// ========================================================================== +// Header Layout Styles +// Professional Resume Builder - Header/Navigation +// ========================================================================== + +// Main navigation +.main-navbar { + background: var(--color-white); + box-shadow: 0 2px 4px -1px rgba(0, 0, 0, 0.06), 0 1px 2px -1px rgba(0, 0, 0, 0.06); + padding: 0.5rem 0; + position: sticky; + top: 0; + z-index: 1030; + transition: all 0.3s ease; + + &.scrolled { + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); + } + + .navbar-brand { + font-weight: 700; + font-size: 1.5rem; + color: var(--brand-primary); + text-decoration: none; + display: flex; + align-items: center; + transition: all 0.2s ease; + + &:hover { + color: var(--brand-primary-dark); + transform: scale(1.02); + } + + .brand-icon { + width: 2.5rem; + height: 2.5rem; + border-radius: var(--border-radius-md); + background: var(--brand-gradient); + color: var(--color-white); + display: flex; + align-items: center; + justify-content: center; + margin-right: 0.75rem; + font-size: 1.25rem; + box-shadow: 0 4px 8px rgba(102, 126, 234, 0.3); + } + } + + .navbar-nav { + .nav-link { + font-weight: 500; + color: var(--color-gray-700); + padding: 0.625rem 1rem; + border-radius: var(--border-radius-md); + transition: all 0.2s ease; + position: relative; + + &:hover { + color: var(--brand-primary); + background-color: rgba(102, 126, 234, 0.1); + } + + &.active { + color: var(--brand-primary); + font-weight: 600; + + &::after { + content: ''; + position: absolute; + bottom: -0.5rem; + left: 50%; + transform: translateX(-50%); + width: 20px; + height: 2px; + background: var(--brand-primary); + border-radius: var(--border-radius-full); + } + } + + i { + margin-right: 0.5rem; + font-size: 1rem; + } + } + } + + .navbar-toggler { + border: none; + padding: 0.375rem 0.5rem; + border-radius: var(--border-radius-md); + transition: all 0.2s ease; + + &:focus { + box-shadow: 0 0 0 0.2rem rgba(102, 126, 234, 0.1); + } + + &:hover { + background-color: rgba(102, 126, 234, 0.1); + } + + .navbar-toggler-icon { + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28102, 126, 234, 0.75%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e"); + } + } +} + +// User dropdown +.user-dropdown { + .dropdown-toggle { + border: none; + background: transparent; + padding: 0.375rem; + border-radius: var(--border-radius-full); + transition: all 0.2s ease; + display: flex; + align-items: center; + + &:hover { + background-color: rgba(102, 126, 234, 0.1); + } + + &::after { + display: none; + } + + .user-avatar { + width: 2.5rem; + height: 2.5rem; + border-radius: 50%; + border: 2px solid var(--color-white); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + object-fit: cover; + margin-right: 0.5rem; + } + + .user-info { + text-align: left; + + .user-name { + font-size: 0.875rem; + font-weight: 600; + color: var(--color-dark); + line-height: 1.2; + margin: 0; + } + + .user-role { + font-size: 0.75rem; + color: var(--color-gray-600); + line-height: 1.2; + margin: 0; + } + } + + .dropdown-arrow { + margin-left: 0.5rem; + color: var(--color-gray-400); + transition: transform 0.2s ease; + } + + &[aria-expanded="true"] { + .dropdown-arrow { + transform: rotate(180deg); + } + } + } + + .dropdown-menu { + border: none; + border-radius: var(--border-radius-lg); + box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); + padding: 0.5rem; + min-width: 200px; + margin-top: 0.5rem; + + .dropdown-item { + border-radius: var(--border-radius-md); + padding: 0.625rem 1rem; + font-weight: 400; + color: var(--color-gray-700); + transition: all 0.2s ease; + display: flex; + align-items: center; + + &:hover { + background-color: var(--color-gray-50); + color: var(--color-dark); + } + + &:active { + background-color: var(--color-gray-100); + } + + i { + margin-right: 0.75rem; + width: 1rem; + color: var(--color-gray-500); + } + + &.text-danger { + color: var(--brand-danger); + + &:hover { + background-color: rgba(239, 68, 68, 0.1); + color: var(--brand-danger); + } + + i { + color: var(--brand-danger); + } + } + } + + .dropdown-divider { + border-color: var(--color-gray-100); + margin: 0.5rem 0; + } + } +} + +// Notification badge +.notification-badge { + position: relative; + + .badge { + position: absolute; + top: -0.375rem; + right: -0.375rem; + background: var(--brand-danger); + color: var(--color-white); + font-size: 0.625rem; + padding: 0.25rem 0.375rem; + border-radius: var(--border-radius-full); + font-weight: 600; + min-width: 1.125rem; + height: 1.125rem; + display: flex; + align-items: center; + justify-content: center; + border: 2px solid var(--color-white); + } +} + +// Search bar in header +.header-search { + position: relative; + max-width: 400px; + margin: 0 auto; + + .search-input { + width: 100%; + padding: 0.625rem 1rem 0.625rem 2.75rem; + border: 2px solid var(--color-gray-200); + border-radius: var(--border-radius-full); + background: var(--color-white); + transition: all 0.2s ease; + font-size: 0.9rem; + + &:focus { + border-color: var(--brand-primary); + box-shadow: 0 0 0 0.2rem rgba(102, 126, 234, 0.1); + outline: none; + } + + &::placeholder { + color: var(--color-gray-500); + } + } + + .search-icon { + position: absolute; + left: 1rem; + top: 50%; + transform: translateY(-50%); + color: var(--color-gray-400); + z-index: 3; + } + + .search-results { + position: absolute; + top: calc(100% + 0.5rem); + left: 0; + right: 0; + background: var(--color-white); + border-radius: var(--border-radius-lg); + box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); + max-height: 300px; + overflow-y: auto; + z-index: 1050; + display: none; + + &.show { + display: block; + } + + .search-item { + padding: 0.75rem 1rem; + border-bottom: 1px solid var(--color-gray-100); + cursor: pointer; + transition: background-color 0.2s ease; + + &:hover { + background-color: var(--color-gray-50); + } + + &:last-child { + border-bottom: none; + } + + .item-title { + font-weight: 500; + color: var(--color-dark); + margin-bottom: 0.25rem; + } + + .item-description { + font-size: 0.875rem; + color: var(--color-gray-600); + } + } + } +} + +// Breadcrumb +.custom-breadcrumb { + background: transparent; + padding: 0; + margin-bottom: var(--spacing-lg); + + .breadcrumb { + background: transparent; + padding: 0; + margin: 0; + + .breadcrumb-item { + font-size: 0.875rem; + color: var(--color-gray-600); + + &.active { + color: var(--color-gray-800); + font-weight: 500; + } + + a { + color: var(--brand-primary); + text-decoration: none; + transition: color 0.2s ease; + + &:hover { + color: var(--brand-primary-dark); + text-decoration: underline; + } + } + + &::before { + color: var(--color-gray-400); + } + + i { + margin-right: 0.25rem; + } + } + } +} + +// Mobile navigation +@include media-breakpoint-down(lg) { + .main-navbar { + .navbar-collapse { + margin-top: 1rem; + padding: 1rem; + background: var(--color-white); + border-radius: var(--border-radius-lg); + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); + + .navbar-nav { + .nav-link { + padding: 0.875rem 1rem; + margin-bottom: 0.25rem; + + &.active::after { + display: none; + } + + &.active { + background-color: rgba(102, 126, 234, 0.1); + } + } + } + } + + .navbar-brand { + .brand-icon { + width: 2rem; + height: 2rem; + font-size: 1rem; + } + } + } + + .user-dropdown { + margin-top: var(--spacing-md); + padding-top: var(--spacing-md); + border-top: 1px solid var(--color-gray-100); + + .dropdown-toggle { + width: 100%; + justify-content: space-between; + padding: 0.75rem 1rem; + background-color: var(--color-gray-50); + border-radius: var(--border-radius-md); + + .user-info { + text-align: left; + } + } + + .dropdown-menu { + position: static; + box-shadow: none; + border: none; + background: transparent; + padding: 0; + margin-top: 0.5rem; + + .dropdown-item { + background: var(--color-white); + margin-bottom: 0.25rem; + border-radius: var(--border-radius-md); + + &:hover { + background-color: var(--color-gray-50); + } + } + } + } + + .header-search { + margin: var(--spacing-md) 0; + + .search-input { + font-size: 1rem; + padding: 0.75rem 1rem 0.75rem 3rem; + } + + .search-icon { + left: 1.125rem; + } + } +} + +// Header animations +@keyframes slideDown { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.navbar-collapse.collapsing, +.navbar-collapse.show { + animation: slideDown 0.3s ease-out; +} + +// Sticky header behavior +.header-hidden { + transform: translateY(-100%); + transition: transform 0.3s ease-in-out; +} + +.header-visible { + transform: translateY(0); + transition: transform 0.3s ease-in-out; +} diff --git a/resources/sass/pages/_dashboard-old.scss b/resources/sass/pages/_dashboard-old.scss new file mode 100644 index 0000000..eb14386 --- /dev/null +++ b/resources/sass/pages/_dashboard-old.scss @@ -0,0 +1,570 @@ +/** + * Dashboard Page Styles - Enterprise SCSS Architecture + * Professional Resume Builder + * + * @author David Valera Melendez + * @created 2025-08-08 + * @location Made in Germany ๐Ÿ‡ฉ๐Ÿ‡ช + */ + +// ========================================================================== +// Dashboard Container +// ========================================================================== +.dashboard-container { + padding: $spacing-lg 0; + + @include media-down($breakpoint-md) { + padding: $spacing-base 0; + } +} + +// ========================================================================== +// Hero Section +// ========================================================================== +.hero-section { + margin-bottom: $spacing-4xl; + + .hero-card { + @include card-base; + background: $gradient-primary; + color: $white; + border-radius: $radius-2xl; + box-shadow: $shadow-2xl; + border: none; + overflow: hidden; + position: relative; + + &::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + @include grid-pattern(60px, rgba(255, 255, 255, 0.1)); + pointer-events: none; + } + + .card-body { + padding: $spacing-4xl; + position: relative; + z-index: 1; + + @include media-down($breakpoint-md) { + padding: $spacing-3xl $spacing-lg; + } + } + + .hero-content { + text-align: center; + position: relative; + } + + .hero-icon { + margin-bottom: $spacing-xl; + + .large-icon { + font-size: 4rem; + transition: all $transition-base; + + &:hover { + transform: scale(1.1) rotate(5deg); + } + + @include media-down($breakpoint-md) { + font-size: 3rem; + } + } + } + + .hero-title { + font-size: $font-size-4xl; + font-weight: $font-weight-bold; + margin-bottom: $spacing-lg; + text-shadow: 0 2px 4px rgba($black, 0.2); + + @include media-down($breakpoint-md) { + font-size: $font-size-3xl; + } + } + + .hero-subtitle { + font-size: $font-size-lg; + opacity: 0.9; + margin-bottom: $spacing-lg; + font-weight: $font-weight-light; + + @include media-down($breakpoint-md) { + font-size: $font-size-base; + } + } + + .author-signature { + font-size: $font-size-base; + opacity: 0.8; + margin-bottom: $spacing-2xl; + font-weight: $font-weight-medium; + } + + .hero-actions { + @include flex-center; + gap: $spacing-lg; + flex-wrap: wrap; + + @include media-down($breakpoint-md) { + flex-direction: column; + gap: $spacing-base; + + .btn { + width: 100%; + max-width: 300px; + } + } + + .btn { + @include button-base; + padding: $spacing-md $spacing-xl; + font-size: $font-size-base; + font-weight: $font-weight-semibold; + border-radius: $radius-md; + transition: all $transition-base; + + &.btn-light { + background-color: $white; + color: $primary-color; + + &:hover { + background-color: rgba($white, 0.9); + transform: translateY(-2px); + box-shadow: $shadow-lg; + } + } + + &.btn-outline-light { + background-color: transparent; + color: $white; + border: 2px solid rgba($white, 0.8); + + &:hover { + background-color: rgba($white, 0.1); + border-color: $white; + transform: translateY(-2px); + } + } + } + } + } +} + +// ========================================================================== +// Stats Section +// ========================================================================== +.stats-section { + margin-bottom: $spacing-4xl; + + .stat-card { + @include card-base; + @include card-hover; + border: none; + height: 100%; + transition: all $transition-base; + + &:hover { + transform: translateY(-8px); + box-shadow: $shadow-xl; + + .stat-icon { + transform: scale(1.1) rotate(5deg); + } + } + + .card-body { + padding: $spacing-2xl; + text-align: center; + } + + .stat-icon { + width: 60px; + height: 60px; + @include flex-center; + margin: 0 auto $spacing-lg auto; + border-radius: $radius-full; + transition: all $transition-base; + + i { + font-size: $font-size-2xl; + } + + &.bg-primary { + background: linear-gradient(135deg, $primary-color, lighten($primary-color, 10%)); + } + + &.bg-success { + background: linear-gradient(135deg, $success-color, lighten($success-color, 10%)); + } + + &.bg-warning { + background: linear-gradient(135deg, $warning-color, lighten($warning-color, 10%)); + } + + &.bg-info { + background: linear-gradient(135deg, $info-color, lighten($info-color, 10%)); + } + } + + .stat-number { + font-size: $font-size-3xl; + font-weight: $font-weight-bold; + margin-bottom: $spacing-xs; + line-height: 1; + + &.text-primary { color: $primary-color; } + &.text-success { color: $success-color; } + &.text-warning { color: $warning-color; } + &.text-info { color: $info-color; } + + @include media-down($breakpoint-md) { + font-size: $font-size-2xl; + } + } + + .stat-label { + font-size: $font-size-base; + color: $text-muted; + font-weight: $font-weight-medium; + margin: 0; + } + } +} + +// ========================================================================== +// Features Section +// ========================================================================== +.features-section { + margin-bottom: $spacing-4xl; + + .section-title { + font-size: $font-size-3xl; + font-weight: $font-weight-semibold; + color: $text-primary; + margin-bottom: $spacing-sm; + + @include media-down($breakpoint-md) { + font-size: $font-size-2xl; + } + } + + .section-subtitle { + font-size: $font-size-lg; + color: $text-muted; + margin-bottom: $spacing-2xl; + } + + .feature-card { + @include card-base; + @include card-hover; + border: none; + height: 100%; + transition: all $transition-base; + + &:hover { + transform: translateY(-8px); + box-shadow: $shadow-xl; + + .feature-avatar { + transform: scale(1.1); + } + } + + .card-body { + padding: $spacing-2xl; + text-align: center; + } + + .feature-avatar { + width: 80px; + height: 80px; + @include flex-center; + margin: 0 auto $spacing-lg auto; + border-radius: $radius-full; + background-color: rgba($primary-color, 0.1); + transition: all $transition-base; + + i { + font-size: $font-size-3xl; + color: $primary-color; + } + } + + .card-title { + font-size: $font-size-xl; + font-weight: $font-weight-semibold; + color: $text-primary; + margin-bottom: $spacing-lg; + } + + .card-text { + font-size: $font-size-base; + color: $text-muted; + line-height: $line-height-relaxed; + margin: 0; + } + } +} + +// ========================================================================== +// Recent Activity Section +// ========================================================================== +.recent-activity-section { + margin-bottom: $spacing-4xl; + + .card { + @include card-base; + border: none; + + .card-header { + background: transparent; + border: none; + padding: $spacing-xl $spacing-xl 0 $spacing-xl; + + .card-title { + font-size: $font-size-xl; + font-weight: $font-weight-semibold; + color: $text-primary; + margin: 0; + + i { + color: $primary-color; + margin-right: $spacing-sm; + } + } + } + + .card-body { + padding: $spacing-lg $spacing-xl $spacing-xl $spacing-xl; + } + } + + .activity-item { + padding: $spacing-lg; + border: 2px solid $border-color; + border-radius: $radius-md; + background-color: rgba($gray-50, 0.5); + transition: all $transition-base; + + &:hover { + background-color: rgba($gray-100, 0.8); + border-color: $border-color-hover; + transform: translateX(4px); + } + + h6 { + font-size: $font-size-base; + font-weight: $font-weight-semibold; + color: $text-primary; + margin-bottom: $spacing-xs; + } + + p { + font-size: $font-size-sm; + color: $text-muted; + margin-bottom: $spacing-sm; + } + + .text-muted { + &.small { + font-size: $font-size-xs; + + i { + margin-right: $spacing-xs; + } + } + } + } +} + +// ========================================================================== +// Quick Actions Section +// ========================================================================== +.quick-actions-section { + .card { + @include card-base; + border: none; + + .card-header { + background: transparent; + border: none; + padding: $spacing-xl $spacing-xl 0 $spacing-xl; + + .card-title { + font-size: $font-size-xl; + font-weight: $font-weight-semibold; + color: $text-primary; + margin: 0; + + i { + color: $primary-color; + margin-right: $spacing-sm; + } + } + } + + .card-body { + padding: $spacing-lg $spacing-xl $spacing-xl $spacing-xl; + } + } + + .action-card { + @include card-base; + text-decoration: none; + height: 100%; + transition: all $transition-base; + position: relative; + overflow: hidden; + + &.border-primary { + border-color: rgba($primary-color, 0.3); + + &:hover { + border-color: $primary-color; + background-color: rgba($primary-color, 0.02); + transform: translateY(-8px) scale(1.02); + box-shadow: $shadow-xl; + + i { + transform: scale(1.2) rotate(10deg); + } + } + } + + &.border-success { + border-color: rgba($success-color, 0.3); + + &:hover { + border-color: $success-color; + background-color: rgba($success-color, 0.02); + transform: translateY(-8px) scale(1.02); + box-shadow: $shadow-xl; + + i { + transform: scale(1.2) rotate(-10deg); + } + } + } + + &.border-warning { + border-color: rgba($warning-color, 0.3); + + &:hover { + border-color: $warning-color; + background-color: rgba($warning-color, 0.02); + transform: translateY(-8px) scale(1.02); + box-shadow: $shadow-xl; + + i { + transform: scale(1.2) rotate(5deg); + } + } + } + + .card-body { + padding: $spacing-2xl; + text-align: center; + } + + i { + font-size: 4rem; + margin-bottom: $spacing-lg; + transition: all $transition-base; + + &.text-primary { color: $primary-color; } + &.text-success { color: $success-color; } + &.text-warning { color: $warning-color; } + } + + h6 { + font-size: $font-size-base; + font-weight: $font-weight-semibold; + margin-bottom: $spacing-sm; + + &.text-primary { color: $primary-color; } + &.text-success { color: $success-color; } + &.text-warning { color: $warning-color; } + } + + p { + font-size: $font-size-sm; + color: $text-muted; + margin: 0; + line-height: $line-height-normal; + } + } +} + +// ========================================================================== +// Animations +// ========================================================================== +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(30px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.dashboard-container > section { + animation: fadeInUp 0.6s ease-out; + + &:nth-child(1) { animation-delay: 0; } + &:nth-child(2) { animation-delay: 0.1s; } + &:nth-child(3) { animation-delay: 0.2s; } + &:nth-child(4) { animation-delay: 0.3s; } + &:nth-child(5) { animation-delay: 0.4s; } +} + +// ========================================================================== +// Responsive Design +// ========================================================================== +@include media-down($breakpoint-lg) { + .features-section { + .feature-card { + margin-bottom: $spacing-xl; + } + } + + .quick-actions-section { + .action-card { + margin-bottom: $spacing-lg; + } + } +} + +@include media-down($breakpoint-md) { + .dashboard-container { + padding: $spacing-base 0; + } + + .stats-section, + .features-section, + .recent-activity-section { + margin-bottom: $spacing-2xl; + } + + .hero-section { + margin-bottom: $spacing-2xl; + } + + .stat-card, + .feature-card { + margin-bottom: $spacing-lg; + } + + .recent-activity-section { + .activity-item { + margin-bottom: $spacing-base; + } + } +} diff --git a/resources/sass/pages/_dashboard.scss b/resources/sass/pages/_dashboard.scss new file mode 100644 index 0000000..cd7985d --- /dev/null +++ b/resources/sass/pages/_dashboard.scss @@ -0,0 +1,357 @@ +/** + * Dashboard/Home Page - Professional Bootstrap Design + * Clean professional homepage styling + * + * @author David Valera Melendez + * @created 2025-08-08 + * @location Made in Germany ๐Ÿ‡ฉ๐Ÿ‡ช + */ + +/* ========================================================================== + Home Container & Layout (Matching Angular) + ========================================================================== */ + +.home-container { + max-width: 1200px; + margin: 0 auto; + padding: 32px 24px 0 24px; +} + +/* ========================================================================== + Hero Section (Exact Angular Material Match) + ========================================================================== */ + +.hero-section { + margin-bottom: 48px; +} + +.hero-card { + text-align: center; + padding: 48px 24px; + @include gradient-background(var(--gradient-primary)); + color: var(--color-text-white); + border-radius: var(--border-radius-xl); + border: none; + box-shadow: var(--shadow-lg); +} + +.hero-content { + max-width: 600px; + margin: 0 auto; +} + +.hero-icon .large-icon { + font-size: 72px; + width: 72px; + height: 72px; + margin-bottom: 24px; + color: var(--color-text-white-muted); +} + +.hero-title { + font-size: 2.5rem; + font-weight: $font-weight-light; + margin-bottom: 16px; + line-height: 1.2; + color: var(--color-text-white); +} + +.hero-subtitle { + font-size: 1.25rem; + margin-bottom: 16px; + opacity: 0.9; + line-height: 1.5; + color: var(--color-text-white); +} + +.author-signature { + font-size: 1rem; + margin-bottom: 32px; + opacity: 0.8; + color: var(--color-text-white); +} + +.hero-actions { + display: flex; + gap: 16px; + justify-content: center; + flex-wrap: wrap; +} + +.primary-cta, .secondary-cta { + @include material-button(); + display: flex; + align-items: center; + gap: 8px; + padding: 12px 24px; + font-size: 1rem; + font-weight: $font-weight-medium; + border-radius: var(--border-radius-md); + transition: var(--transition-normal); + text-decoration: none; + + i { + font-size: 1.1rem; + } +} + +.primary-cta { + background-color: var(--color-text-white); + color: var(--color-primary); + border: 2px solid var(--color-text-white); + + &:hover { + background-color: transparent; + color: var(--color-text-white); + border-color: var(--color-text-white); + transform: translateY(-2px); + box-shadow: var(--shadow-md); + } +} + +.secondary-cta { + background-color: transparent; + color: var(--color-text-white); + border: 2px solid var(--color-text-white); + + &:hover { + background-color: var(--color-text-white); + color: var(--color-primary); + transform: translateY(-2px); + box-shadow: var(--shadow-md); + } +} + +/* ========================================================================== + Features Section (Angular Material Grid Match) + ========================================================================== */ + +.features-section { + margin-bottom: 48px; +} + +.section-header { + text-align: center; + margin-bottom: 32px; +} + +.section-header h2 { + font-size: 2rem; + font-weight: $font-weight-normal; + margin-bottom: 8px; + color: var(--color-text-primary); +} + +.section-header p { + font-size: 1.125rem; + color: var(--color-text-secondary); +} + +.features-grid { + margin-bottom: 24px; +} + +.feature-card { + @include material-card; + height: 100%; + padding: 24px; + display: flex; + flex-direction: column; + border: 1px solid var(--color-border-light); + + .card-header { + background: none; + border: none; + padding: 0; + margin-bottom: 16px; + } + + .card-body { + padding: 0; + flex-grow: 1; + } +} + +.feature-avatar { + background-color: var(--color-primary); + color: var(--color-text-white); + width: 48px; + height: 48px; + border-radius: var(--border-radius-round); + display: flex; + align-items: center; + justify-content: center; + font-size: 1.5rem; + flex-shrink: 0; +} + +.feature-highlights { + list-style: none; + padding: 0; + margin: 16px 0 0; +} + +.feature-highlights li { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; + font-size: 0.9rem; + color: var(--color-text-secondary); +} + +.check-icon { + color: var(--color-success); + font-size: 16px; + flex-shrink: 0; +} + +/* ========================================================================== + Technology Stack Section (Angular Material Match) + ========================================================================== */ + +.tech-section { + margin-bottom: 48px; +} + +.tech-card { + @include material-card; + padding: 24px; + border: 1px solid var(--color-border-light); + + .card-header { + background: none; + border: none; + padding: 0; + margin-bottom: 24px; + + .card-title { + color: var(--color-text-primary); + font-size: 1.25rem; + font-weight: $font-weight-medium; + + i { + color: var(--color-primary); + font-size: 1.5rem; + } + } + } + + .card-body { + padding: 0; + } +} + +.tech-stack { + display: flex; + flex-wrap: wrap; + gap: 24px; + margin: 24px 0; + justify-content: center; +} + +.tech-item { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 16px; + border-radius: var(--border-radius-md); + background-color: var(--color-surface-variant); + border: 1px solid var(--color-border-light); + transition: var(--transition-normal); + min-width: 100px; + + &:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-md); + background-color: var(--color-surface); + } +} + +.tech-item i { + color: var(--color-primary); + font-size: 32px; + width: 32px; + height: 32px; +} + +.tech-item span { + font-size: 0.9rem; + font-weight: $font-weight-medium; + color: var(--color-text-primary); + text-align: center; +} + +.tech-description { + text-align: center; + color: var(--color-text-secondary); + margin-top: 24px; + line-height: 1.6; + font-size: 1rem; +} + +/* ========================================================================== + Responsive Design (Angular Material Breakpoints) + ========================================================================== */ + +@media (max-width: 768px) { + .home-container { + padding: 24px 16px 0 16px; + } + + .hero-card { + padding: 32px 16px; + } + + .hero-title { + font-size: 2rem; + } + + .hero-actions { + flex-direction: column; + align-items: center; + + .primary-cta, + .secondary-cta { + width: 100%; + max-width: 280px; + justify-content: center; + } + } + + .tech-stack { + justify-content: space-around; + } + + .tech-item { + min-width: 80px; + padding: 12px; + } +} + +@media (max-width: 992px) { + .features-grid { + .col-lg-4:nth-child(3) { + margin-top: 1rem; + } + } +} + +@media (max-width: 480px) { + .home-container { + padding: 16px 12px 0 12px; + } + + .hero-title { + font-size: 1.75rem; + } + + .hero-subtitle { + font-size: 1rem; + } + + .section-header h2 { + font-size: 1.5rem; + } +} diff --git a/resources/sass/pages/_login.scss b/resources/sass/pages/_login.scss new file mode 100644 index 0000000..7eac1ee --- /dev/null +++ b/resources/sass/pages/_login.scss @@ -0,0 +1,494 @@ +/** + * Login Page - Professional Bootstrap Design + * Clean and modern login interface styling + * + * @author David Valera Melendez + * @created 2025-08-08 + * @location Made in Germany ๐Ÿ‡ฉ๐Ÿ‡ช + */ + +/* ========================================================================== + Login Container & Layout (Angular Material Match) + ========================================================================== */ + +.login-container { + min-height: 100vh; + display: flex; + background-color: var(--color-background); +} + +/* ========================================================================== + Hero Background Section (Left Side - Angular Material Style) + ========================================================================== */ + +.hero-background { + flex: 1; + @include gradient-background(var(--gradient-primary)); + position: relative; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + + @media (max-width: 992px) { + display: none; + } +} + +.hero-overlay { + position: relative; + z-index: 2; + text-align: center; + padding: 48px; + color: var(--color-text-white); +} + +.hero-content { + max-width: 400px; +} + +.hero-icon .large-icon { + font-size: 72px; + width: 72px; + height: 72px; + margin-bottom: 24px; + color: var(--color-text-white-muted); +} + +.hero-title { + font-size: 2.5rem; + font-weight: $font-weight-light; + margin-bottom: 16px; + line-height: 1.2; +} + +.hero-subtitle { + font-size: 1.125rem; + margin-bottom: 16px; + opacity: 0.9; + line-height: 1.5; +} + +.author-signature { + font-size: 0.9rem; + opacity: 0.8; +} + +/* ========================================================================== + Login Form Container (Right Side - Angular Material Card) + ========================================================================== */ + +.login-form-container { + flex: 0 0 520px; + background-color: transparent; + display: flex; + align-items: center; + justify-content: center; + padding: 48px 32px; + + @media (max-width: 992px) { + flex: 1; + background: var(--gradient-primary); + } +} + +.login-card { + @include material-card; + width: 100%; + max-width: 480px; + background-color: var(--color-surface); + border: none; + box-shadow: var(--shadow-xl); + border-radius: var(--border-radius-xl); + overflow: hidden; + + @media (max-width: 992px) { + box-shadow: var(--shadow-2xl); + } + + .card-body { + padding: 48px 32px; + + @media (max-width: 576px) { + padding: 32px 24px; + } + } +} + +/* ========================================================================== + Form Header (Angular Material Avatar & Title) + ========================================================================== */ + +.form-header { + text-align: center; + margin-bottom: 32px; +} + +.login-avatar { + width: 64px; + height: 64px; + background-color: var(--color-primary); + border-radius: var(--border-radius-round); + display: flex; + align-items: center; + justify-content: center; + margin: 0 auto 24px; + color: var(--color-text-white); + font-size: 1.5rem; + box-shadow: var(--shadow-md); +} + +.form-title { + font-size: 1.75rem; + font-weight: $font-weight-normal; + color: var(--color-text-primary); + margin-bottom: 8px; +} + +.form-subtitle { + font-size: 1rem; + color: var(--color-text-secondary); + margin-bottom: 0; +} + +/* ========================================================================== + Material Design Form Fields (Angular Material Input Style) + ========================================================================== */ + +.login-form { + .form-group { + margin-bottom: 24px; + } +} + +.form-field { + position: relative; + margin-bottom: 16px; +} + +.material-input { + @include material-input; + width: 100%; + padding: 16px 12px 8px; + font-size: 1rem; + background-color: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--border-radius-md); + transition: var(--transition-normal); + + &:focus { + border-color: var(--color-primary); + box-shadow: 0 0 0 2px rgba(25, 118, 210, 0.1); + outline: none; + + + .form-label { + color: var(--color-primary); + transform: translateY(-20px) scale(0.85); + } + } + + &:not(:placeholder-shown) + .form-label { + transform: translateY(-20px) scale(0.85); + color: var(--color-text-secondary); + } + + &.is-invalid { + border-color: var(--color-error); + + &:focus { + box-shadow: 0 0 0 2px rgba(244, 67, 54, 0.1); + } + + + .form-label { + color: var(--color-error); + } + } +} + +.form-label { + position: absolute; + top: 12px; + left: 12px; + font-size: 1rem; + color: var(--color-text-hint); + pointer-events: none; + transition: var(--transition-normal); + transform-origin: left top; + background-color: var(--color-surface); + padding: 0 4px; + margin-left: -4px; + z-index: 1; +} + +.form-outline { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + pointer-events: none; + border-radius: var(--border-radius-md); +} + +/* Password Toggle Button */ +.password-toggle { + position: absolute; + top: 50%; + right: 12px; + transform: translateY(-50%); + background: none; + border: none; + color: var(--color-text-hint); + cursor: pointer; + font-size: 1.2rem; + padding: 4px; + border-radius: var(--border-radius-sm); + transition: var(--transition-fast); + + &:hover { + color: var(--color-text-secondary); + background-color: rgba(0, 0, 0, 0.04); + } +} + +/* ========================================================================== + Form Options (Remember Me & Forgot Password) + ========================================================================== */ + +.form-options { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 24px; + + @media (max-width: 576px) { + flex-direction: column; + align-items: flex-start; + gap: 12px; + } +} + +.form-check { + display: flex; + align-items: center; + gap: 8px; + + .form-check-input { + width: 18px; + height: 18px; + border: 2px solid var(--color-border-dark); + border-radius: 3px; + background-color: var(--color-surface); + + &:checked { + background-color: var(--color-primary); + border-color: var(--color-primary); + } + + &:focus { + box-shadow: 0 0 0 2px rgba(25, 118, 210, 0.1); + } + } + + .form-check-label { + font-size: 0.9rem; + color: var(--color-text-secondary); + cursor: pointer; + } +} + +.forgot-password-link { + font-size: 0.9rem; + color: var(--color-primary); + text-decoration: none; + transition: var(--transition-fast); + + &:hover { + color: var(--color-primary-dark); + text-decoration: underline; + } +} + +/* ========================================================================== + Login Button (Angular Material Raised Button) + ========================================================================== */ + +.login-btn { + @include material-button(var(--color-primary)); + width: 100%; + padding: 14px 24px; + font-size: 1rem; + font-weight: $font-weight-medium; + border-radius: var(--border-radius-md); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 24px; + + &:hover { + background-color: var(--color-primary-dark); + transform: translateY(-1px); + box-shadow: var(--shadow-lg); + } + + &:active { + transform: translateY(0); + } +} + +/* ========================================================================== + Social Login Section (Angular Material Style) + ========================================================================== */ + +.social-login { + margin-bottom: 24px; +} + +.divider { + position: relative; + text-align: center; + margin: 24px 0; + + &::before { + content: ''; + position: absolute; + top: 50%; + left: 0; + right: 0; + height: 1px; + background-color: var(--color-border); + } + + span { + background-color: var(--color-surface); + padding: 0 16px; + color: var(--color-text-hint); + font-size: 0.9rem; + } +} + +.social-buttons { + display: flex; + gap: 12px; + + @media (max-width: 576px) { + flex-direction: column; + } +} + +.social-btn { + flex: 1; + padding: 12px 16px; + border: 1px solid var(--color-border); + border-radius: var(--border-radius-md); + background-color: var(--color-surface); + color: var(--color-text-secondary); + font-weight: $font-weight-medium; + transition: var(--transition-normal); + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + + &:hover { + border-color: var(--color-border-dark); + background-color: var(--color-surface-variant); + transform: translateY(-1px); + box-shadow: var(--shadow-sm); + color: var(--color-text-primary); + } + + &.google-btn:hover { + border-color: #db4437; + color: #db4437; + } + + &.github-btn:hover { + border-color: #333; + color: #333; + } +} + +/* ========================================================================== + Register Link + ========================================================================== */ + +.register-link { + text-align: center; + margin: 0; + + p { + color: var(--color-text-secondary); + font-size: 0.9rem; + margin: 0; + } +} + +.register-link-text { + color: var(--color-primary); + text-decoration: none; + font-weight: $font-weight-medium; + transition: var(--transition-fast); + + &:hover { + color: var(--color-primary-dark); + text-decoration: underline; + } +} + +/* ========================================================================== + Error Messages (Angular Material Style) + ========================================================================== */ + +.alert { + border-radius: var(--border-radius-md); + border: none; + padding: 16px; + margin-bottom: 24px; + + &.alert-danger { + background-color: var(--color-error-bg); + color: var(--color-error-dark); + border-left: 4px solid var(--color-error); + } +} + +.invalid-feedback { + display: block; + font-size: 0.85rem; + color: var(--color-error); + margin-top: 8px; + padding-left: 12px; +} + +/* ========================================================================== + Responsive Design + ========================================================================== */ + +@media (max-width: 992px) { + .login-container { + background: var(--gradient-primary); + } + + .login-form-container { + padding: 24px 16px; + } + + .login-card { + margin: 0; + box-shadow: var(--shadow-2xl); + } +} + +@media (max-width: 576px) { + .hero-title { + font-size: 2rem; + } + + .form-title { + font-size: 1.5rem; + } + + .login-card .card-body { + padding: 24px 20px; + } +} diff --git a/resources/sass/pages/_register-complete.scss b/resources/sass/pages/_register-complete.scss new file mode 100644 index 0000000..d850362 --- /dev/null +++ b/resources/sass/pages/_register-complete.scss @@ -0,0 +1,188 @@ +/* ========================================================================== + Register Button (Angular Material Raised Button) + ========================================================================== */ + +.register-btn { + @include material-button(var(--color-success)); + width: 100%; + padding: 14px 24px; + font-size: 1rem; + font-weight: $font-weight-medium; + border-radius: var(--border-radius-md); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 24px; + + &:hover { + background-color: var(--color-success-dark); + transform: translateY(-1px); + box-shadow: var(--shadow-lg); + } + + &:active { + transform: translateY(0); + } +} + +/* ========================================================================== + Social Registration Section (Angular Material Style) + ========================================================================== */ + +.social-login { + margin-bottom: 24px; +} + +.divider { + position: relative; + text-align: center; + margin: 24px 0; + + &::before { + content: ''; + position: absolute; + top: 50%; + left: 0; + right: 0; + height: 1px; + background-color: var(--color-border); + } + + span { + background-color: var(--color-surface); + padding: 0 16px; + color: var(--color-text-hint); + font-size: 0.9rem; + } +} + +.social-buttons { + display: flex; + gap: 12px; + + @media (max-width: 576px) { + flex-direction: column; + } +} + +.social-btn { + flex: 1; + padding: 12px 16px; + border: 1px solid var(--color-border); + border-radius: var(--border-radius-md); + background-color: var(--color-surface); + color: var(--color-text-secondary); + font-weight: $font-weight-medium; + transition: var(--transition-normal); + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + + &:hover { + border-color: var(--color-border-dark); + background-color: var(--color-surface-variant); + transform: translateY(-1px); + box-shadow: var(--shadow-sm); + color: var(--color-text-primary); + } + + &.google-btn:hover { + border-color: #db4437; + color: #db4437; + } + + &.github-btn:hover { + border-color: #333; + color: #333; + } +} + +/* ========================================================================== + Login Link + ========================================================================== */ + +.login-link { + text-align: center; + margin: 0; + + p { + color: var(--color-text-secondary); + font-size: 0.9rem; + margin: 0; + } +} + +.login-link-text { + color: var(--color-success); + text-decoration: none; + font-weight: $font-weight-medium; + transition: var(--transition-fast); + + &:hover { + color: var(--color-success-dark); + text-decoration: underline; + } +} + +/* ========================================================================== + Error Messages (Angular Material Style) + ========================================================================== */ + +.alert { + border-radius: var(--border-radius-md); + border: none; + padding: 16px; + margin-bottom: 24px; + + &.alert-danger { + background-color: var(--color-error-bg); + color: var(--color-error-dark); + border-left: 4px solid var(--color-error); + } +} + +.invalid-feedback { + display: block; + font-size: 0.85rem; + color: var(--color-error); + margin-top: 8px; + padding-left: 12px; +} + +/* ========================================================================== + Responsive Design + ========================================================================== */ + +@media (max-width: 992px) { + .register-container { + background: var(--gradient-success); + } + + .register-form-container { + padding: 24px 16px; + } + + .register-card { + margin: 0; + box-shadow: var(--shadow-2xl); + } +} + +@media (max-width: 576px) { + .hero-title { + font-size: 2rem; + } + + .form-title { + font-size: 1.5rem; + } + + .register-card .card-body { + padding: 24px 20px; + } + + .register-form-container { + flex: 0 0 auto; + width: 100%; + } +} diff --git a/resources/sass/pages/_register.scss b/resources/sass/pages/_register.scss new file mode 100644 index 0000000..32f4ca4 --- /dev/null +++ b/resources/sass/pages/_register.scss @@ -0,0 +1,516 @@ +/** + * Register Page - Professional Bootstrap Design + * Clean and modern registration interface styling + * + * @author David Valera Melendez + * @created 2025-08-08 + * @location Made in Germany ๐Ÿ‡ฉ๐Ÿ‡ช + */ + +/* ========================================================================== + Register Container & Layout (Angular Material Match) + ========================================================================== */ + +.register-container { + min-height: 100vh; + display: flex; + background-color: var(--color-background); +} + +/* ========================================================================== + Hero Background Section (Left Side - Angular Material Style) + ========================================================================== */ + +.hero-background { + flex: 1; + @include gradient-background(var(--gradient-success)); + position: relative; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + + @media (max-width: 992px) { + display: none; + } +} + +.hero-overlay { + position: relative; + z-index: 2; + text-align: center; + padding: 48px; + color: var(--color-text-white); +} + +.hero-content { + max-width: 400px; +} + +.hero-icon .large-icon { + font-size: 72px; + width: 72px; + height: 72px; + margin-bottom: 24px; + color: var(--color-text-white-muted); +} + +.hero-title { + font-size: 2.5rem; + font-weight: $font-weight-light; + margin-bottom: 16px; + line-height: 1.2; +} + +.hero-subtitle { + font-size: 1.125rem; + margin-bottom: 16px; + opacity: 0.9; + line-height: 1.5; +} + +.author-signature { + font-size: 0.9rem; + opacity: 0.8; +} + +/* ========================================================================== + Register Form Container (Right Side - Angular Material Card) + ========================================================================== */ + +.register-form-container { + flex: 0 0 520px; + background-color: transparent; + display: flex; + align-items: center; + justify-content: center; + padding: 32px 24px; + overflow-y: auto; + + @media (max-width: 992px) { + flex: 1; + background: var(--gradient-success); + } +} + +.register-card { + @include material-card; + width: 100%; + max-width: 480px; + background-color: var(--color-surface); + border: none; + box-shadow: var(--shadow-xl); + border-radius: var(--border-radius-xl); + overflow: hidden; + + @media (max-width: 992px) { + box-shadow: var(--shadow-2xl); + } + + .card-body { + padding: 40px 32px; + + @media (max-width: 576px) { + padding: 32px 24px; + } + } +} + +/* ========================================================================== + Form Header (Angular Material Avatar & Title) + ========================================================================== */ + +.form-header { + text-align: center; + margin-bottom: 32px; +} + +.register-avatar { + width: 64px; + height: 64px; + background-color: var(--color-success); + border-radius: var(--border-radius-round); + display: flex; + align-items: center; + justify-content: center; + margin: 0 auto 24px; + color: var(--color-text-white); + font-size: 1.5rem; + box-shadow: var(--shadow-md); +} + +.form-title { + font-size: 1.75rem; + font-weight: $font-weight-normal; + color: var(--color-text-primary); + margin-bottom: 8px; +} + +.form-subtitle { + font-size: 1rem; + color: var(--color-text-secondary); + margin-bottom: 0; +} + +/* ========================================================================== + Material Design Form Fields (Angular Material Input Style) + ========================================================================== */ + +.register-form { + .form-group { + margin-bottom: 24px; + } + + .row { + margin-bottom: 16px; + } +} + +.form-field { + position: relative; + margin-bottom: 16px; +} + +.material-input { + @include material-input; + width: 100%; + padding: 16px 12px 8px; + font-size: 1rem; + background-color: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--border-radius-md); + transition: var(--transition-normal); + + &:focus { + border-color: var(--color-success); + box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.1); + outline: none; + + + .form-label { + color: var(--color-success); + transform: translateY(-20px) scale(0.85); + } + } + + &:not(:placeholder-shown) + .form-label { + transform: translateY(-20px) scale(0.85); + color: var(--color-text-secondary); + } + + &.is-invalid { + border-color: var(--color-error); + + &:focus { + box-shadow: 0 0 0 2px rgba(244, 67, 54, 0.1); + } + + + .form-label { + color: var(--color-error); + } + } + + &.is-valid { + border-color: var(--color-success); + + &:focus { + box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.1); + } + + + .form-label { + color: var(--color-success); + } + } +} + +.form-label { + position: absolute; + top: 12px; + left: 12px; + font-size: 1rem; + color: var(--color-text-hint); + pointer-events: none; + transition: var(--transition-normal); + transform-origin: left top; + background-color: var(--color-surface); + padding: 0 4px; + margin-left: -4px; + z-index: 1; +} + +.form-outline { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + pointer-events: none; + border-radius: var(--border-radius-md); +} + +/* Password Toggle Button */ +.password-toggle { + position: absolute; + top: 50%; + right: 12px; + transform: translateY(-50%); + background: none; + border: none; + color: var(--color-text-hint); + cursor: pointer; + font-size: 1.2rem; + padding: 4px; + border-radius: var(--border-radius-sm); + transition: var(--transition-fast); + z-index: 2; + + &:hover { + color: var(--color-text-secondary); + background-color: rgba(0, 0, 0, 0.04); + } +} + +/* ========================================================================== + Password Requirements & Validation + ========================================================================== */ + +.password-requirements { + margin-top: 8px; + padding-left: 12px; +} + +/* ========================================================================== + Form Checkboxes (Terms & Newsletter) + ========================================================================== */ + +.form-check { + display: flex; + align-items: flex-start; + gap: 12px; + margin-bottom: 16px; + + .form-check-input { + width: 18px; + height: 18px; + border: 2px solid var(--color-border-dark); + border-radius: 3px; + background-color: var(--color-surface); + margin-top: 2px; + + &:checked { + background-color: var(--color-success); + border-color: var(--color-success); + } + + &:focus { + box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.1); + } + + &.is-invalid { + border-color: var(--color-error); + } + } + + .form-check-label { + font-size: 0.9rem; + color: var(--color-text-secondary); + cursor: pointer; + line-height: 1.4; + + a { + color: var(--color-success); + + &:hover { + color: var(--color-success-dark); + } + } + } +} + +/* ========================================================================== + Register Button (Angular Material Raised Button) + ========================================================================== */ + +.register-btn { + @include material-button(var(--color-success)); + width: 100%; + padding: 14px 24px; + font-size: 1rem; + font-weight: $font-weight-medium; + border-radius: var(--border-radius-md); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 24px; + + &:hover { + background-color: var(--color-success-dark); + transform: translateY(-1px); + box-shadow: var(--shadow-lg); + } + + &:active { + transform: translateY(0); + } +} + +/* ========================================================================== + Social Registration Section (Angular Material Style) + ========================================================================== */ + +.social-login { + margin-bottom: 24px; +} + +.divider { + position: relative; + text-align: center; + margin: 24px 0; + + &::before { + content: ''; + position: absolute; + top: 50%; + left: 0; + right: 0; + height: 1px; + background-color: var(--color-border); + } + + span { + background-color: var(--color-surface); + padding: 0 16px; + color: var(--color-text-hint); + font-size: 0.9rem; + } +} + +.social-buttons { + display: flex; + gap: 12px; + + @media (max-width: 576px) { + flex-direction: column; + } +} + +.social-btn { + flex: 1; + padding: 12px 16px; + border: 1px solid var(--color-border); + border-radius: var(--border-radius-md); + background-color: var(--color-surface); + color: var(--color-text-secondary); + font-weight: $font-weight-medium; + transition: var(--transition-normal); + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + + &:hover { + border-color: var(--color-border-dark); + background-color: var(--color-surface-variant); + transform: translateY(-1px); + box-shadow: var(--shadow-sm); + color: var(--color-text-primary); + } + + &.google-btn:hover { + border-color: #db4437; + color: #db4437; + } + + &.github-btn:hover { + border-color: #333; + color: #333; + } +} + +/* ========================================================================== + Login Link + ========================================================================== */ + +.login-link { + text-align: center; + margin: 0; + + p { + color: var(--color-text-secondary); + font-size: 0.9rem; + margin: 0; + } +} + +.login-link-text { + color: var(--color-success); + text-decoration: none; + font-weight: $font-weight-medium; + transition: var(--transition-fast); + + &:hover { + color: var(--color-success-dark); + text-decoration: underline; + } +} + +/* ========================================================================== + Error Messages (Angular Material Style) + ========================================================================== */ + +.alert { + border-radius: var(--border-radius-md); + border: none; + padding: 16px; + margin-bottom: 24px; + + &.alert-danger { + background-color: var(--color-error-bg); + color: var(--color-error-dark); + border-left: 4px solid var(--color-error); + } +} + +.invalid-feedback { + display: block; + font-size: 0.85rem; + color: var(--color-error); + margin-top: 8px; + padding-left: 12px; +} + +/* ========================================================================== + Responsive Design + ========================================================================== */ + +@media (max-width: 992px) { + .register-container { + background: var(--gradient-success); + } + + .register-form-container { + padding: 24px 16px; + } + + .register-card { + margin: 0; + box-shadow: var(--shadow-2xl); + } +} + +@media (max-width: 576px) { + .hero-title { + font-size: 2rem; + } + + .form-title { + font-size: 1.5rem; + } + + .register-card .card-body { + padding: 24px 20px; + } + + .register-form-container { + flex: 0 0 auto; + width: 100%; + } +} diff --git a/resources/sass/pages/_resume-create.scss b/resources/sass/pages/_resume-create.scss new file mode 100644 index 0000000..8a4aeb9 --- /dev/null +++ b/resources/sass/pages/_resume-create.scss @@ -0,0 +1,529 @@ +// ========================================================================== +// Resume Builder Create Page Styles +// Professional Resume Builder - Template Selection +// ========================================================================== + +.resume-create-page { + .page-header { + margin-bottom: var(--spacing-xl); + + h1 { + color: var(--color-dark); + font-weight: 700; + margin-bottom: 0.5rem; + } + + .back-btn { + padding: 0.75rem 1.5rem; + font-weight: 500; + border-radius: var(--border-radius-md); + transition: all 0.2s ease; + + &:hover { + transform: translateY(-1px); + } + } + } +} + +// Template Cards Grid +.templates-grid { + .template-card { + background: var(--color-white); + border: none; + border-radius: var(--border-radius-lg); + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); + transition: all 0.3s ease; + height: 100%; + overflow: hidden; + position: relative; + + &:hover { + transform: translateY(-6px); + box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.15), 0 10px 10px -5px rgba(0, 0, 0, 0.08); + + .template-overlay { + opacity: 1; + } + } + + .template-preview { + position: relative; + height: 300px; + background: linear-gradient(135deg, var(--color-gray-50) 0%, var(--color-gray-100) 100%); + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + + img { + width: 100%; + height: 100%; + object-fit: cover; + transition: transform 0.3s ease; + } + + .preview-placeholder { + text-align: center; + color: var(--color-gray-400); + + i { + font-size: 4rem; + margin-bottom: var(--spacing-md); + display: block; + } + + p { + font-size: 1.125rem; + font-weight: 500; + margin-bottom: 0; + } + } + } + + .template-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient(135deg, rgba(102, 126, 234, 0.9) 0%, rgba(59, 130, 246, 0.9) 100%); + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 0.3s ease; + z-index: 2; + + .preview-btn { + background: rgba(255, 255, 255, 0.2); + border: 2px solid rgba(255, 255, 255, 0.6); + color: var(--color-white); + padding: 0.75rem 1.5rem; + border-radius: var(--border-radius-md); + font-weight: 600; + text-decoration: none; + transition: all 0.2s ease; + backdrop-filter: blur(10px); + + &:hover { + background: rgba(255, 255, 255, 0.3); + border-color: rgba(255, 255, 255, 0.8); + color: var(--color-white); + transform: scale(1.05); + } + + i { + margin-right: 0.5rem; + } + } + } + + .card-body { + padding: var(--spacing-lg); + } + + .template-header { + display: flex; + justify-content: between; + align-items: start; + margin-bottom: var(--spacing-md); + + .template-title { + font-size: 1.25rem; + font-weight: 600; + color: var(--color-dark); + margin-bottom: 0; + flex-grow: 1; + } + + .template-category { + font-size: 0.75rem; + font-weight: 500; + padding: 0.375rem 0.75rem; + border-radius: var(--border-radius-full); + + &.bg-primary { + background: rgba(102, 126, 234, 0.1) !important; + color: var(--brand-primary); + } + + &.bg-success { + background: rgba(34, 197, 94, 0.1) !important; + color: var(--brand-success); + } + + &.bg-warning { + background: rgba(251, 191, 36, 0.1) !important; + color: var(--brand-warning); + } + + &.bg-info { + background: rgba(59, 130, 246, 0.1) !important; + color: var(--brand-info); + } + } + } + + .template-description { + color: var(--color-gray-600); + font-size: 0.875rem; + line-height: 1.5; + margin-bottom: var(--spacing-lg); + } + + .template-features { + margin-bottom: var(--spacing-lg); + + .feature-list { + list-style: none; + padding: 0; + margin: 0; + + li { + font-size: 0.875rem; + color: var(--color-gray-600); + margin-bottom: 0.25rem; + display: flex; + align-items: center; + + i { + color: var(--brand-success); + margin-right: 0.5rem; + font-size: 0.75rem; + } + } + } + } + + .template-actions { + .select-btn { + width: 100%; + background: var(--brand-gradient); + border: none; + color: var(--color-white); + padding: 0.875rem 1.5rem; + font-weight: 600; + border-radius: var(--border-radius-md); + transition: all 0.3s ease; + position: relative; + overflow: hidden; + + &:hover { + transform: translateY(-2px); + box-shadow: 0 8px 25px rgba(102, 126, 234, 0.4); + } + + &:active { + transform: translateY(0); + } + + &:disabled { + opacity: 0.6; + transform: none !important; + box-shadow: none !important; + } + + .btn-text { + transition: opacity 0.2s ease; + } + + .btn-loading { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + opacity: 0; + transition: opacity 0.2s ease; + + .spinner-border { + width: 1.25rem; + height: 1.25rem; + } + } + + &.loading { + .btn-text { + opacity: 0; + } + + .btn-loading { + opacity: 1; + } + } + } + + .preview-link { + display: block; + text-align: center; + color: var(--brand-primary); + text-decoration: none; + font-size: 0.875rem; + font-weight: 500; + margin-top: var(--spacing-sm); + transition: color 0.2s ease; + + &:hover { + color: var(--brand-primary-dark); + text-decoration: underline; + } + + i { + margin-right: 0.25rem; + } + } + } + } +} + +// Template Filters +.template-filters { + background: var(--color-white); + border-radius: var(--border-radius-lg); + padding: var(--spacing-lg); + margin-bottom: var(--spacing-xl); + box-shadow: 0 2px 4px -1px rgba(0, 0, 0, 0.06), 0 1px 2px -1px rgba(0, 0, 0, 0.06); + + .filter-section { + margin-bottom: var(--spacing-md); + + &:last-child { + margin-bottom: 0; + } + + .filter-label { + font-weight: 600; + color: var(--color-dark); + margin-bottom: var(--spacing-sm); + font-size: 0.875rem; + } + + .filter-options { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + + .filter-btn { + background: var(--color-white); + border: 1px solid var(--color-gray-300); + color: var(--color-gray-600); + padding: 0.5rem 1rem; + border-radius: var(--border-radius-full); + font-size: 0.875rem; + font-weight: 500; + transition: all 0.2s ease; + cursor: pointer; + + &:hover { + background: var(--color-gray-50); + border-color: var(--color-gray-400); + } + + &.active { + background: var(--brand-primary); + border-color: var(--brand-primary); + color: var(--color-white); + + &:hover { + background: var(--brand-primary-dark); + border-color: var(--brand-primary-dark); + } + } + } + } + } +} + +// Empty State +.no-templates { + text-align: center; + padding: var(--spacing-4xl) var(--spacing-lg); + + .empty-icon { + font-size: 4rem; + color: var(--color-gray-300); + margin-bottom: var(--spacing-lg); + } + + h3 { + color: var(--color-gray-700); + font-weight: 600; + margin-bottom: var(--spacing-md); + } + + p { + color: var(--color-gray-500); + font-size: 1.1rem; + margin-bottom: 0; + max-width: 400px; + margin-left: auto; + margin-right: auto; + } +} + +// Loading States +.templates-loading { + .loading-card { + background: var(--color-white); + border-radius: var(--border-radius-lg); + box-shadow: 0 2px 4px -1px rgba(0, 0, 0, 0.06); + height: 500px; + + .loading-preview { + height: 300px; + background: linear-gradient(90deg, var(--color-gray-200) 25%, var(--color-gray-100) 50%, var(--color-gray-200) 75%); + background-size: 200% 100%; + animation: shimmer 2s infinite; + } + + .loading-body { + padding: var(--spacing-lg); + + .loading-title { + height: 1.5rem; + width: 70%; + background: linear-gradient(90deg, var(--color-gray-200) 25%, var(--color-gray-100) 50%, var(--color-gray-200) 75%); + background-size: 200% 100%; + animation: shimmer 2s infinite; + border-radius: var(--border-radius-sm); + margin-bottom: var(--spacing-sm); + } + + .loading-description { + height: 1rem; + background: linear-gradient(90deg, var(--color-gray-200) 25%, var(--color-gray-100) 50%, var(--color-gray-200) 75%); + background-size: 200% 100%; + animation: shimmer 2s infinite; + border-radius: var(--border-radius-sm); + margin-bottom: 0.5rem; + + &:last-of-type { + width: 60%; + margin-bottom: var(--spacing-lg); + } + } + + .loading-button { + height: 3rem; + background: linear-gradient(90deg, var(--color-gray-200) 25%, var(--color-gray-100) 50%, var(--color-gray-200) 75%); + background-size: 200% 100%; + animation: shimmer 2s infinite; + border-radius: var(--border-radius-md); + } + } + } +} + +// Responsive Design +@include media-breakpoint-down(lg) { + .template-filters { + .filter-options { + .filter-btn { + flex: 1; + text-align: center; + min-width: auto; + } + } + } +} + +@include media-breakpoint-down(md) { + .resume-create-page { + .page-header { + text-align: center; + + .back-btn { + width: 100%; + margin-top: var(--spacing-md); + } + } + } + + .template-filters { + .filter-section { + .filter-options { + .filter-btn { + width: 100%; + margin-bottom: 0.5rem; + + &:last-child { + margin-bottom: 0; + } + } + } + } + } + + .templates-grid { + .template-card { + .template-preview { + height: 250px; + } + + .template-header { + flex-direction: column; + align-items: flex-start; + + .template-category { + margin-top: 0.5rem; + } + } + } + } +} + +@keyframes shimmer { + 0% { + background-position: -200% 0; + } + 100% { + background-position: 200% 0; + } +} + +// Template Selection Animations +@keyframes selectTemplate { + 0% { + transform: scale(1); + } + 50% { + transform: scale(0.95); + } + 100% { + transform: scale(1); + } +} + +.template-card.selecting { + animation: selectTemplate 0.3s ease-in-out; +} + +// Success State +.template-selected { + border: 2px solid var(--brand-success) !important; + + .template-overlay { + background: linear-gradient(135deg, rgba(34, 197, 94, 0.9) 0%, rgba(22, 163, 74, 0.9) 100%); + opacity: 1; + + .success-message { + color: var(--color-white); + text-align: center; + + i { + font-size: 3rem; + margin-bottom: var(--spacing-md); + } + + h4 { + font-weight: 600; + margin-bottom: 0.5rem; + } + + p { + margin-bottom: 0; + opacity: 0.9; + } + } + } +} diff --git a/resources/sass/pages/_resume-index.scss b/resources/sass/pages/_resume-index.scss new file mode 100644 index 0000000..2fada5b --- /dev/null +++ b/resources/sass/pages/_resume-index.scss @@ -0,0 +1,510 @@ +// ========================================================================== +// Resume Builder Index Page Styles +// Professional Resume Builder - Resume Management +// ========================================================================== + +.resume-index-page { + .page-header { + margin-bottom: var(--spacing-xl); + + h1 { + color: var(--color-dark); + font-weight: 700; + margin-bottom: 0.5rem; + } + + .header-actions { + .btn { + padding: 0.75rem 1.5rem; + font-weight: 500; + border-radius: var(--border-radius-md); + transition: all 0.2s ease; + + &:hover { + transform: translateY(-1px); + } + } + } + } +} + +// Statistics Cards +.stats-cards { + margin-bottom: var(--spacing-xl); + + .stats-card { + background: var(--color-white); + border: none; + border-radius: var(--border-radius-lg); + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); + transition: all 0.3s ease; + height: 100%; + + &:hover { + transform: translateY(-4px); + box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); + } + + .card-body { + padding: var(--spacing-lg); + } + + .stats-number { + font-size: 2.5rem; + font-weight: 800; + margin-bottom: 0.5rem; + line-height: 1; + + &.text-primary { + color: var(--brand-primary) !important; + } + + &.text-success { + color: var(--brand-success) !important; + } + + &.text-warning { + color: var(--brand-warning) !important; + } + + &.text-info { + color: var(--brand-info) !important; + } + } + + .stats-label { + font-size: 0.875rem; + font-weight: 500; + color: var(--color-gray-600); + text-transform: uppercase; + letter-spacing: 0.05em; + } + + .stats-icon { + width: 3.5rem; + height: 3.5rem; + border-radius: var(--border-radius-lg); + display: flex; + align-items: center; + justify-content: center; + font-size: 1.5rem; + + &.bg-primary { + background: rgba(102, 126, 234, 0.1) !important; + color: var(--brand-primary); + } + + &.bg-success { + background: rgba(34, 197, 94, 0.1) !important; + color: var(--brand-success); + } + + &.bg-warning { + background: rgba(251, 191, 36, 0.1) !important; + color: var(--brand-warning); + } + + &.bg-info { + background: rgba(59, 130, 246, 0.1) !important; + color: var(--brand-info); + } + } + } +} + +// Resume Cards Grid +.resumes-grid { + .resume-card { + background: var(--color-white); + border: none; + border-radius: var(--border-radius-lg); + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); + transition: all 0.3s ease; + height: 100%; + overflow: hidden; + + &:hover { + transform: translateY(-4px); + box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); + } + + .resume-thumbnail { + height: 200px; + background: linear-gradient(135deg, var(--color-gray-50) 0%, var(--color-gray-100) 100%); + display: flex; + align-items: center; + justify-content: center; + border-bottom: 1px solid var(--color-gray-100); + + .thumbnail-placeholder { + color: var(--color-gray-400); + text-align: center; + + i { + font-size: 3rem; + margin-bottom: 0.5rem; + display: block; + } + + span { + font-size: 0.875rem; + font-weight: 500; + } + } + + img { + width: 100%; + height: 100%; + object-fit: cover; + } + } + + .card-body { + padding: var(--spacing-lg); + } + + .resume-title { + font-size: 1.25rem; + font-weight: 600; + color: var(--color-dark); + margin-bottom: 0.5rem; + + a { + color: inherit; + text-decoration: none; + transition: color 0.2s ease; + + &:hover { + color: var(--brand-primary); + } + } + } + + .resume-meta { + display: flex; + align-items: center; + gap: var(--spacing-md); + margin-bottom: var(--spacing-md); + + .meta-item { + font-size: 0.875rem; + color: var(--color-gray-600); + + i { + margin-right: 0.25rem; + color: var(--color-gray-400); + } + } + } + + .resume-status { + margin-bottom: var(--spacing-md); + + .badge { + font-size: 0.75rem; + font-weight: 500; + padding: 0.375rem 0.75rem; + border-radius: var(--border-radius-full); + + &.bg-success { + background: var(--brand-success) !important; + } + + &.bg-warning { + background: var(--brand-warning) !important; + color: var(--color-white); + } + + &.bg-secondary { + background: var(--color-gray-500) !important; + } + } + } + + .resume-actions { + display: flex; + gap: 0.5rem; + + .btn { + flex: 1; + padding: 0.5rem; + font-size: 0.875rem; + font-weight: 500; + border-radius: var(--border-radius-md); + transition: all 0.2s ease; + + &:hover { + transform: translateY(-1px); + } + + i { + font-size: 0.875rem; + } + } + + .btn-outline-primary { + border-color: var(--brand-primary); + color: var(--brand-primary); + + &:hover { + background: var(--brand-primary); + border-color: var(--brand-primary); + } + } + + .btn-outline-success { + border-color: var(--brand-success); + color: var(--brand-success); + + &:hover { + background: var(--brand-success); + border-color: var(--brand-success); + } + } + + .btn-outline-danger { + border-color: var(--brand-danger); + color: var(--brand-danger); + + &:hover { + background: var(--brand-danger); + border-color: var(--brand-danger); + } + } + } + } +} + +// Empty State +.empty-state { + text-align: center; + padding: var(--spacing-4xl) var(--spacing-lg); + + .empty-icon { + font-size: 4rem; + color: var(--color-gray-300); + margin-bottom: var(--spacing-lg); + } + + h3 { + color: var(--color-gray-700); + font-weight: 600; + margin-bottom: var(--spacing-md); + } + + p { + color: var(--color-gray-500); + font-size: 1.1rem; + margin-bottom: var(--spacing-xl); + max-width: 400px; + margin-left: auto; + margin-right: auto; + } + + .btn { + padding: 0.875rem 2rem; + font-weight: 600; + border-radius: var(--border-radius-md); + } +} + +// Filters and Search +.resume-filters { + background: var(--color-white); + border-radius: var(--border-radius-lg); + padding: var(--spacing-lg); + margin-bottom: var(--spacing-xl); + box-shadow: 0 2px 4px -1px rgba(0, 0, 0, 0.06), 0 1px 2px -1px rgba(0, 0, 0, 0.06); + + .search-input { + position: relative; + + .form-control { + border-radius: var(--border-radius-md); + border: 1px solid var(--color-gray-300); + padding: 0.75rem 1rem 0.75rem 3rem; + transition: all 0.2s ease; + + &:focus { + border-color: var(--brand-primary); + box-shadow: 0 0 0 0.2rem rgba(102, 126, 234, 0.1); + } + } + + .search-icon { + position: absolute; + left: 1rem; + top: 50%; + transform: translateY(-50%); + color: var(--color-gray-400); + z-index: 3; + } + } + + .filter-buttons { + .btn { + border-radius: var(--border-radius-full); + padding: 0.5rem 1rem; + font-weight: 500; + border: 1px solid var(--color-gray-300); + color: var(--color-gray-600); + background: var(--color-white); + transition: all 0.2s ease; + + &.active { + background: var(--brand-primary); + border-color: var(--brand-primary); + color: var(--color-white); + } + + &:hover:not(.active) { + background: var(--color-gray-50); + border-color: var(--color-gray-400); + } + } + } +} + +// Pagination +.pagination-wrapper { + display: flex; + justify-content: center; + margin-top: var(--spacing-xl); + + .pagination { + .page-item { + .page-link { + border: none; + background: transparent; + color: var(--color-gray-600); + font-weight: 500; + padding: 0.5rem 0.75rem; + margin: 0 0.125rem; + border-radius: var(--border-radius-md); + transition: all 0.2s ease; + + &:hover { + background: var(--color-gray-100); + color: var(--color-gray-800); + } + } + + &.active .page-link { + background: var(--brand-primary); + color: var(--color-white); + + &:hover { + background: var(--brand-primary-dark); + } + } + } + } +} + +// Responsive Design +@include media-breakpoint-down(lg) { + .resume-index-page { + .page-header { + .header-actions { + margin-top: var(--spacing-md); + + .btn { + width: 100%; + margin-bottom: 0.5rem; + + &:last-child { + margin-bottom: 0; + } + } + } + } + } + + .stats-cards { + .col-xl-3 { + margin-bottom: var(--spacing-md); + } + } +} + +@include media-breakpoint-down(md) { + .resume-filters { + .search-input { + margin-bottom: var(--spacing-md); + } + + .filter-buttons { + .btn { + width: 100%; + margin-bottom: 0.5rem; + + &:last-child { + margin-bottom: 0; + } + } + } + } + + .resumes-grid { + .resume-card { + .resume-actions { + .btn { + font-size: 0.8rem; + padding: 0.375rem 0.5rem; + } + } + } + } +} + +// Loading States +.loading-skeleton { + .skeleton-card { + background: var(--color-white); + border-radius: var(--border-radius-lg); + padding: var(--spacing-lg); + box-shadow: 0 2px 4px -1px rgba(0, 0, 0, 0.06); + + .skeleton-thumbnail { + height: 200px; + background: linear-gradient(90deg, var(--color-gray-200) 25%, var(--color-gray-100) 50%, var(--color-gray-200) 75%); + background-size: 200% 100%; + animation: shimmer 2s infinite; + border-radius: var(--border-radius-md); + margin-bottom: var(--spacing-md); + } + + .skeleton-text { + height: 1rem; + background: linear-gradient(90deg, var(--color-gray-200) 25%, var(--color-gray-100) 50%, var(--color-gray-200) 75%); + background-size: 200% 100%; + animation: shimmer 2s infinite; + border-radius: var(--border-radius-sm); + margin-bottom: 0.5rem; + + &.title { + height: 1.5rem; + width: 80%; + } + + &.subtitle { + height: 1rem; + width: 60%; + } + + &.meta { + height: 0.875rem; + width: 40%; + } + } + } +} + +@keyframes shimmer { + 0% { + background-position: -200% 0; + } + 100% { + background-position: 200% 0; + } +} diff --git a/resources/sass/utilities/_helpers.scss b/resources/sass/utilities/_helpers.scss new file mode 100644 index 0000000..c26d3c4 --- /dev/null +++ b/resources/sass/utilities/_helpers.scss @@ -0,0 +1,618 @@ +// ========================================================================== +// Utility Classes +// Professional Resume Builder - Helper Classes +// ========================================================================== + +// Spacing utilities (extending Bootstrap) +.spacing-xs { margin: var(--spacing-xs); } +.spacing-sm { margin: var(--spacing-sm); } +.spacing-md { margin: var(--spacing-md); } +.spacing-lg { margin: var(--spacing-lg); } +.spacing-xl { margin: var(--spacing-xl); } +.spacing-2xl { margin: var(--spacing-2xl); } +.spacing-3xl { margin: var(--spacing-3xl); } +.spacing-4xl { margin: var(--spacing-4xl); } + +.p-xs { padding: var(--spacing-xs); } +.p-sm { padding: var(--spacing-sm); } +.p-md { padding: var(--spacing-md); } +.p-lg { padding: var(--spacing-lg); } +.p-xl { padding: var(--spacing-xl); } +.p-2xl { padding: var(--spacing-2xl); } +.p-3xl { padding: var(--spacing-3xl); } +.p-4xl { padding: var(--spacing-4xl); } + +// Margin utilities +.m-xs { margin: var(--spacing-xs); } +.m-sm { margin: var(--spacing-sm); } +.m-md { margin: var(--spacing-md); } +.m-lg { margin: var(--spacing-lg); } +.m-xl { margin: var(--spacing-xl); } +.m-2xl { margin: var(--spacing-2xl); } + +.mt-xs { margin-top: var(--spacing-xs); } +.mt-sm { margin-top: var(--spacing-sm); } +.mt-md { margin-top: var(--spacing-md); } +.mt-lg { margin-top: var(--spacing-lg); } +.mt-xl { margin-top: var(--spacing-xl); } +.mt-2xl { margin-top: var(--spacing-2xl); } + +.mb-xs { margin-bottom: var(--spacing-xs); } +.mb-sm { margin-bottom: var(--spacing-sm); } +.mb-md { margin-bottom: var(--spacing-md); } +.mb-lg { margin-bottom: var(--spacing-lg); } +.mb-xl { margin-bottom: var(--spacing-xl); } +.mb-2xl { margin-bottom: var(--spacing-2xl); } + +// Text utilities +.text-primary-custom { + color: var(--brand-primary) !important; +} + +.text-success-custom { + color: var(--brand-success) !important; +} + +.text-warning-custom { + color: var(--brand-warning) !important; +} + +.text-danger-custom { + color: var(--brand-danger) !important; +} + +.text-info-custom { + color: var(--brand-info) !important; +} + +.text-gradient { + background: var(--brand-gradient); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + display: inline-block; +} + +.text-gradient-success { + background: var(--brand-gradient-success); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + display: inline-block; +} + +.text-shadow { + text-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); +} + +.text-shadow-sm { + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); +} + +.text-shadow-lg { + text-shadow: 0 2px 4px rgba(0, 0, 0, 0.4); +} + +// Background utilities +.bg-gradient-primary { + background: var(--brand-gradient) !important; + color: var(--color-white); +} + +.bg-gradient-success { + background: var(--brand-gradient-success) !important; + color: var(--color-white); +} + +.bg-gradient-warning { + background: var(--brand-gradient-warning) !important; + color: var(--color-white); +} + +.bg-gradient-danger { + background: var(--brand-gradient-danger) !important; + color: var(--color-white); +} + +.bg-gradient-info { + background: var(--brand-gradient-info) !important; + color: var(--color-white); +} + +.bg-primary-light { + background-color: var(--brand-primary-light) !important; + color: var(--brand-primary-dark); +} + +.bg-success-light { + background-color: var(--brand-success-light) !important; + color: var(--brand-success-dark); +} + +.bg-warning-light { + background-color: var(--brand-warning-light) !important; + color: var(--brand-warning-dark); +} + +.bg-danger-light { + background-color: var(--brand-danger-light) !important; + color: var(--brand-danger-dark); +} + +.bg-info-light { + background-color: var(--brand-info-light) !important; + color: var(--brand-info-dark); +} + +// Border utilities +.border-primary-custom { + border-color: var(--brand-primary) !important; +} + +.border-success-custom { + border-color: var(--brand-success) !important; +} + +.border-warning-custom { + border-color: var(--brand-warning) !important; +} + +.border-danger-custom { + border-color: var(--brand-danger) !important; +} + +.border-info-custom { + border-color: var(--brand-info) !important; +} + +.border-radius-xs { + border-radius: var(--border-radius-xs) !important; +} + +.border-radius-sm { + border-radius: var(--border-radius-sm) !important; +} + +.border-radius-md { + border-radius: var(--border-radius-md) !important; +} + +.border-radius-lg { + border-radius: var(--border-radius-lg) !important; +} + +.border-radius-xl { + border-radius: var(--border-radius-xl) !important; +} + +.border-radius-full { + border-radius: var(--border-radius-full) !important; +} + +// Shadow utilities +.shadow-xs { + box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05) !important; +} + +.shadow-sm { + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06) !important; +} + +.shadow-md { + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06) !important; +} + +.shadow-lg { + box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05) !important; +} + +.shadow-xl { + box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04) !important; +} + +.shadow-2xl { + box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25) !important; +} + +.shadow-inner { + box-shadow: inset 0 2px 4px 0 rgba(0, 0, 0, 0.06) !important; +} + +.shadow-none { + box-shadow: none !important; +} + +// Colored shadows +.shadow-primary { + box-shadow: 0 4px 12px rgba(102, 126, 234, 0.15) !important; +} + +.shadow-success { + box-shadow: 0 4px 12px rgba(34, 197, 94, 0.15) !important; +} + +.shadow-warning { + box-shadow: 0 4px 12px rgba(251, 191, 36, 0.15) !important; +} + +.shadow-danger { + box-shadow: 0 4px 12px rgba(239, 68, 68, 0.15) !important; +} + +.shadow-info { + box-shadow: 0 4px 12px rgba(59, 130, 246, 0.15) !important; +} + +// Display utilities +.d-grid { + display: grid !important; +} + +.grid-cols-1 { + grid-template-columns: repeat(1, minmax(0, 1fr)) !important; +} + +.grid-cols-2 { + grid-template-columns: repeat(2, minmax(0, 1fr)) !important; +} + +.grid-cols-3 { + grid-template-columns: repeat(3, minmax(0, 1fr)) !important; +} + +.grid-cols-4 { + grid-template-columns: repeat(4, minmax(0, 1fr)) !important; +} + +.grid-gap-1 { + gap: 0.25rem !important; +} + +.grid-gap-2 { + gap: 0.5rem !important; +} + +.grid-gap-3 { + gap: 1rem !important; +} + +.grid-gap-4 { + gap: 1.5rem !important; +} + +.grid-gap-5 { + gap: 2rem !important; +} + +// Flexbox utilities +.flex-center { + display: flex !important; + align-items: center !important; + justify-content: center !important; +} + +.flex-between { + display: flex !important; + align-items: center !important; + justify-content: space-between !important; +} + +.flex-around { + display: flex !important; + align-items: center !important; + justify-content: space-around !important; +} + +.flex-column-center { + display: flex !important; + flex-direction: column !important; + align-items: center !important; + justify-content: center !important; +} + +// Position utilities +.position-center { + position: absolute !important; + top: 50% !important; + left: 50% !important; + transform: translate(-50%, -50%) !important; +} + +.position-center-x { + position: absolute !important; + left: 50% !important; + transform: translateX(-50%) !important; +} + +.position-center-y { + position: absolute !important; + top: 50% !important; + transform: translateY(-50%) !important; +} + +// Visibility utilities +.invisible { + visibility: hidden !important; +} + +.visible { + visibility: visible !important; +} + +.opacity-0 { + opacity: 0 !important; +} + +.opacity-25 { + opacity: 0.25 !important; +} + +.opacity-50 { + opacity: 0.5 !important; +} + +.opacity-75 { + opacity: 0.75 !important; +} + +.opacity-100 { + opacity: 1 !important; +} + +// Cursor utilities +.cursor-pointer { + cursor: pointer !important; +} + +.cursor-not-allowed { + cursor: not-allowed !important; +} + +.cursor-wait { + cursor: wait !important; +} + +.cursor-help { + cursor: help !important; +} + +// Transform utilities +.transform-none { + transform: none !important; +} + +.scale-90 { + transform: scale(0.9) !important; +} + +.scale-95 { + transform: scale(0.95) !important; +} + +.scale-100 { + transform: scale(1) !important; +} + +.scale-105 { + transform: scale(1.05) !important; +} + +.scale-110 { + transform: scale(1.1) !important; +} + +.rotate-90 { + transform: rotate(90deg) !important; +} + +.rotate-180 { + transform: rotate(180deg) !important; +} + +.rotate-270 { + transform: rotate(270deg) !important; +} + +// Transition utilities +.transition-none { + transition: none !important; +} + +.transition-all { + transition: all 0.15s ease-in-out !important; +} + +.transition-slow { + transition: all 0.3s ease-in-out !important; +} + +.transition-fast { + transition: all 0.1s ease-in-out !important; +} + +// Animation utilities +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +@keyframes ping { + 75%, 100% { + transform: scale(2); + opacity: 0; + } +} + +@keyframes pulse { + 50% { + opacity: 0.5; + } +} + +@keyframes bounce { + 0%, 100% { + transform: translateY(-25%); + animation-timing-function: cubic-bezier(0.8, 0, 1, 1); + } + 50% { + transform: none; + animation-timing-function: cubic-bezier(0, 0, 0.2, 1); + } +} + +.animate-spin { + animation: spin 1s linear infinite; +} + +.animate-ping { + animation: ping 1s cubic-bezier(0, 0, 0.2, 1) infinite; +} + +.animate-pulse { + animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; +} + +.animate-bounce { + animation: bounce 1s infinite; +} + +// Hover utilities +.hover-lift { + transition: transform 0.2s ease-in-out !important; + + &:hover { + transform: translateY(-2px) !important; + } +} + +.hover-scale { + transition: transform 0.2s ease-in-out !important; + + &:hover { + transform: scale(1.05) !important; + } +} + +.hover-rotate { + transition: transform 0.2s ease-in-out !important; + + &:hover { + transform: rotate(5deg) !important; + } +} + +// Loading states +.loading-skeleton { + background: linear-gradient(90deg, var(--color-gray-200) 25%, var(--color-gray-100) 50%, var(--color-gray-200) 75%); + background-size: 200% 100%; + animation: shimmer 2s infinite; + border-radius: var(--border-radius-sm); +} + +@keyframes shimmer { + 0% { + background-position: -200% 0; + } + 100% { + background-position: 200% 0; + } +} + +// Responsive utilities for custom breakpoints +@include media-breakpoint-up(xs) { + .d-xs-block { display: block !important; } + .d-xs-none { display: none !important; } +} + +@include media-breakpoint-up(sm) { + .d-sm-block { display: block !important; } + .d-sm-none { display: none !important; } +} + +@include media-breakpoint-up(md) { + .d-md-block { display: block !important; } + .d-md-none { display: none !important; } +} + +@include media-breakpoint-up(lg) { + .d-lg-block { display: block !important; } + .d-lg-none { display: none !important; } +} + +@include media-breakpoint-up(xl) { + .d-xl-block { display: block !important; } + .d-xl-none { display: none !important; } +} + +// Print utilities +@media print { + .print-hidden { + display: none !important; + } + + .print-visible { + display: block !important; + } + + .print-break-before { + page-break-before: always !important; + } + + .print-break-after { + page-break-after: always !important; + } + + .print-no-break { + page-break-inside: avoid !important; + } +} + +// Accessibility utilities +.sr-only-focusable { + &:focus { + position: static; + width: auto; + height: auto; + margin: 0; + overflow: visible; + clip: auto; + } +} + +.focus-visible { + &:focus-visible { + outline: 2px solid var(--brand-primary); + outline-offset: 2px; + } +} + +// High contrast mode support +@media (prefers-contrast: high) { + .text-muted { + color: var(--color-gray-800) !important; + } + + .border { + border-color: var(--color-gray-800) !important; + } + + .shadow-sm, + .shadow-md, + .shadow-lg { + box-shadow: none !important; + border: 1px solid var(--color-gray-800) !important; + } +} + +// Reduced motion support +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php new file mode 100644 index 0000000..dcb3394 --- /dev/null +++ b/resources/views/auth/login.blade.php @@ -0,0 +1,194 @@ +{{-- + Professional Login Page - Professional Bootstrap Design + Clean and modern login interface + + @author David Valera Melendez + @created 2025-08-08 + @location Made in Germany ๐Ÿ‡ฉ๐Ÿ‡ช +--}} + +@extends('layouts.material-auth') + +@section('title', 'Sign In - Professional Resume Builder') +@section('page_class', 'auth-page login-page') + +@section('content') + + + +@push('scripts') + +@endpush +@endsection diff --git a/resources/views/auth/register.blade.php b/resources/views/auth/register.blade.php new file mode 100644 index 0000000..9b9c8cd --- /dev/null +++ b/resources/views/auth/register.blade.php @@ -0,0 +1,315 @@ +{{-- Professional Register Page - Bootstrap Implementation --}} +@extends('layouts.material-auth') + +@section('title', 'Create Account - ' . config('app.name')) + +@section('head') + + +@endsection + +@section('content') +
+ {{-- Hero Background Section (Left) --}} +
+
+
+
+ +
+

Join Today

+

Create your professional resume with our advanced builder tools

+
+ ๐Ÿ‡ฉ๐Ÿ‡ช Made in Germany by David Valera Melendez +
+
+
+
+ + {{-- Register Form Section (Right) --}} +
+
+
+ {{-- Form Header --}} +
+
+ +
+

Create Account

+

Join us to build your professional resume

+
+ + {{-- Error Messages --}} + @if ($errors->any()) + + @endif + + {{-- Registration Form --}} +
+ @csrf + + {{-- Name Fields Row --}} +
+
+
+ + + @error('first_name') +
{{ $message }}
+ @enderror +
+
+
+
+ + + @error('last_name') +
{{ $message }}
+ @enderror +
+
+
+ + {{-- Email Field --}} +
+ + + @error('email') +
{{ $message }}
+ @enderror +
+ + {{-- Password Field --}} +
+ + + + @error('password') +
{{ $message }}
+ @enderror + +
+ + {{-- Confirm Password Field --}} +
+ + + + @error('password_confirmation') +
{{ $message }}
+ @enderror +
+ + {{-- Terms and Conditions --}} +
+ + + @error('terms') +
{{ $message }}
+ @enderror +
+ + {{-- Newsletter Subscription --}} +
+ + +
+ + {{-- Register Button --}} + +
+ + {{-- Divider --}} +
+ or register with +
+ + {{-- Social Registration --}} + + + {{-- Login Link --}} + +
+
+
+
+ +@push('scripts') + +@endpush +@endsection diff --git a/resources/views/dashboard/index.blade.php b/resources/views/dashboard/index.blade.php new file mode 100644 index 0000000..1645974 --- /dev/null +++ b/resources/views/dashboard/index.blade.php @@ -0,0 +1,197 @@ +{{-- + Professional Home Dashboard - Professional Bootstrap Design + Clean Professional Resume Builder Dashboard + + @author David Valera Melendez + @created 2025-08-09 + @location Made in Germany ๐Ÿ‡ฉ๐Ÿ‡ช +--}} + +@extends('layouts.app') + +@section('title', 'Professional Resume Builder - Dashboard') +@section('page_class', 'home-page') + +@section('content') + +
+ +
+
+
+
+
+ +
+

Professional Resume Builder

+

+ Create impressive, professional resumes with modern design and expert guidance +

+

+ Created by David Valera Melendez | Made in Germany ๐Ÿ‡ฉ๐Ÿ‡ช +

+ +
+
+
+
+ + +
+
+

Why Choose Our Resume Builder?

+

Professional features designed for modern job seekers

+
+ +
+
+
+
+
+ +
+
Professional Design
+
+
+

Modern, clean templates that make a lasting impression on employers and hiring managers.

+
    +
  • + + Clean, modern layouts +
  • +
  • + + ATS-friendly formats +
  • +
  • + + Professional typography +
  • +
+
+
+
+ +
+
+
+
+ +
+
Easy to Use
+
+
+

Intuitive step-by-step builder that guides you through creating your perfect resume.

+
    +
  • + + Step-by-step guidance +
  • +
  • + + Real-time preview +
  • +
  • + + Smart suggestions +
  • +
+
+
+
+ +
+
+
+
+ +
+
Export Options
+
+
+

Download your resume in multiple formats, optimized for different use cases.

+
    +
  • + + PDF export +
  • +
  • + + Print-ready format +
  • +
  • + + Mobile optimized +
  • +
+
+
+
+
+
+ + +
+
+

Built with Modern Technology

+

Powered by enterprise-grade tools and frameworks

+
+ +
+
+ + Laravel 10 +
+
+ + Bootstrap 5 +
+
+ + SCSS +
+
+ + Responsive +
+
+ + Fast +
+
+

+ This resume builder is built with Laravel 10, Bootstrap 5, and modern SCSS, + ensuring a professional, responsive, and user-friendly experience across all devices. +

+
+
+ +@push('scripts') + +@endpush +@endsection diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php new file mode 100644 index 0000000..e25d100 --- /dev/null +++ b/resources/views/layouts/app.blade.php @@ -0,0 +1,251 @@ +{{-- + Main Application Layout + Professional Resume Builder - Laravel Application + + @author David Valera Melendez + @created 2025-08-08 + @location Made in Germany ๐Ÿ‡ฉ๐Ÿ‡ช +--}} + + + + + + + + + + + + {{ $title ?? 'Professional Resume Builder' }} | David Valera Melendez + + + + + + + + + + + + + + + @vite(['resources/sass/app.scss']) + @stack('styles') + + + + + + + + Skip to main content + + + + @auth + + @endauth + + +
+ + @if (session('success')) + + @endif + + @if (session('error')) + + @endif + + @if (session('warning')) + + @endif + + @if (session('info')) + + @endif + + + @yield('content') +
+ + + @guest +
+
+
+
+
Professional Resume Builder
+

+ Create outstanding resumes with our enterprise-grade platform. + Trusted by professionals worldwide. +

+
+
+
+
+
Platform
+ +
+
+
Support
+ +
+
+
+
+
+
+
+

+ ยฉ {{ date('Y') }} David Valera Melendez. Made in Germany ๐Ÿ‡ฉ๐Ÿ‡ช +

+
+
+
+ + + SSL Secured + + + + GDPR Compliant + +
+
+
+
+
+ @endguest + + + @vite(['resources/js/app.js']) + @stack('scripts') + + + + + diff --git a/resources/views/layouts/auth.blade.php b/resources/views/layouts/auth.blade.php new file mode 100644 index 0000000..7795b04 --- /dev/null +++ b/resources/views/layouts/auth.blade.php @@ -0,0 +1,205 @@ +{{-- + Authentication Layout + Professional Resume Builder - Auth Pages Layout + + @author David Valera Melendez + @created 2025-08-08 + @location Made in Germany ๐Ÿ‡ฉ๐Ÿ‡ช +--}} + + + + + + + + + + + {{ $title ?? 'Authentication' }} | Professional Resume Builder + + + + + + + + + + + + + + @vite(['resources/sass/app.scss']) + @stack('styles') + + + + + + + Skip to main content + + +
+ +
+
+ +
+

+ Resume Builder +

+

Professional CV Creation Platform

+
+ + +
+ @yield('content') +
+ + +
+
+ + SSL Encryption +
+
+ + GDPR Compliant +
+
+ + Privacy Protected +
+
+ + + +
+ + + @vite(['resources/js/app.js']) + @stack('scripts') + + + + + diff --git a/resources/views/profile/edit.blade.php b/resources/views/profile/edit.blade.php new file mode 100644 index 0000000..920649e --- /dev/null +++ b/resources/views/profile/edit.blade.php @@ -0,0 +1,220 @@ +{{-- + Edit Profile - Professional Bootstrap Design + Professional Profile Edit Page + + @author David Valera Melendez + @created 2025-08-09 + @location Made in Germany ๐Ÿ‡ฉ๐Ÿ‡ช +--}} + +@extends('layouts.app') + +@section('title', 'Edit Profile - Professional Resume Builder') +@section('page_class', 'profile-edit-page') + +@section('content') +
+ + + + +
+
+ @csrf + @method('PUT') + +
+ +
+
+
+
+ + Personal Information +
+
+
+
+ +
+
+ + + @error('name') +
{{ $message }}
+ @enderror +
+
+ + +
+
+ + + @error('email') +
{{ $message }}
+ @enderror +
+
+ + +
+
+ + + @error('phone') +
{{ $message }}
+ @enderror +
+
+ + +
+
+ + + @error('bio') +
{{ $message }}
+ @enderror +
+
+ + Optional: Brief description about yourself (max 1000 characters) +
+
+
+
+
+
+ + +
+
+
+
+ + Profile Picture +
+
+
+
+ @if($user->avatar) + {{ $user->name }} + @else +
+ +
+ @endif +
+ +
+ + + @error('avatar') +
{{ $message }}
+ @enderror +
+ + JPG, PNG or GIF (max 2MB) +
+
+
+
+ + +
+
+
+ + + + Cancel + +
+
+
+
+
+
+
+
+ +@push('scripts') + +@endpush +@endsection diff --git a/resources/views/profile/settings.blade.php b/resources/views/profile/settings.blade.php new file mode 100644 index 0000000..c95be68 --- /dev/null +++ b/resources/views/profile/settings.blade.php @@ -0,0 +1,421 @@ +{{-- + Account Settings - Professional Bootstrap Design + Professional Account Management Page + + @author David Valera Melendez + @created 2025-08-09 + @location Made in Germany ๐Ÿ‡ฉ๐Ÿ‡ช +--}} + +@extends('layouts.app') + +@section('title', 'Account Settings - Professional Resume Builder') +@section('page_class', 'account-settings-page') + +@section('content') + + +@push('scripts') + +@endpush +@endsection diff --git a/resources/views/profile/show.blade.php b/resources/views/profile/show.blade.php new file mode 100644 index 0000000..996262a --- /dev/null +++ b/resources/views/profile/show.blade.php @@ -0,0 +1,233 @@ +{{-- + User Profile - Professional Bootstrap Design + Professional Profile Management Page + + @author David Valera Melendez + @created 2025-08-09 + @location Made in Germany ๐Ÿ‡ฉ๐Ÿ‡ช +--}} + +@extends('layouts.app') + +@section('title', 'My Profile - Professional Resume Builder') +@section('page_class', 'profile-page') + +@section('content') +
+ +
+
+
+
+
+
+ @if($user->avatar) + {{ $user->name }} + @else +
+ +
+ @endif +
+ + + +
+
+
+
+

{{ $user->name }}

+

{{ $user->email }}

+ @if($user->phone) +

+ + {{ $user->phone }} +

+ @endif + +
+
+
+
+
+ + +
+
+ +
+
+
+
+ + Personal Information +
+
+
+
+
+ + {{ $user->name }} +
+
+ + {{ $user->email }} +
+ @if($user->phone) +
+ + {{ $user->phone }} +
+ @endif +
+ + {{ $user->created_at->format('F j, Y') }} +
+
+
+
+
+ + +
+
+
+
+ + Account Status +
+
+
+
+
+
+ +
+
+ Email Verified + Your email address is verified +
+
+
+
+ +
+
+ Account Active + Your account is in good standing +
+
+
+
+ +
+
+ Last Login + {{ $user->updated_at->diffForHumans() }} +
+
+
+
+
+
+
+
+ + + @if($user->bio) +
+
+
+
+ + About Me +
+
+
+

{{ $user->bio }}

+
+
+
+ @endif + + +
+
+
+
+ + Recent Activity +
+ + View All Resumes + +
+
+ @if($user->resumes && $user->resumes->count() > 0) +
+ @foreach($user->resumes->take(5) as $resume) +
+
+ +
+
+ {{ $resume->title ?? 'Untitled Resume' }} + + Updated {{ $resume->updated_at->diffForHumans() }} + +
+ +
+ @endforeach +
+ @else +
+
+ +
+
No Resumes Yet
+

Start building your professional resume today

+ + + Create Your First Resume + +
+ @endif +
+
+
+
+ +@push('scripts') + +@endpush +@endsection diff --git a/resources/views/resume-builder/create.blade.php b/resources/views/resume-builder/create.blade.php new file mode 100644 index 0000000..6c159b5 --- /dev/null +++ b/resources/views/resume-builder/create.blade.php @@ -0,0 +1,189 @@ +@extends('layouts.app') + +@section('title', 'Create New Resume') + +@section('content') +
+
+
+ +
+
+

Create New Resume

+

Choose a template to get started with your professional resume

+
+ + Back to Dashboard + +
+ + +
+ @forelse($templates as $template) +
+
+
+ @if(isset($template['preview']) && $template['preview']) + {{ $template['name'] }} Template + @else +
+ +

{{ $template['name'] }}

+
+ @endif +
+
+
+
{{ $template['name'] }}
+ @if(isset($template['category'])) + {{ $template['category'] }} + @endif +
+

{{ $template['description'] }}

+
+
+ @csrf + + +
+ + Preview + +
+
+
+
+ @empty +
+
+ +

No Templates Available

+

Please contact support to add templates to the system.

+
+
+ @endforelse +
+ + @if(count($templates) > 0) + +
+
+
+
+
โœจ What You Get
+
+
+
    +
  • Professional layouts
  • +
  • ATS-friendly formats
  • +
+
+
+
    +
  • Easy customization
  • +
  • PDF export
  • +
+
+
+
    +
  • Public sharing links
  • +
  • Real-time preview
  • +
+
+
+
+
+
+
+ @endif +
+
+
+ + + +@endsection + +@push('styles') + +@endpush + +@push('scripts') + +@endpush diff --git a/resources/views/resume-builder/index.blade.php b/resources/views/resume-builder/index.blade.php new file mode 100644 index 0000000..658885f --- /dev/null +++ b/resources/views/resume-builder/index.blade.php @@ -0,0 +1,327 @@ +@extends('layouts.app') + +@section('title', 'My Resumes') + +@section('content') +
+
+
+ +
+
+

My Resumes

+

Manage and edit your professional resumes

+
+ +
+ + +
+
+
+
+
+
+
Total Resumes
+

{{ $resumeStats['total_resumes'] ?? 0 }}

+
+
+ +
+
+
+
+
+
+
+
+
+
+
Published
+

{{ $resumeStats['published_resumes'] ?? 0 }}

+
+
+ +
+
+
+
+
+
+
+
+
+
+
Draft
+

{{ $resumeStats['draft_resumes'] ?? 0 }}

+
+
+ +
+
+
+
+
+
+
+
+
+
+
Total Views
+

{{ $resumeStats['total_views'] ?? 0 }}

+
+
+ +
+
+
+
+
+
+ + +
+ @forelse($resumes as $resume) +
+
+
+
+
+
{{ $resume->title ?? 'Untitled Resume' }}
+

+ Template: {{ ucfirst($resume->template_id ?? 'Unknown') }} +

+
+ +
+ + +
+ @if($resume->status === 'published') + Published + @elseif($resume->status === 'draft') + Draft + @else + {{ ucfirst($resume->status ?? 'Unknown') }} + @endif + + @if($resume->is_public) + Public + @endif +
+ + + @php + $completion = $resume->completion_percentage ?? 0; + @endphp +
+
+ Completion + {{ $completion }}% +
+
+
+
+
+ + +
+
+ Created: {{ $resume->created_at->format('M j, Y') }} +
+
+ Updated: {{ $resume->updated_at->diffForHumans() }} +
+
+ + +
+ + @if($resume->is_public && $resume->public_url) + + @endif +
+
+
+
+ @empty +
+
+
+ +
+

No Resumes Yet

+

Create your first professional resume to get started.

+ + Create Your First Resume + +
+
+ @endforelse +
+ + + @if($resumes->hasPages()) +
+ {{ $resumes->links() }} +
+ @endif +
+
+
+ + + +@endsection + +@push('styles') + +@endpush + +@push('scripts') + +@endpush diff --git a/resources/views/templates/index.blade.php b/resources/views/templates/index.blade.php new file mode 100644 index 0000000..3a0198b --- /dev/null +++ b/resources/views/templates/index.blade.php @@ -0,0 +1,277 @@ +{{-- + Resume Templates - Professional Bootstrap Design + Professional Template Selection Page + + @author David Valera Melendez + @created 2025-08-09 + @location Made in Germany ๐Ÿ‡ฉ๐Ÿ‡ช +--}} + +@extends('layouts.app') + +@section('title', 'Resume Templates - Professional Resume Builder') +@section('page_class', 'templates-page') + +@section('content') +
+ + + + +
+
+
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+
+
+ + +
+
+ @forelse($templates as $template) +
+
+ +
+ @if($template['preview_image']) + {{ $template['name'] }} Template + @else +
+ + {{ $template['name'] }} Template +
+ @endif + + @if($template['is_premium']) +
+ + Premium +
+ @else +
+ + Free +
+ @endif +
+ + +
+
{{ $template['name'] }}
+

{{ $template['description'] }}

+
+ + + +
+
+ @empty +
+
+
+ +
+
No Templates Available
+

+ We're working on adding more professional templates for you to choose from. +

+ + + Back to Dashboard + +
+
+ @endforelse +
+
+ + +
+
+

Template Categories

+

Choose the style that best represents your professional image

+
+ +
+
+
+
+
+ +
+
Professional
+

Clean, formal templates perfect for traditional industries

+
+
+
+ +
+
+
+
+ +
+
Modern
+

Contemporary designs for tech and startup environments

+
+
+
+ +
+
+
+
+ +
+
Classic
+

Timeless layouts that work across all industries

+
+
+
+ +
+
+
+
+ +
+
Creative
+

Unique designs for creative and artistic professionals

+
+
+
+
+
+
+ +@push('scripts') + +@endpush +@endsection diff --git a/resources/views/templates/preview.blade.php b/resources/views/templates/preview.blade.php new file mode 100644 index 0000000..f54e4f8 --- /dev/null +++ b/resources/views/templates/preview.blade.php @@ -0,0 +1,443 @@ +{{-- + Template Preview - Professional Bootstrap Design + Full Screen Template Preview with Sample Data + + @author David Valera Melendez + @created 2025-08-09 + @location Made in Germany ๐Ÿ‡ฉ๐Ÿ‡ช +--}} + +@extends('layouts.app') + +@section('title', 'Preview: ' . $templateData['name'] . ' Template - Professional Resume Builder') +@section('page_class', 'template-preview-page') + +@section('content') +
+ +
+
+
+

{{ $templateData['name'] }} Template Preview

+

Sample resume with placeholder content

+
+ +
+
+ + +
+
+
+ +
+ +
+
+

{{ $sampleData['name'] }}

+

{{ $sampleData['title'] }}

+
+
+ + {{ $sampleData['email'] }} +
+
+ + {{ $sampleData['phone'] }} +
+
+ + Berlin, Germany +
+
+ + linkedin.com/in/johndoe +
+
+
+
+ + +
+

Professional Summary

+
+

{{ $sampleData['summary'] }} Proven track record of delivering high-quality solutions and leading cross-functional teams to achieve business objectives.

+
+
+ + +
+

Professional Experience

+
+
+
+

Senior Software Developer

+ Tech Solutions GmbH + Jan 2020 - Present +
+
    +
  • Led development of web applications using modern frameworks
  • +
  • Collaborated with cross-functional teams to deliver projects on time
  • +
  • Mentored junior developers and conducted code reviews
  • +
  • Improved system performance by 40% through optimization
  • +
+
+ +
+
+

Software Developer

+ Digital Innovations Ltd + Mar 2018 - Dec 2019 +
+
    +
  • Developed and maintained web applications using PHP and Laravel
  • +
  • Implemented responsive designs and improved user experience
  • +
  • Participated in agile development processes and sprint planning
  • +
+
+
+
+ + +
+

Education

+
+
+

Bachelor of Science in Computer Science

+ Technical University of Berlin + 2018 +
+
+
+ + +
+

Technical Skills

+
+
+
+
Programming Languages
+
+ PHP + JavaScript + Python + TypeScript +
+
+
+
Frameworks & Libraries
+
+ Laravel + Vue.js + React + Bootstrap +
+
+
+
Tools & Technologies
+
+ Git + Docker + MySQL + Redis +
+
+
+
+
+ + +
+

Languages

+
+
+
+ English + Native +
+
+ German + Fluent +
+
+ Spanish + Intermediate +
+
+
+
+
+
+
+
+ + +
+
+
+
+
+
+
Template: {{ $templateData['name'] }}
+

This is a sample preview with placeholder content

+
+
+
+
+ + + + Edit This Template + +
+
+
+
+
+
+
+ +@push('styles') + +@endpush + +@push('scripts') + +@endpush +@endsection diff --git a/resources/views/templates/show.blade.php b/resources/views/templates/show.blade.php new file mode 100644 index 0000000..beb09a0 --- /dev/null +++ b/resources/views/templates/show.blade.php @@ -0,0 +1,267 @@ +{{-- + Template Details - Professional Bootstrap Design + Professional Template Detail Page + + @author David Valera Melendez + @created 2025-08-09 + @location Made in Germany ๐Ÿ‡ฉ๐Ÿ‡ช +--}} + +@extends('layouts.app') + +@section('title', $templateData['name'] . ' Template - Professional Resume Builder') +@section('page_class', 'template-detail-page') + +@section('content') +
+ +
+
+
+
+ +

{{ $templateData['name'] }} Template

+

{{ $templateData['description'] }}

+ +
+ @if($templateData['is_premium']) + + + Premium Template + + @else + + + Free Template + + @endif + Professional +
+
+
+ +
+
+ + +
+
+ +
+
+
+
+ + Template Preview +
+
+
+
+ @if($templateData['preview_image']) + {{ $templateData['name'] }} Template Preview + @else +
+ +
{{ $templateData['name'] }} Template
+

Click "Live Preview" to see the full template

+
+ @endif +
+ + +
+
+
+ + +
+
+
+
+ + Template Features +
+
+
+
+
+ + Professional Layout +
+
+ + ATS Friendly +
+
+ + Print Optimized +
+
+ + Easy Customization +
+
+ + Multiple Formats +
+
+ + Modern Typography +
+
+
+
+ + +
+
+
+ + Template Stats +
+
+
+
+
4.8/5
+
Rating
+
+
+
2,345
+
Downloads
+
+
+
98%
+
Success Rate
+
+
+
+ + + +
+
+
+ + +
+
+
+
+ + Tips for Using This Template +
+
+
+
+
+
+
+ +
+
+
Perfect For
+

Corporate professionals, managers, and executives in traditional industries.

+
+
+
+
+
+
+ +
+
+
Customization
+

Easily customize colors, fonts, and sections to match your personal brand.

+
+
+
+
+ + +
+
+
+
+ +@push('scripts') + +@endpush +@endsection diff --git a/routes/api.php b/routes/api.php new file mode 100644 index 0000000..1b94604 --- /dev/null +++ b/routes/api.php @@ -0,0 +1,166 @@ + + * @created 2025-08-08 + * @location Made in Germany ๐Ÿ‡ฉ๐Ÿ‡ช + */ + +use App\Http\Controllers\Api\AuthController; +use App\Http\Controllers\Api\ResumeController; +use App\Http\Controllers\Api\TemplateController; +use App\Http\Controllers\Api\UserController; +use Illuminate\Http\Request; +use Illuminate\Support\Facades\Route; + +/* +|-------------------------------------------------------------------------- +| API Routes +|-------------------------------------------------------------------------- +| +| Here is where you can register API routes for your application. These +| routes are loaded by the RouteServiceProvider and all of them will +| be assigned to the "api" middleware group. Make something great! +| +*/ + +// Public API Routes +Route::prefix('v1')->group(function () { + // Authentication + Route::prefix('auth')->group(function () { + Route::post('login', [AuthController::class, 'login']); + Route::post('register', [AuthController::class, 'register']); + Route::post('forgot-password', [AuthController::class, 'forgotPassword']); + Route::post('reset-password', [AuthController::class, 'resetPassword']); + Route::post('refresh', [AuthController::class, 'refresh']); + }); + + // Public Templates + Route::get('templates', [TemplateController::class, 'index']); + Route::get('templates/{template}', [TemplateController::class, 'show']); + + // Public Resume Views + Route::get('resumes/public/{publicUrl}', [ResumeController::class, 'showPublic']); +}); + +// Protected API Routes +Route::middleware(['auth:sanctum', 'throttle:60,1'])->prefix('v1')->group(function () { + // Authentication + Route::prefix('auth')->group(function () { + Route::post('logout', [AuthController::class, 'logout']); + Route::get('me', [AuthController::class, 'me']); + Route::put('profile', [AuthController::class, 'updateProfile']); + Route::put('password', [AuthController::class, 'updatePassword']); + }); + + // User Management + Route::prefix('user')->group(function () { + Route::get('profile', [UserController::class, 'profile']); + Route::put('profile', [UserController::class, 'updateProfile']); + Route::post('avatar', [UserController::class, 'uploadAvatar']); + Route::delete('avatar', [UserController::class, 'deleteAvatar']); + Route::get('stats', [UserController::class, 'stats']); + Route::delete('account', [UserController::class, 'deleteAccount']); + }); + + // Resume Management + Route::apiResource('resumes', ResumeController::class); + Route::prefix('resumes')->group(function () { + Route::post('{resume}/duplicate', [ResumeController::class, 'duplicate']); + Route::post('{resume}/make-public', [ResumeController::class, 'makePublic']); + Route::post('{resume}/make-private', [ResumeController::class, 'makePrivate']); + Route::post('{resume}/autosave', [ResumeController::class, 'autosave']); + Route::get('{resume}/download-pdf', [ResumeController::class, 'downloadPdf']); + Route::get('{resume}/analytics', [ResumeController::class, 'analytics']); + Route::post('{resume}/sections/{section}', [ResumeController::class, 'updateSection']); + }); + + // Templates + Route::get('templates', [TemplateController::class, 'index']); + Route::get('templates/{template}', [TemplateController::class, 'show']); + Route::get('templates/{template}/preview', [TemplateController::class, 'preview']); +}); + +// Admin API Routes +Route::middleware(['auth:sanctum', 'admin', 'throttle:100,1'])->prefix('v1/admin')->group(function () { + // User Management + Route::apiResource('users', AdminUserController::class); + Route::post('users/{user}/activate', [AdminUserController::class, 'activate']); + Route::post('users/{user}/deactivate', [AdminUserController::class, 'deactivate']); + + // Template Management + Route::apiResource('templates', AdminTemplateController::class); + + // Analytics + Route::get('analytics/dashboard', [AdminAnalyticsController::class, 'dashboard']); + Route::get('analytics/users', [AdminAnalyticsController::class, 'users']); + Route::get('analytics/resumes', [AdminAnalyticsController::class, 'resumes']); + + // System + Route::get('system/health', [AdminSystemController::class, 'health']); + Route::get('system/logs', [AdminSystemController::class, 'logs']); +}); + +// Health Check +Route::get('health', function () { + return response()->json([ + 'status' => 'ok', + 'service' => 'Professional Resume Builder API', + 'version' => 'v1', + 'timestamp' => now()->toISOString(), + 'author' => 'David Valera Melendez', + 'location' => 'Made in Germany ๐Ÿ‡ฉ๐Ÿ‡ช' + ]); +}); + +// API Documentation +Route::get('docs', function () { + return response()->json([ + 'name' => 'Professional Resume Builder API', + 'version' => 'v1', + 'description' => 'Enterprise-grade API for resume building and management', + 'author' => 'David Valera Melendez', + 'email' => 'david@valera-melendez.de', + 'location' => 'Made in Germany ๐Ÿ‡ฉ๐Ÿ‡ช', + 'endpoints' => [ + 'authentication' => '/api/v1/auth', + 'users' => '/api/v1/user', + 'resumes' => '/api/v1/resumes', + 'templates' => '/api/v1/templates', + 'admin' => '/api/v1/admin' + ], + 'documentation' => 'https://docs.valera-melendez.de/resume-builder-api' + ]); +}); + +// Rate Limited Public Endpoints +Route::middleware(['throttle:30,1'])->prefix('v1/public')->group(function () { + // Contact form + Route::post('contact', [PublicController::class, 'contact']); + + // Newsletter subscription + Route::post('newsletter', [PublicController::class, 'newsletter']); + + // Template preview requests + Route::post('template-preview', [PublicController::class, 'templatePreview']); +}); + +// Webhook Endpoints +Route::prefix('webhooks')->group(function () { + Route::post('stripe', [WebhookController::class, 'stripe'])->name('webhooks.stripe'); + Route::post('mailgun', [WebhookController::class, 'mailgun'])->name('webhooks.mailgun'); + Route::post('analytics', [WebhookController::class, 'analytics'])->name('webhooks.analytics'); +}); + +// Fallback for undefined API routes +Route::fallback(function () { + return response()->json([ + 'error' => 'Endpoint not found', + 'message' => 'The requested API endpoint does not exist.', + 'available_versions' => ['v1'], + 'documentation' => 'https://docs.valera-melendez.de/resume-builder-api' + ], 404); +}); diff --git a/routes/channels.php b/routes/channels.php new file mode 100644 index 0000000..5d451e1 --- /dev/null +++ b/routes/channels.php @@ -0,0 +1,18 @@ +id === (int) $id; +}); diff --git a/routes/console.php b/routes/console.php new file mode 100644 index 0000000..e05f4c9 --- /dev/null +++ b/routes/console.php @@ -0,0 +1,19 @@ +comment(Inspiring::quote()); +})->purpose('Display an inspiring quote'); diff --git a/routes/web.php b/routes/web.php new file mode 100644 index 0000000..c5adcf2 --- /dev/null +++ b/routes/web.php @@ -0,0 +1,170 @@ + + * @created 2025-08-08 + * @location Made in Germany ๐Ÿ‡ฉ๐Ÿ‡ช + */ + +use App\Http\Controllers\Auth\AuthController; +use App\Http\Controllers\DashboardController; +use App\Http\Controllers\ResumeBuilderController; +use App\Http\Controllers\HomeController; +use App\Http\Controllers\TemplateController; +use App\Http\Controllers\ProfileController; +use Illuminate\Support\Facades\Route; + +/* +|-------------------------------------------------------------------------- +| Web Routes +|-------------------------------------------------------------------------- +| +| Here is where you can register web routes for your application. These +| routes are loaded by the RouteServiceProvider and all of them will +| be assigned to the "web" middleware group. Make something great! +| +*/ + +// Public Routes +Route::get('/', [HomeController::class, 'index'])->name('home'); +Route::get('/features', [HomeController::class, 'features'])->name('features'); +Route::get('/pricing', [HomeController::class, 'pricing'])->name('pricing'); +Route::get('/help', [HomeController::class, 'help'])->name('help'); +Route::get('/contact', [HomeController::class, 'contact'])->name('contact'); +Route::get('/privacy', [HomeController::class, 'privacy'])->name('privacy'); +Route::get('/terms', [HomeController::class, 'terms'])->name('terms'); +Route::get('/support', [HomeController::class, 'support'])->name('support'); + +// Public Resume Views +Route::get('/resume/{publicUrl}', [ResumeBuilderController::class, 'showPublic'])->name('public.resume'); + +// Authentication Routes +Route::middleware('guest')->group(function () { + // Login + Route::get('/login', [AuthController::class, 'showLoginForm'])->name('login'); + Route::post('/login', [AuthController::class, 'login']); + + // Registration + Route::get('/register', [AuthController::class, 'showRegistrationForm'])->name('register'); + Route::post('/register', [AuthController::class, 'register']); + + // Password Reset + Route::get('/forgot-password', [AuthController::class, 'showForgotPasswordForm'])->name('password.request'); + Route::post('/forgot-password', [AuthController::class, 'sendResetLinkEmail'])->name('password.email'); + Route::get('/reset-password/{token}', [AuthController::class, 'showResetPasswordForm'])->name('password.reset'); + Route::post('/reset-password', [AuthController::class, 'resetPassword'])->name('password.update'); +}); + +// Authenticated Routes +Route::middleware('auth')->group(function () { + // Logout + Route::post('/logout', [AuthController::class, 'logout'])->name('logout'); + + // Dashboard + Route::get('/dashboard', [DashboardController::class, 'index'])->name('dashboard'); + + // Profile Management + Route::prefix('profile')->name('profile.')->group(function () { + Route::get('/', [ProfileController::class, 'show'])->name('show'); + Route::get('/edit', [ProfileController::class, 'edit'])->name('edit'); + Route::put('/', [ProfileController::class, 'update'])->name('update'); + Route::delete('/', [ProfileController::class, 'destroy'])->name('destroy'); + }); + + // Account Settings + Route::prefix('account')->name('account.')->group(function () { + Route::get('/settings', [ProfileController::class, 'settings'])->name('settings'); + Route::put('/password', [ProfileController::class, 'updatePassword'])->name('password.update'); + Route::put('/preferences', [ProfileController::class, 'updatePreferences'])->name('preferences.update'); + }); + + // Resume Builder + Route::prefix('resume-builder')->name('resume-builder.')->group(function () { + Route::get('/', [ResumeBuilderController::class, 'index'])->name('index'); + Route::get('/create', [ResumeBuilderController::class, 'create'])->name('create'); + Route::post('/', [ResumeBuilderController::class, 'store'])->name('store'); + Route::get('/{resume}/edit', [ResumeBuilderController::class, 'edit'])->name('edit'); + Route::put('/{resume}', [ResumeBuilderController::class, 'update'])->name('update'); + Route::get('/{resume}/preview', [ResumeBuilderController::class, 'preview'])->name('preview'); + Route::get('/{resume}/download-pdf', [ResumeBuilderController::class, 'downloadPdf'])->name('download-pdf'); + Route::delete('/{resume}', [ResumeBuilderController::class, 'destroy'])->name('destroy'); + + // Resume Actions + Route::post('/{resume}/duplicate', [ResumeBuilderController::class, 'duplicate'])->name('duplicate'); + Route::post('/{resume}/make-public', [ResumeBuilderController::class, 'makePublic'])->name('make-public'); + Route::post('/{resume}/make-private', [ResumeBuilderController::class, 'makePrivate'])->name('make-private'); + }); + + // Templates + Route::prefix('templates')->name('templates.')->group(function () { + Route::get('/', [TemplateController::class, 'index'])->name('index'); + Route::get('/{template}', [TemplateController::class, 'show'])->name('show'); + Route::get('/{template}/preview', [TemplateController::class, 'preview'])->name('preview'); + }); +}); + +// API Routes for AJAX requests +Route::middleware(['auth', 'throttle:60,1'])->prefix('api')->name('api.')->group(function () { + // Resume API + Route::prefix('resumes')->name('resumes.')->group(function () { + Route::get('/', [ResumeBuilderController::class, 'apiIndex'])->name('index'); + Route::post('/{resume}/autosave', [ResumeBuilderController::class, 'autosave'])->name('autosave'); + Route::get('/{resume}/analytics', [ResumeBuilderController::class, 'analytics'])->name('analytics'); + }); + + // Profile API + Route::prefix('profile')->name('profile.')->group(function () { + Route::put('/avatar', [ProfileController::class, 'updateAvatar'])->name('avatar.update'); + Route::get('/completion', [ProfileController::class, 'completion'])->name('completion'); + }); +}); + +// Admin Routes (Future Enhancement) +// Route::middleware(['auth', 'admin'])->prefix('admin')->name('admin.')->group(function () { +// Route::get('/dashboard', [AdminController::class, 'dashboard'])->name('dashboard'); +// Route::resource('users', AdminUserController::class); +// Route::resource('templates', AdminTemplateController::class); +// }); + +// Webhook Routes (Future Enhancement) +// Route::prefix('webhooks')->name('webhooks.')->group(function () { +// Route::post('/payment/stripe', [WebhookController::class, 'stripe'])->name('stripe'); +// Route::post('/analytics', [WebhookController::class, 'analytics'])->name('analytics'); +// }); + +// Health Check +Route::get('/health', function () { + return response()->json([ + 'status' => 'ok', + 'timestamp' => now()->toISOString(), + 'version' => config('app.version', '1.0.0'), + 'environment' => app()->environment(), + 'author' => 'David Valera Melendez', + 'location' => 'Made in Germany ๐Ÿ‡ฉ๐Ÿ‡ช' + ]); +})->name('health'); + +// Sitemap (SEO) +Route::get('/sitemap.xml', [SeoController::class, 'sitemap'])->name('sitemap'); + +// Robots.txt (SEO) +Route::get('/robots.txt', function () { + $content = "User-agent: *\n"; + $content .= "Allow: /\n"; + $content .= "Disallow: /admin/\n"; + $content .= "Disallow: /api/\n"; + $content .= "Sitemap: " . route('sitemap') . "\n"; + + return response($content, 200, ['Content-Type' => 'text/plain']); +})->name('robots'); + +// Fallback for SPA-like behavior (if needed) +Route::fallback(function () { + return response()->view('errors.404', [ + 'title' => 'Page Not Found', + 'message' => 'The page you are looking for could not be found.' + ], 404); +}); diff --git a/storage/app/.gitignore b/storage/app/.gitignore new file mode 100644 index 0000000..8f4803c --- /dev/null +++ b/storage/app/.gitignore @@ -0,0 +1,3 @@ +* +!public/ +!.gitignore diff --git a/storage/app/public/.gitignore b/storage/app/public/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/storage/app/public/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/storage/framework/.gitignore b/storage/framework/.gitignore new file mode 100644 index 0000000..05c4471 --- /dev/null +++ b/storage/framework/.gitignore @@ -0,0 +1,9 @@ +compiled.php +config.php +down +events.scanned.php +maintenance.php +routes.php +routes.scanned.php +schedule-* +services.json diff --git a/storage/framework/cache/.gitignore b/storage/framework/cache/.gitignore new file mode 100644 index 0000000..01e4a6c --- /dev/null +++ b/storage/framework/cache/.gitignore @@ -0,0 +1,3 @@ +* +!data/ +!.gitignore diff --git a/storage/framework/cache/data/.gitignore b/storage/framework/cache/data/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/storage/framework/cache/data/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/storage/framework/sessions/.gitignore b/storage/framework/sessions/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/storage/framework/sessions/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/storage/framework/testing/.gitignore b/storage/framework/testing/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/storage/framework/testing/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/storage/framework/views/.gitignore b/storage/framework/views/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/storage/framework/views/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore