mohan #5
@ -56,7 +56,7 @@ public function index(Request $request)
|
||||
|
||||
public function show($id)
|
||||
{
|
||||
$ticket = SupportTicket::with(['user', 'worker'])->findOrFail($id);
|
||||
$ticket = SupportTicket::with(['user', 'worker', 'reason'])->findOrFail($id);
|
||||
|
||||
$replies = SupportTicketReply::where('support_ticket_id', $ticket->id)
|
||||
->with(['user', 'worker'])
|
||||
@ -79,6 +79,8 @@ public function show($id)
|
||||
'ticket_number' => $ticket->ticket_number,
|
||||
'subject' => $ticket->subject,
|
||||
'description' => $ticket->description,
|
||||
'reason_name' => $ticket->reason ? $ticket->reason->reason : null,
|
||||
'voice_note_url' => $ticket->voice_note_path ? asset('storage/' . $ticket->voice_note_path) : null,
|
||||
'status' => $ticket->status,
|
||||
'priority' => $ticket->priority,
|
||||
'user_type' => $ticket->user_id ? 'Employer' : 'Worker',
|
||||
|
||||
@ -269,6 +269,21 @@ public function sendMessage(Request $request, $id)
|
||||
$conv->touch();
|
||||
});
|
||||
|
||||
// Dispatch push notification to worker
|
||||
$worker = $conv->worker;
|
||||
if ($worker && $worker->fcm_token) {
|
||||
\App\Services\FCMService::sendPushNotification(
|
||||
$worker->fcm_token,
|
||||
"New Message from " . ($employer->name ?? "Employer"),
|
||||
$request->text ?: "Sent an attachment",
|
||||
[
|
||||
'conversation_id' => $conv->id,
|
||||
'sender_type' => 'employer',
|
||||
'sender_id' => $employer->id,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Message sent successfully.',
|
||||
|
||||
@ -69,6 +69,7 @@ private function formatWorker(Worker $w)
|
||||
'religion' => $w->religion,
|
||||
'languages' => $langs,
|
||||
'age' => $w->age,
|
||||
'gender' => $w->gender,
|
||||
'verified' => (bool)$w->verified,
|
||||
'preferred_job_type' => $preferredJobType,
|
||||
'bio' => $w->bio,
|
||||
@ -113,7 +114,14 @@ public function getWorkers(Request $request)
|
||||
|
||||
$workersArray = $formattedWorkers->toArray();
|
||||
|
||||
// Apply filters: skills, language/languages, nationality, preferred_location, job_type, live_in_out, in_country, visa_status
|
||||
// Apply filters: gender, skills, language/languages, nationality, preferred_location, job_type, live_in_out, in_country, visa_status
|
||||
if ($request->filled('gender')) {
|
||||
$gender = strtolower($request->gender);
|
||||
$workersArray = array_values(array_filter($workersArray, function ($c) use ($gender) {
|
||||
return isset($c['gender']) && strtolower($c['gender']) === $gender;
|
||||
}));
|
||||
}
|
||||
|
||||
if ($request->filled('nationality')) {
|
||||
$natInput = $request->nationality;
|
||||
$natsArray = is_array($natInput) ? $natInput : array_filter(array_map('trim', explode(',', $natInput)));
|
||||
@ -169,14 +177,24 @@ public function getWorkers(Request $request)
|
||||
$locsArray = is_array($prefLoc) ? $prefLoc : array_filter(array_map('trim', explode(',', $prefLoc)));
|
||||
$locsArray = array_map('strtolower', $locsArray);
|
||||
|
||||
$workersArray = array_values(array_filter($workersArray, function ($c) use ($locsArray) {
|
||||
$locationMapping = [
|
||||
'dubai' => ['dubai', 'marina', 'barsha', 'nahda', 'jumeirah', 'deira', 'downtown', 'silicon', 'sports', 'motor', 'jlt', 'jbr', 'meydan', 'ranches', 'bay', 'mirdif', 'quoz'],
|
||||
'abu dhabi' => ['abu dhabi', 'yas', 'khalifa', 'reem', 'saadiyat', 'raha', 'mussafah', 'zahiyah', 'karamah'],
|
||||
'oman' => ['oman', 'muscat', 'salalah', 'sohar', 'nizwa', 'sur', 'ibri', 'rustaq']
|
||||
];
|
||||
|
||||
$workersArray = array_values(array_filter($workersArray, function ($c) use ($locsArray, $locationMapping) {
|
||||
if (!isset($c['preferred_location'])) return false;
|
||||
$wLoc = strtolower($c['preferred_location']);
|
||||
foreach ($locsArray as $l) {
|
||||
if (str_contains($wLoc, $l)) {
|
||||
if ($l === 'all') return true;
|
||||
$allowedKeywords = $locationMapping[$l] ?? [$l];
|
||||
foreach ($allowedKeywords as $keyword) {
|
||||
if (str_contains($wLoc, $keyword)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}));
|
||||
}
|
||||
@ -374,6 +392,7 @@ public function getCandidates(Request $request)
|
||||
'live_in_out' => $w->live_in_out,
|
||||
'in_country' => (bool)$w->in_country,
|
||||
'visa_status' => $w->visa_status,
|
||||
'gender' => $w->gender,
|
||||
];
|
||||
})->filter()->values()->toArray();
|
||||
|
||||
@ -414,6 +433,7 @@ public function getCandidates(Request $request)
|
||||
'live_in_out' => $w->live_in_out,
|
||||
'in_country' => (bool)$w->in_country,
|
||||
'visa_status' => $w->visa_status,
|
||||
'gender' => $w->gender,
|
||||
];
|
||||
})->filter()->values()->toArray();
|
||||
|
||||
@ -425,7 +445,14 @@ public function getCandidates(Request $request)
|
||||
return $c && $c['status'] === 'Hired';
|
||||
}));
|
||||
|
||||
// Apply filters: skills, languages, nationality, availability, preferred_location, job_type, live_in_out, in_country, visa_status
|
||||
// Apply filters: gender, skills, languages, nationality, availability, preferred_location, job_type, live_in_out, in_country, visa_status
|
||||
if ($request->filled('gender')) {
|
||||
$gender = strtolower($request->gender);
|
||||
$mergedCandidates = array_values(array_filter($mergedCandidates, function ($c) use ($gender) {
|
||||
return isset($c['gender']) && strtolower($c['gender']) === $gender;
|
||||
}));
|
||||
}
|
||||
|
||||
if ($request->filled('nationality')) {
|
||||
$natInput = $request->nationality;
|
||||
$natsArray = is_array($natInput) ? $natInput : array_filter(array_map('trim', explode(',', $natInput)));
|
||||
@ -876,6 +903,7 @@ public function getWorkerDetail(Request $request, $id)
|
||||
'religion' => $w->religion,
|
||||
'languages' => $langs,
|
||||
'age' => $w->age,
|
||||
'gender' => $w->gender,
|
||||
'verified' => (bool)$w->verified,
|
||||
'preferred_job_type' => $preferredJobType,
|
||||
'bio' => $w->bio,
|
||||
|
||||
@ -27,12 +27,14 @@ public function register(Request $request)
|
||||
'mobile' => 'required|string|max:50|unique:sponsors,mobile',
|
||||
'password' => 'required|string|min:6',
|
||||
'license_file' => 'required|file|mimes:jpeg,png,jpg,pdf|max:10240',
|
||||
'emirates_id_file' => 'nullable|file|mimes:jpeg,png,jpg,pdf|max:10240',
|
||||
'organization_name' => 'nullable|string|max:255',
|
||||
'email' => 'nullable|email|max:255|unique:sponsors,email',
|
||||
'nationality' => 'nullable|string|max:100',
|
||||
'city' => 'nullable|string|max:100',
|
||||
'address' => 'nullable|string|max:255',
|
||||
'country_code' => 'nullable|string|max:10',
|
||||
'license_expiry' => 'nullable|date_format:Y-m-d',
|
||||
], [
|
||||
'mobile.unique' => 'This mobile number is already registered.',
|
||||
'email.unique' => 'This email address is already registered.',
|
||||
@ -55,7 +57,7 @@ public function register(Request $request)
|
||||
$email = "sponsor.{$mobileClean}." . rand(10, 99) . "@migrant.ae";
|
||||
}
|
||||
|
||||
// Store license file
|
||||
// Store files
|
||||
$destinationPath = public_path('uploads/licenses');
|
||||
if (!file_exists($destinationPath)) {
|
||||
mkdir($destinationPath, 0755, true);
|
||||
@ -66,9 +68,19 @@ public function register(Request $request)
|
||||
$licenseFile->move($destinationPath, $licenseFileName);
|
||||
$licensePath = 'uploads/licenses/' . $licenseFileName;
|
||||
|
||||
$emiratesIdPath = null;
|
||||
if ($request->hasFile('emirates_id_file')) {
|
||||
$emiratesIdFile = $request->file('emirates_id_file');
|
||||
$emiratesIdFileName = time() . '_emirates_id_' . preg_replace('/[^a-zA-Z0-9_.-]/', '', $emiratesIdFile->getClientOriginalName());
|
||||
$emiratesIdFile->move($destinationPath, $emiratesIdFileName);
|
||||
$emiratesIdPath = 'uploads/licenses/' . $emiratesIdFileName;
|
||||
}
|
||||
|
||||
$apiToken = Str::random(80);
|
||||
|
||||
$sponsor = DB::transaction(function () use ($request, $email, $licensePath, $apiToken) {
|
||||
$licenseExpiry = $request->license_expiry ?? now()->addYears(3)->toDateString();
|
||||
|
||||
$sponsor = DB::transaction(function () use ($request, $email, $licensePath, $emiratesIdPath, $apiToken, $licenseExpiry) {
|
||||
return Sponsor::create([
|
||||
'full_name' => $request->full_name,
|
||||
'organization_name' => $request->organization_name,
|
||||
@ -80,6 +92,8 @@ public function register(Request $request)
|
||||
'city' => $request->city,
|
||||
'address' => $request->address,
|
||||
'license_file' => $licensePath,
|
||||
'emirates_id_file' => $emiratesIdPath,
|
||||
'license_expiry' => $licenseExpiry,
|
||||
'status' => 'active',
|
||||
'is_verified' => false,
|
||||
'subscription_status' => 'none',
|
||||
|
||||
@ -50,6 +50,9 @@ public function getDashboard(Request $request)
|
||||
'is_verified' => $sponsor->is_verified,
|
||||
'status' => $sponsor->status,
|
||||
'license_file' => $sponsor->license_file ? asset($sponsor->license_file) : null,
|
||||
'emirates_id_file' => $sponsor->emirates_id_file ? asset($sponsor->emirates_id_file) : null,
|
||||
'license_expiry' => $sponsor->license_expiry ? $sponsor->license_expiry->toDateString() : null,
|
||||
'validity' => $sponsor->license_expiry ? ($sponsor->license_expiry->isFuture() ? 'Valid' : 'Expired') : 'Pending Review',
|
||||
'joined_at' => $sponsor->created_at->toIso8601String(),
|
||||
],
|
||||
'recent_charity_events' => $recentEvents,
|
||||
@ -279,6 +282,9 @@ public function getProfile(Request $request)
|
||||
'is_verified' => $sponsor->is_verified,
|
||||
'status' => $sponsor->status,
|
||||
'license_file' => $sponsor->license_file ? asset($sponsor->license_file) : null,
|
||||
'emirates_id_file' => $sponsor->emirates_id_file ? asset($sponsor->emirates_id_file) : null,
|
||||
'license_expiry' => $sponsor->license_expiry ? $sponsor->license_expiry->toDateString() : null,
|
||||
'validity' => $sponsor->license_expiry ? ($sponsor->license_expiry->isFuture() ? 'Valid' : 'Expired') : 'Pending Review',
|
||||
'joined_at' => $sponsor->created_at->toIso8601String(),
|
||||
]
|
||||
]
|
||||
|
||||
@ -22,7 +22,7 @@ public function getTicketsForWorker(Request $request)
|
||||
return response()->json(['success' => false, 'message' => 'Unauthorized.'], 401);
|
||||
}
|
||||
|
||||
$tickets = SupportTicket::where('worker_id', $worker->id)
|
||||
$tickets = SupportTicket::with('reason')->where('worker_id', $worker->id)
|
||||
->orderBy('created_at', 'desc')
|
||||
->get()
|
||||
->map(function ($ticket) {
|
||||
@ -31,6 +31,9 @@ public function getTicketsForWorker(Request $request)
|
||||
'ticket_number' => $ticket->ticket_number,
|
||||
'subject' => $ticket->subject,
|
||||
'description' => $ticket->description,
|
||||
'reason_id' => $ticket->reason_id,
|
||||
'reason_name' => $ticket->reason ? $ticket->reason->reason : null,
|
||||
'voice_note_url' => $ticket->voice_note_path ? asset('storage/' . $ticket->voice_note_path) : null,
|
||||
'status' => $ticket->status,
|
||||
'priority' => $ticket->priority,
|
||||
'created_at' => $ticket->created_at->toIso8601String(),
|
||||
@ -54,6 +57,8 @@ public function createTicketFromWorker(Request $request)
|
||||
'subject' => 'required|string|max:255',
|
||||
'description' => 'required|string',
|
||||
'priority' => 'nullable|string|in:low,medium,high',
|
||||
'reason_id' => 'nullable|exists:report_reasons,id',
|
||||
'voice_note' => 'nullable|file|mimes:mp3,wav,m4a,ogg,webm,aac,3gp,amr|max:10240', // 10MB limit
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
@ -64,15 +69,24 @@ public function createTicketFromWorker(Request $request)
|
||||
], 422);
|
||||
}
|
||||
|
||||
$voiceNotePath = null;
|
||||
if ($request->hasFile('voice_note')) {
|
||||
$voiceNotePath = $request->file('voice_note')->store('support_voice_notes', 'public');
|
||||
}
|
||||
|
||||
$ticket = SupportTicket::create([
|
||||
'ticket_number' => 'TKT-' . rand(100000, 999999),
|
||||
'worker_id' => $worker->id,
|
||||
'reason_id' => $request->reason_id,
|
||||
'subject' => $request->subject,
|
||||
'description' => $request->description,
|
||||
'voice_note_path' => $voiceNotePath,
|
||||
'priority' => $request->priority ?? 'medium',
|
||||
'status' => 'open',
|
||||
]);
|
||||
|
||||
$ticket->load('reason');
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Ticket created successfully.',
|
||||
@ -81,6 +95,9 @@ public function createTicketFromWorker(Request $request)
|
||||
'ticket_number' => $ticket->ticket_number,
|
||||
'subject' => $ticket->subject,
|
||||
'description' => $ticket->description,
|
||||
'reason_id' => $ticket->reason_id,
|
||||
'reason_name' => $ticket->reason ? $ticket->reason->reason : null,
|
||||
'voice_note_url' => $ticket->voice_note_path ? asset('storage/' . $ticket->voice_note_path) : null,
|
||||
'status' => $ticket->status,
|
||||
'priority' => $ticket->priority,
|
||||
'created_at' => $ticket->created_at->toIso8601String(),
|
||||
@ -280,7 +297,7 @@ public function getTicketDetail(Request $request, $id)
|
||||
return response()->json(['success' => false, 'message' => 'Unauthorized.'], 401);
|
||||
}
|
||||
|
||||
$query = SupportTicket::where('id', $id);
|
||||
$query = SupportTicket::with('reason')->where('id', $id);
|
||||
if ($worker) {
|
||||
$query->where('worker_id', $worker->id);
|
||||
} else {
|
||||
@ -314,6 +331,9 @@ public function getTicketDetail(Request $request, $id)
|
||||
'ticket_number' => $ticket->ticket_number,
|
||||
'subject' => $ticket->subject,
|
||||
'description' => $ticket->description,
|
||||
'reason_id' => $ticket->reason_id,
|
||||
'reason_name' => $ticket->reason ? $ticket->reason->reason : null,
|
||||
'voice_note_url' => $ticket->voice_note_path ? asset('storage/' . $ticket->voice_note_path) : null,
|
||||
'status' => $ticket->status,
|
||||
'priority' => $ticket->priority,
|
||||
'created_at' => $ticket->created_at->toIso8601String(),
|
||||
|
||||
@ -762,7 +762,10 @@ public function nationalities(Request $request)
|
||||
|
||||
// Apply Pagination
|
||||
$page = (int) $request->input('page', 1);
|
||||
$perPage = (int) $request->input('per_page', 15);
|
||||
$perPage = (int) $request->input('per_page', 500);
|
||||
if ($perPage === 15) {
|
||||
$perPage = 500;
|
||||
}
|
||||
$total = count($list);
|
||||
$offset = ($page - 1) * $perPage;
|
||||
$paginatedList = array_slice($list, $offset, $perPage);
|
||||
@ -780,4 +783,211 @@ public function nationalities(Request $request)
|
||||
],
|
||||
], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of preferred locations translated to requested language.
|
||||
* Supports en, hindi (hi), sawahi (sw), taglo (tl), tamil (ta).
|
||||
*/
|
||||
public function preferredLocations(Request $request)
|
||||
{
|
||||
$lang = strtolower($request->input('lang') ?? $request->header('Accept-Language') ?? 'en');
|
||||
if (str_contains($lang, ',')) {
|
||||
$lang = explode(',', $lang)[0];
|
||||
}
|
||||
if (str_contains($lang, '-')) {
|
||||
$lang = explode('-', $lang)[0];
|
||||
}
|
||||
|
||||
$langMap = [
|
||||
'hindi' => 'hi',
|
||||
'sawahi' => 'sw',
|
||||
'swahili' => 'sw',
|
||||
'taglo' => 'tl',
|
||||
'tagalog' => 'tl',
|
||||
'tamil' => 'ta',
|
||||
];
|
||||
|
||||
if (isset($langMap[$lang])) {
|
||||
$lang = $langMap[$lang];
|
||||
}
|
||||
|
||||
if (!in_array($lang, ['en', 'hi', 'sw', 'tl', 'ta'])) {
|
||||
$lang = 'en';
|
||||
}
|
||||
|
||||
$rawLocations = [
|
||||
['code' => 'dubai', 'name' => 'Dubai', 'hi' => 'दुबई', 'sw' => 'Dubai', 'tl' => 'Dubai', 'ta' => 'துபாய்'],
|
||||
['code' => 'abu dhabi', 'name' => 'Abu Dhabi', 'hi' => 'अबू धाबी', 'sw' => 'Abu Dhabi', 'tl' => 'Abu Dhabi', 'ta' => 'அபுதாபி'],
|
||||
['code' => 'oman', 'name' => 'Oman', 'hi' => 'ओमान', 'sw' => 'Omani', 'tl' => 'Oman', 'ta' => 'ஓமன்']
|
||||
];
|
||||
|
||||
$list = [];
|
||||
foreach ($rawLocations as $item) {
|
||||
$list[] = [
|
||||
'code' => $item['code'],
|
||||
'name' => $item[$lang] ?? $item['name'],
|
||||
];
|
||||
}
|
||||
|
||||
$search = strtolower($request->input('search') ?? $request->input('q') ?? '');
|
||||
if ($search !== '') {
|
||||
$list = array_filter($list, function ($item) use ($search) {
|
||||
return stripos($item['name'], $search) !== false || stripos($item['code'], $search) !== false;
|
||||
});
|
||||
$list = array_values($list);
|
||||
}
|
||||
|
||||
// Apply Pagination
|
||||
$page = (int) $request->input('page', 1);
|
||||
$perPage = (int) $request->input('per_page', 15);
|
||||
$total = count($list);
|
||||
$offset = ($page - 1) * $perPage;
|
||||
$paginatedList = array_slice($list, $offset, $perPage);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'preferred_locations' => $paginatedList,
|
||||
'pagination' => [
|
||||
'total' => $total,
|
||||
'per_page' => $perPage,
|
||||
'current_page' => $page,
|
||||
'last_page' => max(1, (int) ceil($total / $perPage)),
|
||||
]
|
||||
],
|
||||
], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of areas for a specific preferred location translated to requested language.
|
||||
* Supports en, hindi (hi), sawahi (sw), taglo (tl), tamil (ta).
|
||||
*/
|
||||
public function preferredLocationAreas(Request $request, $location = null)
|
||||
{
|
||||
// Get location from path or query parameter
|
||||
$loc = strtolower(trim($location ?? $request->input('location') ?? $request->input('city') ?? ''));
|
||||
|
||||
// Handle URL-friendly formats like abu-dhabi, abu_dhabi, abu dhabi
|
||||
$loc = str_replace(['_', '-'], ' ', $loc);
|
||||
|
||||
if (empty($loc)) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Please provide a valid location (e.g., dubai, abu dhabi, oman).'
|
||||
], 400);
|
||||
}
|
||||
|
||||
$lang = strtolower($request->input('lang') ?? $request->header('Accept-Language') ?? 'en');
|
||||
if (str_contains($lang, ',')) {
|
||||
$lang = explode(',', $lang)[0];
|
||||
}
|
||||
if (str_contains($lang, '-')) {
|
||||
$lang = explode('-', $lang)[0];
|
||||
}
|
||||
|
||||
$langMap = [
|
||||
'hindi' => 'hi',
|
||||
'sawahi' => 'sw',
|
||||
'swahili' => 'sw',
|
||||
'taglo' => 'tl',
|
||||
'tagalog' => 'tl',
|
||||
'tamil' => 'ta',
|
||||
];
|
||||
|
||||
if (isset($langMap[$lang])) {
|
||||
$lang = $langMap[$lang];
|
||||
}
|
||||
|
||||
if (!in_array($lang, ['en', 'hi', 'sw', 'tl', 'ta'])) {
|
||||
$lang = 'en';
|
||||
}
|
||||
|
||||
$areasMapping = [
|
||||
'dubai' => [
|
||||
['code' => 'dubai', 'name' => 'Dubai', 'hi' => 'दुबई', 'sw' => 'Dubai', 'tl' => 'Dubai', 'ta' => 'துபாய்'],
|
||||
['code' => 'marina', 'name' => 'Dubai Marina', 'hi' => 'दुबई मरीना', 'sw' => 'Dubai Marina', 'tl' => 'Dubai Marina', 'ta' => 'துபாய் மெரினா'],
|
||||
['code' => 'barsha', 'name' => 'Al Barsha', 'hi' => 'अल बरशा', 'sw' => 'Al Barsha', 'tl' => 'Al Barsha', 'ta' => 'அல் பர்ஷா'],
|
||||
['code' => 'nahda', 'name' => 'Al Nahda', 'hi' => 'अल नहदा', 'sw' => 'Al Nahda', 'tl' => 'Al Nahda', 'ta' => 'அல் நஹ்தா'],
|
||||
['code' => 'jumeirah', 'name' => 'Jumeirah', 'hi' => 'जुमेराह', 'sw' => 'Jumeirah', 'tl' => 'Jumeirah', 'ta' => 'ஜுமேரா'],
|
||||
['code' => 'deira', 'name' => 'Deira', 'hi' => 'देइरा', 'sw' => 'Deira', 'tl' => 'Deira', 'ta' => 'தேரா'],
|
||||
['code' => 'downtown', 'name' => 'Downtown Dubai', 'hi' => 'डाउनटाउन दुबई', 'sw' => 'Downtown Dubai', 'tl' => 'Downtown Dubai', 'ta' => 'டவுன்டவுன் துபாய்'],
|
||||
['code' => 'silicon', 'name' => 'Silicon Oasis', 'hi' => 'सिलिकॉन ओएसिस', 'sw' => 'Silicon Oasis', 'tl' => 'Silicon Oasis', 'ta' => 'சிலிக்கான் ஓசிஸ்'],
|
||||
['code' => 'sports', 'name' => 'Sports City', 'hi' => 'स्पोर्ट्स सिटी', 'sw' => 'Sports City', 'tl' => 'Sports City', 'ta' => 'ஸ்போர்ட்ஸ் சிட்டி'],
|
||||
['code' => 'motor', 'name' => 'Motor City', 'hi' => 'मोटर सिटी', 'sw' => 'Motor City', 'tl' => 'Motor City', 'ta' => 'மோட்டார் சிட்டி'],
|
||||
['code' => 'jlt', 'name' => 'JLT', 'hi' => 'JLT', 'sw' => 'JLT', 'tl' => 'JLT', 'ta' => 'ஜே.எல்.டி'],
|
||||
['code' => 'jbr', 'name' => 'JBR', 'hi' => 'JBR', 'sw' => 'JBR', 'tl' => 'JBR', 'ta' => 'ஜே.பி.ஆர்'],
|
||||
['code' => 'meydan', 'name' => 'Meydan', 'hi' => 'मेदान', 'sw' => 'Meydan', 'tl' => 'Meydan', 'ta' => 'மெய்தான்'],
|
||||
['code' => 'ranches', 'name' => 'Arabian Ranches', 'hi' => 'अरेबियन रैंचेस', 'sw' => 'Arabian Ranches', 'tl' => 'Arabian Ranches', 'ta' => 'அரேபியன் ரான்சஸ்'],
|
||||
['code' => 'bay', 'name' => 'Business Bay', 'hi' => 'बिजनेस बे', 'sw' => 'Business Bay', 'tl' => 'Business Bay', 'ta' => 'பிசினஸ் பே'],
|
||||
['code' => 'mirdif', 'name' => 'Mirdif', 'hi' => 'मिर्डिफ', 'sw' => 'Mirdif', 'tl' => 'Mirdif', 'ta' => 'மிர்திஃப்'],
|
||||
['code' => 'quoz', 'name' => 'Al Quoz', 'hi' => 'अल क्वोज़', 'sw' => 'Al Quoz', 'tl' => 'Al Quoz', 'ta' => 'அல் கூஸ்'],
|
||||
],
|
||||
'abu dhabi' => [
|
||||
['code' => 'abu dhabi', 'name' => 'Abu Dhabi City', 'hi' => 'अबू धाबी शहर', 'sw' => 'Abu Dhabi City', 'tl' => 'Abu Dhabi City', 'ta' => 'அபுதாபி நகரம்'],
|
||||
['code' => 'yas', 'name' => 'Yas Island', 'hi' => 'यास द्वीप', 'sw' => 'Yas Island', 'tl' => 'Yas Island', 'ta' => 'யாஸ் தீவு'],
|
||||
['code' => 'khalifa', 'name' => 'Khalifa City', 'hi' => 'खलीफा शहर', 'sw' => 'Khalifa City', 'tl' => 'Khalifa City', 'ta' => 'கலீஃபா நகரம்'],
|
||||
['code' => 'reem', 'name' => 'Reem Island', 'hi' => 'रीम द्वीप', 'sw' => 'Reem Island', 'tl' => 'Reem Island', 'ta' => 'ரீம் தீவு'],
|
||||
['code' => 'saadiyat', 'name' => 'Saadiyat Island', 'hi' => 'सादियात द्वीप', 'sw' => 'Saadiyat Island', 'tl' => 'Saadiyat Island', 'ta' => 'சாதியாத் தீவு'],
|
||||
['code' => 'raha', 'name' => 'Al Raha', 'hi' => 'अल राहा', 'sw' => 'Al Raha', 'tl' => 'Al Raha', 'ta' => 'அல் ராஹா'],
|
||||
['code' => 'mussafah', 'name' => 'Mussafah', 'hi' => 'मुसफ्फह', 'sw' => 'Mussafah', 'tl' => 'Mussafah', 'ta' => 'முஸாஃபா'],
|
||||
['code' => 'zahiyah', 'name' => 'Al Zahiyah', 'hi' => 'अल ज़ाहिया', 'sw' => 'Al Zahiyah', 'tl' => 'Al Zahiyah', 'ta' => 'அல் ஜாஹியா'],
|
||||
['code' => 'karamah', 'name' => 'Al Karamah', 'hi' => 'अल करमा', 'sw' => 'Al Karamah', 'tl' => 'Al Karamah', 'ta' => 'அல் கராமா'],
|
||||
],
|
||||
'oman' => [
|
||||
['code' => 'oman', 'name' => 'Oman', 'hi' => 'ओमान', 'sw' => 'Omani', 'tl' => 'Oman', 'ta' => 'ஓமன்'],
|
||||
['code' => 'muscat', 'name' => 'Muscat', 'hi' => 'मस्कट', 'sw' => 'Muscat', 'tl' => 'Muscat', 'ta' => 'மस्कट'],
|
||||
['code' => 'salalah', 'name' => 'Salalah', 'hi' => 'सलालाह', 'sw' => 'Salalah', 'tl' => 'Salalah', 'ta' => 'சலாலா'],
|
||||
['code' => 'sohar', 'name' => 'Sohar', 'hi' => 'सोहर', 'sw' => 'Sohar', 'tl' => 'Sohar', 'ta' => 'சோஹார்'],
|
||||
['code' => 'nizwa', 'name' => 'Nizwa', 'hi' => 'निज़वा', 'sw' => 'Nizwa', 'tl' => 'Nizwa', 'ta' => 'நிஸ்வா'],
|
||||
['code' => 'sur', 'name' => 'Sur', 'hi' => 'सुर', 'sw' => 'Sur', 'tl' => 'Sur', 'ta' => 'சூர்'],
|
||||
['code' => 'ibri', 'name' => 'Ibri', 'hi' => 'इबरी', 'sw' => 'Ibri', 'tl' => 'Ibri', 'ta' => 'இப்ரி'],
|
||||
['code' => 'rustaq', 'name' => 'Rustaq', 'hi' => 'रुस्तक़', 'sw' => 'Rustaq', 'tl' => 'Rustaq', 'ta' => 'रुस्तक़'],
|
||||
]
|
||||
];
|
||||
|
||||
if (!isset($areasMapping[$loc])) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Areas for the specified location were not found. Supported locations: dubai, abu dhabi, oman.'
|
||||
], 404);
|
||||
}
|
||||
|
||||
$rawAreas = $areasMapping[$loc];
|
||||
$list = [];
|
||||
foreach ($rawAreas as $item) {
|
||||
$list[] = [
|
||||
'code' => $item['code'],
|
||||
'name' => $item[$lang] ?? $item['name'],
|
||||
];
|
||||
}
|
||||
|
||||
$search = strtolower($request->input('search') ?? $request->input('q') ?? '');
|
||||
if ($search !== '') {
|
||||
$list = array_filter($list, function ($item) use ($search) {
|
||||
return stripos($item['name'], $search) !== false || stripos($item['code'], $search) !== false;
|
||||
});
|
||||
$list = array_values($list);
|
||||
}
|
||||
|
||||
// Apply Pagination
|
||||
$page = (int) $request->input('page', 1);
|
||||
$perPage = (int) $request->input('per_page', 15);
|
||||
$total = count($list);
|
||||
$offset = ($page - 1) * $perPage;
|
||||
$paginatedList = array_slice($list, $offset, $perPage);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'location' => $loc,
|
||||
'areas' => $paginatedList,
|
||||
'pagination' => [
|
||||
'total' => $total,
|
||||
'per_page' => $perPage,
|
||||
'current_page' => $page,
|
||||
'last_page' => max(1, (int) ceil($total / $perPage)),
|
||||
]
|
||||
],
|
||||
], 200);
|
||||
}
|
||||
}
|
||||
|
||||
@ -71,6 +71,7 @@ public function updateProfile(Request $request)
|
||||
'country' => 'nullable|string|max:100',
|
||||
'city' => 'nullable|string|max:100',
|
||||
'area' => 'nullable|string|max:100',
|
||||
'fcm_token' => 'nullable|string|max:255',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
@ -102,7 +103,8 @@ public function updateProfile(Request $request)
|
||||
'preferred_location',
|
||||
'country',
|
||||
'city',
|
||||
'area'
|
||||
'area',
|
||||
'fcm_token'
|
||||
]);
|
||||
|
||||
// Filter out null inputs if we want to support partial updates
|
||||
|
||||
@ -124,14 +124,24 @@ public function index(Request $request)
|
||||
$locsArray = is_array($prefLoc) ? $prefLoc : array_filter(array_map('trim', explode(',', $prefLoc)));
|
||||
$locsArray = array_map('strtolower', $locsArray);
|
||||
|
||||
$mergedCandidates = array_values(array_filter($mergedCandidates, function ($c) use ($locsArray) {
|
||||
$locationMapping = [
|
||||
'dubai' => ['dubai', 'marina', 'barsha', 'nahda', 'jumeirah', 'deira', 'downtown', 'silicon', 'sports', 'motor', 'jlt', 'jbr', 'meydan', 'ranches', 'bay', 'mirdif', 'quoz'],
|
||||
'abu dhabi' => ['abu dhabi', 'yas', 'khalifa', 'reem', 'saadiyat', 'raha', 'mussafah', 'zahiyah', 'karamah'],
|
||||
'oman' => ['oman', 'muscat', 'salalah', 'sohar', 'nizwa', 'sur', 'ibri', 'rustaq']
|
||||
];
|
||||
|
||||
$mergedCandidates = array_values(array_filter($mergedCandidates, function ($c) use ($locsArray, $locationMapping) {
|
||||
if (!isset($c['preferred_location'])) return false;
|
||||
$wLoc = strtolower($c['preferred_location']);
|
||||
foreach ($locsArray as $l) {
|
||||
if (str_contains($wLoc, $l)) {
|
||||
if ($l === 'all') return true;
|
||||
$allowedKeywords = $locationMapping[$l] ?? [$l];
|
||||
foreach ($allowedKeywords as $keyword) {
|
||||
if (str_contains($wLoc, $keyword)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}));
|
||||
}
|
||||
@ -189,6 +199,13 @@ public function index(Request $request)
|
||||
}));
|
||||
}
|
||||
|
||||
if ($request->filled('gender')) {
|
||||
$gender = strtolower($request->gender);
|
||||
$mergedCandidates = array_values(array_filter($mergedCandidates, function ($c) use ($gender) {
|
||||
return isset($c['gender']) && strtolower($c['gender']) === $gender;
|
||||
}));
|
||||
}
|
||||
|
||||
if ($request->has('in_country')) {
|
||||
$inCountryVal = $request->input('in_country');
|
||||
if ($inCountryVal !== null && $inCountryVal !== '') {
|
||||
@ -250,8 +267,13 @@ public function index(Request $request)
|
||||
}));
|
||||
}
|
||||
|
||||
$nationalitiesResponse = app(\App\Http\Controllers\Api\WorkerAuthController::class)->nationalities(new \Illuminate\Http\Request(['per_page' => 500]));
|
||||
$nationalitiesData = json_decode($nationalitiesResponse->getContent(), true);
|
||||
$allNationalities = collect($nationalitiesData['data']['nationalities'] ?? [])->pluck('name')->filter()->toArray();
|
||||
|
||||
return Inertia::render('Employer/SelectedCandidates', [
|
||||
'selectedWorkers' => $mergedCandidates,
|
||||
'allNationalities' => $allNationalities,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@ -85,6 +85,7 @@ public function register(Request $request)
|
||||
'name' => 'required|string|max:255',
|
||||
'email' => 'required|string|email|max:255',
|
||||
'phone' => 'required|string|regex:/^[0-9]{7,15}$/',
|
||||
'emirates_id_file' => 'nullable|file|mimes:jpeg,png,jpg,pdf|max:10240',
|
||||
], [
|
||||
'phone.regex' => 'The mobile number must be between 7 and 15 digits without any country code (e.g. 501234567).',
|
||||
]);
|
||||
@ -98,6 +99,19 @@ public function register(Request $request)
|
||||
], 409);
|
||||
}
|
||||
|
||||
// Store file if provided
|
||||
$emiratesIdPath = null;
|
||||
if ($request->hasFile('emirates_id_file')) {
|
||||
$destinationPath = public_path('uploads/licenses');
|
||||
if (!file_exists($destinationPath)) {
|
||||
mkdir($destinationPath, 0755, true);
|
||||
}
|
||||
$emiratesIdFile = $request->file('emirates_id_file');
|
||||
$emiratesIdFileName = time() . '_emirates_id_' . preg_replace('/[^a-zA-Z0-9_.-]/', '', $emiratesIdFile->getClientOriginalName());
|
||||
$emiratesIdFile->move($destinationPath, $emiratesIdFileName);
|
||||
$emiratesIdPath = 'uploads/licenses/' . $emiratesIdFileName;
|
||||
}
|
||||
|
||||
// 3. Generate 6-digit OTP, hash & store with 10-min expiry
|
||||
$otp = (string) mt_rand(100000, 999999);
|
||||
$hashedOtp = Hash::make($otp);
|
||||
@ -108,6 +122,7 @@ public function register(Request $request)
|
||||
'name' => $request->name,
|
||||
'email' => $request->email,
|
||||
'phone' => $request->phone,
|
||||
'emirates_id_file' => $emiratesIdPath,
|
||||
],
|
||||
'employer_otp' => [
|
||||
'hash' => $hashedOtp,
|
||||
@ -172,8 +187,8 @@ public function verifyEmail(Request $request)
|
||||
]);
|
||||
}
|
||||
|
||||
// Hash comparison (allow 000000 or 111111 in local environment for automated testing)
|
||||
$isLocalDebug = app()->environment('local') && ($request->otp === '000000' || $request->otp === '111111');
|
||||
// Hash comparison (allow 000000 or 111111 in local/testing environment for automated testing)
|
||||
$isLocalDebug = (app()->environment('local') || app()->environment('testing')) && ($request->otp === '000000' || $request->otp === '111111');
|
||||
if (!$isLocalDebug && !Hash::check($request->otp, $otpSession['hash'])) {
|
||||
$otpSession['attempts']++;
|
||||
session(['employer_otp' => $otpSession]);
|
||||
@ -374,6 +389,7 @@ public function createPassword(Request $request)
|
||||
'otp_verified_at' => now(),
|
||||
'status' => 'active',
|
||||
'last_login_at' => now(),
|
||||
'emirates_id_file' => $pending['emirates_id_file'] ?? null,
|
||||
]);
|
||||
|
||||
// Create active subscription in database
|
||||
|
||||
@ -235,6 +235,21 @@ public function send(Request $request, $id)
|
||||
// Touch conversation updated_at for sorting
|
||||
$conv->touch();
|
||||
|
||||
// Dispatch push notification to worker
|
||||
$worker = $conv->worker;
|
||||
if ($worker && $worker->fcm_token) {
|
||||
\App\Services\FCMService::sendPushNotification(
|
||||
$worker->fcm_token,
|
||||
"New Message from " . ($user->name ?? "Employer"),
|
||||
$request->text ?: "Sent an attachment",
|
||||
[
|
||||
'conversation_id' => $conv->id,
|
||||
'sender_type' => 'employer',
|
||||
'sender_id' => $user->id,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
|
||||
@ -123,14 +123,24 @@ public function index(Request $request)
|
||||
$locsArray = is_array($prefLoc) ? $prefLoc : array_filter(array_map('trim', explode(',', $prefLoc)));
|
||||
$locsArray = array_map('strtolower', $locsArray);
|
||||
|
||||
$workers = array_values(array_filter($workers, function ($c) use ($locsArray) {
|
||||
$locationMapping = [
|
||||
'dubai' => ['dubai', 'marina', 'barsha', 'nahda', 'jumeirah', 'deira', 'downtown', 'silicon', 'sports', 'motor', 'jlt', 'jbr', 'meydan', 'ranches', 'bay', 'mirdif', 'quoz'],
|
||||
'abu dhabi' => ['abu dhabi', 'yas', 'khalifa', 'reem', 'saadiyat', 'raha', 'mussafah', 'zahiyah', 'karamah'],
|
||||
'oman' => ['oman', 'muscat', 'salalah', 'sohar', 'nizwa', 'sur', 'ibri', 'rustaq']
|
||||
];
|
||||
|
||||
$workers = array_values(array_filter($workers, function ($c) use ($locsArray, $locationMapping) {
|
||||
if (!isset($c['preferred_location'])) return false;
|
||||
$wLoc = strtolower($c['preferred_location']);
|
||||
foreach ($locsArray as $l) {
|
||||
if (str_contains($wLoc, $l)) {
|
||||
if ($l === 'all') return true;
|
||||
$allowedKeywords = $locationMapping[$l] ?? [$l];
|
||||
foreach ($allowedKeywords as $keyword) {
|
||||
if (str_contains($wLoc, $keyword)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}));
|
||||
}
|
||||
@ -187,7 +197,12 @@ public function index(Request $request)
|
||||
return false;
|
||||
}));
|
||||
}
|
||||
|
||||
if ($request->filled('gender')) {
|
||||
$gender = strtolower($request->gender);
|
||||
$workers = array_values(array_filter($workers, function ($c) use ($gender) {
|
||||
return isset($c['gender']) && strtolower($c['gender']) === $gender;
|
||||
}));
|
||||
}
|
||||
if ($request->has('in_country')) {
|
||||
$inCountryVal = $request->input('in_country');
|
||||
if ($inCountryVal !== null && $inCountryVal !== '') {
|
||||
@ -253,7 +268,9 @@ public function index(Request $request)
|
||||
$shortlistedIds = Shortlist::where('employer_id', $employerId)->pluck('worker_id')->toArray();
|
||||
|
||||
$dbCategories = WorkerCategory::pluck('name')->toArray();
|
||||
$dbNationalities = Worker::distinct()->pluck('nationality')->toArray();
|
||||
$nationalitiesResponse = app(\App\Http\Controllers\Api\WorkerAuthController::class)->nationalities(new \Illuminate\Http\Request(['per_page' => 500]));
|
||||
$nationalitiesData = json_decode($nationalitiesResponse->getContent(), true);
|
||||
$dbNationalities = collect($nationalitiesData['data']['nationalities'] ?? [])->pluck('name')->filter()->toArray();
|
||||
|
||||
$filtersMetadata = [
|
||||
'categories' => array_merge(['All Categories'], $dbCategories),
|
||||
|
||||
@ -32,6 +32,8 @@ class Sponsor extends Authenticatable
|
||||
'last_login_at',
|
||||
'api_token',
|
||||
'license_file',
|
||||
'emirates_id_file',
|
||||
'license_expiry',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
@ -45,6 +47,7 @@ class Sponsor extends Authenticatable
|
||||
'subscription_start_date' => 'datetime',
|
||||
'subscription_end_date' => 'datetime',
|
||||
'last_login_at' => 'datetime',
|
||||
'license_expiry' => 'datetime',
|
||||
];
|
||||
|
||||
public function hasActiveSubscription(): bool
|
||||
|
||||
@ -16,6 +16,7 @@ class SupportTicket extends Model
|
||||
'worker_id',
|
||||
'subject',
|
||||
'description',
|
||||
'voice_note_path',
|
||||
'status',
|
||||
'priority',
|
||||
];
|
||||
|
||||
@ -36,6 +36,7 @@ class Worker extends Model
|
||||
'in_country',
|
||||
'visa_status',
|
||||
'preferred_job_type',
|
||||
'fcm_token',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
|
||||
155
app/Services/FCMService.php
Normal file
155
app/Services/FCMService.php
Normal file
@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class FCMService
|
||||
{
|
||||
/**
|
||||
* Generate OAuth2 Access Token for Firebase Messaging.
|
||||
* Caches the token for 50 minutes to avoid overhead.
|
||||
*
|
||||
* @return string|null
|
||||
*/
|
||||
public static function getAccessToken()
|
||||
{
|
||||
return Cache::remember('fcm_access_token', 3000, function () {
|
||||
try {
|
||||
$credentialsPath = base_path('migrant-fe5a7-firebase-adminsdk-fbsvc-f8b365ecd0.json');
|
||||
|
||||
if (!file_exists($credentialsPath)) {
|
||||
Log::error("FCM Service Account JSON not found at: {$credentialsPath}");
|
||||
return null;
|
||||
}
|
||||
|
||||
$credentials = json_decode(file_get_contents($credentialsPath), true);
|
||||
if (!$credentials) {
|
||||
Log::error("FCM Service Account JSON is invalid or empty.");
|
||||
return null;
|
||||
}
|
||||
|
||||
$privateKey = $credentials['private_key'];
|
||||
$clientEmail = $credentials['client_email'];
|
||||
|
||||
$header = json_encode(['alg' => 'RS256', 'typ' => 'JWT']);
|
||||
|
||||
$now = time();
|
||||
$payload = json_encode([
|
||||
'iss' => $clientEmail,
|
||||
'scope' => 'https://www.googleapis.com/auth/firebase.messaging',
|
||||
'aud' => 'https://oauth2.googleapis.com/token',
|
||||
'exp' => $now + 3600,
|
||||
'iat' => $now,
|
||||
]);
|
||||
|
||||
$base64UrlHeader = self::base64UrlEncode($header);
|
||||
$base64UrlPayload = self::base64UrlEncode($payload);
|
||||
|
||||
$signatureInput = $base64UrlHeader . '.' . $base64UrlPayload;
|
||||
$signature = '';
|
||||
|
||||
if (!openssl_sign($signatureInput, $signature, $privateKey, OPENSSL_ALGO_SHA256)) {
|
||||
Log::error("Failed to sign JWT with private key using openssl_sign.");
|
||||
return null;
|
||||
}
|
||||
|
||||
$base64UrlSignature = self::base64UrlEncode($signature);
|
||||
$jwtToken = $signatureInput . '.' . $base64UrlSignature;
|
||||
|
||||
$response = Http::asForm()->post('https://oauth2.googleapis.com/token', [
|
||||
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
||||
'assertion' => $jwtToken,
|
||||
]);
|
||||
|
||||
if ($response->failed()) {
|
||||
Log::error("FCM OAuth2 request failed: " . $response->body());
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
return $data['access_token'] ?? null;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error("FCM Access Token Generation Error: " . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a Push Notification to a specific FCM token.
|
||||
*
|
||||
* @param string $token
|
||||
* @param string $title
|
||||
* @param string $body
|
||||
* @param array $data
|
||||
* @return bool
|
||||
*/
|
||||
public static function sendPushNotification($token, $title, $body, array $data = [])
|
||||
{
|
||||
if (empty($token)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$accessToken = self::getAccessToken();
|
||||
if (!$accessToken) {
|
||||
Log::error("Cannot send push notification: FCM Access Token is null.");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Load credentials to fetch the correct project_id
|
||||
$credentialsPath = base_path('migrant-fe5a7-firebase-adminsdk-fbsvc-f8b365ecd0.json');
|
||||
$credentials = json_decode(file_get_contents($credentialsPath), true);
|
||||
$projectId = $credentials['project_id'] ?? 'migrant-fe5a7';
|
||||
|
||||
// Convert all data array values to strings as required by FCM v1
|
||||
$formattedData = [];
|
||||
foreach ($data as $key => $value) {
|
||||
$formattedData[(string)$key] = (string)$value;
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'message' => [
|
||||
'token' => $token,
|
||||
'notification' => [
|
||||
'title' => $title,
|
||||
'body' => $body,
|
||||
],
|
||||
]
|
||||
];
|
||||
|
||||
if (!empty($formattedData)) {
|
||||
$payload['message']['data'] = $formattedData;
|
||||
}
|
||||
|
||||
$response = Http::withToken($accessToken)
|
||||
->post("https://fcm.googleapis.com/v1/projects/{$projectId}/messages:send", $payload);
|
||||
|
||||
if ($response->failed()) {
|
||||
Log::error("FCM Push Notification failed: " . $response->body());
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error("FCM Notification Dispatch Failure: " . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Base64 Url Encode helper.
|
||||
*
|
||||
* @param string $data
|
||||
* @return string
|
||||
*/
|
||||
private static function base64UrlEncode($data)
|
||||
{
|
||||
return str_replace(['+', '/', '='], ['-', '_', ''], base64_encode($data));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,30 @@
|
||||
<?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('sponsors', function (Blueprint $table) {
|
||||
if (!Schema::hasColumn('sponsors', 'emirates_id_file')) {
|
||||
$table->string('emirates_id_file')->nullable()->after('license_file');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('sponsors', function (Blueprint $table) {
|
||||
$table->dropColumn('emirates_id_file');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,30 @@
|
||||
<?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('sponsors', function (Blueprint $table) {
|
||||
if (!Schema::hasColumn('sponsors', 'license_expiry')) {
|
||||
$table->date('license_expiry')->nullable()->after('license_file');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('sponsors', function (Blueprint $table) {
|
||||
$table->dropColumn('license_expiry');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,28 @@
|
||||
<?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('fcm_token')->nullable()->after('api_token');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('workers', function (Blueprint $table) {
|
||||
$table->dropColumn('fcm_token');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,28 @@
|
||||
<?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('support_tickets', function (Blueprint $table) {
|
||||
$table->string('voice_note_path')->nullable()->after('description');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('support_tickets', function (Blueprint $table) {
|
||||
$table->dropColumn('voice_note_path');
|
||||
});
|
||||
}
|
||||
};
|
||||
13
migrant-fe5a7-firebase-adminsdk-fbsvc-f8b365ecd0.json
Normal file
13
migrant-fe5a7-firebase-adminsdk-fbsvc-f8b365ecd0.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"type": "service_account",
|
||||
"project_id": "migrant-fe5a7",
|
||||
"private_key_id": "f8b365ecd04e9a5382841a837826ec6f3a4ddab0",
|
||||
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDoXYB0utGboyNW\nQJcvU3ARTwYGXD4qBNNgWY7z3mJkFdbiaeGp0D1esTuucYb8h2ldS3CURREAZXVG\nS55y26kXTPi4leBnEtEy7h2Mdp03EKQg3rJhwKL+u9KjO6s15Pa85umVNkIW1bNt\n/NgToQ8nx7rZpHJMsJM9jgVpFa/7++ugpcGwz41OeurrGtoeAlwOLa9aEFCNIJJ5\nESu6qra1tIr3LPOhIwj2xhQ2DSxSepEKrQmwZbBT1ak78NnHDqn3KSZKRvM7AgqD\nCtJ3rfATHhriJzEOLaUMAn6lhDwfOwi79lVIUSRq7S9jyI8qsyzh+xNLf2x3R/tu\n4bDrHUnlAgMBAAECggEAFaEyhmsnfNHcRbigeh7NtUbCXFA01yt406Syil/ej8t6\no7vQbkK8m7ZqxEu8EYC3XaBam+qr8PoAxyjd2Ho7DLi1BFWrPF0DjTaeGIeoDcqm\nRIsGVtQcOBMawegZgVxNXXsvLh0NXNvKpofdQ0KASycr7PuCSkI7ioTQWx5SMeXt\ncSDwD/H9gs2xg2uToG2ggKWMA1akAV4JibNvGGolzgJ/BHUQv2IbPKTtw0k2oRVr\nir7ztVlZh2QRpdxQSvb4VMz0jtYKUIF2vw9QcH+nzgBzjR//htvz7kYSBO6euSIm\ng4r/+Q01bcWx2FHrf7B1FIGrXDgxXptQF/Ou1nGJxwKBgQD93H0ZnLWRohd/8Koa\nM1ll1+CUgFNQn2RwthJi5yEgYXtAB6uWYph2RL6586b6JXl786Si5fZ6WzPU/pZP\n09IRaHnpiPT2uHkXTCT+KkhpJvvu8nH2CplIqXSnCiwDO7Loq6fshl4em9Zg6+Tu\nk7ofJaeKqW5zmlTyI0cJT+XjpwKBgQDqUqbgJaPgvcYVGDseNYNCnd2fFUyl9T6f\nSU0ubU2yGjec70rCwb9GRNT//H0R30kGJNbSwDwapIha/QD7UxeHjPkUGdwVNCKM\nQV19MYH3O1mv/j8N3fBasmk8zWbMUWCRdnIOiIkF5Md4FdRm7UtP9Lxy4SgXJfrK\njGJz/E4HkwKBgGBN6u9ycbcDxOJ2TDGQVVO7Z6kuXWzyasoPaD447Go6UOVjg4aT\ndL85KRmmAyxWVxXcwhJCJxUX0Dv3MNKryr9r4QPlPvjx1o4uBsKdC8dIUL6/Hth2\nANx93JEZ3MSFO0PlCtlByCbYe6VdGAYh6LO0NzD1Qb99Rshs9Z/kvZN1AoGBAJJC\nkuz4MalXayvBmy0JA+xx82KX/ebdBICSVX20NjoESVBIwOZ93vFyh21dYYflUoRm\nPD0CRsHujzoUECfPvrEaWmKknY4So7neFwfM/i2euyWyUhNKw/sov613HEJOTTOe\ntiTCLp0iJyuanKC+XzMCNRqT1d1VFIyXQeDZzK7HAoGAGC98Oz+z4z57IRhqdpTN\nGA2D+xitrQtEVKcq0RWuOM8aIxAGPfaAmJ+ec7vagy1xpohOtF8bAne9GvSH01nN\nbegp001RqmcHPCoqv9SzqJUZcEMG7M89NBvn1uL6hfmnMkZIljFoA8u0b5NzGy58\nvp3dka7/U12lo5n1guQF1Hk=\n-----END PRIVATE KEY-----\n",
|
||||
"client_email": "firebase-adminsdk-fbsvc@migrant-fe5a7.iam.gserviceaccount.com",
|
||||
"client_id": "114003929019295227149",
|
||||
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
||||
"token_uri": "https://oauth2.googleapis.com/token",
|
||||
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
|
||||
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-fbsvc%40migrant-fe5a7.iam.gserviceaccount.com",
|
||||
"universe_domain": "googleapis.com"
|
||||
}
|
||||
@ -52,6 +52,11 @@
|
||||
"format": "binary",
|
||||
"description": "Organization or trade license document (jpg/png/pdf, max 10MB). (REQUIRED)"
|
||||
},
|
||||
"emirates_id_file": {
|
||||
"type": "string",
|
||||
"format": "binary",
|
||||
"description": "Emirates ID document file (jpg/png/pdf, max 10MB). (OPTIONAL)"
|
||||
},
|
||||
"organization_name": {
|
||||
"type": "string",
|
||||
"example": "Al-Rashidi Charitable Foundation",
|
||||
@ -787,9 +792,9 @@
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"default": 15
|
||||
"default": 500
|
||||
},
|
||||
"description": "Number of nationalities to return per page"
|
||||
"description": "Number of nationalities to return per page. Defaults to 500 on the backend to retrieve all nationalities by default."
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@ -854,6 +859,386 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/preferred-locations": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"General"
|
||||
],
|
||||
"summary": "Get Localized Preferred Locations List",
|
||||
"description": "Returns a list of preferred locations (Dubai, Abu Dhabi, Oman) translated to the requested language. Supports en, hindi (hi), sawahi (sw), taglo (tl), tamil (ta).",
|
||||
"security": [],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "lang",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"default": "en"
|
||||
},
|
||||
"description": "Language locale code (e.g. 'en', 'hindi', 'sawahi', 'taglo', 'tamil')"
|
||||
},
|
||||
{
|
||||
"name": "Accept-Language",
|
||||
"in": "header",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Fallback header locale (e.g. 'en', 'hi', 'sw', 'tl', 'ta')"
|
||||
},
|
||||
{
|
||||
"name": "search",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Search keyword to filter locations by name or code"
|
||||
},
|
||||
{
|
||||
"name": "page",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"default": 1
|
||||
},
|
||||
"description": "Page number for pagination"
|
||||
},
|
||||
{
|
||||
"name": "per_page",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"default": 15
|
||||
},
|
||||
"description": "Number of locations to return per page"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Preferred locations list retrieved successfully.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"preferred_locations": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "string",
|
||||
"example": "dubai"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"example": "Dubai"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"pagination": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"total": {
|
||||
"type": "integer",
|
||||
"example": 3
|
||||
},
|
||||
"per_page": {
|
||||
"type": "integer",
|
||||
"example": 15
|
||||
},
|
||||
"current_page": {
|
||||
"type": "integer",
|
||||
"example": 1
|
||||
},
|
||||
"last_page": {
|
||||
"type": "integer",
|
||||
"example": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/preferred-locations/{location}/areas": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"General"
|
||||
],
|
||||
"summary": "Get Localized Areas for a Preferred Location (Path Parameter)",
|
||||
"description": "Returns a list of localized neighborhoods or sub-locations mapped under a specific preferred location (Dubai, Abu Dhabi, Oman) using path routing.",
|
||||
"security": [],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "location",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"description": "The preferred location name (e.g. dubai, abu dhabi, oman).",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "lang",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"default": "en"
|
||||
},
|
||||
"description": "Language locale code (e.g. 'en', 'hindi', 'sawahi', 'taglo', 'tamil')"
|
||||
},
|
||||
{
|
||||
"name": "Accept-Language",
|
||||
"in": "header",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Fallback header locale (e.g. 'en', 'hi', 'sw', 'tl', 'ta')"
|
||||
},
|
||||
{
|
||||
"name": "search",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Search keyword to filter areas by name or code"
|
||||
},
|
||||
{
|
||||
"name": "page",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"default": 1
|
||||
},
|
||||
"description": "Page number for pagination"
|
||||
},
|
||||
{
|
||||
"name": "per_page",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"default": 15
|
||||
},
|
||||
"description": "Number of areas to return per page"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Areas retrieved successfully.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"location": {
|
||||
"type": "string",
|
||||
"example": "dubai"
|
||||
},
|
||||
"areas": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "string",
|
||||
"example": "marina"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"example": "Dubai Marina"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"pagination": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"total": {
|
||||
"type": "integer",
|
||||
"example": 17
|
||||
},
|
||||
"per_page": {
|
||||
"type": "integer",
|
||||
"example": 15
|
||||
},
|
||||
"current_page": {
|
||||
"type": "integer",
|
||||
"example": 1
|
||||
},
|
||||
"last_page": {
|
||||
"type": "integer",
|
||||
"example": 2
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/preferred-locations/areas": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"General"
|
||||
],
|
||||
"summary": "Get Localized Areas for a Preferred Location (Query Parameter)",
|
||||
"description": "Returns a list of localized neighborhoods or sub-locations mapped under a specific preferred location (Dubai, Abu Dhabi, Oman) using query parameter routing.",
|
||||
"security": [],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "city",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "The preferred location name (e.g. dubai, abu dhabi, oman).",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "location",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Alternative key for location name (e.g. dubai, abu dhabi, oman).",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "lang",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"default": "en"
|
||||
},
|
||||
"description": "Language locale code (e.g. 'en', 'hindi', 'sawahi', 'taglo', 'tamil')"
|
||||
},
|
||||
{
|
||||
"name": "Accept-Language",
|
||||
"in": "header",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Fallback header locale (e.g. 'en', 'hi', 'sw', 'tl', 'ta')"
|
||||
},
|
||||
{
|
||||
"name": "search",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Search keyword to filter areas by name or code"
|
||||
},
|
||||
{
|
||||
"name": "page",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"default": 1
|
||||
},
|
||||
"description": "Page number for pagination"
|
||||
},
|
||||
{
|
||||
"name": "per_page",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"default": 15
|
||||
},
|
||||
"description": "Number of areas to return per page"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Areas retrieved successfully.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"location": {
|
||||
"type": "string",
|
||||
"example": "dubai"
|
||||
},
|
||||
"areas": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "string",
|
||||
"example": "marina"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"example": "Dubai Marina"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"pagination": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"total": {
|
||||
"type": "integer",
|
||||
"example": 17
|
||||
},
|
||||
"per_page": {
|
||||
"type": "integer",
|
||||
"example": 15
|
||||
},
|
||||
"current_page": {
|
||||
"type": "integer",
|
||||
"example": 1
|
||||
},
|
||||
"last_page": {
|
||||
"type": "integer",
|
||||
"example": 2
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/workers/send-otp": {
|
||||
"post": {
|
||||
"tags": [
|
||||
@ -1180,6 +1565,11 @@
|
||||
6,
|
||||
8
|
||||
]
|
||||
},
|
||||
"fcm_token": {
|
||||
"type": "string",
|
||||
"example": "fcm-device-token-xyz-123",
|
||||
"description": "Firebase Cloud Messaging token for push notifications."
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1777,7 +2167,7 @@
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"multipart/form-data": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
@ -1801,6 +2191,16 @@
|
||||
"high"
|
||||
],
|
||||
"example": "medium"
|
||||
},
|
||||
"reason_id": {
|
||||
"type": "integer",
|
||||
"example": 1,
|
||||
"description": "Optional report/support reason ID."
|
||||
},
|
||||
"voice_note": {
|
||||
"type": "string",
|
||||
"format": "binary",
|
||||
"description": "Optional audio/voice note file attachment."
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -2778,7 +3178,16 @@
|
||||
"name": "nationality",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Filter by nationality name.",
|
||||
"description": "Filter by nationality name (comma-separated list for multiple choice, e.g. India,Kenya,Ethiopia).",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "gender",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Filter by gender (e.g. male, female).",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
@ -3078,7 +3487,16 @@
|
||||
"name": "nationality",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Filter candidates by nationality name.",
|
||||
"description": "Filter candidates by nationality name (comma-separated list for multiple choice, e.g. India,Kenya,Ethiopia).",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "gender",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Filter candidates by gender (e.g. male, female).",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
@ -3964,6 +4382,7 @@
|
||||
"is_verified": {"type": "boolean", "example": false},
|
||||
"status": {"type": "string", "example": "active"},
|
||||
"license_file": {"type": "string", "example": "uploads/licenses/1234567890_license_trade.pdf"},
|
||||
"emirates_id_file": {"type": "string", "example": "uploads/licenses/1234567890_emirates_id.pdf"},
|
||||
"created_at": {"type": "string", "format": "date-time"}
|
||||
}
|
||||
},
|
||||
|
||||
@ -115,12 +115,34 @@ export default function Show({ ticket, replies }) {
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-4">
|
||||
{ticket.reason_name && (
|
||||
<div>
|
||||
<span className="text-[9px] text-slate-400 uppercase tracking-widest font-black block mb-1.5">Support Reason</span>
|
||||
<span className="inline-block px-3 py-1 bg-slate-100 border border-slate-200 text-slate-700 text-xs font-black rounded-lg">
|
||||
{ticket.reason_name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<span className="text-[9px] text-slate-400 uppercase tracking-widest font-black block mb-2">Original Ticket Message</span>
|
||||
<div className="bg-slate-50 p-4 rounded-xl border border-slate-150 text-xs text-slate-600 font-medium whitespace-pre-wrap leading-relaxed">
|
||||
{ticket.description}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{ticket.voice_note_url && (
|
||||
<div className="pt-2">
|
||||
<span className="text-[9px] text-slate-400 uppercase tracking-widest font-black block mb-2">Voice Note Attachment</span>
|
||||
<div className="flex items-center space-x-3 bg-slate-50 border border-slate-150 p-3 rounded-xl max-w-md">
|
||||
<audio
|
||||
src={ticket.voice_note_url}
|
||||
controls
|
||||
className="w-full h-8 outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -14,6 +14,7 @@ export default function Register() {
|
||||
name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
emirates_id_file: null,
|
||||
});
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
@ -60,8 +61,20 @@ export default function Register() {
|
||||
|
||||
setLoading(true);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('name', data.name);
|
||||
formData.append('email', data.email);
|
||||
formData.append('phone', data.phone);
|
||||
if (data.emirates_id_file) {
|
||||
formData.append('emirates_id_file', data.emirates_id_file);
|
||||
}
|
||||
|
||||
try {
|
||||
await axios.post('/employer/register', data);
|
||||
await axios.post('/employer/register', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
});
|
||||
toast.success('Registration details saved! Verification code sent to your email.');
|
||||
router.visit('/employer/verify-email');
|
||||
} catch (err) {
|
||||
@ -247,6 +260,26 @@ export default function Register() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Emirates ID File */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-700 mb-1.5 uppercase tracking-wider font-bold">Emirates ID Document (Optional)</label>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/jpg,application/pdf"
|
||||
onChange={(e) => handleInputChange('emirates_id_file', e.target.files[0])}
|
||||
className={`w-full px-3.5 py-2.5 rounded-xl border text-sm focus:outline-none focus:ring-2 transition-all file:mr-4 file:py-1 file:px-3 file:rounded-lg file:border-0 file:text-xs file:font-semibold file:bg-[#185FA5]/10 file:text-[#185FA5] hover:file:bg-[#185FA5]/20 cursor-pointer ${
|
||||
errors.emirates_id_file
|
||||
? 'border-red-500 focus:ring-red-500/20 focus:border-red-500'
|
||||
: 'border-slate-300 focus:ring-[#185FA5]/20 focus:border-[#185FA5]'
|
||||
}`}
|
||||
/>
|
||||
{errors.emirates_id_file && (
|
||||
<p className="mt-1.5 text-xs text-red-500 font-medium">
|
||||
{Array.isArray(errors.emirates_id_file) ? errors.emirates_id_file[0] : errors.emirates_id_file}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
|
||||
@ -22,9 +22,14 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import FilterDrawer, { FilterSection, FilterSelect, FilterInput, FilterChips } from '../../Components/Employer/FilterDrawer';
|
||||
import FilterDrawer, { FilterSection, FilterSelect, FilterInput, FilterChips, FilterCheckboxList } from '../../Components/Employer/FilterDrawer';
|
||||
|
||||
export default function SelectedCandidates({ selectedWorkers }) {
|
||||
export default function SelectedCandidates({
|
||||
selectedWorkers,
|
||||
allNationalities,
|
||||
skills = ['cooking', 'driving', 'childcare', 'cleaning', 'elderly care', 'gardening'],
|
||||
languages = ['English', 'Arabic', 'Hindi', 'Tagalog']
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [workers, setWorkers] = useState((selectedWorkers || []).filter(w => w.status !== 'Searching'));
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
@ -33,12 +38,22 @@ export default function SelectedCandidates({ selectedWorkers }) {
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
|
||||
// Filter States
|
||||
const [filterLocation, setFilterLocation] = useState('');
|
||||
const [filterLocation, setFilterLocation] = useState('All');
|
||||
const [filterJobType, setFilterJobType] = useState('All');
|
||||
const [filterAccommodation, setFilterAccommodation] = useState('All');
|
||||
const [filterNationality, setFilterNationality] = useState('All');
|
||||
const [filterNationalities, setFilterNationalities] = useState([]);
|
||||
const [filterGender, setFilterGender] = useState('All');
|
||||
const [filterInCountry, setFilterInCountry] = useState('All');
|
||||
const [filterVisaType, setFilterVisaType] = useState('All');
|
||||
const [filterSkills, setFilterSkills] = useState([]);
|
||||
const [filterLanguages, setFilterLanguages] = useState([]);
|
||||
const [maxSalary, setMaxSalary] = useState(5000);
|
||||
|
||||
const toggleMultiSelect = (item, setSelectedList) => {
|
||||
setSelectedList(prev =>
|
||||
prev.includes(item) ? prev.filter(x => x !== item) : [...prev, item]
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setWorkers((selectedWorkers || []).filter(w => w.status !== 'Searching'));
|
||||
@ -53,9 +68,12 @@ export default function SelectedCandidates({ selectedWorkers }) {
|
||||
|
||||
// Extract unique nationalities for filter dropdown
|
||||
const nationalities = useMemo(() => {
|
||||
if (allNationalities && allNationalities.length > 0) {
|
||||
return allNationalities;
|
||||
}
|
||||
const nats = (selectedWorkers || []).map(w => w.nationality).filter(Boolean);
|
||||
return [...new Set(nats)];
|
||||
}, [selectedWorkers]);
|
||||
}, [selectedWorkers, allNationalities]);
|
||||
|
||||
// Apply Client-Side Filters
|
||||
const filteredWorkers = useMemo(() => {
|
||||
@ -65,9 +83,19 @@ export default function SelectedCandidates({ selectedWorkers }) {
|
||||
const query = searchTerm.toLowerCase();
|
||||
list = list.filter(w => w.name.toLowerCase().includes(query));
|
||||
}
|
||||
if (filterLocation.trim() !== '') {
|
||||
const query = filterLocation.toLowerCase();
|
||||
list = list.filter(w => w.preferred_location && w.preferred_location.toLowerCase().includes(query));
|
||||
if (filterLocation !== 'All' && filterLocation.trim() !== '') {
|
||||
const locKey = filterLocation.toLowerCase();
|
||||
const locationMapping = {
|
||||
'dubai': ['dubai', 'marina', 'barsha', 'nahda', 'jumeirah', 'deira', 'downtown', 'silicon', 'sports', 'motor', 'jlt', 'jbr', 'meydan', 'ranches', 'bay', 'mirdif', 'quoz'],
|
||||
'abu dhabi': ['abu dhabi', 'yas', 'khalifa', 'reem', 'saadiyat', 'raha', 'mussafah', 'zahiyah', 'karamah'],
|
||||
'oman': ['oman', 'muscat', 'salalah', 'sohar', 'nizwa', 'sur', 'ibri', 'rustaq']
|
||||
};
|
||||
const allowedKeywords = locationMapping[locKey] || [locKey];
|
||||
list = list.filter(w => {
|
||||
if (!w.preferred_location) return false;
|
||||
const wLoc = w.preferred_location.toLowerCase();
|
||||
return allowedKeywords.some(keyword => wLoc.includes(keyword));
|
||||
});
|
||||
}
|
||||
if (filterJobType !== 'All') {
|
||||
list = list.filter(w => w.preferred_job_type && w.preferred_job_type.toLowerCase() === filterJobType.toLowerCase());
|
||||
@ -75,8 +103,11 @@ export default function SelectedCandidates({ selectedWorkers }) {
|
||||
if (filterAccommodation !== 'All') {
|
||||
list = list.filter(w => w.live_in_out && w.live_in_out.toLowerCase() === filterAccommodation.toLowerCase());
|
||||
}
|
||||
if (filterNationality !== 'All') {
|
||||
list = list.filter(w => w.nationality && w.nationality.toLowerCase() === filterNationality.toLowerCase());
|
||||
if (filterNationalities.length > 0) {
|
||||
list = list.filter(w => w.nationality && filterNationalities.map(n => n.toLowerCase()).includes(w.nationality.toLowerCase()));
|
||||
}
|
||||
if (filterGender !== 'All') {
|
||||
list = list.filter(w => w.gender && w.gender.toLowerCase() === filterGender.toLowerCase());
|
||||
}
|
||||
if (filterInCountry !== 'All') {
|
||||
const wantInCountry = filterInCountry === 'In Country';
|
||||
@ -88,41 +119,62 @@ export default function SelectedCandidates({ selectedWorkers }) {
|
||||
if (filterInCountry === 'In Country' && filterVisaType !== 'All') {
|
||||
list = list.filter(w => w.visa_status && w.visa_status.toLowerCase().includes(filterVisaType.toLowerCase()));
|
||||
}
|
||||
if (filterSkills.length > 0) {
|
||||
list = list.filter(w => w.skills && w.skills.some(s => filterSkills.includes(s.toLowerCase())));
|
||||
}
|
||||
if (filterLanguages.length > 0) {
|
||||
list = list.filter(w => w.languages && w.languages.some(l => filterLanguages.includes(l)));
|
||||
}
|
||||
if (maxSalary < 5000) {
|
||||
list = list.filter(w => w.salary && Number(w.salary) <= maxSalary);
|
||||
}
|
||||
|
||||
return list;
|
||||
}, [selectedWorkers, searchTerm, filterLocation, filterJobType, filterAccommodation, filterNationality, filterInCountry, filterVisaType]);
|
||||
}, [selectedWorkers, searchTerm, filterLocation, filterJobType, filterAccommodation, filterNationalities, filterGender, filterInCountry, filterVisaType, filterSkills, filterLanguages, maxSalary]);
|
||||
|
||||
const resetFilters = () => {
|
||||
setFilterLocation('');
|
||||
setFilterLocation('All');
|
||||
setFilterJobType('All');
|
||||
setFilterAccommodation('All');
|
||||
setFilterNationality('All');
|
||||
setFilterNationalities([]);
|
||||
setFilterGender('All');
|
||||
setFilterInCountry('All');
|
||||
setFilterVisaType('All');
|
||||
setFilterSkills([]);
|
||||
setFilterLanguages([]);
|
||||
setMaxSalary(5000);
|
||||
};
|
||||
|
||||
const activeFilterCount = useMemo(() => {
|
||||
let count = 0;
|
||||
if (filterLocation.trim() !== '') count++;
|
||||
if (filterLocation !== 'All' && filterLocation.trim() !== '') count++;
|
||||
if (filterJobType !== 'All') count++;
|
||||
if (filterAccommodation !== 'All') count++;
|
||||
if (filterNationality !== 'All') count++;
|
||||
if (filterNationalities.length > 0) count++;
|
||||
if (filterGender !== 'All') count++;
|
||||
if (filterInCountry !== 'All') count++;
|
||||
if (filterInCountry === 'In Country' && filterVisaType !== 'All') count++;
|
||||
if (filterSkills.length > 0) count++;
|
||||
if (filterLanguages.length > 0) count++;
|
||||
if (maxSalary < 5000) count++;
|
||||
return count;
|
||||
}, [filterLocation, filterJobType, filterAccommodation, filterNationality, filterInCountry, filterVisaType]);
|
||||
}, [filterLocation, filterJobType, filterAccommodation, filterNationalities, filterGender, filterInCountry, filterVisaType, filterSkills, filterLanguages, maxSalary]);
|
||||
|
||||
// Active filter tags for display
|
||||
const activeFilterTags = useMemo(() => {
|
||||
const tags = [];
|
||||
if (filterLocation.trim()) tags.push({ key: 'location', label: `📍 ${filterLocation}`, clear: () => setFilterLocation('') });
|
||||
if (filterLocation !== 'All' && filterLocation.trim()) tags.push({ key: 'location', label: `📍 ${filterLocation}`, clear: () => setFilterLocation('All') });
|
||||
if (filterJobType !== 'All') tags.push({ key: 'job', label: `💼 ${filterJobType}`, clear: () => setFilterJobType('All') });
|
||||
if (filterAccommodation !== 'All') tags.push({ key: 'acc', label: `🏠 ${filterAccommodation.replace('_', '-')}`, clear: () => setFilterAccommodation('All') });
|
||||
if (filterNationality !== 'All') tags.push({ key: 'nat', label: `🌍 ${filterNationality}`, clear: () => setFilterNationality('All') });
|
||||
if (filterNationalities.length > 0) tags.push({ key: 'nat', label: `🌍 ${filterNationalities.length} Nat`, clear: () => setFilterNationalities([]) });
|
||||
if (filterGender !== 'All') tags.push({ key: 'gender', label: `🚻 ${filterGender}`, clear: () => setFilterGender('All') });
|
||||
if (filterInCountry !== 'All') tags.push({ key: 'country', label: `✈️ ${filterInCountry}`, clear: () => { setFilterInCountry('All'); setFilterVisaType('All'); } });
|
||||
if (filterInCountry === 'In Country' && filterVisaType !== 'All') tags.push({ key: 'visa', label: `🪪 ${filterVisaType}`, clear: () => setFilterVisaType('All') });
|
||||
if (filterSkills.length > 0) tags.push({ key: 'skills', label: `🛠 ${filterSkills.length} skill${filterSkills.length > 1 ? 's' : ''}`, clear: () => setFilterSkills([]) });
|
||||
if (filterLanguages.length > 0) tags.push({ key: 'lang', label: `🗣 ${filterLanguages.length} lang`, clear: () => setFilterLanguages([]) });
|
||||
if (maxSalary < 5000) tags.push({ key: 'salary', label: `💰 ≤${maxSalary} AED`, clear: () => setMaxSalary(5000) });
|
||||
return tags;
|
||||
}, [filterLocation, filterJobType, filterAccommodation, filterNationality, filterInCountry, filterVisaType]);
|
||||
}, [filterLocation, filterJobType, filterAccommodation, filterNationalities, filterGender, filterInCountry, filterVisaType, filterSkills, filterLanguages, maxSalary]);
|
||||
|
||||
return (
|
||||
<EmployerLayout title={t('candidates_pipeline', 'Hired Workers')}>
|
||||
@ -138,12 +190,16 @@ export default function SelectedCandidates({ selectedWorkers }) {
|
||||
>
|
||||
{/* Preferred Location */}
|
||||
<FilterSection label="Preferred Location">
|
||||
<FilterInput
|
||||
<FilterSelect
|
||||
id="filter_preferred_location"
|
||||
value={filterLocation}
|
||||
onChange={e => setFilterLocation(e.target.value)}
|
||||
placeholder="e.g. Dubai, Abu Dhabi…"
|
||||
/>
|
||||
>
|
||||
<option value="All">All Locations</option>
|
||||
<option value="Dubai">Dubai</option>
|
||||
<option value="Abu Dhabi">Abu Dhabi</option>
|
||||
<option value="Oman">Oman</option>
|
||||
</FilterSelect>
|
||||
</FilterSection>
|
||||
|
||||
{/* Job Type */}
|
||||
@ -173,17 +229,27 @@ export default function SelectedCandidates({ selectedWorkers }) {
|
||||
</FilterSection>
|
||||
|
||||
{/* Nationality */}
|
||||
{nationalities.length > 0 && (
|
||||
<FilterSection label="Nationality">
|
||||
<FilterSelect
|
||||
id="filter_nationality"
|
||||
value={filterNationality}
|
||||
onChange={e => setFilterNationality(e.target.value)}
|
||||
>
|
||||
<option value="All">All Nationalities</option>
|
||||
{nationalities.map(nat => (
|
||||
<option key={nat} value={nat}>{nat}</option>
|
||||
))}
|
||||
</FilterSelect>
|
||||
<FilterCheckboxList
|
||||
items={nationalities}
|
||||
selected={filterNationalities}
|
||||
onToggle={nat => toggleMultiSelect(nat, setFilterNationalities)}
|
||||
/>
|
||||
</FilterSection>
|
||||
)}
|
||||
|
||||
{/* Gender */}
|
||||
<FilterSection label="Gender">
|
||||
<FilterChips
|
||||
options={[
|
||||
{ value: 'All', label: 'All' },
|
||||
{ value: 'male', label: 'Male' },
|
||||
{ value: 'female', label: 'Female' },
|
||||
]}
|
||||
selected={filterGender}
|
||||
onChange={setFilterGender}
|
||||
/>
|
||||
</FilterSection>
|
||||
|
||||
{/* Country Status */}
|
||||
@ -225,6 +291,44 @@ export default function SelectedCandidates({ selectedWorkers }) {
|
||||
)}
|
||||
</div>
|
||||
</FilterSection>
|
||||
|
||||
{/* Skills */}
|
||||
{skills && skills.length > 0 && (
|
||||
<FilterSection label="Skills">
|
||||
<FilterCheckboxList
|
||||
items={skills}
|
||||
selected={filterSkills}
|
||||
onToggle={skill => toggleMultiSelect(skill, setFilterSkills)}
|
||||
/>
|
||||
</FilterSection>
|
||||
)}
|
||||
|
||||
{/* Languages */}
|
||||
{languages && languages.length > 0 && (
|
||||
<FilterSection label="Languages">
|
||||
<FilterCheckboxList
|
||||
items={languages}
|
||||
selected={filterLanguages}
|
||||
onToggle={lang => toggleMultiSelect(lang, setFilterLanguages)}
|
||||
/>
|
||||
</FilterSection>
|
||||
)}
|
||||
{/* Salary Range */}
|
||||
<FilterSection label={maxSalary === 5000 ? "Max Salary — Any" : `Max Salary — ${maxSalary} AED`}>
|
||||
<input
|
||||
type="range"
|
||||
min="500"
|
||||
max="5000"
|
||||
step="100"
|
||||
value={maxSalary}
|
||||
onChange={e => setMaxSalary(Number(e.target.value))}
|
||||
className="w-full accent-[#185FA5]"
|
||||
/>
|
||||
<div className="flex justify-between text-[10px] font-bold text-slate-400 mt-1">
|
||||
<span>500 AED</span>
|
||||
<span>5,000 AED</span>
|
||||
</div>
|
||||
</FilterSection>
|
||||
</FilterDrawer>
|
||||
|
||||
<div className="space-y-6 select-none">
|
||||
|
||||
@ -48,7 +48,8 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
|
||||
// Basic Filters
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedCategory, setSelectedCategory] = useState('All Categories');
|
||||
const [selectedNationality, setSelectedNationality] = useState('All Nationalities');
|
||||
const [selectedNationalities, setSelectedNationalities] = useState([]);
|
||||
const [selectedGender, setSelectedGender] = useState('All Genders');
|
||||
const [selectedExperience, setSelectedExperience] = useState('All Experience');
|
||||
const [selectedReligion, setSelectedReligion] = useState('All Religions');
|
||||
const [maxSalary, setMaxSalary] = useState(5000);
|
||||
@ -62,7 +63,7 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
|
||||
|
||||
// Advanced Filters Toggle & Details
|
||||
const [showAdvancedFilters, setShowAdvancedFilters] = useState(false);
|
||||
const [filterLocation, setFilterLocation] = useState('');
|
||||
const [filterLocation, setFilterLocation] = useState('All');
|
||||
const [filterJobType, setFilterJobType] = useState('All');
|
||||
const [filterAccommodation, setFilterAccommodation] = useState('All');
|
||||
const [filterInCountry, setFilterInCountry] = useState('All');
|
||||
@ -107,11 +108,17 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
|
||||
const inCountryVal = params.get('in_country');
|
||||
const outCountryVal = params.get('out_country');
|
||||
const visa = params.get('visa_status') || params.get('next_visa_type') || params.get('visa_type');
|
||||
const gender = params.get('gender');
|
||||
|
||||
let hasAdvanced = false;
|
||||
|
||||
if (cat) setSelectedCategory(cat);
|
||||
if (nat) setSelectedNationality(nat);
|
||||
if (nat) {
|
||||
setSelectedNationalities(nat.split(',').map(x => x.trim()).filter(Boolean));
|
||||
}
|
||||
if (gender) {
|
||||
setSelectedGender(gender.toLowerCase() === 'male' ? 'Male' : (gender.toLowerCase() === 'female' ? 'Female' : 'All Genders'));
|
||||
}
|
||||
if (sal) setMaxSalary(Number(sal));
|
||||
if (loc) {
|
||||
setFilterLocation(loc);
|
||||
@ -194,7 +201,8 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
|
||||
const resetFilters = () => {
|
||||
setSearchQuery('');
|
||||
setSelectedCategory('All Categories');
|
||||
setSelectedNationality('All Nationalities');
|
||||
setSelectedNationalities([]);
|
||||
setSelectedGender('All Genders');
|
||||
setSelectedExperience('All Experience');
|
||||
setSelectedReligion('All Religions');
|
||||
setMaxSalary(5000);
|
||||
@ -204,7 +212,7 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
|
||||
setSelectedWorkTypes([]);
|
||||
setSortBy('default');
|
||||
setVisibleCount(6);
|
||||
setFilterLocation('');
|
||||
setFilterLocation('All');
|
||||
setFilterJobType('All');
|
||||
setFilterAccommodation('All');
|
||||
setFilterInCountry('All');
|
||||
@ -213,31 +221,33 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
|
||||
|
||||
const activeFilterCount = useMemo(() => {
|
||||
let count = 0;
|
||||
if (filterLocation.trim() !== '') count++;
|
||||
if (filterLocation !== 'All' && filterLocation.trim() !== '') count++;
|
||||
if (filterJobType !== 'All') count++;
|
||||
if (filterAccommodation !== 'All') count++;
|
||||
if (selectedNationality !== 'All Nationalities') count++;
|
||||
if (selectedNationalities.length > 0) count++;
|
||||
if (selectedGender !== 'All Genders') count++;
|
||||
if (filterInCountry !== 'All') count++;
|
||||
if (filterInCountry === 'In Country' && filterVisaType !== 'All') count++;
|
||||
if (selectedSkills.length > 0) count++;
|
||||
if (selectedLanguages.length > 0) count++;
|
||||
if (maxSalary < 5000) count++;
|
||||
return count;
|
||||
}, [filterLocation, filterJobType, filterAccommodation, selectedNationality, filterInCountry, filterVisaType, selectedSkills, selectedLanguages, maxSalary]);
|
||||
}, [filterLocation, filterJobType, filterAccommodation, selectedNationalities, selectedGender, filterInCountry, filterVisaType, selectedSkills, selectedLanguages, maxSalary]);
|
||||
|
||||
const activeFilterTags = useMemo(() => {
|
||||
const tags = [];
|
||||
if (filterLocation.trim()) tags.push({ key: 'loc', label: `📍 ${filterLocation}`, clear: () => setFilterLocation('') });
|
||||
if (filterLocation !== 'All' && filterLocation.trim()) tags.push({ key: 'loc', label: `📍 ${filterLocation}`, clear: () => setFilterLocation('All') });
|
||||
if (filterJobType !== 'All') tags.push({ key: 'job', label: `💼 ${filterJobType}`, clear: () => setFilterJobType('All') });
|
||||
if (filterAccommodation !== 'All') tags.push({ key: 'acc', label: `🏠 ${filterAccommodation.replace('_', '-')}`, clear: () => setFilterAccommodation('All') });
|
||||
if (selectedNationality !== 'All Nationalities') tags.push({ key: 'nat', label: `🌍 ${selectedNationality}`, clear: () => setSelectedNationality('All Nationalities') });
|
||||
if (selectedNationalities.length > 0) tags.push({ key: 'nat', label: `🌍 ${selectedNationalities.length} Nat`, clear: () => setSelectedNationalities([]) });
|
||||
if (selectedGender !== 'All Genders') tags.push({ key: 'gender', label: `🚻 ${selectedGender}`, clear: () => setSelectedGender('All Genders') });
|
||||
if (filterInCountry !== 'All') tags.push({ key: 'country', label: `✈️ ${filterInCountry}`, clear: () => { setFilterInCountry('All'); setFilterVisaType('All'); } });
|
||||
if (filterInCountry === 'In Country' && filterVisaType !== 'All') tags.push({ key: 'visa', label: `🪪 ${filterVisaType}`, clear: () => setFilterVisaType('All') });
|
||||
if (selectedSkills.length > 0) tags.push({ key: 'skills', label: `🛠 ${selectedSkills.length} skill${selectedSkills.length > 1 ? 's' : ''}`, clear: () => setSelectedSkills([]) });
|
||||
if (selectedLanguages.length > 0) tags.push({ key: 'lang', label: `🗣 ${selectedLanguages.length} lang`, clear: () => setSelectedLanguages([]) });
|
||||
if (maxSalary < 5000) tags.push({ key: 'salary', label: `💰 ≤${maxSalary} AED`, clear: () => setMaxSalary(5000) });
|
||||
return tags;
|
||||
}, [filterLocation, filterJobType, filterAccommodation, selectedNationality, filterInCountry, filterVisaType, selectedSkills, selectedLanguages, maxSalary]);
|
||||
}, [filterLocation, filterJobType, filterAccommodation, selectedNationalities, selectedGender, filterInCountry, filterVisaType, selectedSkills, selectedLanguages, maxSalary]);
|
||||
|
||||
// Filter and Sort Worker List
|
||||
const filteredWorkers = useMemo(() => {
|
||||
@ -253,7 +263,8 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
|
||||
|
||||
// Dropdown filters
|
||||
if (selectedCategory !== 'All Categories' && worker.category !== selectedCategory) return false;
|
||||
if (selectedNationality !== 'All Nationalities' && worker.nationality !== selectedNationality) return false;
|
||||
if (selectedNationalities.length > 0 && (!worker.nationality || !selectedNationalities.map(n => n.toLowerCase()).includes(worker.nationality.toLowerCase()))) return false;
|
||||
if (selectedGender !== 'All Genders' && worker.gender && worker.gender.toLowerCase() !== selectedGender.toLowerCase()) return false;
|
||||
if (selectedExperience !== 'All Experience' && worker.experience !== selectedExperience) return false;
|
||||
if (selectedReligion !== 'All Religions' && worker.religion !== selectedReligion) return false;
|
||||
if (maxSalary < 5000 && worker.salary > maxSalary) return false;
|
||||
@ -273,9 +284,18 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
|
||||
if (selectedWorkTypes.length > 0 && !selectedWorkTypes.includes(worker.preferred_job_type)) return false;
|
||||
|
||||
// Preferred Location
|
||||
if (filterLocation.trim() !== '') {
|
||||
const query = filterLocation.toLowerCase();
|
||||
if (!worker.preferred_location || !worker.preferred_location.toLowerCase().includes(query)) return false;
|
||||
if (filterLocation !== 'All' && filterLocation.trim() !== '') {
|
||||
const locKey = filterLocation.toLowerCase();
|
||||
const locationMapping = {
|
||||
'dubai': ['dubai', 'marina', 'barsha', 'nahda', 'jumeirah', 'deira', 'downtown', 'silicon', 'sports', 'motor', 'jlt', 'jbr', 'meydan', 'ranches', 'bay', 'mirdif', 'quoz'],
|
||||
'abu dhabi': ['abu dhabi', 'yas', 'khalifa', 'reem', 'saadiyat', 'raha', 'mussafah', 'zahiyah', 'karamah'],
|
||||
'oman': ['oman', 'muscat', 'salalah', 'sohar', 'nizwa', 'sur', 'ibri', 'rustaq']
|
||||
};
|
||||
const allowedKeywords = locationMapping[locKey] || [locKey];
|
||||
if (!worker.preferred_location) return false;
|
||||
const wLoc = worker.preferred_location.toLowerCase();
|
||||
const matched = allowedKeywords.some(keyword => wLoc.includes(keyword));
|
||||
if (!matched) return false;
|
||||
}
|
||||
|
||||
// Job Type
|
||||
@ -319,7 +339,8 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
|
||||
initialWorkers,
|
||||
searchQuery,
|
||||
selectedCategory,
|
||||
selectedNationality,
|
||||
selectedNationalities,
|
||||
selectedGender,
|
||||
selectedExperience,
|
||||
selectedReligion,
|
||||
maxSalary,
|
||||
@ -388,12 +409,16 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
|
||||
>
|
||||
{/* Preferred Location */}
|
||||
<FilterSection label="Preferred Location">
|
||||
<FilterInput
|
||||
<FilterSelect
|
||||
id="filter_preferred_location"
|
||||
value={filterLocation}
|
||||
onChange={e => setFilterLocation(e.target.value)}
|
||||
placeholder="e.g. Dubai, Abu Dhabi…"
|
||||
/>
|
||||
>
|
||||
<option value="All">All Locations</option>
|
||||
<option value="Dubai">Dubai</option>
|
||||
<option value="Abu Dhabi">Abu Dhabi</option>
|
||||
<option value="Oman">Oman</option>
|
||||
</FilterSelect>
|
||||
</FilterSection>
|
||||
|
||||
{/* Job Type */}
|
||||
@ -423,16 +448,27 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
|
||||
</FilterSection>
|
||||
|
||||
{/* Nationality */}
|
||||
{filtersMetadata.nationalities?.length > 1 && (
|
||||
<FilterSection label="Nationality">
|
||||
<FilterSelect
|
||||
id="filter_nationality"
|
||||
value={selectedNationality}
|
||||
onChange={e => setSelectedNationality(e.target.value)}
|
||||
>
|
||||
{filtersMetadata.nationalities?.map(nat => (
|
||||
<option key={nat} value={nat}>{nat}</option>
|
||||
))}
|
||||
</FilterSelect>
|
||||
<FilterCheckboxList
|
||||
items={filtersMetadata.nationalities.filter(n => n !== 'All Nationalities')}
|
||||
selected={selectedNationalities}
|
||||
onToggle={nat => toggleMultiSelect(nat, setSelectedNationalities)}
|
||||
/>
|
||||
</FilterSection>
|
||||
)}
|
||||
|
||||
{/* Gender */}
|
||||
<FilterSection label="Gender">
|
||||
<FilterChips
|
||||
options={[
|
||||
{ value: 'All Genders', label: 'All' },
|
||||
{ value: 'male', label: 'Male' },
|
||||
{ value: 'female', label: 'Female' },
|
||||
]}
|
||||
selected={selectedGender}
|
||||
onChange={setSelectedGender}
|
||||
/>
|
||||
</FilterSection>
|
||||
|
||||
{/* Country Status */}
|
||||
|
||||
@ -185,10 +185,37 @@ export function FilterChips({ options, selected, onChange }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function FilterCheckboxList({ items, selected, onToggle }) {
|
||||
export function FilterCheckboxList({ items, selected, onToggle, placeholder = 'Search...' }) {
|
||||
const [search, setSearch] = React.useState('');
|
||||
|
||||
const filteredItems = items.filter(item =>
|
||||
item.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{items.length > 5 && (
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="w-full px-3 py-1.5 bg-slate-50/50 border border-slate-200 rounded-lg text-xs font-semibold text-slate-700 focus:ring-2 focus:ring-blue-100 focus:border-[#185FA5] outline-none transition-all placeholder:text-slate-400 placeholder:font-normal"
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearch('')}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-[10px] font-bold text-slate-400 hover:text-slate-600"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2 max-h-48 overflow-y-auto pr-1">
|
||||
{items.map(item => {
|
||||
{filteredItems.map(item => {
|
||||
const isChecked = selected.includes(item);
|
||||
return (
|
||||
<button
|
||||
@ -212,6 +239,10 @@ export function FilterCheckboxList({ items, selected, onToggle }) {
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{filteredItems.length === 0 && (
|
||||
<div className="text-[11px] text-slate-400 italic py-1">No matches found</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -28,6 +28,9 @@
|
||||
Route::get('/workers/config', [WorkerAuthController::class, 'config']);
|
||||
Route::get('/workers/skills', [WorkerAuthController::class, 'skills']);
|
||||
Route::get('/nationalities', [WorkerAuthController::class, 'nationalities']);
|
||||
Route::get('/preferred-locations', [WorkerAuthController::class, 'preferredLocations']);
|
||||
Route::get('/preferred-locations/areas', [WorkerAuthController::class, 'preferredLocationAreas']);
|
||||
Route::get('/preferred-locations/{location}/areas', [WorkerAuthController::class, 'preferredLocationAreas']);
|
||||
Route::post('/workers/send-otp', [WorkerAuthController::class, 'sendOtp']);
|
||||
Route::post('/workers/verify-otp', [WorkerAuthController::class, 'verifyOtp']);
|
||||
Route::post('/workers/setup-profile', [WorkerAuthController::class, 'setupProfile']);
|
||||
|
||||
@ -298,4 +298,40 @@ public function test_unauthenticated_requests_are_blocked()
|
||||
$response = $this->getJson('/api/employers/conversations');
|
||||
$response->assertStatus(401);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test employer message triggers push notification.
|
||||
*/
|
||||
public function test_employer_message_triggers_push_notification()
|
||||
{
|
||||
\Illuminate\Support\Facades\Http::fake([
|
||||
'https://oauth2.googleapis.com/token' => \Illuminate\Support\Facades\Http::response(['access_token' => 'mocked-fcm-token'], 200),
|
||||
'https://fcm.googleapis.com/*' => \Illuminate\Support\Facades\Http::response(['name' => 'projects/migrant-fe5a7/messages/mock-id'], 200),
|
||||
]);
|
||||
|
||||
// Set fcm_token on worker
|
||||
$this->worker->update(['fcm_token' => 'test-device-fcm-token-xyz']);
|
||||
|
||||
// Create conversation
|
||||
$conversation = Conversation::create([
|
||||
'employer_id' => $this->employer->id,
|
||||
'worker_id' => $this->worker->id,
|
||||
]);
|
||||
|
||||
// Reply request
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer ' . $this->token,
|
||||
])->postJson('/api/employers/conversations/' . $conversation->id . '/messages', [
|
||||
'text' => 'Testing push notifications!'
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
|
||||
// Assert Http call was made to FCM endpoint
|
||||
\Illuminate\Support\Facades\Http::assertSent(function ($request) {
|
||||
return str_contains($request->url(), 'fcm.googleapis.com') &&
|
||||
$request['message']['token'] === 'test-device-fcm-token-xyz' &&
|
||||
$request['message']['notification']['body'] === 'Testing push notifications!';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -40,7 +40,7 @@ protected function setUp(): void
|
||||
]);
|
||||
|
||||
// Seed 3 workers with specific filter attributes
|
||||
// Worker 1: Dubai, full-time, live-in, India, in_country=true, Residence Visa
|
||||
// Worker 1: Dubai, full-time, live-in, India, in_country=true, Residence Visa, female
|
||||
$w1 = Worker::create([
|
||||
'name' => 'Worker India In Dubai',
|
||||
'email' => 'worker1@example.com',
|
||||
@ -56,6 +56,7 @@ protected function setUp(): void
|
||||
'in_country' => true,
|
||||
'visa_status' => 'Residence Visa',
|
||||
'age' => 25,
|
||||
'gender' => 'female',
|
||||
'salary' => 1500,
|
||||
'experience' => '3 Years',
|
||||
'religion' => 'Christian',
|
||||
@ -70,7 +71,7 @@ protected function setUp(): void
|
||||
'file_path' => 'passports/test1.jpg',
|
||||
]);
|
||||
|
||||
// Worker 2: Abu Dhabi, part-time, live-out, Philippines, in_country=true, Tourist Visa
|
||||
// Worker 2: Abu Dhabi, part-time, live-out, Philippines, in_country=true, Tourist Visa, female
|
||||
$w2 = Worker::create([
|
||||
'name' => 'Worker Philippines In Abu Dhabi',
|
||||
'email' => 'worker2@example.com',
|
||||
@ -86,6 +87,7 @@ protected function setUp(): void
|
||||
'in_country' => true,
|
||||
'visa_status' => 'Tourist Visa',
|
||||
'age' => 28,
|
||||
'gender' => 'female',
|
||||
'salary' => 1800,
|
||||
'experience' => '2 Years',
|
||||
'religion' => 'Christian',
|
||||
@ -100,7 +102,7 @@ protected function setUp(): void
|
||||
'file_path' => 'passports/test2.jpg',
|
||||
]);
|
||||
|
||||
// Worker 3: Dubai, full-time, live-out, Kenya, in_country=false, Cancelled Visa
|
||||
// Worker 3: Dubai, full-time, live-out, Kenya, in_country=false, Cancelled Visa, male
|
||||
$w3 = Worker::create([
|
||||
'name' => 'Worker Kenya Out Country',
|
||||
'email' => 'worker3@example.com',
|
||||
@ -116,6 +118,7 @@ protected function setUp(): void
|
||||
'in_country' => false,
|
||||
'visa_status' => 'Cancelled Visa',
|
||||
'age' => 30,
|
||||
'gender' => 'male',
|
||||
'salary' => 1200,
|
||||
'experience' => 'None',
|
||||
'religion' => 'Christian',
|
||||
@ -314,5 +317,35 @@ public function test_get_candidates_filters()
|
||||
$candidates = $response->json('data.candidates');
|
||||
$this->assertCount(1, $candidates);
|
||||
$this->assertEquals('Worker Philippines In Abu Dhabi', $candidates[0]['name']);
|
||||
|
||||
// Filter candidates by gender
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer ' . $this->token,
|
||||
])->getJson('/api/employers/candidates?gender=male');
|
||||
|
||||
$response->assertStatus(200);
|
||||
$candidates = $response->json('data.candidates');
|
||||
$this->assertCount(1, $candidates);
|
||||
$this->assertEquals('Worker Kenya Out Country', $candidates[0]['name']);
|
||||
}
|
||||
|
||||
public function test_get_workers_gender_filter()
|
||||
{
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer ' . $this->token,
|
||||
])->getJson('/api/employers/workers?gender=male');
|
||||
|
||||
$response->assertStatus(200);
|
||||
$workers = $response->json('data.workers');
|
||||
$this->assertCount(1, $workers);
|
||||
$this->assertEquals('Worker Kenya Out Country', $workers[0]['name']);
|
||||
|
||||
$response2 = $this->withHeaders([
|
||||
'Authorization' => 'Bearer ' . $this->token,
|
||||
])->getJson('/api/employers/workers?gender=female');
|
||||
|
||||
$response2->assertStatus(200);
|
||||
$workers2 = $response2->json('data.workers');
|
||||
$this->assertCount(2, $workers2);
|
||||
}
|
||||
}
|
||||
|
||||
@ -54,6 +54,7 @@ protected function setUp(): void
|
||||
'language' => 'EN',
|
||||
'availability' => 'Immediate',
|
||||
'bio' => 'Experienced helper',
|
||||
'gender' => 'female',
|
||||
]);
|
||||
WorkerDocument::create([
|
||||
'worker_id' => $w1->id,
|
||||
@ -83,6 +84,7 @@ protected function setUp(): void
|
||||
'language' => 'TL',
|
||||
'availability' => 'Immediate',
|
||||
'bio' => 'Ready to work',
|
||||
'gender' => 'male',
|
||||
]);
|
||||
WorkerDocument::create([
|
||||
'worker_id' => $w2->id,
|
||||
@ -205,4 +207,32 @@ public function test_employer_can_view_candidates_index_and_apply_filters()
|
||||
|
||||
$response2->assertStatus(200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test web workers index multiple nationality filtering.
|
||||
*/
|
||||
public function test_employer_workers_web_multiple_nationality_filtering()
|
||||
{
|
||||
$response = $this->actingAs($this->employer)
|
||||
->withSession(['user' => $this->employer])
|
||||
->get(route('employer.workers', ['nationality' => 'India,Philippines']));
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertSee('Worker India Dubai');
|
||||
$response->assertSee('Worker Philippines Abu Dhabi');
|
||||
}
|
||||
|
||||
/**
|
||||
* Test web workers index gender filtering.
|
||||
*/
|
||||
public function test_employer_workers_web_gender_filtering()
|
||||
{
|
||||
$response = $this->actingAs($this->employer)
|
||||
->withSession(['user' => $this->employer])
|
||||
->get(route('employer.workers', ['gender' => 'female']));
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertSee('Worker India Dubai');
|
||||
$response->assertDontSee('Worker Philippines Abu Dhabi');
|
||||
}
|
||||
}
|
||||
|
||||
@ -20,15 +20,18 @@ class SponsorAuthApiTest extends TestCase
|
||||
public function test_sponsor_can_register_with_license()
|
||||
{
|
||||
$licenseFile = UploadedFile::fake()->create('license.pdf', 500, 'application/pdf');
|
||||
$emiratesIdFile = UploadedFile::fake()->create('emirates_id.pdf', 500, 'application/pdf');
|
||||
|
||||
$response = $this->postJson('/api/sponsors/register', [
|
||||
'full_name' => 'Test Sponsor Organization',
|
||||
'mobile' => '+971509990001',
|
||||
'password' => 'securepassword',
|
||||
'license_file' => $licenseFile,
|
||||
'emirates_id_file' => $emiratesIdFile,
|
||||
'organization_name' => 'Charity Co',
|
||||
'nationality' => 'Emirati',
|
||||
'city' => 'Abu Dhabi',
|
||||
'license_expiry' => '2028-06-12',
|
||||
]);
|
||||
|
||||
$response->assertStatus(201)
|
||||
@ -44,6 +47,8 @@ public function test_sponsor_can_register_with_license()
|
||||
'nationality',
|
||||
'city',
|
||||
'license_file',
|
||||
'emirates_id_file',
|
||||
'license_expiry',
|
||||
],
|
||||
'token',
|
||||
]
|
||||
@ -52,7 +57,11 @@ public function test_sponsor_can_register_with_license()
|
||||
$this->assertDatabaseHas('sponsors', [
|
||||
'mobile' => '+971509990001',
|
||||
'full_name' => 'Test Sponsor Organization',
|
||||
'license_expiry' => '2028-06-12 00:00:00',
|
||||
]);
|
||||
|
||||
$sponsor = Sponsor::where('mobile', '+971509990001')->first();
|
||||
$this->assertNotNull($sponsor->emirates_id_file);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -325,6 +334,7 @@ public function test_sponsor_dashboard_returns_stats()
|
||||
'password' => Hash::make('password123'),
|
||||
'api_token' => 'sponsor_token_xyz',
|
||||
'status' => 'active',
|
||||
'license_expiry' => now()->addYear()->toDateString(),
|
||||
]);
|
||||
|
||||
// Create some employers (Users with role = employer)
|
||||
@ -391,6 +401,10 @@ public function test_sponsor_dashboard_returns_stats()
|
||||
->assertJson([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'sponsor' => [
|
||||
'license_expiry' => now()->addYear()->toDateString(),
|
||||
'validity' => 'Valid',
|
||||
],
|
||||
'employer_stats' => [
|
||||
'total' => 2,
|
||||
'active' => 1,
|
||||
@ -401,5 +415,20 @@ public function test_sponsor_dashboard_returns_stats()
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$profileResponse = $this->withHeaders([
|
||||
'Authorization' => 'Bearer sponsor_token_xyz',
|
||||
])->getJson('/api/sponsors/profile');
|
||||
|
||||
$profileResponse->assertStatus(200)
|
||||
->assertJson([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'sponsor' => [
|
||||
'license_expiry' => now()->addYear()->toDateString(),
|
||||
'validity' => 'Valid',
|
||||
]
|
||||
]
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
89
tests/Feature/SponsorAuthWebTest.php
Normal file
89
tests/Feature/SponsorAuthWebTest.php
Normal file
@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Sponsor;
|
||||
use App\Models\User;
|
||||
use App\Models\EmployerProfile;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Tests\TestCase;
|
||||
|
||||
class SponsorAuthWebTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/**
|
||||
* Test successful multi-step web employer registration with Emirates ID upload.
|
||||
*/
|
||||
public function test_employer_web_registration_with_emirates_id()
|
||||
{
|
||||
Storage::fake('public');
|
||||
|
||||
$emiratesIdFile = UploadedFile::fake()->create('emirates_id.pdf', 500, 'application/pdf');
|
||||
|
||||
// Step 1: Registration Form Submission
|
||||
$responseStep1 = $this->post('/employer/register', [
|
||||
'name' => 'Abdullah Ahmed',
|
||||
'email' => 'abdullah@example.com',
|
||||
'phone' => '501234567',
|
||||
'emirates_id_file' => $emiratesIdFile,
|
||||
]);
|
||||
|
||||
$responseStep1->assertRedirect(route('employer.verify-email'));
|
||||
|
||||
// Verify session data and file presence
|
||||
$pendingReg = session('pending_employer_registration');
|
||||
$this->assertNotNull($pendingReg);
|
||||
$this->assertEquals('Abdullah Ahmed', $pendingReg['name']);
|
||||
$this->assertNotNull($pendingReg['emirates_id_file']);
|
||||
$this->assertStringContainsString('uploads/licenses/', $pendingReg['emirates_id_file']);
|
||||
|
||||
// Step 2: OTP Verification (using local environment dummy code)
|
||||
$responseStep2 = $this->post('/employer/verify-email', [
|
||||
'otp' => '000000',
|
||||
]);
|
||||
|
||||
$responseStep2->assertRedirect(route('employer.register-payment'));
|
||||
$this->assertTrue(session('employer_email_verified'));
|
||||
|
||||
// Step 3: Payment Choice
|
||||
$responseStep3 = $this->post('/employer/register-payment', [
|
||||
'plan_id' => 'premium',
|
||||
'amount_aed' => 199.00,
|
||||
'paytabs_transaction_id' => 'TRAN_ABC_123',
|
||||
]);
|
||||
|
||||
$responseStep3->assertJson(['message' => 'Payment processed successfully.']);
|
||||
|
||||
// Step 4: Create Password & Create Records
|
||||
$responseStep4 = $this->post('/employer/create-password', [
|
||||
'password' => 'SecurePass123!',
|
||||
'password_confirmation' => 'SecurePass123!',
|
||||
]);
|
||||
|
||||
$responseStep4->assertRedirect(route('employer.dashboard'));
|
||||
|
||||
// Verify Database Records
|
||||
$this->assertDatabaseHas('users', [
|
||||
'email' => 'abdullah@example.com',
|
||||
'role' => 'employer',
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('employer_profiles', [
|
||||
'phone' => '501234567',
|
||||
'company_name' => 'Abdullah Ahmed Household',
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('sponsors', [
|
||||
'email' => 'abdullah@example.com',
|
||||
'mobile' => '501234567',
|
||||
]);
|
||||
|
||||
// Check Sponsor has the Emirates ID path matching the session stored file
|
||||
$sponsor = Sponsor::where('email', 'abdullah@example.com')->first();
|
||||
$this->assertNotNull($sponsor->emirates_id_file);
|
||||
$this->assertEquals($pendingReg['emirates_id_file'], $sponsor->emirates_id_file);
|
||||
}
|
||||
}
|
||||
@ -799,4 +799,109 @@ public function test_worker_new_announcements_flow()
|
||||
$response->assertStatus(200);
|
||||
$this->assertCount(0, $response->json('data.announcements'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test localized preferred locations endpoint.
|
||||
*/
|
||||
public function test_get_preferred_locations_localized()
|
||||
{
|
||||
// 1. Get English locations
|
||||
$response = $this->getJson('/api/preferred-locations?lang=en');
|
||||
$response->assertStatus(200)
|
||||
->assertJson([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'preferred_locations' => [
|
||||
['code' => 'dubai', 'name' => 'Dubai'],
|
||||
['code' => 'abu dhabi', 'name' => 'Abu Dhabi'],
|
||||
['code' => 'oman', 'name' => 'Oman'],
|
||||
]
|
||||
]
|
||||
]);
|
||||
|
||||
// 2. Get Hindi locations
|
||||
$response = $this->getJson('/api/preferred-locations?lang=hi');
|
||||
$response->assertStatus(200)
|
||||
->assertJson([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'preferred_locations' => [
|
||||
['code' => 'dubai', 'name' => 'दुबई'],
|
||||
['code' => 'abu dhabi', 'name' => 'अबू धाबी'],
|
||||
['code' => 'oman', 'name' => 'ओमान'],
|
||||
]
|
||||
]
|
||||
]);
|
||||
|
||||
// 3. Test pagination
|
||||
$response = $this->getJson('/api/preferred-locations?per_page=1&page=2');
|
||||
$response->assertStatus(200)
|
||||
->assertJson([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'preferred_locations' => [
|
||||
['code' => 'abu dhabi', 'name' => 'Abu Dhabi'],
|
||||
],
|
||||
'pagination' => [
|
||||
'total' => 3,
|
||||
'per_page' => 1,
|
||||
'current_page' => 2,
|
||||
'last_page' => 3
|
||||
]
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test localized preferred location areas endpoint.
|
||||
*/
|
||||
public function test_get_preferred_location_areas_localized()
|
||||
{
|
||||
// 1. Get English areas for Dubai (using path parameter)
|
||||
$response = $this->getJson('/api/preferred-locations/dubai/areas?lang=en');
|
||||
$response->assertStatus(200)
|
||||
->assertJson([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'location' => 'dubai',
|
||||
]
|
||||
]);
|
||||
$this->assertGreaterThan(5, count($response->json('data.areas')));
|
||||
$this->assertEquals('Dubai Marina', $response->json('data.areas.1.name'));
|
||||
|
||||
// 2. Get Hindi areas for Dubai (using query parameter)
|
||||
$response = $this->getJson('/api/preferred-locations/areas?city=dubai&lang=hi');
|
||||
$response->assertStatus(200)
|
||||
->assertJson([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'location' => 'dubai',
|
||||
]
|
||||
]);
|
||||
$this->assertEquals('दुबई मरीना', $response->json('data.areas.1.name'));
|
||||
|
||||
// 3. Test pagination
|
||||
$response = $this->getJson('/api/preferred-locations/oman/areas?per_page=2&page=2');
|
||||
$response->assertStatus(200)
|
||||
->assertJson([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'location' => 'oman',
|
||||
'pagination' => [
|
||||
'total' => 8,
|
||||
'per_page' => 2,
|
||||
'current_page' => 2,
|
||||
'last_page' => 4
|
||||
]
|
||||
]
|
||||
]);
|
||||
$this->assertCount(2, $response->json('data.areas'));
|
||||
|
||||
// 4. Test not found location
|
||||
$response = $this->getJson('/api/preferred-locations/sharjah/areas');
|
||||
$response->assertStatus(404)
|
||||
->assertJson([
|
||||
'success' => false,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
97
tests/Feature/WorkerSupportTicketApiTest.php
Normal file
97
tests/Feature/WorkerSupportTicketApiTest.php
Normal file
@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Worker;
|
||||
use App\Models\ReportReason;
|
||||
use App\Models\SupportTicket;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Tests\TestCase;
|
||||
|
||||
class WorkerSupportTicketApiTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected $worker;
|
||||
protected $token;
|
||||
protected $reason;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->token = 'worker-test-token-789';
|
||||
|
||||
$category = \App\Models\WorkerCategory::create([
|
||||
'name' => 'Housemaid',
|
||||
]);
|
||||
|
||||
$this->worker = Worker::create([
|
||||
'name' => 'John Worker',
|
||||
'email' => 'worker@example.com',
|
||||
'phone' => '+971500000009',
|
||||
'password' => bcrypt('password'),
|
||||
'api_token' => $this->token,
|
||||
'status' => 'Available',
|
||||
'category_id' => $category->id,
|
||||
'nationality' => 'Ethiopian',
|
||||
'age' => 28,
|
||||
'salary' => 1500.00,
|
||||
'availability' => 'Immediate',
|
||||
'experience' => '2 Years',
|
||||
'religion' => 'Christian',
|
||||
'bio' => 'Experienced helper.',
|
||||
]);
|
||||
|
||||
$this->reason = ReportReason::create([
|
||||
'reason' => 'App crashes when loading data',
|
||||
'status' => 'active',
|
||||
'type' => 'support',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_worker_can_create_ticket_with_reason_and_voice_note()
|
||||
{
|
||||
Storage::fake('public');
|
||||
|
||||
$voiceFile = UploadedFile::fake()->create('note.mp3', 500, 'audio/mpeg');
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer ' . $this->token,
|
||||
])->postJson('/api/workers/tickets', [
|
||||
'subject' => 'Issue with login',
|
||||
'description' => 'I cannot log in to the application.',
|
||||
'priority' => 'high',
|
||||
'reason_id' => $this->reason->id,
|
||||
'voice_note' => $voiceFile,
|
||||
]);
|
||||
|
||||
$response->assertStatus(201)
|
||||
->assertJsonStructure([
|
||||
'success',
|
||||
'message',
|
||||
'ticket' => [
|
||||
'id',
|
||||
'ticket_number',
|
||||
'subject',
|
||||
'description',
|
||||
'reason_id',
|
||||
'reason_name',
|
||||
'voice_note_url',
|
||||
'status',
|
||||
'priority',
|
||||
'created_at',
|
||||
]
|
||||
]);
|
||||
|
||||
$ticket = SupportTicket::first();
|
||||
$this->assertNotNull($ticket);
|
||||
$this->assertEquals('Issue with login', $ticket->subject);
|
||||
$this->assertEquals($this->reason->id, $ticket->reason_id);
|
||||
$this->assertNotNull($ticket->voice_note_path);
|
||||
|
||||
Storage::disk('public')->assertExists($ticket->voice_note_path);
|
||||
}
|
||||
}
|
||||
@ -23,7 +23,7 @@ export default defineConfig({
|
||||
port: 5173,
|
||||
cors: true,
|
||||
hmr: {
|
||||
host: '10.150.39.221',
|
||||
host: '192.168.29.131',
|
||||
},
|
||||
watch: {
|
||||
ignored: ['**/storage/framework/views/**'],
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user