From 9920fafab61a55df81e92c94722e144c88a28bba Mon Sep 17 00:00:00 2001 From: Doar Mihai Date: Sat, 17 Oct 2026 15:56:58 +0300 Subject: [PATCH] feat: product badges and show page layout Co-Authored-By: Claude Sonnet 5.5 --- app/Http/Controllers/ProductController.php | 44 +++++- app/Models/Category.php | 3 + app/Models/Product.php | 21 +++ database/factories/ProductFactory.php | 4 + ...17_100000_add_badges_to_products_table.php | 31 ++++ .../views/components/product/badges.blade.php | 15 ++ .../views/components/product/card.blade.php | 5 + resources/views/products/create.blade.php | 20 +++ resources/views/products/edit.blade.php | 28 ++++ resources/views/products/show.blade.php | 148 +++++++++++------- tests/Feature/ProductBadgesTest.php | 102 ++++++++++++ tests/Pest.php | 24 +++ 12 files changed, 382 insertions(+), 63 deletions(-) create mode 100644 database/migrations/2026_10_17_100000_add_badges_to_products_table.php create mode 100644 resources/views/components/product/badges.blade.php create mode 100644 tests/Feature/ProductBadgesTest.php diff --git a/app/Http/Controllers/ProductController.php b/app/Http/Controllers/ProductController.php index 3b649a9..f7624b6 100644 --- a/app/Http/Controllers/ProductController.php +++ b/app/Http/Controllers/ProductController.php @@ -110,7 +110,11 @@ class ProductController extends Controller 'category_id' => 'required|exists:categories,id', 'tags' => 'nullable|array', 'tags.*' => 'exists:tags,id', - ]); + ] + $this->detailRules()); + + foreach (array_keys(Product::BADGES) as $badge) { + $validated[$badge] = $request->boolean($badge); + } $validated['user_id'] = Auth::id(); $validated['status'] = 'pending'; @@ -128,6 +132,8 @@ class ProductController extends Controller $product->tags()->attach($request->tags); } + $this->syncDetails($request, $product); + return redirect()->route('products.show', $product) ->with('success', 'Product submitted successfully! It will be reviewed by our moderators.'); } @@ -142,7 +148,7 @@ class ProductController extends Controller abort(404); } - $product->load(['user', 'category', 'comments.user', 'upvotes', 'bookmarks']); + $product->load(['user', 'category', 'tags', 'comments.user', 'upvotes', 'bookmarks']); return view('products.show', compact('product')); } @@ -174,7 +180,16 @@ class ProductController extends Controller 'category_id' => 'required|exists:categories,id', 'tags' => 'nullable|array', 'tags.*' => 'exists:tags,id', - ]); + ] + $this->detailRules()); + + // Badges are locked for non-moderators once the product is approved + if ($product->status !== 'approved' || Auth::user()->hasAnyRole(['moderator', 'admin'])) { + foreach (array_keys(Product::BADGES) as $badge) { + $validated[$badge] = $request->boolean($badge); + } + } else { + $validated = array_diff_key($validated, Product::BADGES); + } // Handle thumbnail upload if ($request->hasFile('thumbnail')) { @@ -189,6 +204,8 @@ class ProductController extends Controller $product->update($validated); + $this->syncDetails($request, $product); + // Sync tags (will remove old tags and add new ones) $product->tags()->sync($request->tags ?? []); @@ -196,6 +213,27 @@ class ProductController extends Controller ->with('success', 'Product updated successfully!'); } + /** + * Validation rules for the product detail fields. + */ + protected function detailRules(): array + { + return [ + 'free_tier' => 'boolean', + 'open_source' => 'boolean', + 'eu_hosted' => 'boolean', + 'eu_made' => 'boolean', + ]; + } + + /** + * Sync related product detail data (badges are stored on the product itself). + */ + protected function syncDetails(Request $request, Product $product): void + { + // + } + /** * Remove the specified product from storage. */ diff --git a/app/Models/Category.php b/app/Models/Category.php index 157b80e..4aee1a9 100644 --- a/app/Models/Category.php +++ b/app/Models/Category.php @@ -2,12 +2,15 @@ namespace App\Models; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Support\Str; class Category extends Model { + use HasFactory; + /** * The attributes that are mass assignable. * diff --git a/app/Models/Product.php b/app/Models/Product.php index fd34885..55e793c 100644 --- a/app/Models/Product.php +++ b/app/Models/Product.php @@ -3,6 +3,7 @@ namespace App\Models; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; @@ -10,6 +11,18 @@ use Illuminate\Support\Str; class Product extends Model { + use HasFactory; + + /** + * Badge keys and their labels. + */ + public const BADGES = [ + 'free_tier' => 'Free tier', + 'open_source' => 'Open source', + 'eu_hosted' => 'EU hosted', + 'eu_made' => 'EU made', + ]; + /** * The attributes that are mass assignable. * @@ -26,6 +39,10 @@ class Product extends Model 'status', 'approved_at', 'rejected_at', + 'free_tier', + 'open_source', + 'eu_hosted', + 'eu_made', ]; /** @@ -38,6 +55,10 @@ class Product extends Model return [ 'approved_at' => 'datetime', 'rejected_at' => 'datetime', + 'free_tier' => 'boolean', + 'open_source' => 'boolean', + 'eu_hosted' => 'boolean', + 'eu_made' => 'boolean', ]; } diff --git a/database/factories/ProductFactory.php b/database/factories/ProductFactory.php index 81e2739..06e2b4f 100644 --- a/database/factories/ProductFactory.php +++ b/database/factories/ProductFactory.php @@ -36,6 +36,10 @@ class ProductFactory extends Factory 'status' => 'pending', 'approved_at' => null, 'rejected_at' => null, + 'free_tier' => false, + 'open_source' => false, + 'eu_hosted' => false, + 'eu_made' => false, ]; } diff --git a/database/migrations/2026_10_17_100000_add_badges_to_products_table.php b/database/migrations/2026_10_17_100000_add_badges_to_products_table.php new file mode 100644 index 0000000..983e4a6 --- /dev/null +++ b/database/migrations/2026_10_17_100000_add_badges_to_products_table.php @@ -0,0 +1,31 @@ +boolean('free_tier')->default(false); + $table->boolean('open_source')->default(false); + $table->boolean('eu_hosted')->default(false); + $table->boolean('eu_made')->default(false); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('products', function (Blueprint $table) { + $table->dropColumn(['free_tier', 'open_source', 'eu_hosted', 'eu_made']); + }); + } +}; diff --git a/resources/views/components/product/badges.blade.php b/resources/views/components/product/badges.blade.php new file mode 100644 index 0000000..729b321 --- /dev/null +++ b/resources/views/components/product/badges.blade.php @@ -0,0 +1,15 @@ +@props(['product']) + +@php + $ticked = collect(\App\Models\Product::BADGES)->filter(fn ($label, $key) => $product->{$key}); +@endphp + +@if($ticked->isNotEmpty()) +
+ @foreach($ticked as $key => $label) + + {{ $label }} + + @endforeach +
+@endif diff --git a/resources/views/components/product/card.blade.php b/resources/views/components/product/card.blade.php index 0324d76..7ac304a 100644 --- a/resources/views/components/product/card.blade.php +++ b/resources/views/components/product/card.blade.php @@ -37,6 +37,11 @@ {{ Str::limit($product->description, 100) }}

