Compare commits

...

2 Commits

Author SHA1 Message Date
kelly
829dbe0c38 Implement 5-character brand codes for buyer-facing URLs and fix authentication routing issues.
### Brand Code System
- Add brand_code column to brands table (5 chars, unique, indexed)
- Generate codes using character set: 23456789ABCDEFGHJKMNPQRSTUVWXYZ (excludes confusing chars: 0,O,1,L,I)
- Update Brand model to use brand_code for route binding
- Update marketplace routes to use brand codes: /b/{business}/brands/{brand_code}
- Update MarketplaceController to use automatic route model binding with brand codes
- Update marketplace views to use brand model directly instead of brand IDs

### Routing Fixes
- Add Business::getRouteKeyName() to use slug for URLs (/b/imabuyer vs /b/4)
- Fix UnifiedAuthenticatedSessionController to redirect buyers to business dashboard
- Add buyer.dashboard route alias for backwards compatibility

### Test Data Setup
- Set slugs for imabuyer and imaseller businesses
- Configure user_type and email verification for test users
- Assign brands to imaseller busines
2025-11-02 16:26:47 -08:00
kelly
3689d07831 Restructure buyer marketplace routing to add business context and use numeric IDs instead of slugs, following industry-standard B2B marketplace URL patterns similar to LeafLink.
Restructure buyer marketplace routing to add business context and use numeric IDs instead of slugs, following industry-standard B2B marketplace URL patterns similar to LeafLink.

**URL Structure Changes:**
- OLD: /b/browse, /b/brands/{brand-slug}, /b/brands/{brand-slug}/{product-slug}
- NEW: /b/{business}/browse, /b/{business}/brands/{id}, /b/{business}/brands/{brand-id}/{product-id}

**Why This Change:**
- Adds buyer business context to marketplace URLs for better multi-tenancy
- Changes from slug-based to ID-based routing for brands and products
- Prepares architecture for future order splitting by brand feature
- Aligns with industry-standard B2B marketplace URL patterns

**Files Modified:**

1. **routes/buyer.php**
   - Added custom route model binding for buyer business (slug-based with authorization)
   - Moved dashboard and browse routes under /{business}/ prefix
   - Changed brand and product parameters from slugs to numeric IDs with regex constraints
   - Updated route names: buyer.* → buyer.business.*

2. **app/Http/Controllers/MarketplaceController.php**
   - Added Business $business parameter to all marketplace methods
   - Updated showBrand() to use findOrFail($brandId) instead of slug lookup
   - Updated showProduct() to use findOrFail($productId) instead of slug lookup
   - All methods now pass $business to views for route generation

3. **app/Http/Controllers/BuyerDashboardController.php**
   - Added Business $business parameter to index() method
   - Business now injected via route model binding instead of manual lookup

4. **resources/views/components/buyer-sidebar.blade.php**
   - Added $business variable extraction from auth user
   - Updated all dashboard and browse links to use buyer.business.* routes
   - Added business parameter to all route helpers

