employer login issue
This commit is contained in:
parent
2e3697db29
commit
fef928af26
244
app/Http/Controllers/Api/WorkerAuthController.php
Normal file
244
app/Http/Controllers/Api/WorkerAuthController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
210
app/Http/Controllers/Api/WorkerProfileController.php
Normal file
210
app/Http/Controllers/Api/WorkerProfileController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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!');
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
44
app/Http/Middleware/WorkerApiMiddleware.php
Normal file
44
app/Http/Middleware/WorkerApiMiddleware.php
Normal 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
36
app/Models/JobOffer.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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
680
public/swagger.json
Normal 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 |
@ -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';
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
/>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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" />
|
||||
|
||||
63
resources/views/swagger.blade.php
Normal file
63
resources/views/swagger.blade.php
Normal 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
31
routes/api.php
Normal 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']);
|
||||
});
|
||||
@ -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');
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user