Compare commits

...

23 Commits

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

The deals page shows:
- Stats: total deals, percentage off, BOGO, bundles
- Featured deal products grid
- Grouped sections by promo type (%, BOGO, bundle, price override)
- Brands with active deals for quick navigation
2025-12-18 08:15:34 -07:00
kelly
14c30dad03 feat: enhance product cards with visual improvements
- Add THC/CBD/Terpene visual progress bars with gradients
- Color-coded strain type badges (purple=indica, orange=sativa, green=hybrid)
- Show case pricing and units per case
- Unified "Add" button with embedded qty selector (+/- inside button)
- Enhanced hover overlay with qty stepper on image
- Fix quick-view modal to use hashid instead of numeric ID
- Support quantity parameter in add-to-cart events
2025-12-18 08:12:56 -07:00
kelly
08d49b9b67 perf: optimize buyer marketplace pages
MarketplaceController optimizations:
- Use selective column eager loading (brand:id,name,slug...)
- Cache brands/categories for 5 minutes (rarely change)
- Cache trending products for 10 minutes
- Only load homepage sections when not filtering
- Use whereExists instead of whereHas for better SQL performance
- Reuse cached brands for topBrands instead of separate query

Product::scopeInStock optimization:
- Include inventory_mode=unlimited products (always in stock)
- Use whereExists instead of whereHas (faster subquery)

These changes reduce query count and execution time significantly.
2025-12-18 08:03:56 -07:00
kelly
036ae5c6f6 fix: constrain brand logo fallback to 50% of card size via CSS
Use max-w-[50%] and max-h-[50%] to visually limit the brand logo
when displayed as a product image fallback. This ensures logos
appear smaller and centered rather than filling the entire card.
2025-12-18 07:58:33 -07:00
kelly
4c45805390 fix: show brand logo fallback at 50% size in product cards
When products don't have their own image, the brand logo fallback
is now requested at half the normal size to keep it visually smaller
and more balanced within the product card.
2025-12-18 07:56:21 -07:00
kelly
fc943afb36 fix: contain brand logo fallback in product cards
When a product has no image_path, it falls back to showing the brand logo.
Previously, the logo was displayed with object-cover which caused oversized
logos (like 'White Label Canna') to completely fill the card.

Now:
- Products with their own image: object-cover (fill the card)
- Products using brand logo fallback: object-contain with padding

This keeps the logo properly sized and centered within the card.
2025-12-18 07:54:53 -07:00
kelly
25ae50dcd6 perf: add browser caching headers to image responses
- Add 1-year cache headers (Cache-Control, Expires) to all image responses
- Add ETag header based on file path + model updated_at timestamp
- Use 'immutable' cache directive since image URLs include hashids

This fixes slow image loading on /shop by letting browsers cache images
instead of re-requesting them on every page load.
2025-12-18 07:51:18 -07:00
kelly
d1422efe87 fix: use correct buyer layout name (buyer-app-with-sidebar) 2025-12-18 01:08:10 -07:00
kelly
45da5d075d fix: add business parameter to buyer CRM layout route calls
Use request()->route('business') to get the business from the current
route when $business isn't passed directly from child views.
2025-12-18 01:07:03 -07:00
kelly
bdc54da4ad feat: add Buy It Again page for buyers
Add a dedicated "Buy It Again" feature for buyers to quickly reorder
from their favorite brands and purchase history:

- New BuyAgainController with two tabs:
  - Store favorites: Products from followed brands
  - Purchase history: All previously ordered products
- Products grouped by brand with collapsible sections
- Search filtering across products and brands
- Quantity selector with +/- buttons and bulk "Add all to cart"
- Last ordered date display (Month Year format)
- Optional CannaIQ integration for inventory metrics:
  - In Stock count
  - Days until out (with color-coded badges)
- Empty states with CTAs to browse brands/shop

Route: /b/{business}/buy-again
2025-12-18 01:05:38 -07:00
kelly
cb6dc5e433 fix: pass business parameter to inbox view and route calls
The InboxController now passes $business to the view, and all
route() calls in the inbox index view include the business parameter.
2025-12-18 01:05:09 -07:00
kelly
3add610e85 feat: add buyer-side inbox scopes and tracking to CrmThread
- Add forBuyerBusiness scope for filtering threads by buyer
- Add hasUnreadForBuyer, starredByBuyer, archivedByBuyer scopes
- Add notArchivedByBuyer scope for default inbox view
- Add markAsReadForBuyer, toggleStarForBuyer, archiveForBuyer methods
- Add latestMessage and quote relationships
- Add migration for buyer tracking columns:
  - is_read_by_buyer, read_at_by_buyer
  - buyer_starred_by, buyer_archived_by (JSON arrays)
  - quote_id foreign key
