Compare commits

...
15 Commits
Author SHA1 Message Date
Doar MihaiandClaude Sonnet 5.5 56db9e0091 fix: vertically centre the navbar search
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 16:51:07 +03:00
Doar MihaiandClaude Sonnet 5.5 3f8fa9f22f feat: leaderboard with top (by period) and new tabs
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 16:39:28 +03:00
Doar MihaiandClaude Sonnet 5.5 25e1dd2413 fix: dark mode for gradient backgrounds
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 16:26:21 +03:00
Doar MihaiandClaude Sonnet 5.5 2a37695d78 fix: add dark mode variants to light-only views
Forms, upvote button, modal, notifications and several pages had no
dark: classes and rendered as white blocks in dark mode.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 16:24:26 +03:00
Doar Mihai 06ac14c5e6 progress 2026-10-17 16:20:24 +03:00
Doar MihaiandClaude Sonnet 5.5 231123f641 fix: team permissions, transactions, form sentinels and review polish
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 16:12:31 +03:00
Doar MihaiandClaude Sonnet 5.5 c4e6173e46 feat: tag-based product alternatives
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 16:06:27 +03:00
Doar MihaiandClaude Sonnet 5.5 fbb4862454 feat: product team members
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 16:04:25 +03:00
Doar MihaiandClaude Sonnet 5.5 768c814762 feat: product pricing plans
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 16:02:27 +03:00
Doar MihaiandClaude Sonnet 5.5 a3894d2f37 feat: product screenshots gallery
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 16:00:38 +03:00
Doar MihaiandClaude Sonnet 5.5 bf365d029f fix: emit no badge markup when no badge is ticked
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 15:58:47 +03:00
Doar MihaiandClaude Sonnet 5.5 9920fafab6 feat: product badges and show page layout
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 15:56:58 +03:00
Doar MihaiandClaude Sonnet 5.5 029689407b fix: generate user slug on creation
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 15:54:54 +03:00
Doar MihaiandClaude Sonnet 5.5 b60fd99f0a docs: implementation plan for richer product details
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 15:54:23 +03:00
Doar MihaiandClaude Sonnet 5.5 5852f0f921 docs: design for richer product details
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 15:53:14 +03:00
61 changed files with 2583 additions and 317 deletions
@@ -0,0 +1,35 @@
<?php
namespace App\Http\Controllers;
use App\Models\Product;
use Illuminate\Http\Request;
class LeaderboardController extends Controller
{
private const PER_PAGE = 10;
/**
* Display the leaderboard: top products by period, or the newest products.
*/
public function index(Request $request)
{
$tab = $request->query('tab') === 'new' ? 'new' : 'top';
$period = array_key_exists((string) $request->query('period'), Product::LEADERBOARD_PERIODS)
? $request->query('period')
: 'today';
$query = Product::query()->with('tags');
$products = ($tab === 'new' ? $query->approved()->latest('products.created_at')->latest('products.id') : $query->topFor($period))
->paginate(self::PER_PAGE)
->appends($tab === 'new' ? ['tab' => 'new'] : ['tab' => 'top', 'period' => $period]);
return view('leaderboard.index', [
'products' => $products,
'tab' => $tab,
'period' => $period,
'periods' => Product::LEADERBOARD_PERIODS,
]);
}
}
+136 -7
View File
@@ -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,14 +113,24 @@ 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';
$created = [];
$obsolete = [];
try {
$product = DB::transaction(function () use ($request, $validated, &$created, &$obsolete) {
// Handle thumbnail upload // Handle thumbnail upload
if ($request->hasFile('thumbnail')) { if ($request->hasFile('thumbnail')) {
$path = $request->file('thumbnail')->store('thumbnails', 'public'); $path = $request->file('thumbnail')->store('thumbnails', 'public');
$created[] = $path;
$validated['thumbnail'] = $path; $validated['thumbnail'] = $path;
} }
@@ -128,6 +141,15 @@ class ProductController extends Controller
$product->tags()->attach($request->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)
->with('success', 'Product submitted successfully! It will be reviewed by our moderators.'); ->with('success', 'Product submitted successfully! It will be reviewed by our moderators.');
} }
@@ -142,7 +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));
// Badges are locked for non-moderators once the product is approved
if ($product->status !== 'approved' || Auth::user()->hasAnyRole(['moderator', 'admin'])) {
foreach (array_keys(Product::BADGES) as $badge) {
$validated[$badge] = $request->boolean($badge);
}
} else {
$validated = array_diff_key($validated, Product::BADGES);
}
// Only the submitter and admins manage the team
$manageMembers = Auth::id() === $product->user_id || Auth::user()->isAdmin();
$created = [];
$obsolete = [];
try {
DB::transaction(function () use ($request, $product, $validated, $manageMembers, &$created, &$obsolete) {
// Handle thumbnail upload // Handle thumbnail upload
if ($request->hasFile('thumbnail')) { if ($request->hasFile('thumbnail')) {
// Delete old thumbnail if exists
if ($product->thumbnail) { if ($product->thumbnail) {
Storage::disk('public')->delete($product->thumbnail); $obsolete[] = $product->thumbnail;
} }
$path = $request->file('thumbnail')->store('thumbnails', 'public'); $path = $request->file('thumbnail')->store('thumbnails', 'public');
$created[] = $path;
$validated['thumbnail'] = $path; $validated['thumbnail'] = $path;
} }
$product->update($validated); $product->update($validated);
$this->syncDetails($request, $product, $manageMembers, $created, $obsolete);
// Sync tags (will remove old tags and add new ones) // Sync tags (will remove old tags and add new ones)
$product->tags()->sync($request->tags ?? []); $product->tags()->sync($request->tags ?? []);
});
} 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()
);
}
}
+3
View File
@@ -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.
* *
+31
View File
@@ -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);
}
}
+111
View File
@@ -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.
*/ */
@@ -209,6 +284,42 @@ class Product extends Model
return $query->withCount('upvotes')->orderBy('upvotes_count', 'desc'); return $query->withCount('upvotes')->orderBy('upvotes_count', 'desc');
} }
/**
* Leaderboard periods: key => label.
*/
public const LEADERBOARD_PERIODS = [
'today' => 'Today',
'week' => 'This week',
'month' => 'This month',
'all' => 'All time',
];
/**
* Approved products ranked by the upvotes they received in the period,
* then by total upvotes, then newest. Products with no upvotes in the
* period are left out. Adds `period_upvotes_count` and `total_upvotes_count`.
*/
public function scopeTopFor(Builder $query, string $period): Builder
{
$since = match ($period) {
'today' => now()->startOfDay(),
'week' => now()->subDays(7),
'month' => now()->subDays(30),
default => null,
};
$inPeriod = fn (Builder $upvotes) => $since ? $upvotes->where('created_at', '>=', $since) : $upvotes;
return $query->approved()
->whereHas('upvotes', $inPeriod)
->withCount(['upvotes as period_upvotes_count' => $inPeriod])
->withCount('upvotes as total_upvotes_count')
->orderByDesc('period_upvotes_count')
->orderByDesc('total_upvotes_count')
->orderByDesc('products.created_at')
->orderByDesc('products.id');
}
/** /**
* Scope a query to sort products. * Scope a query to sort products.
*/ */
+22
View File
@@ -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));
}
}
+3
View File
@@ -2,11 +2,14 @@
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\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Upvote extends Model class Upvote extends Model
{ {
use HasFactory;
/** /**
* Indicates if the model should be timestamped. * Indicates if the model should be timestamped.
* *
+11
View File
@@ -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.
*/ */
+4
View File
@@ -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.
@@ -4,26 +4,26 @@
<div class="min-h-screen flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8"> <div class="min-h-screen flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-md w-full space-y-8"> <div class="max-w-md w-full space-y-8">
<div> <div>
<h2 class="mt-6 text-center text-3xl font-extrabold text-gray-900"> <h2 class="mt-6 text-center text-3xl font-extrabold text-gray-900 dark:text-white">
{{ __('Confirm Password') }} {{ __('Confirm Password') }}
</h2> </h2>
<p class="mt-2 text-center text-sm text-gray-600"> <p class="mt-2 text-center text-sm text-gray-600 dark:text-gray-400">
{{ __('Please confirm your password before continuing.') }} {{ __('Please confirm your password before continuing.') }}
</p> </p>
</div> </div>
<div class="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10"> <div class="bg-white dark:bg-gray-800 py-8 px-4 shadow sm:rounded-lg sm:px-10">
<form method="POST" action="{{ route('password.confirm') }}" class="space-y-6"> <form method="POST" action="{{ route('password.confirm') }}" class="space-y-6">
@csrf @csrf
<div> <div>
<label for="password" class="block text-sm font-medium text-gray-700"> <label for="password" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ __('Password') }} {{ __('Password') }}
</label> </label>
<div class="mt-1"> <div class="mt-1">
<input id="password" type="password" name="password" required autocomplete="current-password" <input id="password" type="password" name="password" required autocomplete="current-password"
class="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm @error('password') border-red-300 text-red-900 placeholder-red-300 focus:ring-red-500 focus:border-red-500 @enderror"> class="appearance-none block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm @error('password') border-red-300 text-red-900 dark:text-red-200 placeholder-red-300 focus:ring-red-500 focus:border-red-500 @enderror dark:bg-gray-700">
@error('password') @error('password')
<p class="mt-2 text-sm text-red-600">{{ $message }}</p> <p class="mt-2 text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
@enderror @enderror
</div> </div>
</div> </div>
@@ -37,7 +37,7 @@
@if (Route::has('password.request')) @if (Route::has('password.request'))
<div class="text-sm text-center"> <div class="text-sm text-center">
<a href="{{ route('password.request') }}" class="font-medium text-primary-600 hover:text-primary-500"> <a href="{{ route('password.request') }}" class="font-medium text-primary-600 dark:text-primary-400 hover:text-primary-500">
{{ __('Forgot Your Password?') }} {{ __('Forgot Your Password?') }}
</a> </a>
</div> </div>
@@ -4,16 +4,16 @@
<div class="min-h-screen flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8"> <div class="min-h-screen flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-md w-full space-y-8"> <div class="max-w-md w-full space-y-8">
<div> <div>
<h2 class="mt-6 text-center text-3xl font-extrabold text-gray-900"> <h2 class="mt-6 text-center text-3xl font-extrabold text-gray-900 dark:text-white">
{{ __('Reset Password') }} {{ __('Reset Password') }}
</h2> </h2>
<p class="mt-2 text-center text-sm text-gray-600"> <p class="mt-2 text-center text-sm text-gray-600 dark:text-gray-400">
{{ __('Enter your email address and we will send you a password reset link.') }} {{ __('Enter your email address and we will send you a password reset link.') }}
</p> </p>
</div> </div>
<div class="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10"> <div class="bg-white dark:bg-gray-800 py-8 px-4 shadow sm:rounded-lg sm:px-10">
@if (session('status')) @if (session('status'))
<div class="mb-4 rounded-md bg-green-50 p-4"> <div class="mb-4 rounded-md bg-green-50 dark:bg-green-900/20 p-4">
<div class="flex"> <div class="flex">
<div class="flex-shrink-0"> <div class="flex-shrink-0">
<svg class="h-5 w-5 text-green-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"> <svg class="h-5 w-5 text-green-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
@@ -21,7 +21,7 @@
</svg> </svg>
</div> </div>
<div class="ml-3"> <div class="ml-3">
<p class="text-sm font-medium text-green-800"> <p class="text-sm font-medium text-green-800 dark:text-green-200">
{{ session('status') }} {{ session('status') }}
</p> </p>
</div> </div>
@@ -33,14 +33,14 @@
@csrf @csrf
<div> <div>
<label for="email" class="block text-sm font-medium text-gray-700"> <label for="email" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ __('Email Address') }} {{ __('Email Address') }}
</label> </label>
<div class="mt-1"> <div class="mt-1">
<input id="email" type="email" name="email" value="{{ old('email') }}" required autocomplete="email" autofocus <input id="email" type="email" name="email" value="{{ old('email') }}" required autocomplete="email" autofocus
class="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm @error('email') border-red-300 text-red-900 placeholder-red-300 focus:ring-red-500 focus:border-red-500 @enderror"> class="appearance-none block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm @error('email') border-red-300 text-red-900 dark:text-red-200 placeholder-red-300 focus:ring-red-500 focus:border-red-500 @enderror dark:bg-gray-700">
@error('email') @error('email')
<p class="mt-2 text-sm text-red-600">{{ $message }}</p> <p class="mt-2 text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
@enderror @enderror
</div> </div>
</div> </div>
@@ -54,7 +54,7 @@
@if (Route::has('login')) @if (Route::has('login'))
<div class="text-sm text-center"> <div class="text-sm text-center">
<a href="{{ route('login') }}" class="font-medium text-primary-600 hover:text-primary-500"> <a href="{{ route('login') }}" class="font-medium text-primary-600 dark:text-primary-400 hover:text-primary-500">
{{ __('Back to Login') }} {{ __('Back to Login') }}
</a> </a>
</div> </div>
+5 -5
View File
@@ -4,13 +4,13 @@
<div class="min-h-screen flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8"> <div class="min-h-screen flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-md w-full space-y-8"> <div class="max-w-md w-full space-y-8">
<div> <div>
<h2 class="mt-6 text-center text-3xl font-extrabold text-gray-900"> <h2 class="mt-6 text-center text-3xl font-extrabold text-gray-900 dark:text-white">
{{ __('Verify Your Email Address') }} {{ __('Verify Your Email Address') }}
</h2> </h2>
</div> </div>
<div class="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10"> <div class="bg-white dark:bg-gray-800 py-8 px-4 shadow sm:rounded-lg sm:px-10">
@if (session('resent')) @if (session('resent'))
<div class="mb-4 rounded-md bg-green-50 p-4"> <div class="mb-4 rounded-md bg-green-50 dark:bg-green-900/20 p-4">
<div class="flex"> <div class="flex">
<div class="flex-shrink-0"> <div class="flex-shrink-0">
<svg class="h-5 w-5 text-green-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"> <svg class="h-5 w-5 text-green-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
@@ -18,7 +18,7 @@
</svg> </svg>
</div> </div>
<div class="ml-3"> <div class="ml-3">
<p class="text-sm font-medium text-green-800"> <p class="text-sm font-medium text-green-800 dark:text-green-200">
{{ __('A fresh verification link has been sent to your email address.') }} {{ __('A fresh verification link has been sent to your email address.') }}
</p> </p>
</div> </div>
@@ -26,7 +26,7 @@
</div> </div>
@endif @endif
<div class="text-sm text-gray-600 mb-6"> <div class="text-sm text-gray-600 dark:text-gray-400 mb-6">
<p>{{ __('Before proceeding, please check your email for a verification link.') }}</p> <p>{{ __('Before proceeding, please check your email for a verification link.') }}</p>
<p class="mt-2">{{ __('If you did not receive the email') }}</p> <p class="mt-2">{{ __('If you did not receive the email') }}</p>
</div> </div>
+8 -8
View File
@@ -1,13 +1,13 @@
@extends('layouts.app') @extends('layouts.app')
@section('content') @section('content')
<div class="bg-gradient-to-br from-primary-50 via-white to-accent-50 border-b border-gray-200"> <div class="bg-gradient-to-br from-primary-50 via-white to-accent-50 dark:from-gray-900 dark:via-gray-800 dark:to-gray-900 border-b border-gray-200 dark:border-gray-700">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12 sm:py-16"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12 sm:py-16">
<div class="max-w-3xl"> <div class="max-w-3xl">
<h1 class="text-4xl sm:text-5xl font-bold text-gray-900 mb-4"> <h1 class="text-4xl sm:text-5xl font-bold text-gray-900 dark:text-white mb-4">
My Bookmarks My Bookmarks
</h1> </h1>
<p class="text-lg text-gray-600"> <p class="text-lg text-gray-600 dark:text-gray-400">
Products you've saved for later Products you've saved for later
</p> </p>
</div> </div>
@@ -18,8 +18,8 @@
<!-- Results Summary --> <!-- Results Summary -->
@if($bookmarks->total() > 0) @if($bookmarks->total() > 0)
<div class="mb-6"> <div class="mb-6">
<p class="text-sm text-gray-600"> <p class="text-sm text-gray-600 dark:text-gray-400">
You have <span class="font-semibold text-gray-900">{{ $bookmarks->total() }}</span> bookmarked {{ Str::plural('product', $bookmarks->total()) }} You have <span class="font-semibold text-gray-900 dark:text-white">{{ $bookmarks->total() }}</span> bookmarked {{ Str::plural('product', $bookmarks->total()) }}
</p> </p>
</div> </div>
@endif @endif
@@ -32,11 +32,11 @@
<!-- Empty State --> <!-- Empty State -->
<div class="col-span-full"> <div class="col-span-full">
<div class="text-center py-16"> <div class="text-center py-16">
<svg class="mx-auto h-16 w-16 text-gray-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="mx-auto h-16 w-16 text-gray-400 dark:text-gray-500 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z"></path>
</svg> </svg>
<h3 class="text-lg font-medium text-gray-900 mb-2">No bookmarks yet</h3> <h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">No bookmarks yet</h3>
<p class="text-gray-600 mb-6"> <p class="text-gray-600 dark:text-gray-400 mb-6">
Start bookmarking products to save them for later! Start bookmarking products to save them for later!
</p> </p>
<a href="{{ route('products.index') }}" class="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"> <a href="{{ route('products.index') }}" class="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500">
@@ -19,10 +19,14 @@
class="inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium {{ request()->routeIs('products.*') ? 'border-primary-500 text-gray-900 dark:text-white' : 'border-transparent text-gray-500 dark:text-gray-400 hover:border-gray-300 dark:hover:border-gray-600 hover:text-gray-700 dark:hover:text-gray-300' }}"> class="inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium {{ request()->routeIs('products.*') ? 'border-primary-500 text-gray-900 dark:text-white' : 'border-transparent text-gray-500 dark:text-gray-400 hover:border-gray-300 dark:hover:border-gray-600 hover:text-gray-700 dark:hover:text-gray-300' }}">
Products Products
</a> </a>
<a href="{{ route('leaderboard') }}"
class="inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium {{ request()->routeIs('leaderboard') ? 'border-primary-500 text-gray-900 dark:text-white' : 'border-transparent text-gray-500 dark:text-gray-400 hover:border-gray-300 dark:hover:border-gray-600 hover:text-gray-700 dark:hover:text-gray-200' }}">
Leaderboard
</a>
</div> </div>
<!-- Search Bar (Desktop) --> <!-- Search Bar (Desktop) -->
<div class="hidden md:flex md:flex-1 md:ml-8 md:mr-8 md:max-w-md"> <div class="hidden md:flex md:items-center md:flex-1 md:ml-8 md:mr-8 md:max-w-md">
<form action="{{ route('products.index') }}" method="GET" class="w-full"> <form action="{{ route('products.index') }}" method="GET" class="w-full">
<div class="relative"> <div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> <div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
@@ -230,6 +234,10 @@
class="block pl-3 pr-4 py-2 border-l-4 text-base font-medium {{ request()->routeIs('products.index') ? 'border-primary-500 text-primary-700 bg-primary-50' : 'border-transparent text-gray-500 hover:bg-gray-50 hover:border-gray-300 hover:text-gray-700' }}"> class="block pl-3 pr-4 py-2 border-l-4 text-base font-medium {{ request()->routeIs('products.index') ? 'border-primary-500 text-primary-700 bg-primary-50' : 'border-transparent text-gray-500 hover:bg-gray-50 hover:border-gray-300 hover:text-gray-700' }}">
Discover Discover
</a> </a>
<a href="{{ route('leaderboard') }}"
class="block pl-3 pr-4 py-2 border-l-4 text-base font-medium {{ request()->routeIs('leaderboard') ? 'border-primary-500 text-primary-700 dark:text-primary-200 bg-primary-50 dark:bg-primary-900' : 'border-transparent text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700 hover:border-gray-300 hover:text-gray-700 dark:hover:text-gray-200' }}">
Leaderboard
</a>
@auth @auth
@if(auth()->user()->hasRole('admin')) @if(auth()->user()->hasRole('admin'))
<a href="{{ route('admin.dashboard') }}" <a href="{{ route('admin.dashboard') }}"
@@ -1,13 +1,13 @@
<div class="bg-white rounded-lg border border-gray-200 hover:shadow-lg transition-shadow overflow-hidden group"> <div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 hover:shadow-lg transition-shadow overflow-hidden group">
<a href="{{ route('products.show', $product) }}" class="block"> <a href="{{ route('products.show', $product) }}" class="block">
<!-- Thumbnail --> <!-- Thumbnail -->
<div class="relative aspect-video bg-gray-100 overflow-hidden"> <div class="relative aspect-video bg-gray-100 dark:bg-gray-700 overflow-hidden">
@if($product->thumbnail_url) @if($product->thumbnail_url)
<img src="{{ $product->thumbnail_url }}" <img src="{{ $product->thumbnail_url }}"
alt="{{ $product->title }}" alt="{{ $product->title }}"
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"> class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300">
@else @else
<div class="w-full h-full flex items-center justify-center bg-gradient-to-br from-primary-100 to-primary-200"> <div class="w-full h-full flex items-center justify-center bg-gradient-to-br from-primary-100 to-primary-200 dark:from-primary-900 dark:to-primary-800">
<svg class="h-16 w-16 text-primary-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="h-16 w-16 text-primary-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"></path>
</svg> </svg>
@@ -16,7 +16,7 @@
<!-- Category Badge --> <!-- Category Badge -->
<div class="absolute top-2 left-2"> <div class="absolute top-2 left-2">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-medium bg-white/90 backdrop-blur-sm text-gray-800"> <span class="inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-medium bg-white/90 backdrop-blur-sm text-gray-800 dark:text-gray-200">
{{ $product->category->name }} {{ $product->category->name }}
</span> </span>
</div> </div>
@@ -24,10 +24,10 @@
<!-- Content --> <!-- Content -->
<div class="p-4"> <div class="p-4">
<h3 class="text-lg font-semibold text-gray-900 line-clamp-1 group-hover:text-primary-600"> <h3 class="text-lg font-semibold text-gray-900 dark:text-white line-clamp-1 group-hover:text-primary-600">
{{ $product->title }} {{ $product->title }}
</h3> </h3>
<p class="mt-2 text-sm text-gray-600 line-clamp-2"> <p class="mt-2 text-sm text-gray-600 dark:text-gray-400 line-clamp-2">
{{ $product->description }} {{ $product->description }}
</p> </p>
@@ -39,7 +39,7 @@
@csrf @csrf
<button type="submit" <button type="submit"
@guest disabled @endguest @guest disabled @endguest
class="group/upvote flex items-center space-x-1 px-3 py-1.5 rounded-md border {{ $product->isUpvotedBy(auth()->user()) ? 'border-primary-500 bg-primary-50 text-primary-700' : 'border-gray-300 bg-white text-gray-700 hover:border-primary-500 hover:bg-primary-50' }} transition-colors"> class="group/upvote flex items-center space-x-1 px-3 py-1.5 rounded-md border {{ $product->isUpvotedBy(auth()->user()) ? 'border-primary-500 bg-primary-50 dark:bg-primary-900 text-primary-700' : 'border-gray-300 bg-white text-gray-700 dark:text-gray-300 hover:border-primary-500 hover:bg-primary-50' }} transition-colors">
<svg class="h-4 w-4" fill="{{ $product->isUpvotedBy(auth()->user()) ? 'currentColor' : 'none' }}" stroke="currentColor" viewBox="0 0 24 24"> <svg class="h-4 w-4" fill="{{ $product->isUpvotedBy(auth()->user()) ? 'currentColor' : 'none' }}" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7"></path>
</svg> </svg>
@@ -48,7 +48,7 @@
</form> </form>
<!-- Comments Count --> <!-- Comments Count -->
<div class="flex items-center space-x-1 text-gray-500"> <div class="flex items-center space-x-1 text-gray-500 dark:text-gray-400">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"></path>
</svg> </svg>
@@ -62,7 +62,7 @@
<div class="h-6 w-6 rounded-full bg-gradient-to-br from-primary-500 to-accent-500 flex items-center justify-center text-white text-xs font-semibold"> <div class="h-6 w-6 rounded-full bg-gradient-to-br from-primary-500 to-accent-500 flex items-center justify-center text-white text-xs font-semibold">
{{ strtoupper(substr($product->user->name, 0, 1)) }} {{ strtoupper(substr($product->user->name, 0, 1)) }}
</div> </div>
<span class="text-xs text-gray-600">{{ $product->user->name }}</span> <span class="text-xs text-gray-600 dark:text-gray-400">{{ $product->user->name }}</span>
</a> </a>
</div> </div>
</div> </div>
@@ -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
@@ -34,7 +34,7 @@
<button @click="toggleBookmark()" <button @click="toggleBookmark()"
:disabled="loading" :disabled="loading"
:title="bookmarked ? 'Remove bookmark' : 'Bookmark this product'" :title="bookmarked ? 'Remove bookmark' : 'Bookmark this product'"
class="text-gray-400 hover:text-primary-600 transition-colors disabled:opacity-50"> class="text-gray-400 dark:text-gray-500 hover:text-primary-600 transition-colors disabled:opacity-50">
<svg class="h-5 w-5" <svg class="h-5 w-5"
:class="{ 'animate-pulse': loading }" :class="{ 'animate-pulse': loading }"
:fill="bookmarked ? 'currentColor' : 'none'" :fill="bookmarked ? 'currentColor' : 'none'"
@@ -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">&times;</button>
<button type="button" x-show="urls.length > 1" @click="prev()" class="absolute left-4 text-white text-4xl" aria-label="Previous">&lsaquo;</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">&rsaquo;</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 dark:border-gray-600 dark:bg-gray-700 dark:text-white 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 dark:text-gray-300">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 dark:border-gray-700 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 dark:text-red-400 hover:text-red-800">Remove</button>
</div>
</template>
</div>
@foreach($errors->get('plans.*') as $messages)
<p class="mt-2 text-sm text-red-600 dark:text-red-400">{{ $messages[0] }}</p>
@endforeach
@error('plans')
<p class="mt-2 text-sm text-red-600 dark:text-red-400">{{ $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 dark:border-gray-600 rounded-md text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">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 dark:border-gray-600 dark:bg-gray-700 dark:text-white 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 dark:text-gray-300">Team members</label>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">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 dark:text-white">{{ $m['name'] }} <span class="text-gray-500 dark:text-gray-400">- {{ $m['role'] }}</span></li>
@empty
<li class="text-sm text-gray-500 dark:text-gray-400">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 dark:text-gray-300">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 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 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 dark:hover:bg-gray-700" 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 dark:border-gray-700 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 dark:text-white" 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 dark:text-red-400 hover:text-red-800">Remove</button>
</div>
</template>
</div>
@foreach($errors->get('members.*') as $messages)
<p class="mt-2 text-sm text-red-600 dark:text-red-400">{{ $messages[0] }}</p>
@endforeach
@error('members')
<p class="mt-2 text-sm text-red-600 dark:text-red-400">{{ $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>
@@ -48,8 +48,8 @@
<button @click="toggleUpvote()" <button @click="toggleUpvote()"
:disabled="loading" :disabled="loading"
:class="{ :class="{
'bg-primary-100 border-primary-500 text-primary-700': upvoted, 'bg-primary-100 dark:bg-primary-900 border-primary-500 text-primary-700 dark:text-primary-200': upvoted,
'bg-white border-gray-300 text-gray-700 hover:border-gray-400': !upvoted 'bg-white dark:bg-gray-800 border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:border-gray-400': !upvoted
}" }"
class="{{ $sizeClasses }} flex flex-col items-center justify-center border-2 rounded-lg transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"> class="{{ $sizeClasses }} flex flex-col items-center justify-center border-2 rounded-lg transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed">
<svg :class="{ 'animate-bounce': loading }" <svg :class="{ 'animate-bounce': loading }"
@@ -55,7 +55,7 @@ style="display: {{ $show ? 'block' : 'none' }};">
<div class="absolute inset-0 bg-gray-500 opacity-75"></div> <div class="absolute inset-0 bg-gray-500 opacity-75"></div>
</div> </div>
<div x-show="show" class="mb-6 bg-white rounded-lg overflow-hidden shadow-xl transform transition-all sm:w-full sm:mx-auto {{ $maxWidthClass }}" <div x-show="show" class="mb-6 bg-white dark:bg-gray-800 rounded-lg overflow-hidden shadow-xl transform transition-all sm:w-full sm:mx-auto {{ $maxWidthClass }}"
x-transition:enter="ease-out duration-300" x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100" x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
@@ -35,7 +35,7 @@ aria-live="assertive">
x-transition:leave="transition ease-in duration-200" x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="transform translate-x-0 opacity-100" x-transition:leave-start="transform translate-x-0 opacity-100"
x-transition:leave-end="transform translate-x-full opacity-0" x-transition:leave-end="transform translate-x-full opacity-0"
class="w-full max-w-sm bg-white shadow-lg rounded-lg pointer-events-auto ring-1 ring-black ring-opacity-5 overflow-hidden"> class="w-full max-w-sm bg-white dark:bg-gray-800 shadow-lg rounded-lg pointer-events-auto ring-1 ring-black ring-opacity-5 overflow-hidden">
<div class="p-4"> <div class="p-4">
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<div class="flex-shrink-0"> <div class="flex-shrink-0">
@@ -65,12 +65,12 @@ aria-live="assertive">
</template> </template>
</div> </div>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900" x-text="notice.title"></p> <p class="text-sm font-medium text-gray-900 dark:text-white" x-text="notice.title"></p>
<p class="mt-1 text-sm text-gray-500 break-words" x-text="notice.message"></p> <p class="mt-1 text-sm text-gray-500 dark:text-gray-400 break-words" x-text="notice.message"></p>
</div> </div>
<div class="flex-shrink-0"> <div class="flex-shrink-0">
<button @click="remove(notice.id)" <button @click="remove(notice.id)"
class="bg-white rounded-md inline-flex text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"> class="bg-white dark:bg-gray-800 rounded-md inline-flex text-gray-400 dark:text-gray-500 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500">
<span class="sr-only">Close</span> <span class="sr-only">Close</span>
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 20 20"> <svg class="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"></path> <path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"></path>
@@ -0,0 +1,88 @@
@extends('layouts.app')
@php
$title = 'Leaderboard - ' . config('app.name');
$pill = fn (bool $active) => $active
? 'bg-primary-600 text-white border-primary-600'
: 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700';
$tabClass = fn (bool $active) => $active
? 'border-primary-500 text-gray-900 dark:text-white'
: 'border-transparent text-gray-500 dark:text-gray-400 hover:border-gray-300 dark:hover:border-gray-600 hover:text-gray-700 dark:hover:text-gray-200';
$periodLabel = ['today' => 'today', 'week' => 'this week', 'month' => 'this month'][$period] ?? null;
@endphp
@section('content')
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Leaderboard</h1>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">The most upvoted and the newest products from European makers.</p>
<!-- Tabs -->
<div class="mt-6 flex space-x-8 border-b border-gray-200 dark:border-gray-700">
<a href="{{ route('leaderboard', ['tab' => 'top', 'period' => $period]) }}"
class="inline-flex items-center px-1 pb-3 border-b-2 text-sm font-medium {{ $tabClass($tab === 'top') }}">Top</a>
<a href="{{ route('leaderboard', ['tab' => 'new']) }}"
class="inline-flex items-center px-1 pb-3 border-b-2 text-sm font-medium {{ $tabClass($tab === 'new') }}">New</a>
</div>
<!-- Period pills -->
@if($tab === 'top')
<div class="mt-4 flex flex-wrap gap-2">
@foreach($periods as $key => $label)
<a href="{{ route('leaderboard', ['tab' => 'top', 'period' => $key]) }}"
class="inline-flex items-center px-3 py-1 rounded-full border text-sm font-medium {{ $pill($period === $key) }}">{{ $label }}</a>
@endforeach
</div>
@endif
<!-- List -->
@if($products->isEmpty())
<div class="mt-8 text-center py-12 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
@if($tab === 'new')
<p class="text-gray-600 dark:text-gray-400">No products yet.</p>
@else
<p class="text-gray-900 dark:text-white font-medium">No upvotes yet {{ $periodLabel ?? 'at all' }}</p>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Try a longer period.</p>
@endif
</div>
@else
<ol class="mt-6 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700">
@foreach($products as $product)
<li class="flex items-center gap-4 p-4">
<span data-rank="{{ $products->firstItem() + $loop->index }}"
class="w-8 text-xl font-bold text-gray-400 dark:text-gray-500 text-center">{{ $products->firstItem() + $loop->index }}</span>
<a href="{{ route('products.show', $product) }}" class="flex-shrink-0">
@if($product->thumbnail_url)
<img src="{{ $product->thumbnail_url }}" alt="{{ $product->title }}" class="h-12 w-12 rounded-lg object-cover" loading="lazy">
@else
<div class="h-12 w-12 rounded-lg bg-gradient-to-br from-primary-100 to-primary-200 dark:from-primary-900 dark:to-primary-800 flex items-center justify-center text-primary-700 dark:text-primary-200 font-semibold">
{{ strtoupper(substr($product->title, 0, 1)) }}
</div>
@endif
</a>
<div class="flex-1 min-w-0">
<a href="{{ route('products.show', $product) }}" class="text-base font-semibold text-gray-900 dark:text-white hover:text-primary-600 dark:hover:text-primary-400">{{ $product->title }}</a>
<p class="text-sm text-gray-600 dark:text-gray-400 truncate">{{ Str::limit($product->description, 100) }}</p>
<div class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-gray-500 dark:text-gray-400">
@foreach($product->tags->take(3) as $tag)
<span>#{{ $tag->name }}</span>
@endforeach
<x-product.badges :product="$product" />
</div>
</div>
<div class="flex-shrink-0 text-center">
@include('components.product.upvote-button', ['product' => $product, 'size' => 'md'])
@if($tab === 'top' && $periodLabel)
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ $product->period_upvotes_count }} {{ $periodLabel }}</p>
@endif
</div>
</li>
@endforeach
</ol>
<div class="mt-6">{{ $products->links() }}</div>
@endif
</div>
@endsection
+92 -40
View File
@@ -3,19 +3,19 @@
@section('content') @section('content')
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12"> <div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div class="mb-8"> <div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900">Submit a Product</h1> <h1 class="text-3xl font-bold text-gray-900 dark:text-white">Submit a Product</h1>
<p class="mt-2 text-sm text-gray-600">Share your EU-based tech product with the community</p> <p class="mt-2 text-sm text-gray-600 dark:text-gray-400">Share your EU-based tech product with the community</p>
</div> </div>
<div class="bg-white shadow-sm rounded-lg"> <div class="bg-white dark:bg-gray-800 shadow-sm rounded-lg">
<div class="px-4 py-5 sm:p-6"> <div class="px-4 py-5 sm:p-6">
<form method="POST" action="{{ route('products.store') }}" enctype="multipart/form-data" class="space-y-6"> <form method="POST" action="{{ route('products.store') }}" enctype="multipart/form-data" class="space-y-6">
@csrf @csrf
<!-- Title --> <!-- Title -->
<div> <div>
<label for="title" class="block text-sm font-medium text-gray-700"> <label for="title" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Product Title <span class="text-red-600">*</span> Product Title <span class="text-red-600 dark:text-red-400">*</span>
</label> </label>
<div class="mt-1"> <div class="mt-1">
<input type="text" <input type="text"
@@ -25,18 +25,18 @@
required required
maxlength="255" maxlength="255"
placeholder="e.g., My Awesome Product" placeholder="e.g., My Awesome Product"
class="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm @error('title') border-red-300 text-red-900 placeholder-red-300 focus:ring-red-500 focus:border-red-500 @enderror"> class="appearance-none block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm @error('title') border-red-300 text-red-900 dark:text-red-200 placeholder-red-300 focus:ring-red-500 focus:border-red-500 @enderror dark:bg-gray-700">
@error('title') @error('title')
<p class="mt-2 text-sm text-red-600">{{ $message }}</p> <p class="mt-2 text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
@enderror @enderror
<p class="mt-2 text-sm text-gray-500">A catchy, descriptive title for your product.</p> <p class="mt-2 text-sm text-gray-500 dark:text-gray-400">A catchy, descriptive title for your product.</p>
</div> </div>
</div> </div>
<!-- Description --> <!-- Description -->
<div> <div>
<label for="description" class="block text-sm font-medium text-gray-700"> <label for="description" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Description <span class="text-red-600">*</span> Description <span class="text-red-600 dark:text-red-400">*</span>
</label> </label>
<div class="mt-1"> <div class="mt-1">
<textarea id="description" <textarea id="description"
@@ -44,18 +44,18 @@
rows="5" rows="5"
required required
placeholder="Tell us about your product..." placeholder="Tell us about your product..."
class="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm @error('description') border-red-300 text-red-900 placeholder-red-300 focus:ring-red-500 focus:border-red-500 @enderror">{{ old('description') }}</textarea> class="appearance-none block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm @error('description') border-red-300 text-red-900 dark:text-red-200 placeholder-red-300 focus:ring-red-500 focus:border-red-500 @enderror dark:bg-gray-700">{{ old('description') }}</textarea>
@error('description') @error('description')
<p class="mt-2 text-sm text-red-600">{{ $message }}</p> <p class="mt-2 text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
@enderror @enderror
<p class="mt-2 text-sm text-gray-500">Provide a detailed description of your product (what it does, key features, etc.).</p> <p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Provide a detailed description of your product (what it does, key features, etc.).</p>
</div> </div>
</div> </div>
<!-- Website URL --> <!-- Website URL -->
<div> <div>
<label for="website_url" class="block text-sm font-medium text-gray-700"> <label for="website_url" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Website URL <span class="text-red-600">*</span> Website URL <span class="text-red-600 dark:text-red-400">*</span>
</label> </label>
<div class="mt-1"> <div class="mt-1">
<input type="url" <input type="url"
@@ -64,24 +64,24 @@
value="{{ old('website_url') }}" value="{{ old('website_url') }}"
required required
placeholder="https://example.com" placeholder="https://example.com"
class="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm @error('website_url') border-red-300 text-red-900 placeholder-red-300 focus:ring-red-500 focus:border-red-500 @enderror"> class="appearance-none block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm @error('website_url') border-red-300 text-red-900 dark:text-red-200 placeholder-red-300 focus:ring-red-500 focus:border-red-500 @enderror dark:bg-gray-700">
@error('website_url') @error('website_url')
<p class="mt-2 text-sm text-red-600">{{ $message }}</p> <p class="mt-2 text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
@enderror @enderror
<p class="mt-2 text-sm text-gray-500">The full URL to your product's website or landing page.</p> <p class="mt-2 text-sm text-gray-500 dark:text-gray-400">The full URL to your product's website or landing page.</p>
</div> </div>
</div> </div>
<!-- Category --> <!-- Category -->
<div> <div>
<label for="category_id" class="block text-sm font-medium text-gray-700"> <label for="category_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Category <span class="text-red-600">*</span> Category <span class="text-red-600 dark:text-red-400">*</span>
</label> </label>
<div class="mt-1"> <div class="mt-1">
<select id="category_id" <select id="category_id"
name="category_id" name="category_id"
required required
class="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm @error('category_id') border-red-300 text-red-900 focus:ring-red-500 focus:border-red-500 @enderror"> class="appearance-none block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm @error('category_id') border-red-300 text-red-900 dark:text-red-200 focus:ring-red-500 focus:border-red-500 @enderror dark:bg-gray-700">
<option value="">Select a category...</option> <option value="">Select a category...</option>
@foreach($categories as $category) @foreach($categories as $category)
<option value="{{ $category->id }}" {{ old('category_id') == $category->id ? 'selected' : '' }}> <option value="{{ $category->id }}" {{ old('category_id') == $category->id ? 'selected' : '' }}>
@@ -90,52 +90,72 @@
@endforeach @endforeach
</select> </select>
@error('category_id') @error('category_id')
<p class="mt-2 text-sm text-red-600">{{ $message }}</p> <p class="mt-2 text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
@enderror @enderror
<p class="mt-2 text-sm text-gray-500">Choose the category that best fits your product.</p> <p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Choose the category that best fits your product.</p>
</div> </div>
</div> </div>
<!-- Tags --> <!-- Tags -->
@if($tags->count() > 0) @if($tags->count() > 0)
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-2"> <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Tags Tags
</label> </label>
<div class="mt-1 space-y-2"> <div class="mt-1 space-y-2">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3"> <div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
@foreach($tags as $tag) @foreach($tags as $tag)
<label class="relative flex items-start p-3 border border-gray-300 rounded-md cursor-pointer hover:bg-gray-50 transition-colors"> <label class="relative flex items-start p-3 border border-gray-300 dark:border-gray-600 rounded-md cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
<div class="flex items-center h-5"> <div class="flex items-center h-5">
<input type="checkbox" <input type="checkbox"
name="tags[]" name="tags[]"
value="{{ $tag->id }}" value="{{ $tag->id }}"
{{ is_array(old('tags')) && in_array($tag->id, old('tags')) ? 'checked' : '' }} {{ is_array(old('tags')) && in_array($tag->id, old('tags')) ? 'checked' : '' }}
class="focus:ring-primary-500 h-4 w-4 text-primary-600 border-gray-300 rounded"> class="focus:ring-primary-500 h-4 w-4 text-primary-600 dark:text-primary-400 border-gray-300 dark:border-gray-600 rounded">
</div> </div>
<div class="ml-3 flex-1"> <div class="ml-3 flex-1">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="inline-block w-3 h-3 rounded-full" style="background-color: {{ $tag->color }}"></span> <span class="inline-block w-3 h-3 rounded-full" style="background-color: {{ $tag->color }}"></span>
<span class="text-sm font-medium text-gray-900">{{ $tag->name }}</span> <span class="text-sm font-medium text-gray-900 dark:text-white">{{ $tag->name }}</span>
</div> </div>
@if($tag->description) @if($tag->description)
<p class="text-xs text-gray-500 mt-1">{{ $tag->description }}</p> <p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ $tag->description }}</p>
@endif @endif
</div> </div>
</label> </label>
@endforeach @endforeach
</div> </div>
@error('tags') @error('tags')
<p class="mt-2 text-sm text-red-600">{{ $message }}</p> <p class="mt-2 text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
@enderror @enderror
<p class="mt-2 text-sm text-gray-500">Select all tags that apply to your product.</p> <p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Select all tags that apply to your product.</p>
</div> </div>
</div> </div>
@endif @endif
<!-- Badges -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 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 dark:border-gray-600 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 dark:text-primary-400 border-gray-300 dark:border-gray-600 rounded">
<span class="ml-2 text-sm text-gray-900 dark:text-white">{{ $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 dark:text-gray-300">
Product Thumbnail Product Thumbnail
</label> </label>
<div class="mt-1"> <div class="mt-1">
@@ -143,24 +163,56 @@
id="thumbnail" id="thumbnail"
name="thumbnail" name="thumbnail"
accept="image/*" accept="image/*"
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('thumbnail') border-red-300 @enderror"> class="block w-full text-sm text-gray-500 dark:text-gray-400 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('thumbnail') border-red-300 dark:border-red-700 @enderror">
@error('thumbnail') @error('thumbnail')
<p class="mt-2 text-sm text-red-600">{{ $message }}</p> <p class="mt-2 text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
@enderror @enderror
<p class="mt-2 text-sm text-gray-500">Upload a product screenshot or logo (JPEG, PNG, or GIF, max 2MB).</p> <p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Upload a product screenshot or logo (JPEG, PNG, or GIF, max 2MB).</p>
</div> </div>
</div> </div>
<!-- Image Preview --> <!-- Image Preview -->
<div id="imagePreview" class="hidden"> <div id="imagePreview" class="hidden">
<label class="block text-sm font-medium text-gray-700 mb-2">Preview:</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Preview:</label>
<div class="mt-1"> <div class="mt-1">
<img id="preview" src="#" alt="Thumbnail preview" class="max-w-xs rounded-lg border border-gray-300 shadow-sm"> <img id="preview" src="#" alt="Thumbnail preview" class="max-w-xs rounded-lg border border-gray-300 dark:border-gray-600 shadow-sm">
</div> </div>
</div> </div>
<!-- Screenshots -->
<div x-data="{ previews: [] }">
<label for="screenshots" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
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 dark:text-gray-400 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 dark:text-red-400">{{ $message }}</p>
@enderror
@foreach($errors->get('screenshots.*') as $messages)
<p class="mt-2 text-sm text-red-600 dark:text-red-400">{{ $messages[0] }}</p>
@endforeach
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">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 dark:border-gray-600">
</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 dark:bg-blue-900/20 p-4">
<div class="flex"> <div class="flex">
<div class="flex-shrink-0"> <div class="flex-shrink-0">
<svg class="h-5 w-5 text-blue-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"> <svg class="h-5 w-5 text-blue-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
@@ -168,7 +220,7 @@
</svg> </svg>
</div> </div>
<div class="ml-3 flex-1"> <div class="ml-3 flex-1">
<p class="text-sm text-blue-700"> <p class="text-sm text-blue-700 dark:text-blue-300">
<strong>Note:</strong> Your product will be reviewed by our moderators before it appears on the site. <strong>Note:</strong> Your product will be reviewed by our moderators before it appears on the site.
</p> </p>
</div> </div>
@@ -176,9 +228,9 @@
</div> </div>
<!-- Submit Buttons --> <!-- Submit Buttons -->
<div class="flex items-center justify-between pt-5 border-t border-gray-200"> <div class="flex items-center justify-between pt-5 border-t border-gray-200 dark:border-gray-700">
<a href="{{ route('products.index') }}" <a href="{{ route('products.index') }}"
class="inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"> class="inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-600 shadow-sm text-sm font-medium rounded-md text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500">
Cancel Cancel
</a> </a>
<button type="submit" <button type="submit"
+120 -42
View File
@@ -3,11 +3,11 @@
@section('content') @section('content')
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12"> <div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div class="mb-8"> <div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900">Edit Product</h1> <h1 class="text-3xl font-bold text-gray-900 dark:text-white">Edit Product</h1>
<p class="mt-2 text-sm text-gray-600">Update your product information and details</p> <p class="mt-2 text-sm text-gray-600 dark:text-gray-400">Update your product information and details</p>
</div> </div>
<div class="bg-white shadow-sm rounded-lg"> <div class="bg-white dark:bg-gray-800 shadow-sm rounded-lg">
<div class="px-4 py-5 sm:p-6"> <div class="px-4 py-5 sm:p-6">
<form method="POST" action="{{ route('products.update', $product) }}" enctype="multipart/form-data" class="space-y-6"> <form method="POST" action="{{ route('products.update', $product) }}" enctype="multipart/form-data" class="space-y-6">
@csrf @csrf
@@ -15,8 +15,8 @@
<!-- Title --> <!-- Title -->
<div> <div>
<label for="title" class="block text-sm font-medium text-gray-700"> <label for="title" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Product Title <span class="text-red-600">*</span> Product Title <span class="text-red-600 dark:text-red-400">*</span>
</label> </label>
<div class="mt-1"> <div class="mt-1">
<input type="text" <input type="text"
@@ -26,18 +26,18 @@
required required
maxlength="255" maxlength="255"
placeholder="e.g., My Awesome Product" placeholder="e.g., My Awesome Product"
class="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm @error('title') border-red-300 text-red-900 placeholder-red-300 focus:ring-red-500 focus:border-red-500 @enderror"> class="appearance-none block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm @error('title') border-red-300 text-red-900 dark:text-red-200 placeholder-red-300 focus:ring-red-500 focus:border-red-500 @enderror dark:bg-gray-700">
@error('title') @error('title')
<p class="mt-2 text-sm text-red-600">{{ $message }}</p> <p class="mt-2 text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
@enderror @enderror
<p class="mt-2 text-sm text-gray-500">A catchy, descriptive title for your product.</p> <p class="mt-2 text-sm text-gray-500 dark:text-gray-400">A catchy, descriptive title for your product.</p>
</div> </div>
</div> </div>
<!-- Description --> <!-- Description -->
<div> <div>
<label for="description" class="block text-sm font-medium text-gray-700"> <label for="description" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Description <span class="text-red-600">*</span> Description <span class="text-red-600 dark:text-red-400">*</span>
</label> </label>
<div class="mt-1"> <div class="mt-1">
<textarea id="description" <textarea id="description"
@@ -45,18 +45,18 @@
rows="5" rows="5"
required required
placeholder="Tell us about your product..." placeholder="Tell us about your product..."
class="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm @error('description') border-red-300 text-red-900 placeholder-red-300 focus:ring-red-500 focus:border-red-500 @enderror">{{ old('description', $product->description) }}</textarea> class="appearance-none block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm @error('description') border-red-300 text-red-900 dark:text-red-200 placeholder-red-300 focus:ring-red-500 focus:border-red-500 @enderror dark:bg-gray-700">{{ old('description', $product->description) }}</textarea>
@error('description') @error('description')
<p class="mt-2 text-sm text-red-600">{{ $message }}</p> <p class="mt-2 text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
@enderror @enderror
<p class="mt-2 text-sm text-gray-500">Provide a detailed description of your product (what it does, key features, etc.).</p> <p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Provide a detailed description of your product (what it does, key features, etc.).</p>
</div> </div>
</div> </div>
<!-- Website URL --> <!-- Website URL -->
<div> <div>
<label for="website_url" class="block text-sm font-medium text-gray-700"> <label for="website_url" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Website URL <span class="text-red-600">*</span> Website URL <span class="text-red-600 dark:text-red-400">*</span>
</label> </label>
<div class="mt-1"> <div class="mt-1">
<input type="url" <input type="url"
@@ -65,24 +65,24 @@
value="{{ old('website_url', $product->website_url) }}" value="{{ old('website_url', $product->website_url) }}"
required required
placeholder="https://example.com" placeholder="https://example.com"
class="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm @error('website_url') border-red-300 text-red-900 placeholder-red-300 focus:ring-red-500 focus:border-red-500 @enderror"> class="appearance-none block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm @error('website_url') border-red-300 text-red-900 dark:text-red-200 placeholder-red-300 focus:ring-red-500 focus:border-red-500 @enderror dark:bg-gray-700">
@error('website_url') @error('website_url')
<p class="mt-2 text-sm text-red-600">{{ $message }}</p> <p class="mt-2 text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
@enderror @enderror
<p class="mt-2 text-sm text-gray-500">The full URL to your product's website or landing page.</p> <p class="mt-2 text-sm text-gray-500 dark:text-gray-400">The full URL to your product's website or landing page.</p>
</div> </div>
</div> </div>
<!-- Category --> <!-- Category -->
<div> <div>
<label for="category_id" class="block text-sm font-medium text-gray-700"> <label for="category_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Category <span class="text-red-600">*</span> Category <span class="text-red-600 dark:text-red-400">*</span>
</label> </label>
<div class="mt-1"> <div class="mt-1">
<select id="category_id" <select id="category_id"
name="category_id" name="category_id"
required required
class="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm @error('category_id') border-red-300 text-red-900 focus:ring-red-500 focus:border-red-500 @enderror"> class="appearance-none block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm @error('category_id') border-red-300 text-red-900 dark:text-red-200 focus:ring-red-500 focus:border-red-500 @enderror dark:bg-gray-700">
<option value="">Select a category...</option> <option value="">Select a category...</option>
@foreach($categories as $category) @foreach($categories as $category)
<option value="{{ $category->id }}" {{ old('category_id', $product->category_id) == $category->id ? 'selected' : '' }}> <option value="{{ $category->id }}" {{ old('category_id', $product->category_id) == $category->id ? 'selected' : '' }}>
@@ -91,16 +91,16 @@
@endforeach @endforeach
</select> </select>
@error('category_id') @error('category_id')
<p class="mt-2 text-sm text-red-600">{{ $message }}</p> <p class="mt-2 text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
@enderror @enderror
<p class="mt-2 text-sm text-gray-500">Choose the category that best fits your product.</p> <p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Choose the category that best fits your product.</p>
</div> </div>
</div> </div>
<!-- Tags --> <!-- Tags -->
@if($tags->count() > 0) @if($tags->count() > 0)
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-2"> <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Tags Tags
</label> </label>
<div class="mt-1 space-y-2"> <div class="mt-1 space-y-2">
@@ -111,49 +111,77 @@
? in_array($tag->id, old('tags')) ? in_array($tag->id, old('tags'))
: $product->tags->contains($tag->id); : $product->tags->contains($tag->id);
@endphp @endphp
<label class="relative flex items-start p-3 border border-gray-300 rounded-md cursor-pointer hover:bg-gray-50 transition-colors"> <label class="relative flex items-start p-3 border border-gray-300 dark:border-gray-600 rounded-md cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
<div class="flex items-center h-5"> <div class="flex items-center h-5">
<input type="checkbox" <input type="checkbox"
name="tags[]" name="tags[]"
value="{{ $tag->id }}" value="{{ $tag->id }}"
{{ $isChecked ? 'checked' : '' }} {{ $isChecked ? 'checked' : '' }}
class="focus:ring-primary-500 h-4 w-4 text-primary-600 border-gray-300 rounded"> class="focus:ring-primary-500 h-4 w-4 text-primary-600 dark:text-primary-400 border-gray-300 dark:border-gray-600 rounded">
</div> </div>
<div class="ml-3 flex-1"> <div class="ml-3 flex-1">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="inline-block w-3 h-3 rounded-full" style="background-color: {{ $tag->color }}"></span> <span class="inline-block w-3 h-3 rounded-full" style="background-color: {{ $tag->color }}"></span>
<span class="text-sm font-medium text-gray-900">{{ $tag->name }}</span> <span class="text-sm font-medium text-gray-900 dark:text-white">{{ $tag->name }}</span>
</div> </div>
@if($tag->description) @if($tag->description)
<p class="text-xs text-gray-500 mt-1">{{ $tag->description }}</p> <p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ $tag->description }}</p>
@endif @endif
</div> </div>
</label> </label>
@endforeach @endforeach
</div> </div>
@error('tags') @error('tags')
<p class="mt-2 text-sm text-red-600">{{ $message }}</p> <p class="mt-2 text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
@enderror @enderror
<p class="mt-2 text-sm text-gray-500">Select all tags that apply to your product.</p> <p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Select all tags that apply to your product.</p>
</div> </div>
</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 dark:text-gray-300 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 dark:border-gray-600 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 dark:text-primary-400 border-gray-300 dark:border-gray-600 rounded">
<span class="ml-2 text-sm text-gray-900 dark:text-white">{{ $label }}</span>
</label>
@endforeach
</div>
@if($badgesLocked)
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Locked after approval</p>
@endif
</div>
<!-- Current Thumbnail --> <!-- Current Thumbnail -->
@if($product->thumbnail_url) @if($product->thumbnail_url)
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-2">Current Thumbnail:</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Current Thumbnail:</label>
<div class="mt-1"> <div class="mt-1">
<img src="{{ $product->thumbnail_url }}" <img src="{{ $product->thumbnail_url }}"
alt="{{ $product->title }}" alt="{{ $product->title }}"
class="max-w-xs rounded-lg border border-gray-300 shadow-sm"> class="max-w-xs rounded-lg border border-gray-300 dark:border-gray-600 shadow-sm">
</div> </div>
</div> </div>
@endif @endif
<!-- New Thumbnail --> <!-- New 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 dark:text-gray-300">
{{ $product->thumbnail_url ? 'Replace Thumbnail' : 'Product Thumbnail' }} {{ $product->thumbnail_url ? 'Replace Thumbnail' : 'Product Thumbnail' }}
</label> </label>
<div class="mt-1"> <div class="mt-1">
@@ -161,24 +189,74 @@
id="thumbnail" id="thumbnail"
name="thumbnail" name="thumbnail"
accept="image/*" accept="image/*"
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('thumbnail') border-red-300 @enderror"> class="block w-full text-sm text-gray-500 dark:text-gray-400 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('thumbnail') border-red-300 dark:border-red-700 @enderror">
@error('thumbnail') @error('thumbnail')
<p class="mt-2 text-sm text-red-600">{{ $message }}</p> <p class="mt-2 text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
@enderror @enderror
<p class="mt-2 text-sm text-gray-500">Upload a product screenshot or logo (JPEG, PNG, or GIF, max 2MB).</p> <p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Upload a product screenshot or logo (JPEG, PNG, or GIF, max 2MB).</p>
</div> </div>
</div> </div>
<!-- Image Preview --> <!-- Image Preview -->
<div id="imagePreview" class="hidden"> <div id="imagePreview" class="hidden">
<label class="block text-sm font-medium text-gray-700 mb-2">New Preview:</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">New Preview:</label>
<div class="mt-1"> <div class="mt-1">
<img id="preview" src="#" alt="Thumbnail preview" class="max-w-xs rounded-lg border border-gray-300 shadow-sm"> <img id="preview" src="#" alt="Thumbnail preview" class="max-w-xs rounded-lg border border-gray-300 dark:border-gray-600 shadow-sm">
</div> </div>
</div> </div>
@if($product->screenshots->isNotEmpty())
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 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 dark:border-gray-600">
<span class="mt-1 flex items-center text-sm text-gray-700 dark:text-gray-300">
<input type="checkbox" name="remove_screenshots[]" value="{{ $shot->id }}"
class="h-4 w-4 text-primary-600 dark:text-primary-400 border-gray-300 dark:border-gray-600 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 dark:text-gray-300">
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 dark:text-gray-400 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 dark:text-red-400">{{ $message }}</p>
@enderror
@foreach($errors->get('screenshots.*') as $messages)
<p class="mt-2 text-sm text-red-600 dark:text-red-400">{{ $messages[0] }}</p>
@endforeach
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">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 dark:border-gray-600">
</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 dark:bg-blue-900/20 p-4">
<div class="flex"> <div class="flex">
<div class="flex-shrink-0"> <div class="flex-shrink-0">
<svg class="h-5 w-5 text-blue-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"> <svg class="h-5 w-5 text-blue-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
@@ -186,7 +264,7 @@
</svg> </svg>
</div> </div>
<div class="ml-3 flex-1"> <div class="ml-3 flex-1">
<p class="text-sm text-blue-700"> <p class="text-sm text-blue-700 dark:text-blue-300">
<strong>Note:</strong> Changes to your product may need to be reviewed by moderators before appearing on the site. <strong>Note:</strong> Changes to your product may need to be reviewed by moderators before appearing on the site.
</p> </p>
</div> </div>
@@ -194,9 +272,9 @@
</div> </div>
<!-- Submit Buttons --> <!-- Submit Buttons -->
<div class="flex items-center justify-between pt-5 border-t border-gray-200"> <div class="flex items-center justify-between pt-5 border-t border-gray-200 dark:border-gray-700">
<a href="{{ route('products.show', $product) }}" <a href="{{ route('products.show', $product) }}"
class="inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"> class="inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-600 shadow-sm text-sm font-medium rounded-md text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500">
Cancel Cancel
</a> </a>
<button type="submit" <button type="submit"
+26 -26
View File
@@ -6,13 +6,13 @@
@section('content') @section('content')
<!-- Hero Section --> <!-- Hero Section -->
<div class="bg-gradient-to-br from-primary-50 via-white to-accent-50 border-b border-gray-200"> <div class="bg-gradient-to-br from-primary-50 via-white to-accent-50 dark:from-gray-900 dark:via-gray-800 dark:to-gray-900 border-b border-gray-200 dark:border-gray-700">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12 sm:py-16"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12 sm:py-16">
<div class="text-center max-w-3xl mx-auto"> <div class="text-center max-w-3xl mx-auto">
<h1 class="text-4xl sm:text-5xl font-bold text-gray-900 mb-4"> <h1 class="text-4xl sm:text-5xl font-bold text-gray-900 dark:text-white mb-4">
Discover the best tech from Europe Discover the best tech from Europe
</h1> </h1>
<p class="text-lg text-gray-600 mb-8"> <p class="text-lg text-gray-600 dark:text-gray-400 mb-8">
Find amazing products built by European makers and companies Find amazing products built by European makers and companies
</p> </p>
@@ -24,7 +24,7 @@
name="search" name="search"
value="{{ request('search') }}" value="{{ request('search') }}"
placeholder="Search for products..." placeholder="Search for products..."
class="w-full px-6 py-4 text-lg border-2 border-gray-300 rounded-full focus:border-primary-500 focus:ring-2 focus:ring-primary-200 transition-all duration-200"> class="w-full px-6 py-4 text-lg border-2 border-gray-300 dark:border-gray-600 rounded-full focus:border-primary-500 focus:ring-2 focus:ring-primary-200 transition-all duration-200 dark:bg-gray-700 dark:text-white">
<button type="submit" <button type="submit"
class="absolute right-2 top-1/2 -translate-y-1/2 bg-primary-600 text-white px-6 py-2 rounded-full hover:bg-primary-700 transition-colors"> class="absolute right-2 top-1/2 -translate-y-1/2 bg-primary-600 text-white px-6 py-2 rounded-full hover:bg-primary-700 transition-colors">
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -50,7 +50,7 @@
type="button" type="button"
class="w-full inline-flex items-center justify-between px-4 py-2 border border-gray-300 dark:border-gray-600 shadow-sm text-sm font-medium rounded-md text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"> class="w-full inline-flex items-center justify-between px-4 py-2 border border-gray-300 dark:border-gray-600 shadow-sm text-sm font-medium rounded-md text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500">
<span class="flex items-center"> <span class="flex items-center">
<svg class="mr-2 h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="mr-2 h-5 w-5 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="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"></path>
</svg> </svg>
Filters & Sort Filters & Sort
@@ -66,21 +66,21 @@
class="lg:flex lg:items-end lg:gap-4 grid grid-cols-1 sm:grid-cols-2 gap-4 lg:gap-4"> class="lg:flex lg:items-end lg:gap-4 grid grid-cols-1 sm:grid-cols-2 gap-4 lg:gap-4">
<!-- Search (hidden on desktop as it's in hero) --> <!-- Search (hidden on desktop as it's in hero) -->
<div class="lg:hidden sm:col-span-2"> <div class="lg:hidden sm:col-span-2">
<label for="mobile-search" class="block text-sm font-medium text-gray-700 mb-2">Search</label> <label for="mobile-search" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Search</label>
<input type="text" <input type="text"
id="mobile-search" id="mobile-search"
name="search" name="search"
value="{{ request('search') }}" value="{{ request('search') }}"
placeholder="Search products..." placeholder="Search products..."
class="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm"> class="appearance-none block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm dark:bg-gray-700 dark:text-white">
</div> </div>
<!-- Category Filter --> <!-- Category Filter -->
<div class="lg:flex-1"> <div class="lg:flex-1">
<label for="category" class="block text-sm font-medium text-gray-700 mb-2">Category</label> <label for="category" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Category</label>
<select name="category" <select name="category"
id="category" id="category"
class="appearance-none 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"> class="appearance-none block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm dark:bg-gray-700 dark:text-white">
<option value="">All Categories</option> <option value="">All Categories</option>
@foreach($categories as $category) @foreach($categories as $category)
<option value="{{ $category->id }}" {{ request('category') == $category->id ? 'selected' : '' }}> <option value="{{ $category->id }}" {{ request('category') == $category->id ? 'selected' : '' }}>
@@ -92,10 +92,10 @@
<!-- Date Range Filter --> <!-- Date Range Filter -->
<div class="lg:flex-1"> <div class="lg:flex-1">
<label for="date_range" class="block text-sm font-medium text-gray-700 mb-2">Time Period</label> <label for="date_range" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Time Period</label>
<select name="date_range" <select name="date_range"
id="date_range" id="date_range"
class="appearance-none 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"> class="appearance-none block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm dark:bg-gray-700 dark:text-white">
<option value="">All Time</option> <option value="">All Time</option>
<option value="today" {{ request('date_range') === 'today' ? 'selected' : '' }}>Today</option> <option value="today" {{ request('date_range') === 'today' ? 'selected' : '' }}>Today</option>
<option value="week" {{ request('date_range') === 'week' ? 'selected' : '' }}>This Week</option> <option value="week" {{ request('date_range') === 'week' ? 'selected' : '' }}>This Week</option>
@@ -106,10 +106,10 @@
<!-- Sort By --> <!-- Sort By -->
<div class="lg:flex-1"> <div class="lg:flex-1">
<label for="sort" class="block text-sm font-medium text-gray-700 mb-2">Sort By</label> <label for="sort" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Sort By</label>
<select name="sort" <select name="sort"
id="sort" id="sort"
class="appearance-none 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"> class="appearance-none block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm dark:bg-gray-700 dark:text-white">
<option value="newest" {{ request('sort') === 'newest' || !request('sort') ? 'selected' : '' }}>Newest</option> <option value="newest" {{ request('sort') === 'newest' || !request('sort') ? 'selected' : '' }}>Newest</option>
<option value="popular" {{ request('sort') === 'popular' ? 'selected' : '' }}>Most Popular</option> <option value="popular" {{ request('sort') === 'popular' ? 'selected' : '' }}>Most Popular</option>
<option value="oldest" {{ request('sort') === 'oldest' ? 'selected' : '' }}>Oldest</option> <option value="oldest" {{ request('sort') === 'oldest' ? 'selected' : '' }}>Oldest</option>
@@ -137,28 +137,28 @@
<!-- Active Filters Tags --> <!-- Active Filters Tags -->
@if(request()->hasAny(['search', 'category', 'date_range', 'sort'])) @if(request()->hasAny(['search', 'category', 'date_range', 'sort']))
<div class="flex flex-wrap gap-2 pt-4 border-t border-gray-200"> <div class="flex flex-wrap gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
<span class="text-sm font-medium text-gray-700">Active filters:</span> <span class="text-sm font-medium text-gray-700 dark:text-gray-300">Active filters:</span>
@if(request('search')) @if(request('search'))
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm bg-primary-100 text-primary-800"> <span class="inline-flex items-center px-3 py-1 rounded-full text-sm bg-primary-100 dark:bg-primary-900 text-primary-800 dark:text-primary-200">
Search: "{{ request('search') }}" Search: "{{ request('search') }}"
<a href="{{ route('products.index', request()->except('search')) }}" class="ml-2 hover:text-primary-900">×</a> <a href="{{ route('products.index', request()->except('search')) }}" class="ml-2 hover:text-primary-900">×</a>
</span> </span>
@endif @endif
@if(request('category')) @if(request('category'))
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm bg-primary-100 text-primary-800"> <span class="inline-flex items-center px-3 py-1 rounded-full text-sm bg-primary-100 dark:bg-primary-900 text-primary-800 dark:text-primary-200">
{{ $categories->find(request('category'))->name ?? 'Unknown' }} {{ $categories->find(request('category'))->name ?? 'Unknown' }}
<a href="{{ route('products.index', request()->except('category')) }}" class="ml-2 hover:text-primary-900">×</a> <a href="{{ route('products.index', request()->except('category')) }}" class="ml-2 hover:text-primary-900">×</a>
</span> </span>
@endif @endif
@if(request('date_range')) @if(request('date_range'))
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm bg-primary-100 text-primary-800"> <span class="inline-flex items-center px-3 py-1 rounded-full text-sm bg-primary-100 dark:bg-primary-900 text-primary-800 dark:text-primary-200">
{{ ucfirst(request('date_range')) }} {{ ucfirst(request('date_range')) }}
<a href="{{ route('products.index', request()->except('date_range')) }}" class="ml-2 hover:text-primary-900">×</a> <a href="{{ route('products.index', request()->except('date_range')) }}" class="ml-2 hover:text-primary-900">×</a>
</span> </span>
@endif @endif
@if(request('sort') && request('sort') !== 'newest') @if(request('sort') && request('sort') !== 'newest')
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm bg-primary-100 text-primary-800"> <span class="inline-flex items-center px-3 py-1 rounded-full text-sm bg-primary-100 dark:bg-primary-900 text-primary-800 dark:text-primary-200">
{{ ucfirst(request('sort')) }} {{ ucfirst(request('sort')) }}
<a href="{{ route('products.index', request()->except('sort')) }}" class="ml-2 hover:text-primary-900">×</a> <a href="{{ route('products.index', request()->except('sort')) }}" class="ml-2 hover:text-primary-900">×</a>
</span> </span>
@@ -172,10 +172,10 @@
<!-- Results Summary --> <!-- Results Summary -->
@if($products->total() > 0) @if($products->total() > 0)
<div class="mb-6"> <div class="mb-6">
<p class="text-sm text-gray-600"> <p class="text-sm text-gray-600 dark:text-gray-400">
Showing <span class="font-semibold text-gray-900">{{ $products->firstItem() }}</span> to Showing <span class="font-semibold text-gray-900 dark:text-white">{{ $products->firstItem() }}</span> to
<span class="font-semibold text-gray-900">{{ $products->lastItem() }}</span> of <span class="font-semibold text-gray-900 dark:text-white">{{ $products->lastItem() }}</span> of
<span class="font-semibold text-gray-900">{{ $products->total() }}</span> products <span class="font-semibold text-gray-900 dark:text-white">{{ $products->total() }}</span> products
</p> </p>
</div> </div>
@endif @endif
@@ -188,11 +188,11 @@
<!-- Empty State --> <!-- Empty State -->
<div class="col-span-full"> <div class="col-span-full">
<div class="text-center py-16"> <div class="text-center py-16">
<svg class="mx-auto h-16 w-16 text-gray-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="mx-auto h-16 w-16 text-gray-400 dark:text-gray-500 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg> </svg>
<h3 class="text-lg font-medium text-gray-900 mb-2">No products found</h3> <h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">No products found</h3>
<p class="text-gray-600 mb-6"> <p class="text-gray-600 dark:text-gray-400 mb-6">
@if(request()->hasAny(['search', 'category', 'date_range'])) @if(request()->hasAny(['search', 'category', 'date_range']))
Try adjusting your search or filters to find what you're looking for. Try adjusting your search or filters to find what you're looking for.
@else @else
+28 -28
View File
@@ -4,17 +4,17 @@
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<!-- Header --> <!-- Header -->
<div class="mb-8"> <div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900">Product Management</h1> <h1 class="text-3xl font-bold text-gray-900 dark:text-white">Product Management</h1>
<p class="mt-2 text-sm text-gray-600">Review and manage submitted products</p> <p class="mt-2 text-sm text-gray-600 dark:text-gray-400">Review and manage submitted products</p>
</div> </div>
<!-- Status Filter --> <!-- Status Filter -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200 mb-6"> <div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 mb-6">
<div class="px-6 py-4"> <div class="px-6 py-4">
<h2 class="text-lg font-medium text-gray-900 mb-4">Filter by Status</h2> <h2 class="text-lg font-medium text-gray-900 dark:text-white mb-4">Filter by Status</h2>
<div class="flex flex-wrap gap-3"> <div class="flex flex-wrap gap-3">
<a href="{{ route('products.manage') }}" <a href="{{ route('products.manage') }}"
class="inline-flex items-center px-4 py-2 rounded-md text-sm font-medium {{ !request('status') || request('status') == 'pending' ? 'bg-yellow-100 text-yellow-800 ring-2 ring-yellow-600' : 'bg-white text-gray-700 ring-1 ring-gray-300 hover:bg-gray-50' }}"> class="inline-flex items-center px-4 py-2 rounded-md text-sm font-medium {{ !request('status') || request('status') == 'pending' ? 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-200 ring-2 ring-yellow-600' : 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 ring-1 ring-gray-300 dark:ring-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700' }}">
<svg class="mr-2 h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="mr-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="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg> </svg>
@@ -25,7 +25,7 @@
</a> </a>
<a href="{{ route('products.manage', ['status' => 'approved']) }}" <a href="{{ route('products.manage', ['status' => 'approved']) }}"
class="inline-flex items-center px-4 py-2 rounded-md text-sm font-medium {{ request('status') == 'approved' ? 'bg-green-100 text-green-800 ring-2 ring-green-600' : 'bg-white text-gray-700 ring-1 ring-gray-300 hover:bg-gray-50' }}"> class="inline-flex items-center px-4 py-2 rounded-md text-sm font-medium {{ request('status') == 'approved' ? 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200 ring-2 ring-green-600' : 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 ring-1 ring-gray-300 dark:ring-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700' }}">
<svg class="mr-2 h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="mr-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="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg> </svg>
@@ -36,7 +36,7 @@
</a> </a>
<a href="{{ route('products.manage', ['status' => 'rejected']) }}" <a href="{{ route('products.manage', ['status' => 'rejected']) }}"
class="inline-flex items-center px-4 py-2 rounded-md text-sm font-medium {{ request('status') == 'rejected' ? 'bg-red-100 text-red-800 ring-2 ring-red-600' : 'bg-white text-gray-700 ring-1 ring-gray-300 hover:bg-gray-50' }}"> class="inline-flex items-center px-4 py-2 rounded-md text-sm font-medium {{ request('status') == 'rejected' ? 'bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-200 ring-2 ring-red-600' : 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 ring-1 ring-gray-300 dark:ring-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700' }}">
<svg class="mr-2 h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="mr-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 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg> </svg>
@@ -53,7 +53,7 @@
@if($products->count() > 0) @if($products->count() > 0)
<div class="space-y-4"> <div class="space-y-4">
@foreach($products as $product) @foreach($products as $product)
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden hover:shadow-md transition-shadow"> <div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden hover:shadow-md transition-shadow">
<div class="p-6"> <div class="p-6">
<div class="flex items-start gap-4"> <div class="flex items-start gap-4">
<!-- Thumbnail --> <!-- Thumbnail -->
@@ -61,10 +61,10 @@
@if($product->thumbnail_url) @if($product->thumbnail_url)
<img src="{{ $product->thumbnail_url }}" <img src="{{ $product->thumbnail_url }}"
alt="{{ $product->title }}" alt="{{ $product->title }}"
class="w-20 h-20 rounded-lg object-cover border border-gray-200"> class="w-20 h-20 rounded-lg object-cover border border-gray-200 dark:border-gray-700">
@else @else
<div class="w-20 h-20 rounded-lg bg-gradient-to-br from-gray-100 to-gray-200 flex items-center justify-center border border-gray-200"> <div class="w-20 h-20 rounded-lg bg-gradient-to-br from-gray-100 to-gray-200 dark:from-gray-700 dark:to-gray-600 flex items-center justify-center border border-gray-200 dark:border-gray-700">
<svg class="h-8 w-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="h-8 w-8 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> <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> </svg>
</div> </div>
@@ -75,19 +75,19 @@
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<div class="flex items-start justify-between"> <div class="flex items-start justify-between">
<div class="flex-1"> <div class="flex-1">
<h3 class="text-lg font-semibold text-gray-900"> <h3 class="text-lg font-semibold text-gray-900 dark:text-white">
<a href="{{ route('products.show', $product) }}" class="hover:text-primary-600"> <a href="{{ route('products.show', $product) }}" class="hover:text-primary-600">
{{ $product->title }} {{ $product->title }}
</a> </a>
</h3> </h3>
<p class="mt-1 text-sm text-gray-600 line-clamp-2">{{ $product->description }}</p> <p class="mt-1 text-sm text-gray-600 dark:text-gray-400 line-clamp-2">{{ $product->description }}</p>
<div class="mt-3 flex flex-wrap items-center gap-4 text-sm"> <div class="mt-3 flex flex-wrap items-center gap-4 text-sm">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-md bg-primary-100 text-primary-800 font-medium"> <span class="inline-flex items-center px-2.5 py-0.5 rounded-md bg-primary-100 dark:bg-primary-900 text-primary-800 dark:text-primary-200 font-medium">
{{ $product->category->name }} {{ $product->category->name }}
</span> </span>
<span class="inline-flex items-center text-gray-500"> <span class="inline-flex items-center text-gray-500 dark:text-gray-400">
<svg class="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path>
</svg> </svg>
@@ -96,30 +96,30 @@
</a> </a>
</span> </span>
<span class="inline-flex items-center text-gray-500"> <span class="inline-flex items-center text-gray-500 dark:text-gray-400">
<svg class="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
</svg> </svg>
{{ $product->created_at->format('M j, Y') }} {{ $product->created_at->format('M j, Y') }}
</span> </span>
<span class="text-gray-400">•</span> <span class="text-gray-400 dark:text-gray-500">•</span>
<span class="text-gray-500">{{ $product->created_at->diffForHumans() }}</span> <span class="text-gray-500 dark:text-gray-400">{{ $product->created_at->diffForHumans() }}</span>
</div> </div>
</div> </div>
<!-- Status Badge --> <!-- Status Badge -->
<div class="ml-4 flex-shrink-0"> <div class="ml-4 flex-shrink-0">
@if($product->isPending()) @if($product->isPending())
<span class="inline-flex items-center px-3 py-1 rounded-md text-sm font-medium bg-yellow-100 text-yellow-800"> <span class="inline-flex items-center px-3 py-1 rounded-md text-sm font-medium bg-yellow-100 text-yellow-800 dark:text-yellow-200">
Pending Pending
</span> </span>
@elseif($product->isApproved()) @elseif($product->isApproved())
<span class="inline-flex items-center px-3 py-1 rounded-md text-sm font-medium bg-green-100 text-green-800"> <span class="inline-flex items-center px-3 py-1 rounded-md text-sm font-medium bg-green-100 text-green-800 dark:text-green-200">
Approved Approved
</span> </span>
@elseif($product->isRejected()) @elseif($product->isRejected())
<span class="inline-flex items-center px-3 py-1 rounded-md text-sm font-medium bg-red-100 text-red-800"> <span class="inline-flex items-center px-3 py-1 rounded-md text-sm font-medium bg-red-100 text-red-800 dark:text-red-200">
Rejected Rejected
</span> </span>
@endif @endif
@@ -152,7 +152,7 @@
</form> </form>
@endif @endif
<a href="{{ route('products.show', $product) }}" class="inline-flex items-center px-3 py-1.5 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"> <a href="{{ route('products.show', $product) }}" class="inline-flex items-center px-3 py-1.5 border border-gray-300 dark:border-gray-600 text-sm font-medium rounded-md text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500">
<svg class="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
@@ -161,7 +161,7 @@
</a> </a>
@can('edit-product', $product) @can('edit-product', $product)
<a href="{{ route('products.edit', $product) }}" class="inline-flex items-center px-3 py-1.5 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"> <a href="{{ route('products.edit', $product) }}" class="inline-flex items-center px-3 py-1.5 border border-gray-300 dark:border-gray-600 text-sm font-medium rounded-md text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500">
<svg class="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
</svg> </svg>
@@ -173,7 +173,7 @@
<form action="{{ route('products.destroy', $product) }}" method="POST" onsubmit="return confirm('Are you sure you want to delete this product permanently?');"> <form action="{{ route('products.destroy', $product) }}" method="POST" onsubmit="return confirm('Are you sure you want to delete this product permanently?');">
@csrf @csrf
@method('DELETE') @method('DELETE')
<button type="submit" class="inline-flex items-center px-3 py-1.5 border border-red-300 text-sm font-medium rounded-md text-red-700 bg-white hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"> <button type="submit" class="inline-flex items-center px-3 py-1.5 border border-red-300 dark:border-red-700 text-sm font-medium rounded-md text-red-700 dark:text-red-300 bg-white dark:bg-gray-800 hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500">
<svg class="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
</svg> </svg>
@@ -196,12 +196,12 @@
</div> </div>
@endif @endif
@else @else
<div class="text-center py-12 bg-gray-50 rounded-lg border border-gray-200"> <div class="text-center py-12 bg-gray-50 dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="mx-auto h-12 w-12 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="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
</svg> </svg>
<h3 class="mt-2 text-sm font-medium text-gray-900">No products found</h3> <h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-white">No products found</h3>
<p class="mt-1 text-sm text-gray-500"> <p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
No products with status: <strong>{{ request('status', 'pending') }}</strong> No products with status: <strong>{{ request('status', 'pending') }}</strong>
</p> </p>
</div> </div>
+30 -4
View File
@@ -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,9 +115,15 @@
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- Product Content --> <!-- Gallery -->
<div class="px-6 py-8"> <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 --> <!-- Product Thumbnail -->
@if($product->thumbnail_url) @if($product->thumbnail_url)
<div class="mb-8 rounded-lg overflow-hidden border border-gray-200 dark:border-gray-700"> <div class="mb-8 rounded-lg overflow-hidden border border-gray-200 dark:border-gray-700">
@@ -164,10 +180,13 @@
</div> </div>
@endif @endif
</div> </div>
</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')
+41 -41
View File
@@ -4,11 +4,11 @@
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12"> <div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<!-- Header --> <!-- Header -->
<div class="mb-8"> <div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900">Edit Profile</h1> <h1 class="text-3xl font-bold text-gray-900 dark:text-white">Edit Profile</h1>
<p class="mt-2 text-sm text-gray-600">Update your profile information and social links</p> <p class="mt-2 text-sm text-gray-600 dark:text-gray-400">Update your profile information and social links</p>
</div> </div>
<div class="bg-white rounded-lg shadow-sm border border-gray-200"> <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-8"> <div class="px-6 py-8">
<form method="POST" action="{{ route('profile.update') }}" enctype="multipart/form-data" class="space-y-8"> <form method="POST" action="{{ route('profile.update') }}" enctype="multipart/form-data" class="space-y-8">
@csrf @csrf
@@ -16,7 +16,7 @@
<!-- Avatar Section --> <!-- Avatar Section -->
<div> <div>
<h3 class="text-lg font-medium text-gray-900 mb-4">Profile Picture</h3> <h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">Profile Picture</h3>
<div class="flex items-center gap-6"> <div class="flex items-center gap-6">
<div class="flex-shrink-0"> <div class="flex-shrink-0">
@if($user->avatar_url) @if($user->avatar_url)
@@ -34,17 +34,17 @@
id="avatar" id="avatar"
name="avatar" name="avatar"
accept="image/*" accept="image/*"
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('avatar') border-red-300 @enderror"> class="block w-full text-sm text-gray-500 dark:text-gray-400 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('avatar') border-red-300 dark:border-red-700 @enderror">
@error('avatar') @error('avatar')
<p class="mt-2 text-sm text-red-600">{{ $message }}</p> <p class="mt-2 text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
@enderror @enderror
<p class="mt-2 text-sm text-gray-500">JPG, PNG, or GIF. Max size 2MB.</p> <p class="mt-2 text-sm text-gray-500 dark:text-gray-400">JPG, PNG, or GIF. Max size 2MB.</p>
@if($user->avatar) @if($user->avatar)
<form method="POST" action="{{ route('profile.avatar.delete') }}" class="mt-3" onsubmit="return confirm('Are you sure you want to delete your avatar?')"> <form method="POST" action="{{ route('profile.avatar.delete') }}" class="mt-3" onsubmit="return confirm('Are you sure you want to delete your avatar?')">
@csrf @csrf
@method('DELETE') @method('DELETE')
<button type="submit" class="text-sm font-medium text-red-600 hover:text-red-700"> <button type="submit" class="text-sm font-medium text-red-600 dark:text-red-400 hover:text-red-700">
Delete Avatar Delete Avatar
</button> </button>
</form> </form>
@@ -54,13 +54,13 @@
</div> </div>
<!-- Basic Information --> <!-- Basic Information -->
<div class="border-t border-gray-200 pt-8"> <div class="border-t border-gray-200 dark:border-gray-700 pt-8">
<h3 class="text-lg font-medium text-gray-900 mb-4">Basic Information</h3> <h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">Basic Information</h3>
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2"> <div class="grid grid-cols-1 gap-6 sm:grid-cols-2">
<!-- Name --> <!-- Name -->
<div> <div>
<label for="name" class="block text-sm font-medium text-gray-700"> <label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Name <span class="text-red-600">*</span> Name <span class="text-red-600 dark:text-red-400">*</span>
</label> </label>
<div class="mt-1"> <div class="mt-1">
<input type="text" <input type="text"
@@ -68,17 +68,17 @@
name="name" name="name"
value="{{ old('name', $user->name) }}" value="{{ old('name', $user->name) }}"
required required
class="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm @error('name') border-red-300 text-red-900 placeholder-red-300 focus:ring-red-500 focus:border-red-500 @enderror"> class="appearance-none block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm @error('name') border-red-300 text-red-900 dark:text-red-200 placeholder-red-300 focus:ring-red-500 focus:border-red-500 @enderror dark:bg-gray-700">
@error('name') @error('name')
<p class="mt-2 text-sm text-red-600">{{ $message }}</p> <p class="mt-2 text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
@enderror @enderror
</div> </div>
</div> </div>
<!-- Email --> <!-- Email -->
<div> <div>
<label for="email" class="block text-sm font-medium text-gray-700"> <label for="email" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Email <span class="text-red-600">*</span> Email <span class="text-red-600 dark:text-red-400">*</span>
</label> </label>
<div class="mt-1"> <div class="mt-1">
<input type="email" <input type="email"
@@ -86,9 +86,9 @@
name="email" name="email"
value="{{ old('email', $user->email) }}" value="{{ old('email', $user->email) }}"
required required
class="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm @error('email') border-red-300 text-red-900 placeholder-red-300 focus:ring-red-500 focus:border-red-500 @enderror"> class="appearance-none block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm @error('email') border-red-300 text-red-900 dark:text-red-200 placeholder-red-300 focus:ring-red-500 focus:border-red-500 @enderror dark:bg-gray-700">
@error('email') @error('email')
<p class="mt-2 text-sm text-red-600">{{ $message }}</p> <p class="mt-2 text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
@enderror @enderror
</div> </div>
</div> </div>
@@ -96,7 +96,7 @@
<!-- Bio --> <!-- Bio -->
<div class="mt-6"> <div class="mt-6">
<label for="bio" class="block text-sm font-medium text-gray-700"> <label for="bio" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Bio Bio
</label> </label>
<div class="mt-1"> <div class="mt-1">
@@ -105,22 +105,22 @@
rows="4" rows="4"
maxlength="1000" maxlength="1000"
placeholder="Tell us a bit about yourself..." placeholder="Tell us a bit about yourself..."
class="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm @error('bio') border-red-300 text-red-900 placeholder-red-300 focus:ring-red-500 focus:border-red-500 @enderror">{{ old('bio', $user->bio) }}</textarea> class="appearance-none block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm @error('bio') border-red-300 text-red-900 dark:text-red-200 placeholder-red-300 focus:ring-red-500 focus:border-red-500 @enderror dark:bg-gray-700">{{ old('bio', $user->bio) }}</textarea>
@error('bio') @error('bio')
<p class="mt-2 text-sm text-red-600">{{ $message }}</p> <p class="mt-2 text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
@enderror @enderror
<p class="mt-2 text-sm text-gray-500">Max 1000 characters</p> <p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Max 1000 characters</p>
</div> </div>
</div> </div>
</div> </div>
<!-- Website & Social Links --> <!-- Website & Social Links -->
<div class="border-t border-gray-200 pt-8"> <div class="border-t border-gray-200 dark:border-gray-700 pt-8">
<h3 class="text-lg font-medium text-gray-900 mb-4">Website & Social Links</h3> <h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">Website & Social Links</h3>
<div class="space-y-6"> <div class="space-y-6">
<!-- Website --> <!-- Website -->
<div> <div>
<label for="website" class="block text-sm font-medium text-gray-700"> <label for="website" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Website Website
</label> </label>
<div class="mt-1"> <div class="mt-1">
@@ -129,20 +129,20 @@
name="website" name="website"
value="{{ old('website', $user->website) }}" value="{{ old('website', $user->website) }}"
placeholder="https://example.com" placeholder="https://example.com"
class="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm @error('website') border-red-300 text-red-900 placeholder-red-300 focus:ring-red-500 focus:border-red-500 @enderror"> class="appearance-none block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm @error('website') border-red-300 text-red-900 dark:text-red-200 placeholder-red-300 focus:ring-red-500 focus:border-red-500 @enderror dark:bg-gray-700">
@error('website') @error('website')
<p class="mt-2 text-sm text-red-600">{{ $message }}</p> <p class="mt-2 text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
@enderror @enderror
</div> </div>
</div> </div>
<!-- Twitter --> <!-- Twitter -->
<div> <div>
<label for="twitter" class="block text-sm font-medium text-gray-700"> <label for="twitter" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Twitter Username Twitter Username
</label> </label>
<div class="mt-1 flex rounded-md shadow-sm"> <div class="mt-1 flex rounded-md shadow-sm">
<span class="inline-flex items-center px-3 rounded-l-md border border-r-0 border-gray-300 bg-gray-50 text-gray-500 sm:text-sm"> <span class="inline-flex items-center px-3 rounded-l-md border border-r-0 border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-500 dark:text-gray-400 sm:text-sm">
@ @
</span> </span>
<input type="text" <input type="text"
@@ -150,20 +150,20 @@
name="twitter" name="twitter"
value="{{ old('twitter', $user->twitter) }}" value="{{ old('twitter', $user->twitter) }}"
placeholder="username" placeholder="username"
class="flex-1 min-w-0 block w-full px-3 py-2 rounded-none rounded-r-md border-gray-300 focus:ring-primary-500 focus:border-primary-500 sm:text-sm @error('twitter') border-red-300 text-red-900 placeholder-red-300 focus:ring-red-500 focus:border-red-500 @enderror"> class="flex-1 min-w-0 block w-full px-3 py-2 rounded-none rounded-r-md border-gray-300 dark:border-gray-600 focus:ring-primary-500 focus:border-primary-500 sm:text-sm @error('twitter') border-red-300 text-red-900 dark:text-red-200 placeholder-red-300 focus:ring-red-500 focus:border-red-500 @enderror dark:bg-gray-700">
</div> </div>
@error('twitter') @error('twitter')
<p class="mt-2 text-sm text-red-600">{{ $message }}</p> <p class="mt-2 text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
@enderror @enderror
</div> </div>
<!-- GitHub --> <!-- GitHub -->
<div> <div>
<label for="github" class="block text-sm font-medium text-gray-700"> <label for="github" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
GitHub Username GitHub Username
</label> </label>
<div class="mt-1 flex rounded-md shadow-sm"> <div class="mt-1 flex rounded-md shadow-sm">
<span class="inline-flex items-center px-3 rounded-l-md border border-r-0 border-gray-300 bg-gray-50 text-gray-500 text-sm"> <span class="inline-flex items-center px-3 rounded-l-md border border-r-0 border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-500 dark:text-gray-400 text-sm">
github.com/ github.com/
</span> </span>
<input type="text" <input type="text"
@@ -171,20 +171,20 @@
name="github" name="github"
value="{{ old('github', $user->github) }}" value="{{ old('github', $user->github) }}"
placeholder="username" placeholder="username"
class="flex-1 min-w-0 block w-full px-3 py-2 rounded-none rounded-r-md border-gray-300 focus:ring-primary-500 focus:border-primary-500 sm:text-sm @error('github') border-red-300 text-red-900 placeholder-red-300 focus:ring-red-500 focus:border-red-500 @enderror"> class="flex-1 min-w-0 block w-full px-3 py-2 rounded-none rounded-r-md border-gray-300 dark:border-gray-600 focus:ring-primary-500 focus:border-primary-500 sm:text-sm @error('github') border-red-300 text-red-900 dark:text-red-200 placeholder-red-300 focus:ring-red-500 focus:border-red-500 @enderror dark:bg-gray-700">
</div> </div>
@error('github') @error('github')
<p class="mt-2 text-sm text-red-600">{{ $message }}</p> <p class="mt-2 text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
@enderror @enderror
</div> </div>
<!-- LinkedIn --> <!-- LinkedIn -->
<div> <div>
<label for="linkedin" class="block text-sm font-medium text-gray-700"> <label for="linkedin" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
LinkedIn Username LinkedIn Username
</label> </label>
<div class="mt-1 flex rounded-md shadow-sm"> <div class="mt-1 flex rounded-md shadow-sm">
<span class="inline-flex items-center px-3 rounded-l-md border border-r-0 border-gray-300 bg-gray-50 text-gray-500 text-sm"> <span class="inline-flex items-center px-3 rounded-l-md border border-r-0 border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-500 dark:text-gray-400 text-sm">
linkedin.com/in/ linkedin.com/in/
</span> </span>
<input type="text" <input type="text"
@@ -192,19 +192,19 @@
name="linkedin" name="linkedin"
value="{{ old('linkedin', $user->linkedin) }}" value="{{ old('linkedin', $user->linkedin) }}"
placeholder="username" placeholder="username"
class="flex-1 min-w-0 block w-full px-3 py-2 rounded-none rounded-r-md border-gray-300 focus:ring-primary-500 focus:border-primary-500 sm:text-sm @error('linkedin') border-red-300 text-red-900 placeholder-red-300 focus:ring-red-500 focus:border-red-500 @enderror"> class="flex-1 min-w-0 block w-full px-3 py-2 rounded-none rounded-r-md border-gray-300 dark:border-gray-600 focus:ring-primary-500 focus:border-primary-500 sm:text-sm @error('linkedin') border-red-300 text-red-900 dark:text-red-200 placeholder-red-300 focus:ring-red-500 focus:border-red-500 @enderror dark:bg-gray-700">
</div> </div>
@error('linkedin') @error('linkedin')
<p class="mt-2 text-sm text-red-600">{{ $message }}</p> <p class="mt-2 text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
@enderror @enderror
</div> </div>
</div> </div>
</div> </div>
<!-- Form Actions --> <!-- Form Actions -->
<div class="border-t border-gray-200 pt-6 flex items-center justify-between"> <div class="border-t border-gray-200 dark:border-gray-700 pt-6 flex items-center justify-between">
<a href="{{ route('profile.show', $user) }}" <a href="{{ route('profile.show', $user) }}"
class="inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"> class="inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-600 shadow-sm text-sm font-medium rounded-md text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500">
Cancel Cancel
</a> </a>
<button type="submit" <button type="submit"
+8 -8
View File
@@ -1,13 +1,13 @@
@extends('layouts.app') @extends('layouts.app')
@section('content') @section('content')
<div class="bg-gradient-to-br from-primary-50 via-white to-accent-50 border-b border-gray-200"> <div class="bg-gradient-to-br from-primary-50 via-white to-accent-50 dark:from-gray-900 dark:via-gray-800 dark:to-gray-900 border-b border-gray-200 dark:border-gray-700">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12 sm:py-16"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12 sm:py-16">
<div class="max-w-3xl"> <div class="max-w-3xl">
<h1 class="text-4xl sm:text-5xl font-bold text-gray-900 mb-4"> <h1 class="text-4xl sm:text-5xl font-bold text-gray-900 dark:text-white mb-4">
My Upvotes My Upvotes
</h1> </h1>
<p class="text-lg text-gray-600"> <p class="text-lg text-gray-600 dark:text-gray-400">
Products you've upvoted and supported Products you've upvoted and supported
</p> </p>
</div> </div>
@@ -18,8 +18,8 @@
<!-- Results Summary --> <!-- Results Summary -->
@if($upvotes->total() > 0) @if($upvotes->total() > 0)
<div class="mb-6"> <div class="mb-6">
<p class="text-sm text-gray-600"> <p class="text-sm text-gray-600 dark:text-gray-400">
You have upvoted <span class="font-semibold text-gray-900">{{ $upvotes->total() }}</span> {{ Str::plural('product', $upvotes->total()) }} You have upvoted <span class="font-semibold text-gray-900 dark:text-white">{{ $upvotes->total() }}</span> {{ Str::plural('product', $upvotes->total()) }}
</p> </p>
</div> </div>
@endif @endif
@@ -32,11 +32,11 @@
<!-- Empty State --> <!-- Empty State -->
<div class="col-span-full"> <div class="col-span-full">
<div class="text-center py-16"> <div class="text-center py-16">
<svg class="mx-auto h-16 w-16 text-gray-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="mx-auto h-16 w-16 text-gray-400 dark:text-gray-500 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7"></path>
</svg> </svg>
<h3 class="text-lg font-medium text-gray-900 mb-2">No upvotes yet</h3> <h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">No upvotes yet</h3>
<p class="text-gray-600 mb-6"> <p class="text-gray-600 dark:text-gray-400 mb-6">
Start upvoting products you love to show your support! Start upvoting products you love to show your support!
</p> </p>
<a href="{{ route('products.index') }}" class="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"> <a href="{{ route('products.index') }}" class="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500">
+8
View File
@@ -7,10 +7,12 @@ use App\Http\Controllers\BookmarkController;
use App\Http\Controllers\CategoryManagementController; use App\Http\Controllers\CategoryManagementController;
use App\Http\Controllers\CommentController; use App\Http\Controllers\CommentController;
use App\Http\Controllers\CommentModerationController; use App\Http\Controllers\CommentModerationController;
use App\Http\Controllers\LeaderboardController;
use App\Http\Controllers\ProductController; 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;
@@ -33,6 +35,9 @@ Route::middleware(['auth'])->group(function () {
Route::get('/profile/{user}', [ProfileController::class, 'show'])->name('profile.show'); Route::get('/profile/{user}', [ProfileController::class, 'show'])->name('profile.show');
// Leaderboard
Route::get('/leaderboard', [LeaderboardController::class, 'index'])->name('leaderboard');
// Product Routes // Product Routes
Route::get('/products', [ProductController::class, 'index'])->name('products.index'); Route::get('/products', [ProductController::class, 'index'])->name('products.index');
Route::get('/products/{product}', [ProductController::class, 'show'])->name('products.show'); Route::get('/products/{product}', [ProductController::class, 'show'])->name('products.show');
@@ -44,6 +49,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
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
View File
View File
View File
View File
View File
Regular → Executable
View File
+169
View File
@@ -0,0 +1,169 @@
<?php
use App\Models\Product;
use App\Models\Upvote;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
uses(RefreshDatabase::class);
beforeEach(function () {
Carbon::setTestNow(Carbon::parse('2026-10-17 15:00:00'));
});
afterEach(function () {
Carbon::setTestNow();
});
function lbProduct(string $title, string $state = 'approved', array $attrs = []): Product
{
return Product::factory()->{$state}()->create(['title' => $title] + $attrs);
}
function lbUpvotes(Product $product, int $count, ?Carbon $at = null): void
{
Upvote::factory()->count($count)->create([
'product_id' => $product->id,
'created_at' => $at ?? now(),
]);
}
test('top today ranks by upvotes received today, not by product age', function () {
$old = lbProduct('Old Favourite', attrs: ['created_at' => now()->subYear()]);
$new = lbProduct('Fresh Launch');
lbUpvotes($old, 3);
lbUpvotes($new, 1);
$this->get(route('leaderboard', ['period' => 'today']))
->assertOk()
->assertSeeInOrder(['Old Favourite', 'Fresh Launch']);
});
test('today only counts upvotes since midnight', function () {
$a = lbProduct('Upvoted Yesterday');
$b = lbProduct('Upvoted Today');
lbUpvotes($a, 5, now()->startOfDay()->subMinute());
lbUpvotes($b, 1, now()->startOfDay());
$this->get(route('leaderboard', ['period' => 'today']))
->assertSee('Upvoted Today')
->assertDontSee('Upvoted Yesterday');
});
test('week counts the last 7 days and month the last 30', function () {
$sixDays = lbProduct('Six Days Ago');
$eightDays = lbProduct('Eight Days Ago');
$twentyNine = lbProduct('Twenty Nine Days Ago');
$thirtyOne = lbProduct('Thirty One Days Ago');
lbUpvotes($sixDays, 1, now()->subDays(6));
lbUpvotes($eightDays, 1, now()->subDays(8));
lbUpvotes($twentyNine, 1, now()->subDays(29));
lbUpvotes($thirtyOne, 1, now()->subDays(31));
$this->get(route('leaderboard', ['period' => 'week']))
->assertSee('Six Days Ago')
->assertDontSee('Eight Days Ago')
->assertDontSee('Twenty Nine Days Ago');
$this->get(route('leaderboard', ['period' => 'month']))
->assertSee('Six Days Ago')
->assertSee('Eight Days Ago')
->assertSee('Twenty Nine Days Ago')
->assertDontSee('Thirty One Days Ago');
});
test('all time counts every upvote', function () {
$ancient = lbProduct('Ancient Product');
lbUpvotes($ancient, 1, now()->subYears(2));
$this->get(route('leaderboard', ['period' => 'all']))->assertSee('Ancient Product');
});
test('only approved products with upvotes in the period are ranked', function () {
$ok = lbProduct('Ranked Product');
$pending = lbProduct('Pending Product', 'pending');
$rejected = lbProduct('Rejected Product', 'rejected');
lbProduct('Zero Upvotes');
lbUpvotes($ok, 1);
lbUpvotes($pending, 4);
lbUpvotes($rejected, 4);
$this->get(route('leaderboard'))
->assertSee('Ranked Product')
->assertDontSee('Pending Product')
->assertDontSee('Rejected Product')
->assertDontSee('Zero Upvotes');
});
test('ties are broken by total upvotes, then by newest', function () {
$moreTotal = lbProduct('More Total', attrs: ['created_at' => now()->subDays(10)]);
$fewerTotal = lbProduct('Fewer Total', attrs: ['created_at' => now()->subDays(5)]);
$newerTie = lbProduct('Newer Tie', attrs: ['created_at' => now()->subDay()]);
$olderTie = lbProduct('Older Tie', attrs: ['created_at' => now()->subDays(2)]);
lbUpvotes($moreTotal, 2);
lbUpvotes($moreTotal, 3, now()->subDays(40));
lbUpvotes($fewerTotal, 2);
lbUpvotes($newerTie, 1);
lbUpvotes($olderTie, 1);
$this->get(route('leaderboard', ['period' => 'today']))
->assertSeeInOrder(['More Total', 'Fewer Total', 'Newer Tie', 'Older Tie']);
});
test('a removed upvote is not counted', function () {
$product = lbProduct('Changed Mind');
lbUpvotes($product, 2);
Upvote::where('product_id', $product->id)->first()->delete();
$this->get(route('leaderboard'))->assertSee('1 today', false);
});
test('new tab lists approved products newest first', function () {
lbProduct('Oldest One', attrs: ['created_at' => now()->subDays(3)]);
lbProduct('Newest One', attrs: ['created_at' => now()->subHour()]);
lbProduct('Middle One', attrs: ['created_at' => now()->subDay()]);
lbProduct('Pending One', 'pending');
$this->get(route('leaderboard', ['tab' => 'new']))
->assertOk()
->assertSeeInOrder(['Newest One', 'Middle One', 'Oldest One'])
->assertDontSee('Pending One');
});
test('lists 10 per page, ranks continue on page 2 and links keep the filters', function () {
foreach (range(1, 12) as $i) {
lbUpvotes(lbProduct("Product $i"), 20 - $i);
}
$page1 = $this->get(route('leaderboard', ['period' => 'week']));
$page1->assertSee('data-rank="10"', false)
->assertDontSee('data-rank="11"', false)
->assertSee('period=week', false)
->assertSee('page=2', false);
$this->get(route('leaderboard', ['period' => 'week', 'page' => 2]))
->assertSee('data-rank="11"', false)
->assertSee('data-rank="12"', false)
->assertSee('Product 12')
->assertDontSee('Product 1<', false);
});
test('invalid tab and period fall back to top today', function () {
$product = lbProduct('Fallback Product');
lbUpvotes($product, 1);
$this->get(route('leaderboard', ['tab' => 'nonsense', 'period' => 'decade']))
->assertOk()
->assertSee('Fallback Product');
});
test('empty period shows a message', function () {
$this->get(route('leaderboard', ['period' => 'today']))
->assertOk()
->assertSee('No upvotes yet today');
});
test('navbar links to the leaderboard', function () {
$this->get(route('leaderboard'))->assertSee(route('leaderboard'), false);
$this->get('/products')->assertSee(route('leaderboard'), false);
});
+99
View File
@@ -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');
});
+115
View File
@@ -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');
});
+229
View File
@@ -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');
});
+79
View File
@@ -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);
});
+115
View File
@@ -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);
});
+152
View File
@@ -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);
});
+24
View File
@@ -45,3 +45,27 @@ function something()
{ {
// .. // ..
} }
function userWithRole(string $slug): \App\Models\User
{
$role = \App\Models\Role::firstOrCreate(
['slug' => $slug],
['name' => ucfirst($slug), 'description' => ucfirst($slug)]
);
$user = \App\Models\User::factory()->create();
$user->roles()->attach($role);
return $user;
}
function makeCategory(): \App\Models\Category
{
$name = 'Category ' . \Illuminate\Support\Str::random(8);
return \App\Models\Category::create([
'name' => $name,
'slug' => \Illuminate\Support\Str::slug($name),
'description' => 'Test description',
]);
}