Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ APP_DEBUG=true
APP_TIMEZONE=UTC
APP_URL=http://localhost

API_CAT=live_vPWHWd2VHESz6uk5ZbMoxjI5jp9EBlIK2LdX4DUwgu4EZhcCUXtWUSoYCb0Xccaj
API_DOG=live_PPrf0Hbi91a5qFabgHx4epZHQXpTjxSsGrqaNyj1uunCnVuxExRI4fZzohpV0Vdq
CAT_API_KEY=
CAT_API_URL=

APP_LOCALE=en
APP_FALLBACK_LOCALE=en
Expand Down
25 changes: 24 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,29 @@
# Shopblocks PHP Developer Test: Pet-é-dex

Welcome to the **Shopblocks PHP Developer Test: Pet-é-dex**! This project evaluates your skills in working with APIs, building user interfaces, and implementing core functionality using PHP and Laravel. While design skills are a bonus, we will focus mainly on usability, performance, and security.
## Teo Notes
Here’s a quick rundown of what I did:

- Removed the API key from the example `.env`
Didn’t want to leave sensitive stuff lying around. Bots scan GitHub for keys all the time, so better safe than sorry.

- Cached the API results
I added caching for both the full list of breeds and individual breed details. This keeps the site fast, avoids hammering the API, and respects their rate limits.

- Kept the controller simple with a `CatService`
All the API calls and caching live in the service now, so the controller just handles requests and returns views. Makes it easier to read and maintain.

- Redirect to http.cat on 404
Little fun touch and also handles missing data gracefully instead of breaking the page.

If I had more time, here’s what I’d do next:

- Query the API directly for searches instead of filtering in Blade, so results are accurate and performance stays solid even with a big list.
- Add more tests to cover API calls, caching, and edge cases.
- Pagination to keep the pages snappy for large datasets.
- Vue.js for smoother, interactive searching and filtering.
- Better styling to make it look more polished and professional.



## Introduction

Expand Down
Empty file added [
Empty file.
47 changes: 47 additions & 0 deletions app/Http/Controllers/CatController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

namespace App\Http\Controllers;

use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use App\Services\CatService;
use Illuminate\View\View;


class CatController extends Controller
{
private CatService $catService;

/**
* Constructor to initialize the CatService.
*/
public function __construct(CatService $catService)
{
$this->catService = $catService;
}

/**
* Display a listing of cat breeds.
*
*/
public function index(): View
{
$breeds = $this->catService->allBreeds();

return view('cats.index', compact('breeds'));
}

/**
* Display the specified cat breed.
*/
public function show(string $breedId): RedirectResponse|View
{
$breed = $this->catService->findBreedById($breedId);

if (!$breed) {
return redirect()->away("https://http.cat/404");
}

return view('cats.show', compact('breed'));
}
}
68 changes: 68 additions & 0 deletions app/Services/CatService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<?php
namespace App\Services;

use GuzzleHttp\Client;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Support\Facades\Cache;
use Throwable;

class CatService
{
private readonly Client $client;

/**
* Constructor to initialize the HTTP client.
*/
public function __construct(?Client $client= null)
{
$this->client = new Client([
'base_uri' => config('services.cat-api.url'),
'headers' => [
'x-api-key' => config('services.cat-api.key'),
],
]);
}

/**
* Fetch all cat breeds from The Cat API.
*/
public function allBreeds(): array
{
return Cache::remember('cat_breeds', 1800, function () {
try {
$response = $this->client->get('breeds');
return json_decode((string) $response->getBody(), true) ?? [];

} catch (RequestException $e) {
return [];
}
});
}


/**
* Find a specific breed by its ID from The Cat API.
*/
public function findBreedById(string $breedId): ?array
{
return Cache::remember("breeds.{$breedId}", 1800, function () use ($breedId){
try {
$response = $this->client->get("images/search", [
'query' => ['breed_ids' => $breedId],
]);
return collect($this->decodeResponse($response))->first();

} catch (Throwable $error){
return null;
}
});
}

/**
* Decode the JSON response from The Cat API.
*/
private function decodeResponse($response): array
{
return json_decode((string) $response->getBody(), true) ?? [];
}
}
Empty file modified artisan
100755 → 100644
Empty file.
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"license": "MIT",
"require": {
"php": "^8.2",
"guzzlehttp/guzzle": "^7.9",
"laravel/framework": "^11.9",
"laravel/tinker": "^2.9"
},
Expand Down
18 changes: 9 additions & 9 deletions composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions config/services.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,9 @@
],
],

'cat-api' => [
'url' => env('CAT_API_URL', 'https://api.thecatapi.com/v1/'),
'key' => env('CAT_API_KEY'),
]

];
Empty file.
60 changes: 60 additions & 0 deletions resources/views/cats/index.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
@extends('layouts.app')

@section('title', 'Cat Breeds')

@push('style')
<style>
.cat-card img {
height: 200px;
object-fit: cover;
}
.cat-card {
transition: transform 0.2s ease;
}
.cat-card:hover {
transform: translateY(-4px);
}
</style>
@endpush

@section('content')
<div class="container mt-4">
<h2 class="mb-4 text-center">Cat Breeds</h2>

