Files
hub/app/Http/Controllers/MarketplaceController.php
kelly 93678e59bc feat: implement buyer Deals page with active promotions
- Add deals() method to MarketplaceController
- Wire up existing deals.blade.php view with proper data
- Add promotions() relationship to Brand model
- Update buyer sidebar: rename 'Promotion' to 'Deals', link to route

The deals page shows:
- Stats: total deals, percentage off, BOGO, bundles
- Featured deal products grid
- Grouped sections by promo type (%, BOGO, bundle, price override)
- Brands with active deals for quick navigation
2025-12-18 08:15:34 -07:00

435 lines
16 KiB
PHP

<?php
namespace App\Http\Controllers;
use App\Models\Brand;
use App\Models\Product;
use App\Models\ProductCategory;
use App\Models\Strain;
use App\Services\RecentlyViewedService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class MarketplaceController extends Controller
{
public function __construct(
protected RecentlyViewedService $recentlyViewed
) {}
/**
* Display marketplace browse page (Amazon/Shopify style)
*/
public function index(Request $request)
{
$business = auth()->user()->businesses->first();
$hasFilters = $request->hasAny(['search', 'brand_id', 'strain_type', 'price_min', 'price_max', 'in_stock', 'category_id']);
// Start with active products only
$query = Product::query()
->with(['brand:id,name,slug,hashid,logo_path,updated_at', 'strain:id,name,type', 'category:id,name,slug'])
->active();
// Search filter (name, SKU, description)
if ($search = $request->input('search')) {
$query->where(function ($q) use ($search) {
$q->where('name', 'ilike', "%{$search}%")
->orWhere('sku', 'ilike', "%{$search}%")
->orWhere('description', 'ilike', "%{$search}%");
});
}
// Brand filter (supports multiple)
if ($brandIds = $request->input('brand_id')) {
$brandIds = is_array($brandIds) ? $brandIds : [$brandIds];
$query->whereIn('brand_id', $brandIds);
}
// Category filter (uses category_id foreign key)
if ($categoryId = $request->input('category_id')) {
$query->where('category_id', $categoryId);
}
// Strain type filter - use join instead of whereHas for performance
if ($strainType = $request->input('strain_type')) {
$query->whereExists(function ($q) use ($strainType) {
$q->select(DB::raw(1))
->from('strains')
->whereColumn('strains.id', 'products.strain_id')
->where('strains.type', $strainType);
});
}
// Price range filter
if ($priceMin = $request->input('price_min')) {
$query->where('wholesale_price', '>=', $priceMin);
}
if ($priceMax = $request->input('price_max')) {
$query->where('wholesale_price', '<=', $priceMax);
}
// In stock filter
if ($request->input('in_stock')) {
$query->where('quantity_on_hand', '>', 0);
}
// Sorting
$sort = $request->input('sort', 'newest');
match ($sort) {
'name_asc' => $query->orderBy('name', 'asc'),
'name_desc' => $query->orderBy('name', 'desc'),
'price_asc' => $query->orderBy('wholesale_price', 'asc'),
'price_desc' => $query->orderBy('wholesale_price', 'desc'),
'newest' => $query->latest(),
default => $query->latest(),
};
// View mode (grid/list)
$viewMode = $request->input('view', 'grid');
// Paginate results
$perPage = $viewMode === 'list' ? 10 : 12;
$products = $query->paginate($perPage)->withQueryString();
// Cache brands and categories for 5 minutes (used frequently, rarely change)
$brands = cache()->remember('marketplace:brands', 300, function () {
return Brand::query()
->active()
->whereExists(function ($q) {
$q->select(DB::raw(1))
->from('products')
->whereColumn('products.brand_id', 'brands.id')
->where('products.is_active', true);
})
->withCount(['products' => fn ($q) => $q->active()])
->orderBy('name')
->get();
});
// Cache categories for 5 minutes
$categories = cache()->remember('marketplace:categories', 300, function () {
return ProductCategory::query()
->whereNull('parent_id')
->where('is_active', true)
->whereExists(function ($q) {
$q->select(DB::raw(1))
->from('products')
->whereColumn('products.category_id', 'product_categories.id')
->where('products.is_active', true);
})
->withCount(['products' => fn ($q) => $q->active()])
->orderByDesc('products_count')
->get();
});
// Only load extra sections if not filtering (homepage view)
$featuredProducts = collect();
$topBrands = collect();
$newArrivals = collect();
$trending = collect();
$recentlyViewed = collect();
if (! $hasFilters) {
// Featured products for hero carousel
$featuredProducts = Product::query()
->with(['brand:id,name,slug,hashid,logo_path,updated_at', 'strain:id,name,type'])
->featured()
->inStock()
->limit(5)
->get();
// Top brands - reuse cached brands
$topBrands = $brands->sortByDesc('products_count')->take(6);
// New arrivals (products created in last 14 days)
$newArrivals = Product::query()
->with(['brand:id,name,slug,hashid,logo_path,updated_at', 'strain:id,name,type'])
->active()
->inStock()
->where('created_at', '>=', now()->subDays(14))
->orderByDesc('created_at')
->limit(8)
->get();
// Trending products - cache for 10 minutes
$trending = cache()->remember('marketplace:trending', 600, function () {
$trendingIds = DB::table('order_items')
->select('product_id', DB::raw('SUM(quantity) as total_sold'))
->where('created_at', '>=', now()->subDays(30))
->groupBy('product_id')
->orderByDesc('total_sold')
->limit(8)
->pluck('product_id');
if ($trendingIds->isEmpty()) {
return collect();
}
return Product::with(['brand:id,name,slug,hashid,logo_path,updated_at', 'strain:id,name,type'])
->whereIn('id', $trendingIds)
->active()
->get()
->sortBy(fn ($p) => array_search($p->id, $trendingIds->toArray()));
});
// Recently viewed products
$recentlyViewed = $this->recentlyViewed->getProducts(6);
}
// Active filters for pills display
$activeFilters = collect([
'search' => $request->input('search'),
'brand_id' => $request->input('brand_id'),
'category_id' => $request->input('category_id'),
'strain_type' => $request->input('strain_type'),
'in_stock' => $request->input('in_stock'),
])->filter();
return view('buyer.marketplace.index', compact(
'products',
'brands',
'categories',
'featuredProducts',
'topBrands',
'newArrivals',
'trending',
'recentlyViewed',
'business',
'viewMode',
'activeFilters',
'hasFilters'
));
}
/**
* Display all products (redirects to main browse page)
*/
public function products()
{
return redirect()->route('buyer.browse');
}
/**
* Display all brands directory
*/
public function brands(Request $request)
{
$search = $request->input('search');
$sort = $request->input('sort', 'name');
// Only cache if no search (search results shouldn't be cached)
$cacheKey = $search ? null : "marketplace:brands_directory:{$sort}";
$brands = $cacheKey
? cache()->remember($cacheKey, 300, fn () => $this->getBrandsQuery($search, $sort))
: $this->getBrandsQuery($search, $sort);
// Group brands alphabetically for index navigation
$alphabetGroups = $brands->groupBy(fn ($b) => strtoupper(substr($b->name, 0, 1)));
// Featured brands (first 4 with most products)
$featuredBrands = $brands->sortByDesc('products_count')->take(4);
$business = auth()->user()->businesses->first();
return view('buyer.marketplace.brands', compact('brands', 'alphabetGroups', 'featuredBrands', 'business'));
}
/**
* Helper to build brands query for directory
*/
private function getBrandsQuery(?string $search, string $sort)
{
$query = Brand::query()
->select(['id', 'name', 'slug', 'hashid', 'tagline', 'logo_path', 'updated_at'])
->active()
// Filter to only brands with active products using EXISTS (faster than having())
->whereExists(function ($q) {
$q->select(DB::raw(1))
->from('products')
->whereColumn('products.brand_id', 'brands.id')
->where('products.is_active', true);
})
->withCount(['products' => fn ($q) => $q->active()]);
// Search filter
if ($search) {
$query->where(function ($q) use ($search) {
$q->where('name', 'ILIKE', "%{$search}%")
->orWhere('tagline', 'ILIKE', "%{$search}%")
->orWhere('description', 'ILIKE', "%{$search}%");
});
}
// Sorting
match ($sort) {
'name_desc' => $query->orderByDesc('name'),
'products' => $query->orderByDesc('products_count'),
'newest' => $query->orderByDesc('created_at'),
default => $query->orderBy('name'),
};
return $query->get();
}
/**
* Display products in specific category
*/
public function category($category)
{
$business = auth()->user()->businesses->first();
return view('buyer.marketplace.category', compact('category', 'business'));
}
/**
* Show individual product (nested under brand)
*/
public function showProduct($brandSlug, $productSlug)
{
// Find brand by slug - minimal columns
$brand = Brand::query()
->select(['id', 'name', 'slug', 'hashid', 'logo_path', 'banner_path', 'tagline', 'description', 'updated_at'])
->where('slug', $brandSlug)
->active()
->firstOrFail();
// Find product by hashid, slug, or numeric ID within this brand
$product = Product::query()
->with([
'brand:id,name,slug,hashid,logo_path,updated_at',
'strain:id,name,type',
// Only load batches if needed - limit to recent ones
'availableBatches' => function ($query) {
$query->select(['id', 'product_id', 'batch_number', 'production_date', 'quantity_available'])
->with(['coaFiles:id,batch_id,file_path,file_name'])
->orderByDesc('production_date')
->limit(5);
},
])
->where('brand_id', $brand->id)
->where(function ($query) use ($productSlug) {
$query->where('hashid', $productSlug)
->orWhere('slug', $productSlug);
if (is_numeric($productSlug)) {
$query->orWhere('id', $productSlug);
}
})
->active()
->firstOrFail();
// Record this view for recently viewed products (async-friendly)
$this->recentlyViewed->recordView($product->id);
// Get related products from same brand - minimal eager loading
$relatedProducts = Product::query()
->with(['brand:id,name,slug,hashid,logo_path,updated_at', 'strain:id,name,type'])
->where('brand_id', $product->brand_id)
->where('id', '!=', $product->id)
->active()
->inStock()
->limit(4)
->get();
// Get recently viewed products (excluding current product)
$recentlyViewed = $this->recentlyViewed->getProducts(6, $product->id);
$business = auth()->user()->businesses->first();
return view('buyer.marketplace.product', compact('product', 'relatedProducts', 'recentlyViewed', 'brand', 'business'));
}
/**
* Display deals/promotions page for buyers
*/
public function deals()
{
// Get all active promotions with their brands and products
$activePromos = \App\Models\Promotion::query()
->with([
'brand:id,name,slug,hashid,logo_path,updated_at',
'products' => fn ($q) => $q->with(['brand:id,name,slug,hashid,logo_path,updated_at'])->active()->inStock(),
])
->active()
->orderByDesc('discount_value')
->get();
// Group by type for display sections
$percentageDeals = $activePromos->where('type', 'percentage');
$bogoDeals = $activePromos->where('type', 'bogo');
$fixedDeals = $activePromos->where('type', 'bundle');
$priceOverrides = $activePromos->where('type', 'price_override');
// Get all products that are on any active promotion
$dealProducts = Product::query()
->with(['brand:id,name,slug,hashid,logo_path,updated_at', 'strain:id,name,type'])
->whereHas('promotions', fn ($q) => $q->active())
->active()
->inStock()
->limit(16)
->get();
// Get brands with active deals
$brandsWithDeals = Brand::query()
->select(['id', 'name', 'slug', 'hashid', 'logo_path', 'updated_at'])
->whereHas('promotions', fn ($q) => $q->active())
->orderBy('name')
->get();
// Stats for the header
$stats = [
'total_deals' => $activePromos->count(),
'percentage_deals' => $percentageDeals->count(),
'bogo_deals' => $bogoDeals->count(),
'bundle_deals' => $fixedDeals->count() + $priceOverrides->count(),
];
return view('buyer.marketplace.deals', compact(
'activePromos',
'dealProducts',
'percentageDeals',
'bogoDeals',
'fixedDeals',
'priceOverrides',
'brandsWithDeals',
'stats'
));
}
/**
* Show individual brand storefront
*/
public function showBrand($brandSlug)
{
// Find brand by slug with minimal columns
$brand = Brand::query()
->where('slug', $brandSlug)
->active()
->firstOrFail();
// Optimized: Use simple inStock scope instead of expensive whereHas on batches
// The inStock scope should check inventory_mode or quantity_on_hand
$featuredProducts = Product::query()
->with(['strain:id,name,type', 'brand:id,name,slug,hashid,logo_path,updated_at'])
->where('brand_id', $brand->id)
->active()
->featured()
->inStock()
->limit(3)
->get();
// Get products - use simpler inStock check
$products = Product::query()
->with(['strain:id,name,type', 'brand:id,name,slug,hashid,logo_path,updated_at'])
->where('brand_id', $brand->id)
->active()
->inStock()
->orderByDesc('is_featured')
->orderBy('name')
->paginate(20);
$business = auth()->user()->businesses->first();
return view('buyer.marketplace.brand', compact('brand', 'featuredProducts', 'products', 'business'));
}
}