active('WaitlistPage')) { return view('waitlist.index'); } // Get trending products (most upvoted in the last 7 days) $trendingProducts = Product::query() ->with(['user', 'category']) ->approved() ->where('created_at', '>=', now()->subDays(7)) ->withCount('upvotes') ->orderBy('upvotes_count', 'desc') ->limit(6) ->get(); // Get recently added products $recentProducts = Product::query() ->with(['user', 'category']) ->approved() ->latest() ->limit(12) ->get(); // Get popular categories with product counts $categories = Category::query() ->withCount(['products' => function($query) { $query->approved(); }]) ->having('products_count', '>', 0) ->orderBy('products_count', 'desc') ->limit(8) ->get(); return view('welcome', compact('trendingProducts', 'recentProducts', 'categories')); } /** * Display a listing of the products. */ public function index(Request $request) { $validated = $request->validate([ 'search' => 'nullable|string|max:255', 'category' => 'nullable|integer|exists:categories,id', 'status' => 'nullable|string|in:pending,approved,rejected', 'date_range' => 'nullable|string|in:today,week,month,year', 'sort' => 'nullable|string|in:newest,oldest,popular', ]); // Build the query with all filters and search $query = Product::query() ->with(['user', 'category']) ->approved() // Only show approved products on the public index ->search($request->search) ->byCategory($request->category) ->dateRange($request->date_range) ->sortBy($request->sort); // Paginate results with query parameters preserved $products = $query->paginate(12)->appends($request->query()); // Get all categories for the filter dropdown $categories = Category::all(); return view('products.index', compact('products', 'categories')); } /** * Show the form for creating a new product. */ public function create() { $categories = Category::all(); $tags = Tag::all(); return view('products.create', compact('categories', 'tags')); } /** * Store a newly created product in storage. */ public function store(Request $request) { Gate::authorize('create-product'); $validated = $request->validate([ 'title' => 'required|string|max:255', 'description' => 'required|string', 'website_url' => 'required|url', 'thumbnail' => 'nullable|image|mimes:jpeg,png,jpg,gif|max:2048', 'category_id' => 'required|exists:categories,id', 'tags' => 'nullable|array', 'tags.*' => 'exists:tags,id', ] + $this->detailRules(Auth::id())); foreach (array_keys(Product::BADGES) as $badge) { $validated[$badge] = $request->boolean($badge); } $validated['user_id'] = Auth::id(); $validated['status'] = 'pending'; $created = []; $obsolete = []; try { $product = DB::transaction(function () use ($request, $validated, &$created, &$obsolete) { // Handle thumbnail upload if ($request->hasFile('thumbnail')) { $path = $request->file('thumbnail')->store('thumbnails', 'public'); $created[] = $path; $validated['thumbnail'] = $path; } $product = Product::create($validated); // Attach tags if provided if ($request->has('tags')) { $product->tags()->attach($request->tags); } $this->syncDetails($request, $product, true, $created, $obsolete); return $product; }); } catch (\Throwable $e) { Storage::disk('public')->delete($created); throw $e; } return redirect()->route('products.show', $product) ->with('success', 'Product submitted successfully! It will be reviewed by our moderators.'); } /** * Display the specified product. */ public function show(Product $product) { // Only show approved products to non-owners if (!$product->isApproved() && $product->user_id !== Auth::id() && !Auth::user()?->hasAnyRole(['admin', 'moderator'])) { abort(404); } $product->load(['user', 'category', 'tags', 'members', 'comments.user', 'upvotes', 'bookmarks']); return view('products.show', compact('product')); } /** * Show the form for editing the specified product. */ public function edit(Product $product) { Gate::authorize('edit-product', $product); $categories = Category::all(); $tags = Tag::all(); return view('products.edit', compact('product', 'categories', 'tags')); } /** * Update the specified product in storage. */ public function update(Request $request, Product $product) { Gate::authorize('edit-product', $product); $validated = $request->validate([ 'title' => 'required|string|max:255', 'description' => 'required|string', 'website_url' => 'required|url', 'thumbnail' => 'nullable|image|mimes:jpeg,png,jpg,gif|max:2048', 'category_id' => 'required|exists:categories,id', 'tags' => 'nullable|array', 'tags.*' => 'exists:tags,id', ] + $this->detailRules($product->user_id)); // Badges are locked for non-moderators once the product is approved if ($product->status !== 'approved' || Auth::user()->hasAnyRole(['moderator', 'admin'])) { foreach (array_keys(Product::BADGES) as $badge) { $validated[$badge] = $request->boolean($badge); } } else { $validated = array_diff_key($validated, Product::BADGES); } // Only the submitter and admins manage the team $manageMembers = Auth::id() === $product->user_id || Auth::user()->isAdmin(); $created = []; $obsolete = []; try { DB::transaction(function () use ($request, $product, $validated, $manageMembers, &$created, &$obsolete) { // Handle thumbnail upload if ($request->hasFile('thumbnail')) { if ($product->thumbnail) { $obsolete[] = $product->thumbnail; } $path = $request->file('thumbnail')->store('thumbnails', 'public'); $created[] = $path; $validated['thumbnail'] = $path; } $product->update($validated); $this->syncDetails($request, $product, $manageMembers, $created, $obsolete); // Sync tags (will remove old tags and add new ones) $product->tags()->sync($request->tags ?? []); }); } catch (\Throwable $e) { Storage::disk('public')->delete($created); throw $e; } // Old files are only removed once the changes are committed Storage::disk('public')->delete($obsolete); return redirect()->route('products.show', $product) ->with('success', 'Product updated successfully!'); } /** * Validation rules for the product detail fields. */ protected function detailRules(int $ownerId): array { return [ 'free_tier' => 'boolean', 'open_source' => 'boolean', 'eu_hosted' => 'boolean', 'eu_made' => 'boolean', 'screenshots' => 'nullable|array', 'screenshots.*' => 'image|mimes:jpeg,png,jpg,gif|max:2048', 'remove_screenshots' => 'nullable|array', 'remove_screenshots.*' => 'integer', 'plans' => 'nullable|array', 'plans.*.name' => 'required|string|max:100', 'plans.*.price' => 'required|numeric|min:0|max:99999|decimal:0,2', 'plans.*.period' => ['required', Rule::in(PricingPlan::PERIODS)], 'plans.*.description' => 'nullable|string|max:255', 'members' => 'nullable|array', 'members.*.user_id' => ['required', 'distinct', 'exists:users,id', Rule::notIn([$ownerId])], 'members.*.role' => 'required|string|max:100', ]; } /** * Sync related product detail data (badges are stored on the product itself). * Must run inside a transaction: newly stored files are added to $created, * files to remove after commit are added to $obsolete. */ protected function syncDetails(Request $request, Product $product, bool $manageMembers, array &$created, array &$obsolete): void { // The submitted plan list replaces all existing plans (a missing sentinel means the form could not send one) if ($request->has('plans_present') || $product->wasRecentlyCreated) { $product->pricingPlans()->delete(); foreach (array_values((array) $request->input('plans', [])) as $i => $plan) { $product->pricingPlans()->create([ 'name' => $plan['name'], 'price' => $plan['price'], 'period' => $plan['period'], 'description' => $plan['description'] ?? null, 'position' => $i, ]); } } // The submitted member list replaces all existing members if ($manageMembers && ($request->has('members_present') || $product->wasRecentlyCreated)) { $members = []; foreach ((array) $request->input('members', []) as $member) { $members[$member['user_id']] = ['role' => $member['role']]; } $product->members()->sync($members); } // Only rows belonging to this product may be removed $ids = array_filter((array) $request->input('remove_screenshots', [])); if ($ids) { $product->screenshots()->whereIn('id', $ids)->get()->each(function ($shot) use (&$obsolete) { $obsolete[] = $shot->path; $shot->delete(); }); } if ($request->hasFile('screenshots')) { $position = ($product->screenshots()->max('position') ?? -1) + 1; foreach ($request->file('screenshots') as $file) { $path = $file->store('screenshots', 'public'); $created[] = $path; $product->screenshots()->create([ 'path' => $path, 'position' => $position++, ]); } } } /** * Remove the specified product from storage. */ public function destroy(Product $product) { Gate::authorize('delete-product', $product); $files = $product->screenshots->pluck('path')->all(); if ($product->thumbnail) { $files[] = $product->thumbnail; } $product->delete(); // Files go only after the row is gone Storage::disk('public')->delete($files); return redirect()->route('products.index') ->with('success', 'Product deleted successfully!'); } /** * Show the product management dashboard for admin/moderator. */ public function manage(Request $request) { Gate::authorize('approve-product'); $query = Product::with(['user', 'category']); // Filter by status if ($request->has('status') && in_array($request->status, ['pending', 'approved', 'rejected'])) { $query->byStatus($request->status); } else { $query->pending(); // Default to pending } $products = $query->latest()->paginate(20); $statusCounts = [ 'pending' => Product::pending()->count(), 'approved' => Product::approved()->count(), 'rejected' => Product::rejected()->count(), ]; return view('products.manage', compact('products', 'statusCounts')); } /** * Approve a product. */ public function approve(Product $product) { Gate::authorize('approve-product'); $product->approve(); return back()->with('success', 'Product approved successfully!'); } /** * Reject a product. */ public function reject(Product $product) { Gate::authorize('reject-product'); $product->reject(); return back()->with('success', 'Product rejected.'); } }