5. **resources/views/buyer/marketplace/*.blade.php** (4 files updated)
   - index.blade.php: 9 route references updated
   - brands.blade.php: 2 route references updated
   - brand.blade.php: 5 route references updated
   - product.blade.php: 6 route references updated
   - All views now use $business parameter and .id instead of .slug
   - Total: 22 route references converted

6. **app/Http/Controllers/Buyer/CheckoutController.php**
   - Added comprehensive TODO comments for future order splitting by brand feature
   - Documents that checkout should create multiple orders (one per brand) in future
   - References industry pattern from LeafLink

**Cross-Tenant Browsing Preserved:**
- Buyers continue to browse ALL brands from ALL sellers simultaneously
- No changes to product access or filtering logic
- Only URL structure and routing updated

**Future Feature Preparation:**
- TODO comments added in CheckoutController for order splitting by brand
- When implemented, single cart checkout will create multiple orders (one per brand)
- Each seller will receive only their brand's order

**Backwards Compatibility:**
- This is a breaking change for existing marketplace URLs
- Old slug-based URLs will return 404 (expected for this refactor)
- All internal links and route helpers updated to new structure

**Testing:**
- Routes verified with `php artisan route:list --name=buyer.business`
- All caches cleared with `php artisan optimize:clear`
- New routes confirmed:
  - b/{business}/dashboard → buyer.business.dashboard
  - b/{business}/browse → buyer.business.browse
  - b/{business}/brands → buyer.business.brands.index
  - b/{business}/brands/{brandId} → buyer.business.brands.show
  - b/{business}/brands/{brandId}/{productId} → buyer.business.brands.products.show
2025-11-02 15:08:11 -08:00
15 changed files with 250 additions and 77 deletions

View File

@@ -80,8 +80,8 @@ class BusinessResource extends Resource
->required()
->options([
'retailer' => 'Retailer/Dispensary',
'brand' => 'Brand/Manufacturer',
'both' => 'Both Retailer & Brand',
'brand' => 'Seller/Manufacturer',
'both' => 'Both Retailer & Seller',
])
->default('retailer'),
TextInput::make('business_group')
@@ -717,7 +717,7 @@ class BusinessResource extends Resource
->label('Business Type')
->options([
'retailer' => 'Retailer/Dispensary',
'brand' => 'Brand/Manufacturer',
'brand' => 'Seller/Manufacturer',
'both' => 'Both',
]),
TernaryFilter::make('is_active')

View File

@@ -11,6 +11,7 @@ use Filament\Actions\ActionGroup;
use Filament\Actions\EditAction;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
@@ -85,6 +86,21 @@ class UserResource extends Resource
'suspended' => 'Suspended',
])
->default('active'),
TextInput::make('password')
->password()
->dehydrateStateUsing(fn ($state) => bcrypt($state))
->required(fn ($livewire) => $livewire instanceof \Filament\Resources\Pages\CreateRecord)
->label('Password')
->maxLength(255)
->visible(fn ($livewire) => $livewire instanceof \Filament\Resources\Pages\CreateRecord),
TextInput::make('password_confirmation')
->password()
->dehydrated(false)
->required(fn ($livewire) => $livewire instanceof \Filament\Resources\Pages\CreateRecord)
->label('Confirm Password')
->same('password')
->maxLength(255)
->visible(fn ($livewire) => $livewire instanceof \Filament\Resources\Pages\CreateRecord),
])->columns(2),
Section::make('Business Association')

View File

@@ -33,7 +33,12 @@ class UnifiedAuthenticatedSessionController extends Controller
// Smart routing based on user type
switch ($user->user_type) {
case 'buyer':
return redirect()->route('buyer.dashboard');
// Redirect to business dashboard if they have a business
$business = $user->primaryBusiness();
if ($business) {
return redirect()->route('buyer.business.dashboard', $business);
}
return redirect()->route('buyer.setup');
case 'seller':
return redirect()->route('seller.dashboard');

View File

@@ -125,6 +125,16 @@ class CheckoutController extends Controller
$tax = ($subtotal + $surcharge) * $taxRate;
$total = $subtotal + $surcharge + $tax;
// TODO: FUTURE FEATURE - Order Splitting by Brand
// Currently, one order is created per checkout with all cart items.
// Future implementation should:
// 1. Group cart items by brand_id
// 2. Create one separate order per brand (multiple orders from single cart)
// 3. Each seller receives only their brand's order
// 4. Buyer sees multiple orders in their order history (one per brand)
// 5. Update this transaction to loop through grouped items and create multiple orders
// Reference: Similar to LeafLink's multi-vendor checkout behavior
// Create order in transaction
$order = DB::transaction(function () use ($request, $user, $business, $items, $subtotal, $surcharge, $tax, $total, $paymentTerms, $dueDate) {
// Generate order number

View File

@@ -2,6 +2,7 @@
namespace App\Http\Controllers;
use App\Models\Business;
use App\Models\Invoice;
use App\Models\Order;
use App\Services\CartService;
@@ -10,14 +11,12 @@ class BuyerDashboardController extends Controller
{
/**
* Display the buyer marketplace dashboard
* Now requires business context in URL
*/
public function index(CartService $cartService)
public function index(Business $business, CartService $cartService)
{
$user = auth()->user();
// Get user's primary business
$business = $user->primaryBusiness();
// Check if user needs to complete onboarding
$needsOnboarding = ! $user->business_onboarding_completed
|| ($business && in_array($business->status, ['not_started', 'in_progress', 'rejected']));

View File

@@ -3,6 +3,7 @@
namespace App\Http\Controllers;
use App\Models\Brand;
use App\Models\Business;
use App\Models\Product;
use App\Models\Strain;
use Illuminate\Http\Request;
@@ -12,7 +13,7 @@ class MarketplaceController extends Controller
/**
* Display marketplace browse page
*/
public function index(Request $request)
public function index(Business $business, Request $request)
{
// Start with active products only
$query = Product::query()
@@ -78,7 +79,7 @@ class MarketplaceController extends Controller
->limit(3)
->get();
return view('buyer.marketplace.index', compact('products', 'brands', 'featuredProducts'));
return view('buyer.marketplace.index', compact('business', 'products', 'brands', 'featuredProducts'));
}
/**
@@ -92,7 +93,7 @@ class MarketplaceController extends Controller
/**
* Display all brands directory
*/
public function brands()
public function brands(Business $business)
{
$brands = Brand::query()
->active()
@@ -102,29 +103,30 @@ class MarketplaceController extends Controller
->orderBy('name')
->get();
return view('buyer.marketplace.brands', compact('brands'));
return view('buyer.marketplace.brands', compact('brands', 'business'));
}
/**
* Display products in specific category
*/
public function category($category)
public function category(Business $business, $category)
{
return view('buyer.marketplace.category', compact('category'));
return view('buyer.marketplace.category', compact('business', 'category'));
}
/**
* Show individual product (nested under brand)
* Now uses brand_code for URLs (e.g., /brands/2GDWH/123)
*/
public function showProduct($brandSlug, $productSlug)
public function showProduct(Business $business, Brand $brand, $productId)
{
// Find brand by slug
$brand = Brand::query()
->where('slug', $brandSlug)
->active()
->firstOrFail();
// Brand is automatically resolved via route model binding using brand_code
// Verify brand is active
if (!$brand->is_active) {
abort(404);
}
// Find product by slug within this brand
// Find product by ID within this brand
$product = Product::query()
->with([
'brand',
@@ -138,14 +140,8 @@ class MarketplaceController extends Controller
->orderBy('created_at', 'desc');
},
])
->where('id', $productId)
->where('brand_id', $brand->id)
->where(function ($query) use ($productSlug) {
$query->where('slug', $productSlug);
// Only try ID lookup if the value is numeric
if (is_numeric($productSlug)) {
$query->orWhere('id', $productSlug);
}
})
->active()
->firstOrFail();
@@ -159,19 +155,20 @@ class MarketplaceController extends Controller
->limit(4)
->get();
return view('buyer.marketplace.product', compact('product', 'relatedProducts', 'brand'));
return view('buyer.marketplace.product', compact('business', 'product', 'relatedProducts', 'brand'));
}
/**
* Show individual brand storefront
* Now uses brand_code (e.g., /brands/2GDWH)
*/
public function showBrand($brandSlug)
public function showBrand(Business $business, Brand $brand)
{
// Find brand by slug
$brand = Brand::query()
->where('slug', $brandSlug)
->active()
->firstOrFail();
// Brand is automatically resolved via route model binding using brand_code
// Verify brand is active
if (!$brand->is_active) {
abort(404);
}
// Get featured products from this brand
$featuredProducts = Product::query()
@@ -191,6 +188,6 @@ class MarketplaceController extends Controller
->orderBy('name')
->paginate(20);
return view('buyer.marketplace.brand', compact('brand', 'featuredProducts', 'products'));
return view('buyer.marketplace.brand', compact('business', 'brand', 'featuredProducts', 'products'));
}
}

