Herding cats
Multiple sports apps without a DB
There is technically a DB
Still prototyping front-end
Route::prefix('/{article_type}')
->name('articles.')
->group(function () use ($regex) {
Route::get('/{article}', Articles\ShowDetailResponder::class)
->name('detail')
->where('article_type', '(articles|opinions)')
->where('article', $regex);
});
Route::bind('article', function (string $value) {
return Content\ArticleProxy::findOrFail($value);
});
namespace App\Proxies\Content;
use App\Proxies\Concerns\CanFindConcern;
use App\Proxies\Concerns\CanFindOrFailConcern;
use App\Proxies\Concerns\CanMarshalConcern;
use App\Services\Sportal\Components\Content\Articles;
use Illuminate\Support\Carbon;
class ArticleProxy
{
use CanFindConcern;
use CanFindOrFailConcern;
use CanMarshalConcern;
public function __construct(
public int $id,
public string $title,
public null|string $subTitle,
public null|string $strapLine,
public null|string $footer,
public array $body,
public null|Carbon $createdAt,
public null|Carbon $updatedAt,
public null|Carbon $publishedAt,
public string $status,
public array $comments,
public array $urls,
public array $seo,
public string $language,
public bool $shouldRunAds,
public bool $isImportant,
public bool $isBettingContent,
public bool $isAdultContent,
public bool $isSensitiveContent,
public array $category,
public array $additionalCategories,
public array $authors,
public array $mainMedia,
public array $createdBy,
) {
}
public static function findUsing(): string
{
return Articles::class;
}
public static function marshalUsing(): array
{
return [
'id',
'title',
'subTitle' => 'subtitle',
'strapLine' => 'strapline',
'footer',
'body',
'createdAt' => fn (array $data) => static::date($data, 'created_at'),
'updatedAt' => fn (array $data) => static::date($data, 'updated_at'),
'publishedAt' => fn (array $data) => static::date($data, 'published_at'),
'status',
'comments',
'urls',
'seo',
'language',
'shouldRunAds' => 'run_ads',
'isImportant' => 'important',
'isBettingContent' => 'betting_content',
'isAdultContent' => fn (array $data) => $data['is_adult_content'] || $data['adult_content'],
'isSensitiveContent' => fn (array $data) => $data['is_sensitive_content'] || $data['sensitive_content'],
'category',
'additionalCategories' => 'additional_categories',
'authors',
'mainMedia' => 'main_media',
'createdBy' => 'created_by',
];
}
}
namespace App\Services\Sportal\Components\Content;
use App\Services\Sportal\Components\Concerns\CanSearchConcern;
use GuzzleHttp\Promise\PromiseInterface;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Http;
class Articles
{
use CanSearchConcern;
/**
* @throws ConnectionException
*
* @return PromiseInterface<array|null>
*/
public function find(string $id): PromiseInterface
{
return Http::sportalContent()
->withUrlParameters(['id' => $id])
->get(config('services.sportal-content.endpoints.articles.detail'))
->then(fn (Response $response) => $response->json('data'));
}
/**
* @throws ConnectionException
*
* @return PromiseInterface<Collection>
*/
public function related(string $id, int|null $limit = 5): PromiseInterface
{
return Http::sportalContent()
->withUrlParameters(['id' => $id])
->get(config('services.sportal-content.endpoints.articles.related'), [
'optional_data' => 'tags',
])
->then(
fn (Response $response) => $response
->collect('data')
->take($limit)
);
}
protected function searchClient(): string
{
return 'sportalContent';
}
protected function searchUrl(): string
{
return config('services.sportal-content.endpoints.articles.search');
}
}
namespace App\Http\Responders\Articles;
use App\Proxies\Content\ArticleProxy;
use Illuminate\Contracts\View\View;
class ShowDetailResponder
{
public function __invoke(string $articleType, ArticleProxy $article): View
{
return view('articles.detail', [
'article' => $article,
]);
}
}
<x-layouts.app>
<div class="bg-white py-24 sm:py-16">
<div class="mx-auto max-w-2xl px-6 lg:px-8">
<p class="text-sm font-medium uppercase text-primary-600">
{{ $article->category['title'] }}
</p>
<div class="mt-4 text-3xl font-bold tracking-tight text-neutral-900 sm:text-4xl">
{{ $article->title }}
</div>
@if ($article->mainMedia[0])
<figure class="mt-8">
<img class="aspect-video rounded-xl bg-neutral-50 object-cover"
src="https://sportal365images.com/process/{{ $article->mainMedia[0]['data']['path'] }}"
alt="{!! $article->mainMedia[0]['data']['alt'] !!}"
>
<span class="text-sm italic text-neutral-900">
{!! $article->mainMedia[0]['data']['description'] !!}
</span>
</figure>
@endif
<p class="mt-6 text-sm">
<span class="text-primary-600">
{{ $article->createdBy['full_name'] }}
</span>
{{ $article->publishedAt }} / {{ __('Latest Update:') }} {{ $article->updatedAt }}
</p>
<p class="mt-4 text-xl font-medium leading-8">
{{ $article->subTitle }}
</p>
<div class="border-4 border-neutral-200 rounded-2xl mt-2"></div>
<div class="mt-8 max-w-2xl">
@foreach($article->body as $block)
@if($block['type'] == 'editor_block')
@if($block['data']['type'] == 'paragraph')
<span class="my-6 text-base leading-relaxed">
{!! $block['data']['content'] !!}
</span>
@elseif($block['data']['type'] == 'heading')
<div class="mt-6 text-2xl font-medium">
{!! $block['data']['content'] !!}
</div>
<div class="border-4 border-neutral-200 rounded-2xl mt-2 mb-4"></div>
@endif
@elseif($block['type'] == 'image')
<figure class="mt-8">
<img class="aspect-video rounded-xl bg-neutral-50 object-cover"
src="https://sportal365images.com/process/{{ $block['data']['preview']['imageBlock']['image']['path'] }}"
alt="{!! $block['data']['preview']['imageBlock']['image']['alt'] !!}"
>
<span class="text-sm italic text-neutral-900">
{!! $block['data']['preview']['imageBlock']['image']['description'] !!}
</span>
</figure>
@elseif($block['type'] == 'article')
<div class="my-6">
<x-widgets.content.single-entity :extra-attributes="['entity' => ['id' => $block['data']['id'], 'type' => $block['type']]]" />
</div>
@elseif($block['type'] == 'embed')
<div class="my-6">
{!! $block['data']['content'] !!}
</div>
@elseif($block['type'] == 'widget_smp_V2')
<div class="my-6">
{!! $block['data']['content'] !!}
@include('includes.sportal.dependencies.football')
@include('includes.sportal.dependencies.common')
</div>
@endif
@endforeach
</div>
<x-widgets.content.related-articles :extra-attributes="['entity' => ['id' => $article->id]]" />
<x-widgets.content.related-tags :extra-attributes="['entity' => ['id' => $article->id], 'limit' => 30]" />
</div>
</div>
</x-layouts.app>
<?php
namespace App\Widgets\Basketball;
use App\Enums\EntityTypeEnum;
use App\Enums\JsonBlockGroupEnum;
use App\Enums\JsonBlockTypeEnum;
use App\Enums\PlatformEnum;
use App\Forms\Components\EntitySelector;
use App\Widgets\Concerns\Sportal\Fields\Betting\HasBettingBrandColorsConcern;
use App\Widgets\Concerns\Sportal\Fields\Betting\HasBettingIdConcern;
use App\Widgets\Concerns\Sportal\Fields\Betting\HasBettingLogoClickActionConcern;
use App\Widgets\Concerns\Sportal\Fields\Betting\HasBettingLogoOverlapConcern;
use App\Widgets\Concerns\Sportal\Fields\Betting\HasDisplayOddsConcern;
use App\Widgets\Concerns\Sportal\Fields\Betting\HasExtendedOddsMarketConcern;
use App\Widgets\Concerns\Sportal\Fields\Betting\HasOddsMarketValueTypeConcern;
use App\Widgets\Concerns\Sportal\Fields\Betting\HasOddsPreEventOnlyConcern;
use App\Widgets\Concerns\Sportal\Fields\Customisation\HasShortStatusTypeConcern;
use App\Widgets\Concerns\Sportal\Fields\Customisation\HasTeamNameTypeConcern;
use App\Widgets\Concerns\Sportal\Fields\Customisation\HasWrapperLinkConcern;
use App\Widgets\Concerns\Sportal\Fields\Data\HasDateConcern;
use App\Widgets\Concerns\Sportal\Fields\Data\HasRefreshTimeConcern;
use App\Widgets\SportalWidget;
use Filament\Forms\Get;
use Filament\Panel\Concerns\HasTheme;
class SingleEventWidget extends SportalWidget
{
use HasBettingBrandColorsConcern;
use HasBettingIdConcern;
use HasBettingLogoClickActionConcern;
use HasBettingLogoOverlapConcern;
use HasDateConcern;
use HasDisplayOddsConcern;
use HasExtendedOddsMarketConcern;
use HasOddsMarketValueTypeConcern;
use HasOddsPreEventOnlyConcern;
use HasRefreshTimeConcern;
use HasShortStatusTypeConcern;
use HasTheme;
use HasTeamNameTypeConcern;
use HasWrapperLinkConcern;
protected array $dataAttributes = [
'date',
];
protected array $customisationAttributes = [
'shortStatusType',
'teamNameType',
'wrapperLink',
'theme',
'refreshTime',
// 'entityLinks',
// 'labels',
];
protected array $bettingAttributes = [
'bettingId',
'displayOdds',
'oddsPreEventOnly',
'extendedOddsMarket',
'oddsMarketValueType',
'bettingLogoClickAction',
'bettingLogoOverlap',
'bettingBrandColors',
];
protected function buildShape(): array
{
return [
'dataPopularList' => $this
->string()
->optional(),
'dataMatchId' => $this
->string(),
...parent::buildShape(),
];
}
protected function buildFormData(): array
{
return [
EntitySelector::make('team')
->required()
->types(EntityTypeEnum::BasketballTeam)
->reactive(),
EntitySelector::make('match')
->minimumQueryCharacters(0)
->visible(fn (Get $get) => !empty($get('team')))
->filters(function (Get $get) {
$team = $get('team');
if (is_string($team)) {
$team = json_decode($team, associative: true);
}
return ['teamIds' => [$team['id']]];
})
->required()
->types(EntityTypeEnum::BasketballGame)
->searchable(false)
->native(false)
->reactive(),
EntitySelector::make('popularList')
->types(EntityTypeEnum::List)
->label('Link to list'),
...parent::buildFormData(),
];
}
protected function buildDehydrateState(array $state): array
{
return [
'dataMatchId' => $this->extractStateObject($state, 'data.match')->id,
'dataPopularList' => $this->extractStateObject($state, 'data.popularList')->id ?? null,
...parent::buildDehydrateState($state),
];
}
public function group(): string
{
return JsonBlockGroupEnum::Basketball->value;
}
public function title(): string
{
return 'Single Event';
}
public function supportedPlatforms(): array
{
return [
PlatformEnum::Web,
];
}
public function types(): array
{
return [
JsonBlockTypeEnum::BasketballSingleEvent->value,
];
}
public function displayDefaults(): array
{
return [
'dataMatchId' => '551ab050-9e1b-4486-92fd-a1c54ca63bd9',
];
}
public function sportalId(): string
{
return 'basketball-single-event';
}
public function sportalType(): string
{
return 'event';
}
public function sportalSport(): string
{
return 'basketball';
}
public function bladeComponent(): string
{
return 'widgets.basketball.single-event';
}
public function canBeAddedToPage(): bool
{
return true;
}
}
@aware([
'extraAttributes' => [],
])
@php
$widget = widget_for(\App\Enums\JsonBlockTypeEnum::BasketballSingleEvent);
$props = [
'dataMatchId' => $extraAttributes['dataMatchId'] ?? null,
'dataWidgetId' => $widget->sportalId(),
'dataWidgetSport' => $widget->sportalSport(),
'dataWidgetType' => $widget->sportalType(),
];
@endphp
@props($props)
<div>
<x-widgets.base
:data-match-id="$dataMatchId"
:data-widget-id="$dataWidgetId"
:data-widget-sport="$dataWidgetSport"
:data-widget-type="$dataWidgetType"
/>
@include('includes.sportal.dependencies.basketball')
@include('includes.sportal.dependencies.common')
</div>
@aware([
'extraAttributes' => [],
])
<div class="flex flex-col gap-4">
<div
{{ $attributes->except('extra-attributes') }}
{{ encoded_attributes(collect($extraAttributes)) }}
></div>
</div>
namespace App\Forms\Components;
use App\Enums\EntityTypeEnum;
use App\Services\Sportal\SportalSearchService;
use Closure;
use Filament\Forms\Components\Select;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
class EntitySelector extends Select
{
protected array|Closure $types = [];
protected array|Closure $filters = [];
protected int|Closure $minimumQueryCharacters = 3;
public function types(null|Closure|EntityTypeEnum ...$value): array|static
{
if (is_null($value)) {
return $this->types;
}
$this->types = $value;
return $this;
}
public function filters(null|Closure|array $value): array|static
{
if (is_null($value)) {
return $this->filters;
}
$this->filters = $value;
return $this;
}
public function minimumQueryCharacters(null|Closure|int $value): int|static
{
if (is_null($value)) {
return $this->minimumQueryCharacters;
}
$this->minimumQueryCharacters = $value;
return $this;
}
protected function setUp(): void
{
parent::setUp();
$formatLabel = function ($value): null|string {
if (is_string($value)) {
$value = json_decode($value, associative: true);
}
if (!is_array($value)) {
$value = (array) $value;
}
return fmt(
'
<span class="flex justify-between gap-x-1 gap-y-1 text-xs">
<span class="font-bold">%</span>
<span class="italic">%</span>
</span>
<span>%</span>
',
Str::of($value['type'])->replace('-', ' ')->title(),
$value['slug'] ?? '',
$value['title']
);
};
$this
->allowHtml()
->searchable()
->options(
fn () => resolve(SportalSearchService::class)
->search(
query: '*',
types: $this->evaluate($this->types),
filters: $this->evaluate($this->filters),
minimumQueryCharacters: $this->evaluate($this->minimumQueryCharacters),
)
->map(
fn (Collection $group) => $group
->mapWithKeys(
fn ($item) => [
json_encode($item) => $formatLabel($item),
]
)
)
)
->getSearchResultsUsing(
fn (string $query) => resolve(SportalSearchService::class)
->search(
query: $query,
types: $this->evaluate($this->types),
filters: $this->evaluate($this->filters),
minimumQueryCharacters: $this->evaluate($this->minimumQueryCharacters),
)
->map(
fn (Collection $group) => $group
->mapWithKeys(
fn ($item) => [
json_encode($item) => $formatLabel($item),
]
)
)
)
->getOptionLabelUsing(
fn ($value): null|string => $formatLabel($value)
)
->dehydrateStateUsing(
fn ($state) => is_string($state)
? json_decode($state, true)
: $state
)
->optionsLimit(1000);
}
}
namespace App\Services\Sportal;
use App\Enums\EntityTypeEnum;
use App\Models\Page;
use App\Services\Sportal\Components\Basketball;
use App\Services\Sportal\Components\Content;
use App\Services\Sportal\Components\Search;
use GuzzleHttp\Promise\FulfilledPromise;
use GuzzleHttp\Promise\PromiseInterface;
use Illuminate\Support\Arr;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Laravel\Pennant\Feature;
use Throwable;
class SportalSearchService
{
public function search(
string $query,
null|array $types = [],
null|array $filters = [],
null|int $minimumQueryCharacters = 3,
): Collection {
if (mb_strlen($query) < $minimumQueryCharacters) {
return collect();
}
$supportedTypes = collect([
EntityTypeEnum::Article->value => [
__('Articles'),
$this->articles(...),
],
EntityTypeEnum::BasketballCompetition->value => [
__('Basketball Competitions'),
$this->basketballCompetitions(...),
],
EntityTypeEnum::BasketballGame->value => [
__('Basketball Games'),
$this->basketballGames(...),
],
EntityTypeEnum::BasketballSeason->value => [
__('Basketball Seasons'),
$this->basketballSeasons(...),
],
EntityTypeEnum::BasketballStage->value => [
__('Basketball Stages'),
$this->basketballStages(...),
],
EntityTypeEnum::BasketballPlayer->value => [
__('Basketball Players'),
$this->basketballPlayers(...),
],
EntityTypeEnum::BasketballTeam->value => [
__('Basketball Teams'),
$this->basketballTeams(...),
],
EntityTypeEnum::Category->value => [
__('Categories'),
$this->categories(...),
],
EntityTypeEnum::FootballPlayer->value => [
__('Football Players'),
$this->footballPlayers(...),
],
EntityTypeEnum::FootballTeam->value => [
__('Football Teams'),
$this->footballTeams(...),
],
EntityTypeEnum::FootballTournament->value => [
__('Football Tournaments'),
$this->footballTournaments(...),
],
EntityTypeEnum::Gallery->value => [
__('Galleries'),
$this->galleries(...),
],
EntityTypeEnum::IceHockeyCompetition->value => [
__('Ice Hockey Competitions'),
$this->iceHockeyCompetitions(...),
],
EntityTypeEnum::List->value => [
__('Lists'),
$this->lists(...),
],
EntityTypeEnum::Page->value => [
__('Pages'),
$this->pages(...),
],
EntityTypeEnum::Prefab->value => [
__('Suggestions'),
$this->suggestions(...),
],
EntityTypeEnum::Tag->value => [
__('Tags'),
$this->tags(...),
],
EntityTypeEnum::Video->value => [
__('Videos'),
$this->videos(...),
],
]);
if (Feature::active('wiki-pages')) {
$supportedTypes[EntityTypeEnum::WikiPage->value] = [
__('Wiki Pages'),
$this->wikiPages(...),
];
}
if (!$types || count($types) < 1) {
$types = $supportedTypes->keys()->map(fn ($value) => EntityTypeEnum::from($value));
}
$entityOptions = collect();
foreach ($types as $type) {
try {
[$label, $searcher] = $supportedTypes[$type->value];
$entityOptions[$label] = $searcher($query, $filters);
} catch (Throwable) {
// unsupported requested search type (wiki pages disabled?)
}
}
return $entityOptions
->map(
fn (PromiseInterface|Collection $results) => $results instanceof PromiseInterface
? $results->wait()
: $results
)
->filter(fn (Collection $items) => $items->isNotEmpty());
}
private function articles(string $query, array $filters = []): PromiseInterface
{
return resolve(Content\Articles::class)
->search($query)
->then(
fn (Collection $items) => $items
->map(fn (array $article) => [
'type' => EntityTypeEnum::Article->value,
'id' => $article['id'],
'slug' => $article['seo']['slug'] ?? null,
'title' => $article['title'],
'image' => [
'url' => Arr::get($article, 'main_media.0.data.urls.uploaded.original'),
'alt' => Arr::get($article, 'main_media.0.data.description'),
],
'category' => [
'id' => Arr::get($article, 'category.id'),
'title' => Arr::get($article, 'category.title'),
],
'url' => route('articles.detail', [
'article_type' => 'articles',
'article' => $article['id'],
]),
])
);
}
private function categories(string $query, array $filters = []): PromiseInterface
{
return resolve(Content\Categories::class)
->search($query)
->then(
fn (Collection $items) => $items
->map(fn (array $category) => [
'type' => EntityTypeEnum::Category->value,
'id' => $category['id'],
'slug' => $category['seo']['slug'] ?? null,
'title' => $category['title'],
'image' => [
'url' => Arr::get($category, 'main_media.0.data.urls.uploaded.original'),
'alt' => Arr::get($category, 'main_media.0.data.description'),
],
'category' => [
'id' => Arr::get($category, 'category.id'),
'title' => Arr::get($category, 'category.title'),
],
'url' => route('categories.detail', ['category' => $category['id']]),
])
);
}
private function footballPlayers(string $query, array $filters = []): PromiseInterface
{
return resolve(Search\Search::class)
->search($query, 'player', sports: ['football'])
->then(
fn (Collection $items) => $items
->map(fn (array $team) => [
'type' => EntityTypeEnum::FootballPlayer->value,
'id' => $team['id'],
'slug' => $team['slug'],
'title' => $team['name'],
'legacy_id' => $team['legacy_id'],
'image' => [
'url' => Arr::get($team, 'display_asset.url'),
],
'url' => route('football.players.detail', ['football_player' => $team['id']]),
])
);
}
private function footballTeams(string $query, array $filters = []): PromiseInterface
{
return resolve(Search\Search::class)
->search($query, 'team', sports: ['football'])
->then(
fn (Collection $items) => $items
->map(fn (array $team) => [
'type' => EntityTypeEnum::FootballTeam->value,
'id' => $team['id'],
'slug' => $team['slug'],
'title' => $team['name'],
'legacy_id' => $team['legacy_id'],
'image' => [
'url' => Arr::get($team, 'display_asset.url'),
],
'url' => route('football.teams.detail', ['football_team' => $team['id']]),
])
);
}
private function footballTournaments(string $query, array $filters = []): PromiseInterface
{
return resolve(Search\Search::class)
->search($query, 'competition', sports: ['football'])
->then(
fn (Collection $items) => $items
->map(fn (array $tournament) => [
'type' => EntityTypeEnum::FootballTournament->value,
'id' => $tournament['id'],
'slug' => $tournament['slug'],
'title' => $tournament['name'],
'legacy_id' => $tournament['legacy_id'],
'image' => [
'url' => Arr::get($tournament, 'display_asset.url'),
],
'url' => route('football.tournaments.detail', ['football_tournament' => $tournament['id']]),
])
);
}
private function galleries(string $query, array $filters = []): PromiseInterface
{
return resolve(Content\Galleries::class)
->search($query)
->then(
fn (Collection $items) => $items
->map(fn (array $gallery) => [
'type' => EntityTypeEnum::Gallery->value,
'id' => $gallery['id'],
'slug' => $gallery['seo']['slug'] ?? null,
'title' => $gallery['title'],
'image' => [
'url' => Arr::get($gallery, 'main_media.0.data.urls.uploaded.original'),
'alt' => Arr::get($gallery, 'main_media.0.data.description'),
],
'category' => [
'id' => Arr::get($gallery, 'category.id'),
'title' => Arr::get($gallery, 'category.title'),
],
'url' => route('galleries.detail', ['gallery' => $gallery['id']]),
])
);
}
private function lists(string $query, array $filters = []): PromiseInterface
{
return resolve(Content\Lists::class)
->search($query)
->then(
fn (Collection $items) => $items
->map(fn (array $list) => [
'type' => EntityTypeEnum::List->value,
'id' => $list['id'],
'slug' => $list['slug'],
'title' => $list['title'],
])
);
}
private function pages(string $query, array $filters = []): Collection
{
return Page::query()
->where('title', 'like', "%{$query}%")
->get()
->map(fn (Page $page) => [
'type' => EntityTypeEnum::Page->value,
'id' => $page->id,
'slug' => $page->slug,
'title' => $page->title,
'url' => route('test-dynamic-pages', ['page' => $page->slug]),
]);
}
private function suggestions(string $query, array $filters = []): Collection
{
return collect(config('sports.entities.prefab'))
->filter(function (array $prefab) use ($query) {
return str_starts_with(mb_strtolower($prefab['title']), mb_strtolower($query))
&& $prefab['isVisible'];
});
}
private function tags(string $query, array $filters = []): PromiseInterface
{
return resolve(Content\Tags::class)
->search($query)
->then(
fn (Collection $items) => $items
->map(fn (array $tag) => [
'type' => EntityTypeEnum::Tag->value,
'id' => $tag['id'],
'slug' => $tag['seo']['slug'],
'title' => $tag['title'],
'image' => [
'url' => Arr::get($tag, 'main_media.0.data.urls.uploaded.original'),
'alt' => Arr::get($tag, 'main_media.0.data.description'),
],
'url' => route('tags.detail', ['tag' => $tag['id']]),
])
);
}
private function videos(string $query, array $filters = []): PromiseInterface
{
return resolve(Content\Videos::class)
->search($query)
->then(
fn (Collection $items) => $items
->map(fn (array $video) => [
'type' => EntityTypeEnum::Video->value,
'id' => $video['id'],
'slug' => $video['seo']['slug'] ?? null,
'title' => $video['title'],
'image' => [
'url' => Arr::get($video, 'main_media.0.data.urls.uploaded.original'),
'alt' => Arr::get($video, 'main_media.0.data.description'),
],
'category' => [
'id' => Arr::get($video, 'category.id'),
'title' => Arr::get($video, 'category.title'),
],
'url' => route('videos.detail', ['video' => $video['id']]),
])
);
}
private function wikiPages(string $query, array $filters = []): PromiseInterface
{
return resolve(Content\WikiPages::class)
->search($query)
->then(
fn (Collection $items) => $items
->map(fn (array $page) => [
'type' => EntityTypeEnum::WikiPage->value,
'id' => $page['id'],
'title' => $page['title'],
'image' => [
'url' => Arr::get($page, 'main_media.0.data.urls.uploaded.original'),
'alt' => Arr::get($page, 'main_media.0.data.description'),
],
'category' => [
'id' => Arr::get($page, 'category.id'),
'title' => Arr::get($page, 'category.title'),
],
])
);
}
private function basketballCompetitions(string $query, array $filters = []): PromiseInterface
{
return resolve(Search\Search::class)
->search($query, 'competition', sports: ['basketball'])
->then(
fn (Collection $items) => $items
->map(fn (array $tournament) => [
'type' => EntityTypeEnum::BasketballCompetition->value,
'id' => $tournament['id'],
'slug' => $tournament['slug'],
'title' => $tournament['name'],
'legacy_id' => $tournament['legacy_id'],
'image' => [
'url' => Arr::get($tournament, 'display_asset.url'),
],
'url' => route('basketball.competitions.detail', ['basketball_competition' => $tournament['id']]),
])
);
}
private function basketballGames(string $query, array $filters = []): PromiseInterface
{
if (empty($filters['teamIds'])) {
return new FulfilledPromise(collect());
}
return resolve(Basketball\Games::class)
->list(teamIds: $filters['teamIds'])
->then(
fn (Collection $items) => $items
->map(fn (array $match) => [
'type' => EntityTypeEnum::BasketballGame->value,
'id' => $match['id'],
'slug' => $match['slug'],
'title' => Arr::get($match, 'home_team.name') . ' - ' . Arr::get($match, 'away_team.name') . ' (' . Carbon::parse(Arr::get($match, 'game_time'))->format('j M Y') . ')',
'image' => [
'url' => Arr::get($match, 'display_asset.url'),
],
'url' => route('basketball.games.detail', ['basketball_game' => $match['id']]),
])
);
}
private function basketballSeasons(string $query, array $filters = []): PromiseInterface
{
if (empty($filters['competitionId']) && empty($filters['teamId'])) {
return new FulfilledPromise(collect());
}
return resolve(Basketball\Seasons::class)
->list(
competitionId: $filters['competitionId'] ?? null,
teamId: $filters['teamId'] ?? null,
)
->then(
fn (Collection $items) => $items
->map(fn (array $season) => [
'type' => EntityTypeEnum::BasketballSeason->value,
'id' => $season['id'],
'slug' => $season['slug'],
'title' => Arr::get($season, 'name'),
])
);
}
private function basketballStages(string $query, array $filters = []): PromiseInterface
{
if (empty($filters['seasonId']) && empty($filters['competitionId'])) {
return new FulfilledPromise(collect());
}
return resolve(Basketball\Seasons::class)
->stages(
seasonId: $filters['seasonId'] ?? null,
competitionId: $filters['competitionId'] ?? null,
)
->then(
fn (Collection $items) => $items
->map(fn (array $stage) => [
'type' => EntityTypeEnum::BasketballStage->value,
'id' => $stage['id'],
'slug' => $stage['slug'],
'title' => Arr::get($stage, 'name'),
'rounds' => Arr::get($stage, 'rounds'),
])
);
}
private function basketballTeams(string $query, array $filters = []): PromiseInterface
{
return resolve(Search\Search::class)
->search($query, 'team', sports: ['basketball'])
->then(
fn (Collection $items) => $items
->map(fn (array $team) => [
'type' => EntityTypeEnum::BasketballTeam->value,
'id' => $team['id'],
'slug' => $team['slug'],
'title' => $team['name'],
'legacy_id' => $team['legacy_id'],
'image' => [
'url' => Arr::get($team, 'display_asset.url'),
],
'url' => route('basketball.teams.detail', ['basketball_team' => $team['id']]),
])
);
}
private function basketballPlayers(string $query, array $filters = []): PromiseInterface
{
return resolve(Search\Search::class)
->search($query, 'player', sports: ['basketball'])
->then(
fn (Collection $items) => $items
->map(fn (array $team) => [
'type' => EntityTypeEnum::BasketballPlayer->value,
'id' => $team['id'],
'slug' => $team['slug'],
'title' => $team['name'],
'legacy_id' => $team['legacy_id'],
'image' => [
'url' => Arr::get($team, 'display_asset.url'),
],
'url' => route('basketball.players.detail', ['basketball_player' => $team['id']]),
])
);
}
private function iceHockeyCompetitions(string $query, array $filters = []): PromiseInterface
{
return resolve(Search\Search::class)
->search($query, 'competition', sports: ['ice_hockey'])
->then(
fn (Collection $items) => $items
->map(fn (array $tournament) => [
'type' => EntityTypeEnum::IceHockeyCompetition->value,
'id' => $tournament['id'],
'slug' => $tournament['slug'],
'title' => $tournament['name'],
'legacy_id' => $tournament['legacy_id'],
])
);
}
}
namespace App\Services\Sportal\Components\Content;
use App\Services\Sportal\Components\Concerns\CanSearchConcern;
use GuzzleHttp\Promise\PromiseInterface;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Http;
class Articles
{
use CanSearchConcern;
/**
* @throws ConnectionException
*
* @return PromiseInterface<array|null>
*/
public function find(string $id): PromiseInterface
{
return Http::sportalContent()
->withUrlParameters(['id' => $id])
->get(config('services.sportal-content.endpoints.articles.detail'))
->then(fn (Response $response) => $response->json('data'));
}
/**
* @throws ConnectionException
*
* @return PromiseInterface<Collection>
*/
public function related(string $id, int|null $limit = 5): PromiseInterface
{
return Http::sportalContent()
->withUrlParameters(['id' => $id])
->get(config('services.sportal-content.endpoints.articles.related'), [
'optional_data' => 'tags',
])
->then(
fn (Response $response) => $response
->collect('data')
->take($limit)
);
}
protected function searchClient(): string
{
return 'sportalContent';
}
protected function searchUrl(): string
{
return config('services.sportal-content.endpoints.articles.search');
}
}
1. Get desired URL
2. Base64 encode: `https://example.com/football/matches/2749691` → `...LzI3NDk2OTE=`
3. Request `https://example.com/api/v1/url-resolver/...LzI3NDk2OTE=`
4. Get response
```json
{
"type": "football-match",
"id": "2749691",
"slug": "germany-scotland-2749691"
}
```
namespace App\Services\Resolver;
use App\Services\Resolver\Components\Articles;
use App\Services\Resolver\Components\Authors;
use App\Services\Resolver\Components\Basketball;
use App\Services\Resolver\Components\Categories;
use App\Services\Resolver\Components\Cycling;
use App\Services\Resolver\Components\Football;
use App\Services\Resolver\Components\Galleries;
use App\Services\Resolver\Components\Motorsport;
use App\Services\Resolver\Components\Special;
use App\Services\Resolver\Components\Tags;
use App\Services\Resolver\Components\Tennis;
use App\Services\Resolver\Components\Videos;
use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Routing\Route;
use Illuminate\Routing\Router;
class ResolverService
{
/**
* @throws BindingResolutionException
*/
public function resolve(string $url, bool $strict = false): array
{
if ($strict) {
$parsed = parse_url($url);
if (!isset($parsed['scheme']) || !isset($parsed['host'])) {
abort(404);
}
$domain = $parsed['scheme'] . '://' . $parsed['host'];
if (!in_array($domain, (array) tenant()->domain)) {
abort(404);
}
}
/** @var Router $route */
$router = app('router');
/** @var Route $route */
$route = collect($router->getRoutes())->first(
fn ($route) => $route->matches(request()->create(str($url)->finish('/')))
);
if (!$route) {
abort(404);
}
return match ($route->getName()) {
'articles.detail' => (new Articles())->detail($url),
'authors.detail' => (new Authors())->detail($url),
'basketball.competitions.detail' => (new Basketball())->competition($url),
'basketball.competitions.index' => (new Basketball())->competitions(),
'basketball.countries.detail' => (new Basketball())->country($url),
'basketball.games.detail' => (new Basketball())->game($url),
'basketball.index' => (new Basketball())->landing(),
'basketball.players.detail' => (new Basketball())->player($url),
'basketball.seasons.detail' => (new Basketball())->season($url),
'basketball.teams.detail' => (new Basketball())->team($url),
'categories.detail' => (new Categories())->detail($url),
'cycling.index' => (new Cycling())->landing(),
'football.index' => (new Football())->landing(),
'football.matches.detail' => (new Football())->match($url),
'football.players.detail' => (new Football())->player($url),
'football.teams.detail' => (new Football())->team($url),
'football.tournaments.detail' => (new Football())->tournament($url),
'football.tournaments.index' => (new Football())->tournaments(),
'galleries.detail' => (new Galleries())->detail($url),
'home' => (new Special())->home(),
'motorsport.index' => (new Motorsport())->landing(),
'news' => (new Special())->news(),
'search' => (new Special())->search(),
'tags.detail' => (new Tags())->detail($url),
'tennis.competitions.detail' => (new Tennis())->competition($url),
'tennis.competitions.index' => (new Tennis())->competitions(),
'tennis.countries.detail' => (new Tennis())->country($url),
'tennis.index' => (new Tennis())->landing(),
'tennis.matches.detail' => (new Tennis())->match($url),
'tennis.players.detail' => (new Tennis())->player($url),
'tennis.teams.detail' => (new Tennis())->team($url),
'tennis.tournaments.detail' => (new Tennis())->tournament($url),
'tennis.tournaments.index' => (new Tennis())->tournaments(),
'videos.detail' => (new Videos())->detail($url),
'videos.index' => (new Videos())->index(),
};
}
}
namespace App\Services\Resolver\Components;
use App\Enums\EntityTypeEnum;
use App\Proxies\Content\ArticleProxy;
use App\Services\Resolver\Components\Concerns\GetsSlugConcern;
class Articles
{
use GetsSlugConcern;
public function detail(string $url): array
{
$article = ArticleProxy::findOrFail($this->slug($url));
return [
'type' => EntityTypeEnum::Article->value,
'id' => (string) $article->id,
'slug' => $article->seo['slug'],
'title' => $article->title,
];
}
}
{
"title": "Home",
"slug": "home",
"seo": {
"title": null,
"description": null,
"keywords": []
},
"content": [
{
"type": "football-lineups",
"data": {
"dataMatchId": "123",
"dataHeaderDisplay": false,
"dataWidgetId": "lineups",
"dataWidgetSport": "football",
"dataWidgetType": "event"
}
},
{
"type": "football-live-score",
"data": {
"dataHeaderDisplay": false,
"dataOptionsDisplay": false,
"dataWidgetId": "livescore",
"dataWidgetSport": "football",
"dataWidgetType": "tournament",
"dataDate": {
"date": "2024-09-06",
"dateFormat": "YYYY-MM-DD"
}
}
},
{
"type": "football-matches-head-to-head",
"data": {
"dataSportEntityOne": "1",
"dataSportEntityTwo": "2",
"dataOptionsDisplay": false,
"dataWidgetId": "matches-h2h",
"dataWidgetSport": "football",
"dataWidgetType": "h2h"
}
},
{
"type": "football-most-decorated-players",
"data": {
"dataSeason": "123",
"dataWidgetId": "most-decorated",
"dataWidgetSport": "football",
"dataWidgetType": "tournament"
}
},
{
"type": "football-odds",
"data": {
"dataMatchId": "123",
"dataWidgetId": "odds",
"dataWidgetSport": "football",
"dataWidgetType": "event"
}
},
{
"type": "football-player-head-to-head",
"data": {
"dataSportEntityOne": {
"id": "123",
"seasonId": "456"
},
"dataSportEntityTwo": {
"id": "456",
"seasonId": "123"
},
"dataWidgetId": "player-h2h",
"dataWidgetSport": "football",
"dataWidgetType": "h2h"
}
},
{
"type": "football-player",
"data": {
"dataPlayer": "123",
"dataSeason": "345",
"dataWidgetId": "player",
"dataWidgetSport": "football",
"dataWidgetType": "player"
}
},
{
"type": "football-team",
"data": {
"dataTeam": "123",
"dataSeason": "456",
"dataMatchId": "123",
"dataDisplayTabs": false,
"dataWidgetId": "team",
"dataWidgetSport": "football",
"dataWidgetType": "team"
}
},
{
"type": "football-team-squad",
"data": {
"dataHeaderDisplay": false,
"dataOptionsDisplay": false,
"dataImageDisplay": false,
"dataWidgetId": "team-squad",
"dataWidgetSport": "football",
"dataWidgetType": "team"
}
},
{
"type": "football-top-scorers",
"data": {
"dataLimit": 5,
"dataImageDisplay": false,
"dataHeaderDisplay": false,
"dataOptionsDisplay": false,
"dataWidgetId": "top-scorers",
"dataWidgetSport": "football",
"dataWidgetType": "tournament"
}
},
{
"type": "basketball-live-score",
"data": {
"dataWidgetId": "basketball-livescore",
"dataWidgetSport": "basketball",
"dataWidgetType": "tournament",
"dataDate": {
"date": "2024-09-06",
"dateFormat": "YYYY-MM-DD"
}
}
}
]
}
[
{
"title": "Eredivisie",
"entity": {
"id": "96088170-ed55-4b21-9aec-8587c8b8e78a",
"slug": "eredivisie-11",
"type": "competition",
"title": "Eredivisie",
"legacyId": "11"
},
"image": "https://sportal365images.com/process/smp-images-production/assets/14082022/4f636d98-fc5a-461b-8f3d-7bea296c3780.png"
},
{
"title": "Home",
"entity": {
"type": "home",
"title": "Home"
}
},
{
"title": "Separator",
"entity": {
"type": "separator",
"title": "Separator"
}
},
{
"title": "Live",
"url": "/live",
"items": [
{
"title": "Tennis landing page",
"entity": {
"type": "tennis-landing",
"title": "Tennis Landing Page"
}
},
{
"title": "Cycling landing page",
"entity": {
"type": "cycling-landing",
"title": "Cycling Landing Page"
}
},
{
"title": "Football landing page",
"entity": {
"type": "football-landing",
"title": "Football Landing Page"
}
},
{
"title": "Basketball landing page",
"entity": {
"type": "basketball-landing",
"title": "Basketball Landing Page"
}
},
{
"title": "Motorsport landing page",
"entity": {
"type": "motorsport-landing",
"title": "Motorsport Landing Page"
}
}
]
},
{
"title": "new menu item",
"entity": {
"id": "9a142368-7a25-4695-b7a0-cc56a8b40243",
"slug": "abn-amro-world-tennis-tournament-4gk1KzfDj5ZrEdqnXCVEfr",
"type": "competition",
"title": "ABN AMRO World Tennis Tournament",
"legacyId": null
}
},
{
"title": "voetbal",
"entity": {
"id": "2023102515324746186",
"slug": "buitenlands-voetbal",
"type": "category",
"title": "Buitenlands voetbal"
}
},
{
"title": "things",
"entity": {
"id": "2023102515330781152",
"slug": "voetbal",
"type": "category",
"title": "Voetbal"
},
"customPageEntity": {
"id": 3,
"slug": "football-special",
"type": "page",
"title": "Football"
}
}
]
Parallel requests are essential
Visual configuration is endless