*/ use HasFactory; protected function casts(): array { return [ 'tags' => 'array', 'features' => 'array', 'featured' => 'boolean', 'approved' => 'boolean', ]; } protected static function booted(): void { static::creating(function (Tool $tool) { if (empty($tool->slug)) { $tool->slug = Str::slug($tool->name); } }); static::created(function (Tool $tool) { $tool->queueLogoMirrorIfNeeded(); }); static::updated(function (Tool $tool) { if ($tool->wasChanged('logo_url')) { $tool->queueLogoMirrorIfNeeded(); } }); } public function hasLocalLogo(): bool { return str_starts_with((string) $this->logo_url, rtrim(config('app.url'), '/').'/storage/tools/'); } public function queueLogoMirrorIfNeeded(): void { if (! $this->hasLocalLogo()) { MirrorToolLogo::dispatch($this); } } /** * @return BelongsTo */ public function user(): BelongsTo { return $this->belongsTo(User::class); } /** * @param Builder $query */ #[Scope] protected function approved(Builder $query): void { $query->where('approved', true); } protected function categoryLabel(): Attribute { return Attribute::get(fn () => config("tools.categories.{$this->category}", $this->category)); } /** * Structured-data-safe price, parsed from the free-text `pricing` field * (e.g. "Free", "$29/month"). schema.org's Offer.price expects a plain * number with a currency, so values we can't parse into that (e.g. * "Freemium", "Contact for pricing") are left out rather than guessed. */ protected function offerData(): Attribute { return Attribute::get(function () { if (! $this->pricing) { return null; } if (strcasecmp($this->pricing, 'free') === 0) { return ['price' => '0', 'priceCurrency' => 'USD']; } if (preg_match('/^\$(\d+(?:\.\d+)?)/', $this->pricing, $matches)) { return ['price' => $matches[1], 'priceCurrency' => 'USD']; } return null; }); } }