+ +
+ +
+
diff --git a/resources/views/products/create.blade.php b/resources/views/products/create.blade.php index 073a862..de7cf34 100644 --- a/resources/views/products/create.blade.php +++ b/resources/views/products/create.blade.php @@ -133,6 +133,26 @@
@endif + +
+ +
+ @foreach(\App\Models\Product::BADGES as $key => $label) + + @endforeach +
+
+
@endif + @php + $badgesLocked = $product->status === 'approved' && !auth()->user()->hasAnyRole(['moderator', 'admin']); + @endphp + + +
+ +
+ @foreach(\App\Models\Product::BADGES as $key => $label) + + @endforeach +
+ @if($badgesLocked) +

Locked after approval

+ @endif +
+ @if($product->thumbnail_url)
diff --git a/resources/views/products/show.blade.php b/resources/views/products/show.blade.php index e8056a4..0cec866 100644 --- a/resources/views/products/show.blade.php +++ b/resources/views/products/show.blade.php @@ -53,6 +53,18 @@ • {{ $product->created_at->format('M j, Y') }}
+ +
+ +
+ + @if($product->tags->isNotEmpty()) +
+ @foreach($product->tags as $tag) + #{{ $tag->name }} + @endforeach +
+ @endif
@@ -105,69 +117,78 @@ - - -
- - @if($product->thumbnail_url) -
- {{ $product->title }} -
- @endif - - -
-

About

-

{{ $product->description }}

-
- - - - - - @if($product->isApproved() && $product->approved_at) -
-
-
-
Submitted by
-
- - {{ $product->user->name }} - -
-
-
-
Submitted on
-
{{ $product->created_at->format('F j, Y') }}
-
-
-
Approved on
-
{{ $product->approved_at->format('F j, Y') }}
-
-
-
Category
-
{{ $product->category->name }}
-
-
-
- @endif -
+ + + +
+ +
+
+ + @if($product->thumbnail_url) +
+ {{ $product->title }} +
+ @endif + + +
+

