update
This commit is contained in:
parent
541ac6cc41
commit
6600c95584
@ -505,17 +505,71 @@ private function getFormattedRecordings($interview)
|
||||
return $formatted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Live Polling Endpoint for Candidate Warnings & HR Monitoring.
|
||||
/**
|
||||
* Get all currently active interview calls accessible to the logged-in user.
|
||||
*/
|
||||
public function getActiveCalls(Request $request)
|
||||
{
|
||||
$user = Auth::user();
|
||||
if (!$user) {
|
||||
return response()->json(['active_calls' => [], 'current_user_id' => null]);
|
||||
}
|
||||
|
||||
$accessible = $user->getAccessibleInterviews();
|
||||
$activeCalls = [];
|
||||
|
||||
foreach ($accessible as $interview) {
|
||||
if ($interview->call_status === 'active') {
|
||||
$starterName = 'Panelist';
|
||||
if ($interview->call_started_by) {
|
||||
$starter = User::find($interview->call_started_by);
|
||||
if ($starter) {
|
||||
$starterName = $starter->name;
|
||||
}
|
||||
}
|
||||
|
||||
$activeCalls[] = [
|
||||
'id' => $interview->id,
|
||||
'candidate_name' => $interview->candidate_name,
|
||||
'candidate_email' => $interview->candidate_email,
|
||||
'submission_unique_id' => $interview->submission_unique_id,
|
||||
'call_status' => $interview->call_status,
|
||||
'call_started_by' => $interview->call_started_by,
|
||||
'starter_name' => $starterName,
|
||||
'call_started_at' => $interview->call_started_at ? $interview->call_started_at->toIso8601String() : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'active_calls' => $activeCalls,
|
||||
'current_user_id' => $user->id,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Live Polling Endpoint for Candidate Warnings & HR Monitoring.
|
||||
*/
|
||||
public function getPollData($id)
|
||||
{
|
||||
$interview = Interview::with(['warnings.sender'])->findOrFail($id);
|
||||
$interview = Interview::with(['warnings.sender', 'callStarter'])
|
||||
->where('id', $id)
|
||||
->orWhere('submission_unique_id', $id)
|
||||
->firstOrFail();
|
||||
$formattedRecordings = $this->getFormattedRecordings($interview);
|
||||
|
||||
$starterName = $interview->callStarter ? $interview->callStarter->name : null;
|
||||
|
||||
return response()->json([
|
||||
'status' => $interview->status,
|
||||
'call_status' => $interview->call_status ?? 'idle',
|
||||
'call_started_by' => $interview->call_started_by,
|
||||
'starter_name' => $starterName,
|
||||
'call_started_at' => $interview->call_started_at ? $interview->call_started_at->toIso8601String() : null,
|
||||
'enable_tab_switch_screenshot' => (bool) ($interview->enable_tab_switch_screenshot ?? true),
|
||||
'tab_switch_screenshots' => $interview->tab_switch_screenshots ?? [],
|
||||
'submitted_code' => $interview->submitted_code,
|
||||
'submitted_language' => $interview->submitted_language,
|
||||
'code_output' => $interview->code_output,
|
||||
@ -536,18 +590,141 @@ public function getPollData($id)
|
||||
*/
|
||||
public function updateCallStatus(Request $request, $id)
|
||||
{
|
||||
$interview = Interview::findOrFail($id);
|
||||
$interview = Interview::where('id', $id)
|
||||
->orWhere('submission_unique_id', $id)
|
||||
->firstOrFail();
|
||||
$status = $request->input('call_status', 'idle');
|
||||
$interview->update(['call_status' => $status]);
|
||||
return response()->json(['success' => true, 'call_status' => $status]);
|
||||
$isRejoin = $request->boolean('is_rejoin', false);
|
||||
|
||||
$updateData = ['call_status' => $status];
|
||||
$isNewCall = false;
|
||||
|
||||
if ($status === 'active') {
|
||||
if (!$isRejoin && ($interview->call_status !== 'active' || !$interview->call_started_at)) {
|
||||
$updateData['call_started_by'] = Auth::id();
|
||||
$updateData['call_started_at'] = now();
|
||||
$isNewCall = true;
|
||||
}
|
||||
} elseif (in_array($status, ['ended', 'idle'])) {
|
||||
$updateData['call_started_by'] = null;
|
||||
$updateData['call_started_at'] = null;
|
||||
}
|
||||
|
||||
$interview->update($updateData);
|
||||
|
||||
$starterName = Auth::user() ? Auth::user()->name : 'Panelist';
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'call_status' => $status,
|
||||
'is_new_call' => $isNewCall,
|
||||
'call_started_by' => $interview->call_started_by,
|
||||
'starter_name' => $starterName,
|
||||
'call_started_at' => $interview->call_started_at ? $interview->call_started_at->toIso8601String() : null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save Candidate Silent Video Recording Blob / Snapshot Stream.
|
||||
* Upload & Save Candidate Tab-Switch Screenshot under public/uploads/candidate_screenshots/{unique_id}/.
|
||||
*/
|
||||
public function uploadTabScreenshot(Request $request, $id)
|
||||
{
|
||||
$interview = Interview::where('id', $id)
|
||||
->orWhere('submission_unique_id', $id)
|
||||
->firstOrFail();
|
||||
|
||||
if (isset($interview->enable_tab_switch_screenshot) && !$interview->enable_tab_switch_screenshot) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Tab-switch screenshot capture is disabled by admin setting.'
|
||||
]);
|
||||
}
|
||||
|
||||
$url = null;
|
||||
$filename = 'screenshot_tab_switch_' . time() . '_' . rand(100, 999) . '.jpg';
|
||||
$subDir = 'uploads/candidate_screenshots/' . $interview->submission_unique_id;
|
||||
$destinationPath = public_path($subDir);
|
||||
|
||||
if (!file_exists($destinationPath)) {
|
||||
mkdir($destinationPath, 0777, true);
|
||||
}
|
||||
|
||||
if ($request->hasFile('screenshot')) {
|
||||
$file = $request->file('screenshot');
|
||||
$file->move($destinationPath, $filename);
|
||||
$url = asset($subDir . '/' . $filename);
|
||||
} elseif ($request->input('image')) {
|
||||
$base64Image = $request->input('image');
|
||||
if (str_contains($base64Image, ',')) {
|
||||
$base64Image = explode(',', $base64Image)[1];
|
||||
}
|
||||
$imageData = base64_decode($base64Image);
|
||||
file_put_contents($destinationPath . '/' . $filename, $imageData);
|
||||
$url = asset($subDir . '/' . $filename);
|
||||
}
|
||||
|
||||
if ($url) {
|
||||
$relativePath = '/' . $subDir . '/' . $filename;
|
||||
$screenshots = $interview->tab_switch_screenshots ?? [];
|
||||
$screenshotEntry = [
|
||||
'url' => $relativePath,
|
||||
'full_url' => $url,
|
||||
'filename' => $filename,
|
||||
'timestamp' => now()->toIso8601String(),
|
||||
'reason' => 'tab_switch',
|
||||
];
|
||||
$screenshots[] = $screenshotEntry;
|
||||
|
||||
$logs = $interview->proctor_logs ?? [];
|
||||
$logs[] = [
|
||||
'type' => 'tab_switch',
|
||||
'details' => 'Candidate switched active browser tab (Screenshot Captured: ' . $filename . ')',
|
||||
'screenshot_url' => $relativePath,
|
||||
'timestamp' => now()->toIso8601String(),
|
||||
];
|
||||
|
||||
$interview->update([
|
||||
'tab_switch_screenshots' => $screenshots,
|
||||
'proctor_logs' => $logs,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'url' => $relativePath,
|
||||
'total_screenshots' => count($screenshots),
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json(['success' => false, 'message' => 'No image payload received']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin Toggle Enable/Disable Candidate Tab Switch Screenshot Capture.
|
||||
*/
|
||||
public function toggleTabScreenshot(Request $request, $id)
|
||||
{
|
||||
$interview = Interview::where('id', $id)
|
||||
->orWhere('submission_unique_id', $id)
|
||||
->firstOrFail();
|
||||
|
||||
$enable = $request->boolean('enable', true);
|
||||
$interview->update(['enable_tab_switch_screenshot' => $enable]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'enable_tab_switch_screenshot' => $enable,
|
||||
'message' => $enable ? 'Tab-switch screenshot capture ENABLED.' : 'Tab-switch screenshot capture DISABLED.'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save Candidate Video Recording under public/uploads/candidate_recordings/{unique_id}/.
|
||||
*/
|
||||
public function uploadRecording(Request $request, $id)
|
||||
{
|
||||
$interview = Interview::findOrFail($id);
|
||||
$interview = Interview::where('id', $id)
|
||||
->orWhere('submission_unique_id', $id)
|
||||
->firstOrFail();
|
||||
|
||||
if ($request->hasFile('video')) {
|
||||
$file = $request->file('video');
|
||||
@ -556,9 +733,15 @@ public function uploadRecording(Request $request, $id)
|
||||
$ext = 'webm';
|
||||
}
|
||||
|
||||
$filename = 'interview_' . $interview->submission_unique_id . '_' . time() . '.' . $ext;
|
||||
$path = $file->storeAs('recordings', $filename, 'public');
|
||||
$url = Storage::disk('public')->url($path);
|
||||
$subDir = 'uploads/candidate_recordings/' . $interview->submission_unique_id;
|
||||
$destinationPath = public_path($subDir);
|
||||
if (!file_exists($destinationPath)) {
|
||||
mkdir($destinationPath, 0777, true);
|
||||
}
|
||||
|
||||
$filename = 'recording_' . time() . '.' . $ext;
|
||||
$file->move($destinationPath, $filename);
|
||||
$url = asset($subDir . '/' . $filename);
|
||||
|
||||
$recordings = $interview->recordings ?? [];
|
||||
if (!is_array($recordings)) {
|
||||
|
||||
@ -31,14 +31,21 @@ class Interview extends Model
|
||||
'blocked_ip',
|
||||
'created_by',
|
||||
'call_status',
|
||||
'call_started_by',
|
||||
'call_started_at',
|
||||
'enable_tab_switch_screenshot',
|
||||
'tab_switch_screenshots',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'scheduled_at' => 'datetime',
|
||||
'expires_at' => 'datetime',
|
||||
'call_started_at' => 'datetime',
|
||||
'enable_tab_switch_screenshot' => 'boolean',
|
||||
'assigned_interviewers' => 'array',
|
||||
'proctor_logs' => 'array',
|
||||
'recordings' => 'array',
|
||||
'tab_switch_screenshots' => 'array',
|
||||
];
|
||||
|
||||
public function creator()
|
||||
@ -46,6 +53,11 @@ public function creator()
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
public function callStarter()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'call_started_by');
|
||||
}
|
||||
|
||||
public function warnings()
|
||||
{
|
||||
return $this->hasMany(InterviewWarning::class, 'interview_id');
|
||||
|
||||
@ -0,0 +1,33 @@
|
||||
<?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('interviews', function (Blueprint $table) {
|
||||
if (!Schema::hasColumn('interviews', 'call_started_by')) {
|
||||
$table->unsignedBigInteger('call_started_by')->nullable()->after('call_status');
|
||||
}
|
||||
if (!Schema::hasColumn('interviews', 'call_started_at')) {
|
||||
$table->timestamp('call_started_at')->nullable()->after('call_started_by');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('interviews', function (Blueprint $table) {
|
||||
$table->dropColumn(['call_started_by', 'call_started_at']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('interviews', function (Blueprint $table) {
|
||||
$table->boolean('enable_tab_switch_screenshot')->default(true)->after('proctor_logs');
|
||||
$table->json('tab_switch_screenshots')->nullable()->after('enable_tab_switch_screenshot');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('interviews', function (Blueprint $table) {
|
||||
$table->dropColumn(['enable_tab_switch_screenshot', 'tab_switch_screenshots']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -1332,5 +1332,261 @@ function initDragAndDrop(gridId, storageKey) {
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
@if(Auth::user()->isAdmin() || strtolower(Auth::user()->role) === 'hr' || Auth::user()->hasActiveInterviewZone())
|
||||
<!-- INCOMING CALL RING MODAL FOR DASHBOARD -->
|
||||
<div id="incoming-call-modal" style="position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(5, 8, 17, 0.85); backdrop-filter: blur(16px); display: flex; align-items: center; justify-content: center; z-index: 10000; opacity: 0; pointer-events: none; transition: opacity 0.3s ease;">
|
||||
<div style="max-width: 460px; width: 90%; background: linear-gradient(145deg, #0f172a 0%, #1e1b4b 100%); border: 2px solid #6366f1; border-radius: 24px; padding: 32px; box-shadow: 0 0 50px rgba(99, 102, 241, 0.6); text-align: center; color: white;">
|
||||
|
||||
<div style="position: relative; width: 90px; height: 90px; margin: 0 auto 20px auto; display: flex; align-items: center; justify-content: center;">
|
||||
<div style="position: absolute; inset: -15px; border-radius: 50%; border: 2px solid rgba(16, 185, 129, 0.6); animation: ringPulse 1.4s infinite ease-out;"></div>
|
||||
<div style="position: absolute; inset: -30px; border-radius: 50%; border: 2px solid rgba(99, 102, 241, 0.4); animation: ringPulse 1.4s infinite ease-out 0.4s;"></div>
|
||||
<div style="width: 80px; height: 80px; border-radius: 50%; background: linear-gradient(135deg, #10b981 0%, #0078d4 100%); display: flex; align-items: center; justify-content: center; font-size: 2.4rem; font-weight: 800; color: white; box-shadow: 0 10px 25px rgba(16, 185, 129, 0.5); z-index: 5;">
|
||||
📞
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="font-size: 0.75rem; text-transform: uppercase; letter-spacing: 1.5px; color: #34d399; font-weight: 800; margin-bottom: 6px;">
|
||||
⚡ INCOMING INTERVIEW CALL
|
||||
</div>
|
||||
|
||||
<h3 id="incoming-cand-name" style="font-family: 'Outfit', sans-serif; font-size: 1.45rem; font-weight: 800; color: white; margin-bottom: 4px;">
|
||||
Candidate Name
|
||||
</h3>
|
||||
|
||||
<div id="incoming-starter-info" style="font-size: 0.82rem; color: #a5b4fc; margin-bottom: 16px;">
|
||||
Initiated by Panelist
|
||||
</div>
|
||||
|
||||
<div style="background: rgba(255, 255, 255, 0.05); border: 1px solid rgba(255,255,255,0.1); border-radius: 12px; padding: 12px; font-size: 0.78rem; color: #cbd5e1; margin-bottom: 24px; line-height: 1.4;">
|
||||
Another interviewer has started calling the candidate. Click <strong>Accept Call</strong> to join live, or <strong>Decline</strong> if busy.
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 14px; justify-content: center;">
|
||||
<button id="incoming-decline-btn" onclick="declineIncomingCall()" style="flex: 1; padding: 12px 18px; background: rgba(239, 68, 68, 0.2); border: 1.5px solid #ef4444; color: #fca5a5; font-weight: 700; font-size: 0.9rem; border-radius: 12px; cursor: pointer;">
|
||||
🚫 Decline / Miss
|
||||
</button>
|
||||
<button id="incoming-accept-btn" onclick="acceptIncomingCall()" style="flex: 1.3; padding: 12px 18px; background: linear-gradient(135deg, #10b981 0%, #059669 100%); border: none; color: white; font-weight: 800; font-size: 0.9rem; border-radius: 12px; box-shadow: 0 0 20px rgba(16, 185, 129, 0.6); cursor: pointer; animation: acceptGlow 1.5s infinite alternate;">
|
||||
📞 Accept Call
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes ringPulse {
|
||||
0% { transform: scale(0.85); opacity: 1; }
|
||||
100% { transform: scale(1.35); opacity: 0; }
|
||||
}
|
||||
@keyframes acceptGlow {
|
||||
0% { box-shadow: 0 0 15px rgba(16, 185, 129, 0.5); transform: scale(1); }
|
||||
100% { box-shadow: 0 0 28px rgba(16, 185, 129, 0.9); transform: scale(1.03); }
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
class DashboardRingtoneEngine {
|
||||
constructor() {
|
||||
this.ctx = null;
|
||||
this.interval = null;
|
||||
this.isPlaying = false;
|
||||
}
|
||||
|
||||
init() {
|
||||
try {
|
||||
if (!this.ctx) {
|
||||
const AudioCtx = window.AudioContext || window.webkitAudioContext;
|
||||
this.ctx = new AudioCtx();
|
||||
}
|
||||
if (this.ctx && this.ctx.state === 'suspended') {
|
||||
this.ctx.resume();
|
||||
}
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
start() {
|
||||
this.init();
|
||||
if (this.isPlaying) return;
|
||||
this.isPlaying = true;
|
||||
|
||||
this.playTone();
|
||||
if (this.interval) clearInterval(this.interval);
|
||||
this.interval = setInterval(() => {
|
||||
if (this.isPlaying) this.playTone();
|
||||
}, 2400);
|
||||
}
|
||||
|
||||
playTone() {
|
||||
this.init();
|
||||
if (!this.ctx || !this.isPlaying) return;
|
||||
try {
|
||||
const now = this.ctx.currentTime;
|
||||
const osc1 = this.ctx.createOscillator();
|
||||
const osc2 = this.ctx.createOscillator();
|
||||
const gain = this.ctx.createGain();
|
||||
|
||||
osc1.type = 'sine';
|
||||
osc2.type = 'sine';
|
||||
|
||||
osc1.frequency.setValueAtTime(523.25, now);
|
||||
osc2.frequency.setValueAtTime(659.25, now);
|
||||
|
||||
gain.gain.setValueAtTime(0.001, now);
|
||||
gain.gain.linearRampToValueAtTime(0.35, now + 0.08);
|
||||
gain.gain.setValueAtTime(0.35, now + 1.1);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, now + 1.3);
|
||||
|
||||
osc1.connect(gain);
|
||||
osc2.connect(gain);
|
||||
gain.connect(this.ctx.destination);
|
||||
|
||||
osc1.start(now);
|
||||
osc2.start(now);
|
||||
|
||||
osc1.stop(now + 1.35);
|
||||
osc2.stop(now + 1.35);
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.isPlaying = false;
|
||||
if (this.interval) {
|
||||
clearInterval(this.interval);
|
||||
this.interval = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
const dashboardRingtone = new DashboardRingtoneEngine();
|
||||
|
||||
['click', 'keydown', 'touchstart'].forEach(evt => {
|
||||
document.addEventListener(evt, () => dashboardRingtone.init(), { passive: true });
|
||||
});
|
||||
|
||||
let activeIncomingCall = null;
|
||||
let dashboardRingTimeoutTimer = null;
|
||||
const dismissedCallIds = new Set();
|
||||
const currentUserId = {{ Auth::id() }};
|
||||
const globalCallChannel = new BroadcastChannel('global_call_alerts');
|
||||
|
||||
function triggerDashboardIncomingRing(callData) {
|
||||
if (dismissedCallIds.has(callData.id)) return;
|
||||
|
||||
if (callData.call_started_at) {
|
||||
const elapsed = Date.now() - new Date(callData.call_started_at).getTime();
|
||||
if (elapsed > 10000) return;
|
||||
}
|
||||
|
||||
activeIncomingCall = callData;
|
||||
document.getElementById('incoming-cand-name').innerText = callData.candidate_name;
|
||||
document.getElementById('incoming-starter-info').innerText = '📞 Call initiated by: ' + (callData.starter_name || 'Panelist');
|
||||
const modal = document.getElementById('incoming-call-modal');
|
||||
modal.style.opacity = '1';
|
||||
modal.style.pointerEvents = 'auto';
|
||||
dashboardRingtone.start();
|
||||
|
||||
if (dashboardRingTimeoutTimer) clearTimeout(dashboardRingTimeoutTimer);
|
||||
let remainingMs = 10000;
|
||||
if (callData.call_started_at) {
|
||||
const elapsed = Date.now() - new Date(callData.call_started_at).getTime();
|
||||
remainingMs = Math.max(1000, 10000 - elapsed);
|
||||
}
|
||||
|
||||
dashboardRingTimeoutTimer = setTimeout(() => {
|
||||
declineIncomingCall(true);
|
||||
}, remainingMs);
|
||||
}
|
||||
|
||||
globalCallChannel.onmessage = (event) => {
|
||||
const data = event.data;
|
||||
if (!data) return;
|
||||
|
||||
if (data.type === 'incoming_call') {
|
||||
triggerDashboardIncomingRing({
|
||||
id: data.interview_id,
|
||||
candidate_name: data.candidate_name,
|
||||
submission_unique_id: data.submission_unique_id,
|
||||
starter_name: data.starter_name,
|
||||
call_started_at: data.call_started_at || new Date().toISOString()
|
||||
});
|
||||
} else if (data.type === 'call_ended') {
|
||||
if (dashboardRingTimeoutTimer) {
|
||||
clearTimeout(dashboardRingTimeoutTimer);
|
||||
dashboardRingTimeoutTimer = null;
|
||||
}
|
||||
if (activeIncomingCall && activeIncomingCall.id == data.interview_id) {
|
||||
declineIncomingCall(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function pollDashboardActiveCalls() {
|
||||
fetch(`{{ route('interview.active-calls') }}`, {
|
||||
headers: { 'Accept': 'application/json' }
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (!data || !data.active_calls || data.active_calls.length === 0) {
|
||||
if (activeIncomingCall) {
|
||||
declineIncomingCall(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const incoming = data.active_calls.find(call => {
|
||||
if (dismissedCallIds.has(call.id)) return false;
|
||||
if (call.call_started_at) {
|
||||
const elapsed = Date.now() - new Date(call.call_started_at).getTime();
|
||||
if (elapsed > 10000) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (incoming) {
|
||||
if (!activeIncomingCall || activeIncomingCall.id !== incoming.id) {
|
||||
triggerDashboardIncomingRing(incoming);
|
||||
}
|
||||
} else if (activeIncomingCall) {
|
||||
declineIncomingCall(true);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
setInterval(pollDashboardActiveCalls, 2000);
|
||||
|
||||
function acceptIncomingCall() {
|
||||
dashboardRingtone.stop();
|
||||
if (dashboardRingTimeoutTimer) {
|
||||
clearTimeout(dashboardRingTimeoutTimer);
|
||||
dashboardRingTimeoutTimer = null;
|
||||
}
|
||||
const modal = document.getElementById('incoming-call-modal');
|
||||
modal.style.opacity = '0';
|
||||
modal.style.pointerEvents = 'none';
|
||||
|
||||
if (activeIncomingCall) {
|
||||
const targetId = activeIncomingCall.id;
|
||||
activeIncomingCall = null;
|
||||
window.location.href = "{{ route('interview.index') }}?open_interview=" + targetId + "&join_call=1";
|
||||
}
|
||||
}
|
||||
|
||||
function declineIncomingCall(silent = false) {
|
||||
dashboardRingtone.stop();
|
||||
if (dashboardRingTimeoutTimer) {
|
||||
clearTimeout(dashboardRingTimeoutTimer);
|
||||
dashboardRingTimeoutTimer = null;
|
||||
}
|
||||
const modal = document.getElementById('incoming-call-modal');
|
||||
modal.style.opacity = '0';
|
||||
modal.style.pointerEvents = 'none';
|
||||
|
||||
if (activeIncomingCall) {
|
||||
dismissedCallIds.add(activeIncomingCall.id);
|
||||
activeIncomingCall = null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@endif
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -337,6 +337,10 @@
|
||||
<span id="call-status-badge" style="font-size: 0.65rem; background: rgba(16,185,129,0.2); color: #34d399; padding: 2px 8px; border-radius: 999px;">READY</span>
|
||||
</div>
|
||||
|
||||
<button id="candidate-join-active-call-btn" onclick="acceptCandidateCall()" class="btn-ide" style="display: none; width: 100%; padding: 10px; margin-bottom: 10px; font-size: 0.8rem; font-weight: 700; background: linear-gradient(135deg, #10b981 0%, #059669 100%); color: white; border: none; box-shadow: 0 0 15px rgba(16,185,129,0.4); cursor: pointer; animation: acceptGlow 1.5s infinite alternate;">
|
||||
🟢 📞 Call in Progress • Join Call Now
|
||||
</button>
|
||||
|
||||
<div style="display: flex; gap: 8px; margin-bottom: 10px;">
|
||||
<button id="toggle-mic-btn" onclick="toggleMic()" class="btn-ide" style="flex: 1; padding: 8px; font-size: 0.75rem; background: rgba(16,185,129,0.2); border-color: #10b981;">
|
||||
🎤 Mic On
|
||||
@ -372,6 +376,35 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CANDIDATE INCOMING CALL MODAL / RINGING OVERLAY -->
|
||||
<div id="candidate-incoming-modal" style="position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(5, 8, 17, 0.85); backdrop-filter: blur(16px); display: flex; align-items: center; justify-content: center; z-index: 10000; opacity: 0; pointer-events: none; transition: opacity 0.3s ease;">
|
||||
<div style="max-width: 440px; width: 90%; background: linear-gradient(145deg, #0f172a 0%, #1e1b4b 100%); border: 2px solid #10b981; border-radius: 24px; padding: 32px; text-align: center; color: white; box-shadow: 0 0 50px rgba(16, 185, 129, 0.6);">
|
||||
<div style="position: relative; width: 90px; height: 90px; margin: 0 auto 20px auto; display: flex; align-items: center; justify-content: center;">
|
||||
<div style="position: absolute; inset: -15px; border-radius: 50%; border: 2px solid rgba(16, 185, 129, 0.6); animation: ringPulse 1.4s infinite ease-out;"></div>
|
||||
<div style="position: absolute; inset: -30px; border-radius: 50%; border: 2px solid rgba(99, 102, 241, 0.4); animation: ringPulse 1.4s infinite ease-out 0.4s;"></div>
|
||||
<div style="width: 80px; height: 80px; border-radius: 50%; background: linear-gradient(135deg, #10b981 0%, #0078d4 100%); display: flex; align-items: center; justify-content: center; font-size: 2.4rem; font-weight: 800; color: white; box-shadow: 0 10px 25px rgba(16, 185, 129, 0.5); z-index: 5;">
|
||||
📞
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="font-size: 0.75rem; text-transform: uppercase; letter-spacing: 1.5px; color: #34d399; font-weight: 800; margin-bottom: 6px;">
|
||||
⚡ INCOMING INTERVIEW CALL
|
||||
</div>
|
||||
|
||||
<h3 style="font-family: 'Outfit', sans-serif; font-size: 1.4rem; font-weight: 800; color: white; margin-bottom: 4px;">
|
||||
Interviewer Panel Calling...
|
||||
</h3>
|
||||
|
||||
<div style="font-size: 0.8rem; color: #a5b4fc; margin-bottom: 20px;">
|
||||
The interviewer panel has initiated a live 2-way call.
|
||||
</div>
|
||||
|
||||
<button onclick="acceptCandidateCall()" style="width: 100%; padding: 14px; background: linear-gradient(135deg, #10b981 0%, #059669 100%); border: none; color: white; font-weight: 800; font-size: 1rem; border-radius: 14px; box-shadow: 0 0 25px rgba(16, 185, 129, 0.6); cursor: pointer; animation: acceptGlow 1.5s infinite alternate;">
|
||||
📞 Accept & Join Call
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let editor;
|
||||
let interviewId = {{ $interview->id }};
|
||||
@ -485,18 +518,133 @@ function submitSolution() {
|
||||
});
|
||||
}
|
||||
|
||||
// --- ANTI-CHEAT & PROCTORING ENGINE ---
|
||||
let isTabSwitchScreenshotEnabled = true;
|
||||
let lastTabSwitchTime = 0;
|
||||
|
||||
// 1. Tab Switch Detection
|
||||
function captureAndUploadTabScreenshot() {
|
||||
if (!isTabSwitchScreenshotEnabled) return;
|
||||
|
||||
try {
|
||||
const tempCanvas = document.createElement('canvas');
|
||||
let w = 1280;
|
||||
let h = 720;
|
||||
let drewSource = false;
|
||||
|
||||
// Priority 1: Candidate Computer Screen Share Stream (if screen share active)
|
||||
if (typeof screenShareStream !== 'undefined' && screenShareStream && screenShareStream.getVideoTracks().length > 0) {
|
||||
const screenTrack = screenShareStream.getVideoTracks()[0];
|
||||
if (screenTrack.readyState === 'live') {
|
||||
try {
|
||||
const screenVid = document.createElement('video');
|
||||
screenVid.muted = true;
|
||||
screenVid.srcObject = screenShareStream;
|
||||
screenVid.play().catch(e => {});
|
||||
|
||||
const settings = screenTrack.getSettings ? screenTrack.getSettings() : {};
|
||||
w = settings.width || 1280;
|
||||
h = settings.height || 720;
|
||||
tempCanvas.width = w;
|
||||
tempCanvas.height = h;
|
||||
const tempCtx = tempCanvas.getContext('2d');
|
||||
|
||||
tempCtx.drawImage(screenVid, 0, 0, w, h);
|
||||
drewSource = true;
|
||||
} catch(e) {}
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 2: Candidate Assessment Screen Workspace & Video Composite Snapshot
|
||||
if (!drewSource) {
|
||||
const video = document.getElementById('webcam');
|
||||
w = (video && video.videoWidth > 0) ? video.videoWidth : 1280;
|
||||
h = (video && video.videoHeight > 0) ? video.videoHeight : 720;
|
||||
tempCanvas.width = w;
|
||||
tempCanvas.height = h;
|
||||
const tempCtx = tempCanvas.getContext('2d');
|
||||
|
||||
// Dark IDE workspace background
|
||||
tempCtx.fillStyle = '#0b0f19';
|
||||
tempCtx.fillRect(0, 0, w, h);
|
||||
|
||||
// IDE Header bar
|
||||
tempCtx.fillStyle = '#1e293b';
|
||||
tempCtx.fillRect(0, 0, w, 50);
|
||||
tempCtx.fillStyle = '#38bdf8';
|
||||
tempCtx.font = 'bold 16px sans-serif';
|
||||
tempCtx.fillText('💻 Candidate Assessment Workspace • ID: ' + (typeof interviewId !== 'undefined' ? interviewId : ''), 20, 32);
|
||||
|
||||
// Code Editor Panel
|
||||
const codeDisplay = document.querySelector('.CodeMirror') || document.getElementById('code-editor');
|
||||
tempCtx.fillStyle = '#020617';
|
||||
tempCtx.fillRect(20, 70, w - 240, h - 130);
|
||||
tempCtx.fillStyle = '#94a3b8';
|
||||
tempCtx.font = '13px monospace';
|
||||
const codeText = codeDisplay ? (codeDisplay.innerText || codeDisplay.value || '') : '';
|
||||
const lines = codeText.split('\n').slice(0, 30);
|
||||
lines.forEach((line, i) => {
|
||||
tempCtx.fillText(line.substring(0, 100), 35, 95 + (i * 18));
|
||||
});
|
||||
|
||||
// Candidate Camera Feed Inset
|
||||
if (video && video.readyState >= 2) {
|
||||
try {
|
||||
tempCtx.drawImage(video, w - 210, 70, 190, 140);
|
||||
tempCtx.strokeStyle = '#6366f1';
|
||||
tempCtx.lineWidth = 2;
|
||||
tempCtx.strokeRect(w - 210, 70, 190, 140);
|
||||
} catch(e) {}
|
||||
}
|
||||
drewSource = true;
|
||||
}
|
||||
|
||||
// Add red banner overlay at bottom
|
||||
const ctx = tempCanvas.getContext('2d');
|
||||
ctx.fillStyle = 'rgba(15, 23, 42, 0.90)';
|
||||
ctx.fillRect(0, tempCanvas.height - 42, tempCanvas.width, 42);
|
||||
ctx.fillStyle = '#ef4444';
|
||||
ctx.font = 'bold 14px sans-serif';
|
||||
ctx.fillText('🚨 CANDIDATE TAB SWITCH DETECTED • COMPUTER SCREEN SNAPSHOT • ' + new Date().toLocaleString(), 16, tempCanvas.height - 15);
|
||||
|
||||
const imageData = tempCanvas.toDataURL('image/jpeg', 0.85);
|
||||
|
||||
fetch(`{{ route('interview.upload-tab-screenshot', $interview->id) }}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
},
|
||||
body: JSON.stringify({ image: imageData })
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
console.log('Computer screen tab-switch screenshot saved:', data);
|
||||
})
|
||||
.catch(err => console.log('Tab screenshot fetch error:', err));
|
||||
} catch(e) {
|
||||
console.log('Tab switch screenshot capture exception:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Tab Switch Detection (Captures screenshot ONLY on tab switch if enabled by admin)
|
||||
document.addEventListener('visibilitychange', function () {
|
||||
if (document.hidden) {
|
||||
const now = Date.now();
|
||||
if (now - lastTabSwitchTime > 1500) {
|
||||
lastTabSwitchTime = now;
|
||||
logViolation('tab_switch', 'Candidate switched browser tab or minimized window');
|
||||
captureAndUploadTabScreenshot();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Window Focus Loss / External AI Overlay Window Switch Detection (Parakeet AI)
|
||||
// 2. Window Focus Loss / External AI Overlay Window Switch Detection
|
||||
window.addEventListener('blur', function () {
|
||||
logViolation('external_ai_detected', 'External AI Assistant Suspicion: Candidate window lost focus (interacting with floating overlay window or Parakeet AI widget)');
|
||||
const now = Date.now();
|
||||
if (now - lastTabSwitchTime > 2000) {
|
||||
lastTabSwitchTime = now;
|
||||
logViolation('tab_switch', 'Candidate window lost focus / switched application window');
|
||||
captureAndUploadTabScreenshot();
|
||||
}
|
||||
});
|
||||
|
||||
// 3. Code Copy-Paste & Ingestion Detection
|
||||
@ -504,15 +652,16 @@ function submitSolution() {
|
||||
logViolation('paste_event', 'Candidate pasted content into editor window (possible AI answer paste)');
|
||||
});
|
||||
|
||||
// 4. Parakeet AI Global Hotkey Interception (Alt+Space, Cmd+Space, Ctrl+Shift+A/C/X, Win Key)
|
||||
// 4. External AI Hotkey Interception (Targeting explicit overlay launchers, ignoring standard OS tab navigation like Alt+Tab, Cmd+Tab, Win+Tab)
|
||||
document.addEventListener('keydown', function (e) {
|
||||
// Ignore standard OS tab/window switching keys
|
||||
if (e.code === 'Tab' || e.key === 'Tab') return;
|
||||
|
||||
const isAltSpace = e.altKey && (e.code === 'Space' || e.keyCode === 32);
|
||||
const isCmdSpace = e.metaKey && (e.code === 'Space' || e.keyCode === 32);
|
||||
const isCtrlShift = e.ctrlKey && e.shiftKey && ['KeyA', 'KeyC', 'KeyX', 'KeyV'].includes(e.code);
|
||||
const isWinKey = e.key === 'Meta' || e.key === 'Win';
|
||||
|
||||
if (isAltSpace || isCmdSpace || isCtrlShift || isWinKey) {
|
||||
logViolation('external_ai_detected', `External AI Hotkey Intercepted (${e.code || e.key}): Candidate activated external AI assistant overlay (Parakeet AI / Hotkey shortcut)`);
|
||||
if (isAltSpace || isCmdSpace) {
|
||||
logViolation('external_ai_detected', `External AI Hotkey Intercepted (${e.code || e.key}): Candidate activated external AI assistant overlay shortcut`);
|
||||
}
|
||||
});
|
||||
|
||||
@ -526,7 +675,6 @@ function submitSolution() {
|
||||
// --- ACCURATE EYE GAZE, 10-SECOND LOWER SCREEN STARE & QUESTION REPETITION DETECTION ---
|
||||
let gazeDownwardCounter = 0;
|
||||
let gazeDownwardDurationMs = 0;
|
||||
let hasLogged10sLowerGaze = false;
|
||||
let gazeFixedStaringCounter = 0;
|
||||
let previousFramePixels = null;
|
||||
|
||||
@ -541,46 +689,75 @@ function analyzeEyeGazeAndStaring() {
|
||||
const pixels = imgData.data;
|
||||
|
||||
let totalDarkPixels = 0;
|
||||
let lowerQuarterDarkPixels = 0;
|
||||
const quarterY = Math.floor(canvas.height * 0.70);
|
||||
let lowerHalfDarkPixels = 0;
|
||||
let sumY = 0;
|
||||
let facePixelCount = 0;
|
||||
|
||||
for (let y = 0; y < canvas.height; y++) {
|
||||
for (let x = 0; x < canvas.width; x++) {
|
||||
const halfY = Math.floor(canvas.height * 0.50);
|
||||
|
||||
for (let y = 0; y < canvas.height; y += 2) {
|
||||
for (let x = 0; x < canvas.width; x += 2) {
|
||||
const idx = (y * canvas.width + x) * 4;
|
||||
const gray = (pixels[idx] + pixels[idx+1] + pixels[idx+2]) / 3;
|
||||
const r = pixels[idx];
|
||||
const g = pixels[idx+1];
|
||||
const b = pixels[idx+2];
|
||||
const gray = (r + g + b) / 3;
|
||||
|
||||
if (gray < 45) {
|
||||
// Detect dark facial features (eyes, eyebrows, pupils)
|
||||
if (gray < 65) {
|
||||
totalDarkPixels++;
|
||||
if (y > quarterY) lowerQuarterDarkPixels++;
|
||||
if (y > halfY) lowerHalfDarkPixels++;
|
||||
}
|
||||
|
||||
// Skin tone / face region centroid
|
||||
if (r > 40 && g > 25 && b > 15 && (r > g) && (r > b) && Math.abs(r - g) > 10) {
|
||||
sumY += y;
|
||||
facePixelCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const lowerRatio = totalDarkPixels > 0 ? (lowerQuarterDarkPixels / totalDarkPixels) : 0;
|
||||
const lowerDarkRatio = totalDarkPixels > 0 ? (lowerHalfDarkPixels / totalDarkPixels) : 0;
|
||||
const faceYCentroid = facePixelCount > 0 ? (sumY / facePixelCount) : (canvas.height / 2);
|
||||
|
||||
// Lowered Gaze Check (looking down at desk/screen bottom)
|
||||
if (lowerRatio > 0.40) {
|
||||
// Candidate is looking at lower portion of screen / desk if face Y centroid is low or lower dark ratio is high
|
||||
const isLookingDownAtLowerScreen = (faceYCentroid > (canvas.height * 0.52)) || (lowerDarkRatio > 0.35);
|
||||
|
||||
if (isLookingDownAtLowerScreen) {
|
||||
gazeDownwardCounter++;
|
||||
gazeDownwardDurationMs += 400;
|
||||
|
||||
// 2.4s initial alert
|
||||
if (gazeDownwardCounter === 6) {
|
||||
logViolation('gaze_anomaly', 'Candidate lowered eye gaze toward screen bottom or desk');
|
||||
}
|
||||
|
||||
// Fix: 10.0 seconds of accumulated/continuous lower screen gaze (triggers log + SOS alert)
|
||||
if (gazeDownwardDurationMs >= 10000 && !hasLogged10sLowerGaze) {
|
||||
hasLogged10sLowerGaze = true;
|
||||
logViolation('gaze_lower_device', 'Candidate continuously looking at lower portion of screen for more than 10s (suspected reading from mobile or external device)');
|
||||
// 10.0s continuous lower screen stare alert (logs to proctor logs section in reviewer modal)
|
||||
if (gazeDownwardDurationMs >= 10000) {
|
||||
gazeDownwardDurationMs = 0; // Reset timer for next 10s window if candidate continues looking down
|
||||
logViolation('gaze_lower_device', 'Candidate continuously looking at lower portion of screen for 10s (suspected reading from mobile device or cheat sheet)');
|
||||
|
||||
// Show soft candidate warning overlay
|
||||
if (typeof Swal !== 'undefined' && !Swal.isVisible()) {
|
||||
Swal.fire({
|
||||
icon: 'warning',
|
||||
title: '⚠️ Please Look Up at Screen',
|
||||
text: 'Continuous lower screen gaze detected. Please maintain eye contact with your screen and camera.',
|
||||
timer: 3500,
|
||||
showConfirmButton: false,
|
||||
toast: true,
|
||||
position: 'top'
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
gazeDownwardCounter = Math.max(0, gazeDownwardCounter - 1);
|
||||
if (gazeDownwardCounter === 0) {
|
||||
gazeDownwardDurationMs = 0;
|
||||
hasLogged10sLowerGaze = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Fixed Screen Focus / Staring (10 seconds = 25 cycles * 400ms) Check
|
||||
// Fixed Screen Focus / Staring Check (10 seconds = 25 cycles * 400ms)
|
||||
if (previousFramePixels) {
|
||||
let frameDeltaSum = 0;
|
||||
for (let i = 0; i < pixels.length; i += 16) {
|
||||
@ -591,7 +768,7 @@ function analyzeEyeGazeAndStaring() {
|
||||
if (frameDelta >= 0.1 && frameDelta < 3.8) {
|
||||
gazeFixedStaringCounter++;
|
||||
if (gazeFixedStaringCounter === 25) { // 10.0 seconds of continuous fixed stare
|
||||
logViolation('gaze_fixed_staring', 'candidate might see other screen');
|
||||
logViolation('gaze_fixed_staring', 'Candidate maintaining fixed unnatural gaze (possible secondary screen/AI assistant)');
|
||||
}
|
||||
} else {
|
||||
gazeFixedStaringCounter = Math.max(0, gazeFixedStaringCounter - 1);
|
||||
@ -670,6 +847,196 @@ function logViolation(type, details) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- CANDIDATE REAL-TIME CALL RINGTONE SYNTHESIZER ---
|
||||
class CandRingtoneEngine {
|
||||
constructor() {
|
||||
this.ctx = null;
|
||||
this.interval = null;
|
||||
this.isPlaying = false;
|
||||
}
|
||||
|
||||
init() {
|
||||
try {
|
||||
if (!this.ctx) {
|
||||
const AudioCtx = window.AudioContext || window.webkitAudioContext;
|
||||
this.ctx = new AudioCtx();
|
||||
}
|
||||
if (this.ctx && this.ctx.state === 'suspended') {
|
||||
this.ctx.resume();
|
||||
}
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
start() {
|
||||
this.init();
|
||||
if (this.isPlaying) return;
|
||||
this.isPlaying = true;
|
||||
|
||||
this.playTone();
|
||||
if (this.interval) clearInterval(this.interval);
|
||||
this.interval = setInterval(() => {
|
||||
if (this.isPlaying) this.playTone();
|
||||
}, 2400);
|
||||
}
|
||||
|
||||
playTone() {
|
||||
this.init();
|
||||
if (!this.ctx || !this.isPlaying) return;
|
||||
try {
|
||||
const now = this.ctx.currentTime;
|
||||
const osc1 = this.ctx.createOscillator();
|
||||
const osc2 = this.ctx.createOscillator();
|
||||
const gain = this.ctx.createGain();
|
||||
|
||||
osc1.type = 'sine';
|
||||
osc2.type = 'sine';
|
||||
|
||||
osc1.frequency.setValueAtTime(523.25, now);
|
||||
osc2.frequency.setValueAtTime(659.25, now);
|
||||
|
||||
gain.gain.setValueAtTime(0.001, now);
|
||||
gain.gain.linearRampToValueAtTime(0.35, now + 0.08);
|
||||
gain.gain.setValueAtTime(0.35, now + 1.1);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, now + 1.3);
|
||||
|
||||
osc1.connect(gain);
|
||||
osc2.connect(gain);
|
||||
gain.connect(this.ctx.destination);
|
||||
|
||||
osc1.start(now);
|
||||
osc2.start(now);
|
||||
|
||||
osc1.stop(now + 1.35);
|
||||
osc2.stop(now + 1.35);
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.isPlaying = false;
|
||||
if (this.interval) {
|
||||
clearInterval(this.interval);
|
||||
this.interval = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
const candRingtone = new CandRingtoneEngine();
|
||||
|
||||
['click', 'keydown', 'touchstart'].forEach(evt => {
|
||||
document.addEventListener(evt, () => candRingtone.init(), { passive: true });
|
||||
});
|
||||
|
||||
let isCandidateCallConnected = false;
|
||||
let pendingOffer = null;
|
||||
let candRingTimer = null;
|
||||
|
||||
function triggerCandidateRing(offer = null, callStartedAt = null) {
|
||||
if (isCandidateCallConnected) return;
|
||||
if (offer) pendingOffer = offer;
|
||||
|
||||
// Check if call started more than 10 seconds ago
|
||||
if (callStartedAt) {
|
||||
const elapsed = Date.now() - new Date(callStartedAt).getTime();
|
||||
if (elapsed > 10000) {
|
||||
showCandidateJoinOption();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const badge = document.getElementById('call-status-badge');
|
||||
if (badge) {
|
||||
badge.innerText = '🔔 INCOMING CALL... RINGING';
|
||||
badge.style.background = 'rgba(234,179,8,0.3)';
|
||||
badge.style.color = '#fde047';
|
||||
}
|
||||
|
||||
const modal = document.getElementById('candidate-incoming-modal');
|
||||
if (modal) {
|
||||
modal.style.opacity = '1';
|
||||
modal.style.pointerEvents = 'auto';
|
||||
}
|
||||
candRingtone.start();
|
||||
|
||||
if (candRingTimer) clearTimeout(candRingTimer);
|
||||
let remainingMs = 10000;
|
||||
if (callStartedAt) {
|
||||
const elapsed = Date.now() - new Date(callStartedAt).getTime();
|
||||
remainingMs = Math.max(1000, 10000 - elapsed);
|
||||
}
|
||||
|
||||
candRingTimer = setTimeout(() => {
|
||||
stopCandidateRing();
|
||||
}, remainingMs);
|
||||
}
|
||||
|
||||
function stopCandidateRing() {
|
||||
candRingtone.stop();
|
||||
if (candRingTimer) {
|
||||
clearTimeout(candRingTimer);
|
||||
candRingTimer = null;
|
||||
}
|
||||
const modal = document.getElementById('candidate-incoming-modal');
|
||||
if (modal) {
|
||||
modal.style.opacity = '0';
|
||||
modal.style.pointerEvents = 'none';
|
||||
}
|
||||
showCandidateJoinOption();
|
||||
}
|
||||
|
||||
function showCandidateJoinOption() {
|
||||
if (isCandidateCallConnected) return;
|
||||
const badge = document.getElementById('call-status-badge');
|
||||
if (badge) {
|
||||
badge.innerText = '📞 CALL IN PROGRESS';
|
||||
badge.style.background = 'rgba(59,130,246,0.3)';
|
||||
badge.style.color = '#93c5fd';
|
||||
}
|
||||
const joinBtn = document.getElementById('candidate-join-active-call-btn');
|
||||
if (joinBtn) joinBtn.style.display = 'block';
|
||||
}
|
||||
|
||||
async function acceptCandidateCall() {
|
||||
candRingtone.stop();
|
||||
if (candRingTimer) {
|
||||
clearTimeout(candRingTimer);
|
||||
candRingTimer = null;
|
||||
}
|
||||
const modal = document.getElementById('candidate-incoming-modal');
|
||||
if (modal) {
|
||||
modal.style.opacity = '0';
|
||||
modal.style.pointerEvents = 'none';
|
||||
}
|
||||
const joinBtn = document.getElementById('candidate-join-active-call-btn');
|
||||
if (joinBtn) joinBtn.style.display = 'none';
|
||||
|
||||
isCandidateCallConnected = true;
|
||||
|
||||
const badge = document.getElementById('call-status-badge');
|
||||
if (badge) {
|
||||
badge.innerText = '🟢 CALL CONNECTED (LIVE)';
|
||||
badge.style.background = 'rgba(16,185,129,0.3)';
|
||||
badge.style.color = '#34d399';
|
||||
}
|
||||
|
||||
if (typeof Swal !== 'undefined') {
|
||||
Swal.fire({
|
||||
icon: 'success',
|
||||
title: 'Call Connected!',
|
||||
text: 'Live 2-way audio & video stream connected with interviewer.',
|
||||
timer: 3000,
|
||||
showConfirmButton: false,
|
||||
toast: true,
|
||||
position: 'top-end'
|
||||
});
|
||||
}
|
||||
|
||||
if (pendingOffer) {
|
||||
await handleInterviewerOffer(pendingOffer);
|
||||
pendingOffer = null;
|
||||
} else if (localMediaStream) {
|
||||
sendOfferToInterviewer();
|
||||
}
|
||||
}
|
||||
|
||||
// 4. WebRTC Real-Time 2-Way Interview Call Setup (Dual Signaling: BroadcastChannel + PeerJS)
|
||||
let localMediaStream = null;
|
||||
let peerInstance = null;
|
||||
@ -679,6 +1046,34 @@ function logViolation(type, details) {
|
||||
let isCamEnabled = true;
|
||||
let activeCallInstance = null;
|
||||
|
||||
const globalCallChannel = new BroadcastChannel('global_call_alerts');
|
||||
globalCallChannel.onmessage = (event) => {
|
||||
const data = event.data;
|
||||
if (!data) return;
|
||||
|
||||
if (data.type === 'incoming_call' && data.interview_id == interviewId) {
|
||||
triggerCandidateRing(null, data.call_started_at || null);
|
||||
} else if (data.type === 'call_ended' && data.interview_id == interviewId) {
|
||||
candRingtone.stop();
|
||||
if (candRingTimer) clearTimeout(candRingTimer);
|
||||
isCandidateCallConnected = false;
|
||||
const modal = document.getElementById('candidate-incoming-modal');
|
||||
if (modal) {
|
||||
modal.style.opacity = '0';
|
||||
modal.style.pointerEvents = 'none';
|
||||
}
|
||||
const joinBtn = document.getElementById('candidate-join-active-call-btn');
|
||||
if (joinBtn) joinBtn.style.display = 'none';
|
||||
const badge = document.getElementById('call-status-badge');
|
||||
if (badge) {
|
||||
badge.innerText = 'READY';
|
||||
badge.style.background = 'rgba(16,185,129,0.2)';
|
||||
badge.style.color = '#34d399';
|
||||
}
|
||||
candidateLeaveCall();
|
||||
}
|
||||
};
|
||||
|
||||
const callSigChannel = new BroadcastChannel('webrtc_call_' + interviewId);
|
||||
|
||||
callSigChannel.onmessage = async (event) => {
|
||||
@ -686,11 +1081,7 @@ function logViolation(type, details) {
|
||||
if (!msg) return;
|
||||
|
||||
if (msg.type === 'interviewer_joined' || msg.type === 'offer') {
|
||||
if (msg.offer && msg.sender !== 'candidate') {
|
||||
await handleInterviewerOffer(msg.offer);
|
||||
} else if (localMediaStream) {
|
||||
sendOfferToInterviewer();
|
||||
}
|
||||
triggerCandidateRing(msg.offer || null);
|
||||
} else if (msg.type === 'answer' && msg.sender !== 'candidate' && candidatePeerConnection) {
|
||||
await candidatePeerConnection.setRemoteDescription(new RTCSessionDescription(msg.answer));
|
||||
} else if (msg.type === 'ice-candidate' && msg.sender !== 'candidate' && candidatePeerConnection && msg.candidate) {
|
||||
@ -698,6 +1089,13 @@ function logViolation(type, details) {
|
||||
await candidatePeerConnection.addIceCandidate(new RTCIceCandidate(msg.candidate));
|
||||
} catch(e) {}
|
||||
} else if (msg.type === 'end_call_all') {
|
||||
candRingtone.stop();
|
||||
isCandidateCallConnected = false;
|
||||
const modal = document.getElementById('candidate-incoming-modal');
|
||||
if (modal) {
|
||||
modal.style.opacity = '0';
|
||||
modal.style.pointerEvents = 'none';
|
||||
}
|
||||
candidateLeaveCall();
|
||||
}
|
||||
};
|
||||
@ -728,9 +1126,26 @@ function logViolation(type, details) {
|
||||
}
|
||||
if (placeholder) placeholder.style.display = 'none';
|
||||
|
||||
document.getElementById('call-status-badge').innerText = 'CONNECTED';
|
||||
document.getElementById('call-status-badge').style.background = 'rgba(16,185,129,0.3)';
|
||||
document.getElementById('call-status-badge').style.color = '#34d399';
|
||||
const badge = document.getElementById('call-status-badge');
|
||||
if (badge) {
|
||||
badge.innerText = '🟢 CALL CONNECTED (LIVE)';
|
||||
badge.style.background = 'rgba(16,185,129,0.3)';
|
||||
badge.style.color = '#34d399';
|
||||
}
|
||||
};
|
||||
|
||||
candidatePeerConnection.onconnectionstatechange = () => {
|
||||
const state = candidatePeerConnection.connectionState;
|
||||
if (state === 'connected') {
|
||||
const badge = document.getElementById('call-status-badge');
|
||||
if (badge) {
|
||||
badge.innerText = '🟢 CALL CONNECTED (LIVE)';
|
||||
badge.style.background = 'rgba(16,185,129,0.3)';
|
||||
badge.style.color = '#34d399';
|
||||
}
|
||||
} else if (state === 'disconnected' || state === 'failed' || state === 'closed') {
|
||||
candidateLeaveCall();
|
||||
}
|
||||
};
|
||||
|
||||
candidatePeerConnection.onicecandidate = (event) => {
|
||||
@ -793,9 +1208,12 @@ function initCandidatePeer() {
|
||||
}
|
||||
if (placeholder) placeholder.style.display = 'none';
|
||||
|
||||
document.getElementById('call-status-badge').innerText = 'CONNECTED';
|
||||
document.getElementById('call-status-badge').style.background = 'rgba(16,185,129,0.3)';
|
||||
document.getElementById('call-status-badge').style.color = '#34d399';
|
||||
const badge = document.getElementById('call-status-badge');
|
||||
if (badge) {
|
||||
badge.innerText = '🟢 CALL CONNECTED (LIVE)';
|
||||
badge.style.background = 'rgba(16,185,129,0.3)';
|
||||
badge.style.color = '#34d399';
|
||||
}
|
||||
});
|
||||
|
||||
call.on('close', () => {
|
||||
@ -854,10 +1272,15 @@ function candidateLogoutAction() {
|
||||
}
|
||||
|
||||
function candidateLeaveCall() {
|
||||
isCandidateCallConnected = false;
|
||||
if (activeCallInstance) {
|
||||
try { activeCallInstance.close(); } catch(e) {}
|
||||
activeCallInstance = null;
|
||||
}
|
||||
if (candidatePeerConnection) {
|
||||
try { candidatePeerConnection.close(); } catch(e) {}
|
||||
candidatePeerConnection = null;
|
||||
}
|
||||
const remoteVideo = document.getElementById('interviewer-video');
|
||||
if (remoteVideo) remoteVideo.srcObject = null;
|
||||
const placeholder = document.getElementById('interviewer-video-placeholder');
|
||||
@ -865,10 +1288,28 @@ function candidateLeaveCall() {
|
||||
|
||||
const badge = document.getElementById('call-status-badge');
|
||||
if (badge) {
|
||||
badge.innerText = 'DISCONNECTED (LEAVE)';
|
||||
badge.innerText = 'CALL DISCONNECTED';
|
||||
badge.style.background = 'rgba(239,68,68,0.2)';
|
||||
badge.style.color = '#fca5a5';
|
||||
}
|
||||
|
||||
const joinBtn = document.getElementById('candidate-join-active-call-btn');
|
||||
if (joinBtn) {
|
||||
joinBtn.innerText = '🟢 📞 Rejoin Call Now';
|
||||
joinBtn.style.display = 'block';
|
||||
}
|
||||
|
||||
if (typeof Swal !== 'undefined') {
|
||||
Swal.fire({
|
||||
icon: 'info',
|
||||
title: 'Call Disconnected',
|
||||
text: 'You have dropped/left the call. Click "Rejoin Call Now" anytime to connect back.',
|
||||
timer: 3000,
|
||||
showConfirmButton: false,
|
||||
toast: true,
|
||||
position: 'top-end'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function toggleMic() {
|
||||
@ -1129,6 +1570,49 @@ function saveCanvasDrawing() {
|
||||
});
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
setInterval(() => {
|
||||
fetch(`{{ route('interview.candidate.poll', $interview->submission_unique_id) }}`, {
|
||||
headers: { 'Accept': 'application/json' }
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (!data) return;
|
||||
|
||||
if (data.enable_tab_switch_screenshot !== undefined) {
|
||||
isTabSwitchScreenshotEnabled = Boolean(data.enable_tab_switch_screenshot);
|
||||
}
|
||||
|
||||
if (data.call_status === 'active') {
|
||||
if (!isCandidateCallConnected) {
|
||||
const elapsed = data.call_started_at ? (Date.now() - new Date(data.call_started_at).getTime()) : 999999;
|
||||
if (elapsed <= 10000) {
|
||||
triggerCandidateRing(null, data.call_started_at);
|
||||
} else {
|
||||
showCandidateJoinOption();
|
||||
}
|
||||
}
|
||||
} else if (data.call_status === 'ended' || data.call_status === 'idle') {
|
||||
candRingtone.stop();
|
||||
if (candRingTimer) clearTimeout(candRingTimer);
|
||||
isCandidateCallConnected = false;
|
||||
const modal = document.getElementById('candidate-incoming-modal');
|
||||
if (modal) {
|
||||
modal.style.opacity = '0';
|
||||
modal.style.pointerEvents = 'none';
|
||||
}
|
||||
const joinBtn = document.getElementById('candidate-join-active-call-btn');
|
||||
if (joinBtn) joinBtn.style.display = 'none';
|
||||
const badge = document.getElementById('call-status-badge');
|
||||
if (badge) {
|
||||
badge.innerText = 'READY';
|
||||
badge.style.background = 'rgba(16,185,129,0.2)';
|
||||
badge.style.color = '#34d399';
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, 2200);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -280,6 +280,10 @@
|
||||
<span class="badge" style="background: rgba(16,185,129,0.15); color: #34d399; border: 1px solid rgba(16,185,129,0.3);">
|
||||
COMPLETED
|
||||
</span>
|
||||
@elseif($inv->call_status === 'active')
|
||||
<span class="badge" style="background: rgba(16,185,129,0.3); color: #34d399; border: 1px solid #10b981; font-weight: 800; animation: acceptGlow 1.5s infinite alternate;">
|
||||
🟢 📞 CALL IN PROGRESS
|
||||
</span>
|
||||
@elseif($inv->isActiveTimeline())
|
||||
<span class="badge" style="background: rgba(16,185,129,0.2); color: #34d399; border: 1px solid rgba(16,185,129,0.4);">
|
||||
⚡ TIMELINE LIVE
|
||||
@ -331,9 +335,15 @@
|
||||
📄 Report
|
||||
</a>
|
||||
|
||||
@if($inv->call_status === 'active')
|
||||
<button onclick="openReviewModal({{ $inv->id }}, '{{ addslashes($inv->candidate_name) }}', '{{ $inv->submission_unique_id }}', true)" class="btn-nav" style="background: linear-gradient(135deg, #10b981 0%, #059669 100%); color: white; border: none; font-weight: 800; padding: 6px 14px; margin-right: 4px; cursor: pointer; box-shadow: 0 0 15px rgba(16,185,129,0.6); animation: acceptGlow 1.5s infinite alternate;">
|
||||
📞 Join Active Call
|
||||
</button>
|
||||
@else
|
||||
<button onclick="openReviewModal({{ $inv->id }}, '{{ addslashes($inv->candidate_name) }}', '{{ $inv->submission_unique_id }}')" class="btn-nav" style="background: rgba(99,102,241,0.15); color: #818cf8; border-color: rgba(99,102,241,0.3); font-weight: 600; padding: 6px 12px; margin-right: 4px; cursor: pointer;">
|
||||
🔍 Review Code & Proctor Logs
|
||||
</button>
|
||||
@endif
|
||||
|
||||
<a href="{{ route('interview.room', $inv->submission_unique_id) }}" target="_blank" class="btn-nav" style="padding: 6px 10px; font-size: 0.75rem;">
|
||||
Open IDE Link
|
||||
@ -583,17 +593,18 @@
|
||||
<span style="font-size: 0.7rem; color: #34d399;">● Live Synced</span>
|
||||
</div>
|
||||
|
||||
<!-- Tab Switcher for Code Solution, Notepad, Drawing, Saved Profile Video Recordings -->
|
||||
<!-- Tab Switcher for Code Solution, Notepad, Drawing, Saved Profile Video Recordings, Screenshots -->
|
||||
<div style="display: flex; gap: 6px;">
|
||||
<button id="tab-btn-code" onclick="switchIdeTab('code')" class="btn-nav" style="padding: 5px 14px; font-size: 0.75rem; background: #6366f1; color: white; border: none; font-weight: 700;">💻 Live IDE Code</button>
|
||||
<button id="tab-btn-notes" onclick="switchIdeTab('notes')" class="btn-nav" style="padding: 5px 14px; font-size: 0.75rem; background: rgba(255,255,255,0.05); color: #94a3b8; border: 1px solid var(--card-border);">📝 Candidate Notepad</button>
|
||||
<button id="tab-btn-drawing" onclick="switchIdeTab('drawing')" class="btn-nav" style="padding: 5px 14px; font-size: 0.75rem; background: rgba(255,255,255,0.05); color: #94a3b8; border: 1px solid var(--card-border);">🎨 Candidate Drawing</button>
|
||||
<button id="tab-btn-recordings" onclick="switchIdeTab('recordings')" class="btn-nav" style="padding: 5px 14px; font-size: 0.75rem; background: rgba(255,255,255,0.05); color: #94a3b8; border: 1px solid var(--card-border);">📹 Saved Recordings (.MP4)</button>
|
||||
<button id="tab-btn-screenshots" onclick="switchIdeTab('screenshots')" class="btn-nav" style="padding: 5px 14px; font-size: 0.75rem; background: rgba(255,255,255,0.05); color: #94a3b8; border: 1px solid var(--card-border);">📸 Tab Switch Screenshots</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: 2.5fr 1fr; gap: 20px;">
|
||||
<!-- Left Column: Tab Content (Code Solution, Notepad, Canvas Drawing, or Saved Video Recordings) -->
|
||||
<!-- Left Column: Tab Content (Code Solution, Notepad, Canvas Drawing, Saved Recordings, Screenshots) -->
|
||||
<div>
|
||||
<!-- Tab 1: Live Code Solution & Terminal Output -->
|
||||
<div id="tab-content-code" style="display: block;">
|
||||
@ -626,6 +637,22 @@
|
||||
<div style="color: #94a3b8; font-size: 0.8rem; text-align: center; padding-top: 40px;">No video recordings saved for this candidate yet.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab 5: Candidate Tab-Switch Screenshots -->
|
||||
<div id="tab-content-screenshots" style="display: none;">
|
||||
<div style="font-size: 0.75rem; color: #94a3b8; margin-bottom: 6px; font-weight: 600; display: flex; justify-content: space-between; align-items: center;">
|
||||
<span>📸 Candidate Tab-Switch Captured Screenshots:</span>
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<span style="font-size: 0.7rem; color: #a7f3d0;">Admin Screenshot Capture:</span>
|
||||
<button id="modal-toggle-screenshot-btn" onclick="toggleTabScreenshotCapture()" class="btn-nav" style="padding: 3px 10px; font-size: 0.7rem; background: #10b981; color: white; border: none; font-weight: 700; border-radius: 6px; cursor: pointer;">
|
||||
📸 ENABLED
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="modal-screenshots-container" style="background: #050811; border: 1px solid var(--card-border); border-radius: 10px; padding: 12px; min-height: 250px; max-height: 420px; overflow-y: auto;">
|
||||
<div style="color: #94a3b8; font-size: 0.8rem; text-align: center; padding-top: 40px;">No tab-switch screenshots captured for this candidate yet.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column: Proctoring Audit & Silent Warning Form -->
|
||||
@ -683,11 +710,58 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- INCOMING CALL RING MODAL (REAL CALLING APP UI FOR PANELISTS) -->
|
||||
<div id="incoming-call-modal" class="admin-modal" style="z-index: 10000; background: rgba(5, 8, 17, 0.85); backdrop-filter: blur(16px);">
|
||||
<div class="admin-modal-content" style="max-width: 460px; background: linear-gradient(145deg, #0f172a 0%, #1e1b4b 100%); border: 2px solid #6366f1; border-radius: 24px; padding: 32px; box-shadow: 0 0 50px rgba(99, 102, 241, 0.6); text-align: center;">
|
||||
|
||||
<div style="position: relative; width: 90px; height: 90px; margin: 0 auto 20px auto; display: flex; align-items: center; justify-content: center;">
|
||||
<div style="position: absolute; inset: -15px; border-radius: 50%; border: 2px solid rgba(16, 185, 129, 0.6); animation: ringPulse 1.4s infinite ease-out;"></div>
|
||||
<div style="position: absolute; inset: -30px; border-radius: 50%; border: 2px solid rgba(99, 102, 241, 0.4); animation: ringPulse 1.4s infinite ease-out 0.4s;"></div>
|
||||
<div style="width: 80px; height: 80px; border-radius: 50%; background: linear-gradient(135deg, #10b981 0%, #0078d4 100%); display: flex; align-items: center; justify-content: center; font-size: 2.4rem; font-weight: 800; color: white; box-shadow: 0 10px 25px rgba(16, 185, 129, 0.5); z-index: 5;">
|
||||
📞
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="font-size: 0.75rem; text-transform: uppercase; letter-spacing: 1.5px; color: #34d399; font-weight: 800; margin-bottom: 6px;">
|
||||
⚡ INCOMING INTERVIEW CALL
|
||||
</div>
|
||||
|
||||
<h3 id="incoming-cand-name" style="font-family: 'Outfit', sans-serif; font-size: 1.45rem; font-weight: 800; color: white; margin-bottom: 4px;">
|
||||
Candidate Name
|
||||
</h3>
|
||||
|
||||
<div id="incoming-starter-info" style="font-size: 0.82rem; color: #a5b4fc; margin-bottom: 16px;">
|
||||
Initiated by Panelist
|
||||
</div>
|
||||
|
||||
<div style="background: rgba(255, 255, 255, 0.05); border: 1px solid rgba(255,255,255,0.1); border-radius: 12px; padding: 12px; font-size: 0.78rem; color: #cbd5e1; margin-bottom: 24px; line-height: 1.4;">
|
||||
Another interviewer has started calling the candidate. Click <strong>Accept Call</strong> to join live, or <strong>Decline</strong> if busy (you can join later anytime from the dashboard table).
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 14px; justify-content: center;">
|
||||
<button id="incoming-decline-btn" onclick="declineIncomingCall()" class="btn-nav" style="flex: 1; padding: 12px 18px; background: rgba(239, 68, 68, 0.2); border: 1.5px solid #ef4444; color: #fca5a5; font-weight: 700; font-size: 0.9rem; border-radius: 12px; cursor: pointer;">
|
||||
🚫 Decline / Miss
|
||||
</button>
|
||||
<button id="incoming-accept-btn" onclick="acceptIncomingCall()" class="btn-nav" style="flex: 1.3; padding: 12px 18px; background: linear-gradient(135deg, #10b981 0%, #059669 100%); border: none; color: white; font-weight: 800; font-size: 0.9rem; border-radius: 12px; box-shadow: 0 0 20px rgba(16, 185, 129, 0.6); cursor: pointer; animation: acceptGlow 1.5s infinite alternate;">
|
||||
📞 Accept Call
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes sosPulse {
|
||||
0% { transform: scale(1); box-shadow: 0 0 10px rgba(239,68,68,0.5); }
|
||||
100% { transform: scale(1.05); box-shadow: 0 0 25px rgba(239,68,68,1); }
|
||||
}
|
||||
@keyframes ringPulse {
|
||||
0% { transform: scale(0.85); opacity: 1; }
|
||||
100% { transform: scale(1.35); opacity: 0; }
|
||||
}
|
||||
@keyframes acceptGlow {
|
||||
0% { box-shadow: 0 0 15px rgba(16, 185, 129, 0.5); transform: scale(1); }
|
||||
100% { box-shadow: 0 0 28px rgba(16, 185, 129, 0.9); transform: scale(1.03); }
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
@ -768,11 +842,59 @@ function openReportPage() {
|
||||
window.open(reportUrl, '_blank');
|
||||
}
|
||||
|
||||
let currentTabScreenshotEnabled = true;
|
||||
|
||||
function toggleTabScreenshotCapture() {
|
||||
if (!currentInterviewId) return;
|
||||
const newStatus = !currentTabScreenshotEnabled;
|
||||
fetch(`{{ route('interview.toggle-tab-screenshot', ':id') }}`.replace(':id', currentInterviewId), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
},
|
||||
body: JSON.stringify({ enable: newStatus })
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(res => {
|
||||
if (res.success) {
|
||||
currentTabScreenshotEnabled = res.enable_tab_switch_screenshot;
|
||||
updateScreenshotToggleBtnUI(currentTabScreenshotEnabled);
|
||||
if (typeof Swal !== 'undefined') {
|
||||
Swal.fire({
|
||||
icon: 'info',
|
||||
title: 'Screenshot Setting Updated',
|
||||
text: res.message,
|
||||
timer: 2500,
|
||||
showConfirmButton: false,
|
||||
toast: true,
|
||||
position: 'top-end'
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
function updateScreenshotToggleBtnUI(enabled) {
|
||||
const btn = document.getElementById('modal-toggle-screenshot-btn');
|
||||
if (btn) {
|
||||
if (enabled) {
|
||||
btn.innerText = '📸 ENABLED';
|
||||
btn.style.background = '#10b981';
|
||||
} else {
|
||||
btn.innerText = '🔕 DISABLED';
|
||||
btn.style.background = '#ef4444';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function switchIdeTab(tab) {
|
||||
document.getElementById('tab-content-code').style.display = (tab === 'code') ? 'block' : 'none';
|
||||
document.getElementById('tab-content-notes').style.display = (tab === 'notes') ? 'block' : 'none';
|
||||
document.getElementById('tab-content-drawing').style.display = (tab === 'drawing') ? 'block' : 'none';
|
||||
document.getElementById('tab-content-recordings').style.display = (tab === 'recordings') ? 'block' : 'none';
|
||||
document.getElementById('tab-content-screenshots').style.display = (tab === 'screenshots') ? 'block' : 'none';
|
||||
|
||||
document.getElementById('tab-btn-code').style.background = (tab === 'code') ? '#6366f1' : 'rgba(255,255,255,0.05)';
|
||||
document.getElementById('tab-btn-code').style.color = (tab === 'code') ? 'white' : '#94a3b8';
|
||||
@ -785,9 +907,245 @@ function switchIdeTab(tab) {
|
||||
|
||||
document.getElementById('tab-btn-recordings').style.background = (tab === 'recordings') ? '#10b981' : 'rgba(255,255,255,0.05)';
|
||||
document.getElementById('tab-btn-recordings').style.color = (tab === 'recordings') ? 'white' : '#94a3b8';
|
||||
|
||||
const shotBtn = document.getElementById('tab-btn-screenshots');
|
||||
if (shotBtn) {
|
||||
shotBtn.style.background = (tab === 'screenshots') ? '#f59e0b' : 'rgba(255,255,255,0.05)';
|
||||
shotBtn.style.color = (tab === 'screenshots') ? 'white' : '#94a3b8';
|
||||
}
|
||||
}
|
||||
|
||||
function openReviewModal(id, name, uid) {
|
||||
// --- REAL-TIME CALL RINGTONE AUDIO SYNTHESIZER ---
|
||||
class CallRingtoneEngine {
|
||||
constructor() {
|
||||
this.ctx = null;
|
||||
this.interval = null;
|
||||
this.isPlaying = false;
|
||||
}
|
||||
|
||||
init() {
|
||||
try {
|
||||
if (!this.ctx) {
|
||||
const AudioCtx = window.AudioContext || window.webkitAudioContext;
|
||||
this.ctx = new AudioCtx();
|
||||
}
|
||||
if (this.ctx && this.ctx.state === 'suspended') {
|
||||
this.ctx.resume();
|
||||
}
|
||||
} catch(e) { console.log('Audio init:', e); }
|
||||
}
|
||||
|
||||
start() {
|
||||
this.init();
|
||||
if (this.isPlaying) return;
|
||||
this.isPlaying = true;
|
||||
|
||||
this.playTone();
|
||||
if (this.interval) clearInterval(this.interval);
|
||||
this.interval = setInterval(() => {
|
||||
if (this.isPlaying) this.playTone();
|
||||
}, 2400);
|
||||
}
|
||||
|
||||
playTone() {
|
||||
this.init();
|
||||
if (!this.ctx || !this.isPlaying) return;
|
||||
try {
|
||||
const now = this.ctx.currentTime;
|
||||
const osc1 = this.ctx.createOscillator();
|
||||
const osc2 = this.ctx.createOscillator();
|
||||
const gain = this.ctx.createGain();
|
||||
|
||||
osc1.type = 'sine';
|
||||
osc2.type = 'sine';
|
||||
|
||||
// Loud, authentic phone call ring frequencies (C5 523Hz + E5 659Hz)
|
||||
osc1.frequency.setValueAtTime(523.25, now);
|
||||
osc2.frequency.setValueAtTime(659.25, now);
|
||||
|
||||
gain.gain.setValueAtTime(0.001, now);
|
||||
gain.gain.linearRampToValueAtTime(0.35, now + 0.08);
|
||||
gain.gain.setValueAtTime(0.35, now + 1.1);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, now + 1.3);
|
||||
|
||||
osc1.connect(gain);
|
||||
osc2.connect(gain);
|
||||
gain.connect(this.ctx.destination);
|
||||
|
||||
osc1.start(now);
|
||||
osc2.start(now);
|
||||
|
||||
osc1.stop(now + 1.35);
|
||||
osc2.stop(now + 1.35);
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.isPlaying = false;
|
||||
if (this.interval) {
|
||||
clearInterval(this.interval);
|
||||
this.interval = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
const callRingtone = new CallRingtoneEngine();
|
||||
|
||||
// Unlock browser AudioContext on user gesture anywhere on page
|
||||
['click', 'keydown', 'touchstart'].forEach(evt => {
|
||||
document.addEventListener(evt, () => callRingtone.init(), { passive: true });
|
||||
});
|
||||
|
||||
// Active Call Listening & Instant BroadcastChannel Signaling State
|
||||
let activeIncomingCall = null;
|
||||
let iAmTheCaller = false;
|
||||
let ringTimeoutTimer = null;
|
||||
const dismissedCallIds = new Set();
|
||||
const endedCallIds = new Set();
|
||||
const currentUserId = {{ Auth::id() }};
|
||||
const globalCallChannel = new BroadcastChannel('global_call_alerts');
|
||||
|
||||
function triggerIncomingCallRing(callData) {
|
||||
if (endedCallIds.has(callData.id) || dismissedCallIds.has(callData.id)) return;
|
||||
if (iAmTheCaller && currentInterviewId == callData.id) return;
|
||||
if (interviewerLocalStream && currentInterviewId == callData.id) return;
|
||||
|
||||
// Check ring 10-second threshold based on call_started_at
|
||||
if (callData.call_started_at) {
|
||||
const startedTime = new Date(callData.call_started_at).getTime();
|
||||
const elapsedMs = Date.now() - startedTime;
|
||||
if (elapsedMs > 10000) return;
|
||||
}
|
||||
|
||||
activeIncomingCall = callData;
|
||||
document.getElementById('incoming-cand-name').innerText = callData.candidate_name;
|
||||
document.getElementById('incoming-starter-info').innerText = '📞 Call initiated by: ' + (callData.starter_name || 'Panelist');
|
||||
document.getElementById('incoming-call-modal').classList.add('active');
|
||||
callRingtone.start();
|
||||
|
||||
// Set 10-second auto-stop ring timeout
|
||||
if (ringTimeoutTimer) clearTimeout(ringTimeoutTimer);
|
||||
let remainingMs = 10000;
|
||||
if (callData.call_started_at) {
|
||||
const startedTime = new Date(callData.call_started_at).getTime();
|
||||
const elapsedMs = Date.now() - startedTime;
|
||||
remainingMs = Math.max(1000, 10000 - elapsedMs);
|
||||
}
|
||||
|
||||
ringTimeoutTimer = setTimeout(() => {
|
||||
declineIncomingCall(true);
|
||||
}, remainingMs);
|
||||
}
|
||||
|
||||
globalCallChannel.onmessage = (event) => {
|
||||
const data = event.data;
|
||||
if (!data) return;
|
||||
|
||||
if (data.type === 'incoming_call') {
|
||||
triggerIncomingCallRing({
|
||||
id: data.interview_id,
|
||||
candidate_name: data.candidate_name,
|
||||
submission_unique_id: data.submission_unique_id,
|
||||
starter_name: data.starter_name,
|
||||
call_started_at: data.call_started_at || new Date().toISOString()
|
||||
});
|
||||
} else if (data.type === 'call_ended') {
|
||||
endedCallIds.add(data.interview_id);
|
||||
dismissedCallIds.add(data.interview_id);
|
||||
callRingtone.stop();
|
||||
if (ringTimeoutTimer) {
|
||||
clearTimeout(ringTimeoutTimer);
|
||||
ringTimeoutTimer = null;
|
||||
}
|
||||
document.getElementById('incoming-call-modal').classList.remove('active');
|
||||
if (activeIncomingCall && activeIncomingCall.id == data.interview_id) {
|
||||
activeIncomingCall = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function pollGlobalActiveCalls() {
|
||||
fetch(`{{ route('interview.active-calls') }}`, {
|
||||
headers: { 'Accept': 'application/json' }
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (!data || !data.active_calls || data.active_calls.length === 0) {
|
||||
if (activeIncomingCall) {
|
||||
declineIncomingCall(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const incoming = data.active_calls.find(call => {
|
||||
if (endedCallIds.has(call.id)) return false;
|
||||
if (iAmTheCaller && currentInterviewId == call.id) return false;
|
||||
if (interviewerLocalStream && currentInterviewId == call.id) return false;
|
||||
if (dismissedCallIds.has(call.id)) return false;
|
||||
|
||||
if (call.call_started_at) {
|
||||
const elapsedMs = Date.now() - new Date(call.call_started_at).getTime();
|
||||
if (elapsedMs > 10000) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
if (incoming) {
|
||||
if (!activeIncomingCall || activeIncomingCall.id !== incoming.id) {
|
||||
triggerIncomingCallRing(incoming);
|
||||
}
|
||||
} else if (activeIncomingCall) {
|
||||
declineIncomingCall(true);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
// Poll for active calls every 2 seconds
|
||||
setInterval(pollGlobalActiveCalls, 2000);
|
||||
|
||||
function acceptIncomingCall() {
|
||||
callRingtone.stop();
|
||||
if (ringTimeoutTimer) {
|
||||
clearTimeout(ringTimeoutTimer);
|
||||
ringTimeoutTimer = null;
|
||||
}
|
||||
document.getElementById('incoming-call-modal').classList.remove('active');
|
||||
|
||||
if (activeIncomingCall) {
|
||||
const targetCall = activeIncomingCall;
|
||||
activeIncomingCall = null;
|
||||
openReviewModal(targetCall.id, targetCall.candidate_name, targetCall.submission_unique_id, true);
|
||||
}
|
||||
}
|
||||
|
||||
function declineIncomingCall(silent = false) {
|
||||
callRingtone.stop();
|
||||
if (ringTimeoutTimer) {
|
||||
clearTimeout(ringTimeoutTimer);
|
||||
ringTimeoutTimer = null;
|
||||
}
|
||||
document.getElementById('incoming-call-modal').classList.remove('active');
|
||||
|
||||
if (activeIncomingCall) {
|
||||
dismissedCallIds.add(activeIncomingCall.id);
|
||||
activeIncomingCall = null;
|
||||
}
|
||||
|
||||
if (!silent && typeof Swal !== 'undefined') {
|
||||
Swal.fire({
|
||||
icon: 'info',
|
||||
title: 'Call Missed / Declined',
|
||||
text: 'You can click "Join Active Call" from the list whenever you are ready.',
|
||||
timer: 3000,
|
||||
showConfirmButton: false,
|
||||
toast: true,
|
||||
position: 'top-end'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function openReviewModal(id, name, uid, autoJoinCall = false) {
|
||||
currentInterviewId = id;
|
||||
acknowledgedViolationCount = 0;
|
||||
sosDismissed = false;
|
||||
@ -809,6 +1167,12 @@ function openReviewModal(id, name, uid) {
|
||||
pollReviewData();
|
||||
clearInterval(sosPollInterval);
|
||||
sosPollInterval = setInterval(pollReviewData, 2000);
|
||||
|
||||
if (autoJoinCall) {
|
||||
setTimeout(() => {
|
||||
startInterviewerCall(true);
|
||||
}, 400);
|
||||
}
|
||||
}
|
||||
|
||||
function pollReviewData() {
|
||||
@ -913,24 +1277,69 @@ function pollReviewData() {
|
||||
chatHistory.scrollTop = chatHistory.scrollHeight;
|
||||
}
|
||||
|
||||
// React to Global Call Status
|
||||
// Update Tab Switch Screenshot Toggle UI State
|
||||
currentTabScreenshotEnabled = (data.enable_tab_switch_screenshot !== undefined) ? Boolean(data.enable_tab_switch_screenshot) : true;
|
||||
updateScreenshotToggleBtnUI(currentTabScreenshotEnabled);
|
||||
|
||||
// Render Captured Tab-Switch Screenshots Gallery
|
||||
const shotContainer = document.getElementById('modal-screenshots-container');
|
||||
if (shotContainer) {
|
||||
const shotList = data.tab_switch_screenshots || [];
|
||||
if (shotList.length > 0) {
|
||||
let shotHtml = `<div style="font-size: 0.75rem; color: #f59e0b; font-weight: 700; margin-bottom: 10px; display: flex; justify-content: space-between; align-items: center;">
|
||||
<span>📸 Tab-Switch Screenshots Captured (${shotList.length})</span>
|
||||
<span style="font-size: 0.65rem; color: #94a3b8;">Click image to enlarge full size</span>
|
||||
</div><div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 12px;">`;
|
||||
|
||||
shotList.forEach((s, idx) => {
|
||||
const url = typeof s === 'string' ? s : s.url;
|
||||
const timeStr = (s && s.timestamp) ? new Date(s.timestamp).toLocaleTimeString() : '';
|
||||
shotHtml += `<div style="background: rgba(255,255,255,0.04); border: 1px solid var(--card-border); border-radius: 8px; padding: 8px; text-align: center;">
|
||||
<img src="${url}" style="width: 100%; height: 110px; object-fit: cover; border-radius: 6px; cursor: pointer; border: 1px solid rgba(255,255,255,0.1);" onclick="window.open('${url}', '_blank')" title="Click to view full image" />
|
||||
<div style="font-size: 0.65rem; color: #94a3b8; margin-top: 4px;">📅 ${timeStr}</div>
|
||||
<a href="${url}" target="_blank" download class="btn-nav" style="background: #f59e0b; color: white; border: none; font-size: 0.65rem; font-weight: 700; padding: 3px 8px; text-decoration: none; border-radius: 4px; display: inline-block; margin-top: 4px;">
|
||||
📥 View / Download
|
||||
</a>
|
||||
</div>`;
|
||||
});
|
||||
shotHtml += `</div>`;
|
||||
shotContainer.innerHTML = shotHtml;
|
||||
} else {
|
||||
shotContainer.innerHTML = '<div style="color: #94a3b8; font-size: 0.8rem; text-align: center; padding-top: 40px;">No tab-switch screenshots captured for this candidate yet.</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// React to Global Call Status & Control Recording Button Availability
|
||||
const callStatusBadge = document.getElementById('interviewer-call-status');
|
||||
const recStartBtn = document.getElementById('btn-start-recording');
|
||||
const recStopBtn = document.getElementById('btn-stop-recording');
|
||||
|
||||
if (callStatusBadge && data.call_status) {
|
||||
if (data.call_status === 'active' && !interviewerLocalStream) {
|
||||
if (data.call_status === 'active') {
|
||||
if (!interviewerLocalStream) {
|
||||
callStatusBadge.innerText = 'CALL ACTIVE';
|
||||
callStatusBadge.style.background = 'rgba(16,185,129,0.2)';
|
||||
callStatusBadge.style.color = '#34d399';
|
||||
document.getElementById('btn-start-call').innerText = '📞 Join Call';
|
||||
document.getElementById('btn-start-call').style.background = '#6366f1';
|
||||
document.getElementById('btn-start-call').style.display = 'inline-block';
|
||||
} else if (data.call_status === 'ended') {
|
||||
if (interviewerLocalStream) leaveInterviewerCall();
|
||||
callStatusBadge.innerText = 'CALL ENDED';
|
||||
callStatusBadge.style.background = 'rgba(239,68,68,0.2)';
|
||||
callStatusBadge.style.color = '#fca5a5';
|
||||
document.getElementById('btn-start-call').innerText = '📞 Restart Call';
|
||||
}
|
||||
// Recording button ONLY available when call has started
|
||||
if (recStartBtn && (!adminMediaRecorder || adminMediaRecorder.state === 'inactive')) {
|
||||
recStartBtn.style.display = 'inline-flex';
|
||||
}
|
||||
} else if (data.call_status === 'ended' || data.call_status === 'idle') {
|
||||
if (interviewerLocalStream && data.call_status === 'ended') leaveInterviewerCall();
|
||||
callStatusBadge.innerText = (data.call_status === 'ended') ? 'CALL ENDED' : 'DISCONNECTED';
|
||||
callStatusBadge.style.background = (data.call_status === 'ended') ? 'rgba(239,68,68,0.2)' : 'rgba(99,102,241,0.2)';
|
||||
callStatusBadge.style.color = (data.call_status === 'ended') ? '#fca5a5' : '#818cf8';
|
||||
document.getElementById('btn-start-call').innerText = (data.call_status === 'ended') ? '📞 Restart Call' : '📞 Start / Join Call';
|
||||
document.getElementById('btn-start-call').style.background = '#10b981';
|
||||
document.getElementById('btn-start-call').style.display = 'inline-block';
|
||||
|
||||
// Recording button HIDDEN when call is not active
|
||||
if (recStartBtn) recStartBtn.style.display = 'none';
|
||||
if (recStopBtn) recStopBtn.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
@ -945,12 +1354,16 @@ function pollReviewData() {
|
||||
if (l.type === 'gaze_lower_device') icon = '📱';
|
||||
if (l.type === 'question_repeat_lower' || l.type === 'question_repetition') icon = '🗣️';
|
||||
if (l.type === 'external_ai_detected') icon = '🤖';
|
||||
if (l.type === 'tab_switch') icon = '📸';
|
||||
|
||||
if (['paste_event', 'tab_switch', 'focus_lost', 'gaze_anomaly', 'gaze_fixed_staring', 'gaze_lower_device', 'question_repeat_lower', 'question_repetition', 'external_ai_detected'].includes(l.type)) {
|
||||
currentViolations.push(l);
|
||||
}
|
||||
|
||||
let screenshotLink = l.screenshot_url ? `<a href="${l.screenshot_url}" target="_blank" style="color: #38bdf8; text-decoration: underline; margin-left: 6px;">[📸 View Screenshot]</a>` : '';
|
||||
|
||||
logsHtml += `<div style="margin-bottom:6px; border-bottom:1px solid rgba(255,255,255,0.05); padding-bottom:4px;">
|
||||
<strong>${icon} ${l.type.toUpperCase()}:</strong> ${l.details || ''}
|
||||
<strong>${icon} ${l.type.toUpperCase()}:</strong> ${l.details || ''}${screenshotLink}
|
||||
<div style="font-size:0.65rem; color:#94a3b8;">${new Date(l.timestamp).toLocaleTimeString()}</div>
|
||||
</div>`;
|
||||
});
|
||||
@ -1241,9 +1654,23 @@ function initInterviewerSigChannel() {
|
||||
interviewerSigChannel.postMessage({ type: 'answer', answer: answer, sender: 'interviewer' });
|
||||
}
|
||||
|
||||
function startInterviewerCall() {
|
||||
function startInterviewerCall(isRejoin = false) {
|
||||
if (!currentInterviewId) return;
|
||||
|
||||
if (!isRejoin) {
|
||||
iAmTheCaller = true;
|
||||
|
||||
// Broadcast instant incoming call alert to all open tabs/windows
|
||||
const candName = document.getElementById('modal-cand-name') ? document.getElementById('modal-cand-name').innerText : 'Candidate';
|
||||
globalCallChannel.postMessage({
|
||||
type: 'incoming_call',
|
||||
interview_id: currentInterviewId,
|
||||
candidate_name: candName,
|
||||
starter_name: '{{ Auth::user()->name }}',
|
||||
call_started_at: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
initInterviewerSigChannel();
|
||||
|
||||
// Notify backend that call session is active
|
||||
@ -1253,7 +1680,7 @@ function startInterviewerCall() {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
},
|
||||
body: JSON.stringify({ call_status: 'active' })
|
||||
body: JSON.stringify({ call_status: 'active', is_rejoin: isRejoin })
|
||||
});
|
||||
|
||||
const handleStream = (stream) => {
|
||||
@ -1372,7 +1799,19 @@ function endCallForAll() {
|
||||
}).then((result) => {
|
||||
if (result.isConfirmed) {
|
||||
if (currentInterviewId) {
|
||||
fetch(`{{ route('interview.call-status', ':id') }}`.replace(':id', currentInterviewId), {
|
||||
const endingId = currentInterviewId;
|
||||
endedCallIds.add(endingId);
|
||||
dismissedCallIds.add(endingId);
|
||||
callRingtone.stop();
|
||||
document.getElementById('incoming-call-modal').classList.remove('active');
|
||||
activeIncomingCall = null;
|
||||
|
||||
globalCallChannel.postMessage({
|
||||
type: 'call_ended',
|
||||
interview_id: endingId
|
||||
});
|
||||
|
||||
fetch(`{{ route('interview.call-status', ':id') }}`.replace(':id', endingId), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@ -1382,6 +1821,7 @@ function endCallForAll() {
|
||||
});
|
||||
}
|
||||
|
||||
iAmTheCaller = false;
|
||||
leaveInterviewerCall();
|
||||
document.getElementById('interviewer-call-status').innerText = 'CALL ENDED FOR ALL';
|
||||
document.getElementById('interviewer-call-status').style.background = 'rgba(239,68,68,0.2)';
|
||||
@ -1603,6 +2043,24 @@ function sendSilentWarning() {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const openId = urlParams.get('open_interview');
|
||||
const autoJoin = urlParams.get('join_call') === '1';
|
||||
|
||||
if (openId) {
|
||||
fetch(`{{ route('interview.poll', ':id') }}`.replace(':id', openId), {
|
||||
headers: { 'Accept': 'application/json' }
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data && data.status) {
|
||||
openReviewModal(parseInt(openId), data.candidate_name || 'Candidate', data.submission_unique_id || openId, autoJoin);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -32,6 +32,7 @@
|
||||
Route::post('/candidate/submit-code/{id}', [InterviewController::class, 'submitCode'])->name('interview.submit');
|
||||
Route::post('/candidate/log-violation/{id}', [InterviewController::class, 'logViolation'])->name('interview.violation');
|
||||
Route::post('/candidate/upload-recording/{id}', [InterviewController::class, 'uploadRecording'])->name('interview.upload-recording');
|
||||
Route::post('/candidate/upload-tab-screenshot/{id}', [InterviewController::class, 'uploadTabScreenshot'])->name('interview.upload-tab-screenshot');
|
||||
Route::post('/candidate/sync-code/{id}', [InterviewController::class, 'syncCandidateCode'])->name('interview.candidate.sync-code');
|
||||
Route::post('/candidate/notes/{id}', [InterviewController::class, 'saveCandidateNotes'])->name('interview.candidate.notes');
|
||||
Route::post('/candidate/drawing/{id}', [InterviewController::class, 'saveCandidateDrawing'])->name('interview.candidate.drawing');
|
||||
@ -68,6 +69,7 @@
|
||||
|
||||
// Candidate Assessment Management for HR & Panelists
|
||||
Route::get('/interviews', [InterviewController::class, 'index'])->name('interview.index');
|
||||
Route::get('/interviews/active-calls', [InterviewController::class, 'getActiveCalls'])->name('interview.active-calls');
|
||||
Route::post('/interviews', [InterviewController::class, 'store'])->name('interview.store');
|
||||
Route::post('/interviews/{id}/warning', [InterviewController::class, 'sendWarning'])->name('interview.warning');
|
||||
Route::get('/interviews/{id}/poll', [InterviewController::class, 'getPollData'])->name('interview.poll');
|
||||
@ -76,6 +78,7 @@
|
||||
Route::get('/interviews/{id}/report', [InterviewController::class, 'generateReport'])->name('interview.report');
|
||||
Route::get('/interviews/{id}/download-recording', [InterviewController::class, 'downloadRecording'])->name('interview.download-recording');
|
||||
Route::post('/interviews/{id}/call-status', [InterviewController::class, 'updateCallStatus'])->name('interview.call-status');
|
||||
Route::post('/interviews/{id}/toggle-tab-screenshot', [InterviewController::class, 'toggleTabScreenshot'])->name('interview.toggle-tab-screenshot');
|
||||
});
|
||||
|
||||
// Admin Control Panel (requires admin role)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user