openclassify/Modules/Listing/Models/Listing.php
2026-03-07 22:23:53 +03:00

435 lines
14 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace Modules\Listing\Models;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Support\Arr;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Modules\Category\Models\Category;
use Modules\Listing\States\ListingStatus;
use Modules\Listing\Support\ListingPanelHelper;
use Modules\Video\Models\Video;
use Spatie\Activitylog\LogOptions;
use Spatie\Activitylog\Traits\LogsActivity;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
use Spatie\ModelStates\HasStates;
class Listing extends Model implements HasMedia
{
use HasFactory, HasStates, InteractsWithMedia, LogsActivity;
private const DEFAULT_PANEL_EXPIRY_WINDOW_DAYS = 30;
protected $fillable = [
'title', 'description', 'price', 'currency', 'category_id',
'user_id', 'status', 'images', 'custom_fields', 'slug',
'contact_phone', 'contact_email', 'is_featured', 'expires_at',
'city', 'country', 'latitude', 'longitude', 'location', 'view_count',
];
protected $casts = [
'images' => 'array',
'custom_fields' => 'array',
'is_featured' => 'boolean',
'view_count' => 'integer',
'expires_at' => 'datetime',
'price' => 'decimal:2',
'latitude' => 'decimal:7',
'longitude' => 'decimal:7',
'status' => ListingStatus::class,
];
protected $appends = ['location'];
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logFillable()
->logOnlyDirty()
->dontSubmitEmptyLogs();
}
public function category()
{
return $this->belongsTo(\Modules\Category\Models\Category::class);
}
public function user()
{
return $this->belongsTo(\Modules\User\App\Models\User::class);
}
public function favoritedByUsers()
{
return $this->belongsToMany(\Modules\User\App\Models\User::class, 'favorite_listings')
->withTimestamps();
}
public function conversations()
{
return $this->hasMany(\Modules\Conversation\App\Models\Conversation::class);
}
public function videos()
{
return $this->hasMany(Video::class)->ordered();
}
public function scopePublicFeed(Builder $query): Builder
{
return $query
->active()
->orderByDesc('is_featured')
->orderByDesc('created_at');
}
public function scopeActive(Builder $query): Builder
{
return $query->where('status', 'active');
}
public function scopeOwnedByUser(Builder $query, int | string | null $userId): Builder
{
return $query->where('user_id', $userId);
}
public function scopeForPanelStatus(Builder $query, string $status): Builder
{
return match ($status) {
'sold', 'expired', 'pending', 'active' => $query->where('status', $status),
default => $query,
};
}
public function scopeSearchTerm(Builder $query, string $search): Builder
{
$search = trim($search);
if ($search === '') {
return $query;
}
return $query->where(function (Builder $searchQuery) use ($search): void {
$searchQuery
->where('title', 'like', "%{$search}%")
->orWhere('description', 'like', "%{$search}%")
->orWhere('city', 'like', "%{$search}%")
->orWhere('country', 'like', "%{$search}%");
});
}
public function scopeForCategory(Builder $query, ?int $categoryId): Builder
{
return $query->forCategoryIds(Category::listingFilterIds($categoryId));
}
public function scopeForCategoryIds(Builder $query, ?array $categoryIds): Builder
{
if ($categoryIds === null) {
return $query;
}
if ($categoryIds === []) {
return $query->whereRaw('1 = 0');
}
return $query->whereIn('category_id', $categoryIds);
}
public function scopeForBrowseFilters(Builder $query, array $filters): Builder
{
$search = trim((string) ($filters['search'] ?? ''));
$country = isset($filters['country']) ? trim((string) $filters['country']) : null;
$city = isset($filters['city']) ? trim((string) $filters['city']) : null;
$minPrice = is_numeric($filters['min_price'] ?? null) ? max((float) $filters['min_price'], 0) : null;
$maxPrice = is_numeric($filters['max_price'] ?? null) ? max((float) $filters['max_price'], 0) : null;
$dateFilter = (string) ($filters['date_filter'] ?? 'all');
$categoryIds = $filters['category_ids'] ?? null;
$query
->searchTerm($search)
->forCategoryIds(is_array($categoryIds) ? $categoryIds : null)
->when($country !== null && $country !== '', fn (Builder $builder) => $builder->where('country', $country))
->when($city !== null && $city !== '', fn (Builder $builder) => $builder->where('city', $city))
->when(! is_null($minPrice), fn (Builder $builder) => $builder->whereNotNull('price')->where('price', '>=', $minPrice))
->when(! is_null($maxPrice), fn (Builder $builder) => $builder->whereNotNull('price')->where('price', '<=', $maxPrice));
return match ($dateFilter) {
'today' => $query->where('created_at', '>=', Carbon::now()->startOfDay()),
'week' => $query->where('created_at', '>=', Carbon::now()->subDays(7)),
'month' => $query->where('created_at', '>=', Carbon::now()->subDays(30)),
default => $query,
};
}
public function scopeApplyBrowseSort(Builder $query, string $sort): Builder
{
return match ($sort) {
'newest' => $query->reorder()->orderByDesc('created_at'),
'oldest' => $query->reorder()->orderBy('created_at'),
'price_asc' => $query->reorder()->orderByRaw('price is null')->orderBy('price'),
'price_desc' => $query->reorder()->orderByRaw('price is null')->orderByDesc('price'),
default => $query->reorder()->orderByDesc('is_featured')->orderByDesc('created_at'),
};
}
public function themeGallery(): array
{
$mediaUrls = $this->getMedia('listing-images')
->map(fn ($media): string => $media->getUrl())
->filter(fn (string $url): bool => $url !== '')
->values()
->all();
if ($mediaUrls !== []) {
return $mediaUrls;
}
return collect($this->images ?? [])
->filter(fn ($value): bool => is_string($value) && trim($value) !== '')
->values()
->all();
}
public function relatedSuggestions(int $limit = 8): Collection
{
$baseQuery = static::query()
->publicFeed()
->with(['category:id,name', 'videos'])
->whereKeyNot($this->getKey());
$primary = (clone $baseQuery)
->forCategory($this->category_id ? (int) $this->category_id : null)
->limit($limit)
->get();
if ($primary->count() >= $limit) {
return $primary;
}
$missing = $limit - $primary->count();
$excludeIds = $primary->pluck('id')->push($this->getKey())->all();
$fallback = (clone $baseQuery)
->whereNotIn('id', $excludeIds)
->limit($missing)
->get();
return $primary->concat($fallback)->values();
}
public static function panelStatusOptions(): array
{
return [
'pending' => 'Pending',
'active' => 'Active',
'sold' => 'Sold',
'expired' => 'Expired',
];
}
public static function panelStatusCountsForUser(int | string $userId): array
{
$counts = static::query()
->ownedByUser($userId)
->selectRaw('status, COUNT(*) as aggregate')
->groupBy('status')
->pluck('aggregate', 'status');
return [
'all' => (int) $counts->sum(),
'active' => (int) ($counts['active'] ?? 0),
'pending' => (int) ($counts['pending'] ?? 0),
'sold' => (int) ($counts['sold'] ?? 0),
'expired' => (int) ($counts['expired'] ?? 0),
];
}
public function panelPrimaryImageUrl(): ?string
{
$url = trim((string) $this->getFirstMediaUrl('listing-images'));
return $url !== '' ? $url : null;
}
public function panelPriceLabel(): string
{
if (is_null($this->price)) {
return 'Ücretsiz';
}
return number_format((float) $this->price, 2, ',', '.').' '.($this->currency ?? 'TL');
}
public function panelStatusMeta(): array
{
return match ($this->statusValue()) {
'sold' => [
'label' => 'Satıldı',
'badge_class' => 'is-success',
'hint' => 'İlan satıldı olarak işaretlendi.',
],
'expired' => [
'label' => 'Süresi doldu',
'badge_class' => 'is-danger',
'hint' => 'Yeniden yayına alınmayı bekliyor.',
],
'pending' => [
'label' => 'İncelemede',
'badge_class' => 'is-warning',
'hint' => 'Moderasyon onayı bekleniyor.',
],
default => [
'label' => 'Yayında',
'badge_class' => 'is-primary',
'hint' => 'Şu anda ziyaretçilere görünüyor.',
],
};
}
public function panelLocationLabel(): string
{
$parts = collect([
trim((string) $this->city),
trim((string) $this->country),
])->filter()->values();
return $parts->isNotEmpty() ? $parts->implode(', ') : 'Konum belirtilmedi';
}
public function panelPublishedAt(): ?Carbon
{
$createdAt = $this->created_at?->copy();
$expiresAt = $this->expires_at?->copy();
if (! $createdAt) {
return $expiresAt;
}
if (! $expiresAt || $expiresAt->greaterThanOrEqualTo($createdAt)) {
return $createdAt;
}
return $expiresAt->subDays(self::DEFAULT_PANEL_EXPIRY_WINDOW_DAYS);
}
public function panelExpirySummary(): string
{
if (! $this->expires_at) {
return 'Süre sınırı yok';
}
$expiresAt = $this->expires_at->copy()->startOfDay();
$days = Carbon::today()->diffInDays($expiresAt, false);
return match (true) {
$days > 0 => $days.' gün kaldı',
$days === 0 => 'Bugün sona eriyor',
default => abs($days).' gün önce sona erdi',
};
}
public function panelVideoSummary(int $total, int $ready, int $pending): ?array
{
if ($total < 1) {
return null;
}
return [
'label' => $total.' video',
'detail' => $ready.' hazır'.($pending > 0 ? ', '.$pending.' işleniyor' : ''),
];
}
public function statusValue(): string
{
return $this->status instanceof ListingStatus
? $this->status->getValue()
: (string) $this->status;
}
public function statusLabel(): string
{
return match ($this->statusValue()) {
'sold' => 'Sold',
'expired' => 'Expired',
'pending' => 'Pending',
default => 'Active',
};
}
public function updateFromPanel(array $attributes): void
{
$payload = Arr::only($attributes, [
'title',
'description',
'price',
'status',
'contact_phone',
'contact_email',
'country',
'city',
'expires_at',
]);
if (array_key_exists('currency', $attributes)) {
$payload['currency'] = ListingPanelHelper::normalizeCurrency($attributes['currency']);
}
if (array_key_exists('custom_fields', $attributes)) {
$payload['custom_fields'] = $attributes['custom_fields'];
}
$this->forceFill($payload)->save();
}
public static function createFromFrontend(array $data, null | int | string $userId): self
{
$baseSlug = Str::slug((string) ($data['title'] ?? 'listing'));
$baseSlug = $baseSlug !== '' ? $baseSlug : 'listing';
do {
$slug = $baseSlug.'-'.Str::random(6);
} while (static::query()->where('slug', $slug)->exists());
$payload = $data;
$payload['user_id'] = $userId;
$payload['currency'] = ListingPanelHelper::normalizeCurrency($data['currency'] ?? null);
$payload['slug'] = $slug;
return static::query()->create($payload);
}
public function registerMediaCollections(): void
{
$this->addMediaCollection('listing-images');
}
protected function location(): Attribute
{
return Attribute::make(
get: function (mixed $value, array $attributes): ?array {
$latitude = $attributes['latitude'] ?? null;
$longitude = $attributes['longitude'] ?? null;
if ($latitude === null || $longitude === null) {
return null;
}
return [
'lat' => (float) $latitude,
'lng' => (float) $longitude,
];
},
set: fn (?array $value): array => [
'latitude' => is_array($value) ? ($value['lat'] ?? null) : null,
'longitude' => is_array($value) ? ($value['lng'] ?? null) : null,
],
);
}
}