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 0000000..05b35be Binary files /dev/null and b/public/uploads/documents/1779274805_passport_Screenshot2025-08-21131531.png differ 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');