Snapshot of main at 9f39a6e1e (2026-11-02)
This commit is contained in:
commit
6ccf1792d7
2580 files changed
+1040202
No files matched your search
@@ -0,0 +1,450 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Call;
|
||||
use Barryvdh\DomPDF\Facade\Pdf;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class CallController extends Controller
|
||||
{
|
||||
public function showById($id)
|
||||
{
|
||||
$call = Call::withTrashed()->with([
|
||||
'callable',
|
||||
'translations',
|
||||
'tag.contexts.category.translations',
|
||||
'tag.contexts.category.ancestors.translations',
|
||||
'location.city.translations',
|
||||
'location.district.translations',
|
||||
'location.country.translations',
|
||||
'location.division.translations',
|
||||
'loveReactant.reactionCounters',
|
||||
])->findOrFail($id);
|
||||
|
||||
$activeProfileType = session('activeProfileType');
|
||||
$activeProfileId = session('activeProfileId');
|
||||
$isAdmin = $activeProfileType === \App\Models\Admin::class;
|
||||
$isCallable = $activeProfileType === $call->callable_type
|
||||
&& (int) $activeProfileId === (int) $call->callable_id;
|
||||
|
||||
// Deleted calls are only visible to admins and the callable itself
|
||||
$isDeleted = $call->trashed();
|
||||
if ($isDeleted && !$isAdmin && !$isCallable) {
|
||||
return view('calls.unavailable');
|
||||
}
|
||||
|
||||
// Blocked calls are only visible to admins and the callable itself
|
||||
if ($call->is_suppressed && !$isAdmin && !$isCallable) {
|
||||
return view('calls.unavailable');
|
||||
}
|
||||
|
||||
// Paused calls are only visible to admins and the callable itself
|
||||
if ($call->is_paused && !$isAdmin && !$isCallable) {
|
||||
return view('calls.unavailable');
|
||||
}
|
||||
|
||||
// Guests cannot view private calls
|
||||
if (!$call->is_public && !Auth::check()) {
|
||||
return redirect()->route('login')
|
||||
->with('status', __('This call is private. Please log in to view it.'));
|
||||
}
|
||||
|
||||
// Expired calls are only visible to the owner and admins
|
||||
$isExpired = $call->till !== null && $call->till->isPast();
|
||||
if ($isExpired && !$isCallable && !$isAdmin) {
|
||||
return view('calls.unavailable');
|
||||
}
|
||||
|
||||
$locale = App::getLocale();
|
||||
|
||||
// Build category hierarchy
|
||||
$tagCategory = $call->tag?->contexts->first()?->category;
|
||||
$tagColor = $tagCategory?->relatedColor ?? 'gray';
|
||||
$tagCategories = [];
|
||||
if ($tagCategory) {
|
||||
foreach ($tagCategory->ancestorsAndSelf()->get()->reverse() as $cat) {
|
||||
$name = $cat->translations->firstWhere('locale', $locale)?->name
|
||||
?? $cat->translations->first()?->name
|
||||
?? '';
|
||||
if ($name) {
|
||||
$tagCategories[] = ['id' => $cat->id, 'name' => $name, 'color' => $cat->relatedColor ?? 'gray'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build callable profile's location short string (city first, fallback to division or country)
|
||||
$callableLocation = null;
|
||||
if ($call->callable && method_exists($call->callable, 'locations')) {
|
||||
$firstLoc = $call->callable->locations()->with(['city.translations', 'division.translations', 'country.translations'])->first();
|
||||
if ($firstLoc) {
|
||||
$cCity = optional($firstLoc->city?->translations->first())->name;
|
||||
$cDivision = optional($firstLoc->division?->translations->first())->name;
|
||||
$cCountry = optional($firstLoc->country?->translations->first())->name;
|
||||
$callableLocation = $cCity ?? $cDivision ?? $cCountry ?: null;
|
||||
}
|
||||
}
|
||||
|
||||
// Build call exchange location: "City District, Division, COUNTRY_CODE"
|
||||
$callLocation = null;
|
||||
if ($call->location) {
|
||||
$loc = $call->location;
|
||||
$parts = [];
|
||||
$cityPart = null;
|
||||
if ($loc->city) {
|
||||
$cityPart = optional($loc->city->translations->first())->name;
|
||||
}
|
||||
if ($loc->district) {
|
||||
$districtName = optional($loc->district->translations->first())->name;
|
||||
$cityPart = $cityPart ? $cityPart . ' ' . $districtName : $districtName;
|
||||
}
|
||||
if ($cityPart) $parts[] = $cityPart;
|
||||
if ($loc->division) {
|
||||
$divisionName = optional($loc->division->translations->first())->name;
|
||||
if ($divisionName) $parts[] = $divisionName;
|
||||
}
|
||||
if ($loc->country && $loc->country->code !== 'XX') {
|
||||
$parts[] = strtoupper($loc->country->code);
|
||||
} elseif ($loc->country && $loc->country->code === 'XX') {
|
||||
$parts[] = __('Location not specified');
|
||||
}
|
||||
$callLocation = $parts ? implode(', ', $parts) : null;
|
||||
}
|
||||
|
||||
// Determine whether to show the expiry badge (within configured warning window)
|
||||
$expiryBadgeText = $call->till
|
||||
? __('Expires') . ': ' . $call->till->translatedFormat('d F Y')
|
||||
: null;
|
||||
|
||||
$callableType = $call->callable ? strtolower(class_basename($call->callable)) : null;
|
||||
|
||||
$callDescription = $call->translations->firstWhere('locale', App::getLocale())?->content
|
||||
?? $call->translations->firstWhere('locale', config('app.fallback_locale'))?->content
|
||||
?? $call->translations->first()?->content;
|
||||
|
||||
$viewName = Auth::check() ? 'calls.show' : 'calls.show-guest';
|
||||
return view($viewName, compact('call', 'tagColor', 'tagCategories', 'callableType', 'callableLocation', 'callLocation', 'expiryBadgeText', 'isExpired', 'isDeleted', 'isAdmin', 'callDescription'));
|
||||
}
|
||||
|
||||
public function callPdf($id)
|
||||
{
|
||||
$data = $this->buildCallPdfData($id);
|
||||
$pdf = Pdf::loadView('calls.pdf', $data)
|
||||
->setPaper('A6', 'portrait')
|
||||
->setOptions([
|
||||
'isHtml5ParserEnabled' => true,
|
||||
'isRemoteEnabled' => false,
|
||||
'defaultFont' => 'DejaVu Sans',
|
||||
'dpi' => 150,
|
||||
'chroot' => public_path(),
|
||||
]);
|
||||
return $pdf->download('call-' . $data['call']->id . '.pdf');
|
||||
}
|
||||
|
||||
public function callPdfBw($id)
|
||||
{
|
||||
$data = $this->buildCallPdfData($id);
|
||||
$pdf = Pdf::loadView('calls.pdf-bw', $data)
|
||||
->setPaper('A6', 'portrait')
|
||||
->setOptions([
|
||||
'isHtml5ParserEnabled' => true,
|
||||
'isRemoteEnabled' => false,
|
||||
'defaultFont' => 'DejaVu Sans',
|
||||
'dpi' => 150,
|
||||
'chroot' => public_path(),
|
||||
]);
|
||||
return $pdf->download('call-' . $data['call']->id . '-bw.pdf');
|
||||
}
|
||||
|
||||
public function callPdfA4($id)
|
||||
{
|
||||
$data = $this->buildCallPdfData($id);
|
||||
$pdf = Pdf::loadView('calls.pdf-a4', $data)
|
||||
->setPaper('A4', 'portrait')
|
||||
->setOptions([
|
||||
'isHtml5ParserEnabled' => true,
|
||||
'isRemoteEnabled' => false,
|
||||
'defaultFont' => 'DejaVu Sans',
|
||||
'dpi' => 150,
|
||||
'chroot' => public_path(),
|
||||
]);
|
||||
return $pdf->download('call-' . $data['call']->id . '-a4.pdf');
|
||||
}
|
||||
|
||||
public function callPdfA4Bw($id)
|
||||
{
|
||||
$data = $this->buildCallPdfData($id);
|
||||
$pdf = Pdf::loadView('calls.pdf-a4-bw', $data)
|
||||
->setPaper('A4', 'portrait')
|
||||
->setOptions([
|
||||
'isHtml5ParserEnabled' => true,
|
||||
'isRemoteEnabled' => false,
|
||||
'defaultFont' => 'DejaVu Sans',
|
||||
'dpi' => 150,
|
||||
'chroot' => public_path(),
|
||||
]);
|
||||
return $pdf->download('call-' . $data['call']->id . '-a4-bw.pdf');
|
||||
}
|
||||
|
||||
public function callPdfBulk(\Illuminate\Http\Request $request)
|
||||
{
|
||||
$ids = array_filter(array_map('intval', explode(',', $request->input('ids', ''))));
|
||||
$perPage = in_array((int) $request->input('per_page', 1), [1, 2, 4])
|
||||
? (int) $request->input('per_page', 1)
|
||||
: 1;
|
||||
$color = $request->input('color', 'bw') === 'color' ? 'color' : 'bw';
|
||||
|
||||
if (empty($ids)) abort(400);
|
||||
|
||||
$activeProfileType = session('activeProfileType');
|
||||
$activeProfileId = session('activeProfileId');
|
||||
$isAdmin = $activeProfileType === \App\Models\Admin::class
|
||||
|| ($activeProfileType === \App\Models\Bank::class
|
||||
&& \App\Models\Bank::find($activeProfileId)?->level === 0);
|
||||
|
||||
// Build per-call data, each in the call's own locale
|
||||
$calls = [];
|
||||
foreach ($ids as $id) {
|
||||
$call = Call::withTrashed()->find($id);
|
||||
if (!$call) continue;
|
||||
|
||||
// Authorization: non-admins can only print their own calls
|
||||
if (!$isAdmin) {
|
||||
$isOwner = $activeProfileType === $call->callable_type
|
||||
&& (int) $activeProfileId === (int) $call->callable_id;
|
||||
if (!$isOwner) continue;
|
||||
}
|
||||
|
||||
// Use the call's translation locale, fall back to callable's lang_preference
|
||||
$callLocale = $call->translations->first()?->locale
|
||||
?? $call->callable?->lang_preference
|
||||
?? App::getLocale();
|
||||
|
||||
$calls[] = $this->buildCallPdfData($id, $callLocale);
|
||||
}
|
||||
|
||||
if (empty($calls)) abort(403);
|
||||
|
||||
$view = $color === 'color' ? 'calls.pdf-bulk' : 'calls.pdf-bulk-bw';
|
||||
|
||||
$orientation = $perPage === 2 ? 'landscape' : 'portrait';
|
||||
|
||||
$pdf = Pdf::loadView($view, compact('calls', 'perPage'))
|
||||
->setPaper('A4', $orientation)
|
||||
->setOptions([
|
||||
'isHtml5ParserEnabled' => true,
|
||||
'isRemoteEnabled' => false,
|
||||
'defaultFont' => 'DejaVu Sans',
|
||||
'dpi' => 150,
|
||||
'chroot' => public_path(),
|
||||
'margin_top' => 0,
|
||||
'margin_right' => 0,
|
||||
'margin_bottom' => 0,
|
||||
'margin_left' => 0,
|
||||
]);
|
||||
|
||||
return $pdf->download('calls-bulk-' . now()->format('Ymd-His') . '.pdf');
|
||||
}
|
||||
|
||||
protected function buildCallPdfData($id, ?string $locale = null): array
|
||||
{
|
||||
$previousLocale = App::getLocale();
|
||||
$locale = $locale ?? $previousLocale;
|
||||
App::setLocale($locale);
|
||||
|
||||
try {
|
||||
$data = $this->doBuildCallPdfData($id, $locale);
|
||||
} finally {
|
||||
App::setLocale($previousLocale);
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
private function doBuildCallPdfData($id, string $locale): array
|
||||
{
|
||||
$call = Call::withTrashed()->with([
|
||||
'callable',
|
||||
'translations',
|
||||
'tag.contexts.category.translations',
|
||||
'tag.contexts.category.ancestors.translations',
|
||||
'location.city.translations',
|
||||
'location.district.translations',
|
||||
'location.country.translations',
|
||||
'location.division.translations',
|
||||
])->findOrFail($id);
|
||||
|
||||
$activeProfileType = session('activeProfileType');
|
||||
$activeProfileId = session('activeProfileId');
|
||||
$isAdmin = $activeProfileType === \App\Models\Admin::class;
|
||||
$isCallable = $activeProfileType === $call->callable_type
|
||||
&& (int) $activeProfileId === (int) $call->callable_id;
|
||||
|
||||
$isDeleted = $call->trashed();
|
||||
if ($isDeleted && !$isAdmin && !$isCallable) abort(403);
|
||||
if ($call->is_suppressed && !$isAdmin && !$isCallable) abort(403);
|
||||
if ($call->is_paused && !$isAdmin && !$isCallable) abort(403);
|
||||
if (!$call->is_public && !Auth::check()) abort(403);
|
||||
|
||||
$isExpired = $call->till !== null && $call->till->isPast();
|
||||
if ($isExpired && !$isCallable && !$isAdmin) abort(403);
|
||||
|
||||
$tagCategory = $call->tag?->contexts->first()?->category;
|
||||
$tagColor = $tagCategory?->relatedColor ?? 'gray';
|
||||
$tagColorHex = $this->tailwindColorToHex($tagColor);
|
||||
$bgHex = $tagColorHex;
|
||||
$bgDarkHex = $this->darkenHex($tagColorHex, 0.50);
|
||||
|
||||
$tagCategories = [];
|
||||
if ($tagCategory) {
|
||||
foreach ($tagCategory->ancestorsAndSelf()->get()->reverse() as $cat) {
|
||||
$name = $cat->translations->firstWhere('locale', $locale)?->name
|
||||
?? $cat->translations->first()?->name
|
||||
?? '';
|
||||
if ($name) {
|
||||
$tagCategories[] = [
|
||||
'id' => $cat->id,
|
||||
'name' => $name,
|
||||
'colorHex' => $this->tailwindColorToHex($cat->relatedColor ?? 'gray'),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$callableLocation = null;
|
||||
if ($call->callable && method_exists($call->callable, 'locations')) {
|
||||
$firstLoc = $call->callable->locations()->with(['city.translations', 'division.translations', 'country.translations'])->first();
|
||||
if ($firstLoc) {
|
||||
$cCity = optional($firstLoc->city?->translations->first())->name;
|
||||
$cDivision = optional($firstLoc->division?->translations->first())->name;
|
||||
$cCountry = optional($firstLoc->country?->translations->first())->name;
|
||||
$callableLocation = $cCity ?? $cDivision ?? $cCountry ?: null;
|
||||
}
|
||||
}
|
||||
|
||||
$callLocation = null;
|
||||
if ($call->location) {
|
||||
$loc = $call->location;
|
||||
$parts = [];
|
||||
$cityPart = null;
|
||||
if ($loc->city) $cityPart = optional($loc->city->translations->first())->name;
|
||||
if ($loc->district) {
|
||||
$districtName = optional($loc->district->translations->first())->name;
|
||||
$cityPart = $cityPart ? $cityPart . ' ' . $districtName : $districtName;
|
||||
}
|
||||
if ($cityPart) $parts[] = $cityPart;
|
||||
if ($loc->division) {
|
||||
$divisionName = optional($loc->division->translations->first())->name;
|
||||
if ($divisionName) $parts[] = $divisionName;
|
||||
}
|
||||
if ($loc->country && $loc->country->code !== 'XX') {
|
||||
$parts[] = strtoupper($loc->country->code);
|
||||
} elseif ($loc->country && $loc->country->code === 'XX') {
|
||||
$parts[] = __('Location not specified');
|
||||
}
|
||||
$callLocation = $parts ? implode(', ', $parts) : null;
|
||||
}
|
||||
|
||||
$expiryBadgeText = $call->till
|
||||
? __('Expires') . ': ' . $call->till->translatedFormat('d F Y')
|
||||
: null;
|
||||
|
||||
// Pre-translate status labels in the call's locale (used by PDF templates)
|
||||
$statusLabelDeleted = __('DELETED');
|
||||
$statusLabelBlocked = __('BLOCKED');
|
||||
$statusLabelPaused = __('PAUSED');
|
||||
$statusLabelExpired = __('EXPIRED');
|
||||
|
||||
$rawContent = $call->translations->firstWhere('locale', $locale)?->content
|
||||
?? $call->translations->firstWhere('locale', config('app.fallback_locale'))?->content
|
||||
?? $call->translations->first()?->content
|
||||
?? '';
|
||||
$callContent = mb_strlen($rawContent) > 250 ? mb_substr($rawContent, 0, 247) . '…' : $rawContent;
|
||||
|
||||
$avatarBase64 = null;
|
||||
if ($call->callable?->profile_photo_path) {
|
||||
$avatarPath = storage_path('app/public/' . $call->callable->profile_photo_path);
|
||||
$ext = strtolower(pathinfo($avatarPath, PATHINFO_EXTENSION));
|
||||
if (file_exists($avatarPath)) {
|
||||
if ($ext === 'svg') {
|
||||
// SVG avatars cannot be rendered by GD — skip silently.
|
||||
} else {
|
||||
$mime = mime_content_type($avatarPath) ?: 'image/jpeg';
|
||||
$avatarBase64 = 'data:' . $mime . ';base64,' . base64_encode(file_get_contents($avatarPath));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$logoBase64 = null;
|
||||
$logoPath = theme_email_logo_file();
|
||||
if (file_exists($logoPath)) {
|
||||
$logoBase64 = 'data:image/png;base64,' . base64_encode(file_get_contents($logoPath));
|
||||
}
|
||||
|
||||
// QR codes as inline SVG (no ImageMagick needed)
|
||||
$callUrl = route('call.show', ['id' => $call->id]);
|
||||
$qrSvg = 'data:image/svg+xml;base64,' . base64_encode(
|
||||
\SimpleSoftwareIO\QrCode\Facades\QrCode::format('svg')
|
||||
->size(300)->margin(2)->errorCorrection('M')->color(0, 0, 0)->backgroundColor(255, 255, 255)->generate($callUrl)
|
||||
);
|
||||
$qrSvgBw = 'data:image/svg+xml;base64,' . base64_encode(
|
||||
\SimpleSoftwareIO\QrCode\Facades\QrCode::format('svg')
|
||||
->size(300)->margin(2)->errorCorrection('M')->color(0, 0, 0)->generate($callUrl)
|
||||
);
|
||||
|
||||
return compact(
|
||||
'call', 'tagColor', 'tagColorHex', 'bgHex', 'bgDarkHex', 'tagCategories',
|
||||
'callableLocation', 'callLocation', 'expiryBadgeText',
|
||||
'isExpired', 'isDeleted', 'avatarBase64', 'logoBase64', 'callContent', 'qrSvg', 'qrSvgBw',
|
||||
'statusLabelDeleted', 'statusLabelBlocked', 'statusLabelPaused', 'statusLabelExpired'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Darken a 6-char hex color by the given factor (0–1).
|
||||
* Used to simulate the dark overlay on the call card background.
|
||||
*/
|
||||
private function darkenHex(string $hex, float $factor): string
|
||||
{
|
||||
$r = (int) hexdec(substr($hex, 0, 2));
|
||||
$g = (int) hexdec(substr($hex, 2, 2));
|
||||
$b = (int) hexdec(substr($hex, 4, 2));
|
||||
$r = (int) round($r * (1 - $factor));
|
||||
$g = (int) round($g * (1 - $factor));
|
||||
$b = (int) round($b * (1 - $factor));
|
||||
return sprintf('%02x%02x%02x', $r, $g, $b);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a Tailwind color name to its -400 hex value for use in DomPDF.
|
||||
*/
|
||||
private function tailwindColorToHex(string $color): string
|
||||
{
|
||||
return match ($color) {
|
||||
'red' => 'f87171',
|
||||
'orange' => 'fb923c',
|
||||
'amber' => 'fbbf24',
|
||||
'yellow' => 'facc15',
|
||||
'lime' => 'a3e635',
|
||||
'green' => '4ade80',
|
||||
'emerald' => '34d399',
|
||||
'teal' => '2dd4bf',
|
||||
'cyan' => '22d3ee',
|
||||
'sky' => '38bdf8',
|
||||
'blue' => '60a5fa',
|
||||
'indigo' => '818cf8',
|
||||
'violet' => 'a78bfa',
|
||||
'purple' => 'c084fc',
|
||||
'fuchsia' => 'e879f9',
|
||||
'pink' => 'f472b6',
|
||||
'rose' => 'fb7185',
|
||||
default => '9ca3af', // gray-400
|
||||
};
|
||||
}
|
||||
|
||||
public function manage()
|
||||
{
|
||||
return view('calls.manage');
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user