Compare commits
23 Commits
develop
...
feat/marke
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93678e59bc | ||
|
|
14c30dad03 | ||
|
|
08d49b9b67 | ||
|
|
036ae5c6f6 | ||
|
|
4c45805390 | ||
|
|
fc943afb36 | ||
|
|
25ae50dcd6 | ||
|
|
d1422efe87 | ||
|
|
45da5d075d | ||
|
|
bdc54da4ad | ||
|
|
cb6dc5e433 | ||
|
|
3add610e85 | ||
|
|
183a22c475 | ||
|
|
f2297d62f2 | ||
|
|
6b994147c3 | ||
|
|
a6d9e203c2 | ||
|
|
f652c19b24 | ||
|
|
76ce86fb41 | ||
|
|
5a22f7dbb6 | ||
|
|
5b8809b962 | ||
|
|
cdf982ed39 | ||
|
|
aac83a084c | ||
|
|
5f9613290d |
@@ -7,15 +7,22 @@ use App\Enums\BannerAdZone;
|
||||
use App\Filament\Resources\BannerAdResource\Pages;
|
||||
use App\Models\BannerAd;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Actions\ForceDeleteBulkAction;
|
||||
use Filament\Actions\RestoreBulkAction;
|
||||
use Filament\Actions\ViewAction;
|
||||
use Filament\Forms\Components\DateTimePicker;
|
||||
use Filament\Forms\Components\FileUpload;
|
||||
use Filament\Forms\Components\Placeholder;
|
||||
use Filament\Forms\Components\Section;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables;
|
||||
@@ -36,12 +43,31 @@ class BannerAdResource extends Resource
|
||||
|
||||
protected static ?string $navigationLabel = 'Banner Ads';
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
// Hide this resource if the banner_ads table doesn't exist yet
|
||||
if (! \Illuminate\Support\Facades\Schema::hasTable('banner_ads')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return parent::canAccess();
|
||||
}
|
||||
|
||||
public static function getNavigationBadge(): ?string
|
||||
{
|
||||
return cache()->remember('banner_ad_active_count', 60, function () {
|
||||
$count = static::getModel()::where('status', BannerAdStatus::ACTIVE)->count();
|
||||
// Handle case where migrations haven't been run yet
|
||||
if (! \Illuminate\Support\Facades\Schema::hasTable('banner_ads')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $count ?: null;
|
||||
try {
|
||||
$count = static::getModel()::where('status', BannerAdStatus::ACTIVE)->count();
|
||||
|
||||
return $count ?: null;
|
||||
} catch (\Exception $e) {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -255,14 +281,14 @@ class BannerAdResource extends Resource
|
||||
Tables\Filters\TrashedFilter::make(),
|
||||
])
|
||||
->actions([
|
||||
Tables\Actions\ViewAction::make(),
|
||||
Tables\Actions\EditAction::make(),
|
||||
ViewAction::make(),
|
||||
EditAction::make(),
|
||||
])
|
||||
->bulkActions([
|
||||
Tables\Actions\BulkActionGroup::make([
|
||||
Tables\Actions\DeleteBulkAction::make(),
|
||||
Tables\Actions\ForceDeleteBulkAction::make(),
|
||||
Tables\Actions\RestoreBulkAction::make(),
|
||||
BulkActionGroup::make([
|
||||
DeleteBulkAction::make(),
|
||||
ForceDeleteBulkAction::make(),
|
||||
RestoreBulkAction::make(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
101
app/Http/Controllers/Buyer/BuyAgainController.php
Normal file
101
app/Http/Controllers/Buyer/BuyAgainController.php
Normal file
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Buyer;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Brand;
|
||||
use App\Models\Business;
|
||||
use App\Models\Buyer\BuyerBrandFollow;
|
||||
use App\Models\OrderItem;
|
||||
use App\Services\Cannaiq\MarketingIntelligenceService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class BuyAgainController extends Controller
|
||||
{
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
$tab = $request->get('tab', 'favorites'); // 'favorites' or 'history'
|
||||
|
||||
if ($tab === 'favorites') {
|
||||
$brands = $this->getFavoriteBrands($business);
|
||||
} else {
|
||||
$brands = $this->getPurchaseHistory($business);
|
||||
}
|
||||
|
||||
// Optional: Enrich with CannaIQ inventory data if business has it
|
||||
$storeMetrics = null;
|
||||
if ($business->cannaiq_store_id) {
|
||||
$storeMetrics = $this->getStoreInventory($business, $brands);
|
||||
}
|
||||
|
||||
return view('buyer.buy-again.index', compact('business', 'brands', 'tab', 'storeMetrics'));
|
||||
}
|
||||
|
||||
private function getFavoriteBrands(Business $business)
|
||||
{
|
||||
// Get brands the buyer follows
|
||||
$followedBrandIds = BuyerBrandFollow::where('business_id', $business->id)
|
||||
->pluck('brand_id');
|
||||
|
||||
if ($followedBrandIds->isEmpty()) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
// Get products from those brands that user has ordered
|
||||
return Brand::whereIn('id', $followedBrandIds)
|
||||
->with(['products' => function ($query) use ($business) {
|
||||
$query->whereHas('orderItems.order', function ($q) use ($business) {
|
||||
$q->where('business_id', $business->id);
|
||||
})
|
||||
->with(['orderItems' => function ($q) use ($business) {
|
||||
$q->whereHas('order', fn ($o) => $o->where('business_id', $business->id))
|
||||
->latest()
|
||||
->limit(1);
|
||||
}])
|
||||
->where('is_active', true);
|
||||
}])
|
||||
->get()
|
||||
->filter(fn ($brand) => $brand->products->isNotEmpty());
|
||||
}
|
||||
|
||||
private function getPurchaseHistory(Business $business)
|
||||
{
|
||||
// Get all products ever ordered, grouped by brand
|
||||
$orderedProductIds = OrderItem::whereHas('order', function ($q) use ($business) {
|
||||
$q->where('business_id', $business->id);
|
||||
})->distinct()->pluck('product_id');
|
||||
|
||||
if ($orderedProductIds->isEmpty()) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
return Brand::whereHas('products', fn ($q) => $q->whereIn('id', $orderedProductIds))
|
||||
->with(['products' => function ($query) use ($orderedProductIds, $business) {
|
||||
$query->whereIn('id', $orderedProductIds)
|
||||
->with(['orderItems' => function ($q) use ($business) {
|
||||
$q->whereHas('order', fn ($o) => $o->where('business_id', $business->id))
|
||||
->latest()
|
||||
->limit(1);
|
||||
}]);
|
||||
}])
|
||||
->get();
|
||||
}
|
||||
|
||||
private function getStoreInventory(Business $business, $brands)
|
||||
{
|
||||
if ($brands->isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$productIds = $brands->flatMap(fn ($b) => $b->products->pluck('id'));
|
||||
|
||||
try {
|
||||
$cannaiq = app(MarketingIntelligenceService::class);
|
||||
|
||||
return $cannaiq->getStoreMetrics($business->cannaiq_store_id, $productIds->toArray());
|
||||
} catch (\Exception $e) {
|
||||
// Silently fail if CannaIQ unavailable
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
90
app/Http/Controllers/Buyer/CompareController.php
Normal file
90
app/Http/Controllers/Buyer/CompareController.php
Normal file
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Buyer;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Product;
|
||||
use App\Services\ProductComparisonService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class CompareController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected ProductComparisonService $comparison
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Show the comparison page.
|
||||
*/
|
||||
public function index(): View
|
||||
{
|
||||
$products = $this->comparison->getProducts();
|
||||
$business = auth()->user()->businesses->first();
|
||||
|
||||
return view('buyer.compare.index', compact('products', 'business'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current comparison state (AJAX).
|
||||
*/
|
||||
public function state(): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'ids' => $this->comparison->getProductIds(),
|
||||
'count' => $this->comparison->count(),
|
||||
'is_full' => $this->comparison->isFull(),
|
||||
'max' => $this->comparison->maxItems(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle a product in the comparison list (AJAX).
|
||||
*/
|
||||
public function toggle(Product $product): JsonResponse
|
||||
{
|
||||
if (! $product->is_active) {
|
||||
return response()->json(['error' => 'Product not found'], 404);
|
||||
}
|
||||
|
||||
$result = $this->comparison->toggle($product->id);
|
||||
|
||||
return response()->json([
|
||||
'added' => $result['added'],
|
||||
'count' => $result['count'],
|
||||
'is_full' => $this->comparison->isFull(),
|
||||
'message' => $result['added']
|
||||
? 'Added to comparison'
|
||||
: 'Removed from comparison',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a product from comparison list (AJAX).
|
||||
*/
|
||||
public function remove(Product $product): JsonResponse
|
||||
{
|
||||
$this->comparison->remove($product->id);
|
||||
|
||||
return response()->json([
|
||||
'count' => $this->comparison->count(),
|
||||
'is_full' => $this->comparison->isFull(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the comparison list.
|
||||
*/
|
||||
public function clear(): JsonResponse
|
||||
{
|
||||
$this->comparison->clear();
|
||||
|
||||
return response()->json([
|
||||
'count' => 0,
|
||||
'is_full' => false,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Http\Controllers\Buyer\Crm;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use App\Models\Buyer\BuyerMessageSettings;
|
||||
use App\Models\Crm\CrmThread;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -10,9 +11,8 @@ use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class InboxController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
$business = Auth::user()->business;
|
||||
$user = Auth::user();
|
||||
|
||||
$filter = $request->get('filter', 'all');
|
||||
@@ -20,7 +20,7 @@ class InboxController extends Controller
|
||||
|
||||
$query = CrmThread::forBuyerBusiness($business->id)
|
||||
->with(['brand', 'latestMessage', 'messages' => fn ($q) => $q->latest()->limit(1)])
|
||||
->withCount(['messages', 'unreadMessages as unread_count' => fn ($q) => $q->unreadForBuyer()]);
|
||||
->withCount('messages');
|
||||
|
||||
// Apply filters
|
||||
$query = match ($filter) {
|
||||
@@ -54,6 +54,7 @@ class InboxController extends Controller
|
||||
];
|
||||
|
||||
return view('buyer.crm.inbox.index', compact(
|
||||
'business',
|
||||
'threads',
|
||||
'filter',
|
||||
'search',
|
||||
@@ -62,9 +63,8 @@ class InboxController extends Controller
|
||||
));
|
||||
}
|
||||
|
||||
public function show(CrmThread $thread)
|
||||
public function show(Business $business, CrmThread $thread)
|
||||
{
|
||||
$business = Auth::user()->business;
|
||||
|
||||
// Verify thread belongs to this buyer
|
||||
if ($thread->buyer_business_id !== $business->id) {
|
||||
@@ -84,9 +84,8 @@ class InboxController extends Controller
|
||||
return view('buyer.crm.inbox.show', compact('thread'));
|
||||
}
|
||||
|
||||
public function compose(Request $request)
|
||||
public function compose(Request $request, Business $business)
|
||||
{
|
||||
$business = Auth::user()->business;
|
||||
|
||||
// Get brands the buyer has ordered from or can message
|
||||
$brands = \App\Models\Brand::whereHas('products.orderItems.order', function ($q) use ($business) {
|
||||
@@ -107,7 +106,7 @@ class InboxController extends Controller
|
||||
));
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
public function store(Request $request, Business $business)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'brand_id' => 'required|exists:brands,id',
|
||||
@@ -117,7 +116,6 @@ class InboxController extends Controller
|
||||
'quote_id' => 'nullable|exists:crm_quotes,id',
|
||||
]);
|
||||
|
||||
$business = Auth::user()->business;
|
||||
$user = Auth::user();
|
||||
|
||||
// Create thread
|
||||
@@ -143,9 +141,8 @@ class InboxController extends Controller
|
||||
->with('success', 'Message sent successfully.');
|
||||
}
|
||||
|
||||
public function star(CrmThread $thread)
|
||||
public function star(Business $business, CrmThread $thread)
|
||||
{
|
||||
$business = Auth::user()->business;
|
||||
$user = Auth::user();
|
||||
|
||||
if ($thread->buyer_business_id !== $business->id) {
|
||||
@@ -157,9 +154,8 @@ class InboxController extends Controller
|
||||
return back()->with('success', $thread->isStarredByBuyer($user->id) ? 'Conversation starred.' : 'Star removed.');
|
||||
}
|
||||
|
||||
public function archive(CrmThread $thread)
|
||||
public function archive(Business $business, CrmThread $thread)
|
||||
{
|
||||
$business = Auth::user()->business;
|
||||
$user = Auth::user();
|
||||
|
||||
if ($thread->buyer_business_id !== $business->id) {
|
||||
@@ -172,9 +168,8 @@ class InboxController extends Controller
|
||||
->with('success', 'Conversation archived.');
|
||||
}
|
||||
|
||||
public function unarchive(CrmThread $thread)
|
||||
public function unarchive(Business $business, CrmThread $thread)
|
||||
{
|
||||
$business = Auth::user()->business;
|
||||
$user = Auth::user();
|
||||
|
||||
if ($thread->buyer_business_id !== $business->id) {
|
||||
@@ -186,9 +181,8 @@ class InboxController extends Controller
|
||||
return back()->with('success', 'Conversation restored.');
|
||||
}
|
||||
|
||||
public function markAllRead()
|
||||
public function markAllRead(Business $business)
|
||||
{
|
||||
$business = Auth::user()->business;
|
||||
|
||||
CrmThread::forBuyerBusiness($business->id)
|
||||
->hasUnreadForBuyer()
|
||||
|
||||
45
app/Http/Controllers/Buyer/ProductController.php
Normal file
45
app/Http/Controllers/Buyer/ProductController.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Buyer;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Product;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class ProductController extends Controller
|
||||
{
|
||||
/**
|
||||
* Get quick view data for a product (AJAX endpoint).
|
||||
*/
|
||||
public function quickView(Product $product): JsonResponse
|
||||
{
|
||||
// Only return active products
|
||||
if (! $product->is_active) {
|
||||
return response()->json(['error' => 'Product not found'], 404);
|
||||
}
|
||||
|
||||
// Get the product's brand
|
||||
$product->load('brand:id,name,slug');
|
||||
|
||||
return response()->json([
|
||||
'id' => $product->id,
|
||||
'hashid' => $product->hashid,
|
||||
'name' => $product->name,
|
||||
'sku' => $product->sku,
|
||||
'description' => $product->short_description ?? $product->description,
|
||||
'price' => $product->wholesale_price ?? 0,
|
||||
'price_unit' => $product->price_unit,
|
||||
'thc_percentage' => $product->thc_percentage,
|
||||
'cbd_percentage' => $product->cbd_percentage,
|
||||
'in_stock' => $product->isInStock(),
|
||||
'available_quantity' => $product->quantity_on_hand,
|
||||
'image_url' => $product->getImageUrl('medium'),
|
||||
'brand_name' => $product->brand?->name,
|
||||
'brand_slug' => $product->brand?->slug,
|
||||
'brand_url' => $product->brand ? route('buyer.brands.show', $product->brand->slug) : null,
|
||||
'url' => $product->brand ? route('buyer.brands.products.show', [$product->brand->slug, $product->hashid]) : null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
129
app/Http/Controllers/Buyer/SearchController.php
Normal file
129
app/Http/Controllers/Buyer/SearchController.php
Normal file
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Buyer;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Brand;
|
||||
use App\Models\Product;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* Buyer Search Controller
|
||||
*
|
||||
* Provides search autocomplete endpoints for the marketplace header.
|
||||
*/
|
||||
class SearchController extends Controller
|
||||
{
|
||||
/**
|
||||
* Search autocomplete for products and brands.
|
||||
*
|
||||
* GET /b/search/autocomplete?q=...
|
||||
*
|
||||
* Returns products and brands matching the query for dropdown suggestions.
|
||||
*/
|
||||
public function autocomplete(Request $request): JsonResponse
|
||||
{
|
||||
$query = trim($request->input('q', ''));
|
||||
|
||||
if (strlen($query) < 2) {
|
||||
return response()->json(['products' => [], 'brands' => []]);
|
||||
}
|
||||
|
||||
// Search products (limit 8)
|
||||
$products = Product::query()
|
||||
->where('is_active', true)
|
||||
->whereHas('brand', fn ($q) => $q->where('is_active', true))
|
||||
->with('brand:id,name,slug')
|
||||
->where(function ($q) use ($query) {
|
||||
$q->where('name', 'ILIKE', "%{$query}%")
|
||||
->orWhere('sku', 'ILIKE', "%{$query}%")
|
||||
->orWhereHas('brand', fn ($b) => $b->where('name', 'ILIKE', "%{$query}%"));
|
||||
})
|
||||
->orderByRaw("CASE WHEN name ILIKE ? THEN 0 ELSE 1 END", ["{$query}%"])
|
||||
->orderBy('name')
|
||||
->limit(8)
|
||||
->get(['id', 'brand_id', 'name', 'sku', 'wholesale_price', 'image_path']);
|
||||
|
||||
// Search brands (limit 4)
|
||||
$brands = Brand::query()
|
||||
->where('is_active', true)
|
||||
->where(function ($q) use ($query) {
|
||||
$q->where('name', 'ILIKE', "%{$query}%")
|
||||
->orWhere('description', 'ILIKE', "%{$query}%");
|
||||
})
|
||||
->withCount('products')
|
||||
->orderByRaw("CASE WHEN name ILIKE ? THEN 0 ELSE 1 END", ["{$query}%"])
|
||||
->orderBy('name')
|
||||
->limit(4)
|
||||
->get(['id', 'name', 'slug', 'logo_path']);
|
||||
|
||||
return response()->json([
|
||||
'products' => $products->map(fn ($p) => [
|
||||
'id' => $p->id,
|
||||
'hashid' => $p->hashid,
|
||||
'name' => $p->name,
|
||||
'sku' => $p->sku,
|
||||
'price' => $p->wholesale_price ?? 0,
|
||||
'image_url' => $p->getImageUrl('thumb'),
|
||||
'brand_name' => $p->brand?->name,
|
||||
'brand_slug' => $p->brand?->slug,
|
||||
'url' => route('buyer.brands.products.show', [$p->brand?->slug, $p->hashid]),
|
||||
]),
|
||||
'brands' => $brands->map(fn ($b) => [
|
||||
'id' => $b->id,
|
||||
'name' => $b->name,
|
||||
'slug' => $b->slug,
|
||||
'logo_url' => $b->getLogoUrl('thumb'),
|
||||
'products_count' => $b->products_count,
|
||||
'url' => route('buyer.brands.show', $b->slug),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search suggestions based on popular searches.
|
||||
*
|
||||
* GET /b/search/suggestions
|
||||
*
|
||||
* Returns popular search terms and trending products.
|
||||
*/
|
||||
public function suggestions(): JsonResponse
|
||||
{
|
||||
// Popular search terms (could be tracked and stored, for now use static list)
|
||||
$popularTerms = [
|
||||
'gummies',
|
||||
'vape',
|
||||
'flower',
|
||||
'indica',
|
||||
'sativa',
|
||||
'edibles',
|
||||
'pre-roll',
|
||||
'concentrate',
|
||||
];
|
||||
|
||||
// Trending products (recently added or best sellers)
|
||||
$trending = Product::query()
|
||||
->where('is_active', true)
|
||||
->whereHas('brand', fn ($q) => $q->where('is_active', true))
|
||||
->with('brand:id,name,slug')
|
||||
->orderBy('created_at', 'desc')
|
||||
->limit(4)
|
||||
->get(['id', 'brand_id', 'name', 'image_path']);
|
||||
|
||||
return response()->json([
|
||||
'terms' => $popularTerms,
|
||||
'trending' => $trending->map(fn ($p) => [
|
||||
'id' => $p->id,
|
||||
'hashid' => $p->hashid,
|
||||
'name' => $p->name,
|
||||
'image_url' => $p->getImageUrl('thumb'),
|
||||
'brand_name' => $p->brand?->name,
|
||||
'brand_slug' => $p->brand?->slug,
|
||||
'url' => route('buyer.brands.products.show', [$p->brand?->slug, $p->hashid]),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,39 @@ use Intervention\Image\ImageManager;
|
||||
*/
|
||||
class ImageController extends Controller
|
||||
{
|
||||
/**
|
||||
* Cache duration for images (1 year in seconds)
|
||||
*/
|
||||
private const CACHE_TTL = 31536000;
|
||||
|
||||
/**
|
||||
* Return a cached response for an image
|
||||
*/
|
||||
private function cachedResponse(string $contents, string $mimeType, ?string $etag = null): \Illuminate\Http\Response
|
||||
{
|
||||
$response = response($contents)
|
||||
->header('Content-Type', $mimeType)
|
||||
->header('Cache-Control', 'public, max-age='.self::CACHE_TTL.', immutable')
|
||||
->header('Expires', gmdate('D, d M Y H:i:s', time() + self::CACHE_TTL).' GMT');
|
||||
|
||||
if ($etag) {
|
||||
$response->header('ETag', '"'.$etag.'"');
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a cached file response
|
||||
*/
|
||||
private function cachedFileResponse(string $path): \Symfony\Component\HttpFoundation\BinaryFileResponse
|
||||
{
|
||||
return response()->file($path, [
|
||||
'Cache-Control' => 'public, max-age='.self::CACHE_TTL.', immutable',
|
||||
'Expires' => gmdate('D, d M Y H:i:s', time() + self::CACHE_TTL).' GMT',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve a brand logo at a specific size
|
||||
* URL: /images/brand-logo/{brand}/{width?}
|
||||
@@ -67,8 +100,9 @@ class ImageController extends Controller
|
||||
if (! $width) {
|
||||
$contents = Storage::get($brand->logo_path);
|
||||
$mimeType = Storage::mimeType($brand->logo_path);
|
||||
$etag = md5($brand->logo_path.$brand->updated_at);
|
||||
|
||||
return response($contents)->header('Content-Type', $mimeType);
|
||||
return $this->cachedResponse($contents, $mimeType, $etag);
|
||||
}
|
||||
|
||||
// Map common widths to pre-generated sizes (retina-optimized)
|
||||
@@ -104,7 +138,7 @@ class ImageController extends Controller
|
||||
|
||||
$path = storage_path('app/private/'.$thumbnailPath);
|
||||
|
||||
return response()->file($path);
|
||||
return $this->cachedFileResponse($path);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -121,8 +155,9 @@ class ImageController extends Controller
|
||||
if (! $width) {
|
||||
$contents = Storage::get($brand->banner_path);
|
||||
$mimeType = Storage::mimeType($brand->banner_path);
|
||||
$etag = md5($brand->banner_path.$brand->updated_at);
|
||||
|
||||
return response($contents)->header('Content-Type', $mimeType);
|
||||
return $this->cachedResponse($contents, $mimeType, $etag);
|
||||
}
|
||||
|
||||
// Map common widths to pre-generated sizes (retina-optimized)
|
||||
@@ -155,7 +190,7 @@ class ImageController extends Controller
|
||||
|
||||
$path = storage_path('app/private/'.$thumbnailPath);
|
||||
|
||||
return response()->file($path);
|
||||
return $this->cachedFileResponse($path);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -172,8 +207,9 @@ class ImageController extends Controller
|
||||
if (! $width) {
|
||||
$contents = Storage::get($product->image_path);
|
||||
$mimeType = Storage::mimeType($product->image_path);
|
||||
$etag = md5($product->image_path.$product->updated_at);
|
||||
|
||||
return response($contents)->header('Content-Type', $mimeType);
|
||||
return $this->cachedResponse($contents, $mimeType, $etag);
|
||||
}
|
||||
|
||||
// Check if cached dynamic thumbnail exists in local storage
|
||||
@@ -202,6 +238,6 @@ class ImageController extends Controller
|
||||
|
||||
$path = storage_path('app/private/'.$thumbnailPath);
|
||||
|
||||
return response()->file($path);
|
||||
return $this->cachedFileResponse($path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,11 +6,16 @@ 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)
|
||||
*/
|
||||
@@ -21,7 +26,7 @@ class MarketplaceController extends Controller
|
||||
|
||||
// Start with active products only
|
||||
$query = Product::query()
|
||||
->with(['brand', 'strain', 'category'])
|
||||
->with(['brand:id,name,slug,hashid,logo_path,updated_at', 'strain:id,name,type', 'category:id,name,slug'])
|
||||
->active();
|
||||
|
||||
// Search filter (name, SKU, description)
|
||||
@@ -44,10 +49,13 @@ class MarketplaceController extends Controller
|
||||
$query->where('category_id', $categoryId);
|
||||
}
|
||||
|
||||
// Strain type filter
|
||||
// Strain type filter - use join instead of whereHas for performance
|
||||
if ($strainType = $request->input('strain_type')) {
|
||||
$query->whereHas('strain', function ($q) use ($strainType) {
|
||||
$q->where('type', $strainType);
|
||||
$query->whereExists(function ($q) use ($strainType) {
|
||||
$q->select(DB::raw(1))
|
||||
->from('strains')
|
||||
->whereColumn('strains.id', 'products.strain_id')
|
||||
->where('strains.type', $strainType);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -82,66 +90,90 @@ class MarketplaceController extends Controller
|
||||
$perPage = $viewMode === 'list' ? 10 : 12;
|
||||
$products = $query->paginate($perPage)->withQueryString();
|
||||
|
||||
// Get brands with product counts for faceted filter
|
||||
$brands = Brand::query()
|
||||
->active()
|
||||
->withCount(['products' => fn ($q) => $q->active()])
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->filter(fn ($b) => $b->products_count > 0);
|
||||
|
||||
// Get categories with product counts
|
||||
$categories = ProductCategory::query()
|
||||
->whereNull('parent_id') // Only top-level categories
|
||||
->where('is_active', true)
|
||||
->withCount(['products' => fn ($q) => $q->active()])
|
||||
->get()
|
||||
->filter(fn ($c) => $c->products_count > 0)
|
||||
->sortByDesc('products_count');
|
||||
|
||||
// Featured products for hero carousel
|
||||
$featuredProducts = Product::query()
|
||||
->with(['brand', 'strain'])
|
||||
->featured()
|
||||
->inStock()
|
||||
->limit(5)
|
||||
->get();
|
||||
|
||||
// Top brands (by product count) for homepage section
|
||||
$topBrands = Brand::query()
|
||||
->active()
|
||||
->withCount(['products' => fn ($q) => $q->active()])
|
||||
->get()
|
||||
->filter(fn ($b) => $b->products_count > 0)
|
||||
->sortByDesc('products_count')
|
||||
->take(6);
|
||||
|
||||
// New arrivals (products created in last 14 days)
|
||||
$newArrivals = Product::query()
|
||||
->with(['brand', 'strain', 'category'])
|
||||
->active()
|
||||
->inStock()
|
||||
->where('created_at', '>=', now()->subDays(14))
|
||||
->orderByDesc('created_at')
|
||||
->limit(8)
|
||||
->get();
|
||||
|
||||
// Trending products (most ordered in last 30 days - simplified query)
|
||||
$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');
|
||||
|
||||
$trending = $trendingIds->isNotEmpty()
|
||||
? Product::with(['brand', 'strain', 'category'])
|
||||
->whereIn('id', $trendingIds)
|
||||
// Cache brands and categories for 5 minutes (used frequently, rarely change)
|
||||
$brands = cache()->remember('marketplace:brands', 300, function () {
|
||||
return Brand::query()
|
||||
->active()
|
||||
->get()
|
||||
->sortBy(fn ($p) => array_search($p->id, $trendingIds->toArray()))
|
||||
: collect();
|
||||
->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([
|
||||
@@ -160,6 +192,7 @@ class MarketplaceController extends Controller
|
||||
'topBrands',
|
||||
'newArrivals',
|
||||
'trending',
|
||||
'recentlyViewed',
|
||||
'business',
|
||||
'viewMode',
|
||||
'activeFilters',
|
||||
@@ -178,19 +211,64 @@ class MarketplaceController extends Controller
|
||||
/**
|
||||
* Display all brands directory
|
||||
*/
|
||||
public function brands()
|
||||
public function brands(Request $request)
|
||||
{
|
||||
$brands = Brand::query()
|
||||
->active()
|
||||
->withCount(['products' => function ($query) {
|
||||
$query->active();
|
||||
}])
|
||||
->orderBy('name')
|
||||
->get();
|
||||
$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', 'business'));
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -208,27 +286,30 @@ class MarketplaceController extends Controller
|
||||
*/
|
||||
public function showProduct($brandSlug, $productSlug)
|
||||
{
|
||||
// Find brand by slug
|
||||
// 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 slug within this brand
|
||||
// Find product by hashid, slug, or numeric ID within this brand
|
||||
$product = Product::query()
|
||||
->with([
|
||||
'brand',
|
||||
'strain',
|
||||
'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->with(['coaFiles'])
|
||||
->orderBy('production_date', 'desc')
|
||||
->orderBy('created_at', 'desc');
|
||||
$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('slug', $productSlug);
|
||||
// Only try ID lookup if the value is numeric
|
||||
$query->where('hashid', $productSlug)
|
||||
->orWhere('slug', $productSlug);
|
||||
if (is_numeric($productSlug)) {
|
||||
$query->orWhere('id', $productSlug);
|
||||
}
|
||||
@@ -236,9 +317,12 @@ class MarketplaceController extends Controller
|
||||
->active()
|
||||
->firstOrFail();
|
||||
|
||||
// Get related products from same brand
|
||||
// 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', 'strain'])
|
||||
->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()
|
||||
@@ -246,9 +330,69 @@ class MarketplaceController extends Controller
|
||||
->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', 'brand', 'business'));
|
||||
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'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -256,27 +400,30 @@ class MarketplaceController extends Controller
|
||||
*/
|
||||
public function showBrand($brandSlug)
|
||||
{
|
||||
// Find brand by slug
|
||||
// Find brand by slug with minimal columns
|
||||
$brand = Brand::query()
|
||||
->where('slug', $brandSlug)
|
||||
->active()
|
||||
->firstOrFail();
|
||||
|
||||
// Get featured products from this brand
|
||||
// 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', 'brand'])
|
||||
->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 all products from this brand
|
||||
// Get products - use simpler inStock check
|
||||
$products = Product::query()
|
||||
->with(['strain', 'brand'])
|
||||
->with(['strain:id,name,type', 'brand:id,name,slug,hashid,logo_path,updated_at'])
|
||||
->where('brand_id', $brand->id)
|
||||
->active()
|
||||
->orderBy('is_featured', 'desc')
|
||||
->inStock()
|
||||
->orderByDesc('is_featured')
|
||||
->orderBy('name')
|
||||
->paginate(20);
|
||||
|
||||
|
||||
@@ -164,6 +164,14 @@ class Brand extends Model implements Auditable
|
||||
return $this->hasMany(Product::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Promotions for this brand
|
||||
*/
|
||||
public function promotions(): HasMany
|
||||
{
|
||||
return $this->hasMany(Promotion::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Menus for this brand (both system and user-created)
|
||||
*/
|
||||
|
||||
@@ -92,6 +92,12 @@ class CrmThread extends Model
|
||||
'seller_business_id',
|
||||
'thread_type',
|
||||
'order_id',
|
||||
'quote_id',
|
||||
// Buyer-side tracking
|
||||
'is_read_by_buyer',
|
||||
'read_at_by_buyer',
|
||||
'buyer_starred_by',
|
||||
'buyer_archived_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
@@ -108,6 +114,11 @@ class CrmThread extends Model
|
||||
'chat_request_at' => 'datetime',
|
||||
'chat_request_responded_at' => 'datetime',
|
||||
'buyer_context' => 'array',
|
||||
// Buyer-side tracking
|
||||
'is_read_by_buyer' => 'boolean',
|
||||
'read_at_by_buyer' => 'datetime',
|
||||
'buyer_starred_by' => 'array',
|
||||
'buyer_archived_by' => 'array',
|
||||
];
|
||||
|
||||
protected $appends = ['is_snoozed', 'other_viewers'];
|
||||
@@ -337,6 +348,62 @@ class CrmThread extends Model
|
||||
return $query->whereIn('brand_id', $brandIds);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Buyer-side scopes (for buyer inbox/CRM)
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Scope to filter threads for a buyer business.
|
||||
*/
|
||||
public function scopeForBuyerBusiness($query, int $businessId)
|
||||
{
|
||||
return $query->where('buyer_business_id', $businessId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope to filter threads that have unread messages for buyer.
|
||||
*/
|
||||
public function scopeHasUnreadForBuyer($query)
|
||||
{
|
||||
return $query->where('is_read', false)
|
||||
->where('last_message_direction', 'inbound'); // inbound = from seller
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope to filter threads starred by buyer.
|
||||
*/
|
||||
public function scopeStarredByBuyer($query, int $userId)
|
||||
{
|
||||
return $query->whereJsonContains('buyer_starred_by', $userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope to filter threads archived by buyer.
|
||||
*/
|
||||
public function scopeArchivedByBuyer($query, int $userId)
|
||||
{
|
||||
return $query->whereJsonContains('buyer_archived_by', $userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope to filter threads NOT archived by buyer.
|
||||
*/
|
||||
public function scopeNotArchivedByBuyer($query, int $userId)
|
||||
{
|
||||
return $query->where(function ($q) use ($userId) {
|
||||
$q->whereNull('buyer_archived_by')
|
||||
->orWhereJsonDoesntContain('buyer_archived_by', $userId);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope for unread messages from buyer's perspective.
|
||||
*/
|
||||
public function scopeUnreadForBuyer($query)
|
||||
{
|
||||
return $query->where('is_read_by_buyer', false);
|
||||
}
|
||||
|
||||
// Accessors
|
||||
|
||||
public function getIsSnoozedAttribute(): bool
|
||||
@@ -515,4 +582,84 @@ class CrmThread extends Model
|
||||
default => 'badge-ghost',
|
||||
};
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Buyer-side helper methods
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Mark thread as read for buyer.
|
||||
*/
|
||||
public function markAsReadForBuyer(): void
|
||||
{
|
||||
$this->update([
|
||||
'is_read_by_buyer' => true,
|
||||
'read_at_by_buyer' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle star status for buyer.
|
||||
*/
|
||||
public function toggleStarForBuyer(int $userId): void
|
||||
{
|
||||
$starred = $this->buyer_starred_by ?? [];
|
||||
|
||||
if (in_array($userId, $starred)) {
|
||||
$starred = array_values(array_diff($starred, [$userId]));
|
||||
} else {
|
||||
$starred[] = $userId;
|
||||
}
|
||||
|
||||
$this->update(['buyer_starred_by' => $starred]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if thread is starred by buyer.
|
||||
*/
|
||||
public function isStarredByBuyer(int $userId): bool
|
||||
{
|
||||
return in_array($userId, $this->buyer_starred_by ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive thread for buyer.
|
||||
*/
|
||||
public function archiveForBuyer(int $userId): void
|
||||
{
|
||||
$archived = $this->buyer_archived_by ?? [];
|
||||
|
||||
if (! in_array($userId, $archived)) {
|
||||
$archived[] = $userId;
|
||||
}
|
||||
|
||||
$this->update(['buyer_archived_by' => $archived]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unarchive thread for buyer.
|
||||
*/
|
||||
public function unarchiveForBuyer(int $userId): void
|
||||
{
|
||||
$archived = $this->buyer_archived_by ?? [];
|
||||
$archived = array_values(array_diff($archived, [$userId]));
|
||||
|
||||
$this->update(['buyer_archived_by' => $archived]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get latest message relationship.
|
||||
*/
|
||||
public function latestMessage()
|
||||
{
|
||||
return $this->hasOne(CrmChannelMessage::class, 'thread_id')->latestOfMany();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get quote relationship.
|
||||
*/
|
||||
public function quote()
|
||||
{
|
||||
return $this->belongsTo(CrmQuote::class, 'quote_id');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -525,10 +525,18 @@ class Product extends Model implements Auditable
|
||||
|
||||
public function scopeInStock($query)
|
||||
{
|
||||
return $query->whereHas('batches', function ($q) {
|
||||
$q->where('is_active', true)
|
||||
->where('is_quarantined', false)
|
||||
->where('quantity_available', '>', 0);
|
||||
return $query->where(function ($q) {
|
||||
// Unlimited inventory products are always in stock
|
||||
$q->where('inventory_mode', self::INV_UNLIMITED)
|
||||
// Or has available batch inventory (using EXISTS for performance)
|
||||
->orWhereExists(function ($subq) {
|
||||
$subq->select(\DB::raw(1))
|
||||
->from('batches')
|
||||
->whereColumn('batches.product_id', 'products.id')
|
||||
->where('batches.is_active', true)
|
||||
->where('batches.is_quarantined', false)
|
||||
->where('batches.quantity_available', '>', 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -770,9 +778,18 @@ class Product extends Model implements Auditable
|
||||
*/
|
||||
public function getImageUrl(?string $size = null): ?string
|
||||
{
|
||||
// Fall back to brand logo if no product image
|
||||
// Fall back to brand logo at 50% size if no product image
|
||||
if (! $this->image_path) {
|
||||
return $this->brand?->getLogoUrl($size);
|
||||
// Map named sizes to pixel widths, then halve them for logo fallback
|
||||
$sizeMap = [
|
||||
'thumb' => 40, // 50% of 80
|
||||
'small' => 80, // 50% of 160
|
||||
'medium' => 200, // 50% of 400
|
||||
'large' => 400, // 50% of 800
|
||||
];
|
||||
$logoSize = is_numeric($size) ? (int) ($size / 2) : ($sizeMap[$size] ?? null);
|
||||
|
||||
return $this->brand?->getLogoUrl($logoSize);
|
||||
}
|
||||
|
||||
// If no hashid, fall back to direct storage URL (for legacy products)
|
||||
|
||||
135
app/Services/ProductComparisonService.php
Normal file
135
app/Services/ProductComparisonService.php
Normal file
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Product;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* Service for managing product comparison list.
|
||||
*
|
||||
* Stores selected product IDs in the session for side-by-side comparison.
|
||||
*/
|
||||
class ProductComparisonService
|
||||
{
|
||||
private const SESSION_KEY = 'compare_products';
|
||||
|
||||
private const MAX_ITEMS = 4; // Maximum products to compare at once
|
||||
|
||||
/**
|
||||
* Add a product to comparison list.
|
||||
*/
|
||||
public function add(int $productId): bool
|
||||
{
|
||||
$ids = $this->getProductIds();
|
||||
|
||||
if (count($ids) >= self::MAX_ITEMS) {
|
||||
return false; // List is full
|
||||
}
|
||||
|
||||
if (in_array($productId, $ids)) {
|
||||
return true; // Already in list
|
||||
}
|
||||
|
||||
$ids[] = $productId;
|
||||
session()->put(self::SESSION_KEY, $ids);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a product from comparison list.
|
||||
*/
|
||||
public function remove(int $productId): void
|
||||
{
|
||||
$ids = $this->getProductIds();
|
||||
$ids = array_values(array_filter($ids, fn ($id) => $id !== $productId));
|
||||
session()->put(self::SESSION_KEY, $ids);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle a product in the comparison list.
|
||||
*
|
||||
* @return array{added: bool, count: int}
|
||||
*/
|
||||
public function toggle(int $productId): array
|
||||
{
|
||||
if ($this->isInList($productId)) {
|
||||
$this->remove($productId);
|
||||
|
||||
return ['added' => false, 'count' => $this->count()];
|
||||
}
|
||||
|
||||
$added = $this->add($productId);
|
||||
|
||||
return ['added' => $added, 'count' => $this->count()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a product is in the comparison list.
|
||||
*/
|
||||
public function isInList(int $productId): bool
|
||||
{
|
||||
return in_array($productId, $this->getProductIds());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all product IDs in comparison list.
|
||||
*/
|
||||
public function getProductIds(): array
|
||||
{
|
||||
return session()->get(self::SESSION_KEY, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get products with full model data for comparison.
|
||||
*/
|
||||
public function getProducts(): Collection
|
||||
{
|
||||
$ids = $this->getProductIds();
|
||||
|
||||
if (empty($ids)) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
return Product::query()
|
||||
->with(['brand:id,name,slug', 'strain:id,name,type', 'category:id,name'])
|
||||
->whereIn('id', $ids)
|
||||
->where('is_active', true)
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the comparison list.
|
||||
*/
|
||||
public function clear(): void
|
||||
{
|
||||
session()->forget(self::SESSION_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of products in comparison list.
|
||||
*/
|
||||
public function count(): int
|
||||
{
|
||||
return count($this->getProductIds());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if list is full.
|
||||
*/
|
||||
public function isFull(): bool
|
||||
{
|
||||
return $this->count() >= self::MAX_ITEMS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get maximum allowed items.
|
||||
*/
|
||||
public function maxItems(): int
|
||||
{
|
||||
return self::MAX_ITEMS;
|
||||
}
|
||||
}
|
||||
111
app/Services/RecentlyViewedService.php
Normal file
111
app/Services/RecentlyViewedService.php
Normal file
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Product;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* Service for tracking and retrieving recently viewed products.
|
||||
*
|
||||
* Stores product IDs in the session with timestamps, limited to the most recent 20 products.
|
||||
*/
|
||||
class RecentlyViewedService
|
||||
{
|
||||
private const SESSION_KEY = 'recently_viewed_products';
|
||||
|
||||
private const MAX_ITEMS = 20;
|
||||
|
||||
/**
|
||||
* Record a product view.
|
||||
*/
|
||||
public function recordView(int $productId): void
|
||||
{
|
||||
$viewed = session()->get(self::SESSION_KEY, []);
|
||||
|
||||
// Remove if already exists (we'll re-add with new timestamp)
|
||||
$viewed = array_filter($viewed, fn ($item) => $item['id'] !== $productId);
|
||||
|
||||
// Add to beginning of array
|
||||
array_unshift($viewed, [
|
||||
'id' => $productId,
|
||||
'viewed_at' => now()->timestamp,
|
||||
]);
|
||||
|
||||
// Limit to max items
|
||||
$viewed = array_slice($viewed, 0, self::MAX_ITEMS);
|
||||
|
||||
session()->put(self::SESSION_KEY, $viewed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recently viewed product IDs (most recent first).
|
||||
*
|
||||
* @param int|null $limit Limit results (default: all)
|
||||
* @param int|null $excludeId Exclude a specific product ID
|
||||
*/
|
||||
public function getProductIds(?int $limit = null, ?int $excludeId = null): array
|
||||
{
|
||||
$viewed = session()->get(self::SESSION_KEY, []);
|
||||
|
||||
if ($excludeId) {
|
||||
$viewed = array_filter($viewed, fn ($item) => $item['id'] !== $excludeId);
|
||||
}
|
||||
|
||||
$ids = array_column(array_values($viewed), 'id');
|
||||
|
||||
if ($limit) {
|
||||
$ids = array_slice($ids, 0, $limit);
|
||||
}
|
||||
|
||||
return $ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recently viewed products with full model data.
|
||||
*
|
||||
* @param int $limit Maximum number of products to return
|
||||
* @param int|null $excludeId Exclude a specific product ID (e.g., current product)
|
||||
*/
|
||||
public function getProducts(int $limit = 8, ?int $excludeId = null): Collection
|
||||
{
|
||||
$ids = $this->getProductIds($limit + 1, $excludeId); // Get extra to handle filter
|
||||
|
||||
if (empty($ids)) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
// Fetch products in the order they were viewed
|
||||
$products = Product::query()
|
||||
->with('brand:id,name,slug')
|
||||
->whereIn('id', $ids)
|
||||
->where('is_active', true)
|
||||
->get();
|
||||
|
||||
// Sort by the original order and limit
|
||||
$idOrder = array_flip($ids);
|
||||
|
||||
return $products
|
||||
->sortBy(fn ($p) => $idOrder[$p->id] ?? PHP_INT_MAX)
|
||||
->take($limit)
|
||||
->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear recently viewed history.
|
||||
*/
|
||||
public function clear(): void
|
||||
{
|
||||
session()->forget(self::SESSION_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of recently viewed products.
|
||||
*/
|
||||
public function count(): int
|
||||
{
|
||||
return count(session()->get(self::SESSION_KEY, []));
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@ use App\Models\BannerAd as BannerAdModel;
|
||||
use App\Services\BannerAdService;
|
||||
use Closure;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\View\Component;
|
||||
|
||||
class BannerAd extends Component
|
||||
@@ -23,19 +25,29 @@ class BannerAd extends Component
|
||||
$this->zone = BannerAdZone::from($zone);
|
||||
$this->dimensions = $this->zone->dimensions();
|
||||
|
||||
// Get business type from authenticated user
|
||||
$businessType = auth()->user()?->user_type;
|
||||
// Skip if banner_ads table doesn't exist (migrations not run)
|
||||
if (! Schema::hasTable('banner_ads')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get ad from service
|
||||
$service = app(BannerAdService::class);
|
||||
$this->ad = $service->getAdForZone($this->zone, $businessType);
|
||||
try {
|
||||
// Get business type from authenticated user
|
||||
$businessType = auth()->user()?->user_type;
|
||||
|
||||
// Record impression if ad found
|
||||
if ($this->ad) {
|
||||
$service->recordImpression($this->ad, [
|
||||
'business_id' => auth()->user()?->businesses->first()?->id,
|
||||
'user_id' => auth()->id(),
|
||||
]);
|
||||
// Get ad from service
|
||||
$service = app(BannerAdService::class);
|
||||
$this->ad = $service->getAdForZone($this->zone, $businessType);
|
||||
|
||||
// Record impression if ad found
|
||||
if ($this->ad) {
|
||||
$service->recordImpression($this->ad, [
|
||||
'business_id' => auth()->user()?->businesses->first()?->id,
|
||||
'user_id' => auth()->id(),
|
||||
]);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// Log but don't break the page if banner ad system has issues
|
||||
Log::warning('BannerAd component error: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('crm_threads', function (Blueprint $table) {
|
||||
// Buyer-side read tracking (separate from seller-side is_read)
|
||||
$table->boolean('is_read_by_buyer')->default(true)->after('is_read');
|
||||
$table->timestamp('read_at_by_buyer')->nullable()->after('is_read_by_buyer');
|
||||
|
||||
// Buyer-side star/archive (JSON arrays of user IDs)
|
||||
$table->jsonb('buyer_starred_by')->nullable()->after('read_at_by_buyer');
|
||||
$table->jsonb('buyer_archived_by')->nullable()->after('buyer_starred_by');
|
||||
|
||||
// Quote relationship
|
||||
$table->foreignId('quote_id')->nullable()->after('order_id')->constrained('crm_quotes')->nullOnDelete();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('crm_threads', function (Blueprint $table) {
|
||||
$table->dropConstrainedForeignId('quote_id');
|
||||
$table->dropColumn([
|
||||
'is_read_by_buyer',
|
||||
'read_at_by_buyer',
|
||||
'buyer_starred_by',
|
||||
'buyer_archived_by',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
391
resources/views/buyer/buy-again/index.blade.php
Normal file
391
resources/views/buyer/buy-again/index.blade.php
Normal file
@@ -0,0 +1,391 @@
|
||||
@extends('layouts.buyer-app-with-sidebar')
|
||||
|
||||
@section('title', 'Buy It Again - ' . config('app.name'))
|
||||
|
||||
@section('content')
|
||||
<div x-data="buyAgain()" x-init="init()">
|
||||
{{-- Header --}}
|
||||
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">Buy It Again</h1>
|
||||
<p class="text-base-content/60">Quickly reorder from your favorite brands</p>
|
||||
</div>
|
||||
<button class="btn btn-warning gap-2"
|
||||
@click="addAllToCart()"
|
||||
:disabled="!hasItemsWithQuantity()"
|
||||
:class="{ 'btn-disabled': !hasItemsWithQuantity() }">
|
||||
<span class="icon-[heroicons--shopping-cart] size-5"></span>
|
||||
Add all to cart
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{-- Tabs --}}
|
||||
<div class="tabs tabs-boxed mb-6 inline-flex">
|
||||
<a href="{{ route('buyer.business.buy-again', ['business' => $business->slug, 'tab' => 'favorites']) }}"
|
||||
class="tab {{ $tab === 'favorites' ? 'tab-active' : '' }}">
|
||||
<span class="icon-[heroicons--heart] size-4 mr-2"></span>
|
||||
Store favorites
|
||||
</a>
|
||||
<a href="{{ route('buyer.business.buy-again', ['business' => $business->slug, 'tab' => 'history']) }}"
|
||||
class="tab {{ $tab === 'history' ? 'tab-active' : '' }}">
|
||||
<span class="icon-[heroicons--clock] size-4 mr-2"></span>
|
||||
Purchase history
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{{-- Search --}}
|
||||
<div class="form-control mb-6">
|
||||
<div class="relative w-full max-w-xs">
|
||||
<input type="text"
|
||||
placeholder="Search products..."
|
||||
class="input input-bordered w-full pl-10"
|
||||
x-model="search"
|
||||
@input.debounce.300ms="filterProducts()">
|
||||
<span class="icon-[heroicons--magnifying-glass] size-5 absolute left-3 top-1/2 -translate-y-1/2 text-base-content/40"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Empty State --}}
|
||||
@if($brands->isEmpty())
|
||||
<div class="card bg-base-100 shadow-lg">
|
||||
<div class="card-body items-center text-center py-16">
|
||||
@if($tab === 'favorites')
|
||||
<span class="icon-[heroicons--heart] size-16 text-base-content/20 mb-4"></span>
|
||||
<h3 class="text-xl font-semibold mb-2">No favorite brands yet</h3>
|
||||
<p class="text-base-content/60 mb-4">Follow brands to see their products here for quick reordering.</p>
|
||||
<a href="{{ route('buyer.brands.index') }}" class="btn btn-primary">
|
||||
<span class="icon-[heroicons--building-storefront] size-4"></span>
|
||||
Browse Brands
|
||||
</a>
|
||||
@else
|
||||
<span class="icon-[heroicons--shopping-bag] size-16 text-base-content/20 mb-4"></span>
|
||||
<h3 class="text-xl font-semibold mb-2">No purchase history</h3>
|
||||
<p class="text-base-content/60 mb-4">Place your first order to see your purchase history here.</p>
|
||||
<a href="{{ route('buyer.browse') }}" class="btn btn-primary">
|
||||
<span class="icon-[heroicons--shopping-cart] size-4"></span>
|
||||
Start Shopping
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
|
||||
{{-- Brands (Collapsible) --}}
|
||||
@foreach($brands as $brand)
|
||||
<div class="collapse collapse-arrow bg-base-100 border border-base-200 mb-4 shadow-sm"
|
||||
x-show="brandMatchesSearch('{{ addslashes($brand->name) }}', {{ $brand->products->pluck('name')->map(fn($n) => addslashes($n))->toJson() }})">
|
||||
<input type="checkbox" checked>
|
||||
<div class="collapse-title flex items-center gap-3 pe-12">
|
||||
@if($brand->getLogoUrl('thumb'))
|
||||
<img src="{{ $brand->getLogoUrl('thumb') }}"
|
||||
alt="{{ $brand->name }}"
|
||||
class="w-10 h-10 rounded object-cover bg-base-200">
|
||||
@else
|
||||
<div class="w-10 h-10 rounded bg-primary/10 flex items-center justify-center">
|
||||
<span class="text-primary font-bold">{{ substr($brand->name, 0, 2) }}</span>
|
||||
</div>
|
||||
@endif
|
||||
<span class="font-semibold">{{ $brand->name }}</span>
|
||||
<span class="badge badge-ghost">{{ $brand->products->count() }} products</span>
|
||||
</div>
|
||||
<div class="collapse-content">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="min-w-[250px]">Product</th>
|
||||
<th>Price</th>
|
||||
<th>Last Ordered</th>
|
||||
@if($storeMetrics)
|
||||
<th>In Stock</th>
|
||||
<th>Days Left</th>
|
||||
@endif
|
||||
<th class="w-36">Quantity</th>
|
||||
<th class="w-32"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach($brand->products as $product)
|
||||
<tr x-show="productMatchesSearch('{{ addslashes($product->name) }}', '{{ addslashes($product->sku ?? '') }}')"
|
||||
class="hover:bg-base-200/50">
|
||||
<td>
|
||||
<div class="flex items-center gap-3">
|
||||
@if($product->getImageUrl('thumb'))
|
||||
<img src="{{ $product->getImageUrl('thumb') }}"
|
||||
alt="{{ $product->name }}"
|
||||
class="w-12 h-12 rounded object-cover bg-base-200">
|
||||
@else
|
||||
<div class="w-12 h-12 rounded bg-base-200 flex items-center justify-center">
|
||||
<span class="icon-[heroicons--cube] size-6 text-base-content/30"></span>
|
||||
</div>
|
||||
@endif
|
||||
<div>
|
||||
<div class="font-medium">{{ $product->name }}</div>
|
||||
@if($product->sku)
|
||||
<div class="text-xs text-base-content/50 font-mono">
|
||||
{{ $product->sku }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="font-medium">
|
||||
${{ number_format($product->wholesale_price ?? $product->price ?? 0, 2) }}
|
||||
@if($product->unit_type)
|
||||
<span class="text-xs text-base-content/60">/{{ $product->unit_type }}</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="text-base-content/70">
|
||||
@php
|
||||
$lastOrder = $product->orderItems->first()?->order;
|
||||
@endphp
|
||||
{{ $lastOrder?->created_at?->format('M Y') ?? '-' }}
|
||||
</td>
|
||||
@if($storeMetrics)
|
||||
<td>
|
||||
@if(isset($storeMetrics[$product->id]['on_hand']))
|
||||
<span class="font-medium">{{ number_format($storeMetrics[$product->id]['on_hand']) }}</span>
|
||||
@else
|
||||
<span class="text-base-content/40">-</span>
|
||||
@endif
|
||||
</td>
|
||||
<td>
|
||||
@if(isset($storeMetrics[$product->id]['days_until_out']))
|
||||
@php $daysLeft = $storeMetrics[$product->id]['days_until_out']; @endphp
|
||||
@if($daysLeft < 7)
|
||||
<span class="badge badge-error badge-sm">
|
||||
{{ $daysLeft }} days
|
||||
</span>
|
||||
@elseif($daysLeft < 14)
|
||||
<span class="badge badge-warning badge-sm">
|
||||
{{ $daysLeft }} days
|
||||
</span>
|
||||
@else
|
||||
<span class="text-success">{{ $daysLeft }} days</span>
|
||||
@endif
|
||||
@else
|
||||
<span class="text-base-content/40">-</span>
|
||||
@endif
|
||||
</td>
|
||||
@endif
|
||||
<td>
|
||||
<div class="join">
|
||||
<button class="btn btn-sm join-item"
|
||||
@click="decrementQty({{ $product->id }})"
|
||||
:disabled="getQty({{ $product->id }}) <= 0">
|
||||
<span class="icon-[heroicons--minus] size-4"></span>
|
||||
</button>
|
||||
<input type="number"
|
||||
class="input input-sm input-bordered w-16 join-item text-center"
|
||||
:value="getQty({{ $product->id }})"
|
||||
@change="setQty({{ $product->id }}, $event.target.value)"
|
||||
min="0">
|
||||
<button class="btn btn-sm join-item"
|
||||
@click="incrementQty({{ $product->id }})">
|
||||
<span class="icon-[heroicons--plus] size-4"></span>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
@if($product->is_active && ($product->stock_quantity ?? 1) > 0)
|
||||
<button class="btn btn-sm btn-primary"
|
||||
@click="addToCart({{ $product->id }})"
|
||||
:disabled="getQty({{ $product->id }}) <= 0"
|
||||
:class="{ 'btn-disabled': getQty({{ $product->id }}) <= 0 }">
|
||||
<span class="icon-[heroicons--shopping-cart] size-4"></span>
|
||||
Add
|
||||
</button>
|
||||
@else
|
||||
<span class="badge badge-ghost">Unavailable</span>
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
@endif
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
function buyAgain() {
|
||||
return {
|
||||
search: '',
|
||||
quantities: {},
|
||||
cart: {},
|
||||
|
||||
init() {
|
||||
// Initialize quantities to 1 for all products
|
||||
@foreach($brands as $brand)
|
||||
@foreach($brand->products as $product)
|
||||
this.quantities[{{ $product->id }}] = 1;
|
||||
@endforeach
|
||||
@endforeach
|
||||
|
||||
this.loadCartState();
|
||||
},
|
||||
|
||||
async loadCartState() {
|
||||
try {
|
||||
const response = await fetch('{{ route("buyer.business.cart.index", $business->slug) }}', {
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType && contentType.includes('application/json')) {
|
||||
const data = await response.json();
|
||||
if (data.items && Array.isArray(data.items)) {
|
||||
data.items.forEach(item => {
|
||||
this.cart[item.product_id] = {
|
||||
cartId: item.id,
|
||||
quantity: item.quantity
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Error loading cart:', error);
|
||||
}
|
||||
},
|
||||
|
||||
getQty(productId) {
|
||||
return this.quantities[productId] || 0;
|
||||
},
|
||||
|
||||
setQty(productId, value) {
|
||||
this.quantities[productId] = Math.max(0, parseInt(value) || 0);
|
||||
},
|
||||
|
||||
incrementQty(productId) {
|
||||
this.quantities[productId] = (this.quantities[productId] || 0) + 1;
|
||||
},
|
||||
|
||||
decrementQty(productId) {
|
||||
if (this.quantities[productId] > 0) {
|
||||
this.quantities[productId]--;
|
||||
}
|
||||
},
|
||||
|
||||
hasItemsWithQuantity() {
|
||||
return Object.values(this.quantities).some(qty => qty > 0);
|
||||
},
|
||||
|
||||
filterProducts() {
|
||||
// Re-render handled by Alpine x-show directives
|
||||
},
|
||||
|
||||
brandMatchesSearch(brandName, productNames) {
|
||||
if (!this.search) return true;
|
||||
const searchLower = this.search.toLowerCase();
|
||||
if (brandName.toLowerCase().includes(searchLower)) return true;
|
||||
return productNames.some(name => name.toLowerCase().includes(searchLower));
|
||||
},
|
||||
|
||||
productMatchesSearch(productName, sku) {
|
||||
if (!this.search) return true;
|
||||
const searchLower = this.search.toLowerCase();
|
||||
return productName.toLowerCase().includes(searchLower) ||
|
||||
(sku && sku.toLowerCase().includes(searchLower));
|
||||
},
|
||||
|
||||
async addToCart(productId) {
|
||||
const quantity = this.quantities[productId] || 1;
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('product_id', productId);
|
||||
formData.append('quantity', quantity);
|
||||
|
||||
const response = await window.axios.post('{{ route("buyer.business.cart.add", $business->slug) }}', formData);
|
||||
const data = response.data;
|
||||
|
||||
if (data.success) {
|
||||
this.cart[productId] = {
|
||||
cartId: data.cart_item.id,
|
||||
quantity: data.cart_item.quantity
|
||||
};
|
||||
|
||||
window.dispatchEvent(new CustomEvent('cart-updated', {
|
||||
detail: { count: this.getCartCount() }
|
||||
}));
|
||||
|
||||
window.showToast?.('Added to cart', 'success') ||
|
||||
alert('Added to cart!');
|
||||
} else {
|
||||
window.showToast?.(data.message || 'Failed to add to cart', 'error') ||
|
||||
alert(data.message || 'Failed to add to cart');
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error.response?.data?.message || 'Failed to add product to cart';
|
||||
window.showToast?.(message, 'error') || alert(message);
|
||||
}
|
||||
},
|
||||
|
||||
getCartCount() {
|
||||
return Object.values(this.cart).reduce((sum, item) => sum + item.quantity, 0);
|
||||
},
|
||||
|
||||
async addAllToCart() {
|
||||
const productsToAdd = Object.entries(this.quantities)
|
||||
.filter(([_, qty]) => qty > 0);
|
||||
|
||||
if (productsToAdd.length === 0) {
|
||||
window.showToast?.('No products selected', 'warning') ||
|
||||
alert('No products selected');
|
||||
return;
|
||||
}
|
||||
|
||||
let addedCount = 0;
|
||||
let failedCount = 0;
|
||||
|
||||
for (const [productId, quantity] of productsToAdd) {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('product_id', productId);
|
||||
formData.append('quantity', quantity);
|
||||
|
||||
const response = await window.axios.post('{{ route("buyer.business.cart.add", $business->slug) }}', formData);
|
||||
|
||||
if (response.data.success) {
|
||||
this.cart[productId] = {
|
||||
cartId: response.data.cart_item.id,
|
||||
quantity: response.data.cart_item.quantity
|
||||
};
|
||||
addedCount++;
|
||||
} else {
|
||||
failedCount++;
|
||||
}
|
||||
} catch (error) {
|
||||
failedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
window.dispatchEvent(new CustomEvent('cart-updated', {
|
||||
detail: { count: this.getCartCount() }
|
||||
}));
|
||||
|
||||
if (addedCount > 0 && failedCount === 0) {
|
||||
window.showToast?.(`Added ${addedCount} items to cart`, 'success') ||
|
||||
alert(`Added ${addedCount} items to cart`);
|
||||
} else if (addedCount > 0 && failedCount > 0) {
|
||||
window.showToast?.(`Added ${addedCount} items, ${failedCount} failed`, 'warning') ||
|
||||
alert(`Added ${addedCount} items, ${failedCount} failed`);
|
||||
} else {
|
||||
window.showToast?.('Failed to add items to cart', 'error') ||
|
||||
alert('Failed to add items to cart');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@endpush
|
||||
361
resources/views/buyer/compare/index.blade.php
Normal file
361
resources/views/buyer/compare/index.blade.php
Normal file
@@ -0,0 +1,361 @@
|
||||
@extends('layouts.buyer-app-with-sidebar')
|
||||
|
||||
@section('title', 'Compare Products - ' . config('app.name'))
|
||||
|
||||
@section('content')
|
||||
<div class="min-h-screen py-6" x-data="compareProducts()">
|
||||
{{-- Header --}}
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold flex items-center gap-2">
|
||||
<span class="icon-[heroicons--scale] size-7 text-primary"></span>
|
||||
Compare Products
|
||||
</h1>
|
||||
<p class="text-base-content/60 mt-1">Compare up to 4 products side-by-side</p>
|
||||
</div>
|
||||
|
||||
@if($products->count() > 0)
|
||||
<button @click="clearAll()"
|
||||
class="btn btn-ghost btn-sm gap-2">
|
||||
<span class="icon-[heroicons--trash] size-4"></span>
|
||||
Clear All
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if($products->count() === 0)
|
||||
{{-- Empty State --}}
|
||||
<div class="card bg-base-100 shadow-lg">
|
||||
<div class="card-body text-center py-16">
|
||||
<span class="icon-[heroicons--scale] size-20 text-base-content/20 mx-auto mb-4"></span>
|
||||
<h3 class="text-xl font-semibold text-base-content/70 mb-2">No products to compare</h3>
|
||||
<p class="text-base-content/50 mb-6 max-w-md mx-auto">
|
||||
Add products to compare by clicking the compare button on product cards while browsing.
|
||||
</p>
|
||||
<a href="{{ route('buyer.browse') }}" class="btn btn-primary gap-2">
|
||||
<span class="icon-[heroicons--shopping-bag] size-5"></span>
|
||||
Browse Products
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
{{-- Comparison Table --}}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table bg-base-100 shadow-lg rounded-box">
|
||||
{{-- Product Images Row --}}
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="w-48 bg-base-200 sticky left-0 z-10">Product</th>
|
||||
@foreach($products as $product)
|
||||
<th class="min-w-[250px] text-center p-0">
|
||||
<div class="relative">
|
||||
{{-- Remove Button --}}
|
||||
<button @click="removeProduct({{ $product->id }})"
|
||||
class="btn btn-xs btn-circle btn-ghost absolute top-2 right-2 z-10 bg-base-100/80 hover:bg-error hover:text-error-content">
|
||||
<span class="icon-[heroicons--x-mark] size-4"></span>
|
||||
</button>
|
||||
|
||||
{{-- Product Image --}}
|
||||
<a href="{{ route('buyer.brands.products.show', [$product->brand->slug, $product->hashid]) }}"
|
||||
class="block p-4">
|
||||
<div class="aspect-square w-full max-w-[200px] mx-auto bg-base-200 rounded-lg overflow-hidden">
|
||||
@if($product->getImageUrl('medium'))
|
||||
<img src="{{ $product->getImageUrl('medium') }}"
|
||||
alt="{{ $product->name }}"
|
||||
class="w-full h-full object-cover">
|
||||
@else
|
||||
<div class="flex items-center justify-center h-full">
|
||||
<span class="icon-[heroicons--cube] size-16 text-base-content/20"></span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</th>
|
||||
@endforeach
|
||||
|
||||
{{-- Add Product Slot (if under max) --}}
|
||||
@if($products->count() < 4)
|
||||
<th class="min-w-[200px] text-center">
|
||||
<a href="{{ route('buyer.browse') }}"
|
||||
class="block p-4">
|
||||
<div class="aspect-square w-full max-w-[180px] mx-auto border-2 border-dashed border-base-300 rounded-lg flex flex-col items-center justify-center hover:border-primary hover:bg-base-200 transition-colors">
|
||||
<span class="icon-[heroicons--plus] size-10 text-base-content/30"></span>
|
||||
<span class="text-sm text-base-content/50 mt-2">Add Product</span>
|
||||
</div>
|
||||
</a>
|
||||
</th>
|
||||
@endif
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{{-- Product Name --}}
|
||||
<tr>
|
||||
<td class="font-semibold bg-base-200 sticky left-0">Name</td>
|
||||
@foreach($products as $product)
|
||||
<td class="text-center">
|
||||
<a href="{{ route('buyer.brands.products.show', [$product->brand->slug, $product->hashid]) }}"
|
||||
class="font-semibold hover:text-primary">
|
||||
{{ $product->name }}
|
||||
</a>
|
||||
</td>
|
||||
@endforeach
|
||||
@if($products->count() < 4)<td></td>@endif
|
||||
</tr>
|
||||
|
||||
{{-- Brand --}}
|
||||
<tr>
|
||||
<td class="font-semibold bg-base-200 sticky left-0">Brand</td>
|
||||
@foreach($products as $product)
|
||||
<td class="text-center">
|
||||
@if($product->brand)
|
||||
<a href="{{ route('buyer.brands.show', $product->brand->slug) }}"
|
||||
class="link link-primary">
|
||||
{{ $product->brand->name }}
|
||||
</a>
|
||||
@else
|
||||
<span class="text-base-content/50">-</span>
|
||||
@endif
|
||||
</td>
|
||||
@endforeach
|
||||
@if($products->count() < 4)<td></td>@endif
|
||||
</tr>
|
||||
|
||||
{{-- Price --}}
|
||||
<tr>
|
||||
<td class="font-semibold bg-base-200 sticky left-0">Price</td>
|
||||
@foreach($products as $product)
|
||||
<td class="text-center">
|
||||
<span class="text-xl font-bold text-primary">${{ number_format($product->wholesale_price, 2) }}</span>
|
||||
@if($product->price_unit)
|
||||
<span class="text-sm text-base-content/60">/ {{ $product->price_unit }}</span>
|
||||
@endif
|
||||
</td>
|
||||
@endforeach
|
||||
@if($products->count() < 4)<td></td>@endif
|
||||
</tr>
|
||||
|
||||
{{-- SKU --}}
|
||||
<tr>
|
||||
<td class="font-semibold bg-base-200 sticky left-0">SKU</td>
|
||||
@foreach($products as $product)
|
||||
<td class="text-center font-mono text-sm">{{ $product->sku }}</td>
|
||||
@endforeach
|
||||
@if($products->count() < 4)<td></td>@endif
|
||||
</tr>
|
||||
|
||||
{{-- Stock Status --}}
|
||||
<tr>
|
||||
<td class="font-semibold bg-base-200 sticky left-0">Availability</td>
|
||||
@foreach($products as $product)
|
||||
<td class="text-center">
|
||||
@if($product->isInStock())
|
||||
<span class="badge badge-success gap-1">
|
||||
<span class="icon-[heroicons--check-circle] size-4"></span>
|
||||
In Stock
|
||||
</span>
|
||||
@else
|
||||
<span class="badge badge-error gap-1">
|
||||
<span class="icon-[heroicons--x-circle] size-4"></span>
|
||||
Out of Stock
|
||||
</span>
|
||||
@endif
|
||||
</td>
|
||||
@endforeach
|
||||
@if($products->count() < 4)<td></td>@endif
|
||||
</tr>
|
||||
|
||||
{{-- Category --}}
|
||||
<tr>
|
||||
<td class="font-semibold bg-base-200 sticky left-0">Category</td>
|
||||
@foreach($products as $product)
|
||||
<td class="text-center">
|
||||
@if($product->category)
|
||||
<span class="badge badge-outline">{{ $product->category->name }}</span>
|
||||
@else
|
||||
<span class="text-base-content/50">-</span>
|
||||
@endif
|
||||
</td>
|
||||
@endforeach
|
||||
@if($products->count() < 4)<td></td>@endif
|
||||
</tr>
|
||||
|
||||
{{-- Strain Type --}}
|
||||
<tr>
|
||||
<td class="font-semibold bg-base-200 sticky left-0">Strain Type</td>
|
||||
@foreach($products as $product)
|
||||
<td class="text-center">
|
||||
@if($product->strain)
|
||||
<span class="badge badge-primary badge-outline">{{ ucfirst($product->strain->type) }}</span>
|
||||
@else
|
||||
<span class="text-base-content/50">-</span>
|
||||
@endif
|
||||
</td>
|
||||
@endforeach
|
||||
@if($products->count() < 4)<td></td>@endif
|
||||
</tr>
|
||||
|
||||
{{-- THC % --}}
|
||||
<tr>
|
||||
<td class="font-semibold bg-base-200 sticky left-0">THC Content</td>
|
||||
@foreach($products as $product)
|
||||
<td class="text-center">
|
||||
@if($product->thc_percentage)
|
||||
<span class="text-lg font-semibold text-primary">{{ $product->thc_percentage }}%</span>
|
||||
@else
|
||||
<span class="text-base-content/50">-</span>
|
||||
@endif
|
||||
</td>
|
||||
@endforeach
|
||||
@if($products->count() < 4)<td></td>@endif
|
||||
</tr>
|
||||
|
||||
{{-- CBD % --}}
|
||||
<tr>
|
||||
<td class="font-semibold bg-base-200 sticky left-0">CBD Content</td>
|
||||
@foreach($products as $product)
|
||||
<td class="text-center">
|
||||
@if($product->cbd_percentage)
|
||||
<span class="text-lg font-semibold text-secondary">{{ $product->cbd_percentage }}%</span>
|
||||
@else
|
||||
<span class="text-base-content/50">-</span>
|
||||
@endif
|
||||
</td>
|
||||
@endforeach
|
||||
@if($products->count() < 4)<td></td>@endif
|
||||
</tr>
|
||||
|
||||
{{-- Weight --}}
|
||||
<tr>
|
||||
<td class="font-semibold bg-base-200 sticky left-0">Net Weight</td>
|
||||
@foreach($products as $product)
|
||||
<td class="text-center">
|
||||
@if($product->net_weight)
|
||||
{{ $product->net_weight }} {{ $product->weight_unit }}
|
||||
@else
|
||||
<span class="text-base-content/50">-</span>
|
||||
@endif
|
||||
</td>
|
||||
@endforeach
|
||||
@if($products->count() < 4)<td></td>@endif
|
||||
</tr>
|
||||
|
||||
{{-- Units Per Case --}}
|
||||
<tr>
|
||||
<td class="font-semibold bg-base-200 sticky left-0">Units/Case</td>
|
||||
@foreach($products as $product)
|
||||
<td class="text-center">
|
||||
@if($product->units_per_case)
|
||||
{{ $product->units_per_case }}
|
||||
@else
|
||||
<span class="text-base-content/50">-</span>
|
||||
@endif
|
||||
</td>
|
||||
@endforeach
|
||||
@if($products->count() < 4)<td></td>@endif
|
||||
</tr>
|
||||
|
||||
{{-- Add to Cart Row --}}
|
||||
<tr>
|
||||
<td class="bg-base-200 sticky left-0"></td>
|
||||
@foreach($products as $product)
|
||||
<td class="text-center py-4">
|
||||
@if($product->isInStock())
|
||||
<button @click="addToCart({{ $product->id }})"
|
||||
class="btn btn-primary gap-2">
|
||||
<span class="icon-[heroicons--shopping-cart] size-5"></span>
|
||||
Add to Cart
|
||||
</button>
|
||||
@else
|
||||
<button disabled class="btn btn-disabled">Out of Stock</button>
|
||||
@endif
|
||||
</td>
|
||||
@endforeach
|
||||
@if($products->count() < 4)<td></td>@endif
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
function compareProducts() {
|
||||
return {
|
||||
async removeProduct(productId) {
|
||||
try {
|
||||
const response = await fetch(`/b/compare/remove/${productId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content'),
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
window.dispatchEvent(new CustomEvent('compare-updated'));
|
||||
window.location.reload();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to remove product:', error);
|
||||
}
|
||||
},
|
||||
|
||||
async clearAll() {
|
||||
try {
|
||||
const response = await fetch('/b/compare/clear', {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content'),
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
window.dispatchEvent(new CustomEvent('compare-updated'));
|
||||
window.location.reload();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to clear comparison:', error);
|
||||
}
|
||||
},
|
||||
|
||||
async addToCart(productId) {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('product_id', productId);
|
||||
formData.append('quantity', 1);
|
||||
|
||||
const business = document.querySelector('[data-business-slug]')?.dataset.businessSlug;
|
||||
if (!business) throw new Error('Business not found');
|
||||
|
||||
const response = await fetch(`/b/${business}/cart/add`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content'),
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok && data.success) {
|
||||
window.dispatchEvent(new CustomEvent('cart-updated', {
|
||||
detail: { count: data.cart_count }
|
||||
}));
|
||||
window.showToast?.('Added to cart', 'success');
|
||||
} else {
|
||||
throw new Error(data.message || 'Failed to add to cart');
|
||||
}
|
||||
} catch (error) {
|
||||
window.showToast?.(error.message, 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@endpush
|
||||
@@ -9,7 +9,7 @@
|
||||
<p class="text-base-content/60">Your conversations with brands</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a href="{{ route('buyer.crm.inbox.compose') }}" class="btn btn-primary">
|
||||
<a href="{{ route('buyer.crm.inbox.compose', $business) }}" class="btn btn-primary">
|
||||
<x-heroicon-o-pencil-square class="w-4 h-4" />
|
||||
Compose
|
||||
</a>
|
||||
@@ -22,7 +22,7 @@
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body p-4">
|
||||
<nav class="space-y-1">
|
||||
<a href="{{ route('buyer.crm.inbox.index', ['filter' => 'all']) }}"
|
||||
<a href="{{ route('buyer.crm.inbox.index', ['business' => $business, 'filter' => 'all']) }}"
|
||||
class="flex items-center justify-between px-3 py-2 rounded-lg {{ $filter === 'all' ? 'bg-primary text-primary-content' : 'hover:bg-base-200' }}">
|
||||
<span class="flex items-center gap-2">
|
||||
<x-heroicon-o-inbox class="w-5 h-5" />
|
||||
@@ -30,7 +30,7 @@
|
||||
</span>
|
||||
<span class="badge badge-sm {{ $filter === 'all' ? 'badge-primary-content' : '' }}">{{ $counts['all'] }}</span>
|
||||
</a>
|
||||
<a href="{{ route('buyer.crm.inbox.index', ['filter' => 'unread']) }}"
|
||||
<a href="{{ route('buyer.crm.inbox.index', ['business' => $business, 'filter' => 'unread']) }}"
|
||||
class="flex items-center justify-between px-3 py-2 rounded-lg {{ $filter === 'unread' ? 'bg-primary text-primary-content' : 'hover:bg-base-200' }}">
|
||||
<span class="flex items-center gap-2">
|
||||
<x-heroicon-o-envelope class="w-5 h-5" />
|
||||
@@ -40,7 +40,7 @@
|
||||
<span class="badge badge-primary badge-sm">{{ $counts['unread'] }}</span>
|
||||
@endif
|
||||
</a>
|
||||
<a href="{{ route('buyer.crm.inbox.index', ['filter' => 'starred']) }}"
|
||||
<a href="{{ route('buyer.crm.inbox.index', ['business' => $business, 'filter' => 'starred']) }}"
|
||||
class="flex items-center justify-between px-3 py-2 rounded-lg {{ $filter === 'starred' ? 'bg-primary text-primary-content' : 'hover:bg-base-200' }}">
|
||||
<span class="flex items-center gap-2">
|
||||
<x-heroicon-o-star class="w-5 h-5" />
|
||||
@@ -48,7 +48,7 @@
|
||||
</span>
|
||||
<span class="badge badge-sm {{ $filter === 'starred' ? 'badge-primary-content' : '' }}">{{ $counts['starred'] }}</span>
|
||||
</a>
|
||||
<a href="{{ route('buyer.crm.inbox.index', ['filter' => 'archived']) }}"
|
||||
<a href="{{ route('buyer.crm.inbox.index', ['business' => $business, 'filter' => 'archived']) }}"
|
||||
class="flex items-center justify-between px-3 py-2 rounded-lg {{ $filter === 'archived' ? 'bg-primary text-primary-content' : 'hover:bg-base-200' }}">
|
||||
<span class="flex items-center gap-2">
|
||||
<x-heroicon-o-archive-box class="w-5 h-5" />
|
||||
@@ -67,7 +67,7 @@
|
||||
<!-- Search & Actions Bar -->
|
||||
<div class="border-b border-base-300 p-4">
|
||||
<div class="flex flex-col sm:flex-row gap-3">
|
||||
<form class="flex-1" action="{{ route('buyer.crm.inbox.index') }}" method="GET">
|
||||
<form class="flex-1" action="{{ route('buyer.crm.inbox.index', $business) }}" method="GET">
|
||||
<input type="hidden" name="filter" value="{{ $filter }}" />
|
||||
<div class="join w-full">
|
||||
<input type="text" name="search" value="{{ $search }}"
|
||||
@@ -79,7 +79,7 @@
|
||||
</div>
|
||||
</form>
|
||||
@if($counts['unread'] > 0)
|
||||
<form action="{{ route('buyer.crm.inbox.mark-all-read') }}" method="POST">
|
||||
<form action="{{ route('buyer.crm.inbox.mark-all-read', $business) }}" method="POST">
|
||||
@csrf
|
||||
<button type="submit" class="btn btn-ghost btn-sm">
|
||||
<x-heroicon-o-check class="w-4 h-4" />
|
||||
@@ -93,7 +93,7 @@
|
||||
<!-- Threads -->
|
||||
<div class="divide-y divide-base-200">
|
||||
@forelse($threads as $thread)
|
||||
<a href="{{ route('buyer.crm.inbox.show', $thread) }}"
|
||||
<a href="{{ route('buyer.crm.inbox.show', [$business, $thread]) }}"
|
||||
class="flex items-start gap-4 p-4 hover:bg-base-200 transition-colors {{ ($thread->unread_count ?? 0) > 0 ? 'bg-primary/5' : '' }}">
|
||||
<!-- Avatar -->
|
||||
<div class="avatar placeholder flex-shrink-0">
|
||||
@@ -150,7 +150,7 @@
|
||||
@else
|
||||
<p class="text-lg font-medium">No messages yet</p>
|
||||
<p class="text-sm mb-4">Start a conversation with a brand</p>
|
||||
<a href="{{ route('buyer.crm.inbox.compose') }}" class="btn btn-primary">
|
||||
<a href="{{ route('buyer.crm.inbox.compose', $business) }}" class="btn btn-primary">
|
||||
<x-heroicon-o-pencil-square class="w-4 h-4" />
|
||||
Compose Message
|
||||
</a>
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
@extends('layouts.buyer')
|
||||
@extends('layouts.buyer-app-with-sidebar')
|
||||
|
||||
@php
|
||||
$business = $business ?? request()->route('business');
|
||||
@endphp
|
||||
|
||||
@section('content')
|
||||
<div class="flex min-h-screen bg-base-200">
|
||||
@@ -6,19 +10,19 @@
|
||||
<aside class="w-64 bg-base-100 border-r border-base-300 flex-shrink-0 hidden lg:block">
|
||||
<div class="p-4 border-b border-base-300">
|
||||
<h2 class="font-semibold text-lg">CRM Portal</h2>
|
||||
<p class="text-sm text-base-content/60">{{ auth()->user()->business->name ?? 'Your Business' }}</p>
|
||||
<p class="text-sm text-base-content/60">{{ $business->name ?? 'Your Business' }}</p>
|
||||
</div>
|
||||
|
||||
<nav class="p-4 space-y-1">
|
||||
<!-- Dashboard -->
|
||||
<a href="{{ route('buyer.crm.dashboard') }}"
|
||||
<a href="{{ route('buyer.crm.dashboard', $business) }}"
|
||||
class="flex items-center gap-3 px-3 py-2 rounded-lg {{ request()->routeIs('buyer.crm.dashboard') ? 'bg-primary text-primary-content' : 'hover:bg-base-200' }}">
|
||||
<x-heroicon-o-home class="w-5 h-5" />
|
||||
<span>Dashboard</span>
|
||||
</a>
|
||||
|
||||
<!-- Inbox -->
|
||||
<a href="{{ route('buyer.crm.inbox.index') }}"
|
||||
<a href="{{ route('buyer.crm.inbox.index', $business) }}"
|
||||
class="flex items-center gap-3 px-3 py-2 rounded-lg {{ request()->routeIs('buyer.crm.inbox.*') ? 'bg-primary text-primary-content' : 'hover:bg-base-200' }}">
|
||||
<x-heroicon-o-inbox class="w-5 h-5" />
|
||||
<span>Inbox</span>
|
||||
@@ -28,21 +32,21 @@
|
||||
</a>
|
||||
|
||||
<!-- Orders -->
|
||||
<a href="{{ route('buyer.crm.orders.index') }}"
|
||||
<a href="{{ route('buyer.crm.orders.index', $business) }}"
|
||||
class="flex items-center gap-3 px-3 py-2 rounded-lg {{ request()->routeIs('buyer.crm.orders.*') ? 'bg-primary text-primary-content' : 'hover:bg-base-200' }}">
|
||||
<x-heroicon-o-shopping-bag class="w-5 h-5" />
|
||||
<span>Orders</span>
|
||||
</a>
|
||||
|
||||
<!-- Quotes -->
|
||||
<a href="{{ route('buyer.crm.quotes.index') }}"
|
||||
<a href="{{ route('buyer.crm.quotes.index', $business) }}"
|
||||
class="flex items-center gap-3 px-3 py-2 rounded-lg {{ request()->routeIs('buyer.crm.quotes.*') ? 'bg-primary text-primary-content' : 'hover:bg-base-200' }}">
|
||||
<x-heroicon-o-document-text class="w-5 h-5" />
|
||||
<span>Quotes</span>
|
||||
</a>
|
||||
|
||||
<!-- Invoices -->
|
||||
<a href="{{ route('buyer.crm.invoices.index') }}"
|
||||
<a href="{{ route('buyer.crm.invoices.index', $business) }}"
|
||||
class="flex items-center gap-3 px-3 py-2 rounded-lg {{ request()->routeIs('buyer.crm.invoices.*') ? 'bg-primary text-primary-content' : 'hover:bg-base-200' }}">
|
||||
<x-heroicon-o-receipt-percent class="w-5 h-5" />
|
||||
<span>Invoices</span>
|
||||
@@ -51,14 +55,14 @@
|
||||
<div class="divider my-2"></div>
|
||||
|
||||
<!-- Brands -->
|
||||
<a href="{{ route('buyer.crm.brands.index') }}"
|
||||
<a href="{{ route('buyer.crm.brands.index', $business) }}"
|
||||
class="flex items-center gap-3 px-3 py-2 rounded-lg {{ request()->routeIs('buyer.crm.brands.*') ? 'bg-primary text-primary-content' : 'hover:bg-base-200' }}">
|
||||
<x-heroicon-o-building-storefront class="w-5 h-5" />
|
||||
<span>Brands</span>
|
||||
</a>
|
||||
|
||||
<!-- Bookmarks -->
|
||||
<a href="{{ route('buyer.crm.bookmarks.index') }}"
|
||||
<a href="{{ route('buyer.crm.bookmarks.index', $business) }}"
|
||||
class="flex items-center gap-3 px-3 py-2 rounded-lg {{ request()->routeIs('buyer.crm.bookmarks.*') ? 'bg-primary text-primary-content' : 'hover:bg-base-200' }}">
|
||||
<x-heroicon-o-bookmark class="w-5 h-5" />
|
||||
<span>Bookmarks</span>
|
||||
@@ -67,14 +71,14 @@
|
||||
<div class="divider my-2"></div>
|
||||
|
||||
<!-- Analytics -->
|
||||
<a href="{{ route('buyer.crm.analytics.index') }}"
|
||||
<a href="{{ route('buyer.crm.analytics.index', $business) }}"
|
||||
class="flex items-center gap-3 px-3 py-2 rounded-lg {{ request()->routeIs('buyer.crm.analytics.*') ? 'bg-primary text-primary-content' : 'hover:bg-base-200' }}">
|
||||
<x-heroicon-o-chart-bar class="w-5 h-5" />
|
||||
<span>Analytics</span>
|
||||
</a>
|
||||
|
||||
<!-- Tasks -->
|
||||
<a href="{{ route('buyer.crm.tasks.index') }}"
|
||||
<a href="{{ route('buyer.crm.tasks.index', $business) }}"
|
||||
class="flex items-center gap-3 px-3 py-2 rounded-lg {{ request()->routeIs('buyer.crm.tasks.*') ? 'bg-primary text-primary-content' : 'hover:bg-base-200' }}">
|
||||
<x-heroicon-o-clipboard-document-check class="w-5 h-5" />
|
||||
<span>Tasks</span>
|
||||
@@ -84,7 +88,7 @@
|
||||
</a>
|
||||
|
||||
<!-- Team -->
|
||||
<a href="{{ route('buyer.crm.team.index') }}"
|
||||
<a href="{{ route('buyer.crm.team.index', $business) }}"
|
||||
class="flex items-center gap-3 px-3 py-2 rounded-lg {{ request()->routeIs('buyer.crm.team.*') ? 'bg-primary text-primary-content' : 'hover:bg-base-200' }}">
|
||||
<x-heroicon-o-user-group class="w-5 h-5" />
|
||||
<span>Team</span>
|
||||
@@ -93,7 +97,7 @@
|
||||
<div class="divider my-2"></div>
|
||||
|
||||
<!-- Settings -->
|
||||
<a href="{{ route('buyer.crm.settings.index') }}"
|
||||
<a href="{{ route('buyer.crm.settings.index', $business) }}"
|
||||
class="flex items-center gap-3 px-3 py-2 rounded-lg {{ request()->routeIs('buyer.crm.settings.*') ? 'bg-primary text-primary-content' : 'hover:bg-base-200' }}">
|
||||
<x-heroicon-o-cog-6-tooth class="w-5 h-5" />
|
||||
<span>Settings</span>
|
||||
@@ -142,50 +146,50 @@
|
||||
</div>
|
||||
<nav class="p-4 space-y-1">
|
||||
<!-- Same nav items as desktop -->
|
||||
<a href="{{ route('buyer.crm.dashboard') }}" class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-base-200">
|
||||
<a href="{{ route('buyer.crm.dashboard', $business) }}" class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-base-200">
|
||||
<x-heroicon-o-home class="w-5 h-5" />
|
||||
<span>Dashboard</span>
|
||||
</a>
|
||||
<a href="{{ route('buyer.crm.inbox.index') }}" class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-base-200">
|
||||
<a href="{{ route('buyer.crm.inbox.index', $business) }}" class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-base-200">
|
||||
<x-heroicon-o-inbox class="w-5 h-5" />
|
||||
<span>Inbox</span>
|
||||
</a>
|
||||
<a href="{{ route('buyer.crm.orders.index') }}" class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-base-200">
|
||||
<a href="{{ route('buyer.crm.orders.index', $business) }}" class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-base-200">
|
||||
<x-heroicon-o-shopping-bag class="w-5 h-5" />
|
||||
<span>Orders</span>
|
||||
</a>
|
||||
<a href="{{ route('buyer.crm.quotes.index') }}" class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-base-200">
|
||||
<a href="{{ route('buyer.crm.quotes.index', $business) }}" class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-base-200">
|
||||
<x-heroicon-o-document-text class="w-5 h-5" />
|
||||
<span>Quotes</span>
|
||||
</a>
|
||||
<a href="{{ route('buyer.crm.invoices.index') }}" class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-base-200">
|
||||
<a href="{{ route('buyer.crm.invoices.index', $business) }}" class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-base-200">
|
||||
<x-heroicon-o-receipt-percent class="w-5 h-5" />
|
||||
<span>Invoices</span>
|
||||
</a>
|
||||
<div class="divider my-2"></div>
|
||||
<a href="{{ route('buyer.crm.brands.index') }}" class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-base-200">
|
||||
<a href="{{ route('buyer.crm.brands.index', $business) }}" class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-base-200">
|
||||
<x-heroicon-o-building-storefront class="w-5 h-5" />
|
||||
<span>Brands</span>
|
||||
</a>
|
||||
<a href="{{ route('buyer.crm.bookmarks.index') }}" class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-base-200">
|
||||
<a href="{{ route('buyer.crm.bookmarks.index', $business) }}" class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-base-200">
|
||||
<x-heroicon-o-bookmark class="w-5 h-5" />
|
||||
<span>Bookmarks</span>
|
||||
</a>
|
||||
<div class="divider my-2"></div>
|
||||
<a href="{{ route('buyer.crm.analytics.index') }}" class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-base-200">
|
||||
<a href="{{ route('buyer.crm.analytics.index', $business) }}" class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-base-200">
|
||||
<x-heroicon-o-chart-bar class="w-5 h-5" />
|
||||
<span>Analytics</span>
|
||||
</a>
|
||||
<a href="{{ route('buyer.crm.tasks.index') }}" class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-base-200">
|
||||
<a href="{{ route('buyer.crm.tasks.index', $business) }}" class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-base-200">
|
||||
<x-heroicon-o-clipboard-document-check class="w-5 h-5" />
|
||||
<span>Tasks</span>
|
||||
</a>
|
||||
<a href="{{ route('buyer.crm.team.index') }}" class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-base-200">
|
||||
<a href="{{ route('buyer.crm.team.index', $business) }}" class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-base-200">
|
||||
<x-heroicon-o-user-group class="w-5 h-5" />
|
||||
<span>Team</span>
|
||||
</a>
|
||||
<div class="divider my-2"></div>
|
||||
<a href="{{ route('buyer.crm.settings.index') }}" class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-base-200">
|
||||
<a href="{{ route('buyer.crm.settings.index', $business) }}" class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-base-200">
|
||||
<x-heroicon-o-cog-6-tooth class="w-5 h-5" />
|
||||
<span>Settings</span>
|
||||
</a>
|
||||
|
||||
@@ -1,65 +1,262 @@
|
||||
@extends('layouts.buyer-app-with-sidebar')
|
||||
|
||||
@section('title', 'Brand Directory - ' . config('app.name'))
|
||||
|
||||
@section('content')
|
||||
<div class="container-fluid py-6">
|
||||
<!-- Page Header -->
|
||||
<div class="mb-6">
|
||||
<h1 class="text-3xl font-bold text-gray-900">Our Brands</h1>
|
||||
<p class="text-gray-600 mt-1">Explore our collection of premium cannabis brands</p>
|
||||
<div class="min-h-screen" x-data="{ viewMode: localStorage.getItem('brand-view') || 'grid' }"
|
||||
x-init="$watch('viewMode', v => localStorage.setItem('brand-view', v))">
|
||||
|
||||
{{-- Hero Section --}}
|
||||
<div class="bg-gradient-to-br from-primary via-primary to-primary-focus text-primary-content rounded-xl p-8 mb-8 relative overflow-hidden">
|
||||
<div class="absolute inset-0 opacity-10">
|
||||
<div class="absolute top-0 right-0 w-1/2 h-full bg-white/20 skew-x-12 transform origin-top-right"></div>
|
||||
</div>
|
||||
<div class="relative z-10 max-w-2xl">
|
||||
<h1 class="text-3xl md:text-4xl font-bold mb-3">Brand Directory</h1>
|
||||
<p class="text-lg opacity-90 mb-6">
|
||||
Discover our curated collection of premium cannabis brands. Browse by name or explore featured partners.
|
||||
</p>
|
||||
|
||||
{{-- Search Bar --}}
|
||||
<form method="GET" action="{{ route('buyer.brands.index') }}" class="flex gap-2">
|
||||
@if(request('sort'))
|
||||
<input type="hidden" name="sort" value="{{ request('sort') }}">
|
||||
@endif
|
||||
<div class="relative flex-1">
|
||||
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-primary-content/60">
|
||||
<span class="icon-[heroicons--magnifying-glass] size-5"></span>
|
||||
</span>
|
||||
<input type="text"
|
||||
name="search"
|
||||
value="{{ request('search') }}"
|
||||
placeholder="Search brands..."
|
||||
class="input w-full pl-10 bg-white/20 border-white/30 placeholder-primary-content/60 text-primary-content focus:bg-white/30">
|
||||
</div>
|
||||
<button type="submit" class="btn bg-white/20 border-white/30 hover:bg-white/30 text-primary-content">
|
||||
Search
|
||||
</button>
|
||||
@if(request('search'))
|
||||
<a href="{{ route('buyer.brands.index', request()->except('search')) }}" class="btn btn-ghost text-primary-content">
|
||||
Clear
|
||||
</a>
|
||||
@endif
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Brands Grid -->
|
||||
@if($brands->count() > 0)
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
@foreach($brands as $brand)
|
||||
<a href="{{ route('buyer.brands.show', $brand->slug) }}" class="card bg-base-100 shadow-lg hover:shadow-xl transition-all hover:-translate-y-1 duration-300">
|
||||
<div class="card-body">
|
||||
<!-- Brand Logo -->
|
||||
<div class="flex items-center justify-center h-32 mb-4">
|
||||
@if($brand->logo_path)
|
||||
<img src="{{ asset($brand->logo_path) }}" alt="{{ $brand->name }}" class="max-h-full max-w-full object-contain">
|
||||
{{-- Featured Brands (only show when not searching) --}}
|
||||
@if(!request('search') && $featuredBrands->count() > 0)
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-xl font-bold flex items-center gap-2">
|
||||
<span class="icon-[heroicons--star] size-6 text-warning"></span>
|
||||
Featured Brands
|
||||
</h2>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
@foreach($featuredBrands as $brand)
|
||||
<a href="{{ route('buyer.brands.show', $brand->slug) }}"
|
||||
class="card bg-base-100 shadow-lg hover:shadow-xl transition-all hover:-translate-y-1 duration-300 overflow-hidden">
|
||||
{{-- Banner/Hero Image --}}
|
||||
<div class="h-24 bg-gradient-to-br from-primary/20 to-secondary/20 relative">
|
||||
@if($brand->getBannerUrl('medium'))
|
||||
<img src="{{ $brand->getBannerUrl('medium') }}"
|
||||
alt="{{ $brand->name }}"
|
||||
class="w-full h-full object-cover">
|
||||
@endif
|
||||
{{-- Logo overlay --}}
|
||||
<div class="absolute -bottom-8 left-4">
|
||||
<div class="w-16 h-16 bg-base-100 rounded-xl shadow-lg flex items-center justify-center overflow-hidden border-2 border-base-100">
|
||||
@if($brand->getLogoUrl('thumb'))
|
||||
<img src="{{ $brand->getLogoUrl('thumb') }}"
|
||||
alt="{{ $brand->name }}"
|
||||
class="w-full h-full object-contain p-1">
|
||||
@else
|
||||
<div class="flex items-center justify-center h-full w-full bg-base-200 rounded-lg">
|
||||
<span class="icon-[heroicons--building-storefront] size-16 text-base-content/30"></span>
|
||||
</div>
|
||||
<span class="text-2xl font-bold text-base-content/30">{{ substr($brand->name, 0, 1) }}</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body pt-10 pb-4">
|
||||
<h3 class="font-bold truncate">{{ $brand->name }}</h3>
|
||||
<p class="text-sm text-base-content/60">{{ $brand->products_count }} products</p>
|
||||
</div>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- Brand Name -->
|
||||
<h3 class="card-title text-lg justify-center text-center">{{ $brand->name }}</h3>
|
||||
{{-- Controls Bar --}}
|
||||
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6">
|
||||
<div class="text-sm text-base-content/70">
|
||||
<span class="font-semibold text-base-content">{{ $brands->count() }}</span> brands
|
||||
@if(request('search'))
|
||||
matching "<span class="font-medium">{{ request('search') }}</span>"
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<!-- Brand Tagline -->
|
||||
@if($brand->tagline)
|
||||
<p class="text-sm text-center text-base-content/70 line-clamp-2">{{ $brand->tagline }}</p>
|
||||
@endif
|
||||
<div class="flex items-center gap-3">
|
||||
{{-- Sort --}}
|
||||
<form method="GET" action="{{ route('buyer.brands.index') }}" class="flex items-center gap-2">
|
||||
@if(request('search'))
|
||||
<input type="hidden" name="search" value="{{ request('search') }}">
|
||||
@endif
|
||||
<label class="text-sm text-base-content/70">Sort:</label>
|
||||
<select name="sort" class="select select-bordered select-sm" onchange="this.form.submit()">
|
||||
<option value="name" {{ request('sort', 'name') === 'name' ? 'selected' : '' }}>Name (A-Z)</option>
|
||||
<option value="name_desc" {{ request('sort') === 'name_desc' ? 'selected' : '' }}>Name (Z-A)</option>
|
||||
<option value="products" {{ request('sort') === 'products' ? 'selected' : '' }}>Most Products</option>
|
||||
<option value="newest" {{ request('sort') === 'newest' ? 'selected' : '' }}>Newest</option>
|
||||
</select>
|
||||
</form>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="divider my-2"></div>
|
||||
<div class="flex items-center justify-center gap-4 text-sm">
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="icon-[heroicons--cube] size-4 text-primary"></span>
|
||||
<span class="font-medium">{{ $brand->products_count }} {{ Str::plural('product', $brand->products_count) }}</span>
|
||||
{{-- View Toggle --}}
|
||||
<div class="btn-group">
|
||||
<button @click="viewMode = 'grid'"
|
||||
class="btn btn-sm"
|
||||
:class="viewMode === 'grid' ? 'btn-active' : ''"
|
||||
title="Grid view">
|
||||
<span class="icon-[heroicons--squares-2x2] size-4"></span>
|
||||
</button>
|
||||
<button @click="viewMode = 'list'"
|
||||
class="btn btn-sm"
|
||||
:class="viewMode === 'list' ? 'btn-active' : ''"
|
||||
title="List view">
|
||||
<span class="icon-[heroicons--bars-3] size-4"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if($brands->count() > 0)
|
||||
{{-- Alphabet Quick Jump (only in grid view without search) --}}
|
||||
@if(!request('search') && $alphabetGroups->count() > 3)
|
||||
<div class="hidden md:flex flex-wrap gap-1 mb-6" x-show="viewMode === 'grid'">
|
||||
@foreach($alphabetGroups->keys()->sort() as $letter)
|
||||
<a href="#brand-{{ $letter }}"
|
||||
class="btn btn-sm btn-ghost font-mono">{{ $letter }}</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Grid View --}}
|
||||
<div x-show="viewMode === 'grid'" x-cloak>
|
||||
@if(request('search'))
|
||||
{{-- Simple grid when searching --}}
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||
@foreach($brands as $brand)
|
||||
<a href="{{ route('buyer.brands.show', $brand->slug) }}"
|
||||
class="card bg-base-100 shadow hover:shadow-lg transition-all hover:-translate-y-1 duration-200">
|
||||
<div class="card-body p-4 items-center text-center">
|
||||
<div class="w-20 h-20 flex items-center justify-center mb-2">
|
||||
@if($brand->getLogoUrl('thumb'))
|
||||
<img src="{{ $brand->getLogoUrl('thumb') }}"
|
||||
alt="{{ $brand->name }}"
|
||||
class="max-w-full max-h-full object-contain">
|
||||
@else
|
||||
<div class="w-20 h-20 rounded-xl bg-base-200 flex items-center justify-center">
|
||||
<span class="text-3xl font-bold text-base-content/20">{{ substr($brand->name, 0, 1) }}</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
<h3 class="font-semibold text-sm truncate w-full">{{ $brand->name }}</h3>
|
||||
<span class="text-xs text-base-content/60">{{ $brand->products_count }} products</span>
|
||||
</div>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
{{-- Grouped by letter --}}
|
||||
@foreach($alphabetGroups->sortKeys() as $letter => $letterBrands)
|
||||
<div id="brand-{{ $letter }}" class="mb-8 scroll-mt-6">
|
||||
<h3 class="text-2xl font-bold text-primary mb-4 flex items-center gap-3">
|
||||
<span class="w-10 h-10 rounded-lg bg-primary text-primary-content flex items-center justify-center">{{ $letter }}</span>
|
||||
<span class="text-base-content/30 text-sm font-normal">{{ $letterBrands->count() }} brands</span>
|
||||
</h3>
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||
@foreach($letterBrands as $brand)
|
||||
<a href="{{ route('buyer.brands.show', $brand->slug) }}"
|
||||
class="card bg-base-100 shadow hover:shadow-lg transition-all hover:-translate-y-1 duration-200">
|
||||
<div class="card-body p-4 items-center text-center">
|
||||
<div class="w-20 h-20 flex items-center justify-center mb-2">
|
||||
@if($brand->getLogoUrl('thumb'))
|
||||
<img src="{{ $brand->getLogoUrl('thumb') }}"
|
||||
alt="{{ $brand->name }}"
|
||||
class="max-w-full max-h-full object-contain">
|
||||
@else
|
||||
<div class="w-20 h-20 rounded-xl bg-base-200 flex items-center justify-center">
|
||||
<span class="text-3xl font-bold text-base-content/20">{{ substr($brand->name, 0, 1) }}</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
<h3 class="font-semibold text-sm truncate w-full">{{ $brand->name }}</h3>
|
||||
@if($brand->tagline)
|
||||
<p class="text-xs text-base-content/50 line-clamp-1">{{ $brand->tagline }}</p>
|
||||
@endif
|
||||
<span class="text-xs text-primary font-medium">{{ $brand->products_count }} products</span>
|
||||
</div>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- List View --}}
|
||||
<div x-show="viewMode === 'list'" x-cloak class="space-y-3">
|
||||
@foreach($brands as $brand)
|
||||
<a href="{{ route('buyer.brands.show', $brand->slug) }}"
|
||||
class="card card-side bg-base-100 shadow hover:shadow-lg transition-shadow overflow-hidden">
|
||||
{{-- Logo --}}
|
||||
<figure class="w-24 md:w-32 flex-shrink-0 bg-base-200">
|
||||
@if($brand->getLogoUrl('thumb'))
|
||||
<img src="{{ $brand->getLogoUrl('thumb') }}"
|
||||
alt="{{ $brand->name }}"
|
||||
class="w-full h-full object-contain p-3">
|
||||
@else
|
||||
<div class="flex items-center justify-center h-full">
|
||||
<span class="text-3xl font-bold text-base-content/20">{{ substr($brand->name, 0, 1) }}</span>
|
||||
</div>
|
||||
@endif
|
||||
</figure>
|
||||
|
||||
<!-- CTA -->
|
||||
<div class="card-actions justify-center mt-4">
|
||||
<div class="btn btn-primary btn-sm">
|
||||
<span class="icon-[heroicons--arrow-right] size-4"></span>
|
||||
Shop Brand
|
||||
<div class="card-body p-4">
|
||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2">
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="font-bold text-lg">{{ $brand->name }}</h3>
|
||||
@if($brand->tagline)
|
||||
<p class="text-sm text-base-content/60 line-clamp-1">{{ $brand->tagline }}</p>
|
||||
@endif
|
||||
</div>
|
||||
<div class="flex items-center gap-4 flex-shrink-0">
|
||||
<div class="text-right">
|
||||
<div class="text-lg font-bold text-primary">{{ $brand->products_count }}</div>
|
||||
<div class="text-xs text-base-content/60">products</div>
|
||||
</div>
|
||||
<span class="icon-[heroicons--arrow-right] size-5 text-base-content/40"></span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
<!-- Empty State -->
|
||||
{{-- Empty State --}}
|
||||
<div class="card bg-base-100 shadow-lg">
|
||||
<div class="card-body text-center py-16">
|
||||
<span class="icon-[heroicons--building-storefront] size-16 text-gray-300 mx-auto mb-4"></span>
|
||||
<h3 class="text-xl font-semibold text-gray-700 mb-2">No Brands Available</h3>
|
||||
<p class="text-gray-500">Check back soon for our brand collection</p>
|
||||
<span class="icon-[heroicons--magnifying-glass] size-16 text-base-content/20 mx-auto mb-4"></span>
|
||||
@if(request('search'))
|
||||
<h3 class="text-xl font-semibold text-base-content/70 mb-2">No brands found</h3>
|
||||
<p class="text-base-content/50 mb-4">No brands match "{{ request('search') }}"</p>
|
||||
<a href="{{ route('buyer.brands.index') }}" class="btn btn-primary btn-sm">
|
||||
View All Brands
|
||||
</a>
|
||||
@else
|
||||
<h3 class="text-xl font-semibold text-base-content/70 mb-2">No Brands Available</h3>
|
||||
<p class="text-base-content/50">Check back soon for our brand collection</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@@ -185,6 +185,23 @@
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Recently Viewed Section (only show on homepage if user has history) --}}
|
||||
@if(!$hasFilters && isset($recentlyViewed) && $recentlyViewed->count() > 0)
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-xl font-bold flex items-center gap-2">
|
||||
<span class="icon-[heroicons--clock] size-6 text-base-content/60"></span>
|
||||
Recently Viewed
|
||||
</h2>
|
||||
</div>
|
||||
<div class="flex gap-4 overflow-x-auto pb-4 snap-x snap-mandatory scrollbar-hide -mx-2 px-2">
|
||||
@foreach($recentlyViewed as $product)
|
||||
<x-marketplace.product-card :product="$product" variant="compact" :business="$business" />
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Main Products Section --}}
|
||||
<div class="flex flex-col lg:flex-row gap-6">
|
||||
{{-- Filters Sidebar --}}
|
||||
@@ -476,13 +493,13 @@ function marketplaceBrowse() {
|
||||
return Object.values(this.cart).reduce((sum, item) => sum + item.quantity, 0);
|
||||
},
|
||||
|
||||
async addToCart(productId) {
|
||||
async addToCart(productId, quantity = 1) {
|
||||
// Optimistic update
|
||||
this.cart = {
|
||||
...this.cart,
|
||||
[productId]: {
|
||||
cartId: 'pending',
|
||||
quantity: 1
|
||||
quantity: quantity
|
||||
}
|
||||
};
|
||||
|
||||
@@ -493,7 +510,7 @@ function marketplaceBrowse() {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('product_id', productId);
|
||||
formData.append('quantity', 1);
|
||||
formData.append('quantity', quantity);
|
||||
|
||||
const response = await window.axios.post('{{ route("buyer.business.cart.add", $business->slug) }}', formData);
|
||||
const data = response.data;
|
||||
@@ -506,7 +523,7 @@ function marketplaceBrowse() {
|
||||
quantity: data.cart_item.quantity
|
||||
}
|
||||
};
|
||||
window.showToast('Added to cart', 'success');
|
||||
window.showToast(`Added ${quantity} to cart`, 'success');
|
||||
} else {
|
||||
const newCart = { ...this.cart };
|
||||
delete newCart[productId];
|
||||
@@ -531,12 +548,12 @@ function marketplaceBrowse() {
|
||||
init() {
|
||||
// Listen for add-to-cart events from product cards
|
||||
this.$el.addEventListener('add-to-cart', (e) => {
|
||||
this.addToCart(e.detail.productId);
|
||||
this.addToCart(e.detail.productId, e.detail.quantity || 1);
|
||||
});
|
||||
|
||||
// Quick add (from hover overlay)
|
||||
this.$el.addEventListener('quick-add-to-cart', (e) => {
|
||||
this.addToCart(e.detail.productId);
|
||||
this.addToCart(e.detail.productId, e.detail.quantity || 1);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -368,51 +368,24 @@
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
@foreach($relatedProducts as $relatedProduct)
|
||||
<div class="card bg-base-100 shadow-lg hover:shadow-xl transition-shadow">
|
||||
<figure class="relative h-48 bg-gray-100">
|
||||
@php
|
||||
$relatedImage = $relatedProduct->images()->where('is_primary', true)->first()
|
||||
?? $relatedProduct->images()->first();
|
||||
@endphp
|
||||
@if($relatedImage)
|
||||
<img
|
||||
src="{{ asset('storage/' . $relatedImage->path) }}"
|
||||
alt="{{ $relatedProduct->name }}"
|
||||
class="w-full h-full object-cover"
|
||||
>
|
||||
@else
|
||||
<div class="flex items-center justify-center h-full w-full">
|
||||
<span class="icon-[lucide--image] size-12 text-gray-300"></span>
|
||||
</div>
|
||||
@endif
|
||||
</figure>
|
||||
<x-marketplace.product-card :product="$relatedProduct" variant="grid" :business="$business" />
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="card-body">
|
||||
<h4 class="card-title text-base">
|
||||
<a href="{{ route('buyer.brands.products.show', [$product->brand->slug, $relatedProduct->slug ?? $relatedProduct->id]) }}"
|
||||
class="hover:text-primary"
|
||||
data-track-click="related-product"
|
||||
data-track-id="{{ $relatedProduct->id }}"
|
||||
data-track-label="{{ $relatedProduct->name }}">
|
||||
{{ $relatedProduct->name }}
|
||||
</a>
|
||||
</h4>
|
||||
|
||||
<div class="text-lg font-bold text-primary">
|
||||
${{ number_format($relatedProduct->wholesale_price, 2) }}
|
||||
</div>
|
||||
|
||||
<div class="card-actions justify-end mt-2">
|
||||
<a href="{{ route('buyer.brands.products.show', [$product->brand->slug, $relatedProduct->slug ?? $relatedProduct->id]) }}"
|
||||
class="btn btn-primary btn-sm"
|
||||
data-track-click="related-product-cta"
|
||||
data-track-id="{{ $relatedProduct->id }}"
|
||||
data-track-label="View {{ $relatedProduct->name }}">
|
||||
View Details
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Recently Viewed Products -->
|
||||
@if(isset($recentlyViewed) && $recentlyViewed->count() > 0)
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-xl font-bold flex items-center gap-2">
|
||||
<span class="icon-[heroicons--clock] size-6 text-base-content/60"></span>
|
||||
Recently Viewed
|
||||
</h3>
|
||||
</div>
|
||||
<div class="flex gap-4 overflow-x-auto pb-4 snap-x snap-mandatory scrollbar-hide -mx-2 px-2">
|
||||
@foreach($recentlyViewed as $viewedProduct)
|
||||
<x-marketplace.product-card :product="$viewedProduct" variant="compact" :business="$business" />
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -86,9 +86,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a class="menu-item" href="#">
|
||||
<span class="icon-[heroicons--megaphone] size-4"></span>
|
||||
<span class="grow">Promotion</span>
|
||||
<a class="menu-item {{ request()->routeIs('buyer.deals') ? 'active' : '' }}" href="{{ route('buyer.deals') }}">
|
||||
<span class="icon-[heroicons--tag] size-4"></span>
|
||||
<span class="grow">Deals</span>
|
||||
</a>
|
||||
|
||||
<a class="menu-item {{ request()->routeIs('buyer.browse*') ? 'active' : '' }}" href="{{ route('buyer.browse') }}">
|
||||
@@ -96,6 +96,11 @@
|
||||
<span class="grow">Shop</span>
|
||||
</a>
|
||||
|
||||
<a class="menu-item {{ request()->routeIs('buyer.business.buy-again') ? 'active' : '' }}" href="{{ route('buyer.business.buy-again', auth()->user()->businesses->first()->slug) }}">
|
||||
<span class="icon-[heroicons--arrow-path-rounded-square] size-4"></span>
|
||||
<span class="grow">Buy It Again</span>
|
||||
</a>
|
||||
|
||||
<div class="group collapse">
|
||||
<input
|
||||
aria-label="Sidemenu item trigger"
|
||||
|
||||
120
resources/views/components/buyer-topbar-account.blade.php
Normal file
120
resources/views/components/buyer-topbar-account.blade.php
Normal file
@@ -0,0 +1,120 @@
|
||||
{{--
|
||||
Buyer Topbar Account Dropdown
|
||||
|
||||
Positioned in the top-right navbar, next to alerts.
|
||||
Settings is accessible ONLY from this dropdown (not in sidebar).
|
||||
|
||||
Structure:
|
||||
- Profile
|
||||
- Password
|
||||
- Settings (Owner/Admin only)
|
||||
- Sign Out
|
||||
--}}
|
||||
|
||||
@php
|
||||
$business = auth()->user()?->businesses->first();
|
||||
$user = auth()->user();
|
||||
$isOwner = $business && $business->owner_user_id === $user->id;
|
||||
$isSuperAdmin = $user->user_type === 'admin';
|
||||
$canManageSettings = $isOwner || $isSuperAdmin;
|
||||
@endphp
|
||||
|
||||
<div x-data="{ accountOpen: false }" class="relative">
|
||||
{{-- Avatar Button (always visible) --}}
|
||||
<button
|
||||
@click="accountOpen = !accountOpen"
|
||||
class="btn btn-sm btn-circle btn-ghost"
|
||||
aria-label="Account Menu">
|
||||
<div class="w-8 h-8 rounded-full bg-primary flex items-center justify-center text-primary-content text-xs font-semibold">
|
||||
{{ strtoupper(substr($user->first_name ?? 'U', 0, 1)) }}{{ strtoupper(substr($user->last_name ?? 'S', 0, 1)) }}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{{-- Dropdown Menu (opens downward from topbar) --}}
|
||||
<div x-show="accountOpen"
|
||||
x-cloak
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0 scale-95"
|
||||
x-transition:enter-end="opacity-100 scale-100"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100 scale-100"
|
||||
x-transition:leave-end="opacity-0 scale-95"
|
||||
@click.away="accountOpen = false"
|
||||
class="absolute right-0 top-full mt-2 bg-base-100 rounded-lg shadow-xl border border-base-200 z-50 w-56">
|
||||
|
||||
{{-- User Info Header --}}
|
||||
<div class="px-4 py-3 border-b border-base-200">
|
||||
<div class="font-medium text-sm truncate">
|
||||
{{ trim(($user->first_name ?? '') . ' ' . ($user->last_name ?? '')) ?: 'User' }}
|
||||
</div>
|
||||
<div class="text-xs text-base-content/60 truncate">{{ $user->email }}</div>
|
||||
@if($business)
|
||||
<div class="text-xs text-base-content/50 truncate mt-0.5">{{ $business->name }}</div>
|
||||
@endif
|
||||
<div class="mt-1">
|
||||
@if($isOwner)
|
||||
<span class="badge badge-primary badge-xs">Owner</span>
|
||||
@elseif($isSuperAdmin)
|
||||
<span class="badge badge-error badge-xs">Super Admin</span>
|
||||
@else
|
||||
<span class="badge badge-ghost badge-xs">Team Member</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-2">
|
||||
<ul class="space-y-0.5">
|
||||
{{-- Profile --}}
|
||||
<li>
|
||||
<a href="{{ $business ? route('buyer.crm.settings.account', $business->slug) : route('buyer.profile') }}"
|
||||
class="flex items-center gap-3 px-3 py-2 text-sm rounded-lg hover:bg-base-200 transition-colors">
|
||||
<span class="icon-[lucide--user] size-4 text-base-content/50"></span>
|
||||
<span>Profile</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
{{-- Orders --}}
|
||||
<li>
|
||||
<a href="{{ $business ? route('buyer.crm.orders.index', $business->slug) : '#' }}"
|
||||
class="flex items-center gap-3 px-3 py-2 text-sm rounded-lg hover:bg-base-200 transition-colors">
|
||||
<span class="icon-[lucide--package] size-4 text-base-content/50"></span>
|
||||
<span>My Orders</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
{{-- Favorites --}}
|
||||
<li>
|
||||
<a href="{{ $business ? route('buyer.crm.bookmarks.index', $business->slug) : '#' }}"
|
||||
class="flex items-center gap-3 px-3 py-2 text-sm rounded-lg hover:bg-base-200 transition-colors">
|
||||
<span class="icon-[lucide--heart] size-4 text-base-content/50"></span>
|
||||
<span>Favorites</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
{{-- Admin Console Section (Owner/Admin Only) --}}
|
||||
@if($canManageSettings && $business)
|
||||
<li class="pt-2 mt-2 border-t border-base-200">
|
||||
<span class="px-3 text-xs font-semibold text-base-content/40 uppercase tracking-wider">Admin Console</span>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ route('buyer.crm.settings.index', $business->slug) }}"
|
||||
class="flex items-center gap-3 px-3 py-2 text-sm rounded-lg hover:bg-base-200 transition-colors">
|
||||
<span class="icon-[lucide--settings] size-4 text-base-content/50"></span>
|
||||
<span>Settings</span>
|
||||
</a>
|
||||
</li>
|
||||
@endif
|
||||
</ul>
|
||||
|
||||
{{-- Sign Out --}}
|
||||
<div class="border-t border-base-200 my-2"></div>
|
||||
<form method="POST" action="{{ route('logout') }}">
|
||||
@csrf
|
||||
<button type="submit" class="flex items-center gap-3 px-3 py-2 text-sm rounded-lg hover:bg-base-200 transition-colors w-full text-error">
|
||||
<span class="icon-[lucide--log-out] size-4"></span>
|
||||
<span>Sign Out</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
290
resources/views/components/marketplace-search.blade.php
Normal file
290
resources/views/components/marketplace-search.blade.php
Normal file
@@ -0,0 +1,290 @@
|
||||
{{-- Marketplace Search Autocomplete Component --}}
|
||||
<div class="relative flex-1 max-w-xl"
|
||||
x-data="marketplaceSearch()"
|
||||
@click.away="close()"
|
||||
@keydown.escape.window="close()">
|
||||
|
||||
{{-- Search Input --}}
|
||||
<div class="relative">
|
||||
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-base-content/50">
|
||||
<span class="icon-[heroicons--magnifying-glass] size-5"></span>
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
x-model="query"
|
||||
@input.debounce.300ms="search()"
|
||||
@focus="onFocus()"
|
||||
@keydown.down.prevent="moveDown()"
|
||||
@keydown.up.prevent="moveUp()"
|
||||
@keydown.enter.prevent="selectCurrent()"
|
||||
placeholder="Search products, brands..."
|
||||
class="input input-bordered w-full pl-10 pr-10 h-10"
|
||||
autocomplete="off"
|
||||
aria-label="Search products and brands"
|
||||
aria-haspopup="listbox"
|
||||
:aria-expanded="isOpen">
|
||||
|
||||
{{-- Clear button --}}
|
||||
<button
|
||||
x-show="query.length > 0"
|
||||
x-cloak
|
||||
@click="clear()"
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 text-base-content/50 hover:text-base-content">
|
||||
<span class="icon-[heroicons--x-mark] size-5"></span>
|
||||
</button>
|
||||
|
||||
{{-- Loading indicator --}}
|
||||
<span
|
||||
x-show="loading"
|
||||
x-cloak
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2">
|
||||
<span class="loading loading-spinner loading-sm text-primary"></span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{{-- Dropdown Results --}}
|
||||
<div
|
||||
x-show="isOpen"
|
||||
x-cloak
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0 scale-95"
|
||||
x-transition:enter-end="opacity-100 scale-100"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100 scale-100"
|
||||
x-transition:leave-end="opacity-0 scale-95"
|
||||
class="absolute top-full left-0 right-0 mt-2 bg-base-100 rounded-box shadow-xl border border-base-200 z-50 max-h-[70vh] overflow-y-auto"
|
||||
role="listbox">
|
||||
|
||||
{{-- No Query: Show Suggestions --}}
|
||||
<template x-if="!query && suggestions">
|
||||
<div class="p-4">
|
||||
{{-- Popular Searches --}}
|
||||
<template x-if="suggestions.terms && suggestions.terms.length > 0">
|
||||
<div class="mb-4">
|
||||
<h4 class="text-xs font-semibold text-base-content/60 uppercase tracking-wider mb-2">Popular Searches</h4>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<template x-for="term in suggestions.terms" :key="term">
|
||||
<button
|
||||
@click="searchTerm(term)"
|
||||
class="badge badge-outline badge-lg hover:badge-primary cursor-pointer transition-colors"
|
||||
x-text="term">
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
{{-- Trending Products --}}
|
||||
<template x-if="suggestions.trending && suggestions.trending.length > 0">
|
||||
<div>
|
||||
<h4 class="text-xs font-semibold text-base-content/60 uppercase tracking-wider mb-2">Trending Products</h4>
|
||||
<div class="space-y-2">
|
||||
<template x-for="(product, index) in suggestions.trending" :key="product.id">
|
||||
<a :href="product.url"
|
||||
class="flex items-center gap-3 p-2 rounded-lg hover:bg-base-200 transition-colors"
|
||||
@click="close()">
|
||||
<div class="w-10 h-10 bg-base-200 rounded-lg flex-shrink-0 overflow-hidden">
|
||||
<template x-if="product.image_url">
|
||||
<img :src="product.image_url" :alt="product.name" class="w-full h-full object-cover">
|
||||
</template>
|
||||
<template x-if="!product.image_url">
|
||||
<span class="icon-[heroicons--cube] size-5 text-base-content/30 m-auto"></span>
|
||||
</template>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium truncate" x-text="product.name"></p>
|
||||
<p class="text-xs text-base-content/60" x-text="product.brand_name"></p>
|
||||
</div>
|
||||
<span class="icon-[heroicons--fire] size-4 text-orange-500"></span>
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
{{-- Search Results --}}
|
||||
<template x-if="query && !loading">
|
||||
<div>
|
||||
{{-- No Results --}}
|
||||
<template x-if="!hasResults">
|
||||
<div class="p-8 text-center">
|
||||
<span class="icon-[heroicons--magnifying-glass] size-12 text-base-content/20 mb-3 block mx-auto"></span>
|
||||
<p class="text-base-content/60">No results found for "<span x-text="query"></span>"</p>
|
||||
<p class="text-sm text-base-content/40 mt-1">Try a different search term</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
{{-- Brands Section --}}
|
||||
<template x-if="results.brands && results.brands.length > 0">
|
||||
<div class="border-b border-base-200">
|
||||
<h4 class="text-xs font-semibold text-base-content/60 uppercase tracking-wider px-4 pt-3 pb-2">Brands</h4>
|
||||
<div class="pb-2">
|
||||
<template x-for="(brand, index) in results.brands" :key="brand.id">
|
||||
<a :href="brand.url"
|
||||
:class="{'bg-base-200': selectedIndex === index + 'b'}"
|
||||
class="flex items-center gap-3 px-4 py-2 hover:bg-base-200 transition-colors cursor-pointer"
|
||||
@mouseenter="selectedIndex = index + 'b'"
|
||||
@click="close()">
|
||||
<div class="w-10 h-10 bg-base-200 rounded-lg flex-shrink-0 overflow-hidden flex items-center justify-center">
|
||||
<template x-if="brand.logo_url">
|
||||
<img :src="brand.logo_url" :alt="brand.name" class="w-full h-full object-contain p-1">
|
||||
</template>
|
||||
<template x-if="!brand.logo_url">
|
||||
<span class="text-lg font-bold text-base-content/30" x-text="brand.name.charAt(0)"></span>
|
||||
</template>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium" x-text="brand.name"></p>
|
||||
<p class="text-xs text-base-content/60"><span x-text="brand.products_count"></span> products</p>
|
||||
</div>
|
||||
<span class="icon-[heroicons--arrow-right] size-4 text-base-content/40"></span>
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
{{-- Products Section --}}
|
||||
<template x-if="results.products && results.products.length > 0">
|
||||
<div>
|
||||
<h4 class="text-xs font-semibold text-base-content/60 uppercase tracking-wider px-4 pt-3 pb-2">Products</h4>
|
||||
<div class="pb-2">
|
||||
<template x-for="(product, index) in results.products" :key="product.id">
|
||||
<a :href="product.url"
|
||||
:class="{'bg-base-200': selectedIndex === index + 'p'}"
|
||||
class="flex items-center gap-3 px-4 py-2 hover:bg-base-200 transition-colors cursor-pointer"
|
||||
@mouseenter="selectedIndex = index + 'p'"
|
||||
@click="close()">
|
||||
<div class="w-12 h-12 bg-base-200 rounded-lg flex-shrink-0 overflow-hidden flex items-center justify-center">
|
||||
<template x-if="product.image_url">
|
||||
<img :src="product.image_url" :alt="product.name" class="w-full h-full object-cover">
|
||||
</template>
|
||||
<template x-if="!product.image_url">
|
||||
<span class="icon-[heroicons--cube] size-6 text-base-content/30"></span>
|
||||
</template>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium truncate" x-text="product.name"></p>
|
||||
<div class="flex items-center gap-2 text-xs text-base-content/60">
|
||||
<span x-text="product.brand_name"></span>
|
||||
<span>•</span>
|
||||
<span x-text="product.sku"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right flex-shrink-0">
|
||||
<p class="text-sm font-semibold text-primary">$<span x-text="parseFloat(product.price).toFixed(2)"></span></p>
|
||||
</div>
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
{{-- View All Results Link --}}
|
||||
<div class="px-4 py-3 border-t border-base-200">
|
||||
<a :href="'{{ route('buyer.browse') }}?search=' + encodeURIComponent(query)"
|
||||
class="btn btn-primary btn-sm w-full"
|
||||
@click="close()">
|
||||
View all results for "<span x-text="query"></span>"
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function marketplaceSearch() {
|
||||
return {
|
||||
query: '',
|
||||
isOpen: false,
|
||||
loading: false,
|
||||
results: { products: [], brands: [] },
|
||||
suggestions: null,
|
||||
selectedIndex: null,
|
||||
|
||||
async onFocus() {
|
||||
if (!this.query) {
|
||||
await this.loadSuggestions();
|
||||
}
|
||||
this.isOpen = true;
|
||||
},
|
||||
|
||||
async loadSuggestions() {
|
||||
if (this.suggestions) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('{{ route('buyer.search.suggestions') }}');
|
||||
if (response.ok) {
|
||||
this.suggestions = await response.json();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load suggestions:', error);
|
||||
}
|
||||
},
|
||||
|
||||
async search() {
|
||||
if (this.query.length < 2) {
|
||||
this.results = { products: [], brands: [] };
|
||||
if (!this.query) {
|
||||
await this.loadSuggestions();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
this.isOpen = true;
|
||||
|
||||
try {
|
||||
const response = await fetch(`{{ route('buyer.search.autocomplete') }}?q=${encodeURIComponent(this.query)}`);
|
||||
if (response.ok) {
|
||||
this.results = await response.json();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Search failed:', error);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
searchTerm(term) {
|
||||
this.query = term;
|
||||
this.search();
|
||||
},
|
||||
|
||||
clear() {
|
||||
this.query = '';
|
||||
this.results = { products: [], brands: [] };
|
||||
this.selectedIndex = null;
|
||||
},
|
||||
|
||||
close() {
|
||||
this.isOpen = false;
|
||||
this.selectedIndex = null;
|
||||
},
|
||||
|
||||
get hasResults() {
|
||||
return (this.results.products && this.results.products.length > 0) ||
|
||||
(this.results.brands && this.results.brands.length > 0);
|
||||
},
|
||||
|
||||
moveDown() {
|
||||
// Basic keyboard navigation (could be expanded)
|
||||
this.isOpen = true;
|
||||
},
|
||||
|
||||
moveUp() {
|
||||
// Basic keyboard navigation
|
||||
},
|
||||
|
||||
selectCurrent() {
|
||||
if (this.query.length >= 2) {
|
||||
// Navigate to search results page
|
||||
window.location.href = '{{ route('buyer.browse') }}?search=' + encodeURIComponent(this.query);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
100
resources/views/components/marketplace/compare-bar.blade.php
Normal file
100
resources/views/components/marketplace/compare-bar.blade.php
Normal file
@@ -0,0 +1,100 @@
|
||||
{{-- Floating Compare Bar - Shows when products are selected for comparison --}}
|
||||
<div x-data="compareBar()"
|
||||
x-show="count > 0"
|
||||
x-cloak
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="transform translate-y-full opacity-0"
|
||||
x-transition:enter-end="transform translate-y-0 opacity-100"
|
||||
x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-start="transform translate-y-0 opacity-100"
|
||||
x-transition:leave-end="transform translate-y-full opacity-0"
|
||||
class="fixed bottom-0 left-0 right-0 z-50 bg-base-100 border-t border-base-300 shadow-lg py-3 px-4"
|
||||
@compare-updated.window="loadState()">
|
||||
|
||||
<div class="container mx-auto flex items-center justify-between gap-4">
|
||||
{{-- Left side: Count and preview --}}
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="icon-[heroicons--scale] size-5 text-primary"></span>
|
||||
<span class="font-semibold">
|
||||
<span x-text="count"></span> / <span x-text="max"></span> products
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{{-- Product thumbnails --}}
|
||||
<div class="hidden sm:flex items-center -space-x-2">
|
||||
<template x-for="id in ids" :key="id">
|
||||
<div class="w-10 h-10 rounded-lg border-2 border-base-100 bg-base-200 overflow-hidden shadow-sm">
|
||||
{{-- Product image placeholder - would need to fetch images --}}
|
||||
<div class="w-full h-full bg-base-300 flex items-center justify-center">
|
||||
<span class="icon-[heroicons--cube] size-4 text-base-content/30"></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Right side: Actions --}}
|
||||
<div class="flex items-center gap-2">
|
||||
<button @click="clear()"
|
||||
class="btn btn-ghost btn-sm">
|
||||
Clear
|
||||
</button>
|
||||
<a href="{{ route('buyer.compare.index') }}"
|
||||
class="btn btn-primary btn-sm gap-2">
|
||||
<span class="icon-[heroicons--scale] size-4"></span>
|
||||
Compare Now
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function compareBar() {
|
||||
return {
|
||||
count: 0,
|
||||
max: 4,
|
||||
ids: [],
|
||||
isFull: false,
|
||||
|
||||
init() {
|
||||
this.loadState();
|
||||
},
|
||||
|
||||
async loadState() {
|
||||
try {
|
||||
const response = await fetch('{{ route('buyer.compare.state') }}');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
this.count = data.count;
|
||||
this.max = data.max;
|
||||
this.ids = data.ids;
|
||||
this.isFull = data.is_full;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load compare state:', error);
|
||||
}
|
||||
},
|
||||
|
||||
async clear() {
|
||||
try {
|
||||
const response = await fetch('{{ route('buyer.compare.clear') }}', {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content'),
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
this.count = 0;
|
||||
this.ids = [];
|
||||
this.isFull = false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to clear comparison:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -9,6 +9,7 @@
|
||||
@php
|
||||
$business = $business ?? auth()->user()?->businesses->first();
|
||||
$isNew = $product->created_at >= now()->subDays(14);
|
||||
$hasOwnImage = (bool) $product->image_path;
|
||||
$imageUrl = $product->getImageUrl('medium') ?: null;
|
||||
@endphp
|
||||
|
||||
@@ -17,10 +18,33 @@
|
||||
<div class="card bg-base-100 shadow-lg hover:shadow-xl transition-all duration-300 group relative overflow-hidden"
|
||||
x-data="{
|
||||
inCart: false,
|
||||
quantity: 0,
|
||||
quantity: 1,
|
||||
hover: false,
|
||||
productId: {{ $product->id }},
|
||||
availableQty: {{ $product->isUnlimited() ? 999999 : ($product->available_quantity ?? 0) }}
|
||||
productHashid: '{{ $product->hashid }}',
|
||||
availableQty: {{ $product->isUnlimited() ? 999999 : ($product->available_quantity ?? 0) }},
|
||||
inCompare: false,
|
||||
async toggleCompare() {
|
||||
try {
|
||||
const response = await fetch(`/b/compare/toggle/${this.productId}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name=\'csrf-token\']').getAttribute('content'),
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
this.inCompare = data.added;
|
||||
window.dispatchEvent(new CustomEvent('compare-updated'));
|
||||
window.showToast?.(data.message, data.added ? 'success' : 'info');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle compare:', error);
|
||||
}
|
||||
},
|
||||
incrementQty() { if (this.quantity < this.availableQty) this.quantity++; },
|
||||
decrementQty() { if (this.quantity > 1) this.quantity--; }
|
||||
}">
|
||||
{{-- Badges --}}
|
||||
<div class="absolute top-2 left-2 z-10 flex flex-col gap-1">
|
||||
@@ -41,33 +65,65 @@
|
||||
<figure class="relative aspect-square bg-base-200 overflow-hidden"
|
||||
@mouseenter="hover = true"
|
||||
@mouseleave="hover = false">
|
||||
<a href="{{ route('buyer.brands.products.show', [$product->brand->slug, $product->hashid]) }}" class="block size-full">
|
||||
<a href="{{ route('buyer.brands.products.show', [$product->brand->slug, $product->hashid]) }}" class="flex items-center justify-center size-full {{ $hasOwnImage ? '' : 'bg-base-100' }}">
|
||||
@if($imageUrl)
|
||||
<img src="{{ $imageUrl }}"
|
||||
alt="{{ $product->name }}"
|
||||
class="size-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
loading="lazy">
|
||||
@if($hasOwnImage)
|
||||
<img src="{{ $imageUrl }}"
|
||||
alt="{{ $product->name }}"
|
||||
class="size-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
loading="lazy">
|
||||
@else
|
||||
{{-- Brand logo fallback - show at 50% size, centered --}}
|
||||
<img src="{{ $imageUrl }}"
|
||||
alt="{{ $product->name }}"
|
||||
class="max-w-[50%] max-h-[50%] object-contain"
|
||||
loading="lazy">
|
||||
@endif
|
||||
@else
|
||||
<div class="flex items-center justify-center size-full">
|
||||
<span class="icon-[heroicons--cube] size-16 text-base-content/20"></span>
|
||||
</div>
|
||||
<span class="icon-[heroicons--cube] size-16 text-base-content/20"></span>
|
||||
@endif
|
||||
</a>
|
||||
|
||||
{{-- Quick action overlay --}}
|
||||
<div class="absolute inset-0 bg-black/40 flex items-center justify-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||||
<a href="{{ route('buyer.brands.products.show', [$product->brand->slug, $product->hashid]) }}"
|
||||
class="btn btn-circle btn-sm btn-ghost bg-white/90 hover:bg-white"
|
||||
title="Quick view">
|
||||
<span class="icon-[heroicons--eye] size-4"></span>
|
||||
</a>
|
||||
@if($showAddToCart && $product->isInStock())
|
||||
{{-- Quick action overlay with qty selector --}}
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/70 via-black/30 to-transparent flex flex-col justify-end p-3 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||||
{{-- Top row: Quick view & Compare --}}
|
||||
<div class="absolute top-3 right-3 flex gap-1">
|
||||
<button type="button"
|
||||
@click.prevent="$dispatch('quick-add-to-cart', { productId: productId })"
|
||||
class="btn btn-circle btn-sm btn-primary"
|
||||
title="Add to cart">
|
||||
<span class="icon-[heroicons--shopping-cart] size-4"></span>
|
||||
@click.prevent="$dispatch('open-quick-view', { id: productHashid })"
|
||||
class="btn btn-circle btn-xs btn-ghost bg-white/90 hover:bg-white"
|
||||
title="Quick view">
|
||||
<span class="icon-[heroicons--eye] size-3.5"></span>
|
||||
</button>
|
||||
<button type="button"
|
||||
@click.prevent="toggleCompare()"
|
||||
class="btn btn-circle btn-xs btn-ghost bg-white/90 hover:bg-white"
|
||||
:class="{ 'bg-primary text-primary-content hover:bg-primary': inCompare }"
|
||||
title="Add to compare">
|
||||
<span class="icon-[heroicons--scale] size-3.5"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if($showAddToCart && $product->isInStock())
|
||||
{{-- Bottom: Qty selector + Add button --}}
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="join bg-white rounded-lg shadow">
|
||||
<button type="button" @click.prevent="decrementQty()" class="btn btn-xs join-item px-2" :disabled="quantity <= 1">
|
||||
<span class="icon-[heroicons--minus] size-3"></span>
|
||||
</button>
|
||||
<input type="number" x-model.number="quantity" min="1" :max="availableQty"
|
||||
class="w-10 text-center text-sm font-medium bg-white border-0 join-item focus:outline-none"
|
||||
@click.stop>
|
||||
<button type="button" @click.prevent="incrementQty()" class="btn btn-xs join-item px-2" :disabled="quantity >= availableQty">
|
||||
<span class="icon-[heroicons--plus] size-3"></span>
|
||||
</button>
|
||||
</div>
|
||||
<button type="button"
|
||||
@click.prevent="$dispatch('add-to-cart', { productId: productId, quantity: quantity })"
|
||||
class="btn btn-sm btn-primary flex-1 gap-1">
|
||||
<span class="icon-[heroicons--shopping-cart] size-4"></span>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</figure>
|
||||
@@ -89,29 +145,62 @@
|
||||
</a>
|
||||
</h3>
|
||||
|
||||
{{-- Strain/Category badges --}}
|
||||
{{-- Strain type badge (color-coded) + Category --}}
|
||||
@php
|
||||
$strainType = $product->strain_type ?? $product->strain?->type ?? null;
|
||||
$strainColors = [
|
||||
'indica' => 'bg-purple-500/20 text-purple-700 border-purple-300',
|
||||
'sativa' => 'bg-orange-500/20 text-orange-700 border-orange-300',
|
||||
'hybrid' => 'bg-green-500/20 text-green-700 border-green-300',
|
||||
];
|
||||
$strainClass = $strainColors[$strainType] ?? 'badge-outline';
|
||||
@endphp
|
||||
<div class="flex flex-wrap gap-1 min-h-[1.5rem]">
|
||||
@if($product->strain)
|
||||
<span class="badge badge-outline badge-xs">{{ ucfirst($product->strain->type) }}</span>
|
||||
@if($strainType)
|
||||
<span class="badge badge-xs border {{ $strainClass }}">{{ ucfirst($strainType) }}</span>
|
||||
@endif
|
||||
@if($product->relationLoaded('category') && $product->category)
|
||||
<span class="badge badge-ghost badge-xs">{{ $product->category->name }}</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- THC/CBD --}}
|
||||
{{-- THC/CBD Visual Bars --}}
|
||||
@if($product->thc_percentage || $product->cbd_percentage)
|
||||
<div class="flex gap-2 text-xs text-base-content/70">
|
||||
<div class="space-y-1.5 mt-1">
|
||||
@if($product->thc_percentage)
|
||||
<span class="font-medium">THC: {{ $product->thc_percentage }}%</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-[10px] font-semibold w-7 text-base-content/70">THC</span>
|
||||
<div class="flex-1 h-1.5 bg-base-200 rounded-full overflow-hidden">
|
||||
<div class="h-full bg-gradient-to-r from-amber-400 to-red-500 rounded-full"
|
||||
style="width: {{ min($product->thc_percentage * 3, 100) }}%"></div>
|
||||
</div>
|
||||
<span class="text-[10px] font-bold w-8 text-right">{{ $product->thc_percentage }}%</span>
|
||||
</div>
|
||||
@endif
|
||||
@if($product->cbd_percentage)
|
||||
<span class="font-medium">CBD: {{ $product->cbd_percentage }}%</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-[10px] font-semibold w-7 text-base-content/70">CBD</span>
|
||||
<div class="flex-1 h-1.5 bg-base-200 rounded-full overflow-hidden">
|
||||
<div class="h-full bg-gradient-to-r from-blue-400 to-green-500 rounded-full"
|
||||
style="width: {{ min($product->cbd_percentage * 3, 100) }}%"></div>
|
||||
</div>
|
||||
<span class="text-[10px] font-bold w-8 text-right">{{ $product->cbd_percentage }}%</span>
|
||||
</div>
|
||||
@endif
|
||||
@if($product->terpenes_percentage)
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-[10px] font-semibold w-7 text-base-content/70">Terp</span>
|
||||
<div class="flex-1 h-1.5 bg-base-200 rounded-full overflow-hidden">
|
||||
<div class="h-full bg-gradient-to-r from-emerald-400 to-teal-500 rounded-full"
|
||||
style="width: {{ min($product->terpenes_percentage * 10, 100) }}%"></div>
|
||||
</div>
|
||||
<span class="text-[10px] font-bold w-8 text-right">{{ $product->terpenes_percentage }}%</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Price --}}
|
||||
{{-- Price + Case Info --}}
|
||||
<div class="mt-auto pt-2">
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span class="text-xl font-bold text-primary">${{ number_format($product->wholesale_price, 2) }}</span>
|
||||
@@ -119,17 +208,34 @@
|
||||
<span class="text-xs text-base-content/60">/ {{ $product->price_unit }}</span>
|
||||
@endif
|
||||
</div>
|
||||
@if($product->units_per_case)
|
||||
<div class="text-[10px] text-base-content/50 mt-0.5">
|
||||
{{ $product->units_per_case }} units/case
|
||||
@if($product->wholesale_price && $product->units_per_case > 1)
|
||||
• ${{ number_format($product->wholesale_price * $product->units_per_case, 2) }}/case
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Stock & Add to Cart --}}
|
||||
@if($showAddToCart)
|
||||
<div class="card-actions pt-3 border-t border-base-200 mt-2">
|
||||
@if($product->isInStock())
|
||||
{{-- Unified button with qty inside --}}
|
||||
<button type="button"
|
||||
@click="$dispatch('add-to-cart', { productId: productId })"
|
||||
class="btn btn-primary btn-sm flex-1 gap-2">
|
||||
@click="$dispatch('add-to-cart', { productId: productId, quantity: quantity })"
|
||||
class="btn btn-primary btn-sm w-full gap-2 group/btn">
|
||||
<span class="icon-[heroicons--shopping-cart] size-4"></span>
|
||||
Add to Cart
|
||||
<span>Add</span>
|
||||
<span class="bg-primary-content/20 rounded px-1.5 py-0.5 text-xs font-bold flex items-center gap-1"
|
||||
@click.stop>
|
||||
<span class="cursor-pointer hover:bg-primary-content/30 rounded px-0.5"
|
||||
@click.prevent="decrementQty()">-</span>
|
||||
<span x-text="quantity" class="min-w-[1rem] text-center"></span>
|
||||
<span class="cursor-pointer hover:bg-primary-content/30 rounded px-0.5"
|
||||
@click.prevent="incrementQty()">+</span>
|
||||
</span>
|
||||
</button>
|
||||
@else
|
||||
<button disabled class="btn btn-disabled btn-sm flex-1">
|
||||
@@ -144,19 +250,30 @@
|
||||
@elseif($variant === 'list')
|
||||
{{-- LIST VARIANT - Horizontal card for list view --}}
|
||||
<div class="card card-side bg-base-100 shadow-lg hover:shadow-xl transition-shadow"
|
||||
x-data="{ productId: {{ $product->id }}, availableQty: {{ $product->isUnlimited() ? 999999 : ($product->available_quantity ?? 0) }} }">
|
||||
x-data="{
|
||||
productId: {{ $product->id }},
|
||||
quantity: 1,
|
||||
availableQty: {{ $product->isUnlimited() ? 999999 : ($product->available_quantity ?? 0) }},
|
||||
incrementQty() { if (this.quantity < this.availableQty) this.quantity++; },
|
||||
decrementQty() { if (this.quantity > 1) this.quantity--; }
|
||||
}">
|
||||
{{-- Image --}}
|
||||
<figure class="relative w-40 md:w-48 flex-shrink-0">
|
||||
<a href="{{ route('buyer.brands.products.show', [$product->brand->slug, $product->hashid]) }}" class="block size-full">
|
||||
<figure class="relative w-40 md:w-48 flex-shrink-0 bg-base-200">
|
||||
<a href="{{ route('buyer.brands.products.show', [$product->brand->slug, $product->hashid]) }}" class="flex items-center justify-center size-full {{ $hasOwnImage ? '' : 'bg-base-100' }}">
|
||||
@if($imageUrl)
|
||||
<img src="{{ $imageUrl }}"
|
||||
alt="{{ $product->name }}"
|
||||
class="size-full object-cover"
|
||||
loading="lazy">
|
||||
@if($hasOwnImage)
|
||||
<img src="{{ $imageUrl }}"
|
||||
alt="{{ $product->name }}"
|
||||
class="size-full object-cover"
|
||||
loading="lazy">
|
||||
@else
|
||||
<img src="{{ $imageUrl }}"
|
||||
alt="{{ $product->name }}"
|
||||
class="max-w-[50%] max-h-[50%] object-contain"
|
||||
loading="lazy">
|
||||
@endif
|
||||
@else
|
||||
<div class="flex items-center justify-center size-full bg-base-200">
|
||||
<span class="icon-[heroicons--cube] size-12 text-base-content/20"></span>
|
||||
</div>
|
||||
<span class="icon-[heroicons--cube] size-12 text-base-content/20"></span>
|
||||
@endif
|
||||
</a>
|
||||
{{-- Badges --}}
|
||||
@@ -194,16 +311,28 @@
|
||||
<p class="text-sm text-base-content/70 mt-2 line-clamp-2">{{ $product->short_description }}</p>
|
||||
@endif
|
||||
|
||||
{{-- Badges --}}
|
||||
<div class="flex flex-wrap gap-1 mt-2">
|
||||
@if($product->strain)
|
||||
<span class="badge badge-outline badge-sm">{{ ucfirst($product->strain->type) }}</span>
|
||||
{{-- Strain (color-coded) + THC/CBD badges --}}
|
||||
@php
|
||||
$strainType = $product->strain_type ?? $product->strain?->type ?? null;
|
||||
$strainColors = [
|
||||
'indica' => 'bg-purple-500/20 text-purple-700 border-purple-300',
|
||||
'sativa' => 'bg-orange-500/20 text-orange-700 border-orange-300',
|
||||
'hybrid' => 'bg-green-500/20 text-green-700 border-green-300',
|
||||
];
|
||||
$strainClass = $strainColors[$strainType] ?? 'badge-outline';
|
||||
@endphp
|
||||
<div class="flex flex-wrap gap-1.5 mt-2">
|
||||
@if($strainType)
|
||||
<span class="badge badge-sm border {{ $strainClass }}">{{ ucfirst($strainType) }}</span>
|
||||
@endif
|
||||
@if($product->thc_percentage)
|
||||
<span class="badge badge-sm badge-primary/10 text-primary">THC {{ $product->thc_percentage }}%</span>
|
||||
<span class="badge badge-sm bg-amber-500/20 text-amber-700 border-amber-300">THC {{ $product->thc_percentage }}%</span>
|
||||
@endif
|
||||
@if($product->cbd_percentage)
|
||||
<span class="badge badge-sm badge-success/10 text-success">CBD {{ $product->cbd_percentage }}%</span>
|
||||
<span class="badge badge-sm bg-blue-500/20 text-blue-700 border-blue-300">CBD {{ $product->cbd_percentage }}%</span>
|
||||
@endif
|
||||
@if($product->terpenes_percentage)
|
||||
<span class="badge badge-sm bg-emerald-500/20 text-emerald-700 border-emerald-300">Terp {{ $product->terpenes_percentage }}%</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@@ -215,15 +344,29 @@
|
||||
@if($product->price_unit)
|
||||
<div class="text-xs text-base-content/60">per {{ $product->price_unit }}</div>
|
||||
@endif
|
||||
@if($product->units_per_case)
|
||||
<div class="text-[10px] text-base-content/50">
|
||||
{{ $product->units_per_case }} units/case
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if($showAddToCart)
|
||||
@if($product->isInStock())
|
||||
{{-- Unified button with qty inside --}}
|
||||
<button type="button"
|
||||
@click="$dispatch('add-to-cart', { productId: productId })"
|
||||
@click="$dispatch('add-to-cart', { productId: productId, quantity: quantity })"
|
||||
class="btn btn-primary btn-sm gap-2">
|
||||
<span class="icon-[heroicons--shopping-cart] size-4"></span>
|
||||
Add to Cart
|
||||
<span>Add</span>
|
||||
<span class="bg-primary-content/20 rounded px-1.5 py-0.5 text-xs font-bold flex items-center gap-1"
|
||||
@click.stop>
|
||||
<span class="cursor-pointer hover:bg-primary-content/30 rounded px-0.5"
|
||||
@click.prevent="decrementQty()">-</span>
|
||||
<span x-text="quantity" class="min-w-[1rem] text-center"></span>
|
||||
<span class="cursor-pointer hover:bg-primary-content/30 rounded px-0.5"
|
||||
@click.prevent="incrementQty()">+</span>
|
||||
</span>
|
||||
</button>
|
||||
@else
|
||||
<button disabled class="btn btn-disabled btn-sm">Out of Stock</button>
|
||||
@@ -243,16 +386,21 @@
|
||||
<div class="card bg-base-100 shadow hover:shadow-lg transition-shadow w-48 flex-shrink-0 snap-start"
|
||||
x-data="{ productId: {{ $product->id }} }">
|
||||
<figure class="relative aspect-square bg-base-200">
|
||||
<a href="{{ route('buyer.brands.products.show', [$product->brand->slug, $product->hashid]) }}" class="block size-full">
|
||||
<a href="{{ route('buyer.brands.products.show', [$product->brand->slug, $product->hashid]) }}" class="flex items-center justify-center size-full {{ $hasOwnImage ? '' : 'bg-base-100' }}">
|
||||
@if($imageUrl)
|
||||
<img src="{{ $imageUrl }}"
|
||||
alt="{{ $product->name }}"
|
||||
class="size-full object-cover"
|
||||
loading="lazy">
|
||||
@if($hasOwnImage)
|
||||
<img src="{{ $imageUrl }}"
|
||||
alt="{{ $product->name }}"
|
||||
class="size-full object-cover"
|
||||
loading="lazy">
|
||||
@else
|
||||
<img src="{{ $imageUrl }}"
|
||||
alt="{{ $product->name }}"
|
||||
class="max-w-[50%] max-h-[50%] object-contain"
|
||||
loading="lazy">
|
||||
@endif
|
||||
@else
|
||||
<div class="flex items-center justify-center size-full">
|
||||
<span class="icon-[heroicons--cube] size-10 text-base-content/20"></span>
|
||||
</div>
|
||||
<span class="icon-[heroicons--cube] size-10 text-base-content/20"></span>
|
||||
@endif
|
||||
</a>
|
||||
@if($isNew)
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
{{-- Quick View Modal Component --}}
|
||||
<dialog id="quick-view-modal" class="modal"
|
||||
x-data="quickViewModal()"
|
||||
@open-quick-view.window="openModal($event.detail)"
|
||||
@keydown.escape.window="closeModal()">
|
||||
<div class="modal-box max-w-4xl p-0">
|
||||
{{-- Loading State --}}
|
||||
<div x-show="loading" x-cloak class="flex items-center justify-center py-24">
|
||||
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||
</div>
|
||||
|
||||
{{-- Product Content --}}
|
||||
<div x-show="!loading && product" x-cloak>
|
||||
{{-- Close Button --}}
|
||||
<form method="dialog">
|
||||
<button class="btn btn-sm btn-circle btn-ghost absolute right-4 top-4 z-10" @click="closeModal()">
|
||||
<span class="icon-[heroicons--x-mark] size-5"></span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="flex flex-col md:flex-row">
|
||||
{{-- Product Image --}}
|
||||
<div class="w-full md:w-2/5 bg-base-200 flex items-center justify-center p-6 min-h-[300px]">
|
||||
<template x-if="product.image_url">
|
||||
<img :src="product.image_url"
|
||||
:alt="product.name"
|
||||
class="max-h-[300px] max-w-full object-contain">
|
||||
</template>
|
||||
<template x-if="!product.image_url">
|
||||
<span class="icon-[heroicons--cube] size-24 text-base-content/20"></span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
{{-- Product Details --}}
|
||||
<div class="w-full md:w-3/5 p-6">
|
||||
{{-- Brand --}}
|
||||
<a :href="product.brand_url"
|
||||
class="text-sm text-primary font-semibold uppercase tracking-wide hover:underline"
|
||||
x-text="product.brand_name">
|
||||
</a>
|
||||
|
||||
{{-- Name --}}
|
||||
<h3 class="text-2xl font-bold mt-1 mb-2" x-text="product.name"></h3>
|
||||
|
||||
{{-- SKU --}}
|
||||
<div class="text-sm text-base-content/60 mb-3">
|
||||
SKU: <span class="font-mono" x-text="product.sku"></span>
|
||||
</div>
|
||||
|
||||
{{-- THC/CBD Badge --}}
|
||||
<div class="flex gap-2 mb-4" x-show="product.thc_percentage || product.cbd_percentage">
|
||||
<template x-if="product.thc_percentage">
|
||||
<span class="badge badge-primary badge-lg" x-text="'THC ' + product.thc_percentage + '%'"></span>
|
||||
</template>
|
||||
<template x-if="product.cbd_percentage">
|
||||
<span class="badge badge-secondary badge-lg" x-text="'CBD ' + product.cbd_percentage + '%'"></span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
{{-- Stock Status --}}
|
||||
<div class="mb-4">
|
||||
<template x-if="product.in_stock">
|
||||
<span class="badge badge-success gap-1">
|
||||
<span class="icon-[heroicons--check-circle] size-4"></span>
|
||||
In Stock
|
||||
</span>
|
||||
</template>
|
||||
<template x-if="!product.in_stock">
|
||||
<span class="badge badge-error gap-1">
|
||||
<span class="icon-[heroicons--x-circle] size-4"></span>
|
||||
Out of Stock
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
{{-- Price --}}
|
||||
<div class="text-3xl font-bold text-primary mb-4">
|
||||
$<span x-text="parseFloat(product.price).toFixed(2)"></span>
|
||||
<span class="text-lg text-base-content/60 font-normal"
|
||||
x-show="product.price_unit"
|
||||
x-text="'/ ' + product.price_unit"></span>
|
||||
</div>
|
||||
|
||||
{{-- Description --}}
|
||||
<div class="text-sm text-base-content/70 mb-6 line-clamp-3" x-text="product.description"></div>
|
||||
|
||||
{{-- Actions --}}
|
||||
<div class="flex flex-col sm:flex-row gap-3">
|
||||
<template x-if="product.in_stock">
|
||||
<button class="btn btn-primary flex-1 gap-2"
|
||||
@click="addToCart()"
|
||||
:disabled="addingToCart">
|
||||
<span x-show="!addingToCart">
|
||||
<span class="icon-[heroicons--shopping-cart] size-5"></span>
|
||||
Add to Cart
|
||||
</span>
|
||||
<span x-show="addingToCart">
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
Adding...
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<a :href="product.url"
|
||||
class="btn btn-outline flex-1 gap-2"
|
||||
@click="closeModal()">
|
||||
<span class="icon-[heroicons--arrow-right] size-5"></span>
|
||||
View Full Details
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Error State --}}
|
||||
<div x-show="!loading && error" x-cloak class="p-8 text-center">
|
||||
<span class="icon-[heroicons--exclamation-triangle] size-16 text-warning mx-auto mb-4"></span>
|
||||
<h3 class="text-lg font-semibold mb-2">Could not load product</h3>
|
||||
<p class="text-base-content/60 mb-4" x-text="error"></p>
|
||||
<button class="btn btn-ghost" @click="closeModal()">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button @click="closeModal()">close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<script>
|
||||
function quickViewModal() {
|
||||
return {
|
||||
product: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
addingToCart: false,
|
||||
|
||||
async openModal(productData) {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
this.product = null;
|
||||
document.getElementById('quick-view-modal').showModal();
|
||||
|
||||
try {
|
||||
// Use hashid if available, otherwise fall back to id
|
||||
const productKey = productData.hashid || productData.id;
|
||||
const response = await fetch(`/b/products/${productKey}/quick-view`);
|
||||
if (!response.ok) throw new Error('Product not found');
|
||||
this.product = await response.json();
|
||||
} catch (err) {
|
||||
this.error = err.message || 'Failed to load product';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
closeModal() {
|
||||
document.getElementById('quick-view-modal').close();
|
||||
this.product = null;
|
||||
this.error = null;
|
||||
},
|
||||
|
||||
async addToCart() {
|
||||
if (!this.product || this.addingToCart) return;
|
||||
|
||||
this.addingToCart = true;
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('product_id', this.product.id);
|
||||
formData.append('quantity', 1);
|
||||
|
||||
const business = document.querySelector('[data-business-slug]')?.dataset.businessSlug;
|
||||
if (!business) throw new Error('Business not found');
|
||||
|
||||
const response = await fetch(`/b/${business}/cart/add`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content')
|
||||
}
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok && data.success) {
|
||||
window.dispatchEvent(new CustomEvent('cart-updated', {
|
||||
detail: { count: data.cart_count }
|
||||
}));
|
||||
window.showToast?.('Added to cart', 'success');
|
||||
this.closeModal();
|
||||
} else {
|
||||
throw new Error(data.message || 'Failed to add to cart');
|
||||
}
|
||||
} catch (err) {
|
||||
window.showToast?.(err.message, 'error');
|
||||
} finally {
|
||||
this.addingToCart = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -28,7 +28,7 @@
|
||||
[x-cloak] { display: none !important; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="font-sans antialiased">
|
||||
<body class="font-sans antialiased" data-business-slug="{{ auth()->user()?->businesses->first()?->slug }}">
|
||||
<script>
|
||||
// DaisyUI theme system
|
||||
(function() {
|
||||
@@ -49,29 +49,53 @@
|
||||
<!-- Topbar -->
|
||||
<div id="layout-topbar" class="flex items-center justify-between px-6 bg-base-100 border-b border-base-200">
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Single hamburger menu with proper responsive behavior -->
|
||||
<!-- Mobile hamburger menu - only visible on small screens -->
|
||||
<label
|
||||
class="btn btn-square btn-ghost btn-sm"
|
||||
class="btn btn-square btn-ghost btn-sm lg:hidden"
|
||||
aria-label="Toggle sidebar"
|
||||
for="layout-sidebar-toggle-trigger">
|
||||
<span class="icon-[heroicons--bars-3] size-5"></span>
|
||||
</label>
|
||||
|
||||
<!-- Page heading -->
|
||||
<h2 class="font-semibold text-xl text-base-content">Dashboard</h2>
|
||||
|
||||
{{-- Search Autocomplete (desktop) --}}
|
||||
<div class="hidden md:block flex-1 max-w-xl">
|
||||
<x-marketplace-search />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Theme Switcher - Exact Nexus Lucide icons -->
|
||||
{{-- Mobile Search Button --}}
|
||||
<button
|
||||
aria-label="Toggle Theme"
|
||||
class="btn btn-sm btn-circle btn-ghost relative overflow-hidden"
|
||||
onclick="toggleTheme()">
|
||||
<span class="icon-[heroicons--sun] absolute size-4.5 -translate-y-4 opacity-0 transition-all duration-300 group-data-[theme=light]/html:translate-y-0 group-data-[theme=light]/html:opacity-100"></span>
|
||||
<span class="icon-[heroicons--moon] absolute size-4.5 translate-y-4 opacity-0 transition-all duration-300 group-data-[theme=dark]/html:translate-y-0 group-data-[theme=dark]/html:opacity-100"></span>
|
||||
<span class="icon-[heroicons--paint-brush] absolute size-4.5 opacity-100 group-data-[theme=dark]/html:opacity-0 group-data-[theme=light]/html:opacity-0"></span>
|
||||
class="btn btn-sm btn-circle btn-ghost md:hidden"
|
||||
aria-label="Search"
|
||||
onclick="document.getElementById('mobile-search-modal').showModal()">
|
||||
<span class="icon-[heroicons--magnifying-glass] size-5"></span>
|
||||
</button>
|
||||
|
||||
@php
|
||||
$buyerBusiness = auth()->user()?->businesses->first();
|
||||
@endphp
|
||||
|
||||
@if($buyerBusiness)
|
||||
{{-- Chat/Messages --}}
|
||||
<a href="{{ route('buyer.crm.inbox.index', $buyerBusiness->slug) }}"
|
||||
class="btn btn-sm btn-circle btn-ghost relative"
|
||||
aria-label="Messages">
|
||||
<span class="icon-[heroicons--chat-bubble-left-right] size-5"></span>
|
||||
@php
|
||||
$unreadMessageCount = \App\Models\Crm\CrmThread::where('buyer_business_id', $buyerBusiness->id)
|
||||
->where('is_read', false)
|
||||
->where('last_message_direction', 'inbound')
|
||||
->count();
|
||||
@endphp
|
||||
@if($unreadMessageCount > 0)
|
||||
<div class="absolute -top-1 -right-1 bg-success text-success-content text-xs rounded-full min-w-[18px] h-[18px] flex items-center justify-center px-1">
|
||||
<span>{{ $unreadMessageCount > 99 ? '99+' : $unreadMessageCount }}</span>
|
||||
</div>
|
||||
@endif
|
||||
</a>
|
||||
@endif
|
||||
|
||||
<!-- Shopping Cart -->
|
||||
<div class="relative" x-data="cartCounter()">
|
||||
<a href="{{ route('buyer.business.cart.index', auth()->user()->businesses->first()->slug) }}" class="btn btn-sm btn-circle btn-ghost relative" aria-label="Shopping Cart">
|
||||
@@ -87,7 +111,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Notifications - Nexus Basic Style -->
|
||||
<div class="relative" x-data="notificationDropdown()">
|
||||
<div class="relative" x-data="notificationDropdown()" x-cloak>
|
||||
<button class="btn btn-sm btn-circle btn-ghost relative"
|
||||
aria-label="Notifications"
|
||||
@click="isOpen = !isOpen; if (isOpen && notifications.length === 0) fetchNotifications();">
|
||||
@@ -188,7 +212,7 @@
|
||||
<button class="btn btn-sm btn-ghost" @click="markAllAsRead()">
|
||||
Mark all as read
|
||||
</button>
|
||||
<a href="{{ route('buyer.notifications.index') }}"
|
||||
<a href="{{ route('buyer.notifications.index') }}"
|
||||
class="btn btn-sm btn-soft btn-primary"
|
||||
@click="isOpen = false">
|
||||
View All
|
||||
@@ -196,6 +220,19 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Theme Switcher -->
|
||||
<button
|
||||
aria-label="Toggle Theme"
|
||||
class="btn btn-sm btn-circle btn-ghost relative overflow-hidden"
|
||||
onclick="toggleTheme()">
|
||||
<span class="icon-[heroicons--sun] absolute size-4.5 -translate-y-4 opacity-0 transition-all duration-300 group-data-[theme=light]/html:translate-y-0 group-data-[theme=light]/html:opacity-100"></span>
|
||||
<span class="icon-[heroicons--moon] absolute size-4.5 translate-y-4 opacity-0 transition-all duration-300 group-data-[theme=dark]/html:translate-y-0 group-data-[theme=dark]/html:opacity-100"></span>
|
||||
<span class="icon-[heroicons--paint-brush] absolute size-4.5 opacity-100 group-data-[theme=dark]/html:opacity-0 group-data-[theme=light]/html:opacity-0"></span>
|
||||
</button>
|
||||
|
||||
<!-- User Account Dropdown (Top Right, next to notifications) -->
|
||||
<x-buyer-topbar-account />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -444,6 +481,29 @@
|
||||
|
||||
</script>
|
||||
|
||||
{{-- Quick View Modal --}}
|
||||
<x-marketplace.quick-view-modal />
|
||||
|
||||
{{-- Compare Bar (floats at bottom when products selected) --}}
|
||||
<x-marketplace.compare-bar />
|
||||
|
||||
{{-- Mobile Search Modal --}}
|
||||
<dialog id="mobile-search-modal" class="modal modal-top">
|
||||
<div class="modal-box max-w-full rounded-t-none p-4">
|
||||
<form method="dialog">
|
||||
<button class="btn btn-sm btn-circle btn-ghost absolute right-4 top-4">
|
||||
<span class="icon-[heroicons--x-mark] size-5"></span>
|
||||
</button>
|
||||
</form>
|
||||
<div class="mt-2">
|
||||
<x-marketplace-search />
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<!-- Analytics Tracking -->
|
||||
@include('partials.analytics')
|
||||
|
||||
|
||||
@@ -115,6 +115,20 @@ Route::prefix('b')->name('buyer.')->group(function () {
|
||||
Route::get('/notifications/count', [\App\Http\Controllers\Buyer\NotificationController::class, 'count'])->name('notifications.count');
|
||||
Route::post('/notifications/{id}/read', [\App\Http\Controllers\Buyer\NotificationController::class, 'markAsRead'])->name('notifications.read');
|
||||
Route::post('/notifications/read-all', [\App\Http\Controllers\Buyer\NotificationController::class, 'markAllAsRead'])->name('notifications.read-all');
|
||||
|
||||
// Search autocomplete for marketplace
|
||||
Route::get('/search/autocomplete', [\App\Http\Controllers\Buyer\SearchController::class, 'autocomplete'])->name('search.autocomplete');
|
||||
Route::get('/search/suggestions', [\App\Http\Controllers\Buyer\SearchController::class, 'suggestions'])->name('search.suggestions');
|
||||
|
||||
// Quick view product data (AJAX)
|
||||
Route::get('/products/{product}/quick-view', [\App\Http\Controllers\Buyer\ProductController::class, 'quickView'])->name('products.quick-view');
|
||||
|
||||
// Product comparison
|
||||
Route::get('/compare', [\App\Http\Controllers\Buyer\CompareController::class, 'index'])->name('compare.index');
|
||||
Route::get('/compare/state', [\App\Http\Controllers\Buyer\CompareController::class, 'state'])->name('compare.state');
|
||||
Route::post('/compare/toggle/{product}', [\App\Http\Controllers\Buyer\CompareController::class, 'toggle'])->name('compare.toggle');
|
||||
Route::delete('/compare/remove/{product}', [\App\Http\Controllers\Buyer\CompareController::class, 'remove'])->name('compare.remove');
|
||||
Route::delete('/compare/clear', [\App\Http\Controllers\Buyer\CompareController::class, 'clear'])->name('compare.clear');
|
||||
});
|
||||
|
||||
// Legacy routes without business context - redirect to first business
|
||||
@@ -171,6 +185,9 @@ Route::prefix('b')->name('buyer.')->group(function () {
|
||||
|
||||
// BUSINESS-SCOPED ROUTES - Require {business} parameter (CRITICAL SECURITY FIX)
|
||||
Route::prefix('{business}')->middleware(['auth', 'verified', 'approved'])->group(function () {
|
||||
// Buy It Again - Reorder from favorites and purchase history
|
||||
Route::get('/buy-again', [\App\Http\Controllers\Buyer\BuyAgainController::class, 'index'])->name('business.buy-again');
|
||||
|
||||
// Buyer Insights (read-only recommendations)
|
||||
// Gated by has_buyer_intelligence in controller
|
||||
Route::get('/insights', [\App\Http\Controllers\Buyer\InsightsController::class, 'index'])->name('business.insights.index');
|
||||
|
||||
Reference in New Issue
Block a user