diff --git a/app/Enums/AromaProfile.php b/app/Enums/AromaProfile.php new file mode 100644 index 0000000..c2b5b03 --- /dev/null +++ b/app/Enums/AromaProfile.php @@ -0,0 +1,45 @@ + "Citrusy", + self::Fruity => "Fruity", + self::Floral => "Floral", + self::Herbal => "Herbal", + self::Spicy => "Spicy", + self::Resinous => "Resinous", + self::Sugarlike => "Sweet/Sugarlike", + self::Miscellaneous => "Miscellaneous", + }; + } + + public function color(): string + { + return match($this) { + self::Citrusy => "orange-500", + self::Fruity => "red-500", + self::Floral => "pink-400", + self::Herbal => "green-500", + self::Spicy => "amber-700", + self::Resinous => "emerald-700", + self::Sugarlike => "yellow-400", + self::Miscellaneous => "slate-400", + }; + } +} diff --git a/app/Http/Controllers/HopController.php b/app/Http/Controllers/HopController.php new file mode 100644 index 0000000..f488005 --- /dev/null +++ b/app/Http/Controllers/HopController.php @@ -0,0 +1,33 @@ +filter($request->all()) + ->orderBy("name") + ->paginate(12) + ->withQueryString(); + + return view("hops.index", [ + "hops" => $hops, + "filters" => $request->all(), + ]); + } + + public function show(Hop $hop): View + { + return view("hops.show", [ + "hop" => $hop, + ]); + } +} diff --git a/app/Models/Hop.php b/app/Models/Hop.php index af0a558..49b959d 100644 --- a/app/Models/Hop.php +++ b/app/Models/Hop.php @@ -5,6 +5,7 @@ namespace HopsWeb\Models; use HopsWeb\Casts\RangeOrNumberCast; +use HopsWeb\Enums\AromaProfile; use HopsWeb\Enums\Aromaticity; use HopsWeb\Enums\Bitterness; use HopsWeb\Enums\HopDescriptor; @@ -121,5 +122,49 @@ class Hop extends Model "lineage" => "array", "aroma_descriptors" => "array", "substitutes" => "array", + "bitterness" => Bitterness::class, + "aromaticity" => Aromaticity::class, ]; + + public function scopeFilter($query, array $filters) + { + foreach (self::RANGE_FIELDS as $field) { + if (isset($filters[$field . "_min"])) { + $query->where($field . "_max", ">=", $filters[$field . "_min"]); + } + + if (isset($filters[$field . "_max"])) { + $query->where($field . "_min", "<=", $filters[$field . "_max"]); + } + } + + foreach (AromaProfile::cases() as $profile) { + $flag = $profile->value; + + if (isset($filters[$flag]) && $filters[$flag] === "1") { + $query->where($flag, 1); + } + } + + if (isset($filters["bitterness"]) && $filters["bitterness"] !== "all") { + $query->where("bitterness", $filters["bitterness"]); + } + + if (isset($filters["aromaticity"]) && $filters["aromaticity"] !== "all") { + $query->where("aromaticity", $filters["aromaticity"]); + } + + return $query; + } + + /** + * @return array + */ + public function getActiveAromas(): array + { + return array_filter( + AromaProfile::cases(), + fn(AromaProfile $profile) => (bool)$this->{$profile->value}, + ); + } } diff --git a/app/ValueObjects/RangeOrNumber.php b/app/ValueObjects/RangeOrNumber.php index 4840700..225ea2d 100644 --- a/app/ValueObjects/RangeOrNumber.php +++ b/app/ValueObjects/RangeOrNumber.php @@ -24,7 +24,7 @@ public function __construct( public static function fromNumber(float $value): self { - return new self(null, null, $value); + return new self($value, $value, $value); } public static function fromRange(float $min, float $max): self diff --git a/resources/css/app.css b/resources/css/app.css index 07707ca..380c47a 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -1,2 +1,3 @@ @import 'tailwindcss'; +@source "../views/**/*.blade.php"; @plugin "@tailwindcss/forms"; diff --git a/resources/views/components/hops/filters.blade.php b/resources/views/components/hops/filters.blade.php new file mode 100644 index 0000000..b915330 --- /dev/null +++ b/resources/views/components/hops/filters.blade.php @@ -0,0 +1,107 @@ +@props(['filters']) + + diff --git a/resources/views/components/hops/header.blade.php b/resources/views/components/hops/header.blade.php new file mode 100644 index 0000000..aa3c4e0 --- /dev/null +++ b/resources/views/components/hops/header.blade.php @@ -0,0 +1,23 @@ +
+
+
+
+ + + +
+

Hop Variety Browser

+
+
+ @if (Route::has('login')) + + @endif +
+
+
diff --git a/resources/views/components/hops/hop-card.blade.php b/resources/views/components/hops/hop-card.blade.php new file mode 100644 index 0000000..184afe5 --- /dev/null +++ b/resources/views/components/hops/hop-card.blade.php @@ -0,0 +1,72 @@ +@props(['hop']) + + + +
+
+
+

+ {{ $hop->name }} +

+

+ {{ $hop->country ?? 'Unknown origin' }} +

+
+ +
+ +
+
+ Alpha Acid + + @if($hop->alpha_acid?->min === $hop->alpha_acid?->max) + {{ $hop->alpha_acid?->min }}% + @else + {{ $hop->alpha_acid?->min ?? 'N/A' }} - {{ $hop->alpha_acid?->max ?? 'N/A' }}% + @endif + +
+
+ Beta Acid + + @if($hop->beta_acid?->min === $hop->beta_acid?->max) + {{ $hop->beta_acid?->min }}% + @else + {{ $hop->beta_acid?->min ?? 'N/A' }} - {{ $hop->beta_acid?->max ?? 'N/A' }}% + @endif + +
+
+ Cohumulone + + @if($hop->cohumulone?->min === $hop->cohumulone?->max) + {{ $hop->cohumulone?->min }}% + @else + {{ $hop->cohumulone?->min ?? 'N/A' }} - {{ $hop->cohumulone?->max ?? 'N/A' }}% + @endif + +
+
+ Total Oil + + @if($hop->total_oil?->min === $hop->total_oil?->max) + {{ $hop->total_oil?->min }}% + @else + {{ $hop->total_oil?->min ?? 'N/A' }} - {{ $hop->total_oil?->max ?? 'N/A' }}% + @endif + +
+
+ +
+ +
+ Full Details + + + +
+
+
+
+ diff --git a/resources/views/components/hops/layout.blade.php b/resources/views/components/hops/layout.blade.php new file mode 100644 index 0000000..4532146 --- /dev/null +++ b/resources/views/components/hops/layout.blade.php @@ -0,0 +1,25 @@ + + + + + + {{ $title ?? 'Hop Variety Browser' }} - {{ config('app.name', 'Laravel') }} + @vite(['resources/css/app.css', 'resources/js/app.ts']) + + +
+ + + +
+ {{ $slot }} +
+ +
+
+ © {{ date('Y') }} {{ config('app.name') }}. All rights reserved. +
+
+
+ + diff --git a/resources/views/components/hops/navigation.blade.php b/resources/views/components/hops/navigation.blade.php new file mode 100644 index 0000000..4c8abb1 --- /dev/null +++ b/resources/views/components/hops/navigation.blade.php @@ -0,0 +1,15 @@ + diff --git a/resources/views/components/hops/pagination.blade.php b/resources/views/components/hops/pagination.blade.php new file mode 100644 index 0000000..c2b6b42 --- /dev/null +++ b/resources/views/components/hops/pagination.blade.php @@ -0,0 +1,106 @@ +@if ($paginator->hasPages()) + +@endif diff --git a/resources/views/hops/index.blade.php b/resources/views/hops/index.blade.php new file mode 100644 index 0000000..33b8062 --- /dev/null +++ b/resources/views/hops/index.blade.php @@ -0,0 +1,48 @@ + + Browse Hop Varieties + +
+ + +
+
+
+
+

Varieties

+

+ Discover the perfect hops for your next brew. +

+
+
+ Showing {{ $hops->firstItem() ?? 0 }}-{{ $hops->lastItem() ?? 0 }} of {{ $hops->total() }} hops +
+
+ + @if($hops->isEmpty()) +
+ + + +

No hops found

+

Try adjusting your filters or clearing them to see more varieties.

+ +
+ @else +
+ @foreach($hops as $hop) + + @endforeach +
+ +
+ {{ $hops->links('components.hops.pagination') }} +
+ @endif +
+
+
+
diff --git a/resources/views/hops/show.blade.php b/resources/views/hops/show.blade.php new file mode 100644 index 0000000..9b28504 --- /dev/null +++ b/resources/views/hops/show.blade.php @@ -0,0 +1,139 @@ + + {{ $hop->name }} - Hop Details + +
+
+ + +
+ +
+

{{ $hop->name }}

+
+ + {{ $hop->country ?? 'Various' }} + + + {{ $hop->alt_name ? "Also known as: {$hop->alt_name}" : "" }} + +
+ +
+

{{ $hop->description ?? 'Detailed description for this hop variety is currently being compiled.' }}

+
+ +
+

Aroma Profile

+
+ @foreach($hop->getActiveAromas() as $aroma) + + {{ $aroma->label() }} + + @endforeach +
+
+ +
+

Aroma Descriptors

+
+ @if($hop->aroma_descriptors) + @foreach($hop->aroma_descriptors as $descriptor) + + {{ $descriptor }} + + @endforeach + @else + No descriptors available + @endif +
+
+ + @if($hop->substitutes) +
+

Possible Substitutes

+
+
+

Brewhouse

+
    + @foreach($hop->substitutes['brewhouse'] ?? [] as $sub) +
  • • {{ $sub }}
  • + @endforeach +
+
+
+

Dry Hopping

+
    + @foreach($hop->substitutes['dryhopping'] ?? [] as $sub) +
  • • {{ $sub }}
  • + @endforeach +
+
+
+
+ @endif +
+ + +
+

Biochemical Profile

+ +
+ @foreach([ + 'alpha_acid' => 'Alpha Acid (%)', + 'beta_acid' => 'Beta Acid (%)', + 'cohumulone' => 'Cohumulone (%)', + 'total_oil' => 'Total Oil (ml/100g)', + 'polyphenol' => 'Polyphenols (%)', + 'xanthohumol' => 'Xanthohumol (%)', + 'farnesene' => 'Farnesene (%)', + 'linalool' => 'Linalool (%)' + ] as $field => $label) +
+
+ {{ $label }} + + @if($hop->{$field}) + @if($hop->{$field}->min === $hop->{$field}->max) + {{ $hop->{$field}->min }} + @else + {{ $hop->{$field}->min }} - {{ $hop->{$field}->max }} + @endif + @else + N/A + @endif + +
+
+
+
+
+ @endforeach +
+ +
+
+ Bitterness + {{ $hop->bitterness->value ?? 'Balanced' }} +
+
+ Aromaticity + {{ $hop->aromaticity->value ?? 'Medium' }} +
+
+
+
+
+
+
diff --git a/routes/HopController.php b/routes/HopController.php new file mode 100644 index 0000000..497d5dc --- /dev/null +++ b/routes/HopController.php @@ -0,0 +1,46 @@ +when($request->boolean("aroma_$aroma"), function (Builder $q) use ($aroma): void { + $q->where("aroma_$aroma", ">", 0); + }); + } + + $query->when($request->string("bitterness"), fn(Builder $q, string $v) => $q->where("bitterness", $v)) + ->when($request->string("aromaticity"), fn(Builder $q, string $v) => $q->where("aromaticity", $v)); + + foreach (Hop::RANGE_FIELDS as $field) { + $query->when($request->input("{$field}_min"), function (Builder $q, $min) use ($field): void { + $q->where("{$field}_max", ">=", $min); + })->when($request->input("{$field}_max"), function (Builder $q, $max) use ($field): void { + $q->where("{$field}_min", "<=", $max); + }); + } + + return view("hops.index", [ + "hops" => $query->latest()->paginate(12)->withQueryString(), + ]); + } + + public function show(Hop $hop): View + { + return view("hops.show", compact("hop")); + } +} diff --git a/routes/web.php b/routes/web.php index b90ab68..4060adc 100644 --- a/routes/web.php +++ b/routes/web.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use HopsWeb\Http\Controllers\HopController; use HopsWeb\Http\Controllers\ProfileController; use Illuminate\Support\Facades\Route; @@ -9,6 +10,9 @@ Route::get("/dashboard", fn() => view("dashboard"))->middleware(["auth", "verified"])->name("dashboard"); +Route::get("/hops", [HopController::class, "index"])->name("hops.index"); +Route::get("/hops/{hop:slug}", [HopController::class, "show"])->name("hops.show"); + Route::middleware("auth")->group(function (): void { Route::get("/profile", [ProfileController::class, "edit"])->name("profile.edit"); Route::patch("/profile", [ProfileController::class, "update"])->name("profile.update"); diff --git a/tests/Feature/Feature/HopBrowserTest.php b/tests/Feature/Feature/HopBrowserTest.php new file mode 100644 index 0000000..6db25fa --- /dev/null +++ b/tests/Feature/Feature/HopBrowserTest.php @@ -0,0 +1,20 @@ +get("/"); + + $response->assertStatus(200); + } +} diff --git a/tests/Feature/HopBrowserTest.php b/tests/Feature/HopBrowserTest.php new file mode 100644 index 0000000..9a6bb8a --- /dev/null +++ b/tests/Feature/HopBrowserTest.php @@ -0,0 +1,71 @@ +create(["name" => "Citra", "slug" => "citra"]); + Hop::factory()->create(["name" => "Mosaic", "slug" => "mosaic"]); + + $response = $this->get("/hops"); + + $response->assertStatus(200); + $response->assertSee("Citra"); + $response->assertSee("Mosaic"); + } + + public function testItDisplaysHopDetails(): void + { + $hop = Hop::factory()->create(["name" => "Citra", "slug" => "citra"]); + + $response = $this->get("/hops/{$hop->slug}"); + + $response->assertStatus(200); + $response->assertSee("Citra"); + $response->assertSee("Biochemical Profile"); + } + + public function testItFiltersByAroma(): void + { + Hop::factory()->create(["name" => "Citrusy Hop", "slug" => "citrusy-hop", "aroma_citrusy" => 1]); + Hop::factory()->create(["name" => "Fruity Hop", "slug" => "fruity-hop", "aroma_citrusy" => 0]); + + $response = $this->get("/hops?aroma_citrusy=1"); + + $response->assertStatus(200); + $response->assertSee("Citrusy Hop"); + $response->assertDontSee("Fruity Hop"); + } + + public function testItFiltersByAlphaAcidRange(): void + { + Hop::factory()->create([ + "name" => "High Alpha", + "slug" => "high-alpha", + "alpha_acid" => RangeOrNumber::fromRange(10, 12), + ]); + + Hop::factory()->create([ + "name" => "Low Alpha", + "slug" => "low-alpha", + "alpha_acid" => RangeOrNumber::fromRange(4, 6), + ]); + + $response = $this->get("/hops?alpha_acid_min=8"); + + $response->assertStatus(200); + $response->assertSee("High Alpha"); + $response->assertDontSee("Low Alpha"); + } +}