feat: product badges and show page layout

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Doar Mihai
2026-10-17 15:56:58 +03:00
co-authored by Claude Sonnet 5.5
parent 029689407b
commit 9920fafab6
12 changed files with 382 additions and 63 deletions
+41 -3
View File
@@ -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.
*/
+3
View File
@@ -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.
*
+21
View File
@@ -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',
];
}
+4
View File
@@ -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,
];
}
@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('products', function (Blueprint $table) {
$table->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']);
});
}
};
@@ -0,0 +1,15 @@
@props(['product'])
@php
$ticked = collect(\App\Models\Product::BADGES)->filter(fn ($label, $key) => $product->{$key});
@endphp
@if($ticked->isNotEmpty())
<div class="flex flex-wrap items-center gap-1.5">
@foreach($ticked as $key => $label)
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-primary-100 dark:bg-primary-900 text-primary-800 dark:text-primary-200">
{{ $label }}
</span>
@endforeach
</div>
@endif
@@ -37,6 +37,11 @@
{{ Str::limit($product->description, 100) }}
</p>
<!-- Badges -->
<div class="mb-3 empty:hidden">
<x-product.badges :product="$product" />
</div>
<!-- Meta Information -->
<div class="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
<div class="flex items-center space-x-3">
+20
View File
@@ -133,6 +133,26 @@
</div>
@endif
<!-- Badges -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
Badges
</label>
<div class="mt-1 grid grid-cols-2 sm:grid-cols-4 gap-3">
@foreach(\App\Models\Product::BADGES as $key => $label)
<label class="flex items-center p-3 border border-gray-300 rounded-md">
<input type="hidden" name="{{ $key }}" value="0">
<input type="checkbox"
name="{{ $key }}"
value="1"
{{ old($key, false) ? 'checked' : '' }}
class="focus:ring-primary-500 h-4 w-4 text-primary-600 border-gray-300 rounded">
<span class="ml-2 text-sm text-gray-900">{{ $label }}</span>
</label>
@endforeach
</div>
</div>
<!-- Thumbnail -->
<div>
<label for="thumbnail" class="block text-sm font-medium text-gray-700">
+28
View File
@@ -139,6 +139,34 @@
</div>
@endif
@php
$badgesLocked = $product->status === 'approved' && !auth()->user()->hasAnyRole(['moderator', 'admin']);
@endphp
<!-- Badges -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
Badges
</label>
<div class="mt-1 grid grid-cols-2 sm:grid-cols-4 gap-3">
@foreach(\App\Models\Product::BADGES as $key => $label)
<label class="flex items-center p-3 border border-gray-300 rounded-md">
<input type="hidden" name="{{ $key }}" value="0">
<input type="checkbox"
name="{{ $key }}"
value="1"
{{ old($key, $product->{$key}) ? 'checked' : '' }}
{{ $badgesLocked ? 'disabled' : '' }}
class="focus:ring-primary-500 h-4 w-4 text-primary-600 border-gray-300 rounded">
<span class="ml-2 text-sm text-gray-900">{{ $label }}</span>
</label>
@endforeach
</div>
@if($badgesLocked)
<p class="mt-2 text-sm text-gray-500">Locked after approval</p>
@endif
</div>
<!-- Current Thumbnail -->
@if($product->thumbnail_url)
<div>
+88 -60
View File
@@ -53,6 +53,18 @@
<span>•</span>
<span>{{ $product->created_at->format('M j, Y') }}</span>
</div>
<div class="mt-3">
<x-product.badges :product="$product" />
</div>
@if($product->tags->isNotEmpty())
<div class="mt-3 flex flex-wrap gap-x-3 gap-y-1 text-sm text-gray-500 dark:text-gray-400">
@foreach($product->tags as $tag)
<span>#{{ $tag->name }}</span>
@endforeach
</div>
@endif
</div>
<!-- Action Buttons -->
@@ -105,69 +117,78 @@
</div>
</div>
</div>
<!-- Product Content -->
<div class="px-6 py-8">
<!-- Product Thumbnail -->
@if($product->thumbnail_url)
<div class="mb-8 rounded-lg overflow-hidden border border-gray-200 dark:border-gray-700">
<img src="{{ $product->thumbnail_url }}"
alt="{{ $product->title }}"
class="w-full h-auto max-h-96 object-contain bg-gray-50 dark:bg-gray-900">
</div>
@endif
<!-- Product Description -->
<div class="mb-8">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">About</h2>
<p class="text-gray-700 dark:text-gray-300 text-lg leading-relaxed whitespace-pre-line">{{ $product->description }}</p>
</div>
<!-- Visit Website Button -->
<div class="mb-8">
<a href="{{ $product->website_url }}"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center px-6 py-3 border border-transparent text-base font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 shadow-sm">
Visit Website
<svg class="ml-2 h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path>
</svg>
</a>
</div>
<!-- Product Metadata -->
@if($product->isApproved() && $product->approved_at)
<div class="pt-6 border-t border-gray-200 dark:border-gray-700">
<dl class="grid grid-cols-1 gap-x-4 gap-y-4 sm:grid-cols-2">
<div>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Submitted by</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
<a href="{{ route('profile.show', $product->user) }}" class="hover:text-primary-600 dark:hover:text-primary-400">
{{ $product->user->name }}
</a>
</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Submitted on</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white">{{ $product->created_at->format('F j, Y') }}</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Approved on</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white">{{ $product->approved_at->format('F j, Y') }}</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Category</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white">{{ $product->category->name }}</dd>
</div>
</dl>
</div>
@endif
</div>
</div>
<!-- Gallery -->
<!-- slot:gallery -->
<div class="mt-6 grid grid-cols-1 lg:grid-cols-4 gap-6">
<!-- Main column -->
<div class="lg:col-span-3 space-y-6">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 px-6 py-8">
<!-- Product Thumbnail -->
@if($product->thumbnail_url)
<div class="mb-8 rounded-lg overflow-hidden border border-gray-200 dark:border-gray-700">
<img src="{{ $product->thumbnail_url }}"
alt="{{ $product->title }}"
class="w-full h-auto max-h-96 object-contain bg-gray-50 dark:bg-gray-900">
</div>
@endif
<!-- Product Description -->
<div class="mb-8">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">About</h2>
<p class="text-gray-700 dark:text-gray-300 text-lg leading-relaxed whitespace-pre-line">{{ $product->description }}</p>
</div>
<!-- Visit Website Button -->
<div class="mb-8">
<a href="{{ $product->website_url }}"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center px-6 py-3 border border-transparent text-base font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 shadow-sm">
Visit Website
<svg class="ml-2 h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path>
</svg>
</a>
</div>
<!-- Product Metadata -->
@if($product->isApproved() && $product->approved_at)
<div class="pt-6 border-t border-gray-200 dark:border-gray-700">
<dl class="grid grid-cols-1 gap-x-4 gap-y-4 sm:grid-cols-2">
<div>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Submitted by</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
<a href="{{ route('profile.show', $product->user) }}" class="hover:text-primary-600 dark:hover:text-primary-400">
{{ $product->user->name }}
</a>
</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Submitted on</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white">{{ $product->created_at->format('F j, Y') }}</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Approved on</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white">{{ $product->approved_at->format('F j, Y') }}</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Category</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white">{{ $product->category->name }}</dd>
</div>
</dl>
</div>
@endif
</div>
<!-- slot:pricing -->
<!-- slot:alternatives -->
<!-- Comments Section -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 mt-6">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
Comments ({{ $product->comments()->count() }})
@@ -259,6 +280,13 @@
</div>
</div>
</div>
</div>
<!-- Sidebar column -->
<div class="lg:col-span-1 space-y-6">
<!-- slot:team -->
</div>
</div>
<!-- Moderator Actions -->
@can('approve-product')
+102
View File
@@ -0,0 +1,102 @@
<?php
use App\Models\Product;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
function badgePayload(array $overrides = []): array
{
return array_merge([
'title' => '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();
});
+24
View File
@@ -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',
]);
}