View File

@@ -13,6 +13,15 @@ class Brand extends Model
{
use HasFactory, SoftDeletes;
/**
* Get the route key name for Laravel route model binding.
* Uses brand_code for buyer-facing URLs (e.g., /b/imabuyer/brands/2GDWH)
*/
public function getRouteKeyName(): string
{
return 'brand_code';
}
// Product Categories that can be organized under brands
public const PRODUCT_CATEGORIES = [
'flower' => 'Flower',
@@ -33,6 +42,7 @@ class Brand extends Model
// Brand Identity
'name',
'slug',
'brand_code', // 5-char alphanumeric code for buyer URLs
'sku_prefix', // SKU prefix for products
'description',
'tagline',
@@ -166,14 +176,6 @@ class Brand extends Model
->get();
}
/**
* Get route key (slug for URLs)
*/
public function getRouteKeyName(): string
{
return 'slug';
}
/**
* Generate slug from name
*/

View File

@@ -27,6 +27,15 @@ class Business extends Model implements AuditableContract
return ['uuid'];
}
/**
* Get the route key name for Laravel route model binding.
* This ensures URLs use slug instead of ID (e.g., /b/imabuyer/browse instead of /b/4/browse)
*/
public function getRouteKeyName(): string
{
return 'slug';
}
/**
* Generate a new UUID for the model (first 16 hex chars = 18 total with hyphens).
*/
@@ -46,8 +55,10 @@ class Business extends Model implements AuditableContract
];
// Business types aligned with cannabis industry (stored in business_type column)
// NOTE: 'brand' here means seller/manufacturer business (not the Brand model)
// A business with type 'brand' can own multiple Brand records in the brands table
public const BUSINESS_TYPES = [
'brand' => 'Brand/Manufacturer',
'brand' => 'Seller/Manufacturer', // Businesses that sell brands/products
'retailer' => 'Retailer/Dispensary',
'distributor' => 'Distributor',
'cultivator' => 'Cultivator',

View File

@@ -0,0 +1,73 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('brands', function (Blueprint $table) {
$table->string('brand_code', 5)->nullable()->unique()->after('slug');
$table->index('brand_code');
});
// Generate brand codes for existing brands
$this->generateBrandCodes();
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('brands', function (Blueprint $table) {
$table->dropIndex(['brand_code']);
$table->dropColumn('brand_code');
});
}
/**
* Generate unique 5-character alphanumeric codes for existing brands
* Excludes confusing characters: 0, O, 1, L, I
*/
private function generateBrandCodes(): void
{
$brands = DB::table('brands')->whereNull('brand_code')->get();
foreach ($brands as $brand) {
$code = $this->generateUniqueCode();
DB::table('brands')->where('id', $brand->id)->update(['brand_code' => $code]);
}
}
/**
* Generate a unique 5-character code
* Character set: 23456789ABCDEFGHJKMNPQRSTUVWXYZ (excludes 0, O, 1, L, I)
*/
private function generateUniqueCode(): string
{
$characters = '23456789ABCDEFGHJKMNPQRSTUVWXYZ';
$maxAttempts = 100;
for ($attempt = 0; $attempt < $maxAttempts; $attempt++) {
$code = '';
for ($i = 0; $i < 5; $i++) {
$code .= $characters[random_int(0, strlen($characters) - 1)];
}
// Check if code is unique
$exists = DB::table('brands')->where('brand_code', $code)->exists();
if (!$exists) {
return $code;
}
}
throw new \RuntimeException('Could not generate unique brand code after ' . $maxAttempts . ' attempts');
}
};

