- 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
435 lines
16 KiB
PHP
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'));
|
|
}
|
|
}
|