<form method="GET" class="mb-4 d-flex justify-content-center">
<div class="position-relative w-50">
<input type="text" name="search" value="{{ request('search') }}" class="form-control rounded-5 form-control-lg" placeholder="Search for a cat breed">
<i class="ph ph-magnifying-glass position-absolute" style="right: 15px; top: 50%; transform: translateY(-50%);"></i>
</div>
</form>

@php
$filtered = collect($breeds)->filter(function ($breed) {
$query = request('search');
return !$query || stripos($breed['name'], $query) !== false;
});
@endphp

<div class="row justify-content-center">
@forelse($filtered as $breed)
<div class="col-md-3 mb-4">
<a href="{{ route('cats.show', $breed['id']) }}" class="text-decoration-none text-dark">
<div class="card h-100 shadow-sm cat-card">
@if(!empty($breed['image']['url']))
<img src="{{ $breed['image']['url'] }}" class="card-img-top" alt="{{ $breed['name'] }}">
@endif
<div class="card-body">
<h5 class="card-title">{{ $breed['name'] }}</h5>
<p class="card-text small text-muted">
{{ Str::limit($breed['temperament'] ?? 'No info available', 60) }}
</p>
</div>
</div>
</a>
</div>
@empty
<p class="text-center text-muted">No breeds found.</p>
@endforelse
</div>
</div>
@endsection
37 changes: 37 additions & 0 deletions resources/views/cats/show.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
@extends('layouts.app')

@section('title', $breed['breeds'][0]['name'] ?? 'Cat Details')

@section('content')
<div class="container mt-4">
<a href="{{ route('cats.index') }}" class="btn btn-outline-secondary mb-3">← Back to all breeds</a>

@if(isset($breed['breeds'][0]))
@php $info = $breed['breeds'][0]; @endphp

<div class="card shadow-sm">
<div class="row g-0">
<div class="col-md-5">
@if(!empty($breed['url']))
<img src="{{ $breed['url'] }}" alt="{{ $info['name'] }}" class="img-fluid rounded-start">
@endif
</div>
<div class="col-md-7">
<div class="card-body">
<h2 class="card-title">{{ $info['name'] }}</h2>
<p class="card-text">{{ $info['description'] ?? 'No description available.' }}</p>

<ul class="list-unstyled mt-3">
<li><strong>Origin:</strong> {{ $info['origin'] ?? 'Unknown' }}</li>
<li><strong>Temperament:</strong> {{ $info['temperament'] ?? 'Unknown' }}</li>
<li><strong>Life Span:</strong> {{ $info['life_span'] ?? 'N/A' }} years</li>
</ul>
</div>
</div>
</div>
</div>
@else
<p class="text-muted text-center mt-5">Breed information unavailable.</p>
@endif
</div>
@endsection
2 changes: 1 addition & 1 deletion resources/views/layouts/app.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<meta name="description" content="Discover the world of pets, one paw at a time.">
<link rel="icon" href="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMiIgaGVpZ2h0PSIzMiIgZmlsbD0iIzAwMDAwMCIgdmlld0JveD0iMCAwIDI1NiAyNTYiPjxwYXRoIGQ9Ik0yNDAsMTA4YTI4LDI4LDAsMSwxLTI4LTI4QTI4LDI4LDAsMCwxLDI0MCwxMDhaTTcyLDEwOGEyOCwyOCwwLDEsMC0yOCwyOEEyOCwyOCwwLDAsMCw3MiwxMDhaTTkyLDg4QTI4LDI4LDAsMSwwLDY0LDYwLDI4LDI4LDAsMCwwLDkyLDg4Wm03MiwwYTI4LDI4LDAsMSwwLTI4LTI4QTI4LDI4LDAsMCwwLDE2NCw4OFptMjMuMTIsNjAuODZhMzUuMywzNS4zLDAsMCwxLTE2Ljg3LTIxLjE0LDQ0LDQ0LDAsMCwwLTg0LjUsMEEzNS4yNSwzNS4yNSwwLDAsMSw2OSwxNDguODIsNDAsNDAsMCwwLDAsODgsMjI0YTM5LjQ4LDM5LjQ4LDAsMCwwLDE1LjUyLTMuMTMsNjQuMDksNjQuMDksMCwwLDEsNDguODcsMCw0MCw0MCwwLDAsMCwzNC43My03MloiPjwvcGF0aD48L3N2Zz4=">
<link rel="stylesheet" href="{{ asset('css/app.css') }}">
<link rel="stylesheet" href="{{ asset('css/bootstrap.min.css') }}">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<script src="{{ asset('js/phosphor-icons.js') }}"></script>
@stack('style')
</head>
Expand Down
7 changes: 5 additions & 2 deletions routes/web.php
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
<?php

use App\Http\Controllers\CatController;
use Illuminate\Support\Facades\Route;

Route::get('/', function () {
return view('dashboard');
Route::get('/cats', [CatController::class, 'index'])->name('cats.index');
Route::get('/cats/{id}', [CatController::class, 'show'])->name('cats.show');
Route::get('/', function() {
return redirect()->route('cats.index');
});