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',
|
'category_id' => 'required|exists:categories,id',
|
||||||
'tags' => 'nullable|array',
|
'tags' => 'nullable|array',
|
||||||
'tags.*' => 'exists:tags,id',
|
'tags.*' => 'exists:tags,id',
|
||||||
]);
|
] + $this->detailRules());
|
||||||
|
|
||||||
|
foreach (array_keys(Product::BADGES) as $badge) {
|
||||||
|
$validated[$badge] = $request->boolean($badge);
|
||||||
|
}
|
||||||
|
|
||||||
$validated['user_id'] = Auth::id();
|
$validated['user_id'] = Auth::id();
|
||||||
$validated['status'] = 'pending';
|
$validated['status'] = 'pending';
|
||||||
@@ -128,6 +132,8 @@ class ProductController extends Controller
|
|||||||
$product->tags()->attach($request->tags);
|
$product->tags()->attach($request->tags);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->syncDetails($request, $product);
|
||||||
|
|
||||||
return redirect()->route('products.show', $product)
|
return redirect()->route('products.show', $product)
|
||||||
->with('success', 'Product submitted successfully! It will be reviewed by our moderators.');
|
->with('success', 'Product submitted successfully! It will be reviewed by our moderators.');
|
||||||
}
|
}
|
||||||
@@ -142,7 +148,7 @@ class ProductController extends Controller
|
|||||||
abort(404);
|
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'));
|
return view('products.show', compact('product'));
|
||||||
}
|
}
|
||||||
@@ -174,7 +180,16 @@ class ProductController extends Controller
|
|||||||
'category_id' => 'required|exists:categories,id',
|
'category_id' => 'required|exists:categories,id',
|
||||||
'tags' => 'nullable|array',
|
'tags' => 'nullable|array',
|
||||||
'tags.*' => 'exists:tags,id',
|
'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
|
// Handle thumbnail upload
|
||||||
if ($request->hasFile('thumbnail')) {
|
if ($request->hasFile('thumbnail')) {
|
||||||
@@ -189,6 +204,8 @@ class ProductController extends Controller
|
|||||||
|
|
||||||
$product->update($validated);
|
$product->update($validated);
|
||||||
|
|
||||||
|
$this->syncDetails($request, $product);
|
||||||
|
|
||||||
// Sync tags (will remove old tags and add new ones)
|
// Sync tags (will remove old tags and add new ones)
|
||||||
$product->tags()->sync($request->tags ?? []);
|
$product->tags()->sync($request->tags ?? []);
|
||||||
|
|
||||||
@@ -196,6 +213,27 @@ class ProductController extends Controller
|
|||||||
->with('success', 'Product updated successfully!');
|
->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.
|
* Remove the specified product from storage.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -2,12 +2,15 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
class Category extends Model
|
class Category extends Model
|
||||||
{
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The attributes that are mass assignable.
|
* The attributes that are mass assignable.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
@@ -10,6 +11,18 @@ use Illuminate\Support\Str;
|
|||||||
|
|
||||||
class Product extends Model
|
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.
|
* The attributes that are mass assignable.
|
||||||
*
|
*
|
||||||
@@ -26,6 +39,10 @@ class Product extends Model
|
|||||||
'status',
|
'status',
|
||||||
'approved_at',
|
'approved_at',
|
||||||
'rejected_at',
|
'rejected_at',
|
||||||
|
'free_tier',
|
||||||
|
'open_source',
|
||||||
|
'eu_hosted',
|
||||||
|
'eu_made',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -38,6 +55,10 @@ class Product extends Model
|
|||||||
return [
|
return [
|
||||||
'approved_at' => 'datetime',
|
'approved_at' => 'datetime',
|
||||||
'rejected_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',
|
'status' => 'pending',
|
||||||
'approved_at' => null,
|
'approved_at' => null,
|
||||||
'rejected_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) }}
|
{{ Str::limit($product->description, 100) }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<!-- Badges -->
|
||||||
|
<div class="mb-3 empty:hidden">
|
||||||
|
<x-product.badges :product="$product" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Meta Information -->
|
<!-- Meta Information -->
|
||||||
<div class="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
|
<div class="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
|
||||||
<div class="flex items-center space-x-3">
|
<div class="flex items-center space-x-3">
|
||||||
|
|||||||
@@ -133,6 +133,26 @@
|
|||||||
</div>
|
</div>
|
||||||
@endif
|
@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 -->
|
<!-- Thumbnail -->
|
||||||
<div>
|
<div>
|
||||||
<label for="thumbnail" class="block text-sm font-medium text-gray-700">
|
<label for="thumbnail" class="block text-sm font-medium text-gray-700">
|
||||||
|
|||||||
@@ -139,6 +139,34 @@
|
|||||||
</div>
|
</div>
|
||||||
@endif
|
@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 -->
|
<!-- Current Thumbnail -->
|
||||||
@if($product->thumbnail_url)
|
@if($product->thumbnail_url)
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -53,6 +53,18 @@
|
|||||||
<span>•</span>
|
<span>•</span>
|
||||||
<span>{{ $product->created_at->format('M j, Y') }}</span>
|
<span>{{ $product->created_at->format('M j, Y') }}</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Action Buttons -->
|
<!-- Action Buttons -->
|
||||||
@@ -105,69 +117,78 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</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 -->
|
<!-- 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">
|
<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">
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
|
||||||
Comments ({{ $product->comments()->count() }})
|
Comments ({{ $product->comments()->count() }})
|
||||||
@@ -259,6 +280,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sidebar column -->
|
||||||
|
<div class="lg:col-span-1 space-y-6">
|
||||||
|
<!-- slot:team -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Moderator Actions -->
|
<!-- Moderator Actions -->
|
||||||
@can('approve-product')
|
@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