diff --git a/app/Http/Controllers/Admin/AdminExtraController.php b/app/Http/Controllers/Admin/AdminExtraController.php
index e4eda98..68b3ef9 100644
--- a/app/Http/Controllers/Admin/AdminExtraController.php
+++ b/app/Http/Controllers/Admin/AdminExtraController.php
@@ -734,6 +734,9 @@ public function storeAnnouncement(Request $request)
'event_time' => 'required|string',
'location_details' => 'required|string',
'location_pin' => 'required|string|url',
+ 'contact_person_name' => 'required|string|max:255',
+ 'contact_number' => 'required|string|max:50',
+ 'country_code' => 'nullable|string|max:10',
]);
$body = json_encode([
@@ -744,6 +747,9 @@ public function storeAnnouncement(Request $request)
'location_details' => $request->location_details,
'location_pin' => $request->location_pin,
'content' => $request->content,
+ 'contact_person_name' => $request->contact_person_name,
+ 'contact_number' => $request->contact_number,
+ 'country_code' => $request->country_code ?? '+971',
]);
$ann = \App\Models\Announcement::create([
@@ -801,7 +807,7 @@ public function deleteAnnouncement(Request $request, $id)
}
/**
- * Broadcast FCM Push Notification for new Event/Announcement.
+ * Broadcast FCM Push Notification and save to Database for new Event/Announcement.
*/
protected function notifyUsersOfEvent($ann)
{
@@ -816,31 +822,29 @@ protected function notifyUsersOfEvent($ann)
}
// Notify all workers
- $workers = \App\Models\Worker::whereNotNull('fcm_token')->get();
+ $workers = \App\Models\Worker::all();
foreach ($workers as $worker) {
- \App\Services\FCMService::sendPushNotification(
- $worker->fcm_token,
+ $worker->notify(new \App\Notifications\GenericNotification(
$title,
$body,
[
'type' => 'announcement',
'announcement_id' => $ann->id,
]
- );
+ ));
}
// Notify all employers
- $employers = \App\Models\User::where('role', 'employer')->whereNotNull('fcm_token')->get();
+ $employers = \App\Models\User::where('role', 'employer')->get();
foreach ($employers as $employer) {
- \App\Services\FCMService::sendPushNotification(
- $employer->fcm_token,
+ $employer->notify(new \App\Notifications\GenericNotification(
$title,
$body,
[
'type' => 'announcement',
'announcement_id' => $ann->id,
]
- );
+ ));
}
}
}
diff --git a/app/Http/Controllers/Api/EmployerAuthController.php b/app/Http/Controllers/Api/EmployerAuthController.php
index 017be9e..d371462 100644
--- a/app/Http/Controllers/Api/EmployerAuthController.php
+++ b/app/Http/Controllers/Api/EmployerAuthController.php
@@ -324,14 +324,6 @@ public function register(Request $request)
'address' => $request->address,
]);
- if ($request->filled('fcm_token')) {
- \App\Services\FCMService::sendPushNotification(
- $request->fcm_token,
- 'Registration Initiated',
- 'Your verification code has been sent to your email.'
- );
- }
-
// Try sending email
try {
\Illuminate\Support\Facades\Mail::to($request->email)->send(new \App\Mail\EmployerOtpMail(
diff --git a/app/Http/Controllers/Api/EmployerWorkerController.php b/app/Http/Controllers/Api/EmployerWorkerController.php
index 56faa72..031608e 100644
--- a/app/Http/Controllers/Api/EmployerWorkerController.php
+++ b/app/Http/Controllers/Api/EmployerWorkerController.php
@@ -852,10 +852,10 @@ public function hireCandidate(Request $request, $id = null)
));
} else if ($updatedOffer) {
$worker->notify(new \App\Notifications\GenericNotification(
- "New Job Offer",
- "Employer " . ($employer->name ?? "Employer") . " has sent you a direct hire offer.",
+ "Direct Hiring Offer",
+ ($employer->name ?? "Employer") . " wants to hire you. Do you want to accept this job offer?",
[
- 'type' => 'hire_request',
+ 'type' => 'direct_hire_request',
'offer_id' => $updatedOffer->id,
'status' => 'pending',
]
diff --git a/app/Http/Controllers/Api/WorkerJobController.php b/app/Http/Controllers/Api/WorkerJobController.php
index 85ef88f..f883ad9 100644
--- a/app/Http/Controllers/Api/WorkerJobController.php
+++ b/app/Http/Controllers/Api/WorkerJobController.php
@@ -1108,22 +1108,6 @@ private function triggerStatusNotification($application, $status)
$job = $application->jobPost;
$employer = $job ? $job->employer : null;
- // When a new application is submitted: notify employer
- if ($status === 'applied') {
- if ($employer) {
- $employer->notify(new \App\Notifications\GenericNotification(
- "New Job Application",
- "A candidate has applied for your job: " . ($job->title ?? 'Job Post'),
- [
- 'type' => 'job_application_submitted',
- 'job_id' => $job->id,
- 'application_id' => $application->id,
- ]
- ));
- }
- return;
- }
-
// When status is updated: notify worker
if ($worker) {
$title = "Job Application Update";
@@ -1134,6 +1118,7 @@ private function triggerStatusNotification($application, $status)
$body = "You have been shortlisted for the job: " . ($job->title ?? 'Job Post');
$type = 'shortlisted';
break;
+ case 'applied':
case 'review':
case 'reviewing':
$body = "Your application is under review for the job: " . ($job->title ?? 'Job Post');
diff --git a/app/Http/Controllers/Employer/AnnouncementController.php b/app/Http/Controllers/Employer/AnnouncementController.php
index 34f0ae4..578e770 100644
--- a/app/Http/Controllers/Employer/AnnouncementController.php
+++ b/app/Http/Controllers/Employer/AnnouncementController.php
@@ -72,6 +72,9 @@ public function store(Request $request)
'event_time' => 'required|string',
'location_details' => 'required|string',
'location_pin' => 'required|string|url',
+ 'contact_person_name' => 'required|string|max:255',
+ 'contact_number' => 'required|string|max:50',
+ 'country_code' => 'nullable|string|max:10',
]);
$body = json_encode([
@@ -82,6 +85,9 @@ public function store(Request $request)
'location_details' => $request->location_details,
'location_pin' => $request->location_pin,
'content' => $request->content,
+ 'contact_person_name' => $request->contact_person_name,
+ 'contact_number' => $request->contact_number,
+ 'country_code' => $request->country_code ?? '+971',
]);
$sess = session('user');
diff --git a/app/Http/Controllers/Employer/CandidateController.php b/app/Http/Controllers/Employer/CandidateController.php
index f53d200..a71efef 100644
--- a/app/Http/Controllers/Employer/CandidateController.php
+++ b/app/Http/Controllers/Employer/CandidateController.php
@@ -341,10 +341,10 @@ public function updateStatus(Request $request, $id)
// Notify worker about the hire offer if it is set to pending
if ($dbStatus === 'pending') {
$offer->worker->notify(new \App\Notifications\GenericNotification(
- "Action Required: Confirm Hiring",
- "Employer " . ($offer->employer->name ?? "Employer") . " has sent you a direct hire offer. Please confirm.",
+ "Direct Hiring Offer",
+ ($offer->employer->name ?? "Employer") . " wants to hire you. Do you want to accept this job offer?",
[
- 'type' => 'hire_request',
+ 'type' => 'direct_hire_request',
'offer_id' => $offer->id,
'status' => 'pending',
]
@@ -458,22 +458,6 @@ private function triggerStatusNotification($application, $status)
$job = $application->jobPost;
$employer = $job ? $job->employer : null;
- // When a new application is submitted: notify employer
- if ($status === 'applied') {
- if ($employer) {
- $employer->notify(new \App\Notifications\GenericNotification(
- "New Job Application",
- "A candidate has applied for your job: " . ($job->title ?? 'Job Post'),
- [
- 'type' => 'job_application_submitted',
- 'job_id' => $job->id,
- 'application_id' => $application->id,
- ]
- ));
- }
- return;
- }
-
// When status is updated: notify worker
if ($worker) {
$title = "Job Application Update";
@@ -484,6 +468,7 @@ private function triggerStatusNotification($application, $status)
$body = "You have been shortlisted for the job: " . ($job->title ?? 'Job Post');
$type = 'shortlisted';
break;
+ case 'applied':
case 'review':
case 'reviewing':
$body = "Your application is under review for the job: " . ($job->title ?? 'Job Post');
diff --git a/app/Http/Controllers/Employer/WorkerController.php b/app/Http/Controllers/Employer/WorkerController.php
index e2c08a0..41abe6e 100644
--- a/app/Http/Controllers/Employer/WorkerController.php
+++ b/app/Http/Controllers/Employer/WorkerController.php
@@ -497,6 +497,8 @@ public function markHired(Request $request, $id)
->where('worker_id', $worker->id)
->first();
+ $application = null;
+
if ($offer) {
$offer->update(['status' => 'pending']);
} else {
@@ -531,16 +533,28 @@ public function markHired(Request $request, $id)
}
// Notify the worker
- $worker->notify(new \App\Notifications\GenericNotification(
- "Action Required: Confirm Hiring",
- "Employer " . ($user->name ?? "Employer") . " has sent you a hire request. Please confirm.",
- [
- 'type' => 'hire_request',
- 'offer_id' => $offer->id ?? '',
- 'application_id' => $application->id ?? '',
- 'status' => $offer ? 'pending' : 'hire_requested',
- ]
- ));
+ if ($offer) {
+ $worker->notify(new \App\Notifications\GenericNotification(
+ "Direct Hiring Offer",
+ ($user->name ?? "Employer") . " wants to hire you. Do you want to accept this job offer?",
+ [
+ 'type' => 'direct_hire_request',
+ 'offer_id' => $offer->id,
+ 'status' => 'pending',
+ ]
+ ));
+ } else {
+ $worker->notify(new \App\Notifications\GenericNotification(
+ "Action Required: Confirm Hiring",
+ "Employer " . ($user->name ?? "Employer") . " wants to hire you for '" . ($application->jobPost->title ?? 'Job Post') . "'. Please confirm.",
+ [
+ 'type' => 'hire_request',
+ 'job_id' => $application->jobPost->id ?? '',
+ 'application_id' => $application->id,
+ 'status' => 'hire_requested',
+ ]
+ ));
+ }
return back()->with('success', 'Hire request initiated successfully! Awaiting candidate confirmation.');
}
diff --git a/app/Models/JobPost.php b/app/Models/JobPost.php
index c4efafe..f014fce 100644
--- a/app/Models/JobPost.php
+++ b/app/Models/JobPost.php
@@ -10,6 +10,34 @@ class JobPost extends Model
{
use HasFactory, SoftDeletes;
+ protected static function booted()
+ {
+ static::updated(function ($jobPost) {
+ if ($jobPost->wasChanged('status')) {
+ $oldStatus = $jobPost->getOriginal('status');
+ $newStatus = $jobPost->status;
+
+ // Send push notification to all workers who applied to this job
+ $applications = $jobPost->applications()->with('worker')->get();
+ foreach ($applications as $app) {
+ $worker = $app->worker;
+ if ($worker) {
+ $worker->notify(new \App\Notifications\GenericNotification(
+ "Job Status Changed",
+ "The status of the job '" . $jobPost->title . "' has been changed from '" . ucfirst($oldStatus) . "' to '" . ucfirst($newStatus) . "'.",
+ [
+ 'type' => 'job_status_change',
+ 'job_id' => $jobPost->id,
+ 'old_status' => $oldStatus,
+ 'new_status' => $newStatus,
+ ]
+ ));
+ }
+ }
+ }
+ });
+ }
+
protected $fillable = [
'employer_id',
'title',
diff --git a/app/Models/Worker.php b/app/Models/Worker.php
index bbf1926..1e07b2a 100644
--- a/app/Models/Worker.php
+++ b/app/Models/Worker.php
@@ -11,6 +11,27 @@ class Worker extends Model
{
use HasFactory, SoftDeletes, Notifiable;
+ protected static function booted()
+ {
+ static::updated(function ($worker) {
+ if ($worker->wasChanged('status')) {
+ $oldStatus = $worker->getOriginal('status');
+ $newStatus = $worker->status;
+
+ $worker->notify(new \App\Notifications\GenericNotification(
+ "Profile Status Update",
+ "Your profile status has changed from '" . ucfirst($oldStatus) . "' to '" . ucfirst($newStatus) . "'.",
+ [
+ 'type' => 'worker_status_change',
+ 'worker_id' => $worker->id,
+ 'old_status' => $oldStatus,
+ 'new_status' => $newStatus,
+ ]
+ ));
+ }
+ });
+ }
+
protected $fillable = [
'name',
'email',
diff --git a/app/Services/FCMService.php b/app/Services/FCMService.php
index f6d3c00..e09052c 100644
--- a/app/Services/FCMService.php
+++ b/app/Services/FCMService.php
@@ -59,7 +59,7 @@ public static function getAccessToken()
$base64UrlSignature = self::base64UrlEncode($signature);
$jwtToken = $signatureInput . '.' . $base64UrlSignature;
- $response = Http::asForm()->post('https://oauth2.googleapis.com/token', [
+ $response = Http::withoutVerifying()->asForm()->post('https://oauth2.googleapis.com/token', [
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
'assertion' => $jwtToken,
]);
@@ -140,7 +140,7 @@ public static function sendPushNotification($token, $title, $body, array $data =
$payload['message']['data'] = $formattedData;
}
- $response = Http::withToken($accessToken)
+ $response = Http::withoutVerifying()->withToken($accessToken)
->post("https://fcm.googleapis.com/v1/projects/{$projectId}/messages:send", $payload);
if ($response->failed()) {
diff --git a/resources/js/Pages/Admin/Events/Index.jsx b/resources/js/Pages/Admin/Events/Index.jsx
index 875b5ca..a9b9b1d 100644
--- a/resources/js/Pages/Admin/Events/Index.jsx
+++ b/resources/js/Pages/Admin/Events/Index.jsx
@@ -6,6 +6,15 @@ import {
Calendar, MapPin, Clock, Gift, Sparkles, Search,
ChevronDown, ChevronUp, Building, User, XCircle, Eye, BellRing
} from 'lucide-react';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+ DialogFooter
+} from '@/components/ui/dialog';
const hoursList = [
'12:00', '12:30', '01:00', '01:30', '02:00', '02:30',
@@ -17,6 +26,7 @@ const hoursList = [
export default function Announcements({ initialAnnouncements }) {
const [announcements, setAnnouncements] = useState(initialAnnouncements || []);
const [isFormOpen, setIsFormOpen] = useState(false);
+ const [selectedAnnouncement, setSelectedAnnouncement] = useState(null);
const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState('All');
const [expandedCards, setExpandedCards] = useState({});
@@ -36,7 +46,10 @@ export default function Announcements({ initialAnnouncements }) {
event_date: '',
event_time: '09:00 AM - 04:00 PM',
location_details: '',
- location_pin: ''
+ location_pin: '',
+ contact_person_name: '',
+ contact_number: '',
+ country_code: '+971'
});
const [errors, setErrors] = useState({});
const [toastMessage, setToastMessage] = useState(null);
@@ -62,6 +75,8 @@ export default function Announcements({ initialAnnouncements }) {
} else if (!newAnnouncement.location_pin.startsWith('http://') && !newAnnouncement.location_pin.startsWith('https://')) {
newErrors.location_pin = 'Must be a valid URL';
}
+ if (!newAnnouncement.contact_person_name.trim()) newErrors.contact_person_name = 'Contact person name is required';
+ if (!newAnnouncement.contact_number.trim()) newErrors.contact_number = 'Contact number is required';
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors);
@@ -75,7 +90,10 @@ export default function Announcements({ initialAnnouncements }) {
event_date: newAnnouncement.event_date,
event_time: newAnnouncement.event_time,
location_details: newAnnouncement.location_details,
- location_pin: newAnnouncement.location_pin
+ location_pin: newAnnouncement.location_pin,
+ contact_person_name: newAnnouncement.contact_person_name,
+ contact_number: newAnnouncement.contact_number,
+ country_code: newAnnouncement.country_code
}, {
onSuccess: () => {
setNewAnnouncement({
@@ -85,7 +103,10 @@ export default function Announcements({ initialAnnouncements }) {
event_date: '',
event_time: '09:00 AM - 04:00 PM',
location_details: '',
- location_pin: ''
+ location_pin: '',
+ contact_person_name: '',
+ contact_number: '',
+ country_code: '+971'
});
setStartTimeHour('09:00');
setStartTimeAmpm('AM');
@@ -509,6 +530,39 @@ export default function Announcements({ initialAnnouncements }) {
+
+
+
Contact Person Name
+
setNewAnnouncement({ ...newAnnouncement, contact_person_name: e.target.value })}
+ className={`w-full px-4 py-2.5 rounded-xl border ${errors.contact_person_name ? 'border-red-500' : 'border-slate-300'} text-sm focus:ring-2 focus:ring-[#0F6E56]/20 focus:border-[#0F6E56] outline-none transition-all`}
+ placeholder="e.g. Jane Doe"
+ />
+ {errors.contact_person_name &&
{errors.contact_person_name}
}
+
+
+
Contact Mobile Number
+
+ setNewAnnouncement({ ...newAnnouncement, country_code: e.target.value })}
+ className="w-16 px-2 py-2.5 bg-white border border-slate-300 rounded-xl text-xs font-bold focus:ring-2 focus:ring-[#0F6E56]/20 focus:border-[#0F6E56] outline-none text-center"
+ />
+ setNewAnnouncement({ ...newAnnouncement, contact_number: e.target.value })}
+ className={`flex-1 px-4 py-2.5 rounded-xl border ${errors.contact_number ? 'border-red-500' : 'border-slate-300'} text-sm focus:ring-2 focus:ring-[#0F6E56]/20 focus:border-[#0F6E56] outline-none transition-all`}
+ placeholder="551234567"
+ />
+
+ {errors.contact_number &&
{errors.contact_number}
}
+
+
+
Description & Instructions
)}
+
+ {/* Event Detail Popup Modal */}
+ !open && setSelectedAnnouncement(null)}>
+
+ {selectedAnnouncement && (
+ <>
+
+
+
+
+ {selectedAnnouncement.status}
+
+
+
+ Charity
+
+
+
+ {selectedAnnouncement.title}
+
+
+ Sponsor: {selectedAnnouncement.organization} ({selectedAnnouncement.posted_by})
+ Published: {selectedAnnouncement.created_at}
+
+
+
+
+
+
Description & Instructions
+
+ {selectedAnnouncement.content}
+
+
+
+ {selectedAnnouncement.charityDetails && (
+
+
+
+
+ Items Provided
+
+
+ {selectedAnnouncement.charityDetails.provided_items}
+
+
+
+
+
+
+ Event Date
+
+
+ {selectedAnnouncement.charityDetails.event_date}
+
+
+
+
+
+
+ Event Time
+
+
+ {selectedAnnouncement.charityDetails.event_time}
+
+
+
+
+
+
+ Location
+
+
+ {selectedAnnouncement.charityDetails.location_details}
+
+
+
+ {selectedAnnouncement.charityDetails.contact_person_name && (
+
+
+
+ Contact Person Details
+
+
+ Name: {selectedAnnouncement.charityDetails.contact_person_name}
+ Mobile: {selectedAnnouncement.charityDetails.country_code || '+971'} {selectedAnnouncement.charityDetails.contact_number}
+
+
+ )}
+
+ )}
+
+
+ {selectedAnnouncement.charityDetails?.location_pin && (
+
+
+ View on Map
+
+ )}
+
setSelectedAnnouncement(null)}
+ className="flex-1 bg-[#0F6E56] hover:bg-[#0b523f] text-white py-3 rounded-2xl text-xs font-black uppercase tracking-widest cursor-pointer border-none shadow-md shadow-[#0F6E56]/25"
+ >
+ Close
+
+
+
+ >
+ )}
+
+
);
}
diff --git a/resources/js/Pages/Employer/Announcements.jsx b/resources/js/Pages/Employer/Announcements.jsx
index 5368fb8..48ef082 100644
--- a/resources/js/Pages/Employer/Announcements.jsx
+++ b/resources/js/Pages/Employer/Announcements.jsx
@@ -22,7 +22,8 @@ import {
ChevronDown,
ChevronUp,
Gift,
- Plus
+ Plus,
+ User
} from 'lucide-react';
import {
Dialog,
@@ -44,6 +45,7 @@ const hoursList = [
export default function Announcements({ initialAnnouncements }) {
const { t } = useTranslation();
const [isDialogOpen, setIsDialogOpen] = useState(false);
+ const [selectedAnnouncement, setSelectedAnnouncement] = useState(null);
const [searchTerm, setSearchTerm] = useState('');
const [toastMessage, setToastMessage] = useState(null);
const [toastSub, setToastSub] = useState(null);
@@ -72,7 +74,10 @@ export default function Announcements({ initialAnnouncements }) {
event_date: '',
event_time: '09:00 AM - 04:00 PM',
location_details: '',
- location_pin: ''
+ location_pin: '',
+ contact_person_name: '',
+ contact_number: '',
+ country_code: '+971'
});
const updateEventTime = (sh, sa, eh, ea) => {
@@ -165,7 +170,7 @@ export default function Announcements({ initialAnnouncements }) {
{t('post_charity_event', 'Post Charity Event')}
-
+
{t('post_charity_event', 'Post Charity Event')}
@@ -366,6 +371,41 @@ export default function Announcements({ initialAnnouncements }) {
className="w-full px-3.5 py-2.5 bg-white border border-slate-200 rounded-xl text-xs font-bold focus:ring-2 focus:ring-[#185FA5]/20 outline-none"
/>
+
+
+
+ {t('contact_person_name', 'Contact Person Name')}
+ setNewAnnouncement({ ...newAnnouncement, contact_person_name: e.target.value })}
+ className="w-full px-3.5 py-2.5 bg-white border border-slate-200 rounded-xl text-xs font-bold focus:ring-2 focus:ring-[#185FA5]/20 outline-none"
+ />
+
+
+
@@ -462,7 +502,8 @@ export default function Announcements({ initialAnnouncements }) {
return (
setSelectedAnnouncement(ann)}
+ className={`bg-white rounded-2xl border border-slate-200 border-l-4 ${statusColorClass} shadow-sm hover:shadow-md transition-all flex flex-col justify-between overflow-hidden cursor-pointer`}
>
{/* Top Card Area */}
@@ -494,7 +535,7 @@ export default function Announcements({ initialAnnouncements }) {
{ann.content.length > 90 && (
toggleExpand(ann.id)}
+ onClick={(e) => { e.stopPropagation(); toggleExpand(ann.id); }}
className="text-[#185FA5] hover:underline font-extrabold flex items-center space-x-0.5 border-none bg-transparent cursor-pointer p-0 text-[10px]"
>
{isExpanded ? t('show_less', 'Show Less') : t('read_full_description', 'Read Full Description')}
@@ -502,7 +543,7 @@ export default function Announcements({ initialAnnouncements }) {
)}
{ann.status === 'rejected' && ann.remarks && (
-
+
e.stopPropagation()}>
{t('rejection_remarks_label', 'Remarks')}: {ann.remarks}
)}
@@ -531,6 +572,16 @@ export default function Announcements({ initialAnnouncements }) {
)}
+
+ {/* Contact details row */}
+ {details && details.contact_person_name && (
+
+
+
+ {details.contact_person_name} : {details.country_code || '+971'} {details.contact_number}
+
+
+ )}
{/* Bottom Card Footer */}
@@ -541,7 +592,8 @@ export default function Announcements({ initialAnnouncements }) {
href={details.location_pin}
target="_blank"
rel="noreferrer"
- className="text-[#185FA5] hover:underline flex items-center space-x-0.5 normal-case"
+ onClick={(e) => e.stopPropagation()}
+ className="text-[#185FA5] hover:underline flex items-center space-x-0.5 normal-case animate-none"
>
{t('view_on_map', 'View on Map')}
@@ -553,6 +605,121 @@ export default function Announcements({ initialAnnouncements }) {
})
)}
+
+ {/* Event Detail Popup Modal */}
+ !open && setSelectedAnnouncement(null)}>
+
+ {selectedAnnouncement && (
+ <>
+
+
+
+
+ {selectedAnnouncement.status}
+
+
+
+ Charity
+
+
+
+ {selectedAnnouncement.title}
+
+
+ {t('posted_label', 'Posted')}: {selectedAnnouncement.created_at}
+
+
+
+
+
+
{t('description', 'Description')}
+
+ {selectedAnnouncement.content}
+
+
+
+ {selectedAnnouncement.charityDetails && (
+
+
+
+
+ {t('items_provided', 'Items Provided')}
+
+
+ {selectedAnnouncement.charityDetails.provided_items}
+
+
+
+
+
+
+ {t('event_date', 'Event Date')}
+
+
+ {selectedAnnouncement.charityDetails.event_date}
+
+
+
+
+
+
+ {t('event_time', 'Event Time')}
+
+
+ {selectedAnnouncement.charityDetails.event_time}
+
+
+
+
+
+
+ {t('location', 'Location')}
+
+
+ {selectedAnnouncement.charityDetails.location_details}
+
+
+
+ {selectedAnnouncement.charityDetails.contact_person_name && (
+
+
+
+ {t('contact_person_details', 'Contact Person Details')}
+
+
+ Name: {selectedAnnouncement.charityDetails.contact_person_name}
+ Mobile: {selectedAnnouncement.charityDetails.country_code || '+971'} {selectedAnnouncement.charityDetails.contact_number}
+
+
+ )}
+
+ )}
+
+
+ {selectedAnnouncement.charityDetails?.location_pin && (
+
+
+ {t('view_on_map', 'View on Map')}
+
+ )}
+
setSelectedAnnouncement(null)}
+ className="flex-1 bg-[#185FA5] hover:bg-[#14508c] text-white py-3 rounded-2xl text-xs font-black uppercase tracking-widest cursor-pointer border-none shadow-md shadow-[#185FA5]/25"
+ >
+ {t('close', 'Close')}
+
+
+
+ >
+ )}
+
+
);
diff --git a/resources/js/Pages/Employer/Workers/Show.jsx b/resources/js/Pages/Employer/Workers/Show.jsx
index c9903ae..42148d3 100644
--- a/resources/js/Pages/Employer/Workers/Show.jsx
+++ b/resources/js/Pages/Employer/Workers/Show.jsx
@@ -290,21 +290,12 @@ export default function Show({ worker }) {
{t('start_direct_chat', 'Start Direct Chat')}
- {/* Stage 4 Hire: Mark Hired (Finalize Hiring Direct Flow) */}
- {availabilityStatus === 'Pending Confirmation' ? (
+ {/* Stage 4 Hire: Pending Confirmation Indicator */}
+ {availabilityStatus === 'Pending Confirmation' && (
{t('pending_confirmation', 'Pending Confirmation')}
- ) : availabilityStatus !== 'Hired' && (
- setShowHireConfirmModal(true)}
- className="bg-emerald-600 hover:bg-emerald-700 text-white border border-emerald-700 px-8 py-3 rounded-xl font-black text-sm shadow-md transition-all flex items-center justify-center space-x-2 flex-1 sm:flex-initial scale-105 active:scale-100"
- >
-
- {t('mark_hired_action', 'Mark Hired')}
-
)}
@@ -1093,43 +1084,7 @@ export default function Show({ worker }) {
)}
- {/* Hired Confirmation Modal */}
- {showHireConfirmModal && (
-
-
-
-
-
-
-
-
{t('confirm_hire_title', 'Confirm Hiring')}
-
- {t('confirm_hire_desc', 'Verify this worker with your needs and make sure to hire this worker. If hired, you can view this worker in the Hired Workers page.')}
-
-
-
-
- setShowHireConfirmModal(false)}
- className="flex-1 py-3 border border-slate-200 hover:bg-slate-50 rounded-xl transition-colors"
- >
- {t('cancel', 'Cancel')}
-
- {
- setShowHireConfirmModal(false);
- handleMarkHired();
- }}
- className="flex-1 bg-emerald-600 hover:bg-emerald-700 text-white py-3 rounded-xl shadow-sm transition-colors"
- >
- {t('confirm_hire_action', 'Confirm Hire')}
-
-
-
-
- )}
+
);
diff --git a/tests/Feature/AnnouncementApiTest.php b/tests/Feature/AnnouncementApiTest.php
index 0a3e0ec..cdb9b96 100644
--- a/tests/Feature/AnnouncementApiTest.php
+++ b/tests/Feature/AnnouncementApiTest.php
@@ -432,5 +432,119 @@ public function test_employer_can_post_and_get_charity_announcement_with_contact
$this->assertEquals('551234567', $myPostedAnn['charity_details']['contact_number']);
$this->assertEquals('+971', $myPostedAnn['charity_details']['country_code']);
}
+
+ /**
+ * Test web employer can create charity event with contact details.
+ */
+ public function test_web_employer_can_create_charity_event()
+ {
+ $response = $this->actingAs($this->employer)
+ ->post(route('employer.events.store'), [
+ 'title' => 'Web Charity Camp',
+ 'content' => 'Giving away free wellness items and tools.',
+ 'provided_items' => 'Wellness Kits',
+ 'event_date' => '2026-08-20',
+ 'event_time' => '10:00 AM - 3:00 PM',
+ 'location_details' => 'Safa Park, Dubai',
+ 'location_pin' => 'https://maps.app.goo.gl/xyz',
+ 'contact_person_name' => 'John Doe',
+ 'contact_number' => '559876543',
+ 'country_code' => '+971',
+ ]);
+
+ $response->assertStatus(302); // Redirect on success
+
+ $this->assertDatabaseHas('announcements', [
+ 'title' => 'Web Charity Camp',
+ 'employer_id' => $this->employer->id,
+ 'status' => 'pending',
+ ]);
+
+ $announcement = Announcement::where('title', 'Web Charity Camp')->first();
+ $this->assertNotNull($announcement);
+ $body = json_decode($announcement->body, true);
+ $this->assertEquals('John Doe', $body['contact_person_name']);
+ $this->assertEquals('559876543', $body['contact_number']);
+ $this->assertEquals('+971', $body['country_code']);
+ }
+
+ /**
+ * Test web admin can create charity event with contact details.
+ */
+ public function test_web_admin_can_create_charity_event()
+ {
+ $admin = User::create([
+ 'name' => 'Admin User',
+ 'email' => 'admin@example.com',
+ 'password' => bcrypt('password'),
+ 'role' => 'admin',
+ ]);
+
+ $response = $this->actingAs($admin)
+ ->withSession(['user' => $admin])
+ ->post(route('admin.events.store'), [
+ 'title' => 'Admin Charity Camp',
+ 'content' => 'Giving away free medical checkups.',
+ 'provided_items' => 'Medical Checkups',
+ 'event_date' => '2026-09-10',
+ 'event_time' => '08:00 AM - 05:00 PM',
+ 'location_details' => 'Community Center',
+ 'location_pin' => 'https://maps.app.goo.gl/abc',
+ 'contact_person_name' => 'Admin Coordinator',
+ 'contact_number' => '501234567',
+ 'country_code' => '+971',
+ ]);
+
+ $response->assertStatus(302); // Redirect on success
+
+ $this->assertDatabaseHas('announcements', [
+ 'title' => 'Admin Charity Camp',
+ 'status' => 'approved', // Admin events are auto-approved
+ ]);
+
+ $announcement = Announcement::where('title', 'Admin Charity Camp')->first();
+ $this->assertNotNull($announcement);
+ $body = json_decode($announcement->body, true);
+ $this->assertEquals('Admin Coordinator', $body['contact_person_name']);
+ $this->assertEquals('501234567', $body['contact_number']);
+ $this->assertEquals('+971', $body['country_code']);
+ }
+
+ /**
+ * Test admin approving announcement creates database notification for worker.
+ */
+ public function test_admin_approving_announcement_creates_database_notification()
+ {
+ $admin = User::create([
+ 'name' => 'Admin User',
+ 'email' => 'admin@example.com',
+ 'password' => bcrypt('password'),
+ 'role' => 'admin',
+ ]);
+
+ $announcement = Announcement::create([
+ 'title' => 'Important Community Event',
+ 'body' => 'Event description.',
+ 'type' => 'Charity',
+ 'employer_id' => $this->employer->id,
+ 'status' => 'pending',
+ ]);
+
+ $this->assertEquals(0, $this->worker->notifications()->count());
+
+ $response = $this->actingAs($admin)
+ ->withSession(['user' => $admin])
+ ->post(route('admin.events.approve', $announcement->id));
+
+ $response->assertStatus(302);
+
+ // Verify database notification exists for worker
+ $this->assertEquals(1, $this->worker->notifications()->count());
+ $notification = $this->worker->notifications()->first();
+ $this->assertNotNull($notification);
+ $this->assertEquals('New Event: Important Community Event', $notification->data['title']);
+ $this->assertEquals('announcement', $notification->data['type']);
+ $this->assertEquals($announcement->id, $notification->data['announcement_id']);
+ }
}
diff --git a/tests/Feature/NotificationApiTest.php b/tests/Feature/NotificationApiTest.php
index 507bdac..f967315 100644
--- a/tests/Feature/NotificationApiTest.php
+++ b/tests/Feature/NotificationApiTest.php
@@ -245,4 +245,64 @@ public function test_employer_can_delete_single_and_all_notifications()
$response->assertJsonPath('success', true);
$this->assertEquals(0, $employer->fresh()->notifications()->count());
}
+
+ public function test_push_notification_sent_when_job_status_changes()
+ {
+ $worker = $this->createTestWorker();
+ $employer = $this->createTestEmployer();
+
+ $jobPost = \App\Models\JobPost::create([
+ 'employer_id' => $employer->id,
+ 'title' => 'Test Maid Job',
+ 'main_profession' => 'Housekeeper',
+ 'workers_needed' => 1,
+ 'job_type' => 'Full Time',
+ 'location' => 'Dubai',
+ 'salary' => 2000,
+ 'start_date' => now()->addDays(7),
+ 'description' => 'Test description',
+ 'status' => 'active',
+ ]);
+
+ \App\Models\JobApplication::create([
+ 'worker_id' => $worker->id,
+ 'job_id' => $jobPost->id,
+ 'status' => 'applied'
+ ]);
+
+ $this->assertEquals(0, $worker->notifications()->count());
+
+ // Update JobPost status
+ $jobPost->update(['status' => 'closed']);
+
+ $notifications = $worker->fresh()->notifications;
+ $this->assertEquals(1, $notifications->count());
+ $notification = $notifications->first();
+ $this->assertEquals('Job Status Changed', $notification->data['title']);
+ $this->assertEquals('job_status_change', $notification->data['type']);
+ $this->assertEquals($jobPost->id, $notification->data['job_id']);
+ $this->assertEquals('active', $notification->data['old_status']);
+ $this->assertEquals('closed', $notification->data['new_status']);
+ }
+
+ public function test_push_notification_sent_when_worker_status_changes()
+ {
+ $worker = $this->createTestWorker([
+ 'status' => 'active'
+ ]);
+
+ $this->assertEquals(0, $worker->notifications()->count());
+
+ // Update Worker status
+ $worker->update(['status' => 'Hired']);
+
+ $notifications = $worker->fresh()->notifications;
+ $this->assertEquals(1, $notifications->count());
+ $notification = $notifications->first();
+ $this->assertEquals('Profile Status Update', $notification->data['title']);
+ $this->assertEquals('worker_status_change', $notification->data['type']);
+ $this->assertEquals($worker->id, $notification->data['worker_id']);
+ $this->assertEquals('active', $notification->data['old_status']);
+ $this->assertEquals('Hired', $notification->data['new_status']);
+ }
}
diff --git a/tests/Feature/WorkerJobApiTest.php b/tests/Feature/WorkerJobApiTest.php
index 2e1c935..2260bb4 100644
--- a/tests/Feature/WorkerJobApiTest.php
+++ b/tests/Feature/WorkerJobApiTest.php
@@ -821,6 +821,152 @@ public function test_worker_can_filter_and_sort_jobs()
$response->assertJsonFragment(['title' => 'Full Time Plumber Job']);
$response->assertJsonMissingExact(['title' => 'Contract Plumber Job']);
}
+
+ public function test_candidate_status_update_sends_correct_notifications_to_worker()
+ {
+ // Fake Google OAuth and FCM endpoints
+ \Illuminate\Support\Facades\Http::fake([
+ 'https://oauth2.googleapis.com/token' => \Illuminate\Support\Facades\Http::response(['access_token' => 'fake_token'], 200),
+ 'https://fcm.googleapis.com/v1/projects/*' => \Illuminate\Support\Facades\Http::response(['name' => 'projects/migrant-fe5a7/messages/1234'], 200),
+ ]);
+
+ // Create standard credentials file dummy for FCMService if not exists
+ $credentialsPath = base_path('migrant-fe5a7-firebase-adminsdk-fbsvc-f8b365ecd0.json');
+ $createdFile = false;
+ if (!file_exists($credentialsPath)) {
+ file_put_contents($credentialsPath, json_encode([
+ 'private_key' => "-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC3\n-----END PRIVATE KEY-----",
+ 'client_email' => 'fake-fcm@migrant.iam.gserviceaccount.com',
+ 'project_id' => 'migrant-fe5a7'
+ ]));
+ $createdFile = true;
+ }
+
+ // Give the worker an FCM token so FcmChannel is used
+ $this->worker->update(['fcm_token' => 'worker_test_fcm_token']);
+
+ // Create a job post
+ $job = JobPost::create([
+ 'employer_id' => $this->employer->id,
+ 'title' => 'Test Notification Job',
+ 'location' => 'Dubai Marina',
+ 'salary' => 3000,
+ 'workers_needed' => 1,
+ 'job_type' => 'Full Time',
+ 'start_date' => now()->addDays(2),
+ 'description' => 'Description here',
+ 'status' => 'active',
+ ]);
+
+ // Apply for the job
+ $application = JobApplication::create([
+ 'worker_id' => $this->worker->id,
+ 'job_id' => $job->id,
+ 'status' => 'applied'
+ ]);
+
+ // 1. Update status to 'shortlisted' via Web endpoint
+ $response = $this->actingAs($this->employer)
+ ->withSession(['user' => $this->employer])
+ ->post("/employer/candidates/{$application->id}/status", [
+ 'status' => 'Shortlisted',
+ 'notes' => 'Shortlist note'
+ ]);
+
+ $response->assertSessionHasNoErrors();
+
+ // Assert that the database notification was created for worker
+ $this->assertDatabaseHas('notifications', [
+ 'notifiable_id' => $this->worker->id,
+ 'notifiable_type' => get_class($this->worker),
+ ]);
+
+ // Verify FCM request was triggered
+ \Illuminate\Support\Facades\Http::assertSent(function ($request) {
+ return str_contains($request->url(), 'messages:send') &&
+ str_contains($request['message']['token'], 'worker_test_fcm_token') &&
+ str_contains($request['message']['notification']['title'], 'Job Application Update') &&
+ str_contains($request['message']['data']['type'], 'shortlisted');
+ });
+
+ // 2. Update status to 'applied' (Review) and assert notification type is 'review'
+ $response = $this->actingAs($this->employer)
+ ->withSession(['user' => $this->employer])
+ ->post("/employer/candidates/{$application->id}/status", [
+ 'status' => 'Applied',
+ 'notes' => 'Reviewing again'
+ ]);
+
+ $response->assertSessionHasNoErrors();
+
+ // Verify FCM request for 'review' was triggered
+ \Illuminate\Support\Facades\Http::assertSent(function ($request) {
+ return str_contains($request->url(), 'messages:send') &&
+ str_contains($request['message']['token'], 'worker_test_fcm_token') &&
+ str_contains($request['message']['data']['type'], 'review');
+ });
+
+ // Clean up temporary credentials file if we created it
+ if ($createdFile && file_exists($credentialsPath)) {
+ unlink($credentialsPath);
+ }
+ }
+
+ public function test_direct_hiring_sends_correct_notifications_to_worker()
+ {
+ // Fake Google OAuth and FCM endpoints
+ \Illuminate\Support\Facades\Http::fake([
+ 'https://oauth2.googleapis.com/token' => \Illuminate\Support\Facades\Http::response(['access_token' => 'fake_token'], 200),
+ 'https://fcm.googleapis.com/v1/projects/*' => \Illuminate\Support\Facades\Http::response(['name' => 'projects/migrant-fe5a7/messages/1234'], 200),
+ ]);
+
+ $credentialsPath = base_path('migrant-fe5a7-firebase-adminsdk-fbsvc-f8b365ecd0.json');
+ $createdFile = false;
+ if (!file_exists($credentialsPath)) {
+ file_put_contents($credentialsPath, json_encode([
+ 'private_key' => "-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC3\n-----END PRIVATE KEY-----",
+ 'client_email' => 'fake-fcm@migrant.iam.gserviceaccount.com',
+ 'project_id' => 'migrant-fe5a7'
+ ]));
+ $createdFile = true;
+ }
+
+ $this->worker->update(['fcm_token' => 'worker_test_fcm_token']);
+
+ // 1. Create a direct Job Offer for the worker
+ $offer = \App\Models\JobOffer::create([
+ 'employer_id' => $this->employer->id,
+ 'worker_id' => $this->worker->id,
+ 'work_date' => now()->format('Y-m-d'),
+ 'location' => 'Dubai',
+ 'salary' => 3000,
+ 'notes' => 'Direct sponsoring',
+ 'status' => 'pending',
+ ]);
+
+ // Send a status update to 'Hired' via CandidateController (maps to pending for direct offers)
+ $response = $this->actingAs($this->employer)
+ ->withSession(['user' => $this->employer])
+ ->post("/employer/candidates/offer_{$offer->id}/status", [
+ 'status' => 'Hired',
+ 'notes' => 'Re-initiating direct hire offer'
+ ]);
+
+ $response->assertSessionHasNoErrors();
+
+ // Verify FCM request for 'direct_hire_request' was triggered with correct text
+ \Illuminate\Support\Facades\Http::assertSent(function ($request) {
+ return str_contains($request->url(), 'messages:send') &&
+ str_contains($request['message']['token'], 'worker_test_fcm_token') &&
+ str_contains($request['message']['notification']['title'], 'Direct Hiring Offer') &&
+ str_contains($request['message']['notification']['body'], 'wants to hire you. Do you want to accept this job offer?') &&
+ str_contains($request['message']['data']['type'], 'direct_hire_request');
+ });
+
+ if ($createdFile && file_exists($credentialsPath)) {
+ unlink($credentialsPath);
+ }
+ }
}