2025-12-18 00:57:05 -07:00
kelly
183a22c475 fix: use route model binding for Business in buyer InboxController
Auth::user()->business doesn't exist - users have a businesses()
relationship (many-to-many via pivot). Updated all controller methods
to accept Business $business from route model binding instead.
2025-12-18 00:51:30 -07:00
kelly
f2297d62f2 feat: align buyer topbar with seller layout
- Add buyer-topbar-account component for user dropdown in topbar
- Add chat/messages icon with unread badge to buyer topbar
- Move user account from sidebar to topbar (like seller)
- Reorder topbar items: search, chat, cart, notifications, theme, user
- Use buyer CRM routes for profile, orders, favorites, settings
2025-12-18 00:41:38 -07:00
kelly
6b994147c3 fix: Placeholder is in Forms\Components, not Schemas\Components 2025-12-18 00:10:50 -07:00
kelly
a6d9e203c2 fix: use correct Filament v4 schema component imports
Section and Placeholder are in Filament\Schemas\Components namespace.
2025-12-18 00:10:00 -07:00
kelly
f652c19b24 fix: hide BannerAdResource when table doesn't exist
Add canAccess() check to prevent resource from loading when
banner_ads migrations haven't been run yet.
2025-12-18 00:08:16 -07:00
kelly
76ce86fb41 fix: use correct Filament v4 bulk action imports
All bulk actions (BulkActionGroup, DeleteBulkAction, ForceDeleteBulkAction,
RestoreBulkAction) are in Filament\Actions namespace in v4.
2025-12-17 23:10:35 -07:00
kelly
5a22f7dbb6 fix: use correct Filament v4 action imports for BannerAdResource
Filament v4 moved ViewAction and EditAction from Tables\Actions to
the main Filament\Actions namespace.
2025-12-17 23:01:32 -07:00
kelly
5b8809b962 fix: handle missing banner_ads table in Filament navigation badge
Check Schema::hasTable before querying in getNavigationBadge() to
prevent errors when banner ad migrations haven't been run yet.
2025-12-17 22:57:18 -07:00
kelly
cdf982ed39 fix: brand page only shows in-stock or unlimited products
- Filter out out-of-stock products from brand storefront
- Products with unlimited inventory always shown
- Featured products section also filters to in-stock only
2025-12-17 22:56:43 -07:00
kelly
aac83a084c fix: support hashid lookup in product detail page
The route uses hashid (e.g., 17re1) but the controller was only
checking slug and numeric ID. Added hashid as the first check.
2025-12-17 22:54:47 -07:00
kelly
5f9613290d feat: marketplace enhancements - search, compare, quick view, recently viewed
Search Autocomplete:
- SearchController with autocomplete and suggestions endpoints
- Marketplace search component with dropdown results
- Shows matching products, brands, and search suggestions
- Added to buyer layout topbar with mobile modal

Recently Viewed Products:
- RecentlyViewedService for session-based tracking (max 20 items)
- Display sections on marketplace homepage and product pages
- Tracks view when visiting product detail page

Quick View Modal:
- ProductController with quickView endpoint returning JSON
- Quick view modal component with product details
- Add to cart functionality from modal
- Button on product cards and hover overlay

Product Comparison:
- ProductComparisonService for session-based comparison (max 4 items)
- CompareController with toggle, remove, clear endpoints
- Comparison page with side-by-side product table
- Floating compare bar showing selected products
- Toggle button on product cards

Brand Directory Redesign:
- Hero section with gradient and search bar
- Featured brands section (top 4 by product count)
- Alphabet quick navigation
- Grid/list view toggle with localStorage persistence
- Search and sort functionality

BannerAd Fix:
- Handle missing banner_ads table gracefully
- Check Schema::hasTable before querying
- Wrap in try-catch to prevent page errors
2025-12-17 22:53:47 -07:00
30 changed files with 3268 additions and 340 deletions

View File

@@ -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(),
]),
]);
}

View 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;
}
}
}

View 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,
]);
}
}

View File

@@ -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()

View 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,
]);
}
}

View 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]),
]),
]);
}
}

View File

@@ -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);
}
}

View File

@@ -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);

View File

@@ -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)
*/

View File

@@ -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');
}
}

View File

@@ -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)

View 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;
}
}

View 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, []));
}
}

View File

@@ -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());
}
}

View File

@@ -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',
]);
});
}
};

View 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

View 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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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);
});
}
}

View File

@@ -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>

View File

@@ -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"

View 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>

View 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>&bull;</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>

View 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>

View File

@@ -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)
&bull; ${{ 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)

View File

@@ -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>

View File

@@ -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')

View File

@@ -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');