View File

@@ -1,3 +1,7 @@
@php
$business = $business ?? auth()->user()->primaryBusiness();
@endphp
@extends('layouts.buyer-app-with-sidebar')
@section('content')
@@ -6,7 +10,7 @@
<div class="breadcrumbs text-sm mb-6">
<ul>
<li><a href="{{ route('buyer.dashboard') }}">Dashboard</a></li>
<li><a href="{{ route('buyer.brands.index') }}">Brands</a></li>
<li><a href="{{ route('buyer.business.brands.index', $business) }}">Brands</a></li>
<li class="opacity-80">{{ $brand->name }}</li>
</ul>
</div>
@@ -97,7 +101,7 @@
<!-- Product Info -->
<div class="flex-1 min-w-0">
<a href="{{ route('buyer.brands.products.show', [$brand->slug, $product->slug ?? $product->id]) }}" class="font-semibold text-sm hover:text-primary line-clamp-2">
<a href="{{ route('buyer.business.brands.products.show', [$business, $brand->id, $product->id]) }}" class="font-semibold text-sm hover:text-primary line-clamp-2">
{{ $product->name }}
</a>
@@ -160,7 +164,7 @@
</div>
<div>
<div class="font-semibold">
<a href="{{ route('buyer.brands.products.show', [$brand->slug, $product->slug ?? $product->id]) }}" class="hover:text-primary">
<a href="{{ route('buyer.business.brands.products.show', [$business, $brand->id, $product->id]) }}" class="hover:text-primary">
{{ $product->name }}
</a>
</div>
@@ -210,7 +214,7 @@
<!-- Actions -->
<td class="text-right">
<div class="flex gap-2 justify-end">
<a href="{{ route('buyer.brands.products.show', [$brand->slug, $product->slug ?? $product->id]) }}" class="btn btn-ghost btn-sm">
<a href="{{ route('buyer.business.brands.products.show', [$business, $brand->id, $product->id]) }}" class="btn btn-ghost btn-sm">
<span class="icon-[lucide--eye] size-4"></span>
</a>

