From fef928af26dc1d21883581aba6f337e8fa47274a Mon Sep 17 00:00:00 2001 From: mohanmd Date: Thu, 21 May 2026 10:39:23 +0530 Subject: [PATCH] employer login issue --- .../Controllers/Api/WorkerAuthController.php | 244 +++++++ .../Api/WorkerProfileController.php | 210 ++++++ .../Employer/CandidateController.php | 81 ++- .../Employer/EmployerAuthController.php | 66 +- .../Employer/MessageController.php | 22 + .../Controllers/Employer/WorkerController.php | 52 +- app/Http/Middleware/HandleInertiaRequests.php | 13 +- app/Http/Middleware/WorkerApiMiddleware.php | 44 ++ app/Models/JobOffer.php | 36 + app/Models/Worker.php | 14 + bootstrap/app.php | 2 + ...04323_add_auth_fields_to_workers_table.php | 29 + ...6_05_20_104357_create_job_offers_table.php | 34 + public/swagger.json | 680 ++++++++++++++++++ ...05_passport_Screenshot2025-08-21131531.png | Bin 0 -> 38854 bytes resources/js/Layouts/EmployerLayout.jsx | 10 +- .../js/Pages/Employer/Auth/CreatePassword.jsx | 317 ++++---- .../js/Pages/Employer/Auth/ForgotPassword.jsx | 2 +- resources/js/Pages/Employer/Auth/Login.jsx | 67 +- resources/js/Pages/Employer/Auth/Register.jsx | 146 ++-- .../js/Pages/Employer/Auth/VerifyEmail.jsx | 265 +++---- .../js/Pages/Employer/Hiring/Confirm.jsx | 9 +- .../js/Pages/Employer/SelectedCandidates.jsx | 4 +- resources/views/swagger.blade.php | 63 ++ routes/api.php | 31 + routes/web.php | 6 + 26 files changed, 2006 insertions(+), 441 deletions(-) create mode 100644 app/Http/Controllers/Api/WorkerAuthController.php create mode 100644 app/Http/Controllers/Api/WorkerProfileController.php create mode 100644 app/Http/Middleware/WorkerApiMiddleware.php create mode 100644 app/Models/JobOffer.php create mode 100644 database/migrations/2026_05_20_104323_add_auth_fields_to_workers_table.php create mode 100644 database/migrations/2026_05_20_104357_create_job_offers_table.php create mode 100644 public/swagger.json create mode 100644 public/uploads/documents/1779274805_passport_Screenshot2025-08-21131531.png create mode 100644 resources/views/swagger.blade.php create mode 100644 routes/api.php diff --git a/app/Http/Controllers/Api/WorkerAuthController.php b/app/Http/Controllers/Api/WorkerAuthController.php new file mode 100644 index 0000000..130f547 --- /dev/null +++ b/app/Http/Controllers/Api/WorkerAuthController.php @@ -0,0 +1,244 @@ +all(), [ + 'name' => 'required|string|max:255', + 'phone' => 'required|string|max:50|unique:workers,phone', + 'salary' => 'required|numeric|min:0', + 'password' => 'required|string|min:6', // Password is required + 'passport_file' => 'required|file|mimes:jpeg,png,jpg,pdf|max:10240', // STRICTLY REQUIRED (Max 10MB) + 'skills' => 'nullable', // Handled dynamically in controller (JSON, Array, or Comma-separated) + 'visa_file' => 'nullable|file|mimes:jpeg,png,jpg,pdf|max:10240', // Optional (Max 10MB) + ], [ + 'phone.unique' => 'This mobile number is already registered.', + 'password.min' => 'The password must be at least 6 characters long.', + 'passport_file.required' => 'A physical passport file upload is required for registration.', + 'passport_file.mimes' => 'The passport document must be an image (jpg, jpeg, png) or a PDF.', + 'passport_file.max' => 'The passport document must not exceed 10MB.', + 'visa_file.mimes' => 'The visa document must be an image (jpg, jpeg, png) or a PDF.', + 'visa_file.max' => 'The visa document must not exceed 10MB.', + ]); + + if ($validator->fails()) { + return response()->json([ + 'success' => false, + 'message' => 'Validation error.', + 'errors' => $validator->errors() + ], 422); + } + + try { + // 2. Provision backend defaults + $categoryId = 7; // Default to "General Helper" category + $nationality = 'Not Specified'; + $age = 25; + $availability = 'Immediate'; + $experience = 'Not Specified'; + $religion = 'Not Specified'; + $bio = 'Hardworking and reliable General Helper available for immediate hire.'; + + // Clean phone for unique email generation + $phoneClean = preg_replace('/[^0-9]/', '', $request->phone); + $email = "worker.{$phoneClean}@migrant.com"; + + // Avoid duplicate email conflict + if (Worker::where('email', $email)->exists()) { + $email = "worker.{$phoneClean}." . rand(10, 99) . "@migrant.com"; + } + + // Parse skills robustly for multipart form variants + $skillsArray = []; + if ($request->has('skills')) { + $skillsInput = $request->skills; + if (is_array($skillsInput)) { + $skillsArray = $skillsInput; + } elseif (is_string($skillsInput)) { + $decoded = json_decode($skillsInput, true); + if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) { + $skillsArray = $decoded; + } else { + // Fallback: comma separated e.g. "6,8" + $skillsArray = array_filter(array_map('trim', explode(',', $skillsInput))); + } + } + } + + // Handle file uploads securely + $passportPath = null; + $visaPath = null; + + // Ensure destination directory exists + $destinationPath = public_path('uploads/documents'); + if (!file_exists($destinationPath)) { + mkdir($destinationPath, 0755, true); + } + + if ($request->hasFile('passport_file')) { + $file = $request->file('passport_file'); + $fileName = time() . '_passport_' . preg_replace('/[^a-zA-Z0-9_.-]/', '', $file->getClientOriginalName()); + $file->move($destinationPath, $fileName); + $passportPath = 'uploads/documents/' . $fileName; + } + + if ($request->hasFile('visa_file')) { + $file = $request->file('visa_file'); + $fileName = time() . '_visa_' . preg_replace('/[^a-zA-Z0-9_.-]/', '', $file->getClientOriginalName()); + $file->move($destinationPath, $fileName); + $visaPath = 'uploads/documents/' . $fileName; + } + + // Generate initial API token + $apiToken = Str::random(80); + + // 3. Save Worker and Document records inside database transaction + $result = DB::transaction(function () use ($request, $categoryId, $email, $nationality, $age, $availability, $experience, $religion, $bio, $skillsArray, $passportPath, $visaPath, $apiToken) { + + $worker = Worker::create([ + 'name' => $request->name, + 'email' => $email, + 'phone' => $request->phone, + 'password' => $request->password, // Will cast to hashed auto-magically + 'nationality' => $nationality, + 'age' => $age, + 'salary' => $request->salary, + 'availability' => $availability, + 'experience' => $experience, + 'religion' => $religion, + 'bio' => $bio, + 'category_id' => $categoryId, + 'verified' => false, + 'status' => 'active', + 'api_token' => $apiToken, + ]); + + // Sync selected skills + if (!empty($skillsArray)) { + $worker->skills()->sync($skillsArray); + } + + // Provision Passport document record (User uploaded REQUIRED file) + $worker->documents()->create([ + 'type' => 'passport', + 'number' => 'P' . rand(100000, 999999), + 'issue_date' => now()->subYears(2)->toDateString(), + 'expiry_date' => now()->addYears(8)->toDateString(), + 'ocr_accuracy' => 98.5, + 'file_path' => $passportPath, + ]); + + // Provision Visa document record (User uploaded OR mock scaffolding) + $worker->documents()->create([ + 'type' => 'visa', + 'number' => 'V' . rand(100000, 999999), + 'issue_date' => now()->subYear()->toDateString(), + 'expiry_date' => now()->addYears(2)->toDateString(), + 'ocr_accuracy' => $visaPath ? 98.5 : 0.0, + 'file_path' => $visaPath, + ]); + + return $worker; + }); + + // Load relations for response + $result->load(['category', 'skills', 'documents']); + + return response()->json([ + 'success' => true, + 'message' => 'Worker registered and authenticated successfully.', + 'data' => [ + 'worker' => $result, + 'token' => $apiToken + ] + ], 201); + + } catch (\Exception $e) { + logger()->error('Mobile Unified Worker Registration Failure: ' . $e->getMessage()); + + return response()->json([ + 'success' => false, + 'message' => 'An error occurred during worker registration. Please try again.', + 'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error' + ], 500); + } + } + + /** + * Authenticate a worker and return a secure Bearer token. + * + * @param \Illuminate\Http\Request $request + * @return \Illuminate\Http\JsonResponse + */ + public function login(Request $request) + { + $validator = Validator::make($request->all(), [ + 'phone' => 'required|string', + 'password' => 'required|string', + ]); + + if ($validator->fails()) { + return response()->json([ + 'success' => false, + 'message' => 'Validation error.', + 'errors' => $validator->errors() + ], 422); + } + + try { + $worker = Worker::where('phone', $request->phone)->first(); + + if (!$worker || !Hash::check($request->password, $worker->password)) { + return response()->json([ + 'success' => false, + 'message' => 'Invalid mobile number or password.' + ], 401); + } + + // Generate and assign a fresh API token + $apiToken = Str::random(80); + $worker->update(['api_token' => $apiToken]); + + return response()->json([ + 'success' => true, + 'message' => 'Worker logged in successfully.', + 'data' => [ + 'worker' => $worker->load(['category', 'skills', 'documents']), + 'token' => $apiToken + ] + ], 200); + + } catch (\Exception $e) { + logger()->error('Mobile Worker Login Failure: ' . $e->getMessage()); + + return response()->json([ + 'success' => false, + 'message' => 'An error occurred during login. Please try again.', + 'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error' + ], 500); + } + } +} diff --git a/app/Http/Controllers/Api/WorkerProfileController.php b/app/Http/Controllers/Api/WorkerProfileController.php new file mode 100644 index 0000000..cb5364d --- /dev/null +++ b/app/Http/Controllers/Api/WorkerProfileController.php @@ -0,0 +1,210 @@ +attributes->get('worker'); + + $worker->load(['category', 'skills', 'documents']); + + return response()->json([ + 'success' => true, + 'data' => [ + 'worker' => $worker + ] + ], 200); + } + + /** + * Update authorized worker's profile details. + * + * @param \Illuminate\Http\Request $request + * @return \Illuminate\Http\JsonResponse + */ + public function updateProfile(Request $request) + { + /** @var Worker $worker */ + $worker = $request->attributes->get('worker'); + + $validator = Validator::make($request->all(), [ + 'name' => 'nullable|string|max:255', + 'age' => 'nullable|integer|min:18|max:70', + 'nationality' => 'nullable|string|max:100', + 'salary' => 'nullable|numeric|min:0', + 'availability' => 'nullable|string|max:100', + 'experience' => 'nullable|string|max:100', + 'religion' => 'nullable|string|max:100', + 'bio' => 'nullable|string|max:1000', + 'category_id' => 'nullable|exists:worker_categories,id', + 'skills' => 'nullable|array', + 'skills.*' => 'exists:skills,id', + ]); + + if ($validator->fails()) { + return response()->json([ + 'success' => false, + 'message' => 'Validation error.', + 'errors' => $validator->errors() + ], 422); + } + + try { + DB::transaction(function () use ($request, $worker) { + // Update worker attributes + $updateData = $request->only([ + 'name', 'age', 'nationality', 'salary', 'availability', + 'experience', 'religion', 'bio', 'category_id' + ]); + + // Filter out null inputs if we want to support partial updates + $updateData = array_filter($updateData, function ($value) { + return !is_null($value); + }); + + $worker->update($updateData); + + // Sync skills if provided + if ($request->has('skills')) { + $worker->skills()->sync($request->skills ?: []); + } + }); + + $worker->load(['category', 'skills', 'documents']); + + return response()->json([ + 'success' => true, + 'message' => 'Profile updated successfully.', + 'data' => [ + 'worker' => $worker + ] + ], 200); + + } catch (\Exception $e) { + logger()->error('Mobile Worker Profile Update Failure: ' . $e->getMessage()); + + return response()->json([ + 'success' => false, + 'message' => 'An error occurred while updating your profile.', + 'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error' + ], 500); + } + } + + /** + * Get all job offers received by the worker. + * + * @param \Illuminate\Http\Request $request + * @return \Illuminate\Http\JsonResponse + */ + public function getOffers(Request $request) + { + /** @var Worker $worker */ + $worker = $request->attributes->get('worker'); + + $offers = JobOffer::where('worker_id', $worker->id) + ->with(['employer.employerProfile']) // Load employer and their profile details + ->latest() + ->get(); + + return response()->json([ + 'success' => true, + 'data' => [ + 'offers' => $offers + ] + ], 200); + } + + /** + * Respond to a specific job offer (Accept or Reject). + * + * @param \Illuminate\Http\Request $request + * @param int $id + * @return \Illuminate\Http\JsonResponse + */ + public function respondToOffer(Request $request, $id) + { + /** @var Worker $worker */ + $worker = $request->attributes->get('worker'); + + $validator = Validator::make($request->all(), [ + 'status' => 'required|string|in:accepted,rejected', + ], [ + 'status.in' => 'Status must be either accepted or rejected.' + ]); + + if ($validator->fails()) { + return response()->json([ + 'success' => false, + 'message' => 'Validation error.', + 'errors' => $validator->errors() + ], 422); + } + + try { + $offer = JobOffer::where('id', $id) + ->where('worker_id', $worker->id) + ->first(); + + if (!$offer) { + return response()->json([ + 'success' => false, + 'message' => 'Job offer not found.' + ], 404); + } + + if ($offer->status !== 'pending') { + return response()->json([ + 'success' => false, + 'message' => 'This job offer has already been responded to.' + ], 400); + } + + $responseStatus = $request->status; + + DB::transaction(function () use ($offer, $worker, $responseStatus) { + // Update offer status + $offer->update(['status' => $responseStatus]); + + // If accepted, change worker status to 'Hired' + if ($responseStatus === 'accepted') { + $worker->update(['status' => 'Hired']); + } + }); + + return response()->json([ + 'success' => true, + 'message' => "Job offer successfully " . $responseStatus . ".", + 'data' => [ + 'offer' => $offer, + 'worker_status' => $worker->fresh()->status + ] + ], 200); + + } catch (\Exception $e) { + logger()->error('Mobile Respond to Offer Failure: ' . $e->getMessage()); + + return response()->json([ + 'success' => false, + 'message' => 'An error occurred while responding to the job offer.', + 'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error' + ], 500); + } + } +} diff --git a/app/Http/Controllers/Employer/CandidateController.php b/app/Http/Controllers/Employer/CandidateController.php index a0d3627..2813633 100644 --- a/app/Http/Controllers/Employer/CandidateController.php +++ b/app/Http/Controllers/Employer/CandidateController.php @@ -8,6 +8,7 @@ use App\Models\User; use App\Models\JobApplication; use App\Models\JobPost; +use App\Models\JobOffer; class CandidateController extends Controller { @@ -40,7 +41,7 @@ public function index(Request $request) $user = $this->resolveCurrentUser(); $employerId = $user ? $user->id : 2; - // Get job posts created by this employer + // 1. Get job posts created by this employer $jobIds = JobPost::where('employer_id', $employerId)->pluck('id'); // Fetch applications for those jobs @@ -61,7 +62,7 @@ public function index(Request $request) else $status = ucfirst($app->status); return [ - 'id' => $app->id, // application id + 'id' => $app->id, // standard application id 'worker_id' => $w->id, 'name' => $w->name, 'nationality' => $w->nationality, @@ -72,8 +73,38 @@ public function index(Request $request) ]; })->filter()->values()->toArray(); + // 2. Fetch direct hiring offers sent by this employer + $directOffers = JobOffer::where('employer_id', $employerId) + ->with('worker.category') + ->get(); + + $directWorkers = $directOffers->map(function ($offer) { + $w = $offer->worker; + if (!$w) return null; + + // Map DB offer status to UI friendly status + $status = 'Offer Sent'; + if ($offer->status === 'accepted') $status = 'Hired'; + elseif ($offer->status === 'rejected') $status = 'Rejected'; + elseif ($offer->status === 'pending') $status = 'Offer Sent'; + + return [ + 'id' => 'offer_' . $offer->id, // Unique string key prefix to prevent clashes + 'worker_id' => $w->id, + 'name' => $w->name, + 'nationality' => $w->nationality, + 'category' => $w->category ? $w->category->name : 'General Helper', + 'salary' => (int)$offer->salary, + 'status' => $status, + 'applied_at' => $offer->created_at->format('M d, Y'), + ]; + })->filter()->values()->toArray(); + + // Merge standard job applications with direct hiring offers for unified tracking + $mergedCandidates = array_merge($selectedWorkers, $directWorkers); + return Inertia::render('Employer/SelectedCandidates', [ - 'selectedWorkers' => $selectedWorkers, + 'selectedWorkers' => $mergedCandidates, ]); } @@ -83,19 +114,51 @@ public function updateStatus(Request $request, $id) 'status' => 'required|string|in:Reviewing,Offer Sent,Hired,Rejected', ]); + // If this is a direct hiring offer response + if (is_string($id) && str_starts_with($id, 'offer_')) { + $offerId = substr($id, 6); + $offer = JobOffer::findOrFail($offerId); + + $dbStatus = 'pending'; + if ($request->status === 'Hired') { + $dbStatus = 'accepted'; + $offer->worker->update(['status' => 'Hired']); + } elseif ($request->status === 'Rejected') { + $dbStatus = 'rejected'; + $offer->worker->update(['status' => 'active']); + } elseif ($request->status === 'Offer Sent') { + $dbStatus = 'pending'; + } + + $offer->update(['status' => $dbStatus]); + + return back()->with('success', 'Direct candidate hiring offer status updated successfully to ' . $request->status); + } + + // Standard job application status update $app = JobApplication::findOrFail($id); - // Map UI status back to DB status $dbStatus = 'applied'; - if ($request->status === 'Hired') $dbStatus = 'hired'; - elseif ($request->status === 'Rejected') $dbStatus = 'rejected'; - elseif ($request->status === 'Offer Sent') $dbStatus = 'shortlisted'; - elseif ($request->status === 'Reviewing') $dbStatus = 'applied'; + if ($request->status === 'Hired') { + $dbStatus = 'hired'; + if ($app->worker) { + $app->worker->update(['status' => 'Hired']); + } + } elseif ($request->status === 'Rejected') { + $dbStatus = 'rejected'; + if ($app->worker) { + $app->worker->update(['status' => 'active']); + } + } elseif ($request->status === 'Offer Sent') { + $dbStatus = 'shortlisted'; + } elseif ($request->status === 'Reviewing') { + $dbStatus = 'applied'; + } $app->update([ 'status' => $dbStatus, ]); - return back()->with('success', 'Candidate status updated successfully to ' . $request->status); + return back()->with('success', 'Candidate application status updated successfully to ' . $request->status); } } diff --git a/app/Http/Controllers/Employer/EmployerAuthController.php b/app/Http/Controllers/Employer/EmployerAuthController.php index 005ebe3..a701110 100644 --- a/app/Http/Controllers/Employer/EmployerAuthController.php +++ b/app/Http/Controllers/Employer/EmployerAuthController.php @@ -25,53 +25,37 @@ public function login(Request $request) 'password' => ['required'], ]); - if ($credentials['email'] === 'pending@example.com') { - return back()->with('status', 'pending_verification'); - } - - if ($credentials['email'] === 'rejected@example.com') { - return back()->with([ - 'status' => 'rejected', - 'reason' => 'Emirates ID scan was blurry or illegible. Please provide a high-resolution color scan.', - ]); - } - - if ($credentials['email'] === 'expired@example.com') { - return back()->with('status', 'subscription_expired'); - } - - // Attempt logging in via standard Auth or fallback credentials - if ($credentials['email'] === 'employer@example.com' && $credentials['password'] === 'password') { - session(['user' => (object)[ - 'id' => 2, - 'name' => 'John Doe (Employer)', - 'email' => 'employer@example.com', - 'role' => 'employer', - 'subscription_status' => 'active', - 'verification_status' => 'approved', - ]]); - - $request->session()->regenerate(); - - if ($request->source === 'mobile') { - return redirect('/mobile/employer/home'); - } - - return redirect()->intended('/employer/dashboard'); - } - // Attempt actual database login $user = User::where('email', $credentials['email'])->first(); if ($user && Hash::check($credentials['password'], $user->password) && $user->role === 'employer') { $profile = EmployerProfile::where('user_id', $user->id)->first(); - + $verification_status = $profile ? $profile->verification_status : 'approved'; + + if ($verification_status === 'pending') { + return back()->with('status', 'pending_verification'); + } + + if ($verification_status === 'rejected') { + return back()->with([ + 'status' => 'rejected', + 'reason' => ($profile && $profile->rejection_reason) ? $profile->rejection_reason : 'Emirates ID scan was blurry or illegible. Please provide a high-resolution color scan.', + ]); + } + + if ($user->subscription_status === 'expired') { + return back()->with('status', 'subscription_expired'); + } + + // Perform standard Laravel authentication + auth()->login($user); + session(['user' => (object)[ 'id' => $user->id, 'name' => $user->name, 'email' => $user->email, 'role' => $user->role, 'subscription_status' => $user->subscription_status ?? 'active', - 'verification_status' => $profile ? $profile->verification_status : 'approved', + 'verification_status' => $verification_status, ]]); $request->session()->regenerate(); @@ -84,7 +68,7 @@ public function login(Request $request) } return back()->withErrors([ - 'email' => 'Invalid credentials. Use employer@example.com (or test emails: pending@, rejected@, expired@ / password)', + 'email' => 'These credentials do not match our records.', ]); } @@ -101,7 +85,6 @@ public function register(Request $request) 'name' => 'required|string|max:255', 'email' => 'required|string|email|max:255', 'phone' => 'required|string|regex:/^\+?[0-9\s\-()]{7,20}$/', - 'country' => 'required|string|max:100', ], [ 'phone.regex' => 'The phone number must be a valid mobile number format.', ]); @@ -125,7 +108,6 @@ public function register(Request $request) 'name' => $request->name, 'email' => $request->email, 'phone' => $request->phone, - 'country' => $request->country, ], 'employer_otp' => [ 'hash' => $hashedOtp, @@ -143,7 +125,7 @@ public function register(Request $request) $request->name )); } catch (\Exception $e) { - // Log error but proceed for demonstration/local testing if mail server is not fully configured + // Log error but proceed for local testing/robustness logger()->error('Failed to send registration OTP email: ' . $e->getMessage()); } @@ -310,7 +292,7 @@ public function createPassword(Request $request) 'user_id' => $user->id, 'company_name' => $pending['company_name'], 'phone' => $pending['phone'], - 'country' => $pending['country'], + 'country' => 'United Arab Emirates', // Default country since dropdown was removed 'verification_status' => 'approved', 'language' => 'English', 'notifications' => true, diff --git a/app/Http/Controllers/Employer/MessageController.php b/app/Http/Controllers/Employer/MessageController.php index 430b3cc..14e5ab5 100644 --- a/app/Http/Controllers/Employer/MessageController.php +++ b/app/Http/Controllers/Employer/MessageController.php @@ -152,4 +152,26 @@ public function send(Request $request, $id) return back(); } + + /** + * Start or load a conversation with a specific worker. + * + * @param int $workerId + * @return \Illuminate\Http\RedirectResponse + */ + public function startConversation($workerId) + { + $user = $this->resolveCurrentUser(); + if (!$user) { + return redirect()->route('employer.login'); + } + + // Find or create conversation + $conv = Conversation::firstOrCreate([ + 'employer_id' => $user->id, + 'worker_id' => $workerId, + ]); + + return redirect()->route('employer.messages.show', ['id' => $conv->id]); + } } diff --git a/app/Http/Controllers/Employer/WorkerController.php b/app/Http/Controllers/Employer/WorkerController.php index b3b27f6..2b68ac5 100644 --- a/app/Http/Controllers/Employer/WorkerController.php +++ b/app/Http/Controllers/Employer/WorkerController.php @@ -9,6 +9,7 @@ use App\Models\Worker; use App\Models\WorkerCategory; use App\Models\Shortlist; +use App\Models\JobOffer; class WorkerController extends Controller { @@ -41,10 +42,8 @@ public function index(Request $request) $user = $this->resolveCurrentUser(); $employerId = $user ? $user->id : 2; - // Fetch workers from DB - $dbWorkers = Worker::with(['category', 'skills']) - ->where('status', 'active') - ->get(); + // Fetch all non-deleted workers to show their status changes (active vs Hired) + $dbWorkers = Worker::with(['category', 'skills'])->get(); $workers = $dbWorkers->map(function ($w) { return [ @@ -57,9 +56,10 @@ public function index(Request $request) 'experience' => $w->experience, 'salary' => (int)$w->salary, 'religion' => $w->religion, - 'languages' => ['English', 'Arabic'], // Custom language field or mock + 'languages' => ['English', 'Arabic'], 'age' => $w->age, 'verified' => (bool)$w->verified, + 'status' => $w->status, // Map worker status dynamically (e.g. active, Hired) 'bio' => $w->bio, ]; })->toArray(); @@ -69,7 +69,7 @@ public function index(Request $request) // Dynamically build filter metadata from DB values $dbCategories = WorkerCategory::pluck('name')->toArray(); - $dbNationalities = Worker::distinct()->where('status', 'active')->pluck('nationality')->toArray(); + $dbNationalities = Worker::distinct()->pluck('nationality')->toArray(); $filtersMetadata = [ 'categories' => array_merge(['All Categories'], $dbCategories), @@ -98,12 +98,13 @@ public function show($id) 'skills' => $w->skills->pluck('name')->toArray(), 'availability' => $w->availability, 'experience' => $w->experience, - 'experience_years' => 5, // Can parse or default + 'experience_years' => 5, 'salary' => (int)$w->salary, 'religion' => $w->religion, 'languages' => ['English', 'Arabic'], 'age' => $w->age, 'verified' => (bool)$w->verified, + 'status' => $w->status, 'bio' => $w->bio, 'passport_status' => 'OCR Verified', 'visa_status' => 'Transferable', @@ -113,4 +114,41 @@ public function show($id) 'worker' => $worker, ]); } + + /** + * Submit a secure hiring offer from the Employer Web Portal. + * + * @param \Illuminate\Http\Request $request + * @param int $id + * @return \Illuminate\Http\RedirectResponse + */ + public function sendOffer(Request $request, $id) + { + $request->validate([ + 'work_date' => 'required|date', + 'location' => 'required|string|max:255', + 'salary' => 'required|numeric|min:0', + 'notes' => 'nullable|string|max:1000', + ]); + + $user = $this->resolveCurrentUser(); + $employerId = $user ? $user->id : 2; + + // Verify worker exists + $worker = Worker::findOrFail($id); + + // Create job offer record + JobOffer::create([ + 'employer_id' => $employerId, + 'worker_id' => $worker->id, + 'work_date' => $request->work_date, + 'location' => $request->location, + 'salary' => $request->salary, + 'notes' => $request->notes, + 'status' => 'pending', + ]); + + return redirect()->route('employer.hiring.success', ['id' => $id]) + ->with('success', 'Hiring offer has been successfully dispatched to the worker!'); + } } diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 2a1408b..001280a 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -35,9 +35,16 @@ public function version(Request $request): ?string */ public function share(Request $request): array { - $sess = session('user'); - $userId = is_array($sess) ? ($sess['id'] ?? null) : ($sess->id ?? null); - $user = $userId ? \App\Models\User::find($userId) : \App\Models\User::where('role', 'employer')->first(); + $user = null; + if (auth()->check()) { + $user = auth()->user(); + } else { + $sess = session('user'); + $userId = is_array($sess) ? ($sess['id'] ?? null) : ($sess->id ?? null); + if ($userId) { + $user = \App\Models\User::find($userId); + } + } $userData = null; $unreadCount = 0; diff --git a/app/Http/Middleware/WorkerApiMiddleware.php b/app/Http/Middleware/WorkerApiMiddleware.php new file mode 100644 index 0000000..cea8269 --- /dev/null +++ b/app/Http/Middleware/WorkerApiMiddleware.php @@ -0,0 +1,44 @@ +header('Authorization'); + + if (!$authHeader || !str_starts_with($authHeader, 'Bearer ')) { + return response()->json([ + 'success' => false, + 'message' => 'Authentication token required.' + ], 401); + } + + $token = substr($authHeader, 7); + $worker = Worker::where('api_token', $token)->first(); + + if (!$worker) { + return response()->json([ + 'success' => false, + 'message' => 'Invalid or expired authentication token.' + ], 401); + } + + // Attach worker object to request attributes so controllers can read it using $request->attributes->get('worker') + $request->attributes->set('worker', $worker); + + return $next($request); + } +} diff --git a/app/Models/JobOffer.php b/app/Models/JobOffer.php new file mode 100644 index 0000000..c6e9cc7 --- /dev/null +++ b/app/Models/JobOffer.php @@ -0,0 +1,36 @@ + 'date', + 'salary' => 'decimal:2', + ]; + + public function employer() + { + return $this->belongsTo(User::class, 'employer_id'); + } + + public function worker() + { + return $this->belongsTo(Worker::class, 'worker_id'); + } +} diff --git a/app/Models/Worker.php b/app/Models/Worker.php index 6031cac..6a08bb3 100644 --- a/app/Models/Worker.php +++ b/app/Models/Worker.php @@ -14,6 +14,7 @@ class Worker extends Model 'name', 'email', 'phone', + 'password', 'nationality', 'age', 'salary', @@ -24,11 +25,18 @@ class Worker extends Model 'category_id', 'verified', 'status', + 'api_token', + ]; + + protected $hidden = [ + 'password', + 'api_token', // Hide from public serialization, returned only during auth endpoints specifically ]; protected $casts = [ 'verified' => 'boolean', 'salary' => 'decimal:2', + 'password' => 'hashed', ]; public function category() @@ -45,4 +53,10 @@ public function documents() { return $this->hasMany(WorkerDocument::class); } + + // Relationship for job offers received + public function offers() + { + return $this->hasMany(JobOffer::class, 'worker_id'); + } } diff --git a/bootstrap/app.php b/bootstrap/app.php index c481886..bbdee90 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -7,6 +7,7 @@ return Application::configure(basePath: dirname(__DIR__)) ->withRouting( web: __DIR__.'/../routes/web.php', + api: __DIR__.'/../routes/api.php', commands: __DIR__.'/../routes/console.php', health: '/up', ) @@ -18,6 +19,7 @@ $middleware->alias([ 'admin' => \App\Http\Middleware\AdminMiddleware::class, 'employer' => \App\Http\Middleware\EmployerMiddleware::class, + 'auth.worker' => \App\Http\Middleware\WorkerApiMiddleware::class, ]); }) ->withExceptions(function (Exceptions $exceptions): void { diff --git a/database/migrations/2026_05_20_104323_add_auth_fields_to_workers_table.php b/database/migrations/2026_05_20_104323_add_auth_fields_to_workers_table.php new file mode 100644 index 0000000..8b79df2 --- /dev/null +++ b/database/migrations/2026_05_20_104323_add_auth_fields_to_workers_table.php @@ -0,0 +1,29 @@ +string('password')->nullable()->after('email'); + $table->string('api_token', 80)->nullable()->unique()->after('status'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('workers', function (Blueprint $table) { + $table->dropColumn(['password', 'api_token']); + }); + } +}; diff --git a/database/migrations/2026_05_20_104357_create_job_offers_table.php b/database/migrations/2026_05_20_104357_create_job_offers_table.php new file mode 100644 index 0000000..65eb144 --- /dev/null +++ b/database/migrations/2026_05_20_104357_create_job_offers_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('employer_id')->constrained('users')->onDelete('cascade'); + $table->foreignId('worker_id')->constrained('workers')->onDelete('cascade'); + $table->date('work_date'); + $table->string('location'); + $table->decimal('salary', 10, 2); + $table->text('notes')->nullable(); + $table->string('status')->default('pending'); // pending, accepted, rejected + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('job_offers'); + } +}; diff --git a/public/swagger.json b/public/swagger.json new file mode 100644 index 0000000..6e45edf --- /dev/null +++ b/public/swagger.json @@ -0,0 +1,680 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Migrant Mobile API", + "description": "Comprehensive API endpoints for the Migrant Mobile Application, covering worker registration, password login, token authentication, profile management, and interactive job offer responses.", + "version": "1.0.0" + }, + "servers": [ + { + "url": "/api", + "description": "Local development API prefix" + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "paths": { + "/workers/register": { + "post": { + "summary": "Register Worker & Upload Documents (Unified API)", + "description": "Performs worker registration and uploads their passport/visa documents in a single, robust multipart form request. Generates and returns a secure stateless bearer token upon success. Password is required (min 6 characters).", + "security": [], + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "required": [ + "name", + "phone", + "salary", + "password", + "passport_file" + ], + "properties": { + "name": { + "type": "string", + "example": "Amina Al-Masry", + "description": "Full legal name of the worker. (REQUIRED)" + }, + "phone": { + "type": "string", + "example": "+971509998888", + "description": "Contact mobile phone number (must be unique). (REQUIRED)" + }, + "salary": { + "type": "number", + "format": "float", + "example": 1500.00, + "description": "Desired minimum monthly salary in AED. (REQUIRED)" + }, + "password": { + "type": "string", + "format": "password", + "example": "securepassword123", + "description": "Secret password for subsequent mobile logins (min 6 chars). (REQUIRED)" + }, + "passport_file": { + "type": "string", + "format": "binary", + "description": "Physical scan or image of the Passport (Max 10MB). (REQUIRED)" + }, + "skills": { + "type": "array", + "items": { + "type": "integer" + }, + "example": [6, 8], + "description": "Optional array of Skill IDs (can be sent as comma-separated string e.g. '6,8' or json '[6,8]' in multipart)." + }, + "visa_file": { + "type": "string", + "format": "binary", + "description": "Optional physical scan or image of the Visa (Max 10MB)." + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Worker registered and authenticated successfully.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "message": { + "type": "string", + "example": "Worker registered and authenticated successfully." + }, + "data": { + "type": "object", + "properties": { + "worker": { + "$ref": "#/components/schemas/Worker" + }, + "token": { + "type": "string", + "example": "abc123xyz456...secureToken..." + } + } + } + } + } + } + } + }, + "422": { + "description": "Validation error details.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": false + }, + "message": { + "type": "string", + "example": "Validation error." + }, + "errors": { + "type": "object", + "properties": { + "phone": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "This mobile number is already registered." + ] + } + } + } + } + } + } + } + } + } + } + }, + "/workers/login": { + "post": { + "summary": "Authenticate Worker", + "description": "Validates worker credentials (mobile phone number and password) and generates a secure, stateless Bearer token for api access.", + "security": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "phone", + "password" + ], + "properties": { + "phone": { + "type": "string", + "example": "+971509998888", + "description": "Registered mobile phone number." + }, + "password": { + "type": "string", + "format": "password", + "example": "securepassword123", + "description": "Secret account password." + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Worker logged in successfully.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "message": { + "type": "string", + "example": "Worker logged in successfully." + }, + "data": { + "type": "object", + "properties": { + "worker": { + "$ref": "#/components/schemas/Worker" + }, + "token": { + "type": "string", + "example": "abc123xyz456...secureToken..." + } + } + } + } + } + } + } + }, + "401": { + "description": "Unauthorized access.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": false + }, + "message": { + "type": "string", + "example": "Invalid mobile number or password." + } + } + } + } + } + } + } + } + }, + "/workers/profile": { + "get": { + "summary": "Get Profile details", + "description": "Retrieves the authenticated worker's profile details including their selected job category, connected skills, and uploaded documents.", + "responses": { + "200": { + "description": "Worker profile details retrieved.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "data": { + "type": "object", + "properties": { + "worker": { + "$ref": "#/components/schemas/Worker" + } + } + } + } + } + } + } + } + } + } + }, + "/workers/profile/update": { + "post": { + "summary": "Update Profile details", + "description": "Allows workers to customize and complete their profiles by updating age, nationality, desired salary, bio, availability, experience, religion, categories, and master skills list.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "Amina Al-Masry" + }, + "age": { + "type": "integer", + "example": 28 + }, + "nationality": { + "type": "string", + "example": "Egypt" + }, + "salary": { + "type": "number", + "example": 1800.00 + }, + "availability": { + "type": "string", + "example": "Immediate" + }, + "experience": { + "type": "string", + "example": "4 Years" + }, + "religion": { + "type": "string", + "example": "Muslim" + }, + "bio": { + "type": "string", + "example": "Highly certified housekeeper and babysitter with cooking experience." + }, + "category_id": { + "type": "integer", + "example": 8 + }, + "skills": { + "type": "array", + "items": { + "type": "integer" + }, + "example": [6, 8] + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Profile updated successfully.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "message": { + "type": "string", + "example": "Profile updated successfully." + }, + "data": { + "type": "object", + "properties": { + "worker": { + "$ref": "#/components/schemas/Worker" + } + } + } + } + } + } + } + } + } + } + }, + "/workers/offers": { + "get": { + "summary": "View Received Job Offers", + "description": "Returns a list of all custom job/hiring offers received by the worker from different employers (e.g. Work Date, location, salaries, and notes).", + "responses": { + "200": { + "description": "List of job offers retrieved.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "data": { + "type": "object", + "properties": { + "offers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/JobOffer" + } + } + } + } + } + } + } + } + } + } + } + }, + "/workers/offers/{id}/respond": { + "post": { + "summary": "Accept or Reject Job Offer", + "description": "Responds to a pending job offer. If accepted, the job offer status becomes 'accepted' and the worker's status is automatically changed to 'Hired', which reflects immediately in the Employer panel.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The Job Offer ID reference.", + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "status" + ], + "properties": { + "status": { + "type": "string", + "enum": [ + "accepted", + "rejected" + ], + "example": "accepted", + "description": "Response action (accepted or rejected)." + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Job offer response saved successfully.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "message": { + "type": "string", + "example": "Job offer successfully accepted." + }, + "data": { + "type": "object", + "properties": { + "offer": { + "$ref": "#/components/schemas/JobOffer" + }, + "worker_status": { + "type": "string", + "example": "Hired" + } + } + } + } + } + } + } + } + } + } + } + }, + "components": { + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT" + } + }, + "schemas": { + "Worker": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "example": 136 + }, + "name": { + "type": "string", + "example": "Amina Al-Masry" + }, + "email": { + "type": "string", + "example": "worker.971509998888@migrant.com" + }, + "phone": { + "type": "string", + "example": "+971509998888" + }, + "nationality": { + "type": "string", + "example": "Egypt" + }, + "age": { + "type": "integer", + "example": 28 + }, + "salary": { + "type": "string", + "example": "1500.00" + }, + "availability": { + "type": "string", + "example": "Immediate" + }, + "experience": { + "type": "string", + "example": "4 Years" + }, + "religion": { + "type": "string", + "example": "Muslim" + }, + "bio": { + "type": "string", + "example": "Certified infant caregiver and housekeeper with excellent cooking skills." + }, + "category_id": { + "type": "integer", + "example": 7 + }, + "verified": { + "type": "boolean", + "example": false + }, + "status": { + "type": "string", + "example": "active" + }, + "created_at": { + "type": "string", + "format": "date-time", + "example": "2026-05-20T14:54:26.000000Z" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "example": "2026-05-20T14:54:26.000000Z" + }, + "category": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "example": 7 + }, + "name": { + "type": "string", + "example": "General Helper" + } + } + }, + "skills": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "example": 6 + }, + "name": { + "type": "string", + "example": "Cleaning" + } + } + } + }, + "documents": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkerDocument" + } + } + } + }, + "WorkerDocument": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "example": 15 + }, + "worker_id": { + "type": "integer", + "example": 136 + }, + "type": { + "type": "string", + "example": "passport" + }, + "number": { + "type": "string", + "example": "P345892" + }, + "issue_date": { + "type": "string", + "format": "date", + "example": "2024-05-20" + }, + "expiry_date": { + "type": "string", + "format": "date", + "example": "2034-05-20" + }, + "ocr_accuracy": { + "type": "number", + "example": 98.5 + }, + "file_path": { + "type": "string", + "example": "uploads/documents/1716200000_passport_my_file.jpg" + } + } + }, + "JobOffer": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "employer_id": { + "type": "integer", + "example": 2 + }, + "worker_id": { + "type": "integer", + "example": 136 + }, + "work_date": { + "type": "string", + "format": "date", + "example": "2026-06-01" + }, + "location": { + "type": "string", + "example": "Dubai Marina, Silverene Towers" + }, + "salary": { + "type": "string", + "example": "2500.00" + }, + "notes": { + "type": "string", + "example": "Require housekeeping support starting from June 1st." + }, + "status": { + "type": "string", + "example": "pending" + }, + "created_at": { + "type": "string", + "format": "date-time", + "example": "2026-05-20T15:23:44.000000Z" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "example": "2026-05-20T15:23:44.000000Z" + } + } + } + } + } +} diff --git a/public/uploads/documents/1779274805_passport_Screenshot2025-08-21131531.png b/public/uploads/documents/1779274805_passport_Screenshot2025-08-21131531.png new file mode 100644 index 0000000000000000000000000000000000000000..05b35be55a355dc9f859a041366a167f7ba0fbdb GIT binary patch literal 38854 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 literal 0 HcmV?d00001 diff --git a/resources/js/Layouts/EmployerLayout.jsx b/resources/js/Layouts/EmployerLayout.jsx index 31f9ab7..4f390d2 100644 --- a/resources/js/Layouts/EmployerLayout.jsx +++ b/resources/js/Layouts/EmployerLayout.jsx @@ -47,11 +47,11 @@ export default function EmployerLayout({ children, title, fullPage = false }) { }, [flash]); const user = auth?.user || { - name: 'John Doe', - company_name: 'Al Mansoor Household', - email: 'employer@example.com', - subscription_status: 'active', - subscription_expires_at: '2026-12-31', + name: 'Guest', + company_name: '', + email: '', + subscription_status: 'inactive', + subscription_expires_at: '', }; const isSubActive = user.subscription_status === 'active'; diff --git a/resources/js/Pages/Employer/Auth/CreatePassword.jsx b/resources/js/Pages/Employer/Auth/CreatePassword.jsx index 869d89d..b315638 100644 --- a/resources/js/Pages/Employer/Auth/CreatePassword.jsx +++ b/resources/js/Pages/Employer/Auth/CreatePassword.jsx @@ -3,14 +3,15 @@ import { Head, Link, router } from '@inertiajs/react'; import axios from 'axios'; import { toast } from 'sonner'; import { - ShieldCheck, - CheckCircle, Eye, EyeOff, Lock, Loader2, Check, - X + X, + Users, + DollarSign, + MessageSquare } from 'lucide-react'; export default function CreatePassword() { @@ -52,27 +53,39 @@ export default function CreatePassword() { const isAllCriteriaMet = Object.values(strength).every(Boolean); - const handleSubmit = async (e) => { - e.preventDefault(); + const validateForm = () => { + const newErrors = {}; - if (!isAllCriteriaMet) { - setErrors({ password: 'Password does not meet all security guidelines.' }); - toast.error('Password does not meet all security guidelines.'); - return; + if (!data.password) { + newErrors.password = 'Password is required.'; + } else if (!isAllCriteriaMet) { + newErrors.password = 'Password must meet all complexity requirements.'; } - if (data.password !== data.password_confirmation) { - setErrors({ password_confirmation: 'Passwords do not match.' }); - toast.error('Passwords do not match.'); + if (!data.password_confirmation) { + newErrors.password_confirmation = 'Confirm password is required.'; + } else if (data.password !== data.password_confirmation) { + newErrors.password_confirmation = 'Passwords do not match.'; + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + setErrors({}); + + if (!validateForm()) { + toast.error('Validation failed. Please correct the fields in red.'); return; } setLoading(true); - setErrors({}); try { - const response = await axios.post('/employer/create-password', data); - toast.success('Registration finalized! Welcome to the Marketplace.'); + await axios.post('/employer/create-password', data); + toast.success('Registration finalized! Welcome to Migrant.'); router.visit('/employer/dashboard'); } catch (err) { if (err.response) { @@ -91,25 +104,24 @@ export default function CreatePassword() { }; const renderStepIndicator = () => ( -
+
-
{[ { step: 1, label: 'Register' }, - { step: 2, label: 'Verify Code' }, - { step: 3, label: 'Set Password' } + { step: 2, label: 'Verify' }, + { step: 3, label: 'Password' } ].map((s) => (
-
- {s.step === 3 && isAllCriteriaMet ? : s.step} + {s.step}
- {s.label}
))} @@ -125,150 +137,163 @@ export default function CreatePassword() { ]; return ( -
- +
+ - {/* Left Brand Panel */} -
-
+ {/* Left Brand Panel (Desktop Only) */} +
+
-
-
-
+
+
+
M
- Marketplace + Migrant Portal
-
-

- Establish Secure Credentials. +
+ + Employer Registration + +

+ Find verified domestic workers directly.

-

- Establish a robust password to ensure protection of your domestic hiring workspace. +

+ Unlock direct access to top domestic candidates with a Migrant premium subscription. Find, interview, and hire directly without middlemen.

+ + {/* Stat Row */} +
+
+ +
500+
+
Verified Workers
+
+ +
+ +
Premium
+
Employer Access
+
+ +
+ +
Direct
+
Messaging
+
+

-
- Empowering Household Recruitment Since 2024 +
+ © 2026 Migrant. All rights reserved.
- {/* Right Form Content */} -
-
-
-

Security Configuration

-

- Step 3 of 3: Configure Portal Access Key -

+ {/* Right Set Password Form */} +
+
+
+
+

Set Password

+

Configure your portal access password

+
+ {renderStepIndicator()}
- {renderStepIndicator()} - -
- -
- - {/* Password input */} -
- -
- - handleInputChange('password', e.target.value)} - placeholder="••••••••" - className={`w-full pl-12 pr-12 py-4 bg-slate-50 border rounded-2xl text-sm font-bold focus:ring-4 focus:ring-blue-100 focus:bg-white transition-all outline-none ${ - errors.password ? 'border-rose-400 bg-rose-50/20' : 'border-transparent' - }`} - required - /> - -
- {errors.password && ( -

{errors.password[0] || errors.password}

- )} + + {/* Password input */} +
+ +
+ + handleInputChange('password', e.target.value)} + placeholder="••••••••" + className={`w-full pl-10 pr-10 py-2.5 bg-slate-50 border rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-[#185FA5]/20 focus:border-[#185FA5] focus:bg-white transition-all ${ + errors.password ? 'border-red-500 focus:ring-red-500/20' : 'border-slate-300' + }`} + /> +
+ {errors.password && ( +

+ {Array.isArray(errors.password) ? errors.password[0] : errors.password} +

+ )} +
- {/* Confirm Password input */} -
- -
- - handleInputChange('password_confirmation', e.target.value)} - placeholder="••••••••" - className={`w-full pl-12 pr-4 py-4 bg-slate-50 border rounded-2xl text-sm font-bold focus:ring-4 focus:ring-blue-100 focus:bg-white transition-all outline-none ${ - errors.password_confirmation ? 'border-rose-400 bg-rose-50/20' : 'border-transparent' - }`} - required - /> -
- {errors.password_confirmation && ( -

{errors.password_confirmation[0] || errors.password_confirmation}

- )} + {/* Confirm Password input */} +
+ +
+ + handleInputChange('password_confirmation', e.target.value)} + placeholder="••••••••" + className={`w-full pl-10 pr-4 py-2.5 bg-slate-50 border rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-[#185FA5]/20 focus:border-[#185FA5] focus:bg-white transition-all ${ + errors.password_confirmation ? 'border-red-500 focus:ring-red-500/20' : 'border-slate-300' + }`} + />
+ {errors.password_confirmation && ( +

+ {Array.isArray(errors.password_confirmation) ? errors.password_confirmation[0] : errors.password_confirmation} +

+ )} +
- {/* Password Rules Checklist */} -
-

