employer login issue

This commit is contained in:
mohanmd 2026-05-21 10:39:23 +05:30
parent 2e3697db29
commit fef928af26
26 changed files with 2006 additions and 441 deletions

View File

@ -0,0 +1,244 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Worker;
use App\Models\WorkerCategory;
use App\Models\WorkerDocument;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
class WorkerAuthController extends Controller
{
/**
* Register a new worker via Mobile App API (Single Unified Multipart Request).
*
* Accepts: name, phone, salary, password, skills, passport_file (REQUIRED), and visa_file.
* All other fields are generated with backend defaults.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\JsonResponse
*/
public function register(Request $request)
{
// 1. Validate the unified request
$validator = Validator::make($request->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);
}
}
}

View File

@ -0,0 +1,210 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\JobOffer;
use App\Models\Worker;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
class WorkerProfileController extends Controller
{
/**
* Get authorized worker's profile details.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\JsonResponse
*/
public function getProfile(Request $request)
{
/** @var Worker $worker */
$worker = $request->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);
}
}
}

View File

@ -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);
}
}

View File

@ -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,

View File

@ -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]);
}
}

View File

@ -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!');
}
}

View File

@ -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;

View File

@ -0,0 +1,44 @@
<?php
namespace App\Http\Middleware;
use App\Models\Worker;
use Closure;
use Illuminate\Http\Request;
class WorkerApiMiddleware
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @return mixed
*/
public function handle(Request $request, Closure $next)
{
$authHeader = $request->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);
}
}

36
app/Models/JobOffer.php Normal file
View File

@ -0,0 +1,36 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class JobOffer extends Model
{
use HasFactory;
protected $fillable = [
'employer_id',
'worker_id',
'work_date',
'location',
'salary',
'notes',
'status',
];
protected $casts = [
'work_date' => 'date',
'salary' => 'decimal:2',
];
public function employer()
{
return $this->belongsTo(User::class, 'employer_id');
}
public function worker()
{
return $this->belongsTo(Worker::class, 'worker_id');
}
}

View File

@ -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');
}
}

View File

@ -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 {

View File

@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('workers', function (Blueprint $table) {
$table->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']);
});
}
};

View File

@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('job_offers', function (Blueprint $table) {
$table->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');
}
};

680
public/swagger.json Normal file
View File

@ -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"
}
}
}
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

View File

@ -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';

View File