View File

@@ -1,3 +1,7 @@
@php
$business = $business ?? auth()->user()->primaryBusiness();
@endphp
@extends('layouts.buyer-app-with-sidebar')
@section('content')
@@ -12,7 +16,7 @@
@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">
<a href="{{ route('buyer.business.brands.show', [$business, $brand->id]) }}" 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">

View File

@@ -1,3 +1,7 @@
@php
$business = $business ?? auth()->user()->primaryBusiness();
@endphp
@extends('layouts.buyer-app-with-sidebar')
@section('content')
@@ -44,7 +48,7 @@
<span class="badge badge-lg bg-white/20 backdrop-blur-sm text-white border-white/30">THC {{ $featured->thc_percentage }}%</span>
@endif
</div>
<a href="{{ route('buyer.brands.products.show', [$featured->brand->slug, $featured->slug ?? $featured->id]) }}" class="btn btn-warning btn-lg">
<a href="{{ route('buyer.business.brands.products.show', [$business, $featured->brand, $featured->id]) }}" class="btn btn-warning btn-lg">
<span class="icon-[lucide--shopping-cart] size-5"></span>
Shop Now
</a>
@@ -105,7 +109,7 @@
<div class="card-body">
<h3 class="font-bold text-lg mb-4">Filters</h3>
<form method="GET" action="{{ route('buyer.browse') }}" id="filterForm">
<form method="GET" action="{{ route('buyer.business.browse', $business) }}" id="filterForm">
<!-- Search -->
<div class="form-control mb-4">
<label class="label">
@@ -193,7 +197,7 @@
<span class="icon-[lucide--filter] size-4"></span>
Apply
</button>
<a href="{{ route('buyer.browse') }}" class="btn btn-ghost">
<a href="{{ route('buyer.business.browse', $business) }}" class="btn btn-ghost">
<span class="icon-[lucide--x] size-4"></span>
</a>
</div>
@@ -213,7 +217,7 @@
</div>
<!-- Sort Dropdown -->
<form method="GET" action="{{ route('buyer.browse') }}" class="flex items-center gap-2">
<form method="GET" action="{{ route('buyer.business.browse', $business) }}" class="flex items-center gap-2">
<!-- Preserve existing filters -->
@foreach(request()->except('sort') as $key => $value)
@if(is_array($value))
@@ -278,7 +282,7 @@
<!-- Brand -->
@if($product->brand)
<div class="text-xs font-semibold uppercase tracking-wide">
<a href="{{ route('buyer.brands.show', $product->brand->slug) }}" class="text-primary hover:underline">
<a href="{{ route('buyer.business.brands.show', [$business, $product->brand]) }}" class="text-primary hover:underline">
{{ $product->brand->name }}
</a>
</div>
@@ -286,7 +290,7 @@
<!-- Product Name -->
<h3 class="card-title text-lg">
<a href="{{ route('buyer.brands.products.show', [$product->brand->slug, $product->slug ?? $product->id]) }}" class="hover:text-primary">
<a href="{{ route('buyer.business.brands.products.show', [$business, $product->brand, $product->id]) }}" class="hover:text-primary">
{{ $product->name }}
</a>
</h3>
@@ -330,7 +334,7 @@
<!-- Actions -->
<div class="card-actions justify-between items-center mt-4"
x-data="{ productId: {{ $product->id }}, availableQty: {{ $product->available_quantity }} }">
<a href="{{ route('buyer.brands.products.show', [$product->brand->slug, $product->slug ?? $product->id]) }}" class="btn btn-ghost btn-sm">
<a href="{{ route('buyer.business.brands.products.show', [$business, $product->brand, $product->id]) }}" class="btn btn-ghost btn-sm">
<span class="icon-[lucide--eye] size-4"></span>
Details
</a>
@@ -390,7 +394,7 @@
<span class="icon-[lucide--package-search] size-16 text-gray-300 mx-auto mb-4"></span>
<h3 class="text-xl font-semibold text-gray-700 mb-2">No products found</h3>
<p class="text-gray-500 mb-4">Try adjusting your filters or search terms</p>
<a href="{{ route('buyer.browse') }}" class="btn btn-primary btn-sm">
<a href="{{ route('buyer.business.browse', $business) }}" class="btn btn-primary btn-sm">
Clear All Filters
</a>
</div>

