Initial commit
This commit is contained in:
30
app/Http/Livewire/.php-cs-fixer.dist.php
Normal file
30
app/Http/Livewire/.php-cs-fixer.dist.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use PhpCsFixer\Config;
|
||||
use PhpCsFixer\Finder;
|
||||
use PhpCsFixer\Runner\Parallel\ParallelConfigFactory;
|
||||
|
||||
return (new Config())
|
||||
->setParallelConfig(ParallelConfigFactory::detect()) // @TODO 4.0 no need to call this manually
|
||||
->setRiskyAllowed(false)
|
||||
->setRules([
|
||||
'@auto' => true
|
||||
])
|
||||
// 💡 by default, Fixer looks for `*.php` files excluding `./vendor/` - here, you can groom this config
|
||||
->setFinder(
|
||||
(new Finder())
|
||||
// 💡 root folder to check
|
||||
->in(__DIR__)
|
||||
// 💡 additional files, eg bin entry file
|
||||
// ->append([__DIR__.'/bin-entry-file'])
|
||||
// 💡 folders to exclude, if any
|
||||
// ->exclude([/* ... */])
|
||||
// 💡 path patterns to exclude, if any
|
||||
// ->notPath([/* ... */])
|
||||
// 💡 extra configs
|
||||
// ->ignoreDotFiles(false) // true by default in v3, false in v4 or future mode
|
||||
// ->ignoreVCS(true) // true by default
|
||||
)
|
||||
;
|
||||
112
app/Http/Livewire/AcceptPrinciples.php
Normal file
112
app/Http/Livewire/AcceptPrinciples.php
Normal file
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire;
|
||||
|
||||
use App\Models\Post;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Component;
|
||||
use WireUi\Traits\WireUiActions;
|
||||
|
||||
class AcceptPrinciples extends Component
|
||||
{
|
||||
use WireUiActions;
|
||||
|
||||
public bool $agreed = false;
|
||||
|
||||
public function mount()
|
||||
{
|
||||
// Pre-check the checkbox if user has already accepted
|
||||
$user = Auth::guard('web')->user();
|
||||
if ($user && $user->hasAcceptedPrinciples()) {
|
||||
$this->agreed = true;
|
||||
}
|
||||
}
|
||||
|
||||
public function accept()
|
||||
{
|
||||
$user = Auth::guard('web')->user();
|
||||
|
||||
if (!$user) {
|
||||
$this->notification()->error(
|
||||
$title = __('Error'),
|
||||
$description = __('You must be logged in to accept the principles.')
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->agreed) {
|
||||
$this->notification()->warning(
|
||||
$title = __('Confirmation required'),
|
||||
$description = __('Please check the box to confirm you accept the principles.')
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the current principles post with active translation
|
||||
$principlesPost = $this->getCurrentPrinciplesPost();
|
||||
|
||||
if (!$principlesPost || !$principlesPost->translations->first()) {
|
||||
$this->notification()->error(
|
||||
$title = __('Error'),
|
||||
$description = __('Unable to find the current principles document.')
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
$translation = $principlesPost->translations->first();
|
||||
|
||||
// Save acceptance with version tracking
|
||||
$user->update([
|
||||
'principles_terms_accepted' => [
|
||||
'post_id' => $principlesPost->id,
|
||||
'post_translation_id' => $translation->id,
|
||||
'locale' => $translation->locale,
|
||||
'from' => $translation->from,
|
||||
'updated_at' => $translation->updated_at->toDateTimeString(),
|
||||
'accepted_at' => now()->toDateTimeString(),
|
||||
]
|
||||
]);
|
||||
|
||||
$this->notification()->success(
|
||||
$title = __('Thank you'),
|
||||
$description = __('Your acceptance has been recorded.')
|
||||
);
|
||||
|
||||
// Refresh the component to show the acceptance status
|
||||
$this->dispatch('$refresh');
|
||||
}
|
||||
|
||||
protected function getCurrentPrinciplesPost()
|
||||
{
|
||||
$locale = app()->getLocale();
|
||||
|
||||
return Post::with(['translations' => function ($query) use ($locale) {
|
||||
$query->where('locale', 'like', $locale . '%')
|
||||
->whereDate('from', '<=', now())
|
||||
->where(function ($query) {
|
||||
$query->whereDate('till', '>', now())->orWhereNull('till');
|
||||
})
|
||||
->orderBy('updated_at', 'desc')
|
||||
->limit(1);
|
||||
}])
|
||||
->whereHas('category', function ($query) {
|
||||
$query->where('type', 'SiteContents\Static\Principles');
|
||||
})
|
||||
->first();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$user = Auth::guard('web')->user();
|
||||
$hasAccepted = $user && $user->hasAcceptedPrinciples();
|
||||
$needsReaccept = $user && $user->needsToReacceptPrinciples();
|
||||
$acceptedData = $user?->principles_terms_accepted;
|
||||
|
||||
return view('livewire.accept-principles', [
|
||||
'user' => $user,
|
||||
'hasAccepted' => $hasAccepted,
|
||||
'needsReaccept' => $needsReaccept,
|
||||
'acceptedData' => $acceptedData,
|
||||
]);
|
||||
}
|
||||
}
|
||||
132
app/Http/Livewire/AccountInfoModal.php
Normal file
132
app/Http/Livewire/AccountInfoModal.php
Normal file
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire;
|
||||
|
||||
use App\Helpers\ProfileAuthorizationHelper;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Component;
|
||||
|
||||
class AccountInfoModal extends Component
|
||||
{
|
||||
public $show = false;
|
||||
public $accounts = [];
|
||||
public $totalBalance = 0;
|
||||
public $totalBalanceFormatted = '';
|
||||
public $decimalFormat = false;
|
||||
public $totalBalanceDecimal = '0,00';
|
||||
|
||||
protected $listeners = ['openAccountInfoModal' => 'open'];
|
||||
|
||||
public function open()
|
||||
{
|
||||
$this->loadAccounts();
|
||||
$this->show = true;
|
||||
}
|
||||
|
||||
public function close()
|
||||
{
|
||||
$this->show = false;
|
||||
}
|
||||
|
||||
public function updatedDecimalFormat()
|
||||
{
|
||||
$this->reformatBalances();
|
||||
}
|
||||
|
||||
private function formatBalance($minutes)
|
||||
{
|
||||
if ($this->decimalFormat) {
|
||||
$isNegative = $minutes < 0;
|
||||
$decimal = number_format(abs($minutes) / 60, 2, ',', '.');
|
||||
$value = ($isNegative ? '-' : '') . $decimal . ' ' . __('h.');
|
||||
return $value;
|
||||
}
|
||||
|
||||
return tbFormat($minutes);
|
||||
}
|
||||
|
||||
private function reformatBalances()
|
||||
{
|
||||
$this->accounts = array_map(function ($account) {
|
||||
$account['balanceFormatted'] = $this->formatBalance($account['balance']);
|
||||
return $account;
|
||||
}, $this->accounts);
|
||||
|
||||
$this->totalBalanceFormatted = $this->formatBalance($this->totalBalance);
|
||||
}
|
||||
|
||||
private function loadAccounts()
|
||||
{
|
||||
$profileType = session('activeProfileType');
|
||||
$profileId = session('activeProfileId');
|
||||
|
||||
if (!$profileType || !$profileId) {
|
||||
$this->accounts = [];
|
||||
$this->totalBalance = 0;
|
||||
$this->totalBalanceFormatted = $this->formatBalance(0);
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve profile class and verify it has accounts
|
||||
$profile = $profileType::find($profileId);
|
||||
|
||||
if (!$profile || !method_exists($profile, 'accounts')) {
|
||||
$this->accounts = [];
|
||||
$this->totalBalance = 0;
|
||||
$this->totalBalanceFormatted = $this->formatBalance(0);
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify authenticated user owns this profile (IDOR prevention)
|
||||
ProfileAuthorizationHelper::authorize($profile);
|
||||
|
||||
// Load only accounts belonging to the authenticated active profile (IDOR safe)
|
||||
$profileAccounts = $profile->accounts()->get();
|
||||
|
||||
if ($profileAccounts->isEmpty()) {
|
||||
$this->accounts = [];
|
||||
$this->totalBalance = 0;
|
||||
$this->totalBalanceFormatted = $this->formatBalance(0);
|
||||
return;
|
||||
}
|
||||
|
||||
$accountIds = $profileAccounts->pluck('id')->toArray();
|
||||
|
||||
// Fetch all balances in a single query (no cache — fresh DB state)
|
||||
$balanceRows = DB::table('transactions')
|
||||
->whereIn('from_account_id', $accountIds)
|
||||
->orWhereIn('to_account_id', $accountIds)
|
||||
->select('from_account_id', 'to_account_id', 'amount')
|
||||
->get();
|
||||
|
||||
// Calculate per-account balance from query results
|
||||
$balanceMap = array_fill_keys($accountIds, 0);
|
||||
foreach ($balanceRows as $row) {
|
||||
if (isset($balanceMap[$row->to_account_id])) {
|
||||
$balanceMap[$row->to_account_id] += $row->amount;
|
||||
}
|
||||
if (isset($balanceMap[$row->from_account_id])) {
|
||||
$balanceMap[$row->from_account_id] -= $row->amount;
|
||||
}
|
||||
}
|
||||
|
||||
$this->totalBalance = 0;
|
||||
$this->accounts = $profileAccounts->map(function ($account) use ($balanceMap) {
|
||||
$balance = $balanceMap[$account->id] ?? 0;
|
||||
$this->totalBalance += $balance;
|
||||
return [
|
||||
'name' => ucfirst(__('messages.' . strtolower($account->name) . '_account')) . ' ' . __('account'),
|
||||
'balance' => $balance,
|
||||
'balanceFormatted' => $this->formatBalance($balance),
|
||||
];
|
||||
})->toArray();
|
||||
|
||||
$this->totalBalanceFormatted = $this->formatBalance($this->totalBalance);
|
||||
$this->totalBalanceDecimal = number_format(abs($this->totalBalance) / 60, 2, ',', '.');
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.account-info-modal');
|
||||
}
|
||||
}
|
||||
46
app/Http/Livewire/AccountUsageBar.php
Normal file
46
app/Http/Livewire/AccountUsageBar.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire;
|
||||
|
||||
use App\Models\Transaction;
|
||||
use Livewire\Component;
|
||||
|
||||
class AccountUsageBar extends Component
|
||||
{
|
||||
public $selectedAccount;
|
||||
public $balancePct = 1;
|
||||
public $hasTransactions = false;
|
||||
|
||||
protected $listeners = [
|
||||
'fromAccountId',
|
||||
];
|
||||
|
||||
public function mount()
|
||||
{
|
||||
$this->selectedAccount = [
|
||||
'id' => null,
|
||||
'name' => '',
|
||||
'balance' => 0,
|
||||
'limitMin' => 0,
|
||||
'limitMax' => 0,
|
||||
'available' => 0,
|
||||
'limitReceivable' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
public function fromAccountId($selectedAccount)
|
||||
{
|
||||
$this->selectedAccount = $selectedAccount;
|
||||
// Calculate balance percentage (set to 100% if limitMax is 0)
|
||||
$this->balancePct = $selectedAccount['limitMax'] == 0 ? 100 : ($selectedAccount['balance'] / $selectedAccount['limitMax']) * 100;
|
||||
$this->selectedAccount['available'] = $selectedAccount['limitMax'] - $selectedAccount['balance'];
|
||||
$this->hasTransactions = Transaction::where('from_account_id', $selectedAccount['id'])
|
||||
->orWhere('to_account_id', $selectedAccount['id'])
|
||||
->exists();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.account-usage-bar');
|
||||
}
|
||||
}
|
||||
97
app/Http/Livewire/AccountUsageInfoModal.php
Normal file
97
app/Http/Livewire/AccountUsageInfoModal.php
Normal file
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire;
|
||||
|
||||
use App\Models\Post;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Livewire\Component;
|
||||
|
||||
class AccountUsageInfoModal extends Component
|
||||
{
|
||||
public $show = false;
|
||||
public $post = null;
|
||||
public $image = null;
|
||||
public $imageCaption = null;
|
||||
public $imageOwner = null;
|
||||
public $fallbackTitle;
|
||||
public $fallbackDescription;
|
||||
|
||||
protected $listeners = ['openAccountUsageInfoModal' => 'open'];
|
||||
|
||||
public function mount()
|
||||
{
|
||||
$this->fallbackTitle = __('Account usage');
|
||||
$this->fallbackDescription = __('The account usage bar provides a visual representation of your currency holdings, similar to a disk space bar but for money. It displays both the amount of currency you currently possess and the maximum limit of your account.');
|
||||
}
|
||||
|
||||
public function open()
|
||||
{
|
||||
$this->show = true;
|
||||
$this->loadPost();
|
||||
}
|
||||
|
||||
public function loadPost()
|
||||
{
|
||||
$locale = App::getLocale();
|
||||
|
||||
$this->post = Post::with([
|
||||
'category',
|
||||
'media',
|
||||
'translations' => function ($query) use ($locale) {
|
||||
$query->where('locale', $locale)
|
||||
->orderBy('created_at', 'desc')
|
||||
->limit(3);
|
||||
}
|
||||
])
|
||||
->whereHas('category', function ($query) {
|
||||
$query->where('type', 'SiteContents\AccountUsage\Info');
|
||||
})
|
||||
->whereHas('translations', function ($query) use ($locale) {
|
||||
$query->where('locale', $locale)
|
||||
->whereDate('from', '<=', now())
|
||||
->where(function ($query) {
|
||||
$query->whereDate('till', '>', now())->orWhereNull('till');
|
||||
})
|
||||
->orderBy('updated_at', 'desc');
|
||||
})
|
||||
->orderBy('created_at', 'desc')
|
||||
->limit(3)
|
||||
->first();
|
||||
|
||||
if ($this->post && $this->post->hasMedia('posts')) {
|
||||
$this->image = $this->post->getFirstMediaUrl('posts', 'half_hero');
|
||||
$mediaItem = $this->post->getFirstMedia('posts');
|
||||
if ($mediaItem) {
|
||||
// Get owner
|
||||
$this->imageOwner = $mediaItem->getCustomProperty('owner');
|
||||
|
||||
// Try to get caption for current locale
|
||||
$this->imageCaption = $mediaItem->getCustomProperty('caption-' . $locale);
|
||||
|
||||
// If not found, try fallback locales
|
||||
if (!$this->imageCaption) {
|
||||
$fallbackLocales = ['en', 'nl', 'de', 'es', 'fr'];
|
||||
foreach ($fallbackLocales as $fallbackLocale) {
|
||||
$this->imageCaption = $mediaItem->getCustomProperty('caption-' . $fallbackLocale);
|
||||
if ($this->imageCaption) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function close()
|
||||
{
|
||||
$this->show = false;
|
||||
$this->image = null;
|
||||
$this->imageCaption = null;
|
||||
$this->imageOwner = null;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.account-usage-info-modal');
|
||||
}
|
||||
}
|
||||
79
app/Http/Livewire/AddTranslationSelectbox.php
Normal file
79
app/Http/Livewire/AddTranslationSelectbox.php
Normal file
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire;
|
||||
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Component;
|
||||
|
||||
class AddTranslationSelectbox extends Component
|
||||
{
|
||||
public $options = [];
|
||||
public $localeSelected;
|
||||
public $emptyMessage;
|
||||
|
||||
protected $listeners = ['updateLocalesOptions'];
|
||||
/**
|
||||
* Prepare the component.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function mount($locale = null, $options)
|
||||
{
|
||||
// Initially show "Loading..." while options are being fetched
|
||||
$this->emptyMessage = __('Loading...');
|
||||
|
||||
$this->updateLocalesOptions($options);
|
||||
|
||||
$locale = $locale ?? session('locale');
|
||||
|
||||
// Extract lang_code values from the options collection
|
||||
$availableLocales = $this->options ? $this->options->pluck('lang_code')->all() : [];
|
||||
|
||||
if (!in_array($locale, $availableLocales)) {
|
||||
$locale = null;
|
||||
}
|
||||
|
||||
$this->localeSelected = $locale;
|
||||
}
|
||||
|
||||
|
||||
public function updateLocalesOptions($options)
|
||||
{
|
||||
// Set loading message while fetching options
|
||||
$this->emptyMessage = __('Loading...');
|
||||
|
||||
if ($options) {
|
||||
$options = DB::table('languages')
|
||||
->whereIn('lang_code', $options)
|
||||
->orderBy('name')
|
||||
->get(['id','lang_code','name']);
|
||||
|
||||
$this->options = $options->map(function ($item, $key) {
|
||||
return [
|
||||
'id' => $item->id,
|
||||
'lang_code' => $item->lang_code,
|
||||
'name' => __('messages.' . $item->name)];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* When component is updated
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function updated()
|
||||
{
|
||||
if ($this->localeSelected) {
|
||||
$this->dispatch('localeSelected', $this->localeSelected);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.add-translation-selectbox');
|
||||
}
|
||||
}
|
||||
188
app/Http/Livewire/Admin/Log.php
Normal file
188
app/Http/Livewire/Admin/Log.php
Normal file
@@ -0,0 +1,188 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire\Admin;
|
||||
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Session;
|
||||
use Livewire\Component;
|
||||
|
||||
class Log extends Component
|
||||
{
|
||||
public $logContent = '';
|
||||
public $message = '';
|
||||
public $diskUsage = '';
|
||||
public $diskUsageClass = '';
|
||||
public $availableRam;
|
||||
public $availableRamClass;
|
||||
public $queueWorkers = [];
|
||||
public $queueWorkersCount = 0;
|
||||
public $reverbConnected = false;
|
||||
|
||||
|
||||
public function mount()
|
||||
{
|
||||
// Security check: user must be authenticated
|
||||
if (!Auth::check()) {
|
||||
abort(403, 'Unauthorized');
|
||||
}
|
||||
|
||||
// Security check: activeProfileType must be 'App\Models\Admin'
|
||||
if (getActiveProfileType() !== 'Admin') {
|
||||
abort(403, 'Unauthorized');
|
||||
}
|
||||
|
||||
// Security check: user must own the active profile
|
||||
$activeProfile = getActiveProfile();
|
||||
if (!$activeProfile) {
|
||||
abort(403, 'Unauthorized');
|
||||
}
|
||||
|
||||
// CRITICAL SECURITY: Validate user has ownership/access to this profile
|
||||
\App\Helpers\ProfileAuthorizationHelper::authorize($activeProfile);
|
||||
|
||||
// Load log content - support both daily (laravel-YYYY-MM-DD.log) and single (laravel.log) drivers
|
||||
$logPath = storage_path('logs/laravel-' . date('Y-m-d') . '.log');
|
||||
if (!file_exists($logPath)) {
|
||||
$logPath = storage_path('logs/laravel.log');
|
||||
}
|
||||
if (file_exists($logPath)) {
|
||||
$logLines = (int) timebank_config('admin_settings.log_lines', 100);
|
||||
$this->logContent = shell_exec("tail -n {$logLines} " . escapeshellarg($logPath));
|
||||
// Use tail output to check for warnings/errors (avoid file() which loads entire file into memory)
|
||||
$recentLines = explode("\n", $this->logContent ?? '');
|
||||
|
||||
$messages = [];
|
||||
if (collect($recentLines)->contains(fn ($line) => stripos($line, 'WARNING') !== false)) {
|
||||
$messages[] = '<span class="font-bold text-orange-500">Warning</span> detected in the recent log output';
|
||||
}
|
||||
if (collect($recentLines)->contains(fn ($line) => stripos($line, 'ERROR') !== false)) {
|
||||
$messages[] = '<span class="font-bold text-red-500">Error</span> detected in the recent log output';
|
||||
}
|
||||
if (collect($recentLines)->contains(fn ($line) => stripos($line, 'CRITICAL') !== false)) {
|
||||
$messages[] = '<span class="font-bold text-red-500">Critical</span> issue detected in the recent log output';
|
||||
}
|
||||
if (collect($recentLines)->contains(fn ($line) => stripos($line, 'ALERT') !== false)) {
|
||||
$messages[] = '<span class="font-bold text-red-500">Alert</span> detected in the recent log output - IMMEDIATE ACTION REQUIRED';
|
||||
}
|
||||
$this->message = implode('<br>', $messages);
|
||||
}
|
||||
|
||||
// Get disk usage
|
||||
$free = disk_free_space("/");
|
||||
$total = disk_total_space("/");
|
||||
$used = $total - $free;
|
||||
$percent = $total > 0 ? round(($used / $total) * 100, 1) : 0;
|
||||
|
||||
|
||||
// Determine disk usage color class
|
||||
if ($percent > 90) {
|
||||
$this->diskUsageClass = 'text-red-500';
|
||||
} elseif ($percent > 75) {
|
||||
$this->diskUsageClass = 'text-orange-500';
|
||||
} else {
|
||||
$this->diskUsageClass = 'text-green-500';
|
||||
}
|
||||
|
||||
$this->diskUsage = sprintf(
|
||||
'%s used of %s (%.1f%%)',
|
||||
$this->formatBytes($used),
|
||||
$this->formatBytes($total),
|
||||
$percent
|
||||
);
|
||||
|
||||
|
||||
|
||||
// Get RAM memory information
|
||||
$meminfo = file_get_contents('/proc/meminfo');
|
||||
preg_match('/MemTotal:\s+(\d+)/', $meminfo, $matchesTotal);
|
||||
preg_match('/MemAvailable:\s+(\d+)/', $meminfo, $matchesAvailable);
|
||||
|
||||
$totalKb = $matchesTotal[1] ?? 0;
|
||||
$availableKb = $matchesAvailable[1] ?? 0;
|
||||
$usedKb = $totalKb - $availableKb;
|
||||
|
||||
$percentRam = $totalKb > 0 ? round(($usedKb / $totalKb) * 100, 1) : 0;
|
||||
|
||||
$this->availableRam = sprintf(
|
||||
'%s used of %s (%.1f%%)',
|
||||
$this->formatBytes($usedKb * 1024),
|
||||
$this->formatBytes($totalKb * 1024),
|
||||
$percentRam
|
||||
);
|
||||
|
||||
if ($percentRam > 90) {
|
||||
$this->availableRamClass = 'text-red-500';
|
||||
} elseif ($percentRam > 75) {
|
||||
$this->availableRamClass = 'text-orange-500';
|
||||
} else {
|
||||
$this->availableRamClass = 'text-green-500';
|
||||
}
|
||||
|
||||
|
||||
// Check running queue workers
|
||||
$queueWorkers = [];
|
||||
exec("ps aux | grep 'artisan queue:work' | grep -v grep", $output);
|
||||
foreach ($output as $line) {
|
||||
// Parse $line for more details
|
||||
$queueWorkers[] = $line;
|
||||
}
|
||||
$this->queueWorkers = $queueWorkers;
|
||||
$this->queueWorkersCount = count($queueWorkers);
|
||||
|
||||
// Check Reverb server connection (example: check if port is open)
|
||||
$reverbPort = env('REVERB_PORT', 6001);
|
||||
|
||||
// Check Reverb server connection
|
||||
$reverbConnected = false;
|
||||
$connection = @fsockopen('127.0.0.1', $reverbPort, $errno, $errstr, 1);
|
||||
if ($connection) {
|
||||
$reverbConnected = true;
|
||||
fclose($connection);
|
||||
}
|
||||
|
||||
$this->reverbConnected = $reverbConnected;
|
||||
|
||||
|
||||
}
|
||||
|
||||
// Helper to format bytes
|
||||
public function formatBytes($bytes, $precision = 2)
|
||||
{
|
||||
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
$bytes = max($bytes, 0);
|
||||
$pow = $bytes > 0 ? floor(log($bytes) / log(1024)) : 0;
|
||||
$pow = min($pow, count($units) - 1);
|
||||
$bytes /= (1 << (10 * $pow));
|
||||
return round($bytes, $precision) . ' ' . $units[$pow];
|
||||
}
|
||||
|
||||
public function downloadLog()
|
||||
{
|
||||
$logPath = storage_path('logs/laravel-' . date('Y-m-d') . '.log');
|
||||
if (!file_exists($logPath)) {
|
||||
$logPath = storage_path('logs/laravel.log');
|
||||
}
|
||||
if (file_exists($logPath)) {
|
||||
return response()->download($logPath, 'laravel-' . date('Y-m-d') . '.log');
|
||||
}
|
||||
|
||||
session()->flash('error', 'Log file not found');
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$layout = Auth::check() ? 'app-layout' : 'guest-layout';
|
||||
return view('livewire.admin.log', [
|
||||
'layout' => $layout,
|
||||
'logContent' => $this->logContent,
|
||||
'message' => $this->message,
|
||||
'diskUsage' => $this->diskUsage,
|
||||
'diskUsageClass' => $this->diskUsageClass,
|
||||
'availableRam' => $this->availableRam,
|
||||
'availableRamClass' => $this->availableRamClass,
|
||||
'queueWorkers' => $this->queueWorkers,
|
||||
'queueWorkersCount' => $this->queueWorkersCount,
|
||||
'reverbConnected' => $this->reverbConnected,
|
||||
]);
|
||||
}
|
||||
}
|
||||
133
app/Http/Livewire/Admin/LogViewer.php
Normal file
133
app/Http/Livewire/Admin/LogViewer.php
Normal file
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire\Admin;
|
||||
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Livewire\Component;
|
||||
|
||||
class LogViewer extends Component
|
||||
{
|
||||
public $logFilename;
|
||||
public $logContent = '';
|
||||
public $message = '';
|
||||
public $fileSize = '';
|
||||
public $lastModified = '';
|
||||
public $logTitle = '';
|
||||
|
||||
public function mount($logFilename, $logTitle = null)
|
||||
{
|
||||
// Security check: user must be authenticated
|
||||
if (!Auth::check()) {
|
||||
abort(403, 'Unauthorized');
|
||||
}
|
||||
|
||||
// Security check: activeProfileType must be 'App\Models\Admin'
|
||||
if (getActiveProfileType() !== 'Admin') {
|
||||
abort(403, 'Unauthorized');
|
||||
}
|
||||
|
||||
// Security check: user must own the active profile
|
||||
$activeProfile = getActiveProfile();
|
||||
if (!$activeProfile) {
|
||||
abort(403, 'Unauthorized');
|
||||
}
|
||||
|
||||
// CRITICAL SECURITY: Validate user has ownership/access to this profile
|
||||
\App\Helpers\ProfileAuthorizationHelper::authorize($activeProfile);
|
||||
|
||||
$this->logFilename = $logFilename;
|
||||
$this->logTitle = $logTitle ?? $logFilename;
|
||||
|
||||
$this->loadLogContent();
|
||||
}
|
||||
|
||||
public function loadLogContent()
|
||||
{
|
||||
$logPath = storage_path('logs/' . $this->logFilename);
|
||||
|
||||
// Security: prevent directory traversal attacks
|
||||
$realPath = realpath($logPath);
|
||||
$logsDir = realpath(storage_path('logs'));
|
||||
|
||||
if (!$realPath || strpos($realPath, $logsDir) !== 0) {
|
||||
$this->message = '<span class="font-bold text-red-500">Error:</span> Invalid log file path';
|
||||
return;
|
||||
}
|
||||
|
||||
if (file_exists($logPath)) {
|
||||
// Get file info
|
||||
$this->fileSize = $this->formatBytes(filesize($logPath));
|
||||
$this->lastModified = date('Y-m-d H:i:s', filemtime($logPath));
|
||||
|
||||
// Load log content using tail (avoid file() which loads entire file into memory)
|
||||
$logLines = (int) timebank_config('admin_settings.log_lines', 100);
|
||||
$this->logContent = shell_exec("tail -n {$logLines} " . escapeshellarg($logPath));
|
||||
|
||||
$recentLines = explode("\n", $this->logContent ?? '');
|
||||
|
||||
// Check for warnings/errors in log content
|
||||
$messages = [];
|
||||
if (collect($recentLines)->contains(fn ($line) => stripos($line, 'WARNING') !== false)) {
|
||||
$messages[] = '<span class="font-bold text-orange-500">Warning</span> detected in the recent log output';
|
||||
}
|
||||
if (collect($recentLines)->contains(fn ($line) => stripos($line, 'ERROR') !== false)) {
|
||||
$messages[] = '<span class="font-bold text-red-500">Error</span> detected in the recent log output';
|
||||
}
|
||||
if (collect($recentLines)->contains(fn ($line) => stripos($line, 'CRITICAL') !== false)) {
|
||||
$messages[] = '<span class="font-bold text-red-500">Critical</span> issue detected in the recent log output';
|
||||
}
|
||||
if (collect($recentLines)->contains(fn ($line) => stripos($line, 'ALERT') !== false)) {
|
||||
$messages[] = '<span class="font-bold text-red-500">Alert</span> detected in the recent log output - IMMEDIATE ACTION REQUIRED';
|
||||
}
|
||||
|
||||
if (!empty($messages)) {
|
||||
$this->message = implode('<br>', $messages);
|
||||
}
|
||||
} else {
|
||||
$this->message = '<span class="font-bold text-gray-500">Log file not found or empty</span>';
|
||||
}
|
||||
}
|
||||
|
||||
public function downloadLog()
|
||||
{
|
||||
$logPath = storage_path('logs/' . $this->logFilename);
|
||||
|
||||
// Security: prevent directory traversal attacks
|
||||
$realPath = realpath($logPath);
|
||||
$logsDir = realpath(storage_path('logs'));
|
||||
|
||||
if (!$realPath || strpos($realPath, $logsDir) !== 0) {
|
||||
session()->flash('error', 'Invalid log file path');
|
||||
return;
|
||||
}
|
||||
|
||||
if (file_exists($logPath)) {
|
||||
return response()->download($logPath, $this->logFilename . '-' . date('Y-m-d') . '.log');
|
||||
}
|
||||
|
||||
session()->flash('error', 'Log file not found');
|
||||
}
|
||||
|
||||
public function refreshLog()
|
||||
{
|
||||
$this->loadLogContent();
|
||||
$this->dispatch('logRefreshed');
|
||||
}
|
||||
|
||||
// Helper to format bytes
|
||||
public function formatBytes($bytes, $precision = 2)
|
||||
{
|
||||
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
$bytes = max($bytes, 0);
|
||||
$pow = $bytes > 0 ? floor(log($bytes) / log(1024)) : 0;
|
||||
$pow = min($pow, count($units) - 1);
|
||||
$bytes /= (1 << (10 * $pow));
|
||||
return round($bytes, $precision) . ' ' . $units[$pow];
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.admin.log-viewer');
|
||||
}
|
||||
}
|
||||
27
app/Http/Livewire/Admin/MaintenanceBanner.php
Normal file
27
app/Http/Livewire/Admin/MaintenanceBanner.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire\Admin;
|
||||
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Component;
|
||||
|
||||
class MaintenanceBanner extends Component
|
||||
{
|
||||
public $maintenanceMode = false;
|
||||
|
||||
public function mount()
|
||||
{
|
||||
$this->maintenanceMode = isMaintenanceMode();
|
||||
}
|
||||
|
||||
#[On('maintenance-mode-changed')]
|
||||
public function refreshMaintenanceMode()
|
||||
{
|
||||
$this->maintenanceMode = isMaintenanceMode();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.admin.maintenance-banner');
|
||||
}
|
||||
}
|
||||
146
app/Http/Livewire/Admin/MaintenanceMode.php
Normal file
146
app/Http/Livewire/Admin/MaintenanceMode.php
Normal file
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire\Admin;
|
||||
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Component;
|
||||
|
||||
class MaintenanceMode extends Component
|
||||
{
|
||||
public $maintenanceMode = false;
|
||||
public $showModal = false;
|
||||
|
||||
public function mount()
|
||||
{
|
||||
// Check if the active profile is Admin
|
||||
if (getActiveProfileType() !== 'Admin') {
|
||||
abort(403, 'Only administrators can access this feature.');
|
||||
}
|
||||
|
||||
// Load current maintenance mode status
|
||||
$this->maintenanceMode = $this->getMaintenanceMode();
|
||||
}
|
||||
|
||||
public function openModal()
|
||||
{
|
||||
$this->showModal = true;
|
||||
}
|
||||
|
||||
public function closeModal()
|
||||
{
|
||||
$this->showModal = false;
|
||||
}
|
||||
|
||||
public function toggleMaintenanceMode()
|
||||
{
|
||||
// Verify admin profile again before toggling
|
||||
if (getActiveProfileType() !== 'Admin') {
|
||||
$this->dispatch('notify', [
|
||||
'type' => 'error',
|
||||
'message' => 'You must be logged in as an administrator to toggle maintenance mode.'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Toggle the value
|
||||
$this->maintenanceMode = !$this->maintenanceMode;
|
||||
|
||||
// If enabling maintenance mode, log out all non-admin users
|
||||
if ($this->maintenanceMode) {
|
||||
$this->logoutNonAdminUsers();
|
||||
}
|
||||
|
||||
// Update in database
|
||||
DB::table('system_settings')
|
||||
->where('key', 'maintenance_mode')
|
||||
->update([
|
||||
'value' => $this->maintenanceMode ? 'true' : 'false',
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
// Clear cache
|
||||
Cache::forget('system_setting_maintenance_mode');
|
||||
|
||||
// Close modal
|
||||
$this->showModal = false;
|
||||
|
||||
// Dispatch event to refresh the maintenance banner
|
||||
$this->dispatch('maintenance-mode-changed');
|
||||
|
||||
// Notify user
|
||||
$message = $this->maintenanceMode
|
||||
? 'Maintenance mode has been enabled. Only users with admin relationships can now log in.'
|
||||
: 'Maintenance mode has been disabled. All users can now log in.';
|
||||
|
||||
$this->dispatch('notify', [
|
||||
'type' => 'success',
|
||||
'message' => $message
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log out all users who don't have admin relationships
|
||||
*/
|
||||
protected function logoutNonAdminUsers()
|
||||
{
|
||||
// Get the current authenticated user ID to exclude from logout
|
||||
$currentUserId = auth()->id();
|
||||
|
||||
// Get all users without admin relationships, excluding current user
|
||||
$usersToLogout = \App\Models\User::whereDoesntHave('admins')
|
||||
->where('id', '!=', $currentUserId)
|
||||
->get();
|
||||
|
||||
$logoutCount = 0;
|
||||
|
||||
// First, broadcast logout events to all users
|
||||
// This gives browsers a chance to receive the WebSocket message before sessions are deleted
|
||||
foreach ($usersToLogout as $user) {
|
||||
// Determine the guard for this user
|
||||
$guard = 'web'; // Default guard
|
||||
|
||||
// Broadcast forced logout event via WebSocket
|
||||
broadcast(new \App\Events\UserForcedLogout($user->id, $guard));
|
||||
|
||||
$logoutCount++;
|
||||
}
|
||||
|
||||
// Wait briefly for WebSocket messages to be delivered
|
||||
// This helps ensure browsers receive the logout event before sessions are deleted
|
||||
sleep(2);
|
||||
|
||||
// Now delete sessions and clear caches
|
||||
foreach ($usersToLogout as $user) {
|
||||
$guard = 'web';
|
||||
|
||||
// Delete all sessions for this user from database
|
||||
\DB::connection(config('session.connection'))
|
||||
->table(config('session.table', 'sessions'))
|
||||
->where('user_id', $user->id)
|
||||
->delete();
|
||||
|
||||
// Clear cached authentication data
|
||||
Cache::forget('auth_' . $guard . '_' . $user->id);
|
||||
|
||||
// Clear presence cache
|
||||
Cache::forget("presence_{$guard}_{$user->id}");
|
||||
}
|
||||
|
||||
// Clear online users cache to force refresh
|
||||
Cache::forget("online_users_web_" . \App\Services\PresenceService::ONLINE_THRESHOLD_MINUTES);
|
||||
|
||||
// Log the action for debugging
|
||||
info("Maintenance mode enabled: Logged out {$logoutCount} non-admin users. Current admin user ID {$currentUserId} was preserved.");
|
||||
}
|
||||
|
||||
protected function getMaintenanceMode()
|
||||
{
|
||||
return isMaintenanceMode();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.admin.maintenance-mode');
|
||||
}
|
||||
}
|
||||
13
app/Http/Livewire/AdminLoginModal.php
Normal file
13
app/Http/Livewire/AdminLoginModal.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire;
|
||||
|
||||
use Livewire\Component;
|
||||
|
||||
class AdminLoginModal extends Component
|
||||
{
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.admin-login-modal');
|
||||
}
|
||||
}
|
||||
90
app/Http/Livewire/Amount.php
Normal file
90
app/Http/Livewire/Amount.php
Normal file
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire;
|
||||
|
||||
use Livewire\Component;
|
||||
|
||||
class Amount extends Component
|
||||
{
|
||||
public $amount;
|
||||
public $hours;
|
||||
public $minutes;
|
||||
public $label;
|
||||
public $maxLengthHoursInput = 3;
|
||||
|
||||
protected $listeners = ['resetForm'];
|
||||
|
||||
public function mount()
|
||||
{
|
||||
if ($this->amount != null) {
|
||||
// Ensure hours is a positive integer or set to null
|
||||
if (!is_null($this->hours) && (!is_numeric($this->hours) || $this->hours <= 0 || intval($this->hours) != $this->hours)) {
|
||||
$this->hours = null;
|
||||
}
|
||||
|
||||
// Ensure minutes is a positive integer or set to null
|
||||
if (!is_null($this->minutes) && (!is_numeric($this->minutes) || $this->minutes < 0 || intval($this->minutes) != $this->minutes)) {
|
||||
$this->minutes = null;
|
||||
$this->calculateAmount();
|
||||
|
||||
} elseif ($this->minutes > 59) {
|
||||
// If minutes is more than 59, adjust hours and minutes
|
||||
$additionalHours = intdiv($this->minutes, 60);
|
||||
$remainingMinutes = $this->minutes % 60;
|
||||
|
||||
$this->hours = is_null($this->hours) ? 0 : $this->hours;
|
||||
$this->hours += $additionalHours;
|
||||
$this->minutes = $remainingMinutes;
|
||||
$this->calculateAmount();
|
||||
}
|
||||
|
||||
// Add leading zero to minutes if less than 10
|
||||
if (!is_null($this->minutes) && $this->minutes < 10) {
|
||||
$this->minutes = str_pad($this->minutes, 2, '0', STR_PAD_LEFT);
|
||||
$this->calculateAmount();
|
||||
}
|
||||
|
||||
$this->amount = 0;
|
||||
}
|
||||
$this->amount = $this->amount;
|
||||
}
|
||||
|
||||
public function resetForm()
|
||||
{
|
||||
$this->reset();
|
||||
}
|
||||
|
||||
public function updatedHours()
|
||||
{
|
||||
$this->calculateAmount();
|
||||
}
|
||||
|
||||
public function updatedMinutes()
|
||||
{
|
||||
$this->calculateAmount();
|
||||
}
|
||||
|
||||
protected function calculateAmount()
|
||||
{
|
||||
$hours = is_numeric($this->hours) ? (int) $this->hours : 0;
|
||||
$minutes = is_numeric($this->minutes) ? (int) $this->minutes : 0;
|
||||
$this->amount = $hours * 60 + $minutes;
|
||||
$this->dispatch('amount', $this->amount);
|
||||
// Format the inputs for empty values
|
||||
if ($this->amount === 0) {
|
||||
$this->reset(['hours', 'minutes']);
|
||||
} else {
|
||||
if ($this->amount < 60) {
|
||||
$this->hours = 0;
|
||||
}
|
||||
if ($this->amount % 60 === 0) {
|
||||
$this->minutes = '00';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.amount');
|
||||
}
|
||||
}
|
||||
87
app/Http/Livewire/Calls/CallCarouselScorer.php
Normal file
87
app/Http/Livewire/Calls/CallCarouselScorer.php
Normal file
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire\Calls;
|
||||
|
||||
use App\Models\Call;
|
||||
|
||||
class CallCarouselScorer
|
||||
{
|
||||
private array $cfg;
|
||||
private ?int $profileCityId;
|
||||
private ?int $profileDivisionId;
|
||||
private ?int $profileCountryId;
|
||||
|
||||
private const UNKNOWN_COUNTRY_ID = 10;
|
||||
private const REACTION_TYPE_LIKE = 3;
|
||||
private const REACTION_TYPE_STAR = 1;
|
||||
|
||||
public function __construct(
|
||||
array $carouselConfig,
|
||||
?int $profileCityId,
|
||||
?int $profileDivisionId,
|
||||
?int $profileCountryId
|
||||
) {
|
||||
$this->cfg = $carouselConfig;
|
||||
$this->profileCityId = $profileCityId;
|
||||
$this->profileDivisionId = $profileDivisionId;
|
||||
$this->profileCountryId = $profileCountryId;
|
||||
}
|
||||
|
||||
public function score(Call $call): float
|
||||
{
|
||||
$score = 1.0;
|
||||
$loc = $call->location;
|
||||
|
||||
// --- Location specificity (only best-matching tier applied) ---
|
||||
if ($loc) {
|
||||
if ($loc->country_id === self::UNKNOWN_COUNTRY_ID) {
|
||||
$score *= (float) ($this->cfg['boost_location_unknown'] ?? 0.8);
|
||||
} elseif ($loc->city_id) {
|
||||
$score *= (float) ($this->cfg['boost_location_city'] ?? 2.0);
|
||||
} elseif ($loc->division_id) {
|
||||
$score *= (float) ($this->cfg['boost_location_division'] ?? 1.5);
|
||||
} elseif ($loc->country_id) {
|
||||
$score *= (float) ($this->cfg['boost_location_country'] ?? 1.1);
|
||||
}
|
||||
|
||||
// Same-city (district) proximity bonus
|
||||
if ($this->profileCityId && $loc->city_id === $this->profileCityId) {
|
||||
$score *= (float) ($this->cfg['boost_same_district'] ?? 3.0);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Engagement: likes on the call ---
|
||||
$likeCount = $call->loveReactant?->reactionCounters
|
||||
->firstWhere('reaction_type_id', self::REACTION_TYPE_LIKE)?->count ?? 0;
|
||||
$score *= (1.0 + $likeCount * (float) ($this->cfg['boost_like_count'] ?? 0.05));
|
||||
|
||||
// --- Engagement: stars on the callable ---
|
||||
$starCount = $call->callable?->loveReactant?->reactionCounters
|
||||
->firstWhere('reaction_type_id', self::REACTION_TYPE_STAR)?->count ?? 0;
|
||||
$score *= (1.0 + $starCount * (float) ($this->cfg['boost_star_count'] ?? 0.10));
|
||||
|
||||
// --- Recency (created_at) ---
|
||||
$recentDays = (int) ($this->cfg['recent_days'] ?? 14);
|
||||
if ($call->created_at && $call->created_at->gte(now()->subDays($recentDays))) {
|
||||
$score *= (float) ($this->cfg['boost_recent_from'] ?? 1.3);
|
||||
}
|
||||
|
||||
// --- Urgency (till expiry) ---
|
||||
$soonDays = (int) ($this->cfg['soon_days'] ?? 7);
|
||||
if ($call->till && $call->till->lte(now()->addDays($soonDays))) {
|
||||
$score *= (float) ($this->cfg['boost_soon_till'] ?? 1.2);
|
||||
}
|
||||
|
||||
// --- Callable type ---
|
||||
$callableType = $call->callable_type ?? '';
|
||||
if (str_ends_with($callableType, 'User')) {
|
||||
$score *= (float) ($this->cfg['boost_callable_user'] ?? 1.0);
|
||||
} elseif (str_ends_with($callableType, 'Organization')) {
|
||||
$score *= (float) ($this->cfg['boost_callable_organization'] ?? 1.2);
|
||||
} elseif (str_ends_with($callableType, 'Bank')) {
|
||||
$score *= (float) ($this->cfg['boost_callable_bank'] ?? 1.0);
|
||||
}
|
||||
|
||||
return $score;
|
||||
}
|
||||
}
|
||||
510
app/Http/Livewire/Calls/CallSkillInput.php
Normal file
510
app/Http/Livewire/Calls/CallSkillInput.php
Normal file
@@ -0,0 +1,510 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire\Calls;
|
||||
|
||||
use App\Helpers\StringHelper;
|
||||
use App\Jobs\SendEmailNewTag;
|
||||
use App\Models\Category;
|
||||
use App\Models\Language;
|
||||
use App\Models\Tag;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Component;
|
||||
|
||||
class CallSkillInput extends Component
|
||||
{
|
||||
// Tagify state
|
||||
public string $tagsArray = '[]';
|
||||
public array $suggestions = [];
|
||||
|
||||
// New tag creation modal
|
||||
public bool $modalVisible = false;
|
||||
public array $newTag = ['name' => ''];
|
||||
public ?int $newTagCategory = null;
|
||||
public array $categoryOptions = [];
|
||||
public string $categoryColor = 'gray';
|
||||
|
||||
// Language detection
|
||||
public bool $sessionLanguageOk = false;
|
||||
public bool $sessionLanguageIgnored = false;
|
||||
public bool $transLanguageOk = false;
|
||||
public bool $transLanguageIgnored = false;
|
||||
|
||||
// Translation support
|
||||
public bool $translationPossible = true;
|
||||
public bool $translationAllowed = true;
|
||||
public bool $translationVisible = false;
|
||||
public array $translationLanguages = [];
|
||||
public $selectTranslationLanguage = null;
|
||||
public array $translationOptions = [];
|
||||
public $selectTagTranslation = null;
|
||||
public array $inputTagTranslation = [];
|
||||
public bool $inputDisabled = true;
|
||||
public $translateRadioButton = null;
|
||||
|
||||
protected $langDetector = null;
|
||||
|
||||
protected function rules(): array
|
||||
{
|
||||
return $this->createTagValidationRules();
|
||||
}
|
||||
|
||||
public ?int $initialTagId = null;
|
||||
|
||||
public function mount(?int $initialTagId = null): void
|
||||
{
|
||||
$this->initialTagId = $initialTagId;
|
||||
|
||||
if ($initialTagId) {
|
||||
$tag = \App\Models\Tag::find($initialTagId);
|
||||
if ($tag) {
|
||||
$color = $tag->contexts->first()?->category?->relatedColor ?? 'gray';
|
||||
$tagDisplayName = $tag->translation?->name ?? $tag->name;
|
||||
$this->tagsArray = json_encode([[
|
||||
'value' => $tagDisplayName,
|
||||
'tag_id' => $tag->tag_id,
|
||||
'title' => $tagDisplayName,
|
||||
'style' => '--tag-bg:' . tailwindColorToHex($color . '-400') . '; --tag-text-color:#000; --tag-hover:' . tailwindColorToHex($color . '-200'),
|
||||
]]);
|
||||
}
|
||||
}
|
||||
|
||||
$this->suggestions = $this->getSuggestions();
|
||||
$this->checkTranslationAllowed();
|
||||
$this->checkTranslationPossible();
|
||||
}
|
||||
|
||||
protected function getSuggestions(): array
|
||||
{
|
||||
return DB::table('taggable_tags as tt')
|
||||
->join('taggable_locales as tl', 'tt.tag_id', '=', 'tl.taggable_tag_id')
|
||||
->join('taggable_locale_context as tlc', 'tt.tag_id', '=', 'tlc.tag_id')
|
||||
->join('taggable_contexts as tc', 'tlc.context_id', '=', 'tc.id')
|
||||
->join('categories as c', 'tc.category_id', '=', 'c.id')
|
||||
->join('categories as croot', DB::raw('COALESCE(c.parent_id, c.id)'), '=', 'croot.id')
|
||||
->where('tl.locale', app()->getLocale())
|
||||
->select('tt.tag_id', 'tt.name', 'croot.color')
|
||||
->distinct()
|
||||
->orderBy('tt.name')
|
||||
->get()
|
||||
->map(function ($t) {
|
||||
$color = $t->color ?? 'gray';
|
||||
return [
|
||||
'value' => $t->name,
|
||||
'tag_id' => $t->tag_id,
|
||||
'style' => '--tag-bg:' . tailwindColorToHex($color . '-400') . '; --tag-text-color:#000; --tag-hover:' . tailwindColorToHex($color . '-200'),
|
||||
'title' => $t->name,
|
||||
];
|
||||
})
|
||||
->values()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Called from Alpine when the user selects a known tag from the whitelist.
|
||||
* Notifies the parent Create component.
|
||||
*/
|
||||
public function notifyTagSelected(int $tagId): void
|
||||
{
|
||||
$this->dispatch('callTagSelected', tagId: $tagId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called from Alpine when the tag is removed (input cleared).
|
||||
*/
|
||||
public function notifyTagCleared(): void
|
||||
{
|
||||
$this->dispatch('callTagCleared');
|
||||
}
|
||||
|
||||
/**
|
||||
* Called from Alpine when the user types an unknown tag name and confirms it.
|
||||
*/
|
||||
public function openNewTagModal(string $name): void
|
||||
{
|
||||
$this->newTag['name'] = $name;
|
||||
$this->categoryOptions = $this->loadCategoryOptions();
|
||||
$this->modalVisible = true;
|
||||
$this->checkSessionLanguage();
|
||||
}
|
||||
|
||||
protected function loadCategoryOptions(): array
|
||||
{
|
||||
return Category::where('type', Tag::class)
|
||||
->get()
|
||||
->map(function ($category) {
|
||||
return [
|
||||
'category_id' => $category->id,
|
||||
'name' => ucfirst($category->translation->name ?? ''),
|
||||
'description' => $category->relatedPathExSelfTranslation ?? '',
|
||||
'color' => $category->relatedColor ?? 'gray',
|
||||
];
|
||||
})
|
||||
->sortBy('name')
|
||||
->values()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
public function checkTranslationAllowed(): void
|
||||
{
|
||||
$allowTranslations = timebank_config('tags.allow_tag_transations_for_non_admins', false);
|
||||
$profileType = getActiveProfileType();
|
||||
|
||||
if (!$allowTranslations) {
|
||||
$this->translationAllowed = ($profileType === 'admin');
|
||||
} else {
|
||||
$this->translationAllowed = true;
|
||||
}
|
||||
}
|
||||
|
||||
public function checkTranslationPossible(): void
|
||||
{
|
||||
$profile = getActiveProfile();
|
||||
if (!$profile || !method_exists($profile, 'languages')) {
|
||||
$this->translationPossible = false;
|
||||
return;
|
||||
}
|
||||
$countNonBaseLanguages = $profile->languages()->where('lang_code', '!=', timebank_config('base_language'))->count();
|
||||
if ($countNonBaseLanguages === 0 && app()->getLocale() === timebank_config('base_language')) {
|
||||
$this->translationPossible = false;
|
||||
}
|
||||
}
|
||||
|
||||
protected function getLanguageDetector()
|
||||
{
|
||||
if (!$this->langDetector) {
|
||||
$this->langDetector = new \Text_LanguageDetect();
|
||||
$this->langDetector->setNameMode(2);
|
||||
}
|
||||
return $this->langDetector;
|
||||
}
|
||||
|
||||
public function checkSessionLanguage(): void
|
||||
{
|
||||
$this->getLanguageDetector();
|
||||
$detectedLanguage = $this->langDetector->detectSimple($this->newTag['name'] ?? '');
|
||||
if ($detectedLanguage === session('locale')) {
|
||||
$this->sessionLanguageOk = true;
|
||||
$this->sessionLanguageIgnored = false;
|
||||
} else {
|
||||
$this->sessionLanguageOk = false;
|
||||
}
|
||||
$this->validateOnly('newTag.name');
|
||||
}
|
||||
|
||||
public function checkTransLanguage(): void
|
||||
{
|
||||
$this->getLanguageDetector();
|
||||
$detectedLanguage = $this->langDetector->detectSimple($this->inputTagTranslation['name'] ?? '');
|
||||
if ($detectedLanguage === $this->selectTranslationLanguage) {
|
||||
$this->transLanguageOk = true;
|
||||
$this->transLanguageIgnored = false;
|
||||
} else {
|
||||
$this->transLanguageOk = false;
|
||||
}
|
||||
$this->validateOnly('inputTagTranslation.name');
|
||||
}
|
||||
|
||||
public function updatedNewTagCategory(): void
|
||||
{
|
||||
$this->categoryColor = collect($this->categoryOptions)
|
||||
->firstWhere('category_id', $this->newTagCategory)['color'] ?? 'gray';
|
||||
$this->selectTagTranslation = null;
|
||||
$this->translationOptions = $this->getTranslationOptions($this->selectTranslationLanguage);
|
||||
$this->resetErrorBag('inputTagTranslationCategory');
|
||||
}
|
||||
|
||||
public function updatedSessionLanguageIgnored(): void
|
||||
{
|
||||
if (!$this->sessionLanguageIgnored) {
|
||||
$this->checkSessionLanguage();
|
||||
}
|
||||
$this->validateOnly('newTag.name');
|
||||
}
|
||||
|
||||
public function updatedTransLanguageIgnored(): void
|
||||
{
|
||||
if (!$this->transLanguageIgnored) {
|
||||
$this->checkTransLanguage();
|
||||
} else {
|
||||
$this->resetErrorBag('inputTagTranslation.name');
|
||||
}
|
||||
}
|
||||
|
||||
public function updatedSelectTranslationLanguage(): void
|
||||
{
|
||||
$this->selectTagTranslation = null;
|
||||
$this->translationOptions = $this->getTranslationOptions($this->selectTranslationLanguage);
|
||||
}
|
||||
|
||||
public function updatedTranslationVisible(): void
|
||||
{
|
||||
if ($this->translationVisible && $this->translationAllowed) {
|
||||
$this->updatedNewTagCategory();
|
||||
|
||||
$profile = getActiveProfile();
|
||||
if (!$profile || !method_exists($profile, 'languages')) {
|
||||
return;
|
||||
}
|
||||
|
||||
\App\Helpers\ProfileAuthorizationHelper::authorize($profile);
|
||||
|
||||
$this->translationLanguages = $profile
|
||||
->languages()
|
||||
->wherePivot('competence', 1)
|
||||
->where('lang_code', '!=', app()->getLocale())
|
||||
->get()
|
||||
->map(function ($language) {
|
||||
$language->name = trans($language->name);
|
||||
return $language;
|
||||
})
|
||||
->toArray();
|
||||
|
||||
if (!collect($this->translationLanguages)->contains('lang_code', 'en')) {
|
||||
$transLanguage = Language::where('lang_code', timebank_config('base_language'))->first();
|
||||
if ($transLanguage) {
|
||||
$transLanguage->name = trans($transLanguage->name);
|
||||
$this->translationLanguages = collect($this->translationLanguages)->push($transLanguage)->toArray();
|
||||
}
|
||||
if (app()->getLocale() != timebank_config('base_language')) {
|
||||
$this->selectTranslationLanguage = timebank_config('base_language');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function updatedTranslateRadioButton(): void
|
||||
{
|
||||
if ($this->translateRadioButton === 'select') {
|
||||
$this->inputDisabled = true;
|
||||
$this->dispatch('disableInput');
|
||||
} elseif ($this->translateRadioButton === 'input') {
|
||||
$this->inputDisabled = false;
|
||||
}
|
||||
$this->resetErrorBag('selectTagTranslation');
|
||||
$this->resetErrorBag('inputTagTranslation.name');
|
||||
$this->resetErrorBag('newTagCategory');
|
||||
}
|
||||
|
||||
public function updatedSelectTagTranslation(): void
|
||||
{
|
||||
if ($this->selectTagTranslation) {
|
||||
$this->categoryColor = Tag::find($this->selectTagTranslation)->categories->first()->relatedColor ?? 'gray';
|
||||
$this->translateRadioButton = 'select';
|
||||
$this->dispatch('disableInput');
|
||||
}
|
||||
}
|
||||
|
||||
public function updatedInputTagTranslation(): void
|
||||
{
|
||||
$this->translateRadioButton = 'input';
|
||||
$this->inputDisabled = false;
|
||||
$this->checkTransLanguage();
|
||||
}
|
||||
|
||||
public function getTranslationOptions(?string $locale): array
|
||||
{
|
||||
if (!$locale) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$appLocale = app()->getLocale();
|
||||
|
||||
$contextIdsInAppLocale = DB::table('taggable_locale_context')
|
||||
->whereIn('tag_id', function ($query) use ($appLocale) {
|
||||
$query->select('taggable_tag_id')
|
||||
->from('taggable_locales')
|
||||
->where('locale', $appLocale);
|
||||
})
|
||||
->pluck('context_id');
|
||||
|
||||
$tags = Tag::with(['locale', 'contexts.category'])
|
||||
->whereHas('locale', function ($query) use ($locale) {
|
||||
$query->where('locale', $locale);
|
||||
})
|
||||
->whereNotIn('tag_id', function ($subquery) use ($contextIdsInAppLocale) {
|
||||
$subquery->select('tag_id')
|
||||
->from('taggable_locale_context')
|
||||
->whereIn('context_id', $contextIdsInAppLocale);
|
||||
})
|
||||
->get();
|
||||
|
||||
return $tags->map(function ($tag) use ($locale) {
|
||||
$category = optional($tag->contexts->first())->category;
|
||||
$description = optional(optional($category)->translation)->name ?? '';
|
||||
return [
|
||||
'tag_id' => $tag->tag_id,
|
||||
'name' => $locale == 'de' ? $tag->name : StringHelper::DutchTitleCase($tag->normalized),
|
||||
'description' => $description,
|
||||
];
|
||||
})->sortBy('name')->values()->toArray();
|
||||
}
|
||||
|
||||
protected function createTagValidationRules(): array
|
||||
{
|
||||
return [
|
||||
'newTag.name' => array_merge(
|
||||
timebank_config('tags.name_rule'),
|
||||
timebank_config('tags.exists_in_current_locale_rule', []),
|
||||
[
|
||||
'sometimes',
|
||||
function ($attribute, $value, $fail) {
|
||||
if (!$this->sessionLanguageOk && !$this->sessionLanguageIgnored) {
|
||||
$locale = app()->getLocale();
|
||||
$localeName = \Locale::getDisplayName($locale, $locale);
|
||||
$fail(__('Is this :locale? Please confirm here below', ['locale' => $localeName]));
|
||||
}
|
||||
},
|
||||
]
|
||||
),
|
||||
'newTagCategory' => function () {
|
||||
if ($this->translationVisible && $this->translateRadioButton == 'input') {
|
||||
return 'required|int';
|
||||
}
|
||||
if (!$this->translationVisible) {
|
||||
return 'required|int';
|
||||
}
|
||||
return 'nullable';
|
||||
},
|
||||
'selectTagTranslation' => ($this->translationVisible && $this->translateRadioButton == 'select')
|
||||
? 'required|int'
|
||||
: 'nullable',
|
||||
'inputTagTranslation.name' => ($this->translationVisible && $this->translateRadioButton === 'input')
|
||||
? array_merge(
|
||||
timebank_config('tags.name_rule'),
|
||||
timebank_config('tags.exists_in_current_locale_rule', []),
|
||||
[
|
||||
'sometimes',
|
||||
function ($attribute, $value, $fail) {
|
||||
if (!$this->transLanguageOk && !$this->transLanguageIgnored) {
|
||||
$baseLocale = $this->selectTranslationLanguage;
|
||||
$locale = \Locale::getDisplayName($baseLocale, $baseLocale);
|
||||
$fail(__('Is this :locale? Please confirm here below', ['locale' => $locale]));
|
||||
}
|
||||
},
|
||||
function ($attribute, $value, $fail) {
|
||||
$existsInTransLationLanguage = DB::table('taggable_tags')
|
||||
->join('taggable_locales', 'taggable_tags.tag_id', '=', 'taggable_locales.taggable_tag_id')
|
||||
->where('taggable_locales.locale', $this->selectTranslationLanguage)
|
||||
->where(function ($query) use ($value) {
|
||||
$query->where('taggable_tags.name', $value)
|
||||
->orWhere('taggable_tags.normalized', $value);
|
||||
})
|
||||
->exists();
|
||||
if ($existsInTransLationLanguage) {
|
||||
$fail(__('This tag already exists.'));
|
||||
}
|
||||
},
|
||||
]
|
||||
)
|
||||
: 'nullable',
|
||||
];
|
||||
}
|
||||
|
||||
public function createTag(): void
|
||||
{
|
||||
$this->validate($this->createTagValidationRules());
|
||||
$this->resetErrorBag();
|
||||
|
||||
$name = app()->getLocale() == 'de'
|
||||
? trim($this->newTag['name'])
|
||||
: StringHelper::DutchTitleCase(trim($this->newTag['name']));
|
||||
$normalized = call_user_func(config('taggable.normalizer'), $name);
|
||||
|
||||
$existing = Tag::whereHas('locale', fn ($q) => $q->where('locale', app()->getLocale()))
|
||||
->where('name', $name)
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
$tag = $existing;
|
||||
} else {
|
||||
$tag = Tag::create(['name' => $name, 'normalized' => $normalized]);
|
||||
$tag->locale()->create(['locale' => app()->getLocale()]);
|
||||
}
|
||||
|
||||
$context = [
|
||||
'category_id' => $this->newTagCategory,
|
||||
'updated_by_user' => Auth::guard('web')->id(),
|
||||
];
|
||||
|
||||
if ($this->translationVisible) {
|
||||
if ($this->translateRadioButton === 'select') {
|
||||
$tagContext = Tag::find($this->selectTagTranslation)->contexts()->first();
|
||||
$tag->contexts()->attach($tagContext->id);
|
||||
} elseif ($this->translateRadioButton === 'input') {
|
||||
$tagContext = $tag->contexts()->create($context);
|
||||
|
||||
$this->inputTagTranslation['name'] = $this->selectTranslationLanguage == 'de'
|
||||
? $this->inputTagTranslation['name']
|
||||
: StringHelper::DutchTitleCase($this->inputTagTranslation['name']);
|
||||
|
||||
$nameTranslation = $this->inputTagTranslation['name'];
|
||||
$normalizedTranslation = call_user_func(config('taggable.normalizer'), $nameTranslation);
|
||||
|
||||
$tagTranslation = Tag::create([
|
||||
'name' => $nameTranslation,
|
||||
'normalized' => $normalizedTranslation,
|
||||
]);
|
||||
$tagTranslation->locale()->create(['locale' => $this->selectTranslationLanguage]);
|
||||
$tagTranslation->contexts()->attach($tagContext->id);
|
||||
}
|
||||
} else {
|
||||
if (!$tag->contexts()->where('category_id', $this->newTagCategory)->exists()) {
|
||||
$tag->contexts()->create($context);
|
||||
}
|
||||
}
|
||||
|
||||
$color = collect($this->categoryOptions)->firstWhere('category_id', $this->newTagCategory)['color'] ?? 'gray';
|
||||
$this->tagsArray = json_encode([[
|
||||
'value' => $tag->translation?->name ?? $tag->name,
|
||||
'tag_id' => $tag->tag_id,
|
||||
'title' => $name,
|
||||
'style' => '--tag-bg:' . tailwindColorToHex($color . '-400') . '; --tag-text-color:#000; --tag-hover:' . tailwindColorToHex($color . '-200'),
|
||||
]]);
|
||||
|
||||
$this->modalVisible = false;
|
||||
$this->newTag = ['name' => ''];
|
||||
$this->newTagCategory = null;
|
||||
$this->categoryColor = 'gray';
|
||||
$this->translationVisible = false;
|
||||
$this->translateRadioButton = null;
|
||||
$this->selectTagTranslation = null;
|
||||
$this->inputTagTranslation = [];
|
||||
$this->sessionLanguageOk = false;
|
||||
$this->sessionLanguageIgnored = false;
|
||||
$this->transLanguageOk = false;
|
||||
$this->transLanguageIgnored = false;
|
||||
$this->resetErrorBag(['newTag.name', 'newTagCategory']);
|
||||
|
||||
// Reload Tagify badge in this component's input
|
||||
$this->dispatch('callTagifyReload', tagsArray: $this->tagsArray);
|
||||
|
||||
// Notify parent (Create or Edit) of the selected tag
|
||||
$this->dispatch('callTagSelected', tagId: $tag->tag_id);
|
||||
|
||||
SendEmailNewTag::dispatch($tag->tag_id);
|
||||
}
|
||||
|
||||
public function cancelCreateTag(): void
|
||||
{
|
||||
$this->resetErrorBag();
|
||||
$this->newTag = ['name' => ''];
|
||||
$this->newTagCategory = null;
|
||||
$this->categoryColor = 'gray';
|
||||
$this->modalVisible = false;
|
||||
$this->translationVisible = false;
|
||||
$this->translateRadioButton = null;
|
||||
$this->selectTagTranslation = null;
|
||||
$this->inputTagTranslation = [];
|
||||
$this->sessionLanguageOk = false;
|
||||
$this->sessionLanguageIgnored = false;
|
||||
$this->transLanguageOk = false;
|
||||
$this->transLanguageIgnored = false;
|
||||
$this->dispatch('removeLastCallTag');
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.calls.call-skill-input');
|
||||
}
|
||||
}
|
||||
239
app/Http/Livewire/Calls/Create.php
Normal file
239
app/Http/Livewire/Calls/Create.php
Normal file
@@ -0,0 +1,239 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire\Calls;
|
||||
|
||||
use App\Models\Account;
|
||||
use App\Models\Call;
|
||||
use App\Models\CallTranslation;
|
||||
use App\Models\Locations\Location;
|
||||
use App\Models\Transaction;
|
||||
use App\Services\CallCreditService;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Livewire\Component;
|
||||
use WireUi\Traits\WireUiActions;
|
||||
|
||||
class Create extends Component
|
||||
{
|
||||
use WireUiActions;
|
||||
|
||||
public string $content = '';
|
||||
public ?string $till = null;
|
||||
public ?int $tagId = null;
|
||||
public bool $isPublic = false;
|
||||
|
||||
// Location fields
|
||||
public $country = null;
|
||||
public $division = null;
|
||||
public $city = null;
|
||||
public $district = null;
|
||||
|
||||
public bool $showModal = false;
|
||||
public bool $showNoCreditsModal = false;
|
||||
|
||||
protected $listeners = [
|
||||
'countryToParent',
|
||||
'divisionToParent',
|
||||
'cityToParent',
|
||||
'districtToParent',
|
||||
'callTagSelected',
|
||||
'callTagCleared',
|
||||
];
|
||||
|
||||
protected function messages(): array
|
||||
{
|
||||
return [
|
||||
'till.required' => __('Expire date is required.'),
|
||||
'till.date' => __('Expire date must be a valid date.'),
|
||||
'till.after' => __('Expire date must be in the future.'),
|
||||
'till.before_or_equal'=> __('Expire date exceeds the maximum allowed period.'),
|
||||
];
|
||||
}
|
||||
|
||||
public function getTillMaxDays(): ?int
|
||||
{
|
||||
$activeProfileType = session('activeProfileType');
|
||||
if ($activeProfileType && $activeProfileType !== \App\Models\User::class) {
|
||||
return timebank_config('calls.till_max_days_non_user');
|
||||
}
|
||||
return timebank_config('calls.till_max_days');
|
||||
}
|
||||
|
||||
protected function rules(): array
|
||||
{
|
||||
$tillMaxDays = $this->getTillMaxDays();
|
||||
$tillRule = 'required|date|after:today';
|
||||
if ($tillMaxDays !== null) {
|
||||
$tillRule .= '|before_or_equal:' . now()->addDays($tillMaxDays)->format('Y-m-d');
|
||||
}
|
||||
|
||||
return [
|
||||
'content' => ['required', 'string', 'max:' . timebank_config('calls.content_max_input', 200)],
|
||||
'till' => $tillRule,
|
||||
'tagId' => 'required|integer|exists:taggable_tags,tag_id',
|
||||
'country' => 'required|integer|exists:countries,id',
|
||||
'city' => 'nullable|integer|exists:cities,id',
|
||||
'district'=> 'nullable|integer|exists:districts,id',
|
||||
];
|
||||
}
|
||||
|
||||
public function openModal(): void
|
||||
{
|
||||
$activeProfileType = session('activeProfileType');
|
||||
|
||||
if (!$activeProfileType || !in_array($activeProfileType, [
|
||||
\App\Models\User::class,
|
||||
\App\Models\Organization::class,
|
||||
\App\Models\Bank::class,
|
||||
])) {
|
||||
abort(403, __('Only platform profiles (User, Organization, Bank) can create Calls.'));
|
||||
}
|
||||
|
||||
$activeProfileId = session('activeProfileId');
|
||||
if (!CallCreditService::profileHasCredits($activeProfileType, (int) $activeProfileId)) {
|
||||
$this->showNoCreditsModal = true;
|
||||
return;
|
||||
}
|
||||
|
||||
$this->reset(['content', 'till', 'tagId', 'country', 'division', 'city', 'district', 'isPublic']);
|
||||
$this->resetValidation();
|
||||
|
||||
// Pre-fill expiry date from platform config default
|
||||
$defaultExpiryDays = timebank_config('calls.default_expiry_days');
|
||||
if ($defaultExpiryDays) {
|
||||
$this->till = now()->addDays($defaultExpiryDays)->format('Y-m-d');
|
||||
}
|
||||
|
||||
// Pre-fill location from the callable profile's primary location
|
||||
$profile = $activeProfileType::find(session('activeProfileId'));
|
||||
$location = $profile?->locations()->with(['country', 'division', 'city', 'district'])->first();
|
||||
if ($location) {
|
||||
$this->country = $location->country_id;
|
||||
$this->division = $location->division_id;
|
||||
$this->city = $location->city_id;
|
||||
$this->district = $location->district_id;
|
||||
}
|
||||
|
||||
$this->showModal = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by the CallSkillInput child component when a tag is selected or created.
|
||||
*/
|
||||
public function callTagSelected(int $tagId): void
|
||||
{
|
||||
$this->tagId = $tagId;
|
||||
$this->resetValidation('tagId');
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by the CallSkillInput child component when the tag input is cleared.
|
||||
*/
|
||||
public function callTagCleared(): void
|
||||
{
|
||||
$this->tagId = null;
|
||||
}
|
||||
|
||||
public function countryToParent($value): void { $this->country = $value ?: null; }
|
||||
public function divisionToParent($value): void { $this->division = $value ?: null; }
|
||||
public function cityToParent($value): void { $this->city = $value ?: null; }
|
||||
public function districtToParent($value): void { $this->district = $value ?: null; }
|
||||
|
||||
public function save(): void
|
||||
{
|
||||
$activeProfileType = session('activeProfileType');
|
||||
|
||||
if (!$activeProfileType || !in_array($activeProfileType, [
|
||||
\App\Models\User::class,
|
||||
\App\Models\Organization::class,
|
||||
\App\Models\Bank::class,
|
||||
])) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$this->validate();
|
||||
|
||||
// Resolve or create a standalone Location record
|
||||
$locationId = null;
|
||||
if ($this->country || $this->city) {
|
||||
$attributes = array_filter([
|
||||
'country_id' => $this->country ?: null,
|
||||
'division_id' => $this->division ?: null,
|
||||
'city_id' => $this->city ?: null,
|
||||
'district_id' => $this->district ?: null,
|
||||
]);
|
||||
$location = Location::whereNull('locatable_id')
|
||||
->whereNull('locatable_type')
|
||||
->where($attributes)
|
||||
->first();
|
||||
if (!$location) {
|
||||
$location = new Location($attributes);
|
||||
$location->save();
|
||||
}
|
||||
$locationId = $location->id;
|
||||
}
|
||||
|
||||
$call = Call::create([
|
||||
'callable_id' => session('activeProfileId'),
|
||||
'callable_type' => $activeProfileType,
|
||||
'tag_id' => $this->tagId ?: null,
|
||||
'location_id' => $locationId,
|
||||
'from' => now()->utc(),
|
||||
'till' => $this->till ?: null,
|
||||
'is_public' => $this->isPublic,
|
||||
]);
|
||||
|
||||
CallTranslation::create([
|
||||
'call_id' => $call->id,
|
||||
'locale' => App::getLocale(),
|
||||
'content' => $this->content ?: null,
|
||||
]);
|
||||
|
||||
$call->searchable();
|
||||
|
||||
$this->showModal = false;
|
||||
$this->reset(['content', 'till', 'tagId', 'country', 'division', 'city', 'district', 'isPublic']);
|
||||
|
||||
$this->dispatch('callSaved');
|
||||
|
||||
$this->notification()->success(
|
||||
title: __('Saved'),
|
||||
description: __('Your Call has been published.')
|
||||
);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$activeProfileType = session('activeProfileType');
|
||||
$activeProfileId = session('activeProfileId');
|
||||
$profileName = $activeProfileType
|
||||
? ($activeProfileType::find($activeProfileId)?->name ?? '')
|
||||
: '';
|
||||
|
||||
$canCreate = $activeProfileType && $activeProfileId
|
||||
? CallCreditService::profileHasCredits($activeProfileType, (int) $activeProfileId)
|
||||
: false;
|
||||
|
||||
// Calculate total spendable balance across all accounts for the active profile
|
||||
$spendableBalance = null;
|
||||
if ($activeProfileType && $activeProfileId) {
|
||||
$profile = $activeProfileType::find($activeProfileId);
|
||||
if ($profile) {
|
||||
$total = 0;
|
||||
foreach ($profile->accounts()->notRemoved()->get() as $account) {
|
||||
$balance = Transaction::where('from_account_id', $account->id)
|
||||
->orWhere('to_account_id', $account->id)
|
||||
->selectRaw('SUM(CASE WHEN to_account_id = ? THEN amount ELSE -amount END) as balance', [$account->id])
|
||||
->value('balance') ?? 0;
|
||||
$total += $balance - $account->limit_min;
|
||||
}
|
||||
$spendableBalance = $total;
|
||||
}
|
||||
}
|
||||
|
||||
return view('livewire.calls.create', [
|
||||
'profileName' => $profileName,
|
||||
'canCreate' => $canCreate,
|
||||
'spendableBalance' => $spendableBalance,
|
||||
]);
|
||||
}
|
||||
}
|
||||
200
app/Http/Livewire/Calls/Edit.php
Normal file
200
app/Http/Livewire/Calls/Edit.php
Normal file
@@ -0,0 +1,200 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire\Calls;
|
||||
|
||||
use App\Models\Call;
|
||||
use App\Models\CallTranslation;
|
||||
use App\Models\Locations\Location;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Livewire\Component;
|
||||
use WireUi\Traits\WireUiActions;
|
||||
|
||||
class Edit extends Component
|
||||
{
|
||||
use WireUiActions;
|
||||
|
||||
public Call $call;
|
||||
|
||||
public string $content = '';
|
||||
public ?string $till = null;
|
||||
public ?int $tagId = null;
|
||||
public bool $isPublic = false;
|
||||
|
||||
// Location fields
|
||||
public $country = null;
|
||||
public $division = null;
|
||||
public $city = null;
|
||||
public $district = null;
|
||||
|
||||
public bool $showModal = false;
|
||||
public bool $showDeleteConfirm = false;
|
||||
public bool $compact = false;
|
||||
|
||||
protected $listeners = [
|
||||
'countryToParent',
|
||||
'divisionToParent',
|
||||
'cityToParent',
|
||||
'districtToParent',
|
||||
'callTagSelected',
|
||||
'callTagCleared',
|
||||
];
|
||||
|
||||
protected function messages(): array
|
||||
{
|
||||
return [
|
||||
'till.required' => __('Expire date is required.'),
|
||||
'till.date' => __('Expire date must be a valid date.'),
|
||||
'till.after' => __('Expire date must be in the future.'),
|
||||
'till.before_or_equal'=> __('Expire date exceeds the maximum allowed period.'),
|
||||
];
|
||||
}
|
||||
|
||||
public function getTillMaxDays(): ?int
|
||||
{
|
||||
$callableType = $this->call->callable_type ?? session('activeProfileType');
|
||||
if ($callableType && $callableType !== \App\Models\User::class) {
|
||||
return timebank_config('calls.till_max_days_non_user');
|
||||
}
|
||||
return timebank_config('calls.till_max_days');
|
||||
}
|
||||
|
||||
protected function rules(): array
|
||||
{
|
||||
$tillMaxDays = $this->getTillMaxDays();
|
||||
$tillRule = 'required|date|after:today';
|
||||
if ($tillMaxDays !== null) {
|
||||
$tillRule .= '|before_or_equal:' . now()->addDays($tillMaxDays)->format('Y-m-d');
|
||||
}
|
||||
|
||||
return [
|
||||
'content' => ['required', 'string', 'max:' . timebank_config('calls.content_max_input', 200)],
|
||||
'till' => $tillRule,
|
||||
'tagId' => 'required|integer|exists:taggable_tags,tag_id',
|
||||
'country' => 'required|integer|exists:countries,id',
|
||||
'city' => 'nullable|integer|exists:cities,id',
|
||||
'district'=> 'nullable|integer|exists:districts,id',
|
||||
];
|
||||
}
|
||||
|
||||
public function openModal(): void
|
||||
{
|
||||
$this->resetValidation();
|
||||
|
||||
// Pre-fill from existing call
|
||||
$this->tagId = $this->call->tag_id;
|
||||
$this->content = $this->call->translations->where('locale', App::getLocale())->first()?->content
|
||||
?? $this->call->translations->first()?->content
|
||||
?? '';
|
||||
$this->till = $this->call->till?->format('Y-m-d');
|
||||
$this->isPublic = (bool) $this->call->is_public;
|
||||
|
||||
$location = $this->call->location;
|
||||
$this->country = $location?->country_id;
|
||||
$this->division = $location?->division_id;
|
||||
$this->city = $location?->city_id;
|
||||
$this->district = $location?->district_id;
|
||||
|
||||
$this->showModal = true;
|
||||
}
|
||||
|
||||
public function callTagSelected(int $tagId): void
|
||||
{
|
||||
$this->tagId = $tagId;
|
||||
$this->resetValidation('tagId');
|
||||
}
|
||||
|
||||
public function callTagCleared(): void
|
||||
{
|
||||
$this->tagId = null;
|
||||
}
|
||||
|
||||
public function countryToParent($value): void { $this->country = $value ?: null; }
|
||||
public function divisionToParent($value): void { $this->division = $value ?: null; }
|
||||
public function cityToParent($value): void { $this->city = $value ?: null; }
|
||||
public function districtToParent($value): void { $this->district = $value ?: null; }
|
||||
|
||||
public function confirmDelete(): void
|
||||
{
|
||||
$this->showDeleteConfirm = true;
|
||||
}
|
||||
|
||||
public function delete(): void
|
||||
{
|
||||
$activeProfile = getActiveProfile();
|
||||
if (!$activeProfile ||
|
||||
get_class($activeProfile) !== $this->call->callable_type ||
|
||||
$activeProfile->id !== $this->call->callable_id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$this->call->unsearchable();
|
||||
$this->call->translations()->delete();
|
||||
$this->call->delete();
|
||||
|
||||
$this->redirect(route('home'));
|
||||
}
|
||||
|
||||
public function save(): void
|
||||
{
|
||||
// Only the callable owner may edit
|
||||
$activeProfile = getActiveProfile();
|
||||
if (!$activeProfile ||
|
||||
get_class($activeProfile) !== $this->call->callable_type ||
|
||||
$activeProfile->id !== $this->call->callable_id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$this->validate();
|
||||
|
||||
// Resolve or create a standalone Location record
|
||||
$locationId = null;
|
||||
if ($this->country || $this->city) {
|
||||
$attributes = array_filter([
|
||||
'country_id' => $this->country ?: null,
|
||||
'division_id' => $this->division ?: null,
|
||||
'city_id' => $this->city ?: null,
|
||||
'district_id' => $this->district ?: null,
|
||||
]);
|
||||
$location = Location::whereNull('locatable_id')
|
||||
->whereNull('locatable_type')
|
||||
->where($attributes)
|
||||
->first();
|
||||
if (!$location) {
|
||||
$location = new Location($attributes);
|
||||
$location->save();
|
||||
}
|
||||
$locationId = $location->id;
|
||||
}
|
||||
|
||||
$this->call->update([
|
||||
'tag_id' => $this->tagId,
|
||||
'location_id' => $locationId,
|
||||
'till' => $this->till ?: null,
|
||||
'is_public' => $this->isPublic,
|
||||
]);
|
||||
|
||||
// Update or create translation for current locale
|
||||
CallTranslation::updateOrCreate(
|
||||
['call_id' => $this->call->id, 'locale' => App::getLocale()],
|
||||
['content' => $this->content ?: null]
|
||||
);
|
||||
|
||||
$this->call->searchable();
|
||||
|
||||
$this->showModal = false;
|
||||
|
||||
$this->notification()->success(
|
||||
title: __('Saved'),
|
||||
description: __('Your Call has been updated.')
|
||||
);
|
||||
|
||||
$this->redirect(request()->header('Referer') ?: route('call.show', ['id' => $this->call->id]));
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.calls.edit', [
|
||||
'profileName' => $this->call->callable?->name ?? '',
|
||||
]);
|
||||
}
|
||||
}
|
||||
573
app/Http/Livewire/Calls/Manage.php
Normal file
573
app/Http/Livewire/Calls/Manage.php
Normal file
@@ -0,0 +1,573 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire\Calls;
|
||||
|
||||
use App\Helpers\ProfileAuthorizationHelper;
|
||||
use App\Mail\CallBlockedMail;
|
||||
use App\Models\Call;
|
||||
use App\Models\CallTranslation;
|
||||
use App\Models\Locations\Location;
|
||||
use App\Services\CallCreditService;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithPagination;
|
||||
use WireUi\Traits\WireUiActions;
|
||||
|
||||
class Manage extends Component
|
||||
{
|
||||
use WithPagination;
|
||||
use WireUiActions;
|
||||
|
||||
public string $search = '';
|
||||
public ?string $statusFilter = ''; // 'active', 'expired', 'deleted'
|
||||
public ?string $callableFilter = ''; // 'user', 'organization', 'bank'
|
||||
public ?string $localeFilter = ''; // 'en', 'nl', 'de', etc.
|
||||
|
||||
public array $bulkSelected = [];
|
||||
public bool $selectAll = false;
|
||||
public int $perPage = 10;
|
||||
public string $sortField = 'id';
|
||||
public string $sortDirection = 'desc';
|
||||
|
||||
public bool $isAdminView = false; // true for Admin/Bank manager, false for own calls
|
||||
|
||||
// Inline edit state
|
||||
public ?int $editCallId = null;
|
||||
public string $editContent = '';
|
||||
public ?string $editTill = null;
|
||||
public ?int $editTagId = null;
|
||||
public bool $editIsPublic = false;
|
||||
public $editCountry = null;
|
||||
public $editDivision = null;
|
||||
public $editCity = null;
|
||||
public $editDistrict = null;
|
||||
public bool $showEditModal = false;
|
||||
public bool $showDeleteConfirm = false;
|
||||
|
||||
// Admin pause/publish confirmation
|
||||
public bool $showAdminActionConfirm = false;
|
||||
public ?int $adminActionCallId = null;
|
||||
public string $adminActionType = ''; // 'pause' or 'publish'
|
||||
public string $adminActionCallableName = '';
|
||||
|
||||
// Admin bulk delete confirmation
|
||||
public bool $showAdminDeleteConfirm = false;
|
||||
public string $adminDeleteCallableNames = '';
|
||||
|
||||
// Non-admin bulk delete confirmation
|
||||
public bool $showDeleteConfirmModal = false;
|
||||
|
||||
protected $listeners = [
|
||||
'countryToParent',
|
||||
'divisionToParent',
|
||||
'cityToParent',
|
||||
'districtToParent',
|
||||
'callTagSelected',
|
||||
'callTagCleared',
|
||||
'callSaved' => '$refresh',
|
||||
];
|
||||
|
||||
protected $queryString = [
|
||||
'search' => ['except' => ''],
|
||||
'statusFilter' => ['except' => ''],
|
||||
'callableFilter' => ['except' => ''],
|
||||
'localeFilter' => ['except' => ''],
|
||||
'perPage' => ['except' => 10],
|
||||
'sortField' => ['except' => 'id'],
|
||||
'sortDirection' => ['except' => 'desc'],
|
||||
];
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$activeProfileType = session('activeProfileType');
|
||||
$activeProfileId = session('activeProfileId');
|
||||
|
||||
if (!$activeProfileType || !$activeProfileId) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$profile = $activeProfileType::find($activeProfileId);
|
||||
if (!$profile) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
ProfileAuthorizationHelper::authorize($profile);
|
||||
|
||||
// Admin and Central Bank get full view of all calls
|
||||
if ($profile instanceof \App\Models\Admin) {
|
||||
$this->isAdminView = true;
|
||||
} elseif ($profile instanceof \App\Models\Bank && $profile->level === 0) {
|
||||
$this->isAdminView = true;
|
||||
} elseif ($profile instanceof \App\Models\User
|
||||
|| $profile instanceof \App\Models\Organization
|
||||
|| $profile instanceof \App\Models\Bank) {
|
||||
$this->isAdminView = false;
|
||||
} else {
|
||||
abort(403);
|
||||
}
|
||||
}
|
||||
|
||||
// Listeners for location dropdown child component
|
||||
public function countryToParent($value): void { $this->editCountry = $value ?: null; }
|
||||
public function divisionToParent($value): void { $this->editDivision = $value ?: null; }
|
||||
public function cityToParent($value): void { $this->editCity = $value ?: null; }
|
||||
public function districtToParent($value): void { $this->editDistrict = $value ?: null; }
|
||||
|
||||
public function callTagSelected(int $tagId): void
|
||||
{
|
||||
$this->editTagId = $tagId;
|
||||
$this->resetValidation('editTagId');
|
||||
}
|
||||
|
||||
public function callTagCleared(): void
|
||||
{
|
||||
$this->editTagId = null;
|
||||
}
|
||||
|
||||
public function updatedShowEditModal(bool $value): void
|
||||
{
|
||||
if (!$value) {
|
||||
$this->dispatch('edit-done');
|
||||
}
|
||||
}
|
||||
|
||||
public function openEdit(int $id): void
|
||||
{
|
||||
$call = $this->findCall($id);
|
||||
|
||||
$this->editCallId = $call->id;
|
||||
$this->editTagId = $call->tag_id;
|
||||
$this->editContent = $call->translations->firstWhere('locale', App::getLocale())?->content
|
||||
?? $call->translations->first()?->content
|
||||
?? '';
|
||||
$this->editTill = $call->till?->format('Y-m-d');
|
||||
$this->editIsPublic = (bool) $call->is_public;
|
||||
|
||||
$location = $call->location;
|
||||
$this->editCountry = $location?->country_id;
|
||||
$this->editDivision = $location?->division_id;
|
||||
$this->editCity = $location?->city_id;
|
||||
$this->editDistrict = $location?->district_id;
|
||||
|
||||
$this->resetValidation();
|
||||
$this->showEditModal = true;
|
||||
}
|
||||
|
||||
protected function editRules(): array
|
||||
{
|
||||
$call = $this->editCallId ? Call::find($this->editCallId) : null;
|
||||
$callableType = $call?->callable_type ?? session('activeProfileType');
|
||||
$tillMaxDays = ($callableType && $callableType !== \App\Models\User::class)
|
||||
? timebank_config('calls.till_max_days_non_user')
|
||||
: timebank_config('calls.till_max_days');
|
||||
|
||||
$tillRule = 'required|date|after:today';
|
||||
if ($tillMaxDays !== null) {
|
||||
$tillRule .= '|before_or_equal:' . now()->addDays($tillMaxDays)->format('Y-m-d');
|
||||
}
|
||||
|
||||
return [
|
||||
'editContent' => ['required', 'string', 'max:' . timebank_config('calls.content_max_input', 200)],
|
||||
'editTill' => $tillRule,
|
||||
'editTagId' => 'required|integer|exists:taggable_tags,tag_id',
|
||||
'editCountry' => 'required|integer|exists:countries,id',
|
||||
'editCity' => 'nullable|integer|exists:cities,id',
|
||||
'editDistrict'=> 'nullable|integer|exists:districts,id',
|
||||
];
|
||||
}
|
||||
|
||||
public function saveEdit(): void
|
||||
{
|
||||
$this->validate($this->editRules());
|
||||
|
||||
$call = $this->findCall($this->editCallId);
|
||||
|
||||
$locationId = null;
|
||||
if ($this->editCountry || $this->editCity) {
|
||||
$attributes = array_filter([
|
||||
'country_id' => $this->editCountry ?: null,
|
||||
'division_id' => $this->editDivision ?: null,
|
||||
'city_id' => $this->editCity ?: null,
|
||||
'district_id' => $this->editDistrict ?: null,
|
||||
]);
|
||||
$location = Location::whereNull('locatable_id')
|
||||
->whereNull('locatable_type')
|
||||
->where($attributes)
|
||||
->first();
|
||||
if (!$location) {
|
||||
$location = new Location($attributes);
|
||||
$location->save();
|
||||
}
|
||||
$locationId = $location->id;
|
||||
}
|
||||
|
||||
$call->update([
|
||||
'tag_id' => $this->editTagId,
|
||||
'location_id' => $locationId,
|
||||
'till' => $this->editTill ?: null,
|
||||
'is_public' => $this->editIsPublic,
|
||||
]);
|
||||
|
||||
CallTranslation::updateOrCreate(
|
||||
['call_id' => $call->id, 'locale' => App::getLocale()],
|
||||
['content' => $this->editContent ?: null]
|
||||
);
|
||||
|
||||
$call->searchable();
|
||||
|
||||
$this->showEditModal = false;
|
||||
$this->editCallId = null;
|
||||
$this->dispatch('edit-done');
|
||||
$this->notification()->success(title: __('Saved'), description: __('Your Call has been updated.'));
|
||||
}
|
||||
|
||||
public function confirmDelete(): void
|
||||
{
|
||||
$this->showDeleteConfirm = true;
|
||||
}
|
||||
|
||||
public function deleteCall(): void
|
||||
{
|
||||
$call = $this->findCall($this->editCallId);
|
||||
$call->unsearchable();
|
||||
$call->translations()->delete();
|
||||
$call->delete();
|
||||
|
||||
$this->showEditModal = false;
|
||||
$this->showDeleteConfirm = false;
|
||||
$this->editCallId = null;
|
||||
$this->notification()->success(title: __('Deleted'), description: __('The call has been deleted.'));
|
||||
}
|
||||
|
||||
public function sortBy(string $field): void
|
||||
{
|
||||
if ($this->sortField === $field) {
|
||||
$this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
$this->sortField = $field;
|
||||
$this->sortDirection = 'desc';
|
||||
}
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
public function updatedSearch(): void { $this->resetPage(); }
|
||||
public function updatedStatusFilter(): void { $this->resetPage(); $this->bulkSelected = []; }
|
||||
public function updatedCallableFilter(): void { $this->resetPage(); $this->bulkSelected = []; }
|
||||
public function updatedLocaleFilter(): void { $this->resetPage(); $this->bulkSelected = []; }
|
||||
public function updatedPerPage(): void { $this->resetPage(); }
|
||||
|
||||
public function updatedSelectAll(bool $value): void
|
||||
{
|
||||
if ($value) {
|
||||
$activeProfileType = session('activeProfileType');
|
||||
$activeProfileId = session('activeProfileId');
|
||||
$query = Call::query();
|
||||
if (!$this->isAdminView) {
|
||||
$query->where('callable_type', $activeProfileType)->where('callable_id', $activeProfileId);
|
||||
}
|
||||
if ($this->statusFilter === 'deleted') {
|
||||
$query->onlyTrashed();
|
||||
}
|
||||
$this->bulkSelected = $query->pluck('id')->map(fn ($id) => (string) $id)->toArray();
|
||||
} else {
|
||||
$this->bulkSelected = [];
|
||||
}
|
||||
}
|
||||
|
||||
public function confirmBulkDelete(): void
|
||||
{
|
||||
$this->showDeleteConfirmModal = true;
|
||||
}
|
||||
|
||||
public function confirmAdminDelete(): void
|
||||
{
|
||||
$calls = Call::whereIn('id', $this->bulkSelected)
|
||||
->with('callable')
|
||||
->get();
|
||||
|
||||
$nameList = $calls->map(fn ($c) => $c->callable?->name ?? '?')->unique()->values()->all();
|
||||
$names = count($nameList) > 1
|
||||
? implode(', ', array_slice($nameList, 0, -1)) . ' ' . __('and') . ' ' . end($nameList)
|
||||
: ($nameList[0] ?? '?');
|
||||
$this->adminDeleteCallableNames = $names;
|
||||
$this->showAdminDeleteConfirm = true;
|
||||
}
|
||||
|
||||
public function deleteSelected(): void
|
||||
{
|
||||
$this->authorizeWrite();
|
||||
|
||||
$calls = Call::whereIn('id', $this->bulkSelected)->get();
|
||||
foreach ($calls as $call) {
|
||||
$call->unsearchable();
|
||||
$call->translations()->delete();
|
||||
$call->delete();
|
||||
}
|
||||
|
||||
$this->bulkSelected = [];
|
||||
$this->selectAll = false;
|
||||
$this->showAdminDeleteConfirm = false;
|
||||
$this->adminDeleteCallableNames = '';
|
||||
$this->showDeleteConfirmModal = false;
|
||||
$this->notification()->success(title: __('Deleted'), description: __('Selected calls have been deleted.'));
|
||||
}
|
||||
|
||||
public function undeleteSelected(): void
|
||||
{
|
||||
$this->authorizeWrite();
|
||||
|
||||
$calls = Call::onlyTrashed()->whereIn('id', $this->bulkSelected)->get();
|
||||
foreach ($calls as $call) {
|
||||
$call->translations()->withTrashed()->restore();
|
||||
$call->restore();
|
||||
$call->searchable();
|
||||
}
|
||||
|
||||
$this->bulkSelected = [];
|
||||
$this->selectAll = false;
|
||||
$this->notification()->success(title: __('Restored'), description: __('Selected calls have been restored.'));
|
||||
}
|
||||
|
||||
public function confirmAdminAction(int $id, string $type): void
|
||||
{
|
||||
$call = $this->findCall($id);
|
||||
$this->adminActionCallId = $id;
|
||||
$this->adminActionType = $type;
|
||||
$this->adminActionCallableName = $call->callable?->name ?? '?';
|
||||
$this->showAdminActionConfirm = true;
|
||||
$this->dispatch('admin-action-ready');
|
||||
}
|
||||
|
||||
public function executeAdminAction(): void
|
||||
{
|
||||
if (!$this->adminActionCallId || !$this->adminActionType) {
|
||||
return;
|
||||
}
|
||||
if ($this->adminActionType === 'pause') {
|
||||
$this->adminPause($this->adminActionCallId);
|
||||
} elseif ($this->adminActionType === 'publish') {
|
||||
$this->adminPublish($this->adminActionCallId);
|
||||
}
|
||||
$this->showAdminActionConfirm = false;
|
||||
$this->adminActionCallId = null;
|
||||
$this->adminActionType = '';
|
||||
$this->adminActionCallableName = '';
|
||||
}
|
||||
|
||||
public function adminPause(int $id): void
|
||||
{
|
||||
if (!$this->isAdminView) abort(403);
|
||||
$call = Call::findOrFail($id);
|
||||
$call->update(['is_paused' => true]);
|
||||
$call->unsearchable();
|
||||
$this->dispatch('pause-publish-done');
|
||||
$this->notification()->success(title: __('Paused'), description: __('The call has been paused.'));
|
||||
}
|
||||
|
||||
public function adminPublish(int $id): void
|
||||
{
|
||||
if (!$this->isAdminView) abort(403);
|
||||
$call = Call::findOrFail($id);
|
||||
$call->update(['is_paused' => false]);
|
||||
if ($call->shouldBeSearchable()) {
|
||||
$call->searchable();
|
||||
}
|
||||
$this->dispatch('pause-publish-done');
|
||||
$this->notification()->success(title: __('Published'), description: __('The call has been published.'));
|
||||
}
|
||||
|
||||
public function pause(int $id): void
|
||||
{
|
||||
$this->authorizeWrite();
|
||||
$call = $this->findCall($id);
|
||||
$call->update(['is_paused' => true]);
|
||||
$call->unsearchable();
|
||||
$this->dispatch('pause-publish-done');
|
||||
$this->notification()->success(title: __('Paused'), description: __('The call has been paused.'));
|
||||
}
|
||||
|
||||
public function publish(int $id): void
|
||||
{
|
||||
$this->authorizeWrite();
|
||||
$call = $this->findCall($id);
|
||||
|
||||
if (!CallCreditService::profileHasCredits($call->callable_type, $call->callable_id)) {
|
||||
$this->notification()->error(
|
||||
title: __('Cannot publish'),
|
||||
description: trans_with_platform(__('You need @PLATFORM_CURRENCY_NAME_PLURAL@ to post a call.'))
|
||||
);
|
||||
$this->dispatch('pause-publish-done');
|
||||
return;
|
||||
}
|
||||
|
||||
// If till has expired, reset it to the max allowed duration
|
||||
$updates = ['is_paused' => false];
|
||||
if ($call->till === null || $call->till->isPast()) {
|
||||
$updates['till'] = now()->addDays(timebank_config('calls.till_max_days', 90));
|
||||
}
|
||||
$call->update($updates);
|
||||
$call->searchable();
|
||||
$this->dispatch('pause-publish-done');
|
||||
$this->notification()->success(title: __('Published'), description: __('The call has been published.'));
|
||||
}
|
||||
|
||||
public function block(int $id): void
|
||||
{
|
||||
if (!$this->isAdminView) {
|
||||
abort(403);
|
||||
}
|
||||
$call = Call::findOrFail($id);
|
||||
$call->update(['is_suppressed' => true]);
|
||||
$call->unsearchable();
|
||||
|
||||
$callable = $call->callable;
|
||||
if ($callable && $callable->email) {
|
||||
Mail::to($callable->email)->queue(
|
||||
new CallBlockedMail(
|
||||
$call->load(['tag']),
|
||||
$callable,
|
||||
class_basename($callable)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
$this->notification()->error(title: __('Blocked'), description: __('The call has been blocked.'));
|
||||
}
|
||||
|
||||
public function unblock(int $id): void
|
||||
{
|
||||
if (!$this->isAdminView) {
|
||||
abort(403);
|
||||
}
|
||||
$call = Call::findOrFail($id);
|
||||
$call->update(['is_suppressed' => false]);
|
||||
if ($call->shouldBeSearchable()) {
|
||||
$call->searchable();
|
||||
}
|
||||
$this->notification()->success(title: __('Unblocked'), description: __('The call has been unblocked.'));
|
||||
}
|
||||
|
||||
private function findCall(int $id): Call
|
||||
{
|
||||
if ($this->isAdminView) {
|
||||
return Call::findOrFail($id);
|
||||
}
|
||||
return Call::where('callable_type', session('activeProfileType'))
|
||||
->where('callable_id', session('activeProfileId'))
|
||||
->findOrFail($id);
|
||||
}
|
||||
|
||||
private function authorizeWrite(): void
|
||||
{
|
||||
$activeProfileType = session('activeProfileType');
|
||||
$activeProfileId = session('activeProfileId');
|
||||
if (!$activeProfileType || !$activeProfileId) {
|
||||
abort(403);
|
||||
}
|
||||
$profile = $activeProfileType::find($activeProfileId);
|
||||
ProfileAuthorizationHelper::authorize($profile);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$activeProfileType = session('activeProfileType');
|
||||
$activeProfileId = session('activeProfileId');
|
||||
|
||||
$query = Call::with([
|
||||
'callable',
|
||||
'tag.contexts.category',
|
||||
'location.city.translations',
|
||||
'location.country.translations',
|
||||
'translations',
|
||||
]);
|
||||
|
||||
// Scope to own calls for non-admin
|
||||
if (!$this->isAdminView) {
|
||||
$query->where('callable_type', $activeProfileType)
|
||||
->where('callable_id', $activeProfileId);
|
||||
}
|
||||
|
||||
// Callable type filter (admin view)
|
||||
if ($this->isAdminView && $this->callableFilter) {
|
||||
$map = [
|
||||
'user' => \App\Models\User::class,
|
||||
'organization' => \App\Models\Organization::class,
|
||||
'bank' => \App\Models\Bank::class,
|
||||
];
|
||||
if (isset($map[$this->callableFilter])) {
|
||||
$query->where('callable_type', $map[$this->callableFilter]);
|
||||
}
|
||||
}
|
||||
|
||||
// Status / deleted
|
||||
if ($this->statusFilter === 'deleted') {
|
||||
$query->onlyTrashed();
|
||||
} elseif ($this->statusFilter === 'active') {
|
||||
$query->where('is_paused', false)
|
||||
->where(fn ($q) => $q->whereNull('till')->orWhere('till', '>=', now()));
|
||||
} elseif ($this->statusFilter === 'expired') {
|
||||
$query->where('till', '<', now());
|
||||
} elseif ($this->statusFilter === 'paused') {
|
||||
$query->where('is_paused', true);
|
||||
}
|
||||
|
||||
// Search
|
||||
if ($this->search) {
|
||||
$search = '%' . $this->search . '%';
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->whereHas('translations', fn ($t) => $t->where('content', 'like', $search))
|
||||
->orWhereHas('tag', fn ($t) => $t->where('name', 'like', $search))
|
||||
->orWhereHas('callable', fn ($t) => $t->where('name', 'like', $search));
|
||||
});
|
||||
}
|
||||
|
||||
// Locale filter
|
||||
if ($this->localeFilter) {
|
||||
$query->whereHas('translations', fn ($q) => $q->where('locale', $this->localeFilter));
|
||||
}
|
||||
|
||||
$allowedSorts = ['id', 'created_at', 'till', 'updated_at'];
|
||||
if (in_array($this->sortField, $allowedSorts)) {
|
||||
$query->orderBy($this->sortField, $this->sortDirection);
|
||||
} else {
|
||||
// Sort by first locale via subquery to avoid duplicate rows
|
||||
$localeSubquery = DB::table('call_translations')
|
||||
->select('call_id', DB::raw('MIN(locale) as first_locale'))
|
||||
->whereNull('deleted_at')
|
||||
->groupBy('call_id');
|
||||
|
||||
$query->leftJoinSub($localeSubquery, 'ct_locale', 'ct_locale.call_id', '=', 'calls.id')
|
||||
->select('calls.*')
|
||||
->orderBy('ct_locale.first_locale', $this->sortDirection)
|
||||
->orderBy('calls.created_at', 'desc');
|
||||
}
|
||||
|
||||
$calls = $query->paginate($this->perPage);
|
||||
|
||||
$editCall = $this->editCallId ? Call::find($this->editCallId) : null;
|
||||
|
||||
// For non-admin view, check if the active profile has credits
|
||||
$canPublish = true;
|
||||
if (!$this->isAdminView && $activeProfileType && $activeProfileId) {
|
||||
$canPublish = CallCreditService::profileHasCredits($activeProfileType, (int) $activeProfileId);
|
||||
}
|
||||
|
||||
$availableLocales = \App\Models\CallTranslation::select('locale')
|
||||
->distinct()
|
||||
->orderBy('locale')
|
||||
->pluck('locale')
|
||||
->map(fn ($locale) => ['id' => $locale, 'name' => strtoupper($locale)])
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return view('livewire.calls.manage', [
|
||||
'calls' => $calls,
|
||||
'bulkDisabled' => empty($this->bulkSelected),
|
||||
'editCall' => $editCall,
|
||||
'canPublish' => $canPublish,
|
||||
'availableLocales' => $availableLocales,
|
||||
]);
|
||||
}
|
||||
}
|
||||
142
app/Http/Livewire/Calls/ProfileCalls.php
Normal file
142
app/Http/Livewire/Calls/ProfileCalls.php
Normal file
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire\Calls;
|
||||
|
||||
use App\Models\Call;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\App;
|
||||
|
||||
class ProfileCalls
|
||||
{
|
||||
public static function getCallsForProfile($profile, bool $showPrivate = false): Collection
|
||||
{
|
||||
$calls = Call::with([
|
||||
'tag.contexts.category.translations',
|
||||
'tag.contexts.category.ancestors.translations',
|
||||
'translations',
|
||||
'location.city.translations',
|
||||
'location.country.translations',
|
||||
'callable.locations.city.translations',
|
||||
'callable.locations.division.translations',
|
||||
'callable.locations.country.translations',
|
||||
'loveReactant.reactionCounters',
|
||||
])
|
||||
->where('callable_type', get_class($profile))
|
||||
->where('callable_id', $profile->id)
|
||||
->when(!$showPrivate, fn ($q) => $q->where('is_public', true))
|
||||
->where('is_paused', false)
|
||||
->where('is_suppressed', false)
|
||||
->whereNull('deleted_at')
|
||||
->where(fn ($q) => $q->whereNull('till')->orWhere('till', '>=', now()))
|
||||
->orderBy('till')
|
||||
->get();
|
||||
|
||||
$locale = App::getLocale();
|
||||
|
||||
return $calls->map(function (Call $model) use ($locale) {
|
||||
$translation = $model->translations->firstWhere('locale', $locale)
|
||||
?? $model->translations->first();
|
||||
|
||||
$tag = $model->tag;
|
||||
$tagContext = $tag?->contexts->first();
|
||||
$tagCategory = $tagContext?->category;
|
||||
$tagColor = $tagCategory?->relatedColor ?? 'gray';
|
||||
$tagName = $tag?->translation?->name ?? $tag?->name;
|
||||
|
||||
$locationStr = null;
|
||||
if ($model->location) {
|
||||
$loc = $model->location;
|
||||
$parts = [];
|
||||
if ($loc->city) {
|
||||
$cityName = optional($loc->city->translations->first())->name;
|
||||
if ($cityName) $parts[] = $cityName;
|
||||
}
|
||||
if ($loc->country) {
|
||||
if ($loc->country->code === 'XX') {
|
||||
$parts[] = __('Location not specified');
|
||||
} elseif ($loc->country->code) {
|
||||
$parts[] = strtoupper($loc->country->code);
|
||||
}
|
||||
}
|
||||
$locationStr = $parts ? implode(', ', $parts) : null;
|
||||
}
|
||||
|
||||
$tagCategories = [];
|
||||
if ($tagCategory) {
|
||||
$ancestors = $tagCategory->ancestorsAndSelf()->get()->reverse();
|
||||
foreach ($ancestors as $cat) {
|
||||
$catName = $cat->translations->firstWhere('locale', $locale)?->name
|
||||
?? $cat->translations->first()?->name
|
||||
?? '';
|
||||
if ($catName) {
|
||||
$tagCategories[] = [
|
||||
'name' => $catName,
|
||||
'color' => $cat->relatedColor ?? 'gray',
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $model->id,
|
||||
'model' => Call::class,
|
||||
'title' => $tagName ?? '',
|
||||
'excerpt' => $translation?->content ?? '',
|
||||
'photo' => $model->callable?->profile_photo_url ?? '',
|
||||
'location' => $locationStr,
|
||||
'tag_color' => $tagColor,
|
||||
'tag_categories' => $tagCategories,
|
||||
'callable_name' => $model->callable?->name ?? '',
|
||||
'callable_location' => self::buildCallableLocation($model->callable),
|
||||
'till' => $model->till,
|
||||
'expiry_badge_text' => self::buildExpiryBadgeText($model->till),
|
||||
'like_count' => $model->loveReactant?->reactionCounters->firstWhere('reaction_type_id', 3)?->count ?? 0,
|
||||
'score' => 0,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
public static function buildCallableLocation($callable): ?string
|
||||
{
|
||||
if (!$callable || !method_exists($callable, 'locations')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$firstLoc = $callable->locations->first();
|
||||
if (!$firstLoc) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$cCity = optional($firstLoc->city?->translations->first())->name;
|
||||
$cDivision = optional($firstLoc->division?->translations->first())->name;
|
||||
$cCountry = optional($firstLoc->country?->translations->first())->name;
|
||||
|
||||
return $cCity ?? $cDivision ?? $cCountry ?? null;
|
||||
}
|
||||
|
||||
public static function buildExpiryBadgeText($till): ?string
|
||||
{
|
||||
if (!$till) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$expiryWarningDays = timebank_config('calls.expiry_warning_days', 7);
|
||||
if ($expiryWarningDays === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$daysLeft = (int) now()->startOfDay()->diffInDays(\Carbon\Carbon::parse($till)->startOfDay(), false);
|
||||
|
||||
if ($daysLeft > $expiryWarningDays) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($daysLeft <= 0) {
|
||||
return __('Expires today');
|
||||
} elseif ($daysLeft === 1) {
|
||||
return __('Expires tomorrow');
|
||||
} else {
|
||||
return __('Expires in :days days', ['days' => $daysLeft]);
|
||||
}
|
||||
}
|
||||
}
|
||||
33
app/Http/Livewire/Calls/SendMessageButton.php
Normal file
33
app/Http/Livewire/Calls/SendMessageButton.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire\Calls;
|
||||
|
||||
use Livewire\Component;
|
||||
|
||||
class SendMessageButton extends Component
|
||||
{
|
||||
public $callable;
|
||||
public $call;
|
||||
|
||||
public function mount($callable, $call)
|
||||
{
|
||||
$this->callable = $callable;
|
||||
$this->call = $call;
|
||||
}
|
||||
|
||||
public function createConversation()
|
||||
{
|
||||
if (!$this->callable || $this->callable->isRemoved()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$conversation = getActiveProfile()->createConversationWith($this->callable);
|
||||
|
||||
return redirect()->route('chat', ['conversation' => $conversation->id]);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.calls.send-message-button');
|
||||
}
|
||||
}
|
||||
56
app/Http/Livewire/Categories/ColorPicker.php
Normal file
56
app/Http/Livewire/Categories/ColorPicker.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire\Categories;
|
||||
|
||||
use Livewire\Component;
|
||||
|
||||
class ColorPicker extends Component
|
||||
{
|
||||
public $selectedColor = 'gray';
|
||||
public $label = 'Color';
|
||||
public $required = false;
|
||||
public $previewName = 'Category name';
|
||||
|
||||
protected $listeners = ['colorSelected'];
|
||||
|
||||
public function mount($color = 'gray', $label = 'Color', $required = false, $previewName = 'Category name')
|
||||
{
|
||||
$this->selectedColor = $color;
|
||||
$this->label = $label;
|
||||
$this->required = $required;
|
||||
$this->previewName = $previewName;
|
||||
}
|
||||
|
||||
public function updatedSelectedColor($value)
|
||||
{
|
||||
$this->dispatch('colorUpdated', $value);
|
||||
}
|
||||
|
||||
public function updatePreviewName($name)
|
||||
{
|
||||
$this->previewName = $name ?: __('Category name');
|
||||
}
|
||||
|
||||
public function getAvailableColorsProperty()
|
||||
{
|
||||
$colors = [
|
||||
'slate', 'gray', 'zinc', 'neutral', 'stone',
|
||||
'red', 'orange', 'amber', 'yellow', 'lime',
|
||||
'green', 'emerald', 'teal', 'cyan', 'sky',
|
||||
'blue', 'indigo', 'violet', 'purple', 'fuchsia',
|
||||
'pink', 'rose'
|
||||
];
|
||||
|
||||
return collect($colors)->map(function ($color) {
|
||||
return [
|
||||
'value' => $color,
|
||||
'label' => ucfirst($color)
|
||||
];
|
||||
})->toArray();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.categories.color-picker');
|
||||
}
|
||||
}
|
||||
13
app/Http/Livewire/Categories/Create.php
Normal file
13
app/Http/Livewire/Categories/Create.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire\Categories;
|
||||
|
||||
use Livewire\Component;
|
||||
|
||||
class Create extends Component
|
||||
{
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.categories.create');
|
||||
}
|
||||
}
|
||||
1069
app/Http/Livewire/Categories/Manage.php
Normal file
1069
app/Http/Livewire/Categories/Manage.php
Normal file
File diff suppressed because it is too large
Load Diff
58
app/Http/Livewire/CategorySelectbox.php
Normal file
58
app/Http/Livewire/CategorySelectbox.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire;
|
||||
|
||||
use App\Models\Category;
|
||||
use Livewire\Component;
|
||||
|
||||
class CategorySelectbox extends Component
|
||||
{
|
||||
public $categoryOptions = [];
|
||||
public $categorySelected;
|
||||
|
||||
/**
|
||||
* Prepare the component.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function mount($categorySelected)
|
||||
{
|
||||
$this->categoryOptions = Category::with('translations')
|
||||
->whereNot('type', 'App\Models\Tag')
|
||||
->get()
|
||||
->sortBy(function ($category) {
|
||||
// Use the translated name (accessor) for sorting, checking if translation exists
|
||||
return $category->translation->name ?? __('Untitled category');
|
||||
})
|
||||
->mapWithKeys(function ($category) {
|
||||
return [
|
||||
$category->id => [
|
||||
'category_id' => $category->id,
|
||||
// Check if translation exists before trying to access its name property
|
||||
'name' => $category->translation ? $category->translation->name : __('Category') . ' ' . __('id') . ' ' . $category->id . ' ' . $category->type,
|
||||
]
|
||||
];
|
||||
})
|
||||
->toArray();
|
||||
|
||||
$this->categorySelected = $categorySelected;
|
||||
$this->updated();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* When component is updated
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function updated()
|
||||
{
|
||||
$this->dispatch('categorySelected', $this->categorySelected);
|
||||
}
|
||||
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.category-selectbox');
|
||||
}
|
||||
}
|
||||
171
app/Http/Livewire/ContactForm.php
Normal file
171
app/Http/Livewire/ContactForm.php
Normal file
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire;
|
||||
|
||||
use Livewire\Component;
|
||||
use App\Mail\ContactFormMailable;
|
||||
use App\Mail\ContactFormCopyMailable;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use WireUi\Traits\WireUiActions;
|
||||
|
||||
/**
|
||||
* Generic contact form component with context-aware content
|
||||
*
|
||||
* Usage examples:
|
||||
* - @livewire('contact-form', ['context' => 'contact'])
|
||||
* - @livewire('contact-form', ['context' => 'report-issue'])
|
||||
* - @livewire('contact-form', ['context' => 'report-error', 'url' => request()->fullUrl()])
|
||||
* - @livewire('contact-form', ['context' => 'delete-profile'])
|
||||
*/
|
||||
class ContactForm extends Component
|
||||
{
|
||||
use WireUiActions;
|
||||
|
||||
// Form context: 'contact', 'report-issue', 'report-error', 'delete-profile'
|
||||
public $context = 'contact';
|
||||
|
||||
// Form fields
|
||||
public $name;
|
||||
public $full_name;
|
||||
public $email;
|
||||
public $subject;
|
||||
public $message;
|
||||
public $url; // For error/issue reporting - URL where issue occurred
|
||||
|
||||
// UI state
|
||||
public $showSuccessMessage = false;
|
||||
|
||||
protected $rules = [
|
||||
'name' => 'required|string|max:255',
|
||||
'full_name' => 'nullable|string|max:255',
|
||||
'email' => 'required|email|max:255',
|
||||
'subject' => 'nullable|string|max:255',
|
||||
'message' => 'required|string|min:10|max:2000',
|
||||
'url' => 'nullable|url|max:500',
|
||||
];
|
||||
|
||||
public function mount($context = 'contact', $url = null, $subject = null, $message = null)
|
||||
{
|
||||
$this->context = $context;
|
||||
$this->url = $url;
|
||||
|
||||
// Pre-fill subject/message from params or query string
|
||||
$this->subject = $subject ?? request('subject');
|
||||
$this->message = $message ?? request('message');
|
||||
|
||||
// Pre-fill user data if authenticated
|
||||
if (auth()->check()) {
|
||||
$profile = getActiveProfile();
|
||||
if ($profile) {
|
||||
$this->name = $profile->name ?? '';
|
||||
$this->full_name = $profile->full_name ?? $profile->name ?? '';
|
||||
$this->email = $profile->email ?? '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function updated($propertyName)
|
||||
{
|
||||
$this->validateOnly($propertyName);
|
||||
}
|
||||
|
||||
public function submitForm()
|
||||
{
|
||||
$data = $this->validate();
|
||||
$data['context'] = $this->context;
|
||||
|
||||
// Ensure full_name is set, fallback to name if not
|
||||
if (empty($data['full_name'])) {
|
||||
$data['full_name'] = $data['name'];
|
||||
}
|
||||
|
||||
// Add authentication and profile data
|
||||
$data['is_authenticated'] = auth()->check();
|
||||
if ($data['is_authenticated']) {
|
||||
$profile = getActiveProfile();
|
||||
if ($profile) {
|
||||
$data['profile_url'] = $profile->profile_url ?? null;
|
||||
$data['profile_type'] = class_basename(get_class($profile));
|
||||
$data['profile_lang_preference'] = $profile->lang_preference ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
// Add browser locale (current app locale) for non-authenticated users
|
||||
if (!$data['is_authenticated']) {
|
||||
$data['browser_locale'] = app()->getLocale();
|
||||
}
|
||||
|
||||
try {
|
||||
// Get recipient email from config or default
|
||||
$recipientEmail = config('mail.from.address', 'info@timebank.cc');
|
||||
|
||||
\Log::info('ContactForm: Queueing emails', [
|
||||
'context' => $this->context,
|
||||
'recipient' => $recipientEmail,
|
||||
'submitter' => $data['email'],
|
||||
]);
|
||||
|
||||
// Queue email to the recipient on 'emails' queue
|
||||
Mail::to($recipientEmail)->queue((new ContactFormMailable($data))->onQueue('emails'));
|
||||
\Log::info('ContactForm: Email queued to recipient');
|
||||
|
||||
// Queue a copy to the submitter on 'emails' queue
|
||||
Mail::to($data['email'])->queue((new ContactFormCopyMailable($data))->onQueue('emails'));
|
||||
\Log::info('ContactForm: Copy queued to submitter');
|
||||
|
||||
// Show success notification
|
||||
$this->notification()->success(
|
||||
title: __('Message sent'),
|
||||
description: __('We received your message successfully and will get back to you shortly!')
|
||||
);
|
||||
|
||||
$this->showSuccessMessage = true;
|
||||
$this->resetForm();
|
||||
|
||||
// Dispatch event for parent components
|
||||
$this->dispatch('contact-form-submitted');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->notification()->error(
|
||||
title: __('Error'),
|
||||
description: __('Sorry, there was an error sending your message. Please try again later.')
|
||||
);
|
||||
|
||||
\Log::error('Contact form submission failed', [
|
||||
'error' => $e->getMessage(),
|
||||
'context' => $this->context,
|
||||
'email' => $this->email
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function resetForm()
|
||||
{
|
||||
$this->message = '';
|
||||
$this->subject = '';
|
||||
$this->url = '';
|
||||
|
||||
// Don't reset name/email if user is authenticated
|
||||
if (!auth()->check()) {
|
||||
$this->name = '';
|
||||
$this->full_name = '';
|
||||
$this->email = '';
|
||||
}
|
||||
}
|
||||
|
||||
public function getSubmitButtonTextProperty()
|
||||
{
|
||||
return match($this->context) {
|
||||
'report-issue' => __('Submit report'),
|
||||
'report-error' => __('Report error'),
|
||||
'delete-profile' => __('Request deletion'),
|
||||
'contact' => __('Send message'),
|
||||
default => __('Submit'),
|
||||
};
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.contact-form');
|
||||
}
|
||||
}
|
||||
667
app/Http/Livewire/Contacts.php
Normal file
667
app/Http/Livewire/Contacts.php
Normal file
@@ -0,0 +1,667 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire;
|
||||
|
||||
use App\Models\Transaction;
|
||||
use App\Models\User;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Bank;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithPagination;
|
||||
use Namu\WireChat\Models\Conversation;
|
||||
use Namu\WireChat\Models\Participant;
|
||||
use Namu\WireChat\Enums\ConversationType;
|
||||
|
||||
class Contacts extends Component
|
||||
{
|
||||
use WithPagination;
|
||||
|
||||
public $showSearchSection = false;
|
||||
public $search;
|
||||
public $searchInput = ''; // Temporary input for search field
|
||||
public $filterType = []; // Array of selected filter types
|
||||
public $filterTypeInput = []; // Temporary input for filter multiselect
|
||||
public $perPage = 15;
|
||||
public $sortField = 'last_interaction';
|
||||
public $sortAsc = false;
|
||||
|
||||
/**
|
||||
* Sort by a specific field.
|
||||
*/
|
||||
public function sortBy($field)
|
||||
{
|
||||
if ($this->sortField === $field) {
|
||||
$this->sortAsc = !$this->sortAsc;
|
||||
} else {
|
||||
$this->sortField = $field;
|
||||
$this->sortAsc = true;
|
||||
}
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
protected $rules = [
|
||||
'search' => 'nullable|string|min:2|max:100',
|
||||
];
|
||||
|
||||
protected $messages = [
|
||||
'search.min' => 'Search must be at least 2 characters.',
|
||||
'search.max' => 'Search cannot exceed 100 characters.',
|
||||
];
|
||||
|
||||
|
||||
/**
|
||||
* Apply search and filter when button is clicked.
|
||||
*/
|
||||
public function applySearch()
|
||||
{
|
||||
// Validate search input if provided
|
||||
if (!empty($this->searchInput) && strlen($this->searchInput) < 2) {
|
||||
$this->addError('search', 'Search must be at least 2 characters.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!empty($this->searchInput) && strlen($this->searchInput) > 100) {
|
||||
$this->addError('search', 'Search cannot exceed 100 characters.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Apply the input values to the actual search properties
|
||||
$this->search = $this->searchInput;
|
||||
$this->filterType = $this->filterTypeInput;
|
||||
|
||||
// Reset to first page when searching
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all contacts (profiles) the active profile has interacted with.
|
||||
* Includes interactions from:
|
||||
* - Laravel-love reactions (bookmarks, stars)
|
||||
* - Transactions (sent to or received from)
|
||||
* - WireChat private conversations
|
||||
*
|
||||
* @return \Illuminate\Pagination\LengthAwarePaginator
|
||||
*/
|
||||
public function getContacts()
|
||||
{
|
||||
$activeProfile = getActiveProfile();
|
||||
|
||||
if (!$activeProfile) {
|
||||
// Return empty paginator instead of null
|
||||
return new \Illuminate\Pagination\LengthAwarePaginator(
|
||||
collect([]),
|
||||
0,
|
||||
$this->perPage,
|
||||
1,
|
||||
['path' => request()->url(), 'pageName' => 'page']
|
||||
);
|
||||
}
|
||||
|
||||
// CRITICAL SECURITY: Validate user has ownership/access to this profile
|
||||
\App\Helpers\ProfileAuthorizationHelper::authorize($activeProfile);
|
||||
|
||||
// Initialize contacts collection
|
||||
$contactsData = collect();
|
||||
|
||||
// Get the reacter_id and reactant_id for the active profile
|
||||
$reacterId = $activeProfile->love_reacter_id;
|
||||
$reactantId = $activeProfile->love_reactant_id;
|
||||
|
||||
// If no filters selected, show all
|
||||
$showAll = empty($this->filterType);
|
||||
|
||||
// 1. Get profiles the active profile has reacted to (stars, bookmarks)
|
||||
if ($showAll || in_array('stars', $this->filterType) || in_array('bookmarks', $this->filterType)) {
|
||||
if ($reacterId) {
|
||||
$reactedProfiles = $this->getReactedProfiles($reacterId);
|
||||
|
||||
// Filter by specific reaction types if selected
|
||||
if (!$showAll && (in_array('stars', $this->filterType) || in_array('bookmarks', $this->filterType))) {
|
||||
$reactedProfiles = $reactedProfiles->filter(function ($profile) {
|
||||
if (in_array('stars', $this->filterType) && $profile['interaction_type'] === 'star') {
|
||||
return true;
|
||||
}
|
||||
if (in_array('bookmarks', $this->filterType) && $profile['interaction_type'] === 'bookmark') {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
$contactsData = $contactsData->merge($reactedProfiles);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Get profiles that have transacted with the active profile
|
||||
if ($showAll || in_array('transactions', $this->filterType)) {
|
||||
$transactionProfiles = $this->getTransactionProfiles($activeProfile);
|
||||
$contactsData = $contactsData->merge($transactionProfiles);
|
||||
}
|
||||
|
||||
// 3. Get profiles from private WireChat conversations
|
||||
if ($showAll || in_array('conversations', $this->filterType)) {
|
||||
$conversationProfiles = $this->getConversationProfiles($activeProfile);
|
||||
$contactsData = $contactsData->merge($conversationProfiles);
|
||||
}
|
||||
|
||||
// Group by profile and merge interaction data
|
||||
$contacts = $contactsData->groupBy('profile_key')->map(function ($group) {
|
||||
$first = $group->first();
|
||||
|
||||
// Construct profile path like in SingleTransactionTable
|
||||
$profileTypeLower = strtolower($first['profile_type_name']);
|
||||
$profilePath = URL::to('/') . '/' . __($profileTypeLower) . '/' . $first['profile_id'];
|
||||
|
||||
return [
|
||||
'profile_id' => $first['profile_id'],
|
||||
'profile_type' => $first['profile_type'],
|
||||
'profile_type_name' => $first['profile_type_name'],
|
||||
'name' => $first['name'],
|
||||
'full_name' => $first['full_name'],
|
||||
'location' => $first['location'],
|
||||
'profile_photo' => $first['profile_photo'],
|
||||
'profile_path' => $profilePath,
|
||||
'has_star' => $group->contains('interaction_type', 'star'),
|
||||
'has_bookmark' => $group->contains('interaction_type', 'bookmark'),
|
||||
'has_transaction' => $group->contains('interaction_type', 'transaction'),
|
||||
'has_conversation' => $group->contains('interaction_type', 'conversation'),
|
||||
'last_interaction' => $group->max('last_interaction'),
|
||||
'star_count' => $group->where('interaction_type', 'star')->sum('count'),
|
||||
'bookmark_count' => $group->where('interaction_type', 'bookmark')->sum('count'),
|
||||
'transaction_count' => $group->where('interaction_type', 'transaction')->sum('count'),
|
||||
'message_count' => $group->where('interaction_type', 'conversation')->sum('count'),
|
||||
];
|
||||
})->values();
|
||||
|
||||
// Apply search filter
|
||||
if (!empty($this->search) && strlen(trim($this->search)) >= 2) {
|
||||
$search = strtolower(trim($this->search));
|
||||
$contacts = $contacts->filter(function ($contact) use ($search) {
|
||||
$name = strtolower($contact['name'] ?? '');
|
||||
$fullName = strtolower($contact['full_name'] ?? '');
|
||||
$location = strtolower($contact['location'] ?? '');
|
||||
|
||||
return str_contains($name, $search) ||
|
||||
str_contains($fullName, $search) ||
|
||||
str_contains($location, $search);
|
||||
})->values();
|
||||
}
|
||||
|
||||
// Sort contacts
|
||||
$contacts = $this->sortContacts($contacts);
|
||||
|
||||
// Paginate manually
|
||||
$currentPage = $this->paginators['page'] ?? 1;
|
||||
$total = $contacts->count();
|
||||
$items = $contacts->forPage($currentPage, $this->perPage);
|
||||
|
||||
return new \Illuminate\Pagination\LengthAwarePaginator(
|
||||
$items,
|
||||
$total,
|
||||
$this->perPage,
|
||||
$currentPage,
|
||||
['path' => request()->url(), 'pageName' => 'page']
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get profiles the active profile has reacted to.
|
||||
*/
|
||||
private function getReactedProfiles($reacterId)
|
||||
{
|
||||
// Get all reactions by this reacter, grouped by reactant type
|
||||
$reactions = DB::table('love_reactions')
|
||||
->join('love_reactants', 'love_reactions.reactant_id', '=', 'love_reactants.id')
|
||||
->where('love_reactions.reacter_id', $reacterId)
|
||||
->select(
|
||||
'love_reactants.type as reactant_type',
|
||||
DB::raw('CAST(SUBSTRING_INDEX(love_reactants.type, "\\\\", -1) AS CHAR) as reactant_model')
|
||||
)
|
||||
->groupBy('love_reactants.type')
|
||||
->get();
|
||||
|
||||
$profiles = collect();
|
||||
|
||||
foreach ($reactions as $reaction) {
|
||||
// Only process User, Organization, and Bank models
|
||||
if (!in_array($reaction->reactant_model, ['User', 'Organization', 'Bank'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$modelClass = "App\\Models\\{$reaction->reactant_model}";
|
||||
|
||||
// Get all profiles of this type that were reacted to, with reaction type breakdown
|
||||
$reactedToProfiles = DB::table('love_reactions')
|
||||
->join('love_reactants', 'love_reactions.reactant_id', '=', 'love_reactants.id')
|
||||
->join(
|
||||
DB::raw("(SELECT id, love_reactant_id, name,
|
||||
full_name,
|
||||
profile_photo_path
|
||||
FROM " . strtolower($reaction->reactant_model) . "s) as profiles"),
|
||||
'love_reactants.id',
|
||||
'=',
|
||||
'profiles.love_reactant_id'
|
||||
)
|
||||
->where('love_reactions.reacter_id', $reacterId)
|
||||
->where('love_reactants.type', $reaction->reactant_type)
|
||||
->select(
|
||||
'profiles.id as profile_id',
|
||||
'profiles.name',
|
||||
'profiles.full_name',
|
||||
'profiles.profile_photo_path',
|
||||
DB::raw("'{$modelClass}' as profile_type"),
|
||||
DB::raw("'{$reaction->reactant_model}' as profile_type_name"),
|
||||
'love_reactions.reaction_type_id',
|
||||
DB::raw('MAX(love_reactions.created_at) as last_interaction'),
|
||||
DB::raw('COUNT(*) as count')
|
||||
)
|
||||
->groupBy('profiles.id', 'profiles.name', 'profiles.full_name', 'profiles.profile_photo_path', 'love_reactions.reaction_type_id')
|
||||
->get();
|
||||
|
||||
// Batch load locations for all profiles of this type
|
||||
$profileIds = $reactedToProfiles->pluck('profile_id');
|
||||
$locations = $this->batchLoadLocations($modelClass, $profileIds);
|
||||
|
||||
foreach ($reactedToProfiles as $profile) {
|
||||
// Get location from batch-loaded data
|
||||
$location = $locations[$profile->profile_id] ?? '';
|
||||
|
||||
// Determine reaction type (1 = Star, 2 = Bookmark)
|
||||
$interactionType = $profile->reaction_type_id == 1 ? 'star' : ($profile->reaction_type_id == 2 ? 'bookmark' : 'reaction');
|
||||
|
||||
$profiles->push([
|
||||
'profile_key' => $modelClass . '_' . $profile->profile_id,
|
||||
'profile_id' => $profile->profile_id,
|
||||
'profile_type' => $profile->profile_type,
|
||||
'profile_type_name' => $profile->profile_type_name,
|
||||
'name' => $profile->name,
|
||||
'full_name' => $profile->full_name,
|
||||
'location' => $location,
|
||||
'profile_photo' => $profile->profile_photo_path,
|
||||
'interaction_type' => $interactionType,
|
||||
'last_interaction' => $profile->last_interaction,
|
||||
'count' => $profile->count,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return $profiles;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get profiles that have transacted with the active profile.
|
||||
*/
|
||||
private function getTransactionProfiles($activeProfile)
|
||||
{
|
||||
// Get all accounts belonging to the active profile
|
||||
$accountIds = DB::table('accounts')
|
||||
->where('accountable_type', get_class($activeProfile))
|
||||
->where('accountable_id', $activeProfile->id)
|
||||
->pluck('id');
|
||||
|
||||
if ($accountIds->isEmpty()) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
// Get all transactions involving these accounts
|
||||
$transactions = DB::table('transactions')
|
||||
->whereIn('from_account_id', $accountIds)
|
||||
->orWhereIn('to_account_id', $accountIds)
|
||||
->select(
|
||||
'from_account_id',
|
||||
'to_account_id',
|
||||
DB::raw('MAX(created_at) as last_interaction'),
|
||||
DB::raw('COUNT(*) as count')
|
||||
)
|
||||
->groupBy('from_account_id', 'to_account_id')
|
||||
->get();
|
||||
|
||||
// Group counter accounts by type for batch loading
|
||||
$counterAccountsByType = collect();
|
||||
|
||||
foreach ($transactions as $transaction) {
|
||||
// Determine the counter account (the other party in the transaction)
|
||||
$counterAccountId = null;
|
||||
if ($accountIds->contains($transaction->from_account_id) && !$accountIds->contains($transaction->to_account_id)) {
|
||||
$counterAccountId = $transaction->to_account_id;
|
||||
} elseif ($accountIds->contains($transaction->to_account_id) && !$accountIds->contains($transaction->from_account_id)) {
|
||||
$counterAccountId = $transaction->from_account_id;
|
||||
}
|
||||
|
||||
if ($counterAccountId) {
|
||||
$transaction->counter_account_id = $counterAccountId;
|
||||
}
|
||||
}
|
||||
|
||||
// Get all counter account details in one query
|
||||
$counterAccountIds = $transactions->pluck('counter_account_id')->filter()->unique();
|
||||
$accounts = DB::table('accounts')
|
||||
->whereIn('id', $counterAccountIds)
|
||||
->select('id', 'accountable_type', 'accountable_id')
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
// Group profile IDs by type
|
||||
$profileIdsByType = [];
|
||||
foreach ($accounts as $account) {
|
||||
$profileTypeName = class_basename($account->accountable_type);
|
||||
if (!isset($profileIdsByType[$profileTypeName])) {
|
||||
$profileIdsByType[$profileTypeName] = [];
|
||||
}
|
||||
$profileIdsByType[$profileTypeName][] = $account->accountable_id;
|
||||
}
|
||||
|
||||
// Batch load profile data and locations for each type
|
||||
$profileDataByType = [];
|
||||
$locationsByType = [];
|
||||
foreach ($profileIdsByType as $typeName => $ids) {
|
||||
$tableName = strtolower($typeName) . 's';
|
||||
$modelClass = "App\\Models\\{$typeName}";
|
||||
|
||||
// Load profile data
|
||||
$profileDataByType[$typeName] = DB::table($tableName)
|
||||
->whereIn('id', $ids)
|
||||
->select('id', 'name', 'full_name', 'profile_photo_path')
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
// Batch load locations
|
||||
$locationsByType[$typeName] = $this->batchLoadLocations($modelClass, $ids);
|
||||
}
|
||||
|
||||
// Build final profiles collection
|
||||
$profiles = collect();
|
||||
foreach ($transactions as $transaction) {
|
||||
if (!isset($transaction->counter_account_id)) {
|
||||
continue; // Skip self-transactions
|
||||
}
|
||||
|
||||
$account = $accounts->get($transaction->counter_account_id);
|
||||
if (!$account) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$profileModel = $account->accountable_type;
|
||||
$profileId = $account->accountable_id;
|
||||
$profileTypeName = class_basename($profileModel);
|
||||
|
||||
$profile = $profileDataByType[$profileTypeName][$profileId] ?? null;
|
||||
if (!$profile) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$location = $locationsByType[$profileTypeName][$profileId] ?? '';
|
||||
$profileKey = $profileModel . '_' . $profileId;
|
||||
|
||||
$profiles->push([
|
||||
'profile_key' => $profileKey,
|
||||
'profile_id' => $profileId,
|
||||
'profile_type' => $profileModel,
|
||||
'profile_type_name' => $profileTypeName,
|
||||
'name' => $profile->name,
|
||||
'full_name' => $profile->full_name,
|
||||
'location' => $location,
|
||||
'profile_photo' => $profile->profile_photo_path,
|
||||
'interaction_type' => 'transaction',
|
||||
'last_interaction' => $transaction->last_interaction,
|
||||
'count' => $transaction->count,
|
||||
]);
|
||||
}
|
||||
|
||||
return $profiles;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get profiles from private WireChat conversations.
|
||||
*/
|
||||
private function getConversationProfiles($activeProfile)
|
||||
{
|
||||
// Get all private conversations the active profile is participating in
|
||||
$participantType = get_class($activeProfile);
|
||||
$participantId = $activeProfile->id;
|
||||
|
||||
// Get participant record for active profile
|
||||
$myParticipants = DB::table('wirechat_participants')
|
||||
->join('wirechat_conversations', 'wirechat_participants.conversation_id', '=', 'wirechat_conversations.id')
|
||||
->where('wirechat_participants.participantable_type', $participantType)
|
||||
->where('wirechat_participants.participantable_id', $participantId)
|
||||
->where('wirechat_conversations.type', ConversationType::PRIVATE->value)
|
||||
->whereNull('wirechat_participants.deleted_at')
|
||||
->select(
|
||||
'wirechat_participants.conversation_id',
|
||||
'wirechat_participants.last_active_at'
|
||||
)
|
||||
->get();
|
||||
|
||||
if ($myParticipants->isEmpty()) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
$conversationIds = $myParticipants->pluck('conversation_id');
|
||||
|
||||
// Get all other participants in one query
|
||||
$otherParticipants = DB::table('wirechat_participants')
|
||||
->whereIn('conversation_id', $conversationIds)
|
||||
->where(function ($query) use ($participantType, $participantId) {
|
||||
$query->where('participantable_type', '!=', $participantType)
|
||||
->orWhere('participantable_id', '!=', $participantId);
|
||||
})
|
||||
->whereNull('deleted_at')
|
||||
->get()
|
||||
->keyBy('conversation_id');
|
||||
|
||||
// Get message counts for all conversations in one query
|
||||
$messageCounts = DB::table('wirechat_messages')
|
||||
->whereIn('conversation_id', $conversationIds)
|
||||
->whereNull('deleted_at')
|
||||
->select(
|
||||
'conversation_id',
|
||||
DB::raw('COUNT(DISTINCT DATE(created_at)) as day_count')
|
||||
)
|
||||
->groupBy('conversation_id')
|
||||
->get()
|
||||
->keyBy('conversation_id');
|
||||
|
||||
// Get last messages for all conversations in one query
|
||||
$lastMessages = DB::table('wirechat_messages as wm1')
|
||||
->whereIn('wm1.conversation_id', $conversationIds)
|
||||
->whereNull('wm1.deleted_at')
|
||||
->whereRaw('wm1.created_at = (SELECT MAX(wm2.created_at) FROM wirechat_messages wm2 WHERE wm2.conversation_id = wm1.conversation_id AND wm2.deleted_at IS NULL)')
|
||||
->select('wm1.conversation_id', 'wm1.created_at')
|
||||
->get()
|
||||
->keyBy('conversation_id');
|
||||
|
||||
// Group profile IDs by type
|
||||
$profileIdsByType = [];
|
||||
foreach ($otherParticipants as $participant) {
|
||||
$profileTypeName = class_basename($participant->participantable_type);
|
||||
if (!isset($profileIdsByType[$profileTypeName])) {
|
||||
$profileIdsByType[$profileTypeName] = [];
|
||||
}
|
||||
$profileIdsByType[$profileTypeName][] = $participant->participantable_id;
|
||||
}
|
||||
|
||||
// Batch load profile data and locations for each type
|
||||
$profileDataByType = [];
|
||||
$locationsByType = [];
|
||||
foreach ($profileIdsByType as $typeName => $ids) {
|
||||
$tableName = strtolower($typeName) . 's';
|
||||
$modelClass = "App\\Models\\{$typeName}";
|
||||
|
||||
// Load profile data
|
||||
$profileDataByType[$typeName] = DB::table($tableName)
|
||||
->whereIn('id', $ids)
|
||||
->select('id', 'name', 'full_name', 'profile_photo_path')
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
// Batch load locations
|
||||
$locationsByType[$typeName] = $this->batchLoadLocations($modelClass, $ids);
|
||||
}
|
||||
|
||||
// Build final profiles collection
|
||||
$profiles = collect();
|
||||
foreach ($myParticipants as $myParticipant) {
|
||||
$otherParticipant = $otherParticipants->get($myParticipant->conversation_id);
|
||||
if (!$otherParticipant) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$profileModel = $otherParticipant->participantable_type;
|
||||
$profileId = $otherParticipant->participantable_id;
|
||||
$profileTypeName = class_basename($profileModel);
|
||||
|
||||
$profile = $profileDataByType[$profileTypeName][$profileId] ?? null;
|
||||
if (!$profile) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$messageCount = $messageCounts->get($myParticipant->conversation_id)->day_count ?? 0;
|
||||
$lastMessage = $lastMessages->get($myParticipant->conversation_id);
|
||||
$location = $locationsByType[$profileTypeName][$profileId] ?? '';
|
||||
$profileKey = $profileModel . '_' . $profileId;
|
||||
|
||||
$profiles->push([
|
||||
'profile_key' => $profileKey,
|
||||
'profile_id' => $profileId,
|
||||
'profile_type' => $profileModel,
|
||||
'profile_type_name' => $profileTypeName,
|
||||
'name' => $profile->name,
|
||||
'full_name' => $profile->full_name,
|
||||
'location' => $location,
|
||||
'profile_photo' => $profile->profile_photo_path,
|
||||
'interaction_type' => 'conversation',
|
||||
'last_interaction' => $lastMessage ? $lastMessage->created_at : $myParticipant->last_active_at,
|
||||
'count' => $messageCount,
|
||||
]);
|
||||
}
|
||||
|
||||
return $profiles;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Batch load locations for multiple profiles of the same type.
|
||||
* This replaces the N+1 query problem in getProfileLocation().
|
||||
*
|
||||
* @param string $modelClass The model class (e.g., 'App\Models\User')
|
||||
* @param array|\Illuminate\Support\Collection $profileIds Array of profile IDs
|
||||
* @return array Associative array of profile_id => location_name
|
||||
*/
|
||||
private function batchLoadLocations($modelClass, $profileIds)
|
||||
{
|
||||
if (empty($profileIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Ensure it's an array
|
||||
if ($profileIds instanceof \Illuminate\Support\Collection) {
|
||||
$profileIds = $profileIds->toArray();
|
||||
}
|
||||
|
||||
// Load all profiles with their location relationships
|
||||
$profiles = $modelClass::with([
|
||||
'locations.city.translations',
|
||||
'locations.district.translations',
|
||||
'locations.division.translations',
|
||||
'locations.country.translations'
|
||||
])
|
||||
->whereIn('id', $profileIds)
|
||||
->get();
|
||||
|
||||
// Build location map
|
||||
$locationMap = [];
|
||||
foreach ($profiles as $profile) {
|
||||
if (method_exists($profile, 'getLocationFirst')) {
|
||||
$locationData = $profile->getLocationFirst(false);
|
||||
$locationMap[$profile->id] = $locationData['name'] ?? $locationData['name_short'] ?? '';
|
||||
} else {
|
||||
$locationMap[$profile->id] = '';
|
||||
}
|
||||
}
|
||||
|
||||
return $locationMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get location for a profile (deprecated, use batchLoadLocations instead).
|
||||
* Kept for backwards compatibility.
|
||||
*/
|
||||
private function getProfileLocation($modelClass, $profileId)
|
||||
{
|
||||
$locations = $this->batchLoadLocations($modelClass, [$profileId]);
|
||||
return $locations[$profileId] ?? '';
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Sort contacts based on sort field and direction.
|
||||
*/
|
||||
private function sortContacts($contacts)
|
||||
{
|
||||
$sortField = $this->sortField;
|
||||
$sortAsc = $this->sortAsc;
|
||||
|
||||
return $contacts->sort(function ($a, $b) use ($sortField, $sortAsc) {
|
||||
$aVal = $a[$sortField] ?? '';
|
||||
$bVal = $b[$sortField] ?? '';
|
||||
|
||||
if ($sortField === 'last_interaction') {
|
||||
$comparison = strtotime($bVal) <=> strtotime($aVal); // Default: most recent first
|
||||
return $sortAsc ? -$comparison : $comparison;
|
||||
}
|
||||
|
||||
// For count fields, use numeric comparison
|
||||
if (in_array($sortField, ['transaction_count', 'message_count'])) {
|
||||
$comparison = ($aVal ?? 0) <=> ($bVal ?? 0);
|
||||
return $sortAsc ? $comparison : -$comparison;
|
||||
}
|
||||
|
||||
// For boolean fields
|
||||
if (in_array($sortField, ['has_star', 'has_bookmark'])) {
|
||||
$comparison = ($aVal ? 1 : 0) <=> ($bVal ? 1 : 0);
|
||||
return $sortAsc ? $comparison : -$comparison;
|
||||
}
|
||||
|
||||
// String comparison for name, location, etc.
|
||||
$comparison = strcasecmp($aVal, $bVal);
|
||||
return $sortAsc ? $comparison : -$comparison;
|
||||
})->values();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Reset search and filters.
|
||||
*/
|
||||
public function resetSearch()
|
||||
{
|
||||
$this->resetPage();
|
||||
$this->showSearchSection = false;
|
||||
$this->search = null;
|
||||
$this->searchInput = '';
|
||||
$this->filterType = [];
|
||||
$this->filterTypeInput = [];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Scroll to top when page changes.
|
||||
*/
|
||||
public function updatedPage()
|
||||
{
|
||||
$this->dispatch('scroll-to-top');
|
||||
}
|
||||
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.contacts.show', [
|
||||
'contacts' => $this->getContacts(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
47
app/Http/Livewire/Description.php
Normal file
47
app/Http/Livewire/Description.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire;
|
||||
|
||||
use Livewire\Component;
|
||||
|
||||
class Description extends Component
|
||||
{
|
||||
public $description;
|
||||
public $requiredError = false;
|
||||
|
||||
protected $listeners = ['resetForm'];
|
||||
|
||||
|
||||
/**
|
||||
* Extra check if field is empty on blur textarea
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function checkRequired()
|
||||
{
|
||||
|
||||
$this->dispatch('description', $this->description);
|
||||
|
||||
if ($this->description === null || '')
|
||||
{
|
||||
$this->requiredError = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public function resetForm()
|
||||
{
|
||||
$this->description = null;
|
||||
}
|
||||
|
||||
public function updated()
|
||||
{
|
||||
$this->dispatch('description', $this->description);
|
||||
}
|
||||
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.description');
|
||||
}
|
||||
}
|
||||
110
app/Http/Livewire/EventCalendarPost.php
Normal file
110
app/Http/Livewire/EventCalendarPost.php
Normal file
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire;
|
||||
|
||||
use App\Models\Language;
|
||||
use App\Models\Post;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Livewire\Component;
|
||||
|
||||
class EventCalendarPost extends Component
|
||||
{
|
||||
public $type;
|
||||
public int $limit;
|
||||
public bool $hideAuthor = false;
|
||||
public bool $showFallback = false;
|
||||
public bool $fallbackExists = false; // Add this new property
|
||||
|
||||
public function mount($type, $limit = 1, $hideAuthor = false)
|
||||
{
|
||||
$this->type = $type;
|
||||
if ($limit) {
|
||||
$this->limit = $limit;
|
||||
}
|
||||
$this->hideAuthor = $hideAuthor ?? false;
|
||||
}
|
||||
|
||||
public function loadFallback()
|
||||
{
|
||||
$this->showFallback = true;
|
||||
}
|
||||
|
||||
public function getPosts($locale)
|
||||
{
|
||||
return Post::with([
|
||||
'category',
|
||||
'images' => function ($query) {
|
||||
$query->select('images.id', 'caption', 'path');
|
||||
},
|
||||
// Eager load meeting with related data for event display
|
||||
'meeting' => function ($query) {
|
||||
$query->with(['meetingable', 'transactionType']);
|
||||
},
|
||||
// Eager load all active translations for the given locale, ordered by most recent
|
||||
'translations' => function ($query) use ($locale) {
|
||||
$query->where('locale', 'like', $locale . '%') // Use LIKE for locale flexibility
|
||||
->whereDate('from', '<=', now())
|
||||
->where(function ($query) {
|
||||
$query->whereDate('till', '>', now())->orWhereNull('till');
|
||||
})
|
||||
->orderBy('updated_at', 'desc'); // No limit here for robustness
|
||||
}
|
||||
])
|
||||
->whereHas('category', function ($query) {
|
||||
$query->where('type', $this->type);
|
||||
})
|
||||
// Filter for posts that have at least one active translation for the given locale
|
||||
->whereHas('translations', function ($query) use ($locale) {
|
||||
$query->where('locale', 'like', $locale . '%') // Use LIKE for locale flexibility
|
||||
->whereDate('from', '<=', now())
|
||||
->where(function ($query) {
|
||||
$query->whereDate('till', '>', now())->orWhereNull('till');
|
||||
});
|
||||
})
|
||||
->orderBy('created_at', 'desc')
|
||||
->limit($this->limit)
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* The render method prepares all data needed by the view.
|
||||
*/
|
||||
public function render()
|
||||
{
|
||||
$locale = $this->showFallback ? config('app.fallback_locale') : App::getLocale();
|
||||
$posts = $this->getPosts($locale);
|
||||
|
||||
// If no posts are found and we're not already showing the fallback,
|
||||
// perform an efficient check to see if any fallback content exists.
|
||||
if ($posts->isEmpty() && !$this->showFallback) {
|
||||
$fallbackLocale = config('app.fallback_locale');
|
||||
if (trim(App::getLocale()) !== trim($fallbackLocale)) {
|
||||
$this->fallbackExists = Post::whereHas('category', function ($query) {
|
||||
$query->where('type', $this->type);
|
||||
})
|
||||
->whereHas('translations', function ($query) use ($fallbackLocale) {
|
||||
$query->where('locale', 'like', $fallbackLocale . '%')
|
||||
->whereDate('from', '<=', now())
|
||||
->where(function ($query) {
|
||||
$query->whereDate('till', '>', now())->orWhereNull('till');
|
||||
});
|
||||
})
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
|
||||
$photo = null;
|
||||
if ($posts->isNotEmpty()) {
|
||||
$firstPost = $posts->first();
|
||||
if ($firstPost->hasMedia('*')) {
|
||||
$photo = $firstPost->getFirstMediaUrl('*');
|
||||
}
|
||||
}
|
||||
|
||||
return view('livewire.event-calendar-post', [
|
||||
'posts' => $posts,
|
||||
'photo' => $photo,
|
||||
'fallbackLocale' => __(Language::where('lang_code', config('app.fallback_locale'))->first()->name ?? 'Fallback Language'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
32
app/Http/Livewire/ForcedLogoutModal.php
Normal file
32
app/Http/Livewire/ForcedLogoutModal.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire;
|
||||
|
||||
use Livewire\Component;
|
||||
|
||||
class ForcedLogoutModal extends Component
|
||||
{
|
||||
public $showModal = false;
|
||||
public $message = '';
|
||||
public $title = '';
|
||||
|
||||
protected $listeners = ['showForcedLogoutModal'];
|
||||
|
||||
public function showForcedLogoutModal($message, $title = 'Logged Out')
|
||||
{
|
||||
$this->message = $message;
|
||||
$this->title = $title;
|
||||
$this->showModal = true;
|
||||
}
|
||||
|
||||
public function confirmLogout()
|
||||
{
|
||||
// Dispatch browser event to submit logout form
|
||||
$this->dispatch('proceed-logout');
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.forced-logout-modal');
|
||||
}
|
||||
}
|
||||
89
app/Http/Livewire/FromAccount.php
Normal file
89
app/Http/Livewire/FromAccount.php
Normal file
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire;
|
||||
|
||||
use App\Http\Controllers\TransactionController;
|
||||
use Livewire\Component;
|
||||
|
||||
class FromAccount extends Component
|
||||
{
|
||||
public $profileAccounts = [];
|
||||
public $fromAccountId;
|
||||
public $selectedAccount = null;
|
||||
public $label;
|
||||
public $active = true;
|
||||
|
||||
public function mount()
|
||||
{
|
||||
$this->profileAccounts = $this->getProfileAccounts();
|
||||
$this->preSelected();
|
||||
}
|
||||
|
||||
public function getProfileAccounts()
|
||||
{
|
||||
$transactionController = new TransactionController();
|
||||
$accounts = $transactionController->getAccountsInfo();
|
||||
|
||||
// Return empty collection if no accounts (e.g., Admin profiles)
|
||||
if (!$accounts || $accounts->isEmpty()) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
// Always filter out deleted accounts
|
||||
$accounts = $accounts->filter(function ($account) {
|
||||
return is_null($account['deletedAt']) || \Illuminate\Support\Carbon::parse($account['deletedAt'])->isFuture();
|
||||
});
|
||||
|
||||
// If $active is true, also filter out inactive accounts
|
||||
if ($this->active) {
|
||||
$accounts = $accounts->filter(function ($account) {
|
||||
return is_null($account['inactiveAt']) || \Illuminate\Support\Carbon::parse($account['inactiveAt'])->isFuture();
|
||||
});
|
||||
}
|
||||
|
||||
return $accounts;
|
||||
}
|
||||
|
||||
|
||||
public function resetForm()
|
||||
{
|
||||
$this->profileAccounts = $this->getProfileAccounts();
|
||||
$this->preSelected();
|
||||
}
|
||||
|
||||
public function preSelected()
|
||||
{
|
||||
if ($this->profileAccounts && count($this->profileAccounts) > 0) {
|
||||
$firstActiveAccount = $this->profileAccounts->first(function ($account) {
|
||||
return is_null($account['inactiveAt']) || \Illuminate\Support\Carbon::parse($account['inactiveAt'])->isFuture();
|
||||
});
|
||||
|
||||
if ($firstActiveAccount) {
|
||||
$activeState = $firstActiveAccount['inactive'] === true ? ' (' . strtolower(__('Inactive')) . ')' : '';
|
||||
$firstActiveAccount['name'] = __(ucfirst(strtolower($firstActiveAccount['name']))) . ' ' . __('account') . $activeState;
|
||||
$this->fromAccountId = $firstActiveAccount['id'];
|
||||
$this->selectedAccount = $firstActiveAccount;
|
||||
$this->dispatch('fromAccountId', $this->selectedAccount);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function fromAccountSelected($fromAccountId)
|
||||
{
|
||||
$this->fromAccountId = $fromAccountId;
|
||||
|
||||
$selectedAccount = collect($this->profileAccounts)->firstWhere('id', $fromAccountId);
|
||||
$activeState = $selectedAccount['inactive'] === true ? ' (' . strtolower(__('Inactive')) . ')' : '';
|
||||
|
||||
// Translate the 'name' field
|
||||
$selectedAccount['name'] = __(ucfirst(strtolower($selectedAccount['name']))) . ' ' . __('account') . $activeState;
|
||||
|
||||
$this->selectedAccount = $selectedAccount;
|
||||
$this->dispatch('fromAccountId', $this->selectedAccount);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.from-account');
|
||||
}
|
||||
}
|
||||
13
app/Http/Livewire/FullPost.php
Normal file
13
app/Http/Livewire/FullPost.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire;
|
||||
|
||||
use Livewire\Component;
|
||||
|
||||
class FullPost extends Component
|
||||
{
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.full-post');
|
||||
}
|
||||
}
|
||||
165
app/Http/Livewire/Locations/LocationsDropdown.php
Normal file
165
app/Http/Livewire/Locations/LocationsDropdown.php
Normal file
@@ -0,0 +1,165 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire\Locations;
|
||||
|
||||
use App\Models\Locations\City;
|
||||
use App\Models\Locations\Country;
|
||||
use App\Models\Locations\District;
|
||||
use App\Models\Locations\Division;
|
||||
use Livewire\Component;
|
||||
|
||||
class LocationsDropdown extends Component
|
||||
{
|
||||
public $country;
|
||||
public $divisions = [];
|
||||
public $division;
|
||||
public $cities = [];
|
||||
public $city;
|
||||
public $districts = [];
|
||||
public $district;
|
||||
public $hideLabel = false;
|
||||
|
||||
protected $listeners = ['countryToChildren', 'divisionToChildren', 'cityToChildren', 'districtToChildren'];
|
||||
|
||||
|
||||
/**
|
||||
* Receive country value from parent component.
|
||||
* Does NOT reset downstream values - that would clear values being set by subsequent ToChildren calls.
|
||||
*/
|
||||
public function countryToChildren($value)
|
||||
{
|
||||
$this->country = $value;
|
||||
// Don't call updatedCountry() as it resets division/city/district
|
||||
// Parent is responsible for setting all values correctly
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Receive division value from parent component.
|
||||
* Does NOT reset downstream values - that would clear values being set by subsequent ToChildren calls.
|
||||
*/
|
||||
public function divisionToChildren($value)
|
||||
{
|
||||
$this->division = $value;
|
||||
if ($this->division === '') {
|
||||
$this->division = null;
|
||||
}
|
||||
// Don't call updatedDivision() as it resets city/district
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Receive city value from parent component.
|
||||
* Does NOT reset downstream values - that would clear values being set by subsequent ToChildren calls.
|
||||
*/
|
||||
public function cityToChildren($value)
|
||||
{
|
||||
$this->city = $value;
|
||||
if ($this->city === '') {
|
||||
$this->city = null;
|
||||
}
|
||||
// Don't call updatedCity() as it resets district
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Receive district value from parent component.
|
||||
*/
|
||||
public function districtToChildren($value)
|
||||
{
|
||||
$this->district = $value;
|
||||
if ($this->district === '') {
|
||||
$this->district = null;
|
||||
}
|
||||
// No downstream values to reset
|
||||
}
|
||||
|
||||
|
||||
public function updatedCountry()
|
||||
{
|
||||
$this->reset(['division', 'divisions']);
|
||||
$this->reset(['city', 'cities']);
|
||||
$this->reset(['district', 'districts']);
|
||||
|
||||
$this->dispatch('countryToParent', $this->country);
|
||||
$this->dispatch('divisionToParent', $this->division);
|
||||
$this->dispatch('cityToParent', $this->city);
|
||||
$this->dispatch('districtToParent', $this->district);
|
||||
}
|
||||
|
||||
|
||||
public function updatedDivision()
|
||||
{
|
||||
$this->reset(['city', 'cities']);
|
||||
$this->reset(['district', 'districts']);
|
||||
if ($this->division === '') {
|
||||
$this->division = null;
|
||||
}
|
||||
$this->dispatch('divisionToParent', $this->division);
|
||||
$this->dispatch('cityToParent', $this->city);
|
||||
$this->dispatch('districtToParent', $this->district);
|
||||
}
|
||||
|
||||
|
||||
public function updatedCity()
|
||||
{
|
||||
$this->reset(['district', 'districts']);
|
||||
if ($this->city === '') {
|
||||
$this->city = null;
|
||||
}
|
||||
$this->dispatch('cityToParent', $this->city);
|
||||
$this->dispatch('districtToParent', $this->district);
|
||||
}
|
||||
|
||||
|
||||
public function updatedDistrict()
|
||||
{
|
||||
if ($this->district === '') {
|
||||
$this->district = null;
|
||||
}
|
||||
$this->dispatch('districtToParent', $this->district);
|
||||
}
|
||||
|
||||
|
||||
public function countrySelected()
|
||||
{
|
||||
$this->dispatch('countryToParent', $this->country);
|
||||
}
|
||||
|
||||
|
||||
public function divisionSelected()
|
||||
{
|
||||
$this->dispatch('divisionToParent', $this->division);
|
||||
}
|
||||
|
||||
|
||||
public function citySelected()
|
||||
{
|
||||
$this->dispatch('cityToParent', $this->city);
|
||||
}
|
||||
|
||||
|
||||
public function districtSelected()
|
||||
{
|
||||
$this->dispatch('districtToParent', $this->district);
|
||||
}
|
||||
|
||||
|
||||
public function render()
|
||||
{
|
||||
|
||||
if (!empty($this->country)) {
|
||||
$country = Country::find($this->country);
|
||||
$this->divisions = Division::with(['translations'])->where('country_id', $this->country)->get();
|
||||
$this->cities = City::with(['translations'])->where('country_id', $this->country)->get();
|
||||
}
|
||||
|
||||
if (!empty($this->city)) {
|
||||
$this->districts = District::with(['translations'])->where('city_id', $this->city)->get();
|
||||
}
|
||||
|
||||
$countries = Country::with(['translations'])->get();
|
||||
|
||||
return view('livewire.locations.locations-dropdown', compact(['countries']));
|
||||
}
|
||||
}
|
||||
207
app/Http/Livewire/Locations/UpdateProfileLocationForm.php
Normal file
207
app/Http/Livewire/Locations/UpdateProfileLocationForm.php
Normal file
@@ -0,0 +1,207 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire\Locations;
|
||||
|
||||
use App\Models\Locations\City;
|
||||
use App\Models\Locations\Country;
|
||||
use App\Models\Locations\District;
|
||||
use App\Models\Locations\Location;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Component;
|
||||
use WireUi\Traits\WireUiActions;
|
||||
|
||||
class UpdateProfileLocationForm extends Component
|
||||
{
|
||||
use WireUiActions;
|
||||
|
||||
|
||||
public $state;
|
||||
public $country;
|
||||
public $division;
|
||||
public $city;
|
||||
public $district;
|
||||
public $validateCountry = true;
|
||||
public $validateDivision = true;
|
||||
public $validateCity = true;
|
||||
|
||||
protected $listeners = ['countryToParent', 'divisionToParent', 'cityToParent', 'districtToParent'];
|
||||
|
||||
public function rules()
|
||||
{
|
||||
return [
|
||||
'country' => 'required_if:validateCountry,true|integer',
|
||||
'division' => 'required_if:validateDivision,true|nullable|integer',
|
||||
'city' => 'required_if:validateCity,true|nullable|integer',
|
||||
'district' => 'nullable|integer'
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare the component.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function mount()
|
||||
{
|
||||
$this->state = session('activeProfileType')::find(session('activeProfileId'))
|
||||
->load([
|
||||
// 'locations',
|
||||
'locations.country',
|
||||
'locations.division',
|
||||
'locations.city',
|
||||
'locations.district']);
|
||||
|
||||
if ($this->state->locations->isNotEmpty()) {
|
||||
if ($this->state->locations->first()->country) {
|
||||
// For now we only use a single location. In the future this can become an array of locations.
|
||||
$this->country = $this->state->locations->first()->country->id;
|
||||
$this->dispatch('countryToChildren', $this->country);
|
||||
}
|
||||
|
||||
|
||||
if ($this->state->locations->first()->division) {
|
||||
// For now we only use a single location. In the future this can become an array of locations.
|
||||
$this->division = $this->state->locations->first()->division->id;
|
||||
}
|
||||
|
||||
|
||||
if ($this->state->locations->first()->city) {
|
||||
// For now we only use a single location. In the future this can become an array of locations.
|
||||
$this->city = $this->state->locations->first()->city->id;
|
||||
|
||||
// In case a location has a city without a country in the db:
|
||||
if (!$this->state->locations->first()->country) {
|
||||
$this->country = City::find($this->city)->country_id;
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->state->locations->first()->district) {
|
||||
// For now we only use a single location. In the future this can become an array of locations.
|
||||
$this->district = $this->state->locations->first()->district->id;
|
||||
|
||||
// In case a location has a district without a city in the db:
|
||||
if (!$this->state->locations->first()->city) {
|
||||
$this->city = District::find($this->district)->city_id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->setValidationOptions();
|
||||
}
|
||||
|
||||
|
||||
public function countryToParent($value)
|
||||
{
|
||||
$this->country = $value;
|
||||
$this->setValidationOptions();
|
||||
}
|
||||
|
||||
|
||||
public function divisionToParent($value)
|
||||
{
|
||||
$this->division = $value;
|
||||
$this->setValidationOptions();
|
||||
}
|
||||
|
||||
|
||||
public function cityToParent($value)
|
||||
{
|
||||
$this->city = $value;
|
||||
$this->setValidationOptions();
|
||||
}
|
||||
|
||||
|
||||
public function districtToParent($value)
|
||||
{
|
||||
$this->district = $value;
|
||||
$this->setValidationOptions();
|
||||
}
|
||||
|
||||
|
||||
public function emitLocationToChildren()
|
||||
{
|
||||
$this->dispatch('countryToChildren', $this->country);
|
||||
$this->dispatch('divisionToChildren', $this->division);
|
||||
$this->dispatch('cityToChildren', $this->city);
|
||||
$this->dispatch('districtToChildren', $this->district);
|
||||
}
|
||||
|
||||
|
||||
public function setValidationOptions()
|
||||
{
|
||||
|
||||
$this->validateCountry = $this->validateDivision = $this->validateCity = true;
|
||||
|
||||
// In case no cities or divisions for selected country are seeded in database
|
||||
if ($this->country) {
|
||||
|
||||
$countDivisions = Country::find($this->country)->divisions()->count();
|
||||
|
||||
$countCities = Country::find($this->country)->cities()->count();
|
||||
|
||||
if ($countDivisions > 0 && $countCities < 1) {
|
||||
$this->validateDivision = true;
|
||||
$this->validateCity = false;
|
||||
} elseif ($countDivisions < 1 && $countCities > 1) {
|
||||
$this->validateDivision = false;
|
||||
$this->validateCity = true;
|
||||
} elseif ($countDivisions < 1 && $countCities < 1) {
|
||||
$this->validateDivision = false;
|
||||
$this->validateCity = false;
|
||||
} elseif ($countDivisions > 0 && $countCities > 0) {
|
||||
$this->validateDivision = false;
|
||||
$this->validateCity = true;
|
||||
}
|
||||
|
||||
}
|
||||
// In case no country is selected, no need to show other validation errors
|
||||
if (!$this->country) {
|
||||
$this->validateCountry = true;
|
||||
$this->validateDivision = $this->validateCity = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
public function updateProfileInformation()
|
||||
{
|
||||
$this->validate();
|
||||
|
||||
// Use a transaction.
|
||||
DB::transaction(function (): void {
|
||||
|
||||
$activeProfile = getActiveProfile();
|
||||
|
||||
// CRITICAL SECURITY: Validate user has ownership/access to this profile
|
||||
\App\Helpers\ProfileAuthorizationHelper::authorize($activeProfile);
|
||||
|
||||
// For now we only use a single location. In the future this can become an array of locations.
|
||||
if ($this->state->locations && $this->state->locations->isNotEmpty()) {
|
||||
$location = Location::find($this->state->locations->first()->id);
|
||||
} else {
|
||||
$location = new Location();
|
||||
$activeProfile->locations()->save($location); // create a new location
|
||||
}
|
||||
// Set country, division, and city IDs on the location
|
||||
$location->country_id = $this->country;
|
||||
$location->division_id = $this->division;
|
||||
$location->city_id = $this->city;
|
||||
$location->district_id = $this->district;
|
||||
|
||||
|
||||
// Save the location with the updated relationship IDs
|
||||
$location->save();
|
||||
$activeProfile->touch(); // Observer catches this and reindexes search index
|
||||
|
||||
|
||||
$this->dispatch('saved');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.locations.update-profile-location-form');
|
||||
}
|
||||
}
|
||||
30
app/Http/Livewire/Mailings/.php-cs-fixer.dist.php
Normal file
30
app/Http/Livewire/Mailings/.php-cs-fixer.dist.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use PhpCsFixer\Config;
|
||||
use PhpCsFixer\Finder;
|
||||
use PhpCsFixer\Runner\Parallel\ParallelConfigFactory;
|
||||
|
||||
return (new Config())
|
||||
->setParallelConfig(ParallelConfigFactory::detect()) // @TODO 4.0 no need to call this manually
|
||||
->setRiskyAllowed(false)
|
||||
->setRules([
|
||||
'@auto' => true
|
||||
])
|
||||
// 💡 by default, Fixer looks for `*.php` files excluding `./vendor/` - here, you can groom this config
|
||||
->setFinder(
|
||||
(new Finder())
|
||||
// 💡 root folder to check
|
||||
->in(__DIR__)
|
||||
// 💡 additional files, eg bin entry file
|
||||
// ->append([__DIR__.'/bin-entry-file'])
|
||||
// 💡 folders to exclude, if any
|
||||
// ->exclude([/* ... */])
|
||||
// 💡 path patterns to exclude, if any
|
||||
// ->notPath([/* ... */])
|
||||
// 💡 extra configs
|
||||
// ->ignoreDotFiles(false) // true by default in v3, false in v4 or future mode
|
||||
// ->ignoreVCS(true) // true by default
|
||||
)
|
||||
;
|
||||
130
app/Http/Livewire/Mailings/LocationFilter.php
Normal file
130
app/Http/Livewire/Mailings/LocationFilter.php
Normal file
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire\Mailings;
|
||||
|
||||
use App\Models\Locations\City;
|
||||
use App\Models\Locations\Country;
|
||||
use App\Models\Locations\District;
|
||||
use App\Models\Locations\Division;
|
||||
use Livewire\Component;
|
||||
|
||||
class LocationFilter extends Component
|
||||
{
|
||||
public $selectedCountries = [];
|
||||
public $selectedDivisions = [];
|
||||
public $selectedCities = [];
|
||||
public $selectedDistricts = [];
|
||||
|
||||
public $countries = [];
|
||||
public $divisions = [];
|
||||
public $cities = [];
|
||||
public $districts = [];
|
||||
|
||||
protected $listeners = ['resetLocationFilter', 'loadLocationFilterData'];
|
||||
|
||||
public function mount($selectedCountries = [], $selectedDivisions = [], $selectedCities = [], $selectedDistricts = [])
|
||||
{
|
||||
$this->selectedCountries = $selectedCountries;
|
||||
$this->selectedDivisions = $selectedDivisions;
|
||||
$this->selectedCities = $selectedCities;
|
||||
$this->selectedDistricts = $selectedDistricts;
|
||||
|
||||
$this->loadLocationData();
|
||||
}
|
||||
|
||||
public function loadLocationData()
|
||||
{
|
||||
// Always load all countries
|
||||
$this->countries = Country::with(['translations'])->get();
|
||||
|
||||
// Load divisions if any countries are selected
|
||||
if (!empty($this->selectedCountries)) {
|
||||
$this->divisions = Division::with(['translations'])
|
||||
->whereIn('country_id', $this->selectedCountries)
|
||||
->get();
|
||||
} else {
|
||||
$this->divisions = collect();
|
||||
}
|
||||
|
||||
// Load cities if any countries are selected
|
||||
if (!empty($this->selectedCountries)) {
|
||||
$this->cities = City::with(['translations'])
|
||||
->whereIn('country_id', $this->selectedCountries)
|
||||
->get();
|
||||
} else {
|
||||
$this->cities = collect();
|
||||
}
|
||||
|
||||
// Load districts if any cities are selected
|
||||
if (!empty($this->selectedCities)) {
|
||||
$this->districts = District::with(['translations'])
|
||||
->whereIn('city_id', $this->selectedCities)
|
||||
->get();
|
||||
} else {
|
||||
$this->districts = collect();
|
||||
}
|
||||
}
|
||||
|
||||
public function updatedSelectedCountries()
|
||||
{
|
||||
// Reset lower levels when countries change
|
||||
$this->selectedDivisions = [];
|
||||
$this->selectedCities = [];
|
||||
$this->selectedDistricts = [];
|
||||
|
||||
$this->loadLocationData();
|
||||
$this->emitSelectionUpdate();
|
||||
}
|
||||
|
||||
public function updatedSelectedDivisions()
|
||||
{
|
||||
$this->emitSelectionUpdate();
|
||||
}
|
||||
|
||||
public function updatedSelectedCities()
|
||||
{
|
||||
// Reset districts when cities change
|
||||
$this->selectedDistricts = [];
|
||||
$this->loadLocationData();
|
||||
$this->emitSelectionUpdate();
|
||||
}
|
||||
|
||||
public function updatedSelectedDistricts()
|
||||
{
|
||||
$this->emitSelectionUpdate();
|
||||
}
|
||||
|
||||
public function resetLocationFilter()
|
||||
{
|
||||
$this->selectedCountries = [];
|
||||
$this->selectedDivisions = [];
|
||||
$this->selectedCities = [];
|
||||
$this->selectedDistricts = [];
|
||||
$this->loadLocationData();
|
||||
$this->emitSelectionUpdate();
|
||||
}
|
||||
|
||||
public function loadLocationFilterData($data)
|
||||
{
|
||||
$this->selectedCountries = $data['countries'] ?? [];
|
||||
$this->selectedDivisions = $data['divisions'] ?? [];
|
||||
$this->selectedCities = $data['cities'] ?? [];
|
||||
$this->selectedDistricts = $data['districts'] ?? [];
|
||||
$this->loadLocationData();
|
||||
}
|
||||
|
||||
private function emitSelectionUpdate()
|
||||
{
|
||||
$this->dispatch('locationFilterUpdated', [
|
||||
'countries' => $this->selectedCountries,
|
||||
'divisions' => $this->selectedDivisions,
|
||||
'cities' => $this->selectedCities,
|
||||
'districts' => $this->selectedDistricts,
|
||||
]);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.mailings.location-filter');
|
||||
}
|
||||
}
|
||||
1210
app/Http/Livewire/Mailings/Manage.php
Normal file
1210
app/Http/Livewire/Mailings/Manage.php
Normal file
File diff suppressed because it is too large
Load Diff
1005
app/Http/Livewire/MainBrowseTagCategories.php
Normal file
1005
app/Http/Livewire/MainBrowseTagCategories.php
Normal file
File diff suppressed because it is too large
Load Diff
77
app/Http/Livewire/MainPage.php
Normal file
77
app/Http/Livewire/MainPage.php
Normal file
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Str;
|
||||
use Livewire\Component;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
|
||||
class MainPage extends Component
|
||||
{
|
||||
public $lastLoginAt;
|
||||
public $user;
|
||||
public $profileType;
|
||||
|
||||
public function mount()
|
||||
{
|
||||
$this->user = [
|
||||
'name' => getActiveProfile()->name,
|
||||
'firstName' => Str::words(getActiveProfile()->full_name, 1, ''),
|
||||
];
|
||||
|
||||
$profile = getActiveProfile();
|
||||
|
||||
// For User profiles, skip current login to get truly previous login
|
||||
// For other profiles (Organization, Bank, etc.), show the most recent login
|
||||
$query = Activity::where('subject_id', $profile->id)
|
||||
->where('subject_type', get_class($profile))
|
||||
->where('event', 'login')
|
||||
->whereNotNull('properties->old->login_at')
|
||||
->latest('id');
|
||||
|
||||
if (get_class($profile) === 'App\\Models\\User') {
|
||||
$query->skip(1);
|
||||
}
|
||||
|
||||
$activityLog = $query->first();
|
||||
|
||||
|
||||
if (
|
||||
$activityLog &&
|
||||
isset($activityLog->properties['old']['login_at']) &&
|
||||
!empty($activityLog->properties['old']['login_at'])
|
||||
) {
|
||||
$lastLoginAt = $activityLog->properties['old']['login_at'];
|
||||
$timestamp = strtotime($lastLoginAt);
|
||||
|
||||
if ($timestamp !== false) {
|
||||
$causer = null;
|
||||
if (!empty($activityLog->causer_type) && !empty($activityLog->causer_id) && class_exists($activityLog->causer_type)) {
|
||||
$causer = $activityLog->causer_type::find($activityLog->causer_id);
|
||||
}
|
||||
$causerName = $causer && isset($causer->name) ? $causer->name : __('unknown user');
|
||||
|
||||
$showBy = !(
|
||||
$activityLog->subject_type === $activityLog->causer_type &&
|
||||
$activityLog->subject_id === $activityLog->causer_id
|
||||
);
|
||||
|
||||
$this->lastLoginAt = Carbon::createFromTimestamp($timestamp)->diffForHumans()
|
||||
. ($showBy ? ' ' . __('by') . ' ' . $causerName : '');
|
||||
} else {
|
||||
$this->lastLoginAt = '';
|
||||
}
|
||||
} else {
|
||||
$this->lastLoginAt = '';
|
||||
}
|
||||
|
||||
$this->profileType = getActiveProfileType();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.main-page');
|
||||
}
|
||||
}
|
||||
172
app/Http/Livewire/MainPage/ArticleCardFull.php
Normal file
172
app/Http/Livewire/MainPage/ArticleCardFull.php
Normal file
@@ -0,0 +1,172 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire\MainPage;
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\Locations\City;
|
||||
use App\Models\Locations\Country;
|
||||
use App\Models\Locations\Division;
|
||||
use App\Models\Locations\Location;
|
||||
use App\Models\News;
|
||||
use App\Models\Post;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Livewire\Component;
|
||||
|
||||
class ArticleCardFull extends Component
|
||||
{
|
||||
public $author;
|
||||
public $post = [];
|
||||
public $posts;
|
||||
public $media;
|
||||
public $postNr;
|
||||
public $related;
|
||||
|
||||
|
||||
public function mount($postNr, $related, Request $request)
|
||||
{
|
||||
$this->postNr = $postNr;
|
||||
$this->related = $related;
|
||||
|
||||
|
||||
$profile = getActiveProfile();
|
||||
|
||||
// SECURITY: If a profile is active, validate the user has access to it
|
||||
// This prevents session manipulation while allowing public access (null profile)
|
||||
if ($profile) {
|
||||
\App\Helpers\ProfileAuthorizationHelper::authorize($profile);
|
||||
}
|
||||
|
||||
$location = $profile && $profile->locations ? $profile->locations()->first() : null;
|
||||
|
||||
$skipLocationFilter = false;
|
||||
|
||||
if ($location) {
|
||||
// If no division and no city as location set
|
||||
if (!$location->division && !$location->city) {
|
||||
$categoryable_id = Location::find($location->id)->country->id;
|
||||
$categoryable_type = Country::class;
|
||||
// Include also all other countries if $related is set in view
|
||||
if ($related) {
|
||||
$categoryable_id = Country::pluck('id');
|
||||
} else {
|
||||
$categoryable_id = [$categoryable_id];
|
||||
}
|
||||
// Division without city is set as location
|
||||
} elseif ($location->division && !$location->city) {
|
||||
$categoryable_id = Location::find($location->id)->division->id;
|
||||
$categoryable_type = Division::class;
|
||||
// Include also all other divisions in the same country if $related is set in view
|
||||
if ($related) {
|
||||
$categoryable_id = Division::find($categoryable_id)->parent->divisions->pluck('id');
|
||||
} else {
|
||||
$categoryable_id = [$categoryable_id];
|
||||
}
|
||||
// City is set as location
|
||||
} elseif ($location->city) {
|
||||
$categoryable_id = Location::find($location->id)->city->id;
|
||||
$categoryable_type = City::class;
|
||||
// Include also all other cities in the same division if $related is set in view
|
||||
if ($related) {
|
||||
$categoryable_id = City::find($categoryable_id)->parent->cities->pluck('id');
|
||||
} else {
|
||||
$categoryable_id = [$categoryable_id];
|
||||
}
|
||||
}
|
||||
// No matching location is set - skip location filtering
|
||||
} else {
|
||||
$skipLocationFilter = true;
|
||||
$categoryable_id = [];
|
||||
$categoryable_type = '';
|
||||
}
|
||||
|
||||
|
||||
// TODO: check what happens when multiple locations per user are used!
|
||||
|
||||
$post =
|
||||
Post::with([
|
||||
'postable' => function ($query) {
|
||||
$query->select(['id', 'name']);
|
||||
},
|
||||
'category' => function ($query) use ($categoryable_id, $categoryable_type, $skipLocationFilter) {
|
||||
$query->where('type', 'App\Models\Article');
|
||||
if (!$skipLocationFilter && !empty($categoryable_id)) {
|
||||
$query->where(function ($query) use ($categoryable_id, $categoryable_type) {
|
||||
$query->where(function ($query) use ($categoryable_id, $categoryable_type) {
|
||||
$query
|
||||
->whereIn('categoryable_id', $categoryable_id)
|
||||
->where('categoryable_type', $categoryable_type);
|
||||
})->orWhere(function ($query) {
|
||||
// Also include categories with no location set
|
||||
$query->whereNull('categoryable_id')
|
||||
->whereNull('categoryable_type');
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
'translations' => function ($query) {
|
||||
$query->where('locale', App::getLocale());
|
||||
},
|
||||
'author',
|
||||
'media',
|
||||
])
|
||||
->whereHas('category', function ($query) use ($categoryable_id, $categoryable_type, $skipLocationFilter) {
|
||||
$query->where('type', 'App\Models\Article');
|
||||
if (!$skipLocationFilter && !empty($categoryable_id)) {
|
||||
$query->where(function ($query) use ($categoryable_id, $categoryable_type) {
|
||||
$query->where(function ($query) use ($categoryable_id, $categoryable_type) {
|
||||
$query
|
||||
->whereIn('categoryable_id', $categoryable_id)
|
||||
->where('categoryable_type', $categoryable_type);
|
||||
})->orWhere(function ($query) {
|
||||
// Also include categories with no location set
|
||||
$query->whereNull('categoryable_id')
|
||||
->whereNull('categoryable_type');
|
||||
});
|
||||
});
|
||||
}
|
||||
})
|
||||
->whereHas('translations', function ($query) {
|
||||
$query
|
||||
->where('locale', App::getLocale())
|
||||
->whereDate('from', '<=', now())
|
||||
->where(function ($query) {
|
||||
$query->whereDate('till', '>', now())->orWhereNull('till');
|
||||
})
|
||||
->orderBy('updated_at', 'desc');
|
||||
})
|
||||
->get()->sortByDesc(function ($query) {
|
||||
if (isset($query->translations)) {
|
||||
return $query->translations->first()->updated_at;
|
||||
};
|
||||
})->values(); // Use values() method to reset the collection keys after sortBy
|
||||
|
||||
|
||||
$lastNr = $post->count() - 1;
|
||||
if ($postNr > $lastNr) {
|
||||
$post = null;
|
||||
} else {
|
||||
$post = $post[$postNr];
|
||||
}
|
||||
|
||||
if (isset($post->translations)) {
|
||||
$translation = $post->translations->first();
|
||||
$this->post = $translation;
|
||||
$this->post['from'] = $translation->from;
|
||||
$this->post['category'] = Category::find($post->category_id)->translations->where('locale', App::getLocale())->first()->name;
|
||||
$this->post['author'] = $post->author ? $post->author->name : timebank_config('posts.site-content-writer');
|
||||
$this->post['post_id'] = $post->id;
|
||||
|
||||
if ($post->media) {
|
||||
$this->media = Post::find($post->id)->getFirstMedia('posts');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.main-page.article-card-full');
|
||||
}
|
||||
}
|
||||
226
app/Http/Livewire/MainPage/CallCardCarousel.php
Normal file
226
app/Http/Livewire/MainPage/CallCardCarousel.php
Normal file
@@ -0,0 +1,226 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire\MainPage;
|
||||
|
||||
use App\Http\Livewire\Calls\CallCarouselScorer;
|
||||
use App\Http\Livewire\Calls\ProfileCalls;
|
||||
use App\Models\Call;
|
||||
use App\Models\Locations\City;
|
||||
use App\Models\Locations\Country;
|
||||
use App\Models\Locations\Division;
|
||||
use App\Models\Locations\Location;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Component;
|
||||
|
||||
class CallCardCarousel extends Component
|
||||
{
|
||||
public array $calls = [];
|
||||
public bool $related = true;
|
||||
public bool $random = false;
|
||||
public int $maxCards = 0;
|
||||
public bool $showScore = false;
|
||||
private const UNKNOWN_COUNTRY_ID = 10;
|
||||
|
||||
public function mount(bool $related, bool $random = false, int $maxCards = 0): void
|
||||
{
|
||||
$this->related = $related;
|
||||
$this->random = $random;
|
||||
$this->maxCards = $maxCards;
|
||||
|
||||
$carouselCfg = timebank_config('calls.carousel', []);
|
||||
$this->maxCards = (int) ($carouselCfg['max_cards'] ?? ($maxCards ?: 12));
|
||||
$locale = App::getLocale();
|
||||
|
||||
// --- Resolve active profile and its location ---
|
||||
$profile = getActiveProfile();
|
||||
if ($profile) {
|
||||
\App\Helpers\ProfileAuthorizationHelper::authorize($profile);
|
||||
}
|
||||
|
||||
$location = $profile?->locations ? $profile->locations()->first() : null;
|
||||
$profileCityId = $location ? ($location->city_id ?? $location->city?->id) : null;
|
||||
$profileDivisionId = $location ? ($location->division_id ?? $location->division?->id) : null;
|
||||
$profileCountryId = $location ? ($location->country_id ?? $location->country?->id) : null;
|
||||
|
||||
// Expand location IDs based on $related flag (same sibling logic as EventCardFull)
|
||||
$locationCityIds = [];
|
||||
$locationDivisionIds = [];
|
||||
$locationCountryIds = [];
|
||||
|
||||
if ($location) {
|
||||
if (!$location->division && !$location->city) {
|
||||
$country = Location::find($location->id)->country;
|
||||
$locationCountryIds = $related
|
||||
? Country::pluck('id')->toArray()
|
||||
: ($country ? [$country->id] : []);
|
||||
} elseif ($location->division && !$location->city) {
|
||||
$divisionId = Location::find($location->id)->division->id;
|
||||
$locationDivisionIds = $related
|
||||
? Division::find($divisionId)->parent->divisions->pluck('id')->toArray()
|
||||
: [$divisionId];
|
||||
} elseif ($location->city) {
|
||||
$cityId = Location::find($location->id)->city->id;
|
||||
$locationCityIds = $related
|
||||
? City::find($cityId)->parent->cities->pluck('id')->toArray()
|
||||
: [$cityId];
|
||||
}
|
||||
}
|
||||
|
||||
// --- Base query — safety exclusions are always hardcoded ---
|
||||
$query = Call::with([
|
||||
'tag.contexts.category.translations',
|
||||
'tag.contexts.category.ancestors.translations',
|
||||
'translations',
|
||||
'location.city.translations',
|
||||
'location.country.translations',
|
||||
'callable.locations.city.translations',
|
||||
'callable.locations.division.translations',
|
||||
'callable.locations.country.translations',
|
||||
'callable.loveReactant.reactionCounters',
|
||||
'loveReactant.reactionCounters',
|
||||
])
|
||||
->whereNull('deleted_at')
|
||||
->where('is_paused', false)
|
||||
->where('is_suppressed', false)
|
||||
->where(fn ($q) => $q->whereNull('till')->orWhere('till', '>=', now()));
|
||||
|
||||
// Enforce is_public for guests or when config requires it
|
||||
if (!Auth::check() || ($carouselCfg['exclude_non_public'] ?? true)) {
|
||||
$query->where('is_public', true);
|
||||
}
|
||||
|
||||
// Configurable: exclude the active profile's own calls
|
||||
if ($profile && ($carouselCfg['exclude_own_calls'] ?? true)) {
|
||||
$query->where(function ($q) use ($profile) {
|
||||
$q->where('callable_type', '!=', get_class($profile))
|
||||
->orWhere('callable_id', '!=', $profile->id);
|
||||
});
|
||||
}
|
||||
|
||||
// --- Locality filter ---
|
||||
$includeUnknown = $carouselCfg['include_unknown_location'] ?? true;
|
||||
$includeDivision = $carouselCfg['include_same_division'] ?? true;
|
||||
$includeCountry = $carouselCfg['include_same_country'] ?? true;
|
||||
|
||||
$hasLocalityFilter = $locationCityIds || $locationDivisionIds || $locationCountryIds
|
||||
|| ($includeDivision && $locationDivisionIds)
|
||||
|| ($includeCountry && $locationCountryIds)
|
||||
|| $includeUnknown;
|
||||
|
||||
if ($hasLocalityFilter) {
|
||||
$query->where(function ($q) use (
|
||||
$locationCityIds, $locationDivisionIds, $locationCountryIds,
|
||||
$includeDivision, $includeCountry, $includeUnknown
|
||||
) {
|
||||
// Always include calls matching the profile's city
|
||||
if ($locationCityIds) {
|
||||
$q->orWhereHas('location', fn ($lq) => $lq->whereIn('city_id', $locationCityIds));
|
||||
}
|
||||
if ($includeDivision && $locationDivisionIds) {
|
||||
$q->orWhereHas('location', fn ($lq) => $lq->whereIn('division_id', $locationDivisionIds));
|
||||
}
|
||||
if ($includeCountry && $locationCountryIds) {
|
||||
$q->orWhereHas('location', fn ($lq) => $lq->whereIn('country_id', $locationCountryIds));
|
||||
}
|
||||
if ($includeUnknown) {
|
||||
$q->orWhereHas('location', fn ($lq) => $lq->where('country_id', self::UNKNOWN_COUNTRY_ID));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch pool for scoring
|
||||
$isAdmin = getActiveProfileType() === 'Admin';
|
||||
$this->showScore = (bool) ($carouselCfg['show_score'] ?? false)
|
||||
|| ($isAdmin && (bool) ($carouselCfg['show_score_for_admins'] ?? true));
|
||||
$poolSize = $this->maxCards * max(1, (int) ($carouselCfg['pool_multiplier'] ?? 5));
|
||||
$calls = $query->limit($poolSize)->get();
|
||||
|
||||
// --- Score, sort, take top $maxCards ---
|
||||
$scorer = new CallCarouselScorer(
|
||||
$carouselCfg,
|
||||
$profileCityId,
|
||||
$profileDivisionId,
|
||||
$profileCountryId
|
||||
);
|
||||
|
||||
$this->calls = $calls->map(function (Call $call) use ($locale, $scorer) {
|
||||
$translation = $call->translations->firstWhere('locale', $locale)
|
||||
?? $call->translations->first();
|
||||
|
||||
$tag = $call->tag;
|
||||
$tagContext = $tag?->contexts->first();
|
||||
$tagCategory = $tagContext?->category;
|
||||
$tagColor = $tagCategory?->relatedColor ?? 'gray';
|
||||
$tagName = $tag?->translation?->name ?? $tag?->name;
|
||||
|
||||
$locationStr = null;
|
||||
if ($call->location) {
|
||||
$loc = $call->location;
|
||||
$parts = [];
|
||||
if ($loc->city) {
|
||||
$cityName = optional($loc->city->translations->first())->name;
|
||||
if ($cityName) $parts[] = $cityName;
|
||||
}
|
||||
if ($loc->country) {
|
||||
if ($loc->country->code === 'XX') {
|
||||
$parts[] = __('Location not specified');
|
||||
} elseif ($loc->country->code) {
|
||||
$parts[] = strtoupper($loc->country->code);
|
||||
}
|
||||
}
|
||||
$locationStr = $parts ? implode(', ', $parts) : null;
|
||||
}
|
||||
|
||||
$tagCategories = [];
|
||||
if ($tagCategory) {
|
||||
$ancestors = $tagCategory->ancestorsAndSelf()->get()->reverse();
|
||||
foreach ($ancestors as $cat) {
|
||||
$catName = $cat->translations->firstWhere('locale', $locale)?->name
|
||||
?? $cat->translations->first()?->name
|
||||
?? '';
|
||||
if ($catName) {
|
||||
$tagCategories[] = [
|
||||
'name' => $catName,
|
||||
'color' => $cat->relatedColor ?? 'gray',
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$score = $scorer->score($call);
|
||||
|
||||
// Add random jitter when $random=true to vary order while preserving scoring preference
|
||||
if ($this->random) {
|
||||
$score *= (random_int(85, 115) / 100);
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $call->id,
|
||||
'model' => Call::class,
|
||||
'title' => $tagName ?? '',
|
||||
'excerpt' => $translation?->content ?? '',
|
||||
'photo' => $call->callable?->profile_photo_url ?? '',
|
||||
'location' => $locationStr,
|
||||
'tag_color' => $tagColor,
|
||||
'tag_categories' => $tagCategories,
|
||||
'callable_name' => $call->callable?->name ?? '',
|
||||
'callable_location' => ProfileCalls::buildCallableLocation($call->callable),
|
||||
'till' => $call->till,
|
||||
'expiry_badge_text' => ProfileCalls::buildExpiryBadgeText($call->till),
|
||||
'like_count' => $call->loveReactant?->reactionCounters
|
||||
->firstWhere('reaction_type_id', 3)?->count ?? 0,
|
||||
'score' => $score,
|
||||
];
|
||||
})
|
||||
->sortByDesc('score')
|
||||
->take($this->maxCards)
|
||||
->values()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.main-page.call-card-carousel');
|
||||
}
|
||||
}
|
||||
180
app/Http/Livewire/MainPage/CallCardFull.php
Normal file
180
app/Http/Livewire/MainPage/CallCardFull.php
Normal file
@@ -0,0 +1,180 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire\MainPage;
|
||||
|
||||
use App\Http\Livewire\Calls\ProfileCalls;
|
||||
use App\Models\Call;
|
||||
use App\Models\Locations\City;
|
||||
use App\Models\Locations\Country;
|
||||
use App\Models\Locations\Division;
|
||||
use App\Models\Locations\Location;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Livewire\Component;
|
||||
|
||||
class CallCardFull extends Component
|
||||
{
|
||||
public $call = null;
|
||||
public int $postNr;
|
||||
public bool $related;
|
||||
public bool $random;
|
||||
|
||||
public function mount(int $postNr, bool $related, bool $random = false, Request $request): void
|
||||
{
|
||||
$this->postNr = $postNr;
|
||||
$this->related = $related;
|
||||
$this->random = $random;
|
||||
|
||||
$profile = getActiveProfile();
|
||||
|
||||
if ($profile) {
|
||||
\App\Helpers\ProfileAuthorizationHelper::authorize($profile);
|
||||
}
|
||||
|
||||
$location = $profile && $profile->locations ? $profile->locations()->first() : null;
|
||||
|
||||
// Resolve location filter arrays (same pattern as EventCardFull)
|
||||
$locationCityIds = [];
|
||||
$locationDivisionIds = [];
|
||||
$locationCountryIds = [];
|
||||
|
||||
if ($location) {
|
||||
if (!$location->division && !$location->city) {
|
||||
$country = Location::find($location->id)->country;
|
||||
if ($related) {
|
||||
$locationCountryIds = Country::pluck('id')->toArray();
|
||||
} else {
|
||||
$locationCountryIds = $country ? [$country->id] : [];
|
||||
}
|
||||
} elseif ($location->division && !$location->city) {
|
||||
$divisionId = Location::find($location->id)->division->id;
|
||||
if ($related) {
|
||||
$locationDivisionIds = Division::find($divisionId)->parent->divisions->pluck('id')->toArray();
|
||||
} else {
|
||||
$locationDivisionIds = [$divisionId];
|
||||
}
|
||||
} elseif ($location->city) {
|
||||
$cityId = Location::find($location->id)->city->id;
|
||||
if ($related) {
|
||||
$locationCityIds = City::find($cityId)->parent->cities->pluck('id')->toArray();
|
||||
} else {
|
||||
$locationCityIds = [$cityId];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$locale = App::getLocale();
|
||||
|
||||
$query = Call::with([
|
||||
'tag.contexts.category.translations',
|
||||
'tag.contexts.category.ancestors.translations',
|
||||
'translations',
|
||||
'location.city.translations',
|
||||
'location.country.translations',
|
||||
'callable.locations.city.translations',
|
||||
'callable.locations.division.translations',
|
||||
'callable.locations.country.translations',
|
||||
'loveReactant.reactionCounters',
|
||||
])
|
||||
->where('is_public', true)
|
||||
->where('is_paused', false)
|
||||
->where('is_suppressed', false)
|
||||
->whereNull('deleted_at')
|
||||
->where(fn ($q) => $q->whereNull('till')->orWhere('till', '>=', now()));
|
||||
|
||||
// Apply location filter when a profile location is known
|
||||
if ($locationCityIds || $locationDivisionIds || $locationCountryIds) {
|
||||
$query->whereHas('location', function ($q) use ($locationCityIds, $locationDivisionIds, $locationCountryIds) {
|
||||
$q->where(function ($q) use ($locationCityIds, $locationDivisionIds, $locationCountryIds) {
|
||||
if ($locationCityIds) {
|
||||
$q->orWhereIn('city_id', $locationCityIds);
|
||||
}
|
||||
if ($locationDivisionIds) {
|
||||
$q->orWhereIn('division_id', $locationDivisionIds);
|
||||
}
|
||||
if ($locationCountryIds) {
|
||||
$q->orWhereIn('country_id', $locationCountryIds);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if ($random) {
|
||||
$query->inRandomOrder();
|
||||
$call = $query->first();
|
||||
} else {
|
||||
$query->orderBy('till');
|
||||
$calls = $query->get();
|
||||
$call = $calls->count() > $postNr ? $calls[$postNr] : null;
|
||||
}
|
||||
|
||||
if (!$call) {
|
||||
return;
|
||||
}
|
||||
|
||||
$translation = $call->translations->firstWhere('locale', $locale)
|
||||
?? $call->translations->first();
|
||||
|
||||
$tag = $call->tag;
|
||||
$tagContext = $tag?->contexts->first();
|
||||
$tagCategory = $tagContext?->category;
|
||||
$tagColor = $tagCategory?->relatedColor ?? 'gray';
|
||||
$tagName = $tag?->translation?->name ?? $tag?->name;
|
||||
|
||||
$locationStr = null;
|
||||
if ($call->location) {
|
||||
$loc = $call->location;
|
||||
$parts = [];
|
||||
if ($loc->city) {
|
||||
$cityName = optional($loc->city->translations->first())->name;
|
||||
if ($cityName) $parts[] = $cityName;
|
||||
}
|
||||
if ($loc->country) {
|
||||
if ($loc->country->code === 'XX') {
|
||||
$parts[] = __('Location not specified');
|
||||
} elseif ($loc->country->code) {
|
||||
$parts[] = strtoupper($loc->country->code);
|
||||
}
|
||||
}
|
||||
$locationStr = $parts ? implode(', ', $parts) : null;
|
||||
}
|
||||
|
||||
$tagCategories = [];
|
||||
if ($tagCategory) {
|
||||
$ancestors = $tagCategory->ancestorsAndSelf()->get()->reverse();
|
||||
foreach ($ancestors as $cat) {
|
||||
$catName = $cat->translations->firstWhere('locale', $locale)?->name
|
||||
?? $cat->translations->first()?->name
|
||||
?? '';
|
||||
if ($catName) {
|
||||
$tagCategories[] = [
|
||||
'name' => $catName,
|
||||
'color' => $cat->relatedColor ?? 'gray',
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->call = [
|
||||
'id' => $call->id,
|
||||
'model' => Call::class,
|
||||
'title' => $tagName ?? '',
|
||||
'excerpt' => $translation?->content ?? '',
|
||||
'photo' => $call->callable?->profile_photo_url ?? '',
|
||||
'location' => $locationStr,
|
||||
'tag_color' => $tagColor,
|
||||
'tag_categories' => $tagCategories,
|
||||
'callable_name' => $call->callable?->name ?? '',
|
||||
'callable_location' => ProfileCalls::buildCallableLocation($call->callable),
|
||||
'till' => $call->till,
|
||||
'expiry_badge_text' => ProfileCalls::buildExpiryBadgeText($call->till),
|
||||
'like_count' => $call->loveReactant?->reactionCounters->firstWhere('reaction_type_id', 3)?->count ?? 0,
|
||||
'score' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.main-page.call-card-full');
|
||||
}
|
||||
}
|
||||
222
app/Http/Livewire/MainPage/CallCardHalf.php
Normal file
222
app/Http/Livewire/MainPage/CallCardHalf.php
Normal file
@@ -0,0 +1,222 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire\MainPage;
|
||||
|
||||
use App\Http\Livewire\Calls\CallCarouselScorer;
|
||||
use App\Http\Livewire\Calls\ProfileCalls;
|
||||
use App\Models\Call;
|
||||
use App\Models\Locations\City;
|
||||
use App\Models\Locations\Country;
|
||||
use App\Models\Locations\Division;
|
||||
use App\Models\Locations\Location;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Component;
|
||||
|
||||
class CallCardHalf extends Component
|
||||
{
|
||||
public array $calls = [];
|
||||
public bool $related = true;
|
||||
public bool $random = false;
|
||||
public int $rows = 1;
|
||||
public bool $showScore = false;
|
||||
|
||||
private const UNKNOWN_COUNTRY_ID = 10;
|
||||
|
||||
public function mount(bool $related, bool $random = false, int $rows = 1): void
|
||||
{
|
||||
$this->related = $related;
|
||||
$this->random = $random;
|
||||
$this->rows = max(1, $rows);
|
||||
|
||||
$carouselCfg = timebank_config('calls.carousel', []);
|
||||
$locale = App::getLocale();
|
||||
|
||||
// --- Resolve active profile and its location ---
|
||||
$profile = getActiveProfile();
|
||||
if ($profile) {
|
||||
\App\Helpers\ProfileAuthorizationHelper::authorize($profile);
|
||||
}
|
||||
|
||||
$location = $profile?->locations ? $profile->locations()->first() : null;
|
||||
$profileCityId = $location ? ($location->city_id ?? $location->city?->id) : null;
|
||||
$profileDivisionId = $location ? ($location->division_id ?? $location->division?->id) : null;
|
||||
$profileCountryId = $location ? ($location->country_id ?? $location->country?->id) : null;
|
||||
|
||||
$locationCityIds = [];
|
||||
$locationDivisionIds = [];
|
||||
$locationCountryIds = [];
|
||||
|
||||
if ($location) {
|
||||
if (!$location->division && !$location->city) {
|
||||
$country = Location::find($location->id)->country;
|
||||
$locationCountryIds = $related
|
||||
? Country::pluck('id')->toArray()
|
||||
: ($country ? [$country->id] : []);
|
||||
} elseif ($location->division && !$location->city) {
|
||||
$divisionId = Location::find($location->id)->division->id;
|
||||
$locationDivisionIds = $related
|
||||
? Division::find($divisionId)->parent->divisions->pluck('id')->toArray()
|
||||
: [$divisionId];
|
||||
} elseif ($location->city) {
|
||||
$cityId = Location::find($location->id)->city->id;
|
||||
$locationCityIds = $related
|
||||
? City::find($cityId)->parent->cities->pluck('id')->toArray()
|
||||
: [$cityId];
|
||||
}
|
||||
}
|
||||
|
||||
// --- Base query ---
|
||||
$query = Call::with([
|
||||
'tag.contexts.category.translations',
|
||||
'tag.contexts.category.ancestors.translations',
|
||||
'translations',
|
||||
'location.city.translations',
|
||||
'location.country.translations',
|
||||
'callable.locations.city.translations',
|
||||
'callable.locations.division.translations',
|
||||
'callable.locations.country.translations',
|
||||
'callable.loveReactant.reactionCounters',
|
||||
'loveReactant.reactionCounters',
|
||||
])
|
||||
->whereNull('deleted_at')
|
||||
->where('is_paused', false)
|
||||
->where('is_suppressed', false)
|
||||
->where(fn ($q) => $q->whereNull('till')->orWhere('till', '>=', now()));
|
||||
|
||||
if (!Auth::check() || ($carouselCfg['exclude_non_public'] ?? true)) {
|
||||
$query->where('is_public', true);
|
||||
}
|
||||
|
||||
if ($profile && ($carouselCfg['exclude_own_calls'] ?? true)) {
|
||||
$query->where(function ($q) use ($profile) {
|
||||
$q->where('callable_type', '!=', get_class($profile))
|
||||
->orWhere('callable_id', '!=', $profile->id);
|
||||
});
|
||||
}
|
||||
|
||||
// --- Locality filter ---
|
||||
$includeUnknown = $carouselCfg['include_unknown_location'] ?? true;
|
||||
$includeDivision = $carouselCfg['include_same_division'] ?? true;
|
||||
$includeCountry = $carouselCfg['include_same_country'] ?? true;
|
||||
|
||||
$hasLocalityFilter = $locationCityIds || $locationDivisionIds || $locationCountryIds
|
||||
|| ($includeDivision && $locationDivisionIds)
|
||||
|| ($includeCountry && $locationCountryIds)
|
||||
|| $includeUnknown;
|
||||
|
||||
if ($hasLocalityFilter) {
|
||||
$query->where(function ($q) use (
|
||||
$locationCityIds, $locationDivisionIds, $locationCountryIds,
|
||||
$includeDivision, $includeCountry, $includeUnknown
|
||||
) {
|
||||
if ($locationCityIds) {
|
||||
$q->orWhereHas('location', fn ($lq) => $lq->whereIn('city_id', $locationCityIds));
|
||||
}
|
||||
if ($includeDivision && $locationDivisionIds) {
|
||||
$q->orWhereHas('location', fn ($lq) => $lq->whereIn('division_id', $locationDivisionIds));
|
||||
}
|
||||
if ($includeCountry && $locationCountryIds) {
|
||||
$q->orWhereHas('location', fn ($lq) => $lq->whereIn('country_id', $locationCountryIds));
|
||||
}
|
||||
if ($includeUnknown) {
|
||||
$q->orWhereHas('location', fn ($lq) => $lq->where('country_id', self::UNKNOWN_COUNTRY_ID));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$isAdmin = getActiveProfileType() === 'Admin';
|
||||
$this->showScore = (bool) ($carouselCfg['show_score'] ?? false)
|
||||
|| ($isAdmin && (bool) ($carouselCfg['show_score_for_admins'] ?? true));
|
||||
|
||||
$limit = $this->rows * 2;
|
||||
$poolSize = $limit * max(1, (int) ($carouselCfg['pool_multiplier'] ?? 5));
|
||||
|
||||
$scorer = new CallCarouselScorer(
|
||||
$carouselCfg,
|
||||
$profileCityId,
|
||||
$profileDivisionId,
|
||||
$profileCountryId
|
||||
);
|
||||
|
||||
$calls = $query->limit($poolSize)->get();
|
||||
|
||||
$this->calls = $calls->map(function (Call $call) use ($locale, $scorer) {
|
||||
$translation = $call->translations->firstWhere('locale', $locale)
|
||||
?? $call->translations->first();
|
||||
|
||||
$tag = $call->tag;
|
||||
$tagContext = $tag?->contexts->first();
|
||||
$tagCategory = $tagContext?->category;
|
||||
$tagColor = $tagCategory?->relatedColor ?? 'gray';
|
||||
$tagName = $tag?->translation?->name ?? $tag?->name;
|
||||
|
||||
$locationStr = null;
|
||||
if ($call->location) {
|
||||
$loc = $call->location;
|
||||
$parts = [];
|
||||
if ($loc->city) {
|
||||
$cityName = optional($loc->city->translations->first())->name;
|
||||
if ($cityName) $parts[] = $cityName;
|
||||
}
|
||||
if ($loc->country) {
|
||||
if ($loc->country->code === 'XX') {
|
||||
$parts[] = __('Location not specified');
|
||||
} elseif ($loc->country->code) {
|
||||
$parts[] = strtoupper($loc->country->code);
|
||||
}
|
||||
}
|
||||
$locationStr = $parts ? implode(', ', $parts) : null;
|
||||
}
|
||||
|
||||
$tagCategories = [];
|
||||
if ($tagCategory) {
|
||||
$ancestors = $tagCategory->ancestorsAndSelf()->get()->reverse();
|
||||
foreach ($ancestors as $cat) {
|
||||
$catName = $cat->translations->firstWhere('locale', $locale)?->name
|
||||
?? $cat->translations->first()?->name
|
||||
?? '';
|
||||
if ($catName) {
|
||||
$tagCategories[] = [
|
||||
'name' => $catName,
|
||||
'color' => $cat->relatedColor ?? 'gray',
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$score = $scorer->score($call);
|
||||
|
||||
if ($this->random) {
|
||||
$score *= (random_int(85, 115) / 100);
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $call->id,
|
||||
'model' => Call::class,
|
||||
'title' => $tagName ?? '',
|
||||
'excerpt' => $translation?->content ?? '',
|
||||
'photo' => $call->callable?->profile_photo_url ?? '',
|
||||
'location' => $locationStr,
|
||||
'tag_color' => $tagColor,
|
||||
'tag_categories' => $tagCategories,
|
||||
'callable_name' => $call->callable?->name ?? '',
|
||||
'callable_location' => ProfileCalls::buildCallableLocation($call->callable),
|
||||
'till' => $call->till,
|
||||
'expiry_badge_text' => ProfileCalls::buildExpiryBadgeText($call->till),
|
||||
'like_count' => $call->loveReactant?->reactionCounters
|
||||
->firstWhere('reaction_type_id', 3)?->count ?? 0,
|
||||
'score' => $score,
|
||||
];
|
||||
})
|
||||
->sortByDesc('score')
|
||||
->take($limit)
|
||||
->values()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.main-page.call-card-half');
|
||||
}
|
||||
}
|
||||
164
app/Http/Livewire/MainPage/EventCardFull.php
Normal file
164
app/Http/Livewire/MainPage/EventCardFull.php
Normal file
@@ -0,0 +1,164 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire\MainPage;
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\Locations\City;
|
||||
use App\Models\Locations\Country;
|
||||
use App\Models\Locations\Division;
|
||||
use App\Models\Locations\Location;
|
||||
use App\Models\Meeting;
|
||||
use App\Models\Post;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Livewire\Component;
|
||||
|
||||
class EventCardFull extends Component
|
||||
{
|
||||
public $author;
|
||||
public $post = [];
|
||||
public $posts;
|
||||
public $media;
|
||||
public $postNr;
|
||||
public $related;
|
||||
|
||||
|
||||
public function mount($postNr, $related, Request $request)
|
||||
{
|
||||
$this->postNr = $postNr;
|
||||
$this->related = $related;
|
||||
|
||||
$profile = getActiveProfile();
|
||||
|
||||
// SECURITY: If a profile is active, validate the user has access to it
|
||||
// This prevents session manipulation while allowing public access (null profile)
|
||||
if ($profile) {
|
||||
\App\Helpers\ProfileAuthorizationHelper::authorize($profile);
|
||||
}
|
||||
|
||||
$location = $profile && $profile->locations ? $profile->locations()->first() : null;
|
||||
|
||||
if ($location) {
|
||||
// If no division and no city as location set
|
||||
if (!$location->division && !$location->city) {
|
||||
$country = Location::find($location->id)->country;
|
||||
$categoryable_id = $country ? $country->id : null;
|
||||
$categoryable_type = Country::class;
|
||||
// Include also all other countries if $related is set in view
|
||||
if ($related) {
|
||||
$categoryable_id = Country::pluck('id');
|
||||
} else {
|
||||
$categoryable_id = [$categoryable_id];
|
||||
}
|
||||
// Division without city is set as location
|
||||
} elseif ($location && $location->division && !$location->city) {
|
||||
$categoryable_id = Location::find($location->id)->division->id;
|
||||
$categoryable_type = Division::class;
|
||||
// Include also all other divisions in the same country if $related is set in view
|
||||
if ($related) {
|
||||
$categoryable_id = Division::find($categoryable_id)->parent->divisions->pluck('id');
|
||||
} else {
|
||||
$categoryable_id = [$categoryable_id];
|
||||
}
|
||||
// City is set as location
|
||||
} elseif ($location && $location->city) {
|
||||
$categoryable_id = Location::find($location->id)->city->id;
|
||||
$categoryable_type = City::class;
|
||||
// Include also all other cities in the same division if $related is set in view
|
||||
if ($related) {
|
||||
$categoryable_id = City::find($categoryable_id)->parent->cities->pluck('id');
|
||||
} else {
|
||||
$categoryable_id = [$categoryable_id];
|
||||
}
|
||||
}
|
||||
// No matching location is set
|
||||
} else {
|
||||
$categoryable_id = [];
|
||||
$categoryable_type = '';
|
||||
}
|
||||
|
||||
$post =
|
||||
Post::with([
|
||||
'postable' => function ($query) {
|
||||
$query->select(['id', 'name']);
|
||||
},
|
||||
'category' => function ($query) use ($categoryable_id, $categoryable_type) {
|
||||
$query
|
||||
->where('type', Meeting::class)
|
||||
->where(function ($query) use ($categoryable_id, $categoryable_type) {
|
||||
$query
|
||||
->whereIn('categoryable_id', $categoryable_id)
|
||||
->where('categoryable_type', $categoryable_type);
|
||||
});
|
||||
},
|
||||
'translations' => function ($query) {
|
||||
$query
|
||||
->where('locale', App::getLocale())
|
||||
;
|
||||
},
|
||||
'meeting',
|
||||
'media',
|
||||
])
|
||||
->whereHas('category', function ($query) use ($categoryable_id, $categoryable_type) {
|
||||
$query
|
||||
->where('type', Meeting::class)
|
||||
->where(function ($query) use ($categoryable_id, $categoryable_type) {
|
||||
$query
|
||||
->whereIn('categoryable_id', $categoryable_id)
|
||||
->where('categoryable_type', $categoryable_type);
|
||||
});
|
||||
})
|
||||
->whereHas('translations', function ($query) {
|
||||
$query
|
||||
->where('locale', App::getLocale())
|
||||
->whereDate('from', '<=', now())
|
||||
->where(function ($query) {
|
||||
$query->whereDate('till', '>', now())->orWhereNull('till');
|
||||
})
|
||||
->orderBy('updated_at', 'desc');
|
||||
})
|
||||
->get()->sortBy(function ($query) {
|
||||
if (isset($query->meeting->from)) {
|
||||
return $query->meeting->from;
|
||||
};
|
||||
})->values(); // Use values() method to reset the collection keys after sortBy
|
||||
|
||||
$lastNr = $post->count() - 1;
|
||||
|
||||
if ($postNr > $lastNr) {
|
||||
$post = null;
|
||||
} else {
|
||||
$post = $post[$postNr];
|
||||
}
|
||||
|
||||
if (isset($post->translations)) {
|
||||
$this->post = $post->translations->first();
|
||||
$this->post['start'] = Carbon::parse($post->translations->first()->updated_at)->isoFormat('LL');
|
||||
$this->post['category'] = Category::find($post->category_id)->translations->where('locale', App::getLocale())->first()->name;
|
||||
$this->post['author'] = $post->postable->name;
|
||||
$this->post['id'] = $post->id;
|
||||
$this->post['model'] = get_class($post);
|
||||
$this->post['like_count'] = $post->like_count ?? 0;
|
||||
if ($post->meeting) {
|
||||
$this->post['address'] = ($post->meeting->address) ? $post->meeting->address : '';
|
||||
$this->post['from'] = ($post->meeting->from) ? $post->meeting->from : '';
|
||||
$this->post['venue'] = ($post->meeting->venue) ? $post->meeting->venue : '';
|
||||
$this->post['city'] = $post->meeting->location?->first()?->city?->translations?->first()?->name ?? '';
|
||||
$this->post['price'] = ($post->meeting->price) ? tbFormat($post->meeting->price) : '';
|
||||
}
|
||||
|
||||
if ($post->media) {
|
||||
$this->media = Post::find($post->id)->getFirstMedia('posts');
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.main-page.event-card-full');
|
||||
}
|
||||
}
|
||||
199
app/Http/Livewire/MainPage/ImageCardFull.php
Normal file
199
app/Http/Livewire/MainPage/ImageCardFull.php
Normal file
@@ -0,0 +1,199 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire\MainPage;
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\Locations\City;
|
||||
use App\Models\Locations\Country;
|
||||
use App\Models\Locations\Division;
|
||||
use App\Models\Locations\Location;
|
||||
use App\Models\News;
|
||||
use App\Models\Post;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Livewire\Component;
|
||||
|
||||
class ImageCardFull extends Component
|
||||
{
|
||||
public $author;
|
||||
public $post = [];
|
||||
public $posts;
|
||||
public $media;
|
||||
public $postNr;
|
||||
public $related;
|
||||
public bool $random = false;
|
||||
|
||||
|
||||
public function mount(Request $request, $postNr, $related, $random = null)
|
||||
{
|
||||
$this->postNr = $postNr;
|
||||
$this->related = $related;
|
||||
|
||||
if ($random) {
|
||||
$this->random = true;
|
||||
}
|
||||
|
||||
|
||||
$profile = getActiveProfile();
|
||||
|
||||
// SECURITY: If a profile is active, validate the user has access to it
|
||||
// This prevents session manipulation while allowing public access (null profile)
|
||||
if ($profile) {
|
||||
\App\Helpers\ProfileAuthorizationHelper::authorize($profile);
|
||||
}
|
||||
|
||||
$location = $profile && $profile->locations ? $profile->locations()->first() : null;
|
||||
|
||||
$skipLocationFilter = false;
|
||||
|
||||
if ($location) {
|
||||
// If no division and no city as location set
|
||||
if (!$location->division && !$location->city) {
|
||||
$categoryable_id = Location::find($location->id)->country->id;
|
||||
$categoryable_type = Country::class;
|
||||
// Include also all other countries if $related is set in view
|
||||
if ($related) {
|
||||
$categoryable_id = Country::pluck('id');
|
||||
} else {
|
||||
$categoryable_id = [$categoryable_id];
|
||||
}
|
||||
// Division without city is set as location
|
||||
} elseif ($location->division && !$location->city) {
|
||||
$categoryable_id = Location::find($location->id)->division->id;
|
||||
$categoryable_type = Division::class;
|
||||
// Include also all other divisions in the same country if $related is set in view
|
||||
if ($related) {
|
||||
$categoryable_id = Division::find($categoryable_id)->parent->divisions->pluck('id');
|
||||
} else {
|
||||
$categoryable_id = [$categoryable_id];
|
||||
}
|
||||
// City is set as location
|
||||
} elseif ($location->city) {
|
||||
$categoryable_id = Location::find($location->id)->city->id;
|
||||
$categoryable_type = City::class;
|
||||
// Include also all other cities in the same division if $related is set in view
|
||||
if ($related) {
|
||||
$categoryable_id = City::find($categoryable_id)->parent->cities->pluck('id');
|
||||
} else {
|
||||
$categoryable_id = [$categoryable_id];
|
||||
}
|
||||
}
|
||||
// No matching location is set - skip location filtering
|
||||
} else {
|
||||
$skipLocationFilter = true;
|
||||
$categoryable_id = [];
|
||||
$categoryable_type = '';
|
||||
}
|
||||
|
||||
|
||||
// TODO: check what happens when multiple locations per user are used!
|
||||
|
||||
$locale = App::getLocale();
|
||||
$categoryTypes = ['App\Models\ImagePost', 'App\Models\ImagePost\\' . $locale];
|
||||
|
||||
$post =
|
||||
Post::with([
|
||||
'postable' => function ($query) {
|
||||
$query->select(['id', 'name']);
|
||||
},
|
||||
'category' => function ($query) use ($categoryable_id, $categoryable_type, $skipLocationFilter, $categoryTypes) {
|
||||
$query->whereIn('type', $categoryTypes);
|
||||
if (!$skipLocationFilter && !empty($categoryable_id)) {
|
||||
$query->where(function ($query) use ($categoryable_id, $categoryable_type) {
|
||||
$query->where(function ($query) use ($categoryable_id, $categoryable_type) {
|
||||
$query
|
||||
->whereIn('categoryable_id', $categoryable_id)
|
||||
->where('categoryable_type', $categoryable_type);
|
||||
})->orWhere(function ($query) {
|
||||
// Also include categories with no location set
|
||||
$query->whereNull('categoryable_id')
|
||||
->whereNull('categoryable_type');
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
'translations' => function ($query) {
|
||||
$query->where('locale', App::getLocale());
|
||||
},
|
||||
'author',
|
||||
'media',
|
||||
])
|
||||
->whereHas('category', function ($query) use ($categoryable_id, $categoryable_type, $skipLocationFilter, $categoryTypes) {
|
||||
$query->whereIn('type', $categoryTypes);
|
||||
if (!$skipLocationFilter && !empty($categoryable_id)) {
|
||||
$query->where(function ($query) use ($categoryable_id, $categoryable_type) {
|
||||
$query->where(function ($query) use ($categoryable_id, $categoryable_type) {
|
||||
$query
|
||||
->whereIn('categoryable_id', $categoryable_id)
|
||||
->where('categoryable_type', $categoryable_type);
|
||||
})->orWhere(function ($query) {
|
||||
// Also include categories with no location set
|
||||
$query->whereNull('categoryable_id')
|
||||
->whereNull('categoryable_type');
|
||||
});
|
||||
});
|
||||
}
|
||||
})
|
||||
->whereHas('translations', function ($query) {
|
||||
$query
|
||||
->where('locale', App::getLocale())
|
||||
->whereDate('from', '<=', now())
|
||||
->where(function ($query) {
|
||||
$query->whereDate('till', '>', now())->orWhereNull('till');
|
||||
})
|
||||
->orderBy('updated_at', 'desc');
|
||||
});
|
||||
|
||||
// Apply random or sorted ordering
|
||||
if ($this->random) {
|
||||
$post = $post->inRandomOrder()->get();
|
||||
} else {
|
||||
$post = $post->get()->sortByDesc(function ($query) {
|
||||
if (isset($query->translations)) {
|
||||
return $query->translations->first()->updated_at;
|
||||
};
|
||||
})->values(); // Use values() method to reset the collection keys after sortBy
|
||||
}
|
||||
|
||||
$lastNr = $post->count() - 1;
|
||||
|
||||
if ($postNr > $lastNr || $post->isEmpty()) {
|
||||
$post = null;
|
||||
$this->post = null;
|
||||
} else {
|
||||
$post = $post[$postNr];
|
||||
}
|
||||
|
||||
if ($post && isset($post->translations) && $post->translations->isNotEmpty()) {
|
||||
$translation = $post->translations->first();
|
||||
$this->post = $translation;
|
||||
$this->post['slug'] = $translation->slug;
|
||||
$this->post['from'] = $translation->from;
|
||||
|
||||
$category = Category::find($post->category_id);
|
||||
$categoryTranslation = $category ? $category->translations->where('locale', App::getLocale())->first() : null;
|
||||
$this->post['category'] = $categoryTranslation ? $categoryTranslation->name : '';
|
||||
|
||||
$this->post['author'] = $post->author ? $post->author->name : timebank_config('posts.site-content-writer');
|
||||
$this->post['author_id'] = $post->author ? $post->author->id : null;
|
||||
$this->post['author_profile_photo_path'] = $post->author && $post->author->profile_photo_path ? $post->author->profile_photo_path : null;
|
||||
$this->post['post_id'] = $post->id;
|
||||
|
||||
if ($post->media && $post->media->isNotEmpty()) {
|
||||
$this->media = Post::find($post->id)->getFirstMedia('posts');
|
||||
|
||||
// Get media owner and caption
|
||||
$this->post['media_owner'] = $this->media->getCustomProperty('owner', '');
|
||||
$captionKey = 'caption-' . App::getLocale();
|
||||
$this->post['media_caption'] = $this->media->getCustomProperty($captionKey, '');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.main-page.image-card-full');
|
||||
}
|
||||
}
|
||||
199
app/Http/Livewire/MainPage/ImageLocalizedCardFull.php
Normal file
199
app/Http/Livewire/MainPage/ImageLocalizedCardFull.php
Normal file
@@ -0,0 +1,199 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire\MainPage;
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\Locations\City;
|
||||
use App\Models\Locations\Country;
|
||||
use App\Models\Locations\Division;
|
||||
use App\Models\Locations\Location;
|
||||
use App\Models\News;
|
||||
use App\Models\Post;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Livewire\Component;
|
||||
|
||||
class ImageLocalizedCardFull extends Component
|
||||
{
|
||||
public $author;
|
||||
public $post = [];
|
||||
public $posts;
|
||||
public $media;
|
||||
public $postNr;
|
||||
public $related;
|
||||
public bool $random = false;
|
||||
|
||||
|
||||
public function mount(Request $request, $postNr, $related, $random = null)
|
||||
{
|
||||
$this->postNr = $postNr;
|
||||
$this->related = $related;
|
||||
|
||||
if ($random) {
|
||||
$this->random = true;
|
||||
}
|
||||
|
||||
|
||||
$profile = getActiveProfile();
|
||||
|
||||
// SECURITY: If a profile is active, validate the user has access to it
|
||||
// This prevents session manipulation while allowing public access (null profile)
|
||||
if ($profile) {
|
||||
\App\Helpers\ProfileAuthorizationHelper::authorize($profile);
|
||||
}
|
||||
|
||||
$location = $profile && $profile->locations ? $profile->locations()->first() : null;
|
||||
|
||||
$skipLocationFilter = false;
|
||||
|
||||
if ($location) {
|
||||
// If no division and no city as location set
|
||||
if (!$location->division && !$location->city) {
|
||||
$categoryable_id = Location::find($location->id)->country->id;
|
||||
$categoryable_type = Country::class;
|
||||
// Include also all other countries if $related is set in view
|
||||
if ($related) {
|
||||
$categoryable_id = Country::pluck('id');
|
||||
} else {
|
||||
$categoryable_id = [$categoryable_id];
|
||||
}
|
||||
// Division without city is set as location
|
||||
} elseif ($location->division && !$location->city) {
|
||||
$categoryable_id = Location::find($location->id)->division->id;
|
||||
$categoryable_type = Division::class;
|
||||
// Include also all other divisions in the same country if $related is set in view
|
||||
if ($related) {
|
||||
$categoryable_id = Division::find($categoryable_id)->parent->divisions->pluck('id');
|
||||
} else {
|
||||
$categoryable_id = [$categoryable_id];
|
||||
}
|
||||
// City is set as location
|
||||
} elseif ($location->city) {
|
||||
$categoryable_id = Location::find($location->id)->city->id;
|
||||
$categoryable_type = City::class;
|
||||
// Include also all other cities in the same division if $related is set in view
|
||||
if ($related) {
|
||||
$categoryable_id = City::find($categoryable_id)->parent->cities->pluck('id');
|
||||
} else {
|
||||
$categoryable_id = [$categoryable_id];
|
||||
}
|
||||
}
|
||||
// No matching location is set - skip location filtering
|
||||
} else {
|
||||
$skipLocationFilter = true;
|
||||
$categoryable_id = [];
|
||||
$categoryable_type = '';
|
||||
}
|
||||
|
||||
|
||||
// TODO: check what happens when multiple locations per user are used!
|
||||
|
||||
$locale = App::getLocale();
|
||||
$categoryType = 'App\\Models\\ImagePost\\' . $locale;
|
||||
|
||||
$post =
|
||||
Post::with([
|
||||
'postable' => function ($query) {
|
||||
$query->select(['id', 'name']);
|
||||
},
|
||||
'category' => function ($query) use ($categoryable_id, $categoryable_type, $skipLocationFilter, $categoryType) {
|
||||
$query->where('type', $categoryType);
|
||||
if (!$skipLocationFilter && !empty($categoryable_id)) {
|
||||
$query->where(function ($query) use ($categoryable_id, $categoryable_type) {
|
||||
$query->where(function ($query) use ($categoryable_id, $categoryable_type) {
|
||||
$query
|
||||
->whereIn('categoryable_id', $categoryable_id)
|
||||
->where('categoryable_type', $categoryable_type);
|
||||
})->orWhere(function ($query) {
|
||||
// Also include categories with no location set
|
||||
$query->whereNull('categoryable_id')
|
||||
->whereNull('categoryable_type');
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
'translations' => function ($query) {
|
||||
$query->where('locale', App::getLocale());
|
||||
},
|
||||
'author',
|
||||
'media',
|
||||
])
|
||||
->whereHas('category', function ($query) use ($categoryable_id, $categoryable_type, $skipLocationFilter, $categoryType) {
|
||||
$query->where('type', $categoryType);
|
||||
if (!$skipLocationFilter && !empty($categoryable_id)) {
|
||||
$query->where(function ($query) use ($categoryable_id, $categoryable_type) {
|
||||
$query->where(function ($query) use ($categoryable_id, $categoryable_type) {
|
||||
$query
|
||||
->whereIn('categoryable_id', $categoryable_id)
|
||||
->where('categoryable_type', $categoryable_type);
|
||||
})->orWhere(function ($query) {
|
||||
// Also include categories with no location set
|
||||
$query->whereNull('categoryable_id')
|
||||
->whereNull('categoryable_type');
|
||||
});
|
||||
});
|
||||
}
|
||||
})
|
||||
->whereHas('translations', function ($query) {
|
||||
$query
|
||||
->where('locale', App::getLocale())
|
||||
->whereDate('from', '<=', now())
|
||||
->where(function ($query) {
|
||||
$query->whereDate('till', '>', now())->orWhereNull('till');
|
||||
})
|
||||
->orderBy('updated_at', 'desc');
|
||||
});
|
||||
|
||||
// Apply random or sorted ordering
|
||||
if ($this->random) {
|
||||
$post = $post->inRandomOrder()->get();
|
||||
} else {
|
||||
$post = $post->get()->sortByDesc(function ($query) {
|
||||
if (isset($query->translations)) {
|
||||
return $query->translations->first()->updated_at;
|
||||
};
|
||||
})->values(); // Use values() method to reset the collection keys after sortBy
|
||||
}
|
||||
|
||||
$lastNr = $post->count() - 1;
|
||||
|
||||
if ($postNr > $lastNr || $post->isEmpty()) {
|
||||
$post = null;
|
||||
$this->post = null;
|
||||
} else {
|
||||
$post = $post[$postNr];
|
||||
}
|
||||
|
||||
if ($post && isset($post->translations) && $post->translations->isNotEmpty()) {
|
||||
$translation = $post->translations->first();
|
||||
$this->post = $translation;
|
||||
$this->post['slug'] = $translation->slug;
|
||||
$this->post['from'] = $translation->from;
|
||||
|
||||
$category = Category::find($post->category_id);
|
||||
$categoryTranslation = $category ? $category->translations->where('locale', App::getLocale())->first() : null;
|
||||
$this->post['category'] = $categoryTranslation ? $categoryTranslation->name : '';
|
||||
|
||||
$this->post['author'] = $post->author ? $post->author->name : timebank_config('posts.site-content-writer');
|
||||
$this->post['author_id'] = $post->author ? $post->author->id : null;
|
||||
$this->post['author_profile_photo_path'] = $post->author && $post->author->profile_photo_path ? $post->author->profile_photo_path : null;
|
||||
$this->post['post_id'] = $post->id;
|
||||
|
||||
if ($post->media && $post->media->isNotEmpty()) {
|
||||
$this->media = Post::find($post->id)->getFirstMedia('posts');
|
||||
|
||||
// Get media owner and caption
|
||||
$this->post['media_owner'] = $this->media->getCustomProperty('owner', '');
|
||||
$captionKey = 'caption-' . App::getLocale();
|
||||
$this->post['media_caption'] = $this->media->getCustomProperty($captionKey, '');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.main-page.image-localized-card-full');
|
||||
}
|
||||
}
|
||||
183
app/Http/Livewire/MainPage/NewsCardFull.php
Normal file
183
app/Http/Livewire/MainPage/NewsCardFull.php
Normal file
@@ -0,0 +1,183 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire\MainPage;
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\Locations\City;
|
||||
use App\Models\Locations\Country;
|
||||
use App\Models\Locations\Division;
|
||||
use App\Models\Locations\Location;
|
||||
use App\Models\News;
|
||||
use App\Models\Post;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Livewire\Component;
|
||||
|
||||
class NewsCardFull extends Component
|
||||
{
|
||||
public $author;
|
||||
public $post = [];
|
||||
public $posts;
|
||||
public $media;
|
||||
public $postNr;
|
||||
public $related;
|
||||
|
||||
|
||||
public function mount($postNr, $related, Request $request)
|
||||
{
|
||||
$this->postNr = $postNr;
|
||||
$this->related = $related;
|
||||
|
||||
|
||||
$profile = getActiveProfile();
|
||||
|
||||
// SECURITY: If a profile is active, validate the user has access to it
|
||||
// This prevents session manipulation while allowing public access (null profile)
|
||||
if ($profile) {
|
||||
\App\Helpers\ProfileAuthorizationHelper::authorize($profile);
|
||||
}
|
||||
|
||||
$location = $profile && $profile->locations ? $profile->locations()->first() : null;
|
||||
|
||||
$skipLocationFilter = false;
|
||||
|
||||
if ($location) {
|
||||
// If no division and no city as location set
|
||||
if (!$location->division && !$location->city) {
|
||||
$categoryable_id = Location::find($location->id)->country->id;
|
||||
$categoryable_type = Country::class;
|
||||
// Include also all other countries if $related is set in view
|
||||
if ($related) {
|
||||
$categoryable_id = Country::pluck('id');
|
||||
} else {
|
||||
$categoryable_id = [$categoryable_id];
|
||||
}
|
||||
// Division without city is set as location
|
||||
} elseif ($location->division && !$location->city) {
|
||||
$categoryable_id = Location::find($location->id)->division->id;
|
||||
$categoryable_type = Division::class;
|
||||
// Include also all other divisions in the same country if $related is set in view
|
||||
if ($related) {
|
||||
$categoryable_id = Division::find($categoryable_id)->parent->divisions->pluck('id');
|
||||
} else {
|
||||
$categoryable_id = [$categoryable_id];
|
||||
}
|
||||
// City is set as location
|
||||
} elseif ($location->city) {
|
||||
$categoryable_id = Location::find($location->id)->city->id;
|
||||
$categoryable_type = City::class;
|
||||
// Include also all other cities in the same division if $related is set in view
|
||||
if ($related) {
|
||||
$categoryable_id = City::find($categoryable_id)->parent->cities->pluck('id');
|
||||
} else {
|
||||
$categoryable_id = [$categoryable_id];
|
||||
}
|
||||
}
|
||||
// No matching location is set - skip location filtering
|
||||
} else {
|
||||
$skipLocationFilter = true;
|
||||
$categoryable_id = [];
|
||||
$categoryable_type = '';
|
||||
}
|
||||
|
||||
|
||||
// TODO: check what happens when multiple locations per user are used!
|
||||
$post =
|
||||
Post::with([
|
||||
'postable' => function ($query) {
|
||||
$query->select(['id', 'name']);
|
||||
},
|
||||
'category' => function ($query) use ($categoryable_id, $categoryable_type, $skipLocationFilter) {
|
||||
$query->where('type', News::class)->with('categoryable.translations');
|
||||
if (!$skipLocationFilter && !empty($categoryable_id)) {
|
||||
$query->where(function ($query) use ($categoryable_id, $categoryable_type) {
|
||||
$query->where(function ($query) use ($categoryable_id, $categoryable_type) {
|
||||
$query
|
||||
->whereIn('categoryable_id', $categoryable_id)
|
||||
->where('categoryable_type', $categoryable_type);
|
||||
})->orWhere(function ($query) {
|
||||
// Also include categories with no location set
|
||||
$query->whereNull('categoryable_id')
|
||||
->whereNull('categoryable_type');
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
'translations' => function ($query) {
|
||||
$query->where('locale', App::getLocale());
|
||||
},
|
||||
'author',
|
||||
'media',
|
||||
])
|
||||
->whereHas('category', function ($query) use ($categoryable_id, $categoryable_type, $skipLocationFilter) {
|
||||
$query->where('type', News::class);
|
||||
if (!$skipLocationFilter && !empty($categoryable_id)) {
|
||||
$query->where(function ($query) use ($categoryable_id, $categoryable_type) {
|
||||
$query->where(function ($query) use ($categoryable_id, $categoryable_type) {
|
||||
$query
|
||||
->whereIn('categoryable_id', $categoryable_id)
|
||||
->where('categoryable_type', $categoryable_type);
|
||||
})->orWhere(function ($query) {
|
||||
// Also include categories with no location set
|
||||
$query->whereNull('categoryable_id')
|
||||
->whereNull('categoryable_type');
|
||||
});
|
||||
});
|
||||
}
|
||||
})
|
||||
->whereHas('translations', function ($query) {
|
||||
$query
|
||||
->where('locale', App::getLocale())
|
||||
->whereDate('from', '<=', now())
|
||||
->where(function ($query) {
|
||||
$query->whereDate('till', '>', now())->orWhereNull('till');
|
||||
})
|
||||
->orderBy('updated_at', 'desc');
|
||||
})
|
||||
->get()->sortByDesc(function ($query) {
|
||||
if (isset($query->translations)) {
|
||||
return $query->translations->first()->updated_at;
|
||||
};
|
||||
})->values(); // Use values() method to reset the collection keys after sortBy
|
||||
|
||||
|
||||
$lastNr = $post->count() - 1;
|
||||
if ($postNr > $lastNr) {
|
||||
$post = null;
|
||||
} else {
|
||||
$post = $post[$postNr];
|
||||
}
|
||||
|
||||
// if ($post != null) { // Show only posts if it has the category type of this model's class
|
||||
if (isset($post->translations)) {
|
||||
$translation = $post->translations->first();
|
||||
$this->post = $translation;
|
||||
$this->post['start'] = Carbon::parse(strtotime($translation->updated_at))->isoFormat('LL');
|
||||
$this->post['category'] = Category::find($post->category_id)->translations->where('locale', App::getLocale())->first()->name;
|
||||
$this->post['author'] = $post->author ? $post->author->name : timebank_config('posts.site-content-writer');
|
||||
|
||||
// Prepend location name to excerpt if available
|
||||
$excerpt = $translation->excerpt;
|
||||
if ($post->category && $post->category->categoryable && $post->category->categoryable->translations) {
|
||||
$locationTranslation = $post->category->categoryable->translations->where('locale', App::getLocale())->first();
|
||||
if ($locationTranslation && $locationTranslation->name) {
|
||||
$locationName = strtoupper($locationTranslation->name);
|
||||
$excerpt = $locationName . ' - ' . $excerpt;
|
||||
}
|
||||
}
|
||||
$this->post['excerpt'] = $excerpt;
|
||||
|
||||
if ($post->media) {
|
||||
$this->media = Post::find($post->id)->getFirstMedia('posts');
|
||||
}
|
||||
}
|
||||
// }
|
||||
}
|
||||
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.main-page.news-card-full');
|
||||
}
|
||||
}
|
||||
862
app/Http/Livewire/MainPage/SkillsCardFull.php
Normal file
862
app/Http/Livewire/MainPage/SkillsCardFull.php
Normal file
@@ -0,0 +1,862 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire\MainPage;
|
||||
|
||||
use App\Helpers\StringHelper;
|
||||
use App\Http\Livewire\MainPage;
|
||||
use App\Jobs\SendEmailNewTag;
|
||||
use App\Models\Category;
|
||||
use App\Models\Language;
|
||||
use App\Models\Tag;
|
||||
use App\Models\TaggableLocale;
|
||||
use App\Traits\TaggableWithLocale;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Livewire\Component;
|
||||
use Throwable;
|
||||
use WireUi\Traits\WireUiActions;
|
||||
|
||||
class SkillsCardFull extends Component
|
||||
{
|
||||
use TaggableWithLocale;
|
||||
use WireUiActions;
|
||||
|
||||
public $label;
|
||||
public $tagsArray = [];
|
||||
public bool $tagsArrayChanged = false;
|
||||
public bool $saveDisabled = false;
|
||||
public $initTagIds = [];
|
||||
public $initTagsArray = [];
|
||||
public $initTagsArrayTranslated = [];
|
||||
public $newTagsArray;
|
||||
public $suggestions = [];
|
||||
|
||||
public $modalVisible = false;
|
||||
|
||||
public $newTag = [];
|
||||
public $newTagCategory;
|
||||
public $categoryOptions = [];
|
||||
public $categoryColor = 'gray';
|
||||
|
||||
public bool $translationPossible = true;
|
||||
public bool $translationAllowed = true;
|
||||
public bool $translationVisible = false;
|
||||
public $translationLanguages = [];
|
||||
public $selectTranslationLanguage;
|
||||
public $translationOptions = [];
|
||||
public $selectTagTranslation;
|
||||
public $inputTagTranslation = [];
|
||||
public bool $inputDisabled = true;
|
||||
public $translateRadioButton = null;
|
||||
|
||||
public bool $sessionLanguageOk = false;
|
||||
public bool $sessionLanguageIgnored = false;
|
||||
public bool $transLanguageOk = false;
|
||||
public bool $transLanguageIgnored = false;
|
||||
|
||||
protected $langDetector = null;
|
||||
protected $listeners = [
|
||||
'save',
|
||||
'cancelCreateTag',
|
||||
'refreshComponent' => '$refresh',
|
||||
'tagifyFocus',
|
||||
'tagifyBlur',
|
||||
'saveCard'=> 'save',
|
||||
];
|
||||
|
||||
protected function rules()
|
||||
{
|
||||
return [
|
||||
'newTagsArray' => 'array',
|
||||
'newTag' => 'array',
|
||||
'newTag.name' => Rule::when(
|
||||
function ($input) {
|
||||
// Check if newTag is not an empty array
|
||||
return count($input['newTag']) > 0;
|
||||
},
|
||||
array_merge(
|
||||
timebank_config('tags.name_rule'),
|
||||
timebank_config('tags.exists_in_current_locale_rule'),
|
||||
[
|
||||
'sometimes',
|
||||
function ($attribute, $value, $fail) {
|
||||
if (!$this->sessionLanguageOk && !$this->sessionLanguageIgnored) {
|
||||
$locale = app()->getLocale();
|
||||
$localeName = \Locale::getDisplayName($locale, $locale);
|
||||
$fail(
|
||||
__('Is this :locale? Please confirm here below', [
|
||||
'locale' => $localeName
|
||||
])
|
||||
);
|
||||
}
|
||||
},
|
||||
]
|
||||
),
|
||||
),
|
||||
'newTagCategory' => Rule::when(
|
||||
function ($input) {
|
||||
if (count($input['newTag']) > 0 && $this->translationVisible === true && $this->translateRadioButton == 'input') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (count($input['newTag']) > 0 && $this->translationVisible === false) {
|
||||
return true;
|
||||
}
|
||||
},
|
||||
['required', 'int'],
|
||||
),
|
||||
'selectTagTranslation' => Rule::when(
|
||||
function ($input) {
|
||||
// Check if existing tag translation is selected
|
||||
return $this->translationVisible === true && $this->translateRadioButton == 'select';
|
||||
},
|
||||
['required', 'int'],
|
||||
),
|
||||
'inputTagTranslation' => 'array',
|
||||
'inputTagTranslation.name' => Rule::when(
|
||||
fn ($input) => $this->translationVisible === true && $this->translateRadioButton === 'input',
|
||||
array_merge(
|
||||
timebank_config('tags.name_rule'),
|
||||
timebank_config('tags.exists_in_current_locale_rule'),
|
||||
[
|
||||
'sometimes',
|
||||
function ($attribute, $value, $fail) {
|
||||
if (!$this->transLanguageOk && !$this->transLanguageIgnored) {
|
||||
$baseLocale = $this->selectTranslationLanguage;
|
||||
$locale = \Locale::getDisplayName($baseLocale, $baseLocale);
|
||||
$fail(__('Is this :locale? Please confirm here below', [
|
||||
'locale' => $locale
|
||||
]));
|
||||
}
|
||||
},
|
||||
function ($attribute, $value, $fail) {
|
||||
$existsInTransLationLanguage = DB::table('taggable_tags')
|
||||
->join('taggable_locales', 'taggable_tags.tag_id', '=', 'taggable_locales.taggable_tag_id')
|
||||
->where('taggable_locales.locale', $this->selectTranslationLanguage)
|
||||
->where(function ($query) use ($value) {
|
||||
$query->where('taggable_tags.name', $value)
|
||||
->orWhere('taggable_tags.normalized', $value);
|
||||
})
|
||||
->exists();
|
||||
|
||||
if ($existsInTransLationLanguage) {
|
||||
$fail(__('This tag already exists.'));
|
||||
}
|
||||
},
|
||||
]
|
||||
),
|
||||
[]
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
public function mount($label = null)
|
||||
{
|
||||
if ($label === null) {
|
||||
$label = __('Activities or skills you offer to other ' . platform_users());
|
||||
}
|
||||
$this->label = $label;
|
||||
|
||||
$owner = getActiveProfile();
|
||||
|
||||
if (!$owner) {
|
||||
abort(403, 'No active profile');
|
||||
}
|
||||
|
||||
// CRITICAL SECURITY: Validate user has ownership/access to this profile
|
||||
\App\Helpers\ProfileAuthorizationHelper::authorize($owner);
|
||||
|
||||
$owner->cleanTaggables();
|
||||
|
||||
$this->checkTranslationAllowed();
|
||||
$this->checkTranslationPossible();
|
||||
|
||||
$this->getSuggestions();
|
||||
$this->getInitialTags();
|
||||
$this->getLanguageDetector();
|
||||
$this->dispatch('load');
|
||||
}
|
||||
|
||||
|
||||
|
||||
protected function getSuggestions()
|
||||
{
|
||||
$suggestions = (new Tag())->localTagArray(app()->getLocale());
|
||||
|
||||
$this->suggestions = collect($suggestions)->map(function ($value) {
|
||||
return app()->getLocale() == 'de' ? $value : StringHelper::DutchTitleCase($value);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
protected function getInitialTags()
|
||||
{
|
||||
$this->initTagIds = getActiveProfile()->tags()->get()->pluck('tag_id');
|
||||
|
||||
$this->initTagsArray = TaggableLocale::whereIn('taggable_tag_id', $this->initTagIds)
|
||||
->select('taggable_tag_id', 'locale', 'updated_by_user')
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
$translatedTags = collect((new Tag())->translateTagIdsWithContexts($this->initTagIds));
|
||||
|
||||
$tags = $translatedTags->map(function ($item, $key) {
|
||||
return [
|
||||
'original_tag_id' => $item['original_tag_id'],
|
||||
'tag_id' => $item['tag_id'],
|
||||
'value' => $item['locale'] == App::getLocale() ? $item['tag'] : $item['tag'] . ' (' . strtoupper($item['locale']) . ')',
|
||||
'readonly' => false, // Tags are never readonly so the remove button is always visible
|
||||
'class' => $item['locale'] == App::getLocale() ? '' : 'tag-foreign-locale', // Mark foreign-locale tags with a class for diagonal stripe styling
|
||||
'locale' => $item['locale'],
|
||||
'category' => $item['category'],
|
||||
'category_path' => $item['category_path'],
|
||||
'category_color' => $item['category_color'],
|
||||
'title' => $item['category_path'], // 'title' is used by Tagify script for text that shows on hover
|
||||
'style' =>
|
||||
'--tag-bg:' .
|
||||
tailwindColorToHex($item['category_color'] . '-400') .
|
||||
'; --tag-text-color:#000' . // #111827 is gray-900
|
||||
'; --tag-hover:' .
|
||||
tailwindColorToHex($item['category_color'] . '-200'), // 'style' is used by Tagify script for background color, tailwindColorToHex is a helper function in app/Helpers/StyleHelper.php
|
||||
];
|
||||
});
|
||||
|
||||
$tags = $tags->sortBy('category_color')->values();
|
||||
$this->initTagsArrayTranslated = $tags->toArray();
|
||||
$this->tagsArray = json_encode($tags->toArray());
|
||||
}
|
||||
|
||||
|
||||
public function checkSessionLanguage()
|
||||
{
|
||||
// Ensure the language detector is initialized
|
||||
$this->getLanguageDetector();
|
||||
|
||||
$detectedLanguage = $this->langDetector->detectSimple($this->newTag['name']);
|
||||
if ($detectedLanguage === session('locale')) {
|
||||
$this->sessionLanguageOk = true;
|
||||
// No need to ignore language detection when session locale is detected
|
||||
$this->sessionLanguageIgnored = false;
|
||||
} else {
|
||||
$this->sessionLanguageOk = false;
|
||||
}
|
||||
|
||||
$this->validateOnly('newTag.name');
|
||||
}
|
||||
|
||||
|
||||
public function checkTransLanguage()
|
||||
{
|
||||
// Ensure the language detector is initialized
|
||||
$this->getLanguageDetector();
|
||||
$detectedLanguage = $this->langDetector->detectSimple($this->inputTagTranslation['name']);
|
||||
|
||||
if ($detectedLanguage === $this->selectTranslationLanguage) {
|
||||
$this->transLanguageOk = true;
|
||||
// No need to ignore language detection when base locale is detected
|
||||
$this->transLanguageIgnored = false;
|
||||
} else {
|
||||
$this->transLanguageOk = false;
|
||||
}
|
||||
$this->validateOnly('inputTagTranslation.name');
|
||||
}
|
||||
|
||||
|
||||
public function checkTranslationAllowed()
|
||||
{
|
||||
// Check if translations are allowed based on config and profile type
|
||||
$allowTranslations = timebank_config('tags.allow_tag_transations_for_non_admins', false);
|
||||
$profileType = getActiveProfileType();
|
||||
|
||||
// If config is false, only admins can add translations
|
||||
if (!$allowTranslations) {
|
||||
$this->translationAllowed = ($profileType === 'admin');
|
||||
} else {
|
||||
// If config is true, all profile types can add translations
|
||||
$this->translationAllowed = true;
|
||||
}
|
||||
}
|
||||
|
||||
public function checkTranslationPossible()
|
||||
{
|
||||
// Check if profile is capable to do any translations
|
||||
$countNonBaseLanguages = getActiveProfile()->languages()->where('lang_code', '!=', timebank_config('base_language'))->count();
|
||||
if ($countNonBaseLanguages === 0 && app()->getLocale() === timebank_config('base_language')) {
|
||||
$this->translationPossible = false;
|
||||
}
|
||||
}
|
||||
|
||||
protected function getLanguageDetector()
|
||||
{
|
||||
if (!$this->langDetector) {
|
||||
$this->langDetector = new \Text_LanguageDetect();
|
||||
$this->langDetector->setNameMode(2); // iso language code with 2 characters
|
||||
}
|
||||
return $this->langDetector;
|
||||
}
|
||||
|
||||
|
||||
public function updatedNewTagName()
|
||||
{
|
||||
$this->resetErrorBag('newTag.name');
|
||||
|
||||
// Check if name is the profiles's session's locale
|
||||
$this->checkSessionLanguage();
|
||||
// Only fall back to initTagsArray if newTagsArray has not been set yet,
|
||||
// to preserve any tags the user already added before opening the create modal
|
||||
if ($this->newTagsArray === null) {
|
||||
$this->newTagsArray = collect($this->initTagsArray);
|
||||
}
|
||||
}
|
||||
|
||||
public function updatedSessionLanguageIgnored()
|
||||
{
|
||||
if (!$this->sessionLanguageIgnored) {
|
||||
$this->checkSessionLanguage();
|
||||
}
|
||||
|
||||
// Revalidate the newTag.name field
|
||||
$this->validateOnly('newTag.name');
|
||||
}
|
||||
|
||||
|
||||
public function updatedTransLanguageIgnored()
|
||||
{
|
||||
if (!$this->transLanguageIgnored) {
|
||||
$this->checkTransLanguage();
|
||||
} else {
|
||||
$this->resetErrorBag('inputTagTranslation.name');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public function updatedSelectTranslationLanguage()
|
||||
{
|
||||
$this->selectTagTranslation = [];
|
||||
// Suggest related tags in the selected translation language
|
||||
$this->translationOptions = $this->getTranslationOptions($this->selectTranslationLanguage);
|
||||
}
|
||||
|
||||
|
||||
public function updatedNewTagCategory()
|
||||
{
|
||||
$this->categoryColor = collect($this->categoryOptions)
|
||||
->firstWhere('category_id', $this->newTagCategory)['color'] ?? 'gray';
|
||||
$this->selectTagTranslation = [];
|
||||
// Suggest related tags in the selected translation language
|
||||
$this->translationOptions = $this->getTranslationOptions($this->selectTranslationLanguage);
|
||||
$this->resetErrorBag('inputTagTranslationCategory');
|
||||
}
|
||||
|
||||
|
||||
public function updatedInputTagTranslationName()
|
||||
{
|
||||
$this->validateOnly('inputTagTranslation.name');
|
||||
}
|
||||
|
||||
|
||||
public function updatedTagsArray()
|
||||
{
|
||||
// Prevent save during updating cycle of the tagsArray
|
||||
$this->saveDisabled = true;
|
||||
$this->newTagsArray = collect(json_decode($this->tagsArray, true));
|
||||
|
||||
$localesToCheck = [app()->getLocale(), '']; // Only current locale and tags without locale should be checked for any new tag keywords
|
||||
$newTagsArrayLocal = $this->newTagsArray->whereIn('locale', $localesToCheck);
|
||||
// map suggestion to lower case for search normalization of the $newEntries
|
||||
$suggestions = collect($this->suggestions)->map(function ($value) {
|
||||
return strtolower($value);
|
||||
});
|
||||
// Retrieve new tag entries not present in suggestions
|
||||
$newEntries = $newTagsArrayLocal->filter(function ($newItem) use ($suggestions) {
|
||||
return !$suggestions->contains(strtolower($newItem['value']));
|
||||
});
|
||||
// Add a new tag modal if there are new entries
|
||||
if (count($newEntries) > 0) {
|
||||
|
||||
$this->newTag['name'] = app()->getLocale() == 'de' ? $newEntries->flatten()->first() : ucfirst($newEntries->flatten()->first());
|
||||
$this->categoryOptions = Category::where('type', Tag::class)
|
||||
->get()
|
||||
->map(function ($category) {
|
||||
// Include all attributes, including appended ones
|
||||
return [
|
||||
'category_id' => $category->id,
|
||||
'name' => ucfirst($category->translation->name ?? ''), // Use the appended 'translation' attribute
|
||||
'description' => $category->relatedPathExSelfTranslation ?? '', // Appended attribute
|
||||
'color' => $category->relatedColor ?? 'gray',
|
||||
];
|
||||
})
|
||||
->sortBy('name')
|
||||
->values();
|
||||
|
||||
// Open the create tag modal
|
||||
$this->modalVisible = true;
|
||||
|
||||
// For proper validation, this needs to be done after the netTag.name input of the modal is visible
|
||||
$this->checkSessionLanguage();
|
||||
|
||||
} else {
|
||||
$newEntries = false;
|
||||
|
||||
// Enable save button as updating cycle tagsArray is finished by now
|
||||
$this->saveDisabled = false;
|
||||
}
|
||||
$this->checkChangesTagsArray();
|
||||
}
|
||||
|
||||
|
||||
public function checkChangesTagsArray()
|
||||
{
|
||||
// Check if tagsArray has been changed, to display 'unsaved changes' message next to save button
|
||||
$initTagIds = collect($this->initTagIds);
|
||||
$newTagIds = $this->newTagsArray->pluck('tag_id');
|
||||
$diffFromNew = $newTagIds->diff($initTagIds);
|
||||
$diffFromInit = $initTagIds->diff($newTagIds);
|
||||
|
||||
if ($diffFromNew->isNotEmpty() || $diffFromInit->isNotEmpty()) {
|
||||
$this->tagsArrayChanged = true;
|
||||
} else {
|
||||
$this->tagsArrayChanged = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// When tagify raises focus, disable the save button
|
||||
public function tagifyFocus()
|
||||
{
|
||||
$this->saveDisabled = true;
|
||||
}
|
||||
|
||||
// When tagify looses focus, enable the save button
|
||||
public function tagifyBlur()
|
||||
{
|
||||
$this->saveDisabled = false;
|
||||
}
|
||||
|
||||
|
||||
|
||||
public function renderedModalVisible()
|
||||
{
|
||||
// Enable save button as updating cycle tagsArray is finished by now
|
||||
$this->saveDisabled = false;
|
||||
}
|
||||
|
||||
|
||||
public function updatedTranslationVisible()
|
||||
{
|
||||
if ($this->translationVisible && $this->translationAllowed) {
|
||||
$this->updatedNewTagCategory();
|
||||
|
||||
$profile = getActiveProfile();
|
||||
|
||||
if (!$profile) {
|
||||
abort(403, 'No active profile');
|
||||
}
|
||||
|
||||
// CRITICAL SECURITY: Validate user has ownership/access to this profile
|
||||
\App\Helpers\ProfileAuthorizationHelper::authorize($profile);
|
||||
|
||||
// Get all languages of the profile with good competence
|
||||
$this->translationLanguages = $profile
|
||||
->languages()
|
||||
->wherePivot('competence', 1)
|
||||
->where('lang_code', '!=', app()->getLocale())
|
||||
->get()
|
||||
->map(function ($language) {
|
||||
$language->name = trans($language->name); // Map the name property to a translation key
|
||||
return $language;
|
||||
});
|
||||
|
||||
// Make sure that always the base language is included even if the profile does not has it as a competence 1
|
||||
if (!$this->translationLanguages->contains('lang_code', 'en')) {
|
||||
$transLanguage = Language::where('lang_code', timebank_config('base_language'))->first();
|
||||
if ($transLanguage) {
|
||||
$transLanguage->name = trans($transLanguage->name); // Map the name property to a translation key
|
||||
// Add the base language to the translationLanguages collection
|
||||
$this->translationLanguages = collect($this->translationLanguages)
|
||||
->push($transLanguage);
|
||||
}
|
||||
|
||||
// Set the default selection to base language
|
||||
if (app()->getLocale() != timebank_config('base_language')) {
|
||||
$this->selectTranslationLanguage = timebank_config('base_language');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
public function updatedTranslateRadioButton()
|
||||
{
|
||||
if ($this->translateRadioButton === 'select') {
|
||||
$this->inputDisabled = true;
|
||||
$this->dispatch('disableInput');
|
||||
} elseif ($this->translateRadioButton === 'input') {
|
||||
$this->inputDisabled = false;
|
||||
// $this->dispatch('disableSelect'); // Script inside view skills-form.blade.php
|
||||
}
|
||||
$this->resetErrorBag('selectTagTranslation');
|
||||
$this->resetErrorBag('inputTagTranslation.name');
|
||||
$this->resetErrorBag('newTagCategory');
|
||||
}
|
||||
|
||||
|
||||
public function updatedSelectTagTranslation()
|
||||
{
|
||||
if ($this->selectTagTranslation) {
|
||||
$this->categoryColor = Tag::find($this->selectTagTranslation)->categories->first()->relatedColor ?? 'gray';
|
||||
$this->translateRadioButton = 'select';
|
||||
$this->dispatch('disableInput');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public function updatedInputTagTranslation()
|
||||
{
|
||||
$this->translateRadioButton = 'input';
|
||||
$this->inputDisabled = false;
|
||||
// $this->dispatch('disableSelect'); // Script inside view skills-form.blade.php
|
||||
$this->checkTransLanguage();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Updates the visibility of the modal. If the modal becomes invisible, dispatches the 'remove' event to remove the last value of the tags array on the front-end.
|
||||
*/
|
||||
public function updatedModalVisible()
|
||||
{
|
||||
if ($this->modalVisible == false) {
|
||||
$this->dispatch('remove'); // Removes last value of the tagsArray on front-end only
|
||||
$this->dispatch('reinitializeComponent');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Retrieves a list of related tags based on the specified category and locale.
|
||||
* Get all translation options in the choosen locale,
|
||||
* but exclude all tags already have a similar context in the current $appLocal.
|
||||
*
|
||||
* @param int|null $category The ID of the category to filter related tags. If null, all tags in the locale are suggested.
|
||||
* @param string|null $locale The locale to use for tag names. If not provided, the application's current locale is used.
|
||||
*
|
||||
* @return \Illuminate\Support\Collection A collection of tags containing 'tag_id' and 'name' keys, sorted by name.
|
||||
*/
|
||||
|
||||
public function getTranslationOptions($locale)
|
||||
{
|
||||
$appLocale = app()->getLocale();
|
||||
|
||||
// 1) Get all context_ids used by tags that match app()->getLocale()
|
||||
$contextIdsInAppLocale = DB::table('taggable_locale_context')
|
||||
->whereIn('tag_id', function ($query) use ($appLocale) {
|
||||
$query->select('taggable_tag_id')
|
||||
->from('taggable_locales')
|
||||
->where('locale', $appLocale);
|
||||
})
|
||||
->pluck('context_id');
|
||||
|
||||
// 2) Exclude tags that share these context_ids
|
||||
$tags = Tag::with(['locale', 'contexts.category'])
|
||||
->whereHas('locale', function ($query) use ($locale) {
|
||||
$query->where('locale', $locale);
|
||||
})
|
||||
->whereNotIn('tag_id', function ($subquery) use ($contextIdsInAppLocale) {
|
||||
$subquery->select('tag_id')
|
||||
->from('taggable_locale_context')
|
||||
->whereIn('context_id', $contextIdsInAppLocale);
|
||||
})
|
||||
->get();
|
||||
|
||||
// 3) Build the options array. Adjust the name logic to your preference.
|
||||
$options = $tags->map(function ($tag) use ($locale) {
|
||||
$category = optional($tag->contexts->first())->category;
|
||||
$description = optional(optional($category)->translation)->name ?? '';
|
||||
|
||||
return [
|
||||
'tag_id' => $tag->tag_id,
|
||||
'name' => $locale == 'de' ? $tag->name : StringHelper::DutchTitleCase($tag->normalized),
|
||||
'description' => $description,
|
||||
];
|
||||
})->sortBy('name')->values();
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Cancels the creation of a new tag by resetting error messages,
|
||||
* clearing input fields, hiding translation and modal visibility,
|
||||
* and resetting tag arrays to their initial state.
|
||||
*/
|
||||
public function cancelCreateTag()
|
||||
{
|
||||
$this->resetErrorBag();
|
||||
$this->newTag = [];
|
||||
$this->newTagCategory = null;
|
||||
$this->translationVisible = false;
|
||||
$this->translateRadioButton = null;
|
||||
$this->sessionLanguageOk = false;
|
||||
$this->sessionLanguageIgnored = false;
|
||||
$this->transLanguageOk = false;
|
||||
$this->transLanguageIgnored = false;
|
||||
$this->categoryColor = 'gray';
|
||||
$this->selectTagTranslation = null;
|
||||
$this->inputTagTranslation = [];
|
||||
$this->inputDisabled = true;
|
||||
// $this->newTagsArray = collect($this->initTagsArray);
|
||||
// $this->tagsArray = json_encode($this->initTagsArray);
|
||||
|
||||
// Remove last value of the tagsArray
|
||||
$tagsArray = is_string($this->tagsArray) ? json_decode($this->tagsArray, true) : $this->tagsArray;
|
||||
array_pop($tagsArray);
|
||||
$this->tagsArray = json_encode($tagsArray);
|
||||
|
||||
// Check of there were also other unsaved new tags in the tagsArray
|
||||
$hasNoTagId = false;
|
||||
if (is_array($tagsArray)) {
|
||||
$this->tagsArrayChanged = count(array_filter($tagsArray, function ($tag) {
|
||||
return !array_key_exists('tag_id', $tag) || $tag['tag_id'] === null;
|
||||
})) > 0;
|
||||
} else {
|
||||
$this->tagsArrayChanged = false;
|
||||
}
|
||||
|
||||
$this->modalVisible = false;
|
||||
$this->updatedModalVisible();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Handles the save button of the create tag modal.
|
||||
*
|
||||
* Creates a new tag for the currently active profile and optionally
|
||||
* associates it with a category or base-language translation.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function createTag()
|
||||
{
|
||||
// TODO: MAKE A TRANSACTION
|
||||
|
||||
$this->validate();
|
||||
$this->resetErrorBag();
|
||||
|
||||
// Format strings to correct case
|
||||
$this->newTag['name'] = app()->getLocale() == 'de' ? $this->newTag['name'] : StringHelper::DutchTitleCase($this->newTag['name']);
|
||||
|
||||
$name = $this->newTag['name'];
|
||||
$normalized = call_user_func(config('taggable.normalizer'), $name);
|
||||
|
||||
// Create the tag and attach the owner and context
|
||||
$tag = Tag::create([
|
||||
'name' => $name,
|
||||
'normalized' => $normalized,
|
||||
]);
|
||||
$owner = getActiveProfile();
|
||||
|
||||
if (!$owner) {
|
||||
abort(403, 'No active profile');
|
||||
}
|
||||
|
||||
// CRITICAL SECURITY: Validate user has ownership/access to this profile
|
||||
\App\Helpers\ProfileAuthorizationHelper::authorize($owner);
|
||||
$owner->tagById($tag->tag_id);
|
||||
$context = [
|
||||
'category_id' => $this->newTagCategory,
|
||||
'updated_by_user' => Auth::guard('web')->user()->id, // use the logged user, not the active profile
|
||||
];
|
||||
|
||||
if ($this->translationVisible) {
|
||||
if ($this->translateRadioButton === 'select') {
|
||||
// Attach an existing context in the base language to the new tag. See timebank_config('base_language')
|
||||
// Note that the category_id and updated_by_user is not updated when selecting an existing context
|
||||
$tagContext = Tag::find($this->selectTagTranslation)
|
||||
->contexts()
|
||||
->first();
|
||||
$tag->contexts()->attach($tagContext->id);
|
||||
} elseif ($this->translateRadioButton === 'input') {
|
||||
// Create a new context for the new tag
|
||||
$tagContext = $tag->contexts()->create($context);
|
||||
|
||||
// Create a new translation of the tag
|
||||
$this->inputTagTranslation['name'] = $this->selectTranslationLanguage == 'de' ? $this->inputTagTranslation['name'] : StringHelper::DutchTitleCase($this->inputTagTranslation['name']);
|
||||
// $owner->tag($this->inputTagTranslation['name']);
|
||||
$nameTranslation = $this->inputTagTranslation['name'];
|
||||
$normalizedTranslation = call_user_func(config('taggable.normalizer'), $nameTranslation);
|
||||
$locale = ['locale' => $this->selectTranslationLanguage ];
|
||||
|
||||
// Create the translation tag with the locale and attach the context
|
||||
$tagTranslation = Tag::create([
|
||||
'name' => $nameTranslation,
|
||||
'normalized' => $normalizedTranslation,
|
||||
]);
|
||||
$tagTranslation->locale()->create($locale);
|
||||
$tagTranslation->contexts()->attach($tagContext->id);
|
||||
|
||||
// The translation now has been recorded. Next, detach owner from this translation as only the locale tag should be attached to the owner
|
||||
$owner->untagById([$tagTranslation->tag_id]);
|
||||
// Also clean up owner's tags that have similar context but have different locale. Only the tag in owner's app()->getLocale() should remain in db.
|
||||
$owner->cleanTaggables(); // In TaggableWithLocale trait
|
||||
|
||||
}
|
||||
} else {
|
||||
// Create a new context for the new tag without translation
|
||||
$tagContext = $tag->contexts()->create($context);
|
||||
}
|
||||
|
||||
$this->modalVisible = false;
|
||||
$this->saveDisabled = false;
|
||||
// Attach the new collection of tags to the active profile
|
||||
$this->save();
|
||||
$this->tagsArrayChanged = false;
|
||||
|
||||
// Dispatch the SendEmailNewTag job
|
||||
SendEmailNewTag::dispatch($tag->tag_id);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Saves the newTagsArray: attaches the current tags to the profile model.
|
||||
* Ignores the tags that are marked read-only (no app locale and no base language locale).
|
||||
* Dispatches notification on success or error.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function save()
|
||||
{
|
||||
if ($this->saveDisabled === false) {
|
||||
|
||||
if ($this->newTagsArray) {
|
||||
try {
|
||||
// Use a transaction for saving skill tags
|
||||
DB::transaction(function () {
|
||||
// Make sure we can count newTag for conditional validation rules
|
||||
if ($this->newTag === null) {
|
||||
$this->newTag = [];
|
||||
}
|
||||
|
||||
$owner = getActiveProfile();
|
||||
|
||||
if (!$owner) {
|
||||
abort(403, 'No active profile');
|
||||
}
|
||||
|
||||
// CRITICAL SECURITY: Validate user has ownership/access to this profile
|
||||
\App\Helpers\ProfileAuthorizationHelper::authorize($owner);
|
||||
|
||||
$this->validate();
|
||||
// try {
|
||||
// $this->validate();
|
||||
// } catch (ValidationException $e) {
|
||||
// // Dump all validation errors to the log or screen
|
||||
// logger()->error('Validation failed', $e->errors());
|
||||
// dd($e->errors()); // or use dump() if you prefer
|
||||
// }
|
||||
$this->resetErrorBag();
|
||||
|
||||
$initTags = collect($this->initTagsArray)->pluck('taggable_tag_id');
|
||||
|
||||
$newTagsArray = collect($this->newTagsArray);
|
||||
|
||||
$newTags = $newTagsArray
|
||||
->where('tag_id', null)
|
||||
->pluck('value')->toArray();
|
||||
$owner->tag($newTags);
|
||||
|
||||
$remainingTags = $this->newTagsArray
|
||||
->where('tag_id')
|
||||
->pluck('tag_id')->toArray();
|
||||
|
||||
$removedTags = $initTags->diff($remainingTags)->toArray();
|
||||
$owner->untagById($removedTags);
|
||||
|
||||
// Finaly clean up taggables table: remove duplicate contexts and any orphaned taggables
|
||||
// In TaggableWithLocale trait
|
||||
$owner->cleanTaggables();
|
||||
|
||||
$owner->touch(); // Observer catches this and reindexes search index
|
||||
|
||||
// WireUI notification
|
||||
$this->notification()->success($title = __('Your have updated your profile successfully!'));
|
||||
});
|
||||
// end of transaction
|
||||
} catch (Throwable $e) {
|
||||
// WireUI notification
|
||||
// TODO!: create event to send error notification to admin
|
||||
$this->notification([
|
||||
'title' => __('Update failed!'),
|
||||
'description' => __('Sorry, your data could not be saved!') . '<br /><br />' . __('Our team has ben notified about this error. Please try again later.') . '<br /><br />' . $e->getMessage(),
|
||||
'icon' => 'error',
|
||||
'timeout' => 100000,
|
||||
]);
|
||||
}
|
||||
|
||||
$this->tagsArrayChanged = false;
|
||||
$this->dispatch('saved');
|
||||
$this->forgetCachedSkills();
|
||||
$this->cacheSkills();
|
||||
$this->initTagsArray = [];
|
||||
$this->newTag = null;
|
||||
$this->newTagsArray = null;
|
||||
$this->newTagCategory = null;
|
||||
$this->dispatch('refreshComponent');
|
||||
$this->dispatch('reinitializeTagify');
|
||||
$this->dispatch('reloadPage');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public function forgetCachedSkills()
|
||||
{
|
||||
// Get the profile type (user / organization) from the session and convert to lowercase
|
||||
$profileType = strtolower(basename(str_replace('\\', '/', session('activeProfileType'))));
|
||||
// Get the supported locales from the config
|
||||
$locales = config('app.supported_locales', [app()->getLocale()]);
|
||||
// Iterate over each locale and forget the cache
|
||||
foreach ($locales as $locale) {
|
||||
Cache::forget('skills-' . $profileType . '-' . session('activeProfileId') . '-lang-' . $locale);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public function cacheSkills()
|
||||
{
|
||||
$profileType = strtolower(basename(str_replace('\\', '/', session('activeProfileType')))); // Get the profile type (user / organization) from the session and convert to lowercase
|
||||
|
||||
$skillsCache = Cache::remember('skills-' . $profileType . '-' . session('activeProfileId') . '-lang-' . app()->getLocale(), now()->addDays(7), function () {
|
||||
// remember cache for 7 days
|
||||
$tagIds = session('activeProfileType')::find(session('activeProfileId'))->tags->pluck('tag_id');
|
||||
$translatedTags = collect((new Tag())->translateTagIdsWithContexts($tagIds, App::getLocale(), App::getFallbackLocale())); // Translate to app locale, if not available to fallback locale, if not available do not translate
|
||||
$skills = $translatedTags->map(function ($item, $key) {
|
||||
return [
|
||||
'original_tag_id' => $item['original_tag_id'],
|
||||
'tag_id' => $item['tag_id'],
|
||||
'name' => $item['tag'],
|
||||
'foreign' => $item['locale'] == App::getLocale() ? false : true, // Mark all tags in a foreign language read-only, as users need to switch locale to edit/update/etc foreign tags
|
||||
'locale' => $item['locale'],
|
||||
'category' => $item['category'],
|
||||
'category_path' => $item['category_path'],
|
||||
'category_color' => $item['category_color'],
|
||||
];
|
||||
});
|
||||
$skills = collect($skills);
|
||||
|
||||
return $skills;
|
||||
});
|
||||
|
||||
$this->tagsArray = json_encode($skillsCache->toArray());
|
||||
}
|
||||
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.main-page.skills-card-full');
|
||||
}
|
||||
}
|
||||
135
app/Http/Livewire/MainPost.php
Normal file
135
app/Http/Livewire/MainPost.php
Normal file
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire;
|
||||
|
||||
use App\Models\Post;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Livewire\Component;
|
||||
|
||||
class MainPost extends Component
|
||||
{
|
||||
public $type;
|
||||
public bool $sticky = false;
|
||||
public bool $random = false;
|
||||
public bool $latest = false;
|
||||
public $fallbackTitle = null;
|
||||
public $fallbackDescription = null;
|
||||
|
||||
public function mount($type, $sticky = null, $random = null, $latest = null, $fallbackTitle = null, $fallbackDescription = null)
|
||||
{
|
||||
$this->type = $type;
|
||||
$this->fallbackTitle = $fallbackTitle;
|
||||
$this->fallbackDescription = $fallbackDescription;
|
||||
|
||||
if ($sticky) {
|
||||
$this->sticky = true;
|
||||
}
|
||||
if ($random) {
|
||||
$this->random = true;
|
||||
}
|
||||
if ($latest) {
|
||||
$this->latest = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public function render()
|
||||
{
|
||||
// Sticky post
|
||||
if ($this->sticky) {
|
||||
$locale = App::getLocale();
|
||||
|
||||
$posts = Post::with([
|
||||
'category',
|
||||
'images' => function ($query) {
|
||||
$query->select('images.id', 'caption', 'path');
|
||||
},
|
||||
'translations' => function ($query) use ($locale) {
|
||||
$query->where('locale', $locale)
|
||||
->orderBy('created_at', 'desc')
|
||||
->limit(3);
|
||||
}
|
||||
])
|
||||
->whereHas('category', function ($query) {
|
||||
$query->where('type', $this->type);
|
||||
})
|
||||
->whereHas('translations', function ($query) use ($locale) {
|
||||
$query->where('locale', $locale)
|
||||
->whereDate('from', '<=', now())
|
||||
->where(function ($query) {
|
||||
$query->whereDate('till', '>', now())->orWhereNull('till');
|
||||
})
|
||||
->orderBy('updated_at', 'desc');
|
||||
})
|
||||
->orderBy('created_at', 'desc')
|
||||
->limit(3)
|
||||
->first();
|
||||
}
|
||||
|
||||
|
||||
// Random post
|
||||
if ($this->random) {
|
||||
$locale = App::getLocale();
|
||||
|
||||
$posts = Post::with([
|
||||
'category',
|
||||
'translations' => function ($query) use ($locale) {
|
||||
$query->where('locale', $locale)
|
||||
->orderBy('created_at', 'desc')
|
||||
->limit(1);
|
||||
}
|
||||
])
|
||||
->whereHas('category', function ($query) {
|
||||
$query->where('type', $this->type);
|
||||
})
|
||||
->whereHas('translations', function ($query) use ($locale) {
|
||||
$query->where('locale', $locale)
|
||||
->whereDate('from', '<=', now())
|
||||
->where(function ($query) {
|
||||
$query->whereDate('till', '>', now())->orWhereNull('till');
|
||||
})
|
||||
->orderBy('updated_at', 'desc');
|
||||
})
|
||||
->inRandomOrder() // This replaces the orderBy() method
|
||||
->first();
|
||||
}
|
||||
|
||||
// Latest post
|
||||
if ($this->latest) {
|
||||
$locale = App::getLocale();
|
||||
|
||||
$posts = Post::with([
|
||||
'category',
|
||||
'translations' => function ($query) use ($locale) {
|
||||
$query->where('locale', $locale)
|
||||
->orderBy('created_at', 'desc')
|
||||
->limit(1);
|
||||
}
|
||||
])
|
||||
->whereHas('category', function ($query) {
|
||||
$query->where('type', $this->type);
|
||||
})
|
||||
->whereHas('translations', function ($query) use ($locale) {
|
||||
$query->where('locale', $locale)
|
||||
->whereDate('from', '<=', now())
|
||||
->where(function ($query) {
|
||||
$query->whereDate('till', '>', now())->orWhereNull('till');
|
||||
})
|
||||
->orderBy('updated_at', 'desc');
|
||||
})
|
||||
->orderBy('created_at', 'desc')
|
||||
->first();
|
||||
}
|
||||
|
||||
|
||||
$image = null;
|
||||
if ($posts && $posts->hasMedia('*')) {
|
||||
$image = $posts->getFirstMediaUrl('*', 'half_hero');
|
||||
}
|
||||
|
||||
return view('livewire.main-post', [
|
||||
'posts' => $posts,
|
||||
'image' => $image,
|
||||
]);
|
||||
}
|
||||
}
|
||||
1555
app/Http/Livewire/MainSearchBar.php
Normal file
1555
app/Http/Livewire/MainSearchBar.php
Normal file
File diff suppressed because it is too large
Load Diff
18
app/Http/Livewire/NavigationMenuGuest.php
Normal file
18
app/Http/Livewire/NavigationMenuGuest.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire;
|
||||
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Component;
|
||||
|
||||
class NavigationMenuGuest extends Component
|
||||
{
|
||||
protected $listeners = ['authStateChanged' => '$refresh'];
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.navigation-menu-guest', [
|
||||
'isAuthenticated' => Auth::check()
|
||||
]);
|
||||
}
|
||||
}
|
||||
20
app/Http/Livewire/Notification.php
Normal file
20
app/Http/Livewire/Notification.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire;
|
||||
|
||||
use Livewire\Component;
|
||||
|
||||
class Notification extends Component
|
||||
{
|
||||
public function dehydrate()
|
||||
{
|
||||
// Clear the session key after the component is rendered
|
||||
session()->forget('notification');
|
||||
}
|
||||
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.notification');
|
||||
}
|
||||
}
|
||||
40
app/Http/Livewire/NotifyEmailVerified.php
Normal file
40
app/Http/Livewire/NotifyEmailVerified.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire;
|
||||
|
||||
use Livewire\Component;
|
||||
use WireUi\Traits\WireUiActions;
|
||||
|
||||
|
||||
class NotifyEmailVerified extends Component
|
||||
{
|
||||
use WireUiActions;
|
||||
|
||||
|
||||
public function mount()
|
||||
{
|
||||
$this->notify();
|
||||
}
|
||||
|
||||
public function notify()
|
||||
{
|
||||
// WireUI notification
|
||||
|
||||
$this->notification()->success(
|
||||
$title = __('Email verified'),
|
||||
$description = __('Your email has been verified successfully')
|
||||
);
|
||||
}
|
||||
|
||||
public function dehydrate()
|
||||
{
|
||||
// Clear the session key after the component is rendered
|
||||
session()->forget('email-verified');
|
||||
session()->forget('email-profile');
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.notify-email-verified');
|
||||
}
|
||||
}
|
||||
66
app/Http/Livewire/NotifySwitchProfile.php
Normal file
66
app/Http/Livewire/NotifySwitchProfile.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire;
|
||||
|
||||
use Livewire\Component;
|
||||
use WireUi\Traits\WireUiActions;
|
||||
|
||||
|
||||
class NotifySwitchProfile extends Component
|
||||
{
|
||||
use WireUiActions;
|
||||
|
||||
|
||||
public function mount()
|
||||
{
|
||||
$this->notify();
|
||||
}
|
||||
|
||||
public function notify()
|
||||
{
|
||||
// WireUI notification
|
||||
|
||||
$this->notification()->success(
|
||||
$title = __('Profile switch'),
|
||||
$description = __('Your profile has been switched successfully')
|
||||
);
|
||||
|
||||
// Check if the profile's email needs verification
|
||||
$this->checkEmailVerification();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current profile's email needs verification and show a warning
|
||||
*/
|
||||
private function checkEmailVerification()
|
||||
{
|
||||
// Don't show unverified warning if we just verified the email
|
||||
if (session('email-verified')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$currentProfile = getActiveProfile();
|
||||
|
||||
// Check if profile implements MustVerifyEmail and has unverified email
|
||||
if ($currentProfile instanceof \Illuminate\Contracts\Auth\MustVerifyEmail && !$currentProfile->hasVerifiedEmail()) {
|
||||
$profileName = $currentProfile->name ?? __('Your profile');
|
||||
|
||||
$this->notification()->warning(
|
||||
$title = __('Email Verification Required'),
|
||||
$description = __('The email address of :profile_name is unverified. Please verify it to ensure you can receive important notifications.', ['profile_name' => $profileName])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public function dehydrate()
|
||||
{
|
||||
// Clear the session key after the component is rendered
|
||||
session()->forget('profile-switched-notification');
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.notify-switch-profile');
|
||||
}
|
||||
}
|
||||
39
app/Http/Livewire/NotifyUnauthorizedAction.php
Normal file
39
app/Http/Livewire/NotifyUnauthorizedAction.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire;
|
||||
|
||||
use Livewire\Component;
|
||||
use WireUi\Traits\WireUiActions;
|
||||
|
||||
|
||||
class NotifyUnauthorizedAction extends Component
|
||||
{
|
||||
use WireUiActions;
|
||||
|
||||
|
||||
public function mount()
|
||||
{
|
||||
$this->notify();
|
||||
}
|
||||
|
||||
public function notify()
|
||||
{
|
||||
// WireUI notification
|
||||
|
||||
$this->notification()->warning(
|
||||
$title = __('Unauthorized action'),
|
||||
$description = session('unauthorizedAction'),
|
||||
);
|
||||
}
|
||||
|
||||
public function dehydrate()
|
||||
{
|
||||
// Clear the session key after the component is rendered
|
||||
session()->forget('unauthorizedAction');
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.notify-unauthorized-action');
|
||||
}
|
||||
}
|
||||
546
app/Http/Livewire/OnlineReactedProfiles.php
Normal file
546
app/Http/Livewire/OnlineReactedProfiles.php
Normal file
@@ -0,0 +1,546 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire;
|
||||
|
||||
use App\Services\PresenceService;
|
||||
use Cog\Laravel\Love\ReactionType\Models\ReactionType;
|
||||
use Cog\Laravel\Love\Reaction\Models\Reaction;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Component;
|
||||
|
||||
class OnlineReactedProfiles extends Component
|
||||
{
|
||||
// Configuration properties
|
||||
public $reactionTypes = []; // ['Bookmark', 'Star', 'Like'] or single 'Bookmark'
|
||||
public $guards = ['web']; // Which guards to check for online users
|
||||
public $authGuard; // Current user's guard
|
||||
public $showCount = true;
|
||||
public $showAvatars = true;
|
||||
public $maxDisplay = 20;
|
||||
public $headerText; // Header text for the component
|
||||
public $refreshInterval = 10;
|
||||
public $groupByModel = true; // Group results by model type
|
||||
public $lastSeen = false; // Show last seen time
|
||||
public $modelLabels = [
|
||||
'App\Models\User' => 'Persons',
|
||||
'App\Models\Organization' => 'Organizations',
|
||||
'App\Models\Bank' => 'Banks',
|
||||
'App\Models\Admin' => 'Admins',
|
||||
];
|
||||
|
||||
// Data properties
|
||||
public $onlineReactedProfiles = [];
|
||||
public $totalCount = 0;
|
||||
public $countByType = [];
|
||||
|
||||
// Logout modal properties
|
||||
public $showLogoutModal = false;
|
||||
public $selectedProfileId = null;
|
||||
public $selectedProfileType = null;
|
||||
public $selectedProfileName = null;
|
||||
|
||||
public function mount(
|
||||
$reactionTypes = null,
|
||||
$guards = null,
|
||||
$showCount = true,
|
||||
$showAvatars = true,
|
||||
$maxDisplay = 20,
|
||||
$header = true,
|
||||
$headerText = null,
|
||||
$groupByModel = false,
|
||||
$lastSeen = false
|
||||
) {
|
||||
// Setup reaction types
|
||||
if ($reactionTypes !== null) {
|
||||
if (is_string($reactionTypes)) {
|
||||
$this->reactionTypes = array_map('trim', explode(',', $reactionTypes));
|
||||
} else {
|
||||
$this->reactionTypes = is_array($reactionTypes) ? $reactionTypes : [$reactionTypes];
|
||||
}
|
||||
} else {
|
||||
// Keep as null to show all online users without filtering
|
||||
$this->reactionTypes = null;
|
||||
}
|
||||
|
||||
// Setup guards to check
|
||||
if ($guards) {
|
||||
$this->guards = is_array($guards) ? $guards : [$guards];
|
||||
} else {
|
||||
// Check all guards by default
|
||||
$this->guards = ['web', 'organization', 'bank'];
|
||||
}
|
||||
|
||||
// Determine current auth guard
|
||||
$this->authGuard = session('active_guard') ?? 'web';
|
||||
|
||||
// Set display options
|
||||
$this->showCount = $showCount;
|
||||
$this->showAvatars = $showAvatars;
|
||||
$this->maxDisplay = $maxDisplay;
|
||||
$this->groupByModel = $groupByModel;
|
||||
$this->lastSeen = $lastSeen;
|
||||
|
||||
// Load initial data
|
||||
$this->loadOnlineReactedProfiles();
|
||||
|
||||
|
||||
// Set header text if required
|
||||
if ($header) {
|
||||
if ($headerText) {
|
||||
$this->headerText = $headerText;
|
||||
} else {
|
||||
$this->headerText = $this->getHeaderText($this->reactionTypes);
|
||||
}
|
||||
} else {
|
||||
$this->headerText = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the header text based on reaction types.
|
||||
* If no reaction types, return a default message.
|
||||
*/
|
||||
public function getHeaderText($reactionTypes)
|
||||
{
|
||||
if ($this->reactionTypes === null) {
|
||||
// Show all online users
|
||||
return trans_choice('messages.profiles_online', $this->totalCount, ['count' => $this->totalCount]);
|
||||
} elseif (!empty($reactionTypes)) {
|
||||
if (count($reactionTypes) === 1) {
|
||||
return trans_choice('messages.' . strtolower($reactionTypes[0]) . '_contacts_online', $this->totalCount, ['count' => $this->totalCount]);
|
||||
} elseif (count($reactionTypes) > 1) {
|
||||
return trans_choice('messages.reactions_contacts_online', $this->totalCount, ['count' => $this->totalCount]);
|
||||
}
|
||||
}
|
||||
return 'Error: no reaction types found.';
|
||||
}
|
||||
|
||||
|
||||
|
||||
public function loadOnlineReactedProfiles()
|
||||
{
|
||||
try {
|
||||
// Get authenticated user/profile
|
||||
$authUser = auth($this->authGuard)->user();
|
||||
|
||||
if (!$authUser) {
|
||||
$this->onlineReactedProfiles = [];
|
||||
$this->totalCount = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
// Only require viaLoveReacter if filtering by reactions
|
||||
if ($this->reactionTypes !== null && !method_exists($authUser, 'viaLoveReacter')) {
|
||||
$this->onlineReactedProfiles = [];
|
||||
$this->totalCount = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
// Collect all online users from specified guards
|
||||
$allOnlineProfiles = collect();
|
||||
|
||||
foreach ($this->guards as $guard) {
|
||||
try {
|
||||
$presenceService = app(PresenceService::class);
|
||||
$onlineUsers = $presenceService->getOnlineUsers($guard);
|
||||
|
||||
foreach ($onlineUsers as $userData) {
|
||||
// Get the actual model instance
|
||||
$model = $this->getModelFromUserData($userData, $guard);
|
||||
|
||||
// If filtering by reactions, check for love reactant trait
|
||||
// If not filtering (reactionTypes is null), just check if model exists
|
||||
if ($model && ($this->reactionTypes === null || method_exists($model, 'viaLoveReactant'))) {
|
||||
$profileData = $this->buildProfileData($model, $userData, $guard);
|
||||
|
||||
if ($profileData) {
|
||||
$allOnlineProfiles->push($profileData);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
\Log::error('Error loading online profiles for guard ' . $guard . ': ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Filter profiles based on whether we're filtering by reactions or not
|
||||
if ($this->reactionTypes === null) {
|
||||
// Show all online profiles without reaction filtering
|
||||
$reactedProfiles = $allOnlineProfiles;
|
||||
} else {
|
||||
// Filter profiles that have been reacted to
|
||||
$reactedProfiles = $allOnlineProfiles->filter(function ($profile) {
|
||||
return $this->hasAuthUserReacted($profile['model']);
|
||||
});
|
||||
}
|
||||
|
||||
// Apply display limit
|
||||
$limitedProfiles = $reactedProfiles->take($this->maxDisplay);
|
||||
|
||||
// Group by model if requested
|
||||
if ($this->groupByModel) {
|
||||
$this->onlineReactedProfiles = $limitedProfiles
|
||||
->groupBy('model_type')
|
||||
->toArray();
|
||||
} else {
|
||||
$this->onlineReactedProfiles = $limitedProfiles->values()->toArray();
|
||||
}
|
||||
|
||||
// Update counts
|
||||
$this->totalCount = $reactedProfiles->count();
|
||||
$this->countByType = $reactedProfiles->groupBy('model_type')->map->count()->toArray();
|
||||
|
||||
// Dispatch event for other components
|
||||
$this->dispatch('online-reacted-profiles-updated', [
|
||||
'count' => $this->totalCount,
|
||||
'profiles' => $this->onlineReactedProfiles
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
\Log::error('Error loading online reacted profiles: ' . $e->getMessage());
|
||||
$this->onlineReactedProfiles = [];
|
||||
$this->totalCount = 0;
|
||||
}
|
||||
}
|
||||
|
||||
protected function getModelFromUserData($userData, $guard)
|
||||
{
|
||||
try {
|
||||
// Get the model class for this guard
|
||||
$modelClass = $this->getModelClassForGuard($guard);
|
||||
|
||||
if (!$modelClass) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Handle both array and object formats
|
||||
$userId = is_array($userData) ? ($userData['id'] ?? null) : ($userData->id ?? null);
|
||||
|
||||
if (!$userId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $modelClass::find($userId);
|
||||
} catch (\Exception $e) {
|
||||
\Log::error('Error getting model from user data: ' . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
protected function getModelClassForGuard($guard)
|
||||
{
|
||||
// Map guards to model classes
|
||||
$guardModelMap = [
|
||||
'web' => \App\Models\User::class,
|
||||
'organization' => \App\Models\Organization::class,
|
||||
'bank' => \App\Models\Bank::class,
|
||||
'admin' => \App\Models\Admin::class,
|
||||
];
|
||||
|
||||
return $guardModelMap[$guard] ?? null;
|
||||
}
|
||||
|
||||
protected function buildProfileData($model, $userData, $guard)
|
||||
{
|
||||
try {
|
||||
// Get reactions for this profile
|
||||
$reactions = $this->getReactionsForProfile($model);
|
||||
// Build profile data array
|
||||
return [
|
||||
'id' => $model->id,
|
||||
'model' => $model,
|
||||
'model_type' => get_class($model),
|
||||
'model_label' => $this->modelLabels[get_class($model)] ?? class_basename($model),
|
||||
'guard' => $guard,
|
||||
'name' => $model->name ?? 'Unknown',
|
||||
'location' => $model->getLocationFirst()['name_short'] ?? '',
|
||||
'avatar' => $model->profile_photo_path ?? null,
|
||||
'last_seen' => is_array($userData) ?
|
||||
($userData['last_seen'] ?? now()) : ($userData->last_seen ?? now()),
|
||||
'reactions' => $reactions,
|
||||
'profile_url' => route('profile.show_by_type_and_id', ['type' => __(strtolower(class_basename($model))), 'id' => $model->id]),
|
||||
'is_online' => true,
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
\Log::error('Error building profile data: ' . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
protected function hasAuthUserReacted($model)
|
||||
{
|
||||
try {
|
||||
// If no reaction types specified, return true (show all)
|
||||
if ($this->reactionTypes === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!method_exists($model, 'viaLoveReactant')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$reactantFacade = $model->viaLoveReactant();
|
||||
|
||||
// Get the authenticated user directly
|
||||
$authUser = auth($this->authGuard)->user();
|
||||
if (!$authUser) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if empty array (different from null)
|
||||
if (is_array($this->reactionTypes) && empty($this->reactionTypes)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($this->reactionTypes as $reactionType) {
|
||||
if ($reactantFacade->isReactedBy($authUser, $reactionType)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (\Exception $e) {
|
||||
\Log::error('Error checking reactions: ' . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
protected function getReactionsForProfile($model)
|
||||
{
|
||||
try {
|
||||
if (!method_exists($model, 'viaLoveReactant')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$reactantFacade = $model->viaLoveReactant();
|
||||
$reactions = [];
|
||||
|
||||
// Get the authenticated user directly
|
||||
$authUser = auth($this->authGuard)->user();
|
||||
if (!$authUser) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if ($this->reactionTypes !== null) {
|
||||
foreach ($this->reactionTypes as $reactionType) {
|
||||
if ($reactantFacade->isReactedBy($authUser, $reactionType)) {
|
||||
$reactions[] = [
|
||||
'type' => $reactionType,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $reactions;
|
||||
} catch (\Exception $e) {
|
||||
\Log::error('Error getting reactions for profile: ' . $e->getMessage());
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public function toggleReaction($profileId, $modelType, $reactionType)
|
||||
{
|
||||
try {
|
||||
$authUser = auth($this->authGuard)->user();
|
||||
if (!$authUser) {
|
||||
return;
|
||||
}
|
||||
|
||||
$model = $modelType::find($profileId);
|
||||
if (!$model) {
|
||||
return;
|
||||
}
|
||||
|
||||
$reacterFacade = $authUser->viaLoveReacter();
|
||||
$reactantFacade = $model->viaLoveReactant();
|
||||
|
||||
if ($reactantFacade->isReactedBy($authUser, $reactionType)) {
|
||||
$reacterFacade->unreactTo($model, $reactionType);
|
||||
} else {
|
||||
$reacterFacade->reactTo($model, $reactionType);
|
||||
}
|
||||
|
||||
$this->loadOnlineReactedProfiles();
|
||||
} catch (\Exception $e) {
|
||||
\Log::error('Error toggling reaction: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
#[On('presence-updated')]
|
||||
public function refreshProfiles()
|
||||
{
|
||||
$this->loadOnlineReactedProfiles();
|
||||
}
|
||||
|
||||
#[On('user-activity')]
|
||||
public function onUserActivity()
|
||||
{
|
||||
$this->loadOnlineReactedProfiles();
|
||||
}
|
||||
|
||||
public function isCurrentUserProfile($profileId, $modelType)
|
||||
{
|
||||
// Get the active profile using getActiveProfile() helper
|
||||
$activeProfile = getActiveProfile();
|
||||
|
||||
if (!$activeProfile) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if this is the current active profile (admin or any other type)
|
||||
if (get_class($activeProfile) === $modelType || get_class($activeProfile) === '\\' . $modelType) {
|
||||
if ($activeProfile->id === $profileId) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// If the active profile is an Admin, check for related users
|
||||
if (get_class($activeProfile) === \App\Models\Admin::class) {
|
||||
if (method_exists($activeProfile, 'users')) {
|
||||
$relatedUsers = $activeProfile->users()->pluck('users.id')->toArray();
|
||||
|
||||
if ($modelType === 'App\Models\User' || $modelType === \App\Models\User::class) {
|
||||
return in_array($profileId, $relatedUsers);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function openLogoutModal($profileId, $modelType)
|
||||
{
|
||||
// Verify admin access
|
||||
if (getActiveProfileType() !== 'Admin') {
|
||||
$this->dispatch('notify', [
|
||||
'type' => 'error',
|
||||
'message' => 'Unauthorized action. Only administrators can log out users.'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent logging out current admin or their related profiles
|
||||
if ($this->isCurrentUserProfile($profileId, $modelType)) {
|
||||
$this->dispatch('notify', [
|
||||
'type' => 'error',
|
||||
'message' => 'You cannot log out your own profile or related user accounts.'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
$this->selectedProfileId = $profileId;
|
||||
$this->selectedProfileType = $modelType;
|
||||
|
||||
// Get profile name for display
|
||||
try {
|
||||
$model = $modelType::find($profileId);
|
||||
$this->selectedProfileName = $model ? $model->name : null;
|
||||
} catch (\Exception $e) {
|
||||
$this->selectedProfileName = null;
|
||||
}
|
||||
|
||||
$this->showLogoutModal = true;
|
||||
}
|
||||
|
||||
public function closeLogoutModal()
|
||||
{
|
||||
$this->showLogoutModal = false;
|
||||
$this->selectedProfileId = null;
|
||||
$this->selectedProfileType = null;
|
||||
$this->selectedProfileName = null;
|
||||
}
|
||||
|
||||
public function logoutUser()
|
||||
{
|
||||
// Verify admin access
|
||||
if (getActiveProfileType() !== 'Admin') {
|
||||
$this->dispatch('notify', [
|
||||
'type' => 'error',
|
||||
'message' => 'Unauthorized action. Only administrators can log out users.'
|
||||
]);
|
||||
$this->closeLogoutModal();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!$this->selectedProfileId || !$this->selectedProfileType) {
|
||||
throw new \Exception('Invalid profile data');
|
||||
}
|
||||
|
||||
$model = $this->selectedProfileType::find($this->selectedProfileId);
|
||||
|
||||
if (!$model) {
|
||||
throw new \Exception('User not found');
|
||||
}
|
||||
|
||||
// Get the guard for this model type
|
||||
$guard = $this->getGuardForModelType($this->selectedProfileType);
|
||||
|
||||
if (!$guard) {
|
||||
throw new \Exception('Invalid guard');
|
||||
}
|
||||
|
||||
// Broadcast logout event via websocket - this will force the user's browser to logout
|
||||
broadcast(new \App\Events\UserForcedLogout($this->selectedProfileId, $guard));
|
||||
|
||||
// Delete all sessions for this user from the database
|
||||
\DB::connection(config('session.connection'))
|
||||
->table(config('session.table', 'sessions'))
|
||||
->where('user_id', $this->selectedProfileId)
|
||||
->delete();
|
||||
|
||||
// Clear any cached authentication data
|
||||
\Cache::forget('auth_' . $guard . '_' . $this->selectedProfileId);
|
||||
|
||||
// Clear presence cache
|
||||
\Cache::forget("presence_{$guard}_{$this->selectedProfileId}");
|
||||
|
||||
// Clear online users cache to force refresh
|
||||
\Cache::forget("online_users_{$guard}_" . \App\Services\PresenceService::ONLINE_THRESHOLD_MINUTES);
|
||||
|
||||
// Set user offline in presence service
|
||||
$presenceService = app(\App\Services\PresenceService::class);
|
||||
$presenceService->setUserOffline($model, $guard);
|
||||
|
||||
$this->dispatch('notify', [
|
||||
'type' => 'success',
|
||||
'message' => __('User has been logged out successfully.')
|
||||
]);
|
||||
|
||||
// Refresh the online profiles list
|
||||
$this->loadOnlineReactedProfiles();
|
||||
|
||||
} catch (\Exception $e) {
|
||||
\Log::error('Error logging out user: ' . $e->getMessage());
|
||||
$this->dispatch('notify', [
|
||||
'type' => 'error',
|
||||
'message' => __('Failed to log out user. Please try again.')
|
||||
]);
|
||||
}
|
||||
|
||||
$this->closeLogoutModal();
|
||||
}
|
||||
|
||||
protected function getGuardForModelType($modelType)
|
||||
{
|
||||
// Map model types to guards
|
||||
$modelGuardMap = [
|
||||
\App\Models\User::class => 'web',
|
||||
'App\Models\User' => 'web',
|
||||
\App\Models\Organization::class => 'organization',
|
||||
'App\Models\Organization' => 'organization',
|
||||
\App\Models\Bank::class => 'bank',
|
||||
'App\Models\Bank' => 'bank',
|
||||
\App\Models\Admin::class => 'admin',
|
||||
'App\Models\Admin' => 'admin',
|
||||
];
|
||||
|
||||
return $modelGuardMap[$modelType] ?? null;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.online-reacted-profiles');
|
||||
}
|
||||
}
|
||||
45
app/Http/Livewire/OnlineUsersList.php
Normal file
45
app/Http/Livewire/OnlineUsersList.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
// app/Http/Livewire/OnlineUsersList.php
|
||||
namespace App\Http\Livewire;
|
||||
|
||||
use App\Services\PresenceService;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Component;
|
||||
|
||||
class OnlineUsersList extends Component
|
||||
{
|
||||
public $onlineUsers = [];
|
||||
public $guard = 'web';
|
||||
public $showCount = true;
|
||||
public $showAvatars = true;
|
||||
public $maxDisplay = 10;
|
||||
public $refreshInterval = 10;
|
||||
|
||||
public function mount($guard = 'web', $showCount = true, $showAvatars = true, $maxDisplay = 10)
|
||||
{
|
||||
$this->guard = $guard;
|
||||
$this->showCount = $showCount;
|
||||
$this->showAvatars = $showAvatars;
|
||||
$this->maxDisplay = $maxDisplay;
|
||||
$this->loadOnlineUsers();
|
||||
}
|
||||
|
||||
public function loadOnlineUsers()
|
||||
{
|
||||
try {
|
||||
$presenceService = app(PresenceService::class);
|
||||
$users = $presenceService->getOnlineUsers($this->guard);
|
||||
|
||||
// Limit the display count
|
||||
$this->onlineUsers = $users->take($this->maxDisplay)->toArray();
|
||||
} catch (\Exception $e) {
|
||||
$this->onlineUsers = [];
|
||||
}
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.online-users-list');
|
||||
}
|
||||
}
|
||||
691
app/Http/Livewire/Pay.php
Normal file
691
app/Http/Livewire/Pay.php
Normal file
@@ -0,0 +1,691 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire;
|
||||
|
||||
use App\Http\Controllers\TransactionController;
|
||||
use App\Mail\TransferReceived;
|
||||
use App\Models\Account;
|
||||
use App\Models\Transaction;
|
||||
use App\Models\TransactionType;
|
||||
use App\Models\User;
|
||||
use Cog\Laravel\Love\ReactionType\Models\ReactionType as LoveReactionType;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Lang;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Livewire\Component;
|
||||
use Mcamara\LaravelLocalization\Facades\LaravelLocalization;
|
||||
use Namu\WireChat\Events\NotifyParticipant;
|
||||
use Stevebauman\Location\Facades\Location as IpLocation;
|
||||
use WireUi\Traits\WireUiActions;
|
||||
|
||||
|
||||
use function Laravel\Prompts\error;
|
||||
|
||||
class Pay extends Component
|
||||
{
|
||||
use WireUiActions;
|
||||
use \App\Traits\ProfilePermissionTrait;
|
||||
|
||||
public $hours;
|
||||
public $minutes;
|
||||
public $amount;
|
||||
public $fromAccountId;
|
||||
public $fromAccountName;
|
||||
public $fromAccountBalance;
|
||||
public $toAccountId;
|
||||
public $toAccountName;
|
||||
public $toHolderId;
|
||||
public $toHolderType;
|
||||
public $toHolderName;
|
||||
public $toHolderPhoto;
|
||||
public $type;
|
||||
public $typeOptions = [];
|
||||
public $description;
|
||||
public $transactionTypeSelected;
|
||||
public $limitError;
|
||||
public $requiredError = false;
|
||||
public $submitEnabled = false;
|
||||
public $modalVisible = false;
|
||||
public $modalErrorVisible = false;
|
||||
public $rememberPaymentData = false;
|
||||
public $transactionTypeRemembered;
|
||||
|
||||
public $typeOptionsProtected;
|
||||
|
||||
protected $listeners = [
|
||||
'amount' => 'amountValidation',
|
||||
'fromAccountId',
|
||||
'toAccountId',
|
||||
'toAccountDetails' => 'toAccountDispatched',
|
||||
'description',
|
||||
'transactionTypeSelected',
|
||||
'resetForm',
|
||||
'removeSelectedAccount',
|
||||
];
|
||||
|
||||
protected function rules()
|
||||
{
|
||||
return [
|
||||
'amount' => timebank_config('payment.amount_rule'),
|
||||
'fromAccountId' => 'nullable|integer|exists:accounts,id',
|
||||
'toAccountId' => 'required|integer',
|
||||
'description' => timebank_config('payment.description_rule'),
|
||||
'transactionTypeSelected.name' => 'required|string|exists:transaction_types,name',
|
||||
];
|
||||
}
|
||||
|
||||
protected function messages()
|
||||
{
|
||||
return [
|
||||
'transactionTypeSelected.name.required' => __('messages.Transaction type is required'),
|
||||
];
|
||||
}
|
||||
|
||||
public function mount($amount = null, $hours = null, $minutes = null)
|
||||
{
|
||||
$this->modalVisible = false;
|
||||
|
||||
$profile = getActiveProfile();
|
||||
|
||||
if (!$profile) {
|
||||
abort(403, 'No active profile');
|
||||
}
|
||||
|
||||
// CRITICAL SECURITY: Validate user has ownership/access to this profile
|
||||
// This prevents unauthorized access to the payment form via session manipulation
|
||||
\App\Helpers\ProfileAuthorizationHelper::authorize($profile);
|
||||
|
||||
if ($amount !== null && is_numeric($amount) && $amount > 0) {
|
||||
$this->amount = $amount;
|
||||
} else {
|
||||
$hours = is_numeric($this->hours) ? (int) $this->hours : 0;
|
||||
$minutes = is_numeric($this->minutes) ? (int) $this->minutes : 0;
|
||||
$this->amount = $hours * 60 + $minutes;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear remembered transaction type when checkbox is unchecked
|
||||
*/
|
||||
public function updatedRememberPaymentData($value)
|
||||
{
|
||||
if (!$value) {
|
||||
$this->transactionTypeRemembered = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extra validation when amount looses focus
|
||||
*
|
||||
* @param mixed $toAccountId
|
||||
* @return void
|
||||
*/
|
||||
public function amountValidation($amount = null)
|
||||
{
|
||||
$this->amount = $amount ?? $this->amount;
|
||||
$this->validateOnly('amount');
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets fromAccountId after From Account drop down is selected
|
||||
*
|
||||
* @param mixed $toAccount
|
||||
* @return void
|
||||
*/
|
||||
public function fromAccountId($selectedAccount)
|
||||
{
|
||||
$this->modalVisible = false;
|
||||
|
||||
// Handle case where no account is selected (e.g., Admin profiles)
|
||||
if (!$selectedAccount || !isset($selectedAccount['id'])) {
|
||||
$this->fromAccountId = null;
|
||||
$this->fromAccountName = null;
|
||||
$this->fromAccountBalance = null;
|
||||
return;
|
||||
}
|
||||
|
||||
$this->fromAccountId = $selectedAccount['id'];
|
||||
$this->fromAccountName = $selectedAccount['name'];
|
||||
$this->fromAccountBalance = $selectedAccount['balance'];
|
||||
$this->validateOnly('fromAccountId');
|
||||
if ($this->fromAccountId == $this->toAccountId) {
|
||||
$this->dispatch('resetForm')->to(ToAccount::class);
|
||||
$this->dispatch('fromAccountId', $this->fromAccountId)->to(ToAccount::class);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets fromAccountId after To Account drop down is selected
|
||||
*
|
||||
* @param mixed $toAccount
|
||||
* @return void
|
||||
*/
|
||||
public function toAccountId($toAccountId)
|
||||
{
|
||||
$this->modalVisible = false;
|
||||
$this->toAccountId = $toAccountId;
|
||||
$this->validateOnly('toAccountId');
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets To account details after it is selected
|
||||
*
|
||||
* @param mixed $details
|
||||
* @return void
|
||||
*/
|
||||
public function toAccountDispatched($details)
|
||||
{
|
||||
if ($details) {
|
||||
// Check if we have a to account
|
||||
$this->requiredError = false;
|
||||
$this->toAccountId = $details['accountId'];
|
||||
$this->toAccountName = __(ucfirst(strtolower($details['accountName'])));
|
||||
$this->toHolderId = $details['holderId'];
|
||||
$this->toHolderType = $details['holderType'];
|
||||
$this->toHolderName = $details['holderName'];
|
||||
$this->toHolderPhoto = url($details['holderPhoto']);
|
||||
|
||||
// Look up in config what transaction types are possible / allowed
|
||||
$canReceive = timebank_config('accounts.' . strtolower(class_basename($details['holderType'])) . '.receiving_types');
|
||||
$canPayConfig = timebank_config('permissions.' . strtolower(class_basename(session('activeProfileType'))) . '.payment_types');
|
||||
$canPay = $canPayConfig ?? [];
|
||||
$allowedTypes = array_intersect($canPay, $canReceive);
|
||||
|
||||
// Check if this is an internal transfer (same accountable holder)
|
||||
$isInternalTransfer = (
|
||||
session('activeProfileType') === $details['holderType'] &&
|
||||
session('activeProfileId') == $details['holderId']
|
||||
);
|
||||
|
||||
// If it's an internal transfer, only allow type 6 (Migration)
|
||||
if ($isInternalTransfer && !in_array(6, $allowedTypes)) {
|
||||
$allowedTypes = [6];
|
||||
}
|
||||
|
||||
$this->typeOptionsProtected = $allowedTypes;
|
||||
$this->typeOptions = $this->typeOptionsProtected;
|
||||
|
||||
// Pass remembered transaction type to the dispatch if remembering payment data
|
||||
$rememberedType = ($this->rememberPaymentData && $this->transactionTypeRemembered)
|
||||
? $this->transactionTypeRemembered
|
||||
: null;
|
||||
$this->dispatch('transactionTypeOptions', $this->typeOptions, $rememberedType);
|
||||
} else {
|
||||
// if no to account is present, set id to null and validate so the user received an error
|
||||
$this->typeOptions = null;
|
||||
$this->dispatch('transactionTypeOptions', $this->typeOptions, null);
|
||||
$this->toAccountId = null;
|
||||
}
|
||||
$this->validateOnly('toAccountId');
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets description after it is updated
|
||||
*
|
||||
* @param mixed $content
|
||||
* @return void
|
||||
*/
|
||||
public function description($description)
|
||||
{
|
||||
$this->description = $description;
|
||||
$this->validateOnly('description');
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets transactionTypeSelected after it is updated
|
||||
*
|
||||
* @param mixed $content
|
||||
* @return void
|
||||
*/
|
||||
public function transactionTypeSelected($selected)
|
||||
{
|
||||
$this->transactionTypeSelected = $selected;
|
||||
$this->validateOnly('transactionTypeSelected');
|
||||
}
|
||||
|
||||
|
||||
public function showModal()
|
||||
{
|
||||
try {
|
||||
$this->validate();
|
||||
} catch (\Illuminate\Validation\ValidationException $errors) {
|
||||
// dump($errors); //TODO! Replace dump and render error message nicely for user
|
||||
$this->validate();
|
||||
// Execution stops here if validation fails.
|
||||
}
|
||||
|
||||
$fromAccountId = $this->fromAccountId;
|
||||
$toAccountId = $this->toAccountId;
|
||||
$amount = $this->amount;
|
||||
|
||||
// Check if fromAccountId is null (e.g., Admin profiles without accounts)
|
||||
if (!$fromAccountId) {
|
||||
$this->notification()->error(
|
||||
__('No account available'),
|
||||
__('Your profile does not have any accounts to make payments from.')
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
$transactionController = new TransactionController();
|
||||
$balanceFrom = $transactionController->getBalance($fromAccountId);
|
||||
$balanceTo = $transactionController->getBalance($toAccountId);
|
||||
|
||||
if ($toAccountId === $fromAccountId) {
|
||||
return redirect()->back()->with('error', 'You cannot transfer Hours from and to the same account');
|
||||
} else {
|
||||
$fromAccountExists = Account::where('id', $toAccountId)->first();
|
||||
if (!$fromAccountExists) {
|
||||
return redirect()->back()->with('error', 'Account not found.');
|
||||
} else {
|
||||
$transferToAccount = $fromAccountExists->id;
|
||||
}
|
||||
|
||||
$f = Account::where('id', $fromAccountId)->select('limit_min')->first();
|
||||
$limitMinFrom = $f->limit_min;
|
||||
$t = Account::where('id', $transferToAccount)->select('limit_max', 'limit_min')->first();
|
||||
$limitMaxTo = $t->limit_max - $t->limit_min;
|
||||
|
||||
$transferBudgetFrom = $balanceFrom - $limitMinFrom;
|
||||
if (timebank_config('account_info.' . strtolower(class_basename($this->toHolderType)) . '.balance_public')) {
|
||||
$transferBudgetTo = $limitMaxTo - $balanceTo;
|
||||
$balanceToPublic = true;
|
||||
} else {
|
||||
$transferBudgetTo = $limitMaxTo - $balanceTo;
|
||||
$balanceToPublic = false;
|
||||
}
|
||||
|
||||
$this->checkBalanceLimits($amount, $transferBudgetTo, $transferBudgetFrom, $limitMinFrom, $balanceToPublic);
|
||||
|
||||
$this->modalVisible = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create transfer, output success / error message and reset from.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function doTransfer()
|
||||
{
|
||||
$fromAccountId = $this->fromAccountId;
|
||||
$toAccountId = $this->toAccountId;
|
||||
$amount = $this->amount;
|
||||
$description = $this->description;
|
||||
$transactionTypeId = $this->transactionTypeSelected['id'];
|
||||
|
||||
// Block payment if the active user only has coordinator role (no payment rights)
|
||||
if (!$this->getCanCreatePayments()) {
|
||||
$warningMessage = 'Unauthorized payment attempt: coordinator role has no payment rights';
|
||||
return $this->logAndReport($warningMessage, $fromAccountId, $toAccountId, $amount, $description);
|
||||
}
|
||||
|
||||
// NOTICE: Livewire public properties can be changed / hacked on the client side!
|
||||
// Check therefore check again ownership of the fromAccountId.
|
||||
// The getAccountsInfo() from the AccountInfoTrait checks the active profile sessions.
|
||||
$transactionController = new TransactionController();
|
||||
$accountsInfo = collect($transactionController->getAccountsInfo());
|
||||
// Check if the session's active profile owns the submitted fromAccountId
|
||||
if (!$accountsInfo->contains('id', $fromAccountId)) {
|
||||
$warningMessage = 'Unauthorized payment attempt: illegal access of From account';
|
||||
return $this->logAndReport($warningMessage, $fromAccountId, $toAccountId, $amount, $description);
|
||||
}
|
||||
|
||||
// Check if From and To Account is different
|
||||
if ($toAccountId === $fromAccountId) {
|
||||
$warningMessage = 'Impossible payment attempt: To and From account are the same';
|
||||
return $this->logAndReport($warningMessage, $fromAccountId, $toAccountId, $amount, $description);
|
||||
}
|
||||
|
||||
// Check if the To Account exists and is not removed
|
||||
$toAccountExists = Account::where('id', $toAccountId)->notRemoved()->first();
|
||||
if (!$toAccountExists) {
|
||||
$warningMessage = 'Impossible payment attempt: To account not found';
|
||||
return $this->logAndReport($warningMessage, $fromAccountId, $toAccountId, $amount, $description);
|
||||
}
|
||||
$transferToAccount = $toAccountExists->id;
|
||||
|
||||
// Check if the To Accountable exists and is not removed
|
||||
$toAccountableExists = Account::find($toAccountId)->accountable()->notRemoved()->first();
|
||||
if (!$toAccountableExists) {
|
||||
$warningMessage = 'Impossible payment attempt: To account holder not found';
|
||||
return $this->logAndReport($warningMessage, $fromAccountId, $toAccountId, $amount, $description);
|
||||
}
|
||||
|
||||
// Check if the From Account exists and is not removed
|
||||
$fromAccountExists = Account::where('id', $fromAccountId)->notRemoved()->first();
|
||||
if (!$fromAccountExists) {
|
||||
$warningMessage = 'Impossible payment attempt: From account not found';
|
||||
return $this->logAndReport($warningMessage, $fromAccountId, $toAccountId, $amount, $description);
|
||||
}
|
||||
|
||||
// Check if the From Accountable exists and is not removed
|
||||
$fromAccountableExists = Account::find($fromAccountId)->accountable()->notRemoved()->first();
|
||||
if (!$fromAccountableExists) {
|
||||
$warningMessage = 'Impossible payment attempt: From account holder not found';
|
||||
return $this->logAndReport($warningMessage, $fromAccountId, $toAccountId, $amount, $description);
|
||||
}
|
||||
|
||||
|
||||
// Check if the transaction type is allowed, with an exception for internal transfers of type 6 (Migration)
|
||||
$fromAccount = Account::find($fromAccountId);
|
||||
$isInternalTransferType = (
|
||||
$fromAccount->accountable_type === $fromAccountExists->accountable_type &&
|
||||
$fromAccount->accountable_id === $fromAccountExists->accountable_id &&
|
||||
$transactionTypeId == 6
|
||||
);
|
||||
|
||||
// Check if the To transactionTypeSelected is allowed, unless it's a specific internal transfer
|
||||
if (!$isInternalTransferType && !in_array($transactionTypeId, $this->typeOptionsProtected)) {
|
||||
$transactionType = TransactionType::find($transactionTypeId)->name ?? 'id: '. $transactionTypeId;
|
||||
$warningMessage = 'Impossible payment attempt: transaction type not allowed';
|
||||
return $this->logAndReport($warningMessage, $fromAccountId, $toAccountId, $amount, $description, $transactionType);
|
||||
}
|
||||
|
||||
$f = Account::where('id', $fromAccountId)->select('limit_min')->first();
|
||||
$limitMinFrom = $f->limit_min;
|
||||
$t = Account::where('id', $transferToAccount)->select('limit_max', 'limit_min')->first();
|
||||
$limitMaxTo = $t->limit_max - $t->limit_min;
|
||||
|
||||
$balanceFrom = $transactionController->getBalance($fromAccountId);
|
||||
$balanceTo = $transactionController->getBalance($toAccountId);
|
||||
|
||||
$transferBudgetFrom = $balanceFrom - $limitMinFrom;
|
||||
if (timebank_config('account_info.' . strtolower(class_basename($this->toHolderType)) . '.balance_public')) {
|
||||
$transferBudgetTo = $limitMaxTo - $balanceTo;
|
||||
$balanceToPublic = true;
|
||||
} else {
|
||||
$transferBudgetTo = $limitMaxTo - $balanceTo;
|
||||
$balanceToPublic = false;
|
||||
}
|
||||
|
||||
// Check balance limits
|
||||
$this->checkBalanceLimits($amount, $transferBudgetTo, $transferBudgetFrom, $limitMinFrom, $balanceToPublic);
|
||||
|
||||
// Use a database transaction for saving the payment
|
||||
DB::beginTransaction();
|
||||
try {
|
||||
|
||||
$transfer = new Transaction();
|
||||
$transfer->from_account_id = $fromAccountId;
|
||||
$transfer->to_account_id = $transferToAccount;
|
||||
$transfer->amount = $amount;
|
||||
$transfer->description = $description;
|
||||
$transfer->transaction_type_id = $transactionTypeId;
|
||||
$transfer->creator_user_id = Auth::user()->id;
|
||||
$save = $transfer->save();
|
||||
|
||||
// TODO: remove testing comment for production
|
||||
// Uncomment to test a failed transaction
|
||||
//$save = false;
|
||||
|
||||
if ($save) {
|
||||
|
||||
// Commit the database transaction
|
||||
DB::commit();
|
||||
// WireUI notification
|
||||
$this->notification()->success(
|
||||
__('Transaction done!'),
|
||||
__('messages.payment.success', [
|
||||
'amount' => tbFormat($amount),
|
||||
'account_name' => $this->toAccountName,
|
||||
'holder_name' => $this->toHolderName,
|
||||
'transaction_url' => route('transaction.show', ['transactionId' => $transfer->id, 'qrModalVisible' => true]),
|
||||
'transaction_id' => $transfer->id,
|
||||
])
|
||||
);
|
||||
|
||||
// Store transaction type if remembering payment data
|
||||
if ($this->rememberPaymentData) {
|
||||
$this->transactionTypeRemembered = $this->transactionTypeSelected;
|
||||
}
|
||||
|
||||
// Conditionally reset form based on rememberPaymentData setting
|
||||
if ($this->rememberPaymentData) {
|
||||
// Only reset to-account related fields, keeping amount and description
|
||||
$this->dispatch('resetForm')->to(ToAccount::class);
|
||||
$this->toAccountId = null;
|
||||
$this->toAccountName = null;
|
||||
$this->toHolderId = null;
|
||||
$this->toHolderType = null;
|
||||
$this->toHolderName = null;
|
||||
$this->toHolderPhoto = null;
|
||||
$this->transactionTypeSelected = null;
|
||||
$this->modalVisible = false;
|
||||
} else {
|
||||
// Reset all fields including remembered transaction type
|
||||
$this->transactionTypeRemembered = null;
|
||||
$this->dispatch('resetForm');
|
||||
}
|
||||
|
||||
// Send chat message and an email if conditions are met
|
||||
$recipient = $transfer->accountTo->accountable;
|
||||
$sender = $transfer->accountFrom->accountable;
|
||||
$messageLocale = $recipient->lang_preference ?? $sender->lang_preference;
|
||||
if (!Lang::has('messages.pay_chat_message', $messageLocale)) { // Check if the translation key exists for the selected locale
|
||||
$messageLocale = config('app.fallback_locale'); // Fallback to the app's default locale
|
||||
}
|
||||
|
||||
$chatMessage = __('messages.pay_chat_message', [
|
||||
'amount' => tbFormat($amount),
|
||||
'account_name' => $this->toAccountName,
|
||||
], $messageLocale);
|
||||
|
||||
$chatTransactionStatement = LaravelLocalization::getURLFromRouteNameTranslated($messageLocale, 'routes.statement', array('transactionId' => $transfer->id));
|
||||
|
||||
// Send Wirechat message
|
||||
$message = $sender->sendMessageTo($recipient, $chatMessage);
|
||||
$message = $sender->sendMessageTo($recipient, $chatTransactionStatement);
|
||||
|
||||
// Broadcast the NotifyParticipant event to wirechat messenger
|
||||
broadcast(new NotifyParticipant($recipient, $message));
|
||||
|
||||
// Check if the recipient has message settings for receiving this email and has also an email address
|
||||
if (isset($recipient->email)) {
|
||||
$messageSettings = method_exists($recipient, 'message_settings') ? $recipient->message_settings()->first() : null;
|
||||
|
||||
// Always send email unless payment_received is explicitly false
|
||||
if (!$messageSettings || !($messageSettings->payment_received === false || $messageSettings->payment_received === 0)) {
|
||||
$now = now();
|
||||
Mail::to($recipient->email)->later($now->addSeconds(5), new TransferReceived($transfer, $messageLocale));
|
||||
}
|
||||
}
|
||||
|
||||
// Add Love Reaction with transaction type name on both models
|
||||
$reactionType = TransactionType::find($transactionTypeId)->name;
|
||||
try {
|
||||
$reacterFacadeSender = $sender->viaLoveReacter()->reactTo($recipient, $reactionType);
|
||||
$reacterFacadeRecipient = $recipient->viaLoveReacter()->reactTo($sender, $reactionType);
|
||||
} catch (\Exception $e) {
|
||||
// Ignore if reaction type does not exist
|
||||
}
|
||||
|
||||
} else {
|
||||
throw new \Exception('Transaction could not be saved');
|
||||
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
|
||||
// WireUI notification
|
||||
$this->notification()->send([
|
||||
'title' => __('Transaction failed!'),
|
||||
'description' => __('messages.payment.failed_description', [
|
||||
'error' => $e->getMessage(),
|
||||
]),
|
||||
'icon' => 'error',
|
||||
'timeout' => 50000
|
||||
]);
|
||||
|
||||
$warningMessage = 'Transaction failed';
|
||||
$this->logAndReport($warningMessage, $fromAccountId, $toAccountId, $amount, $description, '', $e);
|
||||
|
||||
$this->resetForm();
|
||||
|
||||
return back();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Check balance limits for a transfer operation.
|
||||
*
|
||||
* This method checks if the transfer amount exceeds the allowed budget limits
|
||||
* for both the source and destination accounts. It sets an appropriate error
|
||||
* message and makes the error modal visible if any limit is exceeded.
|
||||
*/
|
||||
private function checkBalanceLimits($amount, $transferBudgetTo, $transferBudgetFrom, $limitMinFrom, $balanceToPublic)
|
||||
{
|
||||
if ($amount > $transferBudgetFrom && $amount > $transferBudgetTo && $transferBudgetFrom <= $transferBudgetTo) {
|
||||
$this->limitError = __('messages.pay_limit_error_budget_from', [
|
||||
'limitMinFrom' => tbFormat($limitMinFrom),
|
||||
'transferBudgetFrom' => tbFormat($transferBudgetFrom),
|
||||
]);
|
||||
return $this->modalErrorVisible = true;
|
||||
}
|
||||
if ($amount > $transferBudgetFrom && $amount > $transferBudgetTo && $transferBudgetFrom > $transferBudgetTo) {
|
||||
if ($balanceToPublic) {
|
||||
$this->limitError = __('messages.pay_limit_error_budget_from_and_to', [
|
||||
'limitMinFrom' => tbFormat($limitMinFrom),
|
||||
'transferBudgetTo' => tbFormat($transferBudgetTo),
|
||||
]);
|
||||
} else {
|
||||
$this->limitError = __('messages.pay_limit_error_budget_from_and_to_without_budget_to', [
|
||||
'limitMinFrom' => tbFormat($limitMinFrom),
|
||||
]);
|
||||
}
|
||||
return $this->modalErrorVisible = true;
|
||||
}
|
||||
if ($amount > $transferBudgetFrom) {
|
||||
$this->limitError = __('messages.pay_limit_error_budget_from', [
|
||||
'limitMinFrom' => tbFormat($limitMinFrom),
|
||||
'transferBudgetFrom' => tbFormat($transferBudgetFrom),
|
||||
]);
|
||||
return $this->modalErrorVisible = true;
|
||||
}
|
||||
if ($amount > $transferBudgetTo) {
|
||||
if ($balanceToPublic) {
|
||||
$this->limitError = __('messages.pay_limit_error_budget_to', [
|
||||
'transferBudgetTo' => tbFormat($transferBudgetTo),
|
||||
]);
|
||||
} else {
|
||||
$this->limitError = __('messages.pay_limit_error_budget_to_without_budget_to', [
|
||||
'transferBudgetTo' => tbFormat($transferBudgetTo),
|
||||
'toHolderName' => $this->toHolderName,
|
||||
]);
|
||||
}
|
||||
return $this->modalErrorVisible = true;
|
||||
}
|
||||
$this->limitError = null;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Logs a warning message and reports it via email to the system administrator.
|
||||
*
|
||||
* This method logs a warning message with detailed information about the event,
|
||||
* including account details, user details, IP address, and location. It also
|
||||
* sends an email to the system administrator with the same information.
|
||||
*/
|
||||
private function logAndReport($warningMessage, $fromAccountId, $toAccountId, $amount, $description, $transactionType = '', $error = '')
|
||||
{
|
||||
$ip = request()->ip();
|
||||
$ipLocationInfo = IpLocation::get($ip);
|
||||
// Escape ipLocation errors when not in production
|
||||
if (!$ipLocationInfo || App::environment(['local', 'development', 'staging'])) {
|
||||
$ipLocationInfo = (object) [
|
||||
'cityName' => 'local City',
|
||||
'regionName' => 'local Region',
|
||||
'countryName' => 'local Country',
|
||||
];
|
||||
}
|
||||
$eventTime = now()->toDateTimeString();
|
||||
|
||||
// Log this event and mail to admin
|
||||
$fromAccountInfo = $fromAccountId ? Account::find($fromAccountId)?->accountable()?->value('name') : 'N/A';
|
||||
$toAccountInfo = $toAccountId ? Account::find($toAccountId)?->accountable()?->value('name') : 'N/A';
|
||||
|
||||
Log::warning($warningMessage, [
|
||||
'fromAccountId' => $fromAccountId ?? 'N/A',
|
||||
'fromAccountHolder' => $fromAccountInfo,
|
||||
'toAccountId' => $toAccountId ?? 'N/A',
|
||||
'toAccountHolder' => $toAccountInfo,
|
||||
'amount' => $amount,
|
||||
'description' => $description,
|
||||
'userId' => Auth::id(),
|
||||
'userName' => Auth::user()->name,
|
||||
'activeProfileId' => session('activeProfileId'),
|
||||
'activeProfileType' => session('activeProfileType'),
|
||||
'activeProfileName' => session('activeProfileName'),
|
||||
'transactionType' => ucfirst($transactionType),
|
||||
'IP address' => $ip,
|
||||
'IP location' => $ipLocationInfo->cityName . ', ' . $ipLocationInfo->regionName . ', ' . $ipLocationInfo->countryName,
|
||||
'Event Time' => $eventTime,
|
||||
'Message' => $error,
|
||||
]);
|
||||
Mail::raw(
|
||||
$warningMessage . '.' . "\n\n" .
|
||||
'From Account ID: ' . ($fromAccountId ?? 'N/A') . "\n" .
|
||||
'From Account Holder: ' . $fromAccountInfo . "\n" .
|
||||
'To Account ID: ' . ($toAccountId ?? 'N/A') . "\n" .
|
||||
'To Account Holder: ' . $toAccountInfo . "\n" .
|
||||
'Amount: ' . $amount . "\n" .
|
||||
'Description: ' . $description . "\n" .
|
||||
'User ID: ' . Auth::id() . "\n" . 'User Name: ' . Auth::user()->name . "\n" .
|
||||
'Active Profile ID: ' . session('activeProfileId') . "\n" .
|
||||
'Active Profile Type: ' . session('activeProfileType') . "\n" .
|
||||
'Active Profile Name: ' . session('activeProfileName') . "\n" .
|
||||
'Transaction Type: ' . ucfirst($transactionType) . "\n" .
|
||||
'IP address: ' . $ip . "\n" .
|
||||
'IP location: ' . $ipLocationInfo->cityName . ', ' . $ipLocationInfo->regionName . ', ' . $ipLocationInfo->countryName . "\n" .
|
||||
'Event Time: ' . $eventTime . "\n\n" .
|
||||
$error,
|
||||
function ($message) use ($warningMessage) {
|
||||
$message->to(timebank_config('mail.system_admin.email'))->subject($warningMessage);
|
||||
},
|
||||
);
|
||||
|
||||
return redirect()
|
||||
->back()
|
||||
->with('error', __($warningMessage) . '. ' . __('This event has been logged and reported to our system administrator') . '.');
|
||||
}
|
||||
|
||||
|
||||
public function resetForm()
|
||||
{
|
||||
// Always reset the to account and transaction type
|
||||
$this->toAccountId = null;
|
||||
$this->toAccountName = null;
|
||||
$this->transactionTypeSelected = null;
|
||||
|
||||
// Only reset amount, description, and remembered type if not remembering payment data
|
||||
if (!$this->rememberPaymentData) {
|
||||
$this->amount = null;
|
||||
$this->description = null;
|
||||
$this->transactionTypeRemembered = null;
|
||||
}
|
||||
|
||||
$this->modalVisible = false;
|
||||
}
|
||||
|
||||
public function removeSelectedAccount()
|
||||
{
|
||||
$this->toAccountId = null;
|
||||
$this->toAccountName = null;
|
||||
$this->toHolderId = null;
|
||||
$this->toHolderName = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the livewire component
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.pay');
|
||||
}
|
||||
}
|
||||
13
app/Http/Livewire/Permissions/Create.php
Normal file
13
app/Http/Livewire/Permissions/Create.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire\Permissions;
|
||||
|
||||
use Livewire\Component;
|
||||
|
||||
class Create extends Component
|
||||
{
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.permissions.create');
|
||||
}
|
||||
}
|
||||
13
app/Http/Livewire/Permissions/Manage.php
Normal file
13
app/Http/Livewire/Permissions/Manage.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire\Permissions;
|
||||
|
||||
use Livewire\Component;
|
||||
|
||||
class Manage extends Component
|
||||
{
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.permissions.manage');
|
||||
}
|
||||
}
|
||||
48
app/Http/Livewire/PostForm.php
Normal file
48
app/Http/Livewire/PostForm.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire;
|
||||
|
||||
use App\Models\Post;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Routing\Redirector;
|
||||
use Livewire\Attributes\Rule;
|
||||
use Livewire\Component;
|
||||
|
||||
class PostForm extends Component
|
||||
{
|
||||
public ?Post $post = null;
|
||||
#[Rule('required|min:3')]
|
||||
public string $title = '';
|
||||
#[Rule('required|min:10')]
|
||||
public string $body = '';
|
||||
|
||||
public function mount(Post $post): void
|
||||
{
|
||||
if ($post->exists) {
|
||||
$this->post = $post;
|
||||
$this->title = $post->title;
|
||||
$this->body = $post->body;
|
||||
}
|
||||
}
|
||||
|
||||
public function submitForm(): Redirector|RedirectResponse
|
||||
{
|
||||
$this->validate();
|
||||
|
||||
if (empty($this->post)) {
|
||||
Post::create($this->only('title', 'body'));
|
||||
} else {
|
||||
$this->post->update($this->only('title', 'body'));
|
||||
}
|
||||
|
||||
session()->flash('message', 'Post successfully saved!');
|
||||
|
||||
return redirect()->route('posts.index');
|
||||
}
|
||||
|
||||
public function render(): View
|
||||
{
|
||||
return view('livewire.post-form');
|
||||
}
|
||||
}
|
||||
905
app/Http/Livewire/Posts/BackupRestore.php
Normal file
905
app/Http/Livewire/Posts/BackupRestore.php
Normal file
@@ -0,0 +1,905 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire\Posts;
|
||||
|
||||
use App\Http\Livewire\Traits\RequiresAdminAuthorization;
|
||||
use App\Models\Category;
|
||||
use App\Models\Locations\CityLocale;
|
||||
use App\Models\Locations\CountryLocale;
|
||||
use App\Models\Locations\DistrictLocale;
|
||||
use App\Models\Locations\DivisionLocale;
|
||||
use App\Models\Locations\Location;
|
||||
use App\Models\Meeting;
|
||||
use App\Models\Post;
|
||||
use App\Models\PostTranslation;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Laravel\Scout\Jobs\MakeSearchable;
|
||||
use Livewire\Component;
|
||||
use WireUi\Traits\WireUiActions;
|
||||
use ZipArchive;
|
||||
|
||||
class BackupRestore extends Component
|
||||
{
|
||||
use WireUiActions;
|
||||
use RequiresAdminAuthorization;
|
||||
|
||||
public bool $showRestoreModal = false;
|
||||
|
||||
// Chunked upload state
|
||||
public ?string $uploadId = null;
|
||||
public ?string $uploadedFilePath = null;
|
||||
public ?string $selectedFileName = null;
|
||||
|
||||
// Optional: show "Backup selected" button (requires parent component to provide selection)
|
||||
public bool $showBackupSelected = false;
|
||||
public array $selectedTranslationIds = [];
|
||||
|
||||
// Restore state
|
||||
public array $restorePreview = [];
|
||||
public array $restorePostList = []; // Lightweight summaries for selection display
|
||||
public array $selectedPostIndices = []; // Selected post indices from backup
|
||||
public bool $selectAllPosts = true;
|
||||
public bool $isRestoring = false;
|
||||
public string $duplicateAction = 'skip'; // skip, overwrite
|
||||
public array $restoreStats = [];
|
||||
|
||||
protected $listeners = [
|
||||
'refreshComponent' => '$refresh',
|
||||
'updateSelectedTranslationIds' => 'updateSelectedTranslationIds',
|
||||
];
|
||||
|
||||
public function mount(bool $showBackupSelected = false)
|
||||
{
|
||||
// Verify admin access
|
||||
$this->authorizeAdminAccess();
|
||||
$this->showBackupSelected = $showBackupSelected;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update selected translation IDs from parent component.
|
||||
*/
|
||||
public function updateSelectedTranslationIds(array $ids)
|
||||
{
|
||||
$this->selectedTranslationIds = $ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle select/deselect all posts for restore.
|
||||
*/
|
||||
public function toggleSelectAll()
|
||||
{
|
||||
if ($this->selectAllPosts) {
|
||||
$this->selectedPostIndices = array_column($this->restorePostList, 'index');
|
||||
} else {
|
||||
$this->selectedPostIndices = [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update selectAllPosts state when individual checkboxes change.
|
||||
*/
|
||||
public function updatedSelectedPostIndices()
|
||||
{
|
||||
$this->selectAllPosts = count($this->selectedPostIndices) === count($this->restorePostList);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate and download backup for all posts.
|
||||
*/
|
||||
public function backup()
|
||||
{
|
||||
$this->authorizeAdminAccess();
|
||||
|
||||
// Pass query builder (not ->get()) so generateBackup can chunk it
|
||||
$posts = Post::query();
|
||||
|
||||
return $this->generateBackup($posts, 'posts_backup_');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate and download backup for selected posts only.
|
||||
*/
|
||||
public function backupSelected()
|
||||
{
|
||||
$this->authorizeAdminAccess();
|
||||
|
||||
if (empty($this->selectedTranslationIds)) {
|
||||
$this->notification()->error(
|
||||
__('Error'),
|
||||
__('No posts selected')
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get unique post IDs from selected translation IDs
|
||||
$postIds = PostTranslation::whereIn('id', $this->selectedTranslationIds)
|
||||
->pluck('post_id')
|
||||
->unique()
|
||||
->toArray();
|
||||
|
||||
// Pass query builder (not ->get()) so generateBackup can chunk it
|
||||
$posts = Post::whereIn('id', $postIds);
|
||||
|
||||
return $this->generateBackup($posts, 'posts_selected_backup_');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate backup data and return as download response (ZIP archive with media).
|
||||
*
|
||||
* @param \Illuminate\Database\Eloquent\Builder|\Illuminate\Database\Eloquent\Collection $posts Query builder or collection
|
||||
*/
|
||||
private function generateBackup($posts, string $filenamePrefix)
|
||||
{
|
||||
// Build category type lookup (id => type)
|
||||
$categoryTypes = Category::pluck('type', 'id')->toArray();
|
||||
|
||||
$filename = $filenamePrefix . now()->format('Ymd_His') . '.zip';
|
||||
|
||||
// Create temporary files
|
||||
$tempDir = storage_path('app/temp');
|
||||
if (!File::isDirectory($tempDir)) {
|
||||
File::makeDirectory($tempDir, 0755, true);
|
||||
}
|
||||
$tempPath = $tempDir . '/' . uniqid('backup_') . '.zip';
|
||||
$tempJsonPath = $tempDir . '/' . uniqid('backup_json_') . '.json';
|
||||
|
||||
// Track counts for meta
|
||||
$counts = ['posts' => 0, 'post_translations' => 0, 'meetings' => 0, 'media_files' => 0];
|
||||
|
||||
// Track media files to include in ZIP
|
||||
$mediaFiles = [];
|
||||
|
||||
// Write posts as JSON incrementally to a temp file to avoid holding everything in memory
|
||||
$jsonHandle = fopen($tempJsonPath, 'w');
|
||||
// Write a placeholder for meta - will be replaced via a separate meta file in the ZIP
|
||||
fwrite($jsonHandle, '{"meta":"__PLACEHOLDER__","posts":[');
|
||||
|
||||
$isFirst = true;
|
||||
$chunkSize = 100;
|
||||
|
||||
// Process posts in chunks to limit memory usage
|
||||
$processPost = function ($post) use ($categoryTypes, $jsonHandle, &$isFirst, &$counts, &$mediaFiles) {
|
||||
$categoryType = $categoryTypes[$post->category_id] ?? null;
|
||||
|
||||
$postData = [
|
||||
'category_type' => $categoryType,
|
||||
'love_reactant_id' => $post->love_reactant_id,
|
||||
'author_id' => $post->author_id,
|
||||
'author_model' => $post->author_model,
|
||||
'created_at' => $this->formatDate($post->created_at),
|
||||
'updated_at' => $this->formatDate($post->updated_at),
|
||||
'translations' => [],
|
||||
'meeting' => null,
|
||||
'media' => null,
|
||||
];
|
||||
|
||||
foreach ($post->translations as $translation) {
|
||||
$postData['translations'][] = [
|
||||
'locale' => $translation->locale,
|
||||
'slug' => $translation->slug,
|
||||
'title' => $translation->title,
|
||||
'excerpt' => $translation->excerpt,
|
||||
'content' => $translation->content,
|
||||
'status' => $translation->status,
|
||||
'updated_by_user_id' => $translation->updated_by_user_id,
|
||||
'from' => $this->formatDate($translation->from),
|
||||
'till' => $this->formatDate($translation->till),
|
||||
'created_at' => $this->formatDate($translation->created_at),
|
||||
'updated_at' => $this->formatDate($translation->updated_at),
|
||||
];
|
||||
$counts['post_translations']++;
|
||||
}
|
||||
|
||||
if ($post->meeting) {
|
||||
$meeting = $post->meeting;
|
||||
$postData['meeting'] = [
|
||||
'meetingable_type' => $meeting->meetingable_type,
|
||||
'meetingable_name' => $meeting->meetingable?->name,
|
||||
'venue' => $meeting->venue,
|
||||
'address' => $meeting->address,
|
||||
'price' => $meeting->price,
|
||||
'based_on_quantity' => $meeting->based_on_quantity,
|
||||
'transaction_type_id' => $meeting->transaction_type_id,
|
||||
'status' => $meeting->status,
|
||||
'from' => $this->formatDate($meeting->from),
|
||||
'till' => $this->formatDate($meeting->till),
|
||||
'created_at' => $this->formatDate($meeting->created_at),
|
||||
'updated_at' => $this->formatDate($meeting->updated_at),
|
||||
'location' => $this->getLocationNames($meeting->location),
|
||||
];
|
||||
$counts['meetings']++;
|
||||
}
|
||||
|
||||
$media = $post->getFirstMedia('posts');
|
||||
if ($media) {
|
||||
$originalPath = $media->getPath();
|
||||
if (File::exists($originalPath)) {
|
||||
$archivePath = "media/{$post->id}/{$media->file_name}";
|
||||
$postData['media'] = [
|
||||
'name' => $media->name,
|
||||
'file_name' => $media->file_name,
|
||||
'mime_type' => $media->mime_type,
|
||||
'size' => $media->size,
|
||||
'collection_name' => $media->collection_name,
|
||||
'custom_properties' => $media->custom_properties,
|
||||
'archive_path' => $archivePath,
|
||||
];
|
||||
$mediaFiles[] = [
|
||||
'source' => $originalPath,
|
||||
'archive_path' => $archivePath,
|
||||
];
|
||||
$counts['media_files']++;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$isFirst) {
|
||||
fwrite($jsonHandle, ',');
|
||||
}
|
||||
fwrite($jsonHandle, json_encode($postData, JSON_UNESCAPED_UNICODE));
|
||||
$isFirst = false;
|
||||
$counts['posts']++;
|
||||
};
|
||||
|
||||
// Use chunking for query builders, iterate for collections
|
||||
if ($posts instanceof \Illuminate\Database\Eloquent\Builder) {
|
||||
$posts->with(['translations', 'meeting.location', 'media'])
|
||||
->chunk($chunkSize, function ($chunk) use ($processPost) {
|
||||
foreach ($chunk as $post) {
|
||||
$processPost($post);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
foreach ($posts as $post) {
|
||||
$processPost($post);
|
||||
}
|
||||
}
|
||||
|
||||
// Close JSON array
|
||||
fwrite($jsonHandle, ']}');
|
||||
fclose($jsonHandle);
|
||||
|
||||
// Build meta JSON
|
||||
$meta = json_encode([
|
||||
'version' => '2.0',
|
||||
'created_at' => now()->toIso8601String(),
|
||||
'source_database' => config('database.connections.mysql.database'),
|
||||
'includes_media' => true,
|
||||
'counts' => $counts,
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
|
||||
// Replace the placeholder in the temp JSON file without reading it all into memory
|
||||
// Use a second temp file and stream-copy with the replacement
|
||||
$finalJsonPath = $tempJsonPath . '.final';
|
||||
$inHandle = fopen($tempJsonPath, 'r');
|
||||
$outHandle = fopen($finalJsonPath, 'w');
|
||||
|
||||
// Read the placeholder prefix, replace it, then stream the rest
|
||||
$prefix = fread($inHandle, strlen('{"meta":"__PLACEHOLDER__"'));
|
||||
fwrite($outHandle, '{"meta":' . $meta);
|
||||
|
||||
// Stream the rest of the file in small chunks
|
||||
while (!feof($inHandle)) {
|
||||
fwrite($outHandle, fread($inHandle, 8192));
|
||||
}
|
||||
|
||||
fclose($inHandle);
|
||||
fclose($outHandle);
|
||||
@unlink($tempJsonPath);
|
||||
rename($finalJsonPath, $tempJsonPath);
|
||||
|
||||
// Create ZIP archive
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($tempPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
|
||||
@unlink($tempJsonPath);
|
||||
$this->notification()->error(
|
||||
__('Error'),
|
||||
__('Failed to create ZIP archive')
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
$zip->addFile($tempJsonPath, 'backup.json');
|
||||
|
||||
foreach ($mediaFiles as $mediaFile) {
|
||||
if (File::exists($mediaFile['source'])) {
|
||||
$zip->addFile($mediaFile['source'], $mediaFile['archive_path']);
|
||||
}
|
||||
}
|
||||
|
||||
$zip->close();
|
||||
@unlink($tempJsonPath);
|
||||
|
||||
// Move ZIP to storage for download via dedicated route (bypasses Livewire response buffering)
|
||||
$backupsDir = storage_path('app/backups');
|
||||
if (!File::isDirectory($backupsDir)) {
|
||||
File::makeDirectory($backupsDir, 0755, true);
|
||||
}
|
||||
File::move($tempPath, $backupsDir . '/' . $filename);
|
||||
|
||||
$this->notification()->success(
|
||||
__('Backup created'),
|
||||
__('Downloaded :count posts with :media media files', [
|
||||
'count' => $counts['posts'],
|
||||
'media' => $counts['media_files'],
|
||||
])
|
||||
);
|
||||
|
||||
// Dispatch browser event to trigger download via dedicated HTTP route
|
||||
$this->dispatch('backup-ready', filename: $filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the restore modal.
|
||||
*/
|
||||
public function openRestoreModal()
|
||||
{
|
||||
$this->authorizeAdminAccess();
|
||||
$this->cleanupTempFile();
|
||||
$this->reset(['restorePreview', 'restoreStats', 'duplicateAction', 'restorePostList', 'selectedPostIndices', 'selectAllPosts', 'uploadId', 'uploadedFilePath', 'selectedFileName']);
|
||||
$this->showRestoreModal = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Receive lightweight preview data parsed client-side by JavaScript.
|
||||
* JS extracts meta + post summaries from the backup JSON so we don't
|
||||
* need to send the entire multi-MB JSON string over the wire.
|
||||
*
|
||||
* @param array $meta The backup meta object
|
||||
* @param array $postSummaries Array of {index, title, slug, locales, slugs, has_meeting, has_media, category_type}
|
||||
* @param string $fileName Original file name
|
||||
* @param bool $isZip Whether the file is a ZIP archive
|
||||
*/
|
||||
public function parseBackupPreview(array $meta, array $postSummaries, string $fileName, bool $isZip)
|
||||
{
|
||||
$this->selectedFileName = $fileName;
|
||||
|
||||
try {
|
||||
// Collect all slugs for duplicate checking
|
||||
$allSlugs = [];
|
||||
foreach ($postSummaries as $summary) {
|
||||
foreach ($summary['slugs'] ?? [] as $slug) {
|
||||
$allSlugs[] = $slug;
|
||||
}
|
||||
}
|
||||
|
||||
$existingSlugs = PostTranslation::withTrashed()
|
||||
->whereIn('slug', $allSlugs)
|
||||
->pluck('slug')
|
||||
->toArray();
|
||||
|
||||
$this->restorePreview = [
|
||||
'version' => $meta['version'] ?? 'unknown',
|
||||
'created_at' => $meta['created_at'] ?? 'unknown',
|
||||
'source_database' => $meta['source_database'] ?? 'unknown',
|
||||
'posts' => $meta['counts']['posts'] ?? count($postSummaries),
|
||||
'translations' => $meta['counts']['post_translations'] ?? 0,
|
||||
'meetings' => $meta['counts']['meetings'] ?? 0,
|
||||
'media_files' => $meta['counts']['media_files'] ?? 0,
|
||||
'includes_media' => $meta['includes_media'] ?? false,
|
||||
'duplicates' => count($existingSlugs),
|
||||
'duplicate_slugs' => array_slice($existingSlugs, 0, 10),
|
||||
'is_zip' => $isZip,
|
||||
];
|
||||
|
||||
// Store the post summaries for selection UI (already lightweight)
|
||||
$this->restorePostList = $postSummaries;
|
||||
$this->selectedPostIndices = array_column($postSummaries, 'index');
|
||||
$this->selectAllPosts = true;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->addError('restoreFile', __('Error reading file: ') . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the uploaded file path from a completed chunked upload.
|
||||
* Called by JS after chunk upload finalization succeeds.
|
||||
*/
|
||||
public function setUploadedFilePath(string $uploadId)
|
||||
{
|
||||
$this->authorizeAdminAccess();
|
||||
|
||||
$sessionKey = "backup_restore_file_{$uploadId}";
|
||||
$path = session($sessionKey);
|
||||
|
||||
if (!$path || !File::exists($path)) {
|
||||
$this->notification()->error(__('Error'), __('Uploaded file not found'));
|
||||
return;
|
||||
}
|
||||
|
||||
$this->uploadId = $uploadId;
|
||||
$this->uploadedFilePath = $path;
|
||||
|
||||
// Clean up session key
|
||||
session()->forget($sessionKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the restore operation.
|
||||
*/
|
||||
public function restore()
|
||||
{
|
||||
$this->authorizeAdminAccess();
|
||||
|
||||
if (!$this->uploadedFilePath || !File::exists($this->uploadedFilePath)) {
|
||||
$this->notification()->error(__('Error'), __('No file uploaded. Please upload the file first.'));
|
||||
return;
|
||||
}
|
||||
|
||||
$this->isRestoring = true;
|
||||
$extractDir = null;
|
||||
|
||||
try {
|
||||
$filePath = $this->uploadedFilePath;
|
||||
$extension = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
|
||||
$isZip = $extension === 'zip';
|
||||
|
||||
$data = null;
|
||||
|
||||
if ($isZip) {
|
||||
// Extract ZIP archive
|
||||
$extractDir = storage_path('app/temp/restore_' . uniqid());
|
||||
File::makeDirectory($extractDir, 0755, true);
|
||||
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($filePath) !== true) {
|
||||
$this->notification()->error(__('Error'), __('Failed to open ZIP archive'));
|
||||
$this->isRestoring = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate ZIP entries to prevent zip-slip path traversal attacks
|
||||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||
$entryName = $zip->getNameIndex($i);
|
||||
$fullPath = realpath($extractDir) . '/' . $entryName;
|
||||
if (strpos($fullPath, realpath($extractDir)) !== 0) {
|
||||
$zip->close();
|
||||
File::deleteDirectory($extractDir);
|
||||
$this->notification()->error(__('Error'), __('ZIP archive contains unsafe file paths'));
|
||||
$this->isRestoring = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$zip->extractTo($extractDir);
|
||||
$zip->close();
|
||||
|
||||
$jsonPath = "{$extractDir}/backup.json";
|
||||
if (!File::exists($jsonPath)) {
|
||||
File::deleteDirectory($extractDir);
|
||||
$this->notification()->error(__('Error'), __('Invalid ZIP archive: missing backup.json'));
|
||||
$this->isRestoring = false;
|
||||
return;
|
||||
}
|
||||
|
||||
$data = json_decode(File::get($jsonPath), true);
|
||||
} else {
|
||||
$data = json_decode(file_get_contents($filePath), true);
|
||||
}
|
||||
|
||||
// Get active profile from session
|
||||
$profileId = session('activeProfileId');
|
||||
$profileType = session('activeProfileType');
|
||||
|
||||
if (!$profileId || !$profileType) {
|
||||
if ($extractDir) {
|
||||
File::deleteDirectory($extractDir);
|
||||
}
|
||||
$this->notification()->error(__('Error'), __('No active profile in session'));
|
||||
$this->isRestoring = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Build category type => id lookup
|
||||
$categoryLookup = Category::pluck('id', 'type')->toArray();
|
||||
|
||||
$stats = [
|
||||
'posts_created' => 0,
|
||||
'posts_skipped' => 0,
|
||||
'posts_overwritten' => 0,
|
||||
'translations_created' => 0,
|
||||
'meetings_created' => 0,
|
||||
'media_restored' => 0,
|
||||
'media_skipped' => 0,
|
||||
];
|
||||
|
||||
$createdPostIds = [];
|
||||
|
||||
DB::beginTransaction();
|
||||
|
||||
// Filter to only selected posts
|
||||
$selectedIndices = array_flip($this->selectedPostIndices);
|
||||
|
||||
// Disable Scout indexing during bulk restore to prevent timeout
|
||||
Post::withoutSyncingToSearch(function () use ($data, $profileId, $profileType, $categoryLookup, &$stats, &$createdPostIds, $extractDir, $isZip, $selectedIndices) {
|
||||
|
||||
foreach ($data['posts'] as $index => $postData) {
|
||||
if (!isset($selectedIndices[$index])) {
|
||||
continue;
|
||||
}
|
||||
// Look up category_id by category_type
|
||||
$categoryId = null;
|
||||
if (!empty($postData['category_type'])) {
|
||||
$categoryId = $categoryLookup[$postData['category_type']] ?? null;
|
||||
}
|
||||
|
||||
// Check for existing slugs
|
||||
if (!empty($postData['translations'])) {
|
||||
$existingSlugs = PostTranslation::withTrashed()
|
||||
->whereIn('slug', array_column($postData['translations'], 'slug'))
|
||||
->pluck('slug')
|
||||
->toArray();
|
||||
|
||||
if (!empty($existingSlugs)) {
|
||||
if ($this->duplicateAction === 'skip') {
|
||||
$stats['posts_skipped']++;
|
||||
continue;
|
||||
} elseif ($this->duplicateAction === 'overwrite') {
|
||||
// Delete existing translations and their posts
|
||||
$existingTranslations = PostTranslation::withTrashed()
|
||||
->whereIn('slug', $existingSlugs)
|
||||
->get();
|
||||
|
||||
foreach ($existingTranslations as $existingTranslation) {
|
||||
$postId = $existingTranslation->post_id;
|
||||
$existingTranslation->forceDelete();
|
||||
|
||||
$remainingTranslations = PostTranslation::withTrashed()
|
||||
->where('post_id', $postId)
|
||||
->count();
|
||||
|
||||
if ($remainingTranslations === 0) {
|
||||
$existingPost = Post::withTrashed()->find($postId);
|
||||
if ($existingPost) {
|
||||
// Clear media before deleting post
|
||||
$existingPost->clearMediaCollection('posts');
|
||||
Meeting::withTrashed()->where('post_id', $postId)->forceDelete();
|
||||
$existingPost->forceDelete();
|
||||
}
|
||||
}
|
||||
}
|
||||
$stats['posts_overwritten']++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create post
|
||||
$post = new Post();
|
||||
$post->postable_id = $profileId;
|
||||
$post->postable_type = $profileType;
|
||||
$post->category_id = $categoryId;
|
||||
// Don't set love_reactant_id - let PostObserver register it as reactant
|
||||
$post->author_id = null; // Author IDs are not portable between databases
|
||||
$post->author_model = null;
|
||||
$post->created_at = $postData['created_at'] ? new \DateTime($postData['created_at']) : now();
|
||||
$post->updated_at = $postData['updated_at'] ? new \DateTime($postData['updated_at']) : now();
|
||||
$post->save();
|
||||
|
||||
// Ensure post is registered as reactant (in case observer didn't fire)
|
||||
if (!$post->isRegisteredAsLoveReactant()) {
|
||||
$post->registerAsLoveReactant();
|
||||
}
|
||||
|
||||
// Create translations
|
||||
foreach ($postData['translations'] as $translationData) {
|
||||
$translation = new PostTranslation();
|
||||
$translation->post_id = $post->id;
|
||||
$translation->locale = $translationData['locale'];
|
||||
$translation->slug = $translationData['slug'];
|
||||
$translation->title = $translationData['title'];
|
||||
$translation->excerpt = $translationData['excerpt'];
|
||||
$translation->content = $translationData['content'];
|
||||
$translation->status = $translationData['status'];
|
||||
$translation->updated_by_user_id = $translationData['updated_by_user_id'];
|
||||
$translation->from = $translationData['from'] ? new \DateTime($translationData['from']) : null;
|
||||
$translation->till = $translationData['till'] ? new \DateTime($translationData['till']) : null;
|
||||
$translation->created_at = $translationData['created_at'] ? new \DateTime($translationData['created_at']) : now();
|
||||
$translation->updated_at = $translationData['updated_at'] ? new \DateTime($translationData['updated_at']) : now();
|
||||
$translation->save();
|
||||
|
||||
$stats['translations_created']++;
|
||||
}
|
||||
|
||||
// Create meeting (hasOne relationship)
|
||||
if (!empty($postData['meeting'])) {
|
||||
$meetingData = $postData['meeting'];
|
||||
|
||||
// Look up meetingable by name and type
|
||||
$meetingableId = null;
|
||||
$meetingableType = null;
|
||||
// Whitelist of allowed meetingable types to prevent arbitrary class instantiation
|
||||
$allowedMeetingableTypes = [
|
||||
\App\Models\User::class,
|
||||
\App\Models\Organization::class,
|
||||
\App\Models\Bank::class,
|
||||
];
|
||||
if (!empty($meetingData['meetingable_type']) && !empty($meetingData['meetingable_name'])) {
|
||||
$meetingableType = $meetingData['meetingable_type'];
|
||||
if (in_array($meetingableType, $allowedMeetingableTypes, true)) {
|
||||
$meetingable = $meetingableType::where('name', $meetingData['meetingable_name'])->first();
|
||||
if ($meetingable) {
|
||||
$meetingableId = $meetingable->id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$meeting = new Meeting();
|
||||
$meeting->post_id = $post->id;
|
||||
$meeting->meetingable_id = $meetingableId;
|
||||
$meeting->meetingable_type = $meetingableId ? $meetingableType : null;
|
||||
$meeting->venue = $meetingData['venue'];
|
||||
$meeting->address = $meetingData['address'];
|
||||
$meeting->price = $meetingData['price'];
|
||||
$meeting->based_on_quantity = $meetingData['based_on_quantity'];
|
||||
$meeting->transaction_type_id = $meetingData['transaction_type_id'];
|
||||
$meeting->status = $meetingData['status'];
|
||||
$meeting->from = $meetingData['from'] ? new \DateTime($meetingData['from']) : null;
|
||||
$meeting->till = $meetingData['till'] ? new \DateTime($meetingData['till']) : null;
|
||||
$meeting->created_at = $meetingData['created_at'] ? new \DateTime($meetingData['created_at']) : now();
|
||||
$meeting->updated_at = $meetingData['updated_at'] ? new \DateTime($meetingData['updated_at']) : now();
|
||||
$meeting->save();
|
||||
|
||||
// Create location if location data exists
|
||||
if (!empty($meetingData['location'])) {
|
||||
$locationIds = $this->lookupLocationIds($meetingData['location']);
|
||||
if ($locationIds['country_id'] || $locationIds['division_id'] || $locationIds['city_id'] || $locationIds['district_id']) {
|
||||
$location = new Location();
|
||||
$location->locatable_id = $meeting->id;
|
||||
$location->locatable_type = Meeting::class;
|
||||
$location->country_id = $locationIds['country_id'];
|
||||
$location->division_id = $locationIds['division_id'];
|
||||
$location->city_id = $locationIds['city_id'];
|
||||
$location->district_id = $locationIds['district_id'];
|
||||
$location->save();
|
||||
}
|
||||
}
|
||||
|
||||
$stats['meetings_created']++;
|
||||
}
|
||||
|
||||
// Restore media if available
|
||||
if ($isZip && $extractDir && !empty($postData['media'])) {
|
||||
$mediaData = $postData['media'];
|
||||
$mediaPath = "{$extractDir}/{$mediaData['archive_path']}";
|
||||
|
||||
// Prevent path traversal via crafted archive_path in JSON
|
||||
$realMediaPath = realpath($mediaPath);
|
||||
$realExtractDir = realpath($extractDir);
|
||||
if ($realMediaPath && $realExtractDir && strpos($realMediaPath, $realExtractDir) === 0 && File::exists($mediaPath)) {
|
||||
try {
|
||||
$media = $post->addMedia($mediaPath)
|
||||
->preservingOriginal() // Don't delete from extract dir yet
|
||||
->usingName($mediaData['name'])
|
||||
->usingFileName($mediaData['file_name'])
|
||||
->withCustomProperties($mediaData['custom_properties'] ?? [])
|
||||
->toMediaCollection('posts');
|
||||
|
||||
// Dispatch conversion job to queue
|
||||
$conversionCollection = \Spatie\MediaLibrary\Conversions\ConversionCollection::createForMedia($media);
|
||||
if ($conversionCollection->isNotEmpty()) {
|
||||
dispatch(new \Spatie\MediaLibrary\Conversions\Jobs\PerformConversionsJob($conversionCollection, $media, false))
|
||||
->onQueue('low');
|
||||
}
|
||||
|
||||
$stats['media_restored']++;
|
||||
} catch (\Exception $e) {
|
||||
$stats['media_skipped']++;
|
||||
}
|
||||
} else {
|
||||
$stats['media_skipped']++;
|
||||
}
|
||||
}
|
||||
|
||||
$createdPostIds[] = $post->id;
|
||||
$stats['posts_created']++;
|
||||
}
|
||||
|
||||
}); // End withoutSyncingToSearch
|
||||
|
||||
DB::commit();
|
||||
|
||||
// Clean up extracted files
|
||||
if ($extractDir) {
|
||||
File::deleteDirectory($extractDir);
|
||||
}
|
||||
|
||||
// Queue posts for search indexing on 'low' queue in chunks
|
||||
if (!empty($createdPostIds)) {
|
||||
foreach (array_chunk($createdPostIds, 50) as $chunk) {
|
||||
$posts = Post::whereIn('id', $chunk)->get();
|
||||
dispatch(new MakeSearchable($posts))->onQueue('low');
|
||||
}
|
||||
}
|
||||
|
||||
$this->restoreStats = $stats;
|
||||
|
||||
// Clean up uploaded temp file and free memory
|
||||
if ($this->uploadedFilePath && File::exists($this->uploadedFilePath)) {
|
||||
@unlink($this->uploadedFilePath);
|
||||
}
|
||||
$this->uploadedFilePath = null;
|
||||
$this->uploadId = null;
|
||||
$this->restorePreview = [];
|
||||
$this->restorePostList = [];
|
||||
$this->selectedPostIndices = [];
|
||||
|
||||
$mediaMsg = $stats['media_restored'] > 0 ? " with {$stats['media_restored']} media files" : '';
|
||||
$this->notification()->success(
|
||||
__('Restore completed'),
|
||||
__('Created :count posts', ['count' => $stats['posts_created']]) . $mediaMsg
|
||||
);
|
||||
|
||||
// Refresh parent posts table
|
||||
$this->dispatch('refreshPostsTable')->to('posts.manage');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
if ($extractDir && File::isDirectory($extractDir)) {
|
||||
File::deleteDirectory($extractDir);
|
||||
}
|
||||
$this->notification()->error(__('Error'), $e->getMessage());
|
||||
}
|
||||
|
||||
$this->isRestoring = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close modal and reset state.
|
||||
*/
|
||||
public function closeRestoreModal()
|
||||
{
|
||||
$this->showRestoreModal = false;
|
||||
$this->cleanupTempFile();
|
||||
$this->reset(['restorePreview', 'restoreStats', 'duplicateAction', 'restorePostList', 'selectedPostIndices', 'selectAllPosts', 'uploadId', 'uploadedFilePath', 'selectedFileName']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up any uploaded temp file.
|
||||
*/
|
||||
private function cleanupTempFile(): void
|
||||
{
|
||||
if ($this->uploadedFilePath && File::exists($this->uploadedFilePath)) {
|
||||
@unlink($this->uploadedFilePath);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely format a date value to ISO8601 string.
|
||||
*/
|
||||
private function formatDate($value): ?string
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($value instanceof \Carbon\Carbon || $value instanceof \DateTime) {
|
||||
return $value->format('c');
|
||||
}
|
||||
|
||||
if (is_string($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get location names in the app's base locale for backup.
|
||||
* Names are used for lookup on restore instead of IDs.
|
||||
*/
|
||||
private function getLocationNames($location): ?array
|
||||
{
|
||||
if (!$location) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$baseLocale = config('app.locale');
|
||||
|
||||
// Get country name
|
||||
$countryName = null;
|
||||
if ($location->country_id) {
|
||||
$countryLocale = CountryLocale::withoutGlobalScopes()
|
||||
->where('country_id', $location->country_id)
|
||||
->where('locale', $baseLocale)
|
||||
->first();
|
||||
$countryName = $countryLocale?->name;
|
||||
}
|
||||
|
||||
// Get division name
|
||||
$divisionName = null;
|
||||
if ($location->division_id) {
|
||||
$divisionLocale = DivisionLocale::withoutGlobalScopes()
|
||||
->where('division_id', $location->division_id)
|
||||
->where('locale', $baseLocale)
|
||||
->first();
|
||||
$divisionName = $divisionLocale?->name;
|
||||
}
|
||||
|
||||
// Get city name
|
||||
$cityName = null;
|
||||
if ($location->city_id) {
|
||||
$cityLocale = CityLocale::withoutGlobalScopes()
|
||||
->where('city_id', $location->city_id)
|
||||
->where('locale', $baseLocale)
|
||||
->first();
|
||||
$cityName = $cityLocale?->name;
|
||||
}
|
||||
|
||||
// Get district name
|
||||
$districtName = null;
|
||||
if ($location->district_id) {
|
||||
$districtLocale = DistrictLocale::withoutGlobalScopes()
|
||||
->where('district_id', $location->district_id)
|
||||
->where('locale', $baseLocale)
|
||||
->first();
|
||||
$districtName = $districtLocale?->name;
|
||||
}
|
||||
|
||||
return [
|
||||
'country_name' => $countryName,
|
||||
'division_name' => $divisionName,
|
||||
'city_name' => $cityName,
|
||||
'district_name' => $districtName,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up location IDs by names in the app's base locale.
|
||||
* Returns null for any location component that cannot be found.
|
||||
*/
|
||||
private function lookupLocationIds(array $locationData): array
|
||||
{
|
||||
$baseLocale = config('app.locale');
|
||||
|
||||
$result = [
|
||||
'country_id' => null,
|
||||
'division_id' => null,
|
||||
'city_id' => null,
|
||||
'district_id' => null,
|
||||
];
|
||||
|
||||
// Look up country by name
|
||||
if (!empty($locationData['country_name'])) {
|
||||
$countryLocale = CountryLocale::withoutGlobalScopes()
|
||||
->where('name', $locationData['country_name'])
|
||||
->where('locale', $baseLocale)
|
||||
->first();
|
||||
$result['country_id'] = $countryLocale?->country_id;
|
||||
}
|
||||
|
||||
// Look up division by name
|
||||
if (!empty($locationData['division_name'])) {
|
||||
$divisionLocale = DivisionLocale::withoutGlobalScopes()
|
||||
->where('name', $locationData['division_name'])
|
||||
->where('locale', $baseLocale)
|
||||
->first();
|
||||
$result['division_id'] = $divisionLocale?->division_id;
|
||||
}
|
||||
|
||||
// Look up city by name
|
||||
if (!empty($locationData['city_name'])) {
|
||||
$cityLocale = CityLocale::withoutGlobalScopes()
|
||||
->where('name', $locationData['city_name'])
|
||||
->where('locale', $baseLocale)
|
||||
->first();
|
||||
$result['city_id'] = $cityLocale?->city_id;
|
||||
}
|
||||
|
||||
// Look up district by name
|
||||
if (!empty($locationData['district_name'])) {
|
||||
$districtLocale = DistrictLocale::withoutGlobalScopes()
|
||||
->where('name', $locationData['district_name'])
|
||||
->where('locale', $baseLocale)
|
||||
->first();
|
||||
$result['district_id'] = $districtLocale?->district_id;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.posts.backup-restore');
|
||||
}
|
||||
}
|
||||
13
app/Http/Livewire/Posts/Create.php
Normal file
13
app/Http/Livewire/Posts/Create.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire\Posts;
|
||||
|
||||
use Livewire\Component;
|
||||
|
||||
class Create extends Component
|
||||
{
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.posts.create');
|
||||
}
|
||||
}
|
||||
1684
app/Http/Livewire/Posts/Manage.php
Normal file
1684
app/Http/Livewire/Posts/Manage.php
Normal file
File diff suppressed because it is too large
Load Diff
16
app/Http/Livewire/Posts/ManageActions.php
Normal file
16
app/Http/Livewire/Posts/ManageActions.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire\Posts;
|
||||
|
||||
use App\Models\Post;
|
||||
use Livewire\Component;
|
||||
|
||||
class ManageActions extends Component
|
||||
{
|
||||
public Post $post;
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.posts.manage-actions');
|
||||
}
|
||||
}
|
||||
196
app/Http/Livewire/Posts/SelectAuthor.php
Normal file
196
app/Http/Livewire/Posts/SelectAuthor.php
Normal file
@@ -0,0 +1,196 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire\Posts;
|
||||
|
||||
use App\Models\Bank;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Livewire\Component;
|
||||
|
||||
class SelectAuthor extends Component
|
||||
{
|
||||
public $search;
|
||||
public $searchResults = [];
|
||||
public $showDropdown = false;
|
||||
public $selectedId;
|
||||
public $selected = [];
|
||||
|
||||
// Properties for receiving author data from parent
|
||||
public $authorId;
|
||||
public $authorModel;
|
||||
|
||||
protected $listeners = [
|
||||
'resetForm',
|
||||
'authorExists'
|
||||
];
|
||||
|
||||
public function mount($authorId = null, $authorModel = null)
|
||||
{
|
||||
$this->authorId = $authorId;
|
||||
$this->authorModel = $authorModel;
|
||||
|
||||
// If author data is provided, populate the selected data
|
||||
if ($this->authorId && $this->authorModel) {
|
||||
$this->populateAuthorData($this->authorId, $this->authorModel);
|
||||
}
|
||||
}
|
||||
|
||||
private function populateAuthorData($authorId, $authorModel)
|
||||
{
|
||||
$this->selectedId = $authorId;
|
||||
$this->selected['id'] = $authorId;
|
||||
$this->selected['type'] = $authorModel;
|
||||
|
||||
try {
|
||||
if ($authorModel == User::class) {
|
||||
$author = User::where('id', $authorId)->select('name', 'profile_photo_path')->firstOrFail();
|
||||
$description = '';
|
||||
} elseif ($authorModel == Organization::class) {
|
||||
$author = Organization::where('id', $authorId)->select('name', 'profile_photo_path')->firstOrFail();
|
||||
$description = __('Organization');
|
||||
} elseif ($authorModel == Bank::class) {
|
||||
$author = Bank::where('id', $authorId)->select('name', 'profile_photo_path')->firstOrFail();
|
||||
$description = __('Bank');
|
||||
} else {
|
||||
// Unknown author model type
|
||||
return;
|
||||
}
|
||||
|
||||
$this->selected['name'] = $author->name;
|
||||
$this->selected['profile_photo_path'] = url(Storage::url($author->profile_photo_path));
|
||||
$this->selected['description'] = $description;
|
||||
} catch (\Exception) {
|
||||
// Silently fail if author data cannot be loaded
|
||||
$this->selectedId = null;
|
||||
$this->selected = [];
|
||||
}
|
||||
}
|
||||
|
||||
public function inputBlur()
|
||||
{
|
||||
$this->dispatch('toAuthorValidation');
|
||||
$this->showDropdown = false;
|
||||
$this->search = '';
|
||||
}
|
||||
|
||||
public function resetForm()
|
||||
{
|
||||
$this->reset();
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate dropdown when already author data exists
|
||||
*
|
||||
* @param mixed $value
|
||||
* @return void
|
||||
*/
|
||||
public function authorExists($value)
|
||||
{
|
||||
$this->selectedId = $value['author_id'];
|
||||
$this->selected['id'] = $value['author_id'];
|
||||
$this->selected['type'] = $value['author_model'];
|
||||
|
||||
if ($value['author_model'] == User::class) {
|
||||
$author = User::where('id', $value['author_id'])->select('name', 'profile_photo_path')->firstOrFail();
|
||||
$description = '';
|
||||
} elseif ($value['author_model'] == Organization::class) {
|
||||
$author = Organization::where('id', $value['author_id'])->select('name', 'profile_photo_path')->firstOrFail();
|
||||
$description = __('Organization');
|
||||
} elseif ($value['author_model'] == Bank::class) {
|
||||
$author = Bank::where('id', $value['author_id'])->select('name', 'profile_photo_path')->firstOrFail();
|
||||
$description = __('Bank');
|
||||
}
|
||||
|
||||
$this->selected['name'] = $author->name;
|
||||
$this->selected['profile_photo_path'] = url(Storage::url($author->profile_photo_path));
|
||||
$this->selected['description'] = $description;
|
||||
}
|
||||
|
||||
|
||||
public function authorSelected($value)
|
||||
{
|
||||
$this->selectedId = $value;
|
||||
$this->selected = collect($this->searchResults)->where('id', '=', $value)->first();
|
||||
$this->showDropdown = false;
|
||||
$this->search = '';
|
||||
$this->dispatch('authorSelected', $this->selected);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* updatedSearch: Search query users, organizations, and banks
|
||||
*
|
||||
* @param mixed $newValue
|
||||
* @return void
|
||||
*/
|
||||
public function updatedSearch()
|
||||
{
|
||||
$this->showDropdown = true;
|
||||
$search = $this->search;
|
||||
|
||||
// Search Users
|
||||
$users = User::where('name', 'like', '%' . $search . '%')
|
||||
->where('id', '!=', '1') // Exclude Super-Admin user //TODO: exclude all admin users by role
|
||||
->select('id', 'name', 'profile_photo_path')
|
||||
->get();
|
||||
$users = $users->map(function ($item) {
|
||||
return
|
||||
[
|
||||
'id' => $item['id'],
|
||||
'type' => User::class,
|
||||
'name' => $item['name'],
|
||||
'description' => '',
|
||||
'profile_photo_path' => url('/storage/' . $item['profile_photo_path'])
|
||||
];
|
||||
});
|
||||
|
||||
// Search Organizations
|
||||
$organizations = Organization::where('name', 'like', '%' . $search . '%')
|
||||
->select('id', 'name', 'profile_photo_path')
|
||||
->get();
|
||||
$organizations = $organizations->map(function ($item) {
|
||||
return
|
||||
[
|
||||
'id' => $item['id'],
|
||||
'type' => Organization::class,
|
||||
'name' => $item['name'],
|
||||
'description' => __('Organization'),
|
||||
'profile_photo_path' => url(Storage::url($item['profile_photo_path']))
|
||||
];
|
||||
});
|
||||
|
||||
// Search Banks
|
||||
$banks = Bank::where('name', 'like', '%' . $search . '%')
|
||||
->select('id', 'name', 'profile_photo_path')
|
||||
->get();
|
||||
$banks = $banks->map(function ($item) {
|
||||
return
|
||||
[
|
||||
'id' => $item['id'],
|
||||
'type' => Bank::class,
|
||||
'name' => $item['name'],
|
||||
'description' => __('Bank'),
|
||||
'profile_photo_path' => url(Storage::url($item['profile_photo_path']))
|
||||
];
|
||||
});
|
||||
|
||||
// Merge all results
|
||||
$merged = collect($users)->merge($organizations)->merge($banks);
|
||||
$response = $merged->take(6);
|
||||
$this->searchResults = $response;
|
||||
}
|
||||
|
||||
public function removeSelectedProfile()
|
||||
{
|
||||
$this->selectedId = null;
|
||||
$this->selected = [];
|
||||
$this->dispatch('authorSelected', ['id' => null, 'type' => null]);
|
||||
}
|
||||
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.posts.select-author');
|
||||
}
|
||||
}
|
||||
138
app/Http/Livewire/Posts/SelectOrganizer.php
Normal file
138
app/Http/Livewire/Posts/SelectOrganizer.php
Normal file
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire\Posts;
|
||||
|
||||
use App\Models\Bank;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Livewire\Component;
|
||||
|
||||
class SelectOrganizer extends Component
|
||||
{
|
||||
public $search;
|
||||
public $searchResults = [];
|
||||
public $showDropdown = false;
|
||||
public $selectedId;
|
||||
public $selected = [];
|
||||
|
||||
protected $listeners = [
|
||||
'resetForm',
|
||||
'organizerExists'
|
||||
];
|
||||
|
||||
|
||||
public function inputBlur()
|
||||
{
|
||||
$this->dispatch('toAccountValidation');
|
||||
$this->showDropdown = false;
|
||||
$this->search = '';
|
||||
}
|
||||
|
||||
public function resetForm()
|
||||
{
|
||||
$this->reset();
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate dropdown when already meeting data exists
|
||||
*
|
||||
* @param mixed $value
|
||||
* @return void
|
||||
*/
|
||||
public function organizerExists($value)
|
||||
{
|
||||
$this->selectedId = $value['meetingable_id'];
|
||||
$this->selected['id'] = $value['meetingable_id'];
|
||||
$this->selected['type'] = $value['meetingable_type'];
|
||||
if ($value['meetingable_type'] == User::class) {
|
||||
$organizer = User::where('id', $value['meetingable_id'])->select('name', 'profile_photo_path')->firstOrFail();
|
||||
$description = '';
|
||||
} elseif ($value['meetingable_type'] == Bank::class) {
|
||||
$organizer = Bank::where('id', $value['meetingable_id'])->select('name', 'profile_photo_path')->firstOrFail();
|
||||
$description = __('Bank');
|
||||
} else {
|
||||
$organizer = Organization::where('id', $value['meetingable_id'])->select('name', 'profile_photo_path')->firstOrFail();
|
||||
$description = __('Organization');
|
||||
}
|
||||
$this->selected['name'] = $organizer->name;
|
||||
$this->selected['profile_photo_path'] = url(Storage::url($organizer->profile_photo_path));
|
||||
$this->selected['description'] = $description;
|
||||
}
|
||||
|
||||
|
||||
public function orgSelected($value)
|
||||
{
|
||||
$this->selectedId = $value;
|
||||
$this->selected = collect($this->searchResults)->where('id', '=', $value)->first();
|
||||
$this->showDropdown = false;
|
||||
$this->search = '';
|
||||
$this->dispatch('organizerSelected', $this->selected);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* updatedSearch: Search query users and organizations
|
||||
*
|
||||
* @param mixed $newValue
|
||||
* @return void
|
||||
*/
|
||||
public function updatedSearch($newValue)
|
||||
{
|
||||
$this->showDropdown = true;
|
||||
$search = $this->search;
|
||||
$users = User::where('name', 'like', '%' . $search . '%')
|
||||
->where('id', '!=', '1') // Exclude Super-Admin user //TODO: exclude all admin users by role
|
||||
->select('id', 'name', 'profile_photo_path')
|
||||
->get()
|
||||
->map(function ($item) {
|
||||
return [
|
||||
'id' => $item['id'],
|
||||
'type' => User::class,
|
||||
'name' => $item['name'],
|
||||
'description' => '',
|
||||
'profile_photo_path' => url('/storage/' . $item['profile_photo_path'])
|
||||
];
|
||||
});
|
||||
$organizations = Organization::where('name', 'like', '%' . $search . '%')
|
||||
->select('id', 'name', 'profile_photo_path')
|
||||
->get()
|
||||
->map(function ($item) {
|
||||
return [
|
||||
'id' => $item['id'],
|
||||
'type' => Organization::class,
|
||||
'name' => $item['name'],
|
||||
'description' => __('Organization'),
|
||||
'profile_photo_path' => url(Storage::url($item['profile_photo_path']))
|
||||
];
|
||||
});
|
||||
$banks = Bank::where('name', 'like', '%' . $search . '%')
|
||||
->select('id', 'name', 'profile_photo_path')
|
||||
->get()
|
||||
->map(function ($item) {
|
||||
return [
|
||||
'id' => $item['id'],
|
||||
'type' => Bank::class,
|
||||
'name' => $item['name'],
|
||||
'description' => __('Bank'),
|
||||
'profile_photo_path' => url(Storage::url($item['profile_photo_path']))
|
||||
];
|
||||
});
|
||||
$merged = $users->concat($organizations)->concat($banks);
|
||||
$response = $merged->take(6);
|
||||
$this->searchResults = $response->toArray();
|
||||
}
|
||||
|
||||
public function removeSelectedProfile()
|
||||
{
|
||||
$this->selectedId = null;
|
||||
$this->selected = [];
|
||||
$this->dispatch('organizerSelected', $this->selected);
|
||||
}
|
||||
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.posts.select-organizer');
|
||||
}
|
||||
}
|
||||
452
app/Http/Livewire/Profile/DeleteUserForm.php
Normal file
452
app/Http/Livewire/Profile/DeleteUserForm.php
Normal file
@@ -0,0 +1,452 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire\Profile;
|
||||
|
||||
use App\Mail\UserDeletedMail;
|
||||
use Illuminate\Contracts\Auth\StatefulGuard;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Jetstream\Contracts\DeletesUsers;
|
||||
use Livewire\Component;
|
||||
use WireUi\Traits\WireUiActions;
|
||||
|
||||
|
||||
class DeleteUserForm extends Component
|
||||
{
|
||||
use WireUiActions;
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Indicates if user deletion is being confirmed.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
public $confirmingUserDeletion = false;
|
||||
|
||||
/**
|
||||
* The user's current password.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public $password = '';
|
||||
|
||||
/**
|
||||
* Balance handling option selected by user.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public $balanceHandlingOption = 'delete';
|
||||
|
||||
/**
|
||||
* Selected organization account ID for balance donation.
|
||||
*
|
||||
* @var int|null
|
||||
*/
|
||||
public $donationAccountId = null;
|
||||
|
||||
/**
|
||||
* User's accounts with balances.
|
||||
*
|
||||
* @var \Illuminate\Support\Collection
|
||||
*/
|
||||
public $accounts;
|
||||
|
||||
/**
|
||||
* Total balance across all accounts.
|
||||
*
|
||||
* @var float|int
|
||||
*/
|
||||
public $totalBalance = 0;
|
||||
|
||||
/**
|
||||
* Whether any account has a negative balance.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
public $hasNegativeBalance = false;
|
||||
|
||||
/**
|
||||
* Whether the profile is a central bank (level = 0).
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
public $isCentralBank = false;
|
||||
|
||||
/**
|
||||
* Whether the profile is the final admin.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
public $isFinalAdmin = false;
|
||||
|
||||
/**
|
||||
* Whether the donation would exceed the receiving account's limit.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
public $donationExceedsLimit = false;
|
||||
|
||||
/**
|
||||
* Error message for donation limit exceeded.
|
||||
*
|
||||
* @var string|null
|
||||
*/
|
||||
public $donationLimitError = null;
|
||||
|
||||
/**
|
||||
* Listener to receive toAccountId from ToAccount component.
|
||||
*/
|
||||
protected $listeners = ['toAccountId' => 'setDonationAccountId'];
|
||||
|
||||
/**
|
||||
* Called when balanceHandlingOption is updated.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function updatedBalanceHandlingOption()
|
||||
{
|
||||
$this->checkDonationLimits();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the donation account ID from ToAccount component.
|
||||
*
|
||||
* @param int|null $accountId
|
||||
* @return void
|
||||
*/
|
||||
public function setDonationAccountId($accountId)
|
||||
{
|
||||
$this->donationAccountId = $accountId;
|
||||
$this->checkDonationLimits();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the donation would exceed the receiving account's limits.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function checkDonationLimits()
|
||||
{
|
||||
// Reset error state
|
||||
$this->donationExceedsLimit = false;
|
||||
$this->donationLimitError = null;
|
||||
|
||||
// If no donation account selected or no balance to donate, skip check
|
||||
if (!$this->donationAccountId || $this->totalBalance <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the donation account
|
||||
$donationAccount = \App\Models\Account::find($this->donationAccountId);
|
||||
if (!$donationAccount) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear cache for fresh balance
|
||||
\Cache::forget("account_balance_{$donationAccount->id}");
|
||||
|
||||
// Get current balance of the receiving account
|
||||
$currentBalance = $donationAccount->balance;
|
||||
|
||||
// Calculate the maximum receivable amount
|
||||
// limitMaxTo = limit_max - limit_min (similar to Pay.php logic)
|
||||
$limitMaxTo = $donationAccount->limit_max - $donationAccount->limit_min;
|
||||
|
||||
// Calculate available budget for receiving
|
||||
$transferBudgetTo = $limitMaxTo - $currentBalance;
|
||||
|
||||
// Check if donation amount exceeds the receiving account's budget
|
||||
if ($this->totalBalance > $transferBudgetTo) {
|
||||
$this->donationExceedsLimit = true;
|
||||
|
||||
// Check if the receiving account holder's balance is public
|
||||
$holderType = $donationAccount->accountable_type;
|
||||
$balancePublic = timebank_config('account_info.' . strtolower(class_basename($holderType)) . '.balance_public', false);
|
||||
|
||||
if ($balancePublic) {
|
||||
$this->donationLimitError = __('The selected account cannot receive this donation amount due to account limits. Please select a different account or delete your balance instead.', [
|
||||
'amount' => tbFormat($transferBudgetTo)
|
||||
]);
|
||||
} else {
|
||||
$this->donationLimitError = __('The selected organization account cannot receive this donation amount due to account limits. Please select a different organization or delete your balance instead.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm that the user would like to delete their account.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function confirmUserDeletion()
|
||||
{
|
||||
$this->resetErrorBag();
|
||||
$this->password = '';
|
||||
$this->dispatch('confirming-delete-user');
|
||||
$this->confirmingUserDeletion = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the current user.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @param \Laravel\Jetstream\Contracts\DeletesUsers $deleter
|
||||
* @param \Illuminate\Contracts\Auth\StatefulGuard $auth
|
||||
* @return \Illuminate\Routing\Redirector|\Illuminate\Http\RedirectResponse
|
||||
*/
|
||||
public function deleteUser(Request $request, DeletesUsers $deleter, StatefulGuard $auth)
|
||||
{
|
||||
$this->resetErrorBag();
|
||||
|
||||
// Get the active profile using helper
|
||||
$profile = getActiveProfile();
|
||||
|
||||
if (!$profile) {
|
||||
throw new \Exception('No active profile found.');
|
||||
}
|
||||
|
||||
// CRITICAL SECURITY: Validate user has ownership/access to this profile
|
||||
// This prevents IDOR (Insecure Direct Object Reference) attacks where
|
||||
// a user manipulates session data to delete profiles they don't own
|
||||
\App\Helpers\ProfileAuthorizationHelper::authorize($profile);
|
||||
|
||||
// Check if trying to delete a central bank
|
||||
if ($profile instanceof \App\Models\Bank && isset($profile->level) && $profile->level == 0) {
|
||||
throw ValidationException::withMessages([
|
||||
'password' => [__('Central bank (level 0) cannot be deleted. Central banks are essential for currency creation and management.')],
|
||||
]);
|
||||
}
|
||||
|
||||
// Check if trying to delete the final admin
|
||||
if ($profile instanceof \App\Models\Admin) {
|
||||
$activeAdminCount = \App\Models\Admin::whereNull('deleted_at')->count();
|
||||
if ($activeAdminCount <= 1) {
|
||||
throw ValidationException::withMessages([
|
||||
'password' => [__('Final administrator cannot be deleted. At least one administrator account must remain active in the system.')],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Determine which password to check based on profile type
|
||||
if ($profile instanceof \App\Models\User || $profile instanceof \App\Models\Organization) {
|
||||
// User or Organization: validate against base user password
|
||||
// Organizations don't have their own password (passwordless)
|
||||
$authenticatedUser = Auth::user();
|
||||
if (! Hash::check($this->password, $authenticatedUser->password)) {
|
||||
throw ValidationException::withMessages([
|
||||
'password' => [__('This password does not match our records.')],
|
||||
]);
|
||||
}
|
||||
} elseif ($profile instanceof \App\Models\Bank || $profile instanceof \App\Models\Admin) {
|
||||
// Bank or Admin: validate against their own password
|
||||
if (! Hash::check($this->password, $profile->password)) {
|
||||
throw ValidationException::withMessages([
|
||||
'password' => [__('This password does not match our records.')],
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
throw new \Exception('Unknown profile type.');
|
||||
}
|
||||
|
||||
// Validate balance handling option if donation is selected
|
||||
if ($this->balanceHandlingOption === 'donate' && !$this->donationAccountId) {
|
||||
throw ValidationException::withMessages([
|
||||
'donationAccountId' => [__('Please select an organization account to donate your balance to.')],
|
||||
]);
|
||||
}
|
||||
|
||||
// Check if donation would exceed limits
|
||||
if ($this->balanceHandlingOption === 'donate' && $this->donationExceedsLimit) {
|
||||
throw ValidationException::withMessages([
|
||||
'donationAccountId' => [$this->donationLimitError ?? __('The selected organization account cannot receive this donation amount.')],
|
||||
]);
|
||||
}
|
||||
|
||||
// Determine table name based on profile type
|
||||
$profileTable = $profile->getTable();
|
||||
|
||||
// Get the profile's updated_at timestamp
|
||||
$time = DB::table($profileTable)
|
||||
->where('id', $profile->id)
|
||||
->pluck('updated_at')
|
||||
->first();
|
||||
|
||||
$time = Carbon::parse($time); // Convert the time to a Carbon instance
|
||||
|
||||
// Pass balance handling options to the deleter
|
||||
$result = $deleter->delete(
|
||||
$profile->fresh(),
|
||||
$this->balanceHandlingOption,
|
||||
$this->donationAccountId
|
||||
);
|
||||
|
||||
$this->confirmingUserDeletion = false;
|
||||
|
||||
if ($result['status'] === 'success') {
|
||||
|
||||
$result['time'] = $time->translatedFormat('j F Y, H:i');
|
||||
$result['deletedUser'] = $profile;
|
||||
$result['mail'] = $profile->email;
|
||||
$result['balanceHandlingOption'] = $this->balanceHandlingOption;
|
||||
$result['totalBalance'] = $this->totalBalance;
|
||||
$result['donationAccountId'] = $this->donationAccountId;
|
||||
$result['gracePeriodDays'] = timebank_config('delete_profile.grace_period_days', 30);
|
||||
|
||||
// Get donation account details if donated
|
||||
if ($this->balanceHandlingOption === 'donate' && $this->donationAccountId) {
|
||||
$donationAccount = \App\Models\Account::find($this->donationAccountId);
|
||||
if ($donationAccount && $donationAccount->accountable) {
|
||||
$result['donationAccountName'] = $donationAccount->name;
|
||||
$result['donationOrganizationName'] = $donationAccount->accountable->name;
|
||||
}
|
||||
}
|
||||
|
||||
Log::notice('Profile deleted: ' . $result['deletedUser']);
|
||||
Mail::to($profile->email)->queue(new UserDeletedMail($result));
|
||||
|
||||
// Handle logout based on profile type
|
||||
if ($profile instanceof \App\Models\User) {
|
||||
// User deletion: logout completely from all guards
|
||||
$auth->logout();
|
||||
session()->invalidate();
|
||||
session()->regenerateToken();
|
||||
// Re-flash the result data after session invalidation
|
||||
session()->flash('result', $result);
|
||||
} else {
|
||||
// Flash result for non-user profiles
|
||||
session()->flash('result', $result);
|
||||
// Non-user profile deletion (Organization/Bank/Admin):
|
||||
// Only logout from the specific guard and switch back to base user
|
||||
$profileType = strtolower(class_basename($profile));
|
||||
|
||||
if ($profileType === 'organization') {
|
||||
Auth::guard('organization')->logout();
|
||||
} elseif ($profileType === 'bank') {
|
||||
Auth::guard('bank')->logout();
|
||||
} elseif ($profileType === 'admin') {
|
||||
Auth::guard('admin')->logout();
|
||||
}
|
||||
|
||||
// Switch back to base user profile
|
||||
$baseUser = Auth::guard('web')->user();
|
||||
if ($baseUser) {
|
||||
session(['activeProfileType' => 'App\\Models\\User']);
|
||||
session(['activeProfileId' => $baseUser->id]);
|
||||
session(['activeProfileName' => $baseUser->name]);
|
||||
session(['activeProfilePhoto' => $baseUser->profile_photo_path]);
|
||||
session(['active_guard' => 'web']);
|
||||
}
|
||||
}
|
||||
|
||||
return redirect()->route('goodbye-deleted-user');
|
||||
|
||||
} else {
|
||||
// Trigger WireUi error notification
|
||||
$this->notification()->error(
|
||||
$title = __('Deletion Failed'),
|
||||
$description = __('There was an error deleting your profile: ') . $result['message']
|
||||
);
|
||||
|
||||
Log::warning('Profile deletion failed for profile ID: ' . $profile->id . ' (Type: ' . get_class($profile) . ')');
|
||||
Log::error('Error message: ' . $result['message']);
|
||||
|
||||
return redirect()->back();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Load user accounts and calculate balances.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function mount()
|
||||
{
|
||||
$this->loadAccounts();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and calculate account balances.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function loadAccounts()
|
||||
{
|
||||
// Get the active profile using helper (could be User, Organization, Bank, etc.)
|
||||
$profile = getActiveProfile();
|
||||
|
||||
// Check if profile is a central bank (level = 0)
|
||||
if ($profile instanceof \App\Models\Bank && isset($profile->level) && $profile->level == 0) {
|
||||
$this->isCentralBank = true;
|
||||
}
|
||||
|
||||
// Check if profile is the final admin
|
||||
if ($profile instanceof \App\Models\Admin) {
|
||||
$activeAdminCount = \App\Models\Admin::whereNull('deleted_at')->count();
|
||||
if ($activeAdminCount <= 1) {
|
||||
$this->isFinalAdmin = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if profile exists and has accounts method
|
||||
if (!$profile || !method_exists($profile, 'accounts')) {
|
||||
$this->accounts = collect();
|
||||
$this->totalBalance = 0;
|
||||
$this->hasNegativeBalance = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Get all active, non-removed accounts
|
||||
$userAccounts = $profile->accounts()
|
||||
->active()
|
||||
->notRemoved()
|
||||
->get();
|
||||
|
||||
// Clear cache and calculate balances
|
||||
$this->accounts = collect();
|
||||
$this->totalBalance = 0;
|
||||
$this->hasNegativeBalance = false;
|
||||
|
||||
foreach ($userAccounts as $account) {
|
||||
// Clear cache for fresh balance
|
||||
Cache::forget("account_balance_{$account->id}");
|
||||
|
||||
$balance = $account->balance;
|
||||
|
||||
$this->accounts->push([
|
||||
'id' => $account->id,
|
||||
'name' => __('messages.' . $account->name . '_account'),
|
||||
'balance' => $balance,
|
||||
'balanceFormatted' => tbFormat($balance),
|
||||
]);
|
||||
|
||||
$this->totalBalance += $balance;
|
||||
|
||||
if ($balance < 0) {
|
||||
$this->hasNegativeBalance = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the component.
|
||||
*
|
||||
* @return \Illuminate\View\View
|
||||
*/
|
||||
public function render()
|
||||
{
|
||||
$showBalanceOptions = timebank_config('delete_profile.account_balances.donate_balances_to_organization_account_specified', false);
|
||||
|
||||
return view('profile.delete-user-form', [
|
||||
'showBalanceOptions' => $showBalanceOptions,
|
||||
]);
|
||||
}
|
||||
}
|
||||
897
app/Http/Livewire/Profile/ExportProfileData.php
Normal file
897
app/Http/Livewire/Profile/ExportProfileData.php
Normal file
@@ -0,0 +1,897 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire\Profile;
|
||||
|
||||
use App\Exports\ProfileContactsExport;
|
||||
use App\Exports\ProfileDataExport;
|
||||
use App\Exports\ProfileMessagesExport;
|
||||
use App\Exports\ProfileTagsExport;
|
||||
use App\Exports\ProfileTransactionsExport;
|
||||
use App\Helpers\ProfileAuthorizationHelper;
|
||||
use App\Models\Account;
|
||||
use App\Models\Tag;
|
||||
use App\Models\Transaction;
|
||||
use App\Models\User;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Bank;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Livewire\Component;
|
||||
use Namu\WireChat\Enums\ConversationType;
|
||||
use Namu\WireChat\Models\Conversation;
|
||||
use Namu\WireChat\Models\Message;
|
||||
use Namu\WireChat\Models\Participant;
|
||||
|
||||
class ExportProfileData extends Component
|
||||
{
|
||||
/**
|
||||
* Export all transactions from all accounts associated with the profile
|
||||
*/
|
||||
public function exportTransactions($type)
|
||||
{
|
||||
set_time_limit(0);
|
||||
|
||||
// Get active profile
|
||||
$profileType = session('activeProfileType');
|
||||
$profileId = session('activeProfileId');
|
||||
$profile = $profileType::find($profileId);
|
||||
|
||||
if (!$profile) {
|
||||
session()->flash('error', __('Profile not found'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate authorization - ensure authenticated user owns/manages this profile
|
||||
ProfileAuthorizationHelper::authorize($profile);
|
||||
|
||||
// Validate export type
|
||||
if (!in_array($type, ['xlsx', 'ods', 'csv', 'json'])) {
|
||||
session()->flash('error', __('Invalid export format'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Get all account IDs for this profile
|
||||
$accountIds = $profile->accounts()->pluck('id')->toArray();
|
||||
|
||||
if (empty($accountIds)) {
|
||||
session()->flash('error', __('No accounts found for this profile'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Get all transactions from all accounts
|
||||
$transactions = Transaction::with([
|
||||
'accountTo.accountable:id,name,full_name,profile_photo_path',
|
||||
'accountFrom.accountable:id,name,full_name,profile_photo_path',
|
||||
'transactionType:id,name'
|
||||
])
|
||||
->where(function ($query) use ($accountIds) {
|
||||
$query->whereIn('to_account_id', $accountIds)
|
||||
->orWhereIn('from_account_id', $accountIds);
|
||||
})
|
||||
->orderBy('created_at', 'desc')
|
||||
->get();
|
||||
|
||||
// Transform data for export
|
||||
$data = $transactions->map(function ($transaction) use ($accountIds) {
|
||||
// Determine if this is debit or credit for this profile
|
||||
$isDebit = in_array($transaction->from_account_id, $accountIds);
|
||||
|
||||
// Get the account and counter account
|
||||
$account = $isDebit ? $transaction->accountFrom : $transaction->accountTo;
|
||||
$counterAccount = $isDebit ? $transaction->accountTo : $transaction->accountFrom;
|
||||
|
||||
// Get relation (the other party)
|
||||
$relation = $counterAccount->accountable;
|
||||
|
||||
return [
|
||||
'trans_id' => $transaction->id,
|
||||
'datetime' => $transaction->created_at->format('Y-m-d H:i:s'),
|
||||
'amount' => $transaction->amount,
|
||||
'c/d' => $isDebit ? 'Debit' : 'Credit',
|
||||
'account_id' => $account->id,
|
||||
'account_name' => $account->name,
|
||||
'account_counter_id' => $counterAccount->id,
|
||||
'account_counter_name' => $counterAccount->name,
|
||||
'relation' => $relation->name ?? '',
|
||||
'relation_full_name' => $relation->full_name ?? $relation->name ?? '',
|
||||
'type' => $transaction->transactionType->name ?? '',
|
||||
'description' => $transaction->description ?? '',
|
||||
];
|
||||
});
|
||||
|
||||
$this->dispatch('saved');
|
||||
|
||||
// Handle JSON export differently
|
||||
if ($type === 'json') {
|
||||
$fileName = 'profile-transactions.json';
|
||||
|
||||
// Transform data for JSON export
|
||||
$jsonData = $data->map(function ($item) {
|
||||
// Rename amount to amount_minutes and add amount_hours
|
||||
$amountMinutes = $item['amount'];
|
||||
unset($item['amount']);
|
||||
$item['amount_minutes'] = $amountMinutes;
|
||||
$item['amount_hours'] = round($amountMinutes / 60, 4);
|
||||
|
||||
return $item;
|
||||
})->toArray();
|
||||
|
||||
$json = json_encode($jsonData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
|
||||
return response()->streamDownload(function () use ($json) {
|
||||
echo $json;
|
||||
}, $fileName, [
|
||||
'Content-Type' => 'application/json',
|
||||
]);
|
||||
}
|
||||
|
||||
return (new ProfileTransactionsExport($data))->download('profile-transactions.' . $type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export profile data
|
||||
*/
|
||||
public function exportProfileData($type)
|
||||
{
|
||||
// Get active profile
|
||||
$profileType = session('activeProfileType');
|
||||
$profileId = session('activeProfileId');
|
||||
$profile = $profileType::find($profileId);
|
||||
|
||||
if (!$profile) {
|
||||
session()->flash('error', __('Profile not found'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate authorization - ensure authenticated user owns/manages this profile
|
||||
ProfileAuthorizationHelper::authorize($profile);
|
||||
|
||||
// Validate export type
|
||||
if (!in_array($type, ['xlsx', 'ods', 'csv', 'json'])) {
|
||||
session()->flash('error', __('Invalid export format'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Get location string if available
|
||||
$location = '';
|
||||
if ($profile->location) {
|
||||
$locationParts = [];
|
||||
if ($profile->location->district) {
|
||||
$locationParts[] = $profile->location->district->name;
|
||||
}
|
||||
if ($profile->location->city) {
|
||||
$locationParts[] = $profile->location->city->name;
|
||||
}
|
||||
if ($profile->location->division) {
|
||||
$locationParts[] = $profile->location->division->name;
|
||||
}
|
||||
if ($profile->location->country) {
|
||||
$locationParts[] = $profile->location->country->name;
|
||||
}
|
||||
$location = implode(', ', $locationParts);
|
||||
}
|
||||
|
||||
// Get location first name
|
||||
$locationFirstName = '';
|
||||
$locationFirst = $profile->getLocationFirst();
|
||||
if ($locationFirst) {
|
||||
$locationFirstName = $locationFirst['name'] ?? '';
|
||||
}
|
||||
|
||||
// Get social media accounts formatted as comma-separated string
|
||||
$socials = [];
|
||||
if ($profile->socials) {
|
||||
foreach ($profile->socials as $social) {
|
||||
$isBlueSky = $social->id == 3;
|
||||
$isFullUrl = str_starts_with($social->pivot->user_on_social, 'https://');
|
||||
|
||||
if ($isBlueSky) {
|
||||
$socials[] = '@' . $social->pivot->user_on_social;
|
||||
} elseif ($isFullUrl) {
|
||||
$socials[] = $social->pivot->user_on_social;
|
||||
} elseif ($social->pivot->server_of_social) {
|
||||
$socials[] = '@' . $social->pivot->user_on_social . '@' . $social->pivot->server_of_social;
|
||||
} else {
|
||||
$socials[] = '@' . $social->pivot->user_on_social;
|
||||
}
|
||||
}
|
||||
}
|
||||
$socialsString = implode("\n", $socials);
|
||||
|
||||
// Transform profile data to array
|
||||
$data = collect([[
|
||||
'name' => $profile->name,
|
||||
'full_name' => $profile->full_name ?? '',
|
||||
'email' => $profile->email ?? '',
|
||||
'about' => strip_tags($profile->about ?? ''),
|
||||
'about_short' => $profile->about_short ?? '',
|
||||
'motivation' => strip_tags($profile->motivation ?? ''),
|
||||
'website' => $profile->website ?? '',
|
||||
'phone' => $profile->phone ?? '',
|
||||
'phone_public' => $profile->phone_public ?? false,
|
||||
'location' => $location,
|
||||
'location_first' => $locationFirstName,
|
||||
'social_media' => $socialsString,
|
||||
'profile_photo_path' => $profile->profile_photo_path ?? '',
|
||||
'lang_preference' => $profile->lang_preference ?? '',
|
||||
'created_at' => $profile->created_at ? (is_object($profile->created_at) ? $profile->created_at->format('Y-m-d H:i:s') : $profile->created_at) : '',
|
||||
'updated_at' => $profile->updated_at ? (is_object($profile->updated_at) ? $profile->updated_at->format('Y-m-d H:i:s') : $profile->updated_at) : '',
|
||||
'last_login_at' => $profile->last_login_at ? (is_object($profile->last_login_at) ? $profile->last_login_at->format('Y-m-d H:i:s') : $profile->last_login_at) : '',
|
||||
]]);
|
||||
|
||||
$profileTypeName = strtolower(class_basename($profileType));
|
||||
|
||||
$this->dispatch('saved');
|
||||
|
||||
// Handle JSON export differently
|
||||
if ($type === 'json') {
|
||||
$fileName = 'profile-data.json';
|
||||
|
||||
// Transform data for JSON export
|
||||
$jsonData = $data->map(function ($item) {
|
||||
// Remove location key
|
||||
unset($item['location']);
|
||||
|
||||
// Replace newlines with commas in social_media
|
||||
if (isset($item['social_media'])) {
|
||||
$item['social_media'] = str_replace("\n", ', ', $item['social_media']);
|
||||
}
|
||||
|
||||
// Reorder keys: put phone_visible_for_platform_users right after phone
|
||||
$ordered = [];
|
||||
foreach ($item as $key => $value) {
|
||||
$ordered[$key] = $value;
|
||||
if ($key === 'phone') {
|
||||
// Rename and insert phone_public right after phone
|
||||
$ordered['phone_visible_for_platform_users'] = $item['phone_public'];
|
||||
}
|
||||
}
|
||||
// Remove the old phone_public key
|
||||
unset($ordered['phone_public']);
|
||||
|
||||
return $ordered;
|
||||
})->toArray();
|
||||
|
||||
$json = json_encode($jsonData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
|
||||
return response()->streamDownload(function () use ($json) {
|
||||
echo $json;
|
||||
}, $fileName, [
|
||||
'Content-Type' => 'application/json',
|
||||
]);
|
||||
}
|
||||
|
||||
return (new ProfileDataExport($data, $profileTypeName))->download('profile-data.' . $type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export all messages from conversations the profile participated in
|
||||
*/
|
||||
public function exportMessages($type)
|
||||
{
|
||||
set_time_limit(0);
|
||||
|
||||
// Get active profile
|
||||
$profileType = session('activeProfileType');
|
||||
$profileId = session('activeProfileId');
|
||||
$profile = $profileType::find($profileId);
|
||||
|
||||
if (!$profile) {
|
||||
session()->flash('error', __('Profile not found'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate authorization - ensure authenticated user owns/manages this profile
|
||||
ProfileAuthorizationHelper::authorize($profile);
|
||||
|
||||
// Validate export type
|
||||
if (!in_array($type, ['xlsx', 'ods', 'csv', 'json'])) {
|
||||
session()->flash('error', __('Invalid export format'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Get all conversation IDs where the profile is a participant
|
||||
$conversationIds = Participant::where('participantable_type', $profileType)
|
||||
->where('participantable_id', $profileId)
|
||||
->pluck('conversation_id');
|
||||
|
||||
if ($conversationIds->isEmpty()) {
|
||||
session()->flash('error', __('No conversations found for this profile'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Get all messages from those conversations with sender information
|
||||
$messages = Message::with([
|
||||
'conversation:id,type',
|
||||
'sendable:id,name,full_name' // Load sender information
|
||||
])
|
||||
->whereIn('conversation_id', $conversationIds)
|
||||
->orderBy('conversation_id', 'asc')
|
||||
->orderBy('created_at', 'asc')
|
||||
->get();
|
||||
|
||||
// Transform data for export
|
||||
$data = $messages->map(function ($message) {
|
||||
$conversationType = '';
|
||||
if ($message->conversation && $message->conversation->type) {
|
||||
$conversationType = is_object($message->conversation->type)
|
||||
? $message->conversation->type->value
|
||||
: $message->conversation->type;
|
||||
}
|
||||
|
||||
// Get sender information
|
||||
$senderName = '';
|
||||
$senderType = '';
|
||||
if ($message->sendable) {
|
||||
$senderName = $message->sendable->full_name ?? $message->sendable->name ?? '';
|
||||
$senderType = class_basename($message->sendable_type);
|
||||
}
|
||||
|
||||
return [
|
||||
'conversation_id' => $message->conversation_id,
|
||||
'conversation_type' => $conversationType,
|
||||
'id' => $message->id,
|
||||
'created_at' => $message->created_at ? (is_object($message->created_at) ? $message->created_at->format('Y-m-d H:i:s') : $message->created_at) : '',
|
||||
'sender_name' => $senderName,
|
||||
'sender_type' => $senderType,
|
||||
'body' => $message->body ?? '',
|
||||
'reply_id' => $message->reply_id ?? '',
|
||||
];
|
||||
});
|
||||
|
||||
$this->dispatch('saved');
|
||||
|
||||
// Handle JSON export differently
|
||||
if ($type === 'json') {
|
||||
$fileName = 'profile-messages.json';
|
||||
$json = json_encode($data->toArray(), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
|
||||
return response()->streamDownload(function () use ($json) {
|
||||
echo $json;
|
||||
}, $fileName, [
|
||||
'Content-Type' => 'application/json',
|
||||
]);
|
||||
}
|
||||
|
||||
return (new ProfileMessagesExport($data))->download('profile-messages.' . $type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export all tags (skills) associated with the profile
|
||||
* Tags are exported in the profile's language preference or fallback locale
|
||||
*/
|
||||
public function exportTags($type)
|
||||
{
|
||||
// Get active profile
|
||||
$profileType = session('activeProfileType');
|
||||
$profileId = session('activeProfileId');
|
||||
$profile = $profileType::find($profileId);
|
||||
|
||||
if (!$profile) {
|
||||
session()->flash('error', __('Profile not found'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate authorization - ensure authenticated user owns/manages this profile
|
||||
ProfileAuthorizationHelper::authorize($profile);
|
||||
|
||||
// Validate export type
|
||||
if (!in_array($type, ['xlsx', 'ods', 'csv', 'json'])) {
|
||||
session()->flash('error', __('Invalid export format'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine which locale to use for tag translation
|
||||
$locale = $profile->lang_preference ?? App::getLocale();
|
||||
$fallbackLocale = App::getFallbackLocale();
|
||||
|
||||
// Get all tag IDs from the profile
|
||||
$tagIds = $profile->tags->pluck('tag_id');
|
||||
|
||||
if ($tagIds->isEmpty()) {
|
||||
session()->flash('error', __('No tags found for this profile'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Translate tags to the profile's language preference (or fallback)
|
||||
$translatedTags = collect((new Tag())->translateTagIdsWithContexts($tagIds));
|
||||
|
||||
// Transform data for export
|
||||
$data = $translatedTags->map(function ($tag) {
|
||||
return [
|
||||
'tag_id' => $tag['tag_id'] ?? '',
|
||||
'tag' => $tag['tag'] ?? '',
|
||||
'category' => $tag['category'] ?? '',
|
||||
'category_path' => $tag['category_path'] ?? '',
|
||||
'locale' => $tag['locale'] ?? '',
|
||||
];
|
||||
});
|
||||
|
||||
$this->dispatch('saved');
|
||||
|
||||
// Handle JSON export differently
|
||||
if ($type === 'json') {
|
||||
$fileName = 'profile-tags.json';
|
||||
$json = json_encode($data->toArray(), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
|
||||
return response()->streamDownload(function () use ($json) {
|
||||
echo $json;
|
||||
}, $fileName, [
|
||||
'Content-Type' => 'application/json',
|
||||
]);
|
||||
}
|
||||
|
||||
return (new ProfileTagsExport($data))->download('profile-tags.' . $type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export all contacts associated with the profile
|
||||
*/
|
||||
public function exportContacts($type)
|
||||
{
|
||||
set_time_limit(0);
|
||||
|
||||
// Get active profile
|
||||
$profileType = session('activeProfileType');
|
||||
$profileId = session('activeProfileId');
|
||||
$profile = $profileType::find($profileId);
|
||||
|
||||
if (!$profile) {
|
||||
session()->flash('error', __('Profile not found'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate authorization - ensure authenticated user owns/manages this profile
|
||||
ProfileAuthorizationHelper::authorize($profile);
|
||||
|
||||
// Validate export type
|
||||
if (!in_array($type, ['xlsx', 'ods', 'csv', 'json'])) {
|
||||
session()->flash('error', __('Invalid export format'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize contacts collection
|
||||
$contactsData = collect();
|
||||
|
||||
// Get the reacter_id and reactant_id for the active profile
|
||||
$reacterId = $profile->love_reacter_id;
|
||||
$reactantId = $profile->love_reactant_id;
|
||||
|
||||
// 1. Get profiles the active profile has reacted to (stars, bookmarks)
|
||||
if ($reacterId) {
|
||||
$reactedProfiles = $this->getReactedProfilesForExport($reacterId);
|
||||
$contactsData = $contactsData->merge($reactedProfiles);
|
||||
}
|
||||
|
||||
// 2. Get profiles that have transacted with the active profile
|
||||
$transactionProfiles = $this->getTransactionProfilesForExport($profile);
|
||||
$contactsData = $contactsData->merge($transactionProfiles);
|
||||
|
||||
// 3. Get profiles from private WireChat conversations
|
||||
$conversationProfiles = $this->getConversationProfilesForExport($profile);
|
||||
$contactsData = $contactsData->merge($conversationProfiles);
|
||||
|
||||
// Group by profile and merge interaction data
|
||||
$contacts = $contactsData->groupBy('profile_key')->map(function ($group) {
|
||||
$first = $group->first();
|
||||
|
||||
return [
|
||||
'profile_id' => $first['profile_id'],
|
||||
'profile_type' => $first['profile_type'],
|
||||
'profile_type_name' => $first['profile_type_name'],
|
||||
'name' => $first['name'],
|
||||
'full_name' => $first['full_name'],
|
||||
'location' => $first['location'],
|
||||
'profile_photo' => $first['profile_photo'],
|
||||
'has_star' => $group->contains('interaction_type', 'star'),
|
||||
'has_bookmark' => $group->contains('interaction_type', 'bookmark'),
|
||||
'has_transaction' => $group->contains('interaction_type', 'transaction'),
|
||||
'has_conversation' => $group->contains('interaction_type', 'conversation'),
|
||||
'last_interaction' => $group->max('last_interaction'),
|
||||
'star_count' => $group->where('interaction_type', 'star')->sum('count'),
|
||||
'bookmark_count' => $group->where('interaction_type', 'bookmark')->sum('count'),
|
||||
'transaction_count' => $group->where('interaction_type', 'transaction')->sum('count'),
|
||||
'message_count' => $group->where('interaction_type', 'conversation')->sum('count'),
|
||||
];
|
||||
})->values();
|
||||
|
||||
// Sort by last interaction (most recent first)
|
||||
$contacts = $contacts->sortByDesc('last_interaction')->values();
|
||||
|
||||
$this->dispatch('saved');
|
||||
|
||||
// Handle JSON export differently
|
||||
if ($type === 'json') {
|
||||
$fileName = 'profile-contacts.json';
|
||||
$json = json_encode($contacts->toArray(), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
|
||||
return response()->streamDownload(function () use ($json) {
|
||||
echo $json;
|
||||
}, $fileName, [
|
||||
'Content-Type' => 'application/json',
|
||||
]);
|
||||
}
|
||||
|
||||
return (new ProfileContactsExport($contacts))->download('profile-contacts.' . $type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get profiles the active profile has reacted to.
|
||||
*/
|
||||
private function getReactedProfilesForExport($reacterId)
|
||||
{
|
||||
// Get all reactions by this reacter, grouped by reactant type
|
||||
$reactions = DB::table('love_reactions')
|
||||
->join('love_reactants', 'love_reactions.reactant_id', '=', 'love_reactants.id')
|
||||
->where('love_reactions.reacter_id', $reacterId)
|
||||
->select(
|
||||
'love_reactants.type as reactant_type',
|
||||
DB::raw('CAST(SUBSTRING_INDEX(love_reactants.type, "\\\", -1) AS CHAR) as reactant_model')
|
||||
)
|
||||
->groupBy('love_reactants.type')
|
||||
->get();
|
||||
|
||||
$profiles = collect();
|
||||
|
||||
foreach ($reactions as $reaction) {
|
||||
// Only process User, Organization, and Bank models
|
||||
if (!in_array($reaction->reactant_model, ['User', 'Organization', 'Bank'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$modelClass = "App\\Models\\{$reaction->reactant_model}";
|
||||
|
||||
// Get all profiles of this type that were reacted to, with reaction type breakdown
|
||||
$reactedToProfiles = DB::table('love_reactions')
|
||||
->join('love_reactants', 'love_reactions.reactant_id', '=', 'love_reactants.id')
|
||||
->join(
|
||||
DB::raw("(SELECT id, love_reactant_id, name,
|
||||
full_name,
|
||||
profile_photo_path
|
||||
FROM " . strtolower($reaction->reactant_model) . "s) as profiles"),
|
||||
'love_reactants.id',
|
||||
'=',
|
||||
'profiles.love_reactant_id'
|
||||
)
|
||||
->where('love_reactions.reacter_id', $reacterId)
|
||||
->where('love_reactants.type', $reaction->reactant_type)
|
||||
->select(
|
||||
'profiles.id as profile_id',
|
||||
'profiles.name',
|
||||
'profiles.full_name',
|
||||
'profiles.profile_photo_path',
|
||||
DB::raw("'{$modelClass}' as profile_type"),
|
||||
DB::raw("'{$reaction->reactant_model}' as profile_type_name"),
|
||||
'love_reactions.reaction_type_id',
|
||||
DB::raw('MAX(love_reactions.created_at) as last_interaction'),
|
||||
DB::raw('COUNT(*) as count')
|
||||
)
|
||||
->groupBy('profiles.id', 'profiles.name', 'profiles.full_name', 'profiles.profile_photo_path', 'love_reactions.reaction_type_id')
|
||||
->get();
|
||||
|
||||
// Batch load locations for all profiles of this type
|
||||
$profileIds = $reactedToProfiles->pluck('profile_id');
|
||||
$locations = $this->batchLoadLocationsForExport($modelClass, $profileIds);
|
||||
|
||||
foreach ($reactedToProfiles as $profile) {
|
||||
// Get location from batch-loaded data
|
||||
$location = $locations[$profile->profile_id] ?? '';
|
||||
|
||||
// Determine reaction type (1 = Star, 2 = Bookmark)
|
||||
$interactionType = $profile->reaction_type_id == 1 ? 'star' : ($profile->reaction_type_id == 2 ? 'bookmark' : 'reaction');
|
||||
|
||||
$profiles->push([
|
||||
'profile_key' => $modelClass . '_' . $profile->profile_id,
|
||||
'profile_id' => $profile->profile_id,
|
||||
'profile_type' => $profile->profile_type,
|
||||
'profile_type_name' => $profile->profile_type_name,
|
||||
'name' => $profile->name,
|
||||
'full_name' => $profile->full_name,
|
||||
'location' => $location,
|
||||
'profile_photo' => $profile->profile_photo_path,
|
||||
'interaction_type' => $interactionType,
|
||||
'last_interaction' => $profile->last_interaction,
|
||||
'count' => $profile->count,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return $profiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get profiles that have transacted with the active profile.
|
||||
*/
|
||||
private function getTransactionProfilesForExport($activeProfile)
|
||||
{
|
||||
// Get all accounts belonging to the active profile
|
||||
$accountIds = DB::table('accounts')
|
||||
->where('accountable_type', get_class($activeProfile))
|
||||
->where('accountable_id', $activeProfile->id)
|
||||
->pluck('id');
|
||||
|
||||
if ($accountIds->isEmpty()) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
// Get all transactions involving these accounts
|
||||
$transactions = DB::table('transactions')
|
||||
->whereIn('from_account_id', $accountIds)
|
||||
->orWhereIn('to_account_id', $accountIds)
|
||||
->select(
|
||||
'from_account_id',
|
||||
'to_account_id',
|
||||
DB::raw('MAX(created_at) as last_interaction'),
|
||||
DB::raw('COUNT(*) as count')
|
||||
)
|
||||
->groupBy('from_account_id', 'to_account_id')
|
||||
->get();
|
||||
|
||||
// Group counter accounts by type for batch loading
|
||||
$counterAccountsByType = collect();
|
||||
|
||||
foreach ($transactions as $transaction) {
|
||||
// Determine the counter account (the other party in the transaction)
|
||||
$counterAccountId = null;
|
||||
if ($accountIds->contains($transaction->from_account_id) && !$accountIds->contains($transaction->to_account_id)) {
|
||||
$counterAccountId = $transaction->to_account_id;
|
||||
} elseif ($accountIds->contains($transaction->to_account_id) && !$accountIds->contains($transaction->from_account_id)) {
|
||||
$counterAccountId = $transaction->from_account_id;
|
||||
}
|
||||
|
||||
if ($counterAccountId) {
|
||||
$transaction->counter_account_id = $counterAccountId;
|
||||
}
|
||||
}
|
||||
|
||||
// Get all counter account details in one query
|
||||
$counterAccountIds = $transactions->pluck('counter_account_id')->filter()->unique();
|
||||
$accounts = DB::table('accounts')
|
||||
->whereIn('id', $counterAccountIds)
|
||||
->select('id', 'accountable_type', 'accountable_id')
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
// Group profile IDs by type
|
||||
$profileIdsByType = [];
|
||||
foreach ($accounts as $account) {
|
||||
$profileTypeName = class_basename($account->accountable_type);
|
||||
if (!isset($profileIdsByType[$profileTypeName])) {
|
||||
$profileIdsByType[$profileTypeName] = [];
|
||||
}
|
||||
$profileIdsByType[$profileTypeName][] = $account->accountable_id;
|
||||
}
|
||||
|
||||
// Batch load profile data and locations for each type
|
||||
$profileDataByType = [];
|
||||
$locationsByType = [];
|
||||
foreach ($profileIdsByType as $typeName => $ids) {
|
||||
$tableName = strtolower($typeName) . 's';
|
||||
$modelClass = "App\\Models\\{$typeName}";
|
||||
|
||||
// Load profile data
|
||||
$profileDataByType[$typeName] = DB::table($tableName)
|
||||
->whereIn('id', $ids)
|
||||
->select('id', 'name', 'full_name', 'profile_photo_path')
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
// Batch load locations
|
||||
$locationsByType[$typeName] = $this->batchLoadLocationsForExport($modelClass, $ids);
|
||||
}
|
||||
|
||||
// Build final profiles collection
|
||||
$profiles = collect();
|
||||
foreach ($transactions as $transaction) {
|
||||
if (!isset($transaction->counter_account_id)) {
|
||||
continue; // Skip self-transactions
|
||||
}
|
||||
|
||||
$account = $accounts->get($transaction->counter_account_id);
|
||||
if (!$account) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$profileModel = $account->accountable_type;
|
||||
$profileId = $account->accountable_id;
|
||||
$profileTypeName = class_basename($profileModel);
|
||||
|
||||
$profile = $profileDataByType[$profileTypeName][$profileId] ?? null;
|
||||
if (!$profile) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$location = $locationsByType[$profileTypeName][$profileId] ?? '';
|
||||
$profileKey = $profileModel . '_' . $profileId;
|
||||
|
||||
$profiles->push([
|
||||
'profile_key' => $profileKey,
|
||||
'profile_id' => $profileId,
|
||||
'profile_type' => $profileModel,
|
||||
'profile_type_name' => $profileTypeName,
|
||||
'name' => $profile->name,
|
||||
'full_name' => $profile->full_name,
|
||||
'location' => $location,
|
||||
'profile_photo' => $profile->profile_photo_path,
|
||||
'interaction_type' => 'transaction',
|
||||
'last_interaction' => $transaction->last_interaction,
|
||||
'count' => $transaction->count,
|
||||
]);
|
||||
}
|
||||
|
||||
return $profiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get profiles from private WireChat conversations.
|
||||
*/
|
||||
private function getConversationProfilesForExport($activeProfile)
|
||||
{
|
||||
// Get all private conversations the active profile is participating in
|
||||
$participantType = get_class($activeProfile);
|
||||
$participantId = $activeProfile->id;
|
||||
|
||||
// Get participant record for active profile
|
||||
$myParticipants = DB::table('wirechat_participants')
|
||||
->join('wirechat_conversations', 'wirechat_participants.conversation_id', '=', 'wirechat_conversations.id')
|
||||
->where('wirechat_participants.participantable_type', $participantType)
|
||||
->where('wirechat_participants.participantable_id', $participantId)
|
||||
->where('wirechat_conversations.type', ConversationType::PRIVATE->value)
|
||||
->whereNull('wirechat_participants.deleted_at')
|
||||
->select(
|
||||
'wirechat_participants.conversation_id',
|
||||
'wirechat_participants.last_active_at'
|
||||
)
|
||||
->get();
|
||||
|
||||
if ($myParticipants->isEmpty()) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
$conversationIds = $myParticipants->pluck('conversation_id');
|
||||
|
||||
// Get all other participants in one query
|
||||
$otherParticipants = DB::table('wirechat_participants')
|
||||
->whereIn('conversation_id', $conversationIds)
|
||||
->where(function ($query) use ($participantType, $participantId) {
|
||||
$query->where('participantable_type', '!=', $participantType)
|
||||
->orWhere('participantable_id', '!=', $participantId);
|
||||
})
|
||||
->whereNull('deleted_at')
|
||||
->get()
|
||||
->keyBy('conversation_id');
|
||||
|
||||
// Get message counts for all conversations in one query
|
||||
$messageCounts = DB::table('wirechat_messages')
|
||||
->whereIn('conversation_id', $conversationIds)
|
||||
->whereNull('deleted_at')
|
||||
->select(
|
||||
'conversation_id',
|
||||
DB::raw('COUNT(DISTINCT DATE(created_at)) as day_count')
|
||||
)
|
||||
->groupBy('conversation_id')
|
||||
->get()
|
||||
->keyBy('conversation_id');
|
||||
|
||||
// Get last messages for all conversations in one query
|
||||
$lastMessages = DB::table('wirechat_messages as wm1')
|
||||
->whereIn('wm1.conversation_id', $conversationIds)
|
||||
->whereNull('wm1.deleted_at')
|
||||
->whereRaw('wm1.created_at = (SELECT MAX(wm2.created_at) FROM wirechat_messages wm2 WHERE wm2.conversation_id = wm1.conversation_id AND wm2.deleted_at IS NULL)')
|
||||
->select('wm1.conversation_id', 'wm1.created_at')
|
||||
->get()
|
||||
->keyBy('conversation_id');
|
||||
|
||||
// Group profile IDs by type
|
||||
$profileIdsByType = [];
|
||||
foreach ($otherParticipants as $participant) {
|
||||
$profileTypeName = class_basename($participant->participantable_type);
|
||||
if (!isset($profileIdsByType[$profileTypeName])) {
|
||||
$profileIdsByType[$profileTypeName] = [];
|
||||
}
|
||||
$profileIdsByType[$profileTypeName][] = $participant->participantable_id;
|
||||
}
|
||||
|
||||
// Batch load profile data and locations for each type
|
||||
$profileDataByType = [];
|
||||
$locationsByType = [];
|
||||
foreach ($profileIdsByType as $typeName => $ids) {
|
||||
$tableName = strtolower($typeName) . 's';
|
||||
$modelClass = "App\\Models\\{$typeName}";
|
||||
|
||||
// Load profile data
|
||||
$profileDataByType[$typeName] = DB::table($tableName)
|
||||
->whereIn('id', $ids)
|
||||
->select('id', 'name', 'full_name', 'profile_photo_path')
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
// Batch load locations
|
||||
$locationsByType[$typeName] = $this->batchLoadLocationsForExport($modelClass, $ids);
|
||||
}
|
||||
|
||||
// Build final profiles collection
|
||||
$profiles = collect();
|
||||
foreach ($myParticipants as $myParticipant) {
|
||||
$otherParticipant = $otherParticipants->get($myParticipant->conversation_id);
|
||||
if (!$otherParticipant) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$profileModel = $otherParticipant->participantable_type;
|
||||
$profileId = $otherParticipant->participantable_id;
|
||||
$profileTypeName = class_basename($profileModel);
|
||||
|
||||
$profile = $profileDataByType[$profileTypeName][$profileId] ?? null;
|
||||
if (!$profile) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$messageCount = $messageCounts->get($myParticipant->conversation_id)->day_count ?? 0;
|
||||
$lastMessage = $lastMessages->get($myParticipant->conversation_id);
|
||||
$location = $locationsByType[$profileTypeName][$profileId] ?? '';
|
||||
$profileKey = $profileModel . '_' . $profileId;
|
||||
|
||||
$profiles->push([
|
||||
'profile_key' => $profileKey,
|
||||
'profile_id' => $profileId,
|
||||
'profile_type' => $profileModel,
|
||||
'profile_type_name' => $profileTypeName,
|
||||
'name' => $profile->name,
|
||||
'full_name' => $profile->full_name,
|
||||
'location' => $location,
|
||||
'profile_photo' => $profile->profile_photo_path,
|
||||
'interaction_type' => 'conversation',
|
||||
'last_interaction' => $lastMessage ? $lastMessage->created_at : $myParticipant->last_active_at,
|
||||
'count' => $messageCount,
|
||||
]);
|
||||
}
|
||||
|
||||
return $profiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch load locations for multiple profiles of the same type.
|
||||
*/
|
||||
private function batchLoadLocationsForExport($modelClass, $profileIds)
|
||||
{
|
||||
if (empty($profileIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Ensure it's an array
|
||||
if ($profileIds instanceof \Illuminate\Support\Collection) {
|
||||
$profileIds = $profileIds->toArray();
|
||||
}
|
||||
|
||||
// Load all profiles with their location relationships
|
||||
$profiles = $modelClass::with([
|
||||
'locations.city.translations',
|
||||
'locations.district.translations',
|
||||
'locations.division.translations',
|
||||
'locations.country.translations'
|
||||
])
|
||||
->whereIn('id', $profileIds)
|
||||
->get();
|
||||
|
||||
// Build location map
|
||||
$locationMap = [];
|
||||
foreach ($profiles as $profile) {
|
||||
if (method_exists($profile, 'getLocationFirst')) {
|
||||
$locationData = $profile->getLocationFirst(false);
|
||||
$locationMap[$profile->id] = $locationData['name'] ?? $locationData['name_short'] ?? '';
|
||||
} else {
|
||||
$locationMap[$profile->id] = '';
|
||||
}
|
||||
}
|
||||
|
||||
return $locationMap;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.profile.export-profile-data');
|
||||
}
|
||||
}
|
||||
83
app/Http/Livewire/Profile/LanguagesDropdown.php
Normal file
83
app/Http/Livewire/Profile/LanguagesDropdown.php
Normal file
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire\Profile;
|
||||
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Component;
|
||||
|
||||
class LanguagesDropdown extends Component
|
||||
{
|
||||
public $state = [];
|
||||
public $langSelected = [];
|
||||
public $langSelectedOptions = [];
|
||||
public $langOptions;
|
||||
public $languages;
|
||||
public string $label;
|
||||
|
||||
/**
|
||||
* Prepare the component.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function mount($languages)
|
||||
{
|
||||
// Create a language options collection that combines all language and competence options
|
||||
$langOptions = DB::table('languages')->get(['id','name']);
|
||||
$compOptions = DB::table('language_competences')->get(['id','name']);
|
||||
$langOptions = collect(Arr::crossJoin($langOptions, $compOptions));
|
||||
$this->langOptions = $langOptions->Map(function ($language, $key) {
|
||||
return [
|
||||
'id' => $key, // index key is needed to select values in dropdown (option-value)
|
||||
'langId' => $language[0]->id,
|
||||
'compId' => $language[1]->id,
|
||||
'name' => trans($language[0]->name) . ' - ' . trans($language[1]->name),
|
||||
];
|
||||
});
|
||||
|
||||
$this->langSelectedOptions = $languages;
|
||||
$this->langSelected = $this->langSelectedOptions->pluck('id');
|
||||
|
||||
$type = getActiveProfileType();
|
||||
if ( $type == 'Organization') {
|
||||
$this->label = __('What language(s) does your organization use?');
|
||||
} elseif ( $type == 'Bank') {
|
||||
$this->label = __('What language(s) does your bank use?');
|
||||
} else {
|
||||
// Users, or other types
|
||||
$this->label = __('What language(s) do you speak?');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* When component is updated, create a selected language collection that holds the selected languages with their selected competences
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function updated()
|
||||
{
|
||||
// Get selected options
|
||||
$selected = collect($this->langOptions)->whereIn('id', $this->langSelected);
|
||||
|
||||
// Group by langId and keep only the one with the lowest compId for each language
|
||||
$filtered = $selected
|
||||
->groupBy('langId')
|
||||
->map(function ($group) {
|
||||
return $group->sortBy('compId')->first();
|
||||
})
|
||||
->values();
|
||||
|
||||
// Update selected options and selected ids
|
||||
$this->langSelectedOptions = $filtered;
|
||||
$this->langSelected = $filtered->pluck('id')->toArray();
|
||||
|
||||
$this->dispatch('languagesToParent', $this->langSelectedOptions);
|
||||
}
|
||||
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.profile.languages-dropdown');
|
||||
}
|
||||
}
|
||||
60
app/Http/Livewire/Profile/MigrateCyclosProfileSkillsForm.php
Normal file
60
app/Http/Livewire/Profile/MigrateCyclosProfileSkillsForm.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire\Profile;
|
||||
|
||||
use App\Http\Livewire\MainPage\SkillsCardFull;
|
||||
use App\Models\Language;
|
||||
|
||||
/**
|
||||
* Class MigrateProfilesProfileSkillsForm
|
||||
*
|
||||
* This class extends the SkillsCardFull class and is responsible for rendering
|
||||
* the update profile skills form view in the Livewire component.
|
||||
*
|
||||
* @package App\Http\Livewire\Profile
|
||||
*/
|
||||
class MigrateCyclosProfileSkillsForm extends SkillsCardFull
|
||||
{
|
||||
public bool $showCyclosReference = true;
|
||||
public $skillsCardLabel;
|
||||
|
||||
public function mount($label = null)
|
||||
{
|
||||
$lang = __(Language::where('lang_code', app()->getLocale())->first()->name) ?? null;
|
||||
if ($lang) {
|
||||
if (app()->getLocale() != 'en' ) {
|
||||
$translationWarning = '(' . __('messages.now_in_language', ['lang' => $lang]) . ')';
|
||||
} else {
|
||||
$translationWarning = '(' . __('messages.in_English') . ')';
|
||||
}
|
||||
} else {
|
||||
$translationWarning = '';
|
||||
}
|
||||
$this->skillsCardLabel = __('Skills on this website') . ' ' . $translationWarning;
|
||||
}
|
||||
|
||||
|
||||
public function removeCyclosTags()
|
||||
{
|
||||
$profile = getActiveProfile();
|
||||
|
||||
if (!$profile) {
|
||||
return;
|
||||
}
|
||||
|
||||
// CRITICAL SECURITY: Validate user has ownership/access to this profile
|
||||
\App\Helpers\ProfileAuthorizationHelper::authorize($profile);
|
||||
|
||||
$profile->cyclos_skills = null;
|
||||
$profile->save();
|
||||
$this->showCyclosReference = false;
|
||||
$this->dispatch('refreshSkills');
|
||||
$this->skillsCardLabel = null;
|
||||
}
|
||||
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.profile.migrate-cyclos-profile-skills-form');
|
||||
}
|
||||
}
|
||||
148
app/Http/Livewire/Profile/SelectProfile.php
Normal file
148
app/Http/Livewire/Profile/SelectProfile.php
Normal file
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire\Profile;
|
||||
|
||||
use App\Models\Admin;
|
||||
use App\Models\Bank;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Livewire\Component;
|
||||
|
||||
class SelectProfile extends Component
|
||||
{
|
||||
public $label = '';
|
||||
public $placeholder = '';
|
||||
public $typesAvailable = [];
|
||||
public $search;
|
||||
public $searchResults = [];
|
||||
public $showDropdown = false;
|
||||
public $selected = [];
|
||||
|
||||
protected $listeners = [
|
||||
'resetForm',
|
||||
'organizerExists'
|
||||
];
|
||||
|
||||
public function mount()
|
||||
{
|
||||
$this->placeholder = implode(', ', array_map(function($class) {
|
||||
// Convert to lowercase and use as translation key
|
||||
return __((strtolower(class_basename($class))));
|
||||
}, $this->typesAvailable));
|
||||
}
|
||||
|
||||
public function inputBlur()
|
||||
{
|
||||
$this->showDropdown = false;
|
||||
$this->search = '';
|
||||
}
|
||||
|
||||
|
||||
public function resetForm()
|
||||
{
|
||||
$this->reset();
|
||||
}
|
||||
|
||||
|
||||
public function selectProfile($value)
|
||||
{
|
||||
$this->selected = collect($this->searchResults)->where('id', '=', $value)->first();
|
||||
$this->showDropdown = false;
|
||||
$this->search = '';
|
||||
$this->dispatch('selectedProfile', $this->selected);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* updatedSearch: Search available profiles
|
||||
*
|
||||
* @param mixed $newValue
|
||||
* @return void
|
||||
*/
|
||||
public function updatedSearch($newValue)
|
||||
{
|
||||
$this->showDropdown = true;
|
||||
$search = $this->search;
|
||||
$results = collect();
|
||||
|
||||
// Loop through typesAvailable and query each model
|
||||
foreach ($this->typesAvailable as $type) {
|
||||
if ($type === User::class) {
|
||||
$users = User::where('name', 'like', '%' . $search . '%')
|
||||
->select('id', 'name', 'profile_photo_path')
|
||||
->get()
|
||||
->map(function ($item) {
|
||||
return [
|
||||
'id' => $item['id'],
|
||||
'type' => User::class,
|
||||
'name' => $item['name'],
|
||||
'description' => '',
|
||||
'profile_photo_path' => url(Storage::url($item['profile_photo_path']))
|
||||
];
|
||||
});
|
||||
$results = $results->merge($users);
|
||||
}
|
||||
if ($type === Organization::class) {
|
||||
$organizations = Organization::where('name', 'like', '%' . $search . '%')
|
||||
->select('id', 'name', 'profile_photo_path')
|
||||
->get()
|
||||
->map(function ($item) {
|
||||
return [
|
||||
'id' => $item['id'],
|
||||
'type' => Organization::class,
|
||||
'name' => $item['name'],
|
||||
'description' => __('Organization'),
|
||||
'profile_photo_path' => url(Storage::url($item['profile_photo_path']))
|
||||
];
|
||||
});
|
||||
$results = $results->merge($organizations);
|
||||
}
|
||||
if (class_exists('App\\Models\\Bank') && $type === Bank::class) {
|
||||
$banks = Bank::where('name', 'like', '%' . $search . '%')
|
||||
->select('id', 'name', 'profile_photo_path')
|
||||
->get()
|
||||
->map(function ($item) {
|
||||
return [
|
||||
'id' => $item['id'],
|
||||
'type' => \App\Models\Bank::class,
|
||||
'name' => $item['name'],
|
||||
'description' => __('Bank'),
|
||||
'profile_photo_path' => url(Storage::url($item['profile_photo_path']))
|
||||
];
|
||||
});
|
||||
$results = $results->merge($banks);
|
||||
}
|
||||
if (class_exists('App\\Models\\Admin') && $type === Admin::class) {
|
||||
$admins = Admin::where('name', 'like', '%' . $search . '%')
|
||||
->select('id', 'name', 'profile_photo_path')
|
||||
->get()
|
||||
->map(function ($item) {
|
||||
return [
|
||||
'id' => $item['id'],
|
||||
'type' => \App\Models\Admin::class,
|
||||
'name' => $item['name'],
|
||||
'description' => __('Admin'),
|
||||
'profile_photo_path' => url(Storage::url($item['profile_photo_path']))
|
||||
];
|
||||
});
|
||||
$results = $results->merge($admins);
|
||||
}
|
||||
}
|
||||
|
||||
$this->searchResults = $results->take(6)->values();
|
||||
}
|
||||
|
||||
|
||||
public function removeSelectedProfile()
|
||||
{
|
||||
$this->selected = [];
|
||||
$this->dispatch('selectedProfile', null);
|
||||
}
|
||||
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.profile.select-profile');
|
||||
}
|
||||
}
|
||||
118
app/Http/Livewire/Profile/Show.php
Normal file
118
app/Http/Livewire/Profile/Show.php
Normal file
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire\Profile;
|
||||
|
||||
use App\Models\Account;
|
||||
use App\Models\Tag;
|
||||
use App\Models\User;
|
||||
use App\Traits\ActiveStatesTrait;
|
||||
use App\Traits\LocationTrait;
|
||||
use App\Traits\ProfileTrait;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Livewire\Component;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
|
||||
class Show extends Component
|
||||
{
|
||||
use LocationTrait;
|
||||
use ActiveStatesTrait;
|
||||
use ProfileTrait;
|
||||
|
||||
public $profile;
|
||||
public $type;
|
||||
public $inactive = false;
|
||||
public bool $hidden = false;
|
||||
public bool $inactiveLabel = false;
|
||||
public string $inactiveSince;
|
||||
public bool $emailUnverifiedLabel = false;
|
||||
public bool $isIncomplete = false;
|
||||
public bool $incompleteLabel = false;
|
||||
public bool $noExchangesYetLabel = false;
|
||||
public string $removedSince;
|
||||
public $showAboutFullText = false;
|
||||
public $age;
|
||||
public $location = [];
|
||||
public $friend;
|
||||
public $pendingFriend;
|
||||
public $phone;
|
||||
public $languagesWithCompetences = [];
|
||||
public $skills;
|
||||
public $lastLoginAt;
|
||||
public $lastExchangeAt;
|
||||
public $registeredSince;
|
||||
public $onlineStatus;
|
||||
public $accountsTotals;
|
||||
public $socials;
|
||||
|
||||
/**
|
||||
* The mount method is called when the component is mounted.
|
||||
*
|
||||
*/
|
||||
public function mount()
|
||||
{
|
||||
$this->type = strtolower(class_basename($this->profile));
|
||||
// $this->onlineStatus = $this->getOnlineStatus();
|
||||
$this->location = $this->getLocation($this->profile);
|
||||
$this->age = $this->getAge($this->profile) ? ' (' . $this->getAge($this->profile) . ')' : '';
|
||||
$this->phone = $this->getPhone($this->profile);
|
||||
$this->lastLoginAt = $this->getLastLogin($this->profile);
|
||||
$this->accountsTotals = $this->getAccountsTotals($this->profile);
|
||||
$this->lastExchangeAt = $this->getLastExchangeAt($this->accountsTotals, $this->profile);
|
||||
$this->registeredSince = $this->getRegisteredSince($this->profile);
|
||||
$this->profile->languages = $this->getLanguages($this->profile);
|
||||
$this->profile->lang_preference = $this->getLangPreference($this->profile);
|
||||
$this->skills = $this->getSkills($this->profile);
|
||||
$this->socials = $this->getSocials($this->profile);
|
||||
}
|
||||
|
||||
|
||||
public function toggleAboutText()
|
||||
{
|
||||
$this->showAboutFullText = !$this->showAboutFullText;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Redirects to the payment page to this user.
|
||||
*
|
||||
* @return \Illuminate\Http\RedirectResponse
|
||||
*/
|
||||
public function payButton()
|
||||
{
|
||||
if ($this->profile->isRemoved()) {
|
||||
return;
|
||||
}
|
||||
return redirect()->route('pay-to-name', ['name' => $this->profile->name]);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Start a conversation with this user.
|
||||
*/
|
||||
public function createConversation()
|
||||
{
|
||||
if ($this->profile->isRemoved()) {
|
||||
return ;
|
||||
}
|
||||
|
||||
$recipient = $this->profile;
|
||||
$conversation = getActiveProfile()->createConversationWith($recipient);
|
||||
|
||||
return redirect()->route('chat', ['conversation' => $conversation->id]);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Render the view for the ProfileUser Show component.
|
||||
*
|
||||
* @return \Illuminate\Contracts\View\View
|
||||
*/
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.profile.show');
|
||||
}
|
||||
}
|
||||
203
app/Http/Livewire/Profile/SocialsForm.php
Normal file
203
app/Http/Livewire/Profile/SocialsForm.php
Normal file
@@ -0,0 +1,203 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire\Profile;
|
||||
|
||||
use App\Models\Social;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Component;
|
||||
|
||||
class SocialsForm extends Component
|
||||
{
|
||||
public $socialsOptions;
|
||||
public $socialsOptionSelected;
|
||||
public $socials;
|
||||
public $userOnSocial;
|
||||
public $serverOfSocial;
|
||||
public $sociables_id;
|
||||
public $updateMode = false;
|
||||
public $selectedPlaceholder;
|
||||
|
||||
|
||||
private function resetInputFields()
|
||||
{
|
||||
$this->reset(['updateMode', 'socialsOptionSelected', 'userOnSocial', 'serverOfSocial']);
|
||||
}
|
||||
|
||||
public function mount()
|
||||
{
|
||||
$this->socialsOptions = Social::select("*")->orderBy("name")->get();
|
||||
$this->getSocials();
|
||||
}
|
||||
|
||||
public function getSocials()
|
||||
{
|
||||
$this->socials = getActiveProfile()->socials;
|
||||
}
|
||||
|
||||
public function socialsUpdated()
|
||||
{
|
||||
$this->resetErrorBag(); // clears all validation errors
|
||||
}
|
||||
|
||||
public function store()
|
||||
{
|
||||
$validatedSocial = $this->validate([
|
||||
'socialsOptionSelected' => 'required|integer',
|
||||
'userOnSocial' => 'required|string|max:150',
|
||||
]);
|
||||
|
||||
$activeProfile = getActiveProfile();
|
||||
|
||||
// CRITICAL SECURITY: Validate user has ownership/access to this profile
|
||||
\App\Helpers\ProfileAuthorizationHelper::authorize($activeProfile);
|
||||
|
||||
// Limit to max 10 socials per profile
|
||||
$currentCount = $activeProfile->socials()->count();
|
||||
if ($currentCount >= $limit = 10) {
|
||||
$this->addError('socialsOptionSelected', __('validation.custom.social_limit', ['limit' => $limit]));
|
||||
return;
|
||||
}
|
||||
|
||||
DB::table('sociables')->insert([
|
||||
'social_id' => $this->socialsOptionSelected,
|
||||
'sociable_type' => session('activeProfileType'),
|
||||
'sociable_id' => session('activeProfileId'),
|
||||
'user_on_social' => $this->formatUserHandle($this->socialsOptionSelected, $this->userOnSocial),
|
||||
'server_of_social' => $this->formatServer($this->socialsOptionSelected, $this->serverOfSocial),
|
||||
'created_at' => Carbon::now(),
|
||||
'updated_at' => Carbon::now(),
|
||||
]);
|
||||
session()->flash('message', __('Saved'));
|
||||
$this->resetInputFields();
|
||||
$this->getSocials();
|
||||
}
|
||||
|
||||
|
||||
public function edit($id)
|
||||
{
|
||||
$activeProfile = getActiveProfile();
|
||||
|
||||
// CRITICAL SECURITY: Validate user has ownership/access to this profile
|
||||
\App\Helpers\ProfileAuthorizationHelper::authorize($activeProfile);
|
||||
|
||||
$this->sociables_id = $id;
|
||||
$this->socialsOptionSelected = $activeProfile->socials->where('pivot.id', $id)->first()->pivot->social_id;
|
||||
$this->userOnSocial = $activeProfile->socials->where('pivot.id', $id)->first()->pivot->user_on_social;
|
||||
$this->serverOfSocial = $activeProfile->socials->where('pivot.id', $id)->first()->pivot->server_of_social;
|
||||
$this->selectedPlaceholder = Social::find($this->socialsOptionSelected);
|
||||
$this->updateMode = true;
|
||||
|
||||
$this->dispatch('contentChanged');
|
||||
}
|
||||
|
||||
|
||||
public function cancel()
|
||||
{
|
||||
$this->updateMode = false;
|
||||
$this->resetInputFields();
|
||||
$this->resetErrorBag(); // clears all validation errors
|
||||
}
|
||||
|
||||
|
||||
public function update()
|
||||
{
|
||||
$validatedDate = $this->validate([
|
||||
'socialsOptionSelected' => 'required|integer',
|
||||
'userOnSocial' => 'required|string|max:150',
|
||||
]);
|
||||
|
||||
$activeProfile = getActiveProfile();
|
||||
|
||||
// CRITICAL SECURITY: Validate user has ownership/access to this profile
|
||||
\App\Helpers\ProfileAuthorizationHelper::authorize($activeProfile);
|
||||
|
||||
if ($this->sociables_id) {
|
||||
DB::table('sociables')->insert([
|
||||
'social_id' => $this->socialsOptionSelected,
|
||||
'sociable_type' => session('activeProfileType'),
|
||||
'sociable_id' => session('activeProfileId'),
|
||||
'user_on_social' => $this->formatUserHandle($this->socialsOptionSelected, $this->userOnSocial),
|
||||
'server_of_social' => $this->formatServer($this->socialsOptionSelected, $this->serverOfSocial),
|
||||
'updated_at' => Carbon::now(),
|
||||
]);
|
||||
|
||||
$this->dispatch('emitSaved');
|
||||
$this->resetInputFields();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public function delete($id)
|
||||
{
|
||||
$activeProfile = getActiveProfile();
|
||||
|
||||
// CRITICAL SECURITY: Validate user has ownership/access to this profile
|
||||
\App\Helpers\ProfileAuthorizationHelper::authorize($activeProfile);
|
||||
|
||||
if ($id) {
|
||||
DB::table('sociables')->where('id', $id)->delete();
|
||||
// refresh the local list
|
||||
$this->getSocials();
|
||||
$this->resetErrorBag(); // clears all validation errors
|
||||
$this->dispatch('emitSaved');
|
||||
|
||||
session()->flash('message', __('Deleted'));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private function formatUserHandle($socialId, $handle)
|
||||
{
|
||||
$handle = str_replace('@', '', $handle);
|
||||
|
||||
// For Blue Sky, the handle already contains the domain
|
||||
if ($socialId == 3) { // Blue Sky ID is 3
|
||||
return $handle; // handle.bsky.social
|
||||
}
|
||||
|
||||
return $handle;
|
||||
}
|
||||
|
||||
private function formatServer($socialId, $server)
|
||||
{
|
||||
$server = str_replace('@', '', $server);
|
||||
|
||||
// Only Mastodon (4) and Blue Sky (3) use server info
|
||||
if (!in_array($socialId, [3, 4])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// For Blue Sky, extract domain from handle if needed
|
||||
if ($socialId == 3) {
|
||||
if (str_contains($this->userOnSocial, '.')) {
|
||||
return substr(strrchr($this->userOnSocial, '.'), 1);
|
||||
}
|
||||
return $server ?: 'bsky.social';
|
||||
}
|
||||
|
||||
return $server;
|
||||
}
|
||||
|
||||
|
||||
public function getSocialUrlAttribute()
|
||||
{
|
||||
$urlStructure = $this->social->url_structure;
|
||||
|
||||
switch ($this->social_id) {
|
||||
case 3: // Blue Sky
|
||||
return "https://bsky.app/profile/{$this->user_on_social}";
|
||||
case 4: // Mastodon
|
||||
return str_replace('#', $this->server_of_social, $urlStructure) . $this->user_on_social;
|
||||
default:
|
||||
return $urlStructure . $this->user_on_social;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public function render()
|
||||
{
|
||||
$this->getSocials();
|
||||
return view('livewire.profile.socials-form');
|
||||
}
|
||||
}
|
||||
244
app/Http/Livewire/Profile/TwoFactorAuthenticationForm.php
Normal file
244
app/Http/Livewire/Profile/TwoFactorAuthenticationForm.php
Normal file
@@ -0,0 +1,244 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire\Profile;
|
||||
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Laravel\Fortify\Actions\ConfirmTwoFactorAuthentication;
|
||||
use Laravel\Fortify\Actions\DisableTwoFactorAuthentication;
|
||||
use Laravel\Fortify\Actions\EnableTwoFactorAuthentication;
|
||||
use Laravel\Fortify\Actions\GenerateNewRecoveryCodes;
|
||||
use Laravel\Fortify\Features;
|
||||
use Laravel\Jetstream\ConfirmsPasswords;
|
||||
use Livewire\Component;
|
||||
|
||||
class TwoFactorAuthenticationForm extends Component
|
||||
{
|
||||
use ConfirmsPasswords;
|
||||
|
||||
/**
|
||||
* Indicates if two factor authentication QR code is being displayed.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
public $showingQrCode = false;
|
||||
|
||||
/**
|
||||
* Indicates if the two factor authentication confirmation input and button are being displayed.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
public $showingConfirmation = false;
|
||||
|
||||
/**
|
||||
* Indicates if two factor authentication recovery codes are being displayed.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
public $showingRecoveryCodes = false;
|
||||
|
||||
/**
|
||||
* The OTP code for confirming two factor authentication.
|
||||
*
|
||||
* @var string|null
|
||||
*/
|
||||
public $code;
|
||||
|
||||
/**
|
||||
* Mount the component.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function mount()
|
||||
{
|
||||
$profile = getActiveProfile();
|
||||
|
||||
if (!$profile) {
|
||||
abort(403, 'No active profile');
|
||||
}
|
||||
|
||||
// CRITICAL SECURITY: Validate user has ownership/access to this profile
|
||||
// This prevents unauthorized 2FA management via session manipulation
|
||||
\App\Helpers\ProfileAuthorizationHelper::authorize($profile);
|
||||
|
||||
if (Features::optionEnabled(Features::twoFactorAuthentication(), 'confirm') &&
|
||||
is_null(Auth::user()->two_factor_confirmed_at)) {
|
||||
app(DisableTwoFactorAuthentication::class)(Auth::user());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable two factor authentication for the user.
|
||||
*
|
||||
* @param \Laravel\Fortify\Actions\EnableTwoFactorAuthentication $enable
|
||||
* @return void
|
||||
*/
|
||||
public function enableTwoFactorAuthentication(EnableTwoFactorAuthentication $enable)
|
||||
{
|
||||
$profile = getActiveProfile();
|
||||
|
||||
if (!$profile) {
|
||||
abort(403, 'No active profile');
|
||||
}
|
||||
|
||||
// CRITICAL SECURITY: Validate authorization before enabling 2FA
|
||||
\App\Helpers\ProfileAuthorizationHelper::authorize($profile);
|
||||
|
||||
if (Features::optionEnabled(Features::twoFactorAuthentication(), 'confirmPassword')) {
|
||||
$this->ensurePasswordIsConfirmed();
|
||||
}
|
||||
|
||||
$enable(Auth::user());
|
||||
|
||||
$this->showingQrCode = true;
|
||||
|
||||
if (Features::optionEnabled(Features::twoFactorAuthentication(), 'confirm')) {
|
||||
$this->showingConfirmation = true;
|
||||
} else {
|
||||
$this->showingRecoveryCodes = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm two factor authentication for the user.
|
||||
*
|
||||
* @param \Laravel\Fortify\Actions\ConfirmTwoFactorAuthentication $confirm
|
||||
* @return void
|
||||
*/
|
||||
public function confirmTwoFactorAuthentication(ConfirmTwoFactorAuthentication $confirm)
|
||||
{
|
||||
$profile = getActiveProfile();
|
||||
|
||||
if (!$profile) {
|
||||
abort(403, 'No active profile');
|
||||
}
|
||||
|
||||
// CRITICAL SECURITY: Validate authorization before confirming 2FA
|
||||
\App\Helpers\ProfileAuthorizationHelper::authorize($profile);
|
||||
|
||||
if (Features::optionEnabled(Features::twoFactorAuthentication(), 'confirmPassword')) {
|
||||
$this->ensurePasswordIsConfirmed();
|
||||
}
|
||||
|
||||
$confirm(Auth::user(), $this->code);
|
||||
|
||||
$this->showingQrCode = false;
|
||||
$this->showingConfirmation = false;
|
||||
$this->showingRecoveryCodes = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the user's recovery codes.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function showRecoveryCodes()
|
||||
{
|
||||
$profile = getActiveProfile();
|
||||
|
||||
if (!$profile) {
|
||||
abort(403, 'No active profile');
|
||||
}
|
||||
|
||||
// CRITICAL SECURITY: Validate authorization before showing recovery codes
|
||||
\App\Helpers\ProfileAuthorizationHelper::authorize($profile);
|
||||
|
||||
if (Features::optionEnabled(Features::twoFactorAuthentication(), 'confirmPassword')) {
|
||||
$this->ensurePasswordIsConfirmed();
|
||||
}
|
||||
|
||||
$this->showingRecoveryCodes = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate new recovery codes for the user.
|
||||
*
|
||||
* @param \Laravel\Fortify\Actions\GenerateNewRecoveryCodes $generate
|
||||
* @return void
|
||||
*/
|
||||
public function regenerateRecoveryCodes(GenerateNewRecoveryCodes $generate)
|
||||
{
|
||||
$profile = getActiveProfile();
|
||||
|
||||
if (!$profile) {
|
||||
abort(403, 'No active profile');
|
||||
}
|
||||
|
||||
// CRITICAL SECURITY: Validate authorization before regenerating recovery codes
|
||||
\App\Helpers\ProfileAuthorizationHelper::authorize($profile);
|
||||
|
||||
if (Features::optionEnabled(Features::twoFactorAuthentication(), 'confirmPassword')) {
|
||||
$this->ensurePasswordIsConfirmed();
|
||||
}
|
||||
|
||||
$generate(Auth::user());
|
||||
|
||||
$this->showingRecoveryCodes = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable two factor authentication for the user.
|
||||
*
|
||||
* @param \Laravel\Fortify\Actions\DisableTwoFactorAuthentication $disable
|
||||
* @return void
|
||||
*/
|
||||
public function disableTwoFactorAuthentication(DisableTwoFactorAuthentication $disable)
|
||||
{
|
||||
$profile = getActiveProfile();
|
||||
|
||||
if (!$profile) {
|
||||
abort(403, 'No active profile');
|
||||
}
|
||||
|
||||
// CRITICAL SECURITY: Validate authorization before disabling 2FA
|
||||
\App\Helpers\ProfileAuthorizationHelper::authorize($profile);
|
||||
|
||||
if (Features::optionEnabled(Features::twoFactorAuthentication(), 'confirmPassword')) {
|
||||
$this->ensurePasswordIsConfirmed();
|
||||
}
|
||||
|
||||
$disable(Auth::user());
|
||||
|
||||
$this->showingQrCode = false;
|
||||
$this->showingConfirmation = false;
|
||||
$this->showingRecoveryCodes = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current user of the application.
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function getUserProperty()
|
||||
{
|
||||
return Auth::user();
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if two factor authentication is enabled.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function getEnabledProperty()
|
||||
{
|
||||
return ! empty($this->user->two_factor_secret);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the component.
|
||||
*
|
||||
* @return \Illuminate\View\View
|
||||
*/
|
||||
public function render()
|
||||
{
|
||||
$profile = getActiveProfile();
|
||||
|
||||
if (!$profile) {
|
||||
abort(403, 'No active profile');
|
||||
}
|
||||
|
||||
// CRITICAL SECURITY: Re-validate authorization on every render
|
||||
\App\Helpers\ProfileAuthorizationHelper::authorize($profile);
|
||||
|
||||
return view('profile.two-factor-authentication-form');
|
||||
}
|
||||
}
|
||||
76
app/Http/Livewire/Profile/TwoFactorMainPageCard.php
Normal file
76
app/Http/Livewire/Profile/TwoFactorMainPageCard.php
Normal file
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire\Profile;
|
||||
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Component;
|
||||
|
||||
class TwoFactorMainPageCard extends Component
|
||||
{
|
||||
/**
|
||||
* Indicates if two factor authentication is confirmed and active.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
public $enabled;
|
||||
|
||||
/**
|
||||
* Mount the component.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function mount()
|
||||
{
|
||||
$user = Auth::guard('web')->user();
|
||||
// $this->enabled strictly means 2FA is fully confirmed in the DB
|
||||
$this->enabled = $user && !empty($user->two_factor_confirmed_at);
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect the user to the admin settings page.
|
||||
*
|
||||
* @return \Illuminate\Http\RedirectResponse
|
||||
*/
|
||||
public function redirectToSettings()
|
||||
{
|
||||
$route = '';
|
||||
$anchor = '#two-factor-authentication-form';
|
||||
|
||||
if (session('activeProfileType') == 'App\Models\Organization') {
|
||||
$route = 'profile.settings';
|
||||
}
|
||||
elseif (session('activeProfileType') == 'App\Models\Bank') {
|
||||
$route = 'profile.bank.settings';
|
||||
}
|
||||
elseif (session('activeProfileType') == 'App\Models\Admin') {
|
||||
$route = 'profile.admin.settings';
|
||||
}
|
||||
else {
|
||||
$route = 'profile.user.settings';
|
||||
}
|
||||
// Generate the URL for the route and append the anchor
|
||||
$url = route($route) . $anchor;
|
||||
|
||||
return redirect()->to($url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current user of the application.
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function getUserProperty()
|
||||
{
|
||||
return Auth::guard('web')->user();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the component.
|
||||
*
|
||||
* @return \Illuminate\View\View
|
||||
*/
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.profile.two-factor-main-page-card');
|
||||
}
|
||||
}
|
||||
112
app/Http/Livewire/Profile/UpdateMessageSettingsForm.php
Normal file
112
app/Http/Livewire/Profile/UpdateMessageSettingsForm.php
Normal file
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire\Profile;
|
||||
|
||||
use Livewire\Component;
|
||||
|
||||
class UpdateMessageSettingsForm extends Component
|
||||
{
|
||||
public bool $systemMessage;
|
||||
public bool $paymentReceived;
|
||||
public bool $starReceived;
|
||||
public bool $localNewsletter;
|
||||
public bool $generalNewsletter;
|
||||
public bool $personalChat;
|
||||
public bool $groupChat;
|
||||
public int $chatUnreadDelay;
|
||||
public bool $callExpiry;
|
||||
|
||||
protected $rules = [
|
||||
'systemMessage' => 'boolean',
|
||||
'paymentReceived' => 'boolean',
|
||||
'starReceived' => 'boolean',
|
||||
'localNewsletter' => 'boolean',
|
||||
'generalNewsletter' => 'boolean',
|
||||
'personalChat' => 'boolean',
|
||||
'groupChat' => 'boolean',
|
||||
'chatUnreadDelay' => 'integer|min:0|max:99', // 168 hours is one week
|
||||
'callExpiry' => 'boolean',
|
||||
];
|
||||
|
||||
|
||||
public function mount()
|
||||
{
|
||||
$profile = getActiveProfile();
|
||||
|
||||
if (!$profile) {
|
||||
abort(403, 'No active profile');
|
||||
}
|
||||
|
||||
// CRITICAL SECURITY: Validate user has ownership/access to this profile
|
||||
\App\Helpers\ProfileAuthorizationHelper::authorize($profile);
|
||||
|
||||
// Load current settings or create default
|
||||
$settings = $profile->message_settings()->first();
|
||||
|
||||
if (!$settings) {
|
||||
// Create default settings
|
||||
$settings = $profile->message_settings()->create([
|
||||
'system_message' => true,
|
||||
'payment_received' => true,
|
||||
'star_received' => true,
|
||||
'local_newsletter' => true,
|
||||
'general_newsletter' => true,
|
||||
'personal_chat' => true,
|
||||
'group_chat' => true,
|
||||
'chat_unread_delay' => timebank_config('messenger.default_unread_mail_delay'),
|
||||
'call_expiry' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
$this->systemMessage = $settings->system_message;
|
||||
$this->paymentReceived = $settings->payment_received;
|
||||
$this->starReceived = $settings->star_received;
|
||||
$this->localNewsletter = $settings->local_newsletter;
|
||||
$this->generalNewsletter = $settings->general_newsletter;
|
||||
$this->personalChat = $settings->personal_chat;
|
||||
$this->groupChat = $settings->group_chat;
|
||||
$this->chatUnreadDelay = $settings->chat_unread_delay;
|
||||
$this->callExpiry = (bool) ($settings->call_expiry ?? true);
|
||||
}
|
||||
|
||||
|
||||
public function updateMessageSettings()
|
||||
{
|
||||
$this->validate();
|
||||
|
||||
$profile = getActiveProfile();
|
||||
|
||||
if (!$profile) {
|
||||
abort(403, 'No active profile');
|
||||
}
|
||||
|
||||
// CRITICAL SECURITY: Validate user has ownership/access to this profile
|
||||
\App\Helpers\ProfileAuthorizationHelper::authorize($profile);
|
||||
|
||||
$profile->message_settings()->updateOrCreate(
|
||||
[],
|
||||
[
|
||||
'system_message' => $this->systemMessage,
|
||||
'payment_received' => $this->paymentReceived,
|
||||
'star_received' => $this->starReceived,
|
||||
'local_newsletter' => $this->localNewsletter,
|
||||
'general_newsletter' => $this->generalNewsletter,
|
||||
'personal_chat' => $this->personalChat,
|
||||
'group_chat' => $this->groupChat,
|
||||
'chat_unread_delay' => $this->chatUnreadDelay,
|
||||
'call_expiry' => $this->callExpiry,
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
$this->dispatch('saved');
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.profile.update-message-settings-form');
|
||||
}
|
||||
}
|
||||
56
app/Http/Livewire/Profile/UpdateNonUserPasswordForm.php
Normal file
56
app/Http/Livewire/Profile/UpdateNonUserPasswordForm.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire\Profile;
|
||||
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Livewire\Component;
|
||||
|
||||
class UpdateNonUserPasswordForm extends Component
|
||||
{
|
||||
public $state = [
|
||||
'current_password' => '',
|
||||
'password' => '',
|
||||
'password_confirmation' => '',
|
||||
];
|
||||
|
||||
public function updatePassword()
|
||||
{
|
||||
$profileName = strtolower(getActiveProfileType());
|
||||
$this->validate([
|
||||
'state.current_password' => ['required', 'string'],
|
||||
'state.password' => timebank_config('rules.profile_' . $profileName . '.password'),
|
||||
]);
|
||||
|
||||
$activeProfile = getActiveprofile();
|
||||
|
||||
// CRITICAL SECURITY: Validate user has ownership/access to this profile
|
||||
\App\Helpers\ProfileAuthorizationHelper::authorize($activeProfile);
|
||||
|
||||
// Check if the current password matches
|
||||
if (!Hash::check($this->state['current_password'], $activeProfile->password)) {
|
||||
$this->addError('state.current_password', __('The provided password does not match your current password.'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Update the password
|
||||
$activeProfile->forceFill([
|
||||
'password' => Hash::make($this->state['password']),
|
||||
])->save();
|
||||
|
||||
activity()
|
||||
->useLog(class_basename(getActiveProfileType()))
|
||||
->performedOn($activeProfile)
|
||||
->causedBy(Auth::guard('web')->user())
|
||||
->event('password_changed')
|
||||
->log('Password changed for ' . $activeProfile->name);
|
||||
|
||||
// Dispatch a success message
|
||||
$this->dispatch('saved');
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.profile.update-non-user-password-form');
|
||||
}
|
||||
}
|
||||
104
app/Http/Livewire/Profile/UpdatePasswordForm.php
Normal file
104
app/Http/Livewire/Profile/UpdatePasswordForm.php
Normal file
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire\Profile;
|
||||
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Laravel\Fortify\Contracts\UpdatesUserPasswords;
|
||||
use Livewire\Component;
|
||||
|
||||
class UpdatePasswordForm extends Component
|
||||
{
|
||||
/**
|
||||
* The component's state.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
public $state = [
|
||||
'current_password' => '',
|
||||
'password' => '',
|
||||
'password_confirmation' => '',
|
||||
];
|
||||
|
||||
/**
|
||||
* Mount the component.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function mount()
|
||||
{
|
||||
$profile = getActiveProfile();
|
||||
|
||||
if (!$profile) {
|
||||
abort(403, 'No active profile');
|
||||
}
|
||||
|
||||
// CRITICAL SECURITY: Validate user has ownership/access to this profile
|
||||
// This prevents unauthorized password changes via session manipulation
|
||||
\App\Helpers\ProfileAuthorizationHelper::authorize($profile);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the user's password.
|
||||
*
|
||||
* @param \Laravel\Fortify\Contracts\UpdatesUserPasswords $updater
|
||||
* @return void
|
||||
*/
|
||||
public function updatePassword(UpdatesUserPasswords $updater)
|
||||
{
|
||||
$profile = getActiveProfile();
|
||||
|
||||
if (!$profile) {
|
||||
abort(403, 'No active profile');
|
||||
}
|
||||
|
||||
// CRITICAL SECURITY: Validate authorization before password update
|
||||
\App\Helpers\ProfileAuthorizationHelper::authorize($profile);
|
||||
|
||||
$this->resetErrorBag();
|
||||
|
||||
$updater->update(Auth::user(), $this->state);
|
||||
|
||||
if (request()->hasSession()) {
|
||||
request()->session()->put([
|
||||
'password_hash_'.Auth::getDefaultDriver() => Auth::user()->getAuthPassword(),
|
||||
]);
|
||||
}
|
||||
|
||||
$this->state = [
|
||||
'current_password' => '',
|
||||
'password' => '',
|
||||
'password_confirmation' => '',
|
||||
];
|
||||
|
||||
$this->dispatch('saved');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current user of the application.
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function getUserProperty()
|
||||
{
|
||||
return Auth::user();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the component.
|
||||
*
|
||||
* @return \Illuminate\View\View
|
||||
*/
|
||||
public function render()
|
||||
{
|
||||
$profile = getActiveProfile();
|
||||
|
||||
if (!$profile) {
|
||||
abort(403, 'No active profile');
|
||||
}
|
||||
|
||||
// CRITICAL SECURITY: Re-validate authorization on every render
|
||||
\App\Helpers\ProfileAuthorizationHelper::authorize($profile);
|
||||
|
||||
return view('profile.update-password-form');
|
||||
}
|
||||
}
|
||||
187
app/Http/Livewire/Profile/UpdateProfilePhoneForm.php
Normal file
187
app/Http/Livewire/Profile/UpdateProfilePhoneForm.php
Normal file
@@ -0,0 +1,187 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire\Profile;
|
||||
|
||||
use Illuminate\Config\Repository;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Component;
|
||||
use Propaganistas\LaravelPhone\PhoneNumber;
|
||||
|
||||
class UpdateProfilePhoneForm extends Component
|
||||
{
|
||||
public $phoneCodeOptions;
|
||||
public $phonecode;
|
||||
public $state = [];
|
||||
|
||||
|
||||
protected $rules = [
|
||||
'state.phone' => [ 'phone:phonecode,mobile,strict', 'regex:/^[\d+()\s-]+$/', ],
|
||||
'phonecode' => 'required_with:state.phone,mobile',
|
||||
'state.phone_public' =>'boolean|nullable',
|
||||
];
|
||||
|
||||
|
||||
/**
|
||||
* Prepare the component.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function mount(Request $request, Repository $config)
|
||||
{
|
||||
|
||||
$activeProfile = getActiveProfile();
|
||||
$phonePublic = isset($activeProfile->phone_public);
|
||||
|
||||
|
||||
$this->state = array_merge([
|
||||
'phone' => $activeProfile->phone,
|
||||
'phone_public' => $phonePublic == true ? $activeProfile->phone_public : null,
|
||||
], $activeProfile->withoutRelations()->toArray());
|
||||
|
||||
$phoneCodeOptions = DB::table('countries')->get()->sortBy('code');
|
||||
$this->phoneCodeOptions = $phoneCodeOptions->Map(function ($options, $key) {
|
||||
return [
|
||||
'id' => $options->id,
|
||||
'code' => $options->code,
|
||||
'label' => $options->flag,
|
||||
];
|
||||
});
|
||||
|
||||
$this->getPhonecode();
|
||||
}
|
||||
|
||||
public function getPhonecode()
|
||||
{
|
||||
$activeProfile = getActiveProfile();
|
||||
|
||||
// Fill country code dropdown
|
||||
$this->phoneCodeOptions->toArray();
|
||||
|
||||
// Ensure the profile is authenticated and retrieve the phone field
|
||||
$profilePhone = $activeProfile->phone ?? '';
|
||||
|
||||
if (!empty($profilePhone)) {
|
||||
try {
|
||||
$country = new PhoneNumber($profilePhone);
|
||||
$this->phonecode = $country->getCountry();
|
||||
$phone = new PhoneNumber($profilePhone, $this->phonecode);
|
||||
$this->state['phone'] = $phone->formatNational();
|
||||
} catch (\Exception) {
|
||||
// If phone parsing fails, reset to empty and set default country
|
||||
$this->state['phone'] = '';
|
||||
$this->setDefaultCountryCode($activeProfile);
|
||||
}
|
||||
} else {
|
||||
$this->setDefaultCountryCode($activeProfile);
|
||||
}
|
||||
}
|
||||
|
||||
private function setDefaultCountryCode($activeProfile)
|
||||
{
|
||||
// Try to get country from profile locations if available
|
||||
$countryIds = [];
|
||||
|
||||
if (method_exists($activeProfile, 'locations')) {
|
||||
$countryIds = get_class($activeProfile)::find($this->state['id'])->locations()
|
||||
->with('city:id,country_id')
|
||||
->get()
|
||||
->pluck('city.country_id')
|
||||
->filter() // Remove null values
|
||||
->toArray();
|
||||
}
|
||||
|
||||
$countries = ($this->phoneCodeOptions)->pluck('id')->toArray();
|
||||
|
||||
// Get the first valid country ID from the profile's locations
|
||||
$firstCountryId = !empty($countryIds) ? $countryIds[0] : null;
|
||||
|
||||
if ($firstCountryId && in_array($firstCountryId, $countries)) {
|
||||
$this->phonecode = DB::table('countries')->select('code')->where('id', $firstCountryId)->pluck('code')->first();
|
||||
} else {
|
||||
// Fallback to first available country code
|
||||
$this->phonecode = $this->phoneCodeOptions[0]['code'] ?? 'NL';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Validate phone field when updated.
|
||||
* This is the 1st validation method on this form.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function updatedStatePhone()
|
||||
{
|
||||
if (!empty($this->state['phone']) && !empty($this->phonecode)) {
|
||||
try {
|
||||
$this->validateOnly('state.phone');
|
||||
$phone = new PhoneNumber($this->state['phone'], $this->phonecode);
|
||||
$this->state['phone'] = $phone->formatNational();
|
||||
} catch (\Exception) {
|
||||
$this->addError('state.phone', __('Invalid phone number format.'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Update the profile's phone information.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function updateProfilePhone()
|
||||
{
|
||||
$activeProfile = getActiveProfile();
|
||||
|
||||
// CRITICAL SECURITY: Validate user has ownership/access to this profile
|
||||
\App\Helpers\ProfileAuthorizationHelper::authorize($activeProfile);
|
||||
|
||||
if (!empty($this->state['phone'])) {
|
||||
$this->validate(); // 2nd validation, just before save method
|
||||
$this->resetErrorBag();
|
||||
|
||||
try {
|
||||
$phone = new PhoneNumber($this->state['phone'], $this->phonecode);
|
||||
$activeProfile->phone = $phone;
|
||||
|
||||
// Check for the existence of phone_public column and update if exists
|
||||
if (in_array('phone_public', $activeProfile->getFillable())) {
|
||||
$activeProfile->phone_public = $this->state['phone_public'] ?? false;
|
||||
}
|
||||
} catch (\Exception) {
|
||||
$this->addError('state.phone', __('Invalid phone number format.'));
|
||||
return;
|
||||
}
|
||||
|
||||
} else {
|
||||
$this->resetErrorBag();
|
||||
$activeProfile->phone = null;
|
||||
|
||||
// Clear the phone_public field if it exists
|
||||
if (in_array('phone_public', $activeProfile->getFillable())) {
|
||||
$activeProfile->phone_public = false;
|
||||
}
|
||||
}
|
||||
|
||||
$activeProfile->save();
|
||||
$this->dispatch('saved');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get the current active profile of the application.
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function getUserProperty()
|
||||
{
|
||||
return getActiveProfile();
|
||||
}
|
||||
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.profile.update-profile-phone-form');
|
||||
}
|
||||
}
|
||||
21
app/Http/Livewire/Profile/UpdateProfileSkillsForm.php
Normal file
21
app/Http/Livewire/Profile/UpdateProfileSkillsForm.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire\Profile;
|
||||
|
||||
use App\Http\Livewire\MainPage\SkillsCardFull;
|
||||
|
||||
/**
|
||||
* Class UpdateProfileSkillsForm
|
||||
*
|
||||
* This class extends the SkillsCardFull class and is responsible for rendering
|
||||
* the update profile skills form view in the Livewire component.
|
||||
*
|
||||
* @package App\Http\Livewire\Profile
|
||||
*/
|
||||
class UpdateProfileSkillsForm extends SkillsCardFull
|
||||
{
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.profile.update-profile-skills-form');
|
||||
}
|
||||
}
|
||||
326
app/Http/Livewire/Profile/UpdateSettingsForm.php
Normal file
326
app/Http/Livewire/Profile/UpdateSettingsForm.php
Normal file
@@ -0,0 +1,326 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire\Profile;
|
||||
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Fortify\Contracts\UpdatesUserProfileInformation;
|
||||
|
||||
|
||||
use Livewire\Component;
|
||||
use Livewire\WithFileUploads;
|
||||
|
||||
|
||||
class UpdateSettingsForm extends Component
|
||||
{
|
||||
use WithFileUploads;
|
||||
|
||||
|
||||
/**
|
||||
* The component's state.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
public $state;
|
||||
|
||||
|
||||
/**
|
||||
* The new avatar for the active profile.
|
||||
*
|
||||
* @var mixed
|
||||
*/
|
||||
public $photo;
|
||||
|
||||
|
||||
/**
|
||||
* Determine if the verification email was sent.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
public $verificationLinkSent = false;
|
||||
|
||||
|
||||
/**
|
||||
* Prepare the component.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function mount()
|
||||
{
|
||||
$activeProfile = getActiveProfile();
|
||||
|
||||
// --- Check roles and permissions --- //
|
||||
// Permissions are assigned to Users (web guard), not to Organizations/Banks/Admins
|
||||
$webUser = Auth::guard('web')->user();
|
||||
|
||||
if (!$webUser) {
|
||||
abort(403, 'Unauthorized action.');
|
||||
}
|
||||
|
||||
$authorized =
|
||||
($activeProfile instanceof \App\Models\User &&
|
||||
($webUser->can('manage users') ||
|
||||
$webUser->id === $activeProfile->id))
|
||||
||
|
||||
($activeProfile instanceof \App\Models\Organization &&
|
||||
($webUser->can('manage organizations') ||
|
||||
$webUser->hasRole('Organization\\' . $activeProfile->id . '\\organization-manager') ||
|
||||
$webUser->organizations()->where('organization_user.organization_id', $activeProfile->id)->exists()))
|
||||
||
|
||||
($activeProfile instanceof \App\Models\Bank &&
|
||||
($webUser->can('manage banks') ||
|
||||
$webUser->hasRole('Bank\\' . $activeProfile->id . '\\bank-manager') ||
|
||||
$webUser->banksManaged()->where('bank_user.bank_id', $activeProfile->id)->exists()))
|
||||
||
|
||||
($activeProfile instanceof \App\Models\Admin &&
|
||||
($webUser->can('manage admins') ||
|
||||
$webUser->hasRole('Admin\\' . $activeProfile->id . '\\admin') ||
|
||||
$webUser->admins()->where('admin_user.admin_id', $activeProfile->id)->exists()));
|
||||
|
||||
if (!$authorized) {
|
||||
abort(403, 'Unauthorized action.');
|
||||
}
|
||||
|
||||
|
||||
$this->state = array_merge([
|
||||
'email' => $activeProfile->email,
|
||||
'full_name' => $activeProfile->full_name,
|
||||
], $activeProfile->withoutRelations()->toArray());
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Update the active profile's profile information.
|
||||
*
|
||||
* @param \Laravel\Fortify\Contracts\UpdatesUserProfileInformation $updater
|
||||
* @return void
|
||||
*/
|
||||
public function updateProfileInformation()
|
||||
{
|
||||
$this->resetErrorBag();
|
||||
|
||||
$activeProfile = getActiveProfile();
|
||||
|
||||
// CRITICAL SECURITY: Validate user has ownership/access to this profile
|
||||
\App\Helpers\ProfileAuthorizationHelper::authorize($activeProfile);
|
||||
|
||||
// Determine the profile type and table
|
||||
$profileType = get_class($activeProfile);
|
||||
$modelKey = match ($profileType) {
|
||||
'App\Models\User' => 'user',
|
||||
'App\Models\Organization' => 'organization',
|
||||
'App\Models\Bank' => 'bank',
|
||||
'App\Models\Admin' => 'admin',
|
||||
default => 'user',
|
||||
};
|
||||
$tableName = (new $profileType())->getTable();
|
||||
|
||||
// Get validation rules from platform config
|
||||
$emailRules = timebank_config("rules.profile_{$modelKey}.email");
|
||||
$fullNameRules = timebank_config("rules.profile_{$modelKey}.full_name");
|
||||
$photoRules = timebank_config("rules.profile_{$modelKey}.profile_photo");
|
||||
|
||||
// Convert string rules to arrays if needed
|
||||
if (is_string($emailRules)) {
|
||||
$emailRules = explode('|', $emailRules);
|
||||
}
|
||||
if (is_string($fullNameRules)) {
|
||||
$fullNameRules = explode('|', $fullNameRules);
|
||||
}
|
||||
if (is_string($photoRules)) {
|
||||
$photoRules = explode('|', $photoRules);
|
||||
}
|
||||
|
||||
// Process email rules to handle unique constraint for current profile
|
||||
$processedEmailRules = [];
|
||||
foreach ($emailRules as $rule) {
|
||||
if (is_string($rule) && \Illuminate\Support\Str::startsWith(trim($rule), 'unique:')) {
|
||||
// Check if this is the unique rule for the current table
|
||||
if (preg_match("/^unique:{$tableName},email(,|$)/", trim($rule))) {
|
||||
// Replace with a Rule object that ignores current profile
|
||||
$processedEmailRules[] = \Illuminate\Validation\Rule::unique($tableName, 'email')->ignore($activeProfile->id);
|
||||
} else {
|
||||
// Keep unique rules for other tables
|
||||
$processedEmailRules[] = $rule;
|
||||
}
|
||||
} else {
|
||||
$processedEmailRules[] = $rule;
|
||||
}
|
||||
}
|
||||
|
||||
// Process full_name rules to handle unique constraint for current profile
|
||||
$processedFullNameRules = [];
|
||||
if ($fullNameRules) {
|
||||
foreach ($fullNameRules as $rule) {
|
||||
if (is_string($rule) && \Illuminate\Support\Str::startsWith(trim($rule), 'unique:')) {
|
||||
// Check if this is a unique rule for the current table (any column)
|
||||
if (preg_match("/^unique:{$tableName},(\w+)(,|$)/", trim($rule), $matches)) {
|
||||
$column = $matches[1];
|
||||
// Replace with a Rule object that ignores current profile
|
||||
$processedFullNameRules[] = \Illuminate\Validation\Rule::unique($tableName, $column)->ignore($activeProfile->id);
|
||||
} else {
|
||||
// Keep unique rules for other tables
|
||||
$processedFullNameRules[] = $rule;
|
||||
}
|
||||
} else {
|
||||
$processedFullNameRules[] = $rule;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare validation rules
|
||||
$rules = [
|
||||
'state.email' => $processedEmailRules,
|
||||
];
|
||||
|
||||
// Add full_name validation for non-User profiles
|
||||
if (!($activeProfile instanceof \App\Models\User) && $processedFullNameRules) {
|
||||
$rules['state.full_name'] = $processedFullNameRules;
|
||||
}
|
||||
|
||||
// Add photo validation if a photo is being uploaded
|
||||
if ($this->photo && $photoRules) {
|
||||
$rules['photo'] = $photoRules;
|
||||
}
|
||||
|
||||
// Validate the input
|
||||
$this->validate($rules, [
|
||||
'state.email.required' => __('The email field is required.'),
|
||||
'state.email.email' => __('Please enter a valid email address.'),
|
||||
'state.email.unique' => __('This email address is already in use.'),
|
||||
'state.full_name.required' => __('The full name field is required.'),
|
||||
'state.full_name.max' => __('The full name must not exceed the maximum length.'),
|
||||
'photo.image' => __('The file must be an image.'),
|
||||
'photo.max' => __('The image size exceeds the maximum allowed.'),
|
||||
]);
|
||||
|
||||
// Check if the email has changed
|
||||
$emailChanged = $this->state['email'] !== $activeProfile->email;
|
||||
|
||||
if ($this->photo) {
|
||||
// Delete old file if it doesn't start with "app-images/" (as those are default images)
|
||||
if ($activeProfile->profile_photo_path
|
||||
&& !Str::startsWith($activeProfile->profile_photo_path, 'app-images/')) {
|
||||
Storage::disk('public')->delete($activeProfile->profile_photo_path);
|
||||
}
|
||||
|
||||
// Store the new file
|
||||
$photoPath = $this->photo->store('profile-photos', 'public');
|
||||
$this->state['profile_photo_path'] = $photoPath;
|
||||
}
|
||||
|
||||
// Remove protected fields from state to prevent changes
|
||||
$updateData = $this->state;
|
||||
unset($updateData['name']); // Username is always read-only
|
||||
|
||||
// Full name is read-only for Users, but editable for Organizations, Banks, and Admins
|
||||
if ($activeProfile instanceof \App\Models\User) {
|
||||
unset($updateData['full_name']);
|
||||
}
|
||||
|
||||
// Update records of active profile
|
||||
$activeProfile->update($updateData);
|
||||
|
||||
// Refresh the component state with the updated model data
|
||||
$activeProfile = $activeProfile->fresh();
|
||||
$this->state = $activeProfile->toArray();
|
||||
$this->state['email'] = $activeProfile->email;
|
||||
$this->state['full_name'] = $activeProfile->full_name;
|
||||
|
||||
// Update the session variable so the Blade view can display the new photo
|
||||
session(['activeProfilePhoto' => $this->state['profile_photo_path']]);
|
||||
|
||||
// Send email verification if the email has changed
|
||||
if ($emailChanged) {
|
||||
$activeProfile->forceFill(['email_verified_at' => null])->save();
|
||||
$activeProfile->sendEmailVerificationNotification();
|
||||
|
||||
// Refresh state after email verification changes
|
||||
$activeProfile = $activeProfile->fresh();
|
||||
$this->state = $activeProfile->toArray();
|
||||
$this->state['email'] = $activeProfile->email;
|
||||
$this->state['full_name'] = $activeProfile->full_name;
|
||||
}
|
||||
|
||||
if (isset($this->photo)) {
|
||||
return redirect()->route('profile.settings');
|
||||
}
|
||||
|
||||
$this->dispatch('saved');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Delete active profile's profile photo.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function deleteProfilePhoto()
|
||||
{
|
||||
$activeProfile = getActiveProfile();
|
||||
|
||||
// CRITICAL SECURITY: Validate user has ownership/access to this profile
|
||||
\App\Helpers\ProfileAuthorizationHelper::authorize($activeProfile);
|
||||
|
||||
// If the existing photo path is not one of the default images, delete it
|
||||
if ($activeProfile->profile_photo_path
|
||||
&& !Str::startsWith($activeProfile->profile_photo_path, 'app-images/')) {
|
||||
Storage::disk('public')->delete($activeProfile->profile_photo_path);
|
||||
}
|
||||
|
||||
// Set the profile photo path to the configured default in your config file
|
||||
$defaultPath = timebank_config('profiles.' . strtolower(getActiveProfileType()) . '.profile_photo_path_default');
|
||||
$this->state['profile_photo_path'] = $defaultPath;
|
||||
|
||||
// Update the active profile’s record
|
||||
$activeProfile->update(['profile_photo_path' => $defaultPath]);
|
||||
|
||||
// Refresh the component state with the updated model data
|
||||
$this->state = $activeProfile->fresh()->toArray();
|
||||
|
||||
// Update the session variable so the Blade view can display the new photo
|
||||
session(['activeProfilePhoto' => $defaultPath]);
|
||||
|
||||
redirect()->route('profile.settings');
|
||||
|
||||
// Dispatch any events if desired, for example:
|
||||
$this->dispatch('saved');
|
||||
$this->dispatch('refresh-navigation-menu');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Send the email verification.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function sendEmailVerification()
|
||||
{
|
||||
$activeProfile = getActiveProfile();
|
||||
|
||||
// CRITICAL SECURITY: Validate user has ownership/access to this profile
|
||||
\App\Helpers\ProfileAuthorizationHelper::authorize($activeProfile);
|
||||
|
||||
$activeProfile->sendEmailVerificationNotification();
|
||||
|
||||
$this->verificationLinkSent = true;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get the current active profile of the application.
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function getUserProperty()
|
||||
{
|
||||
return getActiveProfile();
|
||||
}
|
||||
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.profile.update-settings-form');
|
||||
}
|
||||
}
|
||||
321
app/Http/Livewire/ProfileBank/UpdateProfileBankForm.php
Normal file
321
app/Http/Livewire/ProfileBank/UpdateProfileBankForm.php
Normal file
@@ -0,0 +1,321 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire\ProfileBank;
|
||||
|
||||
use App\Models\Bank;
|
||||
use App\Traits\FormHelpersTrait;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Laravel\Jetstream\Features;
|
||||
use Laravel\Jetstream\HasProfilePhoto;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithFileUploads;
|
||||
|
||||
class UpdateProfileBankForm extends Component
|
||||
{
|
||||
use WithFileUploads;
|
||||
use HasProfilePhoto;
|
||||
use FormHelpersTrait;
|
||||
|
||||
public $state = [];
|
||||
public $bank;
|
||||
public $photo;
|
||||
public $languages;
|
||||
public $website;
|
||||
|
||||
//Important
|
||||
// Authorization is handled in mount() method instead of middleware to support multi-guard system
|
||||
// protected $middleware = [
|
||||
// 'can:manage banks',
|
||||
// ];
|
||||
|
||||
protected $listeners = ['languagesToParent'];
|
||||
|
||||
|
||||
public function rules()
|
||||
{
|
||||
return [
|
||||
'photo' => timebank_config('rules.profile_bank.profile_photo'),
|
||||
'state.about' => timebank_config('rules.profile_bank.about', 400),
|
||||
'state.about_short' => timebank_config('rules.profile_bank.about_short', 150),
|
||||
'state.motivation' => timebank_config('rules.profile_bank.motivation', 300),
|
||||
'languages' => timebank_config('rules.profile_bank.languages', 'required'),
|
||||
'languages.id' => timebank_config('rules.profile_bank.languages_id', 'int'),
|
||||
'state.date_of_birth' => timebank_config('rules.profile_bank.date_of_birth', 'nullable|date'),
|
||||
'website' => timebank_config('rules.profile_bank.website', 'nullable|regex:/^(https?:\/\/)?([\da-z\.-]+)\.([a-z\.]{2,6})([\/\w \.-]*)*\/?$/i'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare the component.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function mount()
|
||||
{
|
||||
// --- Check roles and permissions --- //
|
||||
$activeProfile = getActiveProfile();
|
||||
|
||||
// Check if active profile is a Bank
|
||||
if (!($activeProfile instanceof \App\Models\Bank)) {
|
||||
abort(403, 'Unauthorized action.');
|
||||
}
|
||||
|
||||
// Check if web user (who owns the bank) has permission or bank manager role
|
||||
// Permissions are assigned to Users (web guard), not to Banks
|
||||
$webUser = Auth::guard('web')->user();
|
||||
|
||||
// User is authorized if ANY of these conditions are true:
|
||||
// 1. Has global "manage banks" permission (admin)
|
||||
// 2. Has bank manager role for this specific bank
|
||||
// 3. Is linked to this bank (owner/member)
|
||||
$authorized = ($webUser && (
|
||||
$webUser->can('manage banks') ||
|
||||
$webUser->hasRole('Bank\\' . $activeProfile->id . '\\bank-manager') ||
|
||||
$webUser->banksManaged()->where('bank_user.bank_id', $activeProfile->id)->exists()
|
||||
));
|
||||
|
||||
if (!$authorized) {
|
||||
abort(403, 'Unauthorized action.');
|
||||
}
|
||||
|
||||
|
||||
$this->state = Bank::find(session('activeProfileId'))->toArray();
|
||||
$this->website = $this->state['website'];
|
||||
$this->bank = Bank::find(session('activeProfileId'));
|
||||
|
||||
$this->getLanguages();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get the profile photo URL for the bank
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getProfilePhotoUrlProperty()
|
||||
{
|
||||
if (!$this->bank) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Use asset() for app-images, Storage::url() for uploaded photos
|
||||
if (str_starts_with($this->bank->profile_photo_path, 'app-images/')) {
|
||||
return asset('storage/' . $this->bank->profile_photo_path);
|
||||
}
|
||||
|
||||
return url(Storage::url($this->bank->profile_photo_path));
|
||||
}
|
||||
|
||||
|
||||
public function getLanguages()
|
||||
{
|
||||
// Create a language options collection that combines all language and competence options
|
||||
$langOptions = DB::table('languages')->get(['id','name']);
|
||||
$compOptions = DB::table('language_competences')->get(['id','name']);
|
||||
$langOptions = collect(Arr::crossJoin($langOptions, $compOptions));
|
||||
$langOptions = $langOptions->Map(function ($language, $key) {
|
||||
return [
|
||||
'id' => $key, // index key is needed to select values in dropdown (option-value)
|
||||
'langId' => $language[0]->id,
|
||||
'compId' => $language[1]->id,
|
||||
'name' => trans($language[0]->name) . ' - ' . trans($language[1]->name),
|
||||
];
|
||||
});
|
||||
|
||||
// Create an array of the pre-selected language options
|
||||
$languages = $this->bank->languages;
|
||||
$languages = $languages->map(function ($language, $key) use ($langOptions) {
|
||||
$competence = DB::table('language_competences')->find($language->pivot->competence);
|
||||
$langSelected = collect($langOptions)->where('name', trans($language->name) . ' - ' . trans($competence->name));
|
||||
return [
|
||||
$langSelected->keys()
|
||||
];
|
||||
});
|
||||
$languages = $languages->flatten();
|
||||
|
||||
// Create a selected language collection that holds the selected languages with their selected competences
|
||||
$this->languages = collect($langOptions)->whereIn('id', $languages)->values();
|
||||
}
|
||||
|
||||
|
||||
public function languagesToParent($values)
|
||||
{
|
||||
$this->languages = $values;
|
||||
$this->validateOnly('languages');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Validate a single field when updated.
|
||||
* This is the 1st validation method on this form.
|
||||
*
|
||||
* @param mixed $field
|
||||
* @return void
|
||||
*/
|
||||
public function updated($field)
|
||||
{
|
||||
if ($field == 'website') {
|
||||
// If website is not empty, add URL scheme
|
||||
if (!empty($this->website)) {
|
||||
$this->website = $this->addUrlScheme($this->website);
|
||||
} else {
|
||||
// If website is empty, remove 'https://' prefix
|
||||
$this->website = str_replace('https://', '', $this->website);
|
||||
}
|
||||
}
|
||||
|
||||
$this->validateOnly($field);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Update the bank's profile contact information.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function updateProfilePersonalForm()
|
||||
{
|
||||
$bank = getActiveProfile();
|
||||
|
||||
// CRITICAL SECURITY: Validate user has ownership/access to this profile
|
||||
\App\Helpers\ProfileAuthorizationHelper::authorize($bank);
|
||||
|
||||
if (isset($this->photo)) {
|
||||
$bank->updateProfilePhoto($this->photo); // Trait (use HasProfilePhoto) needs to attached to Bank model for this to work
|
||||
}
|
||||
|
||||
$this->validate(); // 2nd validation, just before save method
|
||||
|
||||
$bank->about = $this->state['about'];
|
||||
$bank->about_short = $this->state['about_short'];
|
||||
$bank->motivation = $this->state['motivation'];
|
||||
$bank->website = str_replace(['http://', 'https://', ], '', $this->website);
|
||||
|
||||
if (isset($this->languages)) {
|
||||
|
||||
$languages = collect($this->languages)->Map(function ($lang, $key) use ($bank) {
|
||||
return [
|
||||
'language_id' => $lang['langId'],
|
||||
'competence' => $lang['compId'],
|
||||
'languagable_type' => Bank::class,
|
||||
'languagable_id' => $bank->id,
|
||||
];
|
||||
})->toArray();
|
||||
|
||||
$bank->languages()->detach(); // Remove all languages of this bank before inserting the new ones
|
||||
DB::table('languagables')->insert($languages);
|
||||
}
|
||||
|
||||
$bank->save();
|
||||
$this->dispatch('saved');
|
||||
session(['activeProfilePhoto' => $bank->profile_photo_path ]);
|
||||
redirect()->route('profile.edit');
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the bank's profile photo.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function deleteProfilePhoto()
|
||||
{
|
||||
$bank = getActiveProfile();
|
||||
|
||||
// CRITICAL SECURITY: Validate user has ownership/access to this profile
|
||||
\App\Helpers\ProfileAuthorizationHelper::authorize($bank);
|
||||
|
||||
if (! Features::managesProfilePhotos()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (is_null($bank->profile_photo_path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$defaultPath = timebank_config('profiles.bank.profile_photo_path_default');
|
||||
|
||||
// Delete uploaded photos (profile-photos/) and reset to default
|
||||
if (str_starts_with($bank->profile_photo_path, 'profile-photos/')) {
|
||||
Storage::disk(isset($_ENV['VAPOR_ARTIFACT_NAME']) ? 's3' : config('jetstream.profile_photo_disk', 'public'))->delete($bank->profile_photo_path);
|
||||
|
||||
$bank->forceFill([
|
||||
'profile_photo_path' => $defaultPath,
|
||||
])->save();
|
||||
|
||||
Session(['activeProfilePhoto'=> $bank->profile_photo_path ]);
|
||||
}
|
||||
// If current path is app-images but not the correct default, update it
|
||||
elseif (str_starts_with($bank->profile_photo_path, 'app-images/') && $bank->profile_photo_path !== $defaultPath) {
|
||||
$bank->forceFill([
|
||||
'profile_photo_path' => $defaultPath,
|
||||
])->save();
|
||||
|
||||
Session(['activeProfilePhoto'=> $bank->profile_photo_path ]);
|
||||
}
|
||||
|
||||
$this->dispatch('saved');
|
||||
redirect()->route('profile.edit');
|
||||
}
|
||||
|
||||
|
||||
public function addUrlScheme($url, $scheme = 'https://')
|
||||
{
|
||||
return parse_url($url, PHP_URL_SCHEME) === null ?
|
||||
$scheme . $url : $url;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Gets the label for the "about_short" input field, including a character counter if applicable.
|
||||
* The character counter needs to use the `characterLeftCounter` method from the `FormHelpersTrait`.
|
||||
*
|
||||
* @return string The label for the "about" field, optionally including the remaining character count.
|
||||
*/
|
||||
public function getAboutLabelProperty()
|
||||
{
|
||||
$maxInput = timebank_config('rules.profile_bank.about_max_input');
|
||||
$baseLabel = __('Introduce your bank in a few sentences');
|
||||
$counter = $this->characterLeftCounter($this->state['about'] ?? '', $maxInput);
|
||||
|
||||
return $counter ? $baseLabel . ' (' . $counter . ')' : $baseLabel;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Gets the label for the "about_short" input field, including a character counter if applicable.
|
||||
* The character counter needs to use the `characterLeftCounter` method from the `FormHelpersTrait`.
|
||||
*
|
||||
* @return string The label for the "about_short" field, optionally including the remaining character count.
|
||||
*/
|
||||
public function getAboutShortLabelProperty()
|
||||
{
|
||||
$maxInput = timebank_config('rules.profile_bank.about_short_max_input');
|
||||
$baseLabel = __('Introduction in one sentence');
|
||||
$counter = $this->characterLeftCounter($this->state['about_short'] ?? '', $maxInput);
|
||||
|
||||
return $counter ? $baseLabel . ' (' . $counter . ')' : $baseLabel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the label for the "motivation" input field, including a character counter if applicable.
|
||||
* The character counter needs to use the `characterLeftCounter` method from the `FormHelpersTrait`.
|
||||
*
|
||||
* @return string The label for the "about_short" field, optionally including the remaining character count.
|
||||
*/
|
||||
public function getMotivationLabelProperty()
|
||||
{
|
||||
$maxInput = timebank_config('rules.profile_bank.motivation_max_input');
|
||||
$baseLabel = __('What is your motivation to start a ' . platform_name_short() . '?');
|
||||
$counter = $this->characterLeftCounter($this->state['motivation'] ?? '', $maxInput);
|
||||
|
||||
return $counter ? $baseLabel . ' (' . $counter . ')' : $baseLabel;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.profile-bank.update-profile-bank-form');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire\ProfileOrganization;
|
||||
|
||||
use App\Models\Organization;
|
||||
use App\Traits\FormHelpersTrait;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Laravel\Jetstream\Features;
|
||||
use Laravel\Jetstream\HasProfilePhoto;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithFileUploads;
|
||||
|
||||
class UpdateProfileOrganizationForm extends Component
|
||||
{
|
||||
use WithFileUploads;
|
||||
use HasProfilePhoto;
|
||||
use FormHelpersTrait;
|
||||
|
||||
|
||||
public $state = [];
|
||||
public $organization;
|
||||
public $photo;
|
||||
public $languages;
|
||||
public $website;
|
||||
|
||||
// Important
|
||||
// This component is only used by the Organization model, so the user must be an organization manager or have the manage organizations permission.
|
||||
// Authorization is handled in mount() method instead of middleware to support multi-guard system
|
||||
// protected $middleware = [
|
||||
// 'can:manage organizations',
|
||||
// ];
|
||||
|
||||
protected $listeners = ['languagesToParent'];
|
||||
|
||||
|
||||
public function rules()
|
||||
{
|
||||
return [
|
||||
'photo' => timebank_config('rules.profile_organization.profile_photo'),
|
||||
'state.about' => timebank_config('rules.profile_organization.about', 400),
|
||||
'state.about_short' => timebank_config('rules.profile_organization.about_short', 150),
|
||||
'state.motivation' => timebank_config('rules.profile_organization.motivation', 300),
|
||||
'languages' => timebank_config('rules.profile_organization.languages', 'required'),
|
||||
'languages.id' => timebank_config('rules.profile_organization.languages_id', 'int'),
|
||||
'state.date_of_birth' => timebank_config('rules.profile_organization.date_of_birth', 'nullable|date'),
|
||||
'website' => timebank_config('rules.profile_organization.website', 'nullable|regex:/^(https?:\/\/)?([\da-z\.-]+)\.([a-z\.]{2,6})([\/\w \.-]*)*\/?$/i'),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Prepare the component.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function mount()
|
||||
{
|
||||
|
||||
// --- Check roles and permissions --- //
|
||||
$activeProfile = getActiveProfile();
|
||||
|
||||
// Check if active profile is an Organization
|
||||
if (!($activeProfile instanceof \App\Models\Organization)) {
|
||||
abort(403, 'Unauthorized action.');
|
||||
}
|
||||
|
||||
// Check if web user (who owns the organization) has permission or organization manager role
|
||||
// Permissions are assigned to Users (web guard), not to Organizations
|
||||
$webUser = Auth::guard('web')->user();
|
||||
|
||||
// User is authorized if ANY of these conditions are true:
|
||||
// 1. Has global "manage organizations" permission (admin)
|
||||
// 2. Has organization manager role for this specific organization
|
||||
// 3. Is linked to this organization (owner/member)
|
||||
$authorized = ($webUser && (
|
||||
$webUser->can('manage organizations') ||
|
||||
$webUser->hasRole('Organization\\' . $activeProfile->id . '\\organization-manager') ||
|
||||
$webUser->organizations()->where('organization_user.organization_id', $activeProfile->id)->exists()
|
||||
));
|
||||
|
||||
if (!$authorized) {
|
||||
abort(403, 'Unauthorized action.');
|
||||
}
|
||||
|
||||
|
||||
$this->state = Organization::find(session('activeProfileId'))->toArray();
|
||||
$this->website = $this->state['website'];
|
||||
$this->organization = Organization::find(session('activeProfileId'));
|
||||
|
||||
$this->getLanguages();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get the profile photo URL for the organization
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getProfilePhotoUrlProperty()
|
||||
{
|
||||
if (!$this->organization) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Use asset() for app-images, Storage::url() for uploaded photos
|
||||
if (str_starts_with($this->organization->profile_photo_path, 'app-images/')) {
|
||||
return asset('storage/' . $this->organization->profile_photo_path);
|
||||
}
|
||||
|
||||
return url(Storage::url($this->organization->profile_photo_path));
|
||||
}
|
||||
|
||||
|
||||
public function getLanguages()
|
||||
{
|
||||
// Create a language options collection that combines all language and competence options
|
||||
$langOptions = DB::table('languages')->get(['id','name']);
|
||||
$compOptions = DB::table('language_competences')->get(['id','name']);
|
||||
$langOptions = collect(Arr::crossJoin($langOptions, $compOptions));
|
||||
$langOptions = $langOptions->Map(function ($language, $key) {
|
||||
return [
|
||||
'id' => $key, // index key is needed to select values in dropdown (option-value)
|
||||
'langId' => $language[0]->id,
|
||||
'compId' => $language[1]->id,
|
||||
'name' => trans($language[0]->name) . ' - ' . trans($language[1]->name),
|
||||
];
|
||||
});
|
||||
|
||||
// Create an array of the pre-selected language options
|
||||
$languages = $this->organization->languages;
|
||||
$languages = $languages->map(function ($language, $key) use ($langOptions) {
|
||||
$competence = DB::table('language_competences')->find($language->pivot->competence);
|
||||
$langSelected = collect($langOptions)->where('name', trans($language->name) . ' - ' . trans($competence->name));
|
||||
return [
|
||||
$langSelected->keys()
|
||||
];
|
||||
});
|
||||
$languages = $languages->flatten();
|
||||
|
||||
// Create a selected language collection that holds the selected languages with their selected competences
|
||||
$this->languages = collect($langOptions)->whereIn('id', $languages)->values();
|
||||
}
|
||||
|
||||
|
||||
public function languagesToParent($values)
|
||||
{
|
||||
$this->languages = $values;
|
||||
$this->validateOnly('languages');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Validate a single field when updated.
|
||||
* This is the 1st validation method on this form.
|
||||
*
|
||||
* @param mixed $field
|
||||
* @return void
|
||||
*/
|
||||
public function updated($field)
|
||||
{
|
||||
if ($field == 'website') {
|
||||
// If website is not empty, add URL scheme
|
||||
if (!empty($this->website)) {
|
||||
$this->website = $this->addUrlScheme($this->website);
|
||||
} else {
|
||||
// If website is empty, remove 'https://' prefix
|
||||
$this->website = str_replace('https://', '', $this->website);
|
||||
}
|
||||
}
|
||||
|
||||
$this->validateOnly($field);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Update the organization's profile contact information.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function updateProfilePersonalForm()
|
||||
{
|
||||
$org = getActiveProfile();
|
||||
|
||||
// CRITICAL SECURITY: Validate user has ownership/access to this profile
|
||||
\App\Helpers\ProfileAuthorizationHelper::authorize($org);
|
||||
|
||||
if (isset($this->photo)) {
|
||||
$org->updateProfilePhoto($this->photo); // Trait (use HasProfilePhoto) needs to attached to Organization model for this to work
|
||||
}
|
||||
|
||||
$this->validate(); // 2nd validation, just before save method
|
||||
|
||||
$org->about = $this->state['about'];
|
||||
$org->about_short = $this->state['about_short'];
|
||||
$org->motivation = $this->state['motivation'];
|
||||
$org->website = str_replace(['http://', 'https://', ], '', $this->website);
|
||||
|
||||
if (isset($this->languages)) {
|
||||
|
||||
$languages = collect($this->languages)->Map(function ($lang, $key) use ($org) {
|
||||
return [
|
||||
'language_id' => $lang['langId'],
|
||||
'competence' => $lang['compId'],
|
||||
'languagable_type' => Organization::class,
|
||||
'languagable_id' => $org->id,
|
||||
];
|
||||
})->toArray();
|
||||
|
||||
$org->languages()->detach(); // Remove all languages of this organization before inserting the new ones
|
||||
DB::table('languagables')->insert($languages);
|
||||
}
|
||||
|
||||
$org->save();
|
||||
$this->dispatch('saved');
|
||||
session(['activeProfilePhoto' => $org->profile_photo_path ]);
|
||||
redirect()->route('profile.edit');
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete organization's profile photo.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function deleteProfilePhoto()
|
||||
{
|
||||
$org = getActiveProfile();
|
||||
|
||||
// CRITICAL SECURITY: Validate user has ownership/access to this profile
|
||||
\App\Helpers\ProfileAuthorizationHelper::authorize($org);
|
||||
|
||||
if (! Features::managesProfilePhotos()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (is_null($org->profile_photo_path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$defaultPath = timebank_config('profiles.organization.profile_photo_path_default');
|
||||
|
||||
// Delete uploaded photos (profile-photos/) and reset to default
|
||||
if (str_starts_with($org->profile_photo_path, 'profile-photos/')) {
|
||||
Storage::disk(isset($_ENV['VAPOR_ARTIFACT_NAME']) ? 's3' : config('jetstream.profile_photo_disk', 'public'))->delete($org->profile_photo_path);
|
||||
|
||||
$org->forceFill([
|
||||
'profile_photo_path' => $defaultPath,
|
||||
])->save();
|
||||
|
||||
Session(['activeProfilePhoto'=> $org->profile_photo_path ]);
|
||||
}
|
||||
// If current path is app-images but not the correct default, update it
|
||||
elseif (str_starts_with($org->profile_photo_path, 'app-images/') && $org->profile_photo_path !== $defaultPath) {
|
||||
$org->forceFill([
|
||||
'profile_photo_path' => $defaultPath,
|
||||
])->save();
|
||||
|
||||
Session(['activeProfilePhoto'=> $org->profile_photo_path ]);
|
||||
}
|
||||
|
||||
$this->dispatch('saved');
|
||||
return redirect()->route('profile.edit');
|
||||
}
|
||||
|
||||
|
||||
public function addUrlScheme($url, $scheme = 'https://')
|
||||
{
|
||||
return parse_url($url, PHP_URL_SCHEME) === null ?
|
||||
$scheme . $url : $url;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Gets the label for the "about_short" input field, including a character counter if applicable.
|
||||
* The character counter needs to use the `characterLeftCounter` method from the `FormHelpersTrait`.
|
||||
*
|
||||
* @return string The label for the "about" field, optionally including the remaining character count.
|
||||
*/
|
||||
public function getAboutLabelProperty()
|
||||
{
|
||||
$maxInput = timebank_config('rules.profile_organization.about_max_input');
|
||||
$baseLabel = __('Introduce your organization in a few sentences');
|
||||
$counter = $this->characterLeftCounter($this->state['about'] ?? '', $maxInput);
|
||||
|
||||
return $counter ? $baseLabel . ' (' . $counter . ')' : $baseLabel;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Gets the label for the "about_short" input field, including a character counter if applicable.
|
||||
* The character counter needs to use the `characterLeftCounter` method from the `FormHelpersTrait`.
|
||||
*
|
||||
* @return string The label for the "about_short" field, optionally including the remaining character count.
|
||||
*/
|
||||
public function getAboutShortLabelProperty()
|
||||
{
|
||||
$maxInput = timebank_config('rules.profile_organization.about_short_max_input');
|
||||
$baseLabel = __('Introduction in one sentence');
|
||||
$counter = $this->characterLeftCounter($this->state['about_short'] ?? '', $maxInput);
|
||||
|
||||
return $counter ? $baseLabel . ' (' . $counter . ')' : $baseLabel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the label for the "motivation" input field, including a character counter if applicable.
|
||||
* The character counter needs to use the `characterLeftCounter` method from the `FormHelpersTrait`.
|
||||
*
|
||||
* @return string The label for the "about_short" field, optionally including the remaining character count.
|
||||
*/
|
||||
public function getMotivationLabelProperty()
|
||||
{
|
||||
$maxInput = timebank_config('rules.profile_organization.motivation_max_input');
|
||||
$baseLabel = __('Why is your organization using ' . platform_name_short() . '?');
|
||||
$counter = $this->characterLeftCounter($this->state['motivation'] ?? '', $maxInput);
|
||||
|
||||
return $counter ? $baseLabel . ' (' . $counter . ')' : $baseLabel;
|
||||
}
|
||||
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.profile-organization.update-profile-organization-form');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire\ProfileOrganization;
|
||||
|
||||
use Livewire\Component;
|
||||
use WireUi\Traits\WireUiActions;
|
||||
|
||||
class UpdateSocialMediaForm extends Component
|
||||
{
|
||||
use WireUiActions;
|
||||
|
||||
|
||||
public $profile;
|
||||
public $website;
|
||||
|
||||
|
||||
protected $listeners = ['emitSaved'];
|
||||
|
||||
public function rules()
|
||||
{
|
||||
return [
|
||||
'website' => 'regex:/^(https?:\/\/)?([\da-z\.-]+)\.([a-z\.]{2,6})([\/\w \.-]*)*\/?$/',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare the component.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function mount()
|
||||
{
|
||||
|
||||
$this->profile = session('activeProfileType')::find(session('activeProfileId'));
|
||||
$this->website = $this->profile['website'];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Validate a single field when updated.
|
||||
* This is the 1st validation method on this form.
|
||||
*
|
||||
* @param mixed $field
|
||||
* @return void
|
||||
*/
|
||||
public function updated($field)
|
||||
{
|
||||
if ($field = 'website') {
|
||||
$this->website = $this->addUrlScheme($this->website);
|
||||
}
|
||||
$this->validateOnly($field);
|
||||
}
|
||||
|
||||
public function emitSaved()
|
||||
{
|
||||
$this->dispatch('saved');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the organization's profile contact information.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function update()
|
||||
{
|
||||
$this->validate(); // 2nd validation, just before save method
|
||||
|
||||
$this->profile->website = str_replace(['http://', 'https://', ], '', $this->website);
|
||||
|
||||
$this->profile->save();
|
||||
$this->dispatch('saved');
|
||||
}
|
||||
|
||||
|
||||
|
||||
public function addUrlScheme($url, $scheme = 'https://')
|
||||
{
|
||||
return parse_url($url, PHP_URL_SCHEME) === null ?
|
||||
$scheme . $url : $url;
|
||||
}
|
||||
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.profile-organization.update-social-media-form');
|
||||
}
|
||||
}
|
||||
170
app/Http/Livewire/ProfileReactionStatusBadge.php
Normal file
170
app/Http/Livewire/ProfileReactionStatusBadge.php
Normal file
@@ -0,0 +1,170 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire;
|
||||
|
||||
use App\Services\PresenceService;
|
||||
use Livewire\Component;
|
||||
|
||||
class ProfileReactionStatusBadge extends Component
|
||||
{
|
||||
public $profile; // The user/organization/bank model
|
||||
public $guard = 'web';
|
||||
public $showOnlineStatus = true;
|
||||
public $showReactions = true;
|
||||
public $compactMode = false;
|
||||
public $size = 'md'; // sm, md, lg
|
||||
|
||||
// Reaction data
|
||||
public $reactions = [];
|
||||
public $isOnline = false;
|
||||
public $lastSeen = null;
|
||||
|
||||
public function mount($profile, $guard = null, $showOnlineStatus = true, $showReactions = true, $compactMode = false, $size = 'md')
|
||||
{
|
||||
$this->profile = $profile;
|
||||
$this->guard = $guard ?: $this->detectGuard();
|
||||
$this->showOnlineStatus = $showOnlineStatus;
|
||||
$this->showReactions = $showReactions;
|
||||
$this->compactMode = $compactMode;
|
||||
$this->size = $size;
|
||||
|
||||
$this->loadStatus();
|
||||
}
|
||||
|
||||
protected function detectGuard()
|
||||
{
|
||||
// Detect guard based on model type
|
||||
$modelClass = get_class($this->profile);
|
||||
|
||||
$guardMap = [
|
||||
'App\Models\User' => 'web',
|
||||
'App\Models\Organization' => 'organization',
|
||||
'App\Models\Bank' => 'bank',
|
||||
'App\Models\Admin' => 'admin',
|
||||
];
|
||||
|
||||
return $guardMap[$modelClass] ?? 'web';
|
||||
}
|
||||
|
||||
public function loadStatus()
|
||||
{
|
||||
// Load online status
|
||||
if ($this->showOnlineStatus) {
|
||||
$presenceService = app(PresenceService::class);
|
||||
$this->isOnline = $presenceService->isUserOnline($this->profile, $this->guard);
|
||||
$this->lastSeen = $presenceService->getUserLastSeen($this->profile, $this->guard);
|
||||
}
|
||||
|
||||
// Load reactions from authenticated user
|
||||
if ($this->showReactions) {
|
||||
$this->loadReactions();
|
||||
}
|
||||
}
|
||||
|
||||
protected function loadReactions()
|
||||
{
|
||||
$this->reactions = [];
|
||||
|
||||
// Get authenticated user from any guard
|
||||
$authUser = null;
|
||||
foreach (['web', 'organization', 'bank', 'admin'] as $guard) {
|
||||
if (auth($guard)->check()) {
|
||||
$authUser = auth($guard)->user();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$authUser) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if both models have reaction capabilities
|
||||
if (!method_exists($authUser, 'viaLoveReacter') || !method_exists($this->profile, 'viaLoveReactant')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$reactantFacade = $this->profile->viaLoveReactant();
|
||||
$reacterUser = $authUser;
|
||||
|
||||
// Check common reaction types
|
||||
$reactionTypes = ['Like', 'Star', 'Bookmark'];
|
||||
|
||||
foreach ($reactionTypes as $type) {
|
||||
if ($reactantFacade->isReactedBy($reacterUser, $type)) {
|
||||
$this->reactions[] = [
|
||||
'type' => $type,
|
||||
'icon' => $this->getReactionIcon($type),
|
||||
'color' => $this->getReactionColor($type),
|
||||
];
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
\Log::error('Error loading reactions for badge: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
protected function getReactionIcon($type)
|
||||
{
|
||||
$icons = [
|
||||
'Like' => '❤️',
|
||||
'Star' => '⭐',
|
||||
'Bookmark' => '🔖',
|
||||
];
|
||||
|
||||
return $icons[$type] ?? '👍';
|
||||
}
|
||||
|
||||
protected function getReactionColor($type)
|
||||
{
|
||||
$colors = [
|
||||
'Like' => 'text-red-500',
|
||||
'Star' => 'text-yellow-500',
|
||||
'Bookmark' => 'text-blue-500',
|
||||
];
|
||||
|
||||
return $colors[$type] ?? 'text-gray-500';
|
||||
}
|
||||
|
||||
public function toggleReaction($reactionType)
|
||||
{
|
||||
// Get authenticated user
|
||||
$authUser = null;
|
||||
foreach (['web', 'organization', 'bank', 'admin'] as $guard) {
|
||||
if (auth($guard)->check()) {
|
||||
$authUser = auth($guard)->user();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$authUser) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$reacterFacade = $authUser->viaLoveReacter();
|
||||
$reactantFacade = $this->profile->viaLoveReactant();
|
||||
|
||||
if ($reactantFacade->isReactedBy($authUser, $reactionType)) {
|
||||
$reacterFacade->unreactTo($this->profile, $reactionType);
|
||||
} else {
|
||||
$reacterFacade->reactTo($this->profile, $reactionType);
|
||||
}
|
||||
|
||||
$this->loadReactions();
|
||||
|
||||
// Dispatch event for other components
|
||||
$this->dispatch('reaction-toggled', [
|
||||
'profile_id' => $this->profile->id,
|
||||
'reaction_type' => $reactionType
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
\Log::error('Error toggling reaction: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.profile-reaction-status-badge');
|
||||
}
|
||||
}
|
||||
93
app/Http/Livewire/ProfileStatusBadge.php
Normal file
93
app/Http/Livewire/ProfileStatusBadge.php
Normal file
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire;
|
||||
|
||||
use App\Services\PresenceService;
|
||||
use Livewire\Component;
|
||||
|
||||
class ProfileStatusBadge extends Component
|
||||
{
|
||||
public $profileId;
|
||||
public $profileType;
|
||||
public $guard = 'web';
|
||||
public $status = 'offline';
|
||||
public $lastSeen;
|
||||
public $showText = true;
|
||||
public $showIcon = true;
|
||||
public $size = 'sm';
|
||||
|
||||
public function mount($profileId = null, $guard = 'web', $showText = true, $showIcon = true, $size = 'sm')
|
||||
{
|
||||
$this->profileId = $profileId ?? auth($guard)->id();
|
||||
$this->guard = strtolower($guard);
|
||||
$this->showText = $showText;
|
||||
$this->showIcon = $showIcon;
|
||||
$this->size = $size;
|
||||
|
||||
$this->checkStatus();
|
||||
}
|
||||
|
||||
public function checkStatus()
|
||||
{
|
||||
try {
|
||||
if (!$this->profileId) {
|
||||
$this->status = 'offline';
|
||||
return;
|
||||
}
|
||||
|
||||
$profileModel = $this->getUserModel();
|
||||
|
||||
if (!$profileModel) {
|
||||
$this->status = 'offline';
|
||||
return;
|
||||
}
|
||||
|
||||
// Use PresenceService to determine online status
|
||||
// This relies on recent activity tracking, not just authentication status
|
||||
$presenceService = app(PresenceService::class);
|
||||
|
||||
if ($presenceService->isUserOnline($profileModel, $this->guard)) {
|
||||
$lastSeen = $presenceService->getUserLastSeen($profileModel, $this->guard);
|
||||
|
||||
if ($lastSeen) {
|
||||
$this->lastSeen = $lastSeen;
|
||||
$minutesAgo = $lastSeen->diffInMinutes(now());
|
||||
|
||||
if ($minutesAgo <= 2) {
|
||||
$this->status = 'online';
|
||||
} elseif ($minutesAgo <= 5) {
|
||||
$this->status = 'idle';
|
||||
} else {
|
||||
$this->status = 'offline';
|
||||
}
|
||||
} else {
|
||||
$this->status = 'online';
|
||||
}
|
||||
} else {
|
||||
$this->status = 'offline';
|
||||
$this->lastSeen = $presenceService->getUserLastSeen($profileModel, $this->guard);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$this->status = 'offline';
|
||||
}
|
||||
}
|
||||
|
||||
protected function getUserModel()
|
||||
{
|
||||
switch ($this->guard) {
|
||||
case 'admin':
|
||||
return \App\Models\Admin::find($this->profileId);
|
||||
case 'bank':
|
||||
return \App\Models\Bank::find($this->profileId);
|
||||
case 'organization':
|
||||
return \App\Models\Organization::find($this->profileId);
|
||||
default:
|
||||
return \App\Models\User::find($this->profileId);
|
||||
}
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.profile-status-badge');
|
||||
}
|
||||
}
|
||||
283
app/Http/Livewire/ProfileUser/UpdateProfilePersonalForm.php
Normal file
283
app/Http/Livewire/ProfileUser/UpdateProfilePersonalForm.php
Normal file
@@ -0,0 +1,283 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire\ProfileUser;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Traits\FormHelpersTrait;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Laravel\Jetstream\Features;
|
||||
use Laravel\Jetstream\HasProfilePhoto;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithFileUploads;
|
||||
|
||||
class UpdateProfilePersonalForm extends Component
|
||||
{
|
||||
use WithFileUploads;
|
||||
use HasProfilePhoto;
|
||||
use FormHelpersTrait;
|
||||
|
||||
public $state = [];
|
||||
public $user;
|
||||
public $photo;
|
||||
public $languages;
|
||||
public $website;
|
||||
|
||||
protected $listeners = ['languagesToParent'];
|
||||
|
||||
public function rules()
|
||||
{
|
||||
return [
|
||||
'photo' => timebank_config('rules.profile_user.profile_photo'),
|
||||
'state.about' => timebank_config('rules.profile_user.about', 400),
|
||||
'state.about_short' => timebank_config('rules.profile_user.about_short', 150),
|
||||
'state.motivation' => timebank_config('rules.profile_user.motivation', 300),
|
||||
'languages' => timebank_config('rules.profile_user.languages', 'required'),
|
||||
'languages.id' => timebank_config('rules.profile_user.languages_id', 'int'),
|
||||
'state.date_of_birth' => timebank_config('rules.profile_user.date_of_birth', 'nullable|date'),
|
||||
'website' => timebank_config('rules.profile_user.website', 'nullable|regex:/^(https?:\/\/)?([\da-z\.-]+)\.([a-z\.]{2,6})([\/\w \.-]*)*\/?$/i'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare the component.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function mount()
|
||||
{
|
||||
// --- Check roles and permissions --- //
|
||||
$activeProfile = getActiveProfile();
|
||||
|
||||
// Check if active profile is a User
|
||||
if (!($activeProfile instanceof \App\Models\User)) {
|
||||
abort(403, 'Unauthorized action.');
|
||||
}
|
||||
|
||||
// Check if web user has permission or if it's the user's own profile
|
||||
$webUser = Auth::guard('web')->user();
|
||||
$authorized = ($webUser && (
|
||||
$webUser->can('manage users') ||
|
||||
$webUser->id === $activeProfile->id
|
||||
));
|
||||
|
||||
if (!$authorized) {
|
||||
abort(403, 'Unauthorized action.');
|
||||
}
|
||||
|
||||
$this->state = Auth::user()->withoutRelations()->toArray();
|
||||
$this->website = strtolower($this->state['website']);
|
||||
$this->user = Auth::guard('web')->user();
|
||||
|
||||
$this->getLanguages();
|
||||
}
|
||||
|
||||
|
||||
public function getLanguages()
|
||||
{
|
||||
// Create a language options collection that combines all language and competence options
|
||||
$langOptions = DB::table('languages')->get(['id','name']);
|
||||
$compOptions = DB::table('language_competences')->get(['id','name']);
|
||||
$langOptions = collect(Arr::crossJoin($langOptions, $compOptions));
|
||||
$langOptions = $langOptions->Map(function ($language, $key) {
|
||||
return [
|
||||
'id' => $key, // index key is needed to select values in dropdown (option-value)
|
||||
'langId' => $language[0]->id,
|
||||
'compId' => $language[1]->id,
|
||||
'name' => trans($language[0]->name) . ' - ' . trans($language[1]->name),
|
||||
];
|
||||
});
|
||||
|
||||
// Create an array of the pre-selected language options
|
||||
$languages = $this->user->languages;
|
||||
$languages = $languages->map(function ($language, $key) use ($langOptions) {
|
||||
$competence = DB::table('language_competences')->find($language->pivot->competence);
|
||||
$langSelected = collect($langOptions)->where('name', trans($language->name) . ' - ' . trans($competence->name));
|
||||
return [
|
||||
$langSelected->keys()
|
||||
];
|
||||
});
|
||||
$languages = $languages->flatten();
|
||||
|
||||
// Create a selected language collection that holds the selected languages with their selected competences
|
||||
$this->languages = collect($langOptions)->whereIn('id', $languages)->values();
|
||||
}
|
||||
|
||||
|
||||
public function languagesToParent($values)
|
||||
{
|
||||
$this->languages = $values;
|
||||
$this->validateOnly('languages');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a single field when updated.
|
||||
* This is the 1st validation method on this form.
|
||||
*
|
||||
* @param mixed $field
|
||||
* @return void
|
||||
*/
|
||||
public function updated($field)
|
||||
{
|
||||
if ($field == 'website') {
|
||||
// If website is not empty, add URL scheme
|
||||
if (!empty($this->website)) {
|
||||
$this->website = $this->addUrlScheme($this->website);
|
||||
} else {
|
||||
// If website is empty, remove 'https://' prefix
|
||||
$this->website = str_replace('https://', '', $this->website);
|
||||
}
|
||||
strtolower($this->website);
|
||||
}
|
||||
|
||||
$this->validateOnly($field);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the user's profile contact information.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function updateProfilePersonalForm()
|
||||
{
|
||||
$user = Auth::guard('web')->user();
|
||||
|
||||
if (isset($this->photo)) {
|
||||
$user->updateProfilePhoto($this->photo);
|
||||
}
|
||||
|
||||
$this->validate(); // 2nd validation, just before save method
|
||||
|
||||
$user->about = $this->state['about'];
|
||||
$user->about_short = $this->state['about_short'];
|
||||
$user->motivation = $this->state['motivation'];
|
||||
$user->date_of_birth = $this->state['date_of_birth'];
|
||||
$user->website = $this->website;
|
||||
|
||||
if (isset($this->languages)) {
|
||||
|
||||
$languages = collect($this->languages)->Map(function ($lang, $key) use ($user) {
|
||||
return [
|
||||
'language_id' => $lang['langId'],
|
||||
'competence' => $lang['compId'],
|
||||
'languagable_type' => User::class,
|
||||
'languagable_id' => $user->id,
|
||||
];
|
||||
})->toArray();
|
||||
|
||||
$user->languages()->detach(); // Remove all languages of this user before inserting the new ones
|
||||
DB::table('languagables')->insert($languages);
|
||||
}
|
||||
|
||||
|
||||
|
||||
$user->save();
|
||||
$this->dispatch('saved');
|
||||
Session(['activeProfilePhoto' => $user->profile_photo_path ]);
|
||||
redirect()->route('profile.edit');
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete user's profile photo.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function deleteProfilePhoto()
|
||||
{
|
||||
$user = Auth::guard('web')->user();
|
||||
|
||||
if (! Features::managesProfilePhotos()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (is_null($user->profile_photo_path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$defaultPath = timebank_config('profiles.user.profile_photo_path_default');
|
||||
|
||||
// Delete uploaded photos (profile-photos/) and reset to default
|
||||
if (str_starts_with($user->profile_photo_path, 'profile-photos/')) {
|
||||
Storage::disk(isset($_ENV['VAPOR_ARTIFACT_NAME']) ? 's3' : config('jetstream.profile_photo_disk', 'public'))->delete($user->profile_photo_path);
|
||||
|
||||
$user->forceFill([
|
||||
'profile_photo_path' => $defaultPath,
|
||||
])->save();
|
||||
|
||||
Session(['activeProfilePhoto' => $user->profile_photo_path ]);
|
||||
}
|
||||
// If current path is app-images but not the correct default, update it
|
||||
elseif (str_starts_with($user->profile_photo_path, 'app-images/') && $user->profile_photo_path !== $defaultPath) {
|
||||
$user->forceFill([
|
||||
'profile_photo_path' => $defaultPath,
|
||||
])->save();
|
||||
|
||||
Session(['activeProfilePhoto' => $user->profile_photo_path ]);
|
||||
}
|
||||
|
||||
$this->dispatch('saved');
|
||||
Session(['activeProfilePhoto' => $user->profile_photo_path ]);
|
||||
redirect()->route('profile.edit'); // Reloads the navigation bar with the new profile photo
|
||||
}
|
||||
|
||||
|
||||
public function addUrlScheme($url, $scheme = 'https://')
|
||||
{
|
||||
return parse_url($url, PHP_URL_SCHEME) === null ?
|
||||
$scheme . $url : $url;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Gets the label for the "about_short" input field, including a character counter if applicable.
|
||||
* The character counter needs to use the `characterLeftCounter` method from the `FormHelpersTrait`.
|
||||
*
|
||||
* @return string The label for the "about" field, optionally including the remaining character count.
|
||||
*/
|
||||
public function getAboutLabelProperty()
|
||||
{
|
||||
$maxInput = timebank_config('rules.profile_user.about_max_input');
|
||||
$baseLabel = __('Please introduce yourself in a few sentences ');
|
||||
$counter = $this->characterLeftCounter($this->state['about'] ?? '', $maxInput);
|
||||
|
||||
return $counter ? $baseLabel . ' (' . $counter . ')' : $baseLabel;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Gets the label for the "about_short" input field, including a character counter if applicable.
|
||||
* The character counter needs to use the `characterLeftCounter` method from the `FormHelpersTrait`.
|
||||
*
|
||||
* @return string The label for the "about_short" field, optionally including the remaining character count.
|
||||
*/
|
||||
public function getAboutShortLabelProperty()
|
||||
{
|
||||
$maxInput = timebank_config('rules.profile_user.about_short_max_input');
|
||||
$baseLabel = __('Introduction in one sentence');
|
||||
$counter = $this->characterLeftCounter($this->state['about_short'] ?? '', $maxInput);
|
||||
|
||||
return $counter ? $baseLabel . ' (' . $counter . ')' : $baseLabel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the label for the "motivation" input field, including a character counter if applicable.
|
||||
* The character counter needs to use the `characterLeftCounter` method from the `FormHelpersTrait`.
|
||||
*
|
||||
* @return string The label for the "about_short" field, optionally including the remaining character count.
|
||||
*/
|
||||
public function getMotivationLabelProperty()
|
||||
{
|
||||
$maxInput = timebank_config('rules.profile_user.motivation_max_input');
|
||||
$baseLabel = trans_with_platform('Why are you a @PLATFORM_USER@?');
|
||||
$counter = $this->characterLeftCounter($this->state['motivation'] ?? '', $maxInput);
|
||||
|
||||
return $counter ? $baseLabel . ' (' . $counter . ')' : $baseLabel;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.profile-user.update-profile-personal-form');
|
||||
}
|
||||
}
|
||||
825
app/Http/Livewire/Profiles/Create.php
Normal file
825
app/Http/Livewire/Profiles/Create.php
Normal file
@@ -0,0 +1,825 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire\Profiles;
|
||||
|
||||
use App\Events\Auth\RegisteredByAdmin;
|
||||
use App\Http\Livewire\Traits\RequiresAdminAuthorization;
|
||||
use App\Models\Account;
|
||||
use App\Models\Admin;
|
||||
use App\Models\Bank;
|
||||
use App\Models\Language;
|
||||
use App\Models\Locations\Country;
|
||||
use App\Models\Locations\Location;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Livewire\Component;
|
||||
use WireUi\Traits\WireUiActions;
|
||||
|
||||
class Create extends Component
|
||||
{
|
||||
use WireUiActions, RequiresAdminAuthorization;
|
||||
|
||||
public bool $showCreateModal = false;
|
||||
// #[Validate] // Add this attribute
|
||||
public $createProfile = [
|
||||
'name' => null,
|
||||
'full_name' => null,
|
||||
'email' => null,
|
||||
'password' => null,
|
||||
'password_confirmation' => null,
|
||||
'lang_preference' => null,
|
||||
'type' => null,
|
||||
];
|
||||
public $profileTypeOptions = [];
|
||||
public $profileTypeSelected;
|
||||
public $linkBankOptions = [];
|
||||
public $linkBankSelected;
|
||||
public $linkUserOptions = [];
|
||||
public $linkUserSelected;
|
||||
|
||||
public bool $generateRandomPassword = true;
|
||||
private ?string $generatedPlainTextPassword = null; // Add property to store plain password
|
||||
|
||||
|
||||
public $country;
|
||||
public $division;
|
||||
public $city;
|
||||
public $district;
|
||||
// Keep track of whether validation is needed
|
||||
public $validateCountry = false;
|
||||
public $validateDivision = false;
|
||||
public $validateCity = false;
|
||||
|
||||
public $localeOptions;
|
||||
|
||||
protected $listeners = ['countryToParent', 'divisionToParent', 'cityToParent', 'districtToParent'];
|
||||
|
||||
public function mount()
|
||||
{
|
||||
// CRITICAL: Authorize admin access for profile creation component
|
||||
$this->authorizeAdminAccess();
|
||||
}
|
||||
|
||||
protected function rules()
|
||||
{
|
||||
$rules = [
|
||||
'createProfile' => 'array',
|
||||
'createProfile.type' => ['required', 'string'],
|
||||
'country' => 'required_if:validateCountry,true',
|
||||
'division' => 'required_if:validateDivision,true',
|
||||
'city' => 'required_if:validateCity,true',
|
||||
'district' => 'sometimes',
|
||||
];
|
||||
|
||||
// Dynamically add rules based on type
|
||||
if (!empty($this->createProfile['type'])) {
|
||||
$typeKey = 'profile_' . strtolower(class_basename($this->createProfile['type']));
|
||||
$isUserType = $this->createProfile['type'] === \App\Models\User::class;
|
||||
$isOrgType = $this->createProfile['type'] === \App\Models\Organization::class;
|
||||
$isAdminType = $this->createProfile['type'] === \App\Models\Admin::class;
|
||||
$isBankType = $this->createProfile['type'] === \App\Models\Bank::class;
|
||||
|
||||
// --- Add rules for common fields like name, full_name, email ---
|
||||
$rules['createProfile.name'] = Rule::when(
|
||||
fn ($input) => isset($input['createProfile']['name']),
|
||||
timebank_config("rules.{$typeKey}.name", []),
|
||||
[]
|
||||
);
|
||||
$rules['createProfile.full_name'] = Rule::when(
|
||||
fn ($input) => isset($input['createProfile']['full_name']),
|
||||
timebank_config("rules.{$typeKey}.full_name", []),
|
||||
[]
|
||||
);
|
||||
$rules['createProfile.email'] = Rule::when(
|
||||
fn ($input) => isset($input['createProfile']['email']),
|
||||
timebank_config("rules.{$typeKey}.email", []),
|
||||
[]
|
||||
);
|
||||
|
||||
// --- Conditional Password Rules (Only for User type) ---
|
||||
|
||||
//TODO NEXT: fix manual password confirmation
|
||||
|
||||
if ($isUserType) {
|
||||
$rules['createProfile.password'] = Rule::when(
|
||||
!$this->generateRandomPassword,
|
||||
// Explicitly add 'confirmed' here for the final validation
|
||||
// Merge with rules from config, ensuring 'confirmed' is present
|
||||
timebank_config("rules.{$typeKey}.password" // Get base rules
|
||||
),
|
||||
['nullable', 'string'] // Rules when generating random password
|
||||
);
|
||||
$rules['createProfile.lang_preference'] = ['string', 'max:3'];
|
||||
} else {
|
||||
$rules['createProfile.password'] = ['nullable', 'string'];
|
||||
$rules['createProfile.password_confirmation'] = ['nullable', 'string'];
|
||||
}
|
||||
|
||||
|
||||
// --- Conditional Link Rules ---
|
||||
// Link Bank is required for User and Organization
|
||||
if ($isUserType || $isOrgType) {
|
||||
$rules['createProfile.linkBank'] = ['required', 'integer'];
|
||||
} else {
|
||||
// Ensure it's not required if not rendered
|
||||
$rules['createProfile.linkBank'] = ['nullable', 'integer'];
|
||||
}
|
||||
|
||||
// Link User is required for Organization, Admin, and Bank
|
||||
if ($isOrgType || $isAdminType || $isBankType) {
|
||||
$rules['createProfile.linkUser'] = ['required', 'integer'];
|
||||
} else {
|
||||
// Ensure it's not required if not rendered
|
||||
$rules['createProfile.linkUser'] = ['nullable', 'integer'];
|
||||
}
|
||||
} else {
|
||||
// Default rules if type is not yet selected (optional, but good practice)
|
||||
$rules['createProfile.linkBank'] = ['nullable', 'integer'];
|
||||
$rules['createProfile.linkUser'] = ['nullable', 'integer'];
|
||||
}
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
|
||||
public function openCreateModal()
|
||||
{
|
||||
$this->resetErrorBag();
|
||||
$this->showCreateModal = true;
|
||||
|
||||
$appLocale = app()->getLocale();
|
||||
|
||||
// Get all optional profiles from config
|
||||
$this->profileTypeOptions = collect(timebank_config('profiles'))
|
||||
->map(function ($data, $key) {
|
||||
return [
|
||||
'name' => ucfirst($key),
|
||||
'value' => 'App\Models\\'. ucfirst($key),
|
||||
];
|
||||
})
|
||||
->values()
|
||||
->toArray();
|
||||
|
||||
$this->generateRandomPassword = true; // Ensure it's checked on open
|
||||
$this->generateAndSetPassword(); // Generate initial password
|
||||
|
||||
$this->localeOptions = Language::all()->filter(function ($lang) {
|
||||
return ($lang->lang_code);
|
||||
})->map(function ($lang) {
|
||||
return [
|
||||
'lang_code' => $lang->lang_code,
|
||||
'label' => $lang->flag . ' ' . trans('messages.' . $lang->name),
|
||||
];
|
||||
})->toArray();
|
||||
|
||||
|
||||
$this->resetErrorBag(['country', 'division', 'city', 'district']); // Clear location errors
|
||||
}
|
||||
|
||||
|
||||
public function updatedCreateProfileType()
|
||||
{
|
||||
$selectedType = $this->createProfile['type'] ?? null;
|
||||
$optionsCollection = collect(); // Initialize empty collection
|
||||
|
||||
if ($selectedType === \App\Models\User::class || $selectedType === \App\Models\Organization::class) {
|
||||
// Banks higher than level 1 are non-system banks
|
||||
$optionsBankCollection = Bank::where('level', '>=', 2)->get(['id', 'name', 'full_name', 'email', 'profile_photo_path']);
|
||||
// Make email visible if it's hidden and needed for description
|
||||
$optionsBankCollection->each(fn ($item) => $item->makeVisible('email'));
|
||||
// Same procedure for User model
|
||||
$optionsUserCollection = User::get(['id', 'name', 'full_name', 'email', 'profile_photo_path']);
|
||||
$optionsUserCollection->each(fn ($item) => $item->makeVisible('email'));
|
||||
|
||||
|
||||
// Map to a plain array structure, needed for the wireUi user-option template
|
||||
$this->linkBankOptions = $optionsBankCollection->map(function ($item) {
|
||||
return [
|
||||
'id' => $item->id,
|
||||
'name' => $item->name,
|
||||
'email' => $item->email,
|
||||
'profile_photo_url' => $item->profile_photo_url,
|
||||
];
|
||||
})->toArray();
|
||||
$this->linkUserOptions = $optionsUserCollection->map(function ($item) {
|
||||
return [
|
||||
'id' => $item->id,
|
||||
'name' => $item->name,
|
||||
'email' => $item->email,
|
||||
'profile_photo_url' => $item->profile_photo_url,
|
||||
];
|
||||
})->toArray();
|
||||
|
||||
|
||||
} elseif ($selectedType) {
|
||||
$optionsUserCollection = User::get(['id', 'name', 'full_name', 'email', 'profile_photo_path']);
|
||||
$optionsUserCollection->each(fn ($item) => $item->makeVisible('email'));
|
||||
|
||||
$this->linkUserOptions = $optionsUserCollection->map(function ($item) {
|
||||
return [
|
||||
'id' => $item->id,
|
||||
'name' => $item->name,
|
||||
'email' => $item->email,
|
||||
'profile_photo_url' => $item->profile_photo_url,
|
||||
];
|
||||
})->toArray();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public function updated($propertyName)
|
||||
{
|
||||
// Only validate createProfile.* fields when they themselves change
|
||||
if (str_starts_with($propertyName, 'createProfile.')
|
||||
&& $propertyName !== 'createProfile.type') {
|
||||
$this->validateOnly($propertyName);
|
||||
}
|
||||
|
||||
// If the 'type' field specifically was updated, handle that separately
|
||||
if ($propertyName === 'createProfile.type') {
|
||||
$this->resetErrorBag(['createProfile.name', 'createProfile.full_name']);
|
||||
$this->validateOnly('createProfile.name');
|
||||
$this->validateOnly('createProfile.full_name');
|
||||
$this->updatedCreateProfileType();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Method called when checkbox state changes
|
||||
public function updatedGenerateRandomPassword(bool $value)
|
||||
{
|
||||
if ($value) {
|
||||
// Checkbox is CHECKED - Generate password
|
||||
$this->generateAndSetPassword();
|
||||
} else {
|
||||
// Checkbox is UNCHECKED - Clear password fields for manual input
|
||||
$this->createProfile['password'] = null;
|
||||
$this->createProfile['password_confirmation'] = null;
|
||||
// Reset validation errors for password fields
|
||||
$this->resetErrorBag(['createProfile.password', 'createProfile.password_confirmation']);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Helper function to generate and set password
|
||||
private function generateAndSetPassword()
|
||||
{
|
||||
$password = Str::password(12, true, true, true, false);
|
||||
$this->generatedPlainTextPassword = $password; // Store plain text
|
||||
$this->createProfile['password'] = $password; // Set for validation/hashing
|
||||
$this->createProfile['passwordConfirmation'] = null;
|
||||
$this->resetErrorBag(['createProfile.password', 'createProfile.password_confirmation']);
|
||||
}
|
||||
|
||||
|
||||
public function emitLocationToChildren()
|
||||
{
|
||||
$this->dispatch('countryToChildren', $this->country);
|
||||
$this->dispatch('divisionToChildren', $this->division);
|
||||
$this->dispatch('cityToChildren', $this->city);
|
||||
$this->dispatch('districtToChildren', $this->district);
|
||||
}
|
||||
|
||||
// --- Listener methods ---
|
||||
// When a location value changes (from child), update the property,
|
||||
// recalculate validation requirements, and trigger validation for that specific field.
|
||||
public function countryToParent($value)
|
||||
{
|
||||
$this->country = $value;
|
||||
|
||||
if ($value) {
|
||||
// Look up language preference by country, if available
|
||||
$countryLanguage = DB::table('country_languages')->where('country_id', $this->country)->pluck('code');
|
||||
count($countryLanguage) === 1 ? $this->createProfile['lang_preference'] = $countryLanguage->first() : $this->createProfile['lang_preference'] = null;
|
||||
}
|
||||
|
||||
$this->setLocationValidationOptions();
|
||||
$this->validateOnly('country'); // Validate country immediately
|
||||
// Also re-validate division/city as their requirement might change
|
||||
$this->validateOnly('division');
|
||||
$this->validateOnly('city');
|
||||
}
|
||||
|
||||
public function divisionToParent($value)
|
||||
{
|
||||
$this->division = $value;
|
||||
$this->setLocationValidationOptions(); // Recalculate requirements
|
||||
$this->validateOnly('division'); // Validate division immediately
|
||||
}
|
||||
|
||||
public function cityToParent($value)
|
||||
{
|
||||
$this->city = $value;
|
||||
$this->setLocationValidationOptions(); // Recalculate requirements
|
||||
$this->validateOnly('city'); // Validate city immediately
|
||||
}
|
||||
|
||||
// District doesn't usually affect others, just validate itself
|
||||
public function districtToParent($value)
|
||||
{
|
||||
$this->district = $value;
|
||||
$this->validateOnly('district');
|
||||
}
|
||||
// --- End Listener methods ---
|
||||
|
||||
|
||||
public function setLocationValidationOptions()
|
||||
{
|
||||
// Store previous state to check if requirements changed
|
||||
$oldValidateDivision = $this->validateDivision;
|
||||
$oldValidateCity = $this->validateCity;
|
||||
|
||||
// Default to true, then adjust based on country data
|
||||
$this->validateCountry = true; // Country is always potentially required initially
|
||||
$this->validateDivision = true;
|
||||
$this->validateCity = true;
|
||||
|
||||
if ($this->country) {
|
||||
$countryModel = Country::find($this->country);
|
||||
if ($countryModel) {
|
||||
$countDivisions = $countryModel->divisions()->count();
|
||||
$countCities = $countryModel->cities()->count();
|
||||
|
||||
// Logic based on available sub-locations for the selected country
|
||||
if ($countDivisions > 0 && $countCities < 1) {
|
||||
$this->validateDivision = true;
|
||||
$this->validateCity = false; // City not needed if none exist for country
|
||||
} elseif ($countDivisions < 1 && $countCities > 0) {
|
||||
$this->validateDivision = false; // Division not needed if none exist
|
||||
$this->validateCity = true;
|
||||
} elseif ($countDivisions < 1 && $countCities < 1) {
|
||||
$this->validateDivision = false; // Neither needed if none exist
|
||||
$this->validateCity = false;
|
||||
} elseif ($countDivisions > 0 && $countCities > 0) {
|
||||
// Prefer City over Division if both exist
|
||||
$this->validateDivision = false; // Assuming city is the primary choice here
|
||||
$this->validateCity = true;
|
||||
|
||||
}
|
||||
} else {
|
||||
// Invalid country selected, potentially keep validation? Or reset?
|
||||
// For now, keep defaults (true) as the country rule itself will fail.
|
||||
}
|
||||
} else {
|
||||
// No country selected, only country is required.
|
||||
$this->validateCountry = true;
|
||||
$this->validateDivision = false;
|
||||
$this->validateCity = false;
|
||||
}
|
||||
|
||||
// --- Re-validate if requirements changed ---
|
||||
// If the requirement for division/city changed, re-trigger their validation
|
||||
// This helps clear errors if they become non-required.
|
||||
if ($this->validateDivision !== $oldValidateDivision) {
|
||||
$this->validateOnly('division');
|
||||
}
|
||||
if ($this->validateCity !== $oldValidateCity) {
|
||||
$this->validateOnly('city');
|
||||
}
|
||||
// --- End Re-validation ---
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Handles the save button of the create profile modal.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function create()
|
||||
{
|
||||
// CRITICAL: Authorize admin access for creating profiles
|
||||
$this->authorizeAdminAccess();
|
||||
|
||||
// --- If a user, bank, admin profile will be created, determine the plain password that will be emailed ---
|
||||
if ($this->createProfile['type'] === \App\Models\User::class
|
||||
|| $this->createProfile['type'] === \App\Models\Bank::class
|
||||
|| $this->createProfile['type'] === \App\Models\Admin::class ) {
|
||||
if ($this->generateRandomPassword) {
|
||||
$this->generateAndSetPassword();
|
||||
} elseif (!empty($this->createProfile['password'])) {
|
||||
// Capture manually entered password (after trimming)
|
||||
$this->generatedPlainTextPassword = trim($this->createProfile['password']);
|
||||
} else {
|
||||
// Manual mode, but password field is empty
|
||||
$this->generatedPlainTextPassword = null;
|
||||
}
|
||||
} else {
|
||||
// Not a profile type with password, ensure plain password is null
|
||||
$this->generatedPlainTextPassword = null;
|
||||
// Also nullify password fields before validation if not User
|
||||
$this->createProfile['password'] = null;
|
||||
$this->createProfile['passwordConfirmation'] = null;
|
||||
}
|
||||
|
||||
|
||||
// Trim password fields if they exist (important for validation)
|
||||
if (isset($this->createProfile['password'])) {
|
||||
$this->createProfile['password'] = trim($this->createProfile['password']);
|
||||
}
|
||||
if (isset($this->createProfile['passwordConfirmation'])) {
|
||||
$this->createProfile['passwordConfirmation'] = trim($this->createProfile['passwordConfirmation']);
|
||||
}
|
||||
|
||||
// Validate all fields based on current rules
|
||||
$validatedData = $this->validate();
|
||||
$profileData = $validatedData['createProfile']; // Get the nested profile data
|
||||
|
||||
// Remove password confirmation if it exists
|
||||
unset($profileData['password_confirmation']);
|
||||
|
||||
// Add location data to the profile data array for helper methods
|
||||
$profileData['country_id'] = $validatedData['country'] ?? null;
|
||||
$profileData['division_id'] = $validatedData['division'] ?? null;
|
||||
$profileData['city_id'] = $validatedData['city'] ?? null;
|
||||
$profileData['district_id'] = $validatedData['district'] ?? null;
|
||||
|
||||
$newProfile = null;
|
||||
|
||||
try {
|
||||
// Use a transaction for creating the new profile and related models
|
||||
DB::transaction(function () use ($profileData, &$newProfile) {
|
||||
|
||||
switch ($profileData['type']) {
|
||||
case \App\Models\User::class:
|
||||
$newProfile = $this->createUserProfile($profileData);
|
||||
break;
|
||||
case \App\Models\Organization::class:
|
||||
$newProfile = $this->createOrganizationProfile($profileData);
|
||||
break;
|
||||
case \App\Models\Bank::class:
|
||||
$newProfile = $this->createBankProfile($profileData);
|
||||
break;
|
||||
case \App\Models\Admin::class:
|
||||
$newProfile = $this->createAdminProfile($profileData);
|
||||
break;
|
||||
default:
|
||||
throw new \Exception("Unknown profile type: " . $profileData['type']);
|
||||
}
|
||||
|
||||
// Common logic after profile creation (if any) can go here
|
||||
// e.g., creating a default location if not handled in helpers
|
||||
if ($newProfile && !$newProfile->locations()->exists()) {
|
||||
$this->createDefaultLocation($newProfile, $profileData);
|
||||
}
|
||||
|
||||
}); // End of transaction
|
||||
|
||||
|
||||
if ($newProfile) {
|
||||
// Dispatch RegisteredByAdmin event to send email confirmation / password / welcome
|
||||
event(new RegisteredByAdmin($newProfile, $this->generatedPlainTextPassword));
|
||||
}
|
||||
|
||||
|
||||
// Success
|
||||
$this->notification()->success(
|
||||
__('Profile Created'),
|
||||
__('The profile has been successfully created.')
|
||||
);
|
||||
|
||||
$this->showCreateModal = false;
|
||||
$this->dispatch('profileCreated');
|
||||
$this->resetForm();
|
||||
|
||||
} catch (Throwable $e) {
|
||||
// --- Failure ---
|
||||
Log::error('Profile creation failed: ' . $e->getMessage(), [
|
||||
'profile_data' => $profileData, // Log data for debugging
|
||||
'exception' => $e
|
||||
]);
|
||||
|
||||
$this->notification()->error(
|
||||
__('Error'),
|
||||
// Provide a generic error message to the user
|
||||
__('Failed to create profile. Please check the details and try again. If the problem persists, contact support.')
|
||||
);
|
||||
// Keep the modal open for correction
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private function createUserProfile(array $data): User
|
||||
{
|
||||
// Hash password
|
||||
$data['password'] = Hash::make($data['password']);
|
||||
|
||||
// Add default values from config
|
||||
$data['profile_photo_path'] = timebank_config('profiles.user.profile_photo_path_new');
|
||||
$data['limit_min'] = timebank_config('profiles.user.limit_min');
|
||||
$data['limit_max'] = timebank_config('profiles.user.limit_max');
|
||||
$data['lang_preference'] = $data['lang_preference'] ?? null;
|
||||
|
||||
|
||||
// Create the User
|
||||
$profile = User::create($data);
|
||||
|
||||
// Attach to Bank
|
||||
if (!empty($data['linkBank'])) {
|
||||
$profile->attachBankClient($data['linkBank']);
|
||||
}
|
||||
|
||||
// Create Account
|
||||
$this->createDefaultAccount($profile, 'user');
|
||||
|
||||
// Create Location
|
||||
$this->createDefaultLocation($profile, $data);
|
||||
|
||||
|
||||
// TODO: Replace commented rtippin messenger logic with wirechat logic
|
||||
// Attach to Messenger
|
||||
// Messenger::getProviderMessenger($profile);
|
||||
|
||||
return $profile;
|
||||
}
|
||||
|
||||
|
||||
|
||||
private function createOrganizationProfile(array $data): Organization
|
||||
{
|
||||
// Add default values from config
|
||||
$data['profile_photo_path'] = timebank_config('profiles.organization.profile_photo_path_new');
|
||||
$data['limit_min'] = timebank_config('profiles.organization.limit_min');
|
||||
$data['limit_max'] = timebank_config('profiles.organization.limit_max');
|
||||
$data['lang_preference'] = $data['lang_preference'] ?? null;
|
||||
|
||||
// Create the profile
|
||||
$profile = Organization::create($data);
|
||||
|
||||
// Attach to Bank
|
||||
if (!empty($data['linkBank'])) {
|
||||
$profile->attachBankClient($data['linkBank']);
|
||||
}
|
||||
|
||||
// Attach to profile manager
|
||||
if (!empty($data['linkUser'])) {
|
||||
$profile->managers()->attach($data['linkUser']);
|
||||
|
||||
// Send email notifications to linked user about the new organization
|
||||
$linkedUser = User::find($data['linkUser']);
|
||||
if ($linkedUser) {
|
||||
$this->sendProfileLinkNotifications($profile, $linkedUser);
|
||||
}
|
||||
}
|
||||
|
||||
// Create Account
|
||||
$this->createDefaultAccount($profile, 'organization');
|
||||
|
||||
// Create Location
|
||||
$this->createDefaultLocation($profile, $data);
|
||||
|
||||
|
||||
// TODO: Replace commented rtippin messenger logic with wirechat logic
|
||||
// Attach to Messenger
|
||||
// Messenger::getProviderMessenger($profile);
|
||||
|
||||
return $profile;
|
||||
}
|
||||
|
||||
|
||||
private function createBankProfile(array $data): Bank
|
||||
{
|
||||
// Hash password
|
||||
$data['password'] = Hash::make($data['password']);
|
||||
|
||||
// Add default values from config
|
||||
$data['profile_photo_path'] = timebank_config('profiles.bank.profile_photo_path_new');
|
||||
$data['level'] = $data['level'] ?? timebank_config('profiles.bank.level', 1);
|
||||
$data['limit_min'] = timebank_config('profiles.bank.limit_min');
|
||||
$data['limit_max'] = timebank_config('profiles.bank.limit_max');
|
||||
$data['lang_preference'] = $data['lang_preference'] ?? null;
|
||||
|
||||
// Create the profile
|
||||
$profile = Bank::create($data);
|
||||
|
||||
// Attach to profile manager
|
||||
if (!empty($data['linkUser'])) {
|
||||
$profile->managers()->attach($data['linkUser']);
|
||||
|
||||
// Send email notifications to linked user about the new bank
|
||||
$linkedUser = User::find($data['linkUser']);
|
||||
if ($linkedUser) {
|
||||
$this->sendProfileLinkNotifications($profile, $linkedUser);
|
||||
}
|
||||
}
|
||||
// Create Account
|
||||
$this->createDefaultAccount($profile, 'bank');
|
||||
|
||||
// Create debit account for level 0 (source) banks
|
||||
if ($profile->level === 0) {
|
||||
$this->createDefaultAccount($profile, 'debit');
|
||||
}
|
||||
|
||||
// Create Location
|
||||
$this->createDefaultLocation($profile, $data);
|
||||
|
||||
|
||||
// TODO: Replace commented rtippin messenger logic with wirechat logic
|
||||
// Attach to Messenger
|
||||
// Messenger::getProviderMessenger($profile);
|
||||
|
||||
return $profile;
|
||||
}
|
||||
|
||||
|
||||
private function createAdminProfile(array $data): Admin
|
||||
{
|
||||
// Hash password
|
||||
$data['password'] = Hash::make($data['password']);
|
||||
|
||||
// Add default values from config
|
||||
$data['profile_photo_path'] = timebank_config('profiles.admin.profile_photo_path_new');
|
||||
$data['limit_min'] = timebank_config('profiles.admin.limit_min');
|
||||
$data['limit_max'] = timebank_config('profiles.admin.limit_max');
|
||||
$data['lang_preference'] = $data['lang_preference'] ?? null;
|
||||
|
||||
// Create the profile
|
||||
$profile = Admin::create($data);
|
||||
|
||||
// Attach to User
|
||||
if (!empty($data['linkUser'])) {
|
||||
$profile->users()->attach($data['linkUser']);
|
||||
$linkedUser = User::find($data['linkUser']);
|
||||
$linkedUser->assignRole('admin');
|
||||
|
||||
// Create and assign scoped Admin role (required by getCanManageProfiles())
|
||||
$scopedRoleName = "Admin\\{$profile->id}\\admin";
|
||||
$scopedRole = \Spatie\Permission\Models\Role::findOrCreate($scopedRoleName, 'web');
|
||||
$scopedRole->syncPermissions(\Spatie\Permission\Models\Permission::all());
|
||||
$linkedUser->assignRole($scopedRoleName);
|
||||
|
||||
// Send email notifications to linked user about the new admin profile
|
||||
$this->sendProfileLinkNotifications($profile, $linkedUser);
|
||||
}
|
||||
|
||||
// Create Location
|
||||
$this->createDefaultLocation($profile, $data);
|
||||
|
||||
|
||||
// TODO: Replace commented rtippin messenger logic with wirechat logic
|
||||
// Attach to Messenger
|
||||
// Messenger::getProviderMessenger($profile);
|
||||
|
||||
return $profile;
|
||||
}
|
||||
|
||||
|
||||
|
||||
// --- Helper function to create Default Location ---
|
||||
private function createDefaultLocation($profileModel, array $data): void
|
||||
{
|
||||
if (empty($data['country_id'])) {
|
||||
return;
|
||||
} // Don't create if no country
|
||||
|
||||
$location = new Location();
|
||||
$location->name = __('Default location');
|
||||
$location->country_id = $data['country_id'];
|
||||
$location->division_id = $data['division_id'] ?? null;
|
||||
$location->city_id = $data['city_id'] ?? null;
|
||||
$location->district_id = $data['district_id'] ?? null;
|
||||
$profileModel->locations()->save($location);
|
||||
}
|
||||
|
||||
// --- Helper function to create Default Account ---
|
||||
private function createDefaultAccount($profileModel, string $type): void
|
||||
{
|
||||
// Check if accounts are enabled for this type and config exists
|
||||
$accountConfig = timebank_config("accounts.{$type}");
|
||||
if (!$accountConfig) {
|
||||
Log::info("Account creation skipped for type '{$type}': No config found.");
|
||||
return;
|
||||
}
|
||||
|
||||
$account = new Account();
|
||||
$account->name = __(timebank_config("accounts.{$type}.name", 'default Account'));
|
||||
$account->limit_min = timebank_config("accounts.{$type}.limit_min", 0);
|
||||
$account->limit_max = timebank_config("accounts.{$type}.limit_max", 0);
|
||||
|
||||
// Associate account with the profile model (assuming polymorphic relation 'accounts')
|
||||
$profileModel->accounts()->save($account);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Resets the form fields to their initial state.
|
||||
*/
|
||||
public function resetForm()
|
||||
{
|
||||
$this->showCreateModal = false;
|
||||
|
||||
// Reset the main profile data array
|
||||
$this->createProfile = [
|
||||
'name' => null,
|
||||
'full_name' => null,
|
||||
'email' => null,
|
||||
'password' => null,
|
||||
'password_confirmation' => null,
|
||||
'phone' => null,
|
||||
'comment' => null,
|
||||
'lang_preference' => null,
|
||||
'type' => 'user', // Reset type back to default
|
||||
'linkBank' => null, // Add linkBank if it's part of the array
|
||||
'linkUser' => null, // Add linkUser if it's part of the array
|
||||
];
|
||||
|
||||
// Reset select options and selections
|
||||
$this->profileTypeOptions = [];
|
||||
$this->profileTypeSelected = null;
|
||||
$this->linkBankOptions = [];
|
||||
$this->linkBankSelected = null;
|
||||
$this->linkUserOptions = [];
|
||||
$this->linkUserSelected = null;
|
||||
|
||||
// Reset password generation flag
|
||||
$this->generateRandomPassword = true;
|
||||
$this->generatedPlainTextPassword = null; // Clear stored password
|
||||
|
||||
// Reset location properties
|
||||
$this->country = null;
|
||||
$this->division = null;
|
||||
$this->city = null;
|
||||
$this->district = null;
|
||||
|
||||
// Reset location validation flags
|
||||
$this->validateCountry = true;
|
||||
$this->validateDivision = true;
|
||||
$this->validateCity = true;
|
||||
|
||||
// Clear validation errors
|
||||
$this->resetErrorBag();
|
||||
|
||||
// Re-fetch initial options if needed when resetting
|
||||
$this->updatedCreateProfileType();
|
||||
}
|
||||
|
||||
/**
|
||||
* Send profile link notification emails to both the profile and the linked user
|
||||
*
|
||||
* @param mixed $profile The newly created profile (Organization/Bank/Admin)
|
||||
* @param User $linkedUser The user being linked to the profile
|
||||
* @return void
|
||||
*/
|
||||
private function sendProfileLinkNotifications($profile, User $linkedUser): void
|
||||
{
|
||||
Log::info('ProfileLinkCreated: Sending email notifications', [
|
||||
'profile_id' => $profile->id,
|
||||
'profile_type' => get_class($profile),
|
||||
'profile_name' => $profile->name,
|
||||
'linked_user_id' => $linkedUser->id,
|
||||
'linked_user_email' => $linkedUser->email ?? 'NO EMAIL',
|
||||
]);
|
||||
|
||||
// Send email to the linked user
|
||||
$userMessageSetting = \App\Models\MessageSetting::where('message_settingable_id', $linkedUser->id)
|
||||
->where('message_settingable_type', get_class($linkedUser))
|
||||
->first();
|
||||
|
||||
$sendUserEmail = $userMessageSetting ? $userMessageSetting->system_message : true;
|
||||
|
||||
Log::info('ProfileLinkCreated: Linked user message setting check', [
|
||||
'has_message_setting' => $userMessageSetting ? 'yes' : 'no',
|
||||
'system_message' => $userMessageSetting ? $userMessageSetting->system_message : 'default:true',
|
||||
'will_send_email' => $sendUserEmail ? 'yes' : 'no',
|
||||
]);
|
||||
|
||||
if ($sendUserEmail) {
|
||||
\App\Jobs\SendProfileLinkChangedMail::dispatch($linkedUser, $profile, 'attached');
|
||||
Log::info('ProfileLinkCreated: Dispatched email to linked user', [
|
||||
'recipient_email' => $linkedUser->email,
|
||||
'profile_name' => $profile->name,
|
||||
]);
|
||||
} else {
|
||||
Log::info('ProfileLinkCreated: Skipped email to linked user (system_message disabled)');
|
||||
}
|
||||
|
||||
// Send email to the newly created profile
|
||||
$profileMessageSetting = \App\Models\MessageSetting::where('message_settingable_id', $profile->id)
|
||||
->where('message_settingable_type', get_class($profile))
|
||||
->first();
|
||||
|
||||
$sendProfileEmail = $profileMessageSetting ? $profileMessageSetting->system_message : true;
|
||||
|
||||
Log::info('ProfileLinkCreated: Profile message setting check', [
|
||||
'has_message_setting' => $profileMessageSetting ? 'yes' : 'no',
|
||||
'system_message' => $profileMessageSetting ? $profileMessageSetting->system_message : 'default:true',
|
||||
'will_send_email' => $sendProfileEmail ? 'yes' : 'no',
|
||||
]);
|
||||
|
||||
if ($sendProfileEmail) {
|
||||
\App\Jobs\SendProfileLinkChangedMail::dispatch($profile, $linkedUser, 'attached');
|
||||
Log::info('ProfileLinkCreated: Dispatched email to profile', [
|
||||
'recipient_email' => $profile->email,
|
||||
'linked_user_name' => $linkedUser->name,
|
||||
]);
|
||||
} else {
|
||||
Log::info('ProfileLinkCreated: Skipped email to profile (system_message disabled)');
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
2154
app/Http/Livewire/Profiles/Manage.php
Normal file
2154
app/Http/Livewire/Profiles/Manage.php
Normal file
File diff suppressed because it is too large
Load Diff
40
app/Http/Livewire/Profiles/ProfileTypesDropdown.php
Normal file
40
app/Http/Livewire/Profiles/ProfileTypesDropdown.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire\Profiles;
|
||||
|
||||
use Livewire\Component;
|
||||
|
||||
class ProfileTypesDropdown extends Component
|
||||
{
|
||||
public $selectedTypes = [];
|
||||
public $hideLabel = false;
|
||||
|
||||
protected $listeners = ['profileTypesToChildren'];
|
||||
|
||||
public function profileTypesToChildren($value)
|
||||
{
|
||||
$this->selectedTypes = $value;
|
||||
}
|
||||
|
||||
public function updatedSelectedTypes()
|
||||
{
|
||||
$this->dispatch('profileTypesToParent', $this->selectedTypes);
|
||||
}
|
||||
|
||||
public function profileTypesSelected()
|
||||
{
|
||||
$this->dispatch('profileTypesToParent', $this->selectedTypes);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$profileTypes = [
|
||||
'User' => __('User'),
|
||||
'Organization' => __('Organization'),
|
||||
'Bank' => __('Bank'),
|
||||
'Admin' => __('Admin')
|
||||
];
|
||||
|
||||
return view('livewire.profiles.profile-types-dropdown', compact('profileTypes'));
|
||||
}
|
||||
}
|
||||
39
app/Http/Livewire/QuillEditor.php
Normal file
39
app/Http/Livewire/QuillEditor.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire;
|
||||
|
||||
use App\Rules\MaxLengthWithoutHtml;
|
||||
use Livewire\Attributes\Rule;
|
||||
use Livewire\Component;
|
||||
|
||||
class QuillEditor extends Component
|
||||
{
|
||||
public $content;
|
||||
|
||||
public function mount($content)
|
||||
{
|
||||
$this->content = $content;
|
||||
}
|
||||
|
||||
|
||||
public function updatedContent()
|
||||
{
|
||||
$maxLength = timebank_config('posts.content_max_input', 1000);
|
||||
|
||||
$this->validateOnly('content', [
|
||||
'content' => ['required', 'string', 'min:10', new MaxLengthWithoutHtml($maxLength)]
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
public function updated()
|
||||
{
|
||||
$this->dispatch('quillEditor', $this->content);
|
||||
}
|
||||
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.quill-editor');
|
||||
}
|
||||
}
|
||||
387
app/Http/Livewire/ReactionButton.php
Normal file
387
app/Http/Livewire/ReactionButton.php
Normal file
@@ -0,0 +1,387 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire;
|
||||
|
||||
use Livewire\Component;
|
||||
|
||||
class ReactionButton extends Component
|
||||
{
|
||||
public $targetModel = null;
|
||||
public $modelClass = null;
|
||||
public $modelId = null;
|
||||
public $count = 0;
|
||||
public $activeProfile;
|
||||
public $reactedByReactor = false;
|
||||
public $typeName;
|
||||
public $showCounter = true;
|
||||
public $isEnabled = false;
|
||||
public $canReact = false;
|
||||
public $requiresInteraction = false;
|
||||
public $hasInteraction = false;
|
||||
public $interactionMethod = null;
|
||||
public $disabledReason = null;
|
||||
public $size = 'w-10 h-10'; // Default size, can be overridden
|
||||
public $showConfirmModal = false;
|
||||
public $inverseColors = false; // Use light/background colors instead of reaction colors
|
||||
public ?string $redirectUrl = null; // When set and user is not logged in, clicking redirects here
|
||||
|
||||
public function mount($typeName = 'like', $showCounter = true, $reactionCounter = null, $modelInstance = null, $modelClass = null, $modelId = null, $inverseColors = false, $redirectUrl = null)
|
||||
{
|
||||
$this->typeName = $typeName;
|
||||
$this->count = $reactionCounter ?? 0;
|
||||
$this->showCounter = $showCounter;
|
||||
$this->inverseColors = $inverseColors;
|
||||
$this->redirectUrl = $redirectUrl;
|
||||
$this->activeProfile = getActiveProfile();
|
||||
|
||||
// Handle different ways of passing model data
|
||||
if ($modelInstance !== null) {
|
||||
// Traditional way: full model instance passed
|
||||
$this->targetModel = $modelInstance;
|
||||
|
||||
// For reserve reactions on Posts, ensure meeting is loaded
|
||||
if ($typeName === 'reserve' && $modelInstance instanceof \App\Models\Post) {
|
||||
if (!$modelInstance->relationLoaded('meeting')) {
|
||||
$this->targetModel->load('meeting');
|
||||
}
|
||||
}
|
||||
} elseif ($modelClass !== null && $modelId !== null) {
|
||||
// New way: class and ID passed separately
|
||||
$this->modelClass = $modelClass;
|
||||
$this->modelId = $modelId;
|
||||
try {
|
||||
if ($typeName === 'reserve' && $modelClass === 'App\Models\Post') {
|
||||
$this->targetModel = $modelClass::with('meeting')->find($modelId);
|
||||
} else {
|
||||
$this->targetModel = $modelClass::find($modelId);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
\Log::error('ReactionButton: Failed to load model: ' . $e->getMessage());
|
||||
$this->targetModel = null;
|
||||
}
|
||||
} else {
|
||||
$this->targetModel = null;
|
||||
}
|
||||
|
||||
// If model not found or invalid, set component as disabled and return early
|
||||
if (!$this->targetModel || !is_object($this->targetModel)) {
|
||||
$this->isEnabled = false;
|
||||
$this->canReact = false;
|
||||
$this->disabledReason = 'model_not_found';
|
||||
return;
|
||||
}
|
||||
|
||||
$this->initializeReactionSettings();
|
||||
$this->checkReactionPermissions();
|
||||
$this->checkIfReacted();
|
||||
}
|
||||
|
||||
private function initializeReactionSettings()
|
||||
{
|
||||
// Check if this reaction type is enabled in config
|
||||
$this->isEnabled = timebank_config("reactions.{$this->typeName}.enabled", false);
|
||||
|
||||
// Check if interaction is required for this reaction type
|
||||
$this->requiresInteraction = timebank_config("reactions.{$this->typeName}.only_with_interaction", false);
|
||||
|
||||
// Get the interaction method to use for checking
|
||||
$this->interactionMethod = timebank_config("reactions.{$this->typeName}.interaction", 'hasTransactionsWith');
|
||||
}
|
||||
|
||||
private function checkReactionPermissions()
|
||||
{
|
||||
// If reaction type is not enabled, early return
|
||||
if (!$this->isEnabled) {
|
||||
$this->canReact = false;
|
||||
$this->disabledReason = 'reaction_disabled';
|
||||
return;
|
||||
}
|
||||
|
||||
// Guests cannot react
|
||||
if (!$this->activeProfile) {
|
||||
$this->canReact = false;
|
||||
$this->disabledReason = 'not_logged_in';
|
||||
return;
|
||||
}
|
||||
|
||||
// Admins cannot react
|
||||
if ($this->activeProfile instanceof \App\Models\Admin) {
|
||||
$this->canReact = false;
|
||||
$this->disabledReason = 'admin_cannot_react';
|
||||
return;
|
||||
}
|
||||
|
||||
// Profile must be registered as love reacter
|
||||
if (
|
||||
!method_exists($this->activeProfile, 'isRegisteredAsLoveReacter') ||
|
||||
!$this->activeProfile->isRegisteredAsLoveReacter()
|
||||
) {
|
||||
$this->canReact = false;
|
||||
$this->disabledReason = 'not_registered_reacter';
|
||||
return;
|
||||
}
|
||||
|
||||
// Cannot react to your own profile/model
|
||||
if ($this->isOwnProfile()) {
|
||||
$this->canReact = false;
|
||||
$this->disabledReason = 'cannot_react_own_profile';
|
||||
return;
|
||||
}
|
||||
|
||||
// Check interaction requirement
|
||||
if ($this->requiresInteraction) {
|
||||
// Use the configured interaction method
|
||||
if (method_exists($this->activeProfile, $this->interactionMethod)) {
|
||||
$this->hasInteraction = $this->activeProfile->{$this->interactionMethod}($this->targetModel);
|
||||
if (!$this->hasInteraction) {
|
||||
$this->canReact = false;
|
||||
$this->disabledReason = 'no_interaction';
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Fallback if method doesn't exist - treat as no interaction
|
||||
$this->canReact = false;
|
||||
$this->disabledReason = 'no_interaction';
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$this->canReact = true;
|
||||
$this->disabledReason = null;
|
||||
}
|
||||
|
||||
private function isOwnProfile()
|
||||
{
|
||||
// Safety check - ensure we have a valid model object
|
||||
if (!$this->targetModel || !is_object($this->targetModel) || !isset($this->targetModel->id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get the model class name safely
|
||||
$modelClass = get_class($this->targetModel);
|
||||
$modelId = $this->targetModel->id;
|
||||
|
||||
return session('activeProfileType') === $modelClass &&
|
||||
session('activeProfileId') === $modelId;
|
||||
} catch (\Exception $e) {
|
||||
\Log::error('ReactionButton isOwnProfile error: ' . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private function checkIfReacted()
|
||||
{
|
||||
if (!$this->canReact || !$this->activeProfile->isRegisteredAsLoveReacter()) {
|
||||
$this->reactedByReactor = false;
|
||||
return;
|
||||
}
|
||||
|
||||
$this->reactedByReactor = $this->activeProfile
|
||||
->viaLoveReacter()
|
||||
->hasReactedTo($this->targetModel, $this->typeName);
|
||||
}
|
||||
|
||||
public function react()
|
||||
{
|
||||
// For reserve reaction type, show confirmation modal first
|
||||
if ($this->typeName === 'reserve') {
|
||||
$this->showConfirmModal = true;
|
||||
return;
|
||||
}
|
||||
|
||||
$this->confirmReaction();
|
||||
}
|
||||
|
||||
public function confirmReaction()
|
||||
{
|
||||
// Close modal if open
|
||||
$this->showConfirmModal = false;
|
||||
|
||||
// Security checks
|
||||
if (!$this->canReactSecurityCheck()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't react if already reacted
|
||||
if ($this->reactedByReactor) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->activeProfile->viaLoveReacter()->reactTo($this->targetModel, $this->typeName);
|
||||
|
||||
// Update count and reacted state
|
||||
$this->count++;
|
||||
$this->reactedByReactor = true;
|
||||
|
||||
// Optional: dispatch browser event for success feedback
|
||||
$this->dispatch('reaction-added', [
|
||||
'type' => $this->typeName,
|
||||
'model' => get_class($this->targetModel),
|
||||
'modelId' => $this->targetModel->id
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
// Log error and show user-friendly message
|
||||
\Log::error('Failed to add reaction: ' . $e->getMessage());
|
||||
$this->dispatch('reaction-error', ['message' => 'Failed to add reaction. Please try again.']);
|
||||
}
|
||||
}
|
||||
|
||||
public function unReact()
|
||||
{
|
||||
// Security checks
|
||||
if (!$this->canReactSecurityCheck()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't unreact if not reacted
|
||||
if (!$this->reactedByReactor) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->activeProfile->viaLoveReacter()->unReactTo($this->targetModel, $this->typeName);
|
||||
$this->count = max(0, $this->count - 1); // Prevent negative counts
|
||||
$this->reactedByReactor = false;
|
||||
|
||||
// Optional: dispatch browser event for success feedback
|
||||
$this->dispatch('reaction-removed', [
|
||||
'type' => $this->typeName,
|
||||
'model' => get_class($this->targetModel),
|
||||
'modelId' => $this->targetModel->id
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
// Log error and show user-friendly message
|
||||
\Log::error('Failed to remove reaction: ' . $e->getMessage());
|
||||
$this->dispatch('reaction-error', ['message' => 'Failed to remove reaction. Please try again.']);
|
||||
}
|
||||
}
|
||||
|
||||
private function canReactSecurityCheck()
|
||||
{
|
||||
// Re-run all security checks on each action for security
|
||||
if (!$this->targetModel || (method_exists($this->targetModel, 'isRemoved') && $this->targetModel->isRemoved())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!$this->isEnabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->activeProfile instanceof \App\Models\Admin) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
!method_exists($this->activeProfile, 'isRegisteredAsLoveReacter') ||
|
||||
!$this->activeProfile->isRegisteredAsLoveReacter()
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->isOwnProfile()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
$this->requiresInteraction &&
|
||||
method_exists($this->activeProfile, $this->interactionMethod) &&
|
||||
!$this->activeProfile->{$this->interactionMethod}($this->targetModel)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function getDisabledReasonText()
|
||||
{
|
||||
// Only show text for specific disabled reasons that should be visible to users
|
||||
$visibleReasons = [
|
||||
'cannot_react_own_profile',
|
||||
'no_interaction'
|
||||
];
|
||||
|
||||
if (!in_array($this->disabledReason, $visibleReasons)) {
|
||||
return null; // Don't show text for other reasons
|
||||
}
|
||||
|
||||
// Get reaction-specific disabled reason text
|
||||
$reason = timebank_config("reactions.{$this->typeName}.disabled_reasons.{$this->disabledReason}");
|
||||
|
||||
// Fallback to default messages if reaction-specific ones don't exist
|
||||
if (!$reason) {
|
||||
$fallbackReasons = [
|
||||
'cannot_react_own_profile' => __('You cannot react to your own content.'),
|
||||
'no_interaction' => __('You need an interaction to react.'),
|
||||
];
|
||||
$reason = $fallbackReasons[$this->disabledReason] ?? '';
|
||||
}
|
||||
|
||||
return $reason;
|
||||
}
|
||||
|
||||
private function shouldShowComponent()
|
||||
{
|
||||
// Hide component if model is not loaded
|
||||
if (!$this->targetModel) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Hide component for these reasons instead of showing disabled state
|
||||
$hiddenReasons = [
|
||||
'reaction_disabled',
|
||||
'admin_cannot_react',
|
||||
'not_registered_reacter',
|
||||
'model_not_found'
|
||||
];
|
||||
|
||||
return !in_array($this->disabledReason, $hiddenReasons);
|
||||
}
|
||||
|
||||
private function getIconSvg($typeName)
|
||||
{
|
||||
// Get icon file from config
|
||||
$iconFile = timebank_config("reactions.{$typeName}.icon_file");
|
||||
|
||||
if (!$iconFile) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$svgPath = public_path('storage/app-images/' . $iconFile);
|
||||
|
||||
if (!file_exists($svgPath)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$svgContent = file_get_contents($svgPath);
|
||||
|
||||
// Extract only the path content from the SVG (remove outer svg tag)
|
||||
if (preg_match('/<path[^>]*>.*?<\/path>|<path[^>]*\/>/s', $svgContent, $matches)) {
|
||||
return $matches[0];
|
||||
}
|
||||
|
||||
// If no path found, try to extract circle or other shapes
|
||||
if (preg_match('/<(?:circle|rect|polygon|line)[^>]*>.*?<\/(?:circle|rect|polygon|line)>|<(?:circle|rect|polygon|line)[^>]*\/>/s', $svgContent, $matches)) {
|
||||
return $matches[0];
|
||||
}
|
||||
|
||||
return $svgContent;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$iconSvg = $this->getIconSvg($this->typeName);
|
||||
$shouldShow = $this->shouldShowComponent();
|
||||
$disabledReasonText = $this->getDisabledReasonText();
|
||||
|
||||
return view('livewire.reaction-button', [
|
||||
'iconSvg' => $iconSvg,
|
||||
'disabledReasonText' => $disabledReasonText,
|
||||
'shouldShow' => $shouldShow,
|
||||
'inverseColors' => $this->inverseColors,
|
||||
'redirectUrl' => $this->redirectUrl,
|
||||
])->layout('layouts.app');
|
||||
}
|
||||
}
|
||||
405
app/Http/Livewire/Registration.php
Normal file
405
app/Http/Livewire/Registration.php
Normal file
@@ -0,0 +1,405 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire;
|
||||
|
||||
use App\Models\Account;
|
||||
use App\Models\Locations\Country;
|
||||
use App\Models\Locations\Location;
|
||||
use App\Models\Post;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Events\Registered;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Laravel\Fortify\Contracts\CreatesNewUsers;
|
||||
use Livewire\Component;
|
||||
use Stevebauman\Location\Facades\Location as IpLocation;
|
||||
use Throwable;
|
||||
use WireUi\Traits\WireUiActions;
|
||||
|
||||
class Registration extends Component implements CreatesNewUsers
|
||||
{
|
||||
use WireUiActions;
|
||||
|
||||
|
||||
public $full_name;
|
||||
public $name;
|
||||
public $email;
|
||||
public $password;
|
||||
public $password_confirmation;
|
||||
public $country;
|
||||
public $division;
|
||||
public $city;
|
||||
public $district;
|
||||
public $validateCountry = true;
|
||||
public $validateDivision = true;
|
||||
public $validateCity = true;
|
||||
public $waitMessage = false;
|
||||
public $captcha;
|
||||
|
||||
// Principles acceptance
|
||||
public bool $showPrinciplesModal = true;
|
||||
public bool $principlesAccepted = false;
|
||||
public bool $principlesAgreed = false;
|
||||
public $principlesData = null;
|
||||
|
||||
// GDPR age verification
|
||||
public bool $ageConfirmed = false;
|
||||
|
||||
protected $listeners = ['countryToParent', 'divisionToParent', 'cityToParent', 'districtToParent'];
|
||||
|
||||
public function rules()
|
||||
{
|
||||
return [
|
||||
'full_name' => 'required|string|max:255',
|
||||
'name' => timebank_config('rules.profile_user.name'),
|
||||
'email' => timebank_config('rules.profile_user.email'),
|
||||
'password' => timebank_config('rules.profile_user.password'),
|
||||
'country' => 'required_if:validateCountry,true|integer',
|
||||
'division' => 'required_if:validateDivision,true',
|
||||
'city' => 'required_if:validateCity,true',
|
||||
'district' => 'sometimes',
|
||||
'ageConfirmed' => 'accepted',
|
||||
// 'captcha' => 'hiddencaptcha:3,300', // min, max time in sec for submitting form without captcha validation error
|
||||
];
|
||||
}
|
||||
|
||||
public function messages()
|
||||
{
|
||||
return [
|
||||
'captcha.hiddencaptcha' => __('Automatic form completion detected. Try again filling in the form manually. Robots are not allowed to register.'),
|
||||
'ageConfirmed.accepted' => __('You must confirm that you meet the minimum age requirement to register.'),
|
||||
];
|
||||
}
|
||||
|
||||
public function mount(Request $request)
|
||||
{
|
||||
if (App::environment(['local'])) {
|
||||
// $ip = '103.75.231.255'; // Static IP address Brussels for testing
|
||||
$ip = '31.20.250.12'; // Static IP address The Hague for testing
|
||||
//$ip = '101.33.29.255'; // Static IP address in Amsterdam for testing
|
||||
//$ip = '102.129.156.0'; // Static IP address Berlin for testing
|
||||
} else {
|
||||
// TODO: Test ip lookup in production
|
||||
$ip = $request->ip(); // Dynamic IP address
|
||||
}
|
||||
$IpLocationInfo = IpLocation::get($ip);
|
||||
if ($IpLocationInfo) {
|
||||
|
||||
$country = Country::select('id')->where('code', $IpLocationInfo->countryCode)->first();
|
||||
if ($country) {
|
||||
$this->country = $country->id;
|
||||
}
|
||||
|
||||
$division = DB::table('division_locales')->select('division_id')->where('name', 'LIKE', $IpLocationInfo->regionName)->where('locale', app()->getLocale())->first(); //We only need the city_id, therefore we use the default app locale in the where query.
|
||||
if ($division) {
|
||||
$this->division = $division->division_id;
|
||||
}
|
||||
|
||||
$city = DB::table('city_locales')->select('city_id')->where('name', $IpLocationInfo->cityName)->where('locale', app()->getLocale())->first(); //We only need the city_id, therefore we use the default app locale in the where query.
|
||||
if ($city) {
|
||||
$this->city = $city->city_id;
|
||||
};
|
||||
}
|
||||
|
||||
$this->setValidationOptions();
|
||||
|
||||
// Failsafe: If no published principles post exists, bypass the principles modal
|
||||
$principlesPost = $this->getPrinciplesPost();
|
||||
if (!$principlesPost) {
|
||||
$this->showPrinciplesModal = false;
|
||||
$this->principlesAccepted = true;
|
||||
// principlesData remains null, user will be registered without principles acceptance
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public function emitLocationToChildren()
|
||||
{
|
||||
$this->dispatch('countryToChildren', $this->country);
|
||||
$this->dispatch('divisionToChildren', $this->division);
|
||||
$this->dispatch('cityToChildren', $this->city);
|
||||
$this->dispatch('districtToChildren', $this->district);
|
||||
}
|
||||
|
||||
|
||||
public function countryToParent($value)
|
||||
{
|
||||
$this->country = $value;
|
||||
$this->setValidationOptions();
|
||||
}
|
||||
|
||||
|
||||
public function divisionToParent($value)
|
||||
{
|
||||
$this->division = $value;
|
||||
$this->setValidationOptions();
|
||||
}
|
||||
|
||||
|
||||
public function cityToParent($value)
|
||||
{
|
||||
$this->city = $value;
|
||||
$this->setValidationOptions();
|
||||
}
|
||||
|
||||
|
||||
public function districtToParent($value)
|
||||
{
|
||||
$this->district = $value;
|
||||
$this->setValidationOptions();
|
||||
}
|
||||
|
||||
|
||||
public function updated($field)
|
||||
{
|
||||
$this->validateOnly($field);
|
||||
}
|
||||
|
||||
|
||||
public function setValidationOptions()
|
||||
{
|
||||
$this->validateCountry = $this->validateDivision = $this->validateCity = true;
|
||||
|
||||
// In case no cities or divisions for selected country are seeded in database
|
||||
if ($this->country) {
|
||||
$countDivisions = Country::find($this->country)->divisions->count();
|
||||
$countCities = Country::find($this->country)->cities->count();
|
||||
|
||||
if ($countDivisions > 0 && $countCities < 1) {
|
||||
$this->validateDivision = true;
|
||||
$this->validateCity = false;
|
||||
} elseif ($countDivisions < 1 && $countCities > 1) {
|
||||
$this->validateDivision = false;
|
||||
$this->validateCity = true;
|
||||
} elseif ($countDivisions < 1 && $countCities < 1) {
|
||||
$this->validateDivision = false;
|
||||
$this->validateCity = false;
|
||||
} elseif ($countDivisions > 0 && $countCities > 0) {
|
||||
$this->validateDivision = false;
|
||||
$this->validateCity = true;
|
||||
}
|
||||
|
||||
}
|
||||
// In case no country is selected, no need to show other validation errors
|
||||
if (!$this->country) {
|
||||
$this->validateCountry = true;
|
||||
$this->validateDivision = $this->validateCity = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get the current principles post for the current locale
|
||||
*
|
||||
* @return Post|null
|
||||
*/
|
||||
protected function getPrinciplesPost()
|
||||
{
|
||||
$locale = app()->getLocale();
|
||||
|
||||
return Post::with(['translations' => function ($query) use ($locale) {
|
||||
$query->where('locale', 'like', $locale . '%')
|
||||
->whereDate('from', '<=', now())
|
||||
->where(function ($query) {
|
||||
$query->whereDate('till', '>', now())->orWhereNull('till');
|
||||
})
|
||||
->orderBy('updated_at', 'desc')
|
||||
->limit(1);
|
||||
}])
|
||||
->whereHas('category', function ($query) {
|
||||
$query->where('type', 'SiteContents\\Static\\Principles');
|
||||
})
|
||||
->whereHas('translations', function ($query) use ($locale) {
|
||||
$query->where('locale', 'like', $locale . '%')
|
||||
->whereDate('from', '<=', now())
|
||||
->where(function ($query) {
|
||||
$query->whereDate('till', '>', now())->orWhereNull('till');
|
||||
});
|
||||
})
|
||||
->first();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Accept the principles and store metadata for later user creation
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function acceptPrinciples()
|
||||
{
|
||||
// Get the current principles post
|
||||
$principlesPost = $this->getPrinciplesPost();
|
||||
|
||||
if (!$principlesPost || !$principlesPost->translations->first()) {
|
||||
$this->notification()->error(
|
||||
$title = __('Error'),
|
||||
$description = __('Unable to find the current principles document.')
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
$translation = $principlesPost->translations->first();
|
||||
|
||||
// Store acceptance metadata for later user creation
|
||||
$this->principlesData = [
|
||||
'post_id' => $principlesPost->id,
|
||||
'post_translation_id' => $translation->id,
|
||||
'locale' => $translation->locale,
|
||||
'from' => $translation->from,
|
||||
'updated_at' => $translation->updated_at->toDateTimeString(),
|
||||
'accepted_at' => now()->toDateTimeString(),
|
||||
];
|
||||
|
||||
$this->principlesAccepted = true;
|
||||
$this->showPrinciplesModal = false;
|
||||
|
||||
$this->notification()->success(
|
||||
$title = __('Thank you'),
|
||||
$description = __('You can now proceed with registration.')
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
public function create($input = null)
|
||||
{
|
||||
$this->waitMessage = true;
|
||||
$valid = $this->validate();
|
||||
|
||||
try {
|
||||
// Use a transaction for creating the new user
|
||||
DB::transaction(function () use ($valid): void {
|
||||
|
||||
$user = User::create([
|
||||
'full_name' => $valid['full_name'],
|
||||
'name' => $valid['name'],
|
||||
'email' => $valid['email'],
|
||||
'password' => Hash::make($valid['password']),
|
||||
'profile_photo_path' => timebank_config('profiles.user.profile_photo_path_new'),
|
||||
'lang_preference' => app()->getLocale(), // App locale is set by mcamara/laravel-localization package: set app locale according to browser language
|
||||
'limit_min' => timebank_config('profiles.user.limit_min'),
|
||||
'limit_max' => timebank_config('profiles.user.limit_max'),
|
||||
'principles_terms_accepted' => $this->principlesData,
|
||||
]);
|
||||
|
||||
$location = new Location();
|
||||
$location->name = __('Default location');
|
||||
$location->country_id = $valid['country'];
|
||||
$location->division_id = $valid['division'];
|
||||
$location->city_id = $valid['city'];
|
||||
$location->district_id = $valid['district'];
|
||||
$user->locations()->save($location); // save the new location for the user
|
||||
|
||||
$account = new Account();
|
||||
$account->name = __(timebank_config('accounts.user.name'));
|
||||
$account->limit_min = timebank_config('accounts.user.limit_min');
|
||||
$account->limit_max = timebank_config('accounts.user.limit_max');
|
||||
|
||||
// TODO: remove testing comment for production
|
||||
// Uncomment to test a failed transaction
|
||||
// Simulate an error by throwing an exception
|
||||
// throw new \Exception('Simulated error before saving account');
|
||||
|
||||
$user->accounts()->save($account); // create the new account for the user
|
||||
|
||||
|
||||
// TODO: Replace commented rtippin messenger logic with wirechat logic
|
||||
// Attach user to Messenger as a provider
|
||||
// Messenger::getProviderMessenger($user);
|
||||
|
||||
// WireUI notification
|
||||
$this->notification()->success(
|
||||
$title = __('Your registration is saved!'),
|
||||
);
|
||||
|
||||
$this->reset();
|
||||
Auth::guard('web')->login($user);
|
||||
event(new Registered($user));
|
||||
});
|
||||
// End of transaction
|
||||
|
||||
$this->waitMessage = false;
|
||||
|
||||
return redirect()->route('verification.notice');
|
||||
|
||||
} catch (Throwable $e) {
|
||||
$this->waitMessage = false;
|
||||
|
||||
// WireUI notification
|
||||
$this->notification()->send([
|
||||
'title' => __('Registration failed') . '! ',
|
||||
'description' => __('Sorry, your data could not be saved!') . '<br /><br />' . __('Our team has been notified. Please try again later.') . '<br /><br />' . __('Error') . ': ' . $e->getMessage(),
|
||||
'icon' => 'error',
|
||||
'timeout' => 100000
|
||||
]);
|
||||
|
||||
$warningMessage = 'User registration failed';
|
||||
$error = $e;
|
||||
$eventTime = now()->toDateTimeString();
|
||||
$ip = request()->ip();
|
||||
$ipLocationInfo = IpLocation::get($ip);
|
||||
// Escape ipLocation errors when not in production
|
||||
if (!$ipLocationInfo || App::environment(['local', 'development', 'staging'])) {
|
||||
$ipLocationInfo = (object) [
|
||||
'cityName' => 'local City',
|
||||
'regionName' => 'local Region',
|
||||
'countryName' => 'local Country',
|
||||
];
|
||||
}
|
||||
$lang_preference = app()->getLocale();
|
||||
$country = DB::table('country_locales')->where('country_id', $valid['country'])->where('locale', timebank_config('base_language'))->value('name');
|
||||
$division = DB::table('division_locales')->where('division_id', $valid['division'])->where('locale', timebank_config('base_language'))->value('name');
|
||||
$city = DB::table('city_locales')->where('city_id', $valid['city'])->where('locale', timebank_config('base_language'))->value('name');
|
||||
$district = DB::table('district_locales')->where('district_id', $valid['district'])->where('locale', timebank_config('base_language'))->value('name');
|
||||
|
||||
// Log this event and mail to admin
|
||||
Log::warning($warningMessage, [
|
||||
'full_name' => $valid['full_name'],
|
||||
'name' => $valid['name'],
|
||||
'email' => $valid['email'],
|
||||
'lang_preference' => $lang_preference,
|
||||
'country' => $country,
|
||||
'division' => $division,
|
||||
'city' => $city,
|
||||
'district' => $district,
|
||||
'IP address' => $ip,
|
||||
'IP location' => $ipLocationInfo->cityName . ', ' . $ipLocationInfo->regionName . ', ' . $ipLocationInfo->countryName,
|
||||
'Event Time' => $eventTime,
|
||||
'Message' => $error,
|
||||
]);
|
||||
Mail::raw(
|
||||
$warningMessage . '.' . "\n\n" .
|
||||
'Full name: ' . $valid['full_name'] . "\n" .
|
||||
'Name: ' . $valid['name'] . "\n" .
|
||||
'Email: ' . $valid['email'] . "\n" .
|
||||
'Language preference: ' . $lang_preference . "\n" .
|
||||
'Country: ' . $country . "\n" .
|
||||
'Division: ' . $division . "\n" .
|
||||
'City: ' . $city . "\n" .
|
||||
'District: ' . $district . "\n" .
|
||||
'IP address: ' . $ip . "\n" .
|
||||
'IP location: ' . $ipLocationInfo->cityName . ', ' . $ipLocationInfo->regionName . ', ' . $ipLocationInfo->countryName . "\n" .
|
||||
'Event Time: ' . $eventTime . "\n\n" .
|
||||
$error,
|
||||
function ($message) use ($warningMessage) {
|
||||
$message->to(timebank_config('mail.system_admin.email'))->subject($warningMessage);
|
||||
},
|
||||
);
|
||||
|
||||
return back();
|
||||
}
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$principlesPost = $this->getPrinciplesPost();
|
||||
|
||||
return view('livewire.registration', [
|
||||
'principlesPost' => $principlesPost,
|
||||
]);
|
||||
}
|
||||
}
|
||||
199
app/Http/Livewire/Reports.php
Normal file
199
app/Http/Livewire/Reports.php
Normal file
@@ -0,0 +1,199 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire;
|
||||
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Livewire\Component;
|
||||
|
||||
class Reports extends Component
|
||||
{
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.reports');
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode and validate a base64 chart image string.
|
||||
* Returns the raw image bytes, or throws if the content is not a PNG or JPEG.
|
||||
*/
|
||||
private function decodeChartImage(string $chartImage): string
|
||||
{
|
||||
$base64Data = explode(',', $chartImage)[1] ?? $chartImage;
|
||||
$imageData = base64_decode($base64Data, strict: true);
|
||||
|
||||
if ($imageData === false) {
|
||||
abort(422, 'Invalid chart image data');
|
||||
}
|
||||
|
||||
// Validate magic bytes — must be PNG (\x89PNG) or JPEG (\xFF\xD8\xFF)
|
||||
$isPng = str_starts_with($imageData, "\x89PNG");
|
||||
$isJpeg = str_starts_with($imageData, "\xFF\xD8\xFF");
|
||||
|
||||
if (!$isPng && !$isJpeg) {
|
||||
Log::warning('Reports: rejected chart image with invalid magic bytes', [
|
||||
'guard' => session('activeProfileType'),
|
||||
'profileId' => session('activeProfileId'),
|
||||
]);
|
||||
abort(422, 'Chart image must be a PNG or JPEG');
|
||||
}
|
||||
|
||||
return $imageData;
|
||||
}
|
||||
|
||||
public function exportPdfWithChart($chartImage, $fromDate = null, $toDate = null)
|
||||
{
|
||||
abort_unless(Auth::check(), 403);
|
||||
|
||||
Log::info('Reports::exportPdfWithChart called', [
|
||||
'chartImageLength' => strlen($chartImage),
|
||||
'fromDate' => $fromDate,
|
||||
'toDate' => $toDate
|
||||
]);
|
||||
|
||||
// Generate unique filename for temporary chart image
|
||||
$chartImageId = uniqid('chart_', true);
|
||||
$tempImagePath = storage_path('app/temp/' . $chartImageId . '.png');
|
||||
|
||||
// Ensure temp directory exists
|
||||
if (!file_exists(dirname($tempImagePath))) {
|
||||
mkdir(dirname($tempImagePath), 0755, true);
|
||||
}
|
||||
|
||||
// Decode and validate image content before writing
|
||||
$imageData = $this->decodeChartImage($chartImage);
|
||||
file_put_contents($tempImagePath, $imageData);
|
||||
|
||||
Log::info('Chart image saved to temp file', [
|
||||
'chartImageId' => $chartImageId,
|
||||
'fileSize' => strlen($imageData)
|
||||
]);
|
||||
|
||||
// Store only the chart image ID in session (much smaller)
|
||||
session(['chart_image_id' => $chartImageId]);
|
||||
|
||||
// Build URL with current report parameters - use passed dates or fallback
|
||||
$params = [
|
||||
'fromDate' => $fromDate ?? request()->get('fromDate'),
|
||||
'toDate' => $toDate ?? request()->get('toDate'),
|
||||
'with_chart' => 1,
|
||||
'decimal' => request()->get('decimal', 0),
|
||||
];
|
||||
|
||||
$url = route('reports.pdf', $params);
|
||||
Log::info('PDF URL generated', ['url' => $url]);
|
||||
|
||||
// Use JavaScript to open PDF in new window/tab
|
||||
$this->dispatch('openPdf', $url);
|
||||
}
|
||||
|
||||
public function exportPdf()
|
||||
{
|
||||
Log::info('Reports::exportPdf called - delegating to SingleReport');
|
||||
|
||||
// This method should delegate to SingleReport component
|
||||
// For now, just use basic parameters
|
||||
$params = [
|
||||
'fromDate' => request()->get('fromDate'),
|
||||
'toDate' => request()->get('toDate'),
|
||||
'decimal' => request()->get('decimal', 0),
|
||||
];
|
||||
|
||||
$url = route('reports.pdf', $params);
|
||||
Log::info('PDF URL generated from Reports component', ['url' => $url]);
|
||||
$this->dispatch('openPdf', $url);
|
||||
}
|
||||
|
||||
public function exportReport($type)
|
||||
{
|
||||
Log::info('Reports::exportReport called - delegating to SingleReport', ['type' => $type]);
|
||||
|
||||
// Delegate to SingleReport for actual export functionality
|
||||
$params = [
|
||||
'fromDate' => request()->get('fromDate'),
|
||||
'toDate' => request()->get('toDate'),
|
||||
'decimal' => request()->get('decimal', 0),
|
||||
];
|
||||
|
||||
$url = route('reports.pdf', $params);
|
||||
$this->dispatch('openPdf', $url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export report as PDF with both chart images
|
||||
*/
|
||||
public function exportPdfWithCharts($returnRatioChartImage = null, $accountBalancesChartImage = null, $fromDate = null, $toDate = null)
|
||||
{
|
||||
abort_unless(Auth::check(), 403);
|
||||
|
||||
Log::info('Reports::exportPdfWithCharts called', [
|
||||
'returnRatioChartImageLength' => $returnRatioChartImage ? strlen($returnRatioChartImage) : 0,
|
||||
'accountBalancesChartImageLength' => $accountBalancesChartImage ? strlen($accountBalancesChartImage) : 0,
|
||||
'fromDate' => $fromDate,
|
||||
'toDate' => $toDate
|
||||
]);
|
||||
|
||||
$chartImageIds = [];
|
||||
|
||||
// Process Return Ratio Chart image
|
||||
if ($returnRatioChartImage) {
|
||||
$returnRatioChartImageId = uniqid('return_ratio_chart_', true);
|
||||
$tempImagePath = storage_path('app/temp/' . $returnRatioChartImageId . '.png');
|
||||
|
||||
// Ensure temp directory exists
|
||||
if (!file_exists(dirname($tempImagePath))) {
|
||||
mkdir(dirname($tempImagePath), 0755, true);
|
||||
}
|
||||
|
||||
// Decode and validate image content before writing
|
||||
$imageData = $this->decodeChartImage($returnRatioChartImage);
|
||||
file_put_contents($tempImagePath, $imageData);
|
||||
|
||||
$chartImageIds['return_ratio_chart_id'] = $returnRatioChartImageId;
|
||||
|
||||
Log::info('Return Ratio Chart image saved to temp file', [
|
||||
'chartImageId' => $returnRatioChartImageId,
|
||||
'fileSize' => strlen($imageData)
|
||||
]);
|
||||
}
|
||||
|
||||
// Process Account Balances Chart image
|
||||
if ($accountBalancesChartImage) {
|
||||
$accountBalancesChartImageId = uniqid('account_balances_chart_', true);
|
||||
$tempImagePath = storage_path('app/temp/' . $accountBalancesChartImageId . '.png');
|
||||
|
||||
// Ensure temp directory exists
|
||||
if (!file_exists(dirname($tempImagePath))) {
|
||||
mkdir(dirname($tempImagePath), 0755, true);
|
||||
}
|
||||
|
||||
// Decode and validate image content before writing
|
||||
$imageData = $this->decodeChartImage($accountBalancesChartImage);
|
||||
file_put_contents($tempImagePath, $imageData);
|
||||
|
||||
$chartImageIds['account_balances_chart_id'] = $accountBalancesChartImageId;
|
||||
|
||||
Log::info('Account Balances Chart image saved to temp file', [
|
||||
'chartImageId' => $accountBalancesChartImageId,
|
||||
'fileSize' => strlen($imageData)
|
||||
]);
|
||||
}
|
||||
|
||||
// Store chart image IDs in session
|
||||
session(['chart_image_ids' => $chartImageIds]);
|
||||
|
||||
// Build URL with current report parameters
|
||||
$params = [
|
||||
'fromDate' => $fromDate ?? request()->get('fromDate'),
|
||||
'toDate' => $toDate ?? request()->get('toDate'),
|
||||
'with_charts' => 1,
|
||||
'decimal' => request()->get('decimal', 0),
|
||||
];
|
||||
|
||||
$url = route('reports.pdf', $params);
|
||||
Log::info('PDF URL generated with charts', ['url' => $url, 'chartImageIds' => $chartImageIds]);
|
||||
|
||||
// Use JavaScript to open PDF in new window/tab
|
||||
$this->dispatch('openPdf', $url);
|
||||
}
|
||||
}
|
||||
372
app/Http/Livewire/ReserveButton.php
Normal file
372
app/Http/Livewire/ReserveButton.php
Normal file
@@ -0,0 +1,372 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire;
|
||||
|
||||
use Illuminate\Support\Facades\Lang;
|
||||
use Livewire\Component;
|
||||
use Namu\WireChat\Events\NotifyParticipant;
|
||||
use WireUi\Traits\WireUiActions;
|
||||
|
||||
class ReserveButton extends Component
|
||||
{
|
||||
use WireUiActions;
|
||||
public $post;
|
||||
public $activeProfile;
|
||||
public $hasReserved = false;
|
||||
public $reservationCount = 0;
|
||||
public $canReserve = false;
|
||||
public $disabledReason = null;
|
||||
public $showConfirmModal = false;
|
||||
public $showCancelModal = false;
|
||||
public $showReservationsModal = false;
|
||||
public $reservationsByType = [];
|
||||
public $isOrganizer = false;
|
||||
public $messageToReserved = '';
|
||||
public $isGuest = false;
|
||||
|
||||
public function mount($post)
|
||||
{
|
||||
$this->post = $post;
|
||||
$this->activeProfile = getActiveProfile();
|
||||
$this->isGuest = !$this->activeProfile;
|
||||
|
||||
// Ensure meeting is loaded
|
||||
if (!$this->post->relationLoaded('meeting')) {
|
||||
$this->post->load('meeting');
|
||||
}
|
||||
|
||||
// Get reservation count
|
||||
if ($this->post && method_exists($this->post, 'loveReactant')) {
|
||||
$reactant = $this->post->getLoveReactant();
|
||||
if ($reactant) {
|
||||
$reactionType = \Cog\Laravel\Love\ReactionType\Models\ReactionType::fromName('Reserve');
|
||||
if ($reactionType) {
|
||||
$this->reservationCount = $reactant->getReactionCounterOfType($reactionType)?->count ?? 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->checkReservationPermissions();
|
||||
$this->checkIfReserved();
|
||||
$this->loadReservations();
|
||||
}
|
||||
|
||||
private function loadReservations()
|
||||
{
|
||||
// Only load if current profile is the organizer of the meeting
|
||||
if (!$this->post || !isset($this->post->meeting)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->isOrganizer = session('activeProfileType') === $this->post->meeting->meetingable_type
|
||||
&& session('activeProfileId') === $this->post->meeting->meetingable_id;
|
||||
|
||||
if (!$this->isOrganizer) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get all reacters for this post with Reserve reaction
|
||||
$reactionType = \Cog\Laravel\Love\ReactionType\Models\ReactionType::fromName('Reserve');
|
||||
if (!$reactionType) {
|
||||
return;
|
||||
}
|
||||
|
||||
$reactions = \Cog\Laravel\Love\Reaction\Models\Reaction::where('reactant_id', $this->post->love_reactant_id)
|
||||
->where('reaction_type_id', $reactionType->getId())
|
||||
->get();
|
||||
|
||||
$groupedReactions = [
|
||||
'App\\Models\\User' => [],
|
||||
'App\\Models\\Organization' => [],
|
||||
'App\\Models\\Bank' => [],
|
||||
'App\\Models\\Admin' => [],
|
||||
];
|
||||
|
||||
foreach ($reactions as $reaction) {
|
||||
$reacter = $reaction->reacter;
|
||||
if ($reacter) {
|
||||
$reacterable = $reacter->reacterable;
|
||||
if ($reacterable) {
|
||||
$type = get_class($reacterable);
|
||||
if (isset($groupedReactions[$type])) {
|
||||
$groupedReactions[$type][] = $reacterable;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->reservationsByType = $groupedReactions;
|
||||
}
|
||||
|
||||
private function checkReservationPermissions()
|
||||
{
|
||||
// Check if reserve reaction is enabled in config
|
||||
if (!timebank_config('reactions.reserve.enabled', false)) {
|
||||
$this->canReserve = false;
|
||||
$this->disabledReason = 'reaction_disabled';
|
||||
return;
|
||||
}
|
||||
|
||||
// Admins cannot reserve
|
||||
if ($this->activeProfile instanceof \App\Models\Admin) {
|
||||
$this->canReserve = false;
|
||||
$this->disabledReason = 'admin_cannot_reserve';
|
||||
return;
|
||||
}
|
||||
|
||||
// Profile must be registered as love reacter
|
||||
if (
|
||||
!$this->activeProfile ||
|
||||
!method_exists($this->activeProfile, 'isRegisteredAsLoveReacter') ||
|
||||
!$this->activeProfile->isRegisteredAsLoveReacter()
|
||||
) {
|
||||
$this->canReserve = false;
|
||||
$this->disabledReason = 'not_registered_reacter';
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if post has a meeting
|
||||
if (!$this->post || !isset($this->post->meeting)) {
|
||||
$this->canReserve = false;
|
||||
$this->disabledReason = 'no_meeting';
|
||||
return;
|
||||
}
|
||||
|
||||
$this->canReserve = true;
|
||||
$this->disabledReason = null;
|
||||
}
|
||||
|
||||
private function checkIfReserved()
|
||||
{
|
||||
if (!$this->canReserve || !$this->activeProfile->isRegisteredAsLoveReacter()) {
|
||||
$this->hasReserved = false;
|
||||
return;
|
||||
}
|
||||
|
||||
$this->hasReserved = $this->activeProfile
|
||||
->viaLoveReacter()
|
||||
->hasReactedTo($this->post, 'Reserve');
|
||||
}
|
||||
|
||||
public function redirectToLogin()
|
||||
{
|
||||
// Get the referrer URL (the page the user is viewing)
|
||||
$intendedUrl = request()->header('Referer');
|
||||
|
||||
// Store as intended URL
|
||||
if ($intendedUrl) {
|
||||
session(['url.intended' => $intendedUrl]);
|
||||
}
|
||||
|
||||
return redirect()->route('login');
|
||||
}
|
||||
|
||||
public function openConfirmModal()
|
||||
{
|
||||
$this->showConfirmModal = true;
|
||||
}
|
||||
|
||||
public function openCancelModal()
|
||||
{
|
||||
$this->showCancelModal = true;
|
||||
}
|
||||
|
||||
public function openReservationsModal()
|
||||
{
|
||||
// Security check: only organizer can view
|
||||
if (!$this->isOrganizer) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->showReservationsModal = true;
|
||||
}
|
||||
|
||||
public function confirmReservation()
|
||||
{
|
||||
$this->showConfirmModal = false;
|
||||
|
||||
if (!$this->canReserveSecurityCheck()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->hasReserved) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->activeProfile->viaLoveReacter()->reactTo($this->post, 'Reserve');
|
||||
|
||||
// Update state
|
||||
$this->hasReserved = true;
|
||||
|
||||
// Refresh count from database
|
||||
$reactionType = \Cog\Laravel\Love\ReactionType\Models\ReactionType::fromName('Reserve');
|
||||
if ($reactionType) {
|
||||
$this->reservationCount = $this->post->getLoveReactant()->getReactionCounterOfType($reactionType)?->count ?? 0;
|
||||
}
|
||||
|
||||
\Log::info('Reservation added. hasReserved: ' . ($this->hasReserved ? 'true' : 'false') . ', count: ' . $this->reservationCount);
|
||||
|
||||
// Reload reservations list
|
||||
$this->loadReservations();
|
||||
} catch (\Exception $e) {
|
||||
\Log::error('Failed to add reservation: ' . $e->getMessage());
|
||||
session()->flash('error', __('Failed to add reservation. Please try again.'));
|
||||
}
|
||||
}
|
||||
|
||||
public function confirmCancellation()
|
||||
{
|
||||
$this->showCancelModal = false;
|
||||
|
||||
if (!$this->canReserveSecurityCheck()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->hasReserved) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->activeProfile->viaLoveReacter()->unReactTo($this->post, 'Reserve');
|
||||
$this->reservationCount = max(0, $this->reservationCount - 1);
|
||||
$this->hasReserved = false;
|
||||
|
||||
// Reload reservations list
|
||||
$this->loadReservations();
|
||||
} catch (\Exception $e) {
|
||||
\Log::error('Failed to remove reservation: ' . $e->getMessage());
|
||||
session()->flash('error', __('Failed to cancel reservation. Please try again.'));
|
||||
}
|
||||
}
|
||||
|
||||
private function canReserveSecurityCheck()
|
||||
{
|
||||
if (!$this->post || !isset($this->post->meeting)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!timebank_config('reactions.reserve.enabled', false)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->activeProfile instanceof \App\Models\Admin) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
!method_exists($this->activeProfile, 'isRegisteredAsLoveReacter') ||
|
||||
!$this->activeProfile->isRegisteredAsLoveReacter()
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function sendMessageToReserved()
|
||||
{
|
||||
// Security check: only organizer can send messages
|
||||
if (!$this->isOrganizer) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate the message
|
||||
$this->validate([
|
||||
'messageToReserved' => 'required|string|max:300',
|
||||
]);
|
||||
|
||||
// Strip HTML tags for security
|
||||
$cleanMessage = strip_tags($this->messageToReserved);
|
||||
|
||||
// Get the send delay configuration (same as bulk mail)
|
||||
$sendDelay = timebank_config('bulk_mail.send_delay_seconds', 2);
|
||||
|
||||
// Get all reserved participants
|
||||
$allReserved = [];
|
||||
foreach ($this->reservationsByType as $type => $reacters) {
|
||||
$allReserved = array_merge($allReserved, $reacters);
|
||||
}
|
||||
|
||||
// Get the organizer profile
|
||||
$organizer = getActiveProfile();
|
||||
|
||||
// Create group chat with all participants
|
||||
// Get organizer's locale for group name
|
||||
$groupLocale = $organizer->lang_preference ?? config('app.fallback_locale');
|
||||
|
||||
// Get post title for group name with fallback logic
|
||||
$groupPostTranslation = $this->post->translations->where('locale', $groupLocale)->first();
|
||||
if (!$groupPostTranslation) {
|
||||
$fallbackLocale = config('app.fallback_locale');
|
||||
$groupPostTranslation = $this->post->translations->where('locale', $fallbackLocale)->first();
|
||||
}
|
||||
if (!$groupPostTranslation) {
|
||||
$groupPostTranslation = $this->post->translations->first();
|
||||
}
|
||||
$groupName = $groupPostTranslation ? $groupPostTranslation->title : 'Event ' . $this->post->id;
|
||||
|
||||
// Create group conversation
|
||||
$groupConversation = $organizer->createGroup(
|
||||
name: $groupName,
|
||||
description: trans('messages.Reservation_update', [], $groupLocale)
|
||||
);
|
||||
|
||||
// Add all participants to the group (skip organizer as they're already added)
|
||||
foreach ($allReserved as $reacter) {
|
||||
// Skip if this is the organizer (already added when creating group)
|
||||
if (get_class($reacter) === get_class($organizer) && $reacter->id === $organizer->id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$groupConversation->addParticipant($reacter);
|
||||
}
|
||||
|
||||
// Construct the chat message using organizer's locale
|
||||
$chatMessage = $groupName . ' ' . __('update', [], $groupLocale) . ':' . PHP_EOL . PHP_EOL . $cleanMessage;
|
||||
|
||||
// Send message to the group
|
||||
$message = $organizer->sendMessageTo($groupConversation, $chatMessage);
|
||||
|
||||
// Broadcast to all participants for real-time notifications
|
||||
foreach ($allReserved as $reacter) {
|
||||
broadcast(new NotifyParticipant($reacter, $message));
|
||||
}
|
||||
|
||||
// Dispatch email jobs with delays
|
||||
$delay = 0;
|
||||
foreach ($allReserved as $reacter) {
|
||||
\App\Jobs\SendReservationUpdateMail::dispatch($reacter, $this->post, $cleanMessage, $organizer)
|
||||
->delay(now()->addSeconds($delay))
|
||||
->onQueue('emails');
|
||||
|
||||
$delay += $sendDelay;
|
||||
}
|
||||
|
||||
// Also send a copy to the organizer
|
||||
\App\Jobs\SendReservationUpdateMail::dispatch($organizer, $this->post, $cleanMessage, $organizer)
|
||||
->delay(now()->addSeconds($delay))
|
||||
->onQueue('emails');
|
||||
|
||||
\Log::info('Reservation update messages dispatched', [
|
||||
'post_id' => $this->post->id,
|
||||
'organizer_id' => $organizer->id,
|
||||
'recipients_count' => count($allReserved) + 1, // +1 for organizer
|
||||
'group_conversation_id' => $groupConversation->id
|
||||
]);
|
||||
|
||||
// Clear the message and show success
|
||||
$this->messageToReserved = '';
|
||||
$this->showReservationsModal = false;
|
||||
|
||||
$this->notification()->success(
|
||||
__('Reservation update sent!'),
|
||||
__('All participants have been notified by email and chat message.')
|
||||
);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.reserve-button');
|
||||
}
|
||||
}
|
||||
13
app/Http/Livewire/Roles/Create.php
Normal file
13
app/Http/Livewire/Roles/Create.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire\Roles;
|
||||
|
||||
use Livewire\Component;
|
||||
|
||||
class Create extends Component
|
||||
{
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.roles.create');
|
||||
}
|
||||
}
|
||||
13
app/Http/Livewire/Roles/Manage.php
Normal file
13
app/Http/Livewire/Roles/Manage.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire\Roles;
|
||||
|
||||
use Livewire\Component;
|
||||
|
||||
class Manage extends Component
|
||||
{
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.roles.manage');
|
||||
}
|
||||
}
|
||||
332
app/Http/Livewire/Search/Show.php
Normal file
332
app/Http/Livewire/Search/Show.php
Normal file
@@ -0,0 +1,332 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire\Search;
|
||||
|
||||
use App\Models\Bank;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Post;
|
||||
use App\Models\User;
|
||||
use App\Traits\LocationTrait;
|
||||
use App\Traits\ProfileTrait;
|
||||
use Illuminate\Pagination\Paginator;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithPagination;
|
||||
|
||||
class Show extends Component
|
||||
{
|
||||
use WithPagination;
|
||||
use ProfileTrait;
|
||||
use LocationTrait;
|
||||
|
||||
public $searchTerm = '';
|
||||
public $resultRefs = [];
|
||||
public $total = 0;
|
||||
public $perPage = 15;
|
||||
|
||||
protected $queryString = [
|
||||
'perPage' => ['except' => 15],
|
||||
'searchTerm' => ['except' => '']
|
||||
];
|
||||
|
||||
public function mount($resultRefs = [], $searchTerm = '', $total = 0)
|
||||
{
|
||||
$this->resultRefs = $resultRefs;
|
||||
$this->searchTerm = $searchTerm;
|
||||
$this->total = $total;
|
||||
|
||||
}
|
||||
|
||||
public function showProfile($id, $model)
|
||||
{
|
||||
$this->extendCachedResults();
|
||||
$modelName = __(strtolower($model));
|
||||
$this->redirectRoute('profile.show_by_type_and_id', ['type' => $modelName, 'id' => $id]);
|
||||
}
|
||||
|
||||
|
||||
public function showPost($id)
|
||||
{
|
||||
$this->extendCachedResults();
|
||||
$this->redirectRoute('post.show', ['id' => $id]);
|
||||
}
|
||||
|
||||
public function showCall($id)
|
||||
{
|
||||
$this->extendCachedResults();
|
||||
$this->redirectRoute('call.show', ['id' => $id]);
|
||||
}
|
||||
|
||||
|
||||
private function extendCachedResults()
|
||||
{
|
||||
$key = 'main_search_bar_results_' . Auth::guard('web')->id();
|
||||
$cacheData = cache()->get($key);
|
||||
|
||||
if ($cacheData) {
|
||||
// Re-store with extended TTL (e.g., reset TTL and add 5 more minutes)
|
||||
cache()->put($key, $cacheData, now()->addMinutes(timebank_config('main_search_bar.cache_results', 5)));
|
||||
}
|
||||
}
|
||||
|
||||
public function updatedPage()
|
||||
{
|
||||
$this->dispatch('scroll-to-top');
|
||||
}
|
||||
|
||||
|
||||
|
||||
public function render()
|
||||
{
|
||||
// Group model IDs by type for efficient eager loading
|
||||
$modelsByType = collect($this->resultRefs)->groupBy('model');
|
||||
|
||||
// Eager load models with their reaction data
|
||||
$loadedModels = [];
|
||||
foreach ($modelsByType as $modelClass => $refs) {
|
||||
$ids = collect($refs)->pluck('id')->toArray();
|
||||
|
||||
if ($modelClass === \App\Models\Call::class) {
|
||||
$callQuery = $modelClass::with([
|
||||
'callable.locations.city.translations',
|
||||
'callable.locations.division.translations',
|
||||
'callable.locations.country.translations',
|
||||
'translations' => function ($query) {
|
||||
$query->where('locale', App::getLocale());
|
||||
},
|
||||
'tag.contexts.category.translations',
|
||||
'tag.contexts.category.ancestors.translations',
|
||||
'location.city.translations',
|
||||
'location.country.translations',
|
||||
'loveReactant.reactionCounters',
|
||||
])->whereIn('id', $ids);
|
||||
if (!\Illuminate\Support\Facades\Auth::check()) {
|
||||
$callQuery->where('is_public', true);
|
||||
}
|
||||
$models = $callQuery->get()->keyBy('id');
|
||||
} elseif ($modelClass === \App\Models\Post::class) {
|
||||
// Posts don't need reaction loading for now
|
||||
$models = $modelClass::with([
|
||||
'postable' => function ($query) {
|
||||
$query->select(['id', 'name']);
|
||||
},
|
||||
'category' => function ($query) {
|
||||
$query->with('translations');
|
||||
},
|
||||
'translations' => function ($query) {
|
||||
$query
|
||||
->where('locale', App::getLocale())
|
||||
->whereDate('from', '<=', now())
|
||||
->where(function ($query) {
|
||||
$query->whereDate('till', '>', now())->orWhereNull('till');
|
||||
});
|
||||
},
|
||||
'meeting.location.district.translations',
|
||||
'meeting.location.city.translations',
|
||||
'meeting.location.country.translations',
|
||||
])->whereIn('id', $ids)->get()->keyBy('id');
|
||||
} else {
|
||||
// Profile models (User, Organization, Bank) with reaction data
|
||||
$models = $modelClass::with([
|
||||
'loveReactant.reactionCounters'
|
||||
])->whereIn('id', $ids)->get()->keyBy('id');
|
||||
}
|
||||
|
||||
$loadedModels[$modelClass] = $models;
|
||||
}
|
||||
|
||||
// Map refs to actual models and result arrays
|
||||
$results = collect($this->resultRefs)->map(function ($ref) use ($loadedModels) {
|
||||
$modelClass = $ref['model'];
|
||||
$model = $loadedModels[$modelClass][$ref['id']] ?? null;
|
||||
|
||||
if (!$model) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$highlight = $ref['highlight'] ?? [];
|
||||
$score = $ref['score'] ?? null;
|
||||
|
||||
// POST & CALL MODELS
|
||||
switch ($modelClass) {
|
||||
case \App\Models\Call::class:
|
||||
$translation = $model->translations->first();
|
||||
$tag = $model->tag;
|
||||
$tagContext = $tag?->contexts->first();
|
||||
$tagCategory = $tagContext?->category;
|
||||
$tagColor = $tagCategory?->relatedColor ?? 'gray';
|
||||
$tagName = $tag?->translation?->name ?? $tag?->name;
|
||||
$locationStr = null;
|
||||
if ($model->location) {
|
||||
$loc = $model->location;
|
||||
$locParts = [];
|
||||
if ($loc->city) {
|
||||
$cityName = optional($loc->city->translations->first())->name;
|
||||
if ($cityName) $locParts[] = $cityName;
|
||||
}
|
||||
if ($loc->country) {
|
||||
if ($loc->country->code === 'XX') {
|
||||
$locParts[] = __('Location not specified');
|
||||
} elseif ($loc->country->code) {
|
||||
$locParts[] = strtoupper($loc->country->code);
|
||||
}
|
||||
}
|
||||
$locationStr = $locParts ? implode(', ', $locParts) : null;
|
||||
}
|
||||
|
||||
// Build category hierarchy: ancestors (root first) + self
|
||||
$tagCategories = [];
|
||||
if ($tagCategory) {
|
||||
$ancestors = $tagCategory->ancestorsAndSelf()->get()->reverse();
|
||||
$locale = App::getLocale();
|
||||
foreach ($ancestors as $cat) {
|
||||
$catName = $cat->translations->firstWhere('locale', $locale)?->name
|
||||
?? $cat->translations->first()?->name
|
||||
?? '';
|
||||
if ($catName) {
|
||||
$tagCategories[] = [
|
||||
'name' => $catName,
|
||||
'color' => $cat->relatedColor ?? 'gray',
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $model->id,
|
||||
'model' => $modelClass,
|
||||
'category_id' => null,
|
||||
'category' => trans_with_platform('@PLATFORM_NAME@ call'),
|
||||
'title' => $tagName ?? '',
|
||||
'excerpt' => $translation?->content ?? '',
|
||||
'photo' => $model->callable?->profile_photo_url ?? '',
|
||||
'location' => $locationStr,
|
||||
'location_short' => $city ?? '',
|
||||
'tag_name' => $tagName,
|
||||
'tag_color' => $tagColor,
|
||||
'tag_categories' => $tagCategories,
|
||||
'callable_name' => $model->callable?->name ?? '',
|
||||
'callable_location' => \App\Http\Livewire\Calls\ProfileCalls::buildCallableLocation($model->callable),
|
||||
'till' => $model->till,
|
||||
'expiry_badge_text' => \App\Http\Livewire\Calls\ProfileCalls::buildExpiryBadgeText($model->till),
|
||||
'meeting_venue' => '',
|
||||
'meeting_address' => '',
|
||||
'meeting_city' => '',
|
||||
'meeting_location' => '',
|
||||
'meeting_date_from' => '',
|
||||
'meeting_date_till' => '',
|
||||
'status' => '',
|
||||
'highlight' => $highlight,
|
||||
'score' => $score,
|
||||
'like_count' => $model->loveReactant?->reactionCounters->firstWhere('reaction_type_id', 3)?->count ?? 0,
|
||||
'bookmark_count' => $model->loveReactant?->reactionCounters->firstWhere('reaction_type_id', 2)?->count ?? 0,
|
||||
'star_count' => 0,
|
||||
];
|
||||
|
||||
case \App\Models\Post::class:
|
||||
$categoryId = optional($model->category)->id;
|
||||
$categoryName = optional($model->category->translations->where('locale', app()->getLocale())->first())->name;
|
||||
$photoUrl = $model->getFirstMediaUrl('posts', 'hero');
|
||||
|
||||
return [
|
||||
'id' => $model->id,
|
||||
'model' => $modelClass,
|
||||
'category_id' => $categoryId,
|
||||
'category' => $categoryName,
|
||||
'title' => $model->translations->first()->title,
|
||||
'excerpt' => $model->translations->first()->excerpt,
|
||||
'photo' => $photoUrl,
|
||||
'location' => '',
|
||||
'location_short' => '',
|
||||
'meeting_venue' => $model->meeting->venue ?? '',
|
||||
'meeting_address' => $model->meeting->address ?? '',
|
||||
'meeting_city' => $model->meeting->location->city->translations ?? '',
|
||||
'meeting_location' => $model->meeting ? $model->meeting->getLocationFirst() : '',
|
||||
'meeting_date_from' => $model->meeting->from ?? '',
|
||||
'meeting_date_till' => $model->meeting->till ?? '',
|
||||
'status' => '',
|
||||
'highlight' => $highlight,
|
||||
'score' => $score,
|
||||
'bookmark_count' => 0,
|
||||
'star_count' => 0,
|
||||
'like_count' => 0,
|
||||
];
|
||||
|
||||
// PROFILE MODELS
|
||||
case \App\Models\User::class:
|
||||
case \App\Models\Organization::class:
|
||||
case \App\Models\Bank::class:
|
||||
// Extract reaction counts using Laravel-Love's built-in methods
|
||||
$bookmarkCount = 0;
|
||||
$starCount = 0;
|
||||
$likeCount = 0;
|
||||
|
||||
if ($model->loveReactant) {
|
||||
// Get reaction types first
|
||||
$bookmarkType = \Cog\Laravel\Love\ReactionType\Models\ReactionType::fromName('bookmark');
|
||||
$starType = \Cog\Laravel\Love\ReactionType\Models\ReactionType::fromName('star');
|
||||
$likeType = \Cog\Laravel\Love\ReactionType\Models\ReactionType::fromName('like');
|
||||
|
||||
// Get counts using the ReactionType objects and extract the count value
|
||||
if ($bookmarkType) {
|
||||
$bookmarkCounter = $model->loveReactant->getReactionCounterOfType($bookmarkType);
|
||||
$bookmarkCount = $bookmarkCounter->getCount();
|
||||
}
|
||||
if ($starType) {
|
||||
$starCounter = $model->loveReactant->getReactionCounterOfType($starType);
|
||||
$starCount = $starCounter->getCount();
|
||||
}
|
||||
if ($likeType) {
|
||||
$likeCounter = $model->loveReactant->getReactionCounterOfType($likeType);
|
||||
$likeCount = $likeCounter->getCount();
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $model->id,
|
||||
'model' => $modelClass,
|
||||
'name' => $model->name,
|
||||
'full_name' => $model->full_name,
|
||||
'about_short' => $model->about_short,
|
||||
'about' => $model->about,
|
||||
'motivation' => $model->motivation,
|
||||
'photo' => $model->profile_photo_path,
|
||||
'location' => $model->getLocationFirst(false)['name'] ?? '',
|
||||
'location_short' => $model->getLocationFirst(false)['name_short'] ?? '',
|
||||
'skills' => $this->getSkills($model) ?? '',
|
||||
'cyclos_skills' => $model->cyclos_skills ?? '',
|
||||
'deleted_at' => $model->deleted_at,
|
||||
'status' => '',
|
||||
'highlight' => $highlight,
|
||||
'score' => $score,
|
||||
'bookmark_count' => $bookmarkCount,
|
||||
'star_count' => $starCount,
|
||||
'like_count' => $likeCount,
|
||||
];
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})->filter()->values();
|
||||
|
||||
// Use Livewire's built-in pagination
|
||||
$currentPage = $this->getPage();
|
||||
$results = $results->slice(($currentPage - 1) * $this->perPage, $this->perPage)->values();
|
||||
|
||||
$paginator = new \Illuminate\Pagination\LengthAwarePaginator(
|
||||
$results,
|
||||
collect($this->resultRefs)->count(),
|
||||
$this->perPage,
|
||||
$currentPage,
|
||||
[
|
||||
'path' => request()->url(),
|
||||
'pageName' => 'page'
|
||||
]
|
||||
);
|
||||
|
||||
$paginator->withQueryString();
|
||||
|
||||
return view('livewire.search.show', ['results' => $paginator]);
|
||||
}
|
||||
}
|
||||
97
app/Http/Livewire/SearchInfoModal.php
Normal file
97
app/Http/Livewire/SearchInfoModal.php
Normal file
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire;
|
||||
|
||||
use App\Models\Post;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Livewire\Component;
|
||||
|
||||
class SearchInfoModal extends Component
|
||||
{
|
||||
public $show = false;
|
||||
public $post = null;
|
||||
public $image = null;
|
||||
public $imageCaption = null;
|
||||
public $imageOwner = null;
|
||||
public $fallbackTitle;
|
||||
public $fallbackDescription;
|
||||
|
||||
protected $listeners = ['openSearchInfoModal' => 'open'];
|
||||
|
||||
public function mount()
|
||||
{
|
||||
$this->fallbackTitle = __('How search works');
|
||||
$this->fallbackDescription = __('The search bar helps you find people, organizations, events and posts. Posts and events are pushed to the top. People or organizations nearby your location get also a higher search ranking. ');
|
||||
}
|
||||
|
||||
public function open()
|
||||
{
|
||||
$this->show = true;
|
||||
$this->loadPost();
|
||||
}
|
||||
|
||||
public function loadPost()
|
||||
{
|
||||
$locale = App::getLocale();
|
||||
|
||||
$this->post = Post::with([
|
||||
'category',
|
||||
'media',
|
||||
'translations' => function ($query) use ($locale) {
|
||||
$query->where('locale', $locale)
|
||||
->orderBy('created_at', 'desc')
|
||||
->limit(3);
|
||||
}
|
||||
])
|
||||
->whereHas('category', function ($query) {
|
||||
$query->where('type', 'SiteContents\Search\Info');
|
||||
})
|
||||
->whereHas('translations', function ($query) use ($locale) {
|
||||
$query->where('locale', $locale)
|
||||
->whereDate('from', '<=', now())
|
||||
->where(function ($query) {
|
||||
$query->whereDate('till', '>', now())->orWhereNull('till');
|
||||
})
|
||||
->orderBy('updated_at', 'desc');
|
||||
})
|
||||
->orderBy('created_at', 'desc')
|
||||
->limit(3)
|
||||
->first();
|
||||
|
||||
if ($this->post && $this->post->hasMedia('posts')) {
|
||||
$this->image = $this->post->getFirstMediaUrl('posts', 'half_hero');
|
||||
$mediaItem = $this->post->getFirstMedia('posts');
|
||||
if ($mediaItem) {
|
||||
// Get owner
|
||||
$this->imageOwner = $mediaItem->getCustomProperty('owner');
|
||||
|
||||
// Try to get caption for current locale
|
||||
$this->imageCaption = $mediaItem->getCustomProperty('caption-' . $locale);
|
||||
|
||||
// If not found, try fallback locales
|
||||
if (!$this->imageCaption) {
|
||||
$fallbackLocales = ['en', 'nl', 'de', 'es', 'fr'];
|
||||
foreach ($fallbackLocales as $fallbackLocale) {
|
||||
$this->imageCaption = $mediaItem->getCustomProperty('caption-' . $fallbackLocale);
|
||||
if ($this->imageCaption) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function close()
|
||||
{
|
||||
$this->show = false;
|
||||
$this->image = null;
|
||||
$this->imageCaption = null;
|
||||
$this->imageOwner = null;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.search-info-modal');
|
||||
}
|
||||
}
|
||||
108
app/Http/Livewire/SelectPeriod.php
Normal file
108
app/Http/Livewire/SelectPeriod.php
Normal file
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Livewire\Component;
|
||||
|
||||
class SelectPeriod extends Component
|
||||
{
|
||||
public $selectedPeriod = 'previous_year';
|
||||
public $customFromDate;
|
||||
public $customToDate;
|
||||
public $label;
|
||||
|
||||
protected $rules = [
|
||||
'customFromDate' => 'nullable|date',
|
||||
'customToDate' => 'nullable|date|after_or_equal:customFromDate',
|
||||
];
|
||||
|
||||
protected $messages = [
|
||||
'customFromDate.date' => 'The from date must be a valid date.',
|
||||
'customToDate.date' => 'The to date must be a valid date.',
|
||||
'customToDate.after_or_equal' => 'The to date must be after or equal to the from date.',
|
||||
];
|
||||
|
||||
public function mount($label = 'Select Period')
|
||||
{
|
||||
$this->label = $label;
|
||||
$this->setPeriodDates();
|
||||
}
|
||||
|
||||
public function updatedSelectedPeriod()
|
||||
{
|
||||
$this->setPeriodDates();
|
||||
}
|
||||
|
||||
public function applyCustomPeriod(string $fromDate = '', string $toDate = '')
|
||||
{
|
||||
$this->customFromDate = $fromDate ?: null;
|
||||
$this->customToDate = $toDate ?: null;
|
||||
$this->validate();
|
||||
$this->dispatchDates();
|
||||
}
|
||||
|
||||
private function setPeriodDates()
|
||||
{
|
||||
$fromDate = null;
|
||||
$toDate = null;
|
||||
|
||||
switch ($this->selectedPeriod) {
|
||||
case 'previous_year':
|
||||
$fromDate = Carbon::now()->subYear()->startOfYear()->format('Y-m-d');
|
||||
$toDate = Carbon::now()->subYear()->endOfYear()->format('Y-m-d');
|
||||
break;
|
||||
|
||||
case 'previous_quarter':
|
||||
$fromDate = Carbon::now()->subQuarter()->startOfQuarter()->format('Y-m-d');
|
||||
$toDate = Carbon::now()->subQuarter()->endOfQuarter()->format('Y-m-d');
|
||||
break;
|
||||
|
||||
case 'previous_month':
|
||||
$fromDate = Carbon::now()->subMonth()->startOfMonth()->format('Y-m-d');
|
||||
$toDate = Carbon::now()->subMonth()->endOfMonth()->format('Y-m-d');
|
||||
break;
|
||||
|
||||
case 'custom':
|
||||
$fromDate = $this->customFromDate;
|
||||
$toDate = $this->customToDate;
|
||||
break;
|
||||
}
|
||||
|
||||
if ($this->selectedPeriod !== 'custom') {
|
||||
$this->customFromDate = $fromDate;
|
||||
$this->customToDate = $toDate;
|
||||
}
|
||||
|
||||
$this->dispatchDates();
|
||||
}
|
||||
|
||||
private function dispatchDates()
|
||||
{
|
||||
// If no period is selected, dispatch clear event
|
||||
if (!$this->selectedPeriod) {
|
||||
$this->dispatch('periodCleared');
|
||||
return;
|
||||
}
|
||||
|
||||
// For custom periods, ensure both dates are provided before dispatching
|
||||
if ($this->selectedPeriod === 'custom') {
|
||||
if (!$this->customFromDate || !$this->customToDate) {
|
||||
// If custom is selected but dates are missing, dispatch clear
|
||||
$this->dispatch('periodCleared');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$this->dispatch('periodSelected', [
|
||||
'fromDate' => $this->customFromDate,
|
||||
'toDate' => $this->customToDate,
|
||||
'period' => $this->selectedPeriod
|
||||
]);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.select-period');
|
||||
}
|
||||
}
|
||||
141
app/Http/Livewire/SidePost.php
Normal file
141
app/Http/Livewire/SidePost.php
Normal file
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Livewire;
|
||||
|
||||
use App\Models\Post;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Livewire\Component;
|
||||
|
||||
class SidePost extends Component
|
||||
{
|
||||
public $type;
|
||||
public bool $sticky = false;
|
||||
public bool $random = false;
|
||||
public bool $latest = false;
|
||||
public $fallbackTitle = null;
|
||||
public $fallbackDescription = null;
|
||||
public bool $alwaysShowFull = false;
|
||||
|
||||
public function mount($type, $sticky = null, $random = null, $latest = null, $fallbackTitle = null, $fallbackDescription = null, $alwaysShowFull = false)
|
||||
{
|
||||
$this->type = $type;
|
||||
$this->fallbackTitle = $fallbackTitle;
|
||||
$this->fallbackDescription = $fallbackDescription;
|
||||
|
||||
if ($sticky) {
|
||||
$this->sticky = true;
|
||||
}
|
||||
if ($random) {
|
||||
$this->random = true;
|
||||
}
|
||||
if ($latest) {
|
||||
$this->latest = true;
|
||||
}
|
||||
if ($alwaysShowFull) {
|
||||
$this->alwaysShowFull = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public function render()
|
||||
{
|
||||
$post = null;
|
||||
|
||||
// Sticky post
|
||||
if ($this->sticky) {
|
||||
$locale = App::getLocale();
|
||||
|
||||
$post = Post::with([
|
||||
'category',
|
||||
'images' => function ($query) {
|
||||
$query->select('images.id', 'caption', 'path');
|
||||
},
|
||||
'translations' => function ($query) use ($locale) {
|
||||
$query->where('locale', $locale)
|
||||
->orderBy('created_at', 'desc')
|
||||
->limit(3);
|
||||
}
|
||||
])
|
||||
->whereHas('category', function ($query) {
|
||||
$query->where('type', $this->type);
|
||||
})
|
||||
->whereHas('translations', function ($query) use ($locale) {
|
||||
$query->where('locale', $locale)
|
||||
->whereDate('from', '<=', now())
|
||||
->where(function ($query) {
|
||||
$query->whereDate('till', '>', now())->orWhereNull('till');
|
||||
})
|
||||
->orderBy('updated_at', 'desc');
|
||||
})
|
||||
->orderBy('created_at', 'desc')
|
||||
->limit(3)
|
||||
->first();
|
||||
}
|
||||
|
||||
|
||||
// Random post
|
||||
if ($this->random) {
|
||||
$locale = App::getLocale();
|
||||
|
||||
$post = Post::with([
|
||||
'category',
|
||||
'translations' => function ($query) use ($locale) {
|
||||
$query->where('locale', $locale)
|
||||
->orderBy('created_at', 'desc')
|
||||
->limit(1);
|
||||
}
|
||||
])
|
||||
->whereHas('category', function ($query) {
|
||||
$query->where('type', $this->type);
|
||||
})
|
||||
->whereHas('translations', function ($query) use ($locale) {
|
||||
$query->where('locale', $locale)
|
||||
->whereDate('from', '<=', now())
|
||||
->where(function ($query) {
|
||||
$query->whereDate('till', '>', now())->orWhereNull('till');
|
||||
})
|
||||
->orderBy('updated_at', 'desc');
|
||||
})
|
||||
->inRandomOrder() // This replaces the orderBy() method
|
||||
->first();
|
||||
}
|
||||
|
||||
// Latest post
|
||||
if ($this->latest) {
|
||||
$locale = App::getLocale();
|
||||
|
||||
$post = Post::with([
|
||||
'category',
|
||||
'translations' => function ($query) use ($locale) {
|
||||
$query->where('locale', $locale)
|
||||
->orderBy('created_at', 'desc')
|
||||
->limit(1);
|
||||
}
|
||||
])
|
||||
->whereHas('category', function ($query) {
|
||||
$query->where('type', $this->type);
|
||||
})
|
||||
->whereHas('translations', function ($query) use ($locale) {
|
||||
$query->where('locale', $locale)
|
||||
->whereDate('from', '<=', now())
|
||||
->where(function ($query) {
|
||||
$query->whereDate('till', '>', now())->orWhereNull('till');
|
||||
})
|
||||
->orderBy('updated_at', 'desc');
|
||||
})
|
||||
->orderBy('created_at', 'desc')
|
||||
->first();
|
||||
}
|
||||
|
||||
|
||||
$image = null;
|
||||
if ($post && $post->hasMedia('*')) {
|
||||
$image = $post->getFirstMediaUrl('*', 'half_hero');
|
||||
}
|
||||
|
||||
return view('livewire.side-post', [
|
||||
'post' => $post,
|
||||
'image' => $image,
|
||||
]);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user