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

-
- @endif
-
-
-
-
About
-
{{ $product->description }}
-
-
-
-
-
-
- @if($product->isApproved() && $product->approved_at)
-
-
-
-
-
- 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)
+
+

+
+ @endif
+
+
+
+
About
+
{{ $product->description }}
+
+
+
+
+
+
+ @if($product->isApproved() && $product->approved_at)
+
+
+
+
+
- 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',
+ ]);
+}