View File

@@ -1,3 +1,7 @@
@php
$business = $business ?? auth()->user()->primaryBusiness();
@endphp
@extends('layouts.buyer-app-with-sidebar')
@section('content')
@@ -6,9 +10,9 @@
<div class="breadcrumbs text-sm mb-6">
<ul>
<li><a href="{{ route('buyer.dashboard') }}">Dashboard</a></li>
<li><a href="{{ route('buyer.brands.index') }}">Brands</a></li>
<li><a href="{{ route('buyer.business.brands.index', $business) }}">Brands</a></li>
@if($product->brand)
<li><a href="{{ route('buyer.brands.show', $product->brand->slug) }}">{{ $product->brand->name }}</a></li>
<li><a href="{{ route('buyer.business.brands.show', [$business, $product->brand]) }}">{{ $product->brand->name }}</a></li>
@endif
<li class="opacity-80">{{ $product->name }}</li>
</ul>
@@ -66,7 +70,7 @@
<div>
<!-- Brand -->
@if($product->brand)
<a href="{{ route('buyer.brands.show', $product->brand->slug) }}" class="text-sm text-primary font-semibold uppercase tracking-wide hover:underline">
<a href="{{ route('buyer.business.brands.show', [$business, $product->brand]) }}" class="text-sm text-primary font-semibold uppercase tracking-wide hover:underline">
{{ $product->brand->name }}
</a>
@endif
@@ -383,7 +387,7 @@
<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">
<a href="{{ route('buyer.business.brands.products.show', [$business, $product->brand, $relatedProduct->id]) }}" class="hover:text-primary">
{{ $relatedProduct->name }}
</a>
</h4>
@@ -393,7 +397,7 @@
</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">
<a href="{{ route('buyer.business.brands.products.show', [$business, $product->brand, $relatedProduct->id]) }}" class="btn btn-primary btn-sm">
View Details
</a>
</div>

View File

@@ -10,9 +10,13 @@
aria-label="Dense layout sidebar" />
<div id="layout-sidebar-hover" class="bg-base-300 h-screen w-1"></div>
@php
$business = auth()->user()->primaryBusiness();
@endphp
<div id="layout-sidebar" class="sidebar-menu sidebar-menu-activation">
<div class="flex min-h-16 items-center justify-center gap-3 px-4">
<a href="{{ route('buyer.dashboard') }}">
<a href="{{ $business ? route('buyer.business.dashboard', $business) : route('buyer.setup') }}">
<img alt="logo" class="h-8 logo-img" src="{{ asset('images/canna_white.svg') }}" />
</a>
</div>
@@ -56,7 +60,7 @@
</div>
<div class="collapse-content ms-6.5 !p-0">
<div class="mt-0.5 space-y-0.5">
<a class="menu-item {{ request()->routeIs('buyer.dashboard') ? 'active' : '' }}" href="{{ route('buyer.dashboard') }}">
<a class="menu-item {{ request()->routeIs('buyer.business.dashboard') ? 'active' : '' }}" href="{{ $business ? route('buyer.business.dashboard', $business) : '#' }}">
<span class="grow">Overview</span>
</a>
<a class="menu-item" href="#">
@@ -74,7 +78,7 @@
<span class="grow">Promotion</span>
</a>
<a class="menu-item {{ request()->routeIs('buyer.browse*') ? 'active' : '' }}" href="{{ route('buyer.browse') }}">
<a class="menu-item {{ request()->routeIs('buyer.business.browse*') ? 'active' : '' }}" href="{{ $business ? route('buyer.business.browse', $business) : '#' }}">
<span class="icon-[lucide--store] size-4"></span>
<span class="grow">Shop</span>
</a>

View File

