Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06ac14c5e6 | ||
|
|
231123f641 | ||
|
|
c4e6173e46 | ||
|
|
fbb4862454 | ||
|
|
768c814762 | ||
|
|
a3894d2f37 | ||
|
|
bf365d029f | ||
|
|
9920fafab6 | ||
|
|
029689407b | ||
|
|
b60fd99f0a | ||
|
|
5852f0f921 |
@@ -3,12 +3,15 @@
|
|||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Models\Category;
|
use App\Models\Category;
|
||||||
|
use App\Models\PricingPlan;
|
||||||
use App\Models\Product;
|
use App\Models\Product;
|
||||||
use App\Models\Tag;
|
use App\Models\Tag;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Gate;
|
use Illuminate\Support\Facades\Gate;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
use Laravel\Pennant\Feature;
|
use Laravel\Pennant\Feature;
|
||||||
|
|
||||||
class ProductController extends Controller
|
class ProductController extends Controller
|
||||||
@@ -110,22 +113,41 @@ 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(Auth::id()));
|
||||||
|
|
||||||
|
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';
|
||||||
|
|
||||||
// Handle thumbnail upload
|
$created = [];
|
||||||
if ($request->hasFile('thumbnail')) {
|
$obsolete = [];
|
||||||
$path = $request->file('thumbnail')->store('thumbnails', 'public');
|
|
||||||
$validated['thumbnail'] = $path;
|
|
||||||
}
|
|
||||||
|
|
||||||
$product = Product::create($validated);
|
try {
|
||||||
|
$product = DB::transaction(function () use ($request, $validated, &$created, &$obsolete) {
|
||||||
|
// Handle thumbnail upload
|
||||||
|
if ($request->hasFile('thumbnail')) {
|
||||||
|
$path = $request->file('thumbnail')->store('thumbnails', 'public');
|
||||||
|
$created[] = $path;
|
||||||
|
$validated['thumbnail'] = $path;
|
||||||
|
}
|
||||||
|
|
||||||
// Attach tags if provided
|
$product = Product::create($validated);
|
||||||
if ($request->has('tags')) {
|
|
||||||
$product->tags()->attach($request->tags);
|
// Attach tags if provided
|
||||||
|
if ($request->has('tags')) {
|
||||||
|
$product->tags()->attach($request->tags);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->syncDetails($request, $product, true, $created, $obsolete);
|
||||||
|
|
||||||
|
return $product;
|
||||||
|
});
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Storage::disk('public')->delete($created);
|
||||||
|
throw $e;
|
||||||
}
|
}
|
||||||
|
|
||||||
return redirect()->route('products.show', $product)
|
return redirect()->route('products.show', $product)
|
||||||
@@ -142,7 +164,7 @@ class ProductController extends Controller
|
|||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
$product->load(['user', 'category', 'comments.user', 'upvotes', 'bookmarks']);
|
$product->load(['user', 'category', 'tags', 'members', 'comments.user', 'upvotes', 'bookmarks']);
|
||||||
|
|
||||||
return view('products.show', compact('product'));
|
return view('products.show', compact('product'));
|
||||||
}
|
}
|
||||||
@@ -174,28 +196,132 @@ 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($product->user_id));
|
||||||
|
|
||||||
// Handle thumbnail upload
|
// Badges are locked for non-moderators once the product is approved
|
||||||
if ($request->hasFile('thumbnail')) {
|
if ($product->status !== 'approved' || Auth::user()->hasAnyRole(['moderator', 'admin'])) {
|
||||||
// Delete old thumbnail if exists
|
foreach (array_keys(Product::BADGES) as $badge) {
|
||||||
if ($product->thumbnail) {
|
$validated[$badge] = $request->boolean($badge);
|
||||||
Storage::disk('public')->delete($product->thumbnail);
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
$path = $request->file('thumbnail')->store('thumbnails', 'public');
|
$validated = array_diff_key($validated, Product::BADGES);
|
||||||
$validated['thumbnail'] = $path;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$product->update($validated);
|
// Only the submitter and admins manage the team
|
||||||
|
$manageMembers = Auth::id() === $product->user_id || Auth::user()->isAdmin();
|
||||||
|
|
||||||
// Sync tags (will remove old tags and add new ones)
|
$created = [];
|
||||||
$product->tags()->sync($request->tags ?? []);
|
$obsolete = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
DB::transaction(function () use ($request, $product, $validated, $manageMembers, &$created, &$obsolete) {
|
||||||
|
// Handle thumbnail upload
|
||||||
|
if ($request->hasFile('thumbnail')) {
|
||||||
|
if ($product->thumbnail) {
|
||||||
|
$obsolete[] = $product->thumbnail;
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = $request->file('thumbnail')->store('thumbnails', 'public');
|
||||||
|
$created[] = $path;
|
||||||
|
$validated['thumbnail'] = $path;
|
||||||
|
}
|
||||||
|
|
||||||
|
$product->update($validated);
|
||||||
|
|
||||||
|
$this->syncDetails($request, $product, $manageMembers, $created, $obsolete);
|
||||||
|
|
||||||
|
// Sync tags (will remove old tags and add new ones)
|
||||||
|
$product->tags()->sync($request->tags ?? []);
|
||||||
|
});
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Storage::disk('public')->delete($created);
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Old files are only removed once the changes are committed
|
||||||
|
Storage::disk('public')->delete($obsolete);
|
||||||
|
|
||||||
return redirect()->route('products.show', $product)
|
return redirect()->route('products.show', $product)
|
||||||
->with('success', 'Product updated successfully!');
|
->with('success', 'Product updated successfully!');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validation rules for the product detail fields.
|
||||||
|
*/
|
||||||
|
protected function detailRules(int $ownerId): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'free_tier' => 'boolean',
|
||||||
|
'open_source' => 'boolean',
|
||||||
|
'eu_hosted' => 'boolean',
|
||||||
|
'eu_made' => 'boolean',
|
||||||
|
'screenshots' => 'nullable|array',
|
||||||
|
'screenshots.*' => 'image|mimes:jpeg,png,jpg,gif|max:2048',
|
||||||
|
'remove_screenshots' => 'nullable|array',
|
||||||
|
'remove_screenshots.*' => 'integer',
|
||||||
|
'plans' => 'nullable|array',
|
||||||
|
'plans.*.name' => 'required|string|max:100',
|
||||||
|
'plans.*.price' => 'required|numeric|min:0|max:99999|decimal:0,2',
|
||||||
|
'plans.*.period' => ['required', Rule::in(PricingPlan::PERIODS)],
|
||||||
|
'plans.*.description' => 'nullable|string|max:255',
|
||||||
|
'members' => 'nullable|array',
|
||||||
|
'members.*.user_id' => ['required', 'distinct', 'exists:users,id', Rule::notIn([$ownerId])],
|
||||||
|
'members.*.role' => 'required|string|max:100',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync related product detail data (badges are stored on the product itself).
|
||||||
|
* Must run inside a transaction: newly stored files are added to $created,
|
||||||
|
* files to remove after commit are added to $obsolete.
|
||||||
|
*/
|
||||||
|
protected function syncDetails(Request $request, Product $product, bool $manageMembers, array &$created, array &$obsolete): void
|
||||||
|
{
|
||||||
|
// The submitted plan list replaces all existing plans (a missing sentinel means the form could not send one)
|
||||||
|
if ($request->has('plans_present') || $product->wasRecentlyCreated) {
|
||||||
|
$product->pricingPlans()->delete();
|
||||||
|
foreach (array_values((array) $request->input('plans', [])) as $i => $plan) {
|
||||||
|
$product->pricingPlans()->create([
|
||||||
|
'name' => $plan['name'],
|
||||||
|
'price' => $plan['price'],
|
||||||
|
'period' => $plan['period'],
|
||||||
|
'description' => $plan['description'] ?? null,
|
||||||
|
'position' => $i,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The submitted member list replaces all existing members
|
||||||
|
if ($manageMembers && ($request->has('members_present') || $product->wasRecentlyCreated)) {
|
||||||
|
$members = [];
|
||||||
|
foreach ((array) $request->input('members', []) as $member) {
|
||||||
|
$members[$member['user_id']] = ['role' => $member['role']];
|
||||||
|
}
|
||||||
|
$product->members()->sync($members);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only rows belonging to this product may be removed
|
||||||
|
$ids = array_filter((array) $request->input('remove_screenshots', []));
|
||||||
|
if ($ids) {
|
||||||
|
$product->screenshots()->whereIn('id', $ids)->get()->each(function ($shot) use (&$obsolete) {
|
||||||
|
$obsolete[] = $shot->path;
|
||||||
|
$shot->delete();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->hasFile('screenshots')) {
|
||||||
|
$position = ($product->screenshots()->max('position') ?? -1) + 1;
|
||||||
|
foreach ($request->file('screenshots') as $file) {
|
||||||
|
$path = $file->store('screenshots', 'public');
|
||||||
|
$created[] = $path;
|
||||||
|
$product->screenshots()->create([
|
||||||
|
'path' => $path,
|
||||||
|
'position' => $position++,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove the specified product from storage.
|
* Remove the specified product from storage.
|
||||||
*/
|
*/
|
||||||
@@ -203,13 +329,16 @@ class ProductController extends Controller
|
|||||||
{
|
{
|
||||||
Gate::authorize('delete-product', $product);
|
Gate::authorize('delete-product', $product);
|
||||||
|
|
||||||
// Delete thumbnail if exists
|
$files = $product->screenshots->pluck('path')->all();
|
||||||
if ($product->thumbnail) {
|
if ($product->thumbnail) {
|
||||||
Storage::disk('public')->delete($product->thumbnail);
|
$files[] = $product->thumbnail;
|
||||||
}
|
}
|
||||||
|
|
||||||
$product->delete();
|
$product->delete();
|
||||||
|
|
||||||
|
// Files go only after the row is gone
|
||||||
|
Storage::disk('public')->delete($files);
|
||||||
|
|
||||||
return redirect()->route('products.index')
|
return redirect()->route('products.index')
|
||||||
->with('success', 'Product deleted successfully!');
|
->with('success', 'Product deleted successfully!');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class UserSearchController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Search users by name (only public identifiers are returned).
|
||||||
|
*/
|
||||||
|
public function __invoke(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$q = trim((string) $request->query('q', ''));
|
||||||
|
|
||||||
|
if (mb_strlen($q) < 2) {
|
||||||
|
return response()->json([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$escaped = addcslashes($q, '\\%_');
|
||||||
|
|
||||||
|
return response()->json(
|
||||||
|
User::query()
|
||||||
|
->where('name', 'like', "%{$escaped}%")
|
||||||
|
->orderBy('name')
|
||||||
|
->limit(10)
|
||||||
|
->get(['id', 'name', 'slug'])
|
||||||
|
->map(fn (User $u) => ['id' => $u->id, 'name' => $u->name, 'slug' => $u->slug])
|
||||||
|
->values()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class PricingPlan extends Model
|
||||||
|
{
|
||||||
|
public const PERIODS = ['monthly', 'yearly', 'one-time'];
|
||||||
|
|
||||||
|
protected $fillable = ['product_id', 'name', 'price', 'period', 'description', 'position'];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return ['price' => 'decimal:2'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function product(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Product::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Price formatted for display, e.g. "€9.99" ("€0" for free plans).
|
||||||
|
*/
|
||||||
|
public function formattedPrice(): string
|
||||||
|
{
|
||||||
|
return (float) $this->price == 0 ? '€0' : '€'.number_format((float) $this->price, 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,13 +3,28 @@
|
|||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
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;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Support\Str;
|
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 +41,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 +57,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',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,6 +123,33 @@ class Product extends Model
|
|||||||
return $query->exists();
|
return $query->exists();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the product's screenshots in display order.
|
||||||
|
*/
|
||||||
|
public function screenshots(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(ProductScreenshot::class)->orderBy('position')->orderBy('id');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the product's pricing plans in display order.
|
||||||
|
*/
|
||||||
|
public function pricingPlans(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(PricingPlan::class)->orderBy('position')->orderBy('id');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the team members of the product (besides the submitter).
|
||||||
|
*/
|
||||||
|
public function members(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(User::class, 'product_members')
|
||||||
|
->withPivot('role')
|
||||||
|
->withTimestamps()
|
||||||
|
->orderBy('product_members.id');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the user that owns the product.
|
* Get the user that owns the product.
|
||||||
*/
|
*/
|
||||||
@@ -124,6 +174,31 @@ class Product extends Model
|
|||||||
return $this->belongsToMany(Tag::class);
|
return $this->belongsToMany(Tag::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get approved products sharing tags with this one, best matches first.
|
||||||
|
* Single query; each result has a shared_tags_count attribute.
|
||||||
|
*/
|
||||||
|
public function alternatives(int $limit = 3): Collection
|
||||||
|
{
|
||||||
|
return static::query()
|
||||||
|
->approved()
|
||||||
|
->where('products.id', '!=', $this->id)
|
||||||
|
->join('product_tag', 'product_tag.product_id', '=', 'products.id')
|
||||||
|
->whereIn('product_tag.tag_id', function ($query) {
|
||||||
|
$query->select('tag_id')->from('product_tag')->where('product_id', $this->id);
|
||||||
|
})
|
||||||
|
->select('products.*')
|
||||||
|
->selectRaw('COUNT(product_tag.tag_id) as shared_tags_count')
|
||||||
|
->withCount('upvotes')
|
||||||
|
->groupBy('products.id')
|
||||||
|
->orderByDesc('shared_tags_count')
|
||||||
|
->orderByDesc('upvotes_count')
|
||||||
|
->orderByDesc('products.created_at')
|
||||||
|
->orderByDesc('products.id')
|
||||||
|
->limit($limit)
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scope a query to only include approved products.
|
* Scope a query to only include approved products.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class ProductScreenshot extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = ['product_id', 'path', 'position'];
|
||||||
|
|
||||||
|
public function product(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Product::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function url(): Attribute
|
||||||
|
{
|
||||||
|
return Attribute::get(fn () => asset('storage/'.$this->path));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -165,6 +165,17 @@ class User extends Authenticatable
|
|||||||
return $slug;
|
return $slug;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected static function boot()
|
||||||
|
{
|
||||||
|
parent::boot();
|
||||||
|
|
||||||
|
static::creating(function ($user) {
|
||||||
|
if (empty($user->slug)) {
|
||||||
|
$user->slug = static::generateUniqueSlug($user->name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the route key for the model.
|
* Get the route key for the model.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('product_screenshots', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('product_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->string('path');
|
||||||
|
$table->unsignedInteger('position')->default(0);
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('product_screenshots');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('pricing_plans', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('product_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->string('name', 100);
|
||||||
|
$table->decimal('price', 8, 2);
|
||||||
|
$table->string('period');
|
||||||
|
$table->string('description')->nullable();
|
||||||
|
$table->unsignedInteger('position')->default(0);
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('pricing_plans');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('product_members', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('product_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->string('role', 100);
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->unique(['product_id', 'user_id']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('product_members');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
# Richer Product Details Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Add badges, screenshots, pricing plans, a team, and tag-based alternatives to products, on the form, edit form, product page and cards.
|
||||||
|
|
||||||
|
**Architecture:** New columns and tables hang off `products`. `ProductController` gains two private helpers, `detailRules()` and `syncDetails()`, that every task extends, so `store` and `update` stay thin. Each section is a Blade component included from `products/show.blade.php`, and each hides itself when empty.
|
||||||
|
|
||||||
|
**Tech Stack:** Laravel (Sail, MySQL), Blade, Tailwind 4, Alpine, Pest. Run tests with `docker compose exec -T laravel.test php artisan test --filter=<name>`.
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-10-17-product-details-design.md`
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Badges are exactly four booleans: `free_tier`, `open_source`, `eu_hosted`, `eu_made`; labels "Free tier", "Open source", "EU hosted", "EU made".
|
||||||
|
- Once a product is approved, only users with role `moderator` or `admin` change badges; the submitter's badge input is ignored.
|
||||||
|
- Screenshots: unlimited count; each image jpeg, png, jpg or gif, max 2048 KB; stored on the `public` disk under `screenshots/`.
|
||||||
|
- Pricing plan: name, price (decimal 2 places, euros, 0 or more), period one of `monthly`, `yearly`, `one-time`, short description.
|
||||||
|
- Team: registered users only, direct add, free-text role; submitter cannot be a member; no duplicate member.
|
||||||
|
- Alternatives: approved products only, never the product itself, at least one shared tag, 3 shown, ordered by shared tags then upvotes then newest.
|
||||||
|
- Every new section is hidden when empty; products with no new data must render exactly as before.
|
||||||
|
- Dark mode classes like the rest of the site; no feature flag.
|
||||||
|
|
||||||
|
## Review Focus
|
||||||
|
|
||||||
|
- Submitter posts `eu_hosted=1` to the update of an approved product: ignored. (Task 1)
|
||||||
|
- Submitter posts `remove_screenshots[]` with a screenshot id from another product: nothing deleted. (Task 2)
|
||||||
|
- Negative, non-numeric price or unknown period: validation error, nothing saved. (Task 3)
|
||||||
|
- Team member list containing the submitter or the same user twice: validation error. (Task 4)
|
||||||
|
- Another product sharing zero tags, a pending product sharing tags, and the product itself never appear as alternatives. (Task 5)
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
- `database/migrations/*_add_badges_to_products_table.php`, `*_create_product_screenshots_table.php`, `*_create_pricing_plans_table.php`, `*_create_product_members_table.php`
|
||||||
|
- `app/Models/ProductScreenshot.php`, `app/Models/PricingPlan.php` (new); `app/Models/Product.php` (relations, badge constants, `alternatives()`)
|
||||||
|
- `app/Http/Controllers/ProductController.php` (`detailRules`, `syncDetails`, delete cleanup); `app/Http/Controllers/UserSearchController.php` (new)
|
||||||
|
- `resources/views/components/product/{badges,gallery,pricing,team,alternatives}.blade.php` (new); `products/{create,edit,show}.blade.php`, `components/product/card.blade.php`
|
||||||
|
- Tests: `tests/Feature/Product{Badges,Screenshots,Pricing,Team,Alternatives}Test.php`; helpers in `tests/Pest.php`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Badges and show-page skeleton
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: migration `add_badges_to_products_table`, `resources/views/components/product/badges.blade.php`, `tests/Feature/ProductBadgesTest.php`
|
||||||
|
- Modify: `tests/Pest.php`, `app/Models/Product.php`, `app/Http/Controllers/ProductController.php`, `database/factories/ProductFactory.php`, `products/create|edit|show.blade.php`, `components/product/card.blade.php`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `Product::BADGES` (`['free_tier' => 'Free tier', 'open_source' => 'Open source', 'eu_hosted' => 'EU hosted', 'eu_made' => 'EU made']`); the four boolean attributes, fillable and cast to bool; component `<x-product.badges :product="$product" />`; Pest helper `userWithRole(string $slug): User` (creates the role if missing) and `makeCategory(): Category` in `tests/Pest.php`; `ProductController::detailRules(): array` and `syncDetails(Request $request, Product $product): void` (both start with badges only); `show.blade.php` has a main column and a sidebar column with comment markers `<!-- slot:gallery -->` (above columns), `<!-- slot:pricing -->`, `<!-- slot:alternatives -->` (main column, in that order, before Comments) and `<!-- slot:team -->` (sidebar).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing tests in `ProductBadgesTest.php`**
|
||||||
|
- `submitter ticks badges on create`: POST `products.store` with `eu_hosted=1, open_source=1` → product has those two true, others false.
|
||||||
|
- `submitter can change badges while pending`: PUT update on a pending product flips `eu_made` to true.
|
||||||
|
- `submitter cannot change badges after approval`: approved product with `eu_hosted=false`; owner PUTs `eu_hosted=1` plus valid required fields → `eu_hosted` still false.
|
||||||
|
- `moderator can untick a badge on an approved product`: product with `eu_hosted=true`; moderator PUTs without `eu_hosted` → false.
|
||||||
|
- `edit form shows badges read-only to owner of approved product`: GET edit as owner, assertSee `disabled` next to "EU hosted"; for pending product assertDontSee that.
|
||||||
|
- `show page lists ticked badges only`: assertSee "EU hosted", assertDontSee "EU made".
|
||||||
|
- `existing product without new data still renders`: factory product, GET show → 200.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run to verify they fail** — `--filter=ProductBadgesTest`; expect failures on missing columns.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement**
|
||||||
|
- Migration: four `boolean` columns default `false` on `products`.
|
||||||
|
- `Product`: add the constants, fillable, casts, and a `ProductFactory` default of false for each.
|
||||||
|
- `detailRules()` returns `['free_tier' => 'boolean', ...]` for the four; `store` and `update` merge it into their validation. In `store`, the validated booleans are stored directly. In `update`, drop the four keys unless `$product->status !== 'approved'` or `Auth::user()->hasAnyRole(['moderator', 'admin'])`. Use a hidden `0` input before each checkbox so unticking sends false.
|
||||||
|
- Views: checkbox row (create), checkbox row that renders `disabled` plus a "Locked after approval" note when locked (edit), `badges` component (pills) used in the show header and on `card.blade.php`. Restructure `show.blade.php` into header, gallery slot, then a two-column area (main about 3/4, sidebar 1/4) with the slot markers listed above; show the product's tags as `#tag` text in the header.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run `--filter=ProductBadgesTest` and the existing `ProductTest`**; expect all pass.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit** — `git commit -m "feat: product badges and show page layout"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Screenshots
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: migration `create_product_screenshots_table` (`product_id` cascade FK, `path`, `position` unsigned int default 0, timestamps), `app/Models/ProductScreenshot.php`, `components/product/gallery.blade.php`, `tests/Feature/ProductScreenshotsTest.php`
|
||||||
|
- Modify: `Product.php`, `ProductController.php`, `create|edit|show.blade.php`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `Product::screenshots(): HasMany` ordered by `position`, `id`; `ProductScreenshot::$url` accessor (`asset('storage/'.$path)`); form fields `screenshots[]` (files) and `remove_screenshots[]` (ids); `<x-product.gallery :product="$product" />` placed at `<!-- slot:gallery -->`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing tests**
|
||||||
|
- `submitter uploads several screenshots`: store with 3 fake images → 3 rows, 3 files on fake `public` disk.
|
||||||
|
- `screenshots must be images up to 2 MB`: pdf and a 3000 KB image each fail validation.
|
||||||
|
- `owner removes a screenshot on edit`: row and file deleted.
|
||||||
|
- `remove_screenshots ignores ids from another product`: other product's screenshot survives and returns no error.
|
||||||
|
- `deleting a product removes its screenshot files`.
|
||||||
|
- `show page renders gallery only when screenshots exist` (assertSee a gallery marker like `data-gallery` only with rows).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run to verify fail.**
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement** — add `screenshots` and `screenshots.*` rules to `detailRules()`; in `syncDetails` store new files with next `position` values and delete only rows where `product_id` matches. Product `destroy` deletes screenshot files (rows go by cascade). Gallery: large first image left, up to three small tiles, "+N more" tile; Alpine lightbox opens the clicked image full size with next/previous. Form: file input `multiple` with client-side thumbnails; edit form lists existing ones with remove checkboxes.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run `--filter=ProductScreenshotsTest` and `ProductTest`; expect pass.**
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit** — `git commit -m "feat: product screenshots gallery"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Pricing plans
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: migration `create_pricing_plans_table` (`product_id` cascade FK, `name`, `price` decimal(8,2), `period`, `description` nullable, `position`), `app/Models/PricingPlan.php`, `components/product/pricing.blade.php`, `tests/Feature/ProductPricingTest.php`
|
||||||
|
- Modify: `Product.php`, `ProductController.php`, `create|edit|show.blade.php`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `PricingPlan::PERIODS = ['monthly', 'yearly', 'one-time']`; `Product::pricingPlans(): HasMany` ordered by `position`; form field `plans[i][name|price|period|description]`; on update the submitted list replaces all existing plans; `<x-product.pricing>` at `<!-- slot:pricing -->`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing tests**
|
||||||
|
- `submitter adds plans`: two plans (Free €0 monthly, Team 9.99 monthly) saved in order.
|
||||||
|
- `price must be zero or more`, `price must be numeric`, `period must be one of the allowed values`, `name is required` — each fails validation and saves nothing.
|
||||||
|
- `update replaces the plan list`: update with one plan → only that plan remains.
|
||||||
|
- `show page prints price and period`: assertSee "€9.99", "monthly"; a €0 plan shows "Free" copy as "€0".
|
||||||
|
- `no plans, no pricing section`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run to verify fail.**
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement** — rules `plans` array, `plans.*.name required|max:100`, `price required|numeric|min:0|max:99999`, `period in:PERIODS`, `description nullable|max:255`; replace-all in `syncDetails`. Form: Alpine repeater rows with "Add plan" and remove buttons, prefilled from `old()` or the product. Cards side by side.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run `--filter=ProductPricingTest`; expect pass.**
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit** — `git commit -m "feat: product pricing plans"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Team
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: migration `create_product_members_table` (`product_id` and `user_id` cascade FKs, `role` string 100, timestamps, unique `product_id,user_id`), `app/Http/Controllers/UserSearchController.php`, `components/product/team.blade.php`, `tests/Feature/ProductTeamTest.php`
|
||||||
|
- Modify: `Product.php`, `routes/web.php`, `ProductController.php`, `create|edit|show.blade.php`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `Product::members(): BelongsToMany` (users, table `product_members`, `withPivot('role')`); route `GET /users/search?q=` named `users.search`, behind `auth`, returns JSON `[{id, name, slug}]` for up to 10 users whose name contains `q` (min 2 characters, else `[]`); form field `members[i][user_id|role]`, replace-all on update; `<x-product.team>` at `<!-- slot:team -->`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing tests**
|
||||||
|
- `user search returns matching users, requires auth`, and returns `[]` for one-character queries.
|
||||||
|
- `submitter adds members with roles`.
|
||||||
|
- `submitter cannot be added as a member` and `same user cannot be added twice`: validation errors, nothing saved.
|
||||||
|
- `unknown user id fails validation`.
|
||||||
|
- `deleting a user removes their membership` (row gone, product stays).
|
||||||
|
- `show page lists submitter first as Author then members linking to profiles`; `no members shows only the submitter as author`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run to verify fail.**
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement** — rules `members.*.user_id required|exists:users,id|distinct|different from the product owner` (the owner is `Auth::id()` on store, `$product->user_id` on update), `members.*.role required|max:100`; `syncDetails` uses `sync` with pivot roles. Form: Alpine search box calling `users.search` (debounced), selected people listed with a role input and remove button.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run `--filter=ProductTeamTest`; expect pass.**
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit** — `git commit -m "feat: product team members"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Alternatives
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `components/product/alternatives.blade.php`, `tests/Feature/ProductAlternativesTest.php`
|
||||||
|
- Modify: `app/Models/Product.php`, `ProductController@show`, `show.blade.php`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `Product::alternatives(int $limit = 3): \Illuminate\Database\Eloquent\Collection` (approved products other than this one that share at least one tag, ordered by shared-tag count desc, upvote count desc, `created_at` desc; each result has a `shared_tags_count` attribute); `<x-product.alternatives :product="$product" />` at `<!-- slot:alternatives -->`, each card showing thumbnail or placeholder, title linking to the product, description excerpt, its badges, and "N shared tags".
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing tests**
|
||||||
|
- `orders by shared tags`: products sharing 3, 2 tags come first and second.
|
||||||
|
- `excludes self, pending, rejected and zero-shared-tag products`.
|
||||||
|
- `ties are ordered by upvotes then newest`.
|
||||||
|
- `limits to 3`.
|
||||||
|
- `show page renders alternative cards and hides the section when none`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run to verify fail.**
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement** `alternatives()` as one query: join `product_tag` on tag ids of this product, group by product, `withCount('upvotes')`. Eager load `tags` is not needed.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the full suite** — `docker compose exec -T laravel.test php artisan test`; expect all pass.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit** — `git commit -m "feat: tag-based product alternatives"`
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
# Richer product details: design
|
||||||
|
|
||||||
|
Date: 2026-10-17
|
||||||
|
|
||||||
|
## Intent
|
||||||
|
|
||||||
|
besttechfrom.eu is a Product Hunt style directory of European tech products. Today a product has a title, description, website, logo (stored as `thumbnail`), category, tags and a status. Visitors deciding between products need more: what it looks like, what it costs, who makes it, whether it is open source or EU based, and what else they could pick instead.
|
||||||
|
|
||||||
|
The site shows information. Visitors decide for themselves. Nothing here ranks, scores or endorses a product.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
1. Screenshots: any number per product, shown as a gallery.
|
||||||
|
2. Badges: four tick marks, "Free tier", "Open source", "EU hosted", "EU made".
|
||||||
|
3. Pricing: a list of plans per product.
|
||||||
|
4. Team: a product has either a single author or a team of registered users with roles.
|
||||||
|
5. Alternatives: other products on the site, worked out automatically from shared tags.
|
||||||
|
6. Tags: already exist (`tags`, `product_tag`, tag picker on the create and edit forms). Keep them and make sure the submission form and product page expose them as in the mockup.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- No scoring or "trust" level from badges.
|
||||||
|
- No team invitations or acceptance flow.
|
||||||
|
- No edit rights for team members. They are listed only.
|
||||||
|
- No hand-picked alternatives, no external (non-listed) alternatives.
|
||||||
|
- No filter-by-badge on listings. (Possible later; see "Left to the builder".)
|
||||||
|
- No notifications when an admin unticks a badge.
|
||||||
|
- Not part of this work: the existing bugs (comment visibility, feature flags, and so on).
|
||||||
|
|
||||||
|
## Anti-goals
|
||||||
|
|
||||||
|
- A badge that implies a claim the site has not checked. This is why the approving admin can untick badges.
|
||||||
|
- A product page that looks broken for products with no screenshots, no pricing, no team or no alternatives. Each section must hide itself when empty.
|
||||||
|
|
||||||
|
## Decisions (made by the product owner)
|
||||||
|
|
||||||
|
### Badges
|
||||||
|
- Four booleans on the product: free tier, open source, EU hosted, EU made.
|
||||||
|
- The submitter ticks them on the submission form.
|
||||||
|
- The approving admin can see and untick any badge while reviewing (on the manage screen or an edit view available to them). Whatever is saved after review is what visitors see.
|
||||||
|
- Badges are independent. "Free tier" is a tick, not derived from the pricing plans.
|
||||||
|
- Shown as pills in the product header and as small pills on product cards.
|
||||||
|
- Once a product is approved, badges are locked for the submitter: the edit form shows them read-only and the update ignores any badge input from them. Only admins and moderators can change badges after approval. (Product owner's decision.) While the product is still pending, or after rejection, the submitter can still tick and untick them.
|
||||||
|
|
||||||
|
### Screenshots
|
||||||
|
- Unlimited count.
|
||||||
|
- Uploaded by the submitter on the form, removable on the edit form.
|
||||||
|
- Shown as a gallery under the header: one large image with smaller ones beside it, an "+N more" tile when there are many, and clicking opens the image full size.
|
||||||
|
- Section hidden when there are none.
|
||||||
|
|
||||||
|
### Pricing
|
||||||
|
- A list of plans per product, any number, added by the submitter with an "Add plan" button.
|
||||||
|
- Each plan: name, price in euros, billing period (monthly, yearly or one-time), short description. A free plan is price 0.
|
||||||
|
- Shown as side-by-side cards in the main column under the description.
|
||||||
|
- Section hidden when there are none.
|
||||||
|
|
||||||
|
### Team
|
||||||
|
- A product shows either its single submitter (when it has no extra members) or a list of people, each with a role (free text such as "Founder"), linked to their profile page.
|
||||||
|
- The submitter searches registered users and adds them directly. No invitation, no acceptance.
|
||||||
|
- Shown in the sidebar.
|
||||||
|
- Only the submitter and admins can add or remove team members. (Product owner confirmed the direct-add flow. The permission rule is our call, flagged below.)
|
||||||
|
|
||||||
|
### Alternatives
|
||||||
|
- Computed automatically: other approved products ordered by the number of tags shared with this product, top few shown (default 3, our call).
|
||||||
|
- Products sharing zero tags are not alternatives.
|
||||||
|
- Shown as cards in the main column, below pricing and above comments, each with logo, name, short tagline (description excerpt), the product's badges and the number of shared tags.
|
||||||
|
- Section hidden when there are none.
|
||||||
|
|
||||||
|
## Page layout (agreed in the mockup; the mockup files are not in the repo)
|
||||||
|
|
||||||
|
Product page, top to bottom:
|
||||||
|
1. Header: logo, title, tagline line with author, badge pills, tag list, upvote button on the right.
|
||||||
|
2. Screenshot gallery: one large image on the left, smaller ones beside it, an "+N more" tile when there are many.
|
||||||
|
3. Two columns. Main column (about three quarters): About, Pricing (plan cards side by side), Alternatives (cards), Comments. Sidebar (one quarter): Team.
|
||||||
|
|
||||||
|
Submission form, new fields in this order: badge checkboxes, tag picker (already exists), screenshot uploader with thumbnails and remove buttons, pricing rows with an "Add plan" button (name, price, period, description), team search with role field and remove buttons.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- Laravel, Blade, Tailwind, Alpine, MySQL, Pennant for feature flags (already in the project). Follow existing conventions: slug route keys, `Gate` authorization, `public` disk for uploads (`thumbnails/` is the existing folder), Pest tests.
|
||||||
|
- Existing products have none of the new data and must keep rendering normally.
|
||||||
|
- Only approved products appear as alternatives.
|
||||||
|
- Dark mode styles like the rest of the site.
|
||||||
|
|
||||||
|
## Our calls (flagged for review)
|
||||||
|
|
||||||
|
- Only the submitter and admins manage the team.
|
||||||
|
- Show 3 alternatives.
|
||||||
|
- Screenshot limits per file follow the existing thumbnail rules (image types jpeg, png, jpg, gif; 2 MB each). Total count has no limit, as requested.
|
||||||
|
- Billing period is a fixed set: monthly, yearly, one-time.
|
||||||
|
- A user cannot be added to the same product's team twice, and the submitter cannot be added as a member.
|
||||||
|
- The submitter is always shown. With no extra members the page shows just the author. With extra members the submitter is listed first as "Author", followed by the members and their roles.
|
||||||
|
- Admins and moderators (the roles that already approve products) can tick or untick badges. They do it in the existing product edit form, which the edit Gate must now allow them to open. Their changes persist whether the product is then approved or rejected, and they can also do it on an already approved product.
|
||||||
|
- Submitters can add and remove screenshots, pricing plans and team members when editing, as with other fields today. Editing does not send an approved product back to review.
|
||||||
|
- Deleting a user deletes their team memberships. Deleting a product deletes its screenshots (files too), plans and team rows.
|
||||||
|
- Prices are stored as decimals with two places (for example 9.99), euros only, no "contact us" option.
|
||||||
|
- Alternatives never include the product itself. Ties in shared-tag count are ordered by upvotes, then newest.
|
||||||
|
- No feature flag. The new sections ship on for everyone.
|
||||||
|
|
||||||
|
## Left to the builder
|
||||||
|
|
||||||
|
- Table and column design, migrations, indexes.
|
||||||
|
- Gallery and lightbox implementation (Alpine).
|
||||||
|
- User search endpoint for the team picker.
|
||||||
|
- Whether to cap the displayed screenshots in the gallery tile row.
|
||||||
|
- Whether the badge filter on the listing page is cheap enough to include. It is not required.
|
||||||
|
- Tests.
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
@props(['product'])
|
||||||
|
|
||||||
|
@php
|
||||||
|
$alternatives = $product->alternatives();
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
@if($alternatives->isNotEmpty())
|
||||||
|
<div data-alternatives {{ $attributes->class('bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6 mb-6') }}>
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Alternatives</h2>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
|
@foreach($alternatives as $alt)
|
||||||
|
<div class="rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
|
<a href="{{ route('products.show', $alt) }}" class="block aspect-video bg-gradient-to-br from-gray-100 to-gray-200 dark:from-gray-700 dark:to-gray-600">
|
||||||
|
@if($alt->thumbnail_url)
|
||||||
|
<img src="{{ $alt->thumbnail_url }}" alt="{{ $alt->title }}" class="w-full h-full object-cover" loading="lazy">
|
||||||
|
@else
|
||||||
|
<div class="w-full h-full flex items-center justify-center">
|
||||||
|
<svg class="h-10 w-10 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</a>
|
||||||
|
<div class="p-3">
|
||||||
|
<a href="{{ route('products.show', $alt) }}" class="font-semibold text-gray-900 dark:text-white hover:text-primary-600">{{ $alt->title }}</a>
|
||||||
|
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">{{ \Illuminate\Support\Str::limit($alt->description, 80) }}</p>
|
||||||
|
<x-product.badges :product="$alt" class="mt-2" />
|
||||||
|
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">{{ $alt->shared_tags_count }} shared {{ \Illuminate\Support\Str::plural('tag', $alt->shared_tags_count) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
@props(['product'])
|
||||||
|
|
||||||
|
@php
|
||||||
|
$ticked = collect(\App\Models\Product::BADGES)->filter(fn ($label, $key) => $product->{$key});
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
@if($ticked->isNotEmpty())
|
||||||
|
<div {{ $attributes->merge(['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,9 @@
|
|||||||
{{ Str::limit($product->description, 100) }}
|
{{ Str::limit($product->description, 100) }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<!-- Badges -->
|
||||||
|
<x-product.badges :product="$product" class="mb-3" />
|
||||||
|
|
||||||
<!-- 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">
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
@props(['product'])
|
||||||
|
|
||||||
|
@php
|
||||||
|
$shots = $product->screenshots->values();
|
||||||
|
$urls = $shots->map->url->all();
|
||||||
|
$tiles = $shots->slice(1, 3)->values();
|
||||||
|
$more = max(0, $shots->count() - 4);
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
@if($shots->isNotEmpty())
|
||||||
|
<div data-gallery
|
||||||
|
x-data="{ open: false, index: 0, urls: @js($urls),
|
||||||
|
show(i) { this.index = i; this.open = true },
|
||||||
|
next() { this.index = (this.index + 1) % this.urls.length },
|
||||||
|
prev() { this.index = (this.index - 1 + this.urls.length) % this.urls.length } }"
|
||||||
|
@keydown.escape.window="open = false"
|
||||||
|
@keydown.arrow-right.window="open && next()"
|
||||||
|
@keydown.arrow-left.window="open && prev()"
|
||||||
|
{{ $attributes }}>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||||
|
<button type="button" @click="show(0)" class="{{ $tiles->isEmpty() ? 'sm:col-span-3' : 'sm:col-span-2 sm:row-span-3' }} block overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700">
|
||||||
|
<img src="{{ $urls[0] }}" alt="{{ $product->title }} screenshot 1" class="w-full h-full object-cover aspect-video sm:aspect-auto">
|
||||||
|
</button>
|
||||||
|
@foreach($tiles as $i => $shot)
|
||||||
|
<button type="button" @click="show({{ $i + 1 }})" class="relative block overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700">
|
||||||
|
<img src="{{ $shot->url }}" alt="{{ $product->title }} screenshot {{ $i + 2 }}" class="w-full h-full object-cover aspect-video">
|
||||||
|
@if($loop->last && $more > 0)
|
||||||
|
<span class="absolute inset-0 flex items-center justify-center bg-black/50 text-white font-semibold">+{{ $more }} more</span>
|
||||||
|
@endif
|
||||||
|
</button>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="open" style="display: none" x-transition.opacity
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4"
|
||||||
|
@click.self="open = false" role="dialog" aria-modal="true">
|
||||||
|
<button type="button" @click="open = false" class="absolute top-4 right-4 text-white text-3xl leading-none" aria-label="Close">×</button>
|
||||||
|
<button type="button" x-show="urls.length > 1" @click="prev()" class="absolute left-4 text-white text-4xl" aria-label="Previous">‹</button>
|
||||||
|
<img :src="urls[index]" alt="" class="max-h-full max-w-full object-contain">
|
||||||
|
<button type="button" x-show="urls.length > 1" @click="next()" class="absolute right-4 text-white text-4xl" aria-label="Next">›</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
@props(['plans' => []])
|
||||||
|
|
||||||
|
@php
|
||||||
|
$initial = collect(old('plans_present') ? old('plans', []) : $plans)->map(fn ($p) => [
|
||||||
|
'name' => $p['name'] ?? '',
|
||||||
|
'price' => $p['price'] ?? '',
|
||||||
|
'period' => $p['period'] ?? 'monthly',
|
||||||
|
'description' => $p['description'] ?? '',
|
||||||
|
])->values()->all();
|
||||||
|
$input = 'block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm';
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<!-- Pricing plans -->
|
||||||
|
<div x-data="{ plans: @js($initial) }">
|
||||||
|
<input type="hidden" name="plans_present" value="1">
|
||||||
|
<label class="block text-sm font-medium text-gray-700">Pricing plans</label>
|
||||||
|
<div class="mt-2 space-y-3">
|
||||||
|
<template x-for="(plan, i) in plans" :key="i">
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-12 gap-2 p-3 border border-gray-200 rounded-md">
|
||||||
|
<input type="text" :name="`plans[${i}][name]`" x-model="plan.name" placeholder="Name" maxlength="100" class="{{ $input }} sm:col-span-3">
|
||||||
|
<input type="number" :name="`plans[${i}][price]`" x-model="plan.price" placeholder="Price (EUR)" min="0" max="99999" step="0.01" class="{{ $input }} sm:col-span-2">
|
||||||
|
<select :name="`plans[${i}][period]`" x-model="plan.period" class="{{ $input }} sm:col-span-2">
|
||||||
|
@foreach(\App\Models\PricingPlan::PERIODS as $period)
|
||||||
|
<option value="{{ $period }}">{{ $period }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
<input type="text" :name="`plans[${i}][description]`" x-model="plan.description" placeholder="Description (optional)" maxlength="255" class="{{ $input }} sm:col-span-4">
|
||||||
|
<button type="button" @click="plans.splice(i, 1)" class="sm:col-span-1 text-sm text-red-600 hover:text-red-800">Remove</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
@foreach($errors->get('plans.*') as $messages)
|
||||||
|
<p class="mt-2 text-sm text-red-600">{{ $messages[0] }}</p>
|
||||||
|
@endforeach
|
||||||
|
@error('plans')
|
||||||
|
<p class="mt-2 text-sm text-red-600">{{ $message }}</p>
|
||||||
|
@enderror
|
||||||
|
<button type="button" @click="plans.push({ name: '', price: '', period: 'monthly', description: '' })"
|
||||||
|
class="mt-3 inline-flex items-center px-3 py-1.5 border border-gray-300 rounded-md text-sm text-gray-700 hover:bg-gray-50">Add plan</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
@props(['product'])
|
||||||
|
|
||||||
|
@php($plans = $product->pricingPlans)
|
||||||
|
|
||||||
|
@if($plans->isNotEmpty())
|
||||||
|
<div data-pricing {{ $attributes->class('bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6 mb-6') }}>
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Pricing</h2>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
@foreach($plans as $plan)
|
||||||
|
<div class="rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||||
|
<h3 class="font-medium text-gray-900 dark:text-white">{{ $plan->name }}</h3>
|
||||||
|
<p class="mt-2 text-2xl font-bold text-gray-900 dark:text-white">{{ $plan->formattedPrice() }}</p>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">{{ $plan->period }}</p>
|
||||||
|
@if($plan->description)
|
||||||
|
<p class="mt-2 text-sm text-gray-600 dark:text-gray-300">{{ $plan->description }}</p>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
@props(['members' => [], 'readonly' => false])
|
||||||
|
|
||||||
|
@php
|
||||||
|
$initial = collect(old('members_present') ? old('members', []) : (collect($members)->map(fn ($m) => [
|
||||||
|
'user_id' => $m->id,
|
||||||
|
'name' => $m->name,
|
||||||
|
'role' => $m->pivot->role,
|
||||||
|
])->all()))->map(fn ($m) => [
|
||||||
|
'user_id' => $m['user_id'] ?? '',
|
||||||
|
'name' => $m['name'] ?? ('User #' . ($m['user_id'] ?? '')),
|
||||||
|
'role' => $m['role'] ?? '',
|
||||||
|
])->values()->all();
|
||||||
|
$input = 'block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm';
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<!-- Team members -->
|
||||||
|
@if($readonly)
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700">Team members</label>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">Only the submitter or an admin can change the team.</p>
|
||||||
|
<ul class="mt-2 space-y-1">
|
||||||
|
@forelse($initial as $m)
|
||||||
|
<li class="text-sm text-gray-900">{{ $m['name'] }} <span class="text-gray-500">- {{ $m['role'] }}</span></li>
|
||||||
|
@empty
|
||||||
|
<li class="text-sm text-gray-500">No team members.</li>
|
||||||
|
@endforelse
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div x-data="{
|
||||||
|
members: @js($initial),
|
||||||
|
q: '',
|
||||||
|
results: [],
|
||||||
|
timer: null,
|
||||||
|
search() {
|
||||||
|
clearTimeout(this.timer);
|
||||||
|
if (this.q.trim().length < 2) { this.results = []; return; }
|
||||||
|
this.timer = setTimeout(async () => {
|
||||||
|
const res = await fetch(@js(route('users.search')) + '?q=' + encodeURIComponent(this.q.trim()), { headers: { 'Accept': 'application/json' } });
|
||||||
|
this.results = res.ok ? (await res.json()).filter(u => !this.members.some(m => String(m.user_id) === String(u.id))) : [];
|
||||||
|
}, 300);
|
||||||
|
},
|
||||||
|
add(u) {
|
||||||
|
this.members.push({ user_id: u.id, name: u.name, role: '' });
|
||||||
|
this.q = ''; this.results = [];
|
||||||
|
}
|
||||||
|
}">
|
||||||
|
<input type="hidden" name="members_present" value="1">
|
||||||
|
<label class="block text-sm font-medium text-gray-700">Team members</label>
|
||||||
|
<div class="relative mt-2">
|
||||||
|
<input type="text" x-model="q" @input="search()" placeholder="Search users by name" autocomplete="off" class="{{ $input }}">
|
||||||
|
<ul x-show="results.length" x-cloak class="absolute z-10 mt-1 w-full bg-white border border-gray-200 rounded-md shadow-lg">
|
||||||
|
<template x-for="u in results" :key="u.id">
|
||||||
|
<li><button type="button" @click="add(u)" class="w-full text-left px-3 py-2 text-sm hover:bg-gray-50" x-text="u.name"></button></li>
|
||||||
|
</template>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 space-y-3">
|
||||||
|
<template x-for="(member, i) in members" :key="member.user_id">
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-12 gap-2 p-3 border border-gray-200 rounded-md items-center">
|
||||||
|
<input type="hidden" :name="`members[${i}][user_id]`" :value="member.user_id">
|
||||||
|
<input type="hidden" :name="`members[${i}][name]`" :value="member.name">
|
||||||
|
<span class="sm:col-span-4 text-sm text-gray-900" x-text="member.name"></span>
|
||||||
|
<input type="text" :name="`members[${i}][role]`" x-model="member.role" placeholder="Role (e.g. Designer)" maxlength="100" class="{{ $input }} sm:col-span-7">
|
||||||
|
<button type="button" @click="members.splice(i, 1)" class="sm:col-span-1 text-sm text-red-600 hover:text-red-800">Remove</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
@foreach($errors->get('members.*') as $messages)
|
||||||
|
<p class="mt-2 text-sm text-red-600">{{ $messages[0] }}</p>
|
||||||
|
@endforeach
|
||||||
|
@error('members')
|
||||||
|
<p class="mt-2 text-sm text-red-600">{{ $message }}</p>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
@props(['product'])
|
||||||
|
|
||||||
|
<div data-team {{ $attributes->class('bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6') }}>
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Team</h2>
|
||||||
|
<ul class="space-y-3">
|
||||||
|
<li>
|
||||||
|
<a href="{{ route('profile.show', $product->user) }}" class="font-medium text-gray-900 dark:text-white hover:text-primary-600">{{ $product->user->name }}</a>
|
||||||
|
<span class="block text-sm text-gray-500 dark:text-gray-400">Author</span>
|
||||||
|
</li>
|
||||||
|
@foreach($product->members as $member)
|
||||||
|
<li data-team-members>
|
||||||
|
<a href="{{ route('profile.show', $member) }}" class="font-medium text-gray-900 dark:text-white hover:text-primary-600">{{ $member->name }}</a>
|
||||||
|
<span class="block text-sm text-gray-500 dark:text-gray-400">{{ $member->pivot->role }}</span>
|
||||||
|
</li>
|
||||||
|
@endforeach
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
@@ -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">
|
||||||
@@ -159,6 +179,38 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Screenshots -->
|
||||||
|
<div x-data="{ previews: [] }">
|
||||||
|
<label for="screenshots" class="block text-sm font-medium text-gray-700">
|
||||||
|
Screenshots
|
||||||
|
</label>
|
||||||
|
<div class="mt-1">
|
||||||
|
<input type="file"
|
||||||
|
id="screenshots"
|
||||||
|
name="screenshots[]"
|
||||||
|
accept="image/*"
|
||||||
|
multiple
|
||||||
|
@change="previews = Array.from($event.target.files).map(f => URL.createObjectURL(f))"
|
||||||
|
class="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-semibold file:bg-primary-50 file:text-primary-700 hover:file:bg-primary-100">
|
||||||
|
@error('screenshots')
|
||||||
|
<p class="mt-2 text-sm text-red-600">{{ $message }}</p>
|
||||||
|
@enderror
|
||||||
|
@foreach($errors->get('screenshots.*') as $messages)
|
||||||
|
<p class="mt-2 text-sm text-red-600">{{ $messages[0] }}</p>
|
||||||
|
@endforeach
|
||||||
|
<p class="mt-2 text-sm text-gray-500">Add several images (JPEG, PNG, or GIF, max 2MB each).</p>
|
||||||
|
<div class="mt-3 flex flex-wrap gap-2" x-show="previews.length" style="display: none">
|
||||||
|
<template x-for="src in previews" :key="src">
|
||||||
|
<img :src="src" alt="" class="h-20 w-28 object-cover rounded border border-gray-300">
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<x-product.plans-form />
|
||||||
|
|
||||||
|
<x-product.team-form />
|
||||||
|
|
||||||
<!-- Info Alert -->
|
<!-- Info Alert -->
|
||||||
<div class="rounded-md bg-blue-50 p-4">
|
<div class="rounded-md bg-blue-50 p-4">
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -177,6 +205,56 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if($product->screenshots->isNotEmpty())
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">Current Screenshots:</label>
|
||||||
|
<div class="mt-1 grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||||
|
@foreach($product->screenshots as $shot)
|
||||||
|
<label class="block">
|
||||||
|
<img src="{{ $shot->url }}" alt="" class="h-24 w-full object-cover rounded border border-gray-300">
|
||||||
|
<span class="mt-1 flex items-center text-sm text-gray-700">
|
||||||
|
<input type="checkbox" name="remove_screenshots[]" value="{{ $shot->id }}"
|
||||||
|
class="h-4 w-4 text-primary-600 border-gray-300 rounded">
|
||||||
|
<span class="ml-2">Remove</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<!-- Screenshots -->
|
||||||
|
<div x-data="{ previews: [] }">
|
||||||
|
<label for="screenshots" class="block text-sm font-medium text-gray-700">
|
||||||
|
Screenshots
|
||||||
|
</label>
|
||||||
|
<div class="mt-1">
|
||||||
|
<input type="file"
|
||||||
|
id="screenshots"
|
||||||
|
name="screenshots[]"
|
||||||
|
accept="image/*"
|
||||||
|
multiple
|
||||||
|
@change="previews = Array.from($event.target.files).map(f => URL.createObjectURL(f))"
|
||||||
|
class="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-semibold file:bg-primary-50 file:text-primary-700 hover:file:bg-primary-100">
|
||||||
|
@error('screenshots')
|
||||||
|
<p class="mt-2 text-sm text-red-600">{{ $message }}</p>
|
||||||
|
@enderror
|
||||||
|
@foreach($errors->get('screenshots.*') as $messages)
|
||||||
|
<p class="mt-2 text-sm text-red-600">{{ $messages[0] }}</p>
|
||||||
|
@endforeach
|
||||||
|
<p class="mt-2 text-sm text-gray-500">Add several images (JPEG, PNG, or GIF, max 2MB each).</p>
|
||||||
|
<div class="mt-3 flex flex-wrap gap-2" x-show="previews.length" style="display: none">
|
||||||
|
<template x-for="src in previews" :key="src">
|
||||||
|
<img :src="src" alt="" class="h-20 w-28 object-cover rounded border border-gray-300">
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<x-product.plans-form :plans="$product->pricingPlans->toArray()" />
|
||||||
|
|
||||||
|
<x-product.team-form :members="$product->members" :readonly="! (auth()->id() === $product->user_id || auth()->user()->isAdmin())" />
|
||||||
|
|
||||||
<!-- Info Alert -->
|
<!-- Info Alert -->
|
||||||
<div class="rounded-md bg-blue-50 p-4">
|
<div class="rounded-md bg-blue-50 p-4">
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
|
|||||||
@@ -53,6 +53,16 @@
|
|||||||
<span>•</span>
|
<span>•</span>
|
||||||
<span>{{ $product->created_at->format('M j, Y') }}</span>
|
<span>{{ $product->created_at->format('M j, Y') }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<x-product.badges :product="$product" class="mt-3" />
|
||||||
|
|
||||||
|
@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 +115,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 -->
|
||||||
|
<x-product.gallery :product="$product" class="mt-6" />
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<x-product.pricing :product="$product" />
|
||||||
|
|
||||||
|
<x-product.alternatives :product="$product" />
|
||||||
|
|
||||||
<!-- 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 +278,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sidebar column -->
|
||||||
|
<div class="lg:col-span-1 space-y-6">
|
||||||
|
<x-product.team :product="$product" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Moderator Actions -->
|
<!-- Moderator Actions -->
|
||||||
@can('approve-product')
|
@can('approve-product')
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use App\Http\Controllers\ProductController;
|
|||||||
use App\Http\Controllers\ProfileController;
|
use App\Http\Controllers\ProfileController;
|
||||||
use App\Http\Controllers\TagController;
|
use App\Http\Controllers\TagController;
|
||||||
use App\Http\Controllers\UpvoteController;
|
use App\Http\Controllers\UpvoteController;
|
||||||
|
use App\Http\Controllers\UserSearchController;
|
||||||
use App\Http\Controllers\WaitlistController;
|
use App\Http\Controllers\WaitlistController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
@@ -44,6 +45,9 @@ Route::middleware(['auth'])->group(function () {
|
|||||||
Route::put('/products/{product}', [ProductController::class, 'update'])->name('products.update');
|
Route::put('/products/{product}', [ProductController::class, 'update'])->name('products.update');
|
||||||
Route::delete('/products/{product}', [ProductController::class, 'destroy'])->name('products.destroy');
|
Route::delete('/products/{product}', [ProductController::class, 'destroy'])->name('products.destroy');
|
||||||
|
|
||||||
|
// User search (team member picker)
|
||||||
|
Route::get('/users/search', UserSearchController::class)->name('users.search');
|
||||||
|
|
||||||
// Upvotes
|
// Upvotes
|
||||||
Route::post('/products/{product}/upvote', [UpvoteController::class, 'store'])->name('products.upvote');
|
Route::post('/products/{product}/upvote', [UpvoteController::class, 'store'])->name('products.upvote');
|
||||||
Route::get('/upvotes', [UpvoteController::class, 'index'])->name('upvotes.index');
|
Route::get('/upvotes', [UpvoteController::class, 'index'])->name('upvotes.index');
|
||||||
|
|||||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
@@ -0,0 +1,99 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Product;
|
||||||
|
use App\Models\Tag;
|
||||||
|
use App\Models\Upvote;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
function altTags(int $n): array
|
||||||
|
{
|
||||||
|
return collect(range(1, $n))->map(fn ($i) => Tag::create([
|
||||||
|
'name' => "Alt Tag $i",
|
||||||
|
'slug' => "alt-tag-$i",
|
||||||
|
]))->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
function altProduct(array $tags, string $state = 'approved', array $attrs = []): Product
|
||||||
|
{
|
||||||
|
$product = Product::factory()->{$state}()->create($attrs);
|
||||||
|
$product->tags()->sync(collect($tags)->pluck('id')->all());
|
||||||
|
|
||||||
|
return $product;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('orders by shared tags', function () {
|
||||||
|
[$a, $b, $c, $d] = altTags(4);
|
||||||
|
$self = altProduct([$a, $b, $c]);
|
||||||
|
$two = altProduct([$a, $b]);
|
||||||
|
$three = altProduct([$a, $b, $c, $d]);
|
||||||
|
$one = altProduct([$a]);
|
||||||
|
|
||||||
|
$result = $self->alternatives();
|
||||||
|
|
||||||
|
expect($result->pluck('id')->all())->toBe([$three->id, $two->id, $one->id]);
|
||||||
|
expect($result->pluck('shared_tags_count')->map(fn ($v) => (int) $v)->all())->toBe([3, 2, 1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('excludes self, pending, rejected and zero-shared-tag products', function () {
|
||||||
|
[$a, $b] = altTags(2);
|
||||||
|
$self = altProduct([$a]);
|
||||||
|
$good = altProduct([$a]);
|
||||||
|
altProduct([$a], 'pending');
|
||||||
|
altProduct([$a], 'rejected');
|
||||||
|
altProduct([$b]);
|
||||||
|
|
||||||
|
expect($self->alternatives()->pluck('id')->all())->toBe([$good->id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ties are ordered by upvotes then newest', function () {
|
||||||
|
[$a] = altTags(1);
|
||||||
|
$self = altProduct([$a]);
|
||||||
|
$old = altProduct([$a], 'approved', ['created_at' => now()->subDays(3)]);
|
||||||
|
$new = altProduct([$a], 'approved', ['created_at' => now()->subDay()]);
|
||||||
|
$voted = altProduct([$a], 'approved', ['created_at' => now()->subDays(5)]);
|
||||||
|
Upvote::create(['product_id' => $voted->id, 'user_id' => userWithRole('user')->id]);
|
||||||
|
|
||||||
|
expect($self->alternatives()->pluck('id')->all())->toBe([$voted->id, $new->id, $old->id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('limits to 3', function () {
|
||||||
|
[$a] = altTags(1);
|
||||||
|
$self = altProduct([$a]);
|
||||||
|
foreach (range(1, 5) as $i) {
|
||||||
|
altProduct([$a]);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect($self->alternatives())->toHaveCount(3);
|
||||||
|
expect($self->alternatives(2))->toHaveCount(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('alternatives is a single query', function () {
|
||||||
|
[$a, $b] = altTags(2);
|
||||||
|
$self = altProduct([$a, $b]);
|
||||||
|
altProduct([$a]);
|
||||||
|
altProduct([$b]);
|
||||||
|
|
||||||
|
DB::enableQueryLog();
|
||||||
|
$self->alternatives();
|
||||||
|
expect(DB::getQueryLog())->toHaveCount(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('show page renders alternative cards and hides the section when none', function () {
|
||||||
|
[$a] = altTags(1);
|
||||||
|
$self = altProduct([$a]);
|
||||||
|
|
||||||
|
$this->get(route('products.show', $self))->assertOk()->assertDontSee('data-alternatives', false);
|
||||||
|
|
||||||
|
$alt = altProduct([$a], 'approved', ['title' => 'Cool Alternative', 'eu_made' => true]);
|
||||||
|
|
||||||
|
$this->get(route('products.show', $self))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('data-alternatives', false)
|
||||||
|
->assertSee('Cool Alternative')
|
||||||
|
->assertSee(route('products.show', $alt), false)
|
||||||
|
->assertSee('1 shared tag')
|
||||||
|
->assertSee('EU made');
|
||||||
|
});
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
<?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();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('badge-less product emits no badge markup on card or show page', function () {
|
||||||
|
$product = Product::factory()->approved()->create();
|
||||||
|
|
||||||
|
$this->get(route('products.show', $product))->assertOk()->assertDontSee('rounded-full text-xs font-medium', false);
|
||||||
|
|
||||||
|
$html = view('components.product.card', ['product' => $product->load(['user', 'category'])])->render();
|
||||||
|
expect($html)->not->toContain('mb-3 flex')
|
||||||
|
->and($html)->not->toContain('gap-1.5');
|
||||||
|
|
||||||
|
$badged = Product::factory()->approved()->create(['eu_made' => true])->load(['user', 'category']);
|
||||||
|
expect(view('components.product.card', ['product' => $badged])->render())->toContain('gap-1.5')->toContain('EU made');
|
||||||
|
});
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\PricingPlan;
|
||||||
|
use App\Models\Product;
|
||||||
|
use App\Models\ProductScreenshot;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
function integrityPayload(Product $product, array $overrides = []): array
|
||||||
|
{
|
||||||
|
return array_merge([
|
||||||
|
'title' => 'Integrity Product',
|
||||||
|
'description' => 'A product description',
|
||||||
|
'website_url' => 'https://example.com',
|
||||||
|
'category_id' => $product->category_id,
|
||||||
|
], $overrides);
|
||||||
|
}
|
||||||
|
|
||||||
|
function productWithTeam(User $owner, string $status = 'approved'): array
|
||||||
|
{
|
||||||
|
$member = User::factory()->create();
|
||||||
|
$product = Product::factory()->{$status}()->create(['user_id' => $owner->id]);
|
||||||
|
$product->members()->attach($member->id, ['role' => 'Designer']);
|
||||||
|
|
||||||
|
return [$product, $member];
|
||||||
|
}
|
||||||
|
|
||||||
|
test('moderator update leaves members untouched', function () {
|
||||||
|
$owner = userWithRole('user');
|
||||||
|
[$product, $member] = productWithTeam($owner);
|
||||||
|
$other = User::factory()->create();
|
||||||
|
|
||||||
|
$moderator = userWithRole('moderator');
|
||||||
|
$this->actingAs($moderator)->put(route('products.update', $product), integrityPayload($product, [
|
||||||
|
'members_present' => 1,
|
||||||
|
'members' => [['user_id' => $other->id, 'role' => 'Hacker']],
|
||||||
|
]))->assertRedirect()->assertSessionHasNoErrors();
|
||||||
|
|
||||||
|
expect($product->members()->pluck('users.id')->all())->toBe([$member->id]);
|
||||||
|
|
||||||
|
$this->actingAs($moderator)->put(route('products.update', $product->fresh()), integrityPayload($product, [
|
||||||
|
'members_present' => 1,
|
||||||
|
]))->assertRedirect();
|
||||||
|
expect($product->members()->count())->toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('admin can change members on another users product', function () {
|
||||||
|
$owner = userWithRole('user');
|
||||||
|
[$product] = productWithTeam($owner);
|
||||||
|
$other = User::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs(userWithRole('admin'))->put(route('products.update', $product), integrityPayload($product, [
|
||||||
|
'members_present' => 1,
|
||||||
|
'members' => [['user_id' => $other->id, 'role' => 'Lead']],
|
||||||
|
]))->assertRedirect()->assertSessionHasNoErrors();
|
||||||
|
|
||||||
|
expect($product->members()->pluck('users.id')->all())->toBe([$other->id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('owner can change members', function () {
|
||||||
|
$owner = userWithRole('user');
|
||||||
|
[$product] = productWithTeam($owner, 'pending');
|
||||||
|
$other = User::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs($owner)->put(route('products.update', $product), integrityPayload($product, [
|
||||||
|
'members_present' => 1,
|
||||||
|
'members' => [['user_id' => $other->id, 'role' => 'Lead']],
|
||||||
|
]))->assertRedirect();
|
||||||
|
|
||||||
|
expect($product->members()->pluck('users.id')->all())->toBe([$other->id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('moderator sees a read-only team on the edit form, owner sees inputs', function () {
|
||||||
|
$owner = userWithRole('user');
|
||||||
|
[$product, $member] = productWithTeam($owner);
|
||||||
|
|
||||||
|
$this->actingAs(userWithRole('moderator'))->get(route('products.edit', $product))
|
||||||
|
->assertOk()->assertSee($member->name)
|
||||||
|
->assertDontSee('members_present')
|
||||||
|
->assertDontSee('Search users by name');
|
||||||
|
|
||||||
|
$this->actingAs($owner)->get(route('products.edit', $product))
|
||||||
|
->assertOk()->assertSee('members_present')->assertSee('Search users by name');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('update without sentinels keeps existing plans and members', function () {
|
||||||
|
$owner = userWithRole('user');
|
||||||
|
[$product, $member] = productWithTeam($owner, 'pending');
|
||||||
|
$product->pricingPlans()->create(['name' => 'Keep', 'price' => 1, 'period' => 'monthly', 'position' => 0]);
|
||||||
|
|
||||||
|
$this->actingAs($owner)->put(route('products.update', $product), integrityPayload($product))->assertRedirect();
|
||||||
|
|
||||||
|
expect($product->pricingPlans()->pluck('name')->all())->toBe(['Keep']);
|
||||||
|
expect($product->members()->pluck('users.id')->all())->toBe([$member->id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('update with sentinels and no rows clears plans and members', function () {
|
||||||
|
$owner = userWithRole('user');
|
||||||
|
[$product] = productWithTeam($owner, 'pending');
|
||||||
|
$product->pricingPlans()->create(['name' => 'Gone', 'price' => 1, 'period' => 'monthly', 'position' => 0]);
|
||||||
|
|
||||||
|
$this->actingAs($owner)->put(route('products.update', $product), integrityPayload($product, [
|
||||||
|
'plans_present' => 1,
|
||||||
|
'members_present' => 1,
|
||||||
|
]))->assertRedirect();
|
||||||
|
|
||||||
|
expect($product->pricingPlans()->count())->toBe(0);
|
||||||
|
expect($product->members()->count())->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('forms post the sentinels and honour them after a validation error', function () {
|
||||||
|
$owner = userWithRole('user');
|
||||||
|
$product = Product::factory()->pending()->create(['user_id' => $owner->id]);
|
||||||
|
$product->pricingPlans()->create(['name' => 'Stored', 'price' => 1, 'period' => 'monthly', 'position' => 0]);
|
||||||
|
|
||||||
|
$this->actingAs($owner)->get(route('products.edit', $product))
|
||||||
|
->assertOk()->assertSee('plans_present')->assertSee('Stored');
|
||||||
|
|
||||||
|
// User removed every plan, then validation failed: the plan must not come back.
|
||||||
|
$this->actingAs($owner)->from(route('products.edit', $product))
|
||||||
|
->put(route('products.update', $product), integrityPayload($product, ['title' => '', 'plans_present' => 1]));
|
||||||
|
$this->actingAs($owner)->get(route('products.edit', $product))->assertOk();
|
||||||
|
$html = $this->actingAs($owner)->followingRedirects()->from(route('products.edit', $product))
|
||||||
|
->put(route('products.update', $product), integrityPayload($product, ['title' => '', 'plans_present' => 1]))
|
||||||
|
->getContent();
|
||||||
|
expect($html)->not->toContain('Stored');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('restored members show their names after a validation error', function () {
|
||||||
|
$owner = userWithRole('user');
|
||||||
|
$product = Product::factory()->pending()->create(['user_id' => $owner->id]);
|
||||||
|
$m = User::factory()->create(['name' => 'Zed Zephyr']);
|
||||||
|
|
||||||
|
$html = $this->actingAs($owner)->followingRedirects()->from(route('products.edit', $product))
|
||||||
|
->put(route('products.update', $product), integrityPayload($product, [
|
||||||
|
'title' => '',
|
||||||
|
'members_present' => 1,
|
||||||
|
'members' => [['user_id' => $m->id, 'role' => 'Dev', 'name' => 'Zed Zephyr']],
|
||||||
|
]))->getContent();
|
||||||
|
|
||||||
|
expect($html)->toContain('Zed Zephyr')->not->toContain('User #')->toContain('members[${i}][name]');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failure part-way through update rolls back and removes new files', function () {
|
||||||
|
Storage::fake('public');
|
||||||
|
$owner = userWithRole('user');
|
||||||
|
$product = Product::factory()->pending()->create(['user_id' => $owner->id, 'thumbnail' => 'thumbnails/old.jpg']);
|
||||||
|
Storage::disk('public')->put('thumbnails/old.jpg', 'x');
|
||||||
|
$product->pricingPlans()->create(['name' => 'Old', 'price' => 1, 'period' => 'monthly', 'position' => 0]);
|
||||||
|
$shot = $product->screenshots()->create(['path' => 'screenshots/old.jpg']);
|
||||||
|
Storage::disk('public')->put('screenshots/old.jpg', 'x');
|
||||||
|
|
||||||
|
ProductScreenshot::creating(fn () => throw new RuntimeException('boom'));
|
||||||
|
|
||||||
|
$this->withoutExceptionHandling();
|
||||||
|
expect(fn () => $this->actingAs($owner)->put(route('products.update', $product), integrityPayload($product, [
|
||||||
|
'title' => 'Changed',
|
||||||
|
'plans_present' => 1,
|
||||||
|
'plans' => [['name' => 'New', 'price' => '3', 'period' => 'yearly']],
|
||||||
|
'remove_screenshots' => [$shot->id],
|
||||||
|
'thumbnail' => UploadedFile::fake()->image('t.jpg'),
|
||||||
|
'screenshots' => [UploadedFile::fake()->image('s.jpg')],
|
||||||
|
])))->toThrow(RuntimeException::class);
|
||||||
|
|
||||||
|
expect($product->fresh()->title)->not->toBe('Changed');
|
||||||
|
expect($product->pricingPlans()->pluck('name')->all())->toBe(['Old']);
|
||||||
|
expect($product->screenshots()->count())->toBe(1);
|
||||||
|
expect(Storage::disk('public')->allFiles())->toEqualCanonicalizing(['thumbnails/old.jpg', 'screenshots/old.jpg']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failure during store leaves no product and no files', function () {
|
||||||
|
Storage::fake('public');
|
||||||
|
$owner = userWithRole('user');
|
||||||
|
ProductScreenshot::creating(fn () => throw new RuntimeException('boom'));
|
||||||
|
|
||||||
|
$this->withoutExceptionHandling();
|
||||||
|
expect(fn () => $this->actingAs($owner)->post(route('products.store'), [
|
||||||
|
'title' => 'X', 'description' => 'D', 'website_url' => 'https://example.com',
|
||||||
|
'category_id' => makeCategory()->id,
|
||||||
|
'thumbnail' => UploadedFile::fake()->image('t.jpg'),
|
||||||
|
'screenshots' => [UploadedFile::fake()->image('s.jpg')],
|
||||||
|
]))->toThrow(RuntimeException::class);
|
||||||
|
|
||||||
|
expect(Product::count())->toBe(0);
|
||||||
|
expect(Storage::disk('public')->allFiles())->toBe([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('price allows at most two decimals', function () {
|
||||||
|
$owner = userWithRole('user');
|
||||||
|
$product = Product::factory()->pending()->create(['user_id' => $owner->id]);
|
||||||
|
|
||||||
|
$this->actingAs($owner)->put(route('products.update', $product), integrityPayload($product, [
|
||||||
|
'plans_present' => 1,
|
||||||
|
'plans' => [['name' => 'P', 'price' => '9.999', 'period' => 'monthly']],
|
||||||
|
]))->assertSessionHasErrors('plans.0.price');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('admin can change badges on an approved product', function () {
|
||||||
|
$product = Product::factory()->approved()->create(['eu_hosted' => false]);
|
||||||
|
|
||||||
|
$this->actingAs(userWithRole('admin'))->put(route('products.update', $product), integrityPayload($product, [
|
||||||
|
'eu_hosted' => 1,
|
||||||
|
]))->assertRedirect();
|
||||||
|
|
||||||
|
expect($product->fresh()->eu_hosted)->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('submitter of a rejected product can change badges', function () {
|
||||||
|
$owner = userWithRole('user');
|
||||||
|
$product = Product::factory()->rejected()->create(['user_id' => $owner->id, 'eu_made' => false]);
|
||||||
|
|
||||||
|
$this->actingAs($owner)->put(route('products.update', $product), integrityPayload($product, [
|
||||||
|
'eu_made' => 1,
|
||||||
|
]))->assertRedirect();
|
||||||
|
|
||||||
|
expect($product->fresh()->eu_made)->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('single screenshot gallery tile spans the full width', function () {
|
||||||
|
$product = Product::factory()->approved()->create();
|
||||||
|
$product->screenshots()->create(['path' => 'screenshots/a.jpg']);
|
||||||
|
|
||||||
|
$html = view('components.product.gallery', ['product' => $product->load('screenshots')])->render();
|
||||||
|
expect($html)->toContain('sm:col-span-3')->not->toContain('sm:col-span-2');
|
||||||
|
});
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\PricingPlan;
|
||||||
|
use App\Models\Product;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
function pricingPayload(array $overrides = []): array
|
||||||
|
{
|
||||||
|
return array_merge([
|
||||||
|
'title' => 'Priced Product',
|
||||||
|
'description' => 'A product description',
|
||||||
|
'website_url' => 'https://example.com',
|
||||||
|
'category_id' => makeCategory()->id,
|
||||||
|
], $overrides);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('submitter adds plans', function () {
|
||||||
|
$user = userWithRole('user');
|
||||||
|
|
||||||
|
$this->actingAs($user)->post(route('products.store'), pricingPayload([
|
||||||
|
'plans' => [
|
||||||
|
['name' => 'Free', 'price' => '0', 'period' => 'monthly', 'description' => 'Basics'],
|
||||||
|
['name' => 'Team', 'price' => '9.99', 'period' => 'monthly'],
|
||||||
|
],
|
||||||
|
]))->assertRedirect()->assertSessionHasNoErrors();
|
||||||
|
|
||||||
|
$plans = Product::firstOrFail()->pricingPlans;
|
||||||
|
expect($plans->pluck('name')->all())->toBe(['Free', 'Team']);
|
||||||
|
expect($plans->pluck('position')->all())->toBe([0, 1]);
|
||||||
|
expect((float) $plans[1]->price)->toBe(9.99);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('invalid plans fail validation and save nothing', function (array $plan, string $key) {
|
||||||
|
$user = userWithRole('user');
|
||||||
|
|
||||||
|
$this->actingAs($user)->post(route('products.store'), pricingPayload([
|
||||||
|
'plans' => [array_merge(['name' => 'Pro', 'price' => '5', 'period' => 'yearly'], $plan)],
|
||||||
|
]))->assertSessionHasErrors($key);
|
||||||
|
|
||||||
|
expect(Product::count())->toBe(0);
|
||||||
|
expect(PricingPlan::count())->toBe(0);
|
||||||
|
})->with([
|
||||||
|
'negative price' => [['price' => '-1'], 'plans.0.price'],
|
||||||
|
'non numeric price' => [['price' => 'abc'], 'plans.0.price'],
|
||||||
|
'bad period' => [['period' => 'weekly'], 'plans.0.period'],
|
||||||
|
'missing name' => [['name' => ''], 'plans.0.name'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
test('update replaces the plan list', function () {
|
||||||
|
$user = userWithRole('user');
|
||||||
|
$product = Product::factory()->pending()->create(['user_id' => $user->id]);
|
||||||
|
$product->pricingPlans()->create(['name' => 'Old', 'price' => 1, 'period' => 'monthly', 'position' => 0]);
|
||||||
|
$product->pricingPlans()->create(['name' => 'Older', 'price' => 2, 'period' => 'yearly', 'position' => 1]);
|
||||||
|
|
||||||
|
$this->actingAs($user)->put(route('products.update', $product), pricingPayload([
|
||||||
|
'category_id' => $product->category_id,
|
||||||
|
'plans_present' => 1,
|
||||||
|
'plans' => [['name' => 'New', 'price' => '3', 'period' => 'one-time']],
|
||||||
|
]))->assertRedirect();
|
||||||
|
|
||||||
|
expect($product->pricingPlans()->pluck('name')->all())->toBe(['New']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('show page prints price and period', function () {
|
||||||
|
$product = Product::factory()->approved()->create();
|
||||||
|
$product->pricingPlans()->create(['name' => 'Free', 'price' => 0, 'period' => 'monthly', 'position' => 0]);
|
||||||
|
$product->pricingPlans()->create(['name' => 'Team', 'price' => 9.99, 'period' => 'monthly', 'position' => 1]);
|
||||||
|
|
||||||
|
$this->get(route('products.show', $product))->assertOk()
|
||||||
|
->assertSee('€9.99')->assertSee('monthly')->assertSee('€0');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no plans, no pricing section', function () {
|
||||||
|
$product = Product::factory()->approved()->create();
|
||||||
|
|
||||||
|
$this->get(route('products.show', $product))->assertOk()->assertDontSee('data-pricing', false);
|
||||||
|
});
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Product;
|
||||||
|
use App\Models\ProductScreenshot;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
function shotPayload(array $overrides = []): array
|
||||||
|
{
|
||||||
|
return array_merge([
|
||||||
|
'title' => 'Shot Product',
|
||||||
|
'description' => 'A product description',
|
||||||
|
'website_url' => 'https://example.com',
|
||||||
|
'category_id' => makeCategory()->id,
|
||||||
|
], $overrides);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeShot(Product $product, string $name = 'a.jpg'): ProductScreenshot
|
||||||
|
{
|
||||||
|
Storage::disk('public')->put("screenshots/{$product->id}-{$name}", 'x');
|
||||||
|
|
||||||
|
return $product->screenshots()->create(['path' => "screenshots/{$product->id}-{$name}"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('submitter uploads several screenshots', function () {
|
||||||
|
Storage::fake('public');
|
||||||
|
$user = userWithRole('user');
|
||||||
|
|
||||||
|
$this->actingAs($user)->post(route('products.store'), shotPayload([
|
||||||
|
'screenshots' => [
|
||||||
|
UploadedFile::fake()->image('a.jpg'),
|
||||||
|
UploadedFile::fake()->image('b.png'),
|
||||||
|
UploadedFile::fake()->image('c.jpg'),
|
||||||
|
],
|
||||||
|
]))->assertRedirect();
|
||||||
|
|
||||||
|
$product = Product::firstOrFail();
|
||||||
|
expect($product->screenshots)->toHaveCount(3);
|
||||||
|
expect($product->screenshots->pluck('position')->all())->toBe([0, 1, 2]);
|
||||||
|
foreach ($product->screenshots as $shot) {
|
||||||
|
Storage::disk('public')->assertExists($shot->path);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('screenshots must be images up to 2 MB', function () {
|
||||||
|
Storage::fake('public');
|
||||||
|
$user = userWithRole('user');
|
||||||
|
|
||||||
|
$this->actingAs($user)->post(route('products.store'), shotPayload([
|
||||||
|
'screenshots' => [UploadedFile::fake()->create('doc.pdf', 100, 'application/pdf')],
|
||||||
|
]))->assertSessionHasErrors('screenshots.0');
|
||||||
|
|
||||||
|
$this->actingAs($user)->post(route('products.store'), shotPayload([
|
||||||
|
'screenshots' => [UploadedFile::fake()->image('big.jpg')->size(3000)],
|
||||||
|
]))->assertSessionHasErrors('screenshots.0');
|
||||||
|
|
||||||
|
expect(Product::count())->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('owner removes a screenshot on edit', function () {
|
||||||
|
Storage::fake('public');
|
||||||
|
$user = userWithRole('user');
|
||||||
|
$product = Product::factory()->pending()->create(['user_id' => $user->id]);
|
||||||
|
$shot = makeShot($product);
|
||||||
|
|
||||||
|
$this->actingAs($user)->put(route('products.update', $product), shotPayload([
|
||||||
|
'category_id' => $product->category_id,
|
||||||
|
'remove_screenshots' => [$shot->id],
|
||||||
|
]))->assertRedirect();
|
||||||
|
|
||||||
|
expect(ProductScreenshot::count())->toBe(0);
|
||||||
|
Storage::disk('public')->assertMissing($shot->path);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('remove_screenshots ignores ids from another product', function () {
|
||||||
|
Storage::fake('public');
|
||||||
|
$user = userWithRole('user');
|
||||||
|
$product = Product::factory()->pending()->create(['user_id' => $user->id]);
|
||||||
|
$other = Product::factory()->pending()->create();
|
||||||
|
$foreign = makeShot($other, 'f.jpg');
|
||||||
|
|
||||||
|
$this->actingAs($user)->put(route('products.update', $product), shotPayload([
|
||||||
|
'category_id' => $product->category_id,
|
||||||
|
'remove_screenshots' => [$foreign->id],
|
||||||
|
]))->assertRedirect()->assertSessionHasNoErrors();
|
||||||
|
|
||||||
|
expect($foreign->fresh())->not->toBeNull();
|
||||||
|
Storage::disk('public')->assertExists($foreign->path);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deleting a product removes its screenshot files', function () {
|
||||||
|
Storage::fake('public');
|
||||||
|
$user = userWithRole('user');
|
||||||
|
$product = Product::factory()->pending()->create(['user_id' => $user->id]);
|
||||||
|
$shot = makeShot($product);
|
||||||
|
|
||||||
|
$this->actingAs($user)->delete(route('products.destroy', $product))->assertRedirect();
|
||||||
|
|
||||||
|
Storage::disk('public')->assertMissing($shot->path);
|
||||||
|
expect(ProductScreenshot::count())->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('show page renders gallery only when screenshots exist', function () {
|
||||||
|
Storage::fake('public');
|
||||||
|
$product = Product::factory()->approved()->create();
|
||||||
|
|
||||||
|
$this->get(route('products.show', $product))->assertOk()->assertDontSee('data-gallery', false);
|
||||||
|
|
||||||
|
makeShot($product);
|
||||||
|
|
||||||
|
$this->get(route('products.show', $product))->assertOk()->assertSee('data-gallery', false);
|
||||||
|
});
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Product;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
function teamPayload(array $overrides = []): array
|
||||||
|
{
|
||||||
|
return array_merge([
|
||||||
|
'title' => 'Team Product',
|
||||||
|
'description' => 'A product description',
|
||||||
|
'website_url' => 'https://example.com',
|
||||||
|
'category_id' => makeCategory()->id,
|
||||||
|
], $overrides);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('user search returns matching users, requires auth', function () {
|
||||||
|
$this->getJson(route('users.search', ['q' => 'ali']))->assertUnauthorized();
|
||||||
|
|
||||||
|
$viewer = userWithRole('user');
|
||||||
|
$alice = User::factory()->create(['name' => 'Alice Wonder']);
|
||||||
|
User::factory()->create(['name' => 'Bob Builder']);
|
||||||
|
|
||||||
|
$response = $this->actingAs($viewer)->getJson(route('users.search', ['q' => 'lic']))->assertOk();
|
||||||
|
|
||||||
|
expect($response->json())->toHaveCount(1);
|
||||||
|
expect($response->json(0))->toBe(['id' => $alice->id, 'name' => 'Alice Wonder', 'slug' => $alice->slug]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('user search returns empty for one character queries', function () {
|
||||||
|
$viewer = userWithRole('user');
|
||||||
|
User::factory()->create(['name' => 'Alice Wonder']);
|
||||||
|
|
||||||
|
$this->actingAs($viewer)->getJson(route('users.search', ['q' => 'a']))->assertOk()->assertExactJson([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('submitter adds members with roles', function () {
|
||||||
|
$user = userWithRole('user');
|
||||||
|
$a = User::factory()->create();
|
||||||
|
$b = User::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs($user)->post(route('products.store'), teamPayload([
|
||||||
|
'members' => [
|
||||||
|
['user_id' => $a->id, 'role' => 'Designer'],
|
||||||
|
['user_id' => $b->id, 'role' => 'Engineer'],
|
||||||
|
],
|
||||||
|
]))->assertRedirect()->assertSessionHasNoErrors();
|
||||||
|
|
||||||
|
$members = Product::firstOrFail()->members;
|
||||||
|
expect($members->pluck('id')->all())->toEqualCanonicalizing([$a->id, $b->id]);
|
||||||
|
expect($members->firstWhere('id', $a->id)->pivot->role)->toBe('Designer');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('update replaces members and uses the product owner for the owner rule', function () {
|
||||||
|
$owner = userWithRole('user');
|
||||||
|
$admin = userWithRole('admin');
|
||||||
|
$a = User::factory()->create();
|
||||||
|
$b = User::factory()->create();
|
||||||
|
$product = Product::factory()->pending()->create(['user_id' => $owner->id]);
|
||||||
|
$product->members()->attach($a->id, ['role' => 'Old']);
|
||||||
|
|
||||||
|
$this->actingAs($admin)->put(route('products.update', $product), teamPayload([
|
||||||
|
'category_id' => $product->category_id,
|
||||||
|
'members_present' => 1,
|
||||||
|
'members' => [['user_id' => $owner->id, 'role' => 'Founder']],
|
||||||
|
]))->assertSessionHasErrors('members.0.user_id');
|
||||||
|
expect($product->members()->pluck('users.id')->all())->toBe([$a->id]);
|
||||||
|
|
||||||
|
$this->actingAs($admin)->put(route('products.update', $product), teamPayload([
|
||||||
|
'category_id' => $product->category_id,
|
||||||
|
'members_present' => 1,
|
||||||
|
'members' => [['user_id' => $admin->id, 'role' => 'Helper'], ['user_id' => $b->id, 'role' => 'New']],
|
||||||
|
]))->assertRedirect()->assertSessionHasNoErrors();
|
||||||
|
expect($product->members()->pluck('users.id')->all())->toEqualCanonicalizing([$admin->id, $b->id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('submitter cannot be added as a member', function () {
|
||||||
|
$user = userWithRole('user');
|
||||||
|
|
||||||
|
$this->actingAs($user)->post(route('products.store'), teamPayload([
|
||||||
|
'members' => [['user_id' => $user->id, 'role' => 'Founder']],
|
||||||
|
]))->assertSessionHasErrors('members.0.user_id');
|
||||||
|
|
||||||
|
expect(Product::count())->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('same user cannot be added twice', function () {
|
||||||
|
$user = userWithRole('user');
|
||||||
|
$a = User::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs($user)->post(route('products.store'), teamPayload([
|
||||||
|
'members' => [
|
||||||
|
['user_id' => $a->id, 'role' => 'One'],
|
||||||
|
['user_id' => $a->id, 'role' => 'Two'],
|
||||||
|
],
|
||||||
|
]))->assertSessionHasErrors();
|
||||||
|
|
||||||
|
expect(Product::count())->toBe(0);
|
||||||
|
expect(\DB::table('product_members')->count())->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unknown user id fails validation', function () {
|
||||||
|
$user = userWithRole('user');
|
||||||
|
|
||||||
|
$this->actingAs($user)->post(route('products.store'), teamPayload([
|
||||||
|
'members' => [['user_id' => 999999, 'role' => 'Ghost']],
|
||||||
|
]))->assertSessionHasErrors('members.0.user_id');
|
||||||
|
|
||||||
|
expect(Product::count())->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('missing role fails validation', function () {
|
||||||
|
$user = userWithRole('user');
|
||||||
|
$a = User::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs($user)->post(route('products.store'), teamPayload([
|
||||||
|
'members' => [['user_id' => $a->id, 'role' => '']],
|
||||||
|
]))->assertSessionHasErrors('members.0.role');
|
||||||
|
|
||||||
|
expect(Product::count())->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deleting a user removes their membership', function () {
|
||||||
|
$product = Product::factory()->approved()->create();
|
||||||
|
$member = User::factory()->create();
|
||||||
|
$product->members()->attach($member->id, ['role' => 'Dev']);
|
||||||
|
|
||||||
|
$member->delete();
|
||||||
|
|
||||||
|
expect(\DB::table('product_members')->count())->toBe(0);
|
||||||
|
expect(Product::find($product->id))->not->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('show page lists submitter first as Author then members linking to profiles', function () {
|
||||||
|
$product = Product::factory()->approved()->create();
|
||||||
|
$member = User::factory()->create(['name' => 'Zed Member']);
|
||||||
|
$product->members()->attach($member->id, ['role' => 'Designer']);
|
||||||
|
|
||||||
|
$this->get(route('products.show', $product))->assertOk()
|
||||||
|
->assertSeeInOrder([$product->user->name, 'Author', 'Zed Member', 'Designer'])
|
||||||
|
->assertSee(route('profile.show', $member), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no members shows only the submitter as author', function () {
|
||||||
|
$product = Product::factory()->approved()->create();
|
||||||
|
|
||||||
|
$this->get(route('products.show', $product))->assertOk()
|
||||||
|
->assertSee('Author')
|
||||||
|
->assertDontSee('data-team-members', false);
|
||||||
|
});
|
||||||
@@ -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