feat: product badges and show page layout
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
029689407b
commit
9920fafab6
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,9 +117,15 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Product Content -->
|
||||
<div class="px-6 py-8">
|
||||
<!-- 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">
|
||||
@@ -164,10 +182,13 @@
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</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')
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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',
|
||||
]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user