@@ -3,6 +3,18 @@
use App\Http\Controllers\ProfileController;
use Illuminate\Support\Facades\Route;
// Custom route model binding for buyer business by slug
Route::bind('business', function (string $value) {
$business = \App\Models\Business::where('slug', $value)->firstOrFail();
// Verify user has access to this business
if (! auth()->check() || ! auth()->user()->businesses->contains($business->id)) {
abort(403, 'You do not have access to this business.');
}
return $business;
});
// Custom route model binding for orders by order number
Route::bind('order', function (string $value) {
return \App\Models\Order::where('order_number', $value)
@@ -17,6 +29,32 @@ Route::bind('invoice', function (string $value) {
// Buyer-specific routes under /b/ prefix (NEW buyer functionality)
Route::prefix('b')->name('buyer.')->middleware('buyer')->group(function () {
// Root redirect - redirect /b/ to user's business dashboard
Route::get('/', function () {
$business = auth()->user()?->primaryBusiness();
if ($business) {
return redirect()->route('buyer.business.dashboard', $business);
}
return redirect()->route('buyer.setup');
})->middleware(['auth', 'verified']);
// Legacy route redirects for backwards compatibility
Route::get('/browse', function () {
$business = auth()->user()?->primaryBusiness();
if ($business) {
return redirect()->route('buyer.business.browse', $business);
}
return redirect()->route('buyer.setup');
})->middleware(['auth', 'verified']);
Route::get('/dashboard', function () {
$business = auth()->user()?->primaryBusiness();
if ($business) {
return redirect()->route('buyer.business.dashboard', $business);
}
return redirect()->route('buyer.setup');
})->middleware(['auth', 'verified'])->name('dashboard');
// Buyer registration routes (email-first flow)
Route::middleware('guest')->group(function () {
// Step 1: Email collection
@@ -48,8 +86,17 @@ Route::prefix('b')->name('buyer.')->middleware('buyer')->group(function () {
Route::post('/setup/{step?}', [\App\Http\Controllers\BusinessSetupController::class, 'store'])->name('setup.store')->where('step', '[1-4]');
});
// Buyer browsing and dashboard (accessible without approval)
// Buyer notifications (not business-scoped)
Route::middleware(['auth', 'verified'])->group(function () {
Route::get('/notifications', [\App\Http\Controllers\Buyer\NotificationController::class, 'index'])->name('notifications.index');
Route::get('/notifications/dropdown', [\App\Http\Controllers\Buyer\NotificationController::class, 'dropdown'])->name('notifications.dropdown');
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');
});
// Business-scoped buyer routes (browsing and dashboard)
Route::prefix('{business}')->name('business.')->middleware(['auth', 'verified'])->group(function () {
// Main buyer dashboard (marketplace browsing)
Route::get('/dashboard', [\App\Http\Controllers\BuyerDashboardController::class, 'index'])->name('dashboard');
@@ -58,17 +105,10 @@ Route::prefix('b')->name('buyer.')->middleware('buyer')->group(function () {
Route::get('/browse/products', [\App\Http\Controllers\MarketplaceController::class, 'products'])->name('browse.products');
Route::get('/browse/categories/{category}', [\App\Http\Controllers\MarketplaceController::class, 'category'])->name('browse.category');
// Brand directory and pages
// Brand directory and pages (using 5-char brand codes: e.g., /brands/2GDWH)
Route::get('/brands', [\App\Http\Controllers\MarketplaceController::class, 'brands'])->name('brands.index');
Route::get('/brands/{brand}', [\App\Http\Controllers\MarketplaceController::class, 'showBrand'])->name('brands.show');
Route::get('/brands/{brand}/{product}', [\App\Http\Controllers\MarketplaceController::class, 'showProduct'])->name('brands.products.show');
// Buyer Notification Routes
Route::get('/notifications', [\App\Http\Controllers\Buyer\NotificationController::class, 'index'])->name('notifications.index');
Route::get('/notifications/dropdown', [\App\Http\Controllers\Buyer\NotificationController::class, 'dropdown'])->name('notifications.dropdown');
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');
Route::get('/brands/{brand}', [\App\Http\Controllers\MarketplaceController::class, 'showBrand'])->name('brands.show')->where('brand', '[23456789ABCDEFGHJKMNPQRSTUVWXYZ]{5}');
Route::get('/brands/{brand}/{productId}', [\App\Http\Controllers\MarketplaceController::class, 'showProduct'])->name('brands.products.show')->where(['brand' => '[23456789ABCDEFGHJKMNPQRSTUVWXYZ]{5}', 'productId' => '[0-9]+']);
});
// Buyer purchasing routes (require approval)