About

+

{{ $product->description }}

+
+ + + + + + @if($product->isApproved() && $product->approved_at) +
+
+
+
Submitted by
+
+ + {{ $product->user->name }} + +
+
+
+
Submitted on
+
{{ $product->created_at->format('F j, Y') }}
+
+
+
Approved on
+
{{ $product->approved_at->format('F j, Y') }}
+
+
+
Category
+
{{ $product->category->name }}
+
+
+
+ @endif +
+ + + + + -
+

Comments ({{ $product->comments()->count() }}) @@ -259,6 +280,13 @@

+
+ + +
+ +
+
@can('approve-product') diff --git a/tests/Feature/ProductBadgesTest.php b/tests/Feature/ProductBadgesTest.php new file mode 100644 index 0000000..b35d17f --- /dev/null +++ b/tests/Feature/ProductBadgesTest.php @@ -0,0 +1,102 @@ + 'Badge Product', + 'description' => 'A product description', + 'website_url' => 'https://example.com', + 'category_id' => makeCategory()->id, + ], $overrides); +} + +test('submitter ticks badges on create', function () { + $user = userWithRole('user'); + + $this->actingAs($user)->post(route('products.store'), badgePayload([ + 'eu_hosted' => 1, + 'open_source' => 1, + ]))->assertRedirect(); + + $product = Product::firstOrFail(); + expect($product->eu_hosted)->toBeTrue() + ->and($product->open_source)->toBeTrue() + ->and($product->free_tier)->toBeFalse() + ->and($product->eu_made)->toBeFalse(); +}); + +test('submitter can change badges while pending', function () { + $user = userWithRole('user'); + $product = Product::factory()->pending()->create(['user_id' => $user->id]); + + $this->actingAs($user)->put(route('products.update', $product), badgePayload([ + 'category_id' => $product->category_id, + 'eu_made' => 1, + ]))->assertRedirect(); + + expect($product->fresh()->eu_made)->toBeTrue(); +}); + +test('submitter cannot change badges after approval', function () { + $user = userWithRole('user'); + $product = Product::factory()->approved()->create([ + 'user_id' => $user->id, + 'eu_hosted' => false, + ]); + + $this->actingAs($user)->put(route('products.update', $product), badgePayload([ + 'category_id' => $product->category_id, + 'eu_hosted' => 1, + ]))->assertRedirect(); + + expect($product->fresh()->eu_hosted)->toBeFalse(); +}); + +test('moderator can untick a badge on an approved product', function () { + $moderator = userWithRole('moderator'); + $product = Product::factory()->approved()->create(['eu_hosted' => true]); + + $this->actingAs($moderator)->put(route('products.update', $product), badgePayload([ + 'category_id' => $product->category_id, + ]))->assertRedirect(); + + expect($product->fresh()->eu_hosted)->toBeFalse(); +}); + +test('edit form shows badges read-only to owner of approved product', function () { + $user = userWithRole('user'); + $approved = Product::factory()->approved()->create(['user_id' => $user->id]); + $pending = Product::factory()->pending()->create(['user_id' => $user->id]); + + $this->actingAs($user)->get(route('products.edit', $approved)) + ->assertOk() + ->assertSee('EU hosted') + ->assertSee('disabled') + ->assertSee('Locked after approval'); + + $this->actingAs($user)->get(route('products.edit', $pending)) + ->assertOk() + ->assertSee('EU hosted') + ->assertDontSee('disabled') + ->assertDontSee('Locked after approval'); +}); + +test('show page lists ticked badges only', function () { + $product = Product::factory()->approved()->create(['eu_hosted' => true]); + + $this->get(route('products.show', $product)) + ->assertOk() + ->assertSee('EU hosted') + ->assertDontSee('EU made'); +}); + +test('existing product without new data still renders', function () { + $product = Product::factory()->approved()->create(); + + $this->get(route('products.show', $product))->assertOk(); +}); diff --git a/tests/Pest.php b/tests/Pest.php index 60f04a4..4e0843a 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -45,3 +45,27 @@ function something() { // .. } + +function userWithRole(string $slug): \App\Models\User +{ + $role = \App\Models\Role::firstOrCreate( + ['slug' => $slug], + ['name' => ucfirst($slug), 'description' => ucfirst($slug)] + ); + + $user = \App\Models\User::factory()->create(); + $user->roles()->attach($role); + + return $user; +} + +function makeCategory(): \App\Models\Category +{ + $name = 'Category ' . \Illuminate\Support\Str::random(8); + + return \App\Models\Category::create([ + 'name' => $name, + 'slug' => \Illuminate\Support\Str::slug($name), + 'description' => 'Test description', + ]); +}