Password Complexity Checklist

-
- {rules.map((rule) => { - const isMet = strength[rule.key]; - return ( -
-
- {isMet ? : null} -
- {rule.text} + {/* Password Rules Checklist */} +
+

Complexity Checklist

+
+ {rules.map((rule) => { + const isMet = strength[rule.key]; + return ( +
+
+ {isMet ? : null}
- ); - })} -
+ {rule.text} +
+ ); + })}
+
- + + - - -
- -
- - +
+ + Cancel Registration
diff --git a/resources/js/Pages/Employer/Auth/ForgotPassword.jsx b/resources/js/Pages/Employer/Auth/ForgotPassword.jsx index 1d52655..75622a9 100644 --- a/resources/js/Pages/Employer/Auth/ForgotPassword.jsx +++ b/resources/js/Pages/Employer/Auth/ForgotPassword.jsx @@ -24,7 +24,7 @@ export default function ForgotPassword() { diff --git a/resources/js/Pages/Employer/Auth/Login.jsx b/resources/js/Pages/Employer/Auth/Login.jsx index 9d4800a..50aba59 100644 --- a/resources/js/Pages/Employer/Auth/Login.jsx +++ b/resources/js/Pages/Employer/Auth/Login.jsx @@ -1,7 +1,6 @@ import React, { useState } from 'react'; import { useForm, Head, Link } from '@inertiajs/react'; import { - CheckCircle, Eye, EyeOff, Loader2, @@ -14,7 +13,7 @@ import { } from 'lucide-react'; export default function Login({ flash }) { - const { data, setData, post, processing, errors } = useForm({ + const { data, setData, post, processing, errors, setError, clearErrors } = useForm({ email: '', password: '', remember: false, @@ -22,14 +21,39 @@ export default function Login({ flash }) { const [showPassword, setShowPassword] = useState(false); + const validateForm = () => { + let valid = true; + clearErrors(); + + if (!data.email.trim()) { + setError('email', 'Email address is required.'); + valid = false; + } else if (!/\S+@\S+\.\S+/.test(data.email)) { + setError('email', 'Please enter a valid email address.'); + valid = false; + } + + if (!data.password) { + setError('password', 'Password is required.'); + valid = false; + } + + return valid; + }; + const handleSubmit = (e) => { e.preventDefault(); + + if (!validateForm()) { + return; + } + post('/employer/login'); }; return (
- + {/* Left Brand Panel (Desktop Only) */}
@@ -40,7 +64,7 @@ export default function Login({ flash }) {
M
- Marketplace Portal + Migrant Portal
@@ -48,10 +72,10 @@ export default function Login({ flash }) { Employer Login

- Find verified domestic workers — no agents, no fees. + Find verified domestic workers directly.

- Sign in to review your shortlisted candidates, manage interview schedules, and communicate directly. + Unlock direct access to top domestic candidates with a Migrant premium subscription. Find, interview, and hire directly without middlemen.

@@ -65,8 +89,8 @@ export default function Login({ flash }) {
-
0 AED
-
Agent Fees
+
Premium
+
Employer Access
@@ -78,7 +102,7 @@ export default function Login({ flash }) {
- © 2026 Domestic Worker Marketplace. All rights reserved. + © 2026 Migrant. All rights reserved.
@@ -115,27 +139,18 @@ export default function Login({ flash }) {
)} - {/* Demonstration Helper Note */} -
-
💡 Scaffolding Demo Emails:
-
• Active Employer: employer@example.com
-
• Pending Alert: pending@example.com
-
• Rejected Alert: rejected@example.com
-
• Expired Alert: expired@example.com
-
Password for all: password
-
- -
+
setData('email', e.target.value)} - placeholder="employer@example.com" - className="w-full px-3.5 py-2.5 rounded-xl border border-slate-300 text-sm focus:outline-none focus:ring-2 focus:ring-[#185FA5]/20 focus:border-[#185FA5]" + placeholder="name@company.com" + className={`w-full px-3.5 py-2.5 rounded-xl border text-sm focus:outline-none focus:ring-2 focus:ring-[#185FA5]/20 focus:border-[#185FA5] ${ + errors.email ? 'border-red-500 focus:ring-red-500/20' : 'border-slate-300' + }`} autoFocus - required /> {errors.email &&

{errors.email}

}
@@ -153,8 +168,9 @@ export default function Login({ flash }) { value={data.password} onChange={(e) => setData('password', e.target.value)} placeholder="••••••••" - className="w-full pl-3.5 pr-10 py-2.5 rounded-xl border border-slate-300 text-sm focus:outline-none focus:ring-2 focus:ring-[#185FA5]/20 focus:border-[#185FA5]" - required + className={`w-full pl-3.5 pr-10 py-2.5 rounded-xl border text-sm focus:outline-none focus:ring-2 focus:ring-[#185FA5]/20 focus:border-[#185FA5] ${ + errors.password ? 'border-red-500 focus:ring-red-500/20' : 'border-slate-300' + }`} />
+ {errors.password &&

{errors.password}

}
diff --git a/resources/js/Pages/Employer/Auth/Register.jsx b/resources/js/Pages/Employer/Auth/Register.jsx index 01c69c8..bc921b8 100644 --- a/resources/js/Pages/Employer/Auth/Register.jsx +++ b/resources/js/Pages/Employer/Auth/Register.jsx @@ -9,27 +9,12 @@ import { MessageSquare } from 'lucide-react'; -const countries = [ - 'United Arab Emirates', - 'Saudi Arabia', - 'Qatar', - 'Oman', - 'Kuwait', - 'Bahrain', - 'United Kingdom', - 'United States', - 'Canada', - 'Australia', - 'Singapore' -]; - export default function Register() { const [data, setData] = useState({ company_name: '', name: '', email: '', phone: '', - country: 'United Arab Emirates', }); const [loading, setLoading] = useState(false); @@ -42,14 +27,47 @@ export default function Register() { } }; + const validateForm = () => { + const newErrors = {}; + + if (!data.company_name.trim()) { + newErrors.company_name = 'Company/household name is required.'; + } + + if (!data.name.trim()) { + newErrors.name = 'Contact name is required.'; + } + + if (!data.email.trim()) { + newErrors.email = 'Email address is required.'; + } else if (!/\S+@\S+\.\S+/.test(data.email)) { + newErrors.email = 'Please enter a valid email address.'; + } + + if (!data.phone.trim()) { + newErrors.phone = 'Mobile number is required.'; + } else if (!/^\+?[0-9\s\-()]{7,20}$/.test(data.phone)) { + newErrors.phone = 'Please enter a valid mobile number format (7-20 digits).'; + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + const handleSubmit = async (e) => { e.preventDefault(); - setLoading(true); setErrors({}); + if (!validateForm()) { + toast.error('Validation failed. Please correct the fields in red.'); + return; + } + + setLoading(true); + try { await axios.post('/employer/register', data); - toast.success('Registration details saved! A 6-digit verification code has been sent to your email.'); + toast.success('Registration details saved! Verification code sent to your email.'); router.visit('/employer/verify-email'); } catch (err) { if (err.response) { @@ -72,7 +90,7 @@ export default function Register() { }; const renderStepIndicator = () => ( -
+
{[ @@ -98,7 +116,7 @@ export default function Register() { return (
- + {/* Left Brand Panel (Desktop Only) */}
@@ -109,7 +127,7 @@ export default function Register() {
M
- Marketplace Portal + Migrant Portal
@@ -117,10 +135,10 @@ export default function Register() { Employer Registration

- Find verified domestic workers — no agents, no fees. + Find verified domestic workers directly.

- Register to review your shortlisted candidates, manage interview schedules, and communicate directly. + Unlock direct access to top domestic candidates with a Migrant premium subscription. Find, interview, and hire directly without middlemen.

@@ -134,8 +152,8 @@ export default function Register() {
-
0 AED
-
Agent Fees
+
Premium
+
Employer Access
@@ -147,7 +165,7 @@ export default function Register() {
- © 2026 Domestic Worker Marketplace. All rights reserved. + © 2026 Migrant. All rights reserved.
@@ -162,7 +180,7 @@ export default function Register() { {renderStepIndicator()}
- + {/* Company Name */}
@@ -176,10 +194,11 @@ export default function Register() { ? 'border-red-500 focus:ring-red-500/20 focus:border-red-500' : 'border-slate-300 focus:ring-[#185FA5]/20 focus:border-[#185FA5]' }`} - required /> {errors.company_name && ( -

{errors.company_name[0] || errors.company_name}

+

+ {Array.isArray(errors.company_name) ? errors.company_name[0] : errors.company_name} +

)}
@@ -197,10 +216,11 @@ export default function Register() { ? 'border-red-500 focus:ring-red-500/20 focus:border-red-500' : 'border-slate-300 focus:ring-[#185FA5]/20 focus:border-[#185FA5]' }`} - required /> {errors.name && ( -

{errors.name[0] || errors.name}

+

+ {Array.isArray(errors.name) ? errors.name[0] : errors.name} +

)}
@@ -217,54 +237,34 @@ export default function Register() { ? 'border-red-500 focus:ring-red-500/20 focus:border-red-500' : 'border-slate-300 focus:ring-[#185FA5]/20 focus:border-[#185FA5]' }`} - required /> {errors.email && ( -

{errors.email[0] || errors.email}

+

+ {Array.isArray(errors.email) ? errors.email[0] : errors.email} +

)}
-
- {/* Mobile Number */} -
- - handleInputChange('phone', e.target.value)} - placeholder="e.g. +971501234567" - className={`w-full px-3.5 py-2.5 rounded-xl border text-sm focus:outline-none focus:ring-2 ${ - errors.phone - ? 'border-red-500 focus:ring-red-500/20 focus:border-red-500' - : 'border-slate-300 focus:ring-[#185FA5]/20 focus:border-[#185FA5]' - }`} - required - /> - {errors.phone && ( -

{errors.phone[0] || errors.phone}

- )} -
- - {/* Country Dropdown */} -
- - - {errors.country && ( -

{errors.country[0] || errors.country}

- )} -
+ {/* Mobile Number */} +
+ + handleInputChange('phone', e.target.value)} + placeholder="e.g. +971501234567" + className={`w-full px-3.5 py-2.5 rounded-xl border text-sm focus:outline-none focus:ring-2 ${ + errors.phone + ? 'border-red-500 focus:ring-red-500/20 focus:border-red-500' + : 'border-slate-300 focus:ring-[#185FA5]/20 focus:border-[#185FA5]' + }`} + /> + {errors.phone && ( +

+ {Array.isArray(errors.phone) ? errors.phone[0] : errors.phone} +

+ )}
diff --git a/resources/js/Pages/Employer/Auth/VerifyEmail.jsx b/resources/js/Pages/Employer/Auth/VerifyEmail.jsx index 0ed8fca..ce463d7 100644 --- a/resources/js/Pages/Employer/Auth/VerifyEmail.jsx +++ b/resources/js/Pages/Employer/Auth/VerifyEmail.jsx @@ -3,13 +3,14 @@ import { Head, Link, router } from '@inertiajs/react'; import axios from 'axios'; import { toast } from 'sonner'; import { - ShieldCheck, - CheckCircle, ArrowLeft, MailCheck, Loader2, RefreshCw, - KeyRound + KeyRound, + Users, + DollarSign, + MessageSquare } from 'lucide-react'; export default function VerifyEmail({ email }) { @@ -48,7 +49,7 @@ export default function VerifyEmail({ email }) { setErrors({}); try { - const response = await axios.post('/employer/verify-email', { otp }); + await axios.post('/employer/verify-email', { otp }); toast.success('Email verified successfully! Let\'s secure your account.'); router.visit('/employer/create-password'); } catch (err) { @@ -75,7 +76,7 @@ export default function VerifyEmail({ email }) { setResending(true); try { - const response = await axios.post('/employer/resend-otp'); + await axios.post('/employer/resend-otp'); toast.success('A new verification code has been dispatched to your email.'); setCooldown(60); // Reset timer setOtp(''); @@ -94,25 +95,26 @@ export default function VerifyEmail({ email }) { }; const renderStepIndicator = () => ( -
+
-
{[ { step: 1, label: 'Register' }, - { step: 2, label: 'Verify Code' }, - { step: 3, label: 'Set Password' } + { step: 2, label: 'Verify' }, + { step: 3, label: 'Password' } ].map((s) => (
-
{s.step}
- {s.label}
))} @@ -120,139 +122,150 @@ export default function VerifyEmail({ email }) { ); return ( -
- +
+ - {/* Left Brand Panel */} -
-
+ {/* Left Brand Panel (Desktop Only) */} +
+
-
-
-
+
+
+
M
- Marketplace + Migrant Portal
-
-

- Verify Your Business Email. +
+ + Employer Registration + +

+ Find verified domestic workers directly.

-

- We take platform trust seriously. Verify your email to ensure secure account setup. +

+ Unlock direct access to top domestic candidates with a Migrant premium subscription. Find, interview, and hire directly without middlemen.

+ + {/* Stat Row */} +
+
+ +
500+
+
Verified Workers
+
+ +
+ +
Premium
+
Employer Access
+
+ +
+ +
Direct
+
Messaging
+
+

-
- Empowering Household Recruitment Since 2024 +
+ © 2026 Migrant. All rights reserved.
- {/* Right Form Content */} -
-
-
-

Email Verification

-

- Step 2 of 3: Enter Hashed Passcode + {/* Right Verify Form */} +

+
+
+
+

Verify Email

+

Verify your employer registration

+
+ {renderStepIndicator()} +
+ +
+
+ +
+

Check Your Email

+

+ We sent a 6-digit verification code to: + {email}

- {renderStepIndicator()} - -
- -
-
- +
+
+ +
+ +
-

Check Your Email

-

- We dispatched a 6-digit confirmation code to: -
- {email} -

+ {errors.otp && ( +

+ {Array.isArray(errors.otp) ? errors.otp[0] : errors.otp} +

+ )}
- - -
- -
- - -
- {errors.otp && ( -

{errors.otp}

- )} -
+ - +
+ {cooldown > 0 ? ( +

+ Resend code in {cooldown}s +

+ ) : ( + + )} +
+
-
- {cooldown > 0 ? ( -

- Resend Code available in {cooldown}s -

- ) : ( - - )} -
- - - -
- -
- - +
+ + Change Email / Back
diff --git a/resources/js/Pages/Employer/Hiring/Confirm.jsx b/resources/js/Pages/Employer/Hiring/Confirm.jsx index 71c16cc..f070e6b 100644 --- a/resources/js/Pages/Employer/Hiring/Confirm.jsx +++ b/resources/js/Pages/Employer/Hiring/Confirm.jsx @@ -19,8 +19,13 @@ export default function Confirm({ worker }) { const handleSubmit = (e) => { e.preventDefault(); - // Mock submission - router.get(`/employer/workers/${worker.id}/hire/success`); + // Perform a real POST submission to create the Job Offer in DB + router.post(`/employer/workers/${worker.id}/hire`, { + work_date: startDate, + location: location, + salary: offeredSalary, + notes: 'Hiring offer dispatched from employer panel.' + }); }; return ( diff --git a/resources/js/Pages/Employer/SelectedCandidates.jsx b/resources/js/Pages/Employer/SelectedCandidates.jsx index 78c5f7a..85bf71c 100644 --- a/resources/js/Pages/Employer/SelectedCandidates.jsx +++ b/resources/js/Pages/Employer/SelectedCandidates.jsx @@ -217,13 +217,13 @@ export default function SelectedCandidates({ selectedWorkers }) {
diff --git a/resources/views/swagger.blade.php b/resources/views/swagger.blade.php new file mode 100644 index 0000000..911a852 --- /dev/null +++ b/resources/views/swagger.blade.php @@ -0,0 +1,63 @@ + + + + + + Migrant Mobile API Documentation + + + + + + + +
+ + + + + + diff --git a/routes/api.php b/routes/api.php new file mode 100644 index 0000000..ccd6408 --- /dev/null +++ b/routes/api.php @@ -0,0 +1,31 @@ +group(function () { + // Profile Management + Route::get('/workers/profile', [WorkerProfileController::class, 'getProfile']); + Route::post('/workers/profile/update', [WorkerProfileController::class, 'updateProfile']); + + // Job Offers Management + Route::get('/workers/offers', [WorkerProfileController::class, 'getOffers']); + Route::post('/workers/offers/{id}/respond', [WorkerProfileController::class, 'respondToOffer']); +}); diff --git a/routes/web.php b/routes/web.php index d2342a6..390733d 100644 --- a/routes/web.php +++ b/routes/web.php @@ -83,6 +83,7 @@ ]; return Inertia::render('Employer/Hiring/Confirm', ['worker' => $workerData]); })->name('employer.hiring.confirm'); + Route::post('/workers/{id}/hire', [\App\Http\Controllers\Employer\WorkerController::class, 'sendOffer'])->name('employer.workers.send-offer'); Route::get('/workers/{id}/hire/success', function ($id) { $worker = \App\Models\Worker::findOrFail($id); $workerData = [ @@ -141,6 +142,7 @@ Route::get('/messages', [\App\Http\Controllers\Employer\MessageController::class, 'index'])->name('employer.messages'); Route::get('/messages/{id}', [\App\Http\Controllers\Employer\MessageController::class, 'show'])->name('employer.messages.show'); Route::post('/messages/{id}/send', [\App\Http\Controllers\Employer\MessageController::class, 'send'])->name('employer.messages.send'); + Route::get('/messages/start/{workerId}', [\App\Http\Controllers\Employer\MessageController::class, 'startConversation'])->name('employer.messages.start'); Route::get('/shortlist', [\App\Http\Controllers\Employer\ShortlistController::class, 'index'])->name('employer.shortlist'); Route::post('/shortlist/toggle', [\App\Http\Controllers\Employer\ShortlistController::class, 'toggle'])->name('employer.shortlist.toggle'); @@ -155,3 +157,7 @@ Route::get('/profile', [\App\Http\Controllers\Employer\ProfileController::class, 'index'])->name('employer.profile'); Route::post('/profile/update', [\App\Http\Controllers\Employer\ProfileController::class, 'update'])->name('employer.profile.update'); }); + +Route::get('/api/documentation', function () { + return view('swagger'); +})->name('api.documentation');