Herding cats

Multiple sports apps without a DB

Disclaimers

There is technically a DB

Still prototyping front-end

Where information lives

How information is shown

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>

Widgets

<?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>

Entity linking

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

Resolving URLs

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

Mobile app endpoints

{ "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" } } ]

Challenges

Menu management is hard

Parallel requests are essential

Visual configuration is endless

Presets didn't work

Back to assertchris.dev →