@ -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 = () => (
<div className="flex items-center justify-between mb-12 relative max-w-md mx-auto px-4 select-none">
<div className="flex items-center justify-between mb-6 relative max-w-xs mx-auto px-2 select-none w-48">
<div className="absolute top-1/2 left-0 w-full h-0.5 bg-slate-100 -translate-y-1/2 -z-10" />
<div className="absolute top-1/2 left-0 h-0.5 bg-[#185FA5] -translate-y-1/2 -z-10 transition-all duration-500" style={{ width: '100%' }} />
{[
{ step: 1, label: 'Register' },
{ step: 2, label: 'Verify Code' },
{ step: 3, label: 'Set Password' }
{ step: 2, label: 'Verify' },
{ step: 3, label: 'Password' }
].map((s) => (
<div key={s.step} className="flex flex-col items-center">
<div className={`w-10 h-10 rounded-full flex items-center justify-center font-black text-sm border-4 transition-all duration-300 ${
s.step <= 3
? 'bg-[#185FA5] text-white border-blue-100 scale-110 shadow-lg'
: 'bg-white text-slate-400 border-slate-50'
<div className={`w-8 h-8 rounded-full flex items-center justify-center font-bold text-xs border-2 transition-all duration-300 ${
s.step === 3
? 'bg-[#185FA5] text-white border-[#185FA5] shadow-sm'
: 'bg-blue-50 text-[#185FA5] border-blue-200'
}`}>
{s.step === 3 && isAllCriteriaMet ? <Check className="w-5 h-5" /> : s.step}
{s.step}
</div>
<span className={`text-[9px] font-black uppercase mt-2 tracking-wider ${
s.step <= 3 ? 'text-[#185FA5]' : 'text-slate-400'
<span className={`text-[9px] font-semibold uppercase mt-1 tracking-wider ${
s.step === 3 ? 'text-[#185FA5]' : 'text-slate-400'
}`}>{s.label}</span>
</div>
))}
@ -125,150 +137,163 @@ export default function CreatePassword() {
];
return (
<div className="min-h-screen grid grid-cols-1 lg:grid-cols-12 bg-[#F8FAFC] font-sans selection:bg-blue-100 selection:text-[#185FA5]">
<Head title="Secure Password Setup - Step 3" />
<div className="min-h-screen grid grid-cols-1 lg:grid-cols-12 bg-slate-50 font-sans">
<Head title="Secure Password Setup - Migrant Portal" />
{/* Left Brand Panel */}
<div className="hidden lg:flex lg:col-span-4 bg-[#185FA5] p-16 flex-col justify-between text-white relative overflow-hidden select-none sticky top-0 h-screen">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(255,255,255,0.1),transparent)] pointer-events-none" />
{/* Left Brand Panel (Desktop Only) */}
<div className="hidden lg:flex lg:col-span-5 bg-[#185FA5] p-12 flex-col justify-between text-white relative overflow-hidden select-none">
<div className="absolute inset-0 bg-gradient-to-br from-blue-600/20 to-black/30 pointer-events-none" />
<div className="relative z-10 space-y-10">
<div className="flex items-center space-x-4">
<div className="w-12 h-12 bg-white text-[#185FA5] rounded-2xl flex items-center justify-center font-black text-2xl shadow-2xl">
<div className="relative z-10 space-y-6">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-white text-[#185FA5] rounded-xl flex items-center justify-center font-bold text-xl shadow-md">
M
</div>
<span className="font-black text-2xl tracking-tighter uppercase">Marketplace</span>
<span className="font-bold text-2xl tracking-tight">Migrant Portal</span>
</div>
<div className="space-y-6 pt-10">
<h1 className="text-5xl font-black tracking-tighter leading-[1.1] drop-shadow-sm uppercase">
Establish Secure Credentials.
<div className="space-y-3 pt-6">
<span className="inline-block px-3 py-1 bg-white/10 border border-white/20 rounded-full text-xs font-semibold uppercase tracking-wider">
Employer Registration
</span>
<h1 className="text-4xl font-extrabold tracking-tight leading-tight">
Find verified domestic workers directly.
</h1>
<p className="text-blue-100 text-lg font-medium leading-relaxed opacity-90">
Establish a robust password to ensure protection of your domestic hiring workspace.
<p className="text-blue-100 text-sm font-medium">
Unlock direct access to top domestic candidates with a Migrant premium subscription. Find, interview, and hire directly without middlemen.
</p>
</div>
{/* Stat Row */}
<div className="grid grid-cols-3 gap-4 pt-8 border-t border-white/10">
<div className="bg-white/10 backdrop-blur-sm p-4 rounded-xl border border-white/10 text-center space-y-1">
<Users className="w-6 h-6 text-emerald-300 mx-auto" />
<div className="font-bold text-lg">500+</div>
<div className="text-[10px] text-blue-100 uppercase tracking-wider font-semibold">Verified Workers</div>
</div>
<div className="bg-white/10 backdrop-blur-sm p-4 rounded-xl border border-white/10 text-center space-y-1">
<DollarSign className="w-6 h-6 text-emerald-300 mx-auto" />
<div className="font-bold text-lg">Premium</div>
<div className="text-[10px] text-blue-100 uppercase tracking-wider font-semibold">Employer Access</div>
</div>
<div className="bg-white/10 backdrop-blur-sm p-4 rounded-xl border border-white/10 text-center space-y-1">
<MessageSquare className="w-6 h-6 text-emerald-300 mx-auto" />
<div className="font-bold text-lg">Direct</div>
<div className="text-[10px] text-blue-100 uppercase tracking-wider font-semibold">Messaging</div>
</div>
</div>
</div>
<div className="relative z-10 text-[10px] font-black uppercase tracking-[0.3em] text-blue-300 opacity-60">
Empowering Household Recruitment Since 2024
<div className="relative z-10 text-xs text-blue-200 font-medium">
© 2026 Migrant. All rights reserved.
</div>
</div>
{/* Right Form Content */}
<div className="col-span-1 lg:col-span-8 flex flex-col items-center py-12 px-6 sm:px-12 overflow-y-auto justify-center">
<div className="w-full max-w-xl">
<div className="text-center mb-10">
<h2 className="text-3xl font-black text-slate-900 tracking-tight uppercase">Security Configuration</h2>
<p className="text-sm font-bold text-slate-400 mt-2 uppercase tracking-widest">
Step 3 of 3: Configure Portal Access Key
</p>
{/* Right Set Password Form */}
<div className="col-span-1 lg:col-span-7 flex flex-col justify-center items-center p-6 sm:p-12 overflow-y-auto">
<div className="w-full max-w-lg bg-white rounded-2xl shadow-sm border border-slate-200 p-8 space-y-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h2 className="text-2xl font-bold text-gray-900 tracking-tight">Set Password</h2>
<p className="text-xs text-gray-500 mt-1">Configure your portal access password</p>
</div>
{renderStepIndicator()}
</div>
{renderStepIndicator()}
<div className="bg-white rounded-[40px] shadow-[0_20px_50px_rgba(0,0,0,0.04)] border border-slate-100 p-8 sm:p-12 transition-all duration-500 animate-in fade-in slide-in-from-bottom-4">
<form onSubmit={handleSubmit} className="space-y-6">
{/* Password input */}
<div className="space-y-2">
<label className="text-[10px] font-black text-slate-500 uppercase tracking-widest ml-1">Secure Password</label>
<div className="relative group">
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400 group-focus-within:text-[#185FA5] transition-colors" />
<input
type={showPassword ? 'text' : 'password'}
value={data.password}
onChange={(e) => 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
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-4 top-1/2 -translate-y-1/2 p-2 text-slate-400 hover:text-slate-600 cursor-pointer"
>
{showPassword ? <EyeOff className="w-4.5 h-4.5" /> : <Eye className="w-4.5 h-4.5" />}
</button>
</div>
{errors.password && (
<p className="text-xs text-rose-500 font-bold ml-1 uppercase tracking-wider">{errors.password[0] || errors.password}</p>
)}
<form onSubmit={handleSubmit} noValidate className="space-y-4">
{/* Password input */}
<div className="space-y-2">
<label className="block text-xs font-medium text-gray-700">Secure Password</label>
<div className="relative group">
<Lock className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 group-focus-within:text-[#185FA5] transition-colors" />
<input
type={showPassword ? 'text' : 'password'}
value={data.password}
onChange={(e) => 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'
}`}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3.5 top-1/2 -translate-y-1/2 p-1 text-gray-400 hover:text-gray-600"
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
{errors.password && (
<p className="text-xs text-red-500 mt-1">
{Array.isArray(errors.password) ? errors.password[0] : errors.password}
</p>
)}
</div>
{/* Confirm Password input */}
<div className="space-y-2">
<label className="text-[10px] font-black text-slate-500 uppercase tracking-widest ml-1">Confirm Access Key</label>
<div className="relative group">
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400 group-focus-within:text-[#185FA5] transition-colors" />
<input
type={showPassword ? 'text' : 'password'}
value={data.password_confirmation}
onChange={(e) => 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
/>
</div>
{errors.password_confirmation && (
<p className="text-xs text-rose-500 font-bold ml-1 uppercase tracking-wider">{errors.password_confirmation[0] || errors.password_confirmation}</p>
)}
{/* Confirm Password input */}
<div className="space-y-2">
<label className="block text-xs font-medium text-gray-700">Confirm Password</label>
<div className="relative group">
<Lock className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 group-focus-within:text-[#185FA5] transition-colors" />
<input
type={showPassword ? 'text' : 'password'}
value={data.password_confirmation}
onChange={(e) => 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'
}`}
/>
</div>
{errors.password_confirmation && (
<p className="text-xs text-red-500 mt-1">
{Array.isArray(errors.password_confirmation) ? errors.password_confirmation[0] : errors.password_confirmation}
</p>
)}
</div>
{/* Password Rules Checklist */}
<div className="bg-slate-50 p-6 rounded-3xl border border-slate-100 space-y-3">
<h4 className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Password Complexity Checklist</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-2">
{rules.map((rule) => {
const isMet = strength[rule.key];
return (
<div key={rule.key} className="flex items-center space-x-2.5">
<div className={`w-4 h-4 rounded-full flex items-center justify-center border transition-all ${
isMet
? 'bg-emerald-500 border-emerald-600 text-white'
: 'bg-white border-slate-200 text-slate-300'
}`}>
{isMet ? <Check className="w-2.5 h-2.5" /> : null}
</div>
<span className={`text-[10px] font-bold ${
isMet ? 'text-slate-600' : 'text-slate-400'
}`}>{rule.text}</span>
{/* Password Rules Checklist */}
<div className="bg-slate-50 p-4 rounded-xl border border-slate-100 space-y-2">
<h4 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest">Complexity Checklist</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-1.5">
{rules.map((rule) => {
const isMet = strength[rule.key];
return (
<div key={rule.key} className="flex items-center space-x-2">
<div className={`w-3.5 h-3.5 rounded-full flex items-center justify-center border transition-all ${
isMet
? 'bg-emerald-500 border-emerald-600 text-white'
: 'bg-white border-slate-200 text-slate-300'
}`}>
{isMet ? <Check className="w-2 h-2" /> : null}
</div>
);
})}
</div>
<span className={`text-[10px] ${
isMet ? 'text-gray-700 font-medium' : 'text-gray-400'
}`}>{rule.text}</span>
</div>
);
})}
</div>
</div>
<button
type="submit"
disabled={loading || !isAllCriteriaMet || data.password !== data.password_confirmation}
className="w-full bg-emerald-600 hover:bg-emerald-700 disabled:opacity-50 disabled:hover:bg-emerald-600 text-white py-5 rounded-[24px] font-black text-xs uppercase tracking-[0.2em] transition-all shadow-xl shadow-emerald-500/20 flex items-center justify-center space-x-2 mt-8 select-none cursor-pointer"
>
{loading ? (
<>
<Loader2 className="w-5 h-5 animate-spin mr-2" />
<span>Saving Employer Profile...</span>
</>
) : (
<>
<span>Submit & Activate Account</span>
<ShieldCheck className="w-4 h-4" />
</>
)}
</button>
<button
type="submit"
disabled={loading || !isAllCriteriaMet || data.password !== data.password_confirmation}
className="w-full bg-[#185FA5] hover:bg-[#0C447C] active:bg-[#08305c] text-white rounded-xl h-11 font-semibold text-sm transition-colors shadow-sm flex items-center justify-center disabled:opacity-50 disabled:cursor-not-allowed mt-4"
>
{loading ? (
<Loader2 className="w-5 h-5 animate-spin mr-2" />
) : (
'Submit & Activate Account'
)}
</button>
</form>
</form>
</div>
<div className="mt-8 text-center select-none">
<Link href="/employer/register" className="inline-flex items-center space-x-2 text-xs font-bold text-slate-400 hover:text-slate-600 uppercase tracking-widest">
<X className="w-4 h-4" />
<div className="border-t border-slate-100 pt-6 text-center">
<Link href="/employer/register" className="inline-flex items-center space-x-1 text-xs text-gray-600 font-medium hover:text-[#185FA5] hover:underline">
<X className="w-3.5 h-3.5 mr-1" />
<span>Cancel Registration</span>
</Link>
</div>

View File

@ -24,7 +24,7 @@ export default function ForgotPassword() {
<label className="block text-xs font-medium text-gray-700 mb-1">Email Address</label>
<input
type="email"
placeholder="employer@example.com"
placeholder="name@company.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]"
autoFocus
/>

View File

@ -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 (
<div className="min-h-screen grid grid-cols-1 lg:grid-cols-12 bg-slate-50 font-sans">
<Head title="Employer Login - Marketplace Portal" />
<Head title="Employer Login - Migrant Portal" />
{/* Left Brand Panel (Desktop Only) */}
<div className="hidden lg:flex lg:col-span-5 bg-[#185FA5] p-12 flex-col justify-between text-white relative overflow-hidden select-none">
@ -40,7 +64,7 @@ export default function Login({ flash }) {
<div className="w-10 h-10 bg-white text-[#185FA5] rounded-xl flex items-center justify-center font-bold text-xl shadow-md">
M
</div>
<span className="font-bold text-2xl tracking-tight">Marketplace Portal</span>
<span className="font-bold text-2xl tracking-tight">Migrant Portal</span>
</div>
<div className="space-y-3 pt-6">
@ -48,10 +72,10 @@ export default function Login({ flash }) {
Employer Login
</span>
<h1 className="text-4xl font-extrabold tracking-tight leading-tight">
Find verified domestic workers no agents, no fees.
Find verified domestic workers directly.
</h1>
<p className="text-blue-100 text-sm font-medium">
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.
</p>
</div>
@ -65,8 +89,8 @@ export default function Login({ flash }) {
<div className="bg-white/10 backdrop-blur-sm p-4 rounded-xl border border-white/10 text-center space-y-1">
<DollarSign className="w-6 h-6 text-emerald-300 mx-auto" />
<div className="font-bold text-lg">0 AED</div>
<div className="text-[10px] text-blue-100 uppercase tracking-wider font-semibold">Agent Fees</div>
<div className="font-bold text-lg">Premium</div>
<div className="text-[10px] text-blue-100 uppercase tracking-wider font-semibold">Employer Access</div>
</div>
<div className="bg-white/10 backdrop-blur-sm p-4 rounded-xl border border-white/10 text-center space-y-1">
@ -78,7 +102,7 @@ export default function Login({ flash }) {
</div>
<div className="relative z-10 text-xs text-blue-200 font-medium">
© 2026 Domestic Worker Marketplace. All rights reserved.
© 2026 Migrant. All rights reserved.
</div>
</div>
@ -115,27 +139,18 @@ export default function Login({ flash }) {
</div>
)}
{/* Demonstration Helper Note */}
<div className="p-3 bg-slate-50 border border-slate-200 rounded-xl text-[11px] text-slate-600 space-y-1">
<div className="font-semibold text-slate-700">💡 Scaffolding Demo Emails:</div>
<div> Active Employer: <code className="text-[#185FA5] font-bold">employer@example.com</code></div>
<div> Pending Alert: <code className="text-amber-600 font-bold">pending@example.com</code></div>
<div> Rejected Alert: <code className="text-red-600 font-bold">rejected@example.com</code></div>
<div> Expired Alert: <code className="text-blue-600 font-bold">expired@example.com</code></div>
<div className="text-[10px] text-slate-500 pt-1">Password for all: <code className="font-bold">password</code></div>
</div>
<form onSubmit={handleSubmit} className="space-y-5">
<form onSubmit={handleSubmit} noValidate className="space-y-5">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Email Address</label>
<input
type="email"
value={data.email}
onChange={(e) => 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 && <p className="mt-1 text-xs text-red-500">{errors.email}</p>}
</div>
@ -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'
}`}
/>
<button
type="button"
@ -164,6 +180,7 @@ export default function Login({ flash }) {
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
{errors.password && <p className="mt-1 text-xs text-red-500">{errors.password}</p>}
</div>
<div className="flex items-center">

View File

@ -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 = () => (
<div className="flex items-center justify-between mb-6 relative max-w-xs mx-auto px-2 select-none">
<div className="flex items-center justify-between mb-6 relative max-w-xs mx-auto px-2 select-none w-48">
<div className="absolute top-1/2 left-0 w-full h-0.5 bg-slate-100 -translate-y-1/2 -z-10" />
{[
@ -98,7 +116,7 @@ export default function Register() {
return (
<div className="min-h-screen grid grid-cols-1 lg:grid-cols-12 bg-slate-50 font-sans">
<Head title="Employer Registration - Marketplace Portal" />
<Head title="Employer Registration - Migrant Portal" />
{/* Left Brand Panel (Desktop Only) */}
<div className="hidden lg:flex lg:col-span-5 bg-[#185FA5] p-12 flex-col justify-between text-white relative overflow-hidden select-none">
@ -109,7 +127,7 @@ export default function Register() {
<div className="w-10 h-10 bg-white text-[#185FA5] rounded-xl flex items-center justify-center font-bold text-xl shadow-md">
M
</div>
<span className="font-bold text-2xl tracking-tight">Marketplace Portal</span>
<span className="font-bold text-2xl tracking-tight">Migrant Portal</span>
</div>
<div className="space-y-3 pt-6">
@ -117,10 +135,10 @@ export default function Register() {
Employer Registration
</span>
<h1 className="text-4xl font-extrabold tracking-tight leading-tight">
Find verified domestic workers no agents, no fees.
Find verified domestic workers directly.
</h1>
<p className="text-blue-100 text-sm font-medium">
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.
</p>
</div>
@ -134,8 +152,8 @@ export default function Register() {
<div className="bg-white/10 backdrop-blur-sm p-4 rounded-xl border border-white/10 text-center space-y-1">
<DollarSign className="w-6 h-6 text-emerald-300 mx-auto" />
<div className="font-bold text-lg">0 AED</div>
<div className="text-[10px] text-blue-100 uppercase tracking-wider font-semibold">Agent Fees</div>
<div className="font-bold text-lg">Premium</div>
<div className="text-[10px] text-blue-100 uppercase tracking-wider font-semibold">Employer Access</div>
</div>
<div className="bg-white/10 backdrop-blur-sm p-4 rounded-xl border border-white/10 text-center space-y-1">
@ -147,7 +165,7 @@ export default function Register() {
</div>
<div className="relative z-10 text-xs text-blue-200 font-medium">
© 2026 Domestic Worker Marketplace. All rights reserved.
© 2026 Migrant. All rights reserved.
</div>
</div>
@ -162,7 +180,7 @@ export default function Register() {
{renderStepIndicator()}
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<form onSubmit={handleSubmit} noValidate className="space-y-4">
{/* Company Name */}
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Company / Household Name</label>
@ -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 && (
<p className="mt-1 text-xs text-red-500">{errors.company_name[0] || errors.company_name}</p>
<p className="mt-1 text-xs text-red-500">
{Array.isArray(errors.company_name) ? errors.company_name[0] : errors.company_name}
</p>
)}
</div>
@ -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 && (
<p className="mt-1 text-xs text-red-500">{errors.name[0] || errors.name}</p>
<p className="mt-1 text-xs text-red-500">
{Array.isArray(errors.name) ? errors.name[0] : errors.name}
</p>
)}
</div>
@ -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 && (
<p className="mt-1 text-xs text-red-500">{errors.email[0] || errors.email}</p>
<p className="mt-1 text-xs text-red-500">
{Array.isArray(errors.email) ? errors.email[0] : errors.email}
</p>
)}
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{/* Mobile Number */}
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Mobile Number</label>
<input
type="tel"
value={data.phone}
onChange={(e) => 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 && (
<p className="mt-1 text-xs text-red-500">{errors.phone[0] || errors.phone}</p>
)}
</div>
{/* Country Dropdown */}
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Country of Residence</label>
<select
value={data.country}
onChange={(e) => handleInputChange('country', e.target.value)}
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] bg-white cursor-pointer appearance-none"
required
>
{countries.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
{errors.country && (
<p className="mt-1 text-xs text-red-500">{errors.country[0] || errors.country}</p>
)}
</div>
{/* Mobile Number */}
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Mobile Number</label>
<input
type="tel"
value={data.phone}
onChange={(e) => 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 && (
<p className="mt-1 text-xs text-red-500">
{Array.isArray(errors.phone) ? errors.phone[0] : errors.phone}
</p>
)}
</div>
<button
@ -275,7 +275,7 @@ export default function Register() {
{loading ? (
<Loader2 className="w-5 h-5 animate-spin mr-2" />
) : (
'Send Verification Code'
'Continue to Verification'
)}
</button>
</form>

View File

@ -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 = () => (
<div className="flex items-center justify-between mb-12 relative max-w-md mx-auto px-4 select-none">
<div className="flex items-center justify-between mb-6 relative max-w-xs mx-auto px-2 select-none">
<div className="absolute top-1/2 left-0 w-full h-0.5 bg-slate-100 -translate-y-1/2 -z-10" />
<div className="absolute top-1/2 left-0 h-0.5 bg-[#185FA5] -translate-y-1/2 -z-10 transition-all duration-500" style={{ width: '50%' }} />
{[
{ step: 1, label: 'Register' },
{ step: 2, label: 'Verify Code' },
{ step: 3, label: 'Set Password' }
{ step: 2, label: 'Verify' },
{ step: 3, label: 'Password' }
].map((s) => (
<div key={s.step} className="flex flex-col items-center">
<div className={`w-10 h-10 rounded-full flex items-center justify-center font-black text-sm border-4 transition-all duration-300 ${
s.step <= 2
? 'bg-[#185FA5] text-white border-blue-100 scale-110 shadow-lg'
: 'bg-white text-slate-400 border-slate-50'
<div className={`w-8 h-8 rounded-full flex items-center justify-center font-bold text-xs border-2 transition-all duration-300 ${
s.step === 2
? 'bg-[#185FA5] text-white border-[#185FA5] shadow-sm'
: s.step === 1
? 'bg-blue-50 text-[#185FA5] border-blue-200'
: 'bg-white text-slate-400 border-slate-200'
}`}>
{s.step}
</div>
<span className={`text-[9px] font-black uppercase mt-2 tracking-wider ${
s.step <= 2 ? 'text-[#185FA5]' : 'text-slate-400'
<span className={`text-[9px] font-semibold uppercase mt-1 tracking-wider ${
s.step === 2 ? 'text-[#185FA5]' : 'text-slate-400'
}`}>{s.label}</span>
</div>
))}
@ -120,139 +122,150 @@ export default function VerifyEmail({ email }) {
);
return (
<div className="min-h-screen grid grid-cols-1 lg:grid-cols-12 bg-[#F8FAFC] font-sans selection:bg-blue-100 selection:text-[#185FA5]">
<Head title="Verify Email - Step 2" />
<div className="min-h-screen grid grid-cols-1 lg:grid-cols-12 bg-slate-50 font-sans">
<Head title="Verify Email - Migrant Portal" />
{/* Left Brand Panel */}
<div className="hidden lg:flex lg:col-span-4 bg-[#185FA5] p-16 flex-col justify-between text-white relative overflow-hidden select-none sticky top-0 h-screen">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(255,255,255,0.1),transparent)] pointer-events-none" />
{/* Left Brand Panel (Desktop Only) */}
<div className="hidden lg:flex lg:col-span-5 bg-[#185FA5] p-12 flex-col justify-between text-white relative overflow-hidden select-none">
<div className="absolute inset-0 bg-gradient-to-br from-blue-600/20 to-black/30 pointer-events-none" />
<div className="relative z-10 space-y-10">
<div className="flex items-center space-x-4">
<div className="w-12 h-12 bg-white text-[#185FA5] rounded-2xl flex items-center justify-center font-black text-2xl shadow-2xl">
<div className="relative z-10 space-y-6">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-white text-[#185FA5] rounded-xl flex items-center justify-center font-bold text-xl shadow-md">
M
</div>
<span className="font-black text-2xl tracking-tighter uppercase">Marketplace</span>
<span className="font-bold text-2xl tracking-tight">Migrant Portal</span>
</div>
<div className="space-y-6 pt-10">
<h1 className="text-5xl font-black tracking-tighter leading-[1.1] drop-shadow-sm uppercase">
Verify Your Business Email.
<div className="space-y-3 pt-6">
<span className="inline-block px-3 py-1 bg-white/10 border border-white/20 rounded-full text-xs font-semibold uppercase tracking-wider">
Employer Registration
</span>
<h1 className="text-4xl font-extrabold tracking-tight leading-tight">
Find verified domestic workers directly.
</h1>
<p className="text-blue-100 text-lg font-medium leading-relaxed opacity-90">
We take platform trust seriously. Verify your email to ensure secure account setup.
<p className="text-blue-100 text-sm font-medium">
Unlock direct access to top domestic candidates with a Migrant premium subscription. Find, interview, and hire directly without middlemen.
</p>
</div>
{/* Stat Row */}
<div className="grid grid-cols-3 gap-4 pt-8 border-t border-white/10">
<div className="bg-white/10 backdrop-blur-sm p-4 rounded-xl border border-white/10 text-center space-y-1">
<Users className="w-6 h-6 text-emerald-300 mx-auto" />
<div className="font-bold text-lg">500+</div>
<div className="text-[10px] text-blue-100 uppercase tracking-wider font-semibold">Verified Workers</div>
</div>
<div className="bg-white/10 backdrop-blur-sm p-4 rounded-xl border border-white/10 text-center space-y-1">
<DollarSign className="w-6 h-6 text-emerald-300 mx-auto" />
<div className="font-bold text-lg">Premium</div>
<div className="text-[10px] text-blue-100 uppercase tracking-wider font-semibold">Employer Access</div>
</div>
<div className="bg-white/10 backdrop-blur-sm p-4 rounded-xl border border-white/10 text-center space-y-1">
<MessageSquare className="w-6 h-6 text-emerald-300 mx-auto" />
<div className="font-bold text-lg">Direct</div>
<div className="text-[10px] text-blue-100 uppercase tracking-wider font-semibold">Messaging</div>
</div>
</div>
</div>
<div className="relative z-10 text-[10px] font-black uppercase tracking-[0.3em] text-blue-300 opacity-60">
Empowering Household Recruitment Since 2024
<div className="relative z-10 text-xs text-blue-200 font-medium">
© 2026 Migrant. All rights reserved.
</div>
</div>
{/* Right Form Content */}
<div className="col-span-1 lg:col-span-8 flex flex-col items-center py-12 px-6 sm:px-12 overflow-y-auto justify-center">
<div className="w-full max-w-xl">
<div className="text-center mb-10">
<h2 className="text-3xl font-black text-slate-900 tracking-tight uppercase">Email Verification</h2>
<p className="text-sm font-bold text-slate-400 mt-2 uppercase tracking-widest">
Step 2 of 3: Enter Hashed Passcode
{/* Right Verify Form */}
<div className="col-span-1 lg:col-span-7 flex flex-col justify-center items-center p-6 sm:p-12 overflow-y-auto">
<div className="w-full max-w-lg bg-white rounded-2xl shadow-sm border border-slate-200 p-8 space-y-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h2 className="text-2xl font-bold text-gray-900 tracking-tight">Verify Email</h2>
<p className="text-xs text-gray-500 mt-1">Verify your employer registration</p>
</div>
{renderStepIndicator()}
</div>
<div className="bg-white p-2 text-center">
<div className="w-12 h-12 bg-blue-50 text-[#185FA5] rounded-xl flex items-center justify-center mx-auto mb-3 border border-blue-100 shadow-sm">
<MailCheck className="w-6 h-6" />
</div>
<h3 className="text-sm font-bold text-gray-900">Check Your Email</h3>
<p className="text-xs text-gray-500 mt-1">
We sent a 6-digit verification code to:
<span className="text-[#185FA5] font-semibold block mt-0.5">{email}</span>
</p>
</div>
{renderStepIndicator()}
<div className="bg-white rounded-[40px] shadow-[0_20px_50px_rgba(0,0,0,0.04)] border border-slate-100 p-8 sm:p-12 transition-all duration-500 animate-in fade-in slide-in-from-bottom-4">
<div className="text-center mb-8">
<div className="w-16 h-16 bg-blue-50 text-[#185FA5] rounded-3xl flex items-center justify-center mx-auto mb-4 border border-blue-100 shadow-sm animate-pulse">
<MailCheck className="w-8 h-8" />
<form onSubmit={handleSubmit} noValidate className="space-y-6">
<div className="space-y-2">
<label className="block text-xs font-medium text-gray-700 text-center">6-Digit Passcode</label>
<div className="relative max-w-xs mx-auto group">
<KeyRound className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 group-focus-within:text-[#185FA5] transition-colors" />
<input
type="text"
pattern="[0-9]*"
inputMode="numeric"
maxLength="6"
value={otp}
onChange={handleOtpChange}
placeholder="•••••"
className={`w-full pl-12 pr-4 py-3 bg-slate-50 border rounded-xl text-center text-xl font-bold tracking-[0.25em] focus:outline-none focus:ring-2 focus:ring-[#185FA5]/20 focus:border-[#185FA5] focus:bg-white transition-all outline-none ${
errors.otp ? 'border-red-500 focus:ring-red-500/20' : 'border-slate-300'
}`}
/>
</div>
<h3 className="text-lg font-black text-slate-900 uppercase">Check Your Email</h3>
<p className="text-xs font-bold text-slate-400 mt-1 leading-relaxed">
We dispatched a 6-digit confirmation code to:
<br />
<span className="text-slate-800 font-extrabold lowercase text-sm mt-1 inline-block">{email}</span>
</p>
{errors.otp && (
<p className="text-xs text-red-500 text-center mt-1">
{Array.isArray(errors.otp) ? errors.otp[0] : errors.otp}
</p>
)}
</div>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-3">
<label className="text-[10px] font-black text-slate-500 uppercase tracking-widest ml-1 text-center block">6-Digit Passcode</label>
<div className="relative max-w-xs mx-auto group">
<KeyRound className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400 group-focus-within:text-[#185FA5] transition-colors" />
<input
type="text"
pattern="[0-9]*"
inputMode="numeric"
maxLength="6"
value={otp}
onChange={handleOtpChange}
placeholder="••••••"
className={`w-full pl-12 pr-4 py-5 bg-slate-50 border rounded-2xl text-center text-2xl font-black tracking-[0.25em] focus:ring-4 focus:ring-blue-100 focus:bg-white transition-all outline-none ${
errors.otp ? 'border-rose-400 bg-rose-50/20' : 'border-transparent'
}`}
required
/>
</div>
{errors.otp && (
<p className="text-xs text-rose-500 font-bold text-center uppercase tracking-wider">{errors.otp}</p>
)}
</div>
<button
type="submit"
disabled={loading || otp.length !== 6}
className="w-full bg-[#185FA5] hover:bg-[#0C447C] active:bg-[#08305c] text-white rounded-xl h-11 font-semibold text-sm transition-colors shadow-sm flex items-center justify-center disabled:opacity-70 disabled:cursor-not-allowed mt-4"
>
{loading ? (
<Loader2 className="w-5 h-5 animate-spin mr-2" />
) : (
'Confirm Verification'
)}
</button>
<button
type="submit"
disabled={loading || otp.length !== 6}
className="w-full bg-[#185FA5] hover:bg-[#144f8a] disabled:opacity-70 text-white py-5 rounded-[24px] font-black text-xs uppercase tracking-[0.2em] transition-all shadow-xl shadow-blue-500/20 flex items-center justify-center space-x-2 mt-8 select-none cursor-pointer"
>
{loading ? (
<>
<Loader2 className="w-5 h-5 animate-spin mr-2" />
<span>Verifying Code...</span>
</>
) : (
<>
<span>Confirm Verification</span>
<CheckCircle className="w-4 h-4" />
</>
)}
</button>
<div className="text-center pt-4 border-t border-slate-100">
{cooldown > 0 ? (
<p className="text-xs text-gray-500">
Resend code in <span className="text-[#185FA5] font-semibold">{cooldown}s</span>
</p>
) : (
<button
type="button"
onClick={handleResend}
disabled={resending}
className="inline-flex items-center space-x-1 text-xs font-bold text-[#185FA5] hover:underline cursor-pointer"
>
{resending ? (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin mr-1" />
<span>Requesting...</span>
</>
) : (
<>
<RefreshCw className="w-3 h-3 mr-1" />
<span>Resend Passcode</span>
</>
)}
</button>
)}
</div>
</form>
<div className="text-center pt-4 border-t border-slate-100">
{cooldown > 0 ? (
<p className="text-[10px] font-black text-slate-400 uppercase tracking-wider">
Resend Code available in <span className="text-[#185FA5] font-extrabold">{cooldown}s</span>
</p>
) : (
<button
type="button"
onClick={handleResend}
disabled={resending}
className="inline-flex items-center space-x-1.5 text-xs font-black text-[#185FA5] hover:text-[#144f8a] uppercase tracking-wider cursor-pointer"
>
{resending ? (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin" />
<span>Requesting...</span>
</>
) : (
<>
<RefreshCw className="w-3.5 h-3.5" />
<span>Resend Passcode</span>
</>
)}
</button>
)}
</div>
</form>
</div>
<div className="mt-8 text-center select-none">
<Link href="/employer/register" className="inline-flex items-center space-x-2 text-xs font-bold text-slate-400 hover:text-slate-600 uppercase tracking-widest">
<ArrowLeft className="w-4 h-4" />
<div className="border-t border-slate-100 pt-6 text-center">
<Link href="/employer/register" className="inline-flex items-center space-x-1 text-xs text-gray-600 font-medium hover:text-[#185FA5] hover:underline">
<ArrowLeft className="w-3.5 h-3.5 mr-1" />
<span>Change Email / Back</span>
</Link>
</div>

View File

@ -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 (

View File

@ -217,13 +217,13 @@ export default function SelectedCandidates({ selectedWorkers }) {
<td className="px-6 py-5 text-right">
<div className="flex items-center justify-end space-x-1">
<Link
href={`/employer/messages/${worker.id}`}
href={`/employer/messages/start/${worker.worker_id}`}
className="p-2 bg-blue-50 text-[#185FA5] hover:bg-[#185FA5] hover:text-white rounded-lg transition-all"
>
<MessageSquare className="w-3.5 h-3.5" />
</Link>
<Link
href={`/employer/workers/${worker.id}`}
href={`/employer/workers/${worker.worker_id}`}
className="p-2 bg-slate-50 text-slate-400 hover:text-slate-900 rounded-lg transition-all"
>
<MoreHorizontal className="w-3.5 h-3.5" />

View File

@ -0,0 +1,63 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Migrant Mobile API Documentation</title>
<!-- Swagger UI Assets from official CDN -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/5.11.0/swagger-ui.css" />
<link rel="icon" type="image/png" href="https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/5.11.0/favicon-32x32.png" sizes="32x32" />
<link rel="icon" type="image/png" href="https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/5.11.0/favicon-16x16.png" sizes="16x16" />
<style>
html {
box-sizing: border-box;
overflow-y: scroll;
}
*, *:before, *:after {
box-sizing: inherit;
}
body {
margin: 0;
background: #fafafa;
}
/* Custom themed brand matching the Migrant portal color scheme */
.swagger-ui .topbar {
background-color: #185FA5;
padding: 12px 0;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.swagger-ui .topbar .download-url-wrapper .select-label {
color: #ffffff;
font-weight: bold;
}
.swagger-ui .topbar a {
max-width: 150px;
}
</style>
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/5.11.0/swagger-ui-bundle.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/5.11.0/swagger-ui-standalone-preset.js"></script>
<script>
window.onload = function() {
// Load and render Swagger UI with local public/swagger.json specification
const ui = SwaggerUIBundle({
url: "/swagger.json",
dom_id: '#swagger-ui',
deepLinking: true,
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIStandalonePreset
],
plugins: [
SwaggerUIBundle.plugins.DownloadUrl
],
layout: "StandaloneLayout"
});
window.ui = ui;
};
</script>
</body>
</html>

31
routes/api.php Normal file
View File

@ -0,0 +1,31 @@
<?php
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\Api\WorkerAuthController;
use App\Http\Controllers\Api\WorkerProfileController;
/*
|--------------------------------------------------------------------------
| API Routes
|--------------------------------------------------------------------------
|
| Here is where you can register API routes for your mobile app. These
| routes are loaded by bootstrap/app.php and are automatically prefixed
| with "api/" and assigned the stateless api middleware.
|
*/
// Unprotected Worker Mobile Auth Endpoints
Route::post('/workers/register', [WorkerAuthController::class, 'register']);
Route::post('/workers/login', [WorkerAuthController::class, 'login']);
// Protected Worker Mobile Endpoints (Token Authenticated via Bearer Token)
Route::middleware(['auth.worker'])->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']);
});

View File

@ -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');