Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f8fa9f22f | ||
|
|
25e1dd2413 | ||
|
|
2a37695d78 |
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -284,6 +284,42 @@ class Product extends Model
|
||||
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.
|
||||
*/
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class Upvote extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
/**
|
||||
* Indicates if the model should be timestamped.
|
||||
*
|
||||
|
||||
@@ -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="max-w-md w-full space-y-8">
|
||||
<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') }}
|
||||
</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.') }}
|
||||
</p>
|
||||
</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">
|
||||
@csrf
|
||||
|
||||
<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') }}
|
||||
</label>
|
||||
<div class="mt-1">
|
||||
<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')
|
||||
<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
|
||||
</div>
|
||||
</div>
|
||||
@@ -37,7 +37,7 @@
|
||||
|
||||
@if (Route::has('password.request'))
|
||||
<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?') }}
|
||||
</a>
|
||||
</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="max-w-md w-full space-y-8">
|
||||
<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') }}
|
||||
</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.') }}
|
||||
</p>
|
||||
</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'))
|
||||
<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-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">
|
||||
@@ -21,7 +21,7 @@
|
||||
</svg>
|
||||
</div>
|
||||
<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') }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -33,14 +33,14 @@
|
||||
@csrf
|
||||
|
||||
<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') }}
|
||||
</label>
|
||||
<div class="mt-1">
|
||||
<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')
|
||||
<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
|
||||
</div>
|
||||
</div>
|
||||
@@ -54,7 +54,7 @@
|
||||
|
||||
@if (Route::has('login'))
|
||||
<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') }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -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="max-w-md w-full space-y-8">
|
||||
<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') }}
|
||||
</h2>
|
||||
</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'))
|
||||
<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-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">
|
||||
@@ -18,7 +18,7 @@
|
||||
</svg>
|
||||
</div>
|
||||
<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.') }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -26,7 +26,7 @@
|
||||
</div>
|
||||
@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 class="mt-2">{{ __('If you did not receive the email') }}</p>
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@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-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
|
||||
</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
|
||||
</p>
|
||||
</div>
|
||||
@@ -18,8 +18,8 @@
|
||||
<!-- Results Summary -->
|
||||
@if($bookmarks->total() > 0)
|
||||
<div class="mb-6">
|
||||
<p class="text-sm text-gray-600">
|
||||
You have <span class="font-semibold text-gray-900">{{ $bookmarks->total() }}</span> bookmarked {{ Str::plural('product', $bookmarks->total()) }}
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
You have <span class="font-semibold text-gray-900 dark:text-white">{{ $bookmarks->total() }}</span> bookmarked {{ Str::plural('product', $bookmarks->total()) }}
|
||||
</p>
|
||||
</div>
|
||||
@endif
|
||||
@@ -32,11 +32,11 @@
|
||||
<!-- Empty State -->
|
||||
<div class="col-span-full">
|
||||
<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>
|
||||
</svg>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">No bookmarks yet</h3>
|
||||
<p class="text-gray-600 mb-6">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">No bookmarks yet</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-6">
|
||||
Start bookmarking products to save them for later!
|
||||
</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">
|
||||
|
||||
@@ -19,6 +19,10 @@
|
||||
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
|
||||
</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>
|
||||
|
||||
<!-- Search Bar (Desktop) -->
|
||||
@@ -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' }}">
|
||||
Discover
|
||||
</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
|
||||
@if(auth()->user()->hasRole('admin'))
|
||||
<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">
|
||||
<!-- 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)
|
||||
<img src="{{ $product->thumbnail_url }}"
|
||||
alt="{{ $product->title }}"
|
||||
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300">
|
||||
@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">
|
||||
<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>
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
<!-- Category Badge -->
|
||||
<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 }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -24,10 +24,10 @@
|
||||
|
||||
<!-- Content -->
|
||||
<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 }}
|
||||
</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 }}
|
||||
</p>
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
@csrf
|
||||
<button type="submit"
|
||||
@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">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7"></path>
|
||||
</svg>
|
||||
@@ -48,7 +48,7 @@
|
||||
</form>
|
||||
|
||||
<!-- 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">
|
||||
<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>
|
||||
@@ -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">
|
||||
{{ strtoupper(substr($product->user->name, 0, 1)) }}
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
<button @click="toggleBookmark()"
|
||||
:disabled="loading"
|
||||
: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"
|
||||
:class="{ 'animate-pulse': loading }"
|
||||
:fill="bookmarked ? 'currentColor' : 'none'"
|
||||
|
||||
@@ -7,16 +7,16 @@
|
||||
'period' => $p['period'] ?? 'monthly',
|
||||
'description' => $p['description'] ?? '',
|
||||
])->values()->all();
|
||||
$input = 'block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm';
|
||||
$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">Pricing plans</label>
|
||||
<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 rounded-md">
|
||||
<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">
|
||||
@@ -25,17 +25,17 @@
|
||||
@endforeach
|
||||
</select>
|
||||
<input type="text" :name="`plans[${i}][description]`" x-model="plan.description" placeholder="Description (optional)" maxlength="255" class="{{ $input }} sm:col-span-4">
|
||||
<button type="button" @click="plans.splice(i, 1)" class="sm:col-span-1 text-sm text-red-600 hover:text-red-800">Remove</button>
|
||||
<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">{{ $messages[0] }}</p>
|
||||
<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">{{ $message }}</p>
|
||||
<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 rounded-md text-sm text-gray-700 hover:bg-gray-50">Add plan</button>
|
||||
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>
|
||||
|
||||
|
||||
@@ -10,19 +10,19 @@
|
||||
'name' => $m['name'] ?? ('User #' . ($m['user_id'] ?? '')),
|
||||
'role' => $m['role'] ?? '',
|
||||
])->values()->all();
|
||||
$input = 'block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm';
|
||||
$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">Team members</label>
|
||||
<p class="mt-1 text-sm text-gray-500">Only the submitter or an admin can change the team.</p>
|
||||
<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">{{ $m['name'] }} <span class="text-gray-500">- {{ $m['role'] }}</span></li>
|
||||
<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">No team members.</li>
|
||||
<li class="text-sm text-gray-500 dark:text-gray-400">No team members.</li>
|
||||
@endforelse
|
||||
</ul>
|
||||
</div>
|
||||
@@ -46,31 +46,31 @@
|
||||
}
|
||||
}">
|
||||
<input type="hidden" name="members_present" value="1">
|
||||
<label class="block text-sm font-medium text-gray-700">Team members</label>
|
||||
<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 border border-gray-200 rounded-md shadow-lg">
|
||||
<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" x-text="u.name"></button></li>
|
||||
<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 rounded-md items-center">
|
||||
<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" x-text="member.name"></span>
|
||||
<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 hover:text-red-800">Remove</button>
|
||||
<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">{{ $messages[0] }}</p>
|
||||
<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">{{ $message }}</p>
|
||||
<p class="mt-2 text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@@ -48,8 +48,8 @@
|
||||
<button @click="toggleUpvote()"
|
||||
:disabled="loading"
|
||||
:class="{
|
||||
'bg-primary-100 border-primary-500 text-primary-700': upvoted,
|
||||
'bg-white border-gray-300 text-gray-700 hover:border-gray-400': !upvoted
|
||||
'bg-primary-100 dark:bg-primary-900 border-primary-500 text-primary-700 dark:text-primary-200': 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">
|
||||
<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>
|
||||
|
||||
<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-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"
|
||||
|
||||
@@ -35,7 +35,7 @@ aria-live="assertive">
|
||||
x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-start="transform translate-x-0 opacity-100"
|
||||
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="flex items-start gap-3">
|
||||
<div class="flex-shrink-0">
|
||||
@@ -65,12 +65,12 @@ aria-live="assertive">
|
||||
</template>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-gray-900" x-text="notice.title"></p>
|
||||
<p class="mt-1 text-sm text-gray-500 break-words" x-text="notice.message"></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 dark:text-gray-400 break-words" x-text="notice.message"></p>
|
||||
</div>
|
||||
<div class="flex-shrink-0">
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@@ -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
|
||||
@@ -3,19 +3,19 @@
|
||||
@section('content')
|
||||
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900">Submit a Product</h1>
|
||||
<p class="mt-2 text-sm text-gray-600">Share your EU-based tech product with the community</p>
|
||||
<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 dark:text-gray-400">Share your EU-based tech product with the community</p>
|
||||
</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">
|
||||
<form method="POST" action="{{ route('products.store') }}" enctype="multipart/form-data" class="space-y-6">
|
||||
@csrf
|
||||
|
||||
<!-- Title -->
|
||||
<div>
|
||||
<label for="title" class="block text-sm font-medium text-gray-700">
|
||||
Product Title <span class="text-red-600">*</span>
|
||||
<label for="title" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Product Title <span class="text-red-600 dark:text-red-400">*</span>
|
||||
</label>
|
||||
<div class="mt-1">
|
||||
<input type="text"
|
||||
@@ -25,18 +25,18 @@
|
||||
required
|
||||
maxlength="255"
|
||||
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')
|
||||
<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
|
||||
<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>
|
||||
|
||||
<!-- Description -->
|
||||
<div>
|
||||
<label for="description" class="block text-sm font-medium text-gray-700">
|
||||
Description <span class="text-red-600">*</span>
|
||||
<label for="description" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Description <span class="text-red-600 dark:text-red-400">*</span>
|
||||
</label>
|
||||
<div class="mt-1">
|
||||
<textarea id="description"
|
||||
@@ -44,18 +44,18 @@
|
||||
rows="5"
|
||||
required
|
||||
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')
|
||||
<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
|
||||
<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>
|
||||
|
||||
<!-- Website URL -->
|
||||
<div>
|
||||
<label for="website_url" class="block text-sm font-medium text-gray-700">
|
||||
Website URL <span class="text-red-600">*</span>
|
||||
<label for="website_url" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Website URL <span class="text-red-600 dark:text-red-400">*</span>
|
||||
</label>
|
||||
<div class="mt-1">
|
||||
<input type="url"
|
||||
@@ -64,24 +64,24 @@
|
||||
value="{{ old('website_url') }}"
|
||||
required
|
||||
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')
|
||||
<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
|
||||
<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>
|
||||
|
||||
<!-- Category -->
|
||||
<div>
|
||||
<label for="category_id" class="block text-sm font-medium text-gray-700">
|
||||
Category <span class="text-red-600">*</span>
|
||||
<label for="category_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Category <span class="text-red-600 dark:text-red-400">*</span>
|
||||
</label>
|
||||
<div class="mt-1">
|
||||
<select id="category_id"
|
||||
name="category_id"
|
||||
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>
|
||||
@foreach($categories as $category)
|
||||
<option value="{{ $category->id }}" {{ old('category_id') == $category->id ? 'selected' : '' }}>
|
||||
@@ -90,64 +90,64 @@
|
||||
@endforeach
|
||||
</select>
|
||||
@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
|
||||
<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>
|
||||
|
||||
<!-- Tags -->
|
||||
@if($tags->count() > 0)
|
||||
<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
|
||||
</label>
|
||||
<div class="mt-1 space-y-2">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
@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">
|
||||
<input type="checkbox"
|
||||
name="tags[]"
|
||||
value="{{ $tag->id }}"
|
||||
{{ 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 class="ml-3 flex-1">
|
||||
<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="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>
|
||||
@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
|
||||
</div>
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
@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
|
||||
<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>
|
||||
@endif
|
||||
|
||||
<!-- Badges -->
|
||||
<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">
|
||||
Badges
|
||||
</label>
|
||||
<div class="mt-1 grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
@foreach(\App\Models\Product::BADGES as $key => $label)
|
||||
<label class="flex items-center p-3 border border-gray-300 rounded-md">
|
||||
<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 border-gray-300 rounded">
|
||||
<span class="ml-2 text-sm text-gray-900">{{ $label }}</span>
|
||||
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>
|
||||
@@ -155,7 +155,7 @@
|
||||
|
||||
<!-- Thumbnail -->
|
||||
<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
|
||||
</label>
|
||||
<div class="mt-1">
|
||||
@@ -163,25 +163,25 @@
|
||||
id="thumbnail"
|
||||
name="thumbnail"
|
||||
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')
|
||||
<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
|
||||
<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>
|
||||
|
||||
<!-- Image Preview -->
|
||||
<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">
|
||||
<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>
|
||||
|
||||
<!-- Screenshots -->
|
||||
<div x-data="{ previews: [] }">
|
||||
<label for="screenshots" class="block text-sm font-medium text-gray-700">
|
||||
<label for="screenshots" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Screenshots
|
||||
</label>
|
||||
<div class="mt-1">
|
||||
@@ -191,17 +191,17 @@
|
||||
accept="image/*"
|
||||
multiple
|
||||
@change="previews = Array.from($event.target.files).map(f => URL.createObjectURL(f))"
|
||||
class="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-semibold file:bg-primary-50 file:text-primary-700 hover:file:bg-primary-100">
|
||||
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">{{ $message }}</p>
|
||||
<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">{{ $messages[0] }}</p>
|
||||
<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">Add several images (JPEG, PNG, or GIF, max 2MB each).</p>
|
||||
<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">
|
||||
<img :src="src" alt="" class="h-20 w-28 object-cover rounded border border-gray-300 dark:border-gray-600">
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
@@ -212,7 +212,7 @@
|
||||
<x-product.team-form />
|
||||
|
||||
<!-- 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-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">
|
||||
@@ -220,7 +220,7 @@
|
||||
</svg>
|
||||
</div>
|
||||
<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.
|
||||
</p>
|
||||
</div>
|
||||
@@ -228,9 +228,9 @@
|
||||
</div>
|
||||
|
||||
<!-- 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') }}"
|
||||
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
|
||||
</a>
|
||||
<button type="submit"
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
@section('content')
|
||||
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900">Edit Product</h1>
|
||||
<p class="mt-2 text-sm text-gray-600">Update your product information and details</p>
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Edit Product</h1>
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">Update your product information and details</p>
|
||||
</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">
|
||||
<form method="POST" action="{{ route('products.update', $product) }}" enctype="multipart/form-data" class="space-y-6">
|
||||
@csrf
|
||||
@@ -15,8 +15,8 @@
|
||||
|
||||
<!-- Title -->
|
||||
<div>
|
||||
<label for="title" class="block text-sm font-medium text-gray-700">
|
||||
Product Title <span class="text-red-600">*</span>
|
||||
<label for="title" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Product Title <span class="text-red-600 dark:text-red-400">*</span>
|
||||
</label>
|
||||
<div class="mt-1">
|
||||
<input type="text"
|
||||
@@ -26,18 +26,18 @@
|
||||
required
|
||||
maxlength="255"
|
||||
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')
|
||||
<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
|
||||
<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>
|
||||
|
||||
<!-- Description -->
|
||||
<div>
|
||||
<label for="description" class="block text-sm font-medium text-gray-700">
|
||||
Description <span class="text-red-600">*</span>
|
||||
<label for="description" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Description <span class="text-red-600 dark:text-red-400">*</span>
|
||||
</label>
|
||||
<div class="mt-1">
|
||||
<textarea id="description"
|
||||
@@ -45,18 +45,18 @@
|
||||
rows="5"
|
||||
required
|
||||
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')
|
||||
<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
|
||||
<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>
|
||||
|
||||
<!-- Website URL -->
|
||||
<div>
|
||||
<label for="website_url" class="block text-sm font-medium text-gray-700">
|
||||
Website URL <span class="text-red-600">*</span>
|
||||
<label for="website_url" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Website URL <span class="text-red-600 dark:text-red-400">*</span>
|
||||
</label>
|
||||
<div class="mt-1">
|
||||
<input type="url"
|
||||
@@ -65,24 +65,24 @@
|
||||
value="{{ old('website_url', $product->website_url) }}"
|
||||
required
|
||||
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')
|
||||
<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
|
||||
<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>
|
||||
|
||||
<!-- Category -->
|
||||
<div>
|
||||
<label for="category_id" class="block text-sm font-medium text-gray-700">
|
||||
Category <span class="text-red-600">*</span>
|
||||
<label for="category_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Category <span class="text-red-600 dark:text-red-400">*</span>
|
||||
</label>
|
||||
<div class="mt-1">
|
||||
<select id="category_id"
|
||||
name="category_id"
|
||||
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>
|
||||
@foreach($categories as $category)
|
||||
<option value="{{ $category->id }}" {{ old('category_id', $product->category_id) == $category->id ? 'selected' : '' }}>
|
||||
@@ -91,16 +91,16 @@
|
||||
@endforeach
|
||||
</select>
|
||||
@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
|
||||
<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>
|
||||
|
||||
<!-- Tags -->
|
||||
@if($tags->count() > 0)
|
||||
<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
|
||||
</label>
|
||||
<div class="mt-1 space-y-2">
|
||||
@@ -111,30 +111,30 @@
|
||||
? in_array($tag->id, old('tags'))
|
||||
: $product->tags->contains($tag->id);
|
||||
@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">
|
||||
<input type="checkbox"
|
||||
name="tags[]"
|
||||
value="{{ $tag->id }}"
|
||||
{{ $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 class="ml-3 flex-1">
|
||||
<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="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>
|
||||
@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
|
||||
</div>
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
@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
|
||||
<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>
|
||||
@endif
|
||||
@@ -145,43 +145,43 @@
|
||||
|
||||
<!-- Badges -->
|
||||
<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">
|
||||
Badges
|
||||
</label>
|
||||
<div class="mt-1 grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
@foreach(\App\Models\Product::BADGES as $key => $label)
|
||||
<label class="flex items-center p-3 border border-gray-300 rounded-md">
|
||||
<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 border-gray-300 rounded">
|
||||
<span class="ml-2 text-sm text-gray-900">{{ $label }}</span>
|
||||
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">Locked after approval</p>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Locked after approval</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<!-- Current Thumbnail -->
|
||||
@if($product->thumbnail_url)
|
||||
<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">
|
||||
<img src="{{ $product->thumbnail_url }}"
|
||||
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>
|
||||
@endif
|
||||
|
||||
<!-- New Thumbnail -->
|
||||
<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' }}
|
||||
</label>
|
||||
<div class="mt-1">
|
||||
@@ -189,32 +189,32 @@
|
||||
id="thumbnail"
|
||||
name="thumbnail"
|
||||
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')
|
||||
<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
|
||||
<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>
|
||||
|
||||
<!-- Image Preview -->
|
||||
<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">
|
||||
<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>
|
||||
|
||||
@if($product->screenshots->isNotEmpty())
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Current Screenshots:</label>
|
||||
<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">
|
||||
<span class="mt-1 flex items-center text-sm text-gray-700">
|
||||
<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 border-gray-300 rounded">
|
||||
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>
|
||||
@@ -225,7 +225,7 @@
|
||||
|
||||
<!-- Screenshots -->
|
||||
<div x-data="{ previews: [] }">
|
||||
<label for="screenshots" class="block text-sm font-medium text-gray-700">
|
||||
<label for="screenshots" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Screenshots
|
||||
</label>
|
||||
<div class="mt-1">
|
||||
@@ -235,17 +235,17 @@
|
||||
accept="image/*"
|
||||
multiple
|
||||
@change="previews = Array.from($event.target.files).map(f => URL.createObjectURL(f))"
|
||||
class="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-semibold file:bg-primary-50 file:text-primary-700 hover:file:bg-primary-100">
|
||||
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">{{ $message }}</p>
|
||||
<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">{{ $messages[0] }}</p>
|
||||
<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">Add several images (JPEG, PNG, or GIF, max 2MB each).</p>
|
||||
<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">
|
||||
<img :src="src" alt="" class="h-20 w-28 object-cover rounded border border-gray-300 dark:border-gray-600">
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
@@ -256,7 +256,7 @@
|
||||
<x-product.team-form :members="$product->members" :readonly="! (auth()->id() === $product->user_id || auth()->user()->isAdmin())" />
|
||||
|
||||
<!-- 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-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">
|
||||
@@ -264,7 +264,7 @@
|
||||
</svg>
|
||||
</div>
|
||||
<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.
|
||||
</p>
|
||||
</div>
|
||||
@@ -272,9 +272,9 @@
|
||||
</div>
|
||||
|
||||
<!-- 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) }}"
|
||||
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
|
||||
</a>
|
||||
<button type="submit"
|
||||
|
||||
@@ -6,13 +6,13 @@
|
||||
|
||||
@section('content')
|
||||
<!-- 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="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
|
||||
</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
|
||||
</p>
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
name="search"
|
||||
value="{{ request('search') }}"
|
||||
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"
|
||||
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">
|
||||
@@ -50,7 +50,7 @@
|
||||
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">
|
||||
<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>
|
||||
</svg>
|
||||
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">
|
||||
<!-- Search (hidden on desktop as it's in hero) -->
|
||||
<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"
|
||||
id="mobile-search"
|
||||
name="search"
|
||||
value="{{ request('search') }}"
|
||||
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>
|
||||
|
||||
<!-- Category Filter -->
|
||||
<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"
|
||||
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>
|
||||
@foreach($categories as $category)
|
||||
<option value="{{ $category->id }}" {{ request('category') == $category->id ? 'selected' : '' }}>
|
||||
@@ -92,10 +92,10 @@
|
||||
|
||||
<!-- Date Range Filter -->
|
||||
<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"
|
||||
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="today" {{ request('date_range') === 'today' ? 'selected' : '' }}>Today</option>
|
||||
<option value="week" {{ request('date_range') === 'week' ? 'selected' : '' }}>This Week</option>
|
||||
@@ -106,10 +106,10 @@
|
||||
|
||||
<!-- Sort By -->
|
||||
<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"
|
||||
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="popular" {{ request('sort') === 'popular' ? 'selected' : '' }}>Most Popular</option>
|
||||
<option value="oldest" {{ request('sort') === 'oldest' ? 'selected' : '' }}>Oldest</option>
|
||||
@@ -137,28 +137,28 @@
|
||||
|
||||
<!-- Active Filters Tags -->
|
||||
@if(request()->hasAny(['search', 'category', 'date_range', 'sort']))
|
||||
<div class="flex flex-wrap gap-2 pt-4 border-t border-gray-200">
|
||||
<span class="text-sm font-medium text-gray-700">Active filters:</span>
|
||||
<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 dark:text-gray-300">Active filters:</span>
|
||||
@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') }}"
|
||||
<a href="{{ route('products.index', request()->except('search')) }}" class="ml-2 hover:text-primary-900">×</a>
|
||||
</span>
|
||||
@endif
|
||||
@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' }}
|
||||
<a href="{{ route('products.index', request()->except('category')) }}" class="ml-2 hover:text-primary-900">×</a>
|
||||
</span>
|
||||
@endif
|
||||
@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')) }}
|
||||
<a href="{{ route('products.index', request()->except('date_range')) }}" class="ml-2 hover:text-primary-900">×</a>
|
||||
</span>
|
||||
@endif
|
||||
@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')) }}
|
||||
<a href="{{ route('products.index', request()->except('sort')) }}" class="ml-2 hover:text-primary-900">×</a>
|
||||
</span>
|
||||
@@ -172,10 +172,10 @@
|
||||
<!-- Results Summary -->
|
||||
@if($products->total() > 0)
|
||||
<div class="mb-6">
|
||||
<p class="text-sm text-gray-600">
|
||||
Showing <span class="font-semibold text-gray-900">{{ $products->firstItem() }}</span> to
|
||||
<span class="font-semibold text-gray-900">{{ $products->lastItem() }}</span> of
|
||||
<span class="font-semibold text-gray-900">{{ $products->total() }}</span> products
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Showing <span class="font-semibold text-gray-900 dark:text-white">{{ $products->firstItem() }}</span> to
|
||||
<span class="font-semibold text-gray-900 dark:text-white">{{ $products->lastItem() }}</span> of
|
||||
<span class="font-semibold text-gray-900 dark:text-white">{{ $products->total() }}</span> products
|
||||
</p>
|
||||
</div>
|
||||
@endif
|
||||
@@ -188,11 +188,11 @@
|
||||
<!-- Empty State -->
|
||||
<div class="col-span-full">
|
||||
<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>
|
||||
</svg>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">No products found</h3>
|
||||
<p class="text-gray-600 mb-6">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">No products found</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-6">
|
||||
@if(request()->hasAny(['search', 'category', 'date_range']))
|
||||
Try adjusting your search or filters to find what you're looking for.
|
||||
@else
|
||||
|
||||
@@ -4,17 +4,17 @@
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<!-- Header -->
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900">Product Management</h1>
|
||||
<p class="mt-2 text-sm text-gray-600">Review and manage submitted products</p>
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Product Management</h1>
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">Review and manage submitted products</p>
|
||||
</div>
|
||||
|
||||
<!-- 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">
|
||||
<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">
|
||||
<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">
|
||||
<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>
|
||||
@@ -25,7 +25,7 @@
|
||||
</a>
|
||||
|
||||
<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">
|
||||
<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>
|
||||
@@ -36,7 +36,7 @@
|
||||
</a>
|
||||
|
||||
<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">
|
||||
<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>
|
||||
@@ -53,7 +53,7 @@
|
||||
@if($products->count() > 0)
|
||||
<div class="space-y-4">
|
||||
@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="flex items-start gap-4">
|
||||
<!-- Thumbnail -->
|
||||
@@ -61,10 +61,10 @@
|
||||
@if($product->thumbnail_url)
|
||||
<img src="{{ $product->thumbnail_url }}"
|
||||
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
|
||||
<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">
|
||||
<svg class="h-8 w-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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 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>
|
||||
@@ -75,19 +75,19 @@
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-start justify-between">
|
||||
<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">
|
||||
{{ $product->title }}
|
||||
</a>
|
||||
</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">
|
||||
<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 }}
|
||||
</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">
|
||||
<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>
|
||||
@@ -96,30 +96,30 @@
|
||||
</a>
|
||||
</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">
|
||||
<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>
|
||||
{{ $product->created_at->format('M j, Y') }}
|
||||
</span>
|
||||
|
||||
<span class="text-gray-400">•</span>
|
||||
<span class="text-gray-500">{{ $product->created_at->diffForHumans() }}</span>
|
||||
<span class="text-gray-400 dark:text-gray-500">•</span>
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ $product->created_at->diffForHumans() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status Badge -->
|
||||
<div class="ml-4 flex-shrink-0">
|
||||
@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
|
||||
</span>
|
||||
@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
|
||||
</span>
|
||||
@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
|
||||
</span>
|
||||
@endif
|
||||
@@ -152,7 +152,7 @@
|
||||
</form>
|
||||
@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">
|
||||
<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>
|
||||
@@ -161,7 +161,7 @@
|
||||
</a>
|
||||
|
||||
@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">
|
||||
<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>
|
||||
@@ -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?');">
|
||||
@csrf
|
||||
@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">
|
||||
<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>
|
||||
@@ -196,12 +196,12 @@
|
||||
</div>
|
||||
@endif
|
||||
@else
|
||||
<div class="text-center py-12 bg-gray-50 rounded-lg border border-gray-200">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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 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>
|
||||
</svg>
|
||||
<h3 class="mt-2 text-sm font-medium text-gray-900">No products found</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">
|
||||
<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 dark:text-gray-400">
|
||||
No products with status: <strong>{{ request('status', 'pending') }}</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<!-- Header -->
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900">Edit Profile</h1>
|
||||
<p class="mt-2 text-sm text-gray-600">Update your profile information and social links</p>
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Edit Profile</h1>
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">Update your profile information and social links</p>
|
||||
</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">
|
||||
<form method="POST" action="{{ route('profile.update') }}" enctype="multipart/form-data" class="space-y-8">
|
||||
@csrf
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
<!-- Avatar Section -->
|
||||
<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-shrink-0">
|
||||
@if($user->avatar_url)
|
||||
@@ -34,17 +34,17 @@
|
||||
id="avatar"
|
||||
name="avatar"
|
||||
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')
|
||||
<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
|
||||
<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)
|
||||
<form method="POST" action="{{ route('profile.avatar.delete') }}" class="mt-3" onsubmit="return confirm('Are you sure you want to delete your avatar?')">
|
||||
@csrf
|
||||
@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
|
||||
</button>
|
||||
</form>
|
||||
@@ -54,13 +54,13 @@
|
||||
</div>
|
||||
|
||||
<!-- Basic Information -->
|
||||
<div class="border-t border-gray-200 pt-8">
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-4">Basic Information</h3>
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-8">
|
||||
<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">
|
||||
<!-- Name -->
|
||||
<div>
|
||||
<label for="name" class="block text-sm font-medium text-gray-700">
|
||||
Name <span class="text-red-600">*</span>
|
||||
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Name <span class="text-red-600 dark:text-red-400">*</span>
|
||||
</label>
|
||||
<div class="mt-1">
|
||||
<input type="text"
|
||||
@@ -68,17 +68,17 @@
|
||||
name="name"
|
||||
value="{{ old('name', $user->name) }}"
|
||||
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')
|
||||
<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
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Email -->
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium text-gray-700">
|
||||
Email <span class="text-red-600">*</span>
|
||||
<label for="email" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Email <span class="text-red-600 dark:text-red-400">*</span>
|
||||
</label>
|
||||
<div class="mt-1">
|
||||
<input type="email"
|
||||
@@ -86,9 +86,9 @@
|
||||
name="email"
|
||||
value="{{ old('email', $user->email) }}"
|
||||
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')
|
||||
<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
|
||||
</div>
|
||||
</div>
|
||||
@@ -96,7 +96,7 @@
|
||||
|
||||
<!-- Bio -->
|
||||
<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
|
||||
</label>
|
||||
<div class="mt-1">
|
||||
@@ -105,22 +105,22 @@
|
||||
rows="4"
|
||||
maxlength="1000"
|
||||
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')
|
||||
<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
|
||||
<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>
|
||||
|
||||
<!-- Website & Social Links -->
|
||||
<div class="border-t border-gray-200 pt-8">
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-4">Website & Social Links</h3>
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-8">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">Website & Social Links</h3>
|
||||
<div class="space-y-6">
|
||||
<!-- Website -->
|
||||
<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
|
||||
</label>
|
||||
<div class="mt-1">
|
||||
@@ -129,20 +129,20 @@
|
||||
name="website"
|
||||
value="{{ old('website', $user->website) }}"
|
||||
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')
|
||||
<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
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Twitter -->
|
||||
<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
|
||||
</label>
|
||||
<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>
|
||||
<input type="text"
|
||||
@@ -150,20 +150,20 @@
|
||||
name="twitter"
|
||||
value="{{ old('twitter', $user->twitter) }}"
|
||||
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>
|
||||
@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
|
||||
</div>
|
||||
|
||||
<!-- GitHub -->
|
||||
<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
|
||||
</label>
|
||||
<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/
|
||||
</span>
|
||||
<input type="text"
|
||||
@@ -171,20 +171,20 @@
|
||||
name="github"
|
||||
value="{{ old('github', $user->github) }}"
|
||||
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>
|
||||
@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
|
||||
</div>
|
||||
|
||||
<!-- LinkedIn -->
|
||||
<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
|
||||
</label>
|
||||
<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/
|
||||
</span>
|
||||
<input type="text"
|
||||
@@ -192,19 +192,19 @@
|
||||
name="linkedin"
|
||||
value="{{ old('linkedin', $user->linkedin) }}"
|
||||
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>
|
||||
@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
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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) }}"
|
||||
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
|
||||
</a>
|
||||
<button type="submit"
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@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-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
|
||||
</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
|
||||
</p>
|
||||
</div>
|
||||
@@ -18,8 +18,8 @@
|
||||
<!-- Results Summary -->
|
||||
@if($upvotes->total() > 0)
|
||||
<div class="mb-6">
|
||||
<p class="text-sm text-gray-600">
|
||||
You have upvoted <span class="font-semibold text-gray-900">{{ $upvotes->total() }}</span> {{ Str::plural('product', $upvotes->total()) }}
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
You have upvoted <span class="font-semibold text-gray-900 dark:text-white">{{ $upvotes->total() }}</span> {{ Str::plural('product', $upvotes->total()) }}
|
||||
</p>
|
||||
</div>
|
||||
@endif
|
||||
@@ -32,11 +32,11 @@
|
||||
<!-- Empty State -->
|
||||
<div class="col-span-full">
|
||||
<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>
|
||||
</svg>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">No upvotes yet</h3>
|
||||
<p class="text-gray-600 mb-6">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">No upvotes yet</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-6">
|
||||
Start upvoting products you love to show your support!
|
||||
</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">
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Http\Controllers\BookmarkController;
|
||||
use App\Http\Controllers\CategoryManagementController;
|
||||
use App\Http\Controllers\CommentController;
|
||||
use App\Http\Controllers\CommentModerationController;
|
||||
use App\Http\Controllers\LeaderboardController;
|
||||
use App\Http\Controllers\ProductController;
|
||||
use App\Http\Controllers\ProfileController;
|
||||
use App\Http\Controllers\TagController;
|
||||
@@ -34,6 +35,9 @@ Route::middleware(['auth'])->group(function () {
|
||||
|
||||
Route::get('/profile/{user}', [ProfileController::class, 'show'])->name('profile.show');
|
||||
|
||||
// Leaderboard
|
||||
Route::get('/leaderboard', [LeaderboardController::class, 'index'])->name('leaderboard');
|
||||
|
||||
// Product Routes
|
||||
Route::get('/products', [ProductController::class, 'index'])->name('products.index');
|
||||
Route::get('/products/{product}', [ProductController::class, 'show'])->name('products.show');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user