Initial commit

This commit is contained in:
Ronald Huynen
2026-03-23 21:37:59 +01:00
commit 2547717edb
2193 changed files with 972171 additions and 0 deletions

View 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;
}
}

View 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');
}
}

View 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,
]);
}
}

View 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 ?? '',
]);
}
}

View 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,
]);
}
}

View 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]);
}
}
}

View 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');
}
}